commit 94abc5461d0f1512573f4a5266df152c175f215f Author: umsangdon Date: Wed Jul 15 18:37:19 2026 +0900 초기화 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..70904ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/__pycache__/ +**/*.pyc +backup/ +.agents/ +.claude/ +.codex/ +**/.vscode/ \ No newline at end of file diff --git a/gateway/.gitignore b/gateway/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/gateway/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/gateway/0_gateway_v1-6_개선2.zip b/gateway/0_gateway_v1-6_개선2.zip new file mode 100644 index 0000000..ed8971f Binary files /dev/null and b/gateway/0_gateway_v1-6_개선2.zip differ diff --git a/gateway/config.cpp b/gateway/config.cpp new file mode 100644 index 0000000..a4743ec --- /dev/null +++ b/gateway/config.cpp @@ -0,0 +1,118 @@ +#include "config.h" + +// --- Wi-Fi 및 MQTT 설정 값 정의 --- +const char* ssid = "KT_GiGA_B517"; //KT_GiGA_B517 | esd +const char* password = "axf29xc666"; //axf29xc666 | usdd6595 +const char* mqtt_server = "218.155.105.34"; +const char* mqtt_user = "ctnt_root"; +const char* mqtt_password = "Umsang6595!!"; +const char* clientName = "ESP32_PLC_Gateway"; + +// --- 통신 버퍼 정의 --- +uint16_t M_In_New[M_IN_WORDS]; +uint16_t M_In_Old[M_IN_WORDS]; + +uint16_t M_Out_New[M_OUT_WORDS]; +uint16_t M_Out_Old[M_OUT_WORDS]; +uint16_t M_Out_Pending_Write[M_OUT_WORDS]; +uint16_t M_Out_Pending_Clear[M_OUT_WORDS]; + +uint16_t D_In_New[D_IN_WORDS]; +uint16_t D_In_Old[D_IN_WORDS]; + +uint16_t D_Out_New[D_OUT_WORDS]; +uint16_t D_Out_Old[D_OUT_WORDS]; +uint8_t D_Out_Pending_Groups = 0; + +// 제어 명령 적용 직전 상태. 실패/타임아웃 시 Old가 아닌 이 스냅샷으로 복원한다. +static uint16_t M_Out_Command_Backup[M_OUT_WORDS]; +static uint16_t M_Out_Pending_Write_Backup[M_OUT_WORDS]; +static uint16_t M_Out_Pending_Clear_Backup[M_OUT_WORDS]; +static uint16_t D_Out_Command_Backup[D_OUT_WORDS]; +static uint8_t D_Out_Pending_Groups_Backup = 0; + +// --- 시스템 상태 변수 정의 --- +SystemState currentState = STATE_INIT; +unsigned long lastStateChange = 0; +const unsigned long SLOT_DURATION = 200; // 0.2초 (200ms) +const unsigned long INIT_DURATION = 5000; // 5초 (5000ms) + +bool readExecuted = false; +bool writeExecuted = false; +unsigned long lastInitReadTime = 0; + +int modbusErrorCount = 0; +const int MODBUS_MAX_RETRY = 10; // PLC 재부팅 감지 임계값 (10회) +bool plcOnline = true; + +unsigned long lastMqttRetry = 0; +bool initSequenceStarted = false; +bool force_write_M_once = false; +bool force_write_D_once = false; +bool full_sync_pending = false; +char pending_command_id[40] = ""; +bool pending_command_active = false; +bool pending_command_type_is_m = false; +unsigned long pending_command_time = 0; + +// --- D Apply 펄스 상태 정의 --- +bool d_apply_pending = false; +uint16_t d_apply_word_mask = 0; +int d_apply_word_idx = -1; + +// --- [G1] 버퍼 초기화 --- +void setup_buffers() { + memset(M_In_New, 0, sizeof(M_In_New)); + memset(M_In_Old, 0, sizeof(M_In_Old)); + memset(M_Out_New, 0, sizeof(M_Out_New)); + memset(M_Out_Old, 0, sizeof(M_Out_Old)); + memset(M_Out_Pending_Write, 0, sizeof(M_Out_Pending_Write)); + memset(M_Out_Pending_Clear, 0, sizeof(M_Out_Pending_Clear)); + memset(D_In_New, 0, sizeof(D_In_New)); + memset(D_In_Old, 0, sizeof(D_In_Old)); + memset(D_Out_New, 0, sizeof(D_Out_New)); + memset(D_Out_Old, 0, sizeof(D_Out_Old)); + D_Out_Pending_Groups = 0; + memset(M_Out_Command_Backup, 0, sizeof(M_Out_Command_Backup)); + memset(M_Out_Pending_Write_Backup, 0, sizeof(M_Out_Pending_Write_Backup)); + memset(M_Out_Pending_Clear_Backup, 0, sizeof(M_Out_Pending_Clear_Backup)); + memset(D_Out_Command_Backup, 0, sizeof(D_Out_Command_Backup)); + + force_write_M_once = false; + force_write_D_once = false; + full_sync_pending = true; // Full-Sync 개시 플래그 활성화 + + // 대기 중인 명령 상태 명시적 초기화 + pending_command_active = false; + pending_command_id[0] = '\0'; + pending_command_time = 0; + + // D Apply 펄스 상태 초기화 + d_apply_pending = false; + d_apply_word_mask = 0; + d_apply_word_idx = -1; + + Serial.println("[INIT] 버퍼 전체 0 초기화 및 강제 쓰기 플래그, Full-Sync 대기 플래그 활성화 완료."); +} + +void snapshot_pending_command_buffers(bool is_m_command) { + if (is_m_command) { + memcpy(M_Out_Command_Backup, M_Out_New, sizeof(M_Out_New)); + memcpy(M_Out_Pending_Write_Backup, M_Out_Pending_Write, sizeof(M_Out_Pending_Write)); + memcpy(M_Out_Pending_Clear_Backup, M_Out_Pending_Clear, sizeof(M_Out_Pending_Clear)); + } else { + memcpy(D_Out_Command_Backup, D_Out_New, sizeof(D_Out_New)); + D_Out_Pending_Groups_Backup = D_Out_Pending_Groups; + } +} + +void rollback_pending_command_buffers() { + if (pending_command_type_is_m) { + memcpy(M_Out_New, M_Out_Command_Backup, sizeof(M_Out_New)); + memcpy(M_Out_Pending_Write, M_Out_Pending_Write_Backup, sizeof(M_Out_Pending_Write)); + memcpy(M_Out_Pending_Clear, M_Out_Pending_Clear_Backup, sizeof(M_Out_Pending_Clear)); + } else { + memcpy(D_Out_New, D_Out_Command_Backup, sizeof(D_Out_New)); + D_Out_Pending_Groups = D_Out_Pending_Groups_Backup; + } +} diff --git a/gateway/config.h b/gateway/config.h new file mode 100644 index 0000000..2d96152 --- /dev/null +++ b/gateway/config.h @@ -0,0 +1,97 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include +#include +#include + +// --- 펌웨어 버전 식별 --- +#define FIRMWARE_VERSION "d-apply-v2" + +// --- Wi-Fi 및 MQTT 설정 --- +extern const char* ssid; +extern const char* password; +extern const char* mqtt_server; +extern const char* mqtt_user; +extern const char* mqtt_password; +extern const char* clientName; + +// --- PLC Modbus 주소 및 크기 설정 --- +#define PLC_SLAVE_ID 1 // PLC 국번 (Modbus ID) + +// 임시 비트(Coil) 방식 상수 +#define M_IN_START_ADDR 1600 // M01000 시작 비트 주소 (100 * 16) +#define M_IN_COILS 208 // M01000 ~ M0112F (208개 비트) +#define M_IN_WORDS 13 // 내부 버퍼 호환용 워드 크기 유지 +#define M_OUT_START_ADDR 3200 // M02000 시작 비트 주소 (200 * 16) +#define M_OUT_COILS 192 // M02000 ~ M0211F (192개 비트, D Apply M211C~F 포함하도록 확장) +#define M_OUT_WORDS 12 // 내부 버퍼 호환용 워드 크기 유지 + +// --- D 워드 원격 제어 Apply 비트 (M211C~M211F, 그룹 0~3 대응) --- +// PLC 래더는 이 A접이 ON인 동안에만 D203x→실제 설정값 DMOV를 수행한다. +// 모멘터리(P접)는 PLC에서 어려워 게이트웨이가 기존 M 모멘터리 엔진으로 ON→OFF 펄스를 생성한다. +#define D_APPLY_BIT_BASE 188 // M211C = M2000 기준 offset 188 (그룹 0), 189/190/191 = 그룹 1/2/3 + +#define D_IN_START_ADDR 2000 // D2000 시작 주소 +#define D_IN_WORDS 20 // D2000 ~ D2018 (20 워드, 10 그룹) +#define D_OUT_START_ADDR 2030 // D2030 시작 주소 (PLC 메모리 맵 매핑 수정) +#define D_OUT_WORDS 8 // D2030 ~ D2036 (8 워드, 4 그룹) + +// --- 통신 버퍼 (Old/New Double Buffering) --- +extern uint16_t M_In_New[M_IN_WORDS]; +extern uint16_t M_In_Old[M_IN_WORDS]; + +extern uint16_t M_Out_New[M_OUT_WORDS]; +extern uint16_t M_Out_Old[M_OUT_WORDS]; +extern uint16_t M_Out_Pending_Write[M_OUT_WORDS]; +extern uint16_t M_Out_Pending_Clear[M_OUT_WORDS]; + +extern uint16_t D_In_New[D_IN_WORDS]; +extern uint16_t D_In_Old[D_IN_WORDS]; + +extern uint16_t D_Out_New[D_OUT_WORDS]; +extern uint16_t D_Out_Old[D_OUT_WORDS]; +extern uint8_t D_Out_Pending_Groups; + +// --- 시스템 상태 및 오류 통계 제어 변수 --- +enum SystemState { + STATE_INIT, + STATE_READ, + STATE_WRITE +}; + +extern SystemState currentState; +extern unsigned long lastStateChange; +extern const unsigned long SLOT_DURATION; // 0.5초 (500ms) +extern const unsigned long INIT_DURATION; // 5초 (5000ms) + +extern bool readExecuted; +extern bool writeExecuted; +extern unsigned long lastInitReadTime; + +extern int modbusErrorCount; +extern const int MODBUS_MAX_RETRY; // PLC 재부팅 감지 임계값 (10회) +extern bool plcOnline; + +extern unsigned long lastMqttRetry; +extern bool initSequenceStarted; +extern bool force_write_M_once; +extern bool force_write_D_once; +extern bool full_sync_pending; +extern char pending_command_id[40]; +extern bool pending_command_active; +extern bool pending_command_type_is_m; +extern unsigned long pending_command_time; // 명령 수신 시점 저장용 타임스탬프 + +// --- D Apply 펄스 상태 (D 쓰기 성공 후 M211C~F ON→OFF 완료 시점에 PLC_APPLIED 발행) --- +// 기존 M 모멘터리 엔진을 재사용하되, D 명령의 ACK를 Apply OFF 완료까지 지연시키기 위한 최소 상태. +extern bool d_apply_pending; // D 쓰기는 끝났고 Apply 펄스 완료를 기다리는 중 +extern uint16_t d_apply_word_mask; // Apply 대상 M211C~F 비트가 속한 워드의 비트 마스크 +extern int d_apply_word_idx; // Apply 비트가 속한 워드 인덱스 (M211C~F는 모두 word 11) + +// 함수 선언 +void setup_buffers(); +void snapshot_pending_command_buffers(bool is_m_command); +void rollback_pending_command_buffers(); + +#endif // CONFIG_H diff --git a/gateway/gateway.ino b/gateway/gateway.ino new file mode 100644 index 0000000..f998104 --- /dev/null +++ b/gateway/gateway.ino @@ -0,0 +1,168 @@ +#include +#include +#include "config.h" +#include "modbus.h" +#include "mqtt_handler.h" + +// --- [G1] Wi-Fi 연결 설정 --- +void setup_wifi() { + delay(10); + Serial.println(); + Serial.print("[WIFI] Connecting to "); + Serial.println(ssid); + + WiFi.disconnect(true, true); + delay(500); + WiFi.mode(WIFI_STA); + delay(500); + + esp_wifi_set_ps(WIFI_PS_NONE); + + WiFi.begin(ssid, password); + + int attempts = 0; + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + attempts++; + + if (attempts >= 20) { + Serial.println("\n[WIFI] 연결 지연 발생. 무선 재시작 중..."); + WiFi.disconnect(); + delay(500); + WiFi.begin(ssid, password); + attempts = 0; + } + } + + Serial.println(""); + Serial.println("[WIFI] connected"); + Serial.print("[WIFI] IP address: "); + Serial.println(WiFi.localIP()); +} + +// --- [G7] 부팅 초기화 루틴 제어 함수 --- +void handle_init_sequence() { + if (!initSequenceStarted) { + initSequenceStarted = true; + setup_buffers(); + + Serial.println("[EVENT] id=999 Full-Sync 시작"); + + if (client.connected()) { + client.publish("/line1/event", "{\"id\":999,\"val\":1}"); + } + } +} + +// --- [G8] PLC 재부팅 감지 이벤트 처리 함수 --- +void trigger_plc_reboot_event() { + Serial.println("[EVENT] id=998 PLC_REBOOT 감지"); + if (client.connected()) { + client.publish("/line1/event", "{\"id\":998,\"val\":1}"); + } + + currentState = STATE_INIT; + lastStateChange = millis(); + initSequenceStarted = false; +} + +// --- [G8] PLC 통신 복구 감지 처리 함수 --- +void trigger_plc_recovery() { + Serial.println("[EVENT] PLC 통신 복구 감지 -> Full-Sync 재수행 준비"); + currentState = STATE_INIT; + lastStateChange = millis(); + initSequenceStarted = false; +} + +void setup() { + Serial.begin(115200); + Serial.printf("[SYS] Firmware version: %s\n", FIRMWARE_VERSION); + setup_buffers(); + setup_wifi(); + setup_mqtt(); + setup_modbus(); + lastStateChange = millis(); + Serial.println("[SYS] Setup 완료."); +} + +// --- [G2] 타이밍 스케줄러 메인 루프 --- +void loop() { + maintain_mqtt(); + + unsigned long now = millis(); + + switch (currentState) { + case STATE_INIT: + // 초기화 시퀀스 핸들링 (버퍼 클리어 및 999 이벤트 송신) + handle_init_sequence(); + + // 정상 주기적 PLC 읽기를 진행하지 않고 대기 (정상 로직 일시 중단) + if (now - lastStateChange >= INIT_DURATION) { + Serial.println("[STATE] INIT 완료 -> PLC 상태 조회 및 Full-Sync 데이터 전송 개시"); + + // 1회 PLC 데이터를 가져와 plcOnline 여부 및 버퍼 업데이트 + run_plc_read(); + + if (plcOnline) { + // full_sync_pending 플래그가 설정되어 있으므로 전체 스냅샷이 송신됨 + if (process_read_changes()) { + bool pub_ok = false; + Serial.println("[EVENT] id=997 Full-Sync 완료 이벤트 발행 시도..."); + if (client.connected()) { + pub_ok = client.publish("/line1/event", "{\"id\":997,\"val\":1}"); + } + + if (pub_ok) { + full_sync_pending = false; // Full-Sync 완료 이벤트 발행 성공 시에만 일반 운전 상태로 전환 + currentState = STATE_READ; + lastStateChange = now; + readExecuted = false; + Serial.println("[EVENT] id=997 Full-Sync 완료 이벤트 발행 성공, 정상 루틴 진입"); + } else { + Serial.println("[WARN] Full-Sync 완료 이벤트(997) 발행 실패. 다음 주기에서 재발행을 재시도합니다."); + // 상태 전환을 하지 않고 STATE_INIT 대기 유지하여 다음 주기에서 재시도 + } + } else { + Serial.println("[WARN] Full-Sync 전송 실패. 다음 주기에서 재동기화 재시도합니다."); + // 상태 전환을 하지 않고 STATE_INIT 대기 유지하여 다음 주기에서 재시도 + } + } else { + Serial.println("[WARN] PLC 오프라인 상태 - Full-Sync 보류, 정상 루틴 진입"); + full_sync_pending = false; // 오프라인이어도 플래그는 일단 정리하여 일반 모드 진입 준비 + + currentState = STATE_READ; + lastStateChange = now; + readExecuted = false; + } + } + break; + + case STATE_READ: + if (now - lastStateChange >= SLOT_DURATION) { + currentState = STATE_WRITE; + lastStateChange = now; + writeExecuted = false; + } else { + if (!readExecuted) { + run_plc_read(); + process_read_changes(); + readExecuted = true; + } + } + break; + + case STATE_WRITE: + if (now - lastStateChange >= SLOT_DURATION) { + currentState = STATE_READ; + lastStateChange = now; + readExecuted = false; + } else { + if (!writeExecuted) { + run_plc_write(); + writeExecuted = true; + } + } + break; + } +} diff --git a/gateway/modbus.cpp b/gateway/modbus.cpp new file mode 100644 index 0000000..e3c6782 --- /dev/null +++ b/gateway/modbus.cpp @@ -0,0 +1,582 @@ +#include "modbus.h" +#include "config.h" + +// --- 물리적 GPIO 및 통신 상수 정의 (현장 배선 사양 적용) --- +#define MODBUS_TX_PIN 27 +#define MODBUS_RX_PIN 26 +#define MODBUS_DE_PIN 4 +#define MODBUS_BAUDRATE 115200 + +// Forward declarations for MQTT recovery/reboot functions +void trigger_plc_reboot_event(); +void trigger_plc_recovery(); +void publish_ack(const char* command_id, const char* status); + +// M 모멘터리 펌스의 ON~OFF 실제 소요 시간 추적용 +static bool m_pulse_release_pending = false; +static unsigned long m_pulse_started_at = 0; +static char m_pulse_command_id[40] = ""; + +// --- [G1] Modbus RTU / RS-485 초기화 (상수 정의 사용) --- +void setup_modbus() { + Serial2.begin(MODBUS_BAUDRATE, SERIAL_8N1, MODBUS_RX_PIN, MODBUS_TX_PIN); + pinMode(MODBUS_DE_PIN, OUTPUT); + digitalWrite(MODBUS_DE_PIN, LOW); // 기본 상태: 수신 모드 + Serial.printf("[MODBUS SETUP] Serial2 initialized at %d bps (RX:%d, TX:%d, DE:%d)\n", + MODBUS_BAUDRATE, MODBUS_RX_PIN, MODBUS_TX_PIN, MODBUS_DE_PIN); +} + +// --- Modbus RTU CRC-16 계산 함수 --- +uint16_t calculateCRC(uint8_t* buffer, int length) { + uint16_t crc = 0xFFFF; + for (int pos = 0; pos < length; pos++) { + crc ^= (uint16_t)buffer[pos]; + for (int i = 8; i != 0; i--) { + if ((crc & 0x0001) != 0) { + crc >>= 1; + crc ^= 0xA001; + } else { + crc >>= 1; + } + } + } + return crc; +} + +// --- RS-485 물리 프레임 전송 함수 (DE 핀 전환 제어 및 TX 프레임 로그 출력) --- +void send_modbus_frame(uint8_t* frame, int length) { + // TX 프레임 Hex 디버그 로그 출력 + Serial.print("[MODBUS TX] "); + for (int i = 0; i < length; i++) { + Serial.printf("%02X ", frame[i]); + } + Serial.println(); + + digitalWrite(MODBUS_DE_PIN, HIGH); // 송신 모드로 전환 + delayMicroseconds(100); // 안정화를 위한 미세 지연 + + Serial2.write(frame, length); + Serial2.flush(); // 데이터가 시리얼 버스를 통해 나갈 때까지 대기 + + delayMicroseconds(100); + digitalWrite(MODBUS_DE_PIN, LOW); // 다시 수신 모드로 전환 +} + +// --- [G3] Modbus Holding Register 읽기 공통 함수 --- +bool modbus_read_registers(uint16_t start_addr, uint16_t quantity, uint16_t* dest_buffer) { + uint8_t frame[8]; + frame[0] = PLC_SLAVE_ID; + frame[1] = 0x03; // Read Holding Registers + frame[2] = (start_addr >> 8) & 0xFF; + frame[3] = start_addr & 0xFF; + frame[4] = (quantity >> 8) & 0xFF; + frame[5] = quantity & 0xFF; + + uint16_t crc = calculateCRC(frame, 6); + frame[6] = crc & 0xFF; + frame[7] = (crc >> 8) & 0xFF; + + while (Serial2.available()) Serial2.read(); + + send_modbus_frame(frame, 8); + + unsigned long start = millis(); + int expected_bytes = 5 + (quantity * 2); + uint8_t response[60]; + int bytes_read = 0; + + while (millis() - start < 200) { + if (Serial2.available()) { + response[bytes_read++] = Serial2.read(); + if (bytes_read >= expected_bytes || bytes_read >= sizeof(response)) { + break; + } + } + delay(1); + } + + // RX 프레임 Hex 디버그 로그 출력 + if (bytes_read > 0) { + Serial.print("[MODBUS RX FC03] "); + for (int i = 0; i < bytes_read; i++) { + Serial.printf("%02X ", response[i]); + } + Serial.println(); + } else { + Serial.println("[MODBUS RX FC03 ERROR] No response (Timeout)"); + } + + if (bytes_read < expected_bytes) { + Serial.printf("[MODBUS RX FC03 ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read); + return false; + } + + uint16_t rx_crc = calculateCRC(response, bytes_read - 2); + uint16_t pkt_crc = response[bytes_read - 2] | (response[bytes_read - 1] << 8); + if (rx_crc != pkt_crc) { + Serial.printf("[MODBUS RX FC03 ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc); + return false; + } + + if (response[0] != PLC_SLAVE_ID || response[1] != 0x03) { + Serial.printf("[MODBUS RX FC03 ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]); + return false; + } + + for (int i = 0; i < quantity; i++) { + dest_buffer[i] = (response[3 + i * 2] << 8) | response[4 + i * 2]; + } + + return true; +} + +// --- 임시 비트(Coil) 읽기 함수 (FC 01) --- +bool modbus_read_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* dest_word_buffer) { + uint8_t frame[8]; + frame[0] = PLC_SLAVE_ID; + frame[1] = 0x01; // Read Coils + frame[2] = (start_addr >> 8) & 0xFF; + frame[3] = start_addr & 0xFF; + frame[4] = (coil_qty >> 8) & 0xFF; + frame[5] = coil_qty & 0xFF; + + uint16_t crc = calculateCRC(frame, 6); + frame[6] = crc & 0xFF; + frame[7] = (crc >> 8) & 0xFF; + + while (Serial2.available()) Serial2.read(); + + send_modbus_frame(frame, 8); + + unsigned long start = millis(); + int byte_count = (coil_qty + 7) / 8; + int expected_bytes = 5 + byte_count; + uint8_t response[60]; + int bytes_read = 0; + + while (millis() - start < 200) { + if (Serial2.available()) { + response[bytes_read++] = Serial2.read(); + if (bytes_read >= expected_bytes || bytes_read >= sizeof(response)) { + break; + } + } + delay(1); + } + + // RX 프레임 Hex 디버그 로그 출력 + if (bytes_read > 0) { + Serial.print("[MODBUS RX FC01] "); + for (int i = 0; i < bytes_read; i++) { + Serial.printf("%02X ", response[i]); + } + Serial.println(); + } else { + Serial.println("[MODBUS RX FC01 ERROR] No response (Timeout)"); + } + + if (bytes_read < expected_bytes) { + Serial.printf("[MODBUS RX FC01 ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read); + return false; + } + + uint16_t rx_crc = calculateCRC(response, bytes_read - 2); + uint16_t pkt_crc = response[bytes_read - 2] | (response[bytes_read - 1] << 8); + if (rx_crc != pkt_crc) { + Serial.printf("[MODBUS RX FC01 ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc); + return false; + } + + if (response[0] != PLC_SLAVE_ID || response[1] != 0x01) { + Serial.printf("[MODBUS RX FC01 ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]); + return false; + } + + int word_qty = (coil_qty + 15) / 16; + for (int i = 0; i < word_qty; i++) { + // PLC Modbus RTU는 빅엔디안으로 한 워드의 상위 바이트를 먼저 수신함 + uint8_t first_byte = response[3 + i * 2]; + uint8_t second_byte = (i * 2 + 1 < byte_count) ? response[3 + i * 2 + 1] : 0x00; + // 바이트 순서 교정: 첫 번째 수신 바이트(낮은 코일 주소)가 하위 8비트, 두 번째 바이트가 상위 8비트가 되도록 조립 + dest_word_buffer[i] = (second_byte << 8) | first_byte; + } + + return true; +} + +// --- 임시 비트(Coil) 다중 쓰기 함수 (FC 0F) --- +bool modbus_write_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* src_word_buffer) { + int byte_count = (coil_qty + 7) / 8; + int payload_len = 7 + byte_count; + uint8_t frame[40]; + + frame[0] = PLC_SLAVE_ID; + frame[1] = 0x0F; // Write Multiple Coils + frame[2] = (start_addr >> 8) & 0xFF; + frame[3] = start_addr & 0xFF; + frame[4] = (coil_qty >> 8) & 0xFF; + frame[5] = coil_qty & 0xFF; + frame[6] = byte_count; + + int word_qty = (coil_qty + 15) / 16; + for (int i = 0; i < word_qty; i++) { + // PLC Cnet 모듈은 전송받은 바이트 순서 그대로 코일 주소에 대입하므로 스왑 없이 하위 바이트를 먼저 보냄 + frame[7 + i * 2] = src_word_buffer[i] & 0xFF; + if (i * 2 + 1 < byte_count) { + frame[7 + i * 2 + 1] = (src_word_buffer[i] >> 8) & 0xFF; + } + } + + uint16_t crc = calculateCRC(frame, payload_len); + frame[payload_len] = crc & 0xFF; + frame[payload_len + 1] = (crc >> 8) & 0xFF; + + while (Serial2.available()) Serial2.read(); + + send_modbus_frame(frame, payload_len + 2); + + unsigned long start = millis(); + int expected_bytes = 8; + uint8_t response[10]; + int bytes_read = 0; + + while (millis() - start < 200) { + if (Serial2.available()) { + response[bytes_read++] = Serial2.read(); + if (bytes_read >= expected_bytes) { + break; + } + } + delay(1); + } + + // RX 프레임 Hex 디버그 로그 출력 + if (bytes_read > 0) { + Serial.print("[MODBUS RX FC0F] "); + for (int i = 0; i < bytes_read; i++) { + Serial.printf("%02X ", response[i]); + } + Serial.println(); + } else { + Serial.println("[MODBUS RX FC0F ERROR] No response (Timeout)"); + } + + if (bytes_read < expected_bytes) { + Serial.printf("[MODBUS RX FC0F ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read); + return false; + } + + uint16_t rx_crc = calculateCRC(response, expected_bytes - 2); + uint16_t pkt_crc = response[expected_bytes - 2] | (response[expected_bytes - 1] << 8); + if (rx_crc != pkt_crc) { + Serial.printf("[MODBUS RX FC0F ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc); + return false; + } + + if (response[0] != PLC_SLAVE_ID || response[1] != 0x0F) { + Serial.printf("[MODBUS RX FC0F ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]); + return false; + } + + return true; +} + +// --- [G4] Modbus Holding Register 다중 쓰기 공통 함수 (FC 16) --- +bool modbus_write_registers(uint16_t start_addr, uint16_t quantity, uint16_t* src_buffer) { + int payload_len = 7 + (quantity * 2); + uint8_t frame[35]; + + frame[0] = PLC_SLAVE_ID; + frame[1] = 0x10; // Write Multiple Registers (FC16) + frame[2] = (start_addr >> 8) & 0xFF; + frame[3] = start_addr & 0xFF; + frame[4] = (quantity >> 8) & 0xFF; + frame[5] = quantity & 0xFF; + frame[6] = quantity * 2; + + for (int i = 0; i < quantity; i++) { + frame[7 + i * 2] = (src_buffer[i] >> 8) & 0xFF; + frame[8 + i * 2] = src_buffer[i] & 0xFF; + } + + uint16_t crc = calculateCRC(frame, payload_len); + frame[payload_len] = crc & 0xFF; + frame[payload_len + 1] = (crc >> 8) & 0xFF; + + while (Serial2.available()) Serial2.read(); + + send_modbus_frame(frame, payload_len + 2); + + unsigned long start = millis(); + uint8_t response[10]; + int bytes_read = 0; + int expected_bytes = 8; + + while (millis() - start < 200) { + if (Serial2.available()) { + response[bytes_read++] = Serial2.read(); + + if (bytes_read == 2) { + if (response[0] == PLC_SLAVE_ID && response[1] == 0x90) { + expected_bytes = 5; + } + } + + if (bytes_read >= expected_bytes) { + break; + } + } + delay(1); + } + + // RX 프레임 Hex 디버그 로그 출력 + if (bytes_read > 0) { + Serial.print("[MODBUS RX FC10] "); + for (int i = 0; i < bytes_read; i++) { + Serial.printf("%02X ", response[i]); + } + Serial.println(); + } else { + Serial.println("[MODBUS RX FC10 ERROR] No response (Timeout)"); + } + + if (bytes_read < expected_bytes) { + Serial.printf("[MODBUS RX FC10 ERROR] Bytes read mismatch (expected: %d, got: %d)\n", expected_bytes, bytes_read); + return false; + } + + uint16_t rx_crc = calculateCRC(response, expected_bytes - 2); + uint16_t pkt_crc = response[expected_bytes - 2] | (response[expected_bytes - 1] << 8); + if (rx_crc != pkt_crc) { + Serial.printf("[MODBUS RX FC10 ERROR] CRC mismatch (calc: 0x%04X, packet: 0x%04X)\n", rx_crc, pkt_crc); + return false; + } + + if (response[1] == 0x90) { + Serial.println("[MODBUS RX FC10 ERROR] Exception Response (0x90)"); + return false; + } + + if (response[0] != PLC_SLAVE_ID || response[1] != 0x10) { + Serial.printf("[MODBUS RX FC10 ERROR] Invalid Slave ID or Function Code (slave: %d, FC: 0x%02X)\n", response[0], response[1]); + return false; + } + + return true; +} + +// --- [G3] PLC 읽기 슬롯 처리 함수 --- +void run_plc_read() { + bool success_M = modbus_read_coils(M_IN_START_ADDR, M_IN_COILS, M_In_New); + bool success_D = modbus_read_registers(D_IN_START_ADDR, D_IN_WORDS, D_In_New); + + if (success_M && success_D) { + if (modbusErrorCount > 0) { + Serial.println("[MODBUS] 통신 복구 완료! 에러 카운트를 리셋합니다."); + } + modbusErrorCount = 0; + + if (!plcOnline) { + plcOnline = true; + trigger_plc_recovery(); + } + } else { + if (modbusErrorCount < MODBUS_MAX_RETRY) { + modbusErrorCount++; + Serial.printf("[ERROR] Modbus Read 실패! (M:%d, D:%d) (연속 %d회 실패 / 최대 10회)\n", success_M, success_D, modbusErrorCount); + } + + if (modbusErrorCount >= MODBUS_MAX_RETRY && plcOnline) { + plcOnline = false; + Serial.printf("[ERROR] Modbus Read 장기 실패! (연속 %d회 실패 / 최대 10회)\n", modbusErrorCount); + trigger_plc_reboot_event(); + } + } +} + +// --- [G4] PLC 쓰기 슬롯 처리 함수 --- +void run_plc_write() { + if (!plcOnline) return; + + // 1. M 영역 (Coils) 쓰기 판단 및 실행 + bool need_write_M = force_write_M_once || + (pending_command_active && pending_command_type_is_m) || + (memcmp(M_Out_New, M_Out_Old, sizeof(M_Out_Old)) != 0); + if (!need_write_M) { + for (int i = 0; i < M_OUT_WORDS; i++) { + if (M_Out_Pending_Write[i] != 0 || M_Out_Pending_Clear[i] != 0) { + need_write_M = true; + break; + } + } + } + + if (need_write_M) { + bool is_command_apply = pending_command_active && pending_command_type_is_m; + bool is_auto_off = !is_command_apply && m_pulse_release_pending; + const char* phase = force_write_M_once ? "INIT_FORCE" : + (is_command_apply ? "COMMAND_ON" : + (is_auto_off ? "AUTO_OFF" : "INTERNAL_RETRY")); + char command_id_for_trace[40] = ""; + if (is_command_apply) { + strncpy(command_id_for_trace, pending_command_id, sizeof(command_id_for_trace) - 1); + command_id_for_trace[sizeof(command_id_for_trace) - 1] = '\0'; + } else if (m_pulse_command_id[0] != '\0') { + strncpy(command_id_for_trace, m_pulse_command_id, sizeof(command_id_for_trace) - 1); + command_id_for_trace[sizeof(command_id_for_trace) - 1] = '\0'; + } else { + strncpy(command_id_for_trace, "none", sizeof(command_id_for_trace) - 1); + } + + Serial.printf("[M PULSE TRACE] phase=%s command_id=%s started_ms=%lu\n", + phase, command_id_for_trace, millis()); + if (is_command_apply && m_pulse_release_pending) { + Serial.printf("[M PULSE WARN] command_id=%s arrived before previous pulse AUTO_OFF completed; repeated HIGH may not create a new PLC rising edge.\n", + command_id_for_trace); + } + for (int i = 0; i < M_OUT_WORDS; i++) { + if (M_Out_New[i] != M_Out_Old[i] || M_Out_Pending_Write[i] != 0) { + Serial.printf("[M PULSE TRACE] word=%d plc=M%04d0~M%04dF old=0x%04X outgoing=0x%04X pending=0x%04X\n", + i, 200 + i, 200 + i, M_Out_Old[i], M_Out_New[i], M_Out_Pending_Write[i]); + } + } + + bool success_M = modbus_write_coils(M_OUT_START_ADDR, M_OUT_COILS, M_Out_New); + if (!success_M) { + Serial.printf("[ERROR] M_Out Coil Write 실패! (addr=%d, qty=%d, FC0F)\n", M_OUT_START_ADDR, M_OUT_COILS); + if (pending_command_active && pending_command_type_is_m) { + rollback_pending_command_buffers(); + publish_ack(pending_command_id, "FAILED"); + pending_command_active = false; + pending_command_id[0] = '\0'; + pending_command_time = 0; + } else { + // 모멘터리 OFF 등 내부 쓰기는 목표 버퍼를 유지해 다음 WRITE 슬롯에서 재시도한다. + Serial.println("[MODBUS WRITE M] Internal write will be retried without rollback."); + } + } else { + Serial.println("[MODBUS WRITE M] M_Out write sequence completed successfully."); + if (is_auto_off) { + Serial.printf("[M PULSE TRACE] phase=AUTO_OFF_COMPLETE command_id=%s pulse_width_ms=%lu\n", + command_id_for_trace, millis() - m_pulse_started_at); + m_pulse_release_pending = false; + m_pulse_command_id[0] = '\0'; + + // D Apply 펄스의 OFF가 방금 완료되었는지 확인. + // d_apply_pending이 true인 D 명령에 한해 이 시점에 PLC_APPLIED를 발행한다(상태 경합 방지). + if (d_apply_pending && pending_command_active && !pending_command_type_is_m) { + Serial.printf("[D APPLY] command_id=%s Apply OFF 완료 -> PLC_APPLIED 발행\n", pending_command_id); + publish_ack(pending_command_id, "PLC_APPLIED"); + pending_command_active = false; + pending_command_id[0] = '\0'; + pending_command_time = 0; + d_apply_pending = false; + d_apply_word_mask = 0; + d_apply_word_idx = -1; + } + } + if (pending_command_active && pending_command_type_is_m) { + publish_ack(pending_command_id, "PLC_APPLIED"); + pending_command_active = false; + pending_command_id[0] = '\0'; + pending_command_time = 0; + } + + // 쓰기 성공 시 Old 버퍼 동기화 및 플래그 해제 + memcpy(M_Out_Old, M_Out_New, sizeof(M_Out_Old)); + + bool scheduled_auto_off = false; + for (int i = 0; i < M_OUT_WORDS; i++) { + if (M_Out_Pending_Write[i] != 0) { + for (int j = 0; j < 16; j++) { + if ((M_Out_Pending_Write[i] >> j) & 1) { + scheduled_auto_off = true; + M_Out_Pending_Write[i] &= ~(1 << j); + M_Out_New[i] &= ~(1 << j); + Serial.printf("[MODBUS WRITE RESET] Automatically cleared absolute bit %d to 0 for momentary emulation\n", i * 16 + j); + Serial.printf("[M PULSE TRACE] command_id=%s plc_target=M%04d%X state=HIGH_APPLIED auto_off=SCHEDULED\n", + command_id_for_trace, 200 + i, j); + } + } + } + M_Out_Pending_Clear[i] = 0; + } + + if (scheduled_auto_off) { + m_pulse_release_pending = true; + m_pulse_started_at = millis(); + strncpy(m_pulse_command_id, command_id_for_trace, sizeof(m_pulse_command_id) - 1); + m_pulse_command_id[sizeof(m_pulse_command_id) - 1] = '\0'; + Serial.printf("[M PULSE TRACE] phase=ON_COMPLETE command_id=%s auto_off_pending=1\n", command_id_for_trace); + } + + force_write_M_once = false; // M 영역 최초 쓰기 완료 + } + } + + // 2. D 영역 (Registers) 쓰기 판단 및 실행 + bool need_write_D = D_Out_Pending_Groups != 0; + if (need_write_D) { + uint8_t groups_to_write = D_Out_Pending_Groups; + bool success_D = true; + for (int group_id = 0; group_id < 4; group_id++) { + if ((groups_to_write & (1U << group_id)) == 0) continue; + if (!modbus_write_registers(D_OUT_START_ADDR + group_id * 2, 2, &D_Out_New[group_id * 2])) { + success_D = false; + break; + } + } + if (!success_D) { + Serial.printf("[ERROR] D_Out Write 실패! (addr=%d, qty=%d, FC16)\n", D_OUT_START_ADDR, D_OUT_WORDS); + if (pending_command_active && !pending_command_type_is_m) { + rollback_pending_command_buffers(); + publish_ack(pending_command_id, "FAILED"); + pending_command_active = false; + pending_command_id[0] = '\0'; + pending_command_time = 0; + } else { + Serial.println("[MODBUS WRITE D] Internal write will be retried without rollback."); + } + } else { + Serial.println("[MODBUS WRITE D] Pending REAL groups written successfully."); + + // 쓰기 성공 시 Old 버퍼 동기화 + memcpy(D_Out_Old, D_Out_New, sizeof(D_Out_Old)); + D_Out_Pending_Groups &= ~groups_to_write; + force_write_D_once = false; // D 영역 최초 쓰기 완료 + + // D203x 쓰기가 끝났으므로, 이번에 쓴 그룹들의 Apply 비트(M211C~F)를 기존 M 모멘터리 엔진에 예약한다. + // PLC는 이 A접이 ON인 동안 DMOV로 실제 설정값을 복사한다. ACK(PLC_APPLIED)는 Apply OFF 완료까지 지연한다. + uint16_t apply_mask = 0; + for (int group_id = 0; group_id < 4; group_id++) { + if (groups_to_write & (1U << group_id)) { + int apply_bit = D_APPLY_BIT_BASE + group_id; // 188~191 = M211C~F + int w = apply_bit / 16; // 워드 인덱스 (모두 11) + int b = apply_bit % 16; // 워드 내 비트 위치 + M_Out_New[w] |= (1 << b); + M_Out_Pending_Write[w] |= (1 << b); // 다음 WRITE 슬롯에서 FC0F ON → 이후 자동 OFF + M_Out_Pending_Clear[w] &= ~(1 << b); + apply_mask |= (1 << b); + d_apply_word_idx = w; + // M211C~F: word=11, bit=12~15 → PLC 표기 M2%02d%X + Serial.printf("[D APPLY] Group %d -> Apply bit M2%02d%X (offset %d) ON 예약\n", + group_id, 200 + w, b, apply_bit); + } + } + + if (apply_mask != 0 && pending_command_active && !pending_command_type_is_m) { + // Apply 펄스가 예약된 D 명령: OFF 완료 시점에 PLC_APPLIED를 발행하도록 대기 상태 진입. + d_apply_pending = true; + d_apply_word_mask = apply_mask; + Serial.printf("[D APPLY] command_id=%s Apply 펄스 예약 완료, PLC_APPLIED는 Apply OFF 후 발행\n", + pending_command_id); + } else if (pending_command_active && !pending_command_type_is_m) { + // 예약된 Apply 비트가 없는 예외 상황: 기존 동작대로 즉시 완료 처리. + publish_ack(pending_command_id, "PLC_APPLIED"); + pending_command_active = false; + pending_command_id[0] = '\0'; + pending_command_time = 0; + } + } + } +} diff --git a/gateway/modbus.h b/gateway/modbus.h new file mode 100644 index 0000000..95cb313 --- /dev/null +++ b/gateway/modbus.h @@ -0,0 +1,16 @@ +#ifndef MODBUS_H +#define MODBUS_H + +#include + +void setup_modbus(); +uint16_t calculateCRC(uint8_t* buffer, int length); +bool modbus_read_registers(uint16_t start_addr, uint16_t quantity, uint16_t* dest_buffer); +bool modbus_read_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* dest_word_buffer); +bool modbus_write_coils(uint16_t start_addr, uint16_t coil_qty, uint16_t* src_word_buffer); +bool modbus_write_registers(uint16_t start_addr, uint16_t quantity, uint16_t* src_buffer); + +void run_plc_read(); +void run_plc_write(); + +#endif // MODBUS_H diff --git a/gateway/modbus_mapping.json b/gateway/modbus_mapping.json new file mode 100644 index 0000000..e440bf3 --- /dev/null +++ b/gateway/modbus_mapping.json @@ -0,0 +1,22 @@ +{ + "m_read": { + "start_addr": 1600, + "coil_count": 208, + "word_count": 13 + }, + "m_write": { + "start_addr": 3200, + "coil_count": 188, + "word_count": 12 + }, + "d_read": { + "start_addr": 2000, + "word_count": 20, + "groups": 10 + }, + "d_write": { + "start_addr": 2030, + "word_count": 8, + "groups": 4 + } +} diff --git a/gateway/mqtt_handler.cpp b/gateway/mqtt_handler.cpp new file mode 100644 index 0000000..88e8d0b --- /dev/null +++ b/gateway/mqtt_handler.cpp @@ -0,0 +1,469 @@ +#include "mqtt_handler.h" +#include "config.h" +#include "modbus.h" +#include +#include + +WiFiClient espClient; +PubSubClient client(espClient); + +// --- MQTT ACK 전송 함수 --- +void publish_ack(const char* command_id, const char* status) { + if (!client.connected()) return; + + JsonDocument doc; + doc["command_id"] = command_id; + doc["status"] = status; + doc["timestamp"] = millis(); + + char buffer[128]; + serializeJson(doc, buffer, sizeof(buffer)); + client.publish("/line1/ack", buffer); + Serial.printf("[MQTT ACK PUBLISH] Published status %s for command %s to /line1/ack\n", status, command_id); +} + +// --- MQTT 수신 콜백: [[id, val], ...] 배치 파싱 및 제어 적용 --- +void on_mqtt_message(char* topic, byte* payload, unsigned int length) { + // 수신된 페이로드 로그 출력 + char log_buf[256]; + unsigned int log_len = (length < 255) ? length : 255; + memcpy(log_buf, payload, log_len); + log_buf[log_len] = '\0'; + Serial.printf("\n[MQTT RECEIVED] Topic: %s | Len: %d | Payload: %s\n", topic, length, log_buf); + + // 명령(cmd) 토픽 처리 + if (strcmp(topic, "/line1/cmd") == 0) { + char cmd_str[32]; + unsigned int len = (length < 31) ? length : 31; + memcpy(cmd_str, payload, len); + cmd_str[len] = '\0'; + + char* clean_cmd = cmd_str; + if (clean_cmd[0] == '"') clean_cmd++; + int clean_len = strlen(clean_cmd); + if (clean_len > 0 && clean_cmd[clean_len - 1] == '"') { + clean_cmd[clean_len - 1] = '\0'; + } + + if (strcmp(clean_cmd, "sync") == 0) { + Serial.println("[CMD PROCESS] Manual Full-Sync Request Received!"); + + if (pending_command_active) { + publish_ack(pending_command_id, "FAILED"); + } + + setup_buffers(); + currentState = STATE_INIT; + lastStateChange = millis(); + initSequenceStarted = false; + + if (client.connected()) { + client.publish("/line1/event", "{\"id\":999,\"val\":1}"); + Serial.println("[CMD PROCESS] Published event 999 to broker."); + } + return; + } + } + + JsonDocument doc; + DeserializationError error = deserializeJson(doc, payload, length); + if (error) { + Serial.printf("[MQTT ERROR] JSON Deserialization failed: %s\n", error.c_str()); + return; + } + + const char* cmd_id = nullptr; + JsonArray arr; + bool has_envelope = false; + + // JSON 객체(새로운 Envelope 규격)와 JSON 배열(기존 규격) 둘 다 호환되도록 처리 + if (doc.is()) { + JsonObject obj = doc.as(); + if (obj["command_id"].is() && obj["items"].is()) { + cmd_id = obj["command_id"].as(); + arr = obj["items"].as(); + has_envelope = true; + Serial.printf("[MQTT ENVELOPE] command_id: %s\n", cmd_id); + } else { + Serial.println("[MQTT ERROR] JSON Object requires string command_id and array items"); + return; + } + } else if (doc.is()) { + arr = doc.as(); + } else { + Serial.println("[MQTT ERROR] Payload is neither JSON Array nor Object"); + return; + } + + const bool is_m_topic = strcmp(topic, "/line1/m/out") == 0; + const bool is_d_topic = strcmp(topic, "/line1/d/out") == 0; + bool valid_items = (is_m_topic || is_d_topic) && !arr.isNull() && arr.size() > 0; + + // ArduinoJson 7에서 중첩 배열을 JsonVariant -> JsonArray로 명시 변환해 검증한다. + for (JsonVariant item_variant : arr) { + if (!item_variant.is()) { + valid_items = false; + break; + } + JsonArray item = item_variant.as(); + if (item.size() < 2 || !item[0].is()) { + valid_items = false; + break; + } + + int id = item[0].as(); + if (is_m_topic) { + if (!item[1].is()) { + valid_items = false; + break; + } + int val = item[1].as(); + if (id < 0 || id >= M_OUT_COILS || (val != 0 && val != 1)) { + valid_items = false; + break; + } + } else if (is_d_topic) { + if (id < 0 || id >= 4 || !item[1].is()) { + valid_items = false; + break; + } + } + } + + if (!valid_items) { + Serial.printf("[MQTT ERROR] Invalid or empty items for topic %s; command will not be written to PLC\n", topic); + if (has_envelope && cmd_id != nullptr) { + publish_ack(cmd_id, "FAILED"); + } + return; + } + + // Envelope 규격 수신 시 상태를 RECEIVED로 즉시 업데이트하고 전송 대기 정보 설정 + if (has_envelope && cmd_id != nullptr) { + if (!plcOnline || currentState == STATE_INIT) { + Serial.printf("[MQTT REJECT] Command %s rejected because PLC is offline or in INIT state\n", cmd_id); + publish_ack(cmd_id, "FAILED"); + return; + } + if (pending_command_active) { + Serial.printf("[MQTT WARN] Command %s rejected because command %s is still active (BUSY)\n", cmd_id, pending_command_id); + publish_ack(cmd_id, "BUSY"); + return; + } + strncpy(pending_command_id, cmd_id, sizeof(pending_command_id) - 1); + pending_command_id[sizeof(pending_command_id) - 1] = '\0'; + pending_command_active = true; + pending_command_time = millis(); + pending_command_type_is_m = is_m_topic; + snapshot_pending_command_buffers(pending_command_type_is_m); + + publish_ack(pending_command_id, "RECEIVED"); + } + + Serial.printf("[MQTT PROCESS] Parsed array size: %d\n", arr.size()); + + if (is_m_topic) { + for (JsonVariant item_variant : arr) { + JsonArray item = item_variant.as(); + if (item.size() >= 2) { + int id = item[0]; + int val = item[1]; + int word_idx = id / 16; + int bit_idx = id % 16; + + if (word_idx >= 0 && word_idx < M_OUT_WORDS) { + uint16_t before_word = M_Out_New[word_idx]; + Serial.printf("[M_OUT REG] Requested bit: %d | val: %d | Calculated word_idx: %d, bit_idx: %d\n", id, val, word_idx, bit_idx); + if (val) { + M_Out_New[word_idx] |= (1 << bit_idx); + M_Out_Pending_Write[word_idx] |= (1 << bit_idx); + M_Out_Pending_Clear[word_idx] &= ~(1 << bit_idx); + } else { + M_Out_New[word_idx] &= ~(1 << bit_idx); + M_Out_Pending_Write[word_idx] &= ~(1 << bit_idx); + M_Out_Pending_Clear[word_idx] &= ~(1 << bit_idx); + } + Serial.printf("[M_OUT PROCESS completed] Updated M_Out_New[%d] to 0x%04X (Pending Write: 0x%04X)\n", word_idx, M_Out_New[word_idx], M_Out_Pending_Write[word_idx]); + Serial.printf( + "[M CMD TRACE] command_id=%s ui_bit=M%d plc_target=M%04d%X coil_addr=%d requested=%d word_before=0x%04X word_after=0x%04X pending=0x%04X\n", + pending_command_active ? pending_command_id : "legacy", + id, + 200 + word_idx, + bit_idx, + M_OUT_START_ADDR + id, + val, + before_word, + M_Out_New[word_idx], + M_Out_Pending_Write[word_idx] + ); + } else { + Serial.printf("[M_OUT LIMIT ERROR] word_idx %d is out of bounds (max: %d)\n", word_idx, M_OUT_WORDS); + } + } + } + } + else if (is_d_topic) { + for (JsonVariant item_variant : arr) { + JsonArray item = item_variant.as(); + if (item.size() >= 2) { + int id = item[0]; + float val = item[1].as(); + + if (id >= 0 && id < 4) { + uint32_t raw; + memcpy(&raw, &val, sizeof(raw)); + uint16_t w_low = raw & 0xFFFF; + uint16_t w_high = (raw >> 16) & 0xFFFF; + + D_Out_New[id * 2] = w_low; + D_Out_New[id * 2 + 1] = w_high; + D_Out_Pending_Groups |= (1U << id); + + Serial.printf("[D_OUT REG] Requested Group ID: %d | REAL: %.6g | Low Word: 0x%04X, High Word: 0x%04X\n", id, val, w_low, w_high); + } else { + Serial.printf("[D_OUT LIMIT ERROR] Group ID %d is out of bounds (max: 4)\n", id); + } + } + } + } +} + +// --- MQTT 초기화 및 최초 구독 연결 설정 --- +void setup_mqtt() { + client.setServer(mqtt_server, 1883); + client.setCallback(on_mqtt_message); + client.setBufferSize(2048); + Serial.println("[MQTT SETUP] Server configured to 1883. Buffer size set to 2048."); +} + +// --- MQTT 연결 유지 및 비차단 재연결 함수 --- +void maintain_mqtt() { + if (WiFi.status() != WL_CONNECTED) { + return; + } + + if (!client.connected()) { + unsigned long now = millis(); + if (now - lastMqttRetry >= 5000) { + lastMqttRetry = now; + Serial.print("[MQTT LOOP] Attempting connection..."); + + uint8_t mac[6]; + WiFi.macAddress(mac); + char clientId[30]; + snprintf(clientId, sizeof(clientId), "ESP32_Gateway_%02X%02X%02X", mac[3], mac[4], mac[5]); + + // Last Will & Testament 설정 (어플리케이션 비정상 종료 시 브로커가 OFFLINE 메시지 자동 배포) + if (client.connect(clientId, mqtt_user, mqtt_password, "/line1/status", 1, true, "{\"gateway\": \"OFFLINE\"}")) { + Serial.println(" OK (connected)"); + + // 연결 성공 직후 retained SYNCING 상태 전송 (초기화 중이므로) + char status_msg[160]; + snprintf(status_msg, sizeof(status_msg), + "{\"gateway\": \"SYNCING\", \"plc_state\": \"%s\", \"firmware\": \"%s\"}", + plcOnline ? "ONLINE" : "OFFLINE", FIRMWARE_VERSION); + client.publish("/line1/status", status_msg, true); + + // MQTT 연결 복구 시 Full-Sync 강제 수행 상태로 전이 (동기 정합성 확보) + Serial.println("[MQTT LOOP] Reconnected successfully. Triggering Full-Sync..."); + setup_buffers(); + currentState = STATE_INIT; + lastStateChange = millis(); + initSequenceStarted = false; + + client.publish("/line1/event", "{\"id\":999,\"val\":1}"); + + client.subscribe("/line1/m/out"); + client.subscribe("/line1/d/out"); + client.subscribe("/line1/cmd"); + Serial.println("[MQTT LOOP] Subscribed to /line1/m/out, /line1/d/out, /line1/cmd successfully."); + } else { + Serial.print(" FAILED, rc="); + Serial.println(client.state()); + } + } + } else { + client.loop(); + + // 주기적인 (5초 간격) Retained Heartbeat 발행 + static unsigned long lastHeartbeat = 0; + if (millis() - lastHeartbeat >= 5000) { + char status_msg[160]; + const char* gw_state = (currentState == STATE_INIT) ? "SYNCING" : "ONLINE"; + snprintf(status_msg, sizeof(status_msg), + "{\"gateway\": \"%s\", \"plc_state\": \"%s\", \"firmware\": \"%s\"}", + gw_state, plcOnline ? "ONLINE" : "OFFLINE", FIRMWARE_VERSION); + client.publish("/line1/status", status_msg, true); + lastHeartbeat = millis(); + } + } + + // --- 명령 처리 타임아웃 감시 --- + // M 명령: 3초. D 명령: D 쓰기 + Apply ON + Apply OFF의 여러 통신 슬롯을 포함하므로 6초. + unsigned long cmd_timeout = pending_command_type_is_m ? 3000 : 6000; + if (pending_command_active && (millis() - pending_command_time > cmd_timeout)) { + Serial.printf("[TIMEOUT] Command %s expired (%lums). Force unlocking and rolling back buffers.\n", + pending_command_id, cmd_timeout); + publish_ack(pending_command_id, "FAILED"); + + // Old는 모멘터리 ON 상태일 수 있으므로, 명령 수신 직전 스냅샷으로 복원한다. + rollback_pending_command_buffers(); + + // D Apply 펄스가 걸려 있었다면 Apply 비트와 대기 상태를 정리한다. + if (d_apply_pending && d_apply_word_idx >= 0 && d_apply_word_idx < M_OUT_WORDS) { + M_Out_New[d_apply_word_idx] &= ~d_apply_word_mask; + M_Out_Pending_Write[d_apply_word_idx] &= ~d_apply_word_mask; + M_Out_Pending_Clear[d_apply_word_idx] &= ~d_apply_word_mask; + } + d_apply_pending = false; + d_apply_word_mask = 0; + d_apply_word_idx = -1; + + pending_command_active = false; + pending_command_id[0] = '\0'; + pending_command_time = 0; + } +} + +// --- M영역 변경 감지 및 배치 MQTT 전송 (JsonDocument 힙 고갈 방지를 위해 3개 워드씩 Chunk 분할 전송, 워드 경계 일치로 변경 유실 차단) --- +bool detect_and_publish_M() { + if (!client.connected()) return false; + + bool overall_success = true; + JsonDocument doc; + JsonArray arr = doc.to(); + + int pending_words[M_IN_WORDS]; + int pending_word_count = 0; + + for (int i = 0; i < M_IN_WORDS; i++) { + // Full-Sync 대기 상태인 경우 0xFFFF를 대입하여 전체 비트 송신 강제 + uint16_t diff = full_sync_pending ? 0xFFFF : (M_In_New[i] ^ M_In_Old[i]); + if (diff != 0) { + pending_words[pending_word_count++] = i; + for (int j = 0; j < 16; j++) { + if ((diff >> j) & 1) { + int bit_id = i * 16 + j; + int val = (M_In_New[i] >> j) & 1; + + Serial.printf("[LOG M_IN CHANGE] PLC M-Read Bit Offset %d (M%d) changed to %d\n", bit_id, 1000 + bit_id, val); + + JsonArray pair = arr.add(); + pair.add(bit_id); + pair.add(val); + } + } + } + + // 워드 경계 정렬을 위해 루프 본문 완료 시점에 3개 워드 단위로 청크 발행 판단 + if (pending_word_count >= 3) { + char buffer[2048]; + serializeJson(doc, buffer, sizeof(buffer)); + + // 발행 성공 시에만 해당 청크에 완전히 포함된 워드들의 Old 버퍼를 New와 동기화 + bool ok = client.publish("/line1/m/in", buffer); + Serial.printf("[M_IN PUBLISH Chunk] size=%d success=%d payload=%s\n", arr.size(), ok, buffer); + if (ok) { + for (int w = 0; w < pending_word_count; w++) { + M_In_Old[pending_words[w]] = M_In_New[pending_words[w]]; + } + } else { + overall_success = false; + } + pending_word_count = 0; + + doc.clear(); + arr = doc.to(); + } + } + + if (arr.size() > 0) { + char buffer[2048]; + serializeJson(doc, buffer, sizeof(buffer)); + + // 발행 성공 시에만 남은 워드들의 Old 버퍼를 New와 동기화 + bool ok = client.publish("/line1/m/in", buffer); + Serial.printf("[M_IN PUBLISH Chunk Last] size=%d success=%d payload=%s\n", arr.size(), ok, buffer); + if (ok) { + for (int w = 0; w < pending_word_count; w++) { + M_In_Old[pending_words[w]] = M_In_New[pending_words[w]]; + } + } else { + overall_success = false; + } + } + return overall_success; +} + +// --- D영역 변경 감지 및 배치 MQTT 전송 --- +bool detect_and_publish_D() { + if (!client.connected()) return false; + + bool overall_success = true; + JsonDocument doc; + JsonArray arr = doc.to(); + + int pending_groups[10]; + int pending_group_count = 0; + + for (int g = 0; g < 10; g++) { + uint16_t w_low_new = D_In_New[g * 2]; + uint16_t w_high_new = D_In_New[g * 2 + 1]; + + uint16_t w_low_old = D_In_Old[g * 2]; + uint16_t w_high_old = D_In_Old[g * 2 + 1]; + + // Full-Sync 대기 상태인 경우 무조건 송신 강제 + if (full_sync_pending || w_low_new != w_low_old || w_high_new != w_high_old) { + JsonArray pair = arr.add(); + pair.add(g); + + if (g == 0 || g == 5) { + // Pump restart time is an unsigned 16-bit value in the first word. + // The second word is still read to preserve the fixed 32-bit group layout, but is not part of the value. + pair.add(w_low_new); + Serial.printf("[LOG D_IN CHANGE] PLC D-Read Group %d (D%d only, UINT16) changed from %u to %u\n", + g, 2000 + g * 2, w_low_old, w_low_new); + } else { + // All other groups contain an IEEE-754 REAL split into low/high PLC words. + uint32_t raw_new = ((uint32_t)w_high_new << 16) | w_low_new; + uint32_t raw_old = ((uint32_t)w_high_old << 16) | w_low_old; + float val; + float old_val; + memcpy(&val, &raw_new, sizeof(val)); + memcpy(&old_val, &raw_old, sizeof(old_val)); + pair.add(val); + Serial.printf("[LOG D_IN CHANGE] PLC D-Read Group %d (D%d-D%d, REAL) changed from %.6g to %.6g\n", + g, 2000 + g * 2, 2000 + g * 2 + 1, old_val, val); + } + + pending_groups[pending_group_count++] = g; + } + } + + if (arr.size() > 0) { + char buffer[1024]; + serializeJson(doc, buffer, sizeof(buffer)); + + // 발행 성공 시에만 해당 그룹의 Old 버퍼를 New와 동기화 + bool ok = client.publish("/line1/d/in", buffer); + Serial.printf("[D_IN PUBLISH Batch] size=%d success=%d payload=%s\n", arr.size(), ok, buffer); + if (ok) { + for (int w = 0; w < pending_group_count; w++) { + int idx = pending_groups[w]; + D_In_Old[idx * 2] = D_In_New[idx * 2]; + D_In_Old[idx * 2 + 1] = D_In_New[idx * 2 + 1]; + } + } else { + overall_success = false; + } + } + return overall_success; +} + +// --- Read 변경사항 종합 프로세스 실행 --- +bool process_read_changes() { + bool success_m = detect_and_publish_M(); + bool success_d = detect_and_publish_D(); + return success_m && success_d; +} diff --git a/gateway/mqtt_handler.h b/gateway/mqtt_handler.h new file mode 100644 index 0000000..581bd79 --- /dev/null +++ b/gateway/mqtt_handler.h @@ -0,0 +1,16 @@ +#ifndef MQTT_HANDLER_H +#define MQTT_HANDLER_H + +#include +#include + +extern PubSubClient client; + +void setup_mqtt(); +void maintain_mqtt(); +bool detect_and_publish_M(); +bool detect_and_publish_D(); +bool process_read_changes(); +void publish_ack(const char* command_id, const char* status); + +#endif // MQTT_HANDLER_H diff --git a/gateway/platformio.ini b/gateway/platformio.ini new file mode 100644 index 0000000..d7878b7 --- /dev/null +++ b/gateway/platformio.ini @@ -0,0 +1,18 @@ +[platformio] +src_dir = . +build_dir = C:/aaa +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino + +monitor_speed = 115200 +upload_port = COM7 +monitor_port = COM7 +COM4monitor_encoding = UTF-8 ; 인코딩 강제 지정 +; 이 프로젝트에서 사용하는 3가지 라이브러리를 정확히 등록합니다. +lib_deps = + bblanchon/ArduinoJson @ ^7.0.0 + knolleary/PubSubClient @ ^2.8 + robtillaart/CRC @ ^1.0.3 + \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..a7c5d6f --- /dev/null +++ b/readme.md @@ -0,0 +1,3 @@ +# Control_Panel + +생산라인제어 \ No newline at end of file diff --git a/server/0_server_v1-11_ui개선.zip b/server/0_server_v1-11_ui개선.zip new file mode 100644 index 0000000..f20d44c Binary files /dev/null and b/server/0_server_v1-11_ui개선.zip differ diff --git a/server/communication/__init__.py b/server/communication/__init__.py new file mode 100644 index 0000000..d72662f --- /dev/null +++ b/server/communication/__init__.py @@ -0,0 +1,2 @@ +# communication package marker +from .mqtt_client import start_mqtt_client, stop_mqtt_client diff --git a/server/communication/line_config.json b/server/communication/line_config.json new file mode 100644 index 0000000..57dde65 --- /dev/null +++ b/server/communication/line_config.json @@ -0,0 +1,11 @@ +{ + "lines": [ + { + "line_id": "line1", + "equipment_id": 8, + "mqtt_prefix": "line1", + "plc_slave_id": 1, + "description": "양산 1호기 (CC_MC_TYPE_A)" + } + ] +} diff --git a/server/communication/line_manager.py b/server/communication/line_manager.py new file mode 100644 index 0000000..a24935b --- /dev/null +++ b/server/communication/line_manager.py @@ -0,0 +1,56 @@ +import json +import logging +import os +import re +from typing import Dict, List + +logger = logging.getLogger("line_manager") + +class LineManager: + def __init__(self, config_file: str): + self.lines: Dict[str, dict] = {} + self.equipment_to_prefix: Dict[int, str] = {} + self.load_config(config_file) + + def load_config(self, config_file: str): + try: + if os.path.exists(config_file): + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + for line_cfg in config.get('lines', []): + line_id = line_cfg.get('line_id') + equipment_id = line_cfg.get('equipment_id') + + self.lines[line_id] = line_cfg + self.equipment_to_prefix[equipment_id] = line_id + + logger.info(f"[LINE CONFIG] Loaded {len(self.lines)} line configurations") + else: + logger.warning(f"[LINE CONFIG] Config file not found: {config_file}. Defaults to line1 -> ID 8.") + except Exception as e: + logger.error(f"[LINE CONFIG ERROR] Failed to load config: {e}") + + def get_equipment_id(self, prefix: str) -> int: + if prefix in self.lines: + return self.lines[prefix].get('equipment_id', 8) + # Default fallback + if "line1" in prefix: + return 8 + match = re.search(r'\d+', prefix) + if match: + line_num = int(match.group()) + return 7 + line_num + return 8 + + def get_prefix(self, equipment_id: int) -> str: + return self.equipment_to_prefix.get(equipment_id, "line1") + + def get_all_prefixes(self) -> List[str]: + if not self.lines: + return ["line1"] + return list(self.lines.keys()) + +# 전역 인스턴스 +config_path = os.path.join(os.path.dirname(__file__), "line_config.json") +line_manager = LineManager(config_path) diff --git a/server/communication/mqtt_client.py b/server/communication/mqtt_client.py new file mode 100644 index 0000000..166d8b3 --- /dev/null +++ b/server/communication/mqtt_client.py @@ -0,0 +1,321 @@ +import paho.mqtt.client as mqtt +import json +import logging +import time +import re +import asyncio + +logger = logging.getLogger("mqtt_client") + +# ── 설정 ────────────────────────────────────────────────── +BROKER_HOST = "localhost" +BROKER_PORT = 1883 + +# MQTT 토픽 규칙: +# 장비→서버(읽기): /{prefix}/m/in, /{prefix}/d/in, /{prefix}/event +# 서버→장비(쓰기): /{prefix}/m/out, /{prefix}/d/out + +_client: mqtt.Client = None + + +from .line_manager import line_manager + +def _get_equipment_id_by_prefix(prefix: str) -> int: + """ + 토픽 프리픽스를 기반으로 DB의 설비 ID를 반환합니다 (LineManager 연동). + """ + return line_manager.get_equipment_id(prefix) + + +def _publish_hmi_update(equipment_id: int): + """ + HMI 실시간 상태 업데이트 데이터를 조회하여 WebSocket으로 브로드캐스트합니다. + MQTT 수신 스레드가 블로킹되는 현상을 피하기 위해 Future 대기 없이 이벤트 루프에 비동기 처리만 위임합니다. + """ + try: + from equipment.db_manager import get_machine_status_detail_joined + from communication.websocket_manager import ws_manager + + result = get_machine_status_detail_joined(equipment_id) + if result: + # Decimal 타입 등의 변환 오류 방지 안전 필터 적용 + # 그룹 0,5(순환펌프 재작동 시간)만 정수, 나머지 d_read와 모든 d_write는 PLC REAL이므로 소수점 보존 + for i in range(10): + key = f"d_read_g{i}" + if key in result and result[key] is not None: + try: + result[key] = int(result[key]) if i in (0, 5) else float(result[key]) + except: + result[key] = 0 + for i in range(4): + key = f"d_write_g{i}" + if key in result and result[key] is not None: + try: + result[key] = float(result[key]) + except: + result[key] = 0 + + # 웹소켓 매니저의 메인 이벤트 루프로 비동기 브로드캐스트 위임 + loop = getattr(ws_manager, 'loop', None) + if loop and loop.is_running(): + asyncio.run_coroutine_threadsafe( + ws_manager.broadcast({ + "type": "equipment_update", + "equipment_id": equipment_id, + "data": result, + "timestamp": int(time.time() * 1000) + }), + loop + ) + except Exception as e: + logger.error(f"[WS BROADCAST ERROR] Failed to broadcast for equipment_id {equipment_id}: {e}", exc_info=True) + + +def _on_connect(client, userdata, flags, rc): + if rc == 0: + logger.info("[MQTT] Connected to broker successfully.") + + # line_config.json에 등록된 모든 라인의 토픽 동적 구독 + prefixes = line_manager.get_all_prefixes() + for prefix in prefixes: + client.subscribe(f"/{prefix}/m/in") + client.subscribe(f"/{prefix}/d/in") + client.subscribe(f"/{prefix}/event") + client.subscribe(f"/{prefix}/ack") + client.subscribe(f"/{prefix}/status") + + logger.info(f"[MQTT] Dynamically subscribed to topics for prefixes: {prefixes}") + else: + logger.error(f"[MQTT] Connection failed: rc={rc}") + + +def _on_message(client, userdata, msg): + """ + 배치 데이터 [[id, val], ...] 수집 및 DB 동기화, HMI 실시간 전계 (성능 모니터링 포함) + """ + start_time = time.time() + topic = msg.topic + try: + payload_str = msg.payload.decode() + logger.info(f"[MQTT RECEIVE] Topic: {topic} | Payload: {payload_str}") + + payload = json.loads(payload_str) + + # 토픽 프리픽스 동적 해석 + match = re.search(r'/([^/]+)/(m|d|event|ack|status)', topic) + line_prefix = match.group(1) if match else "line1" + equipment_id = _get_equipment_id_by_prefix(line_prefix) + + from database.connection import get_db_connection + + if "/ack" in topic: + command_id = payload.get("command_id") + status = payload.get("status") + applied_value = payload.get("applied_value") # 적용 데이터 파싱 + logger.info(f"[MQTT ACK Callback] Received Event command {command_id} status={status} applied_value={applied_value} for prefix: {line_prefix}") + + # DB 상태 및 적용값 분리 저장 업데이트 + from equipment.db_manager import update_control_command_status + update_control_command_status(command_id, status, applied_value) + + # WebSocket HMI 브로드캐스트 + from communication.websocket_manager import ws_manager + loop = getattr(ws_manager, 'loop', None) + if loop and loop.is_running(): + asyncio.run_coroutine_threadsafe( + ws_manager.broadcast({ + "type": "command_ack", + "command_id": command_id, + "status": status, + "applied_value": applied_value, + "timestamp": int(time.time() * 1000) + }), + loop + ) + + elif "/status" in topic: + gateway = payload.get("gateway", "OFFLINE") + plc_state = payload.get("plc_state", "OFFLINE") + + logger.info( + f"[MQTT STATUS Callback] gateway={gateway}, " + f"plc_state={plc_state} for prefix: {line_prefix}" + ) + + # 상태 토픽을 기준으로 장비 상태와 동기화 상태를 함께 갱신 + sync_state = None + + if gateway == "OFFLINE" or plc_state == "OFFLINE": + device_state = "OFFLINE" + elif gateway == "SYNCING": + device_state = "SYNCING" + sync_state = "SYNCING" + else: + device_state = "ONLINE" + sync_state = "IDLE" + + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + """ + UPDATE machine_status + SET gateway_state = %s, + plc_state = %s, + device_state = %s, + sync_state = COALESCE(%s, sync_state), + timestamp = CURRENT_TIMESTAMP + WHERE equipment_id = %s + """, + ( + gateway, + plc_state, + device_state, + sync_state, + equipment_id + ) + ) + conn.commit() + + _publish_hmi_update(equipment_id) + + elif "/event" in topic: + event_id = payload.get("id") + event_val = payload.get("val") + logger.info(f"[MQTT Event Callback] Received Event {event_id} (value={event_val}) for prefix: {line_prefix}") + + # Full-Sync 혹은 PLC 오프라인 상태값 업데이트 + device_state = "ONLINE" + sync_state = "IDLE" + if event_id == 999: # Full-Sync 시작 (STATE_INIT 진입) + device_state = "SYNCING" + sync_state = "SYNCING" + elif event_id == 998: # PLC Modbus 통신 실패 감지 + device_state = "OFFLINE" + elif event_id == 997: # Full-Sync 완료 (정상 루프 진입) + device_state = "ONLINE" + sync_state = "IDLE" + + with get_db_connection() as conn: + with conn.cursor() as cursor: + if event_id == 998: + cursor.execute( + "UPDATE machine_status SET device_state = %s, plc_state = 'OFFLINE', timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s", + (device_state, equipment_id) + ) + else: + cursor.execute( + "UPDATE machine_status SET device_state = %s, sync_state = %s, timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s", + (device_state, sync_state, equipment_id) + ) + conn.commit() + + logger.info(f"[MQTT Event DB Update Completed] device_state updated to: {device_state}") + _publish_hmi_update(equipment_id) + + elif "/m/in" in topic: + if not isinstance(payload, list): + raise ValueError("M_in payload is not a JSON list") + logger.info(f"[MQTT Processing M_in] Size of incoming batch: {len(payload)} for prefix: {line_prefix}") + # 1. 기존 m_read_raw 및 m_write_raw JSON 데이터 조회 + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT m_read_raw, m_write_raw FROM machine_status WHERE equipment_id = %s", (equipment_id,)) + row = cursor.fetchone() + + m_read = {} + if row and row.get("m_read_raw"): + try: + m_read = json.loads(row["m_read_raw"]) + except: + pass + + m_write = {} + if row and row.get("m_write_raw"): + try: + m_write = json.loads(row["m_write_raw"]) + except: + pass + + # 2. 배치 배열 수신값 머지 [[bit_id, val], ...] + for item in payload: + if isinstance(item, list) and len(item) == 2: + bit_id = str(item[0]) + val = int(item[1]) + m_read[bit_id] = val + + # 3. 모멘터리 비트 정리: 쓰기 버퍼(m_write_raw) 중 1로 켜져있던 비트들을 0으로 복구 + # (ESP32 측에서 이미 쓰기 실행 및 자동 리셋을 처리했으므로 서버측 DB만 동기화하고 패킷은 송신 안함) + for k, v in m_write.items(): + if v == 1: + m_write[k] = 0 + + # 4. DB에 병합된 M 데이터 업데이트 + cursor.execute( + "UPDATE machine_status SET m_read_raw = %s, m_write_raw = %s, device_state = 'ONLINE', timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s", + (json.dumps(m_read), json.dumps(m_write), equipment_id) + ) + conn.commit() + + logger.info(f"[MQTT Processing M_in Completed] Successfully merged and saved {len(payload)} bits to DB.") + _publish_hmi_update(equipment_id) + + elif "/d/in" in topic: + if not isinstance(payload, list): + raise ValueError("D_in payload is not a JSON list") + logger.info(f"[MQTT Processing D_in] Size of incoming batch: {len(payload)} for prefix: {line_prefix}") + # D영역 업데이트: [[group_id, val], ...] + with get_db_connection() as conn: + with conn.cursor() as cursor: + for item in payload: + if isinstance(item, list) and len(item) == 2: + group_id = int(item[0]) + # 그룹 0,5(순환펌프 재작동 시간)는 UINT16 정수, 그 외는 PLC REAL이므로 소수점 보존 + val = int(item[1]) if group_id in (0, 5) else float(item[1]) + if 0 <= group_id <= 9: + sql = f"UPDATE machine_status SET d_read_g{group_id} = %s, device_state = 'ONLINE', timestamp = CURRENT_TIMESTAMP WHERE equipment_id = %s" + cursor.execute(sql, (val, equipment_id)) + conn.commit() + + logger.info(f"[MQTT Processing D_in Completed] Successfully saved {len(payload)} D-registers to DB.") + _publish_hmi_update(equipment_id) + + # 성공 처리 시간 기록 + duration_ms = (time.time() - start_time) * 1000 + from monitoring.metrics import record_metric + record_metric(topic, duration_ms) + + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + from monitoring.metrics import record_metric + record_metric(topic, duration_ms, error=True, error_msg=str(e)) + logger.error(f"[MQTT ERROR] Message parse/update error: {e}", exc_info=True) + + +def start_mqtt_client(db=None): + """ + FastAPI 어플리케이션 구동 시 호출됩니다. + """ + global _client + _client = mqtt.Client(client_id="ChemiFactory_HMI_Server") + + # MQTT 인증 계정 정보 적용 (rc=5 방지) + _client.username_pw_set("ctnt_root", "Umsang6595!!") + + _client.on_connect = _on_connect + _client.on_message = _on_message + + try: + logger.info(f"[MQTT INIT] Connecting to broker at {BROKER_HOST}:{BROKER_PORT}...") + _client.connect(BROKER_HOST, BROKER_PORT, 60) + _client.loop_start() + logger.info("[MQTT INIT] Background client started and loop active.") + except Exception as e: + logger.error(f"[MQTT INIT ERROR] Startup failed: {e}", exc_info=True) + + +def stop_mqtt_client(): + global _client + if _client: + _client.loop_stop() + _client.disconnect() + logger.info("[MQTT] Background client stopped successfully.") diff --git a/server/communication/mqtt_handlers.py b/server/communication/mqtt_handlers.py new file mode 100644 index 0000000..4d6ceab --- /dev/null +++ b/server/communication/mqtt_handlers.py @@ -0,0 +1,317 @@ +import json +import logging +import re +import os +from datetime import datetime +from database.connection import get_db_connection +import mysql.connector + +logger = logging.getLogger(__name__) + +# --- 3단계 핵심: plc_mapping.json 매핑 설정 로드 --- +MAPPING_FILE_PATH = os.path.join(os.path.dirname(__file__), "plc_mapping.json") +plc_mapping = {} + +def load_plc_mapping(): + global plc_mapping + try: + if os.path.exists(MAPPING_FILE_PATH): + with open(MAPPING_FILE_PATH, "r", encoding="utf-8") as f: + plc_mapping = json.load(f) + logger.info("[MAPPING] Successfully loaded plc_mapping.json settings.") + else: + logger.warning("[MAPPING] plc_mapping.json does not exist. Default placeholders will be used.") + plc_mapping = {"m_area": {}, "d_area": {}} + except Exception as e: + logger.error(f"[MAPPING] Failed to load plc_mapping.json: {e}") + plc_mapping = {"m_area": {}, "d_area": {}} + +# 최초 1회 즉시 로드 +load_plc_mapping() + +# 각 라인 프리픽스별 동적 모멘터리 리셋 대기 비트 집합 (라인 -> set) +pending_momentary_resets = {} + + +# --- 헬스체크 --- +def handle_health_check(client, payload_str, topic): + logger.info(f"[MQTT] Health Check Ping received (Topic: {topic}). Replying with Pong.") + client.publish('/app/health_check/pong', payload_str) + +# --- 3단계 핵심: 특수 이벤트 핸들러 (999/998/997) --- +def handle_system_event(client, payload_str, topic): + """ + 이벤트 수신 시 DB 초기화 및 상태 제어를 담당합니다. + - 999 (BOOT / Full-Sync 시작): DB 초기화 준비 + - 998 (PLC_REBOOT 감지 / Modbus 실패 10회): DB의 기계 상태값을 전부 0으로 강제 리셋 + - 997 (Full-Sync 완료): 정상 대기 상태 전환 + """ + logger.info(f"[MQTT EVENT] Event received: {payload_str} (Topic: {topic})") + try: + data = json.loads(payload_str) + event_id = data.get("id") + + match = re.search(r'/([^/]+)/event', topic) + line_prefix = match.group(1) if match else "line1" + + if event_id == 998: + logger.warn(f"[MQTT EVENT] PLC_REBOOT(998) 감지! {line_prefix} 관련 DB 실시간 상태를 0으로 리셋합니다.") + _reset_machine_status_db(line_prefix) + elif event_id == 999: + logger.info(f"[MQTT EVENT] 게이트웨이 BOOT(999) 감지. {line_prefix} 관련 DB 실시간 상태를 0으로 리셋하고 Full-Sync를 개시합니다.") + _reset_machine_status_db(line_prefix) + elif event_id == 997: + logger.info(f"[MQTT EVENT] Full-Sync 완료(997). 정상 대기 상태 전환.") + + except Exception as e: + logger.error(f"[MQTT EVENT] Failed to process event: {e}", exc_info=True) + +# --- 3단계 핵심: M영역/D영역 변경 배치 수신 및 DB Upsert --- +def handle_m_in_batch(client, payload_str, topic): + """ + ESP32 -> 서버 (/line1/m/in) + 배치 데이터 [[id, val], [id, val], ...] 수신 시 DB 업데이트 + """ + global pending_momentary_resets + logger.debug(f"[MQTT M_IN] Batch raw data received: {payload_str}") + try: + batch = json.loads(payload_str) + if not isinstance(batch, list): + logger.warning("[MQTT M_IN] Payload is not a list array.") + return + + match = re.search(r'/([^/]+)/m/in', topic) + line_prefix = match.group(1) if match else "line1" + equipment_id = _get_equipment_id_by_prefix(line_prefix) + + _update_batch_values_in_db(equipment_id, "m_area", batch, client) + + # [신규 요구사항] 다음 읽기 영역 데이터를 받은 직후, 대기 중인 모멘터리 비트들을 0으로 복귀 + if line_prefix in pending_momentary_resets and pending_momentary_resets[line_prefix]: + reset_bits = list(pending_momentary_resets[line_prefix]) + off_batch = [[bit_id, 0] for bit_id in reset_bits] + reset_payload = json.dumps(off_batch) + + # 대기 목록 비우기 + pending_momentary_resets[line_prefix].clear() + + # ESP32 쓰기 영역 토픽(/m/out)으로 리셋 패킷 송신 + out_topic = f"/{line_prefix}/m/out" + client.publish(out_topic, reset_payload) + logger.info(f"[MQTT MOMENTARY DYNAMIC RESET] Sent reset command for bits {reset_bits} to {out_topic} after M_IN received.") + + except Exception as e: + logger.error(f"[MQTT M_IN] Error processing batch: {e}", exc_info=True) + +def handle_d_in_batch(client, payload_str, topic): + """ + ESP32 -> 서버 (/line1/d/in) + 배치 데이터 [[id, val], [id, val], ...] 수신 시 DB 업데이트 + """ + logger.debug(f"[MQTT D_IN] Batch raw data received: {payload_str}") + try: + batch = json.loads(payload_str) + if not isinstance(batch, list): + logger.warning("[MQTT D_IN] Payload is not a list array.") + return + + match = re.search(r'/([^/]+)/d/in', topic) + line_prefix = match.group(1) if match else "line1" + equipment_id = _get_equipment_id_by_prefix(line_prefix) + + _update_batch_values_in_db(equipment_id, "d_area", batch, client) + + except Exception as e: + logger.error(f"[MQTT D_IN] Error processing batch: {e}", exc_info=True) + + + +# --- 3단계 핵심: 모멘터리(Momentary) 제어 로직 --- +def handle_momentary_control(client, payload_str, topic): + """ + 서버 -> ESP32 방향 (/line1/m/out)으로 제어 명령 전송 시 동작 + 모멘터리 제어: 비트 ON 제어 명령을 대기열에 등록하고, 다음 M_IN(읽기 영역 수신) 직후 0으로 복귀시킵니다. + """ + global pending_momentary_resets + logger.info(f"[MQTT MOMENTARY] Control command detected: {payload_str} (Topic: {topic})") + try: + batch = json.loads(payload_str) + if not isinstance(batch, list): + return + + match = re.search(r'/([^/]+)/m/out', topic) + line_prefix = match.group(1) if match else "line1" + + # 1(ON)로 쓴 비트들만 추출하여 대기열에 추가 + on_bits = [item[0] for item in batch if len(item) >= 2 and item[1] == 1] + + if on_bits: + if line_prefix not in pending_momentary_resets: + pending_momentary_resets[line_prefix] = set() + for bit_id in on_bits: + pending_momentary_resets[line_prefix].add(bit_id) + logger.info(f"[MQTT MOMENTARY] Registered bits {on_bits} for line {line_prefix} for dynamic reset on next M_IN packet.") + + except Exception as e: + logger.error(f"[MQTT MOMENTARY] Error handling momentary control: {e}") + + +# --- DB 제어 관련 내부 헬퍼 함수 --- + +def _get_equipment_id_by_prefix(prefix: str) -> int: + """ + 토픽의 라인 구분자(prefix)를 기준으로 실제 DB의 설비 ID(PK)를 반환합니다. + - line1 (ESP32 Gateway 토픽) -> 실제 DB의 양산 1호기 id = 8번에 대응 + """ + if "line1" in prefix: + return 8 + match = re.search(r'\d+', prefix) + return int(match.group()) if match else 8 + + +def _reset_machine_status_db(line_prefix: str): + """PLC 연결 통신 장애 감지(998) 시 관련 장비 상태 데이터를 0 및 OFFLINE으로 강제 초기화""" + equipment_id = _get_equipment_id_by_prefix(line_prefix) + + # 208개 M_IN 비트와 188개 M_OUT 비트를 명시적으로 0으로 채운 JSON 문자열 생성 + initial_m_read = json.dumps({str(i): 0 for i in range(208)}) + initial_m_write = json.dumps({str(i): 0 for i in range(188)}) + + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + sql = """ + UPDATE machine_status + SET device_state = 'OFFLINE', + m_read_raw = %s, + m_write_raw = %s, + d_read_g0 = 0, d_read_g1 = 0, d_read_g2 = 0, d_read_g3 = 0, d_read_g4 = 0, + d_read_g5 = 0, d_read_g6 = 0, d_read_g7 = 0, d_read_g8 = 0, d_read_g9 = 0, + timestamp = 0 + WHERE equipment_id = %s + """ + cursor.execute(sql, (initial_m_read, initial_m_write, equipment_id)) + conn.commit() + logger.info(f"[DB RESET] Successfully reset machine_status columns (explicitly set all M bits to 0, excluded D-write columns) for equipment_id: {equipment_id}") + except mysql.connector.Error as err: + logger.error(f"[DB RESET] Failed to reset db: {err}") + +def _update_batch_values_in_db(equipment_id: int, area_type: str, batch: list, client): + """ + ESP32로부터 수신된 변경 배치 데이터를 DB에 누적 병합(Merge)합니다. + - M영역: m_read_raw JSON에 비트상태 누적 업데이트 + - D영역: 10개 d_read_g{0~9} 개별 컬럼에 수치값 매핑 업데이트 + """ + + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM machine_status WHERE equipment_id = %s", (equipment_id,)) + status = cursor.fetchone() + + if not status: + cursor.execute( + "INSERT INTO machine_status (equipment_id, device_state, m_read_raw, m_write_raw) VALUES (%s, 'ONLINE', '{}', '{}')", + (equipment_id,) + ) + conn.commit() + cursor.execute("SELECT * FROM machine_status WHERE equipment_id = %s", (equipment_id,)) + status = cursor.fetchone() + + # 기존 JSON 데이터 로드 + m_read = json.loads(status.get("m_read_raw") or '{}') + m_write = json.loads(status.get("m_write_raw") or '{}') + + updated = False + update_fields = {} + + if area_type == "m_area": + for item in batch: + if len(item) < 2: + continue + bit_id = str(item[0]) + val = item[1] + m_read[bit_id] = val + update_fields["m_read_raw"] = json.dumps(m_read) + updated = True + + elif area_type == "d_area": + for item in batch: + if len(item) < 2: + continue + group_id = int(item[0]) + val = int(item[1]) if group_id in (0, 5) else float(item[1]) + + if 0 <= group_id <= 9: + # d_read_g0 ~ d_read_g9 컬럼 바인딩 + update_fields[f"d_read_g{group_id}"] = val + updated = True + + if updated: + import time + current_ts = int(time.time() * 1000) + update_fields["timestamp"] = current_ts + update_fields["device_state"] = 'ONLINE' + + # 동적 SQL 쿼리 구성 + set_clause = ", ".join([f"{k} = %s" for k in update_fields.keys()]) + sql_update = f"UPDATE machine_status SET {set_clause} WHERE equipment_id = %s" + + params = list(update_fields.values()) + [equipment_id] + cursor.execute(sql_update, params) + conn.commit() + logger.debug(f"[DB UPDATE] Successfully updated machine_status for machine {equipment_id}") + + _publish_hmi_update(equipment_id, client) + + except Exception as e: + logger.error(f"[DB BATCH] Error merging & updating batch values: {e}", exc_info=True) + +def _get_prefix_by_equipment_id(equipment_id: int) -> str: + """DB 설비 ID에 대응하는 게이트웨이 토픽 프리픽스 조회""" + if equipment_id == 8: + return "line1" + return f"line{equipment_id}" + +def _publish_hmi_update(equipment_id: int, client): + try: + from equipment.db_manager import get_machine_status_detail_joined + result = get_machine_status_detail_joined(equipment_id) + if result: + # Decimal 등의 변환 이슈 방지 (C-2) + for i in range(10): + key = f"d_read_g{i}" + if key in result and result[key] is not None: + try: + result[key] = int(result[key]) + except Exception: + pass + for i in range(4): + key = f"d_write_g{i}" + if key in result and result[key] is not None: + try: + result[key] = int(result[key]) + except Exception: + pass + + # 2. --- 웹소켓 실시간 브로드캐스트 전송 (스레드 세이프 호출) --- + import asyncio + from communication.websocket_manager import ws_manager + try: + loop = getattr(ws_manager, 'loop', None) + if loop and loop.is_running(): + asyncio.run_coroutine_threadsafe( + ws_manager.broadcast({ + "type": "equipment_update", + "equipment_id": equipment_id, + "data": result + }), + loop + ) + except Exception as wse: + logger.warning(f"[WS BROADCAST] Could not schedule websocket broadcast: {wse}") + + except Exception as e: + logger.error(f"Failed to publish HMI update: {e}") + + diff --git a/server/communication/plc_mapping.json b/server/communication/plc_mapping.json new file mode 100644 index 0000000..915fff4 --- /dev/null +++ b/server/communication/plc_mapping.json @@ -0,0 +1,425 @@ +{ + "comment": "이 파일은 PLC의 읽기(입력) 영역과 쓰기(제어) 영역의 센서/동작명을 정의합니다.", + + "m_read_area": { + "0": "A그룹 | 인터락 리셋 상태", + "1": "A그룹 | E/STOP 상태", + "2": "A그룹 | DOOR좌 인터락 상태", + "3": "A그룹 | DOOR우 인터락 상태", + "4": "A그룹 | 수위 인터락 상태", + "5": "A그룹 | 히터 인터락 상태", + "6": "A그룹 | 전체그룹 활성화 ON 상태", + "7": "A그룹 | 전체그룹 활성화 OFF 상태(반전신호)", + "8": "A그룹 | 전체그룹 정지 상태", + "9": "A그룹 | 전체그룹 일시정지 상태", + "10": "A그룹 | 전체그룹 재시작 상태", + "11": "A그룹 | 함침,이송 그룹 ON 상태", + "12": "A그룹 | 함침,이송 그룹 OFF 상태(반전신호)", + "13": "A그룹 | 건조부 그룹 ON 상태", + "14": "A그룹 | 건조부 그룹 OFF 상태(반전신호)", + "15": "A그룹 | 커팅부 그룹 ON 상태", + "16": "A그룹 | 커팅부 그룹 OFF 상태(반전신호)", + "17": "A그룹 | 이송부 그룹 ON 상태", + "18": "A그룹 | 이송부 그룹 OFF 상태(반전신호)", + "19": "B그룹 | 인터락 리셋 상태", + "20": "B그룹 | E/STOP 상태", + "21": "B그룹 | DOOR좌 인터락 상태", + "22": "B그룹 | DOOR우 인터락 상태", + "23": "B그룹 | 수위 인터락 상태", + "24": "B그룹 | 히터 인터락 상태", + "25": "B그룹 | 전체그룹 활성화 ON 상태", + "26": "B그룹 | 전체그룹 활성화 OFF 상태(반전신호)", + "27": "B그룹 | 전체그룹 정지 상태", + "28": "B그룹 | 전체그룹 일시정지 상태", + "29": "B그룹 | 전체그룹 재시작 상태", + "30": "B그룹 | 함침,이송 그룹 ON 상태", + "31": "B그룹 | 함침,이송 그룹 OFF 상태(반전신호)", + "32": "B그룹 | 건조부 그룹 ON 상태", + "33": "B그룹 | 건조부 그룹 OFF 상태(반전신호)", + "34": "B그룹 | 커팅부 그룹 ON 상태", + "35": "B그룹 | 커팅부 그룹 OFF 상태(반전신호)", + "36": "B그룹 | 이송부 그룹 ON 상태", + "37": "B그룹 | 이송부 그룹 OFF 상태(반전신호)", + "38": "A그룹 | 함침부 자동모드 ON 상태", + "39": "A그룹 | 함침부 수동모드 ON 상태", + "40": "A그룹 | 순환 펌프 상태", + "41": "A그룹 | 배출 펌프 상태", + "42": "B그룹 | 함침부 자동모드 ON 상태", + "43": "B그룹 | 함침부 수동모드 ON 상태", + "44": "B그룹 | 순환 펌프 상태", + "45": "B그룹 | 배출 펌프 상태", + "46": "A그룹 | 건조부 자동모드 ON 상태", + "47": "A그룹 | 건조부 수동모드 ON 상태", + "48": "A그룹 | 송풍기 트립 상태", + "49": "A그룹 | 히터 제어요청 상태", + "50": "A그룹 | 송풍기1 작동 상태", + "51": "A그룹 | 히터 1 작동 상태", + "52": "A그룹 | 히터 2 작동 상태", + "53": "A그룹 | 히터 3 작동 상태", + "54": "A그룹 | 히터 4 작동 상태", + "55": "A그룹 | 히터 5 작동 상태", + "56": "B그룹 | 건조부 자동모드 ON 상태", + "57": "B그룹 | 건조부 수동모드 ON 상태", + "58": "B그룹 | 송풍기2 트립 상태", + "59": "B그룹 | 히터 제어요청 상태", + "60": "B그룹 | 송풍기2 작동 상태", + "61": "B그룹 | 히터 6 작동 상태", + "62": "B그룹 | 히터 7 작동 상태", + "63": "B그룹 | 히터 8 작동 상태", + "64": "B그룹 | 히터 9 작동 상태", + "65": "B그룹 | 히터 10 작동 상태", + "66": "A그룹 | 커팅모터 활성 상태", + "67": "A그룹 | 커팅모터 시작 상태", + "68": "B그룹 | 커팅모터 활성 상태", + "69": "B그룹 | 커팅모터 시작 상태", + "70": "A그룹 | 이송부 자동모드 ON 상태", + "71": "A그룹 | 이송부 수동모드 ON 상태", + "72": "A그룹 | 이송모터 자동시작 상태", + "73": "A그룹 | 이송모터 자동정지 상태", + "74": "A그룹 | 이송모터 수동 정방향 시작 상태", + "75": "A그룹 | 이송모터 수동 역방향 시작 상태", + "76": "A그룹 | 이송모터 수동 정지 상태", + "77": "A그룹 | 이송모터 제로셋 상태", + "78": "A그룹 | 이송모터 에러리셋 상태", + "79": "B그룹 | 이송부 자동모드 ON 상태", + "80": "B그룹 | 이송부 수동모드 ON 상태", + "81": "B그룹 | 이송모터 자동시작 상태", + "82": "B그룹 | 이송모터 자동정지 상태", + "83": "B그룹 | 이송모터 수동 정방향 시작 상태", + "84": "B그룹 | 이송모터 수동 역방향 시작 상태", + "85": "B그룹 | 이송모터 수동 정지 상태", + "86": "B그룹 | 이송모터 제로셋 상태", + "87": "B그룹 | 이송모터 에러리셋 상태", + "88": "A그룹 | 포트01 자동하강 상태", + "89": "A그룹 | 포트02 자동하강 상태", + "90": "A그룹 | 포트03 자동하강 상태", + "91": "A그룹 | 포트04 자동하강 상태", + "92": "A그룹 | 포트05 자동하강 상태", + "93": "A그룹 | 포트06 자동하강 상태", + "94": "A그룹 | 포트07 자동하강 상태", + "95": "A그룹 | 포트08 자동하강 상태", + "96": "A그룹 | 포트09 자동하강 상태", + "97": "A그룹 | 포트10 자동하강 상태", + "98": "A그룹 | 포트11 자동하강 상태", + "99": "A그룹 | 포트12 자동하강 상태", + "100": "A그룹 | 포트13 자동하강 상태", + "101": "A그룹 | 포트14 자동하강 상태", + "102": "A그룹 | 포트15 자동하강 상태", + "103": "A그룹 | 포트16 자동하강 상태", + "104": "A그룹 | 포트17 자동하강 상태", + "105": "A그룹 | 포트18 자동하강 상태", + "106": "A그룹 | 포트19 자동하강 상태", + "107": "A그룹 | 포트20 자동하강 상태", + "108": "B그룹 | 포트21 자동하강 상태", + "109": "B그룹 | 포트22 자동하강 상태", + "110": "B그룹 | 포트23 자동하강 상태", + "111": "B그룹 | 포트24 자동하강 상태", + "112": "B그룹 | 포트25 자동하강 상태", + "113": "B그룹 | 포트26 자동하강 상태", + "114": "B그룹 | 포트27 자동하강 상태", + "115": "B그룹 | 포트28 자동하강 상태", + "116": "B그룹 | 포트29 자동하강 상태", + "117": "B그룹 | 포트30 자동하강 상태", + "118": "B그룹 | 포트31 자동하강 상태", + "119": "B그룹 | 포트32 자동하강 상태", + "120": "B그룹 | 포트33 자동하강 상태", + "121": "B그룹 | 포트34 자동하강 상태", + "122": "B그룹 | 포트35 자동하강 상태", + "123": "B그룹 | 포트36 자동하강 상태", + "124": "B그룹 | 포트37 자동하강 상태", + "125": "B그룹 | 포트38 자동하강 상태", + "126": "B그룹 | 포트39 자동하강 상태", + "127": "B그룹 | 포트40 자동하강 상태", + "128": "A그룹 | 포트01 수동하강 상태", + "129": "A그룹 | 포트02 수동하강 상태", + "130": "A그룹 | 포트03 수동하강 상태", + "131": "A그룹 | 포트04 수동하강 상태", + "132": "A그룹 | 포트05 수동하강 상태", + "133": "A그룹 | 포트06 수동하강 상태", + "134": "A그룹 | 포트07 수동하강 상태", + "135": "A그룹 | 포트08 수동하강 상태", + "136": "A그룹 | 포트09 수동하강 상태", + "137": "A그룹 | 포트10 수동하강 상태", + "138": "A그룹 | 포트11 수동하강 상태", + "139": "A그룹 | 포트12 수동하강 상태", + "140": "A그룹 | 포트13 수동하강 상태", + "141": "A그룹 | 포트14 수동하강 상태", + "142": "A그룹 | 포트15 수동하강 상태", + "143": "A그룹 | 포트16 수동하강 상태", + "144": "A그룹 | 포트17 수동하강 상태", + "145": "A그룹 | 포트18 수동하강 상태", + "146": "A그룹 | 포트19 수동하강 상태", + "147": "A그룹 | 포트20 수동하강 상태", + "148": "B그룹 | 포트21 수동하강 상태", + "149": "B그룹 | 포트22 수동하강 상태", + "150": "B그룹 | 포트23 수동하강 상태", + "151": "B그룹 | 포트24 수동하강 상태", + "152": "B그룹 | 포트25 수동하강 상태", + "153": "B그룹 | 포트26 수동하강 상태", + "154": "B그룹 | 포트27 수동하강 상태", + "155": "B그룹 | 포트28 수동하강 상태", + "156": "B그룹 | 포트29 수동하강 상태", + "157": "B그룹 | 포트30 수동하강 상태", + "158": "B그룹 | 포트31 수동하강 상태", + "159": "B그룹 | 포트32 수동하강 상태", + "160": "B그룹 | 포트33 수동하강 상태", + "161": "B그룹 | 포트34 수동하강 상태", + "162": "B그룹 | 포트35 수동하강 상태", + "163": "B그룹 | 포트36 수동하강 상태", + "164": "B그룹 | 포트37 수동하강 상태", + "165": "B그룹 | 포트38 수동하강 상태", + "166": "B그룹 | 포트39 수동하강 상태", + "167": "B그룹 | 포트40 수동하강 상태", + "168": "A그룹 | 포트01 자동상승 상태", + "169": "A그룹 | 포트02 수동상승 상태", + "170": "A그룹 | 포트03 수동상승 상태", + "171": "A그룹 | 포트04 수동상승 상태", + "172": "A그룹 | 포트05 수동상승 상태", + "173": "A그룹 | 포트06 수동상승 상태", + "174": "A그룹 | 포트07 수동상승 상태", + "175": "A그룹 | 포트08 수동상승 상태", + "176": "A그룹 | 포트09 수동상승 상태", + "177": "A그룹 | 포트10 수동상승 상태", + "178": "A그룹 | 포트11 수동상승 상태", + "179": "A그룹 | 포트12 수동상승 상태", + "180": "A그룹 | 포트13 수동상승 상태", + "181": "A그룹 | 포트14 수동상승 상태", + "182": "A그룹 | 포트15 수동상승 상태", + "183": "A그룹 | 포트16 수동상승 상태", + "184": "A그룹 | 포트17 수동상승 상태", + "185": "A그룹 | 포트18 수동상승 상태", + "186": "A그룹 | 포트19 수동상승 상태", + "187": "A그룹 | 포트20 수동상승 상태", + "188": "B그룹 | 포트21 수동상승 상태", + "189": "B그룹 | 포트22 수동상승 상태", + "190": "B그룹 | 포트23 수동상승 상태", + "191": "B그룹 | 포트24 수동상승 상태", + "192": "B그룹 | 포트25 수동상승 상태", + "193": "B그룹 | 포트26 수동상승 상태", + "194": "B그룹 | 포트27 수동상승 상태", + "195": "B그룹 | 포트28 수동상승 상태", + "196": "B그룹 | 포트29 수동상승 상태", + "197": "B그룹 | 포트30 수동상승 상태", + "198": "B그룹 | 포트31 수동상승 상태", + "199": "B그룹 | 포트32 수동상승 상태", + "200": "B그룹 | 포트33 수동상승 상태", + "201": "B그룹 | 포트34 수동상승 상태", + "202": "B그룹 | 포트35 수동상승 상태", + "203": "B그룹 | 포트36 수동상승 상태", + "204": "B그룹 | 포트37 수동상승 상태", + "205": "B그룹 | 포트38 수동상승 상태", + "206": "B그룹 | 포트39 수동상승 상태", + "207": "B그룹 | 포트40 수동상승 상태" + }, + + "d_read_area": { + "0": "A그룹 | 순환펌프 재작동 시간", + "1": "A그룹 | 커팅모터 속도", + "2": "A그룹 | 이송모터 자동 목표 이동량", + "3": "A그룹 | 이송모터 1Hz 기준 현재 이동량", + "4": "A그룹 | 이송모터 수동 목표 속도", + "5": "B그룹 | 순환펌프 재작동 시간", + "6": "B그룹 | 커팅모터 속도", + "7": "B그룹 | 이송모터 자동 목표 이동량", + "8": "B그룹 | 이송모터 1Hz 기준 현재 이동량", + "9": "B그룹 | 이송모터 수동 목표 속도" + }, + + "m_write_area": { + "0": "A그룹 | 인터락 리셋 버튼", + "1": "A그룹 | 전체그룹 활성화 ON 버튼", + "2": "A그룹 | 전체그룹 활성화 OFF 버튼", + "3": "A그룹 | 전체그룹 정지 버튼", + "4": "A그룹 | 함침,이송 그룹 ON 버튼", + "5": "A그룹 | 함침,이송 그룹 OFF 버튼", + "6": "A그룹 | 건조부 그룹 ON 버튼", + "7": "A그룹 | 건조부 그룹 OFF 버튼", + "8": "A그룹 | 커팅부 그룹 ON 버튼", + "9": "A그룹 | 커팅부 그룹 OFF 버튼", + "10": "A그룹 | 이송부 그룹 ON 버튼", + "11": "A그룹 | 이송부 그룹 OFF 버튼", + "12": "B그룹 | 인터락 리셋 버튼", + "13": "B그룹 | 전체그룹 활성화 ON 버튼", + "14": "B그룹 | 전체그룹 활성화 OFF 버튼", + "15": "B그룹 | 전체그룹 정지 버튼", + "16": "B그룹 | 함침,이송 그룹 ON 버튼", + "17": "B그룹 | 함침,이송 그룹 OFF 버튼", + "18": "B그룹 | 건조부 그룹 ON 버튼", + "19": "B그룹 | 건조부 그룹 OFF 버튼", + "20": "B그룹 | 커팅부 그룹 ON 버튼", + "21": "B그룹 | 커팅부 그룹 OFF 버튼", + "22": "B그룹 | 이송부 그룹 ON 버튼", + "23": "B그룹 | 이송부 그룹 OFF 버튼", + "24": "A그룹 | 함침부 자동모드 ON 버튼", + "25": "A그룹 | 함침부 수동모드 ON 버튼", + "26": "A그룹 | 순환 펌프 작동 버튼", + "27": "A그룹 | 배출 펌프 작동 버튼", + "28": "B그룹 | 함침부 자동모드 ON 버튼", + "29": "B그룹 | 함침부 수동모드 ON 버튼", + "30": "B그룹 | 순환 펌프 작동 버튼", + "31": "B그룹 | 배출 펌프 작동 버튼", + "32": "A그룹 | 건조부 자동모드 ON 버튼", + "33": "A그룹 | 건조부 수동모드 ON 버튼", + "34": "A그룹 | 송풍기1 작동 버튼", + "35": "A그룹 | 히터 1 작동 버튼", + "36": "A그룹 | 히터 2 작동 버튼", + "37": "A그룹 | 히터 3 작동 버튼", + "38": "A그룹 | 히터 4 작동 버튼", + "39": "A그룹 | 히터 5 작동 버튼", + "40": "B그룹 | 건조부 자동모드 ON 버튼", + "41": "B그룹 | 건조부 수동모드 ON 버튼", + "42": "B그룹 | 송풍기2 작동 버튼", + "43": "B그룹 | 히터 6 작동 버튼", + "44": "B그룹 | 히터 7 작동 버튼", + "45": "B그룹 | 히터 8 작동 버튼", + "46": "B그룹 | 히터 9 작동 버튼", + "47": "B그룹 | 히터 10 작동 버튼", + "48": "A그룹 | 커팅모터 작동 버튼", + "49": "B그룹 | 커팅모터 작동 버튼", + "50": "A그룹 | 이송부 자동모드 ON 버튼", + "51": "A그룹 | 이송부 수동모드 ON 버튼", + "52": "A그룹 | 이송모터 자동시작 버튼", + "53": "A그룹 | 이송모터 자동정지 버튼", + "54": "A그룹 | 이송모터 수동 정방향 시작 버튼", + "55": "A그룹 | 이송모터 수동 역방향 시작 버튼", + "56": "A그룹 | 이송모터 수동 정지 버튼", + "57": "A그룹 | 이송모터 제로셋 버튼", + "58": "A그룹 | 이송모터 에러리셋 버튼", + "59": "B그룹 | 이송부 자동모드 ON 버튼", + "60": "B그룹 | 이송부 수동모드 ON 버튼", + "61": "B그룹 | 이송모터 자동시작 버튼", + "62": "B그룹 | 이송모터 자동정지 버튼", + "63": "B그룹 | 이송모터 수동 정방향 시작 버튼", + "64": "B그룹 | 이송모터 수동 역방향 시작 버튼", + "65": "B그룹 | 이송모터 수동 정지 버튼", + "66": "B그룹 | 이송모터 제로셋 버튼", + "67": "B그룹 | 이송모터 에러리셋 버튼", + "68": "A그룹 | 포트01 자동하강 버튼", + "69": "A그룹 | 포트02 자동하강 버튼", + "70": "A그룹 | 포트03 자동하강 버튼", + "71": "A그룹 | 포트04 자동하강 버튼", + "72": "A그룹 | 포트05 자동하강 버튼", + "73": "A그룹 | 포트06 자동하강 버튼", + "74": "A그룹 | 포트07 자동하강 버튼", + "75": "A그룹 | 포트08 자동하강 버튼", + "76": "A그룹 | 포트09 자동하강 버튼", + "77": "A그룹 | 포트10 자동하강 버튼", + "78": "A그룹 | 포트11 자동하강 버튼", + "79": "A그룹 | 포트12 자동하강 버튼", + "80": "A그룹 | 포트13 자동하강 버튼", + "81": "A그룹 | 포트14 자동하강 버튼", + "82": "A그룹 | 포트15 자동하강 버튼", + "83": "A그룹 | 포트16 자동하강 버튼", + "84": "A그룹 | 포트17 자동하강 버튼", + "85": "A그룹 | 포트18 자동하강 버튼", + "86": "A그룹 | 포트19 자동하강 버튼", + "87": "A그룹 | 포트20 자동하강 버튼", + "88": "B그룹 | 포트21 자동하강 버튼", + "89": "B그룹 | 포트22 자동하강 버튼", + "90": "B그룹 | 포트23 자동하강 버튼", + "91": "B그룹 | 포트24 자동하강 버튼", + "92": "B그룹 | 포트25 자동하강 버튼", + "93": "B그룹 | 포트26 자동하강 버튼", + "94": "B그룹 | 포트27 자동하강 버튼", + "95": "B그룹 | 포트28 자동하강 버튼", + "96": "B그룹 | 포트29 자동하강 버튼", + "97": "B그룹 | 포트30 자동하강 버튼", + "98": "B그룹 | 포트31 자동하강 버튼", + "99": "B그룹 | 포트32 자동하강 버튼", + "100": "B그룹 | 포트33 자동하강 버튼", + "101": "B그룹 | 포트34 자동하강 버튼", + "102": "B그룹 | 포트35 자동하강 버튼", + "103": "B그룹 | 포트36 자동하강 버튼", + "104": "B그룹 | 포트37 자동하강 버튼", + "105": "B그룹 | 포트38 자동하강 버튼", + "106": "B그룹 | 포트39 자동하강 버튼", + "107": "B그룹 | 포트40 자동하강 버튼", + "108": "A그룹 | 포트01 수동하강 버튼", + "109": "A그룹 | 포트02 수동하강 버튼", + "110": "A그룹 | 포트03 수동하강 버튼", + "111": "A그룹 | 포트04 수동하강 버튼", + "112": "A그룹 | 포트05 수동하강 버튼", + "113": "A그룹 | 포트06 수동하강 버튼", + "114": "A그룹 | 포트07 수동하강 버튼", + "115": "A그룹 | 포트08 수동하강 버튼", + "116": "A그룹 | 포트09 수동하강 버튼", + "117": "A그룹 | 포트10 수동하강 버튼", + "118": "A그룹 | 포트11 수동하강 버튼", + "119": "A그룹 | 포트12 수동하강 버튼", + "120": "A그룹 | 포트13 수동하강 버튼", + "121": "A그룹 | 포트14 수동하강 버튼", + "122": "A그룹 | 포트15 수동하강 버튼", + "123": "A그룹 | 포트16 수동하강 버튼", + "124": "A그룹 | 포트17 수동하강 버튼", + "125": "A그룹 | 포트18 수동하강 버튼", + "126": "A그룹 | 포트19 수동하강 버튼", + "127": "A그룹 | 포트20 수동하강 버튼", + "128": "B그룹 | 포트21 수동하강 버튼", + "129": "B그룹 | 포트22 수동하강 버튼", + "130": "B그룹 | 포트23 수동하강 버튼", + "131": "B그룹 | 포트24 수동하강 버튼", + "132": "B그룹 | 포트25 수동하강 버튼", + "133": "B그룹 | 포트26 수동하강 버튼", + "134": "B그룹 | 포트27 수동하강 버튼", + "135": "B그룹 | 포트28 수동하강 버튼", + "136": "B그룹 | 포트29 수동하강 버튼", + "137": "B그룹 | 포트30 수동하강 버튼", + "138": "B그룹 | 포트31 수동하강 버튼", + "139": "B그룹 | 포트32 수동하강 버튼", + "140": "B그룹 | 포트33 수동하강 버튼", + "141": "B그룹 | 포트34 수동하강 버튼", + "142": "B그룹 | 포트35 수동하강 버튼", + "143": "B그룹 | 포트36 수동하강 버튼", + "144": "B그룹 | 포트37 수동하강 버튼", + "145": "B그룹 | 포트38 수동하강 버튼", + "146": "B그룹 | 포트39 수동하강 버튼", + "147": "B그룹 | 포트40 수동하강 버튼", + "148": "A그룹 | 포트01 수동상승 버튼", + "149": "A그룹 | 포트02 수동상승 버튼", + "150": "A그룹 | 포트03 수동상승 버튼", + "151": "A그룹 | 포트04 수동상승 버튼", + "152": "A그룹 | 포트05 수동상승 버튼", + "153": "A그룹 | 포트06 수동상승 버튼", + "154": "A그룹 | 포트07 수동상승 버튼", + "155": "A그룹 | 포트08 수동상승 버튼", + "156": "A그룹 | 포트09 수동상승 버튼", + "157": "A그룹 | 포트10 수동상승 버튼", + "158": "A그룹 | 포트11 수동상승 버튼", + "159": "A그룹 | 포트12 수동상승 버튼", + "160": "A그룹 | 포트13 수동상승 버튼", + "161": "A그룹 | 포트14 수동상승 버튼", + "162": "A그룹 | 포트15 수동상승 버튼", + "163": "A그룹 | 포트16 수동상승 버튼", + "164": "A그룹 | 포트17 수동상승 버튼", + "165": "A그룹 | 포트18 수동상승 버튼", + "166": "A그룹 | 포트19 수동상승 버튼", + "167": "A그룹 | 포트20 수동상승 버튼", + "168": "B그룹 | 포트21 수동상승 버튼", + "169": "B그룹 | 포트22 수동상승 버튼", + "170": "B그룹 | 포트23 수동상승 버튼", + "171": "B그룹 | 포트24 수동상승 버튼", + "172": "B그룹 | 포트25 수동상승 버튼", + "173": "B그룹 | 포트26 수동상승 버튼", + "174": "B그룹 | 포트27 수동상승 버튼", + "175": "B그룹 | 포트28 수동상승 버튼", + "176": "B그룹 | 포트29 수동상승 버튼", + "177": "B그룹 | 포트30 수동상승 버튼", + "178": "B그룹 | 포트31 수동상승 버튼", + "179": "B그룹 | 포트32 수동상승 버튼", + "180": "B그룹 | 포트33 수동상승 버튼", + "181": "B그룹 | 포트34 수동상승 버튼", + "182": "B그룹 | 포트35 수동상승 버튼", + "183": "B그룹 | 포트36 수동상승 버튼", + "184": "B그룹 | 포트37 수동상승 버튼", + "185": "B그룹 | 포트38 수동상승 버튼", + "186": "B그룹 | 포트39 수동상승 버튼", + "187": "B그룹 | 포트40 수동상승 버튼" + }, + + "d_write_area": { + "0": "A그룹 | 이송모터 자동 목표 이동량", + "1": "A그룹 | 이송모터 수동 목표 속도", + "2": "B그룹 | 이송모터 자동 목표 이동량", + "3": "B그룹 | 이송모터 수동 목표 속도" + } +} diff --git a/server/communication/websocket_manager.py b/server/communication/websocket_manager.py new file mode 100644 index 0000000..111e79c --- /dev/null +++ b/server/communication/websocket_manager.py @@ -0,0 +1,43 @@ +import logging +from typing import List +from fastapi import WebSocket, WebSocketDisconnect + +logger = logging.getLogger(__name__) + +class ConnectionManager: + def __init__(self): + # 활성화된 웹소켓 커넥션 목록 관리 + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + logger.info(f"[WS] Client connected. Total active connections: {len(self.active_connections)}") + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections: + self.active_connections.remove(websocket) + logger.info(f"[WS] Client disconnected. Total active connections: {len(self.active_connections)}") + + async def broadcast(self, message: dict): + """ + 접속한 모든 웹 브라우저 클라이언트에게 JSON 데이터를 전송합니다. + """ + if not self.active_connections: + return + + logger.debug(f"[WS BROADCAST] Broadcasting message to {len(self.active_connections)} clients.") + disconnected_clients = [] + for connection in self.active_connections: + try: + await connection.send_json(message) + except Exception as e: + logger.warning(f"[WS BROADCAST] Failed to send message to client, marked for cleanup: {e}") + disconnected_clients.append(connection) + + # 오류가 난 비정상 커넥션 정리 + for conn in disconnected_clients: + self.disconnect(conn) + +# 전역 싱글톤 매니저 인스턴스 +ws_manager = ConnectionManager() diff --git a/server/customer/__init__.py b/server/customer/__init__.py new file mode 100644 index 0000000..74a3b82 --- /dev/null +++ b/server/customer/__init__.py @@ -0,0 +1,17 @@ +# customer package marker +from .db_manager import ( + add_customer_to_db, + update_customer_in_db, + delete_customer_from_db, + get_all_customers_from_db, + get_customer_details_from_db, + add_contact_to_db, + update_contact_in_db, + delete_contact_from_db, + add_sales_activity_to_db, + update_sales_activity_in_db, + delete_sales_activity_from_db, + get_customer_id_from_contact_id, + get_customer_id_from_sales_activity_id +) +from .router import router diff --git a/server/customer/db_manager.py b/server/customer/db_manager.py new file mode 100644 index 0000000..f8fb7dc --- /dev/null +++ b/server/customer/db_manager.py @@ -0,0 +1,243 @@ +import mysql.connector +from database.connection import get_db_connection +import logging + +# 고객사 추가 +def add_customer_to_db(customer_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO customers (name_kr, name_en, business_registration_number, contact_number, address) + VALUES (%s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + customer_data.get('name_kr'), + customer_data.get('name_en'), + customer_data.get('business_registration_number'), + customer_data.get('contact_number'), + customer_data.get('address') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding customer: {err}") + return None + +# 고객사 수정 +def update_customer_in_db(customer_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE customers SET + name_kr = %s, + name_en = %s, + business_registration_number = %s, + contact_number = %s, + address = %s + WHERE id = %s + """ + cursor.execute(sql, ( + customer_data.get('name_kr'), + customer_data.get('name_en'), + customer_data.get('business_registration_number'), + customer_data.get('contact_number'), + customer_data.get('address'), + customer_data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating customer: {err}") + return False + +# 고객사 삭제 +def delete_customer_from_db(customer_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM sales_activities WHERE customer_id = %s", (customer_id,)) + cursor.execute("DELETE FROM contacts WHERE customer_id = %s", (customer_id,)) + cursor.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting customer: {err}") + return False + +# 모든 고객사 목록 조회 +def get_all_customers_from_db(): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT id, name_kr, name_en, business_registration_number, contact_number, address FROM customers") + return cursor.fetchall() + except mysql.connector.Error as err: + logging.error(f"Error getting all customers: {err}") + return [] + +# 특정 고객사 상세 정보 조회 (담당자, 영업활동 포함) +def get_customer_details_from_db(customer_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM customers WHERE id = %s", (customer_id,)) + customer_data = cursor.fetchone() + if not customer_data: + return None + + cursor.execute("SELECT * FROM contacts WHERE customer_id = %s", (customer_id,)) + contacts_data = cursor.fetchall() + + cursor.execute("SELECT * FROM sales_activities WHERE customer_id = %s", (customer_id,)) + sales_activities_data = cursor.fetchall() + + return { + "customer": customer_data, + "contacts": contacts_data, + "salesActivities": sales_activities_data + } + except mysql.connector.Error as err: + logging.error(f"Error getting customer details: {err}") + return None + +# 담당자 추가 +def add_contact_to_db(contact_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO contacts (customer_id, name, email, phone_number, position, work_location) + VALUES (%s, %s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + contact_data.get('customer_id'), + contact_data.get('name'), + contact_data.get('email'), + contact_data.get('phone_number'), + contact_data.get('position'), + contact_data.get('work_location') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding contact: {err}") + return None + +# 담당자 수정 +def update_contact_in_db(contact_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE contacts SET + name = %s, email = %s, phone_number = %s, position = %s, work_location = %s + WHERE id = %s + """ + cursor.execute(sql, ( + contact_data.get('name'), + contact_data.get('email'), + contact_data.get('phone_number'), + contact_data.get('position'), + contact_data.get('work_location'), + contact_data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating contact: {err}") + return False + +# 담당자 삭제 +def delete_contact_from_db(contact_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM contacts WHERE id = %s", (contact_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting contact: {err}") + return False + +# 담당자 ID로 고객사 ID 조회 +def get_customer_id_from_contact_id(contact_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT customer_id FROM contacts WHERE id = %s", (contact_id,)) + result = cursor.fetchone() + return result['customer_id'] if result else None + except mysql.connector.Error as err: + logging.error(f"Error getting customer ID from contact: {err}") + return None + +# 영업 활동 추가 +def add_sales_activity_to_db(activity_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO sales_activities (customer_id, contact_id, activity_date, activity_type, memo) + VALUES (%s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + activity_data.get('customer_id'), + activity_data.get('contact_id'), + activity_data.get('activity_date'), + activity_data.get('activity_type'), + activity_data.get('memo') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding sales activity: {err}") + return None + +# 영업 활동 수정 +def update_sales_activity_in_db(activity_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE sales_activities SET + contact_id = %s, activity_date = %s, activity_type = %s, memo = %s + WHERE id = %s + """ + cursor.execute(sql, ( + activity_data.get('contact_id'), + activity_data.get('activity_date'), + activity_data.get('activity_type'), + activity_data.get('memo'), + activity_data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating sales activity: {err}") + return False + +# 영업 활동 삭제 +def delete_sales_activity_from_db(activity_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM sales_activities WHERE id = %s", (activity_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting sales activity: {err}") + return False + +# 영업 활동 ID로 고객사 ID 조회 +def get_customer_id_from_sales_activity_id(activity_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT customer_id FROM sales_activities WHERE id = %s", (activity_id,)) + result = cursor.fetchone() + return result['customer_id'] if result else None + except mysql.connector.Error as err: + logging.error(f"Error getting customer ID from sales activity: {err}") + return None diff --git a/server/customer/router.py b/server/customer/router.py new file mode 100644 index 0000000..b3d4e9a --- /dev/null +++ b/server/customer/router.py @@ -0,0 +1,127 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import List, Optional +from .db_manager import ( + add_customer_to_db, + update_customer_in_db, + delete_customer_from_db, + get_all_customers_from_db, + get_customer_details_from_db, + add_contact_to_db, + update_contact_in_db, + delete_contact_from_db, + add_sales_activity_to_db, + update_sales_activity_in_db, + delete_sales_activity_from_db +) + +router = APIRouter(prefix="/customers", tags=["customers"]) + +# --- Pydantic Schemas --- +class CustomerSchema(BaseModel): + id: Optional[int] = None + name_kr: str + name_en: Optional[str] = None + business_registration_number: Optional[str] = None + contact_number: Optional[str] = None + address: Optional[str] = None + +class ContactSchema(BaseModel): + id: Optional[int] = None + customer_id: int + name: str + email: Optional[str] = None + phone_number: Optional[str] = None + position: Optional[str] = None + work_location: Optional[str] = None + +class SalesActivitySchema(BaseModel): + id: Optional[int] = None + customer_id: int + contact_id: Optional[int] = None + activity_date: str # YYYY-MM-DD + activity_type: str + memo: Optional[str] = None + +# --- Customers APIs --- +@router.post("/", response_model=dict) +def create_customer(customer: CustomerSchema): + result = add_customer_to_db(customer.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create customer") + return {"id": result, "status": "success"} + +@router.put("/{customer_id}", response_model=dict) +def update_customer(customer_id: int, customer: CustomerSchema): + data = customer.dict() + data['id'] = customer_id + success = update_customer_in_db(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update customer") + return {"status": "success"} + +@router.delete("/{customer_id}", response_model=dict) +def delete_customer(customer_id: int): + success = delete_customer_from_db(customer_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete customer") + return {"status": "success"} + +@router.get("/", response_model=List[CustomerSchema]) +def list_customers(): + return get_all_customers_from_db() + +@router.get("/{customer_id}", response_model=dict) +def get_customer_details(customer_id: int): + details = get_customer_details_from_db(customer_id) + if not details: + raise HTTPException(status_code=404, detail="Customer not found") + return details + +# --- Contacts APIs --- +@router.post("/contacts", response_model=dict) +def create_contact(contact: ContactSchema): + result = add_contact_to_db(contact.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create contact") + return {"id": result, "status": "success"} + +@router.put("/contacts/{contact_id}", response_model=dict) +def update_contact(contact_id: int, contact: ContactSchema): + data = contact.dict() + data['id'] = contact_id + success = update_contact_in_db(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update contact") + return {"status": "success"} + +@router.delete("/contacts/{contact_id}", response_model=dict) +def delete_contact(contact_id: int): + success = delete_contact_from_db(contact_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete contact") + return {"status": "success"} + +# --- Sales Activities APIs --- +@router.post("/activities", response_model=dict) +def create_sales_activity(activity: SalesActivitySchema): + result = add_sales_activity_to_db(activity.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create sales activity") + return {"id": result, "status": "success"} + +@router.put("/activities/{activity_id}", response_model=dict) +def update_sales_activity(activity_id: int, activity: SalesActivitySchema): + data = activity.dict() + data['id'] = activity_id + success = update_sales_activity_in_db(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update sales activity") + return {"status": "success"} + +@router.delete("/activities/{activity_id}", response_model=dict) +def delete_sales_activity(activity_id: int): + success = delete_sales_activity_from_db(activity_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete sales activity") + return {"status": "success"} diff --git a/server/database/__init__.py b/server/database/__init__.py new file mode 100644 index 0000000..2e1c7f3 --- /dev/null +++ b/server/database/__init__.py @@ -0,0 +1 @@ +# database package marker diff --git a/server/database/connection.py b/server/database/connection.py new file mode 100644 index 0000000..074c41f --- /dev/null +++ b/server/database/connection.py @@ -0,0 +1,14 @@ +import mysql.connector + +# --- DB 설정 --- +DB_HOST = "localhost" +DB_PORT = 3306 +DB_USER = "ctnt_root" +DB_PASSWORD = "Umsang6595!!" +DB_NAME = "carbon_cutting_mc_db" + +from .pool import get_pooled_connection + +def get_db_connection(): + """DB 연결 풀에서 연결을 획득합니다.""" + return get_pooled_connection() diff --git a/server/database/pool.py b/server/database/pool.py new file mode 100644 index 0000000..f75b0e4 --- /dev/null +++ b/server/database/pool.py @@ -0,0 +1,38 @@ +import mysql.connector.pooling +import logging + +logger = logging.getLogger("db_pool") + +DB_CONFIG = { + "host": "localhost", + "port": 3306, + "user": "ctnt_root", + "password": "Umsang6595!!", + "database": "carbon_cutting_mc_db", + "autocommit": True, +} + +try: + dbpool = mysql.connector.pooling.MySQLConnectionPool( + pool_name="carbon_cutting_pool", + pool_size=5, + pool_reset_session=True, + **DB_CONFIG + ) + logger.info("[DB POOL] Connection pool initialized (pool_size=5)") +except Exception as e: + logger.error(f"[DB POOL ERROR] Failed to initialize pool: {e}", exc_info=True) + dbpool = None + +def get_pooled_connection(): + """풀에서 연결 획득""" + if dbpool is None: + raise RuntimeError("[DB POOL ERROR] Pool not initialized") + conn = dbpool.get_connection() + try: + # DB 세션이 만료되거나 끊어진 경우 자동 재연결 시도 + conn.ping(reconnect=True, attempts=3, delay=1) + except Exception as e: + logger.warning(f"[DB POOL] Ping/Reconnect failed: {e}") + return conn + diff --git a/server/equipment/__init__.py b/server/equipment/__init__.py new file mode 100644 index 0000000..3966ad8 --- /dev/null +++ b/server/equipment/__init__.py @@ -0,0 +1,12 @@ +# equipment package marker +from .db_manager import ( + add_equipment_to_db, + update_equipment_in_db, + delete_equipment_from_db, + get_all_equipment_from_db, + get_equipment_details_from_db, + save_to_db, + get_all_machine_statuses_joined, + get_machine_status_detail_joined +) +from .router import router diff --git a/server/equipment/db_manager.py b/server/equipment/db_manager.py new file mode 100644 index 0000000..e184911 --- /dev/null +++ b/server/equipment/db_manager.py @@ -0,0 +1,365 @@ +import mysql.connector +from database.connection import get_db_connection +import logging +import json + +# 설비 추가 +def add_equipment_to_db(equip_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + # DB 테이블에 만약 equipment_type 컬럼이 추가되었는지 파악 + # 없을 경우 기본값을 채워 안전하게 동작 + sql = """ + INSERT INTO equipment (name, yarn_bobbin_count, dryer_type, power_consumption, max_cutting_speed, max_feed_speed) + VALUES (%s, %s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + equip_data.get('name'), + equip_data.get('yarn_bobbin_count'), + equip_data.get('dryer_type'), + equip_data.get('power_consumption'), + equip_data.get('max_cutting_speed'), + equip_data.get('max_feed_speed') + )) + conn.commit() + + new_equipment_id = cursor.lastrowid + if new_equipment_id: + init_status_sql = "INSERT INTO machine_status (equipment_id, device_state, sync_state) VALUES (%s, %s, %s)" + cursor.execute(init_status_sql, (new_equipment_id, "OFFLINE", "IDLE")) + conn.commit() + return new_equipment_id + except mysql.connector.Error as err: + logging.error(f"Error adding equipment: {err}") + return None + +# 설비 수정 +def update_equipment_in_db(equip_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE equipment SET + name = %s, + yarn_bobbin_count = %s, + dryer_type = %s, + power_consumption = %s, + max_cutting_speed = %s, + max_feed_speed = %s + WHERE id = %s + """ + cursor.execute(sql, ( + equip_data.get('name'), + equip_data.get('yarn_bobbin_count'), + equip_data.get('dryer_type'), + equip_data.get('power_consumption'), + equip_data.get('max_cutting_speed'), + equip_data.get('max_feed_speed'), + equip_data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating equipment: {err}") + return False + +# 설비 삭제 +def delete_equipment_from_db(equipment_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM machine_status WHERE equipment_id = %s", (equipment_id,)) + cursor.execute("DELETE FROM plan_assignments WHERE equipment_id = %s", (equipment_id,)) + cursor.execute("DELETE FROM equipment WHERE id = %s", (equipment_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting equipment: {err}") + return False + +# 모든 설비 목록 조회 +def get_all_equipment_from_db(): + # --- DEBUG LOG --- + print("[DB DEBUG] get_all_equipment_from_db function starts.") + # ----------------- + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + # machine_status 테이블과 JOIN하여 실시간 장치 상태(device_state)를 함께 조회 + sql = """ + SELECT e.*, ms.device_state + FROM equipment e + LEFT JOIN machine_status ms ON e.id = ms.equipment_id + """ + cursor.execute(sql) + results = cursor.fetchall() + # --- DEBUG LOG --- + print(f"[DB DEBUG] Query executed. Raw rows fetched: {len(results)}") + # ----------------- + for r in results: + # device_state가 'ONLINE'이면 is_online을 True로 설정 + r['is_online'] = (r.get('device_state') == 'ONLINE') + + if 'equipment_type' not in r or not r['equipment_type']: + r['equipment_type'] = 'CC_MC_TYPE_A' + # 프론트엔드가 카드를 렌더링할 때 요구하는 필수 필드 보완 (방어 코드) + if 'type' not in r or not r['type']: + r['type'] = 'cutter' + if 'ip_address' not in r or not r['ip_address']: + r['ip_address'] = '127.0.0.1' + if 'line_prefix' not in r or not r['line_prefix']: + r['line_prefix'] = 'line1' + return results + except mysql.connector.Error as err: + logging.error(f"Error getting all equipment: {err}") + # --- DEBUG LOG --- + print(f"[DB DEBUG] Error fetching equipment: {err}") + # ----------------- + return [] + + + +# 특정 설비 상세 정보 조회 +def get_equipment_details_from_db(equipment_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM equipment WHERE id = %s", (equipment_id,)) + equipment_data = cursor.fetchone() + if not equipment_data: + return None + if 'equipment_type' not in equipment_data: + equipment_data['equipment_type'] = 'CC_MC_TYPE_A' + + return { + "equipment": equipment_data + } + except mysql.connector.Error as err: + logging.error(f"Error getting equipment details: {err}") + return None + +# 실시간 설비 상태 DB에 저장/업데이트 (UPSERT) +def save_to_db(data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO machine_status ( + equipment_id, timestamp, device_state, operation_mode, + yarn_sensors, dryer_states, temperatures, pump_states, + cutting_motor_is_running, cutting_motor_value, + transfer_motor_is_running, transfer_motor_value, + fan_state, door_sensors, water_level_sensor + ) VALUES ( + %(equipment_id)s, %(timestamp)s, %(device_state)s, %(operation_mode)s, + %(yarn_sensors)s, %(dryer_states)s, %(temperatures)s, %(pump_states)s, + %(cutting_motor_is_running)s, %(cutting_motor_value)s, + %(transfer_motor_is_running)s, %(transfer_motor_value)s, + %(fan_state)s, %(door_sensors)s, %(water_level_sensor)s + ) + ON DUPLICATE KEY UPDATE + timestamp = VALUES(timestamp), + device_state = VALUES(device_state), + operation_mode = VALUES(operation_mode), + yarn_sensors = VALUES(yarn_sensors), + dryer_states = VALUES(dryer_states), + temperatures = VALUES(temperatures), + pump_states = VALUES(pump_states), + cutting_motor_is_running = VALUES(cutting_motor_is_running), + cutting_motor_value = VALUES(cutting_motor_value), + transfer_motor_is_running = VALUES(transfer_motor_is_running), + transfer_motor_value = VALUES(transfer_motor_value), + fan_state = VALUES(fan_state), + door_sensors = VALUES(door_sensors), + water_level_sensor = VALUES(water_level_sensor) + """ + params = data.copy() + for key, value in params.items(): + if isinstance(value, list) or isinstance(value, dict): + params[key] = json.dumps(value) + + cursor.execute(sql, params) + conn.commit() + logging.info(f"Successfully saved status for equipment_id: {data['equipment_id']}") + return True + except mysql.connector.Error as err: + logging.error(f"DB Error in save_to_db: {err}") + return False + except Exception as e: + logging.error(f"An unexpected error occurred in save_to_db: {e}") + return False + +# 모든 설비의 현재 상태를 설비 정보와 JOIN하여 조회 +def get_all_machine_statuses_joined(): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + SELECT + e.id as equipment_id, + e.name, + ms.timestamp, + ms.device_state, + ms.d_read_g0, + ms.d_read_g1, + ms.d_read_g2 + FROM equipment e + LEFT JOIN machine_status ms ON e.id = ms.equipment_id + ORDER BY e.id + """ + cursor.execute(sql) + results = cursor.fetchall() + for r in results: + if 'equipment_type' not in r: + r['equipment_type'] = 'CC_MC_TYPE_A' + return results + except mysql.connector.Error as err: + logging.error(f"Error in get_all_machine_statuses_joined: {err}") + return [] + +# 특정 설비의 상세 상태를 설비 정보와 JOIN하여 조회 +def get_machine_status_detail_joined(equipment_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + SELECT + e.*, + ms.timestamp, + ms.device_state, + ms.sync_state, + ms.m_read_raw, + ms.m_write_raw, + ms.d_read_g0, ms.d_read_g1, ms.d_read_g2, ms.d_read_g3, ms.d_read_g4, + ms.d_read_g5, ms.d_read_g6, ms.d_read_g7, ms.d_read_g8, ms.d_read_g9, + ms.d_write_g0, ms.d_write_g1, ms.d_write_g2, ms.d_write_g3 + FROM equipment e + LEFT JOIN machine_status ms ON e.id = ms.equipment_id + WHERE e.id = %s + """ + cursor.execute(sql, (equipment_id,)) + result = cursor.fetchone() + + if result: + if 'equipment_type' not in result: + result['equipment_type'] = 'CC_MC_TYPE_A' + # JSON 컬럼 자동 파싱 처리 + for key in ["m_read_raw", "m_write_raw"]: + val = result.get(key) + if isinstance(val, str): + try: + result[key] = json.loads(val) + except json.JSONDecodeError: + logging.warning(f"Could not decode JSON for key {key}: {val}") + return result + except mysql.connector.Error as err: + logging.error(f"Error in get_machine_status_detail_joined: {err}") + return None + +def update_m_write_in_db(equipment_id: int, payload: list): + """M영역 쓰기 설정값을 machine_status의 m_write_raw JSON에 병합 업데이트합니다.""" + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + # 기존 데이터 가져오기 + cursor.execute("SELECT m_write_raw FROM machine_status WHERE equipment_id = %s", (equipment_id,)) + row = cursor.fetchone() + + m_write = {} + if row and row.get("m_write_raw"): + try: + m_write = json.loads(row["m_write_raw"]) + except: + pass + + # 새로운 값 병합 + for item in payload: + if len(item) >= 2: + bit_id = str(item[0]) + val = int(item[1]) + m_write[bit_id] = val + + # 업데이트 수행 + sql = "UPDATE machine_status SET m_write_raw = %s WHERE equipment_id = %s" + cursor.execute(sql, (json.dumps(m_write), equipment_id)) + conn.commit() + return True + except Exception as e: + logging.error(f"Error in update_m_write_in_db: {e}") + return False + +def update_d_write_in_db(equipment_id: int, payload: list): + """D영역 쓰기 설정값을 machine_status의 d_write_g0~g3 개별 컬럼에 업데이트합니다.""" + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + for item in payload: + if len(item) >= 2: + group_id = int(item[0]) + val = float(item[1]) + + if 0 <= group_id <= 3: + sql = f"UPDATE machine_status SET d_write_g{group_id} = %s WHERE equipment_id = %s" + cursor.execute(sql, (val, equipment_id)) + conn.commit() + return True + except Exception as e: + logging.error(f"Error in update_d_write_in_db: {e}") + return False + + +def save_control_command(equipment_id: int, command_id: str, cmd_type: str, requested_value: list, status: str): + """제어 명령 요청을 생성하여 control_commands 테이블에 기록합니다.""" + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + sql = """ + INSERT INTO control_commands (equipment_id, command_id, type, requested_value, command_status) + VALUES (%s, %s, %s, %s, %s) + """ + cursor.execute(sql, (equipment_id, command_id, cmd_type, json.dumps(requested_value), status)) + conn.commit() + return True + except Exception as e: + logging.error(f"Error in save_control_command: {e}") + return False + + +def update_control_command_status(command_id: str, status: str, applied_value: list = None): + """수신된 ACK 신호나 타임아웃에 기반하여 제어 명령 상태를 업데이트합니다.""" + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + if applied_value is not None: + sql = """ + UPDATE control_commands + SET command_status = %s, applied_value = %s, applied_at = CURRENT_TIMESTAMP + WHERE command_id = %s + """ + cursor.execute(sql, (status, json.dumps(applied_value), command_id)) + elif status == "PLC_APPLIED": + sql = """ + UPDATE control_commands + SET command_status = %s, applied_value = requested_value, + applied_at = CURRENT_TIMESTAMP + WHERE command_id = %s + """ + cursor.execute(sql, (status, command_id)) + elif status in ("FAILED", "TIMEOUT"): + sql = """ + UPDATE control_commands + SET command_status = %s, applied_at = CURRENT_TIMESTAMP + WHERE command_id = %s + """ + cursor.execute(sql, (status, command_id)) + else: + cursor.execute( + "UPDATE control_commands SET command_status = %s WHERE command_id = %s", + (status, command_id) + ) + conn.commit() + return True + except Exception as e: + logging.error(f"Error in update_control_command_status: {e}") + return False diff --git a/server/equipment/router.py b/server/equipment/router.py new file mode 100644 index 0000000..4459ab4 --- /dev/null +++ b/server/equipment/router.py @@ -0,0 +1,316 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, List +import logging +import math +import uuid +from datetime import datetime + +logger = logging.getLogger("equipment_router") +router = APIRouter(prefix="/equipment", tags=["equipment"]) + +class EquipInfo(BaseModel): + equip_id: str + equip_name: str + equip_type: str + description: Optional[str] = "" + +class ControlRequestM(BaseModel): + bit: Optional[int] = None + value: int + +from .db_manager import ( + get_all_equipment_from_db, + get_equipment_details_from_db, + get_all_machine_statuses_joined, + get_machine_status_detail_joined, + save_control_command +) + +def update_m_write_in_db(equipment_id: int, payload: list): + try: + from database.connection import get_db_connection + import json + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT m_write_raw FROM machine_status WHERE equipment_id = %s", (equipment_id,)) + row = cursor.fetchone() + m_write = {} + if row and row.get("m_write_raw"): + try: + m_write = json.loads(row["m_write_raw"]) + except: + pass + for item in payload: + if len(item) >= 2: + m_write[str(item[0])] = int(item[1]) + cursor.execute( + "UPDATE machine_status SET m_write_raw = %s WHERE equipment_id = %s", + (json.dumps(m_write), equipment_id) + ) + conn.commit() + return True + except Exception as e: + logger.error(f"[DB Error in update_m_write_in_db]: {e}") + return False + +def update_d_write_in_db(equipment_id: int, payload: list): + try: + from database.connection import get_db_connection + with get_db_connection() as conn: + with conn.cursor() as cursor: + for item in payload: + if len(item) >= 2: + group_id = int(item[0]) + val = float(item[1]) + if 0 <= group_id <= 3: + cursor.execute( + f"UPDATE machine_status SET d_write_g{group_id} = %s WHERE equipment_id = %s", + (val, equipment_id) + ) + conn.commit() + return True + except Exception as e: + logger.error(f"[DB Error in update_d_write_in_db]: {e}") + return False + +def _get_line_prefix_by_id(equipment_id: int) -> str: + if equipment_id == 8: + return "line1" + return f"line{equipment_id}" + + +def _ensure_equipment_is_ready(equipment_id: int): + status = get_machine_status_detail_joined(equipment_id) + if not status: + raise HTTPException(status_code=404, detail="Machine status detail not found") + if status.get("sync_state") == "SYNCING" or status.get("device_state") != "ONLINE": + raise HTTPException(status_code=423, detail="Equipment initialization is in progress") + + +@router.get("", response_model=List[dict]) +def list_equipment_root(): + return get_all_equipment_from_db() + + +@router.get("/status/all", response_model=List[dict]) +def list_machine_statuses(): + return get_all_machine_statuses_joined() + + +@router.get("/monitoring/metrics", response_model=dict) +def get_performance_metrics(): + from monitoring.metrics import get_metrics_summary + return get_metrics_summary() + + +@router.get("/{equipment_id}", response_model=dict) +def get_equipment_details(equipment_id: int): + details = get_equipment_details_from_db(equipment_id) + if not details: + raise HTTPException(status_code=404, detail="Equipment not found") + return details + + +@router.get("/{equipment_id}/status", response_model=dict) +def get_machine_status_detail(equipment_id: int): + status = get_machine_status_detail_joined(equipment_id) + if not status: + raise HTTPException(status_code=404, detail="Machine status detail not found") + return status + + +@router.post("/{equipment_id}/control/m", response_model=dict, status_code=202) +def send_momentary_control_m(equipment_id: int, payload: List[List[int]]): + from communication.mqtt_client import _client + import json + + logger.info(f"[REST API WRITE M] Started. equipment_id: {equipment_id} | Payload: {payload}") + _ensure_equipment_is_ready(equipment_id) + + if not payload: + raise HTTPException(status_code=400, detail="Empty payload not allowed") + + seen_ids = set() + for item in payload: + if len(item) < 2: + raise HTTPException(status_code=400, detail="Invalid payload format: each item must be [id, value]") + bit_id, val = item[0], item[1] + if bit_id in seen_ids: + raise HTTPException(status_code=400, detail=f"Duplicate bit ID in payload: {bit_id}") + seen_ids.add(bit_id) + if not (0 <= bit_id <= 187): + raise HTTPException(status_code=400, detail=f"M-bit ID {bit_id} out of bounds (0~187)") + if val not in (0, 1): + raise HTTPException(status_code=400, detail=f"Invalid M-bit value {val} (must be 0 or 1)") + + if _client is None or not _client.is_connected(): + logger.error("[REST API WRITE M ERROR] MQTT client is offline") + raise HTTPException(status_code=503, detail="MQTT broker connection is offline") + + command_id = str(uuid.uuid4()) + + db_ok = save_control_command(equipment_id, command_id, "m_write", payload, "ACCEPTED") + if not db_ok: + logger.error("[REST API WRITE M ERROR] Failed to save control command in database") + raise HTTPException(status_code=500, detail="Database transaction failed (Command not queued)") + + ok = update_m_write_in_db(equipment_id, payload) + if not ok: + logger.error("[REST API WRITE M ERROR] Failed to update machine status write buffer in database") + from .db_manager import update_control_command_status + update_control_command_status(command_id, "FAILED") + raise HTTPException(status_code=500, detail="Database write buffer update failed") + + prefix = _get_line_prefix_by_id(equipment_id) + topic = f"/{prefix}/m/out" + + envelope = { + "command_id": command_id, + "type": "m_write", + "items": payload, + "requested_at": datetime.utcnow().isoformat() + } + + try: + msg_payload = json.dumps(envelope) + result = _client.publish(topic, msg_payload, qos=1) + if result.rc != 0: + raise Exception(f"Paho MQTT publish returned error code: {result.rc}") + logger.info(f"[REST API WRITE M SUCCESS] Published to topic: {topic} | Payload: {msg_payload}") + except Exception as e: + logger.error(f"[REST API WRITE M MQTT ERROR] Publish failed: {e}", exc_info=True) + from .db_manager import update_control_command_status + update_control_command_status(command_id, "FAILED") + raise HTTPException(status_code=500, detail=f"MQTT publish failed: {e}") + + return { + "status": "accepted", + "command_id": command_id, + "message": "Command queued, ACK pending" + } + + +@router.post("/{equipment_id}/control/d", response_model=dict, status_code=202) +def send_register_control_d(equipment_id: int, payload: List[List[int]]): + from communication.mqtt_client import _client + import json + + logger.info(f"[REST API WRITE D] Started. equipment_id: {equipment_id} | Payload: {payload}") + _ensure_equipment_is_ready(equipment_id) + + if not payload: + raise HTTPException(status_code=400, detail="Empty payload not allowed") + + float_payload = [] + seen_ids = set() + for item in payload: + if len(item) < 2: + raise HTTPException(status_code=400, detail="Invalid payload format: each item must be [id, value]") + group_id, val = int(item[0]), float(item[1]) + if group_id in seen_ids: + raise HTTPException(status_code=400, detail=f"Duplicate group ID in payload: {group_id}") + seen_ids.add(group_id) + if not (0 <= group_id <= 3): + raise HTTPException(status_code=400, detail=f"D-group ID {group_id} out of bounds (0~3)") + if not math.isfinite(val): + raise HTTPException(status_code=400, detail=f"Invalid REAL value {val}") + float_payload.append([group_id, val]) + + if _client is None or not _client.is_connected(): + logger.error("[REST API WRITE D ERROR] MQTT client is offline") + raise HTTPException(status_code=503, detail="MQTT broker connection is offline") + + command_id = str(uuid.uuid4()) + + db_ok = save_control_command(equipment_id, command_id, "d_write", float_payload, "ACCEPTED") + if not db_ok: + logger.error("[REST API WRITE D ERROR] Failed to save control command in database") + raise HTTPException(status_code=500, detail="Database transaction failed (Command not queued)") + + ok = update_d_write_in_db(equipment_id, float_payload) + if not ok: + logger.error("[REST API WRITE D ERROR] Failed to update machine status write buffer in database") + from .db_manager import update_control_command_status + update_control_command_status(command_id, "FAILED") + raise HTTPException(status_code=500, detail="Database write buffer update failed") + + prefix = _get_line_prefix_by_id(equipment_id) + topic = f"/{prefix}/d/out" + + envelope = { + "command_id": command_id, + "type": "d_write", + "items": float_payload, + "requested_at": datetime.utcnow().isoformat() + } + + try: + msg_payload = json.dumps(envelope) + result = _client.publish(topic, msg_payload, qos=1) + if result.rc != 0: + raise Exception(f"Paho MQTT publish returned error code: {result.rc}") + logger.info(f"[REST API WRITE D SUCCESS] Published to topic: {topic} | Payload: {msg_payload}") + except Exception as e: + logger.error(f"[REST API WRITE D MQTT ERROR] Publish failed: {e}", exc_info=True) + from .db_manager import update_control_command_status + update_control_command_status(command_id, "FAILED") + raise HTTPException(status_code=500, detail=f"MQTT publish failed: {e}") + + return { + "status": "accepted", + "command_id": command_id, + "message": "Command queued, ACK pending" + } + + +@router.post("/{equipment_id}/sync", response_model=dict) +def trigger_manual_sync(equipment_id: int): + from communication.mqtt_client import _client + import json + + logger.info(f"[REST API SYNC] Manual Sync requested for equipment_id: {equipment_id}") + + if _client is None or not _client.is_connected(): + logger.error("[REST API SYNC ERROR] MQTT client is offline") + raise HTTPException(status_code=503, detail="MQTT broker connection is offline") + + prefix = _get_line_prefix_by_id(equipment_id) + + initial_m_write = json.dumps({str(i): 0 for i in range(188)}) + try: + from database.connection import get_db_connection + with get_db_connection() as conn: + with conn.cursor() as cursor: + cursor.execute( + "UPDATE machine_status SET m_write_raw = %s, device_state = 'SYNCING' WHERE equipment_id = %s", + (initial_m_write, equipment_id) + ) + conn.commit() + logger.info(f"[REST API SYNC] Successfully cleared database status values for equipment_id: {equipment_id}") + except Exception as dbe: + logger.error(f"[REST API SYNC ERROR] DB status reset failed: {dbe}", exc_info=True) + raise HTTPException(status_code=500, detail="Database reset transaction failed") + + m_reset_payload = [[i, 0] for i in range(188)] + + try: + result_m = _client.publish(f"/{prefix}/m/out", json.dumps(m_reset_payload), qos=1) + if result_m.rc != 0: + raise Exception(f"M output reset publish failed with rc: {result_m.rc}") + logger.info(f"[REST API SYNC] Published M output RESET payload to /{prefix}/m/out") + except Exception as e: + logger.error(f"[REST API SYNC ERROR] M reset payload publish failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"MQTT reset publish failed: {e}") + + cmd_topic = f"/{prefix}/cmd" + try: + result_cmd = _client.publish(cmd_topic, "sync", qos=1) + if result_cmd.rc != 0: + raise Exception(f"Sync command publish failed with rc: {result_cmd.rc}") + logger.info(f"[REST API SYNC SUCCESS] Published 'sync' command to topic: {cmd_topic}") + except Exception as e: + logger.error(f"[REST API SYNC ERROR] Sync command publish failed: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"MQTT sync command publish failed: {e}") + + return {"status": "success", "reset_topic_m": f"/{prefix}/m/out", "reset_topic_d": "", "cmd_topic": cmd_topic} diff --git a/server/frontend/analysis.html b/server/frontend/analysis.html new file mode 100644 index 0000000..28b3098 --- /dev/null +++ b/server/frontend/analysis.html @@ -0,0 +1,291 @@ + + + + + + ChemiFactory 생산성 및 비용 시뮬레이터 (계산기) + + + + + + + + + + +
+ + + + +
+
+
+

생산성 및 비용 시뮬레이터 (계산/분석)

+

기기 가동 물성 변수들을 조정하여 최종 생산 소요 기간 및 기대 회사 마진을 미리 측정합니다.

+
+
+ +
+ +
+

물성 변수 입력

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

가동 연산 결과 보고서

+
+
+ 소요 작업일 (필요일수) + - 일 +
+
+ 총 가공비 (Processing Fee) + - 원 +
+
+ 예상 총매출 (Estimated Sales) + - 원 +
+
+ 회사 영업 이익 (Margin) + - 원 +
+
+ +
+

원자재 소요 비용

+
+
+ 원사 구매 소요 비용 + - 원 +
+
+ 본드 구매 소요 비용 + - 원 +
+
+
+
+ +
+ ※ 본 연산 시뮬레이터 결과치는 등록된 자재 매입 원장(Inventory)의 단가와 밀도 기준에 의해 자동으로 산출되며, 실제 생산 여건에 따라 오차가 있을 수 있습니다. +
+
+
+ + +
+ +
+

만능 조절 계산기

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+

전기 건조 필요 전력 계산기

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
+ + + + + + diff --git a/server/frontend/css/base.css b/server/frontend/css/base.css new file mode 100644 index 0000000..92f5620 --- /dev/null +++ b/server/frontend/css/base.css @@ -0,0 +1,390 @@ +/* --- Sleek Dark & Glassmorphism Design System --- */ +/* + * 테마 색상은 CSS 변수로 통합 관리한다. + * :root = 다크 테마(기본 상수), [data-theme="light"] = 라이트 테마 오버라이드. + * data-theme 속성은 js/theme.js가 요소에 설정한다. + * 색상 하드코딩 금지 — 반드시 아래 시맨틱 변수를 사용할 것. + */ +:root { + /* 배경 / 표면 */ + --bg-dark: #0b0f19; + --bg-card: #151c2c; + --border-color: rgba(255, 255, 255, 0.08); + --border-focus: #3b82f6; + + /* 텍스트 */ + --text-primary: #f3f4f6; + --text-secondary: #9ca3af; + --text-muted: #6b7280; + + /* 강조색 (accent) */ + --accent-blue: #3b82f6; + --accent-blue-hover: #2563eb; + --accent-blue-glow: rgba(59, 130, 246, 0.35); + --accent-green: #10b981; + --accent-green-glow: rgba(16, 185, 129, 0.35); + --accent-red: #ef4444; + --accent-red-glow: rgba(239, 68, 68, 0.35); + --accent-orange: #f59e0b; + --accent-blue-soft: #60a5fa; /* 밝은 블루 텍스트 (plan.html 등) */ + --accent-red-soft: #f87171; /* 밝은 레드 텍스트 (plan.html 등) */ + + /* 시맨틱 오버레이 / 상태 (테마별로 명암 반전) */ + --overlay-subtle: rgba(255, 255, 255, 0.02); /* 아주 옅은 표면 틴트 */ + --overlay-hover: rgba(255, 255, 255, 0.05); /* hover / 입력 배경 */ + --overlay-strong: rgba(255, 255, 255, 0.08); /* 강한 hover / focus 배경 */ + --overlay-border: rgba(255, 255, 255, 0.15); /* 강조 테두리 */ + --nav-active-bg: rgba(59, 130, 246, 0.15); /* 활성 메뉴 배경 */ + --card-shadow: rgba(0, 0, 0, 0.2); /* 카드 hover 그림자 */ + --modal-backdrop: rgba(8, 15, 30, 0.82); /* 모달 뒷배경 */ + --modal-bg: #111827; /* 모달 본체 배경 */ + --text-on-accent: #ffffff; /* accent 위 텍스트(버튼 등) */ + --toggle-off-bg: #374151; /* 토글 OFF 배경 */ + --danger-solid: #dc2626; /* 삭제 버튼 등 솔리드 위험색 */ + --badge-green-bg: rgba(16, 185, 129, 0.1); + --badge-green-border: rgba(16, 185, 129, 0.2); + --badge-red-bg: rgba(239, 68, 68, 0.1); + --badge-red-border: rgba(239, 68, 68, 0.2); + + --sidebar-width: 260px; + --sidebar-collapsed-width: 70px; + + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; +} + +/* --- Light Theme (기본값) --- */ +/* + * 완전한 순백이 아니라 은은한 블루-그레이 톤을 사용해 + * 다크 테마(남색 계열)와 통일감을 준다. + */ +[data-theme="light"] { + --bg-dark: #eef1f6; /* 페이지 배경: 옅은 블루-그레이 */ + --bg-card: #ffffff; /* 카드/사이드바: 흰색 표면 */ + --border-color: rgba(15, 23, 42, 0.10); + --border-focus: #3b82f6; + + --text-primary: #1e293b; /* 진한 슬레이트 */ + --text-secondary: #64748b; + --text-muted: #94a3b8; + + --accent-blue: #3b82f6; + --accent-blue-hover: #2563eb; + --accent-blue-glow: rgba(59, 130, 246, 0.25); + --accent-green: #059669; + --accent-green-glow: rgba(16, 185, 129, 0.25); + --accent-red: #dc2626; + --accent-red-glow: rgba(239, 68, 68, 0.25); + --accent-orange: #d97706; + --accent-blue-soft: #2563eb; + --accent-red-soft: #dc2626; + + /* 오버레이는 어두운 색 기반으로 반전 (밝은 배경 위에서 보이도록) */ + --overlay-subtle: rgba(15, 23, 42, 0.02); + --overlay-hover: rgba(15, 23, 42, 0.04); + --overlay-strong: rgba(15, 23, 42, 0.07); + --overlay-border: rgba(15, 23, 42, 0.14); + --nav-active-bg: rgba(59, 130, 246, 0.12); + --card-shadow: rgba(15, 23, 42, 0.10); + --modal-backdrop: rgba(15, 23, 42, 0.45); + --modal-bg: #ffffff; + --text-on-accent: #ffffff; + --toggle-off-bg: #cbd5e1; + --danger-solid: #dc2626; + --badge-green-bg: rgba(5, 150, 105, 0.12); + --badge-green-border: rgba(5, 150, 105, 0.28); + --badge-red-bg: rgba(220, 38, 38, 0.10); + --badge-red-border: rgba(220, 38, 38, 0.24); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background-color: var(--bg-dark); + color: var(--text-primary); + font-family: var(--font-family); + display: flex; + min-height: 100vh; + overflow-x: hidden; +} + +/* --- Layout --- */ +.app-container { + display: flex; + width: 100%; +} + +/* --- Sidebar --- */ +aside.sidebar { + width: var(--sidebar-width); + background-color: var(--bg-card); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + height: 100vh; + position: fixed; + left: 0; + top: 0; + z-index: 100; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar-brand { + padding: 24px; + display: flex; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--border-color); +} + +.brand-icon { + font-size: 24px; +} + +.brand-name { + font-size: 18px; + font-weight: 700; + background: linear-gradient(135deg, var(--accent-blue-soft), var(--accent-blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: 0.5px; +} + +.nav-links { + list-style: none; + padding: 20px 12px; + flex-grow: 1; + overflow-y: auto; +} + +.nav-links li { + margin-bottom: 8px; +} + +.nav-item-link { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 16px; + color: var(--text-secondary); + text-decoration: none; + border-radius: 8px; + transition: all 0.2s ease; +} + +.nav-item-link:hover { + background-color: var(--overlay-hover); + color: var(--text-primary); +} + +.nav-item-link.active { + background-color: var(--nav-active-bg); + color: var(--accent-blue); + font-weight: 600; + border-left: 3px solid var(--accent-blue); +} + +.nav-icon { + font-size: 18px; +} + +.nav-text { + font-size: 14px; +} + +.sidebar-footer { + padding: 20px; + border-top: 1px solid var(--border-color); +} + +.user-profile { + display: flex; + align-items: center; + gap: 12px; +} + +.user-avatar { + font-size: 24px; + background: var(--overlay-hover); + padding: 8px; + border-radius: 50%; +} + +.user-info { + display: flex; + flex-direction: column; +} + +.user-name { + font-size: 14px; + font-weight: 600; +} + +.user-role { + font-size: 11px; + color: var(--text-muted); +} + +/* --- Main Content Area --- */ +main.main-content { + margin-left: var(--sidebar-width); + width: calc(100% - var(--sidebar-width)); + flex-grow: 1; + padding: 40px; + min-height: 100vh; + transition: margin-left 0.3s ease; + min-width: 0; +} + +header.content-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.page-title h1 { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.5px; +} + +.page-title p { + color: var(--text-secondary); + font-size: 14px; + margin-top: 4px; +} + +/* --- Responsive Layout (Grid) --- */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + margin-bottom: 30px; +} + +.card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 24px; + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px var(--card-shadow); + border-color: var(--overlay-border); +} + +/* --- Status Badges --- */ +.badge { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 9999px; + font-size: 12px; + font-weight: 600; +} + +.badge-online { + background-color: var(--badge-green-bg); + color: var(--accent-green); + border: 1px solid var(--badge-green-border); +} + +.badge-offline { + background-color: var(--badge-red-bg); + color: var(--accent-red); + border: 1px solid var(--badge-red-border); +} + +/* --- CSS Forms & UI Kit --- */ +.btn { + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + border-radius: 8px; + cursor: pointer; + border: none; + transition: all 0.2s ease; +} + +.btn-primary { + background-color: var(--accent-blue); + color: var(--text-on-accent); +} + +.btn-primary:hover { + background-color: var(--accent-blue-hover); + box-shadow: 0 0 12px var(--accent-blue-glow); +} + +/* --- Common Form Inputs & Selects --- */ +.form-group { + margin-bottom: 16px; +} +.form-group label { + display: block; + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 600; +} +.form-control { + background-color: var(--overlay-hover) !important; + border: 1px solid var(--border-color) !important; + color: var(--text-primary) !important; + padding: 10px 14px !important; + border-radius: 8px !important; + font-size: 14px !important; + width: 100% !important; + transition: all 0.2s ease !important; +} +.form-control:focus { + outline: none !important; + border-color: var(--border-focus) !important; + background-color: var(--overlay-strong) !important; +} +select.form-control { + appearance: none !important; + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important; + background-repeat: no-repeat !important; + background-position: right 14px center !important; + background-size: 16px !important; + padding-right: 40px !important; +} +select.form-control option { + background-color: var(--bg-card) !important; + color: var(--text-primary) !important; +} +/* 라이트 테마에서 select 화살표 색을 밝은 배경에 맞게 어둡게 */ +[data-theme="light"] select.form-control { + background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E") !important; +} + +/* --- Responsive Media Queries --- */ +@media (max-width: 1200px) { + .dashboard-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 768px) { + aside.sidebar { + width: var(--sidebar-collapsed-width); + } + .brand-name, .nav-text, .user-info { + display: none; + } + main.main-content { + margin-left: var(--sidebar-collapsed-width); + padding: 20px; + } + .dashboard-grid { + grid-template-columns: 1fr; + } +} diff --git a/server/frontend/customer.html b/server/frontend/customer.html new file mode 100644 index 0000000..4400d6f --- /dev/null +++ b/server/frontend/customer.html @@ -0,0 +1,442 @@ + + + + + + ChemiFactory 고객사 관리 + + + + + + + + + + +
+ + + + +
+
+
+

고객사 관리

+

고객사 등록 정보, 담당자 연락망 및 거래 영업 활동 일지를 관리합니다.

+
+ +
+ +
+ +
+
+

고객사 목록

+
+ +
+
+
+ + + + + + + + + + + + + +
고객사명(한글)대표 번호등록번호
데이터를 불러오는 중...
+
+
+ + + + + +
+
왼쪽 목록에서 고객사를 선택하시면 상세 내역을 확인할 수 있습니다.
+
+
+
+
+ + + + + + + + + + + + + + + diff --git a/server/frontend/device.html b/server/frontend/device.html new file mode 100644 index 0000000..f0ca0b2 --- /dev/null +++ b/server/frontend/device.html @@ -0,0 +1,333 @@ + + + + + + ChemiFactory 설비 기기 관리 + + + + + + + + + + +
+ + + + +
+
+
+

설비 기기 마스터 관리

+

공장에 등록된 카본 절단 설비의 기본 사양 및 전장 파라미터를 등록하고 편집합니다.

+
+
+ +
+
+ 🔍 + +
+ +
+ + +
+ +
+
+
+ + + + + + + + + diff --git a/server/frontend/equipment.html b/server/frontend/equipment.html new file mode 100644 index 0000000..7fff510 --- /dev/null +++ b/server/frontend/equipment.html @@ -0,0 +1,728 @@ + + + + + + 설비 관리 및 실시간 HMI - ChemiFactory + + + + + + + + + + +
+ + + + +
+
+
+

설비 및 실시간 HMI 제어

+

등록된 설비 리스트 확인 및 개별 HMI 원격 제어 패널

+
+
+ + +
+ +
+ + +
+
+ + + + + + + + + diff --git a/server/frontend/index.html b/server/frontend/index.html new file mode 100644 index 0000000..cbce282 --- /dev/null +++ b/server/frontend/index.html @@ -0,0 +1,195 @@ + + + + + + ChemiFactory 통합 회사 관리 시스템 + + + + + + + + + + +
+ + + + +
+
+
+

통합 대시보드

+

ChemiFactory 사업장 현황 및 설비 모니터링 개요

+
+
+ 연결 중... +
+
+ + +
+
+
+ 설비 가동 상태 + ⚙️ +
+
0 / 0
+
동작중인 설비 / 전체 설비
+
+
+
+ 오늘의 생산량 + 📈 +
+
0 kg
+
금일 목표 대비 진행도 0%
+
+
+
+ 재고 경보 + 📦 +
+
0 건
+
안전재고 미달 품목
+
+
+
+ 등록 고객사 + 🤝 +
+
0 개사
+
비즈니스 파트너사 목록
+
+
+ + +
+ +
+

오늘의 생산 지시 및 실적

+ + + + + + + + + + + + + + + +
계획 ID설비명목표 수량현재 생산 실적상태
등록된 생산 지시가 없습니다.
+
+ + +
+

실시간 설비 모니터링 (HMI)

+
+
설비 정보를 불러오는 중...
+
+
+
+
+
+ + + + + + diff --git a/server/frontend/inventory.html b/server/frontend/inventory.html new file mode 100644 index 0000000..5a2a945 --- /dev/null +++ b/server/frontend/inventory.html @@ -0,0 +1,399 @@ + + + + + + ChemiFactory 재고 및 자재 관리 + + + + + + + + + + +
+ + + + +
+
+
+

재고 및 물성 관리

+

자재 물성 데이터 및 원자재 실재고 입출고 상태를 실시간 통합 관리합니다.

+
+
+ + +
+
+ + +
+ + +
+ + +
+
+

재고 원장 목록

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
입고일자품명/품번자재 구분입고량 (kg)사용량 (kg)현재고 (kg)매입 단가 (원)공급처상태관리
재고 데이터를 로드하고 있습니다...
+
+
+ + +
+
+

자재 기준 정보 관리

+
+ +
+
+
+ + + + + + + + + + + + + + + + +
자재 코드자재 명칭자재 구분밀도 (g/cm³)상세 비고관리
자재 물성 정보를 로드하고 있습니다...
+
+
+
+
+ + + + + + + + + + + + diff --git a/server/frontend/js/analysis.js b/server/frontend/js/analysis.js new file mode 100644 index 0000000..6db91e3 --- /dev/null +++ b/server/frontend/js/analysis.js @@ -0,0 +1,205 @@ +// ChemiFactory MES - Productivity Cost Simulator Frontend Logic +const API_INVENTORY = "/inventory"; + +let allInventories = []; + +document.addEventListener("DOMContentLoaded", () => { + fetchInventoryDropdowns(); + document.getElementById("simulator-form").addEventListener("submit", runSimulation); + document.getElementById("adjustment-form").addEventListener("submit", runAdjustmentCalculator); + document.getElementById("dryer-form").addEventListener("submit", runDryingCalculator); +}); + +async function fetchInventoryDropdowns() { + try { + const res = await fetch(`${API_INVENTORY}/`); + if (!res.ok) throw new Error("Failed to fetch inventory dropdowns"); + allInventories = await res.json(); + + const yarns = allInventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'yarn' || i.material_type === '원사') && i.is_depleted !== 1); + const bonds = allInventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'bond' || i.material_type === '본드') && i.is_depleted !== 1); + + const yarnSelect = document.getElementById("sim-yarn"); + yarnSelect.innerHTML = ''; + yarns.forEach(y => { + yarnSelect.innerHTML += ``; + }); + + const bondSelect = document.getElementById("sim-bond"); + bondSelect.innerHTML = ''; + bonds.forEach(b => { + bondSelect.innerHTML += ``; + }); + } catch (err) { + console.error(err); + } +} + +async function runSimulation(e) { + e.preventDefault(); + + const yarnId = document.getElementById("sim-yarn").value; + const qty = document.getElementById("sim-qty").value; + + if (!yarnId || !qty) { + alert("원사 자재와 목표 수량을 올바르게 지정하십시오."); + return; + } + + const payload = { + inputs: { + yarn_inventory_id: parseInt(yarnId), + bond_inventory_id: parseInt(document.getElementById("sim-bond").value) || null, + yarn_diameter_micron: parseFloat(document.getElementById("sim-dia").value), + yarn_k: parseInt(document.getElementById("sim-k").value), + ports: parseInt(document.getElementById("sim-ports").value), + cycle_time_hz: parseFloat(document.getElementById("sim-hz").value), + cut_length_mm: parseFloat(document.getElementById("sim-cut").value), + work_hours_day: parseInt(document.getElementById("sim-hours").value), + work_days_month: 20.0, + num_machines: parseFloat(document.getElementById("sim-machines").value), + bond_percentage: parseFloat(document.getElementById("sim-bond-pct").value) || 3.0 + }, + targetProductionQuantity: parseFloat(qty) + }; + + try { + const res = await fetch(`${API_INVENTORY}/analysis/calculate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errData = await res.json(); + throw new Error(errData.detail || "시뮬레이션 연산 실패"); + } + + const data = await res.json(); + + // Bind 결과 데이터 + document.getElementById("res-days").innerText = `${data.required_days_target.toFixed(1)} 일`; + document.getElementById("res-fee").innerText = `${Math.round(data.plan_processing_fee).toLocaleString()} 원`; + document.getElementById("res-sales").innerText = `${Math.round(data.plan_estimated_sales).toLocaleString()} 원`; + document.getElementById("res-margin").innerText = `${Math.round(data.plan_company_margin).toLocaleString()} 원`; + document.getElementById("res-yarn-cost").innerText = `${Math.round(data.plan_yarn_cost).toLocaleString()} 원`; + document.getElementById("res-bond-cost").innerText = `${Math.round(data.plan_bond_cost).toLocaleString()} 원`; + + } catch (err) { + console.error(err); + alert(`시뮬레이션 가동 중 오류 발생: ${err.message}`); + } +} + +function runAdjustmentCalculator(e) { + e.preventDefault(); + const resultBox = document.getElementById("adj-result-box"); + const resultsContainer = document.getElementById("adj-results"); + + resultBox.style.display = "none"; + resultsContainer.innerHTML = ""; + + const cRaw = parseFloat(document.getElementById("adj-raw-conc").value); + const vCurr = parseFloat(document.getElementById("adj-curr-vol").value); + const cCurr = parseFloat(document.getElementById("adj-curr-conc").value); + const vTarget = parseFloat(document.getElementById("adj-tgt-vol").value); + const cTarget = parseFloat(document.getElementById("adj-tgt-conc").value); + + if ([cRaw, vCurr, cCurr, vTarget, cTarget].some(val => isNaN(val))) { + alert("모든 필드에 유효한 숫자를 입력해 주십시오."); + return; + } + + if (vTarget < vCurr) { + alert("목표 부피는 현재 부피보다 작을 수 없습니다."); + return; + } + + if (cRaw <= 0 || cTarget < 0 || cCurr < 0 || cRaw > 100 || cTarget > 100 || cCurr > 100) { + alert("농도 값은 0%에서 100% 사이여야 합니다."); + return; + } + + if (cTarget > cRaw) { + alert("목표 농도는 원자재 본드 농도보다 높을 수 없습니다."); + return; + } + + const cRawDec = cRaw / 100; + const cCurrDec = cCurr / 100; + const cTargetDec = cTarget / 100; + + const currentBond = vCurr * cCurrDec; + const targetBond = vTarget * cTargetDec; + + if (targetBond < currentBond - 0.001) { + alert("계산 불가: 목표 본드량이 현재 본드량보다 적습니다."); + return; + } + + let rawMaterialToAdd = 0; + if (cRawDec > 0) { + rawMaterialToAdd = (targetBond - currentBond) / cRawDec; + } + if (rawMaterialToAdd < 0) rawMaterialToAdd = 0; + + const waterToAdd = vTarget - vCurr - rawMaterialToAdd; + + if (waterToAdd < -0.001) { + if (cRaw - cTarget > 0) { + const suggestedMinVolume = vCurr * (cRaw - cCurr) / (cRaw - cTarget); + alert(`목표 부피(${vTarget.toFixed(1)}L)로는 목표 농도(${cTarget}%)를 맞출 수 없습니다.\n해당 농도를 맞추기 위한 최소 목표 부피는 ${suggestedMinVolume.toFixed(2)}L 입니다.`); + } else { + alert("계산 불가: 목표 농도가 원자재 농도와 같거나 높아 도달할 수 없습니다."); + } + return; + } + + const finalPureBond = targetBond; + const finalTotalWater = vTarget - finalPureBond; + + resultsContainer.innerHTML = ` +

• 추가할 물: ${waterToAdd.toFixed(2)} L

+

• 추가할 원자재: ${rawMaterialToAdd.toFixed(2)} L

+
+

• 최종 용액 총 부피: ${vTarget.toFixed(1)} L

+

- 순수 본드 성분: ${finalPureBond.toFixed(2)} L

+

- 포함된 총 물: ${finalTotalWater.toFixed(2)} L

+ `; + + resultBox.style.display = "block"; +} + +function runDryingCalculator(e) { + e.preventDefault(); + const resultBox = document.getElementById("dry-result-box"); + const resultsContainer = document.getElementById("dry-results"); + + resultBox.style.display = "none"; + resultsContainer.innerHTML = ""; + + const speed = parseFloat(document.getElementById("dry-speed").value); + const concentration = parseFloat(document.getElementById("dry-conc").value); + const usage = parseFloat(document.getElementById("dry-usage").value); + const loss = parseFloat(document.getElementById("dry-loss").value); + + if ([speed, concentration, usage, loss].some(val => isNaN(val))) { + alert("모든 필드에 유효한 숫자를 입력해 주십시오."); + return; + } + + const usageMlPerSec = (usage * 1000) / 86400; // L/day -> ml/s + const waterFraction = 1 - (concentration / 100); + const waterRateGps = usageMlPerSec * waterFraction; + const latentHeat = 2260; // J/g + const powerW = waterRateGps * latentHeat; + const recommendedPowerW = powerW * (1 + (loss / 100)); + + resultsContainer.innerHTML = ` +

• 이론상 최소 필요 전력: ${powerW.toFixed(2)} W

+

• 권장 히터 동작 전력 (Loss 포함): ${recommendedPowerW.toFixed(2)} W

+

* 물의 잠열 ${latentHeat} J/g 및 입력 건조 손실률 ${loss}%를 대입한 추산치입니다.

+ `; + + resultBox.style.display = "block"; +} diff --git a/server/frontend/js/common.js b/server/frontend/js/common.js new file mode 100644 index 0000000..388ee58 --- /dev/null +++ b/server/frontend/js/common.js @@ -0,0 +1,73 @@ +// Common Navigation and UI Utilities for ChemiFactory MES/HMI +document.addEventListener("DOMContentLoaded", () => { + renderNavigation(); + highlightActiveLink(); +}); + +// Navigation configuration +const navItems = [ + { name: "대시보드", icon: "📊", path: "index.html" }, + { name: "생산 계획", icon: "📅", path: "plan.html" }, + { name: "생산 현황", icon: "⚙️", path: "equipment.html" }, + { name: "고객사 관리", icon: "🤝", path: "customer.html" }, + { name: "공급사 관리", icon: "🏭", path: "supplier.html" }, + { name: "기기 관리", icon: "🛠️", path: "device.html" }, + { name: "재고 관리", icon: "📦", path: "inventory.html" }, + { name: "계산/분석", icon: "📐", path: "analysis.html" }, + { name: "설정", icon: "⚙️", path: "setting.html" } +]; + +function renderNavigation() { + const sidebar = document.getElementById("sidebar"); + if (!sidebar) return; + + let html = ` + + + + `; + + sidebar.innerHTML = html; +} + +function highlightActiveLink() { + // Current page filename detection + const path = window.location.pathname; + const page = path.split("/").pop() || "index.html"; + + const links = document.querySelectorAll(".nav-item-link"); + links.forEach(link => { + const itemPath = link.getAttribute("data-path"); + if (page === itemPath || (page === "" && itemPath === "index.html")) { + link.classList.add("active"); + } else { + link.classList.remove("active"); + } + }); +} diff --git a/server/frontend/js/customer.js b/server/frontend/js/customer.js new file mode 100644 index 0000000..3f239d1 --- /dev/null +++ b/server/frontend/js/customer.js @@ -0,0 +1,409 @@ +// ChemiFactory MES - Customer Management Frontend Logic +const API_BASE = "/customers"; + +let allCustomers = []; +let selectedCustomerId = null; + +document.addEventListener("DOMContentLoaded", () => { + initEvents(); + fetchCustomers(); +}); + +// APIs Integration +async function fetchCustomers() { + try { + const res = await fetch(`${API_BASE}/`); + if (!res.ok) throw new Error("Failed to fetch customers"); + allCustomers = await res.json(); + renderCustomerList(allCustomers); + } catch (err) { + console.error(err); + showListError(); + } +} + +async function showCustomerDetail(id) { + try { + selectedCustomerId = id; + const res = await fetch(`${API_BASE}/${id}`); + if (!res.ok) throw new Error("Failed to fetch customer details"); + const data = await res.json(); + renderCustomerDetail(data); + } catch (err) { + console.error(err); + alert("고객사 정보를 상세히 불러오지 못했습니다."); + } +} + +// Render Lists +function renderCustomerList(list) { + const tbody = document.getElementById("customer-list-body"); + tbody.innerHTML = ""; + + if (list.length === 0) { + tbody.innerHTML = `등록된 고객사가 없습니다.`; + return; + } + + list.forEach(cust => { + const tr = document.createElement("tr"); + if (selectedCustomerId === cust.id) tr.className = "active-row"; + tr.innerHTML = ` + ${cust.name_kr} + ${cust.contact_number || "-"} + ${cust.business_registration_number || "-"} + `; + tr.addEventListener("click", () => { + document.querySelectorAll("#customer-list-body tr").forEach(el => el.classList.remove("active-row")); + tr.classList.add("active-row"); + showCustomerDetail(cust.id); + }); + tbody.appendChild(tr); + }); +} + +function showListError() { + const tbody = document.getElementById("customer-list-body"); + tbody.innerHTML = `데이터 통신에 실패했습니다. DB 작동을 확인하십시오.`; +} + +function renderCustomerDetail(data) { + // Show details card, hide placeholder + document.getElementById("customer-placeholder-card").style.display = "none"; + document.getElementById("customer-detail-card").style.display = "block"; + + const cust = data.customer; + document.getElementById("detail-name-kr").innerText = cust.name_kr; + document.getElementById("detail-name-en").innerText = cust.name_en || ""; + document.getElementById("detail-brn").innerText = cust.business_registration_number || "-"; + document.getElementById("detail-contact").innerText = cust.contact_number || "-"; + document.getElementById("detail-address").innerText = cust.address || "-"; + + // Bind Contacts + renderContactsList(data.contacts || []); + + // Bind Sales Activities + renderActivitiesList(data.salesActivities || []); +} + +function renderContactsList(contacts) { + const tbody = document.getElementById("contact-list-body"); + tbody.innerHTML = ""; + + if (contacts.length === 0) { + tbody.innerHTML = `등록된 담당자가 없습니다.`; + return; + } + + contacts.forEach(c => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${c.name} + ${c.position || "-"} ${c.work_location ? `(${c.work_location})` : ""} + ${c.phone_number || "-"} + ${c.email || "-"} + + + + + `; + tbody.appendChild(tr); + }); +} + +function renderActivitiesList(activities) { + const tbody = document.getElementById("activity-list-body"); + tbody.innerHTML = ""; + + if (activities.length === 0) { + tbody.innerHTML = `영업 활동 내역이 없습니다.`; + return; + } + + // Sort by date descending + activities.sort((a, b) => new Date(b.activity_date) - new Date(a.activity_date)); + + activities.forEach(act => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${act.activity_date} + ${act.activity_type} + ${act.memo} + + + + + `; + tbody.appendChild(tr); + }); +} + +// Event Bindings +function initEvents() { + // Search Filter + document.getElementById("search-customer").addEventListener("input", (e) => { + const query = e.target.value.toLowerCase(); + const filtered = allCustomers.filter(c => + c.name_kr.toLowerCase().includes(query) || + (c.name_en && c.name_en.toLowerCase().includes(query)) || + (c.business_registration_number && c.business_registration_number.includes(query)) + ); + renderCustomerList(filtered); + }); + + // Tab Toggle + document.querySelectorAll(".tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active")); + + btn.classList.add("active"); + document.getElementById(btn.dataset.tab).classList.add("active"); + }); + }); + + // Modals Control + const modals = ["customer-modal", "contact-modal", "activity-modal"]; + modals.forEach(mId => { + const modal = document.getElementById(mId); + modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => { + btn.addEventListener("click", () => modal.style.display = "none"); + }); + }); + + // Add Customer + document.getElementById("btn-add-customer").addEventListener("click", () => { + document.getElementById("customer-form").reset(); + document.getElementById("customer-id").value = ""; + document.getElementById("customer-modal-title").innerText = "고객사 등록"; + document.getElementById("customer-modal").style.display = "flex"; + }); + + document.getElementById("customer-form").addEventListener("submit", handleCustomerSubmit); + + // Edit & Delete Customer + document.getElementById("btn-edit-customer").addEventListener("click", () => { + const activeCust = allCustomers.find(c => c.id === selectedCustomerId); + if (!activeCust) return; + + document.getElementById("customer-id").value = activeCust.id; + document.getElementById("cust-name-kr").value = activeCust.name_kr; + document.getElementById("cust-name-en").value = activeCust.name_en || ""; + document.getElementById("cust-brn").value = activeCust.business_registration_number || ""; + document.getElementById("cust-contact").value = activeCust.contact_number || ""; + document.getElementById("cust-address").value = activeCust.address || ""; + + document.getElementById("customer-modal-title").innerText = "고객사 정보 수정"; + document.getElementById("customer-modal").style.display = "flex"; + }); + + document.getElementById("btn-delete-customer").addEventListener("click", handleDeleteCustomer); + + // Add Contact + document.getElementById("btn-add-contact").addEventListener("click", () => { + document.getElementById("contact-form").reset(); + document.getElementById("contact-id").value = ""; + document.getElementById("contact-modal-title").innerText = "담당자 등록"; + document.getElementById("contact-modal").style.display = "flex"; + }); + document.getElementById("contact-form").addEventListener("submit", handleContactSubmit); + + // Add Activity + document.getElementById("btn-add-activity").addEventListener("click", () => { + document.getElementById("activity-form").reset(); + document.getElementById("activity-id").value = ""; + document.getElementById("act-date").value = new Date().toISOString().substring(0, 10); + document.getElementById("activity-modal-title").innerText = "활동 일지 등록"; + document.getElementById("activity-modal").style.display = "flex"; + }); + document.getElementById("activity-form").addEventListener("submit", handleActivitySubmit); +} + +// Form Handlers +async function handleCustomerSubmit(e) { + e.preventDefault(); + const id = document.getElementById("customer-id").value; + const payload = { + name_kr: document.getElementById("cust-name-kr").value, + name_en: document.getElementById("cust-name-en").value || null, + business_registration_number: document.getElementById("cust-brn").value || null, + contact_number: document.getElementById("cust-contact").value || null, + address: document.getElementById("cust-address").value || null + }; + + try { + let res; + if (id) { + // Update + res = await fetch(`${API_BASE}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + // Create + res = await fetch(`${API_BASE}/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("API error occurred"); + const result = await res.json(); + + document.getElementById("customer-modal").style.display = "none"; + await fetchCustomers(); + + if (id) { + showCustomerDetail(parseInt(id)); + } else { + showCustomerDetail(result.id); + } + } catch (err) { + console.error(err); + alert("고객사 정보를 저장하는 중 오류가 발생했습니다."); + } +} + +async function handleDeleteCustomer() { + if (!selectedCustomerId) return; + if (!confirm("이 고객사를 정말 삭제하시겠습니까?\n해당 거래처의 연락처 및 활동 일지 정보가 영구 삭제됩니다.")) return; + + try { + const res = await fetch(`${API_BASE}/${selectedCustomerId}`, { + method: "DELETE" + }); + if (!res.ok) throw new Error("Failed to delete customer"); + + selectedCustomerId = null; + document.getElementById("customer-detail-card").style.display = "none"; + document.getElementById("customer-placeholder-card").style.display = "flex"; + + await fetchCustomers(); + } catch (err) { + console.error(err); + alert("고객사 삭제에 실패했습니다."); + } +} + +// Contact Handlers +window.openEditContactModal = function(contact) { + document.getElementById("contact-id").value = contact.id; + document.getElementById("cont-name").value = contact.name; + document.getElementById("cont-position").value = contact.position || ""; + document.getElementById("cont-phone").value = contact.phone_number || ""; + document.getElementById("cont-email").value = contact.email || ""; + document.getElementById("cont-location").value = contact.work_location || ""; + + document.getElementById("contact-modal-title").innerText = "담당자 정보 수정"; + document.getElementById("contact-modal").style.display = "flex"; +}; + +async function handleContactSubmit(e) { + e.preventDefault(); + const id = document.getElementById("contact-id").value; + const payload = { + customer_id: selectedCustomerId, + name: document.getElementById("cont-name").value, + position: document.getElementById("cont-position").value || null, + phone_number: document.getElementById("cont-phone").value || null, + email: document.getElementById("cont-email").value || null, + work_location: document.getElementById("cont-location").value || null + }; + + try { + let res; + if (id) { + res = await fetch(`${API_BASE}/contacts/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_BASE}/contacts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("Contact save API error"); + document.getElementById("contact-modal").style.display = "none"; + showCustomerDetail(selectedCustomerId); + } catch (err) { + console.error(err); + alert("담당자 저장에 실패했습니다."); + } +} + +window.deleteContact = async function(contactId) { + if (!confirm("이 담당자 정보를 정말 삭제하시겠습니까?")) return; + try { + const res = await fetch(`${API_BASE}/contacts/${contactId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete contact"); + showCustomerDetail(selectedCustomerId); + } catch (err) { + console.error(err); + alert("담당자 삭제에 실패했습니다."); + } +}; + +// Activity Handlers +window.openEditActivityModal = function(act) { + document.getElementById("activity-id").value = act.id; + document.getElementById("act-date").value = act.activity_date; + document.getElementById("act-type").value = act.activity_type; + document.getElementById("act-memo").value = act.memo || ""; + + document.getElementById("activity-modal-title").innerText = "활동 일지 수정"; + document.getElementById("activity-modal").style.display = "flex"; +}; + +async function handleActivitySubmit(e) { + e.preventDefault(); + const id = document.getElementById("activity-id").value; + const payload = { + customer_id: selectedCustomerId, + contact_id: null, // Optional in back-end + activity_date: document.getElementById("act-date").value, + activity_type: document.getElementById("act-type").value, + memo: document.getElementById("act-memo").value + }; + + try { + let res; + if (id) { + res = await fetch(`${API_BASE}/activities/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_BASE}/activities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("Activity save API error"); + document.getElementById("activity-modal").style.display = "none"; + showCustomerDetail(selectedCustomerId); + } catch (err) { + console.error(err); + alert("일지 저장에 실패했습니다."); + } +} + +window.deleteActivity = async function(actId) { + if (!confirm("이 활동 일지를 삭제하시겠습니까?")) return; + try { + const res = await fetch(`${API_BASE}/activities/${actId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete activity"); + showCustomerDetail(selectedCustomerId); + } catch (err) { + console.error(err); + alert("일지 삭제에 실패했습니다."); + } +}; diff --git a/server/frontend/js/dashboard.js b/server/frontend/js/dashboard.js new file mode 100644 index 0000000..a4d9315 --- /dev/null +++ b/server/frontend/js/dashboard.js @@ -0,0 +1,136 @@ +// Dashboard Business logic and data visualization for ChemiFactory MES +document.addEventListener("DOMContentLoaded", () => { + fetchAPIStatus(); + loadDashboardData(); +}); + +const BACKEND_URL = `${window.location.protocol}//${window.location.host}`; + +// API 연결 상태 확인 +async function fetchAPIStatus() { + const badge = document.getElementById("api-status-badge"); + try { + const response = await fetch(`${BACKEND_URL}/api/status`); + if (response.ok) { + badge.textContent = "시스템 정상"; + badge.className = "badge badge-online"; + } else { + throw new Error("HTTP Status Error"); + } + } catch (e) { + badge.textContent = "연결 실패"; + badge.className = "badge badge-offline"; + } +} + +// 대시보드 데이터 바인딩 +async function loadDashboardData() { + try { + // 1. 고객사 개수 로드 (API: /customers/) + const custRes = await fetch(`${BACKEND_URL}/customers/`); + if (custRes.ok) { + const list = await custRes.json(); + document.getElementById("customer-count").textContent = `${list.length} 개사`; + } + + // 2. 재고 정보 로드 (API: /inventory/) + const invRes = await fetch(`${BACKEND_URL}/inventory/`); + if (invRes.ok) { + const list = await invRes.json(); + // 품절되거나 소진 임계에 도달한 재고 경보 카운트 + const lowStockCount = list.filter(item => item.is_depleted === 1 || item.stock <= 10).length; + document.getElementById("low-inventory-count").textContent = `${lowStockCount} 건`; + } + + // 3. 설비 리스트 및 작동 요약 로드 (⚠️ 제외 대상에 따른 대시보드 미니 가동기기 출력 유지) + const equipRes = await fetch(`${BACKEND_URL}/equipment/`); + let totalEquip = 0; + let activeEquip = 0; + if (equipRes.ok) { + const list = await equipRes.json(); + totalEquip = list.length; + + // 미니 HMI 대시보드 리스트 생성 + const hmiContainer = document.getElementById("equipment-mini-list"); + if (list.length === 0) { + hmiContainer.innerHTML = `
등록된 설비가 없습니다.
`; + } else { + hmiContainer.innerHTML = ""; // 초기화 + list.forEach(eq => { + const isOnline = eq.status === "available" || eq.is_online; // 활성화 체크 필드 + if (isOnline) activeEquip++; + + const dotClass = isOnline ? "dot-green" : "dot-red"; + const statusText = isOnline ? "가동준비" : "점검필요"; + + const miniItem = document.createElement("div"); + miniItem.className = "hmi-mini-card"; + miniItem.innerHTML = ` +
+ ${eq.name || eq.id} + ${eq.status || "상태미필"} +
+
+ ${statusText} + +
+ `; + hmiContainer.appendChild(miniItem); + }); + } + document.getElementById("active-equip-count").textContent = `${activeEquip} / ${totalEquip}`; + } + + // 4. 생산 지시 리스트 및 실적 요약 (API: /plans/) + const planRes = await fetch(`${BACKEND_URL}/plans/`); + if (planRes.ok) { + const list = await planRes.json(); + const planTable = document.getElementById("plan-list"); + + // 실적 합계 계산 + let totalActualQty = 0; + let totalTargetQty = 0; + + if (list.length === 0) { + planTable.innerHTML = `등록된 생산 지시가 없습니다.`; + } else { + planTable.innerHTML = ""; // 초기화 + + // 최신 계획 5개만 대시보드에 표기 + const recentPlans = list.slice(0, 5); + recentPlans.forEach(plan => { + // actual_quantity 속성이 없는 경우 baseline 0kg으로 방어 + const actual = plan.actual_quantity || 0; + const target = plan.requested_quantity_kg || 0; + totalActualQty += actual; + totalTargetQty += target; + + const row = document.createElement("tr"); + row.innerHTML = ` + Plan #${plan.plan_id} + ${plan.plan_name || "-"} + ${target.toLocaleString()} kg + ${actual.toLocaleString()} kg + + + ${plan.status === 'Completed' ? '완료' : '예정'} + + + `; + planTable.appendChild(row); + }); + } + + // 오늘의 생산량 카드 바인딩 + document.getElementById("today-prod").textContent = `${totalActualQty.toLocaleString()} kg`; + const progress = totalTargetQty > 0 ? Math.round((totalActualQty / totalTargetQty) * 100) : 0; + const statCard = document.querySelector(".stat-card:nth-child(2) .stat-desc"); + if (statCard) { + statCard.textContent = `전체 목표 대비 진행도 ${progress}%`; + } + } + + } catch (e) { + console.error("Failed to load dashboard statistics:", e); + } +} diff --git a/server/frontend/js/device.js b/server/frontend/js/device.js new file mode 100644 index 0000000..e8ebd27 --- /dev/null +++ b/server/frontend/js/device.js @@ -0,0 +1,216 @@ +// ChemiFactory MES - Equipment Device Master Management Logic +const API_EQUIPMENT = "/api/equipment"; + +let allDevices = []; + +document.addEventListener("DOMContentLoaded", () => { + initEvents(); + loadDevices(); +}); + +async function loadDevices() { + try { + const res = await fetch(API_EQUIPMENT); + if (!res.ok) throw new Error("Failed to fetch equipment devices"); + allDevices = await res.json(); + renderDevices(allDevices); + } catch (err) { + console.error(err); + document.getElementById("device-grid-container").innerHTML = ` +
+ 서버로부터 설비 정보를 조회하지 못했습니다. (DB 또는 네트워크 점검 필요) +
+ `; + } +} + +function renderDevices(devices) { + const container = document.getElementById("device-grid-container"); + container.innerHTML = ""; + + if (devices.length === 0) { + container.innerHTML = ` +
+ 등록된 설비 기기가 없습니다. +
+ `; + return; + } + + devices.forEach(d => { + const card = document.createElement("div"); + card.className = "device-card"; + card.addEventListener("click", () => openEditModal(d)); + + // 상태 한글 변환 + let statusText = "가동 가능"; + let statusBadgeClass = "device-badge"; + if (d.status === "maintenance") { + statusText = "점검 중"; + } else if (d.status === "broken") { + statusText = "고장/정지"; + } + + card.innerHTML = ` +
+
+ ${d.name} + ${statusText} +
+
+ IP: ${d.ip_address} | Prefix: ${d.line_prefix} +
+
+
+
+ 보빈 수 + ${d.yarn_bobbin_count}개 +
+
+ 건조기 타입 + ${d.dryer_type} +
+
+ 소비 전력 + ${d.power_consumption} kW +
+
+ 최대 속도 + Cut: ${d.max_cutting_speed} / Feed: ${d.max_feed_speed} +
+
+ `; + container.appendChild(card); + }); +} + +function initEvents() { + const modal = document.getElementById("device-modal"); + const closeBtns = modal.querySelectorAll(".close-btn, .close-modal-btn"); + const addBtn = document.getElementById("btn-add-device"); + const form = document.getElementById("device-form"); + const deleteBtn = document.getElementById("btn-delete-device"); + const searchInput = document.getElementById("search-input"); + + // 모달 닫기 + closeBtns.forEach(btn => { + btn.addEventListener("click", () => { + modal.style.display = "none"; + }); + }); + + // 신규 추가 오픈 + addBtn.addEventListener("click", () => { + form.reset(); + document.getElementById("device-id").value = ""; + document.getElementById("modal-title").innerText = "신규 기기 등록"; + deleteBtn.style.display = "none"; + document.getElementById("delete-spacer").style.display = "none"; + modal.style.display = "flex"; + }); + + // 검색 필터링 + searchInput.addEventListener("input", (e) => { + const query = e.target.value.toLowerCase().trim(); + const filtered = allDevices.filter(d => + d.name.toLowerCase().includes(query) || + (d.dryer_type && d.dryer_type.toLowerCase().includes(query)) || + (d.ip_address && d.ip_address.includes(query)) + ); + renderDevices(filtered); + }); + + // 폼 저장 제출 + form.addEventListener("submit", saveDevice); + + // 삭제 실행 + deleteBtn.addEventListener("click", deleteDevice); +} + +function openEditModal(device) { + const modal = document.getElementById("device-modal"); + const deleteBtn = document.getElementById("btn-delete-device"); + + document.getElementById("device-id").value = device.id; + document.getElementById("device-name").value = device.name; + document.getElementById("device-bobbins").value = device.yarn_bobbin_count; + document.getElementById("device-dryer").value = device.dryer_type; + document.getElementById("device-power").value = device.power_consumption; + document.getElementById("device-max-cut").value = device.max_cutting_speed; + document.getElementById("device-max-feed").value = device.max_feed_speed; + document.getElementById("device-ip").value = device.ip_address; + document.getElementById("device-prefix").value = device.line_prefix; + document.getElementById("device-status").value = device.status; + + document.getElementById("modal-title").innerText = "설비 정보 편집"; + + // 삭제 버튼 노출 + deleteBtn.style.display = "block"; + document.getElementById("delete-spacer").style.display = "block"; + + modal.style.display = "flex"; +} + +async function saveDevice(e) { + e.preventDefault(); + + const id = document.getElementById("device-id").value; + const payload = { + name: document.getElementById("device-name").value, + yarn_bobbin_count: parseInt(document.getElementById("device-bobbins").value), + dryer_type: document.getElementById("device-dryer").value, + power_consumption: parseFloat(document.getElementById("device-power").value), + max_cutting_speed: parseFloat(document.getElementById("device-max-cut").value), + max_feed_speed: parseFloat(document.getElementById("device-max-feed").value), + ip_address: document.getElementById("device-ip").value, + line_prefix: document.getElementById("device-prefix").value, + status: document.getElementById("device-status").value + }; + + try { + let res; + if (id) { + // 수정 + res = await fetch(`${API_EQUIPMENT}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + // 추가 + res = await fetch(API_EQUIPMENT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("Failed to save device data"); + document.getElementById("device-modal").style.display = "none"; + loadDevices(); + } catch (err) { + console.error(err); + alert("기기 저장 중 요류가 발생했습니다."); + } +} + +async function deleteDevice() { + const id = document.getElementById("device-id").value; + if (!id) return; + + if (!confirm("정말로 해당 설비를 삭제하시겠습니까? 관련 데이터가 소실될 수 있습니다.")) { + return; + } + + try { + const res = await fetch(`${API_EQUIPMENT}/${id}`, { + method: "DELETE" + }); + if (!res.ok) throw new Error("Failed to delete device"); + document.getElementById("device-modal").style.display = "none"; + loadDevices(); + } catch (err) { + console.error(err); + alert("기기 삭제에 실패했습니다."); + } +} diff --git a/server/frontend/js/equipment.js b/server/frontend/js/equipment.js new file mode 100644 index 0000000..daa7b46 --- /dev/null +++ b/server/frontend/js/equipment.js @@ -0,0 +1,684 @@ +// Real-time HMI Websocket Client & REST API Manager +let socket = null; +let currentSelectedEquipment = null; +let currentSelectedPrefix = "line1"; // 기본 프리픽스 설정 +let currentActiveGroup = "A"; // 현재 선택된 제어 그룹 ('A' 또는 'B') +let lastReceivedStatusData = null; // 가장 최근 수신한 실시간 상태 정보 캐시 +const pendingControlCommands = new Map(); +let equipmentControlsLocked = true; + +const BACKEND_URL = `${window.location.protocol}//${window.location.host}`; +const WS_PROTOCOL = window.location.protocol === "https:" ? "wss:" : "ws:"; +const WS_URL = `${WS_PROTOCOL}//${window.location.host}/ws/equipment`; + +document.addEventListener("DOMContentLoaded", () => { + loadEquipmentCards(); + connectWebsocket(); + initHangerPopoverOutsideClick(); +}); + +// Websocket 연결 및 자동 재접속 로직 +function connectWebsocket() { + const wsStatusBadge = document.getElementById("hmi-websocket-status"); + socket = new WebSocket(WS_URL); + + socket.onopen = () => { + if (wsStatusBadge) { + wsStatusBadge.textContent = "실시간 연결"; + wsStatusBadge.className = "badge badge-online"; + } + }; + + socket.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "equipment_update" && currentSelectedEquipment) { + // 선택한 설비의 업데이트 정보인 경우 HMI 갱신 수행 + if (String(msg.equipment_id) === String(currentSelectedEquipment.id)) { + lastReceivedStatusData = msg.data; + updateHMILiveDisplay(msg.data); + } + } else if (msg.type === "command_ack") { + handleCommandAck(msg); + } + } catch (err) { + console.error("Websocket parsing error:", err); + } + }; + + socket.onclose = () => { + if (wsStatusBadge) { + wsStatusBadge.textContent = "연결 안됨 (재접속중...)"; + wsStatusBadge.className = "badge badge-offline"; + } + setTimeout(connectWebsocket, 3000); // 3초 후 자동 재연결 시도 + }; + + socket.onerror = (err) => { + console.error("Websocket error:", err); + }; +} + +function handleCommandAck(message) { + if (!message.command_id || !["PLC_APPLIED", "FAILED", "TIMEOUT", "BUSY"].includes(message.status)) return; + + const label = pendingControlCommands.get(message.command_id) || "PLC 제어 명령"; + pendingControlCommands.delete(message.command_id); + + if (message.status === "PLC_APPLIED") { + // alert(`${label}이(가) PLC에 정상 적용되었습니다.`); + } else if (message.status === "TIMEOUT") { + // alert(`${label} 적용 확인 시간이 초과되었습니다.`); + } else if (message.status === "BUSY") { + // alert(`${label} 요청 실패: 게이트웨이가 이전 제어 명령을 처리 중입니다 (바쁨).`); + } else { + // alert(`${label} 적용에 실패했습니다.`); + } +} + +function setEquipmentControlsLocked(locked) { + equipmentControlsLocked = locked; + const overlay = document.getElementById("initialization-overlay"); + if (!overlay) return; + overlay.classList.toggle("active", locked); + overlay.setAttribute("aria-hidden", String(!locked)); +} + +let cachedEquipments = []; + +// 설비 카드 목록 로드 +async function loadEquipmentCards() { + const container = document.getElementById("equipment-cards-container"); + try { + const response = await fetch(`${BACKEND_URL}/api/equipment`); + if (response.ok) { + const list = await response.json(); + cachedEquipments = list; + container.innerHTML = ""; // 초기화 + + if (currentSelectedEquipment) { + // 상세페이지 뷰 모드 + container.style.display = "none"; + document.getElementById("hmi-detail-panel").style.display = "block"; + + // 페이지 헤더 타이틀 업데이트 + document.getElementById("main-page-title").innerText = "실시간 HMI 제어"; + document.getElementById("main-page-desc").style.display = "none"; + return; + } + + // 목록 뷰 모드 + container.style.display = "grid"; + document.getElementById("hmi-detail-panel").style.display = "none"; + + // 페이지 헤더 타이틀 업데이트 + document.getElementById("main-page-title").innerText = "설비 리스트"; + document.getElementById("main-page-desc").innerText = "등록된 설비 목록 및 상세 사양 정보를 확인할 수 있습니다."; + document.getElementById("main-page-desc").style.display = "block"; + + if (list.length === 0) { + container.innerHTML = `
등록된 설비가 없습니다.
`; + return; + } + + list.forEach(eq => { + const isOnline = eq.status === "running" || eq.is_online; + const card = document.createElement("div"); + card.className = `card equip-card`; + card.onclick = () => selectEquipment(eq); + + card.innerHTML = ` +
+
+ ${eq.name || eq.equipment_id} +
타입: ${eq.type || "N/A"}
+
+ ${isOnline ? 'ONLINE' : 'OFFLINE'} +
+
IP Address: ${eq.ip_address || "0.0.0.0"}
+
+
보빈 수: ${eq.yarn_bobbin_count || 0}개
+
건조기 타입: ${eq.dryer_type || "N/A"}
+
소비 전력: ${eq.power_consumption || 0} kW
+
PLC 연결상태: ${eq.device_state || 'OFFLINE'}
+
최대 속도: Cut: ${eq.max_cutting_speed || 0} / Feed: ${eq.max_feed_speed || 0}
+
+ `; + container.appendChild(card); + }); + } + } catch (e) { + console.error("Failed to load equipment list:", e); + } +} + +// 전체 목록으로 돌아가기 +function goBackToEquipmentList() { + currentSelectedEquipment = null; + document.getElementById("hmi-detail-panel").style.display = "none"; + document.getElementById("equipment-cards-container").style.display = "grid"; + loadEquipmentCards(); +} + +// 설비 선택 및 HMI 렌더링 +async function selectEquipment(eq) { + currentSelectedEquipment = eq; + currentSelectedPrefix = eq.line_prefix || "line1"; + + // 목록 화면 숨김 & HMI 디테일 표출 + document.getElementById("equipment-cards-container").style.display = "none"; + document.getElementById("hmi-detail-panel").style.display = "block"; + + // 페이지 헤더 타이틀 업데이트 + document.getElementById("main-page-title").innerText = "실시간 HMI 제어"; + document.getElementById("main-page-desc").style.display = "none"; + + // 초기 그룹은 'A'로 설정 및 탭 활성화 + setHmiActiveGroup('A'); + + // DB에서 실시간 상세 상태 데이터 가져와 UI 동기화 + try { + const response = await fetch(`${BACKEND_URL}/api/equipment/${eq.id}/status`); + if (response.ok) { + const statusData = await response.json(); + lastReceivedStatusData = statusData; + updateHMILiveDisplay(statusData); + } + } catch (e) { + console.error("실시간 설비 상태 로드 실패:", e); + } +} + +// A그룹 / B그룹 제어 전환 설정 +function setHmiActiveGroup(groupChar) { + currentActiveGroup = groupChar; + + // 각 그룹 카드의 제목 span 태그를 찾아 뒤에 A 또는 B 추가 + document.querySelectorAll('.hmi-group-title > span:first-child').forEach(span => { + if (!span.dataset.baseTitle) { + span.dataset.baseTitle = span.textContent.trim(); + } + span.textContent = `${span.dataset.baseTitle} ${groupChar}`; + }); + + // 건조부 히터 번호 변경 (A그룹: H1~H5, B그룹: H6~H10) + for (let h = 1; h <= 5; h++) { + const btn = document.getElementById(`btn-dryer-h${h}`); + if (btn) { + const heaterNum = groupChar === 'A' ? h : (h + 5); + const lampSpan = btn.querySelector('.hmi-lamp'); + btn.innerHTML = `H${heaterNum} `; + if (lampSpan) { + btn.appendChild(lampSpan); + } + } + } + + // 탭 UI 교체 + const tabA = document.getElementById("btn-tab-a"); + const tabB = document.getElementById("btn-tab-b"); + + if (groupChar === 'A') { + tabA.classList.add("active"); + tabB.classList.remove("active"); + } else { + tabB.classList.add("active"); + tabA.classList.remove("active"); + } + + // 행거 포트 그리드 재렌더링 + renderHangerPorts(); + + // 팝오버 닫기 + hideHangerPopover(); + + // 기존 캐싱 데이터가 존재한다면 즉시 새로운 오프셋 기준으로 리렌더링 + if (lastReceivedStatusData) { + updateHMILiveDisplay(lastReceivedStatusData); + } +} + +// 6그룹: 행거부 20개 포트 콤팩트 그리드 렌더링 +function renderHangerPorts() { + const container = document.getElementById("hanger-ports-container"); + container.innerHTML = ""; + + const startPortNum = currentActiveGroup === 'A' ? 1 : 21; + const endPortNum = currentActiveGroup === 'A' ? 20 : 40; + + for (let p = startPortNum; p <= endPortNum; p++) { + const cell = document.createElement("div"); + const paddedNum = String(p).padStart(2, '0'); + cell.className = "port-cell port-idle"; + cell.id = `port-cell-${p}`; + cell.innerText = `P${paddedNum}`; + cell.onclick = (e) => { + e.stopPropagation(); + showHangerPopover(p, cell); + }; + container.appendChild(cell); + } +} + +// 행거 포트 클릭시 공용 제어 팝오버 표시 +function showHangerPopover(portNum, cellElement) { + const popover = document.getElementById("hanger-popover"); + const title = document.getElementById("popover-title"); + + const paddedNum = String(portNum).padStart(2, '0'); + title.innerText = `P${paddedNum} 제어`; + + // 팝오버 위치를 클릭된 셀 뒤로 가져다 붙임 + popover.style.display = "flex"; + + // 1행인지 2행 이하인지 구분하여 위/아래 배치 결정 (그리드가 5열이므로 index 5(6번째 포트)부터 2행) + const startPortNum = currentActiveGroup === 'A' ? 1 : 21; + const relativeIndex = portNum - startPortNum; + + if (relativeIndex >= 5) { + // 2행 이하: 셀 위에 배치 (위로 열림) + popover.style.top = `${cellElement.offsetTop - popover.offsetHeight - 6}px`; + } else { + // 1행: 셀 아래에 배치 (아래로 열림) + popover.style.top = `${cellElement.offsetTop + cellElement.offsetHeight + 6}px`; + } + + popover.style.left = `${cellElement.offsetLeft + (cellElement.offsetWidth / 2) - 65}px`; + + // 제어 버튼 바인딩 (PLC 쓰기 비트 인덱스 계산) + const btnAutoDown = document.getElementById("btn-popover-auto-down"); + const btnManualDown = document.getElementById("btn-popover-manual-down"); + const btnManualUp = document.getElementById("btn-popover-manual-up"); + + let autoDownBitId, manualDownBitId, manualUpBitId; + + if (currentActiveGroup === 'A') { + autoDownBitId = 68 + (portNum - 1); // 68 ~ 87 + manualDownBitId = 108 + (portNum - 1); // 108 ~ 127 + manualUpBitId = 148 + (portNum - 1); // 148 ~ 167 + } else { + autoDownBitId = 88 + (portNum - 21); // 88 ~ 107 + manualDownBitId = 128 + (portNum - 21); // 128 ~ 147 + manualUpBitId = 168 + (portNum - 21); // 168 ~ 187 + } + + btnAutoDown.onclick = () => { sendHmiBitDirect(autoDownBitId); hideHangerPopover(); }; + btnManualDown.onclick = () => { sendHmiBitDirect(manualDownBitId); hideHangerPopover(); }; + btnManualUp.onclick = () => { sendHmiBitDirect(manualUpBitId); hideHangerPopover(); }; +} + +function hideHangerPopover() { + const popover = document.getElementById("hanger-popover"); + if (popover) popover.style.display = "none"; +} + +function initHangerPopoverOutsideClick() { + document.addEventListener("click", () => { + hideHangerPopover(); + }); + const popover = document.getElementById("hanger-popover"); + if (popover) { + popover.addEventListener("click", (e) => { + e.stopPropagation(); // 팝오버 내부 클릭은 닫히지 않음 + }); + } +} + +// 실시간 수신 데이터의 A/B 오프셋을 판단하여 UI 상태 갱신 +function updateHMILiveDisplay(data) { + if (!data) return; + setEquipmentControlsLocked(data.sync_state === "SYNCING" || data.device_state !== "ONLINE"); + + const mRead = typeof data.m_read_raw === 'string' ? JSON.parse(data.m_read_raw || '{}') : (data.m_read_raw || {}); + + // A/B 그룹에 따른 읽기 비트 오프셋 설정 + const isGroupB = (currentActiveGroup === 'B'); + const mOffset = isGroupB ? 19 : 0; + const dOffset = isGroupB ? 5 : 0; + + // ---------------------------------------------------- + // 1그룹: 활성화 및 리셋 비트 상태 동기화 (m_read_raw) + // ---------------------------------------------------- + // 안전 인터락 상태 (A-1) - 정상=녹색, 이상=적색 + setInterlockLamp("lamp-estop", mRead[String(1 + mOffset)], 1); + setInterlockLamp("lamp-door-left", mRead[String(2 + mOffset)], 1); + setInterlockLamp("lamp-door-right", mRead[String(3 + mOffset)], 1); + setInterlockLamp("lamp-water-level", mRead[String(4 + mOffset)], 0); + setInterlockLamp("lamp-heater-interlock", mRead[String(5 + mOffset)], 1); + + setLampStatus("lamp-group-active-on", mRead[String(6 + mOffset)]); // 활성화 ON + setLampStatus("lamp-group-active-off", mRead[String(7 + mOffset)], true); // 활성화 OFF (반전/빨간색) + + // 함침&행거 ON/OFF (2그룹 & 6그룹 동시 업데이트) + setLampStatus("lamp-impreg-hanger-on-g2", mRead[String(11 + mOffset)]); + setLampStatus("lamp-impreg-hanger-off-g2", mRead[String(12 + mOffset)], true); + setLampStatus("lamp-impreg-hanger-on-g6", mRead[String(11 + mOffset)]); + setLampStatus("lamp-impreg-hanger-off-g6", mRead[String(12 + mOffset)], true); + + setLampStatus("lamp-dryer-on", mRead[String(13 + mOffset)]); // 건조 ON + setLampStatus("lamp-dryer-off", mRead[String(14 + mOffset)], true); + setLampStatus("lamp-cutter-on", mRead[String(15 + mOffset)]); // 커팅 ON + setLampStatus("lamp-cutter-off", mRead[String(16 + mOffset)], true); + setLampStatus("lamp-transfer-on", mRead[String(17 + mOffset)]); // 이송 ON + setLampStatus("lamp-transfer-off", mRead[String(18 + mOffset)], true); + + setLampStatus("lamp-group-stop", mRead[String(8 + mOffset)], true); // 전체정지 + setLampStatus("lamp-group-pause", mRead[String(9 + mOffset)], true); // 일시정지 + setLampStatus("lamp-group-restart", mRead[String(10 + mOffset)]); // 재시작 + setLampStatus("lamp-interlock-reset", mRead[String(0 + mOffset)]); // 인터락 리셋 + + // ---------------------------------------------------- + // 2그룹: 함침부 작동 상태 및 수치 동기화 + // ---------------------------------------------------- + const impregAutoOffset = isGroupB ? 42 : 38; + const impregManualOffset = isGroupB ? 43 : 39; + const pumpCircOffset = isGroupB ? 44 : 40; + const pumpDrainOffset = isGroupB ? 45 : 41; + + setLampStatus("lamp-impreg-auto", mRead[String(impregAutoOffset)]); + setLampStatus("lamp-impreg-manual", mRead[String(impregManualOffset)]); + setLampStatus("lamp-pump-circ", mRead[String(pumpCircOffset)]); + setLampStatus("lamp-pump-drain", mRead[String(pumpDrainOffset)]); + + // 순환펌프 재작동 시간 (D0 / D5) + const circTimeVal = data[`d_read_g${0 + dOffset}`] !== undefined ? data[`d_read_g${0 + dOffset}`] : 0; + document.getElementById("val-pump-circ-time").innerHTML = `${circTimeVal} `; + + // ---------------------------------------------------- + // 3그룹: 건조기 작동 상태 동기화 + // ---------------------------------------------------- + const dryerAutoOffset = isGroupB ? 56 : 46; + const dryerManualOffset = isGroupB ? 57 : 47; + const fanTripOffset = isGroupB ? 58 : 48; + const heaterReqOffset = isGroupB ? 59 : 49; + const fanActiveOffset = isGroupB ? 60 : 50; + const heaterStartOffset = isGroupB ? 61 : 51; + + setLampStatus("lamp-dryer-auto", mRead[String(dryerAutoOffset)]); + setLampStatus("lamp-dryer-manual", mRead[String(dryerManualOffset)]); + setLampStatus("lamp-dryer-fan", mRead[String(fanActiveOffset)]); + + // 히터 1~5 (A그룹: 51~55, B그룹: 61~65) + for (let h = 1; h <= 5; h++) { + setLampStatus(`lamp-dryer-h${h}`, mRead[String(heaterStartOffset + (h - 1))]); + } + + setLampStatus("lamp-fan-trip", mRead[String(fanTripOffset)], true); // 송풍기 트립 (알람이므로 빨간색) + setLampStatus("lamp-heater-req", mRead[String(heaterReqOffset)]); + + // ---------------------------------------------------- + // 4그룹: 커팅부 작동 상태 및 수치 동기화 + // ---------------------------------------------------- + const cutterActiveOffset = isGroupB ? 68 : 66; + const cutterStartOffset = isGroupB ? 69 : 67; + + setLampStatus("lamp-cutter-motor-active", mRead[String(cutterActiveOffset)]); + setLampStatus("lamp-cutter-motor-start", mRead[String(cutterStartOffset)]); + + // 커팅속도 (D1 / D6) - 소수점 1째자리 포맷팅 및 Hz 단위 적용 + const rawCutterSpeed = data[`d_read_g${1 + dOffset}`] !== undefined ? data[`d_read_g${1 + dOffset}`] : 0; + const cutterSpeedVal = Number(rawCutterSpeed).toFixed(1); + document.getElementById("val-cutter-speed").innerHTML = `${cutterSpeedVal} Hz`; + + // ---------------------------------------------------- + // 5그룹: 이송 작동 상태 및 수치 동기화 + // ---------------------------------------------------- + const transAutoOffset = isGroupB ? 79 : 70; + const transManualOffset = isGroupB ? 80 : 71; + const transAutoStartOffset = isGroupB ? 81 : 72; + const transAutoStopOffset = isGroupB ? 82 : 73; + const transFwdOffset = isGroupB ? 83 : 74; + const transRevOffset = isGroupB ? 84 : 75; + const transStopOffset = isGroupB ? 85 : 76; + const transZeroOffset = isGroupB ? 86 : 77; + const transErrOffset = isGroupB ? 87 : 78; + + setLampStatus("lamp-transfer-auto", mRead[String(transAutoOffset)]); + setLampStatus("lamp-transfer-manual", mRead[String(transManualOffset)]); + setLampStatus("lamp-transfer-auto-start", mRead[String(transAutoStartOffset)]); + setLampStatus("lamp-transfer-auto-stop", mRead[String(transAutoStopOffset)], true); + setLampStatus("lamp-transfer-fwd", mRead[String(transFwdOffset)]); + setLampStatus("lamp-transfer-rev", mRead[String(transRevOffset)]); + setLampStatus("lamp-transfer-stop", mRead[String(transStopOffset)], true); + setLampStatus("lamp-transfer-zero", mRead[String(transZeroOffset)]); + setLampStatus("lamp-transfer-err-reset", mRead[String(transErrOffset)]); + + // 이송 데이터 (D2, D3, D4 / D7, D8, D9) + const transTargetPos = data[`d_read_g${2 + dOffset}`] !== undefined ? data[`d_read_g${2 + dOffset}`] : 0; + const transCurPos = data[`d_read_g${3 + dOffset}`] !== undefined ? data[`d_read_g${3 + dOffset}`] : 0; + const transTargetSpeed = data[`d_read_g${4 + dOffset}`] !== undefined ? data[`d_read_g${4 + dOffset}`] : 0; + + // 자동 목표 이동량(그룹 2/7)은 REAL 소수 1자리, 수동 목표 속도(그룹 4/9)는 REAL 정수 표기 + document.getElementById("val-transfer-target-pos").innerText = Number(transTargetPos).toFixed(1); + document.getElementById("val-transfer-cur-pos").innerHTML = `${Number(transCurPos).toFixed(1)} mm/s`; + document.getElementById("val-transfer-target-speed").innerText = Number(transTargetSpeed).toFixed(0); + + // ---------------------------------------------------- + // 6그룹: 행거부 작동 (20개 포트 상태 셀 리프레시) + // ---------------------------------------------------- + const startPortNum = isGroupB ? 21 : 1; + const endPortNum = isGroupB ? 40 : 20; + + for (let p = startPortNum; p <= endPortNum; p++) { + const cell = document.getElementById(`port-cell-${p}`); + if (!cell) continue; + + let autoDownBitId, manualDownBitId, manualUpBitId; + + if (!isGroupB) { + // A그룹 포트 읽기 비트 매핑 + autoDownBitId = 88 + (p - 1); // 88 ~ 107 + manualDownBitId = 128 + (p - 1); // 128 ~ 147 + manualUpBitId = 168 + (p - 1); // 168 ~ 187 (168:자동상승, 169~187:수동상승) + } else { + // B그룹 포트 읽기 비트 매핑 + autoDownBitId = 108 + (p - 21); // 108 ~ 127 + manualDownBitId = 148 + (p - 21); // 148 ~ 167 + manualUpBitId = 188 + (p - 21); // 188 ~ 207 + } + + // CSS 스타일 초기화 후 상태에 따라 네온 효과 부여 + cell.className = "port-cell"; + + if (mRead[String(autoDownBitId)] === 1) { + cell.classList.add("port-active-auto-down"); + } else if (mRead[String(manualDownBitId)] === 1) { + cell.classList.add("port-active-manual-down"); + } else if (mRead[String(manualUpBitId)] === 1) { + cell.classList.add("port-active-manual-up"); + } else { + cell.classList.add("port-idle"); + } + } +} + +// 램프 인디케이터 상태 변경 유틸리티 함수 +function setLampStatus(elementId, value, isRed = false) { + const el = document.getElementById(elementId); + if (!el) return; + + const activeClass = isRed ? "active-red" : "active"; + + // 버튼 바인딩용 클래스 처리 (상위 부모가 btn-hmi인 경우 버튼 테두리 색상 처리 포함) + const btnParent = el.closest(".btn-hmi"); + + if (value === 1 || value === true || String(value).toLowerCase() === "true" || String(value) === "1") { + el.classList.add("active"); + if (btnParent) btnParent.classList.add(activeClass); + } else { + el.classList.remove("active"); + if (btnParent) { + btnParent.classList.remove("active"); + btnParent.classList.remove("active-red"); + } + } +} + +// 안전 인터락 전용 상태 갱신 함수 (정상 = 녹색(active), 이상 = 적색(active-red)) +function setInterlockLamp(elementId, value, normalValue) { + const el = document.getElementById(elementId); + if (!el) return; + + // value와 normalValue 값을 비교 (타입 무관하게 스트링 및 boolean 유연하게 비교) + const isNormal = (String(value) === String(normalValue) || + (value === true && normalValue === 1) || + (value === false && normalValue === 0)); + + if (isNormal) { + el.classList.add("active"); + el.classList.remove("active-red"); + } else { + el.classList.add("active-red"); + el.classList.remove("active"); + } +} + +// HMI 비트 제어 패킷 전송 (A/B 오프셋 적용하여 자동 제어) +async function sendHmiBit(baseBitId, actionName) { + if (!currentSelectedEquipment) return; + + // A/B 그룹별 PLC 쓰기 오프셋 계산 + const isGroupB = (currentActiveGroup === 'B'); + let actualBitId = baseBitId; + + // 그룹별 매핑 규정 오프셋 적용 + if (isGroupB) { + if (baseBitId >= 0 && baseBitId <= 11) { + actualBitId = baseBitId + 12; // 1그룹: Offset 12 (12~23) + } else if (baseBitId >= 24 && baseBitId <= 27) { + actualBitId = baseBitId + 4; // 2그룹: Offset 4 (28~31) + } else if (baseBitId >= 32 && baseBitId <= 39) { + actualBitId = baseBitId + 8; // 3그룹: Offset 8 (40~47) + } else if (baseBitId === 48) { + actualBitId = baseBitId + 1; // 4그룹: Offset 1 (49) + } else if (baseBitId >= 50 && baseBitId <= 58) { + actualBitId = baseBitId + 9; // 5그룹: Offset 9 (59~67) + } + } + + await sendHmiBitDirect(actualBitId); +} + +// 실제 HTTP 통신으로 비트 명령 전송 +async function sendHmiBitDirect(bitId) { + if (!currentSelectedEquipment || equipmentControlsLocked) return; + + try { + const payload = [[bitId, 1]]; // 제어 패킷 [[ID, 1]] (모멘터리 비트) + const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/m`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (!response.ok) { + console.error(`Control failed for bit ${bitId}`); + } else { + const result = await response.json(); + pendingControlCommands.set(result.command_id, `M${bitId} 비트 제어`); + } + } catch (e) { + console.error("비트 전송 실패:", e); + } +} + +// HMI 설정 레지스터 전송 (D영역 설정) +async function sendHmiRegister(baseRegId, inputElementId) { + if (!currentSelectedEquipment || equipmentControlsLocked) return; + + const inputElement = document.getElementById(inputElementId); + if (!inputElement) return; + + const rawVal = parseFloat(inputElement.value); + if (!Number.isFinite(rawVal)) return; + + // A/B 그룹에 따른 쓰기 레지스터 ID 오프셋 계산 (A: D0, D1 / B: D2, D3) + const isGroupB = (currentActiveGroup === 'B'); + let actualRegId = baseRegId; + if (isGroupB) { + actualRegId = baseRegId + 2; // Offset = 2 + } + + try { + const payload = [[actualRegId, rawVal]]; + const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/control/d`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (response.ok) { + const result = await response.json(); + pendingControlCommands.set(result.command_id, `D${actualRegId} 설정`); + inputElement.value = ""; // 입력창 초기화 (접수 팝업은 조작 편의를 위해 표시하지 않음) + } else { + alert("전송 실패"); + } + } catch (e) { + console.error("수치 전송 실패:", e); + } +} + + +// 모달 다이얼로그 제어 +function openAddModal() { + document.getElementById("add-equipment-modal").style.display = "flex"; +} + +function closeAddModal() { + document.getElementById("add-equipment-modal").style.display = "none"; + document.getElementById("add-equipment-form").reset(); +} + +// 새 설비 저장 +async function saveEquipment(event) { + event.preventDefault(); + + const eqId = document.getElementById("eq-id").value; + const name = document.getElementById("eq-name").value; + const type = document.getElementById("eq-type").value; + const ip = document.getElementById("eq-ip").value; + + const payload = { + equipment_id: eqId, + name: name, + type: type, + ip_address: ip, + line_prefix: "line1" // 기본 기본라인 할당 + }; + + try { + const response = await fetch(`${BACKEND_URL}/api/equipment`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (response.ok) { + closeAddModal(); + loadEquipmentCards(); + } else { + alert("중복된 ID이거나 등록 정보가 올바르지 않습니다."); + } + } catch (e) { + console.error("Save equipment error:", e); + } +} + +// 수동 Full-Sync 초기화 요청 전송 +async function triggerManualSync() { + if (!currentSelectedEquipment) return; + if (!confirm("게이트웨이의 내부 캐시를 초기화하고 PLC로부터 전체 데이터를 강제 수신(Full-Sync)하시겠습니까?")) return; + + try { + const response = await fetch(`${BACKEND_URL}/api/equipment/${currentSelectedEquipment.id}/sync`, { + method: 'POST' + }); + if (response.ok) { + setEquipmentControlsLocked(true); + alert("Full-Sync 초기화 명령이 전송되었습니다. 게이트웨이가 즉시 전체 상태를 갱신합니다."); + } else { + alert("초기화 명령 전송 실패"); + } + } catch (e) { + console.error("Manual sync request failed:", e); + alert("서버 통신 오류가 발생했습니다."); + } +} diff --git a/server/frontend/js/inventory.js b/server/frontend/js/inventory.js new file mode 100644 index 0000000..0ed0a6b --- /dev/null +++ b/server/frontend/js/inventory.js @@ -0,0 +1,327 @@ +// ChemiFactory MES - Inventory and Material Property Management Logic +const API_INVENTORY = "/inventory"; +const API_SUPPLIERS = "/suppliers"; + +let allInventories = []; +let allMaterials = []; +let allSuppliers = []; + +document.addEventListener("DOMContentLoaded", () => { + initEvents(); + fetchInventory(); + fetchMaterials(); + fetchSuppliers(); +}); + +// Fetch API Data +async function fetchInventory() { + try { + const res = await fetch(`${API_INVENTORY}/`); + if (!res.ok) throw new Error("Failed to fetch inventory"); + allInventories = await res.json(); + renderInventoryList(allInventories); + } catch (err) { + console.error(err); + document.getElementById("inventory-list-body").innerHTML = `재고 데이터를 불러오지 못했습니다.`; + } +} + +async function fetchMaterials() { + try { + const res = await fetch(`${API_INVENTORY}/materials`); + if (!res.ok) throw new Error("Failed to fetch materials"); + allMaterials = await res.json(); + renderMaterialList(allMaterials); + bindMaterialDropdown(allMaterials); + } catch (err) { + console.error(err); + document.getElementById("material-list-body").innerHTML = `자재 데이터를 불러오지 못했습니다.`; + } +} + +async function fetchSuppliers() { + try { + const res = await fetch(`${API_SUPPLIERS}/`); + if (!res.ok) throw new Error("Failed to fetch suppliers"); + allSuppliers = await res.json(); + bindSupplierDropdown(allSuppliers); + } catch (err) { + console.error(err); + } +} + +// Renderers +function renderInventoryList(list) { + const tbody = document.getElementById("inventory-list-body"); + tbody.innerHTML = ""; + + if (list.length === 0) { + tbody.innerHTML = `재고 입고 원장이 비어있습니다.`; + return; + } + + list.forEach(inv => { + const tr = document.createElement("tr"); + const statusBadge = inv.is_depleted + ? `소진 완료` + : `재고 있음`; + + tr.innerHTML = ` + ${inv.receipt_date} + ${inv.item_name || "-"}
${inv.item_number || "-"} + ${inv.material_name || "-"} (${inv.material_code || "-"}) + ${inv.receipt_quantity.toLocaleString()} + ${(inv.usage_quantity || 0).toLocaleString()} + ${(inv.stock || 0).toLocaleString()} + ${inv.unit_cost.toLocaleString()} 원 + ${inv.supplier || "-"} + ${statusBadge} + + + + + `; + tbody.appendChild(tr); + }); +} + +function renderMaterialList(list) { + const tbody = document.getElementById("material-list-body"); + tbody.innerHTML = ""; + + if (list.length === 0) { + tbody.innerHTML = `등록된 자재 기본 물성이 없습니다.`; + return; + } + + list.forEach(mat => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${mat.material_code} + ${mat.material_name} + ${mat.material_type === 'Yarn' ? '원사 (Yarn)' : mat.material_type === 'Bond' ? '본드 (Bond)' : '기타'} + ${mat.density} g/cm³ + ${mat.remarks || "-"} + + + + + `; + tbody.appendChild(tr); + }); +} + +function bindMaterialDropdown(materials) { + const select = document.getElementById("inv-material"); + select.innerHTML = ''; + materials.forEach(m => { + select.innerHTML += ``; + }); +} + +function bindSupplierDropdown(suppliers) { + const select = document.getElementById("inv-supplier"); + select.innerHTML = ''; + suppliers.forEach(s => { + select.innerHTML += ``; + }); +} + +// Events +function initEvents() { + // Tab switching + document.querySelectorAll(".tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(btn.dataset.tab).classList.add("active"); + }); + }); + + // Modal windows cancel + const modalIds = ["material-modal", "inventory-modal"]; + modalIds.forEach(id => { + const modal = document.getElementById(id); + modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => { + btn.addEventListener("click", () => modal.style.display = "none"); + }); + }); + + // Material logic trigger + document.getElementById("btn-add-material").addEventListener("click", () => { + document.getElementById("material-form").reset(); + document.getElementById("material-id").value = ""; + document.getElementById("material-modal-title").innerText = "자재 물성 등록"; + document.getElementById("material-modal").style.display = "flex"; + }); + document.getElementById("material-form").addEventListener("submit", handleMaterialSubmit); + + // Inventory logic trigger + document.getElementById("btn-add-inventory").addEventListener("click", () => { + document.getElementById("inventory-form").reset(); + document.getElementById("inventory-id").value = ""; + document.getElementById("usage-group").style.display = "none"; + document.getElementById("inv-date").value = new Date().toISOString().substring(0, 10); + document.getElementById("inventory-modal-title").innerText = "자재 신규 입고 등록"; + document.getElementById("inventory-modal").style.display = "flex"; + }); + document.getElementById("inventory-form").addEventListener("submit", handleInventorySubmit); + + // Search filters + document.getElementById("search-inventory").addEventListener("input", (e) => { + const query = e.target.value.toLowerCase(); + const filtered = allInventories.filter(i => + (i.item_name && i.item_name.toLowerCase().includes(query)) || + (i.item_number && i.item_number.toLowerCase().includes(query)) || + (i.material_name && i.material_name.toLowerCase().includes(query)) || + (i.material_code && i.material_code.toLowerCase().includes(query)) + ); + renderInventoryList(filtered); + }); + + document.getElementById("search-material").addEventListener("input", (e) => { + const query = e.target.value.toLowerCase(); + const filtered = allMaterials.filter(m => + m.material_name.toLowerCase().includes(query) || + m.material_code.toLowerCase().includes(query) + ); + renderMaterialList(filtered); + }); +} + +// Submits Handlers +async function handleMaterialSubmit(e) { + e.preventDefault(); + const id = document.getElementById("material-id").value; + const payload = { + material_code: document.getElementById("mat-code").value, + material_name: document.getElementById("mat-name").value, + material_type: document.getElementById("mat-type").value, + density: parseFloat(document.getElementById("mat-density").value), + remarks: document.getElementById("mat-remarks").value || null + }; + + try { + let res; + if (id) { + res = await fetch(`${API_INVENTORY}/materials/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_INVENTORY}/materials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("API failed"); + document.getElementById("material-modal").style.display = "none"; + fetchMaterials(); + } catch (err) { + console.error(err); + alert("자재 규격 저장에 실패했습니다."); + } +} + +window.openEditMaterialModal = function(mat) { + document.getElementById("material-id").value = mat.id; + document.getElementById("mat-code").value = mat.material_code; + document.getElementById("mat-name").value = mat.material_name; + document.getElementById("mat-type").value = mat.material_type; + document.getElementById("mat-density").value = mat.density; + document.getElementById("mat-remarks").value = mat.remarks || ""; + + document.getElementById("material-modal-title").innerText = "자재 물성 수정"; + document.getElementById("material-modal").style.display = "flex"; +}; + +window.deleteMaterial = async function(id) { + if (!confirm("이 자재 물성 규격을 정말 삭제하시겠습니까?\n이미 입고된 재고 이력이 존재할 경우 삭제가 거부될 수 있습니다.")) return; + try { + const res = await fetch(`${API_INVENTORY}/materials/${id}`, { method: "DELETE" }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail || "Check references"); + } + fetchMaterials(); + } catch (err) { + console.error(err); + alert(`자재 삭제 실패: ${err.message}`); + } +}; + +// Inventory Submits Handlers +async function handleInventorySubmit(e) { + e.preventDefault(); + const id = document.getElementById("inventory-id").value; + const payload = { + material_id: parseInt(document.getElementById("inv-material").value), + supplier_id: parseInt(document.getElementById("inv-supplier").value), + receipt_date: document.getElementById("inv-date").value, + item_name: document.getElementById("inv-item-name").value || null, + item_number: document.getElementById("inv-item-num").value || null, + receipt_quantity: parseFloat(document.getElementById("inv-qty").value), + usage_quantity: id ? parseFloat(document.getElementById("inv-usage").value || 0) : 0.0, + unit_cost: parseFloat(document.getElementById("inv-cost").value), + remarks: document.getElementById("inv-remarks").value || null + }; + + try { + let res; + if (id) { + res = await fetch(`${API_INVENTORY}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_INVENTORY}/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("API failed"); + document.getElementById("inventory-modal").style.display = "none"; + fetchInventory(); + } catch (err) { + console.error(err); + alert("재고 정보를 저장하는 데 실패했습니다."); + } +} + +window.openEditInventoryModal = function(inv) { + document.getElementById("inventory-id").value = inv.id; + document.getElementById("inv-material").value = inv.material_id; + document.getElementById("inv-supplier").value = inv.supplier_id; + document.getElementById("inv-date").value = inv.receipt_date; + document.getElementById("inv-item-name").value = inv.item_name || ""; + document.getElementById("inv-item-num").value = inv.item_number || ""; + document.getElementById("inv-qty").value = inv.receipt_quantity; + document.getElementById("inv-cost").value = inv.unit_cost; + document.getElementById("inv-remarks").value = inv.remarks || ""; + + // Show usage input when modifying + document.getElementById("usage-group").style.display = "block"; + document.getElementById("inv-usage").value = inv.usage_quantity || 0; + + document.getElementById("inventory-modal-title").innerText = "재고 정보 수정"; + document.getElementById("inventory-modal").style.display = "flex"; +}; + +window.depleteInventory = async function(id) { + if (!confirm("이 입고 건의 잔량 재고를 모두 소진 처리하시겠습니까?")) return; + try { + const res = await fetch(`${API_INVENTORY}/${id}/deplete`, { method: "PUT" }); + if (!res.ok) throw new Error("Failed to deplete"); + fetchInventory(); + } catch (err) { + console.error(err); + alert("재고 소진 처리에 실패했습니다."); + } +}; diff --git a/server/frontend/js/material.js b/server/frontend/js/material.js new file mode 100644 index 0000000..a253ff0 --- /dev/null +++ b/server/frontend/js/material.js @@ -0,0 +1,138 @@ +// ChemiFactory MES - Material Properties Specifications Management Logic +const API_BASE = "/inventory/materials"; + +let allMaterials = []; + +document.addEventListener("DOMContentLoaded", () => { + initEvents(); + fetchMaterials(); +}); + +async function fetchMaterials() { + try { + const res = await fetch(API_BASE); + if (!res.ok) throw new Error("Failed to fetch material specifications"); + allMaterials = await res.json(); + renderMaterialList(allMaterials); + } catch (err) { + console.error(err); + document.getElementById("material-list-body").innerHTML = `자재 규격 데이터를 로드하지 못했습니다.`; + } +} + +function renderMaterialList(list) { + const tbody = document.getElementById("material-list-body"); + tbody.innerHTML = ""; + + if (list.length === 0) { + tbody.innerHTML = `등록된 자재 물성 규격이 존재하지 않습니다.`; + return; + } + + list.forEach(m => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${m.material_code} + ${m.material_name} + ${m.material_type === 'Yarn' ? '원사 (Yarn)' : m.material_type === 'Bond' ? '본드 (Bond)' : '기타'} + ${m.density} g/cm³ + ${m.remarks || "-"} + + + + + `; + tbody.appendChild(tr); + }); +} + +function initEvents() { + // Search filter + document.getElementById("search-material").addEventListener("input", (e) => { + const query = e.target.value.toLowerCase(); + const filtered = allMaterials.filter(m => + m.material_name.toLowerCase().includes(query) || + m.material_code.toLowerCase().includes(query) + ); + renderMaterialList(filtered); + }); + + // Close Modals + const modal = document.getElementById("material-modal"); + modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => { + btn.addEventListener("click", () => modal.style.display = "none"); + }); + + // Trigger Form + document.getElementById("btn-add-material").addEventListener("click", () => { + document.getElementById("material-form").reset(); + document.getElementById("material-id").value = ""; + document.getElementById("material-modal-title").innerText = "자재 규격 정보 등록"; + modal.style.display = "flex"; + }); + + document.getElementById("material-form").addEventListener("submit", handleMaterialSubmit); +} + +async function handleMaterialSubmit(e) { + e.preventDefault(); + const id = document.getElementById("material-id").value; + const payload = { + material_code: document.getElementById("mat-code").value, + material_name: document.getElementById("mat-name").value, + material_type: document.getElementById("mat-type").value, + density: parseFloat(document.getElementById("mat-density").value), + remarks: document.getElementById("mat-remarks").value || null + }; + + try { + let res; + if (id) { + res = await fetch(`${API_BASE}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(API_BASE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("API operation failed"); + modal.style.display = "none"; + fetchMaterials(); + } catch (err) { + console.error(err); + alert("자재 규격 데이터를 저장하는 데 실패했습니다."); + } +} + +window.openEditMaterialModal = function(m) { + document.getElementById("material-id").value = m.id; + document.getElementById("mat-code").value = m.material_code; + document.getElementById("mat-name").value = m.material_name; + document.getElementById("mat-type").value = m.material_type; + document.getElementById("mat-density").value = m.density; + document.getElementById("mat-remarks").value = m.remarks || ""; + + document.getElementById("material-modal-title").innerText = "자재 규격 정보 수정"; + document.getElementById("material-modal").style.display = "flex"; +}; + +window.deleteMaterial = async function(id) { + if (!confirm("이 자재 물성 규격을 삭제하시겠습니까?\n이미 해당 규격으로 입고된 재고 내역이 존재하면 삭제가 불가합니다.")) return; + try { + const res = await fetch(`${API_BASE}/${id}`, { method: "DELETE" }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail || "오류 발생"); + } + fetchMaterials(); + } catch (err) { + console.error(err); + alert(`물성 데이터 삭제 실패: ${err.message}`); + } +}; diff --git a/server/frontend/js/plan.js b/server/frontend/js/plan.js new file mode 100644 index 0000000..5090d5f --- /dev/null +++ b/server/frontend/js/plan.js @@ -0,0 +1,534 @@ +// ChemiFactory MES - Production Plan Gantt Chart & Simulator Logic +const API_PLANS = "/plans"; +const API_CUSTOMERS = "/customers"; +const API_INVENTORY = "/inventory"; + +let allPlans = []; +let allCustomers = []; +let allYarns = []; +let allBonds = []; + +// Gantt configuration +const cellWidth = 40; // px +let ganttStartDate = null; +let ganttEndDate = null; +let datesArray = []; +let selectedEquipmentIds = []; +let simResultData = null; + +document.addEventListener("DOMContentLoaded", () => { + initEvents(); + initGanttDates(); + loadAllData(); + initScrollSync(); +}); + +function initGanttDates() { + const today = new Date(); + // 2 weeks before today + const start = new Date(today); + start.setDate(today.getDate() - 14); + ganttStartDate = start; + + // 5 weeks after today + const end = new Date(today); + end.setDate(today.getDate() + 35); + ganttEndDate = end; + + datesArray = []; + let curr = new Date(start); + while (curr <= end) { + datesArray.push(new Date(curr)); + curr.setDate(curr.getDate() + 1); + } +} + +async function loadAllData() { + await fetchCustomers(); + await fetchInventoryDropdowns(); + await fetchPlans(); +} + +async function fetchPlans() { + try { + const res = await fetch(`${API_PLANS}/`); + if (!res.ok) throw new Error("Failed to fetch plans"); + allPlans = await res.json(); + renderGantt(); + } catch (err) { + console.error("DB 연결 불가로 인해 간트차트 레이아웃 확인용 데모 데이터를 렌더링합니다:", err); + const todayStr = new Date().toISOString().substring(0, 10); + // 데모용 생산계획 데이터 2건 추가 + allPlans = [ + { + plan_id: 1, + plan_name: "샘플고객사A - 원사 12K (데모)", + customer_id: 1, + customer_name: "샘플고객사A", + yarn_inventory_id: 1, + yarn_name: "원사 12K", + bond_inventory_id: null, + requested_quantity_kg: 850.5, + start_date: todayStr, + production_days: 14.5, + assigned_equipment: [{equipment_id: 8}], + works_on_saturday: true, + works_on_sunday: false, + yarn_diameter: 7.0, + yarn_k: 12, + ports: 40, + cycle_time: 8.0, + cut_length: 6.0, + work_hours_day: 16, + plan_estimated_sales: 12500000, + plan_company_margin: 4200000, + plan_total_material_cost: 8300000, + plan_net_processing_cost: 3000000, + plan_processing_fee: 7200000, + plan_yarn_cost: 7500000, + plan_bond_cost: 80000 + }, + { + plan_id: 2, + plan_name: "샘플고객사B - 원사 24K (데모)", + customer_id: 2, + customer_name: "샘플고객사B", + yarn_inventory_id: 2, + yarn_name: "원사 24K", + bond_inventory_id: null, + requested_quantity_kg: 1200.0, + start_date: new Date(Date.now() + 86400000 * 5).toISOString().substring(0, 10), // 5일 뒤 시작 + production_days: 20.0, + assigned_equipment: [{equipment_id: 8}], + works_on_saturday: true, + works_on_sunday: true, + yarn_diameter: 7.5, + yarn_k: 24, + ports: 40, + cycle_time: 8.5, + cut_length: 6.5, + work_hours_day: 16, + plan_estimated_sales: 18000000, + plan_company_margin: 5500000, + plan_total_material_cost: 12500000, + plan_net_processing_cost: 4500000, + plan_processing_fee: 10000000, + plan_yarn_cost: 11000000, + plan_bond_cost: 150000 + } + ]; + renderGantt(); + } +} + +async function fetchCustomers() { + try { + const res = await fetch(`${API_CUSTOMERS}/`); + if (!res.ok) throw new Error("Failed to fetch customers"); + allCustomers = await res.json(); + const select = document.getElementById("plan-customer"); + select.innerHTML = ''; + allCustomers.forEach(c => { + select.innerHTML += ``; + }); + } catch (err) { + console.error(err); + } +} + +async function fetchInventoryDropdowns() { + try { + const res = await fetch(`${API_INVENTORY}/`); + if (!res.ok) throw new Error("Failed to fetch inventory dropdowns"); + const inventories = await res.json(); + + // 대소문자 및 한글 구분 필터링 및 소진되지 않은(is_depleted !== 1) 재고 추출 + allYarns = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'yarn' || i.material_type === '원사') && i.is_depleted !== 1); + allBonds = inventories.filter(i => i.material_type && (i.material_type.toLowerCase() === 'bond' || i.material_type === '본드') && i.is_depleted !== 1); + + const yarnSelect = document.getElementById("plan-yarn"); + yarnSelect.innerHTML = ''; + allYarns.forEach(y => { + const labelName = y.item_name || y.material_name || "미지정 원사"; + yarnSelect.innerHTML += ``; + }); + + const bondSelect = document.getElementById("plan-bond"); + bondSelect.innerHTML = ''; + allBonds.forEach(b => { + const labelName = b.item_name || b.material_name || "미지정 본드"; + bondSelect.innerHTML += ``; + }); + } catch (err) { + console.error("Dropdown load error: ", err); + } +} + +// Gantt Rendering Logic +function renderGantt() { + renderGanttHeaders(); + + const leftBody = document.getElementById("gantt-left-body"); + const rightBody = document.getElementById("gantt-right-body"); + leftBody.innerHTML = ""; + rightBody.innerHTML = ""; + + if (allPlans.length === 0) { + leftBody.innerHTML = `
수립된 계획이 없습니다.
`; + return; + } + + allPlans.forEach(plan => { + // Left Column Panel Row + const leftRow = document.createElement("div"); + leftRow.className = "gantt-row"; + leftRow.innerHTML = ` +
+ ${plan.plan_name} + ${plan.customer_name} | ${plan.yarn_name} +
+ `; + leftBody.appendChild(leftRow); + + // Right Timeline Row + const rightRow = document.createElement("div"); + rightRow.className = "gantt-row"; + rightRow.style.width = `${datesArray.length * cellWidth}px`; + + let gridHtml = `
`; + datesArray.forEach(() => { + gridHtml += `
`; + }); + gridHtml += '
'; + rightRow.innerHTML = gridHtml; + + // Render schedule bar overlay + const planStart = new Date(plan.start_date); + const planEnd = new Date(plan.end_date); + + // Calculate offsets + const startDiff = Math.ceil((planStart - ganttStartDate) / (1000 * 60 * 60 * 24)); + const duration = Math.ceil((planEnd - planStart) / (1000 * 60 * 60 * 24)) + 1; + + // Draw only if it overlaps with our Gantt window + if (planEnd >= ganttStartDate && planStart <= ganttEndDate) { + const visibleStart = Math.max(0, startDiff); + const visibleDuration = duration - (visibleStart - startDiff); + + const barContainer = document.createElement("div"); + barContainer.className = "gantt-bar-container"; + barContainer.style.left = `${visibleStart * cellWidth}px`; + barContainer.style.width = `${visibleDuration * cellWidth}px`; + + const bar = document.createElement("div"); + bar.className = "gantt-bar-item"; + bar.innerText = `[${plan.requested_quantity_kg}kg] ${plan.plan_name}`; + bar.addEventListener("click", () => openEditPlanModal(plan)); + + barContainer.appendChild(bar); + rightRow.appendChild(barContainer); + } + + rightBody.appendChild(rightRow); + }); +} + +function renderGanttHeaders() { + const headerRow = document.getElementById("gantt-date-headers"); + headerRow.style.width = `${datesArray.length * cellWidth}px`; + headerRow.innerHTML = ""; + + const todayStr = new Date().toISOString().substring(0, 10); + + datesArray.forEach(d => { + const day = d.getDate(); + const dow = d.getDay(); + const dateStr = d.toISOString().substring(0, 10); + + let cellClass = "gantt-day-header-cell"; + if (dow === 6) cellClass += " day-sat"; + else if (dow === 0) cellClass += " day-sun"; + if (dateStr === todayStr) cellClass += " day-today"; + + headerRow.innerHTML += ` +
+ ${d.getMonth() + 1}/${day} + ${['일', '월', '화', '수', '목', '금', '토'][dow]} +
+ `; + }); +} + +// Events Integration +function initEvents() { + const modal = document.getElementById("plan-modal"); + modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => { + btn.addEventListener("click", () => modal.style.display = "none"); + }); + + document.getElementById("btn-add-plan").addEventListener("click", () => { + document.getElementById("plan-form").reset(); + document.getElementById("plan-id").value = ""; + document.getElementById("plan-modal-title").innerText = "생산 계획 수립"; + document.getElementById("plan-start-date").value = new Date().toISOString().substring(0, 10); + document.getElementById("equip-assignment-box").style.display = "none"; + document.getElementById("btn-save-plan").disabled = true; + + simResultData = null; + selectedEquipmentIds = []; + + document.getElementById("sim-days").innerText = "-"; + document.getElementById("sim-sales").innerText = "-"; + document.getElementById("sim-margin").innerText = "-"; + + modal.style.display = "flex"; + }); + + document.getElementById("btn-simulate").addEventListener("click", runSimulation); + document.getElementById("plan-form").addEventListener("submit", savePlan); +} + +// Simulation & Optimization +async function runSimulation() { + const yarnInvId = document.getElementById("plan-yarn").value; + const qty = document.getElementById("plan-qty").value; + + if (!yarnInvId || !qty) { + alert("원사 자재와 목표 생산량을 먼저 입력해 주십시오."); + return; + } + + const payload = { + inputs: { + yarn_inventory_id: parseInt(yarnInvId), + bond_inventory_id: parseInt(document.getElementById("plan-bond").value) || null, + yarn_diameter_micron: parseFloat(document.getElementById("plan-yarn-dia").value), + yarn_k: parseInt(document.getElementById("plan-yarn-k").value), + ports: parseInt(document.getElementById("plan-ports").value), + cycle_time_hz: parseFloat(document.getElementById("plan-hz").value), + cut_length_mm: parseFloat(document.getElementById("plan-cut").value), + work_hours_day: parseInt(document.getElementById("plan-hours").value), + work_days_month: 20.0, // Default constraint + num_machines: 2.0, // Baseline machines + bond_percentage: 3.0 + }, + targetProductionQuantity: parseFloat(qty) + }; + + try { + const res = await fetch("/inventory/analysis/calculate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!res.ok) throw new Error("Calculation failure"); + const data = await res.json(); + simResultData = data; + + // Render results + document.getElementById("sim-days").innerText = data.required_days_target.toFixed(1); + document.getElementById("sim-sales").innerText = Math.round(data.plan_estimated_sales).toLocaleString(); + document.getElementById("sim-margin").innerText = Math.round(data.plan_company_margin).toLocaleString(); + + // Query Available equipment + const startDate = document.getElementById("plan-start-date").value; + if (startDate) { + await queryAvailableEquipment(startDate, data.required_days_target); + } + } catch (err) { + console.error(err); + alert("시뮬레이션 가동 계산 중 오류가 발생했습니다."); + } +} + +async function queryAvailableEquipment(startDateStr, requiredDays) { + // ⚠️ 장비 배정 부분 연동 (간트 시각화 및 배정을 위해 가용 장비 1~4호기를 체크할 수 있도록 칩 형태로 조회) + const startDate = new Date(startDateStr); + const endDate = new Date(startDate); + endDate.setDate(startDate.getDate() + Math.ceil(requiredDays) - 1); + + const sat = document.getElementById("plan-sat").checked; + const sun = document.getElementById("plan-sun").checked; + + try { + const url = `${API_PLANS}/find-available-equipment?start_date=${startDateStr}&end_date=${endDate.toISOString().substring(0, 10)}&works_on_saturday=${sat}&works_on_sunday=${sun}`; + const res = await fetch(url); + if (!res.ok) throw new Error("Equipment search failed"); + const machines = await res.json(); + + const container = document.getElementById("available-machines-container"); + container.innerHTML = ""; + + if (machines.length === 0) { + container.innerHTML = "해당 일정에 가용한 설비가 없습니다."; + document.getElementById("btn-save-plan").disabled = true; + } else { + machines.forEach(m => { + const chip = document.createElement("div"); + chip.className = "machine-chip"; + if (selectedEquipmentIds.includes(m.id)) chip.classList.add("selected"); + chip.innerText = m.name; + + chip.addEventListener("click", () => { + if (selectedEquipmentIds.includes(m.id)) { + selectedEquipmentIds = selectedEquipmentIds.filter(id => id !== m.id); + chip.classList.remove("selected"); + } else { + selectedEquipmentIds.push(m.id); + chip.classList.add("selected"); + } + document.getElementById("btn-save-plan").disabled = selectedEquipmentIds.length === 0; + }); + container.appendChild(chip); + }); + } + + document.getElementById("equip-assignment-box").style.display = "block"; + } catch (err) { + console.error(err); + } +} + +async function savePlan(e) { + e.preventDefault(); + if (!simResultData || selectedEquipmentIds.length === 0) return; + + const id = document.getElementById("plan-id").value; + const custSelect = document.getElementById("plan-customer"); + const yarnSelect = document.getElementById("plan-yarn"); + const bondSelect = document.getElementById("plan-bond"); + + const payload = { + plan_name: `${custSelect.options[custSelect.selectedIndex].text} - ${yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0]}`, + customer_id: parseInt(custSelect.value), + customer_name: custSelect.options[custSelect.selectedIndex].text, + yarn_inventory_id: parseInt(yarnSelect.value), + yarn_name: yarnSelect.options[yarnSelect.selectedIndex].text.split(' ')[0], + bond_inventory_id: parseInt(bondSelect.value) || null, + bond_name: bondSelect.value ? bondSelect.options[bondSelect.selectedIndex].text.split(' ')[0] : "", + requested_quantity_kg: parseFloat(document.getElementById("plan-qty").value), + start_date: document.getElementById("plan-start-date").value, + production_days: simResultData.required_days_target, + + // Sim data + hourly_net_cost_per_machine: simResultData.hourly_net_cost_per_machine, + hourly_profit_per_machine: simResultData.hourly_profit_per_machine, + plan_total_material_cost: simResultData.plan_total_material_cost, + yarn_diameter: parseFloat(document.getElementById("plan-yarn-dia").value), + yarn_k: parseFloat(document.getElementById("plan-yarn-k").value), + ports: parseFloat(document.getElementById("plan-ports").value), + cycle_time: parseFloat(document.getElementById("plan-hz").value), + cut_length: parseFloat(document.getElementById("plan-cut").value), + work_hours_day: parseFloat(document.getElementById("plan-hours").value), + work_days_month: 20.0, + num_machines: parseFloat(selectedEquipmentIds.length), + plan_net_processing_cost: simResultData.plan_net_processing_cost, + plan_company_margin: simResultData.plan_company_margin, + plan_processing_fee: simResultData.plan_processing_fee, + plan_yarn_cost: simResultData.plan_yarn_cost, + plan_bond_cost: simResultData.plan_bond_cost, + plan_estimated_sales: simResultData.plan_estimated_sales, + + // Allocations + equipment_ids: selectedEquipmentIds, + works_on_saturday: document.getElementById("plan-sat").checked, + works_on_sunday: document.getElementById("plan-sun").checked + }; + + try { + let res; + if (id) { + res = await fetch(`${API_PLANS}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_PLANS}/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("Plan saving failed"); + document.getElementById("plan-modal").style.display = "none"; + fetchPlans(); + } catch (err) { + console.error(err); + alert("계획 등록에 실패했습니다."); + } +} + +window.openEditPlanModal = function(plan) { + document.getElementById("plan-id").value = plan.plan_id; + document.getElementById("plan-customer").value = plan.customer_id; + document.getElementById("plan-yarn").value = plan.yarn_inventory_id; + document.getElementById("plan-bond").value = plan.bond_inventory_id || ""; + document.getElementById("plan-qty").value = plan.requested_quantity_kg; + document.getElementById("plan-start-date").value = plan.start_date; + + document.getElementById("plan-sat").checked = plan.works_on_saturday || false; + document.getElementById("plan-sun").checked = plan.works_on_sunday || false; + + document.getElementById("plan-yarn-dia").value = plan.yarn_diameter; + document.getElementById("plan-yarn-k").value = plan.yarn_k; + document.getElementById("plan-ports").value = plan.ports; + document.getElementById("plan-hz").value = plan.cycle_time; + document.getElementById("plan-cut").value = plan.cut_length; + document.getElementById("plan-hours").value = plan.work_hours_day; + + selectedEquipmentIds = plan.assigned_equipment ? plan.assigned_equipment.map(ae => ae.equipment_id) : []; + + simResultData = { + required_days_target: plan.production_days, + plan_estimated_sales: plan.plan_estimated_sales, + plan_company_margin: plan.plan_company_margin, + hourly_net_cost_per_machine: plan.hourly_net_cost_per_machine, + hourly_profit_per_machine: plan.hourly_profit_per_machine, + plan_total_material_cost: plan.plan_total_material_cost, + plan_net_processing_cost: plan.plan_net_processing_cost, + plan_processing_fee: plan.plan_processing_fee, + plan_yarn_cost: plan.plan_yarn_cost, + plan_bond_cost: plan.plan_bond_cost + }; + + document.getElementById("sim-days").innerText = plan.production_days.toFixed(1); + document.getElementById("sim-sales").innerText = Math.round(plan.plan_estimated_sales).toLocaleString(); + document.getElementById("sim-margin").innerText = Math.round(plan.plan_company_margin).toLocaleString(); + + queryAvailableEquipment(plan.start_date, plan.production_days); + + document.getElementById("plan-modal-title").innerText = "생산 계획 정보 조회 및 수정"; + document.getElementById("btn-save-plan").disabled = false; + document.getElementById("plan-modal").style.display = "flex"; +}; + +function initScrollSync() { + const leftBody = document.getElementById("gantt-left-body"); + const rightBody = document.getElementById("gantt-right-body"); + + if (!leftBody || !rightBody) return; + + let isLeftScrolling = false; + let isRightScrolling = false; + + leftBody.addEventListener("scroll", () => { + if (isRightScrolling) { + isRightScrolling = false; + return; + } + isLeftScrolling = true; + rightBody.scrollTop = leftBody.scrollTop; + }); + + rightBody.addEventListener("scroll", () => { + if (isLeftScrolling) { + isLeftScrolling = false; + return; + } + isRightScrolling = true; + leftBody.scrollTop = rightBody.scrollTop; + }); +} diff --git a/server/frontend/js/setting.js b/server/frontend/js/setting.js new file mode 100644 index 0000000..a1c8fa7 --- /dev/null +++ b/server/frontend/js/setting.js @@ -0,0 +1,165 @@ +// ChemiFactory MES - System Settings Frontend Logic +const API_SETTINGS = "/settings"; + +let allSettings = []; + +// DOM 준비 상태와 무관하게 theme.js 로딩 완료 대기 +function waitForThemeAndInit() { + // 최대 2초 동안 AppTheme 대기 + let attempts = 0; + const maxAttempts = 20; // 100ms * 20 = 2초 + + const checkReady = () => { + attempts++; + if (window.AppTheme) { + // AppTheme 준비 완료, 테마 선택기 초기화 + initThemeSelector(); + } else if (attempts < maxAttempts) { + // 아직 준비 안 됨, 100ms 후 재시도 + setTimeout(checkReady, 100); + } else { + console.warn("[Setting] AppTheme not loaded after 2 seconds"); + } + }; + + checkReady(); +} + +document.addEventListener("DOMContentLoaded", () => { + fetchSettings(); + document.getElementById("settings-form").addEventListener("submit", saveSettings); + waitForThemeAndInit(); +}); + +// --- 화면 테마 선택기 (theme.js의 전역 AppTheme 사용, 서버 DB와 무관) --- +function initThemeSelector() { + const cards = document.querySelectorAll(".theme-option-card"); + if (!cards.length) { + console.warn("[Theme Selector] No theme option cards found"); + return; + } + + if (!window.AppTheme) { + console.error("[Theme Selector] AppTheme is not available"); + return; + } + + // 현재 저장된 테마에 active 표시 + const applyActiveState = (theme) => { + cards.forEach(card => { + card.classList.toggle("active", card.dataset.themeValue === theme); + }); + }; + + // 초기 테마 상태 적용 + const currentTheme = window.AppTheme.get(); + applyActiveState(currentTheme); + console.log("[Theme Selector] Initialized with theme:", currentTheme); + + cards.forEach(card => { + const selectTheme = () => { + const theme = card.dataset.themeValue; + console.log("[Theme Selector] User selected theme:", theme); + window.AppTheme.set(theme); // localStorage 저장 + 즉시 화면 적용 + applyActiveState(theme); + }; + card.addEventListener("click", selectTheme); + // 키보드 접근성 (Enter/Space) + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + selectTheme(); + } + }); + }); +} + +async function fetchSettings() { + try { + const res = await fetch(`${API_SETTINGS}/`); + if (!res.ok) throw new Error("Failed to fetch settings"); + allSettings = await res.json(); + renderSettings(allSettings); + } catch (err) { + console.error(err); + document.getElementById("settings-container").innerHTML = `
설정 정보를 불러오는 도중 오류가 발생했습니다.
`; + } +} + +function renderSettings(settings) { + const container = document.getElementById("settings-container"); + container.innerHTML = ""; + + // Group settings by category + const grouped = {}; + settings.forEach(s => { + if (!grouped[s.setting_group]) { + grouped[s.setting_group] = []; + } + grouped[s.setting_group].push(s); + }); + + for (const groupName in grouped) { + // Category Header + const categoryDiv = document.createElement("div"); + categoryDiv.className = "settings-category"; + categoryDiv.innerText = translateGroup(groupName); + container.appendChild(categoryDiv); + + // Inputs mapping + grouped[groupName].forEach(s => { + const group = document.createElement("div"); + group.className = "form-group"; + + let inputType = "text"; + if (s.setting_type === "integer" || s.setting_type === "float") { + inputType = "number"; + } + + group.innerHTML = ` + + +
타입: ${s.setting_type}
+ `; + container.appendChild(group); + }); + } +} + +function translateGroup(group) { + const dict = { + "mqtt": "MQTT 통신 파라미터 설정", + "gateway": "ESP32 게이트웨이 기기 연동 설정", + "database": "데이터베이스 연동 세부 설정", + "system": "시스템 전역 상용 매개변수" + }; + return dict[group] || group; +} + +async function saveSettings(e) { + e.preventDefault(); + + const payload = []; + const inputs = document.querySelectorAll("#settings-container input"); + inputs.forEach(input => { + payload.push({ + setting_key: input.dataset.key, + setting_value: input.value + }); + }); + + try { + const res = await fetch(`${API_SETTINGS}/`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + if (!res.ok) throw new Error("Saving settings failed"); + alert("시스템 설정 정보가 성공적으로 업데이트되었습니다!"); + fetchSettings(); + } catch (err) { + console.error(err); + alert("설정 적용 도중 오류가 발생했습니다."); + } +} diff --git a/server/frontend/js/supplier.js b/server/frontend/js/supplier.js new file mode 100644 index 0000000..c9eede0 --- /dev/null +++ b/server/frontend/js/supplier.js @@ -0,0 +1,409 @@ +// ChemiFactory MES - Supplier Management Frontend Logic +const API_BASE = "/suppliers"; + +let allSuppliers = []; +let selectedSupplierId = null; + +document.addEventListener("DOMContentLoaded", () => { + initEvents(); + fetchSuppliers(); +}); + +// APIs Integration +async function fetchSuppliers() { + try { + const res = await fetch(`${API_BASE}/`); + if (!res.ok) throw new Error("Failed to fetch suppliers"); + allSuppliers = await res.json(); + renderSupplierList(allSuppliers); + } catch (err) { + console.error(err); + showListError(); + } +} + +async function showSupplierDetail(id) { + try { + selectedSupplierId = id; + const res = await fetch(`${API_BASE}/${id}`); + if (!res.ok) throw new Error("Failed to fetch supplier details"); + const data = await res.json(); + renderSupplierDetail(data); + } catch (err) { + console.error(err); + alert("공급사 정보를 상세히 불러오지 못했습니다."); + } +} + +// Render Lists +function renderSupplierList(list) { + const tbody = document.getElementById("supplier-list-body"); + tbody.innerHTML = ""; + + if (list.length === 0) { + tbody.innerHTML = `등록된 공급사가 없습니다.`; + return; + } + + list.forEach(supp => { + const tr = document.createElement("tr"); + if (selectedSupplierId === supp.id) tr.className = "active-row"; + tr.innerHTML = ` + ${supp.name_kr} + ${supp.contact_number || "-"} + ${supp.business_registration_number || "-"} + `; + tr.addEventListener("click", () => { + document.querySelectorAll("#supplier-list-body tr").forEach(el => el.classList.remove("active-row")); + tr.classList.add("active-row"); + showSupplierDetail(supp.id); + }); + tbody.appendChild(tr); + }); +} + +function showListError() { + const tbody = document.getElementById("supplier-list-body"); + tbody.innerHTML = `데이터 통신에 실패했습니다. DB 작동을 확인하십시오.`; +} + +function renderSupplierDetail(data) { + // Show details card, hide placeholder + document.getElementById("supplier-placeholder-card").style.display = "none"; + document.getElementById("supplier-detail-card").style.display = "block"; + + const supp = data.supplier; + document.getElementById("detail-name-kr").innerText = supp.name_kr; + document.getElementById("detail-name-en").innerText = supp.name_en || ""; + document.getElementById("detail-brn").innerText = supp.business_registration_number || "-"; + document.getElementById("detail-contact").innerText = supp.contact_number || "-"; + document.getElementById("detail-address").innerText = supp.address || "-"; + + // Bind Contacts + renderContactsList(data.contacts || []); + + // Bind Purchase Activities + renderActivitiesList(data.purchaseActivities || []); +} + +function renderContactsList(contacts) { + const tbody = document.getElementById("contact-list-body"); + tbody.innerHTML = ""; + + if (contacts.length === 0) { + tbody.innerHTML = `등록된 담당자가 없습니다.`; + return; + } + + contacts.forEach(c => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${c.name} + ${c.position || "-"} ${c.work_location ? `(${c.work_location})` : ""} + ${c.phone_number || "-"} + ${c.email || "-"} + + + + + `; + tbody.appendChild(tr); + }); +} + +function renderActivitiesList(activities) { + const tbody = document.getElementById("activity-list-body"); + tbody.innerHTML = ""; + + if (activities.length === 0) { + tbody.innerHTML = `매입 활동 내역이 없습니다.`; + return; + } + + // Sort by date descending + activities.sort((a, b) => new Date(b.activity_date) - new Date(a.activity_date)); + + activities.forEach(act => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${act.activity_date} + ${act.activity_type} + ${act.memo} + + + + + `; + tbody.appendChild(tr); + }); +} + +// Event Bindings +function initEvents() { + // Search Filter + document.getElementById("search-supplier").addEventListener("input", (e) => { + const query = e.target.value.toLowerCase(); + const filtered = allSuppliers.filter(c => + c.name_kr.toLowerCase().includes(query) || + (c.name_en && c.name_en.toLowerCase().includes(query)) || + (c.business_registration_number && c.business_registration_number.includes(query)) + ); + renderSupplierList(filtered); + }); + + // Tab Toggle + document.querySelectorAll(".tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll(".tab-content").forEach(tc => tc.classList.remove("active")); + + btn.classList.add("active"); + document.getElementById(btn.dataset.tab).classList.add("active"); + }); + }); + + // Modals Control + const modals = ["supplier-modal", "contact-modal", "activity-modal"]; + modals.forEach(mId => { + const modal = document.getElementById(mId); + modal.querySelectorAll(".close-btn, .close-modal-btn").forEach(btn => { + btn.addEventListener("click", () => modal.style.display = "none"); + }); + }); + + // Add Supplier + document.getElementById("btn-add-supplier").addEventListener("click", () => { + document.getElementById("supplier-form").reset(); + document.getElementById("supplier-id").value = ""; + document.getElementById("supplier-modal-title").innerText = "공급사 등록"; + document.getElementById("supplier-modal").style.display = "flex"; + }); + + document.getElementById("supplier-form").addEventListener("submit", handleSupplierSubmit); + + // Edit & Delete Supplier + document.getElementById("btn-edit-supplier").addEventListener("click", () => { + const activeSupp = allSuppliers.find(c => c.id === selectedSupplierId); + if (!activeSupp) return; + + document.getElementById("supplier-id").value = activeSupp.id; + document.getElementById("supp-name-kr").value = activeSupp.name_kr; + document.getElementById("supp-name-en").value = activeSupp.name_en || ""; + document.getElementById("supp-brn").value = activeSupp.business_registration_number || ""; + document.getElementById("supp-contact").value = activeSupp.contact_number || ""; + document.getElementById("supp-address").value = activeSupp.address || ""; + + document.getElementById("supplier-modal-title").innerText = "공급사 정보 수정"; + document.getElementById("supplier-modal").style.display = "flex"; + }); + + document.getElementById("btn-delete-supplier").addEventListener("click", handleDeleteSupplier); + + // Add Contact + document.getElementById("btn-add-contact").addEventListener("click", () => { + document.getElementById("contact-form").reset(); + document.getElementById("contact-id").value = ""; + document.getElementById("contact-modal-title").innerText = "담당자 등록"; + document.getElementById("contact-modal").style.display = "flex"; + }); + document.getElementById("contact-form").addEventListener("submit", handleContactSubmit); + + // Add Activity + document.getElementById("btn-add-activity").addEventListener("click", () => { + document.getElementById("activity-form").reset(); + document.getElementById("activity-id").value = ""; + document.getElementById("act-date").value = new Date().toISOString().substring(0, 10); + document.getElementById("activity-modal-title").innerText = "매입 일지 등록"; + document.getElementById("activity-modal").style.display = "flex"; + }); + document.getElementById("activity-form").addEventListener("submit", handleActivitySubmit); +} + +// Form Handlers +async function handleSupplierSubmit(e) { + e.preventDefault(); + const id = document.getElementById("supplier-id").value; + const payload = { + name_kr: document.getElementById("supp-name-kr").value, + name_en: document.getElementById("supp-name-en").value || null, + business_registration_number: document.getElementById("supp-brn").value || null, + contact_number: document.getElementById("supp-contact").value || null, + address: document.getElementById("supp-address").value || null + }; + + try { + let res; + if (id) { + // Update + res = await fetch(`${API_BASE}/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + // Create + res = await fetch(`${API_BASE}/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("API error occurred"); + const result = await res.json(); + + document.getElementById("supplier-modal").style.display = "none"; + await fetchSuppliers(); + + if (id) { + showSupplierDetail(parseInt(id)); + } else { + showSupplierDetail(result.id); + } + } catch (err) { + console.error(err); + alert("공급사 정보를 저장하는 중 오류가 발생했습니다."); + } +} + +async function handleDeleteSupplier() { + if (!selectedSupplierId) return; + if (!confirm("이 공급사를 정말 삭제하시겠습니까?\n해당 공급사의 연락처 및 매입 일지 정보가 영구 삭제됩니다.")) return; + + try { + const res = await fetch(`${API_BASE}/${selectedSupplierId}`, { + method: "DELETE" + }); + if (!res.ok) throw new Error("Failed to delete supplier"); + + selectedSupplierId = null; + document.getElementById("supplier-detail-card").style.display = "none"; + document.getElementById("supplier-placeholder-card").style.display = "flex"; + + await fetchSuppliers(); + } catch (err) { + console.error(err); + alert("공급사 삭제에 실패했습니다."); + } +} + +// Contact Handlers +window.openEditContactModal = function(contact) { + document.getElementById("contact-id").value = contact.id; + document.getElementById("cont-name").value = contact.name; + document.getElementById("cont-position").value = contact.position || ""; + document.getElementById("cont-phone").value = contact.phone_number || ""; + document.getElementById("cont-email").value = contact.email || ""; + document.getElementById("cont-location").value = contact.work_location || ""; + + document.getElementById("contact-modal-title").innerText = "담당자 정보 수정"; + document.getElementById("contact-modal").style.display = "flex"; +}; + +async function handleContactSubmit(e) { + e.preventDefault(); + const id = document.getElementById("contact-id").value; + const payload = { + supplier_id: selectedSupplierId, + name: document.getElementById("cont-name").value, + position: document.getElementById("cont-position").value || null, + phone_number: document.getElementById("cont-phone").value || null, + email: document.getElementById("cont-email").value || null, + work_location: document.getElementById("cont-location").value || null + }; + + try { + let res; + if (id) { + res = await fetch(`${API_BASE}/contacts/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_BASE}/contacts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("Contact save API error"); + document.getElementById("contact-modal").style.display = "none"; + showSupplierDetail(selectedSupplierId); + } catch (err) { + console.error(err); + alert("담당자 저장에 실패했습니다."); + } +} + +window.deleteContact = async function(contactId) { + if (!confirm("이 담당자 정보를 정말 삭제하시겠습니까?")) return; + try { + const res = await fetch(`${API_BASE}/contacts/${contactId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete contact"); + showSupplierDetail(selectedSupplierId); + } catch (err) { + console.error(err); + alert("담당자 삭제에 실패했습니다."); + } +}; + +// Activity Handlers +window.openEditActivityModal = function(act) { + document.getElementById("activity-id").value = act.id; + document.getElementById("act-date").value = act.activity_date; + document.getElementById("act-type").value = act.activity_type; + document.getElementById("act-memo").value = act.memo || ""; + + document.getElementById("activity-modal-title").innerText = "매입 일지 수정"; + document.getElementById("activity-modal").style.display = "flex"; +}; + +async function handleActivitySubmit(e) { + e.preventDefault(); + const id = document.getElementById("activity-id").value; + const payload = { + supplier_id: selectedSupplierId, + contact_id: null, + activity_date: document.getElementById("act-date").value, + activity_type: document.getElementById("act-type").value, + memo: document.getElementById("act-memo").value + }; + + try { + let res; + if (id) { + res = await fetch(`${API_BASE}/activities/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_BASE}/activities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!res.ok) throw new Error("Activity save API error"); + document.getElementById("activity-modal").style.display = "none"; + showSupplierDetail(selectedSupplierId); + } catch (err) { + console.error(err); + alert("일지 저장에 실패했습니다."); + } +} + +window.deleteActivity = async function(actId) { + if (!confirm("이 매입 일지를 삭제하시겠습니까?")) return; + try { + const res = await fetch(`${API_BASE}/activities/${actId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete activity"); + showSupplierDetail(selectedSupplierId); + } catch (err) { + console.error(err); + alert("일지 삭제에 실패했습니다."); + } +}; diff --git a/server/frontend/js/theme.js b/server/frontend/js/theme.js new file mode 100644 index 0000000..cf6406e --- /dev/null +++ b/server/frontend/js/theme.js @@ -0,0 +1,59 @@ +/* --- 테마 통합 관리 모듈 --- + * 라이트/다크 테마를 localStorage 기반으로 관리한다. (DB 저장 없음) + * 이 스크립트는 각 페이지 최상단(base.css 링크보다 먼저)에서 로드되어야 + * FOWT(Flash Of Wrong Theme, 잘못된 테마가 순간적으로 보이는 현상)를 방지한다. + */ +(function () { + "use strict"; + + var THEME_KEY = "app-theme"; + var DEFAULT_THEME = "light"; // 기본값: 화이트 계열 + var VALID_THEMES = ["light", "dark"]; + + /** localStorage에서 저장된 테마를 읽는다. 없거나 잘못된 값이면 기본값 반환. */ + function getStoredTheme() { + var stored; + try { + stored = window.localStorage.getItem(THEME_KEY); + } catch (e) { + stored = null; // localStorage 접근 불가(사생활 보호 모드 등) 시 안전하게 기본값 + } + return VALID_THEMES.indexOf(stored) !== -1 ? stored : DEFAULT_THEME; + } + + /** 요소에 data-theme 속성을 적용한다. (실제 화면 반영) */ + function applyTheme(theme) { + if (VALID_THEMES.indexOf(theme) === -1) { + theme = DEFAULT_THEME; + } + document.documentElement.setAttribute("data-theme", theme); + } + + /** 테마를 저장하고 즉시 화면에 적용한다. */ + function setTheme(theme) { + if (VALID_THEMES.indexOf(theme) === -1) { + theme = DEFAULT_THEME; + } + try { + window.localStorage.setItem(THEME_KEY, theme); + } catch (e) { + /* 저장 실패해도 현재 세션 적용은 계속 진행 */ + } + applyTheme(theme); + } + + // 로드 즉시 동기 실행 → FOWT 방지 (DOMContentLoaded를 기다리지 않음) + applyTheme(getStoredTheme()); + + // 다른 스크립트(setting.js 등)에서 사용할 수 있도록 전역 노출 + try { + window.AppTheme = { + get: getStoredTheme, + set: setTheme, + apply: applyTheme, + THEMES: VALID_THEMES.slice() + }; + } catch (e) { + console.warn("[Theme] Failed to expose AppTheme to window:", e); + } +})(); diff --git a/server/frontend/material.html b/server/frontend/material.html new file mode 100644 index 0000000..a2459ec --- /dev/null +++ b/server/frontend/material.html @@ -0,0 +1,228 @@ + + + + + + ChemiFactory 자재 물성 관리 + + + + + + + + + + +
+ + + + +
+
+
+

자재 물성 규격 관리

+

생산 단가 및 소요 기간 시뮬레이션 연산의 기준이 되는 원자재 사양 규격을 정의합니다.

+
+ +
+ +
+
+

원자재 규격 관리 리스트

+
+ +
+
+
+ + + + + + + + + + + + + + + + +
자재 코드자재 명칭자재 구분밀도 (Density, g/cm³)상세 비고관리
자재 데이터를 조회하는 중...
+
+
+
+
+ + + + + + + + + diff --git a/server/frontend/plan.html b/server/frontend/plan.html new file mode 100644 index 0000000..9251247 --- /dev/null +++ b/server/frontend/plan.html @@ -0,0 +1,439 @@ + + + + + + ChemiFactory 생산 계획 관리 (Gantt) + + + + + + + + + + +
+ + + + +
+
+
+

생산 계획 관리 (간트차트)

+

자재 시뮬레이션을 실행하여 필요 작업 기간을 연산하고, 기기 배정 일정을 수립합니다.

+
+ +
+ + +
+ +
+
+ 생산 계획 / 배정 기기 +
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+ + + + + + + + + diff --git a/server/frontend/setting.html b/server/frontend/setting.html new file mode 100644 index 0000000..3769ca5 --- /dev/null +++ b/server/frontend/setting.html @@ -0,0 +1,202 @@ + + + + + + ChemiFactory 시스템 설정 + + + + + + + + + + +
+ + + + +
+
+
+

시스템 전역 설정

+

FastAPI 서버의 전역 파라미터 및 MQTT 게이트웨이 연동 상수를 설정합니다.

+
+
+ + +
+
화면 테마
+

밝은 화면(라이트) 또는 어두운 화면(다크) 테마를 선택합니다. 이 설정은 사용 중인 브라우저에만 저장됩니다.

+
+
+
+
+
+
+
+ ☀️ 라이트 + ✔ 사용 중 +
+
+
+
+
+
+
+
+ 🌙 다크 + ✔ 사용 중 +
+
+
+
+ +
+
+
+
설정 정보를 조회하는 중...
+
+ +
+ +
+
+
+
+
+ + + + + + diff --git a/server/frontend/source/Thumbs.db b/server/frontend/source/Thumbs.db new file mode 100644 index 0000000..5a8595e Binary files /dev/null and b/server/frontend/source/Thumbs.db differ diff --git a/server/frontend/source/company_name.png b/server/frontend/source/company_name.png new file mode 100644 index 0000000..954abe2 Binary files /dev/null and b/server/frontend/source/company_name.png differ diff --git a/server/frontend/source/logo.png b/server/frontend/source/logo.png new file mode 100644 index 0000000..e00d54d Binary files /dev/null and b/server/frontend/source/logo.png differ diff --git a/server/frontend/source/logo_and_company_name.png b/server/frontend/source/logo_and_company_name.png new file mode 100644 index 0000000..38b1ae7 Binary files /dev/null and b/server/frontend/source/logo_and_company_name.png differ diff --git a/server/frontend/source/logo_and_company_name_slogan.png b/server/frontend/source/logo_and_company_name_slogan.png new file mode 100644 index 0000000..248770b Binary files /dev/null and b/server/frontend/source/logo_and_company_name_slogan.png differ diff --git a/server/frontend/supplier.html b/server/frontend/supplier.html new file mode 100644 index 0000000..86263ad --- /dev/null +++ b/server/frontend/supplier.html @@ -0,0 +1,442 @@ + + + + + + ChemiFactory 공급사 관리 + + + + + + + + + + +
+ + + + +
+
+
+

공급사 관리

+

원자재 공급처 정보, 공급사 담당자 연락처망 및 자재 매입 활동 내역을 관리합니다.

+
+ +
+ +
+ +
+
+

공급사 목록

+
+ +
+
+
+ + + + + + + + + + + + + +
공급사명(한글)대표 번호등록번호
데이터를 불러오는 중...
+
+
+ + + + + +
+
왼쪽 목록에서 공급사를 선택하시면 상세 내역을 확인할 수 있습니다.
+
+
+
+
+ + + + + + + + + + + + + + + diff --git a/server/inventory/__init__.py b/server/inventory/__init__.py new file mode 100644 index 0000000..febf367 --- /dev/null +++ b/server/inventory/__init__.py @@ -0,0 +1,16 @@ +# inventory package marker +from .db_manager import ( + add_material_property, + update_material_property, + delete_material_property, + get_all_material_properties, + get_material_property_by_id, + add_inventory_to_db, + update_inventory_in_db, + deplete_inventory_in_db, + get_all_inventory_from_db, + get_inventory_details_from_db, + get_total_stock_for_material +) +from .analysis import calculate_cost_analysis +from .router import router diff --git a/server/inventory/analysis.py b/server/inventory/analysis.py new file mode 100644 index 0000000..b07b9d4 --- /dev/null +++ b/server/inventory/analysis.py @@ -0,0 +1,221 @@ +import math +import logging +from .db_manager import get_inventory_details_from_db +from setting.db_manager import get_all_settings + +def _safe_float_conversion(value, default_value=None): + try: + if value is not None and value != '': + return float(value) + except (ValueError, TypeError): + pass + return default_value + +def calculate_cost_analysis(inputs: dict, target_production_quantity: float = None) -> dict: + """ + 원가 분석 계산 엔진 (순수 연산 비즈니스 로직) + """ + yarn_inventory_id = inputs.get("yarn_inventory_id") + bond_inventory_id = inputs.get("bond_inventory_id") + + if not yarn_inventory_id: + raise ValueError("분석할 원사 재고가 선택되지 않았습니다.") + + yarn_data_wrapper = get_inventory_details_from_db(yarn_inventory_id) + if not yarn_data_wrapper or "inventory" not in yarn_data_wrapper: + raise ValueError("선택된 원사의 물성 또는 단가 정보를 찾을 수 없습니다.") + yarn_data = yarn_data_wrapper["inventory"] + + yarn_density_g_cm3 = _safe_float_conversion(yarn_data.get("density"), 1.0) + yarn_unit_cost = _safe_float_conversion(yarn_data.get("unit_cost"), 0.0) + + bond_unit_cost = 0.0 + if bond_inventory_id and bond_inventory_id != 'null': + bond_data_wrapper = get_inventory_details_from_db(bond_inventory_id) + if bond_data_wrapper and "inventory" in bond_data_wrapper: + bond_data = bond_data_wrapper["inventory"] + bond_unit_cost = _safe_float_conversion(bond_data.get("unit_cost"), 0.0) + + yarn_diameter_micron = _safe_float_conversion(inputs.get("yarn_diameter_micron"), 7) + yarn_k = _safe_float_conversion(inputs.get("yarn_k"), 12) * 1000 + ports = _safe_float_conversion(inputs.get("ports"), 40) + cycle_time_hz = _safe_float_conversion(inputs.get("cycle_time_hz"), 8) + cut_length_mm = _safe_float_conversion(inputs.get("cut_length_mm"), 6) + work_hours_day = _safe_float_conversion(inputs.get("work_hours_day"), 16) + work_days_month = _safe_float_conversion(inputs.get("work_days_month"), 20) + num_machines = _safe_float_conversion(inputs.get("num_machines"), 2) + bond_percentage = _safe_float_conversion(inputs.get("bond_percentage"), 3.0) / 100.0 + + yarn_radius_cm = (yarn_diameter_micron / 2) * 1e-4 + yarn_cross_section_cm2 = math.pi * (yarn_radius_cm ** 2) + pure_yarn_weight_kg_per_m = (yarn_cross_section_cm2 * 100 * yarn_density_g_cm3) / 1000 + cut_length_m = cut_length_mm / 1000 + production_length_m_per_hour_per_mc = cycle_time_hz * 3600 * cut_length_m + pure_yarn_kg_per_hour_per_mc = production_length_m_per_hour_per_mc * pure_yarn_weight_kg_per_m * ports * yarn_k + + if 0 < bond_percentage < 1.0: + total_kg_per_hour_per_mc = pure_yarn_kg_per_hour_per_mc / (1.0 - bond_percentage) + else: + total_kg_per_hour_per_mc = pure_yarn_kg_per_hour_per_mc + + if total_kg_per_hour_per_mc <= 0: + raise ValueError("시간당 생산량이 0 이하여서 계산할 수 없습니다.") + + settings = get_all_settings() + settings_dict = {f"{s['setting_group']}_{s['setting_key']}": s['setting_value'] for s in settings} + + def get_s(key, default): + return _safe_float_conversion(settings_dict.get(key, default), default) + + avg_power_per_machine_kw = get_s("CostAnalysis_AvgPowerPerMachine", 5000) / 1000 + electricity_price_kwh = get_s("CostAnalysis_ElectricityUnitPricePerKWH", 94.4) + hourly_power_cost = avg_power_per_machine_kw * electricity_price_kwh + + max_machines_per_person = get_s("CostAnalysis_MaxMachinesPerPerson", 10) + num_people_needed = math.ceil(num_machines / max_machines_per_person) + monthly_labor_cost_person = get_s("CostAnalysis_MonthlyLaborCostPerPerson", 6000000) + meal_cost_day = get_s("CostAnalysis_MealCostPerDay", 15000) + hourly_labor_cost = (num_people_needed * monthly_labor_cost_person + num_people_needed * meal_cost_day * work_days_month) / (work_days_month * work_hours_day * num_machines) + + packaging_cost_kg = get_s("CostAnalysis_PackagingCostPerKg", 500) + packaging_weight_kg = get_s("CostAnalysis_PackagingBaseWeightKg", 25) + hourly_packaging_cost = (total_kg_per_hour_per_mc / packaging_weight_kg) * packaging_cost_kg if packaging_weight_kg > 0 else 0 + + monthly_rent = get_s("CostAnalysis_MonthlyRentCost", 0) + monthly_admin = get_s("CostAnalysis_MonthlyGeneralAdminCost", 50000) + monthly_delivery = get_s("CostAnalysis_MonthlyDeliveryCost", 300000) + hourly_overhead_cost = (monthly_rent + monthly_admin + monthly_delivery) / (work_days_month * work_hours_day * num_machines) + + mass_mc_cost = get_s("Investment_MassProductionEquipmentCost", 10000000) + dep_mass_mc = get_s("Investment_DepreciationPeriodMassProduction", 3) * 12 + office_cost = get_s("Investment_OfficeSuppliesCost", 5000000) + dep_office = get_s("Investment_DepreciationPeriodOfficeSupplies", 3) * 12 + proto_cost = get_s("Investment_ProtoEquipmentCost", 5000000) + monthly_depreciation = (mass_mc_cost / dep_mass_mc if dep_mass_mc > 0 else 0) + \ + (office_cost / dep_office if dep_office > 0 else 0) + \ + (proto_cost / dep_office if dep_office > 0 else 0) + hourly_depreciation = monthly_depreciation / (work_days_month * work_hours_day) + + hourly_net_cost_per_machine = hourly_power_cost + hourly_labor_cost + hourly_packaging_cost + hourly_overhead_cost + hourly_depreciation + + company_margin_rate = get_s("CostAnalysis_CompanyMarginRate", 30) / 100.0 + standard_processing_cost_kg = get_s("CostAnalysis_StandardProcessingCost", 0) + + pure_processing_cost_kg = hourly_net_cost_per_machine / total_kg_per_hour_per_mc if total_kg_per_hour_per_mc > 0 else 0 + + calculated_profit_margin_kg = 0 + if pure_processing_cost_kg > 0: + if pure_processing_cost_kg * (1 + company_margin_rate) < standard_processing_cost_kg: + calculated_profit_margin_kg = standard_processing_cost_kg - pure_processing_cost_kg + else: + calculated_profit_margin_kg = pure_processing_cost_kg * company_margin_rate + + hourly_profit_per_machine = calculated_profit_margin_kg * total_kg_per_hour_per_mc + + analysis_result = {} + + total_kg_hour_all_mc = total_kg_per_hour_per_mc * num_machines + total_kg_day_all_mc = total_kg_hour_all_mc * work_hours_day + total_kg_month = total_kg_day_all_mc * work_days_month + + monthly_power_cost = hourly_power_cost * work_hours_day * work_days_month * num_machines + monthly_labor_cost_total = hourly_labor_cost * work_hours_day * work_days_month * num_machines + monthly_packaging_cost = hourly_packaging_cost * work_hours_day * work_days_month * num_machines + monthly_overhead_cost = hourly_overhead_cost * work_hours_day * work_days_month * num_machines + monthly_depreciation_cost_total = hourly_depreciation * work_hours_day * work_days_month * num_machines + + monthly_total_expenses = monthly_power_cost + monthly_labor_cost_total + monthly_packaging_cost + monthly_overhead_cost + monthly_depreciation_cost_total + + pure_yarn_kg_per_hour_all_mc = pure_yarn_kg_per_hour_per_mc * num_machines + required_yarn_kg_month = (pure_yarn_kg_per_hour_all_mc / total_kg_hour_all_mc) * total_kg_month if total_kg_hour_all_mc > 0 else 0 + + bond_kg_per_hour_all_mc = (total_kg_hour_all_mc - pure_yarn_kg_per_hour_all_mc) + required_bond_kg_month = (bond_kg_per_hour_all_mc / total_kg_hour_all_mc) * total_kg_month if total_kg_hour_all_mc > 0 else 0 + + current_yarn_stock = _safe_float_conversion(yarn_data.get("stock"), 0.0) + purchase_order_yarn_kg = max(0, required_yarn_kg_month - current_yarn_stock) + + current_bond_stock = 0.0 + purchase_order_bond_kg = 0.0 + if bond_inventory_id and bond_inventory_id != 'null': + bond_data_wrapper = get_inventory_details_from_db(bond_inventory_id) + if bond_data_wrapper and "inventory" in bond_data_wrapper: + current_bond_stock = _safe_float_conversion(bond_data_wrapper["inventory"].get("stock"), 0.0) + purchase_order_bond_kg = max(0, required_bond_kg_month - current_bond_stock) + + total_yarn_purchase_cost = required_yarn_kg_month * yarn_unit_cost + total_bond_purchase_cost = required_bond_kg_month * bond_unit_cost + total_raw_material_purchase_cost = total_yarn_purchase_cost + total_bond_purchase_cost + + analysis_result.update({ + "production_kg_per_hour": total_kg_hour_all_mc, + "production_kg_per_day": total_kg_day_all_mc, + "production_ton_per_month": total_kg_month / 1000, + "total_product_kg_per_month": total_kg_month, + "pure_processing_cost_per_kg": pure_processing_cost_kg, + "calculated_profit_margin_per_kg": calculated_profit_margin_kg, + "processing_cost_per_kg_total": pure_processing_cost_kg + calculated_profit_margin_kg, + "current_yarn_stock": current_yarn_stock, + "required_yarn_kg": required_yarn_kg_month, + "purchase_order_kg": purchase_order_yarn_kg, + "yarn_unit_cost": yarn_unit_cost, + "current_bond_stock": current_bond_stock, + "required_bond_kg": required_bond_kg_month, + "purchase_order_bond_kg": purchase_order_bond_kg, + "bond_unit_cost": bond_unit_cost, + "total_monthly_expenses": monthly_total_expenses, + "monthly_power_cost": monthly_power_cost, + "total_monthly_kwh": (avg_power_per_machine_kw * num_machines * work_hours_day * work_days_month), + "monthly_labor_cost": monthly_labor_cost_total, + "monthly_meal_cost": (num_people_needed * meal_cost_day * work_days_month), + "monthly_packaging_cost": monthly_packaging_cost, + "monthly_depreciation_cost": monthly_depreciation_cost_total, + "monthly_rent_cost": monthly_rent, + "monthly_general_admin_cost_setting": monthly_admin, + "monthly_delivery_cost": monthly_delivery, + "total_revenue": total_raw_material_purchase_cost + ((pure_processing_cost_kg + calculated_profit_margin_kg) * total_kg_month) + }) + + # 2. 목표 생산량 분석 (요청 시 추가 수행) + if target_production_quantity and target_production_quantity > 0: + total_work_hours_target = target_production_quantity / (total_kg_hour_all_mc if total_kg_hour_all_mc > 0 else 1) + work_days_target = total_work_hours_target / work_hours_day + + required_yarn_kg_target = (pure_yarn_kg_per_hour_all_mc / total_kg_hour_all_mc) * target_production_quantity if total_kg_hour_all_mc > 0 else 0 + purchase_order_yarn_kg_target = max(0, required_yarn_kg_target - current_yarn_stock) + + required_bond_kg_target = (bond_kg_per_hour_all_mc / total_kg_hour_all_mc) * target_production_quantity if total_kg_hour_all_mc > 0 else 0 + purchase_order_bond_kg_target = max(0, required_bond_kg_target - current_bond_stock) + + plan_yarn_cost = required_yarn_kg_target * yarn_unit_cost + plan_bond_cost = required_bond_kg_target * bond_unit_cost + plan_total_material_cost = plan_yarn_cost + plan_bond_cost + + plan_net_processing_cost = hourly_net_cost_per_machine * num_machines * total_work_hours_target + plan_company_margin = hourly_profit_per_machine * num_machines * total_work_hours_target + plan_processing_fee = plan_net_processing_cost + plan_company_margin + plan_estimated_sales = plan_processing_fee + plan_total_material_cost + + analysis_result.update({ + "required_days_target": work_days_target, + "current_yarn_stock_target": current_yarn_stock, + "required_yarn_kg_target": required_yarn_kg_target, + "purchase_order_kg_target": purchase_order_yarn_kg_target, + "current_bond_stock_target": current_bond_stock, + "required_bond_kg_target": required_bond_kg_target, + "purchase_order_bond_kg_target": purchase_order_bond_kg_target, + "plan_yarn_cost": plan_yarn_cost, + "plan_bond_cost": plan_bond_cost, + "plan_total_material_cost": plan_total_material_cost, + "plan_net_processing_cost": plan_net_processing_cost, + "plan_company_margin": plan_company_margin, + "plan_processing_fee": plan_processing_fee, + "plan_estimated_sales": plan_estimated_sales + }) + + analysis_result.update({ + "hourly_net_cost_per_machine": hourly_net_cost_per_machine, + "hourly_profit_per_machine": hourly_profit_per_machine + }) + + return analysis_result diff --git a/server/inventory/db_manager.py b/server/inventory/db_manager.py new file mode 100644 index 0000000..99835ce --- /dev/null +++ b/server/inventory/db_manager.py @@ -0,0 +1,237 @@ +import mysql.connector +from database.connection import get_db_connection +import logging + +# 물성 정보 추가 +def add_material_property(data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO material_properties (material_code, material_name, material_type, density, remarks) + VALUES (%s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + data.get('material_code'), + data.get('material_name'), + data.get('material_type'), + data.get('density'), + data.get('remarks') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding material property: {err}") + return None + +# 물성 정보 수정 +def update_material_property(data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE material_properties SET + material_code = %s, + material_name = %s, + material_type = %s, + density = %s, + remarks = %s + WHERE id = %s + """ + cursor.execute(sql, ( + data.get('material_code'), + data.get('material_name'), + data.get('material_type'), + data.get('density'), + data.get('remarks'), + data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating material property: {err}") + return False + +# 물성 정보 삭제 +def delete_material_property(material_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT COUNT(*) as count FROM inventory WHERE material_id = %s", (material_id,)) + if cursor.fetchone()['count'] > 0: + logging.warning(f"Cannot delete material property {material_id} as it is in use by inventory.") + return False + cursor.execute("DELETE FROM material_properties WHERE id = %s", (material_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting material property: {err}") + return False + +# 모든 물성 정보 조회 +def get_all_material_properties(): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM material_properties") + return cursor.fetchall() + except mysql.connector.Error as err: + logging.error(f"Error getting all material properties: {err}") + return [] + +# ID로 특정 물성 정보 조회 +def get_material_property_by_id(material_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM material_properties WHERE id = %s", (material_id,)) + return cursor.fetchone() + except mysql.connector.Error as err: + logging.error(f"Error getting material property by id: {err}") + return None + +# 재고 입고 (추가) +def add_inventory_to_db(data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + stock = data.get('receipt_quantity', 0) - data.get('usage_quantity', 0) + sql = """ + INSERT INTO inventory (material_id, receipt_date, receipt_quantity, usage_quantity, stock, unit_cost, remarks, is_depleted, supplier_id, item_name, item_number) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + data.get('material_id'), + data.get('receipt_date'), + data.get('receipt_quantity'), + data.get('usage_quantity', 0), + stock, + data.get('unit_cost'), + data.get('remarks'), + 1 if stock <= 0 else 0, + data.get('supplier_id'), + data.get('item_name'), + data.get('item_number') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding inventory: {err}") + return None + +# 재고 정보 수정 +def update_inventory_in_db(data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + stock = data.get('receipt_quantity', 0) - data.get('usage_quantity', 0) + sql = """ + UPDATE inventory SET + material_id = %s, + receipt_date = %s, + receipt_quantity = %s, + usage_quantity = %s, + stock = %s, + unit_cost = %s, + remarks = %s, + is_depleted = %s, + supplier_id = %s, + item_name = %s, + item_number = %s + WHERE id = %s + """ + cursor.execute(sql, ( + data.get('material_id'), + data.get('receipt_date'), + data.get('receipt_quantity'), + data.get('usage_quantity'), + stock, + data.get('unit_cost'), + data.get('remarks'), + 1 if stock <= 0 else 0, + data.get('supplier_id'), + data.get('item_name'), + data.get('item_number'), + data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating inventory: {err}") + return False + +# 재고 소진 처리 +def deplete_inventory_in_db(inventory_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = "UPDATE inventory SET is_depleted = 1, stock = 0 WHERE id = %s" + cursor.execute(sql, (inventory_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error depleting inventory: {err}") + return False + +# 모든 재고 목록 조회 +def get_all_inventory_from_db(): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + SELECT + i.id, i.material_id, i.receipt_date, i.receipt_quantity, i.usage_quantity, + i.stock, i.unit_cost, i.remarks, i.is_depleted, i.supplier_id, + i.item_name, i.item_number, + mp.material_code, mp.material_name, mp.material_type, mp.density, + s.name_kr as supplier + FROM inventory i + LEFT JOIN material_properties mp ON i.material_id = mp.id + LEFT JOIN suppliers s ON i.supplier_id = s.id + ORDER BY i.receipt_date DESC + """ + cursor.execute(sql) + return cursor.fetchall() + except mysql.connector.Error as err: + logging.error(f"Error getting all inventory: {err}") + return [] + +# 특정 재고 상세 정보 조회 +def get_inventory_details_from_db(inventory_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + SELECT + i.*, + mp.material_code, mp.material_name, mp.material_type, mp.density, + s.name_kr as supplier_name + FROM inventory i + LEFT JOIN material_properties mp ON i.material_id = mp.id + LEFT JOIN suppliers s ON i.supplier_id = s.id + WHERE i.id = %s + """ + cursor.execute(sql, (inventory_id,)) + inventory_data = cursor.fetchone() + if not inventory_data: + return None + + return { + "inventory": inventory_data + } + except mysql.connector.Error as err: + logging.error(f"Error getting inventory details: {err}") + return None + +# 특정 자재의 총 재고량 조회 +def get_total_stock_for_material(material_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = "SELECT SUM(stock) as total_stock FROM inventory WHERE material_id = %s AND is_depleted = 0" + cursor.execute(sql, (material_id,)) + result = cursor.fetchone() + return result['total_stock'] if result and result['total_stock'] is not None else 0 + except mysql.connector.Error as err: + logging.error(f"Error getting total stock for material: {err}") + return 0 diff --git a/server/inventory/router.py b/server/inventory/router.py new file mode 100644 index 0000000..5c6adf7 --- /dev/null +++ b/server/inventory/router.py @@ -0,0 +1,144 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import List, Optional +from .db_manager import ( + add_material_property, + update_material_property, + delete_material_property, + get_all_material_properties, + get_material_property_by_id, + add_inventory_to_db, + update_inventory_in_db, + deplete_inventory_in_db, + get_all_inventory_from_db, + get_inventory_details_from_db +) +from .analysis import calculate_cost_analysis + +router = APIRouter(prefix="/inventory", tags=["inventory"]) + +# --- Pydantic Schemas --- +class MaterialPropertySchema(BaseModel): + id: Optional[int] = None + material_code: str + material_name: str + material_type: str + density: float + remarks: Optional[str] = None + +class InventorySchema(BaseModel): + id: Optional[int] = None + material_id: int + receipt_date: str # YYYY-MM-DD + receipt_quantity: float + usage_quantity: Optional[float] = 0.0 + stock: Optional[float] = None + unit_cost: float + remarks: Optional[str] = None + is_depleted: Optional[int] = 0 + supplier_id: Optional[int] = None + item_name: Optional[str] = None + item_number: Optional[str] = None + +class AnalysisInputSchema(BaseModel): + yarn_inventory_id: int + bond_inventory_id: Optional[int] = None + yarn_diameter_micron: Optional[float] = 7.0 + yarn_k: Optional[float] = 12.0 + ports: Optional[float] = 40.0 + cycle_time_hz: Optional[float] = 8.0 + cut_length_mm: Optional[float] = 6.0 + work_hours_day: Optional[float] = 16.0 + work_days_month: Optional[float] = 20.0 + num_machines: Optional[float] = 2.0 + bond_percentage: Optional[float] = 3.0 + +class CostAnalysisRequestSchema(BaseModel): + inputs: AnalysisInputSchema + targetProductionQuantity: Optional[float] = None + +# --- Material Properties APIs --- +@router.post("/materials", response_model=dict) +def create_material_property(material: MaterialPropertySchema): + result = add_material_property(material.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create material property") + return {"id": result, "status": "success"} + +@router.put("/materials/{material_id}", response_model=dict) +def update_material(material_id: int, material: MaterialPropertySchema): + data = material.dict() + data['id'] = material_id + success = update_material_property(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update material property") + return {"status": "success"} + +@router.delete("/materials/{material_id}", response_model=dict) +def delete_material(material_id: int): + success = delete_material_property(material_id) + if not success: + raise HTTPException(status_code=400, detail="Failed to delete material property. Check if it's in use.") + return {"status": "success"} + +@router.get("/materials", response_model=List[MaterialPropertySchema]) +def list_material_properties(): + return get_all_material_properties() + +@router.get("/materials/{material_id}", response_model=MaterialPropertySchema) +def get_material_property(material_id: int): + material = get_material_property_by_id(material_id) + if not material: + raise HTTPException(status_code=404, detail="Material property not found") + return material + +# --- Inventory APIs --- +@router.post("/", response_model=dict) +def create_inventory(inventory: InventorySchema): + result = add_inventory_to_db(inventory.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create inventory record") + return {"id": result, "status": "success"} + +@router.put("/{inventory_id}", response_model=dict) +def update_inventory(inventory_id: int, inventory: InventorySchema): + data = inventory.dict() + data['id'] = inventory_id + success = update_inventory_in_db(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update inventory record") + return {"status": "success"} + +@router.put("/{inventory_id}/deplete", response_model=dict) +def deplete_inventory(inventory_id: int): + success = deplete_inventory_in_db(inventory_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to deplete inventory") + return {"status": "success"} + +@router.get("/", response_model=List[dict]) +def list_inventory(): + return get_all_inventory_from_db() + +@router.get("/{inventory_id}", response_model=dict) +def get_inventory_details(inventory_id: int): + details = get_inventory_details_from_db(inventory_id) + if not details: + raise HTTPException(status_code=404, detail="Inventory details not found") + return details + +# --- Cost Analysis APIs --- +@router.post("/analysis/calculate", response_model=dict) +def cost_analysis(request_data: CostAnalysisRequestSchema): + try: + inputs_dict = request_data.inputs.dict() + # Null 문자열 파라미터 방어용 가공 + if not inputs_dict.get("bond_inventory_id"): + inputs_dict["bond_inventory_id"] = "null" + + result = calculate_cost_analysis(inputs_dict, request_data.targetProductionQuantity) + return result + except ValueError as val_err: + raise HTTPException(status_code=400, detail=str(val_err)) + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Internal calculations failed: {exc}") diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..ee5ef5b --- /dev/null +++ b/server/main.py @@ -0,0 +1,288 @@ +import logging +import sys +import os +import asyncio +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse +from contextlib import asynccontextmanager + +# --- Logging Configuration --- +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] (%(name)s) %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger("main") + +# --- Import sub-routers --- +from customer import router as customer_router +from supplier import router as supplier_router +from setting import router as setting_router +from inventory import router as inventory_router +from equipment import router as equipment_router +from plan import router as plan_router + +# --- Import MQTT lifecycle controller --- +from communication import start_mqtt_client, stop_mqtt_client + +async def monitor_timeouts_and_heartbeats(): + """ACCEPTED/RECEIVED 제어 명령 타임아웃(30초) 및 하트비트 타임아웃(15초)을 검사하는 백그라운드 태스크""" + from database.connection import get_db_connection + from communication.mqtt_client import _publish_hmi_update + import logging + loop_logger = logging.getLogger("timeout_monitor") + loop_logger.info("[TIMEOUT MONITOR] Background monitor task started.") + + while True: + try: + await asyncio.sleep(5) + with get_db_connection() as conn: + with conn.cursor() as cursor: + # 1. 30초 초과 ACCEPTED or RECEIVED 상태인 명령 검색 및 TIMEOUT 업데이트 & WebSocket 브로드캐스트 + cursor.execute(""" + SELECT command_id FROM control_commands + WHERE command_status IN ('ACCEPTED', 'RECEIVED') + AND created_at < DATE_SUB(NOW(), INTERVAL 30 SECOND) + """) + timeout_cmds = [row[0] for row in cursor.fetchall()] + if timeout_cmds: + for cmd_id in timeout_cmds: + cursor.execute(""" + UPDATE control_commands + SET command_status = 'TIMEOUT', applied_at = CURRENT_TIMESTAMP + WHERE command_id = %s + """, (cmd_id,)) + conn.commit() + loop_logger.info(f"[TIMEOUT MONITOR] Marked {len(timeout_cmds)} commands as TIMEOUT.") + + # WebSocket으로 프론트엔드에 전달하여 무한 대기 스피너 방지 + from communication.websocket_manager import ws_manager + import time + loop = getattr(ws_manager, 'loop', None) + if loop and loop.is_running(): + for cmd_id in timeout_cmds: + try: + asyncio.run_coroutine_threadsafe( + ws_manager.broadcast({ + "type": "command_ack", + "command_id": cmd_id, + "status": "TIMEOUT", + "applied_value": None, + "timestamp": int(time.time() * 1000) + }), + loop + ) + except Exception as ws_err: + loop_logger.error(f"[TIMEOUT WS BROADCAST ERROR] {ws_err}") + + # 2. 15초 초과 heartbeat 부재 시 gateway/plc/device_state를 OFFLINE으로 전환 + cursor.execute(""" + SELECT equipment_id FROM machine_status + WHERE gateway_state = 'ONLINE' + AND timestamp < DATE_SUB(NOW(), INTERVAL 15 SECOND) + """) + offline_ids = [row[0] for row in cursor.fetchall()] + if offline_ids: + for eq_id in offline_ids: + cursor.execute(""" + UPDATE machine_status + SET gateway_state = 'OFFLINE', plc_state = 'OFFLINE', device_state = 'OFFLINE', timestamp = CURRENT_TIMESTAMP + WHERE equipment_id = %s + """, (eq_id,)) + conn.commit() + loop_logger.warning(f"[TIMEOUT MONITOR] Gateway offline detected for equipment IDs: {offline_ids}. Updated statuses.") + + # HMI 웹소켓 브로드캐스트 위임 + for eq_id in offline_ids: + try: + _publish_hmi_update(eq_id) + except Exception as ws_err: + loop_logger.error(f"[TIMEOUT MONITOR WS ERROR] {ws_err}") + + except asyncio.CancelledError: + loop_logger.info("[TIMEOUT MONITOR] Stopped.") + break + except Exception as e: + loop_logger.error(f"[TIMEOUT MONITOR ERROR] {e}", exc_info=True) + +# --- Lifespan Context Manager --- +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup 이벤트: MQTT 백그라운드 구동 및 DB 기초 데이터 시딩 + logger.info("Starting up FastAPI application...") + + # 필수 설비 데이터(ID=8, 양산 1호기) 자동 생성 (DB가 비어있을 때 FK 에러 방지) + try: + from database.connection import get_db_connection + with get_db_connection() as conn: + with conn.cursor() as cursor: + # control_commands 테이블 자동 생성 + logger.info("[SEED] Ensuring control_commands table exists...") + cursor.execute(""" + CREATE TABLE IF NOT EXISTS control_commands ( + id INT AUTO_INCREMENT PRIMARY KEY, + equipment_id INT NOT NULL, + command_id VARCHAR(36) NOT NULL UNIQUE, + type VARCHAR(10) NOT NULL, + requested_value JSON NOT NULL, + applied_value JSON NULL, + command_status VARCHAR(15) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + applied_at TIMESTAMP NULL, + FOREIGN KEY (equipment_id) REFERENCES equipment(id) + ) + """) + conn.commit() + + # machine_status 테이블 컬럼 확인 및 추가 (장애 상태 분리) + cursor.execute("SHOW COLUMNS FROM machine_status") + column_rows = cursor.fetchall() + cols = [col[0] for col in column_rows] + column_types = {col[0]: str(col[1]).lower() for col in column_rows} + if "gateway_state" not in cols: + logger.info("[SEED] Adding gateway_state to machine_status table...") + cursor.execute("ALTER TABLE machine_status ADD COLUMN gateway_state VARCHAR(10) DEFAULT 'OFFLINE'") + if "plc_state" not in cols: + logger.info("[SEED] Adding plc_state to machine_status table...") + cursor.execute("ALTER TABLE machine_status ADD COLUMN plc_state VARCHAR(10) DEFAULT 'OFFLINE'") + if "sync_state" not in cols: + logger.info("[SEED] Adding sync_state to machine_status table...") + cursor.execute("ALTER TABLE machine_status ADD COLUMN sync_state VARCHAR(10) DEFAULT 'IDLE'") + # D read groups 1~4 and 6~9 are PLC REAL values and must retain decimals. + for group_id in (1, 2, 3, 4, 6, 7, 8, 9): + column_name = f"d_read_g{group_id}" + if column_types.get(column_name) != "double": + cursor.execute( + f"ALTER TABLE machine_status MODIFY COLUMN {column_name} DOUBLE DEFAULT 0" + ) + for group_id in range(4): + column_name = f"d_write_g{group_id}" + if column_types.get(column_name) != "double": + cursor.execute( + f"ALTER TABLE machine_status MODIFY COLUMN {column_name} DOUBLE DEFAULT 0" + ) + conn.commit() + + cursor.execute("SELECT id FROM equipment WHERE id = 8") + if not cursor.fetchone(): + logger.info("[SEED] Seeding default equipment with ID 8...") + cursor.execute( + "INSERT INTO equipment (id, name, status) VALUES (8, '양산 1호기 (CC_MC_TYPE_A)', 'available')" + ) + import json + initial_m_read = json.dumps({str(i): 0 for i in range(208)}) + initial_m_write = json.dumps({str(i): 0 for i in range(188)}) + cursor.execute( + "INSERT INTO machine_status (equipment_id, device_state, m_read_raw, m_write_raw) VALUES (8, 'OFFLINE', %s, %s)", + (initial_m_read, initial_m_write) + ) + conn.commit() + logger.info("[SEED] Seeding complete with explicit 0-initialized M-bit fields.") + except Exception as se: + logger.error(f"[SEED] Database seeding failed (check if database is running): {se}") + + # 비동기 이벤트 루프 캡처하여 웹소켓 매니저에 보관 (타 스레드 호출 목적) + import asyncio + from communication.websocket_manager import ws_manager + ws_manager.loop = asyncio.get_event_loop() + + start_mqtt_client() + + # 타임아웃 및 하트비트 감시 백그라운드 태스크 기동 + timeout_task = asyncio.create_task(monitor_timeouts_and_heartbeats()) + + yield + # Shutdown 이벤트: MQTT 연결 정상 종료 및 백그라운드 태스크 정리 + logger.info("Shutting down FastAPI application...") + timeout_task.cancel() + try: + await timeout_task + except asyncio.CancelledError: + pass + stop_mqtt_client() + +# --- FastAPI Initialization --- +app = FastAPI( + title="CC MC Carbon Cutting MC MES API Server", + description="카본절단 MC 설비 제어 및 MES 통합 백엔드 REST API & MQTT 서버", + version="1.0.0", + lifespan=lifespan +) + +# --- CORS Middleware --- +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 모바일 브라우저 및 웹앱 개발 편의를 위해 모든 오리진 허용 (배포 시 운영 사양에 맞춤) + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# --- Sub-routers Mapping --- +app.include_router(customer_router) +app.include_router(supplier_router) +app.include_router(setting_router) +app.include_router(inventory_router) +app.include_router(equipment_router, prefix="/api") +app.include_router(plan_router) + +@app.get("/api/status") +def read_root(): + return { + "status": "online", + "service": "ChemiFactory MQTT & REST API server", + "docs_url": "/docs" + } + +# --- WebSocket Endpoint for Real-time HMI --- +from fastapi import WebSocket, WebSocketDisconnect +from communication.websocket_manager import ws_manager + +@app.websocket("/ws/equipment") +async def websocket_endpoint(websocket: WebSocket): + await ws_manager.connect(websocket) + try: + while True: + # 클라이언트로부터 메시지 수신 대기 (연결 유지 목적) + data = await websocket.receive_text() + # 필요 시 클라이언트 메시지 핸들링 가능 (여기서는 단순 에코 또는 무시) + except WebSocketDisconnect: + ws_manager.disconnect(websocket) + except Exception as e: + logger.error(f"[WS] Connection error: {e}") + ws_manager.disconnect(websocket) + +# --- Frontend Static Files Serving --- +# server/frontend 디렉토리가 없으면 자동 생성하여 에러 방지 +frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend") +if not os.path.exists(frontend_dir): + os.makedirs(frontend_dir) + +# 캐시 제어 미들웨어: theme.js와 base.css는 항상 최신 버전을 로드하도록 설정 +@app.middleware("http") +async def cache_control_middleware(request, call_next): + response = await call_next(request) + + # 테마 및 CSS 파일은 캐시하지 않음 (개발/배포 환경에서 변경 즉시 반영) + if request.url.path.endswith((".js", ".css")): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return response + +app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend") + + +if __name__ == "__main__": + import uvicorn + # Synology 포트 포워딩 간섭 및 충돌 방지를 위해 미사용 포트인 8999 포트 사용 + # NAS 운영에서는 reload=False (Uvicorn 재로더 자식 프로세스가 포트를 점유하는 문제 방지) + uvicorn.run("main:app", host="0.0.0.0", port=8999, reload=False) +#from fastapi import FastAPI +# +#app = FastAPI() \ No newline at end of file diff --git a/server/monitoring/metrics.py b/server/monitoring/metrics.py new file mode 100644 index 0000000..88a6bd3 --- /dev/null +++ b/server/monitoring/metrics.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass, field +from datetime import datetime +import logging +from typing import List + +logger = logging.getLogger("metrics") + +@dataclass +class MessageMetrics: + topic: str + processing_time_ms: float + error: bool = False + error_msg: str = "" + timestamp: datetime = field(default_factory=datetime.now) + +message_metrics: List[MessageMetrics] = [] + +def record_metric(topic: str, processing_time_ms: float, error: bool = False, error_msg: str = ""): + metric = MessageMetrics( + topic=topic, + processing_time_ms=processing_time_ms, + error=error, + error_msg=error_msg + ) + message_metrics.append(metric) + if len(message_metrics) > 1000: + message_metrics.pop(0) + + if error: + logger.error(f"[METRIC ERROR] {topic}: {error_msg} ({processing_time_ms:.1f}ms)") + elif processing_time_ms > 100: + logger.warning(f"[METRIC SLOW] {topic}: {processing_time_ms:.1f}ms (>100ms)") + +def get_metrics_summary() -> dict: + if not message_metrics: + return {"message": "No metrics recorded yet"} + + total_count = len(message_metrics) + error_count = sum(1 for m in message_metrics if m.error) + avg_time = sum(m.processing_time_ms for m in message_metrics) / total_count + + return { + "total_messages": total_count, + "error_count": error_count, + "error_rate": error_count / total_count if total_count > 0 else 0.0, + "avg_processing_time_ms": round(avg_time, 2), + "last_update": message_metrics[-1].timestamp.isoformat() + } diff --git a/server/plan/__init__.py b/server/plan/__init__.py new file mode 100644 index 0000000..06a72a5 --- /dev/null +++ b/server/plan/__init__.py @@ -0,0 +1,13 @@ +# plan package marker +from .db_manager import ( + add_production_plan, + add_equipment_assignments_to_plan, + get_all_production_plans, + find_available_equipment, + update_plan_assignment, + delete_production_plan, + adjust_and_distribute_assignments, + update_production_plan +) +from .helper import calculate_end_date +from .router import router diff --git a/server/plan/db_manager.py b/server/plan/db_manager.py new file mode 100644 index 0000000..041a032 --- /dev/null +++ b/server/plan/db_manager.py @@ -0,0 +1,294 @@ +import json +import mysql.connector +from database.connection import get_db_connection +import logging +from datetime import datetime, timedelta, date + +# 생산 계획 추가 +def add_production_plan(plan_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + logging.info(f"DB에 추가될 최종 plan_data: {json.dumps(plan_data, indent=4, ensure_ascii=False)}") + sql = """ + INSERT INTO production_plans ( + plan_name, customer_name, customer_id, yarn_name, yarn_inventory_id, + bond_name, bond_inventory_id, requested_quantity_kg, start_date, end_date, + production_days, status, hourly_net_cost_per_machine, hourly_profit_per_machine, + plan_total_material_cost, yarn_diameter, yarn_k, ports, cycle_time, + cut_length, work_hours_day, work_days_month, num_machines, + plan_net_processing_cost, plan_company_margin, plan_processing_fee, + plan_yarn_cost, plan_bond_cost, plan_estimated_sales + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s + ) + """ + cursor.execute(sql, ( + plan_data.get('plan_name'), plan_data.get('customer_name'), plan_data.get('customer_id'), + plan_data.get('yarn_name'), plan_data.get('yarn_inventory_id'), plan_data.get('bond_name'), + plan_data.get('bond_inventory_id'), plan_data.get('requested_quantity_kg'), + plan_data.get('start_date'), plan_data.get('end_date'), plan_data.get('production_days'), + plan_data.get('status', 'Scheduled'), plan_data.get('hourly_net_cost_per_machine'), + plan_data.get('hourly_profit_per_machine'), plan_data.get('plan_total_material_cost'), + plan_data.get('yarn_diameter'), plan_data.get('yarn_k'), plan_data.get('ports'), + plan_data.get('cycle_time'), plan_data.get('cut_length'), plan_data.get('work_hours_day'), + plan_data.get('work_days_month'), plan_data.get('num_machines'), + plan_data.get('plan_net_processing_cost'), plan_data.get('plan_company_margin'), + plan_data.get('plan_processing_fee'), plan_data.get('plan_yarn_cost'), + plan_data.get('plan_bond_cost'), plan_data.get('plan_estimated_sales') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding production plan: {err}") + return None + +# 특정 생산 계획에 설비 할당 정보 추가 +def add_equipment_assignments_to_plan(plan_id, assignments): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO plan_assignments ( + plan_id, equipment_id, start_date, end_date, works_on_saturday, works_on_sunday + ) VALUES (%s, %s, %s, %s, %s, %s) + """ + assignment_data = [ + (plan_id, assign['equipment_id'], assign['start_date'], assign['end_date'], + 1 if assign.get('works_on_saturday') else 0, 1 if assign.get('works_on_sunday') else 0) + for assign in assignments + ] + cursor.executemany(sql, assignment_data) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error adding equipment assignments: {err}") + return False + +# 모든 생산 계획 목록 조회 (설비 할당 정보 포함) +def get_all_production_plans(): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM production_plans ORDER BY start_date") + plans = cursor.fetchall() + if not plans: + return [] + + sql_assignments = """ + SELECT pa.*, e.name as equipment_name + FROM plan_assignments pa + JOIN equipment e ON pa.equipment_id = e.id + """ + cursor.execute(sql_assignments) + assignments = cursor.fetchall() + + plan_map = {plan['plan_id']: plan for plan in plans} + for plan in plans: + plan['assigned_equipment'] = [] + + for assign in assignments: + if assign['plan_id'] in plan_map: + assign['works_on_saturday'] = bool(assign['works_on_saturday']) + assign['works_on_sunday'] = bool(assign['works_on_sunday']) + plan_map[assign['plan_id']]['assigned_equipment'].append(assign) + + return list(plan_map.values()) + except mysql.connector.Error as err: + logging.error(f"Error getting all production plans: {err}") + return [] + +# 특정 기간에 사용 가능한 설비 목록 검색 +def find_available_equipment(start_date_str, end_date_str, works_on_saturday, works_on_sunday): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT id, name FROM equipment") + all_equipment = cursor.fetchall() + return all_equipment + except mysql.connector.Error as err: + logging.error(f"Error finding all equipment: {err}") + return [] + +# 개별 설비 할당 정보 수정 +def update_plan_assignment(plan_id, assignment_data): + conn = None + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + conn.start_transaction() + + sql_update_assignment = """ + UPDATE plan_assignments SET + start_date = %s, + end_date = %s, + works_on_saturday = %s, + works_on_sunday = %s + WHERE assignment_id = %s + """ + cursor.execute(sql_update_assignment, ( + assignment_data.get('start_date'), + assignment_data.get('end_date'), + 1 if assignment_data.get('works_on_saturday') else 0, + 1 if assignment_data.get('works_on_sunday') else 0, + assignment_data.get('assignment_id') + )) + + cursor.execute("SELECT MIN(start_date) as min_start, MAX(end_date) as max_end FROM plan_assignments WHERE plan_id = %s", (plan_id,)) + plan_dates = cursor.fetchone() + if plan_dates and plan_dates['min_start'] and plan_dates['max_end']: + cursor.execute("UPDATE production_plans SET start_date = %s, end_date = %s WHERE plan_id = %s", + (plan_dates['min_start'], plan_dates['max_end'], plan_id)) + + conn.commit() + return True + except mysql.connector.Error as err: + if conn: + conn.rollback() + logging.error(f"Error updating plan assignment for plan_id {plan_id}: {err}") + return False + finally: + if conn and conn.is_connected(): + cursor.close() + conn.close() + +# 생산 계획 삭제 +def delete_production_plan(plan_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM plan_assignments WHERE plan_id = %s", (plan_id,)) + cursor.execute("DELETE FROM production_plans WHERE plan_id = %s", (plan_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting production plan: {err}") + return False + +# 가동일 분배 조정 +def adjust_and_distribute_assignments(plan_id, source_assignment, distribution): + conn = None + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + conn.start_transaction() + + update_source_sql = """ + UPDATE plan_assignments SET + start_date=%s, end_date=%s, works_on_saturday=%s, works_on_sunday=%s + WHERE assignment_id=%s + """ + cursor.execute(update_source_sql, ( + source_assignment['start_date'], source_assignment['end_date'], + 1 if source_assignment['works_on_saturday'] else 0, + 1 if source_assignment['works_on_sunday'] else 0, + source_assignment['assignment_id'] + )) + + for assign_id_str, days_to_add in distribution.items(): + days_to_add = int(days_to_add) + if days_to_add <= 0: + continue + + assign_id = int(assign_id_str) + cursor.execute("SELECT * FROM plan_assignments WHERE assignment_id = %s", (assign_id,)) + target = cursor.fetchone() + if not target: + raise Exception(f"Target assignment with ID {assign_id} not found.") + + current_end_date = target['end_date'] + works_sat = bool(target['works_on_saturday']) + works_sun = bool(target['works_on_sunday']) + + new_end_date = current_end_date + days_added_count = 0 + while days_added_count < days_to_add: + new_end_date += timedelta(days=1) + weekday = new_end_date.weekday() + if (weekday < 5) or (weekday == 5 and works_sat) or (weekday == 6 and works_sun): + days_added_count += 1 + + cursor.execute("UPDATE plan_assignments SET end_date = %s WHERE assignment_id = %s", (new_end_date.isoformat(), assign_id)) + + cursor.execute("SELECT MIN(start_date) as min_start, MAX(end_date) as max_end FROM plan_assignments WHERE plan_id = %s", (plan_id,)) + plan_dates = cursor.fetchone() + if plan_dates and plan_dates['min_start'] and plan_dates['max_end']: + cursor.execute("UPDATE production_plans SET start_date = %s, end_date = %s WHERE plan_id = %s", + (plan_dates['min_start'], plan_dates['max_end'], plan_id)) + + conn.commit() + return True + except Exception as err: + if conn: + conn.rollback() + logging.error(f"Error during assignment redistribution for plan_id {plan_id}: {err}") + return False + finally: + if conn and conn.is_connected(): + cursor.close() + conn.close() + +# 생산 계획 수정 +def update_production_plan(plan_id, plan_data, assigned_machine_ids): + conn = None + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + conn.start_transaction() + + cursor.execute("DELETE FROM plan_assignments WHERE plan_id = %s", (plan_id,)) + + update_sql = """ + UPDATE production_plans SET + plan_name = %s, customer_name = %s, customer_id = %s, yarn_name = %s, yarn_inventory_id = %s, + bond_name = %s, bond_inventory_id = %s, requested_quantity_kg = %s, start_date = %s, end_date = %s, + production_days = %s, status = %s, hourly_net_cost_per_machine = %s, hourly_profit_per_machine = %s, + plan_total_material_cost = %s, yarn_diameter = %s, yarn_k = %s, ports = %s, cycle_time = %s, + cut_length = %s, work_hours_day = %s, work_days_month = %s, num_machines = %s, + plan_net_processing_cost = %s, plan_company_margin = %s, plan_processing_fee = %s, + plan_yarn_cost = %s, plan_bond_cost = %s, plan_estimated_sales = %s + WHERE plan_id = %s + """ + cursor.execute(update_sql, ( + plan_data.get('plan_name'), plan_data.get('customer_name'), plan_data.get('customer_id'), + plan_data.get('yarn_name'), plan_data.get('yarn_inventory_id'), plan_data.get('bond_name'), + plan_data.get('bond_inventory_id'), plan_data.get('requested_quantity_kg'), + plan_data.get('start_date'), plan_data.get('end_date'), plan_data.get('production_days'), + plan_data.get('status', 'Scheduled'), plan_data.get('hourly_net_cost_per_machine'), + plan_data.get('hourly_profit_per_machine'), plan_data.get('plan_total_material_cost'), + plan_data.get('yarn_diameter'), plan_data.get('yarn_k'), plan_data.get('ports'), + plan_data.get('cycle_time'), plan_data.get('cut_length'), plan_data.get('work_hours_day'), + plan_data.get('work_days_month'), plan_data.get('num_machines'), + plan_data.get('plan_net_processing_cost'), plan_data.get('plan_company_margin'), + plan_data.get('plan_processing_fee'), plan_data.get('plan_yarn_cost'), + plan_data.get('plan_bond_cost'), plan_data.get('plan_estimated_sales'), + plan_id + )) + + if assigned_machine_ids: + assign_sql = """ + INSERT INTO plan_assignments ( + plan_id, equipment_id, start_date, end_date, works_on_saturday, works_on_sunday + ) VALUES (%s, %s, %s, %s, %s, %s) + """ + assignment_data = [ + ( + plan_id, eq_id, plan_data.get('start_date'), plan_data.get('end_date'), + 1 if plan_data.get('works_on_saturday') else 0, + 1 if plan_data.get('works_on_sunday') else 0 + ) for eq_id in assigned_machine_ids + ] + cursor.executemany(assign_sql, assignment_data) + + conn.commit() + return True + except Exception as err: + if conn: + conn.rollback() + logging.error(f"Error updating production plan for plan_id {plan_id}: {err}") + return False + finally: + if conn and conn.is_connected(): + cursor.close() + conn.close() diff --git a/server/plan/helper.py b/server/plan/helper.py new file mode 100644 index 0000000..65e0701 --- /dev/null +++ b/server/plan/helper.py @@ -0,0 +1,31 @@ +import math +from datetime import date, timedelta + +def calculate_end_date(start_date_str: str, work_days: float, works_on_saturday: bool, works_on_sunday: bool) -> str: + """주말(토/일) 근무 여부 플래그를 고려하여 가동 후 실제 종료 날짜(YYYY-MM-DD)를 계산합니다.""" + start_date = date.fromisoformat(start_date_str) + work_days_required = math.ceil(work_days) + + current_date = start_date + days_counted = 0 + if work_days_required <= 0: + return start_date.isoformat() + + while days_counted < work_days_required: + weekday = current_date.weekday() # 월요일 0, 일요일 6 + + is_working_day = True + if weekday == 5: # 토요일 + if not works_on_saturday: + is_working_day = False + elif weekday == 6: # 일요일 + if not works_on_sunday: + is_working_day = False + + if is_working_day: + days_counted += 1 + + if days_counted < work_days_required: + current_date += timedelta(days=1) + + return current_date.isoformat() diff --git a/server/plan/router.py b/server/plan/router.py new file mode 100644 index 0000000..d1fa829 --- /dev/null +++ b/server/plan/router.py @@ -0,0 +1,162 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Dict, Any, Optional +from .db_manager import ( + add_production_plan, + add_equipment_assignments_to_plan, + get_all_production_plans, + find_available_equipment, + update_plan_assignment, + delete_production_plan, + adjust_and_distribute_assignments, + update_production_plan +) +from .helper import calculate_end_date + +router = APIRouter(prefix="/plans", tags=["plans"]) + +# --- Pydantic Schemas --- +class PlanAssignmentSchema(BaseModel): + assignment_id: Optional[int] = None + plan_id: Optional[int] = None + equipment_id: int + start_date: str + end_date: str + works_on_saturday: bool + works_on_sunday: bool + +class PlanSchema(BaseModel): + plan_id: Optional[int] = None + plan_name: str + customer_name: Optional[str] = None + customer_id: Optional[int] = None + yarn_name: Optional[str] = None + yarn_inventory_id: int + bond_name: Optional[str] = None + bond_inventory_id: Optional[int] = None + requested_quantity_kg: float + start_date: str + end_date: Optional[str] = None + production_days: float + status: Optional[str] = "Scheduled" + + # Cost & Margin properties + hourly_net_cost_per_machine: float + hourly_profit_per_machine: float + plan_total_material_cost: float + yarn_diameter: float + yarn_k: float + ports: float + cycle_time: float + cut_length: float + work_hours_day: float + work_days_month: float + num_machines: float + plan_net_processing_cost: float + plan_company_margin: float + plan_processing_fee: float + plan_yarn_cost: float + plan_bond_cost: float + plan_estimated_sales: float + + # Machine allocations + equipment_ids: List[int] + works_on_saturday: Optional[bool] = False + works_on_sunday: Optional[bool] = False + +class AssignmentUpdatePayload(BaseModel): + assignment_id: int + start_date: str + end_date: str + works_on_saturday: bool + works_on_sunday: bool + +class RedistributionPayload(BaseModel): + source_assignment: Dict[str, Any] + distribution: Dict[str, int] + +# --- Plan APIs --- +@router.post("/", response_model=dict) +def create_production_plan(plan: PlanSchema): + plan_data = plan.dict() + assigned_machine_ids = plan.equipment_ids + start_date_str = plan.start_date + required_days = plan.production_days + + works_on_saturday = plan.works_on_saturday + works_on_sunday = plan.works_on_sunday + + if not assigned_machine_ids or not start_date_str or required_days is None: + raise HTTPException(status_code=400, detail="Missing required parameters: equipment_ids, start_date, production_days") + + # 주말 가동 플래그를 고려하여 가용 완료 날짜 계산 + end_date_str = calculate_end_date(start_date_str, required_days, works_on_saturday, works_on_sunday) + plan_data["end_date"] = end_date_str + + plan_id = add_production_plan(plan_data) + if not plan_id: + raise HTTPException(status_code=500, detail="Failed to add production plan to DB") + + # 설비 할당 정보 생성 + assignments = [ + { + 'equipment_id': eq_id, + 'start_date': start_date_str, + 'end_date': end_date_str, + 'works_on_saturday': works_on_saturday, + 'works_on_sunday': works_on_sunday + } for eq_id in assigned_machine_ids + ] + + assign_success = add_equipment_assignments_to_plan(plan_id, assignments) + if not assign_success: + raise HTTPException(status_code=500, detail="Plan was created but equipment assignments failed.") + + return {"plan_id": plan_id, "status": "success"} + +@router.put("/{plan_id}", response_model=dict) +def edit_production_plan(plan_id: int, plan: PlanSchema): + plan_data = plan.dict() + assigned_machine_ids = plan.equipment_ids + start_date_str = plan.start_date + required_days = plan.production_days + + works_on_saturday = plan.works_on_saturday + works_on_sunday = plan.works_on_sunday + + end_date_str = calculate_end_date(start_date_str, required_days, works_on_saturday, works_on_sunday) + plan_data["end_date"] = end_date_str + + success = update_production_plan(plan_id, plan_data, assigned_machine_ids) + if not success: + raise HTTPException(status_code=500, detail="Failed to update production plan") + return {"status": "success"} + +@router.delete("/{plan_id}", response_model=dict) +def delete_plan(plan_id: int): + success = delete_production_plan(plan_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete production plan") + return {"status": "success"} + +@router.get("/", response_model=List[dict]) +def list_production_plans(): + return get_all_production_plans() + +@router.get("/find-available-equipment", response_model=List[dict]) +def get_available_equipment(start_date: str, end_date: str, works_on_saturday: bool = False, works_on_sunday: bool = False): + return find_available_equipment(start_date, end_date, works_on_saturday, works_on_sunday) + +@router.put("/{plan_id}/assignments", response_model=dict) +def update_assignment(plan_id: int, payload: AssignmentUpdatePayload): + success = update_plan_assignment(plan_id, payload.dict()) + if not success: + raise HTTPException(status_code=500, detail="Failed to update assignment") + return {"status": "success"} + +@router.post("/{plan_id}/assignments/redistribute", response_model=dict) +def redistribute_assignments(plan_id: int, payload: RedistributionPayload): + success = adjust_and_distribute_assignments(plan_id, payload.source_assignment, payload.distribution) + if not success: + raise HTTPException(status_code=500, detail="Failed to redistribute assignments") + return {"status": "success"} diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..8ab20cd --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.95.0 +uvicorn[standard]>=0.20.0 +paho-mqtt>=1.6.1 +mysql-connector-python>=8.0.32 diff --git a/server/scratch/carbon_cutting_mc_db.sql b/server/scratch/carbon_cutting_mc_db.sql new file mode 100644 index 0000000..875bfb2 --- /dev/null +++ b/server/scratch/carbon_cutting_mc_db.sql @@ -0,0 +1,553 @@ +-- phpMyAdmin SQL Dump +-- version 5.2.2 +-- https://www.phpmyadmin.net/ +-- +-- 호스트: localhost +-- 생성 시간: 26-06-22 18:35 +-- 서버 버전: 10.11.11-MariaDB +-- PHP 버전: 8.2.28 + +SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; +START TRANSACTION; +SET time_zone = "+00:00"; + + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8mb4 */; + +-- +-- 데이터베이스: `carbon_cutting_mc_db` +-- + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `contacts` +-- + +CREATE TABLE `contacts` ( + `id` int(11) NOT NULL, + `customer_id` int(11) NOT NULL, + `name` varchar(100) NOT NULL, + `email` varchar(255) DEFAULT NULL, + `phone_number` varchar(50) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `position` varchar(255) DEFAULT '', + `work_location` varchar(255) DEFAULT '', + `is_deleted` tinyint(1) DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `control_target` +-- + +CREATE TABLE `control_target` ( + `equipment_id` int(11) NOT NULL, + `operation_mode_target` varchar(20) DEFAULT NULL COMMENT 'Target mode (auto, manual)', + `operation_command_target` varchar(20) DEFAULT NULL COMMENT 'Target command (start, stop, reset)', + `line_heaters_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for 20 heaters. e.g., {"heater_1": 1, "heater_2": 0}' CHECK (json_valid(`line_heaters_target`)), + `line_feeders_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for 10 feeder motors. e.g., {"line_1": 1} (0:STOP, 1:FWD,2:REV)' CHECK (json_valid(`line_feeders_target`)), + `utility_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for pumps and fans. e.g., {"pumps": {"pump_1": 1}, "fans":{"fan_1": 1}}' CHECK (json_valid(`utility_target`)), + `transfer_roller_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for transfer roller. e.g., {"speed": 120.5, "direction":1, "enabled": 1}' CHECK (json_valid(`transfer_roller_target`)), + `cutting_motor_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for cutting motor. e.g., {"speed": 300.0, "direction": 0,"enabled": 1}' CHECK (json_valid(`cutting_motor_target`)), + `system_outputs_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for buzzer, tower light. e.g., {"buzzer": 0, "tower_red":1}' CHECK (json_valid(`system_outputs_target`)), + `spare_digital_outputs_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for spare digital outputs. e.g., {"R21": 1, "R24":0}' CHECK (json_valid(`spare_digital_outputs_target`)), + `spare_pwm_outputs_target` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'Target for spare PWM outputs. e.g., {"D10": 128, "D11": 255}' CHECK (json_valid(`spare_pwm_outputs_target`)), + `last_updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Target control states set by users or apps.'; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `customers` +-- + +CREATE TABLE `customers` ( + `id` int(11) NOT NULL, + `name_kr` varchar(255) NOT NULL, + `name_en` varchar(255) DEFAULT NULL, + `business_registration_number` varchar(50) DEFAULT NULL, + `contact_number` varchar(50) DEFAULT NULL, + `address` varchar(255) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `equipment` +-- + +CREATE TABLE `equipment` ( + `id` int(11) NOT NULL, + `name` varchar(255) NOT NULL, + `yarn_bobbin_count` int(11) DEFAULT NULL, + `dryer_type` varchar(255) DEFAULT NULL, + `power_consumption` double DEFAULT NULL, + `max_cutting_speed` int(11) DEFAULT NULL, + `max_feed_speed` int(11) DEFAULT NULL, + `status` varchar(50) NOT NULL DEFAULT 'available' COMMENT '장비의 현재 상태 (available, maintenance, broken)' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `inventory` +-- + +CREATE TABLE `inventory` ( + `id` int(11) NOT NULL, + `material_id` int(11) DEFAULT NULL COMMENT 'material_properties 테이블 ID', + `item_name` varchar(255) NOT NULL, + `item_number` varchar(255) NOT NULL, + `supplier` varchar(255) NOT NULL, + `supplier_id` int(11) DEFAULT NULL, + `receipt_date` date NOT NULL, + `receipt_quantity` int(11) NOT NULL, + `usage_quantity` int(11) NOT NULL, + `stock` int(11) NOT NULL, + `unit_cost` decimal(10,2) DEFAULT NULL COMMENT '입고 시점의 단위당 원가 (원/kg)', + `remarks` text DEFAULT NULL, + `is_depleted` tinyint(1) DEFAULT 0 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `machine_status` +-- + +CREATE TABLE `machine_status` ( + `equipment_id` int(11) NOT NULL COMMENT '설비 마스터 ID', + `device_state` varchar(20) DEFAULT 'OFFLINE' COMMENT 'ONLINE / OFFLINE 상태', + `m_read_raw` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'M영역 읽기비트 전체 상태 JSON {bit_id: value}', + `m_write_raw` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'M영역 쓰기제어비트 전체 상태 JSON {bit_id: value}', + `d_read_g0` bigint(20) DEFAULT 0 COMMENT 'D영역 읽기 그룹0: 컷팅속도', + `d_read_g1` double DEFAULT 0 COMMENT 'D영역 읽기 그룹1: 이송속도', + `d_read_g2` double DEFAULT 0 COMMENT 'D영역 읽기 그룹2: 챔버온도', + `d_read_g3` double DEFAULT 0 COMMENT 'D영역 읽기 그룹3', + `d_read_g4` double DEFAULT 0 COMMENT 'D영역 읽기 그룹4', + `d_read_g5` bigint(20) DEFAULT 0 COMMENT 'D영역 읽기 그룹5', + `d_read_g6` double DEFAULT 0 COMMENT 'D영역 읽기 그룹6', + `d_read_g7` double DEFAULT 0 COMMENT 'D영역 읽기 그룹7', + `d_read_g8` double DEFAULT 0 COMMENT 'D영역 읽기 그룹8', + `d_read_g9` double DEFAULT 0 COMMENT 'D영역 읽기 그룹9', + `d_write_g0` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹0: 컷팅 타겟 속도', + `d_write_g1` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹1: 이송 타겟 속도', + `d_write_g2` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹2', + `d_write_g3` bigint(20) DEFAULT 0 COMMENT 'D영역 쓰기 제어 설정 그룹3', + `timestamp` bigint(20) NOT NULL DEFAULT 0 COMMENT '게이트웨이 전송 타임스탬프 (ms)', + `last_updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() COMMENT '서버 최종 수신 시간' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='각 설비의 14개 워드 및 M분리 최신 상태 저장 테이블'; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `material_properties` +-- + +CREATE TABLE `material_properties` ( + `id` int(11) NOT NULL, + `material_code` varchar(100) DEFAULT NULL COMMENT '자재 관리 코드', + `material_name` varchar(255) NOT NULL COMMENT '자재명', + `material_type` varchar(50) NOT NULL COMMENT '자재 타입 (Yarn, Bond 등)', + `density` decimal(10,5) DEFAULT NULL COMMENT '비중 또는 밀도 (g/cm³)', + `remarks` text DEFAULT NULL COMMENT '비고', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='원자재 물성 마스터 테이블'; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `plan_assignments` +-- + +CREATE TABLE `plan_assignments` ( + `assignment_id` int(11) NOT NULL, + `plan_id` int(11) NOT NULL, + `equipment_id` int(11) NOT NULL, + `start_date` date NOT NULL, + `end_date` date NOT NULL, + `works_on_saturday` tinyint(1) NOT NULL DEFAULT 1 COMMENT '토요일 작업 여부 (1: 작업, 0: 휴무)', + `works_on_sunday` tinyint(1) NOT NULL DEFAULT 0 COMMENT '일요일 작업 여부 (1: 작업, 0: 휴무)' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `production_plans` +-- + +CREATE TABLE `production_plans` ( + `plan_id` int(11) NOT NULL, + `plan_name` varchar(255) NOT NULL COMMENT 'Plan name (Customer + Yarn + Bond + Quantity)', + `customer_name` varchar(255) NOT NULL COMMENT 'Snapshot of customer name', + `customer_id` int(11) DEFAULT NULL COMMENT 'FK to customers table', + `yarn_name` varchar(255) NOT NULL COMMENT 'Snapshot of yarn name', + `yarn_inventory_id` int(11) DEFAULT NULL COMMENT 'FK to inventory table for yarn', + `bond_name` varchar(255) NOT NULL COMMENT 'Snapshot of bond name', + `bond_inventory_id` int(11) DEFAULT NULL COMMENT 'FK to inventory table for bond', + `requested_quantity_kg` decimal(10,2) NOT NULL COMMENT 'Requested production quantity in kg', + `start_date` date NOT NULL COMMENT 'Planned start date', + `end_date` date NOT NULL COMMENT 'Calculated end date', + `production_days` int(11) NOT NULL COMMENT 'Calculated number of days for production', + `status` varchar(50) NOT NULL DEFAULT 'Planned' COMMENT 'Status of the plan (e.g., Planned, In Progress, Completed, Cancelled)', + `yarn_diameter` float DEFAULT NULL COMMENT 'Yarn Diameter (μm)', + `yarn_k` int(11) DEFAULT NULL COMMENT 'Yarn K (number of strands)', + `ports` int(11) DEFAULT NULL COMMENT 'Number of ports used', + `cycle_time` float DEFAULT NULL COMMENT 'Cycle Time (Hz)', + `cut_length` float DEFAULT NULL COMMENT 'Cut Length (mm)', + `work_hours_day` int(11) DEFAULT NULL COMMENT 'Work hours per day', + `num_machines` int(11) DEFAULT NULL COMMENT 'Number of machines assigned', + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + `hourly_net_cost_per_machine` decimal(15,2) DEFAULT NULL COMMENT '설비 1대당 시간당 순가공비', + `hourly_profit_per_machine` decimal(15,2) DEFAULT NULL COMMENT '설비 1대당 시간당 이윤', + `plan_total_material_cost` decimal(15,2) DEFAULT NULL COMMENT '계획에 필요한 총 원자재 비용', + `plan_net_processing_cost` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 총 순가공비', + `plan_company_margin` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 총 기업 마진', + `plan_processing_fee` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 총 가공비', + `plan_yarn_cost` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 원사 매입비', + `plan_bond_cost` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 본드 매입비', + `plan_estimated_sales` decimal(15,2) DEFAULT NULL COMMENT '계획 전체의 예상 매출액', + `work_days_month` int(11) DEFAULT NULL COMMENT '분석에 사용된 월 작업일' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `purchase_activities` +-- + +CREATE TABLE `purchase_activities` ( + `id` int(11) NOT NULL, + `supplier_id` int(11) NOT NULL, + `contact_id` int(11) DEFAULT NULL, + `activity_type` varchar(100) NOT NULL, + `activity_date` date NOT NULL, + `memo` text DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `sales_activities` +-- + +CREATE TABLE `sales_activities` ( + `id` int(11) NOT NULL, + `customer_id` int(11) NOT NULL, + `contact_id` int(11) DEFAULT NULL, + `activity_date` date NOT NULL, + `activity_type` varchar(100) DEFAULT NULL, + `memo` text DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `settings` +-- + +CREATE TABLE `settings` ( + `id` int(11) NOT NULL, + `setting_group` varchar(100) NOT NULL COMMENT '설정 그룹 (예: analysis, general)', + `setting_key` varchar(100) NOT NULL COMMENT '설정 키 (예: labor_cost)', + `setting_value` text DEFAULT NULL COMMENT '설정 값', + `setting_type` varchar(50) DEFAULT 'string' COMMENT '값의 타입 힌트 (string, integer, decimal, json)', + `description` text DEFAULT NULL COMMENT '설정에 대한 설명', + `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='앱 전체 설정 관리 테이블'; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `suppliers` +-- + +CREATE TABLE `suppliers` ( + `id` int(11) NOT NULL, + `name_kr` varchar(255) NOT NULL, + `name_en` varchar(255) DEFAULT NULL, + `business_registration_number` varchar(20) DEFAULT NULL, + `contact_number` varchar(20) DEFAULT NULL, + `address` varchar(255) DEFAULT NULL, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- -------------------------------------------------------- + +-- +-- 테이블 구조 `supplier_contacts` +-- + +CREATE TABLE `supplier_contacts` ( + `id` int(11) NOT NULL, + `supplier_id` int(11) NOT NULL, + `name` varchar(255) NOT NULL, + `email` varchar(255) DEFAULT NULL, + `phone_number` varchar(20) DEFAULT NULL, + `position` varchar(100) DEFAULT NULL, + `work_location` varchar(255) DEFAULT NULL, + `is_deleted` tinyint(1) DEFAULT 0, + `created_at` timestamp NULL DEFAULT current_timestamp(), + `updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp() +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- +-- 덤프된 테이블의 인덱스 +-- + +-- +-- 테이블의 인덱스 `contacts` +-- +ALTER TABLE `contacts` + ADD PRIMARY KEY (`id`), + ADD KEY `company_id` (`customer_id`); + +-- +-- 테이블의 인덱스 `control_target` +-- +ALTER TABLE `control_target` + ADD PRIMARY KEY (`equipment_id`); + +-- +-- 테이블의 인덱스 `customers` +-- +ALTER TABLE `customers` + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `business_registration_number` (`business_registration_number`); + +-- +-- 테이블의 인덱스 `equipment` +-- +ALTER TABLE `equipment` + ADD PRIMARY KEY (`id`); + +-- +-- 테이블의 인덱스 `inventory` +-- +ALTER TABLE `inventory` + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `item_number` (`item_number`), + ADD KEY `fk_inventory_material_properties` (`material_id`), + ADD KEY `fk_inventory_supplier` (`supplier_id`); + +-- +-- 테이블의 인덱스 `machine_status` +-- +ALTER TABLE `machine_status` + ADD PRIMARY KEY (`equipment_id`); + +-- +-- 테이블의 인덱스 `material_properties` +-- +ALTER TABLE `material_properties` + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `material_name` (`material_name`); + +-- +-- 테이블의 인덱스 `plan_assignments` +-- +ALTER TABLE `plan_assignments` + ADD PRIMARY KEY (`assignment_id`), + ADD KEY `plan_id` (`plan_id`), + ADD KEY `equipment_id` (`equipment_id`); + +-- +-- 테이블의 인덱스 `production_plans` +-- +ALTER TABLE `production_plans` + ADD PRIMARY KEY (`plan_id`), + ADD KEY `fk_plan_customer` (`customer_id`), + ADD KEY `fk_plan_yarn_inventory` (`yarn_inventory_id`), + ADD KEY `fk_plan_bond_inventory` (`bond_inventory_id`); + +-- +-- 테이블의 인덱스 `purchase_activities` +-- +ALTER TABLE `purchase_activities` + ADD PRIMARY KEY (`id`), + ADD KEY `supplier_id` (`supplier_id`), + ADD KEY `contact_id` (`contact_id`); + +-- +-- 테이블의 인덱스 `sales_activities` +-- +ALTER TABLE `sales_activities` + ADD PRIMARY KEY (`id`), + ADD KEY `company_id` (`customer_id`), + ADD KEY `contact_id` (`contact_id`); + +-- +-- 테이블의 인덱스 `settings` +-- +ALTER TABLE `settings` + ADD PRIMARY KEY (`id`), + ADD UNIQUE KEY `unique_group_key` (`setting_group`,`setting_key`); + +-- +-- 테이블의 인덱스 `suppliers` +-- +ALTER TABLE `suppliers` + ADD PRIMARY KEY (`id`); + +-- +-- 테이블의 인덱스 `supplier_contacts` +-- +ALTER TABLE `supplier_contacts` + ADD PRIMARY KEY (`id`), + ADD KEY `supplier_id` (`supplier_id`); + +-- +-- 덤프된 테이블의 AUTO_INCREMENT +-- + +-- +-- 테이블의 AUTO_INCREMENT `contacts` +-- +ALTER TABLE `contacts` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `customers` +-- +ALTER TABLE `customers` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `equipment` +-- +ALTER TABLE `equipment` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `inventory` +-- +ALTER TABLE `inventory` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `material_properties` +-- +ALTER TABLE `material_properties` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `plan_assignments` +-- +ALTER TABLE `plan_assignments` + MODIFY `assignment_id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `production_plans` +-- +ALTER TABLE `production_plans` + MODIFY `plan_id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `purchase_activities` +-- +ALTER TABLE `purchase_activities` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `sales_activities` +-- +ALTER TABLE `sales_activities` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `settings` +-- +ALTER TABLE `settings` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `suppliers` +-- +ALTER TABLE `suppliers` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 테이블의 AUTO_INCREMENT `supplier_contacts` +-- +ALTER TABLE `supplier_contacts` + MODIFY `id` int(11) NOT NULL AUTO_INCREMENT; + +-- +-- 덤프된 테이블의 제약사항 +-- + +-- +-- 테이블의 제약사항 `contacts` +-- +ALTER TABLE `contacts` + ADD CONSTRAINT `contacts_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE; + +-- +-- 테이블의 제약사항 `control_target` +-- +ALTER TABLE `control_target` + ADD CONSTRAINT `fk_control_target_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE; + +-- +-- 테이블의 제약사항 `inventory` +-- +ALTER TABLE `inventory` + ADD CONSTRAINT `fk_inventory_material_properties` FOREIGN KEY (`material_id`) REFERENCES `material_properties` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + ADD CONSTRAINT `fk_inventory_supplier` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers` (`id`); + +-- +-- 테이블의 제약사항 `machine_status` +-- +ALTER TABLE `machine_status` + ADD CONSTRAINT `fk_machine_status_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE; + +-- +-- 테이블의 제약사항 `plan_assignments` +-- +ALTER TABLE `plan_assignments` + ADD CONSTRAINT `fk_assignment_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE, + ADD CONSTRAINT `fk_assignment_plan` FOREIGN KEY (`plan_id`) REFERENCES `production_plans` (`plan_id`) ON DELETE CASCADE; + +-- +-- 테이블의 제약사항 `production_plans` +-- +ALTER TABLE `production_plans` + ADD CONSTRAINT `fk_plan_bond_inventory` FOREIGN KEY (`bond_inventory_id`) REFERENCES `inventory` (`id`) ON DELETE SET NULL, + ADD CONSTRAINT `fk_plan_customer` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE SET NULL, + ADD CONSTRAINT `fk_plan_yarn_inventory` FOREIGN KEY (`yarn_inventory_id`) REFERENCES `inventory` (`id`) ON DELETE SET NULL; + +-- +-- 테이블의 제약사항 `purchase_activities` +-- +ALTER TABLE `purchase_activities` + ADD CONSTRAINT `purchase_activities_ibfk_1` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers` (`id`) ON DELETE CASCADE, + ADD CONSTRAINT `purchase_activities_ibfk_2` FOREIGN KEY (`contact_id`) REFERENCES `supplier_contacts` (`id`) ON DELETE SET NULL; + +-- +-- 테이블의 제약사항 `sales_activities` +-- +ALTER TABLE `sales_activities` + ADD CONSTRAINT `sa_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE, + ADD CONSTRAINT `sa_ibfk_2` FOREIGN KEY (`contact_id`) REFERENCES `contacts` (`id`) ON DELETE SET NULL; + +-- +-- 테이블의 제약사항 `supplier_contacts` +-- +ALTER TABLE `supplier_contacts` + ADD CONSTRAINT `supplier_contacts_ibfk_1` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers` (`id`) ON DELETE CASCADE; +COMMIT; + +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/server/scratch/check_db_status.py b/server/scratch/check_db_status.py new file mode 100644 index 0000000..07ab04b --- /dev/null +++ b/server/scratch/check_db_status.py @@ -0,0 +1,46 @@ +import sys +import os + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database.connection import get_db_connection + +def check(): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + # Check tables + cursor.execute("SHOW TABLES") + tables = cursor.fetchall() + print("Tables in database:", [list(t.values())[0] for t in tables]) + + # Check equipment table structure and data + cursor.execute("DESCRIBE equipment") + cols = cursor.fetchall() + print("\nEquipment columns:") + for c in cols: + print(f" {c['Field']}: {c['Type']}") + + cursor.execute("SELECT * FROM equipment") + eqs = cursor.fetchall() + print(f"\nEquipment rows ({len(eqs)}):") + for eq in eqs: + print(" ", eq) + + # Check machine_status table structure + cursor.execute("DESCRIBE machine_status") + cols = cursor.fetchall() + print("\nmachine_status columns:") + for c in cols: + print(f" {c['Field']}: {c['Type']}") + + cursor.execute("SELECT * FROM machine_status") + statuses = cursor.fetchall() + print(f"\nmachine_status rows ({len(statuses)}):") + for s in statuses: + print(" ", s) + except Exception as e: + print("Error during check:", e) + +if __name__ == "__main__": + check() diff --git a/server/scratch/check_status.py b/server/scratch/check_status.py new file mode 100644 index 0000000..6478573 --- /dev/null +++ b/server/scratch/check_status.py @@ -0,0 +1,49 @@ +import mysql.connector +import json +from datetime import datetime + +DB_HOST = "localhost" +DB_PORT = 3306 +DB_USER = "ctnt_root" +DB_PASSWORD = "Umsang6595!!" +DB_NAME = "carbon_cutting_mc_db" + +def main(): + try: + conn = mysql.connector.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD, + database=DB_NAME + ) + cur = conn.cursor(dictionary=True) + cur.execute("SELECT m_read_raw, device_state, timestamp FROM machine_status WHERE equipment_id = 8") + row = cur.fetchone() + + print("\n================ [DB REAL-TIME STATUS] ================") + if row: + print(f"Device State : {row['device_state']}") + print(f"Last Update : {row['timestamp']}") + + m_read_raw_str = row['m_read_raw'] + print(f"Raw String : {m_read_raw_str}") + + if m_read_raw_str: + m_read = json.loads(m_read_raw_str) + active_bits = [int(k) for k, v in m_read.items() if int(v) == 1] + active_bits.sort() + print(f"Active Bits (val=1): {active_bits}") + else: + print("Active Bits : No Data") + else: + print("No status row found for equipment_id = 8") + print("========================================================\n") + + cur.close() + conn.close() + except Exception as e: + print(f"DB Connection or Query failed: {e}") + +if __name__ == "__main__": + main() diff --git a/server/scratch/find_db_settings.py b/server/scratch/find_db_settings.py new file mode 100644 index 0000000..695c6d5 --- /dev/null +++ b/server/scratch/find_db_settings.py @@ -0,0 +1,21 @@ +import os + +def search_files(directory): + for root, dirs, files in os.walk(directory): + if "venv" in root or "__pycache__" in root or ".git" in root or ".gemini" in root: + continue + for file in files: + if file.endswith((".py", ".json", ".ini", ".conf", ".cfg", ".yml", ".yaml")): + path = os.path.join(root, file) + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + if "mysql" in content.lower() or "db_host" in content.lower() or "host =" in content.lower(): + print(f"Found in {path}:") + for line in content.splitlines(): + if any(x in line.lower() for x in ["host", "port", "user", "password", "db"]): + print(f" {line.strip()}") + except Exception as e: + pass + +search_files("D:\\04_CTNT\\01_카본절단MC(CC)\\CC0324_양산_V0\\02_전장제어\\PLC_Communication_MQTT\\step5_modify") diff --git a/server/scratch/run_migration.py b/server/scratch/run_migration.py new file mode 100644 index 0000000..12789ce --- /dev/null +++ b/server/scratch/run_migration.py @@ -0,0 +1,46 @@ +import sys +import os + +# server 디렉토리를 path에 추가하여 database 패키지 임포트 허용 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from database.connection import get_db_connection + +def run_migration(): + sql_script = """ + SET FOREIGN_KEY_CHECKS = 0; + DROP TABLE IF EXISTS `machine_status`; + CREATE TABLE `machine_status` ( + `equipment_id` int(11) NOT NULL, + `device_state` varchar(20) DEFAULT 'OFFLINE', + `m_area_raw` longtext DEFAULT NULL, + `cutting_motor_value` float DEFAULT 0.0, + `transfer_motor_value` float DEFAULT 0.0, + `chamber_temperature` float DEFAULT 0.0, + `d_area_raw` longtext DEFAULT NULL, + `timestamp` bigint(20) NOT NULL DEFAULT 0, + `last_updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`equipment_id`), + CONSTRAINT `fk_machine_status_equipment` FOREIGN KEY (`equipment_id`) REFERENCES `equipment` (`id`) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + SET FOREIGN_KEY_CHECKS = 1; + """ + + print("[MIGRATION] Connecting to MariaDB...") + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + # 다중 쿼리 실행을 위해 세미콜론 기준으로 분할하여 실행 + queries = sql_script.split(';') + for query in queries: + clean_query = query.strip() + if clean_query: + print(f"[SQL] Executing: {clean_query[:60]}...") + cursor.execute(clean_query) + conn.commit() + print("[MIGRATION] Successfully updated machine_status schema in MariaDB!") + except Exception as e: + print(f"[ERROR] Migration failed: {e}") + +if __name__ == "__main__": + run_migration() diff --git a/server/scratch/test_db_conn.py b/server/scratch/test_db_conn.py new file mode 100644 index 0000000..c6ceede --- /dev/null +++ b/server/scratch/test_db_conn.py @@ -0,0 +1,21 @@ +import mysql.connector + +hosts = ["localhost", "dsm.chemifactory.com"] +ports = [3306, 3307] + +for host in hosts: + for port in ports: + print(f"Trying {host}:{port}...") + try: + conn = mysql.connector.connect( + host=host, + port=port, + user="ctnt_root", + password="Umsang6595!!", + database="carbon_cutting_mc_db", + connection_timeout=3 + ) + print(f"SUCCESS: Connected to {host}:{port}!") + conn.close() + except Exception as e: + print(f"FAILED {host}:{port}: {e}") diff --git a/server/setting/__init__.py b/server/setting/__init__.py new file mode 100644 index 0000000..a122d30 --- /dev/null +++ b/server/setting/__init__.py @@ -0,0 +1,7 @@ +# setting package marker +from .db_manager import ( + get_all_settings, + get_setting, + update_settings +) +from .router import router diff --git a/server/setting/db_manager.py b/server/setting/db_manager.py new file mode 100644 index 0000000..5a37653 --- /dev/null +++ b/server/setting/db_manager.py @@ -0,0 +1,75 @@ +import mysql.connector +from database.connection import get_db_connection +import logging + +# 모든 설정 조회 +def get_all_settings(): + settings = [] + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT `id`, `setting_group`, `setting_key`, `setting_value`, `setting_type`, `description` FROM settings") + results = cursor.fetchall() + for row in results: + settings.append({ + "id": row['id'], + "setting_group": row['setting_group'], + "setting_key": row['setting_key'], + "setting_value": row['setting_value'], + "setting_type": row['setting_type'], + "description": row['description'] + }) + return settings + except mysql.connector.Error as err: + logging.error(f"Error getting all settings: {err}") + return [] + +# 특정 설정 조회 +def get_setting(key): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT `setting_value`, `setting_type` FROM settings WHERE `setting_key` = %s", (key,)) + result = cursor.fetchone() + if result: + value = result['setting_value'] + value_type = result['setting_type'] + if value_type == 'integer': + return int(value) + elif value_type == 'decimal': + return float(value) + elif value_type == 'boolean': + return value.lower() in ('true', '1', 't') + else: + return value + return None + except mysql.connector.Error as err: + logging.error(f"Error getting setting for key {key}: {err}") + return None + +# 설정 업데이트 +def update_settings(settings_data): + try: + with get_db_connection() as conn: + with conn.cursor() as cursor: + sql = """ + UPDATE settings + SET `setting_value` = %s + WHERE `setting_key` = %s + """ + + values_to_update = [] + for setting in settings_data: + key = setting.get('setting_key') + value = setting.get('setting_value') + + if key is not None and value is not None: + values_to_update.append((str(value), key)) + + if values_to_update: + cursor.executemany(sql, values_to_update) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating settings: {err}") + return False diff --git a/server/setting/router.py b/server/setting/router.py new file mode 100644 index 0000000..34f4f92 --- /dev/null +++ b/server/setting/router.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +from .db_manager import ( + get_all_settings, + update_settings +) + +router = APIRouter(prefix="/settings", tags=["settings"]) + +# --- Pydantic Schemas --- +class SettingSchema(BaseModel): + id: Optional[int] = None + setting_group: str + setting_key: str + setting_value: str + setting_type: str + description: Optional[str] = None + +class SettingUpdateSchema(BaseModel): + setting_key: str + setting_value: str + +# --- Settings APIs --- +@router.get("/", response_model=List[SettingSchema]) +def list_settings(): + return get_all_settings() + +@router.put("/", response_model=dict) +def update_multiple_settings(settings: List[SettingUpdateSchema]): + # Pydantic 모델 리스트를 일반 dict 리스트로 변환하여 전달 + data = [s.dict() for s in settings] + success = update_settings(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update settings") + return {"status": "success"} diff --git a/server/source/Thumbs.db b/server/source/Thumbs.db new file mode 100644 index 0000000..5a8595e Binary files /dev/null and b/server/source/Thumbs.db differ diff --git a/server/source/company_name.png b/server/source/company_name.png new file mode 100644 index 0000000..954abe2 Binary files /dev/null and b/server/source/company_name.png differ diff --git a/server/source/logo.png b/server/source/logo.png new file mode 100644 index 0000000..e00d54d Binary files /dev/null and b/server/source/logo.png differ diff --git a/server/source/logo_and_company_name.png b/server/source/logo_and_company_name.png new file mode 100644 index 0000000..38b1ae7 Binary files /dev/null and b/server/source/logo_and_company_name.png differ diff --git a/server/source/logo_and_company_name_slogan.png b/server/source/logo_and_company_name_slogan.png new file mode 100644 index 0000000..248770b Binary files /dev/null and b/server/source/logo_and_company_name_slogan.png differ diff --git a/server/start_logger.sh b/server/start_logger.sh new file mode 100644 index 0000000..825578a --- /dev/null +++ b/server/start_logger.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +APP_DIR="/volume1/web/ChemiFactoy_MQTT_DB_Logger" +PYTHON="/volume1/web/venv/bin/python3" +PID_FILE="$APP_DIR/server.pid" + +if [ -f "$PID_FILE" ]; then + OLD_PID=$(cat "$PID_FILE") + if kill -0 "$OLD_PID" 2>/dev/null; then + kill "$OLD_PID" + sleep 5 + fi +fi + +# 로그를 server.log 파일에 기록하여 장애 발생 시 확인 가능하도록 함 +nohup "$PYTHON" "$APP_DIR/main.py" \ + > "$APP_DIR/server.log" 2>&1 & + +NEW_PID=$! +echo "$NEW_PID" > "$PID_FILE" + +sleep 2 + +if kill -0 "$NEW_PID" 2>/dev/null; then + echo "Server started. PID=$NEW_PID" +else + echo "Server startup failed." + exit 1 +fi diff --git a/server/supplier/__init__.py b/server/supplier/__init__.py new file mode 100644 index 0000000..2a3172d --- /dev/null +++ b/server/supplier/__init__.py @@ -0,0 +1,17 @@ +# supplier package marker +from .db_manager import ( + add_supplier_to_db, + update_supplier_in_db, + delete_supplier_from_db, + get_all_suppliers_from_db, + get_supplier_details_from_db, + add_supplier_contact_to_db, + update_supplier_contact_in_db, + delete_supplier_contact_from_db, + add_purchase_activity_to_db, + update_purchase_activity_in_db, + delete_purchase_activity_from_db, + get_supplier_id_from_contact_id, + get_supplier_id_from_purchase_activity_id +) +from .router import router diff --git a/server/supplier/db_manager.py b/server/supplier/db_manager.py new file mode 100644 index 0000000..3a86aa6 --- /dev/null +++ b/server/supplier/db_manager.py @@ -0,0 +1,243 @@ +import mysql.connector +from database.connection import get_db_connection +import logging + +# 공급사 추가 +def add_supplier_to_db(supplier_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO suppliers (name_kr, name_en, business_registration_number, contact_number, address) + VALUES (%s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + supplier_data.get('name_kr'), + supplier_data.get('name_en'), + supplier_data.get('business_registration_number'), + supplier_data.get('contact_number'), + supplier_data.get('address') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding supplier: {err}") + return None + +# 공급사 수정 +def update_supplier_in_db(supplier_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE suppliers SET + name_kr = %s, + name_en = %s, + business_registration_number = %s, + contact_number = %s, + address = %s + WHERE id = %s + """ + cursor.execute(sql, ( + supplier_data.get('name_kr'), + supplier_data.get('name_en'), + supplier_data.get('business_registration_number'), + supplier_data.get('contact_number'), + supplier_data.get('address'), + supplier_data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating supplier: {err}") + return False + +# 공급사 삭제 +def delete_supplier_from_db(supplier_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM purchase_activities WHERE supplier_id = %s", (supplier_id,)) + cursor.execute("DELETE FROM supplier_contacts WHERE supplier_id = %s", (supplier_id,)) + cursor.execute("DELETE FROM suppliers WHERE id = %s", (supplier_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting supplier: {err}") + return False + +# 모든 공급사 목록 조회 +def get_all_suppliers_from_db(): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT id, name_kr, name_en, business_registration_number, contact_number, address FROM suppliers") + return cursor.fetchall() + except mysql.connector.Error as err: + logging.error(f"Error getting all suppliers: {err}") + return [] + +# 특정 공급사 상세 정보 조회 (담당자, 구매활동 포함) +def get_supplier_details_from_db(supplier_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT * FROM suppliers WHERE id = %s", (supplier_id,)) + supplier_data = cursor.fetchone() + if not supplier_data: + return None + + cursor.execute("SELECT * FROM supplier_contacts WHERE supplier_id = %s", (supplier_id,)) + contacts_data = cursor.fetchall() + + cursor.execute("SELECT * FROM purchase_activities WHERE supplier_id = %s", (supplier_id,)) + purchase_activities_data = cursor.fetchall() + + return { + "supplier": supplier_data, + "contacts": contacts_data, + "purchaseActivities": purchase_activities_data + } + except mysql.connector.Error as err: + logging.error(f"Error getting supplier details: {err}") + return None + +# 공급사 담당자 추가 +def add_supplier_contact_to_db(contact_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO supplier_contacts (supplier_id, name, email, phone_number, position, work_location) + VALUES (%s, %s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + contact_data.get('supplier_id'), + contact_data.get('name'), + contact_data.get('email'), + contact_data.get('phone_number'), + contact_data.get('position'), + contact_data.get('work_location') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding supplier contact: {err}") + return None + +# 공급사 담당자 수정 +def update_supplier_contact_in_db(contact_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE supplier_contacts SET + name = %s, email = %s, phone_number = %s, position = %s, work_location = %s + WHERE id = %s + """ + cursor.execute(sql, ( + contact_data.get('name'), + contact_data.get('email'), + contact_data.get('phone_number'), + contact_data.get('position'), + contact_data.get('work_location'), + contact_data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating supplier contact: {err}") + return False + +# 공급사 담당자 삭제 +def delete_supplier_contact_from_db(contact_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM supplier_contacts WHERE id = %s", (contact_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting supplier contact: {err}") + return False + +# 공급사 담당자 ID로 공급사 ID 조회 +def get_supplier_id_from_contact_id(contact_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT supplier_id FROM supplier_contacts WHERE id = %s", (contact_id,)) + result = cursor.fetchone() + return result['supplier_id'] if result else None + except mysql.connector.Error as err: + logging.error(f"Error getting supplier ID from contact: {err}") + return None + +# 구매 활동 추가 +def add_purchase_activity_to_db(activity_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + INSERT INTO purchase_activities (supplier_id, contact_id, activity_date, activity_type, memo) + VALUES (%s, %s, %s, %s, %s) + """ + cursor.execute(sql, ( + activity_data.get('supplier_id'), + activity_data.get('contact_id'), + activity_data.get('activity_date'), + activity_data.get('activity_type'), + activity_data.get('memo') + )) + conn.commit() + return cursor.lastrowid + except mysql.connector.Error as err: + logging.error(f"Error adding purchase activity: {err}") + return None + +# 구매 활동 수정 +def update_purchase_activity_in_db(activity_data): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + sql = """ + UPDATE purchase_activities SET + contact_id = %s, activity_date = %s, activity_type = %s, memo = %s + WHERE id = %s + """ + cursor.execute(sql, ( + activity_data.get('contact_id'), + activity_data.get('activity_date'), + activity_data.get('activity_type'), + activity_data.get('memo'), + activity_data.get('id') + )) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error updating purchase activity: {err}") + return False + +# 구매 활동 삭제 +def delete_purchase_activity_from_db(activity_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("DELETE FROM purchase_activities WHERE id = %s", (activity_id,)) + conn.commit() + return True + except mysql.connector.Error as err: + logging.error(f"Error deleting purchase activity: {err}") + return False + +# 구매 활동 ID로 공급사 ID 조회 +def get_supplier_id_from_purchase_activity_id(activity_id): + try: + with get_db_connection() as conn: + with conn.cursor(dictionary=True) as cursor: + cursor.execute("SELECT supplier_id FROM purchase_activities WHERE id = %s", (activity_id,)) + result = cursor.fetchone() + return result['supplier_id'] if result else None + except mysql.connector.Error as err: + logging.error(f"Error getting supplier ID from purchase activity: {err}") + return None diff --git a/server/supplier/router.py b/server/supplier/router.py new file mode 100644 index 0000000..15eb7cf --- /dev/null +++ b/server/supplier/router.py @@ -0,0 +1,127 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +from .db_manager import ( + add_supplier_to_db, + update_supplier_in_db, + delete_supplier_from_db, + get_all_suppliers_from_db, + get_supplier_details_from_db, + add_supplier_contact_to_db, + update_supplier_contact_in_db, + delete_supplier_contact_from_db, + add_purchase_activity_to_db, + update_purchase_activity_in_db, + delete_purchase_activity_from_db +) + +router = APIRouter(prefix="/suppliers", tags=["suppliers"]) + +# --- Pydantic Schemas --- +class SupplierSchema(BaseModel): + id: Optional[int] = None + name_kr: str + name_en: Optional[str] = None + business_registration_number: Optional[str] = None + contact_number: Optional[str] = None + address: Optional[str] = None + +class SupplierContactSchema(BaseModel): + id: Optional[int] = None + supplier_id: int + name: str + email: Optional[str] = None + phone_number: Optional[str] = None + position: Optional[str] = None + work_location: Optional[str] = None + +class PurchaseActivitySchema(BaseModel): + id: Optional[int] = None + supplier_id: int + contact_id: Optional[int] = None + activity_date: str # YYYY-MM-DD + activity_type: str + memo: Optional[str] = None + +# --- Suppliers APIs --- +@router.post("/", response_model=dict) +def create_supplier(supplier: SupplierSchema): + result = add_supplier_to_db(supplier.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create supplier") + return {"id": result, "status": "success"} + +@router.put("/{supplier_id}", response_model=dict) +def update_supplier(supplier_id: int, supplier: SupplierSchema): + data = supplier.dict() + data['id'] = supplier_id + success = update_supplier_in_db(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update supplier") + return {"status": "success"} + +@router.delete("/{supplier_id}", response_model=dict) +def delete_supplier(supplier_id: int): + success = delete_supplier_from_db(supplier_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete supplier") + return {"status": "success"} + +@router.get("/", response_model=List[SupplierSchema]) +def list_suppliers(): + return get_all_suppliers_from_db() + +@router.get("/{supplier_id}", response_model=dict) +def get_supplier_details(supplier_id: int): + details = get_supplier_details_from_db(supplier_id) + if not details: + raise HTTPException(status_code=404, detail="Supplier not found") + return details + +# --- Contacts APIs --- +@router.post("/contacts", response_model=dict) +def create_contact(contact: SupplierContactSchema): + result = add_supplier_contact_to_db(contact.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create contact") + return {"id": result, "status": "success"} + +@router.put("/contacts/{contact_id}", response_model=dict) +def update_contact(contact_id: int, contact: SupplierContactSchema): + data = contact.dict() + data['id'] = contact_id + success = update_supplier_contact_in_db(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update contact") + return {"status": "success"} + +@router.delete("/contacts/{contact_id}", response_model=dict) +def delete_contact(contact_id: int): + success = delete_supplier_contact_from_db(contact_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete contact") + return {"status": "success"} + +# --- Purchase Activities APIs --- +@router.post("/activities", response_model=dict) +def create_purchase_activity(activity: PurchaseActivitySchema): + result = add_purchase_activity_to_db(activity.dict()) + if result is None: + raise HTTPException(status_code=500, detail="Failed to create purchase activity") + return {"id": result, "status": "success"} + +@router.put("/activities/{activity_id}", response_model=dict) +def update_purchase_activity(activity_id: int, activity: PurchaseActivitySchema): + data = activity.dict() + data['id'] = activity_id + success = update_purchase_activity_in_db(data) + if not success: + raise HTTPException(status_code=500, detail="Failed to update purchase activity") + return {"status": "success"} + +@router.delete("/activities/{activity_id}", response_model=dict) +def delete_purchase_activity(activity_id: int): + success = delete_purchase_activity_from_db(activity_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete purchase activity") + return {"status": "success"} diff --git a/server/venv/Lib/site-packages/_mysql_connector.cp313-win_amd64.pyd b/server/venv/Lib/site-packages/_mysql_connector.cp313-win_amd64.pyd new file mode 100644 index 0000000..311f718 Binary files /dev/null and b/server/venv/Lib/site-packages/_mysql_connector.cp313-win_amd64.pyd differ diff --git a/server/venv/Lib/site-packages/_yaml/__init__.py b/server/venv/Lib/site-packages/_yaml/__init__.py new file mode 100644 index 0000000..7baa8c4 --- /dev/null +++ b/server/venv/Lib/site-packages/_yaml/__init__.py @@ -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__ = '' diff --git a/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/INSTALLER b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/METADATA b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/METADATA new file mode 100644 index 0000000..9bf7a9e --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/METADATA @@ -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?= +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`. + + + Test + + + Coverage + + + Package version + + + Supported Python versions + + +## 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. diff --git a/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/RECORD b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/RECORD new file mode 100644 index 0000000..e040ae2 --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/RECORD @@ -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-313.pyc,, +annotated_doc/__pycache__/main.cpython-313.pyc,, +annotated_doc/main.py,sha256=5Zfvxv80SwwLqpRW73AZyZyiM4bWma9QWRbp_cgD20s,1075 +annotated_doc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 diff --git a/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/WHEEL b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/WHEEL new file mode 100644 index 0000000..045c8ac --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: pdm-backend (2.4.5) +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/entry_points.txt b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/entry_points.txt new file mode 100644 index 0000000..c3ad472 --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/entry_points.txt @@ -0,0 +1,4 @@ +[console_scripts] + +[gui_scripts] + diff --git a/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/licenses/LICENSE b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/licenses/LICENSE new file mode 100644 index 0000000..7a25446 --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc-0.0.4.dist-info/licenses/LICENSE @@ -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. diff --git a/server/venv/Lib/site-packages/annotated_doc/__init__.py b/server/venv/Lib/site-packages/annotated_doc/__init__.py new file mode 100644 index 0000000..a0152a7 --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc/__init__.py @@ -0,0 +1,3 @@ +from .main import Doc as Doc + +__version__ = "0.0.4" diff --git a/server/venv/Lib/site-packages/annotated_doc/main.py b/server/venv/Lib/site-packages/annotated_doc/main.py new file mode 100644 index 0000000..7063c59 --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_doc/main.py @@ -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 diff --git a/server/venv/Lib/site-packages/annotated_doc/py.typed b/server/venv/Lib/site-packages/annotated_doc/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/INSTALLER b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/METADATA b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/METADATA new file mode 100644 index 0000000..3ac05cf --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/METADATA @@ -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 , Zac Hatfield-Dodds +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 + +[![CI](https://github.com/annotated-types/annotated-types/workflows/CI/badge.svg?event=push)](https://github.com/annotated-types/annotated-types/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) +[![pypi](https://img.shields.io/pypi/v/annotated-types.svg)](https://pypi.python.org/pypi/annotated-types) +[![versions](https://img.shields.io/pypi/pyversions/annotated-types.svg)](https://github.com/annotated-types/annotated-types) +[![license](https://img.shields.io/github/license/annotated-types/annotated-types.svg)](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! diff --git a/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/RECORD b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/RECORD new file mode 100644 index 0000000..01b9e66 --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/RECORD @@ -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-313.pyc,, +annotated_types/__pycache__/test_cases.cpython-313.pyc,, +annotated_types/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +annotated_types/test_cases.py,sha256=zHFX6EpcMbGJ8FzBYDbO56bPwx_DYIVSKbZM-4B3_lg,6421 diff --git a/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/WHEEL b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/WHEEL new file mode 100644 index 0000000..516596c --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.24.2 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/licenses/LICENSE b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000..d99323a --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_types-0.7.0.dist-info/licenses/LICENSE @@ -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. diff --git a/server/venv/Lib/site-packages/annotated_types/__init__.py b/server/venv/Lib/site-packages/annotated_types/__init__.py new file mode 100644 index 0000000..74e0dee --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_types/__init__.py @@ -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`` : + - ``astropy.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__", "") == "": + 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) diff --git a/server/venv/Lib/site-packages/annotated_types/py.typed b/server/venv/Lib/site-packages/annotated_types/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/annotated_types/test_cases.py b/server/venv/Lib/site-packages/annotated_types/test_cases.py new file mode 100644 index 0000000..d9164d6 --- /dev/null +++ b/server/venv/Lib/site-packages/annotated_types/test_cases.py @@ -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]) diff --git a/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/INSTALLER b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/METADATA b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/METADATA new file mode 100644 index 0000000..7840c30 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/METADATA @@ -0,0 +1,107 @@ +Metadata-Version: 2.4 +Name: anyio +Version: 4.14.0 +Summary: High-level concurrency and networking framework on top of asyncio or Trio +Author-email: Alex Grönholm +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 `_. + +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 diff --git a/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/RECORD b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/RECORD new file mode 100644 index 0000000..a691626 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/RECORD @@ -0,0 +1,94 @@ +anyio-4.14.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +anyio-4.14.0.dist-info/METADATA,sha256=ZozV8u8z7Ya7tmsk9Uetxz7-QFNpHH2nVBLy041h6xo,4645 +anyio-4.14.0.dist-info/RECORD,, +anyio-4.14.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91 +anyio-4.14.0.dist-info/entry_points.txt,sha256=_d6Yu6uiaZmNe0CydowirE9Cmg7zUL2g08tQpoS3Qvc,39 +anyio-4.14.0.dist-info/licenses/LICENSE,sha256=U2GsncWPLvX9LpsJxoKXwX8ElQkJu8gCO9uC6s8iwrA,1081 +anyio-4.14.0.dist-info/top_level.txt,sha256=QglSMiWX8_5dpoVAEIHdEYzvqFMdSYWmCj6tYw2ITkQ,6 +anyio/__init__.py,sha256=HitUIfzvAojSeaHVmJ9rFn8k_yI63G6s_jUL2QChf4U,6405 +anyio/__pycache__/__init__.cpython-313.pyc,, +anyio/__pycache__/from_thread.cpython-313.pyc,, +anyio/__pycache__/functools.cpython-313.pyc,, +anyio/__pycache__/itertools.cpython-313.pyc,, +anyio/__pycache__/lowlevel.cpython-313.pyc,, +anyio/__pycache__/pytest_plugin.cpython-313.pyc,, +anyio/__pycache__/to_interpreter.cpython-313.pyc,, +anyio/__pycache__/to_process.cpython-313.pyc,, +anyio/__pycache__/to_thread.cpython-313.pyc,, +anyio/_backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +anyio/_backends/__pycache__/__init__.cpython-313.pyc,, +anyio/_backends/__pycache__/_asyncio.cpython-313.pyc,, +anyio/_backends/__pycache__/_trio.cpython-313.pyc,, +anyio/_backends/_asyncio.py,sha256=kX_Zv3F_SnWqWqSLmfPsDFxvsJLaATSeivzg6Gho3Rs,101971 +anyio/_backends/_trio.py,sha256=vR0ZgxVnOo4AHhHcHVG0worMc-3ZNpAZ6Vxh0m0ZZC0,45189 +anyio/_core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +anyio/_core/__pycache__/__init__.cpython-313.pyc,, +anyio/_core/__pycache__/_asyncio_selector_thread.cpython-313.pyc,, +anyio/_core/__pycache__/_contextmanagers.cpython-313.pyc,, +anyio/_core/__pycache__/_eventloop.cpython-313.pyc,, +anyio/_core/__pycache__/_exceptions.cpython-313.pyc,, +anyio/_core/__pycache__/_fileio.cpython-313.pyc,, +anyio/_core/__pycache__/_resources.cpython-313.pyc,, +anyio/_core/__pycache__/_signals.cpython-313.pyc,, +anyio/_core/__pycache__/_sockets.cpython-313.pyc,, +anyio/_core/__pycache__/_streams.cpython-313.pyc,, +anyio/_core/__pycache__/_subprocesses.cpython-313.pyc,, +anyio/_core/__pycache__/_synchronization.cpython-313.pyc,, +anyio/_core/__pycache__/_tasks.cpython-313.pyc,, +anyio/_core/__pycache__/_tempfile.cpython-313.pyc,, +anyio/_core/__pycache__/_testing.cpython-313.pyc,, +anyio/_core/__pycache__/_typedattr.cpython-313.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=U14eUp9tqcP95OcCG7s3A1MZU9WLw84lOS4YB-H795s,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-313.pyc,, +anyio/abc/__pycache__/_eventloop.cpython-313.pyc,, +anyio/abc/__pycache__/_resources.cpython-313.pyc,, +anyio/abc/__pycache__/_sockets.cpython-313.pyc,, +anyio/abc/__pycache__/_streams.cpython-313.pyc,, +anyio/abc/__pycache__/_subprocesses.cpython-313.pyc,, +anyio/abc/__pycache__/_tasks.cpython-313.pyc,, +anyio/abc/__pycache__/_testing.cpython-313.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-313.pyc,, +anyio/streams/__pycache__/buffered.cpython-313.pyc,, +anyio/streams/__pycache__/file.cpython-313.pyc,, +anyio/streams/__pycache__/memory.cpython-313.pyc,, +anyio/streams/__pycache__/stapled.cpython-313.pyc,, +anyio/streams/__pycache__/text.cpython-313.pyc,, +anyio/streams/__pycache__/tls.cpython-313.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 diff --git a/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/WHEEL b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/WHEEL new file mode 100644 index 0000000..14a883f --- /dev/null +++ b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: setuptools (82.0.1) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/entry_points.txt b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/entry_points.txt new file mode 100644 index 0000000..44dd9bd --- /dev/null +++ b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[pytest11] +anyio = anyio.pytest_plugin diff --git a/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/licenses/LICENSE b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000..104eebf --- /dev/null +++ b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/licenses/LICENSE @@ -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. diff --git a/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/top_level.txt b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/top_level.txt new file mode 100644 index 0000000..c77c069 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio-4.14.0.dist-info/top_level.txt @@ -0,0 +1 @@ +anyio diff --git a/server/venv/Lib/site-packages/anyio/__init__.py b/server/venv/Lib/site-packages/anyio/__init__.py new file mode 100644 index 0000000..2502c76 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/__init__.py @@ -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}") diff --git a/server/venv/Lib/site-packages/anyio/_backends/__init__.py b/server/venv/Lib/site-packages/anyio/_backends/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/anyio/_backends/_asyncio.py b/server/venv/Lib/site-packages/anyio/_backends/_asyncio.py new file mode 100644 index 0000000..a51a150 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_backends/_asyncio.py @@ -0,0 +1,3073 @@ +from __future__ import annotations + +import array +import asyncio +import concurrent.futures +import contextvars +import math +import os +import socket +import sys +import threading +import weakref +from asyncio import ( + AbstractEventLoop, + CancelledError, + all_tasks, + create_task, + current_task, + get_running_loop, + sleep, +) +from asyncio.base_events import _run_until_complete_cb # type: ignore[attr-defined] +from collections import OrderedDict, deque +from collections.abc import ( + AsyncGenerator, + AsyncIterator, + Awaitable, + Callable, + Collection, + Coroutine, + Iterable, + Sequence, +) +from concurrent.futures import Future +from contextlib import AbstractContextManager +from contextvars import Context, copy_context +from dataclasses import dataclass, field +from functools import partial, wraps +from inspect import ( + CORO_RUNNING, + CORO_SUSPENDED, + getcoroutinestate, +) +from io import IOBase +from os import PathLike +from queue import Queue +from signal import Signals +from socket import AddressFamily, SocketKind +from threading import Thread +from types import CodeType, TracebackType +from typing import ( + IO, + TYPE_CHECKING, + Any, + Literal, + ParamSpec, + TypeVar, + cast, +) +from weakref import WeakKeyDictionary + +from .. import ( + CapacityLimiterStatistics, + EventStatistics, + LockStatistics, + TaskInfo, + abc, +) +from .._core._eventloop import ( + claim_worker_thread, + set_current_async_library, + threadlocals, +) +from .._core._exceptions import ( + BrokenResourceError, + BusyResourceError, + ClosedResourceError, + EndOfStream, + RunFinishedError, + WouldBlock, +) +from .._core._sockets import convert_ipv6_sockaddr +from .._core._streams import create_memory_object_stream +from .._core._synchronization import ( + CapacityLimiter as BaseCapacityLimiter, +) +from .._core._synchronization import Event as BaseEvent +from .._core._synchronization import Lock as BaseLock +from .._core._synchronization import ( + ResourceGuard, + SemaphoreStatistics, +) +from .._core._synchronization import Semaphore as BaseSemaphore +from .._core._tasks import CancelScope as BaseCancelScope +from .._core._tasks import TaskHandle +from ..abc import ( + AsyncBackend, + IPSockAddrType, + SocketListener, + UDPPacketType, + UNIXDatagramPacketType, +) +from ..abc._eventloop import StrOrBytesPath +from ..abc._tasks import call_for_coroutine, get_callable_name +from ..lowlevel import RunVar +from ..streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream + +if TYPE_CHECKING: + from _typeshed import FileDescriptorLike +else: + FileDescriptorLike = object + +if sys.version_info >= (3, 11): + from asyncio import Runner + from typing import TypeVarTuple, Unpack +else: + import contextvars + import enum + import signal + from asyncio import coroutines, events, exceptions, tasks + + from exceptiongroup import BaseExceptionGroup + from typing_extensions import TypeVarTuple, Unpack + + class _State(enum.Enum): + CREATED = "created" + INITIALIZED = "initialized" + CLOSED = "closed" + + class Runner: + # Copied from CPython 3.11 + def __init__( + self, + *, + debug: bool | None = None, + loop_factory: Callable[[], AbstractEventLoop] | None = None, + ): + self._state = _State.CREATED + self._debug = debug + self._loop_factory = loop_factory + self._loop: AbstractEventLoop | None = None + self._context = None + self._interrupt_count = 0 + self._set_event_loop = False + + def __enter__(self) -> Runner: + self._lazy_init() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def close(self) -> None: + """Shutdown and close event loop.""" + loop = self._loop + if self._state is not _State.INITIALIZED or loop is None: + return + try: + _cancel_all_tasks(loop) + loop.run_until_complete(loop.shutdown_asyncgens()) + if hasattr(loop, "shutdown_default_executor"): + loop.run_until_complete(loop.shutdown_default_executor()) + else: + loop.run_until_complete(_shutdown_default_executor(loop)) + finally: + if self._set_event_loop: + events.set_event_loop(None) + loop.close() + self._loop = None + self._state = _State.CLOSED + + def get_loop(self) -> AbstractEventLoop: + """Return embedded event loop.""" + self._lazy_init() + return self._loop + + def run(self, coro: Coroutine[T_Retval], *, context=None) -> T_Retval: + """Run a coroutine inside the embedded event loop.""" + if not coroutines.iscoroutine(coro): + raise ValueError(f"a coroutine was expected, got {coro!r}") + + if events._get_running_loop() is not None: + # fail fast with short traceback + raise RuntimeError( + "Runner.run() cannot be called from a running event loop" + ) + + self._lazy_init() + + if context is None: + context = self._context + task = context.run(self._loop.create_task, coro) + + if ( + threading.current_thread() is threading.main_thread() + and signal.getsignal(signal.SIGINT) is signal.default_int_handler + ): + sigint_handler = partial(self._on_sigint, main_task=task) + try: + signal.signal(signal.SIGINT, sigint_handler) + except ValueError: + # `signal.signal` may throw if `threading.main_thread` does + # not support signals (e.g. embedded interpreter with signals + # not registered - see gh-91880) + sigint_handler = None + else: + sigint_handler = None + + self._interrupt_count = 0 + try: + return self._loop.run_until_complete(task) + except exceptions.CancelledError: + if self._interrupt_count > 0: + uncancel = getattr(task, "uncancel", None) + if uncancel is not None and uncancel() == 0: + raise KeyboardInterrupt # noqa: B904 + raise # CancelledError + finally: + if ( + sigint_handler is not None + and signal.getsignal(signal.SIGINT) is sigint_handler + ): + signal.signal(signal.SIGINT, signal.default_int_handler) + + def _lazy_init(self) -> None: + if self._state is _State.CLOSED: + raise RuntimeError("Runner is closed") + if self._state is _State.INITIALIZED: + return + if self._loop_factory is None: + self._loop = events.new_event_loop() + if not self._set_event_loop: + # Call set_event_loop only once to avoid calling + # attach_loop multiple times on child watchers + events.set_event_loop(self._loop) + self._set_event_loop = True + else: + self._loop = self._loop_factory() + if self._debug is not None: + self._loop.set_debug(self._debug) + self._context = contextvars.copy_context() + self._state = _State.INITIALIZED + + def _on_sigint(self, signum, frame, main_task: asyncio.Task) -> None: + self._interrupt_count += 1 + if self._interrupt_count == 1 and not main_task.done(): + main_task.cancel() + # wakeup loop if it is blocked by select() with long timeout + self._loop.call_soon_threadsafe(lambda: None) + return + raise KeyboardInterrupt() + + def _cancel_all_tasks(loop: AbstractEventLoop) -> None: + to_cancel = tasks.all_tasks(loop) + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete(tasks.gather(*to_cancel, return_exceptions=True)) + + for task in to_cancel: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during asyncio.run() shutdown", + "exception": task.exception(), + "task": task, + } + ) + + async def _shutdown_default_executor(loop: AbstractEventLoop) -> None: + """Schedule the shutdown of the default executor.""" + + def _do_shutdown(future: asyncio.futures.Future) -> None: + try: + loop._default_executor.shutdown(wait=True) # type: ignore[attr-defined] + loop.call_soon_threadsafe(future.set_result, None) + except Exception as ex: + loop.call_soon_threadsafe(future.set_exception, ex) + + loop._executor_shutdown_called = True + if loop._default_executor is None: + return + future = loop.create_future() + thread = threading.Thread(target=_do_shutdown, args=(future,)) + thread.start() + try: + await future + finally: + thread.join() + + +T_Retval = TypeVar("T_Retval") +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) +PosArgsT = TypeVarTuple("PosArgsT") +P = ParamSpec("P") + +_root_task: RunVar[asyncio.Task | None] = RunVar("_root_task") + + +def find_root_task() -> asyncio.Task: + root_task = _root_task.get(None) + if root_task is not None and not root_task.done(): + return root_task + + # Look for a task that has been started via run_until_complete() + for task in all_tasks(): + if task._callbacks and not task.done(): + callbacks = [cb for cb, context in task._callbacks] + for cb in callbacks: + if ( + cb is _run_until_complete_cb + or getattr(cb, "__module__", None) == "uvloop.loop" + ): + _root_task.set(task) + return task + + # Look up the topmost task in the AnyIO task tree, if possible + task = cast(asyncio.Task, current_task()) + state = _task_states.get(task) + if state: + cancel_scope = state.cancel_scope + while cancel_scope and cancel_scope._parent_scope is not None: + cancel_scope = cancel_scope._parent_scope + + if cancel_scope is not None: + return cast(asyncio.Task, cancel_scope._host_task) + + return task + + +# +# Event loop +# + +_run_vars: WeakKeyDictionary[asyncio.AbstractEventLoop, Any] = WeakKeyDictionary() + + +def _task_started(task: asyncio.Task) -> bool: + """Return ``True`` if the task has been started and has not finished.""" + # The task coro should never be None here, as we never add finished tasks to the + # task list + coro = task.get_coro() + assert coro is not None + return getcoroutinestate(coro) in (CORO_RUNNING, CORO_SUSPENDED) + + +# +# Timeouts and cancellation +# + + +def is_anyio_cancellation(exc: CancelledError) -> bool: + # Sometimes third party frameworks catch a CancelledError and raise a new one, so as + # a workaround we have to look at the previous ones in __context__ too for a + # matching cancel message + while True: + if ( + exc.args + and isinstance(exc.args[0], str) + and exc.args[0].startswith("Cancelled via cancel scope ") + ): + return True + + if isinstance(exc.__context__, CancelledError): + exc = exc.__context__ + continue + + return False + + +class CancelScope(BaseCancelScope): + __slots__ = ( + "_active", + "_cancel_called", + "_cancel_handle", + "_cancel_reason", + "_cancelled_caught", + "_child_scopes", + "_deadline", + "_host_task", + "_parent_scope", + "_pending_uncancellations", + "_shield", + "_tasks", + "_timeout_handle", + ) + + def __new__( + cls, *, deadline: float = math.inf, shield: bool = False + ) -> CancelScope: + return object.__new__(cls) + + def __init__(self, deadline: float = math.inf, shield: bool = False): + self._deadline = deadline + self._shield = shield + self._parent_scope: CancelScope | None = None + self._child_scopes: set[CancelScope] = set() + self._cancel_called = False + self._cancel_reason: str | None = None + self._cancelled_caught = False + self._active = False + self._timeout_handle: asyncio.TimerHandle | None = None + self._cancel_handle: asyncio.Handle | None = None + self._tasks: set[asyncio.Task] = set() + self._host_task: asyncio.Task | None = None + if sys.version_info >= (3, 11): + self._pending_uncancellations: int | None = 0 + else: + self._pending_uncancellations = None + + def __enter__(self) -> CancelScope: + if self._active: + raise RuntimeError( + "Each CancelScope may only be used for a single 'with' block" + ) + + self._host_task = host_task = cast(asyncio.Task, current_task()) + self._tasks.add(host_task) + try: + task_state = _task_states[host_task] + except KeyError: + task_state = TaskState(None, self) + _task_states[host_task] = task_state + else: + self._parent_scope = task_state.cancel_scope + task_state.cancel_scope = self + if self._parent_scope is not None: + # If using an eager task factory, the parent scope may not even contain + # the host task + self._parent_scope._child_scopes.add(self) + self._parent_scope._tasks.discard(host_task) + + self._timeout() + self._active = True + + # Start cancelling the host task if the scope was cancelled before entering + if self._cancel_called: + self._deliver_cancellation(self) + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + del exc_tb + + if not self._active: + raise RuntimeError("This cancel scope is not active") + if current_task() is not self._host_task: + raise RuntimeError( + "Attempted to exit cancel scope in a different task than it was " + "entered in" + ) + + assert self._host_task is not None + host_task_state = _task_states.get(self._host_task) + if host_task_state is None or host_task_state.cancel_scope is not self: + raise RuntimeError( + "Attempted to exit a cancel scope that isn't the current tasks's " + "current cancel scope" + ) + + try: + self._active = False + if self._timeout_handle: + self._timeout_handle.cancel() + self._timeout_handle = None + + self._tasks.remove(self._host_task) + if self._parent_scope is not None: + self._parent_scope._child_scopes.remove(self) + self._parent_scope._tasks.add(self._host_task) + + host_task_state.cancel_scope = self._parent_scope + + # Restart the cancellation effort in the closest visible, cancelled parent + # scope if necessary + self._restart_cancellation_in_parent() + + # We only swallow the exception iff it was an AnyIO CancelledError, either + # directly as exc_val or inside an exception group and there are no cancelled + # parent cancel scopes visible to us here + if self._cancel_called and not self._parent_cancellation_is_visible_to_us: + # For each level-cancel() call made on the host task, call uncancel() + while self._pending_uncancellations: + self._host_task.uncancel() + self._pending_uncancellations -= 1 + + # Update cancelled_caught and check for exceptions we must not swallow + if isinstance(exc_val, BaseExceptionGroup): + cancelleds_caught, remaining = exc_val.split( + lambda exc: ( + isinstance(exc, CancelledError) + and is_anyio_cancellation(exc) + ) + ) + + if cancelleds_caught is None: + return False + + self._cancelled_caught = True + + if remaining is None: + return True + + context = remaining.__context__ + try: + # Preserve __cause__ and __suppress_context__ by avoiding `raise + # ... from ...` + raise remaining + finally: + # Preserve __context__ + remaining.__context__ = context + del context + else: + if isinstance(exc_val, CancelledError) and is_anyio_cancellation( + exc_val + ): + self._cancelled_caught = True + return True + else: + return False + else: + if self._pending_uncancellations: + assert self._parent_scope is not None + assert self._parent_scope._pending_uncancellations is not None + self._parent_scope._pending_uncancellations += ( + self._pending_uncancellations + ) + self._pending_uncancellations = 0 + + return False + finally: + self._host_task = None + del exc_val + + @property + def _effectively_cancelled(self) -> bool: + cancel_scope: CancelScope | None = self + while cancel_scope is not None: + if cancel_scope._cancel_called: + return True + + if cancel_scope.shield: + return False + + cancel_scope = cancel_scope._parent_scope + + return False + + @property + def _parent_cancellation_is_visible_to_us(self) -> bool: + return ( + self._parent_scope is not None + and not self.shield + and self._parent_scope._effectively_cancelled + ) + + def _timeout(self) -> None: + if self._deadline != math.inf: + loop = get_running_loop() + if loop.time() >= self._deadline: + self.cancel("deadline exceeded") + else: + self._timeout_handle = loop.call_at(self._deadline, self._timeout) + + def _deliver_cancellation(self, origin: CancelScope) -> bool: + """ + Deliver cancellation to directly contained tasks and nested cancel scopes. + + Schedule another run at the end if we still have tasks eligible for + cancellation. + + :param origin: the cancel scope that originated the cancellation + :return: ``True`` if the delivery needs to be retried on the next cycle + + """ + should_retry = False + current = current_task() + for task in self._tasks: + should_retry = True + if task._must_cancel: # type: ignore[attr-defined] + continue + + # The task is eligible for cancellation if it has started + if task is not current and (task is self._host_task or _task_started(task)): + waiter = task._fut_waiter # type: ignore[attr-defined] + if not isinstance(waiter, asyncio.Future) or not waiter.done(): + task.cancel(origin._cancel_reason) + if ( + task is origin._host_task + and origin._pending_uncancellations is not None + ): + origin._pending_uncancellations += 1 + + # Deliver cancellation to child scopes that aren't shielded or running their own + # cancellation callbacks + for scope in self._child_scopes: + if not scope._shield and not scope.cancel_called: + should_retry = scope._deliver_cancellation(origin) or should_retry + + # Schedule another callback if there are still tasks left + if origin is self: + if should_retry: + self._cancel_handle = get_running_loop().call_soon( + self._deliver_cancellation, origin + ) + else: + self._cancel_handle = None + + return should_retry + + def _restart_cancellation_in_parent(self) -> None: + """ + Restart the cancellation effort in the closest directly cancelled parent scope. + + """ + scope = self._parent_scope + while scope is not None: + if scope._cancel_called: + if scope._cancel_handle is None: + scope._deliver_cancellation(scope) + + break + + # No point in looking beyond any shielded scope + if scope._shield: + break + + scope = scope._parent_scope + + def cancel(self, reason: str | None = None) -> None: + if not self._cancel_called: + if self._timeout_handle: + self._timeout_handle.cancel() + self._timeout_handle = None + + self._cancel_called = True + self._cancel_reason = f"Cancelled via cancel scope {id(self):x}" + if task := current_task(): + self._cancel_reason += f" by {task}" + + if reason: + self._cancel_reason += f"; reason: {reason}" + + if self._host_task is not None: + self._deliver_cancellation(self) + + @property + def deadline(self) -> float: + return self._deadline + + @deadline.setter + def deadline(self, value: float) -> None: + self._deadline = float(value) + if self._timeout_handle is not None: + self._timeout_handle.cancel() + self._timeout_handle = None + + if self._active and not self._cancel_called: + self._timeout() + + @property + def cancel_called(self) -> bool: + return self._cancel_called + + @property + def cancelled_caught(self) -> bool: + return self._cancelled_caught + + @property + def shield(self) -> bool: + return self._shield + + @shield.setter + def shield(self, value: bool) -> None: + if self._shield != value: + self._shield = value + if not value: + self._restart_cancellation_in_parent() + + +# +# Task states +# + + +class TaskState: + """ + Encapsulates auxiliary task information that cannot be added to the Task instance + itself because there are no guarantees about its implementation. + """ + + __slots__ = "parent_id", "cancel_scope", "__weakref__" + + def __init__(self, parent_id: int | None, cancel_scope: CancelScope | None): + self.parent_id = parent_id + self.cancel_scope = cancel_scope + + +_task_states: WeakKeyDictionary[asyncio.Task, TaskState] = WeakKeyDictionary() + + +# +# Task groups +# + + +class _AsyncioTaskStatus(abc.TaskStatus): + def __init__(self, future: asyncio.Future, parent_id: int): + self._future = future + self._parent_id = parent_id + + def started(self, value: T_contra | None = None) -> None: + try: + self._future.set_result(value) + except asyncio.InvalidStateError: + if not self._future.cancelled(): + raise RuntimeError( + "called 'started' twice on the same task status" + ) from None + + task = cast(asyncio.Task, current_task()) + _task_states[task].parent_id = self._parent_id + + +if sys.version_info >= (3, 12): + _eager_task_factory_code: CodeType | None = asyncio.eager_task_factory.__code__ +else: + _eager_task_factory_code = None + + +class TaskGroup(abc.TaskGroup): + def __init__(self) -> None: + self.cancel_scope: CancelScope = CancelScope() + self._entered = False + self._exceptions: list[BaseException] = [] + self._tasks: set[asyncio.Task] = set() + self._on_completed_fut: asyncio.Future[None] | None = None + + async def __aenter__(self) -> TaskGroup: + if self._entered: + raise RuntimeError("TaskGroup cannot be entered more than once") + + self._entered = True + + self.cancel_scope.__enter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + try: + if exc_val is not None: + self.cancel_scope.cancel() + if not isinstance(exc_val, CancelledError): + self._exceptions.append(exc_val) + + loop = get_running_loop() + try: + if self._tasks: + with CancelScope() as wait_scope: + while self._tasks: + self._on_completed_fut = loop.create_future() + + try: + await self._on_completed_fut + except CancelledError as exc: + # Shield the scope against further cancellation attempts, + # as they're not productive (#695) + wait_scope.shield = True + self.cancel_scope.cancel() + + # Set exc_val from the cancellation exception if it was + # previously unset. However, we should not replace a native + # cancellation exception with one raise by a cancel scope. + if exc_val is None or ( + isinstance(exc_val, CancelledError) + and not is_anyio_cancellation(exc) + ): + exc_val = exc + + self._on_completed_fut = None + else: + # If there are no child tasks to wait on, run at least one checkpoint + # anyway + await AsyncIOBackend.cancel_shielded_checkpoint() + + if self._exceptions: + # The exception that got us here should already have been + # added to self._exceptions so it's ok to break exception + # chaining and avoid adding a "During handling of above..." + # for each nesting level. + raise BaseExceptionGroup( + "unhandled errors in a TaskGroup", self._exceptions + ) from None + elif exc_val: + raise exc_val + except BaseException as exc: + if self.cancel_scope.__exit__(type(exc), exc, exc.__traceback__): + return True + + raise + + return self.cancel_scope.__exit__(exc_type, exc_val, exc_tb) + finally: + del exc_val, exc_tb, self._exceptions + + def _spawn( + self, + coro: Coroutine[Any, Any, T_co], + name: object, + task_status_future: asyncio.Future | None = None, + ) -> TaskHandle[T_co]: + def task_done(_task: asyncio.Task) -> None: + if sys.version_info >= (3, 14) and self.cancel_scope._host_task is not None: + asyncio.future_discard_from_awaited_by( + _task, self.cancel_scope._host_task + ) + + task_state = _task_states[_task] + assert task_state.cancel_scope is not None + assert _task in task_state.cancel_scope._tasks + task_state.cancel_scope._tasks.remove(_task) + self._tasks.remove(task) + del _task_states[_task] + + if self._on_completed_fut is not None and not self._tasks: + try: + self._on_completed_fut.set_result(None) + except asyncio.InvalidStateError: + pass + + try: + exc = _task.exception() + except CancelledError as e: + while isinstance(e.__context__, CancelledError): + e = e.__context__ + + exc = e + + if exc is not None: + # The future can only be in the cancelled state if the host task was + # cancelled, so return immediately instead of adding one more + # CancelledError to the exceptions list + if task_status_future is not None and task_status_future.cancelled(): + return + + if task_status_future is None or task_status_future.done(): + if not isinstance(exc, CancelledError): + self._exceptions.append(exc) + + if not self.cancel_scope._effectively_cancelled: + self.cancel_scope.cancel() + else: + task_status_future.set_exception(exc) + elif task_status_future is not None and not task_status_future.done(): + task_status_future.set_exception( + RuntimeError("Child exited without calling task_status.started()") + ) + + if task_status_future: + parent_id = id(current_task()) + else: + parent_id = id(self.cancel_scope._host_task) + + handle = TaskHandle(coro, name) + loop = asyncio.get_running_loop() + wrapper_coro = handle._run_coro() + if ( + (factory := loop.get_task_factory()) + and getattr(factory, "__code__", None) is _eager_task_factory_code + and (closure := getattr(factory, "__closure__", None)) + ): + custom_task_constructor = closure[0].cell_contents + task = custom_task_constructor(wrapper_coro, loop=loop, name=handle.name) + else: + task = loop.create_task(wrapper_coro, name=handle.name) + + # Make the spawned task inherit the task group's cancel scope + _task_states[task] = TaskState( + parent_id=parent_id, cancel_scope=self.cancel_scope + ) + self.cancel_scope._tasks.add(task) + self._tasks.add(task) + if sys.version_info >= (3, 14) and self.cancel_scope._host_task is not None: + asyncio.future_add_to_awaited_by(task, self.cancel_scope._host_task) + + task.add_done_callback(task_done) + return handle + + def create_task( + self, + coro: Coroutine[Any, Any, T_co], + *, + name: object = None, + context: Context | None = None, + ) -> TaskHandle[T_co]: + if not isinstance(coro, Coroutine): + raise TypeError(f"expected a coroutine, got {coro.__class__.__qualname__}") + + if not self._entered or not self.cancel_scope._active: + coro.close() + raise RuntimeError( + "This task group is not active; no new tasks can be started." + ) + + if context is not None: + return context.run(self._spawn, coro, name=name) + else: + return self._spawn(coro, name=name) + + async def start( + self, + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + *args: Unpack[PosArgsT], + name: object = None, + return_handle: Literal[False] | Literal[True] = False, + ) -> Any: + if not self._entered or not self.cancel_scope._active: + raise RuntimeError( + "This task group is not active; no new tasks can be started." + ) + + future: asyncio.Future = asyncio.Future() + final_name = get_callable_name(func, name) + task_status = _AsyncioTaskStatus(future, id(self.cancel_scope._host_task)) + coro = call_for_coroutine(func, args, task_status=task_status) + handle = self._spawn(coro, final_name, future) + + # If the task raises an exception after sending a start value without a switch + # point between, the task group is cancelled and this method never proceeds to + # process the completed future. That's why we have to have a shielded cancel + # scope here. + try: + await future + except BaseException: + if handle.status is TaskHandle.Status.PENDING: + # Cancel the task and wait for it to exit before returning + handle.cancel() + with CancelScope(shield=True): + await handle.wait() + + raise + + if return_handle: + handle._start_value = future.result() + return handle + else: + return future.result() + + +# +# Threads +# + +_Retval_Queue_Type = tuple[T_Retval | None, BaseException | None] + + +class WorkerThread(Thread): + MAX_IDLE_TIME = 10 # seconds + + def __init__( + self, + root_task: asyncio.Task, + workers: set[WorkerThread], + idle_workers: deque[WorkerThread], + ): + super().__init__(name="AnyIO worker thread") + self.root_task = root_task + self.workers = workers + self.idle_workers = idle_workers + self.loop = root_task._loop + self.queue: Queue[ + tuple[Context, Callable, tuple, asyncio.Future, CancelScope] | None + ] = Queue(2) + self.idle_since = AsyncIOBackend.current_time() + self.stopping = False + + def _report_result( + self, future: asyncio.Future, result: Any, exc: BaseException | None + ) -> None: + self.idle_since = AsyncIOBackend.current_time() + if not self.stopping: + self.idle_workers.append(self) + + if not future.cancelled(): + if exc is not None: + if isinstance(exc, StopIteration): + new_exc = RuntimeError("coroutine raised StopIteration") + new_exc.__cause__ = exc + exc = new_exc + + future.set_exception(exc) + else: + future.set_result(result) + + def run(self) -> None: + with claim_worker_thread(AsyncIOBackend, self.loop): + while True: + item = self.queue.get() + if item is None: + # Shutdown command received + return + + context, func, args, future, cancel_scope = item + if not future.cancelled(): + result = None + exception: BaseException | None = None + threadlocals.current_cancel_scope = cancel_scope + try: + result = context.run(func, *args) + except BaseException as exc: + exception = exc + finally: + del threadlocals.current_cancel_scope + + if not self.loop.is_closed(): + self.loop.call_soon_threadsafe( + self._report_result, future, result, exception + ) + + del result, exception + + self.queue.task_done() + del item, context, func, args, future, cancel_scope + + def stop(self, f: asyncio.Task | None = None) -> None: + self.stopping = True + self.queue.put_nowait(None) + self.workers.discard(self) + try: + self.idle_workers.remove(self) + except ValueError: + pass + + +_threadpool_idle_workers: RunVar[deque[WorkerThread]] = RunVar( + "_threadpool_idle_workers" +) +_threadpool_workers: RunVar[set[WorkerThread]] = RunVar("_threadpool_workers") + + +# +# Subprocesses +# + + +@dataclass(eq=False) +class StreamReaderWrapper(abc.ByteReceiveStream): + _stream: asyncio.StreamReader + + async def receive(self, max_bytes: int = 65536) -> bytes: + data = await self._stream.read(max_bytes) + if data: + return data + else: + raise EndOfStream + + async def aclose(self) -> None: + self._stream.set_exception(ClosedResourceError()) + await AsyncIOBackend.checkpoint() + + +@dataclass(eq=False) +class StreamWriterWrapper(abc.ByteSendStream): + _stream: asyncio.StreamWriter + _closed: bool = field(init=False, default=False) + + async def send(self, item: bytes) -> None: + await AsyncIOBackend.checkpoint_if_cancelled() + stream_paused = self._stream._protocol._paused # type: ignore[attr-defined] + try: + self._stream.write(item) + await self._stream.drain() + except (ConnectionResetError, BrokenPipeError, RuntimeError) as exc: + # If closed by us and/or the peer: + # * on stdlib, drain() raises ConnectionResetError or BrokenPipeError + # * on uvloop and Winloop, write() eventually starts raising RuntimeError + if self._closed: + raise ClosedResourceError from exc + elif self._stream.is_closing(): + raise BrokenResourceError from exc + + raise + + if not stream_paused: + await AsyncIOBackend.cancel_shielded_checkpoint() + + async def aclose(self) -> None: + self._closed = True + self._stream.close() + await AsyncIOBackend.checkpoint() + + +@dataclass(eq=False) +class Process(abc.Process): + _process: asyncio.subprocess.Process + _stdin: StreamWriterWrapper | None + _stdout: StreamReaderWrapper | None + _stderr: StreamReaderWrapper | None + + async def aclose(self) -> None: + with CancelScope(shield=True) as scope: + if self._stdin: + await self._stdin.aclose() + if self._stdout: + await self._stdout.aclose() + if self._stderr: + await self._stderr.aclose() + + scope.shield = False + try: + await self.wait() + except BaseException: + scope.shield = True + self.kill() + await self.wait() + raise + + async def wait(self) -> int: + return await self._process.wait() + + def terminate(self) -> None: + self._process.terminate() + + def kill(self) -> None: + self._process.kill() + + def send_signal(self, signal: int) -> None: + self._process.send_signal(signal) + + @property + def pid(self) -> int: + return self._process.pid + + @property + def returncode(self) -> int | None: + return self._process.returncode + + @property + def stdin(self) -> abc.ByteSendStream | None: + return self._stdin + + @property + def stdout(self) -> abc.ByteReceiveStream | None: + return self._stdout + + @property + def stderr(self) -> abc.ByteReceiveStream | None: + return self._stderr + + +def _forcibly_shutdown_process_pool_on_exit( + workers: set[Process], _task: object +) -> None: + """ + Forcibly shuts down worker processes belonging to this event loop.""" + child_watcher: asyncio.AbstractChildWatcher | None = None # type: ignore[name-defined] + if sys.version_info < (3, 12): + try: + child_watcher = asyncio.get_event_loop_policy().get_child_watcher() + except NotImplementedError: + pass + + # Close as much as possible (w/o async/await) to avoid warnings + for process in workers.copy(): + if process.returncode is not None: + continue + + process._stdin._stream._transport.close() # type: ignore[union-attr] + process._stdout._stream._transport.close() # type: ignore[union-attr] + process._stderr._stream._transport.close() # type: ignore[union-attr] + process.kill() + if child_watcher: + child_watcher.remove_child_handler(process.pid) + + +async def _shutdown_process_pool_on_exit(workers: set[abc.Process]) -> None: + """ + Shuts down worker processes belonging to this event loop. + + NOTE: this only works when the event loop was started using asyncio.run() or + anyio.run(). + + """ + process: abc.Process + try: + await sleep(math.inf) + except asyncio.CancelledError: + workers = workers.copy() + for process in workers: + if process.returncode is None: + process.kill() + + for process in workers: + await process.aclose() + + +# +# Sockets and networking +# + + +class StreamProtocol(asyncio.Protocol): + read_queue: deque[bytes] + read_event: asyncio.Event + write_event: asyncio.Event + exception: Exception | None = None + is_at_eof: bool = False + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.read_queue = deque() + self.read_event = asyncio.Event() + self.write_event = asyncio.Event() + self.write_event.set() + cast(asyncio.Transport, transport).set_write_buffer_limits(0) + + def connection_lost(self, exc: Exception | None) -> None: + if exc: + self.exception = exc + + self.read_event.set() + self.write_event.set() + + def data_received(self, data: bytes) -> None: + # ProactorEventloop sometimes sends bytearray instead of bytes + self.read_queue.append(bytes(data)) + self.read_event.set() + + def eof_received(self) -> bool | None: + self.is_at_eof = True + self.read_event.set() + return True + + def pause_writing(self) -> None: + self.write_event = asyncio.Event() + + def resume_writing(self) -> None: + self.write_event.set() + + +class DatagramProtocol(asyncio.DatagramProtocol): + read_queue: deque[tuple[bytes, IPSockAddrType]] + read_event: asyncio.Event + write_event: asyncio.Event + closed_event: asyncio.Event + exception: Exception | None = None + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.read_queue = deque(maxlen=100) # arbitrary value + self.read_event = asyncio.Event() + self.write_event = asyncio.Event() + self.closed_event = asyncio.Event() + self.write_event.set() + + def connection_lost(self, exc: Exception | None) -> None: + self.read_event.set() + self.write_event.set() + self.closed_event.set() + + def datagram_received(self, data: bytes, addr: IPSockAddrType) -> None: + addr = convert_ipv6_sockaddr(addr) + self.read_queue.append((data, addr)) + self.read_event.set() + + def error_received(self, exc: Exception) -> None: + self.exception = exc + + def pause_writing(self) -> None: + self.write_event.clear() + + def resume_writing(self) -> None: + self.write_event.set() + + +class SocketStream(abc.SocketStream): + def __init__(self, transport: asyncio.Transport, protocol: StreamProtocol): + self._transport = transport + self._protocol = protocol + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + self._closed = False + + @property + def _raw_socket(self) -> socket.socket: + return self._transport.get_extra_info("socket") + + async def receive(self, max_bytes: int = 65536) -> bytes: + with self._receive_guard: + if ( + not self._protocol.read_event.is_set() + and not self._transport.is_closing() + and not self._protocol.is_at_eof + ): + self._transport.resume_reading() + await self._protocol.read_event.wait() + self._transport.pause_reading() + else: + await AsyncIOBackend.checkpoint() + + try: + chunk = self._protocol.read_queue.popleft() + except IndexError: + if self._closed: + raise ClosedResourceError from None + elif self._protocol.exception: + raise BrokenResourceError from self._protocol.exception + else: + raise EndOfStream from None + + if len(chunk) > max_bytes: + # Split the oversized chunk + chunk, leftover = chunk[:max_bytes], chunk[max_bytes:] + self._protocol.read_queue.appendleft(leftover) + + # If the read queue is empty, clear the flag so that the next call will + # block until data is available + if not self._protocol.read_queue: + self._protocol.read_event.clear() + + return chunk + + async def send(self, item: bytes) -> None: + with self._send_guard: + await AsyncIOBackend.checkpoint() + + if self._closed: + raise ClosedResourceError + elif self._protocol.exception is not None: + raise BrokenResourceError from self._protocol.exception + + try: + self._transport.write(item) + except RuntimeError as exc: + if self._transport.is_closing(): + raise BrokenResourceError from exc + else: + raise + + await self._protocol.write_event.wait() + + async def send_eof(self) -> None: + try: + self._transport.write_eof() + except OSError: + pass + + async def aclose(self) -> None: + self._closed = True + if not self._transport.is_closing(): + try: + self._transport.write_eof() + except OSError: + pass + + self._transport.close() + await sleep(0) + self._transport.abort() + + +class _RawSocketMixin: + _receive_future: asyncio.Future | None = None + _send_future: asyncio.Future | None = None + _closing = False + + def __init__(self, raw_socket: socket.socket): + self.__raw_socket = raw_socket + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + + @property + def _raw_socket(self) -> socket.socket: + return self.__raw_socket + + def _wait_until_readable(self, loop: asyncio.AbstractEventLoop) -> asyncio.Future: + def callback(f: object) -> None: + del self._receive_future + loop.remove_reader(self.__raw_socket) + + f = self._receive_future = asyncio.Future() + loop.add_reader(self.__raw_socket, f.set_result, None) + f.add_done_callback(callback) + return f + + def _wait_until_writable(self, loop: asyncio.AbstractEventLoop) -> asyncio.Future: + def callback(f: object) -> None: + del self._send_future + loop.remove_writer(self.__raw_socket) + + f = self._send_future = asyncio.Future() + loop.add_writer(self.__raw_socket, f.set_result, None) + f.add_done_callback(callback) + return f + + async def aclose(self) -> None: + if not self._closing: + self._closing = True + if self.__raw_socket.fileno() != -1: + self.__raw_socket.close() + + if self._receive_future: + self._receive_future.set_result(None) + if self._send_future: + self._send_future.set_result(None) + + +class UNIXSocketStream(_RawSocketMixin, abc.UNIXSocketStream): + async def send_eof(self) -> None: + with self._send_guard: + self._raw_socket.shutdown(socket.SHUT_WR) + + async def receive(self, max_bytes: int = 65536) -> bytes: + loop = get_running_loop() + await AsyncIOBackend.checkpoint() + with self._receive_guard: + while True: + try: + data = self._raw_socket.recv(max_bytes) + except BlockingIOError: + await self._wait_until_readable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + else: + if not data: + raise EndOfStream + + return data + + async def send(self, item: bytes) -> None: + loop = get_running_loop() + await AsyncIOBackend.checkpoint() + with self._send_guard: + view = memoryview(item) + while view: + try: + bytes_sent = self._raw_socket.send(view) + except BlockingIOError: + await self._wait_until_writable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + else: + view = view[bytes_sent:] + + async def receive_fds(self, msglen: int, maxfds: int) -> tuple[bytes, list[int]]: + if not isinstance(msglen, int) or msglen < 0: + raise ValueError("msglen must be a non-negative integer") + if not isinstance(maxfds, int) or maxfds < 1: + raise ValueError("maxfds must be a positive integer") + + loop = get_running_loop() + fds = array.array("i") + await AsyncIOBackend.checkpoint() + with self._receive_guard: + while True: + try: + message, ancdata, flags, addr = self._raw_socket.recvmsg( + msglen, socket.CMSG_LEN(maxfds * fds.itemsize) + ) + except BlockingIOError: + await self._wait_until_readable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + else: + if not message and not ancdata: + raise EndOfStream + + break + + for cmsg_level, cmsg_type, cmsg_data in ancdata: + if cmsg_level != socket.SOL_SOCKET or cmsg_type != socket.SCM_RIGHTS: + raise RuntimeError( + f"Received unexpected ancillary data; message = {message!r}, " + f"cmsg_level = {cmsg_level}, cmsg_type = {cmsg_type}" + ) + + fds.frombytes(cmsg_data[: len(cmsg_data) - (len(cmsg_data) % fds.itemsize)]) + + return message, list(fds) + + async def send_fds(self, message: bytes, fds: Collection[int | IOBase]) -> None: + if not message: + raise ValueError("message must not be empty") + if not fds: + raise ValueError("fds must not be empty") + + loop = get_running_loop() + filenos: list[int] = [] + for fd in fds: + if isinstance(fd, int): + filenos.append(fd) + elif isinstance(fd, IOBase): + filenos.append(fd.fileno()) + + fdarray = array.array("i", filenos) + await AsyncIOBackend.checkpoint() + with self._send_guard: + while True: + try: + # The ignore can be removed after mypy picks up + # https://github.com/python/typeshed/pull/5545 + self._raw_socket.sendmsg( + [message], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fdarray)] + ) + break + except BlockingIOError: + await self._wait_until_writable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + + +class TCPSocketListener(abc.SocketListener): + _accept_scope: CancelScope | None = None + _closed = False + + def __init__(self, raw_socket: socket.socket): + self.__raw_socket = raw_socket + self._loop = cast(asyncio.BaseEventLoop, get_running_loop()) + self._accept_guard = ResourceGuard("accepting connections from") + + @property + def _raw_socket(self) -> socket.socket: + return self.__raw_socket + + async def accept(self) -> abc.SocketStream: + if self._closed: + raise ClosedResourceError + + with self._accept_guard: + await AsyncIOBackend.checkpoint() + with CancelScope() as self._accept_scope: + try: + client_sock, _addr = await self._loop.sock_accept(self._raw_socket) + except asyncio.CancelledError: + # Workaround for https://bugs.python.org/issue41317 + try: + self._loop.remove_reader(self._raw_socket) + except (ValueError, NotImplementedError): + pass + + if self._closed: + raise ClosedResourceError from None + + raise + finally: + self._accept_scope = None + + client_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + transport, protocol = await self._loop.connect_accepted_socket( + StreamProtocol, client_sock + ) + return SocketStream(transport, protocol) + + async def aclose(self) -> None: + if self._closed: + return + + self._closed = True + if self._accept_scope: + # Workaround for https://bugs.python.org/issue41317 + try: + self._loop.remove_reader(self._raw_socket) + except (ValueError, NotImplementedError): + pass + + self._accept_scope.cancel() + await sleep(0) + + self._raw_socket.close() + + +class UNIXSocketListener(abc.SocketListener): + def __init__(self, raw_socket: socket.socket): + self.__raw_socket = raw_socket + self._loop = get_running_loop() + self._accept_guard = ResourceGuard("accepting connections from") + self._closed = False + + async def accept(self) -> abc.SocketStream: + await AsyncIOBackend.checkpoint() + with self._accept_guard: + while True: + try: + client_sock, _ = self.__raw_socket.accept() + client_sock.setblocking(False) + return UNIXSocketStream(client_sock) + except BlockingIOError: + f: asyncio.Future = asyncio.Future() + self._loop.add_reader(self.__raw_socket, f.set_result, None) + f.add_done_callback( + lambda _: self._loop.remove_reader(self.__raw_socket) + ) + await f + except OSError as exc: + if self._closed: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + + async def aclose(self) -> None: + self._closed = True + self.__raw_socket.close() + + @property + def _raw_socket(self) -> socket.socket: + return self.__raw_socket + + +class UDPSocket(abc.UDPSocket): + def __init__( + self, transport: asyncio.DatagramTransport, protocol: DatagramProtocol + ): + self._transport = transport + self._protocol = protocol + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + self._closed = False + + @property + def _raw_socket(self) -> socket.socket: + return self._transport.get_extra_info("socket") + + async def aclose(self) -> None: + self._closed = True + if not self._transport.is_closing(): + self._transport.close() + + await self._protocol.closed_event.wait() + + async def receive(self) -> tuple[bytes, IPSockAddrType]: + with self._receive_guard: + await AsyncIOBackend.checkpoint() + + # If the buffer is empty, ask for more data + if not self._protocol.read_queue and not self._transport.is_closing(): + self._protocol.read_event.clear() + await self._protocol.read_event.wait() + + try: + return self._protocol.read_queue.popleft() + except IndexError: + if self._closed: + raise ClosedResourceError from None + else: + raise BrokenResourceError from None + + async def send(self, item: UDPPacketType) -> None: + with self._send_guard: + await AsyncIOBackend.checkpoint() + await self._protocol.write_event.wait() + if self._closed: + raise ClosedResourceError + elif self._transport.is_closing(): + raise BrokenResourceError + else: + self._transport.sendto(*item) + + +class ConnectedUDPSocket(abc.ConnectedUDPSocket): + def __init__( + self, transport: asyncio.DatagramTransport, protocol: DatagramProtocol + ): + self._transport = transport + self._protocol = protocol + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + self._closed = False + + @property + def _raw_socket(self) -> socket.socket: + return self._transport.get_extra_info("socket") + + async def aclose(self) -> None: + self._closed = True + if not self._transport.is_closing(): + self._transport.close() + + await self._protocol.closed_event.wait() + + async def receive(self) -> bytes: + with self._receive_guard: + await AsyncIOBackend.checkpoint() + + # If the buffer is empty, ask for more data + if not self._protocol.read_queue and not self._transport.is_closing(): + self._protocol.read_event.clear() + await self._protocol.read_event.wait() + + try: + packet = self._protocol.read_queue.popleft() + except IndexError: + if self._closed: + raise ClosedResourceError from None + else: + raise BrokenResourceError from None + + return packet[0] + + async def send(self, item: bytes) -> None: + with self._send_guard: + await AsyncIOBackend.checkpoint() + await self._protocol.write_event.wait() + if self._closed: + raise ClosedResourceError + elif self._transport.is_closing(): + raise BrokenResourceError + else: + self._transport.sendto(item) + + +class UNIXDatagramSocket(_RawSocketMixin, abc.UNIXDatagramSocket): + async def receive(self) -> UNIXDatagramPacketType: + loop = get_running_loop() + await AsyncIOBackend.checkpoint() + with self._receive_guard: + while True: + try: + data = self._raw_socket.recvfrom(65536) + except BlockingIOError: + await self._wait_until_readable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + else: + return data + + async def send(self, item: UNIXDatagramPacketType) -> None: + loop = get_running_loop() + await AsyncIOBackend.checkpoint() + with self._send_guard: + while True: + try: + self._raw_socket.sendto(*item) + except BlockingIOError: + await self._wait_until_writable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + else: + return + + +class ConnectedUNIXDatagramSocket(_RawSocketMixin, abc.ConnectedUNIXDatagramSocket): + async def receive(self) -> bytes: + loop = get_running_loop() + await AsyncIOBackend.checkpoint() + with self._receive_guard: + while True: + try: + data = self._raw_socket.recv(65536) + except BlockingIOError: + await self._wait_until_readable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + else: + return data + + async def send(self, item: bytes) -> None: + loop = get_running_loop() + await AsyncIOBackend.checkpoint() + with self._send_guard: + while True: + try: + self._raw_socket.send(item) + except BlockingIOError: + await self._wait_until_writable(loop) + except OSError as exc: + if self._closing: + raise ClosedResourceError from None + else: + raise BrokenResourceError from exc + else: + return + + +_read_events: RunVar[dict[int, asyncio.Future[bool]]] = RunVar("read_events") +_write_events: RunVar[dict[int, asyncio.Future[bool]]] = RunVar("write_events") + + +# +# Synchronization +# + + +class Event(BaseEvent): + __slots__ = ("_event",) + + def __new__(cls) -> Event: + return object.__new__(cls) + + def __init__(self) -> None: + self._event = asyncio.Event() + + def set(self) -> None: + self._event.set() + + def is_set(self) -> bool: + return self._event.is_set() + + async def wait(self) -> None: + if self.is_set(): + await AsyncIOBackend.checkpoint() + else: + await self._event.wait() + + def statistics(self) -> EventStatistics: + return EventStatistics(len(self._event._waiters)) + + +class Lock(BaseLock): + __slots__ = "_fast_acquire", "_owner_task", "_waiters" + + def __new__(cls, *, fast_acquire: bool = False) -> Lock: + return object.__new__(cls) + + def __init__(self, *, fast_acquire: bool = False) -> None: + self._fast_acquire = fast_acquire + self._owner_task: asyncio.Task | None = None + self._waiters: deque[tuple[asyncio.Task, asyncio.Future]] = deque() + + async def acquire(self) -> None: + task = cast(asyncio.Task, current_task()) + if self._owner_task is None and not self._waiters: + await AsyncIOBackend.checkpoint_if_cancelled() + self._owner_task = task + + # Unless on the "fast path", yield control of the event loop so that other + # tasks can run too + if not self._fast_acquire: + try: + await AsyncIOBackend.cancel_shielded_checkpoint() + except CancelledError: + self.release() + raise + + return + + if self._owner_task == task: + raise RuntimeError("Attempted to acquire an already held Lock") + + fut: asyncio.Future[None] = asyncio.Future() + item = task, fut + self._waiters.append(item) + try: + await fut + except CancelledError: + if fut.cancelled(): + try: + self._waiters.remove(item) + except ValueError: + pass + else: + self.release() + + raise + + def acquire_nowait(self) -> None: + task = cast(asyncio.Task, current_task()) + if self._owner_task is None and not self._waiters: + self._owner_task = task + return + + if self._owner_task is task: + raise RuntimeError("Attempted to acquire an already held Lock") + + raise WouldBlock + + def locked(self) -> bool: + return self._owner_task is not None + + def release(self) -> None: + if self._owner_task != current_task(): + raise RuntimeError("The current task is not holding this lock") + + # A cancelled waiter that already received ownership removes itself from + # _waiters before calling release(); any cancelled waiter still queued here + # was cancelled before being woken, so drop it. + while self._waiters: + task, fut = self._waiters.popleft() + if fut.cancelled(): + continue + + self._owner_task = task + fut.set_result(None) + return + + self._owner_task = None + + def statistics(self) -> LockStatistics: + task_info = AsyncIOTaskInfo(self._owner_task) if self._owner_task else None + return LockStatistics(self.locked(), task_info, len(self._waiters)) + + +class Semaphore(BaseSemaphore): + __slots__ = "_value", "_max_value", "_fast_acquire", "_waiters" + + def __new__( + cls, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> Semaphore: + return object.__new__(cls) + + def __init__( + self, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ): + super().__init__(initial_value, max_value=max_value) + self._value = initial_value + self._max_value = max_value + self._fast_acquire = fast_acquire + self._waiters: deque[asyncio.Future[None]] = deque() + + async def acquire(self) -> None: + if self._value > 0 and not self._waiters: + await AsyncIOBackend.checkpoint_if_cancelled() + self._value -= 1 + + # Unless on the "fast path", yield control of the event loop so that other + # tasks can run too + if not self._fast_acquire: + try: + await AsyncIOBackend.cancel_shielded_checkpoint() + except CancelledError: + self.release() + raise + + return + + fut: asyncio.Future[None] = asyncio.Future() + self._waiters.append(fut) + try: + await fut + except CancelledError: + if fut.cancelled(): + try: + self._waiters.remove(fut) + except ValueError: + pass + else: + self.release() + + raise + + def acquire_nowait(self) -> None: + if self._value == 0: + raise WouldBlock + + self._value -= 1 + + def release(self) -> None: + if self._max_value is not None and self._value == self._max_value: + raise ValueError("semaphore released too many times") + + while self._waiters: + fut = self._waiters.popleft() + if fut.cancelled(): + continue + + fut.set_result(None) + return + + self._value += 1 + + @property + def value(self) -> int: + return self._value + + @property + def max_value(self) -> int | None: + return self._max_value + + def statistics(self) -> SemaphoreStatistics: + return SemaphoreStatistics(len(self._waiters)) + + +class CapacityLimiter(BaseCapacityLimiter): + __slots__ = "_total_tokens", "_borrowers", "_wait_queue" + + def __new__(cls, total_tokens: float) -> CapacityLimiter: + return object.__new__(cls) + + def __init__(self, total_tokens: float): + self._total_tokens: float = 0 + self._borrowers: set[Any] = set() + self._wait_queue: OrderedDict[Any, asyncio.Event] = OrderedDict() + self.total_tokens = total_tokens + + async def __aenter__(self) -> None: + await self.acquire() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.release() + + @property + def total_tokens(self) -> float: + return self._total_tokens + + @total_tokens.setter + def total_tokens(self, value: float) -> None: + if not isinstance(value, int) and not math.isinf(value): + raise TypeError("total_tokens must be an int or math.inf") + + if value < 0: + raise ValueError("total_tokens must be >= 0") + + waiters_to_notify = max(value - self._total_tokens, 0) + self._total_tokens = value + + # Notify waiting tasks that they have acquired the limiter + while self._wait_queue and waiters_to_notify: + event = self._wait_queue.popitem(last=False)[1] + event.set() + waiters_to_notify -= 1 + + @property + def borrowed_tokens(self) -> int: + return len(self._borrowers) + + @property + def available_tokens(self) -> float: + return self._total_tokens - len(self._borrowers) + + def _notify_next_waiter(self) -> None: + """Notify the next task in line if this limiter has free capacity now.""" + if self._wait_queue and len(self._borrowers) < self._total_tokens: + event = self._wait_queue.popitem(last=False)[1] + event.set() + + def acquire_nowait(self) -> None: + self.acquire_on_behalf_of_nowait(current_task()) + + def acquire_on_behalf_of_nowait(self, borrower: object) -> None: + if borrower in self._borrowers: + raise RuntimeError( + "this borrower is already holding one of this CapacityLimiter's tokens" + ) + + if self._wait_queue or len(self._borrowers) >= self._total_tokens: + raise WouldBlock + + self._borrowers.add(borrower) + + async def acquire(self) -> None: + return await self.acquire_on_behalf_of(current_task()) + + async def acquire_on_behalf_of(self, borrower: object) -> None: + await AsyncIOBackend.checkpoint_if_cancelled() + try: + self.acquire_on_behalf_of_nowait(borrower) + except WouldBlock: + event = asyncio.Event() + self._wait_queue[borrower] = event + try: + await event.wait() + except BaseException: + self._wait_queue.pop(borrower, None) + if event.is_set(): + self._notify_next_waiter() + + raise + + self._borrowers.add(borrower) + else: + try: + await AsyncIOBackend.cancel_shielded_checkpoint() + except BaseException: + self.release() + raise + + def release(self) -> None: + self.release_on_behalf_of(current_task()) + + def release_on_behalf_of(self, borrower: object) -> None: + try: + self._borrowers.remove(borrower) + except KeyError: + raise RuntimeError( + "this borrower isn't holding any of this CapacityLimiter's tokens" + ) from None + + self._notify_next_waiter() + + def statistics(self) -> CapacityLimiterStatistics: + return CapacityLimiterStatistics( + self.borrowed_tokens, + self.total_tokens, + tuple(self._borrowers), + len(self._wait_queue), + ) + + +_default_thread_limiter: RunVar[CapacityLimiter] = RunVar("_default_thread_limiter") + + +# +# Operating system signals +# + + +class _SignalReceiver: + def __init__(self, signals: tuple[Signals, ...]): + self._signals = signals + self._loop = get_running_loop() + self._signal_queue: deque[Signals] = deque() + self._future: asyncio.Future = asyncio.Future() + self._handled_signals: set[Signals] = set() + + def _deliver(self, signum: Signals) -> None: + self._signal_queue.append(signum) + if not self._future.done(): + self._future.set_result(None) + + def __enter__(self) -> _SignalReceiver: + for sig in set(self._signals): + self._loop.add_signal_handler(sig, self._deliver, sig) + self._handled_signals.add(sig) + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + for sig in self._handled_signals: + self._loop.remove_signal_handler(sig) + + def __aiter__(self) -> _SignalReceiver: + return self + + async def __anext__(self) -> Signals: + await AsyncIOBackend.checkpoint() + if not self._signal_queue: + self._future = asyncio.Future() + await self._future + + return self._signal_queue.popleft() + + +# +# Testing and debugging +# + + +class AsyncIOTaskInfo(TaskInfo): + def __init__(self, task: asyncio.Task): + task_state = _task_states.get(task) + if task_state is None: + parent_id = None + else: + parent_id = task_state.parent_id + + coro = task.get_coro() + assert coro is not None, "created TaskInfo from a completed Task" + super().__init__(id(task), parent_id, task.get_name(), coro) + self._task = weakref.ref(task) + + def has_pending_cancellation(self) -> bool: + if not (task := self._task()): + # If the task isn't around anymore, it won't have a pending cancellation + return False + + if task._must_cancel: # type: ignore[attr-defined] + return True + elif ( + isinstance(task._fut_waiter, asyncio.Future) # type: ignore[attr-defined] + and task._fut_waiter.cancelled() # type: ignore[attr-defined] + ): + return True + + if task_state := _task_states.get(task): + if cancel_scope := task_state.cancel_scope: + return cancel_scope._effectively_cancelled + + return False + + +class TestRunner(abc.TestRunner): + _send_stream: MemoryObjectSendStream[tuple[Awaitable[Any], asyncio.Future[Any]]] + + def __init__( + self, + *, + debug: bool | None = None, + use_uvloop: bool = False, + loop_factory: Callable[[], AbstractEventLoop] | None = None, + ) -> None: + if use_uvloop and loop_factory is None: + if sys.platform != "win32": + import uvloop + + loop_factory = uvloop.new_event_loop + else: + import winloop + + loop_factory = winloop.new_event_loop + + self._runner = Runner(debug=debug, loop_factory=loop_factory) + self._exceptions: list[BaseException] = [] + self._runner_task: asyncio.Task | None = None + + def __enter__(self) -> TestRunner: + self._runner.__enter__() + self.get_loop().set_exception_handler(self._exception_handler) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self._runner.__exit__(exc_type, exc_val, exc_tb) + + def get_loop(self) -> AbstractEventLoop: + return self._runner.get_loop() + + def is_running(self) -> bool: + try: + asyncio.get_running_loop() + return True + except RuntimeError: + return False + + def _exception_handler( + self, loop: asyncio.AbstractEventLoop, context: dict[str, Any] + ) -> None: + if isinstance(context.get("exception"), Exception): + self._exceptions.append(context["exception"]) + else: + loop.default_exception_handler(context) + + def _raise_async_exceptions(self) -> None: + # Re-raise any exceptions raised in asynchronous callbacks + if self._exceptions: + exceptions, self._exceptions = self._exceptions, [] + if len(exceptions) == 1: + raise exceptions[0] + elif exceptions: + raise BaseExceptionGroup( + "Multiple exceptions occurred in asynchronous callbacks", exceptions + ) + + async def _run_tests_and_fixtures( + self, + receive_stream: MemoryObjectReceiveStream[ + tuple[Awaitable[T_Retval], asyncio.Future[T_Retval]] + ], + ) -> None: + from _pytest.outcomes import OutcomeException + + with receive_stream, self._send_stream: + async for coro, future in receive_stream: + try: + retval = await coro + except CancelledError as exc: + if not future.cancelled(): + future.cancel(*exc.args) + + raise + except BaseException as exc: + if not future.cancelled(): + future.set_exception(exc) + + if not isinstance(exc, (Exception, OutcomeException)): + raise + else: + if not future.cancelled(): + future.set_result(retval) + + async def _call_in_runner_task( + self, + func: Callable[P, Awaitable[T_Retval]], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> T_Retval: + if not self._runner_task: + self._send_stream, receive_stream = create_memory_object_stream[ + tuple[Awaitable[Any], asyncio.Future] + ](1) + self._runner_task = self.get_loop().create_task( + self._run_tests_and_fixtures(receive_stream) + ) + + coro = func(*args, **kwargs) + future: asyncio.Future[T_Retval] = self.get_loop().create_future() + self._send_stream.send_nowait((coro, future)) + return await future + + def run_asyncgen_fixture( + self, + fixture_func: Callable[..., AsyncGenerator[T_Retval, Any]], + kwargs: dict[str, Any], + ) -> Iterable[T_Retval]: + asyncgen = fixture_func(**kwargs) + fixturevalue: T_Retval = self.get_loop().run_until_complete( + self._call_in_runner_task(asyncgen.asend, None) + ) + self._raise_async_exceptions() + + yield fixturevalue + + try: + self.get_loop().run_until_complete( + self._call_in_runner_task(asyncgen.asend, None) + ) + except StopAsyncIteration: + self._raise_async_exceptions() + else: + self.get_loop().run_until_complete(asyncgen.aclose()) + raise RuntimeError("Async generator fixture did not stop") + + def run_fixture( + self, + fixture_func: Callable[..., Coroutine[Any, Any, T_Retval]], + kwargs: dict[str, Any], + ) -> T_Retval: + retval = self.get_loop().run_until_complete( + self._call_in_runner_task(fixture_func, **kwargs) + ) + self._raise_async_exceptions() + return retval + + def run_test( + self, test_func: Callable[..., Coroutine[Any, Any, Any]], kwargs: dict[str, Any] + ) -> None: + try: + self.get_loop().run_until_complete( + self._call_in_runner_task(test_func, **kwargs) + ) + except Exception as exc: + self._exceptions.append(exc) + except BaseException: + # A BaseException (e.g. KeyboardInterrupt, SystemExit) interrupted the event loop before + # the test completed. Cancel _runner_task so it does not resume when the event + # loop is re-entered during async generator fixture teardown. + if self._runner_task is not None and not self._runner_task.done(): + self._runner_task.cancel() + self._send_stream.close() + try: + self.get_loop().run_until_complete(self._runner_task) + except CancelledError: + pass + finally: + self._runner_task = None + raise + self._raise_async_exceptions() + + +class AsyncIOBackend(AsyncBackend): + @classmethod + def run( + cls, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], + args: tuple[Unpack[PosArgsT]], + kwargs: dict[str, Any], + options: dict[str, Any], + ) -> T_Retval: + @wraps(func) + async def wrapper() -> T_Retval: + task = cast(asyncio.Task, current_task()) + task.set_name(get_callable_name(func)) + _task_states[task] = TaskState(None, None) + + try: + return await func(*args) + finally: + del _task_states[task] + + debug = options.get("debug", None) + loop_factory = options.get("loop_factory", None) + if loop_factory is None and options.get("use_uvloop", False): + if sys.platform != "win32": + import uvloop + + loop_factory = uvloop.new_event_loop + else: + import winloop + + loop_factory = winloop.new_event_loop + + with Runner(debug=debug, loop_factory=loop_factory) as runner: + return runner.run(wrapper()) + + @classmethod + def current_token(cls) -> object: + return get_running_loop() + + @classmethod + def current_time(cls) -> float: + return get_running_loop().time() + + @classmethod + def cancelled_exception_class(cls) -> type[BaseException]: + return CancelledError + + @classmethod + async def checkpoint(cls) -> None: + await sleep(0) + + @classmethod + async def checkpoint_if_cancelled(cls) -> None: + task = current_task() + if task is None: + return + + try: + cancel_scope = _task_states[task].cancel_scope + except KeyError: + return + + while cancel_scope: + if cancel_scope.cancel_called: + await sleep(0) + elif cancel_scope.shield: + break + else: + cancel_scope = cancel_scope._parent_scope + + @classmethod + async def cancel_shielded_checkpoint(cls) -> None: + with CancelScope(shield=True): + await sleep(0) + + @classmethod + async def sleep(cls, delay: float) -> None: + await sleep(delay) + + @classmethod + def create_cancel_scope( + cls, *, deadline: float = math.inf, shield: bool = False + ) -> CancelScope: + return CancelScope(deadline=deadline, shield=shield) + + @classmethod + def current_effective_deadline(cls) -> float: + if (task := current_task()) is None: + return math.inf + + try: + cancel_scope = _task_states[task].cancel_scope + except KeyError: + return math.inf + + deadline = math.inf + while cancel_scope: + deadline = min(deadline, cancel_scope.deadline) + if cancel_scope._cancel_called: + deadline = -math.inf + break + elif cancel_scope.shield: + break + else: + cancel_scope = cancel_scope._parent_scope + + return deadline + + @classmethod + def create_task_group(cls) -> abc.TaskGroup: + return TaskGroup() + + @classmethod + def create_event(cls) -> abc.Event: + return Event() + + @classmethod + def create_lock(cls, *, fast_acquire: bool) -> abc.Lock: + return Lock(fast_acquire=fast_acquire) + + @classmethod + def create_semaphore( + cls, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> abc.Semaphore: + return Semaphore(initial_value, max_value=max_value, fast_acquire=fast_acquire) + + @classmethod + def create_capacity_limiter(cls, total_tokens: float) -> abc.CapacityLimiter: + return CapacityLimiter(total_tokens) + + @classmethod + async def run_sync_in_worker_thread( # type: ignore[return] + cls, + func: Callable[[Unpack[PosArgsT]], T_Retval], + args: tuple[Unpack[PosArgsT]], + abandon_on_cancel: bool = False, + limiter: abc.CapacityLimiter | None = None, + ) -> T_Retval: + await cls.checkpoint() + + # If this is the first run in this event loop thread, set up the necessary + # variables + try: + idle_workers = _threadpool_idle_workers.get() + workers = _threadpool_workers.get() + except LookupError: + idle_workers = deque() + workers = set() + _threadpool_idle_workers.set(idle_workers) + _threadpool_workers.set(workers) + + async with limiter or cls.current_default_thread_limiter(): + with CancelScope(shield=not abandon_on_cancel) as scope: + future = asyncio.Future[T_Retval]() + root_task = find_root_task() + if not idle_workers: + worker = WorkerThread(root_task, workers, idle_workers) + worker.start() + workers.add(worker) + root_task.add_done_callback( + worker.stop, context=contextvars.Context() + ) + else: + worker = idle_workers.pop() + + # Prune any other workers that have been idle for MAX_IDLE_TIME + # seconds or longer + now = cls.current_time() + while idle_workers: + if ( + now - idle_workers[0].idle_since + < WorkerThread.MAX_IDLE_TIME + ): + break + + expired_worker = idle_workers.popleft() + expired_worker.root_task.remove_done_callback( + expired_worker.stop + ) + expired_worker.stop() + + context = copy_context() + context.run(set_current_async_library, None) + if abandon_on_cancel or scope._parent_scope is None: + worker_scope = scope + else: + worker_scope = scope._parent_scope + + worker.queue.put_nowait((context, func, args, future, worker_scope)) + return await future + + @classmethod + def check_cancelled(cls) -> None: + scope: CancelScope | None = threadlocals.current_cancel_scope + while scope is not None: + if scope.cancel_called: + raise CancelledError(f"Cancelled via cancel scope {id(scope):x}") + + if scope.shield: + return + + scope = scope._parent_scope + + @classmethod + def run_async_from_thread( + cls, + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + args: tuple[Unpack[PosArgsT]], + token: object, + ) -> T_co: + async def task_wrapper() -> T_co: + __tracebackhide__ = True + if scope is not None: + task = cast(asyncio.Task, current_task()) + _task_states[task] = TaskState(None, scope) + scope._tasks.add(task) + try: + return await func(*args) + except CancelledError as exc: + raise concurrent.futures.CancelledError(str(exc)) from None + finally: + if scope is not None: + scope._tasks.discard(task) + + loop = cast( + "AbstractEventLoop", token or threadlocals.current_token.native_token + ) + if loop.is_closed(): + raise RunFinishedError + + context = copy_context() + context.run(set_current_async_library, "asyncio") + scope = getattr(threadlocals, "current_cancel_scope", None) + f: concurrent.futures.Future[T_co] = context.run( + asyncio.run_coroutine_threadsafe, task_wrapper(), loop=loop + ) + return f.result() + + @classmethod + def run_sync_from_thread( + cls, + func: Callable[[Unpack[PosArgsT]], T_Retval], + args: tuple[Unpack[PosArgsT]], + token: object, + ) -> T_Retval: + @wraps(func) + def wrapper() -> None: + try: + set_current_async_library("asyncio") + f.set_result(func(*args)) + except BaseException as exc: + f.set_exception(exc) + if not isinstance(exc, Exception): + raise + + loop = cast( + "AbstractEventLoop", token or threadlocals.current_token.native_token + ) + if loop.is_closed(): + raise RunFinishedError + + f: concurrent.futures.Future[T_Retval] = Future() + loop.call_soon_threadsafe(wrapper) + return f.result() + + @classmethod + async def open_process( + cls, + command: StrOrBytesPath | Sequence[StrOrBytesPath], + *, + stdin: int | IO[Any] | None, + stdout: int | IO[Any] | None, + stderr: int | IO[Any] | None, + **kwargs: Any, + ) -> Process: + await cls.checkpoint() + if isinstance(command, PathLike): + command = os.fspath(command) + + if isinstance(command, (str, bytes)): + process = await asyncio.create_subprocess_shell( + command, + stdin=stdin, + stdout=stdout, + stderr=stderr, + **kwargs, + ) + else: + process = await asyncio.create_subprocess_exec( + *command, + stdin=stdin, + stdout=stdout, + stderr=stderr, + **kwargs, + ) + + stdin_stream = StreamWriterWrapper(process.stdin) if process.stdin else None + stdout_stream = StreamReaderWrapper(process.stdout) if process.stdout else None + stderr_stream = StreamReaderWrapper(process.stderr) if process.stderr else None + return Process(process, stdin_stream, stdout_stream, stderr_stream) + + @classmethod + def setup_process_pool_exit_at_shutdown(cls, workers: set[abc.Process]) -> None: + create_task( + _shutdown_process_pool_on_exit(workers), + name="AnyIO process pool shutdown task", + ) + find_root_task().add_done_callback( + partial(_forcibly_shutdown_process_pool_on_exit, workers) # type:ignore[arg-type] + ) + + @classmethod + async def connect_tcp( + cls, host: str, port: int, local_address: IPSockAddrType | None = None + ) -> abc.SocketStream: + transport, protocol = cast( + tuple[asyncio.Transport, StreamProtocol], + await get_running_loop().create_connection( + StreamProtocol, host, port, local_addr=local_address + ), + ) + transport.pause_reading() + return SocketStream(transport, protocol) + + @classmethod + async def connect_unix(cls, path: str | bytes) -> abc.UNIXSocketStream: + await cls.checkpoint() + loop = get_running_loop() + raw_socket = socket.socket(socket.AF_UNIX) + raw_socket.setblocking(False) + while True: + try: + raw_socket.connect(path) + except BlockingIOError: + f: asyncio.Future = asyncio.Future() + loop.add_writer(raw_socket, f.set_result, None) + f.add_done_callback(lambda _: loop.remove_writer(raw_socket)) + await f + except BaseException: + raw_socket.close() + raise + else: + return UNIXSocketStream(raw_socket) + + @classmethod + def create_tcp_listener(cls, sock: socket.socket) -> SocketListener: + return TCPSocketListener(sock) + + @classmethod + def create_unix_listener(cls, sock: socket.socket) -> SocketListener: + return UNIXSocketListener(sock) + + @classmethod + async def create_udp_socket( + cls, + family: AddressFamily, + local_address: IPSockAddrType | None, + remote_address: IPSockAddrType | None, + reuse_port: bool, + ) -> UDPSocket | ConnectedUDPSocket: + transport, protocol = await get_running_loop().create_datagram_endpoint( + DatagramProtocol, + local_addr=local_address, + remote_addr=remote_address, + family=family, + reuse_port=reuse_port, + ) + if protocol.exception: + transport.close() + raise protocol.exception + + if not remote_address: + return UDPSocket(transport, protocol) + else: + return ConnectedUDPSocket(transport, protocol) + + @classmethod + async def create_unix_datagram_socket( # type: ignore[override] + cls, raw_socket: socket.socket, remote_path: str | bytes | None + ) -> abc.UNIXDatagramSocket | abc.ConnectedUNIXDatagramSocket: + await cls.checkpoint() + loop = get_running_loop() + + if remote_path: + while True: + try: + raw_socket.connect(remote_path) + except BlockingIOError: + f: asyncio.Future = asyncio.Future() + loop.add_writer(raw_socket, f.set_result, None) + f.add_done_callback(lambda _: loop.remove_writer(raw_socket)) + await f + except BaseException: + raw_socket.close() + raise + else: + return ConnectedUNIXDatagramSocket(raw_socket) + else: + return UNIXDatagramSocket(raw_socket) + + @classmethod + async def getaddrinfo( + cls, + host: bytes | str | None, + port: str | int | None, + *, + family: int | AddressFamily = 0, + type: int | SocketKind = 0, + proto: int = 0, + flags: int = 0, + ) -> Sequence[ + tuple[ + AddressFamily, + SocketKind, + int, + str, + tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes], + ] + ]: + return await get_running_loop().getaddrinfo( + host, port, family=family, type=type, proto=proto, flags=flags + ) + + @classmethod + async def getnameinfo( + cls, sockaddr: IPSockAddrType, flags: int = 0 + ) -> tuple[str, str]: + return await get_running_loop().getnameinfo(sockaddr, flags) + + @classmethod + async def wait_readable(cls, obj: FileDescriptorLike) -> None: + try: + read_events = _read_events.get() + except LookupError: + read_events = {} + _read_events.set(read_events) + + fd = obj if isinstance(obj, int) else obj.fileno() + if read_events.get(fd): + raise BusyResourceError("reading from") + + loop = get_running_loop() + fut: asyncio.Future[bool] = loop.create_future() + + def cb() -> None: + try: + del read_events[fd] + except KeyError: + pass + else: + remove_reader(fd) + + try: + fut.set_result(True) + except asyncio.InvalidStateError: + pass + + try: + loop.add_reader(fd, cb) + except NotImplementedError: + from anyio._core._asyncio_selector_thread import get_selector + + selector = get_selector() + selector.add_reader(fd, cb) + remove_reader = selector.remove_reader + else: + remove_reader = loop.remove_reader + + read_events[fd] = fut + try: + success = await fut + finally: + try: + del read_events[fd] + except KeyError: + pass + else: + remove_reader(fd) + + if not success: + raise ClosedResourceError + + @classmethod + async def wait_writable(cls, obj: FileDescriptorLike) -> None: + try: + write_events = _write_events.get() + except LookupError: + write_events = {} + _write_events.set(write_events) + + fd = obj if isinstance(obj, int) else obj.fileno() + if write_events.get(fd): + raise BusyResourceError("writing to") + + loop = get_running_loop() + fut: asyncio.Future[bool] = loop.create_future() + + def cb() -> None: + try: + del write_events[fd] + except KeyError: + pass + else: + remove_writer(fd) + + try: + fut.set_result(True) + except asyncio.InvalidStateError: + pass + + try: + loop.add_writer(fd, cb) + except NotImplementedError: + from anyio._core._asyncio_selector_thread import get_selector + + selector = get_selector() + selector.add_writer(fd, cb) + remove_writer = selector.remove_writer + else: + remove_writer = loop.remove_writer + + write_events[fd] = fut + try: + success = await fut + finally: + try: + del write_events[fd] + except KeyError: + pass + else: + remove_writer(fd) + + if not success: + raise ClosedResourceError + + @classmethod + def notify_closing(cls, obj: FileDescriptorLike) -> None: + fd = obj if isinstance(obj, int) else obj.fileno() + loop = get_running_loop() + + try: + write_events = _write_events.get() + except LookupError: + pass + else: + try: + fut = write_events.pop(fd) + except KeyError: + pass + else: + try: + fut.set_result(False) + except asyncio.InvalidStateError: + pass + + try: + loop.remove_writer(fd) + except NotImplementedError: + from anyio._core._asyncio_selector_thread import get_selector + + get_selector().remove_writer(fd) + + try: + read_events = _read_events.get() + except LookupError: + pass + else: + try: + fut = read_events.pop(fd) + except KeyError: + pass + else: + try: + fut.set_result(False) + except asyncio.InvalidStateError: + pass + + try: + loop.remove_reader(fd) + except NotImplementedError: + from anyio._core._asyncio_selector_thread import get_selector + + get_selector().remove_reader(fd) + + @classmethod + async def wrap_listener_socket(cls, sock: socket.socket) -> SocketListener: + if hasattr(socket, "AF_UNIX") and sock.family == socket.AF_UNIX: + return UNIXSocketListener(sock) + + return TCPSocketListener(sock) + + @classmethod + async def wrap_stream_socket(cls, sock: socket.socket) -> SocketStream: + transport, protocol = await get_running_loop().create_connection( + StreamProtocol, sock=sock + ) + return SocketStream(transport, protocol) + + @classmethod + async def wrap_unix_stream_socket(cls, sock: socket.socket) -> UNIXSocketStream: + return UNIXSocketStream(sock) + + @classmethod + async def wrap_udp_socket(cls, sock: socket.socket) -> UDPSocket: + transport, protocol = await get_running_loop().create_datagram_endpoint( + DatagramProtocol, sock=sock + ) + return UDPSocket(transport, protocol) + + @classmethod + async def wrap_connected_udp_socket(cls, sock: socket.socket) -> ConnectedUDPSocket: + transport, protocol = await get_running_loop().create_datagram_endpoint( + DatagramProtocol, sock=sock + ) + return ConnectedUDPSocket(transport, protocol) + + @classmethod + async def wrap_unix_datagram_socket(cls, sock: socket.socket) -> UNIXDatagramSocket: + return UNIXDatagramSocket(sock) + + @classmethod + async def wrap_connected_unix_datagram_socket( + cls, sock: socket.socket + ) -> ConnectedUNIXDatagramSocket: + return ConnectedUNIXDatagramSocket(sock) + + @classmethod + def current_default_thread_limiter(cls) -> CapacityLimiter: + try: + return _default_thread_limiter.get() + except LookupError: + limiter = CapacityLimiter(40) + _default_thread_limiter.set(limiter) + return limiter + + @classmethod + def open_signal_receiver( + cls, *signals: Signals + ) -> AbstractContextManager[AsyncIterator[Signals]]: + return _SignalReceiver(signals) + + @classmethod + def get_current_task(cls) -> TaskInfo: + return AsyncIOTaskInfo(current_task()) # type: ignore[arg-type] + + @classmethod + def get_running_tasks(cls) -> Sequence[TaskInfo]: + return [AsyncIOTaskInfo(task) for task in all_tasks() if not task.done()] + + @classmethod + async def wait_all_tasks_blocked(cls) -> None: + await cls.checkpoint() + this_task = current_task() + while True: + for task in all_tasks(): + if task is this_task: + continue + + waiter = task._fut_waiter # type: ignore[attr-defined] + if waiter is None or waiter.done(): + await sleep(0.1) + break + else: + return + + @classmethod + def create_test_runner(cls, options: dict[str, Any]) -> TestRunner: + return TestRunner(**options) + + +backend_class = AsyncIOBackend diff --git a/server/venv/Lib/site-packages/anyio/_backends/_trio.py b/server/venv/Lib/site-packages/anyio/_backends/_trio.py new file mode 100644 index 0000000..091c78c --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_backends/_trio.py @@ -0,0 +1,1456 @@ +from __future__ import annotations + +import array +import math +import os +import socket +import sys +import types +import weakref +from collections.abc import ( + AsyncGenerator, + AsyncIterator, + Awaitable, + Callable, + Collection, + Coroutine, + Iterable, + Sequence, +) +from contextlib import AbstractContextManager +from contextvars import Context +from dataclasses import dataclass +from functools import partial, wraps +from io import IOBase +from os import PathLike +from signal import Signals +from socket import AddressFamily, SocketKind +from types import TracebackType +from typing import ( + IO, + TYPE_CHECKING, + Any, + Generic, + Literal, + NoReturn, + ParamSpec, + TypeVar, + cast, + overload, +) + +import trio.from_thread +import trio.lowlevel +from outcome import Error, Outcome, Value +from trio.lowlevel import ( + current_root_task, + current_task, + notify_closing, + wait_readable, + wait_writable, +) +from trio.socket import SocketType as TrioSocketType +from trio.to_thread import run_sync + +from .. import ( + CapacityLimiterStatistics, + EventStatistics, + LockStatistics, + RunFinishedError, + TaskInfo, + WouldBlock, + abc, +) +from .._core._eventloop import claim_worker_thread +from .._core._exceptions import ( + BrokenResourceError, + BusyResourceError, + ClosedResourceError, + EndOfStream, +) +from .._core._sockets import convert_ipv6_sockaddr +from .._core._streams import create_memory_object_stream +from .._core._synchronization import ( + CapacityLimiter as BaseCapacityLimiter, +) +from .._core._synchronization import Event as BaseEvent +from .._core._synchronization import Lock as BaseLock +from .._core._synchronization import ( + ResourceGuard, + SemaphoreStatistics, +) +from .._core._synchronization import Semaphore as BaseSemaphore +from .._core._tasks import CancelScope as BaseCancelScope +from .._core._tasks import TaskHandle +from ..abc import IPSockAddrType, UDPPacketType, UNIXDatagramPacketType +from ..abc._eventloop import AsyncBackend, StrOrBytesPath +from ..abc._tasks import T_contra, call_for_coroutine, get_callable_name +from ..streams.memory import MemoryObjectSendStream + +if TYPE_CHECKING: + from _typeshed import FileDescriptorLike + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from exceptiongroup import BaseExceptionGroup + from typing_extensions import TypeVarTuple, Unpack + +T = TypeVar("T") +T_Retval = TypeVar("T_Retval") +T_co = TypeVar("T_co", covariant=True) +T_SockAddr = TypeVar("T_SockAddr", str, IPSockAddrType) +PosArgsT = TypeVarTuple("PosArgsT") +P = ParamSpec("P") + + +def ensure_returns_coro( + func: Callable[P, Awaitable[T_Retval]], +) -> Callable[P, Coroutine[Any, Any, T_Retval]]: + @wraps(func) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, T_Retval]: + awaitable = func(*args, **kwargs) + # Check the common case first. + if isinstance(awaitable, Coroutine): + return awaitable + elif not isinstance(awaitable, Awaitable): + # The user violated the type annotations. Still, we should pass this on to + # Trio so it can raise with an appropriate message. + return awaitable + else: + + @wraps(func) + async def inner_wrapper() -> T_Retval: + return await awaitable + + return inner_wrapper() + + return wrapper + + +# +# Event loop +# + +RunVar = trio.lowlevel.RunVar + + +# +# Timeouts and cancellation +# + + +class CancelScope(BaseCancelScope): + __slots__ = ("__original",) + + def __new__( + cls, original: trio.CancelScope | None = None, **kwargs: object + ) -> CancelScope: + return object.__new__(cls) + + def __init__(self, original: trio.CancelScope | None = None, **kwargs: Any) -> None: + self.__original = original or trio.CancelScope(**kwargs) + + def __enter__(self) -> CancelScope: + self.__original.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + return self.__original.__exit__(exc_type, exc_val, exc_tb) + + def cancel(self, reason: str | None = None) -> None: + self.__original.cancel(reason) + + @property + def deadline(self) -> float: + return self.__original.deadline + + @deadline.setter + def deadline(self, value: float) -> None: + self.__original.deadline = value + + @property + def cancel_called(self) -> bool: + return self.__original.cancel_called + + @property + def cancelled_caught(self) -> bool: + return self.__original.cancelled_caught + + @property + def shield(self) -> bool: + return self.__original.shield + + @shield.setter + def shield(self, value: bool) -> None: + self.__original.shield = value + + +# +# Task groups +# + +empty_start_value = object() + + +class _TrioTaskStatus(Generic[T_contra], abc.TaskStatus[T_contra]): + early_start_value: T_contra | object = empty_start_value + real_task_status: trio.TaskStatus[T_contra | None] | None = None + + def started(self, value: T_contra | None = None) -> None: + if self.real_task_status is None: + if self.early_start_value is not empty_start_value: + raise RuntimeError("called 'started' twice on the same task status") + + self.early_start_value = value + else: + self.real_task_status.started(value) + + +class TaskGroup(abc.TaskGroup): + def __init__(self) -> None: + self._entered = False + self._active = False + self._nursery_manager = trio.open_nursery(strict_exception_groups=True) + self.cancel_scope = None # type: ignore[assignment] + + async def __aenter__(self) -> TaskGroup: + if self._entered: + raise RuntimeError("TaskGroup cannot be entered more than once") + + self._entered = True + self._active = True + self._nursery = await self._nursery_manager.__aenter__() + self.cancel_scope = CancelScope(self._nursery.cancel_scope) + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + try: + # trio.Nursery.__exit__ returns bool; .open_nursery has wrong type + return await self._nursery_manager.__aexit__(exc_type, exc_val, exc_tb) # type: ignore[return-value] + except BaseExceptionGroup as exc: + if not exc.split(trio.Cancelled)[1]: + raise trio.Cancelled._create() from exc + + raise + finally: + del exc_val, exc_tb + self._active = False + + def _check_active(self, coro: Coroutine | None = None) -> None: + if not self._active: + if coro is not None: + coro.close() + + raise RuntimeError( + "This task group is not active; no new tasks can be started." + ) + + def create_task( + self, + coro: Coroutine[Any, Any, T_co], + *, + name: object = None, + context: Context | None = None, + ) -> TaskHandle[T_co]: + if not isinstance(coro, Coroutine): + raise TypeError(f"expected a coroutine, got {coro.__class__.__qualname__}") + + self._check_active(coro) + handle = TaskHandle(coro, name) + if context is not None: + context.run( + partial(self._nursery.start_soon, handle._run_coro, name=handle.name) + ) + else: + self._nursery.start_soon(handle._run_coro, name=handle.name) + + return handle + + async def start( + self, + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + *args: Unpack[PosArgsT], + name: object = None, + return_handle: Literal[False] | Literal[True] = False, + ) -> Any: + handle: TaskHandle[T_co] + + async def run_coro_with_task_status( + *, task_status: trio.TaskStatus[Any] + ) -> None: + nonlocal handle + wrapper_task_status = _TrioTaskStatus() + coro = call_for_coroutine(func, args, task_status=wrapper_task_status) + if wrapper_task_status.early_start_value is not empty_start_value: + task_status.started(wrapper_task_status.early_start_value) + else: + wrapper_task_status.real_task_status = task_status + + handle = TaskHandle(coro, name) + await handle._run_coro() + + self._check_active() + final_name = get_callable_name(func, name) + start_value = await self._nursery.start( + run_coro_with_task_status, name=final_name + ) + if return_handle: + handle._start_value = start_value + return handle + else: + return start_value + + +# +# Subprocesses +# + + +@dataclass(eq=False) +class ReceiveStreamWrapper(abc.ByteReceiveStream): + _stream: trio.abc.ReceiveStream + + async def receive(self, max_bytes: int | None = None) -> bytes: + try: + data = await self._stream.receive_some(max_bytes) + except trio.ClosedResourceError as exc: + raise ClosedResourceError from exc.__cause__ + except trio.BrokenResourceError as exc: + raise BrokenResourceError from exc.__cause__ + + if data: + return bytes(data) + else: + raise EndOfStream + + async def aclose(self) -> None: + await self._stream.aclose() + + +@dataclass(eq=False) +class SendStreamWrapper(abc.ByteSendStream): + _stream: trio.abc.SendStream + + async def send(self, item: bytes) -> None: + try: + await self._stream.send_all(item) + except trio.ClosedResourceError as exc: + raise ClosedResourceError from exc.__cause__ + except trio.BrokenResourceError as exc: + raise BrokenResourceError from exc.__cause__ + + async def aclose(self) -> None: + await self._stream.aclose() + + +@dataclass(eq=False) +class Process(abc.Process): + _process: trio.Process + _stdin: abc.ByteSendStream | None + _stdout: abc.ByteReceiveStream | None + _stderr: abc.ByteReceiveStream | None + + async def aclose(self) -> None: + with CancelScope(shield=True): + if self._stdin: + await self._stdin.aclose() + if self._stdout: + await self._stdout.aclose() + if self._stderr: + await self._stderr.aclose() + + try: + await self.wait() + except BaseException: + self.kill() + with CancelScope(shield=True): + await self.wait() + raise + + async def wait(self) -> int: + return await self._process.wait() + + def terminate(self) -> None: + self._process.terminate() + + def kill(self) -> None: + self._process.kill() + + def send_signal(self, signal: Signals) -> None: + self._process.send_signal(signal) + + @property + def pid(self) -> int: + return self._process.pid + + @property + def returncode(self) -> int | None: + return self._process.returncode + + @property + def stdin(self) -> abc.ByteSendStream | None: + return self._stdin + + @property + def stdout(self) -> abc.ByteReceiveStream | None: + return self._stdout + + @property + def stderr(self) -> abc.ByteReceiveStream | None: + return self._stderr + + +class _ProcessPoolShutdownInstrument(trio.abc.Instrument): + def after_run(self) -> None: + super().after_run() + + +current_default_worker_process_limiter: trio.lowlevel.RunVar = RunVar( + "current_default_worker_process_limiter" +) + + +async def _shutdown_process_pool(workers: set[abc.Process]) -> None: + try: + await trio.sleep(math.inf) + except trio.Cancelled: + for process in workers: + if process.returncode is None: + process.kill() + + with CancelScope(shield=True): + for process in workers: + await process.aclose() + + +# +# Sockets and networking +# + + +class _TrioSocketMixin(Generic[T_SockAddr]): + def __init__(self, trio_socket: TrioSocketType) -> None: + self._trio_socket = trio_socket + self._closed = False + + def _check_closed(self) -> None: + if self._closed: + raise ClosedResourceError + if self._trio_socket.fileno() < 0: + raise BrokenResourceError + + @property + def _raw_socket(self) -> socket.socket: + return self._trio_socket._sock # type: ignore[attr-defined] + + async def aclose(self) -> None: + if self._trio_socket.fileno() >= 0: + self._closed = True + self._trio_socket.close() + + def _convert_socket_error(self, exc: BaseException) -> NoReturn: + if isinstance(exc, trio.ClosedResourceError): + raise ClosedResourceError from exc + elif self._trio_socket.fileno() < 0 and self._closed: + raise ClosedResourceError from None + elif isinstance(exc, OSError): + raise BrokenResourceError from exc + else: + raise exc + + +class SocketStream(_TrioSocketMixin, abc.SocketStream): + def __init__(self, trio_socket: TrioSocketType) -> None: + super().__init__(trio_socket) + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + + async def receive(self, max_bytes: int = 65536) -> bytes: + with self._receive_guard: + try: + data = await self._trio_socket.recv(max_bytes) + except BaseException as exc: + self._convert_socket_error(exc) + + if data: + return data + else: + raise EndOfStream + + async def send(self, item: bytes) -> None: + with self._send_guard: + view = memoryview(item) + while view: + try: + bytes_sent = await self._trio_socket.send(view) + except BaseException as exc: + self._convert_socket_error(exc) + + view = view[bytes_sent:] + + async def send_eof(self) -> None: + self._trio_socket.shutdown(socket.SHUT_WR) + + +class UNIXSocketStream(SocketStream, abc.UNIXSocketStream): + async def receive_fds(self, msglen: int, maxfds: int) -> tuple[bytes, list[int]]: + if not isinstance(msglen, int) or msglen < 0: + raise ValueError("msglen must be a non-negative integer") + if not isinstance(maxfds, int) or maxfds < 1: + raise ValueError("maxfds must be a positive integer") + + fds = array.array("i") + await trio.lowlevel.checkpoint() + with self._receive_guard: + while True: + try: + message, ancdata, flags, addr = await self._trio_socket.recvmsg( + msglen, socket.CMSG_LEN(maxfds * fds.itemsize) + ) + except BaseException as exc: + self._convert_socket_error(exc) + else: + if not message and not ancdata: + raise EndOfStream + + break + + for cmsg_level, cmsg_type, cmsg_data in ancdata: + if cmsg_level != socket.SOL_SOCKET or cmsg_type != socket.SCM_RIGHTS: + raise RuntimeError( + f"Received unexpected ancillary data; message = {message!r}, " + f"cmsg_level = {cmsg_level}, cmsg_type = {cmsg_type}" + ) + + fds.frombytes(cmsg_data[: len(cmsg_data) - (len(cmsg_data) % fds.itemsize)]) + + return message, list(fds) + + async def send_fds(self, message: bytes, fds: Collection[int | IOBase]) -> None: + if not message: + raise ValueError("message must not be empty") + if not fds: + raise ValueError("fds must not be empty") + + filenos: list[int] = [] + for fd in fds: + if isinstance(fd, int): + filenos.append(fd) + elif isinstance(fd, IOBase): + filenos.append(fd.fileno()) + + fdarray = array.array("i", filenos) + await trio.lowlevel.checkpoint() + with self._send_guard: + while True: + try: + await self._trio_socket.sendmsg( + [message], + [ + ( + socket.SOL_SOCKET, + socket.SCM_RIGHTS, + fdarray, + ) + ], + ) + break + except BaseException as exc: + self._convert_socket_error(exc) + + +class TCPSocketListener(_TrioSocketMixin, abc.SocketListener): + def __init__(self, raw_socket: socket.socket): + super().__init__(trio.socket.from_stdlib_socket(raw_socket)) + self._accept_guard = ResourceGuard("accepting connections from") + + async def accept(self) -> SocketStream: + with self._accept_guard: + try: + trio_socket, _addr = await self._trio_socket.accept() + except BaseException as exc: + self._convert_socket_error(exc) + + trio_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return SocketStream(trio_socket) + + +class UNIXSocketListener(_TrioSocketMixin, abc.SocketListener): + def __init__(self, raw_socket: socket.socket): + super().__init__(trio.socket.from_stdlib_socket(raw_socket)) + self._accept_guard = ResourceGuard("accepting connections from") + + async def accept(self) -> UNIXSocketStream: + with self._accept_guard: + try: + trio_socket, _addr = await self._trio_socket.accept() + except BaseException as exc: + self._convert_socket_error(exc) + + return UNIXSocketStream(trio_socket) + + +class UDPSocket(_TrioSocketMixin[IPSockAddrType], abc.UDPSocket): + def __init__(self, trio_socket: TrioSocketType) -> None: + super().__init__(trio_socket) + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + + async def receive(self) -> tuple[bytes, IPSockAddrType]: + with self._receive_guard: + try: + data, addr = await self._trio_socket.recvfrom(65536) + return data, convert_ipv6_sockaddr(addr) + except BaseException as exc: + self._convert_socket_error(exc) + + async def send(self, item: UDPPacketType) -> None: + with self._send_guard: + try: + await self._trio_socket.sendto(*item) + except BaseException as exc: + self._convert_socket_error(exc) + + +class ConnectedUDPSocket(_TrioSocketMixin[IPSockAddrType], abc.ConnectedUDPSocket): + def __init__(self, trio_socket: TrioSocketType) -> None: + super().__init__(trio_socket) + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + + async def receive(self) -> bytes: + with self._receive_guard: + try: + return await self._trio_socket.recv(65536) + except BaseException as exc: + self._convert_socket_error(exc) + + async def send(self, item: bytes) -> None: + with self._send_guard: + try: + await self._trio_socket.send(item) + except BaseException as exc: + self._convert_socket_error(exc) + + +class UNIXDatagramSocket(_TrioSocketMixin[str], abc.UNIXDatagramSocket): + def __init__(self, trio_socket: TrioSocketType) -> None: + super().__init__(trio_socket) + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + + async def receive(self) -> UNIXDatagramPacketType: + with self._receive_guard: + try: + data, addr = await self._trio_socket.recvfrom(65536) + return data, addr + except BaseException as exc: + self._convert_socket_error(exc) + + async def send(self, item: UNIXDatagramPacketType) -> None: + with self._send_guard: + try: + await self._trio_socket.sendto(*item) + except BaseException as exc: + self._convert_socket_error(exc) + + +class ConnectedUNIXDatagramSocket( + _TrioSocketMixin[str], abc.ConnectedUNIXDatagramSocket +): + def __init__(self, trio_socket: TrioSocketType) -> None: + super().__init__(trio_socket) + self._receive_guard = ResourceGuard("reading from") + self._send_guard = ResourceGuard("writing to") + + async def receive(self) -> bytes: + with self._receive_guard: + try: + return await self._trio_socket.recv(65536) + except BaseException as exc: + self._convert_socket_error(exc) + + async def send(self, item: bytes) -> None: + with self._send_guard: + try: + await self._trio_socket.send(item) + except BaseException as exc: + self._convert_socket_error(exc) + + +# +# Synchronization +# + + +class Event(BaseEvent): + __slots__ = ("__original",) + + def __new__(cls) -> Event: + return object.__new__(cls) + + def __init__(self) -> None: + self.__original = trio.Event() + + def is_set(self) -> bool: + return self.__original.is_set() + + async def wait(self) -> None: + return await self.__original.wait() + + def statistics(self) -> EventStatistics: + orig_statistics = self.__original.statistics() + return EventStatistics(tasks_waiting=orig_statistics.tasks_waiting) + + def set(self) -> None: + self.__original.set() + + +class Lock(BaseLock): + __slots__ = "_fast_acquire", "__original" + + def __new__(cls, *, fast_acquire: bool = False) -> Lock: + return object.__new__(cls) + + def __init__(self, *, fast_acquire: bool = False) -> None: + self._fast_acquire = fast_acquire + self.__original = trio.Lock() + + @staticmethod + def _convert_runtime_error_msg(exc: RuntimeError) -> None: + if exc.args == ("attempt to re-acquire an already held Lock",): + exc.args = ("Attempted to acquire an already held Lock",) + + async def acquire(self) -> None: + if not self._fast_acquire: + try: + await self.__original.acquire() + except RuntimeError as exc: + self._convert_runtime_error_msg(exc) + raise + + return + + # This is the "fast path" where we don't let other tasks run + await trio.lowlevel.checkpoint_if_cancelled() + try: + self.__original.acquire_nowait() + except trio.WouldBlock: + await self.__original._lot.park() + except RuntimeError as exc: + self._convert_runtime_error_msg(exc) + raise + + def acquire_nowait(self) -> None: + try: + self.__original.acquire_nowait() + except trio.WouldBlock: + raise WouldBlock from None + except RuntimeError as exc: + self._convert_runtime_error_msg(exc) + raise + + def locked(self) -> bool: + return self.__original.locked() + + def release(self) -> None: + self.__original.release() + + def statistics(self) -> LockStatistics: + orig_statistics = self.__original.statistics() + owner = TrioTaskInfo(orig_statistics.owner) if orig_statistics.owner else None + return LockStatistics( + orig_statistics.locked, owner, orig_statistics.tasks_waiting + ) + + +class Semaphore(BaseSemaphore): + __slots__ = ("__original",) + + def __new__( + cls, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> Semaphore: + return object.__new__(cls) + + def __init__( + self, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> None: + super().__init__(initial_value, max_value=max_value, fast_acquire=fast_acquire) + self.__original = trio.Semaphore(initial_value, max_value=max_value) + + async def acquire(self) -> None: + if not self._fast_acquire: + await self.__original.acquire() + return + + # This is the "fast path" where we don't let other tasks run + await trio.lowlevel.checkpoint_if_cancelled() + try: + self.__original.acquire_nowait() + except trio.WouldBlock: + await self.__original._lot.park() + + def acquire_nowait(self) -> None: + try: + self.__original.acquire_nowait() + except trio.WouldBlock: + raise WouldBlock from None + + @property + def max_value(self) -> int | None: + return self.__original.max_value + + @property + def value(self) -> int: + return self.__original.value + + def release(self) -> None: + self.__original.release() + + def statistics(self) -> SemaphoreStatistics: + orig_statistics = self.__original.statistics() + return SemaphoreStatistics(orig_statistics.tasks_waiting) + + +class CapacityLimiter(BaseCapacityLimiter): + __slots__ = ("__original",) + + def __new__( + cls, + total_tokens: float | None = None, + *, + original: trio.CapacityLimiter | None = None, + ) -> CapacityLimiter: + return object.__new__(cls) + + def __init__( + self, + total_tokens: float | None = None, + *, + original: trio.CapacityLimiter | None = None, + ) -> None: + if original is not None: + self.__original = original + else: + assert total_tokens is not None + self.__original = trio.CapacityLimiter(total_tokens) + + async def __aenter__(self) -> None: + return await self.__original.__aenter__() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.__original.__aexit__(exc_type, exc_val, exc_tb) + + @property + def total_tokens(self) -> float: + return self.__original.total_tokens + + @total_tokens.setter + def total_tokens(self, value: float) -> None: + self.__original.total_tokens = value + + @property + def borrowed_tokens(self) -> int: + return self.__original.borrowed_tokens + + @property + def available_tokens(self) -> float: + return self.__original.available_tokens + + def acquire_nowait(self) -> None: + self.__original.acquire_nowait() + + def acquire_on_behalf_of_nowait(self, borrower: object) -> None: + self.__original.acquire_on_behalf_of_nowait(borrower) + + async def acquire(self) -> None: + await self.__original.acquire() + + async def acquire_on_behalf_of(self, borrower: object) -> None: + await self.__original.acquire_on_behalf_of(borrower) + + def release(self) -> None: + return self.__original.release() + + def release_on_behalf_of(self, borrower: object) -> None: + return self.__original.release_on_behalf_of(borrower) + + def statistics(self) -> CapacityLimiterStatistics: + orig = self.__original.statistics() + return CapacityLimiterStatistics( + borrowed_tokens=orig.borrowed_tokens, + total_tokens=orig.total_tokens, + borrowers=tuple(orig.borrowers), + tasks_waiting=orig.tasks_waiting, + ) + + +_capacity_limiter_wrapper: trio.lowlevel.RunVar = RunVar("_capacity_limiter_wrapper") + + +# +# Signal handling +# + + +class _SignalReceiver: + _iterator: AsyncIterator[int] + + def __init__(self, signals: tuple[Signals, ...]): + self._signals = signals + + def __enter__(self) -> _SignalReceiver: + self._cm = trio.open_signal_receiver(*self._signals) + self._iterator = self._cm.__enter__() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + return self._cm.__exit__(exc_type, exc_val, exc_tb) + + def __aiter__(self) -> _SignalReceiver: + return self + + async def __anext__(self) -> Signals: + signum = await self._iterator.__anext__() + return Signals(signum) + + +# +# Testing and debugging +# + + +class TestRunner(abc.TestRunner): + def __init__(self, **options: Any) -> None: + from queue import Queue + + self._call_queue: Queue[Callable[[], object]] = Queue() + self._send_stream: ( + MemoryObjectSendStream[tuple[Awaitable[Any], list[Outcome]]] | None + ) = None + self._options = options + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + if self._send_stream: + self._send_stream.close() + while self._send_stream is not None: + self._call_queue.get()() + + def is_running(self) -> bool: + return trio.lowlevel.in_trio_task() + + async def _run_tests_and_fixtures(self) -> None: + self._send_stream, receive_stream = create_memory_object_stream[ + tuple[Awaitable[Any], list[Outcome]] + ](1) + with receive_stream: + async for awaitable, outcome_holder in receive_stream: + try: + retval = await awaitable + except BaseException as exc: + outcome_holder.append(Error(exc)) + else: + outcome_holder.append(Value(retval)) + + def _main_task_finished(self, outcome: object) -> None: + self._send_stream = None + + def _call_in_runner_task( + self, + func: Callable[P, Awaitable[T_Retval]], + /, + *args: P.args, + **kwargs: P.kwargs, + ) -> T_Retval: + if self._send_stream is None: + trio.lowlevel.start_guest_run( + self._run_tests_and_fixtures, + run_sync_soon_threadsafe=self._call_queue.put, + done_callback=self._main_task_finished, + **self._options, + ) + while self._send_stream is None: + self._call_queue.get()() + + outcome_holder: list[Outcome] = [] + self._send_stream.send_nowait((func(*args, **kwargs), outcome_holder)) + while not outcome_holder: + self._call_queue.get()() + + return outcome_holder[0].unwrap() + + def run_asyncgen_fixture( + self, + fixture_func: Callable[..., AsyncGenerator[T_Retval, Any]], + kwargs: dict[str, Any], + ) -> Iterable[T_Retval]: + asyncgen = fixture_func(**kwargs) + fixturevalue: T_Retval = self._call_in_runner_task(asyncgen.asend, None) + + yield fixturevalue + + try: + self._call_in_runner_task(asyncgen.asend, None) + except StopAsyncIteration: + pass + else: + self._call_in_runner_task(asyncgen.aclose) + raise RuntimeError("Async generator fixture did not stop") + + def run_fixture( + self, + fixture_func: Callable[..., Coroutine[Any, Any, T_Retval]], + kwargs: dict[str, Any], + ) -> T_Retval: + return self._call_in_runner_task(fixture_func, **kwargs) + + def run_test( + self, test_func: Callable[..., Coroutine[Any, Any, Any]], kwargs: dict[str, Any] + ) -> None: + self._call_in_runner_task(test_func, **kwargs) + + +class TrioTaskInfo(TaskInfo): + def __init__(self, task: trio.lowlevel.Task): + parent_id = None + if task.parent_nursery and task.parent_nursery.parent_task: + parent_id = id(task.parent_nursery.parent_task) + + super().__init__(id(task), parent_id, task.name, task.coro) + self._task = weakref.proxy(task) + + def has_pending_cancellation(self) -> bool: + try: + return self._task._cancel_status.effectively_cancelled + except ReferenceError: + # If the task is no longer around, it surely doesn't have a cancellation + # pending + return False + + +class TrioBackend(AsyncBackend): + @classmethod + def run( + cls, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], + args: tuple[Unpack[PosArgsT]], + kwargs: dict[str, Any], + options: dict[str, Any], + ) -> T_Retval: + assert not kwargs, "unreachable, and not supported by Trio" + return trio.run(ensure_returns_coro(func), *args, **options) + + @classmethod + def current_token(cls) -> object: + return trio.lowlevel.current_trio_token() + + @classmethod + def current_time(cls) -> float: + return trio.current_time() + + @classmethod + def cancelled_exception_class(cls) -> type[BaseException]: + return trio.Cancelled + + @classmethod + async def checkpoint(cls) -> None: + await trio.lowlevel.checkpoint() + + @classmethod + async def checkpoint_if_cancelled(cls) -> None: + await trio.lowlevel.checkpoint_if_cancelled() + + @classmethod + async def cancel_shielded_checkpoint(cls) -> None: + await trio.lowlevel.cancel_shielded_checkpoint() + + @classmethod + async def sleep(cls, delay: float) -> None: + await trio.sleep(delay) + + @classmethod + def create_cancel_scope( + cls, *, deadline: float = math.inf, shield: bool = False + ) -> abc.CancelScope: + return CancelScope(deadline=deadline, shield=shield) + + @classmethod + def current_effective_deadline(cls) -> float: + return trio.current_effective_deadline() + + @classmethod + def create_task_group(cls) -> abc.TaskGroup: + return TaskGroup() + + @classmethod + def create_event(cls) -> abc.Event: + return Event() + + @classmethod + def create_lock(cls, *, fast_acquire: bool) -> Lock: + return Lock(fast_acquire=fast_acquire) + + @classmethod + def create_semaphore( + cls, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> abc.Semaphore: + return Semaphore(initial_value, max_value=max_value, fast_acquire=fast_acquire) + + @classmethod + def create_capacity_limiter(cls, total_tokens: float) -> CapacityLimiter: + return CapacityLimiter(total_tokens) + + @classmethod + async def run_sync_in_worker_thread( + cls, + func: Callable[[Unpack[PosArgsT]], T_Retval], + args: tuple[Unpack[PosArgsT]], + abandon_on_cancel: bool = False, + limiter: abc.CapacityLimiter | None = None, + ) -> T_Retval: + def wrapper() -> T_Retval: + with claim_worker_thread(TrioBackend, token): + return func(*args) + + token = TrioBackend.current_token() + return await run_sync( + wrapper, + abandon_on_cancel=abandon_on_cancel, + limiter=cast(trio.CapacityLimiter, limiter), + ) + + @classmethod + def check_cancelled(cls) -> None: + trio.from_thread.check_cancelled() + + @classmethod + def run_async_from_thread( + cls, + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + args: tuple[Unpack[PosArgsT]], + token: object, + ) -> T_co: + trio_token = cast("trio.lowlevel.TrioToken | None", token) + try: + return trio.from_thread.run(func, *args, trio_token=trio_token) + except trio.RunFinishedError: + raise RunFinishedError from None + + @classmethod + def run_sync_from_thread( + cls, + func: Callable[[Unpack[PosArgsT]], T_Retval], + args: tuple[Unpack[PosArgsT]], + token: object, + ) -> T_Retval: + trio_token = cast("trio.lowlevel.TrioToken | None", token) + try: + return trio.from_thread.run_sync(func, *args, trio_token=trio_token) + except trio.RunFinishedError: + raise RunFinishedError from None + + @classmethod + async def open_process( + cls, + command: StrOrBytesPath | Sequence[StrOrBytesPath], + *, + stdin: int | IO[Any] | None, + stdout: int | IO[Any] | None, + stderr: int | IO[Any] | None, + **kwargs: Any, + ) -> Process: + def convert_item(item: StrOrBytesPath) -> str: + str_or_bytes = os.fspath(item) + if isinstance(str_or_bytes, str): + return str_or_bytes + else: + return os.fsdecode(str_or_bytes) + + if isinstance(command, (str, bytes, PathLike)): + process = await trio.lowlevel.open_process( + convert_item(command), + stdin=stdin, + stdout=stdout, + stderr=stderr, + shell=True, + **kwargs, + ) + else: + process = await trio.lowlevel.open_process( + [convert_item(item) for item in command], + stdin=stdin, + stdout=stdout, + stderr=stderr, + shell=False, + **kwargs, + ) + + stdin_stream = SendStreamWrapper(process.stdin) if process.stdin else None + stdout_stream = ReceiveStreamWrapper(process.stdout) if process.stdout else None + stderr_stream = ReceiveStreamWrapper(process.stderr) if process.stderr else None + return Process(process, stdin_stream, stdout_stream, stderr_stream) + + @classmethod + def setup_process_pool_exit_at_shutdown(cls, workers: set[abc.Process]) -> None: + trio.lowlevel.spawn_system_task(_shutdown_process_pool, workers) + + @classmethod + async def connect_tcp( + cls, host: str, port: int, local_address: IPSockAddrType | None = None + ) -> SocketStream: + family = socket.AF_INET6 if ":" in host else socket.AF_INET + trio_socket = trio.socket.socket(family) + trio_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + if local_address: + await trio_socket.bind(local_address) + + try: + await trio_socket.connect((host, port)) + except BaseException: + trio_socket.close() + raise + + return SocketStream(trio_socket) + + @classmethod + async def connect_unix(cls, path: str | bytes) -> abc.UNIXSocketStream: + trio_socket = trio.socket.socket(socket.AF_UNIX) + try: + await trio_socket.connect(path) + except BaseException: + trio_socket.close() + raise + + return UNIXSocketStream(trio_socket) + + @classmethod + def create_tcp_listener(cls, sock: socket.socket) -> abc.SocketListener: + return TCPSocketListener(sock) + + @classmethod + def create_unix_listener(cls, sock: socket.socket) -> abc.SocketListener: + return UNIXSocketListener(sock) + + @classmethod + async def create_udp_socket( + cls, + family: socket.AddressFamily, + local_address: IPSockAddrType | None, + remote_address: IPSockAddrType | None, + reuse_port: bool, + ) -> UDPSocket | ConnectedUDPSocket: + trio_socket = trio.socket.socket(family=family, type=socket.SOCK_DGRAM) + + if reuse_port: + trio_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + + if local_address: + await trio_socket.bind(local_address) + + if remote_address: + await trio_socket.connect(remote_address) + return ConnectedUDPSocket(trio_socket) + else: + return UDPSocket(trio_socket) + + @classmethod + @overload + async def create_unix_datagram_socket( + cls, raw_socket: socket.socket, remote_path: None + ) -> abc.UNIXDatagramSocket: ... + + @classmethod + @overload + async def create_unix_datagram_socket( + cls, raw_socket: socket.socket, remote_path: str | bytes + ) -> abc.ConnectedUNIXDatagramSocket: ... + + @classmethod + async def create_unix_datagram_socket( + cls, raw_socket: socket.socket, remote_path: str | bytes | None + ) -> abc.UNIXDatagramSocket | abc.ConnectedUNIXDatagramSocket: + trio_socket = trio.socket.from_stdlib_socket(raw_socket) + + if remote_path: + await trio_socket.connect(remote_path) + return ConnectedUNIXDatagramSocket(trio_socket) + else: + return UNIXDatagramSocket(trio_socket) + + @classmethod + async def getaddrinfo( + cls, + host: bytes | str | None, + port: str | int | None, + *, + family: int | AddressFamily = 0, + type: int | SocketKind = 0, + proto: int = 0, + flags: int = 0, + ) -> Sequence[ + tuple[ + AddressFamily, + SocketKind, + int, + str, + tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes], + ] + ]: + return await trio.socket.getaddrinfo(host, port, family, type, proto, flags) + + @classmethod + async def getnameinfo( + cls, sockaddr: IPSockAddrType, flags: int = 0 + ) -> tuple[str, str]: + return await trio.socket.getnameinfo(sockaddr, flags) + + @classmethod + async def wait_readable(cls, obj: FileDescriptorLike) -> None: + try: + await wait_readable(obj) + except trio.ClosedResourceError as exc: + raise ClosedResourceError().with_traceback(exc.__traceback__) from None + except trio.BusyResourceError: + raise BusyResourceError("reading from") from None + + @classmethod + async def wait_writable(cls, obj: FileDescriptorLike) -> None: + try: + await wait_writable(obj) + except trio.ClosedResourceError as exc: + raise ClosedResourceError().with_traceback(exc.__traceback__) from None + except trio.BusyResourceError: + raise BusyResourceError("writing to") from None + + @classmethod + def notify_closing(cls, obj: FileDescriptorLike) -> None: + notify_closing(obj) + + @classmethod + async def wrap_listener_socket(cls, sock: socket.socket) -> abc.SocketListener: + if hasattr(socket, "AF_UNIX") and sock.family == socket.AF_UNIX: + return UNIXSocketListener(sock) + + return TCPSocketListener(sock) + + @classmethod + async def wrap_stream_socket(cls, sock: socket.socket) -> SocketStream: + trio_sock = trio.socket.from_stdlib_socket(sock) + return SocketStream(trio_sock) + + @classmethod + async def wrap_unix_stream_socket(cls, sock: socket.socket) -> UNIXSocketStream: + trio_sock = trio.socket.from_stdlib_socket(sock) + return UNIXSocketStream(trio_sock) + + @classmethod + async def wrap_udp_socket(cls, sock: socket.socket) -> UDPSocket: + trio_sock = trio.socket.from_stdlib_socket(sock) + return UDPSocket(trio_sock) + + @classmethod + async def wrap_connected_udp_socket(cls, sock: socket.socket) -> ConnectedUDPSocket: + trio_sock = trio.socket.from_stdlib_socket(sock) + return ConnectedUDPSocket(trio_sock) + + @classmethod + async def wrap_unix_datagram_socket(cls, sock: socket.socket) -> UNIXDatagramSocket: + trio_sock = trio.socket.from_stdlib_socket(sock) + return UNIXDatagramSocket(trio_sock) + + @classmethod + async def wrap_connected_unix_datagram_socket( + cls, sock: socket.socket + ) -> ConnectedUNIXDatagramSocket: + trio_sock = trio.socket.from_stdlib_socket(sock) + return ConnectedUNIXDatagramSocket(trio_sock) + + @classmethod + def current_default_thread_limiter(cls) -> CapacityLimiter: + try: + return _capacity_limiter_wrapper.get() + except LookupError: + limiter = CapacityLimiter( + original=trio.to_thread.current_default_thread_limiter() + ) + _capacity_limiter_wrapper.set(limiter) + return limiter + + @classmethod + def open_signal_receiver( + cls, *signals: Signals + ) -> AbstractContextManager[AsyncIterator[Signals]]: + return _SignalReceiver(signals) + + @classmethod + def get_current_task(cls) -> TaskInfo: + task = current_task() + return TrioTaskInfo(task) + + @classmethod + def get_running_tasks(cls) -> Sequence[TaskInfo]: + root_task = current_root_task() + assert root_task + task_infos = [TrioTaskInfo(root_task)] + nurseries = root_task.child_nurseries + while nurseries: + new_nurseries: list[trio.Nursery] = [] + for nursery in nurseries: + for task in nursery.child_tasks: + task_infos.append(TrioTaskInfo(task)) + new_nurseries.extend(task.child_nurseries) + + nurseries = new_nurseries + + return task_infos + + @classmethod + async def wait_all_tasks_blocked(cls) -> None: + from trio.testing import wait_all_tasks_blocked + + await wait_all_tasks_blocked() + + @classmethod + def create_test_runner(cls, options: dict[str, Any]) -> TestRunner: + return TestRunner(**options) + + +backend_class = TrioBackend diff --git a/server/venv/Lib/site-packages/anyio/_core/__init__.py b/server/venv/Lib/site-packages/anyio/_core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/anyio/_core/_asyncio_selector_thread.py b/server/venv/Lib/site-packages/anyio/_core/_asyncio_selector_thread.py new file mode 100644 index 0000000..9f35bae --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_asyncio_selector_thread.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import asyncio +import socket +import threading +from collections.abc import Callable +from selectors import EVENT_READ, EVENT_WRITE, DefaultSelector +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from _typeshed import FileDescriptorLike + +_selector_lock = threading.Lock() +_selector: Selector | None = None + + +class Selector: + def __init__(self) -> None: + self._thread = threading.Thread(target=self.run, name="AnyIO socket selector") + self._selector = DefaultSelector() + self._send, self._receive = socket.socketpair() + self._send.setblocking(False) + self._receive.setblocking(False) + # This somewhat reduces the amount of memory wasted queueing up data + # for wakeups. With these settings, maximum number of 1-byte sends + # before getting BlockingIOError: + # Linux 4.8: 6 + # macOS (darwin 15.5): 1 + # Windows 10: 525347 + # Windows you're weird. (And on Windows setting SNDBUF to 0 makes send + # blocking, even on non-blocking sockets, so don't do that.) + self._receive.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1) + self._send.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1) + # On Windows this is a TCP socket so this might matter. On other + # platforms this fails b/c AF_UNIX sockets aren't actually TCP. + try: + self._send.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + except OSError: + pass + + self._selector.register(self._receive, EVENT_READ) + self._closed = False + + def start(self) -> None: + self._thread.start() + threading._register_atexit(self._stop) # type: ignore[attr-defined] + + def _stop(self) -> None: + global _selector + self._closed = True + self._notify_self() + self._send.close() + self._thread.join() + self._selector.unregister(self._receive) + self._receive.close() + self._selector.close() + _selector = None + assert not self._selector.get_map(), ( + "selector still has registered file descriptors after shutdown" + ) + + def _notify_self(self) -> None: + try: + self._send.send(b"\x00") + except BlockingIOError: + pass + + def add_reader(self, fd: FileDescriptorLike, callback: Callable[[], Any]) -> None: + loop = asyncio.get_running_loop() + try: + key = self._selector.get_key(fd) + except KeyError: + self._selector.register(fd, EVENT_READ, {EVENT_READ: (loop, callback)}) + else: + if EVENT_READ in key.data: + raise ValueError( + "this file descriptor is already registered for reading" + ) + + key.data[EVENT_READ] = loop, callback + self._selector.modify(fd, key.events | EVENT_READ, key.data) + + self._notify_self() + + def add_writer(self, fd: FileDescriptorLike, callback: Callable[[], Any]) -> None: + loop = asyncio.get_running_loop() + try: + key = self._selector.get_key(fd) + except KeyError: + self._selector.register(fd, EVENT_WRITE, {EVENT_WRITE: (loop, callback)}) + else: + if EVENT_WRITE in key.data: + raise ValueError( + "this file descriptor is already registered for writing" + ) + + key.data[EVENT_WRITE] = loop, callback + self._selector.modify(fd, key.events | EVENT_WRITE, key.data) + + self._notify_self() + + def remove_reader(self, fd: FileDescriptorLike) -> bool: + try: + key = self._selector.get_key(fd) + except KeyError: + return False + + if new_events := key.events ^ EVENT_READ: + del key.data[EVENT_READ] + self._selector.modify(fd, new_events, key.data) + else: + self._selector.unregister(fd) + + return True + + def remove_writer(self, fd: FileDescriptorLike) -> bool: + try: + key = self._selector.get_key(fd) + except KeyError: + return False + + if new_events := key.events ^ EVENT_WRITE: + del key.data[EVENT_WRITE] + self._selector.modify(fd, new_events, key.data) + else: + self._selector.unregister(fd) + + return True + + def run(self) -> None: + while not self._closed: + for key, events in self._selector.select(): + if key.fileobj is self._receive: + try: + while self._receive.recv(4096): + pass + except BlockingIOError: + pass + + continue + + if events & EVENT_READ: + loop, callback = key.data[EVENT_READ] + self.remove_reader(key.fd) + try: + loop.call_soon_threadsafe(callback) + except RuntimeError: + pass # the loop was already closed + + if events & EVENT_WRITE: + loop, callback = key.data[EVENT_WRITE] + self.remove_writer(key.fd) + try: + loop.call_soon_threadsafe(callback) + except RuntimeError: + pass # the loop was already closed + + +def get_selector() -> Selector: + global _selector + + with _selector_lock: + if _selector is None: + _selector = Selector() + _selector.start() + + return _selector diff --git a/server/venv/Lib/site-packages/anyio/_core/_contextmanagers.py b/server/venv/Lib/site-packages/anyio/_core/_contextmanagers.py new file mode 100644 index 0000000..302f32b --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_contextmanagers.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from abc import abstractmethod +from contextlib import AbstractAsyncContextManager, AbstractContextManager +from inspect import isasyncgen, iscoroutine, isgenerator +from types import TracebackType +from typing import Protocol, TypeVar, cast, final + +_T_co = TypeVar("_T_co", covariant=True) +_ExitT_co = TypeVar("_ExitT_co", covariant=True, bound="bool | None") + + +class _SupportsCtxMgr(Protocol[_T_co, _ExitT_co]): + def __contextmanager__(self) -> AbstractContextManager[_T_co, _ExitT_co]: ... + + +class _SupportsAsyncCtxMgr(Protocol[_T_co, _ExitT_co]): + def __asynccontextmanager__( + self, + ) -> AbstractAsyncContextManager[_T_co, _ExitT_co]: ... + + +class ContextManagerMixin: + """ + Mixin class providing context manager functionality via a generator-based + implementation. + + This class allows you to implement a context manager via :meth:`__contextmanager__` + which should return a generator. The mechanics are meant to mirror those of + :func:`@contextmanager `. + + .. note:: Classes using this mix-in are not reentrant as context managers, meaning + that once you enter it, you can't re-enter before first exiting it. + + .. seealso:: :doc:`contextmanagers` + """ + + __cm: AbstractContextManager[object, bool | None] | None = None + + @final + def __enter__(self: _SupportsCtxMgr[_T_co, bool | None]) -> _T_co: + # Needed for mypy to assume self still has the __cm member + assert isinstance(self, ContextManagerMixin) + if self.__cm is not None: + raise RuntimeError( + f"this {self.__class__.__qualname__} has already been entered" + ) + + cm = self.__contextmanager__() + if not isinstance(cm, AbstractContextManager): + if isgenerator(cm): + raise TypeError( + "__contextmanager__() returned a generator object instead of " + "a context manager. Did you forget to add the @contextmanager " + "decorator?" + ) + + raise TypeError( + f"__contextmanager__() did not return a context manager object, " + f"but {cm.__class__!r}" + ) + + if cm is self: + raise TypeError( + f"{self.__class__.__qualname__}.__contextmanager__() returned " + f"self. Did you forget to add the @contextmanager decorator and a " + f"'yield' statement?" + ) + + value = cm.__enter__() + self.__cm = cm + return value + + @final + def __exit__( + self: _SupportsCtxMgr[object, _ExitT_co], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> _ExitT_co: + # Needed for mypy to assume self still has the __cm member + assert isinstance(self, ContextManagerMixin) + if self.__cm is None: + raise RuntimeError( + f"this {self.__class__.__qualname__} has not been entered yet" + ) + + # Prevent circular references + cm = self.__cm + del self.__cm + + return cast(_ExitT_co, cm.__exit__(exc_type, exc_val, exc_tb)) + + @abstractmethod + def __contextmanager__(self) -> AbstractContextManager[object, bool | None]: + """ + Implement your context manager logic here. + + This method **must** be decorated with + :func:`@contextmanager `. + + .. note:: Remember that the ``yield`` will raise any exception raised in the + enclosed context block, so use a ``finally:`` block to clean up resources! + + :return: a context manager object + """ + + +class AsyncContextManagerMixin: + """ + Mixin class providing async context manager functionality via a generator-based + implementation. + + This class allows you to implement a context manager via + :meth:`__asynccontextmanager__`. The mechanics are meant to mirror those of + :func:`@asynccontextmanager `. + + .. note:: Classes using this mix-in are not reentrant as context managers, meaning + that once you enter it, you can't re-enter before first exiting it. + + .. seealso:: :doc:`contextmanagers` + """ + + __cm: AbstractAsyncContextManager[object, bool | None] | None = None + + @final + async def __aenter__(self: _SupportsAsyncCtxMgr[_T_co, bool | None]) -> _T_co: + # Needed for mypy to assume self still has the __cm member + assert isinstance(self, AsyncContextManagerMixin) + if self.__cm is not None: + raise RuntimeError( + f"this {self.__class__.__qualname__} has already been entered" + ) + + cm = self.__asynccontextmanager__() + if not isinstance(cm, AbstractAsyncContextManager): + if isasyncgen(cm): + raise TypeError( + "__asynccontextmanager__() returned an async generator instead of " + "an async context manager. Did you forget to add the " + "@asynccontextmanager decorator?" + ) + elif iscoroutine(cm): + cm.close() + raise TypeError( + "__asynccontextmanager__() returned a coroutine object instead of " + "an async context manager. Did you forget to add the " + "@asynccontextmanager decorator and a 'yield' statement?" + ) + + raise TypeError( + f"__asynccontextmanager__() did not return an async context manager, " + f"but {cm.__class__!r}" + ) + + if cm is self: + raise TypeError( + f"{self.__class__.__qualname__}.__asynccontextmanager__() returned " + f"self. Did you forget to add the @asynccontextmanager decorator and a " + f"'yield' statement?" + ) + + value = await cm.__aenter__() + self.__cm = cm + return value + + @final + async def __aexit__( + self: _SupportsAsyncCtxMgr[object, _ExitT_co], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> _ExitT_co: + assert isinstance(self, AsyncContextManagerMixin) + if self.__cm is None: + raise RuntimeError( + f"this {self.__class__.__qualname__} has not been entered yet" + ) + + # Prevent circular references + cm = self.__cm + del self.__cm + + return cast(_ExitT_co, await cm.__aexit__(exc_type, exc_val, exc_tb)) + + @abstractmethod + def __asynccontextmanager__( + self, + ) -> AbstractAsyncContextManager[object, bool | None]: + """ + Implement your async context manager logic here. + + This method **must** be decorated with + :func:`@asynccontextmanager `. + + .. note:: Remember that the ``yield`` will raise any exception raised in the + enclosed context block, so use a ``finally:`` block to clean up resources! + + :return: an async context manager object + """ diff --git a/server/venv/Lib/site-packages/anyio/_core/_eventloop.py b/server/venv/Lib/site-packages/anyio/_core/_eventloop.py new file mode 100644 index 0000000..a3e2ab1 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_eventloop.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import math +import sys +import threading +from collections.abc import Awaitable, Callable, Generator +from contextlib import contextmanager +from contextvars import Token +from importlib import import_module +from typing import TYPE_CHECKING, Any, TypeVar + +from ._exceptions import NoEventLoopError + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +sniffio: Any +try: + import sniffio +except ModuleNotFoundError: + sniffio = None + +if TYPE_CHECKING: + from ..abc import AsyncBackend + +# This must be updated when new backends are introduced +BACKENDS = "asyncio", "trio" + +T_Retval = TypeVar("T_Retval") +PosArgsT = TypeVarTuple("PosArgsT") + +threadlocals = threading.local() +loaded_backends: dict[str, type[AsyncBackend]] = {} + + +def run( + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], + *args: Unpack[PosArgsT], + backend: str = "asyncio", + backend_options: dict[str, Any] | None = None, +) -> T_Retval: + """ + Run the given coroutine function in an asynchronous event loop. + + The current thread must not be already running an event loop. + + :param func: a coroutine function + :param args: positional arguments to ``func`` + :param backend: name of the asynchronous event loop implementation – currently + either ``asyncio`` or ``trio`` + :param backend_options: keyword arguments to call the backend ``run()`` + implementation with (documented :ref:`here `) + :return: the return value of the coroutine function + :raises RuntimeError: if an asynchronous event loop is already running in this + thread + :raises LookupError: if the named backend is not found + + """ + if asynclib_name := current_async_library(): + raise RuntimeError(f"Already running {asynclib_name} in this thread") + + try: + async_backend = get_async_backend(backend) + except ImportError as exc: + if backend in BACKENDS: + raise LookupError( + f"Backend {backend!r} is not available. " + f"Install it with: pip install anyio[{backend}]" + ) from exc + + raise LookupError(f"No such backend: {backend}") from exc + + token = None + if asynclib_name is None: + # Since we're in control of the event loop, we can cache the name of the async + # library + token = set_current_async_library(backend) + + try: + backend_options = backend_options or {} + return async_backend.run(func, args, {}, backend_options) + finally: + reset_current_async_library(token) + + +async def sleep(delay: float) -> None: + """ + Pause the current task for the specified duration. + + :param delay: the duration, in seconds + + """ + return await get_async_backend().sleep(delay) + + +async def sleep_forever() -> None: + """ + Pause the current task until it's cancelled. + + This is a shortcut for ``sleep(math.inf)``. + + .. versionadded:: 3.1 + + """ + await sleep(math.inf) + + +async def sleep_until(deadline: float) -> None: + """ + Pause the current task until the given time. + + :param deadline: the absolute time to wake up at (according to the internal + monotonic clock of the event loop) + + .. versionadded:: 3.1 + + """ + now = current_time() + await sleep(max(deadline - now, 0)) + + +def current_time() -> float: + """ + Return the current value of the event loop's internal clock. + + :return: the clock value (seconds) + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().current_time() + + +def get_all_backends() -> tuple[str, ...]: + """Return a tuple of the names of all built-in backends.""" + return BACKENDS + + +def get_available_backends() -> tuple[str, ...]: + """ + Test for the availability of built-in backends. + + :return a tuple of the built-in backend names that were successfully imported + + .. versionadded:: 4.12 + + """ + available_backends: list[str] = [] + for backend_name in get_all_backends(): + try: + get_async_backend(backend_name) + except ImportError: + continue + + available_backends.append(backend_name) + + return tuple(available_backends) + + +def get_cancelled_exc_class() -> type[BaseException]: + """ + Return the current async library's cancellation exception class. + + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().cancelled_exception_class() + + +# +# Private API +# + + +@contextmanager +def claim_worker_thread( + backend_class: type[AsyncBackend], token: object +) -> Generator[Any, None, None]: + from ..lowlevel import EventLoopToken + + threadlocals.current_token = EventLoopToken(backend_class, token) + try: + yield + finally: + del threadlocals.current_token + + +def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]: + if asynclib_name is None: + asynclib_name = current_async_library() + if not asynclib_name: + raise NoEventLoopError( + f"Not currently running on any asynchronous event loop. " + f"Available async backends: {', '.join(get_all_backends())}" + ) + + # We use our own dict instead of sys.modules to get the already imported back-end + # class because the appropriate modules in sys.modules could potentially be only + # partially initialized + try: + return loaded_backends[asynclib_name] + except KeyError: + module = import_module(f"anyio._backends._{asynclib_name}") + loaded_backends[asynclib_name] = module.backend_class + return module.backend_class + + +def current_async_library() -> str | None: + if sniffio is None: + # If sniffio is not installed, we assume we're either running asyncio or nothing + import asyncio + + try: + asyncio.get_running_loop() + return "asyncio" + except RuntimeError: + pass + else: + try: + return sniffio.current_async_library() + except sniffio.AsyncLibraryNotFoundError: + pass + + return None + + +def set_current_async_library(asynclib_name: str | None) -> Token | None: + # no-op if sniffio is not installed + if sniffio is None: + return None + + return sniffio.current_async_library_cvar.set(asynclib_name) + + +def reset_current_async_library(token: Token | None) -> None: + if token is not None: + sniffio.current_async_library_cvar.reset(token) diff --git a/server/venv/Lib/site-packages/anyio/_core/_exceptions.py b/server/venv/Lib/site-packages/anyio/_core/_exceptions.py new file mode 100644 index 0000000..cd6eb9b --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_exceptions.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import sys +from collections.abc import Generator +from textwrap import dedent +from typing import Any + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup + + +class BrokenResourceError(Exception): + """ + Raised when trying to use a resource that has been rendered unusable due to external + causes (e.g. a send stream whose peer has disconnected). + """ + + +class BrokenWorkerProcess(Exception): + """ + Raised by :meth:`~anyio.to_process.run_sync` if the worker process terminates abruptly or + otherwise misbehaves. + """ + + +class BrokenWorkerInterpreter(Exception): + """ + Raised by :meth:`~anyio.to_interpreter.run_sync` if an unexpected exception is + raised in the subinterpreter. + """ + + def __init__(self, excinfo: Any): + # This was adapted from concurrent.futures.interpreter.ExecutionFailed + msg = excinfo.formatted + if not msg: + if excinfo.type and excinfo.msg: + msg = f"{excinfo.type.__name__}: {excinfo.msg}" + else: + msg = excinfo.type.__name__ or excinfo.msg + + super().__init__(msg) + self.excinfo = excinfo + + def __str__(self) -> str: + try: + formatted = self.excinfo.errdisplay + except Exception: + return super().__str__() + else: + return dedent( + f""" + {super().__str__()} + + Uncaught in the interpreter: + + {formatted} + """.strip() + ) + + +class BusyResourceError(Exception): + """ + Raised when two tasks are trying to read from or write to the same resource + concurrently. + """ + + def __init__(self, action: str): + super().__init__(f"Another task is already {action} this resource") + + +class ClosedResourceError(Exception): + """Raised when trying to use a resource that has been closed.""" + + +class ConnectionFailed(OSError): + """ + Raised when a connection attempt fails. + + .. note:: This class inherits from :exc:`OSError` for backwards compatibility. + """ + + +def iterate_exceptions( + exception: BaseException, +) -> Generator[BaseException, None, None]: + if isinstance(exception, BaseExceptionGroup): + for exc in exception.exceptions: + yield from iterate_exceptions(exc) + else: + yield exception + + +class DelimiterNotFound(Exception): + """ + Raised during + :meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_until` if the + maximum number of bytes has been read without the delimiter being found. + """ + + def __init__(self, max_bytes: int) -> None: + super().__init__( + f"The delimiter was not found among the first {max_bytes} bytes" + ) + + +class EndOfStream(Exception): + """ + Raised when trying to read from a stream that has been closed from the other end. + """ + + +class IncompleteRead(Exception): + """ + Raised during + :meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_exactly` or + :meth:`~anyio.streams.buffered.BufferedByteReceiveStream.receive_until` if the + connection is closed before the requested amount of bytes has been read. + """ + + def __init__(self) -> None: + super().__init__( + "The stream was closed before the read operation could be completed" + ) + + +class TypedAttributeLookupError(LookupError): + """ + Raised by :meth:`~anyio.TypedAttributeProvider.extra` when the given typed attribute + is not found and no default value has been given. + """ + + +class WouldBlock(Exception): + """Raised by ``X_nowait`` functions if ``X()`` would block.""" + + +class NoEventLoopError(RuntimeError): + """ + Raised by several functions that require an event loop to be running in the current + thread when there is no running event loop. + + This is also raised by :func:`.from_thread.run` and :func:`.from_thread.run_sync` + if not calling from an AnyIO worker thread, and no ``token`` was passed. + """ + + +class RunFinishedError(RuntimeError): + """ + Raised by :func:`.from_thread.run` and :func:`.from_thread.run_sync` if the event + loop associated with the explicitly passed token has already finished. + """ + + def __init__(self) -> None: + super().__init__( + "The event loop associated with the given token has already finished" + ) + + +class TaskFailed(Exception): + """ + Raised when awaiting on, or attempting to access the return value of, a + :class:`.TaskHandle` that raised an exception. + """ + + +class TaskCancelled(TaskFailed): + """ + Raised when awaiting on, or attempting to access the return value of, a + :class:`.TaskHandle` that was cancelled. + """ + + +class TaskNotFinished(Exception): + """ + Raised when attempting to access the return value or exception of a + :class:`.TaskHandle` that is still pending completion. + """ diff --git a/server/venv/Lib/site-packages/anyio/_core/_fileio.py b/server/venv/Lib/site-packages/anyio/_core/_fileio.py new file mode 100644 index 0000000..692c754 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_fileio.py @@ -0,0 +1,960 @@ +from __future__ import annotations + +import os +import pathlib +import sys +from collections.abc import ( + AsyncIterator, + Callable, + Iterable, + Iterator, + Sequence, +) +from dataclasses import dataclass +from functools import partial +from os import PathLike +from typing import ( + IO, + TYPE_CHECKING, + Any, + AnyStr, + ClassVar, + Final, + Generic, + TypeVar, + overload, +) + +from .. import to_thread +from ..abc import AsyncResource +from ._synchronization import CapacityLimiter + +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + +if sys.version_info >= (3, 14): + from pathlib.types import PathInfo + +if TYPE_CHECKING: + from types import ModuleType + + from _typeshed import OpenBinaryMode, OpenTextMode, ReadableBuffer, WriteableBuffer +else: + ReadableBuffer = OpenBinaryMode = OpenTextMode = WriteableBuffer = object + + +T = TypeVar("T", bound="Path") + + +class AsyncFile(AsyncResource, Generic[AnyStr]): + """ + An asynchronous file object. + + This class wraps a standard file object and provides async friendly versions of the + following blocking methods (where available on the original file object): + + * read + * read1 + * readline + * readlines + * readinto + * readinto1 + * write + * writelines + * truncate + * seek + * tell + * flush + + All other methods are directly passed through. + + This class supports the asynchronous context manager protocol which closes the + underlying file at the end of the context block. + + This class also supports asynchronous iteration:: + + async with await open_file(...) as f: + async for line in f: + print(line) + """ + + def __init__( + self, fp: IO[AnyStr], *, limiter: CapacityLimiter | None = None + ) -> None: + if limiter is not None and not isinstance(limiter, CapacityLimiter): + raise TypeError( + f"limiter must be a CapacityLimiter or None, not " + f"{limiter.__class__.__name__}" + ) + + self._fp: Any = fp + self._limiter = limiter + + def __getattr__(self, name: str) -> object: + return getattr(self._fp, name) + + @property + def limiter(self) -> CapacityLimiter | None: + """The capacity limiter used by this file object, if not the global limiter.""" + return self._limiter + + @property + def wrapped(self) -> IO[AnyStr]: + """The wrapped file object.""" + return self._fp + + async def __aiter__(self) -> AsyncIterator[AnyStr]: + while True: + line = await self.readline() + if line: + yield line + else: + break + + async def aclose(self) -> None: + return await to_thread.run_sync(self._fp.close, limiter=self._limiter) + + async def read(self, size: int = -1) -> AnyStr: + return await to_thread.run_sync(self._fp.read, size, limiter=self._limiter) + + async def read1(self: AsyncFile[bytes], size: int = -1) -> bytes: + return await to_thread.run_sync(self._fp.read1, size, limiter=self._limiter) + + async def readline(self) -> AnyStr: + return await to_thread.run_sync(self._fp.readline, limiter=self._limiter) + + async def readlines(self) -> list[AnyStr]: + return await to_thread.run_sync(self._fp.readlines, limiter=self._limiter) + + async def readinto(self: AsyncFile[bytes], b: WriteableBuffer) -> int: + return await to_thread.run_sync(self._fp.readinto, b, limiter=self._limiter) + + async def readinto1(self: AsyncFile[bytes], b: WriteableBuffer) -> int: + return await to_thread.run_sync(self._fp.readinto1, b, limiter=self._limiter) + + @overload + async def write(self: AsyncFile[bytes], b: ReadableBuffer) -> int: ... + + @overload + async def write(self: AsyncFile[str], b: str) -> int: ... + + async def write(self, b: ReadableBuffer | str) -> int: + return await to_thread.run_sync(self._fp.write, b, limiter=self._limiter) + + @overload + async def writelines( + self: AsyncFile[bytes], lines: Iterable[ReadableBuffer] + ) -> None: ... + + @overload + async def writelines(self: AsyncFile[str], lines: Iterable[str]) -> None: ... + + async def writelines(self, lines: Iterable[ReadableBuffer] | Iterable[str]) -> None: + return await to_thread.run_sync( + self._fp.writelines, lines, limiter=self._limiter + ) + + async def truncate(self, size: int | None = None) -> int: + return await to_thread.run_sync(self._fp.truncate, size, limiter=self._limiter) + + async def seek(self, offset: int, whence: int | None = os.SEEK_SET) -> int: + return await to_thread.run_sync( + self._fp.seek, offset, whence, limiter=self._limiter + ) + + async def tell(self) -> int: + return await to_thread.run_sync(self._fp.tell, limiter=self._limiter) + + async def flush(self) -> None: + return await to_thread.run_sync(self._fp.flush, limiter=self._limiter) + + +@overload +async def open_file( + file: str | PathLike[str] | int, + mode: OpenBinaryMode, + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = ..., + opener: Callable[[str, int], int] | None = ..., + *, + limiter: CapacityLimiter | None = ..., +) -> AsyncFile[bytes]: ... + + +@overload +async def open_file( + file: str | PathLike[str] | int, + mode: OpenTextMode = ..., + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + closefd: bool = ..., + opener: Callable[[str, int], int] | None = ..., + *, + limiter: CapacityLimiter | None = ..., +) -> AsyncFile[str]: ... + + +async def open_file( + file: str | PathLike[str] | int, + mode: str = "r", + buffering: int = -1, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + closefd: bool = True, + opener: Callable[[str, int], int] | None = None, + *, + limiter: CapacityLimiter | None = None, +) -> AsyncFile[Any]: + """ + Open a file asynchronously. + + Except for ``limiter``, the arguments are exactly the same as for the builtin :func:`open`. + + :param limiter: an optional capacity limiter to use with the file + instead of the default one + :return: an asynchronous file object + + .. versionchanged:: 4.14.0 + Added the ``limiter`` keyword argument. + + """ + fp = await to_thread.run_sync( + open, + file, + mode, + buffering, + encoding, + errors, + newline, + closefd, + opener, + limiter=limiter, + ) + return AsyncFile(fp, limiter=limiter) + + +def wrap_file( + file: IO[AnyStr], *, limiter: CapacityLimiter | None = None +) -> AsyncFile[AnyStr]: + """ + Wrap an existing file as an asynchronous file. + + :param file: an existing file-like object + :param limiter: an optional capacity limiter to use with the file + instead of the default one + :return: an asynchronous file object + + .. versionchanged:: 4.14.0 + Added the ``limiter`` keyword argument. + + """ + return AsyncFile(file, limiter=limiter) + + +@dataclass(eq=False) +class _PathIterator(AsyncIterator[T]): + iterator: Iterator[PathLike[str]] + limiter: CapacityLimiter | None + # This was added to ensure that iterating over a subclass of Path yields instances + # of that subclass rather than the base Path class. + path_cls: type[T] + + async def __anext__(self) -> T: + nextval = await to_thread.run_sync( + next, self.iterator, None, abandon_on_cancel=True, limiter=self.limiter + ) + if nextval is None: + raise StopAsyncIteration from None + + return self.path_cls(nextval, limiter=self.limiter) + + +class Path: + """ + An asynchronous version of :class:`pathlib.Path`. + + This class cannot be substituted for :class:`pathlib.Path` or + :class:`pathlib.PurePath`, but it is compatible with the :class:`os.PathLike` + interface. + + It implements the Python 3.10 version of :class:`pathlib.Path` interface, except for + the deprecated :meth:`~pathlib.Path.link_to` method. + + Some methods may be unavailable or have limited functionality, based on the Python + version: + + * :meth:`~pathlib.Path.copy` (available on Python 3.14 or later) + * :meth:`~pathlib.Path.copy_into` (available on Python 3.14 or later) + * :meth:`~pathlib.Path.from_uri` (available on Python 3.13 or later) + * :meth:`~pathlib.PurePath.full_match` (available on Python 3.13 or later) + * :attr:`~pathlib.Path.info` (available on Python 3.14 or later) + * :meth:`~pathlib.Path.is_junction` (available on Python 3.12 or later) + * :meth:`~pathlib.PurePath.match` (the ``case_sensitive`` parameter is only + available on Python 3.13 or later) + * :meth:`~pathlib.Path.move` (available on Python 3.14 or later) + * :meth:`~pathlib.Path.move_into` (available on Python 3.14 or later) + * :meth:`~pathlib.PurePath.relative_to` (the ``walk_up`` parameter is only available + on Python 3.12 or later) + * :meth:`~pathlib.Path.walk` (available on Python 3.12 or later) + + Any methods that do disk I/O need to be awaited on. These methods are: + + * :meth:`~pathlib.Path.absolute` + * :meth:`~pathlib.Path.chmod` + * :meth:`~pathlib.Path.cwd` + * :meth:`~pathlib.Path.exists` + * :meth:`~pathlib.Path.expanduser` + * :meth:`~pathlib.Path.group` + * :meth:`~pathlib.Path.hardlink_to` + * :meth:`~pathlib.Path.home` + * :meth:`~pathlib.Path.is_block_device` + * :meth:`~pathlib.Path.is_char_device` + * :meth:`~pathlib.Path.is_dir` + * :meth:`~pathlib.Path.is_fifo` + * :meth:`~pathlib.Path.is_file` + * :meth:`~pathlib.Path.is_junction` + * :meth:`~pathlib.Path.is_mount` + * :meth:`~pathlib.Path.is_socket` + * :meth:`~pathlib.Path.is_symlink` + * :meth:`~pathlib.Path.lchmod` + * :meth:`~pathlib.Path.lstat` + * :meth:`~pathlib.Path.mkdir` + * :meth:`~pathlib.Path.open` + * :meth:`~pathlib.Path.owner` + * :meth:`~pathlib.Path.read_bytes` + * :meth:`~pathlib.Path.read_text` + * :meth:`~pathlib.Path.readlink` + * :meth:`~pathlib.Path.rename` + * :meth:`~pathlib.Path.replace` + * :meth:`~pathlib.Path.resolve` + * :meth:`~pathlib.Path.rmdir` + * :meth:`~pathlib.Path.samefile` + * :meth:`~pathlib.Path.stat` + * :meth:`~pathlib.Path.symlink_to` + * :meth:`~pathlib.Path.touch` + * :meth:`~pathlib.Path.unlink` + * :meth:`~pathlib.Path.walk` + * :meth:`~pathlib.Path.write_bytes` + * :meth:`~pathlib.Path.write_text` + + Additionally, the following methods return an async iterator yielding + :class:`~.Path` objects: + + * :meth:`~pathlib.Path.glob` + * :meth:`~pathlib.Path.iterdir` + * :meth:`~pathlib.Path.rglob` + + .. versionchanged:: 4.14.0 + Added the ``limiter`` keyword argument. + """ + + __slots__ = "_path", "_limiter", "__weakref__" + + __weakref__: Any + + def __init__( + self, *args: str | PathLike[str], limiter: CapacityLimiter | None = None + ) -> None: + if limiter is not None and not isinstance(limiter, CapacityLimiter): + raise TypeError( + f"limiter must be a CapacityLimiter or None, not " + f"{limiter.__class__.__name__}" + ) + + self._path: Final[pathlib.Path] = pathlib.Path(*args) + self._limiter = limiter + + def __fspath__(self) -> str: + return self._path.__fspath__() + + if sys.version_info >= (3, 15): + + def __vfspath__(self) -> str: + return self._path.__vfspath__() + + def __str__(self) -> str: + return self._path.__str__() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.as_posix()!r})" + + def __bytes__(self) -> bytes: + return self._path.__bytes__() + + def __hash__(self) -> int: + return self._path.__hash__() + + def __eq__(self, other: object) -> bool: + target = other._path if isinstance(other, Path) else other + return self._path.__eq__(target) + + def __lt__(self, other: pathlib.PurePath | Path) -> bool: + target = other._path if isinstance(other, Path) else other + return self._path.__lt__(target) + + def __le__(self, other: pathlib.PurePath | Path) -> bool: + target = other._path if isinstance(other, Path) else other + return self._path.__le__(target) + + def __gt__(self, other: pathlib.PurePath | Path) -> bool: + target = other._path if isinstance(other, Path) else other + return self._path.__gt__(target) + + def __ge__(self, other: pathlib.PurePath | Path) -> bool: + target = other._path if isinstance(other, Path) else other + return self._path.__ge__(target) + + def __truediv__(self, other: str | PathLike[str]) -> Self: + return type(self)(self._path / other, limiter=self._limiter) + + def __rtruediv__(self, other: str | PathLike[str]) -> Self: + return type(self)(other, limiter=self._limiter) / self + + @property + def limiter(self) -> CapacityLimiter | None: + """The capacity limiter used by this path, if not the global limiter.""" + return self._limiter + + @property + def parts(self) -> tuple[str, ...]: + return self._path.parts + + @property + def drive(self) -> str: + return self._path.drive + + @property + def root(self) -> str: + return self._path.root + + @property + def anchor(self) -> str: + return self._path.anchor + + @property + def parents(self) -> Sequence[Self]: + return tuple(type(self)(p, limiter=self._limiter) for p in self._path.parents) + + @property + def parent(self) -> Self: + return type(self)(self._path.parent, limiter=self._limiter) + + @property + def name(self) -> str: + return self._path.name + + @property + def suffix(self) -> str: + return self._path.suffix + + @property + def suffixes(self) -> list[str]: + return self._path.suffixes + + @property + def stem(self) -> str: + return self._path.stem + + async def absolute(self) -> Self: + path = await to_thread.run_sync(self._path.absolute, limiter=self._limiter) + return type(self)(path, limiter=self._limiter) + + def as_posix(self) -> str: + return self._path.as_posix() + + def as_uri(self) -> str: + return self._path.as_uri() + + if sys.version_info >= (3, 13): + parser: ClassVar[ModuleType] = pathlib.Path.parser + + @classmethod + def from_uri(cls, uri: str, *, limiter: CapacityLimiter | None = None) -> Self: + return cls(pathlib.Path.from_uri(uri), limiter=limiter) + + def full_match( + self, path_pattern: str, *, case_sensitive: bool | None = None + ) -> bool: + return self._path.full_match(path_pattern, case_sensitive=case_sensitive) + + def match( + self, path_pattern: str, *, case_sensitive: bool | None = None + ) -> bool: + return self._path.match(path_pattern, case_sensitive=case_sensitive) + else: + + def match(self, path_pattern: str) -> bool: + return self._path.match(path_pattern) + + if sys.version_info >= (3, 14): + + @property + def info(self) -> PathInfo: + return self._path.info + + async def copy( + self, + target: str | os.PathLike[str], + *, + follow_symlinks: bool = True, + preserve_metadata: bool = False, + ) -> Self: + func = partial( + self._path.copy, + follow_symlinks=follow_symlinks, + preserve_metadata=preserve_metadata, + ) + return type(self)( + await to_thread.run_sync( + func, pathlib.Path(target), limiter=self._limiter + ), + limiter=self._limiter, + ) + + async def copy_into( + self, + target_dir: str | os.PathLike[str], + *, + follow_symlinks: bool = True, + preserve_metadata: bool = False, + ) -> Self: + func = partial( + self._path.copy_into, + follow_symlinks=follow_symlinks, + preserve_metadata=preserve_metadata, + ) + return type(self)( + await to_thread.run_sync( + func, pathlib.Path(target_dir), limiter=self._limiter + ), + limiter=self._limiter, + ) + + async def move(self, target: str | os.PathLike[str]) -> Self: + # Upstream does not handle anyio.Path properly as a PathLike + target = pathlib.Path(target) + return type(self)( + await to_thread.run_sync( + self._path.move, target, limiter=self._limiter + ), + limiter=self._limiter, + ) + + async def move_into( + self, + target_dir: str | os.PathLike[str], + ) -> Self: + return type(self)( + await to_thread.run_sync( + self._path.move_into, target_dir, limiter=self._limiter + ), + limiter=self._limiter, + ) + + def is_relative_to(self, other: str | PathLike[str]) -> bool: + try: + self.relative_to(other) + return True + except ValueError: + return False + + async def chmod(self, mode: int, *, follow_symlinks: bool = True) -> None: + func = partial(os.chmod, follow_symlinks=follow_symlinks) + return await to_thread.run_sync(func, self._path, mode, limiter=self._limiter) + + @classmethod + async def cwd(cls, *, limiter: CapacityLimiter | None = None) -> Self: + path = await to_thread.run_sync(pathlib.Path.cwd, limiter=limiter) + return cls(path, limiter=limiter) + + async def exists(self) -> bool: + return await to_thread.run_sync( + self._path.exists, abandon_on_cancel=True, limiter=self._limiter + ) + + async def expanduser(self) -> Self: + return type(self)( + await to_thread.run_sync( + self._path.expanduser, abandon_on_cancel=True, limiter=self._limiter + ), + limiter=self._limiter, + ) + + if sys.version_info < (3, 12): + # Python 3.11 and earlier + def glob(self, pattern: str) -> AsyncIterator[Self]: + gen = self._path.glob(pattern) + return _PathIterator(gen, self._limiter, type(self)) + elif (3, 12) <= sys.version_info < (3, 13): + # changed in Python 3.12: + # - The case_sensitive parameter was added. + def glob( + self, + pattern: str, + *, + case_sensitive: bool | None = None, + ) -> AsyncIterator[Self]: + gen = self._path.glob(pattern, case_sensitive=case_sensitive) + return _PathIterator(gen, self._limiter, type(self)) + elif sys.version_info >= (3, 13): + # Changed in Python 3.13: + # - The recurse_symlinks parameter was added. + # - The pattern parameter accepts a path-like object. + def glob( # type: ignore[misc] # mypy doesn't allow for differing signatures in a conditional block + self, + pattern: str | PathLike[str], + *, + case_sensitive: bool | None = None, + recurse_symlinks: bool = False, + ) -> AsyncIterator[Self]: + gen = self._path.glob( + pattern, # type: ignore[arg-type] + case_sensitive=case_sensitive, + recurse_symlinks=recurse_symlinks, + ) + return _PathIterator(gen, self._limiter, type(self)) + + async def group(self) -> str: + return await to_thread.run_sync( + self._path.group, abandon_on_cancel=True, limiter=self._limiter + ) + + async def hardlink_to( + self, target: str | bytes | PathLike[str] | PathLike[bytes] + ) -> None: + if isinstance(target, Path): + target = target._path + + await to_thread.run_sync(os.link, target, self, limiter=self._limiter) + + @classmethod + async def home(cls, *, limiter: CapacityLimiter | None = None) -> Self: + home_path = await to_thread.run_sync(pathlib.Path.home, limiter=limiter) + return cls(home_path, limiter=limiter) + + def is_absolute(self) -> bool: + return self._path.is_absolute() + + async def is_block_device(self) -> bool: + return await to_thread.run_sync( + self._path.is_block_device, abandon_on_cancel=True, limiter=self._limiter + ) + + async def is_char_device(self) -> bool: + return await to_thread.run_sync( + self._path.is_char_device, abandon_on_cancel=True, limiter=self._limiter + ) + + async def is_dir(self) -> bool: + return await to_thread.run_sync( + self._path.is_dir, abandon_on_cancel=True, limiter=self._limiter + ) + + async def is_fifo(self) -> bool: + return await to_thread.run_sync( + self._path.is_fifo, abandon_on_cancel=True, limiter=self._limiter + ) + + async def is_file(self) -> bool: + return await to_thread.run_sync( + self._path.is_file, abandon_on_cancel=True, limiter=self._limiter + ) + + if sys.version_info >= (3, 12): + + async def is_junction(self) -> bool: + return await to_thread.run_sync( + self._path.is_junction, limiter=self._limiter + ) + + async def is_mount(self) -> bool: + return await to_thread.run_sync( + os.path.ismount, self._path, abandon_on_cancel=True, limiter=self._limiter + ) + + if sys.version_info < (3, 15): + + def is_reserved(self) -> bool: + return self._path.is_reserved() + + async def is_socket(self) -> bool: + return await to_thread.run_sync( + self._path.is_socket, abandon_on_cancel=True, limiter=self._limiter + ) + + async def is_symlink(self) -> bool: + return await to_thread.run_sync( + self._path.is_symlink, abandon_on_cancel=True, limiter=self._limiter + ) + + async def iterdir(self) -> AsyncIterator[Self]: + gen = ( + self._path.iterdir() + if sys.version_info < (3, 13) + else await to_thread.run_sync( + self._path.iterdir, abandon_on_cancel=True, limiter=self._limiter + ) + ) + async for path in _PathIterator(gen, self._limiter, type(self)): + yield path + + def joinpath(self, *args: str | PathLike[str]) -> Self: + return type(self)(self._path.joinpath(*args), limiter=self._limiter) + + async def lchmod(self, mode: int) -> None: + await to_thread.run_sync(self._path.lchmod, mode, limiter=self._limiter) + + async def lstat(self) -> os.stat_result: + return await to_thread.run_sync( + self._path.lstat, abandon_on_cancel=True, limiter=self._limiter + ) + + async def mkdir( + self, mode: int = 0o777, parents: bool = False, exist_ok: bool = False + ) -> None: + await to_thread.run_sync( + self._path.mkdir, mode, parents, exist_ok, limiter=self._limiter + ) + + @overload + async def open( + self, + mode: OpenBinaryMode, + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + ) -> AsyncFile[bytes]: ... + + @overload + async def open( + self, + mode: OpenTextMode = ..., + buffering: int = ..., + encoding: str | None = ..., + errors: str | None = ..., + newline: str | None = ..., + ) -> AsyncFile[str]: ... + + async def open( + self, + mode: str = "r", + buffering: int = -1, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> AsyncFile[Any]: + fp = await to_thread.run_sync( + self._path.open, + mode, + buffering, + encoding, + errors, + newline, + limiter=self._limiter, + ) + return AsyncFile(fp, limiter=self._limiter) + + async def owner(self) -> str: + return await to_thread.run_sync( + self._path.owner, abandon_on_cancel=True, limiter=self._limiter + ) + + async def read_bytes(self) -> bytes: + return await to_thread.run_sync(self._path.read_bytes, limiter=self._limiter) + + async def read_text( + self, encoding: str | None = None, errors: str | None = None + ) -> str: + return await to_thread.run_sync( + self._path.read_text, encoding, errors, limiter=self._limiter + ) + + if sys.version_info >= (3, 12): + + def relative_to( + self, *other: str | PathLike[str], walk_up: bool = False + ) -> Self: + # relative_to() should work with any PathLike but it doesn't + others = [pathlib.Path(other) for other in other] + return type(self)( + self._path.relative_to(*others, walk_up=walk_up), limiter=self._limiter + ) + + else: + + def relative_to(self, *other: str | PathLike[str]) -> Self: + return type(self)(self._path.relative_to(*other), limiter=self._limiter) + + async def readlink(self) -> Self: + target = await to_thread.run_sync( + os.readlink, self._path, limiter=self._limiter + ) + return type(self)(target, limiter=self._limiter) + + async def rename(self, target: str | pathlib.PurePath | Path) -> Self: + if isinstance(target, Path): + target = target._path + + await to_thread.run_sync(self._path.rename, target, limiter=self._limiter) + return type(self)(target, limiter=self._limiter) + + async def replace(self, target: str | pathlib.PurePath | Path) -> Self: + if isinstance(target, Path): + target = target._path + + await to_thread.run_sync(self._path.replace, target, limiter=self._limiter) + return type(self)(target, limiter=self._limiter) + + async def resolve(self, strict: bool = False) -> Self: + func = partial(self._path.resolve, strict=strict) + return type(self)( + await to_thread.run_sync( + func, abandon_on_cancel=True, limiter=self._limiter + ), + limiter=self._limiter, + ) + + if sys.version_info < (3, 12): + # Pre Python 3.12 + def rglob(self, pattern: str) -> AsyncIterator[Self]: + gen = self._path.rglob(pattern) + return _PathIterator(gen, self._limiter, type(self)) + elif (3, 12) <= sys.version_info < (3, 13): + # Changed in Python 3.12: + # - The case_sensitive parameter was added. + def rglob( + self, pattern: str, *, case_sensitive: bool | None = None + ) -> AsyncIterator[Self]: + gen = self._path.rglob(pattern, case_sensitive=case_sensitive) + return _PathIterator(gen, self._limiter, type(self)) + elif sys.version_info >= (3, 13): + # Changed in Python 3.13: + # - The recurse_symlinks parameter was added. + # - The pattern parameter accepts a path-like object. + def rglob( # type: ignore[misc] # mypy doesn't allow for differing signatures in a conditional block + self, + pattern: str | PathLike[str], + *, + case_sensitive: bool | None = None, + recurse_symlinks: bool = False, + ) -> AsyncIterator[Self]: + gen = self._path.rglob( + pattern, # type: ignore[arg-type] + case_sensitive=case_sensitive, + recurse_symlinks=recurse_symlinks, + ) + return _PathIterator(gen, self._limiter, type(self)) + + async def rmdir(self) -> None: + await to_thread.run_sync(self._path.rmdir, limiter=self._limiter) + + async def samefile(self, other_path: str | PathLike[str]) -> bool: + if isinstance(other_path, Path): + other_path = other_path._path + + return await to_thread.run_sync( + self._path.samefile, + other_path, + abandon_on_cancel=True, + limiter=self._limiter, + ) + + async def stat(self, *, follow_symlinks: bool = True) -> os.stat_result: + func = partial(os.stat, follow_symlinks=follow_symlinks) + return await to_thread.run_sync( + func, self._path, abandon_on_cancel=True, limiter=self._limiter + ) + + async def symlink_to( + self, + target: str | bytes | PathLike[str] | PathLike[bytes], + target_is_directory: bool = False, + ) -> None: + if isinstance(target, Path): + target = target._path + + await to_thread.run_sync( + self._path.symlink_to, target, target_is_directory, limiter=self._limiter + ) + + async def touch(self, mode: int = 0o666, exist_ok: bool = True) -> None: + await to_thread.run_sync( + self._path.touch, mode, exist_ok, limiter=self._limiter + ) + + async def unlink(self, missing_ok: bool = False) -> None: + try: + await to_thread.run_sync(self._path.unlink, limiter=self._limiter) + except FileNotFoundError: + if not missing_ok: + raise + + if sys.version_info >= (3, 12): + + async def walk( + self, + top_down: bool = True, + on_error: Callable[[OSError], object] | None = None, + follow_symlinks: bool = False, + ) -> AsyncIterator[tuple[Self, list[str], list[str]]]: + def get_next_value() -> tuple[pathlib.Path, list[str], list[str]] | None: + try: + return next(gen) + except StopIteration: + return None + + gen = self._path.walk(top_down, on_error, follow_symlinks) + while True: + value = await to_thread.run_sync(get_next_value, limiter=self._limiter) + if value is None: + return + + root, dirs, paths = value + yield type(self)(root, limiter=self._limiter), dirs, paths + + def with_name(self, name: str) -> Self: + return type(self)(self._path.with_name(name), limiter=self._limiter) + + def with_stem(self, stem: str) -> Self: + return type(self)( + self._path.with_name(stem + self._path.suffix), limiter=self._limiter + ) + + def with_suffix(self, suffix: str) -> Self: + return type(self)(self._path.with_suffix(suffix), limiter=self._limiter) + + def with_segments(self, *pathsegments: str | PathLike[str]) -> Self: + return type(self)(*pathsegments, limiter=self._limiter) + + async def write_bytes(self, data: ReadableBuffer) -> int: + return await to_thread.run_sync( + self._path.write_bytes, data, limiter=self._limiter + ) + + async def write_text( + self, + data: str, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> int: + return await to_thread.run_sync( + self._path.write_text, + data, + encoding, + errors, + newline, + limiter=self._limiter, + ) + + +PathLike.register(Path) diff --git a/server/venv/Lib/site-packages/anyio/_core/_resources.py b/server/venv/Lib/site-packages/anyio/_core/_resources.py new file mode 100644 index 0000000..b9a5344 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_resources.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from ..abc import AsyncResource +from ._tasks import CancelScope + + +async def aclose_forcefully(resource: AsyncResource) -> None: + """ + Close an asynchronous resource in a cancelled scope. + + Doing this closes the resource without waiting on anything. + + :param resource: the resource to close + + """ + with CancelScope() as scope: + scope.cancel() + await resource.aclose() diff --git a/server/venv/Lib/site-packages/anyio/_core/_signals.py b/server/venv/Lib/site-packages/anyio/_core/_signals.py new file mode 100644 index 0000000..e24c79e --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_signals.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import AbstractContextManager +from signal import Signals + +from ._eventloop import get_async_backend + + +def open_signal_receiver( + *signals: Signals, +) -> AbstractContextManager[AsyncIterator[Signals]]: + """ + Start receiving operating system signals. + + :param signals: signals to receive (e.g. ``signal.SIGINT``) + :return: an asynchronous context manager for an asynchronous iterator which yields + signal numbers + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + .. warning:: Windows does not support signals natively so it is best to avoid + relying on this in cross-platform applications. + + .. warning:: On asyncio, this permanently replaces any previous signal handler for + the given signals, as set via :meth:`~asyncio.loop.add_signal_handler`. + + """ + return get_async_backend().open_signal_receiver(*signals) diff --git a/server/venv/Lib/site-packages/anyio/_core/_sockets.py b/server/venv/Lib/site-packages/anyio/_core/_sockets.py new file mode 100644 index 0000000..29f7332 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_sockets.py @@ -0,0 +1,1011 @@ +from __future__ import annotations + +import errno +import os +import socket +import ssl +import stat +import sys +from collections.abc import Awaitable +from dataclasses import dataclass +from ipaddress import IPv4Address, IPv6Address, ip_address +from os import PathLike, chmod +from socket import AddressFamily, SocketKind +from typing import TYPE_CHECKING, Any, Literal, cast, overload + +from .. import ConnectionFailed, to_thread +from ..abc import ( + ByteStreamConnectable, + ConnectedUDPSocket, + ConnectedUNIXDatagramSocket, + IPAddressType, + IPSockAddrType, + SocketListener, + SocketStream, + UDPSocket, + UNIXDatagramSocket, + UNIXSocketStream, +) +from ..streams.stapled import MultiListener +from ..streams.tls import TLSConnectable, TLSStream +from ._eventloop import get_async_backend +from ._resources import aclose_forcefully +from ._synchronization import Event +from ._tasks import create_task_group, move_on_after + +if TYPE_CHECKING: + from _typeshed import FileDescriptorLike +else: + FileDescriptorLike = object + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +if sys.version_info < (3, 13): + from typing_extensions import deprecated +else: + from warnings import deprecated + +IPPROTO_IPV6 = getattr(socket, "IPPROTO_IPV6", 41) # https://bugs.python.org/issue29515 + +AnyIPAddressFamily = Literal[ + AddressFamily.AF_UNSPEC, AddressFamily.AF_INET, AddressFamily.AF_INET6 +] +IPAddressFamily = Literal[AddressFamily.AF_INET, AddressFamily.AF_INET6] + + +# tls_hostname given +@overload +async def connect_tcp( + remote_host: IPAddressType, + remote_port: int, + *, + local_host: IPAddressType | None = ..., + local_port: int | None = ..., + ssl_context: ssl.SSLContext | None = ..., + tls_standard_compatible: bool = ..., + tls_hostname: str, + happy_eyeballs_delay: float = ..., +) -> TLSStream: ... + + +# ssl_context given +@overload +async def connect_tcp( + remote_host: IPAddressType, + remote_port: int, + *, + local_host: IPAddressType | None = ..., + local_port: int | None = ..., + ssl_context: ssl.SSLContext, + tls_standard_compatible: bool = ..., + tls_hostname: str | None = ..., + happy_eyeballs_delay: float = ..., +) -> TLSStream: ... + + +# tls=True +@overload +async def connect_tcp( + remote_host: IPAddressType, + remote_port: int, + *, + local_host: IPAddressType | None = ..., + local_port: int | None = ..., + tls: Literal[True], + ssl_context: ssl.SSLContext | None = ..., + tls_standard_compatible: bool = ..., + tls_hostname: str | None = ..., + happy_eyeballs_delay: float = ..., +) -> TLSStream: ... + + +# tls=False +@overload +async def connect_tcp( + remote_host: IPAddressType, + remote_port: int, + *, + local_host: IPAddressType | None = ..., + local_port: int | None = ..., + tls: Literal[False], + ssl_context: ssl.SSLContext | None = ..., + tls_standard_compatible: bool = ..., + tls_hostname: str | None = ..., + happy_eyeballs_delay: float = ..., +) -> SocketStream: ... + + +# No TLS arguments +@overload +async def connect_tcp( + remote_host: IPAddressType, + remote_port: int, + *, + local_host: IPAddressType | None = ..., + local_port: int | None = ..., + happy_eyeballs_delay: float = ..., +) -> SocketStream: ... + + +async def connect_tcp( + remote_host: IPAddressType, + remote_port: int, + *, + local_host: IPAddressType | None = None, + local_port: int | None = None, + tls: bool = False, + ssl_context: ssl.SSLContext | None = None, + tls_standard_compatible: bool = True, + tls_hostname: str | None = None, + happy_eyeballs_delay: float = 0.25, +) -> SocketStream | TLSStream: + """ + Connect to a host using the TCP protocol. + + This function implements the stateless version of the Happy Eyeballs algorithm (RFC + 6555). If ``remote_host`` is a host name that resolves to multiple IP addresses, + each one is tried until one connection attempt succeeds. If the first attempt does + not connected within 250 milliseconds, a second attempt is started using the next + address in the list, and so on. On IPv6 enabled systems, an IPv6 address (if + available) is tried first. + + When the connection has been established, a TLS handshake will be done if either + ``ssl_context`` or ``tls_hostname`` is not ``None``, or if ``tls`` is ``True``. + + :param remote_host: the IP address or host name to connect to + :param remote_port: port on the target host to connect to + :param local_host: the interface address or name to bind the socket to before + connecting + :param local_port: the local port to bind to (requires ``local_host`` to also be + set) + :param tls: ``True`` to do a TLS handshake with the connected stream and return a + :class:`~anyio.streams.tls.TLSStream` instead + :param ssl_context: the SSL context object to use (if omitted, a default context is + created) + :param tls_standard_compatible: If ``True``, performs the TLS shutdown handshake + before closing the stream and requires that the server does this as well. + Otherwise, :exc:`~ssl.SSLEOFError` may be raised during reads from the stream. + Some protocols, such as HTTP, require this option to be ``False``. + See :meth:`~ssl.SSLContext.wrap_socket` for details. + :param tls_hostname: host name to check the server certificate against (defaults to + the value of ``remote_host``) + :param happy_eyeballs_delay: delay (in seconds) before starting the next connection + attempt + :return: a socket stream object if no TLS handshake was done, otherwise a TLS stream + :raises ConnectionFailed: if the connection fails + + """ + # Placed here due to https://github.com/python/mypy/issues/7057 + connected_stream: SocketStream | None = None + + async def try_connect(remote_host: str, event: Event) -> None: + nonlocal connected_stream + try: + stream = await asynclib.connect_tcp(remote_host, remote_port, local_address) + except OSError as exc: + oserrors.append(exc) + return + else: + if connected_stream is None: + connected_stream = stream + tg.cancel_scope.cancel() + else: + await stream.aclose() + finally: + event.set() + + asynclib = get_async_backend() + local_address: IPSockAddrType | None = None + family = socket.AF_UNSPEC + if local_host: + gai_res = await getaddrinfo(str(local_host), local_port) + family, *_, local_address = gai_res[0] + + target_host = str(remote_host) + try: + addr_obj = ip_address(remote_host) + except ValueError: + addr_obj = None + + if addr_obj is not None: + if isinstance(addr_obj, IPv6Address): + target_addrs = [(socket.AF_INET6, addr_obj.compressed)] + else: + target_addrs = [(socket.AF_INET, addr_obj.compressed)] + else: + # getaddrinfo() will raise an exception if name resolution fails + gai_res = await getaddrinfo( + target_host, remote_port, family=family, type=socket.SOCK_STREAM + ) + + # Organize the list so that the first address is an IPv6 address (if available) + # and the second one is an IPv4 addresses. The rest can be in whatever order. + v6_found = v4_found = False + target_addrs = [] + for af, *_, sa in gai_res: + if af == socket.AF_INET6 and not v6_found: + v6_found = True + target_addrs.insert(0, (af, sa[0])) + elif af == socket.AF_INET and not v4_found and v6_found: + v4_found = True + target_addrs.insert(1, (af, sa[0])) + else: + target_addrs.append((af, sa[0])) + + oserrors: list[OSError] = [] + try: + async with create_task_group() as tg: + for _af, addr in target_addrs: + event = Event() + tg.start_soon(try_connect, addr, event) + with move_on_after(happy_eyeballs_delay): + await event.wait() + + if connected_stream is None: + cause = ( + oserrors[0] + if len(oserrors) == 1 + else ExceptionGroup("multiple connection attempts failed", oserrors) + ) + raise OSError("All connection attempts failed") from cause + finally: + oserrors.clear() + + if tls or tls_hostname or ssl_context: + try: + return await TLSStream.wrap( + connected_stream, + server_side=False, + hostname=tls_hostname or str(remote_host), + ssl_context=ssl_context, + standard_compatible=tls_standard_compatible, + ) + except BaseException: + await aclose_forcefully(connected_stream) + raise + + return connected_stream + + +async def connect_unix(path: str | bytes | PathLike[Any]) -> UNIXSocketStream: + """ + Connect to the given UNIX socket. + + Not available on Windows. + + :param path: path to the socket + :return: a socket stream object + :raises ConnectionFailed: if the connection fails + + """ + path = os.fspath(path) + return await get_async_backend().connect_unix(path) + + +async def create_tcp_listener( + *, + local_host: IPAddressType | None = None, + local_port: int = 0, + family: AnyIPAddressFamily = socket.AddressFamily.AF_UNSPEC, + backlog: int = 65536, + reuse_port: bool = False, +) -> MultiListener[SocketStream]: + """ + Create a TCP socket listener. + + :param local_port: port number to listen on + :param local_host: IP address of the interface to listen on. If omitted, listen on + all IPv4 and IPv6 interfaces. To listen on all interfaces on a specific address + family, use ``0.0.0.0`` for IPv4 or ``::`` for IPv6. + :param family: address family (used if ``local_host`` was omitted) + :param backlog: maximum number of queued incoming connections (up to a maximum of + 2**16, or 65536) + :param reuse_port: ``True`` to allow multiple sockets to bind to the same + address/port (not supported on Windows) + :return: a multi-listener object containing one or more socket listeners + :raises OSError: if there's an error creating a socket, or binding to one or more + interfaces failed + + """ + asynclib = get_async_backend() + backlog = min(backlog, 65536) + local_host = str(local_host) if local_host is not None else None + + def setup_raw_socket( + fam: AddressFamily, + bind_addr: tuple[str, int] | tuple[str, int, int, int], + *, + v6only: bool = True, + ) -> socket.socket: + sock = socket.socket(fam) + try: + sock.setblocking(False) + + if fam == AddressFamily.AF_INET6: + sock.setsockopt(IPPROTO_IPV6, socket.IPV6_V6ONLY, v6only) + + # For Windows, enable exclusive address use. For others, enable address + # reuse. + if sys.platform == "win32": + sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + if reuse_port: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + + # Workaround for #554 + if fam == socket.AF_INET6 and "%" in bind_addr[0]: + addr, scope_id = bind_addr[0].split("%", 1) + bind_addr = (addr, bind_addr[1], 0, int(scope_id)) + + sock.bind(bind_addr) + sock.listen(backlog) + except BaseException: + sock.close() + raise + + return sock + + # We passing type=0 on non-Windows platforms as a workaround for a uvloop bug + # where we don't get the correct scope ID for IPv6 link-local addresses when passing + # type=socket.SOCK_STREAM to getaddrinfo(): + # https://github.com/MagicStack/uvloop/issues/539 + gai_res = await getaddrinfo( + local_host, + local_port, + family=family, + type=socket.SOCK_STREAM if sys.platform == "win32" else 0, + flags=socket.AI_PASSIVE | socket.AI_ADDRCONFIG, + ) + + # The set comprehension is here to work around a glibc bug: + # https://sourceware.org/bugzilla/show_bug.cgi?id=14969 + sockaddrs = sorted({res for res in gai_res if res[1] == SocketKind.SOCK_STREAM}) + + # Special case for dual-stack binding on the "any" interface + if ( + local_host is None + and family == AddressFamily.AF_UNSPEC + and socket.has_dualstack_ipv6() + and any(fam == AddressFamily.AF_INET6 for fam, *_ in gai_res) + ): + raw_socket = setup_raw_socket( + AddressFamily.AF_INET6, ("::", local_port), v6only=False + ) + listener = asynclib.create_tcp_listener(raw_socket) + return MultiListener([listener]) + + errors: list[OSError] = [] + try: + for _ in range(len(sockaddrs)): + listeners: list[SocketListener] = [] + bound_ephemeral_port = local_port + try: + for fam, *_, sockaddr in sockaddrs: + sockaddr = sockaddr[0], bound_ephemeral_port, *sockaddr[2:] + raw_socket = setup_raw_socket(fam, sockaddr) + + # Store the assigned port if an ephemeral port was requested, so + # we'll bind to the same port on all interfaces + if local_port == 0 and len(gai_res) > 1: + bound_ephemeral_port = raw_socket.getsockname()[1] + + listeners.append(asynclib.create_tcp_listener(raw_socket)) + except BaseException as exc: + for listener in listeners: + await listener.aclose() + + # If an ephemeral port was requested but binding the assigned port + # failed for another interface, rotate the address list and try again + if ( + isinstance(exc, OSError) + and exc.errno == errno.EADDRINUSE + and local_port == 0 + and bound_ephemeral_port + ): + errors.append(exc) + sockaddrs.append(sockaddrs.pop(0)) + continue + + raise + + return MultiListener(listeners) + + raise OSError( + f"Could not create {len(sockaddrs)} listeners with a consistent port" + ) from ExceptionGroup("Several bind attempts failed", errors) + finally: + del errors # Prevent reference cycles + + +async def create_unix_listener( + path: str | bytes | PathLike[Any], + *, + mode: int | None = None, + backlog: int = 65536, +) -> SocketListener: + """ + Create a UNIX socket listener. + + Not available on Windows. + + :param path: path of the socket + :param mode: permissions to set on the socket + :param backlog: maximum number of queued incoming connections (up to a maximum of + 2**16, or 65536) + :return: a listener object + + .. versionchanged:: 3.0 + If a socket already exists on the file system in the given path, it will be + removed first. + + """ + backlog = min(backlog, 65536) + raw_socket = await setup_unix_local_socket(path, mode, socket.SOCK_STREAM) + try: + raw_socket.listen(backlog) + return get_async_backend().create_unix_listener(raw_socket) + except BaseException: + raw_socket.close() + raise + + +async def create_udp_socket( + family: AnyIPAddressFamily = AddressFamily.AF_UNSPEC, + *, + local_host: IPAddressType | None = None, + local_port: int = 0, + reuse_port: bool = False, +) -> UDPSocket: + """ + Create a UDP socket. + + If ``port`` has been given, the socket will be bound to this port on the local + machine, making this socket suitable for providing UDP based services. + + :param family: address family (``AF_INET`` or ``AF_INET6``) – automatically + determined from ``local_host`` if omitted + :param local_host: IP address or host name of the local interface to bind to + :param local_port: local port to bind to + :param reuse_port: ``True`` to allow multiple sockets to bind to the same + address/port (not supported on Windows) + :return: a UDP socket + + """ + if family is AddressFamily.AF_UNSPEC and not local_host: + raise ValueError('Either "family" or "local_host" must be given') + + if local_host: + gai_res = await getaddrinfo( + str(local_host), + local_port, + family=family, + type=socket.SOCK_DGRAM, + flags=socket.AI_PASSIVE | socket.AI_ADDRCONFIG, + ) + family = cast(AnyIPAddressFamily, gai_res[0][0]) + local_address = gai_res[0][-1] + elif family is AddressFamily.AF_INET6: + local_address = ("::", 0) + else: + local_address = ("0.0.0.0", 0) + + sock = await get_async_backend().create_udp_socket( + family, local_address, None, reuse_port + ) + return cast(UDPSocket, sock) + + +async def create_connected_udp_socket( + remote_host: IPAddressType, + remote_port: int, + *, + family: AnyIPAddressFamily = AddressFamily.AF_UNSPEC, + local_host: IPAddressType | None = None, + local_port: int = 0, + reuse_port: bool = False, +) -> ConnectedUDPSocket: + """ + Create a connected UDP socket. + + Connected UDP sockets can only communicate with the specified remote host/port, an + any packets sent from other sources are dropped. + + :param remote_host: remote host to set as the default target + :param remote_port: port on the remote host to set as the default target + :param family: address family (``AF_INET`` or ``AF_INET6``) – automatically + determined from ``local_host`` or ``remote_host`` if omitted + :param local_host: IP address or host name of the local interface to bind to + :param local_port: local port to bind to + :param reuse_port: ``True`` to allow multiple sockets to bind to the same + address/port (not supported on Windows) + :return: a connected UDP socket + + """ + local_address = None + if local_host: + gai_res = await getaddrinfo( + str(local_host), + local_port, + family=family, + type=socket.SOCK_DGRAM, + flags=socket.AI_PASSIVE | socket.AI_ADDRCONFIG, + ) + family = cast(AnyIPAddressFamily, gai_res[0][0]) + local_address = gai_res[0][-1] + + gai_res = await getaddrinfo( + str(remote_host), remote_port, family=family, type=socket.SOCK_DGRAM + ) + family = cast(AnyIPAddressFamily, gai_res[0][0]) + remote_address = gai_res[0][-1] + + sock = await get_async_backend().create_udp_socket( + family, local_address, remote_address, reuse_port + ) + return cast(ConnectedUDPSocket, sock) + + +async def create_unix_datagram_socket( + *, + local_path: None | str | bytes | PathLike[Any] = None, + local_mode: int | None = None, +) -> UNIXDatagramSocket: + """ + Create a UNIX datagram socket. + + Not available on Windows. + + If ``local_path`` has been given, the socket will be bound to this path, making this + socket suitable for receiving datagrams from other processes. Other processes can + send datagrams to this socket only if ``local_path`` is set. + + If a socket already exists on the file system in the ``local_path``, it will be + removed first. + + :param local_path: the path on which to bind to + :param local_mode: permissions to set on the local socket + :return: a UNIX datagram socket + + """ + raw_socket = await setup_unix_local_socket( + local_path, local_mode, socket.SOCK_DGRAM + ) + return await get_async_backend().create_unix_datagram_socket(raw_socket, None) + + +async def create_connected_unix_datagram_socket( + remote_path: str | bytes | PathLike[Any], + *, + local_path: None | str | bytes | PathLike[Any] = None, + local_mode: int | None = None, +) -> ConnectedUNIXDatagramSocket: + """ + Create a connected UNIX datagram socket. + + Connected datagram sockets can only communicate with the specified remote path. + + If ``local_path`` has been given, the socket will be bound to this path, making + this socket suitable for receiving datagrams from other processes. Other processes + can send datagrams to this socket only if ``local_path`` is set. + + If a socket already exists on the file system in the ``local_path``, it will be + removed first. + + :param remote_path: the path to set as the default target + :param local_path: the path on which to bind to + :param local_mode: permissions to set on the local socket + :return: a connected UNIX datagram socket + + """ + remote_path = os.fspath(remote_path) + raw_socket = await setup_unix_local_socket( + local_path, local_mode, socket.SOCK_DGRAM + ) + return await get_async_backend().create_unix_datagram_socket( + raw_socket, remote_path + ) + + +async def getaddrinfo( + host: bytes | str | None, + port: str | int | None, + *, + family: int | AddressFamily = 0, + type: int | SocketKind = 0, + proto: int = 0, + flags: int = 0, +) -> list[tuple[AddressFamily, SocketKind, int, str, tuple[str, int]]]: + """ + Look up a numeric IP address given a host name. + + Internationalized domain names are translated according to the (non-transitional) + IDNA 2008 standard. + + .. note:: 4-tuple IPv6 socket addresses are automatically converted to 2-tuples of + (host, port), unlike what :func:`socket.getaddrinfo` does. + + :param host: host name + :param port: port number + :param family: socket family (`'AF_INET``, ...) + :param type: socket type (``SOCK_STREAM``, ...) + :param proto: protocol number + :param flags: flags to pass to upstream ``getaddrinfo()`` + :return: list of tuples containing (family, type, proto, canonname, sockaddr) + + .. seealso:: :func:`socket.getaddrinfo` + + """ + # Handle unicode hostnames + if isinstance(host, str): + try: + encoded_host: bytes | None = host.encode("ascii") + except UnicodeEncodeError: + import idna + + encoded_host = idna.encode(host, uts46=True) + else: + encoded_host = host + + gai_res = await get_async_backend().getaddrinfo( + encoded_host, port, family=family, type=type, proto=proto, flags=flags + ) + return [ + (family, type, proto, canonname, convert_ipv6_sockaddr(sockaddr)) + for family, type, proto, canonname, sockaddr in gai_res + # filter out IPv6 results when IPv6 is disabled + if not isinstance(sockaddr[0], int) + ] + + +def getnameinfo(sockaddr: IPSockAddrType, flags: int = 0) -> Awaitable[tuple[str, str]]: + """ + Look up the host name of an IP address. + + :param sockaddr: socket address (e.g. (ipaddress, port) for IPv4) + :param flags: flags to pass to upstream ``getnameinfo()`` + :return: a tuple of (host name, service name) + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + .. seealso:: :func:`socket.getnameinfo` + + """ + return get_async_backend().getnameinfo(sockaddr, flags) + + +@deprecated("This function is deprecated; use `wait_readable` instead") +def wait_socket_readable(sock: socket.socket) -> Awaitable[None]: + """ + .. deprecated:: 4.7.0 + Use :func:`wait_readable` instead. + + Wait until the given socket has data to be read. + + .. warning:: Only use this on raw sockets that have not been wrapped by any higher + level constructs like socket streams! + + :param sock: a socket object + :raises ~anyio.ClosedResourceError: if the socket was closed while waiting for the + socket to become readable + :raises ~anyio.BusyResourceError: if another task is already waiting for the socket + to become readable + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().wait_readable(sock.fileno()) + + +@deprecated("This function is deprecated; use `wait_writable` instead") +def wait_socket_writable(sock: socket.socket) -> Awaitable[None]: + """ + .. deprecated:: 4.7.0 + Use :func:`wait_writable` instead. + + Wait until the given socket can be written to. + + This does **NOT** work on Windows when using the asyncio backend with a proactor + event loop (default on py3.8+). + + .. warning:: Only use this on raw sockets that have not been wrapped by any higher + level constructs like socket streams! + + :param sock: a socket object + :raises ~anyio.ClosedResourceError: if the socket was closed while waiting for the + socket to become writable + :raises ~anyio.BusyResourceError: if another task is already waiting for the socket + to become writable + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().wait_writable(sock.fileno()) + + +def wait_readable(obj: FileDescriptorLike) -> Awaitable[None]: + """ + Wait until the given object has data to be read. + + On Unix systems, ``obj`` must either be an integer file descriptor, or else an + object with a ``.fileno()`` method which returns an integer file descriptor. Any + kind of file descriptor can be passed, though the exact semantics will depend on + your kernel. For example, this probably won't do anything useful for on-disk files. + + On Windows systems, ``obj`` must either be an integer ``SOCKET`` handle, or else an + object with a ``.fileno()`` method which returns an integer ``SOCKET`` handle. File + descriptors aren't supported, and neither are handles that refer to anything besides + a ``SOCKET``. + + On backends where this functionality is not natively provided (asyncio + ``ProactorEventLoop`` on Windows), it is provided using a separate selector thread + which is set to shut down when the interpreter shuts down. + + .. warning:: Don't use this on raw sockets that have been wrapped by any higher + level constructs like socket streams! + + :param obj: an object with a ``.fileno()`` method or an integer handle + :raises ~anyio.ClosedResourceError: if the object was closed while waiting for the + object to become readable + :raises ~anyio.BusyResourceError: if another task is already waiting for the object + to become readable + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().wait_readable(obj) + + +def wait_writable(obj: FileDescriptorLike) -> Awaitable[None]: + """ + Wait until the given object can be written to. + + :param obj: an object with a ``.fileno()`` method or an integer handle + :raises ~anyio.ClosedResourceError: if the object was closed while waiting for the + object to become writable + :raises ~anyio.BusyResourceError: if another task is already waiting for the object + to become writable + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + .. seealso:: See the documentation of :func:`wait_readable` for the definition of + ``obj`` and notes on backend compatibility. + + .. warning:: Don't use this on raw sockets that have been wrapped by any higher + level constructs like socket streams! + + """ + return get_async_backend().wait_writable(obj) + + +def notify_closing(obj: FileDescriptorLike) -> None: + """ + Call this before closing a file descriptor (on Unix) or socket (on + Windows). This will cause any `wait_readable` or `wait_writable` + calls on the given object to immediately wake up and raise + `~anyio.ClosedResourceError`. + + This doesn't actually close the object – you still have to do that + yourself afterwards. Also, you want to be careful to make sure no + new tasks start waiting on the object in between when you call this + and when it's actually closed. So to close something properly, you + usually want to do these steps in order: + + 1. Explicitly mark the object as closed, so that any new attempts + to use it will abort before they start. + 2. Call `notify_closing` to wake up any already-existing users. + 3. Actually close the object. + + It's also possible to do them in a different order if that's more + convenient, *but only if* you make sure not to have any checkpoints in + between the steps. This way they all happen in a single atomic + step, so other tasks won't be able to tell what order they happened + in anyway. + + :param obj: an object with a ``.fileno()`` method or an integer handle + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + get_async_backend().notify_closing(obj) + + +# +# Private API +# + + +def convert_ipv6_sockaddr( + sockaddr: tuple[str, int, int, int] | tuple[str, int], +) -> tuple[str, int]: + """ + Convert a 4-tuple IPv6 socket address to a 2-tuple (address, port) format. + + If the scope ID is nonzero, it is added to the address, separated with ``%``. + Otherwise the flow id and scope id are simply cut off from the tuple. + Any other kinds of socket addresses are returned as-is. + + :param sockaddr: the result of :meth:`~socket.socket.getsockname` + :return: the converted socket address + + """ + # This is more complicated than it should be because of MyPy + if isinstance(sockaddr, tuple) and len(sockaddr) == 4: + host, port, flowinfo, scope_id = sockaddr + if scope_id: + # PyPy (as of v7.3.11) leaves the interface name in the result, so + # we discard it and only get the scope ID from the end + # (https://foss.heptapod.net/pypy/pypy/-/issues/3938) + host = host.split("%")[0] + + # Add scope_id to the address + return f"{host}%{scope_id}", port + else: + return host, port + else: + return sockaddr + + +async def setup_unix_local_socket( + path: None | str | bytes | PathLike[Any], + mode: int | None, + socktype: int, +) -> socket.socket: + """ + Create a UNIX local socket object, deleting the socket at the given path if it + exists. + + Not available on Windows. + + :param path: path of the socket + :param mode: permissions to set on the socket + :param socktype: socket.SOCK_STREAM or socket.SOCK_DGRAM + + """ + path_str: str | None + if path is not None: + path_str = os.fsdecode(path) + + # Linux abstract namespace sockets aren't backed by a concrete file so skip stat call + if not path_str.startswith("\0"): + # Copied from pathlib... + try: + stat_result = os.stat(path) + except OSError as e: + if e.errno not in ( + errno.ENOENT, + errno.ENOTDIR, + errno.EBADF, + errno.ELOOP, + ): + raise + else: + if stat.S_ISSOCK(stat_result.st_mode): + os.unlink(path) + else: + path_str = None + + raw_socket = socket.socket(socket.AF_UNIX, socktype) + raw_socket.setblocking(False) + + if path_str is not None: + try: + await to_thread.run_sync(raw_socket.bind, path_str, abandon_on_cancel=True) + if mode is not None: + await to_thread.run_sync(chmod, path_str, mode, abandon_on_cancel=True) + except BaseException: + raw_socket.close() + raise + + return raw_socket + + +@dataclass +class TCPConnectable(ByteStreamConnectable): + """ + Connects to a TCP server at the given host and port. + + :param host: host name or IP address of the server + :param port: TCP port number of the server + """ + + host: str | IPv4Address | IPv6Address + port: int + + def __post_init__(self) -> None: + if self.port < 1 or self.port > 65535: + raise ValueError("TCP port number out of range") + + @override + async def connect(self) -> SocketStream: + try: + return await connect_tcp(self.host, self.port) + except OSError as exc: + raise ConnectionFailed( + f"error connecting to {self.host}:{self.port}: {exc}" + ) from exc + + +@dataclass +class UNIXConnectable(ByteStreamConnectable): + """ + Connects to a UNIX domain socket at the given path. + + :param path: the file system path of the socket + """ + + path: str | bytes | PathLike[str] | PathLike[bytes] + + @override + async def connect(self) -> UNIXSocketStream: + try: + return await connect_unix(self.path) + except OSError as exc: + raise ConnectionFailed(f"error connecting to {self.path!r}: {exc}") from exc + + +def as_connectable( + remote: ByteStreamConnectable + | tuple[str | IPv4Address | IPv6Address, int] + | str + | bytes + | PathLike[str], + /, + *, + tls: bool = False, + ssl_context: ssl.SSLContext | None = None, + tls_hostname: str | None = None, + tls_standard_compatible: bool = True, +) -> ByteStreamConnectable: + """ + Return a byte stream connectable from the given object. + + If a bytestream connectable is given, it is returned unchanged. + If a tuple of (host, port) is given, a TCP connectable is returned. + If a string or bytes path is given, a UNIX connectable is returned. + + If ``tls=True``, the connectable will be wrapped in a + :class:`~.streams.tls.TLSConnectable`. + + :param remote: a connectable, a tuple of (host, port) or a path to a UNIX socket + :param tls: if ``True``, wrap the plaintext connectable in a + :class:`~.streams.tls.TLSConnectable`, using the provided TLS settings) + :param ssl_context: if ``tls=True``, the SSLContext object to use (if not provided, + a secure default will be created) + :param tls_hostname: if ``tls=True``, host name of the server to use for checking + the server certificate (defaults to the host portion of the address for TCP + connectables) + :param tls_standard_compatible: if ``False`` and ``tls=True``, makes the TLS stream + skip the closing handshake when closing the connection, so it won't raise an + exception if the server does the same + + """ + connectable: TCPConnectable | UNIXConnectable | TLSConnectable + if isinstance(remote, ByteStreamConnectable): + return remote + elif isinstance(remote, tuple) and len(remote) == 2: + connectable = TCPConnectable(*remote) + elif isinstance(remote, (str, bytes, PathLike)): + connectable = UNIXConnectable(remote) + else: + raise TypeError(f"cannot convert {remote!r} to a connectable") + + if tls: + if not tls_hostname and isinstance(connectable, TCPConnectable): + tls_hostname = str(connectable.host) + + connectable = TLSConnectable( + connectable, + ssl_context=ssl_context, + hostname=tls_hostname, + standard_compatible=tls_standard_compatible, + ) + + return connectable diff --git a/server/venv/Lib/site-packages/anyio/_core/_streams.py b/server/venv/Lib/site-packages/anyio/_core/_streams.py new file mode 100644 index 0000000..2b9c7df --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_streams.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import math +from typing import TypeVar +from warnings import warn + +from ..streams.memory import ( + MemoryObjectReceiveStream, + MemoryObjectSendStream, + _MemoryObjectStreamState, +) + +T_Item = TypeVar("T_Item") + + +class create_memory_object_stream( + tuple[MemoryObjectSendStream[T_Item], MemoryObjectReceiveStream[T_Item]], +): + """ + Create a memory object stream. + + The stream's item type can be annotated like + :func:`create_memory_object_stream[T_Item]`. + + :param max_buffer_size: number of items held in the buffer until ``send()`` starts + blocking + :param item_type: old way of marking the streams with the right generic type for + static typing (does nothing on AnyIO 4) + + .. deprecated:: 4.0 + Use ``create_memory_object_stream[YourItemType](...)`` instead. + :return: a tuple of (send stream, receive stream) + + """ + + def __new__( # type: ignore[misc] + cls, max_buffer_size: float = 0, item_type: object = None + ) -> tuple[MemoryObjectSendStream[T_Item], MemoryObjectReceiveStream[T_Item]]: + if max_buffer_size != math.inf and not isinstance(max_buffer_size, int): + raise ValueError("max_buffer_size must be either an integer or math.inf") + if max_buffer_size < 0: + raise ValueError("max_buffer_size cannot be negative") + if item_type is not None: + warn( + "The item_type argument has been deprecated in AnyIO 4.0. " + "Use create_memory_object_stream[YourItemType](...) instead.", + DeprecationWarning, + stacklevel=2, + ) + + state = _MemoryObjectStreamState[T_Item](max_buffer_size) + return (MemoryObjectSendStream(state), MemoryObjectReceiveStream(state)) diff --git a/server/venv/Lib/site-packages/anyio/_core/_subprocesses.py b/server/venv/Lib/site-packages/anyio/_core/_subprocesses.py new file mode 100644 index 0000000..9796f8b --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_subprocesses.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from collections.abc import AsyncIterable, Iterable, Mapping, Sequence +from io import BytesIO +from os import PathLike +from subprocess import PIPE, CalledProcessError, CompletedProcess +from typing import IO, Any, TypeAlias, cast + +from ..abc import Process +from ._eventloop import get_async_backend +from ._tasks import create_task_group + +StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes] + + +async def run_process( + command: StrOrBytesPath | Sequence[StrOrBytesPath], + *, + input: bytes | None = None, + stdin: int | IO[Any] | None = None, + stdout: int | IO[Any] | None = PIPE, + stderr: int | IO[Any] | None = PIPE, + check: bool = True, + cwd: StrOrBytesPath | None = None, + env: Mapping[str, str] | None = None, + startupinfo: Any = None, + creationflags: int = 0, + start_new_session: bool = False, + pass_fds: Sequence[int] = (), + user: str | int | None = None, + group: str | int | None = None, + extra_groups: Iterable[str | int] | None = None, + umask: int = -1, +) -> CompletedProcess[bytes]: + """ + Run an external command in a subprocess and wait until it completes. + + .. seealso:: :func:`subprocess.run` + + :param command: either a string to pass to the shell, or an iterable of strings + containing the executable name or path and its arguments + :param input: bytes passed to the standard input of the subprocess + :param stdin: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, + a file-like object, or `None`; ``input`` overrides this + :param stdout: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, + a file-like object, or `None` + :param stderr: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, + :data:`subprocess.STDOUT`, a file-like object, or `None` + :param check: if ``True``, raise :exc:`~subprocess.CalledProcessError` if the + process terminates with a return code other than 0 + :param cwd: If not ``None``, change the working directory to this before running the + command + :param env: if not ``None``, this mapping replaces the inherited environment + variables from the parent process + :param startupinfo: an instance of :class:`subprocess.STARTUPINFO` that can be used + to specify process startup parameters (Windows only) + :param creationflags: flags that can be used to control the creation of the + subprocess (see :class:`subprocess.Popen` for the specifics) + :param start_new_session: if ``true`` the setsid() system call will be made in the + child process prior to the execution of the subprocess. (POSIX only) + :param pass_fds: sequence of file descriptors to keep open between the parent and + child processes. (POSIX only) + :param user: effective user to run the process as (Python >= 3.9, POSIX only) + :param group: effective group to run the process as (Python >= 3.9, POSIX only) + :param extra_groups: supplementary groups to set in the subprocess (Python >= 3.9, + POSIX only) + :param umask: if not negative, this umask is applied in the child process before + running the given command (Python >= 3.9, POSIX only) + :return: an object representing the completed process + :raises ~subprocess.CalledProcessError: if ``check`` is ``True`` and the process + exits with a nonzero return code + + """ + + async def drain_stream(stream: AsyncIterable[bytes], index: int) -> None: + buffer = BytesIO() + async for chunk in stream: + buffer.write(chunk) + + stream_contents[index] = buffer.getvalue() + + if stdin is not None and input is not None: + raise ValueError("only one of stdin and input is allowed") + + async with await open_process( + command, + stdin=PIPE if input else stdin, + stdout=stdout, + stderr=stderr, + cwd=cwd, + env=env, + startupinfo=startupinfo, + creationflags=creationflags, + start_new_session=start_new_session, + pass_fds=pass_fds, + user=user, + group=group, + extra_groups=extra_groups, + umask=umask, + ) as process: + stream_contents: list[bytes | None] = [None, None] + async with create_task_group() as tg: + if process.stdout: + tg.start_soon(drain_stream, process.stdout, 0) + + if process.stderr: + tg.start_soon(drain_stream, process.stderr, 1) + + if process.stdin and input: + await process.stdin.send(input) + await process.stdin.aclose() + + await process.wait() + + output, errors = stream_contents + if check and process.returncode != 0: + raise CalledProcessError(cast(int, process.returncode), command, output, errors) + + return CompletedProcess(command, cast(int, process.returncode), output, errors) + + +async def open_process( + command: StrOrBytesPath | Sequence[StrOrBytesPath], + *, + stdin: int | IO[Any] | None = PIPE, + stdout: int | IO[Any] | None = PIPE, + stderr: int | IO[Any] | None = PIPE, + cwd: StrOrBytesPath | None = None, + env: Mapping[str, str] | None = None, + startupinfo: Any = None, + creationflags: int = 0, + start_new_session: bool = False, + pass_fds: Sequence[int] = (), + user: str | int | None = None, + group: str | int | None = None, + extra_groups: Iterable[str | int] | None = None, + umask: int = -1, +) -> Process: + """ + Start an external command in a subprocess. + + .. seealso:: :class:`subprocess.Popen` + + :param command: either a string to pass to the shell, or an iterable of strings + containing the executable name or path and its arguments + :param stdin: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, a + file-like object, or ``None`` + :param stdout: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, + a file-like object, or ``None`` + :param stderr: one of :data:`subprocess.PIPE`, :data:`subprocess.DEVNULL`, + :data:`subprocess.STDOUT`, a file-like object, or ``None`` + :param cwd: If not ``None``, the working directory is changed before executing + :param env: If env is not ``None``, it must be a mapping that defines the + environment variables for the new process + :param creationflags: flags that can be used to control the creation of the + subprocess (see :class:`subprocess.Popen` for the specifics) + :param startupinfo: an instance of :class:`subprocess.STARTUPINFO` that can be used + to specify process startup parameters (Windows only) + :param start_new_session: if ``true`` the setsid() system call will be made in the + child process prior to the execution of the subprocess. (POSIX only) + :param pass_fds: sequence of file descriptors to keep open between the parent and + child processes. (POSIX only) + :param user: effective user to run the process as (POSIX only) + :param group: effective group to run the process as (POSIX only) + :param extra_groups: supplementary groups to set in the subprocess (POSIX only) + :param umask: if not negative, this umask is applied in the child process before + running the given command (POSIX only) + :return: an asynchronous process object + + """ + kwargs: dict[str, Any] = {} + if user is not None: + kwargs["user"] = user + + if group is not None: + kwargs["group"] = group + + if extra_groups is not None: + kwargs["extra_groups"] = group + + if umask >= 0: + kwargs["umask"] = umask + + return await get_async_backend().open_process( + command, + stdin=stdin, + stdout=stdout, + stderr=stderr, + cwd=cwd, + env=env, + startupinfo=startupinfo, + creationflags=creationflags, + start_new_session=start_new_session, + pass_fds=pass_fds, + **kwargs, + ) diff --git a/server/venv/Lib/site-packages/anyio/_core/_synchronization.py b/server/venv/Lib/site-packages/anyio/_core/_synchronization.py new file mode 100644 index 0000000..ed19116 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_synchronization.py @@ -0,0 +1,772 @@ +from __future__ import annotations + +import math +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass +from types import TracebackType +from typing import TypeVar + +from ..lowlevel import checkpoint_if_cancelled +from ._eventloop import get_async_backend +from ._exceptions import BusyResourceError, NoEventLoopError +from ._tasks import CancelScope +from ._testing import TaskInfo, get_current_task + +T = TypeVar("T") + + +@dataclass(frozen=True) +class EventStatistics: + """ + :ivar int tasks_waiting: number of tasks waiting on :meth:`~.Event.wait` + """ + + tasks_waiting: int + + +@dataclass(frozen=True) +class CapacityLimiterStatistics: + """ + :ivar int borrowed_tokens: number of tokens currently borrowed by tasks + :ivar float total_tokens: total number of available tokens + :ivar tuple borrowers: tasks or other objects currently holding tokens borrowed from + this limiter + :ivar int tasks_waiting: number of tasks waiting on + :meth:`~.CapacityLimiter.acquire` or + :meth:`~.CapacityLimiter.acquire_on_behalf_of` + """ + + borrowed_tokens: int + total_tokens: float + borrowers: tuple[object, ...] + tasks_waiting: int + + +@dataclass(frozen=True) +class LockStatistics: + """ + :ivar bool locked: flag indicating if this lock is locked or not + :ivar ~anyio.TaskInfo owner: task currently holding the lock (or ``None`` if the + lock is not held by any task) + :ivar int tasks_waiting: number of tasks waiting on :meth:`~.Lock.acquire` + """ + + locked: bool + owner: TaskInfo | None + tasks_waiting: int + + +@dataclass(frozen=True) +class ConditionStatistics: + """ + :ivar int tasks_waiting: number of tasks blocked on :meth:`~.Condition.wait` + :ivar ~anyio.LockStatistics lock_statistics: statistics of the underlying + :class:`~.Lock` + """ + + tasks_waiting: int + lock_statistics: LockStatistics + + +@dataclass(frozen=True) +class SemaphoreStatistics: + """ + :ivar int tasks_waiting: number of tasks waiting on :meth:`~.Semaphore.acquire` + + """ + + tasks_waiting: int + + +class Event: + __slots__ = ("__weakref__",) + + def __new__(cls) -> Event: + try: + return get_async_backend().create_event() + except NoEventLoopError: + return EventAdapter() + + def set(self) -> None: + """Set the flag, notifying all listeners.""" + raise NotImplementedError + + def is_set(self) -> bool: + """Return ``True`` if the flag is set, ``False`` if not.""" + raise NotImplementedError + + async def wait(self) -> None: + """ + Wait until the flag has been set. + + If the flag has already been set when this method is called, it returns + immediately. + + """ + raise NotImplementedError + + def statistics(self) -> EventStatistics: + """Return statistics about the current state of this event.""" + raise NotImplementedError + + +class EventAdapter(Event): + __slots__ = "_internal_event", "_is_set" + + def __new__(cls) -> EventAdapter: + return object.__new__(cls) + + def __init__(self) -> None: + self._internal_event: Event | None = None + self._is_set = False + + @property + def _event(self) -> Event: + if self._internal_event is None: + self._internal_event = get_async_backend().create_event() + if self._is_set: + self._internal_event.set() + + return self._internal_event + + def set(self) -> None: + if self._internal_event is None: + self._is_set = True + else: + self._event.set() + + def is_set(self) -> bool: + if self._internal_event is None: + return self._is_set + + return self._internal_event.is_set() + + async def wait(self) -> None: + await self._event.wait() + + def statistics(self) -> EventStatistics: + if self._internal_event is None: + return EventStatistics(tasks_waiting=0) + + return self._internal_event.statistics() + + +class Lock: + __slots__ = ("__weakref__",) + + def __new__(cls, *, fast_acquire: bool = False) -> Lock: + try: + return get_async_backend().create_lock(fast_acquire=fast_acquire) + except NoEventLoopError: + return LockAdapter(fast_acquire=fast_acquire) + + async def __aenter__(self) -> None: + await self.acquire() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.release() + + async def acquire(self) -> None: + """Acquire the lock.""" + raise NotImplementedError + + def acquire_nowait(self) -> None: + """ + Acquire the lock, without blocking. + + :raises ~anyio.WouldBlock: if the operation would block + + """ + raise NotImplementedError + + def release(self) -> None: + """Release the lock.""" + raise NotImplementedError + + def locked(self) -> bool: + """Return True if the lock is currently held.""" + raise NotImplementedError + + def statistics(self) -> LockStatistics: + """ + Return statistics about the current state of this lock. + + .. versionadded:: 3.0 + """ + raise NotImplementedError + + +class LockAdapter(Lock): + __slots__ = "_internal_lock", "_fast_acquire" + + def __new__(cls, *, fast_acquire: bool = False) -> LockAdapter: + return object.__new__(cls) + + def __init__(self, *, fast_acquire: bool = False): + self._internal_lock: Lock | None = None + self._fast_acquire = fast_acquire + + @property + def _lock(self) -> Lock: + if self._internal_lock is None: + self._internal_lock = get_async_backend().create_lock( + fast_acquire=self._fast_acquire + ) + + return self._internal_lock + + async def __aenter__(self) -> None: + await self._lock.acquire() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + if self._internal_lock is not None: + self._internal_lock.release() + + async def acquire(self) -> None: + """Acquire the lock.""" + await self._lock.acquire() + + def acquire_nowait(self) -> None: + """ + Acquire the lock, without blocking. + + :raises ~anyio.WouldBlock: if the operation would block + + """ + self._lock.acquire_nowait() + + def release(self) -> None: + """Release the lock.""" + self._lock.release() + + def locked(self) -> bool: + """Return True if the lock is currently held.""" + return self._lock.locked() + + def statistics(self) -> LockStatistics: + """ + Return statistics about the current state of this lock. + + .. versionadded:: 3.0 + + """ + if self._internal_lock is None: + return LockStatistics(False, None, 0) + + return self._internal_lock.statistics() + + +class Condition: + __slots__ = "__weakref__", "_owner_task", "_lock", "_waiters" + + def __init__(self, lock: Lock | None = None): + self._owner_task: TaskInfo | None = None + self._lock = lock or Lock() + self._waiters: deque[Event] = deque() + + async def __aenter__(self) -> None: + await self.acquire() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.release() + + def _check_acquired(self) -> None: + if self._owner_task != get_current_task(): + raise RuntimeError("The current task is not holding the underlying lock") + + async def acquire(self) -> None: + """Acquire the underlying lock.""" + await self._lock.acquire() + self._owner_task = get_current_task() + + def acquire_nowait(self) -> None: + """ + Acquire the underlying lock, without blocking. + + :raises ~anyio.WouldBlock: if the operation would block + + """ + self._lock.acquire_nowait() + self._owner_task = get_current_task() + + def release(self) -> None: + """Release the underlying lock.""" + self._lock.release() + + def locked(self) -> bool: + """Return True if the lock is set.""" + return self._lock.locked() + + def notify(self, n: int = 1) -> None: + """Notify exactly n listeners.""" + self._check_acquired() + for _ in range(n): + try: + event = self._waiters.popleft() + except IndexError: + break + + event.set() + + def notify_all(self) -> None: + """Notify all the listeners.""" + self._check_acquired() + for event in self._waiters: + event.set() + + self._waiters.clear() + + async def wait(self) -> None: + """Wait for a notification.""" + await checkpoint_if_cancelled() + self._check_acquired() + event = Event() + self._waiters.append(event) + self.release() + try: + await event.wait() + except BaseException: + if not event.is_set(): + self._waiters.remove(event) + elif self._waiters: + # This task was notified by could not act on it, so pass + # it on to the next task + self._waiters.popleft().set() + + raise + finally: + with CancelScope(shield=True): + await self.acquire() + + async def wait_for(self, predicate: Callable[[], T]) -> T: + """ + Wait until a predicate becomes true. + + :param predicate: a callable that returns a truthy value when the condition is + met + :return: the result of the predicate + + .. versionadded:: 4.11.0 + + """ + while not (result := predicate()): + await self.wait() + + return result + + def statistics(self) -> ConditionStatistics: + """ + Return statistics about the current state of this condition. + + .. versionadded:: 3.0 + """ + return ConditionStatistics(len(self._waiters), self._lock.statistics()) + + +class Semaphore: + __slots__ = "__weakref__", "_fast_acquire" + + def __new__( + cls, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> Semaphore: + try: + return get_async_backend().create_semaphore( + initial_value, max_value=max_value, fast_acquire=fast_acquire + ) + except NoEventLoopError: + return SemaphoreAdapter(initial_value, max_value=max_value) + + def __init__( + self, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ): + if not isinstance(initial_value, int): + raise TypeError("initial_value must be an integer") + if initial_value < 0: + raise ValueError("initial_value must be >= 0") + if max_value is not None: + if not isinstance(max_value, int): + raise TypeError("max_value must be an integer or None") + if max_value < initial_value: + raise ValueError( + "max_value must be equal to or higher than initial_value" + ) + + self._fast_acquire = fast_acquire + + async def __aenter__(self) -> Semaphore: + await self.acquire() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.release() + + async def acquire(self) -> None: + """Decrement the semaphore value, blocking if necessary.""" + raise NotImplementedError + + def acquire_nowait(self) -> None: + """ + Acquire the underlying lock, without blocking. + + :raises ~anyio.WouldBlock: if the operation would block + + """ + raise NotImplementedError + + def release(self) -> None: + """Increment the semaphore value.""" + raise NotImplementedError + + @property + def value(self) -> int: + """The current value of the semaphore.""" + raise NotImplementedError + + @property + def max_value(self) -> int | None: + """The maximum value of the semaphore.""" + raise NotImplementedError + + def statistics(self) -> SemaphoreStatistics: + """ + Return statistics about the current state of this semaphore. + + .. versionadded:: 3.0 + """ + raise NotImplementedError + + +class SemaphoreAdapter(Semaphore): + __slots__ = "_internal_semaphore", "_initial_value", "_max_value" + + def __new__( + cls, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> SemaphoreAdapter: + return object.__new__(cls) + + def __init__( + self, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> None: + super().__init__(initial_value, max_value=max_value, fast_acquire=fast_acquire) + self._internal_semaphore: Semaphore | None = None + self._initial_value = initial_value + self._max_value = max_value + + @property + def _semaphore(self) -> Semaphore: + if self._internal_semaphore is None: + self._internal_semaphore = get_async_backend().create_semaphore( + self._initial_value, max_value=self._max_value + ) + + return self._internal_semaphore + + async def acquire(self) -> None: + await self._semaphore.acquire() + + def acquire_nowait(self) -> None: + self._semaphore.acquire_nowait() + + def release(self) -> None: + self._semaphore.release() + + @property + def value(self) -> int: + if self._internal_semaphore is None: + return self._initial_value + + return self._semaphore.value + + @property + def max_value(self) -> int | None: + return self._max_value + + def statistics(self) -> SemaphoreStatistics: + if self._internal_semaphore is None: + return SemaphoreStatistics(tasks_waiting=0) + + return self._semaphore.statistics() + + +class CapacityLimiter: + __slots__ = ("__weakref__",) + + def __new__(cls, total_tokens: float) -> CapacityLimiter: + try: + return get_async_backend().create_capacity_limiter(total_tokens) + except NoEventLoopError: + return CapacityLimiterAdapter(total_tokens) + + async def __aenter__(self) -> None: + raise NotImplementedError + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + raise NotImplementedError + + @property + def total_tokens(self) -> float: + """ + The total number of tokens available for borrowing. + + This is a read-write property. If the total number of tokens is increased, the + proportionate number of tasks waiting on this limiter will be granted their + tokens. + + .. versionchanged:: 3.0 + The property is now writable. + .. versionchanged:: 4.12 + The value can now be set to 0. + + """ + raise NotImplementedError + + @total_tokens.setter + def total_tokens(self, value: float) -> None: + raise NotImplementedError + + @property + def borrowed_tokens(self) -> int: + """The number of tokens that have currently been borrowed.""" + raise NotImplementedError + + @property + def available_tokens(self) -> float: + """The number of tokens currently available to be borrowed""" + raise NotImplementedError + + def acquire_nowait(self) -> None: + """ + Acquire a token for the current task without waiting for one to become + available. + + :raises ~anyio.WouldBlock: if there are no tokens available for borrowing + + """ + raise NotImplementedError + + def acquire_on_behalf_of_nowait(self, borrower: object) -> None: + """ + Acquire a token without waiting for one to become available. + + :param borrower: the entity borrowing a token + :raises ~anyio.WouldBlock: if there are no tokens available for borrowing + + """ + raise NotImplementedError + + async def acquire(self) -> None: + """ + Acquire a token for the current task, waiting if necessary for one to become + available. + + """ + raise NotImplementedError + + async def acquire_on_behalf_of(self, borrower: object) -> None: + """ + Acquire a token, waiting if necessary for one to become available. + + :param borrower: the entity borrowing a token + + """ + raise NotImplementedError + + def release(self) -> None: + """ + Release the token held by the current task. + + :raises RuntimeError: if the current task has not borrowed a token from this + limiter. + + """ + raise NotImplementedError + + def release_on_behalf_of(self, borrower: object) -> None: + """ + Release the token held by the given borrower. + + :raises RuntimeError: if the borrower has not borrowed a token from this + limiter. + + """ + raise NotImplementedError + + def statistics(self) -> CapacityLimiterStatistics: + """ + Return statistics about the current state of this limiter. + + .. versionadded:: 3.0 + + """ + raise NotImplementedError + + +class CapacityLimiterAdapter(CapacityLimiter): + __slots__ = "_internal_limiter", "_total_tokens" + + def __new__(cls, total_tokens: float) -> CapacityLimiterAdapter: + return object.__new__(cls) + + def __init__(self, total_tokens: float) -> None: + self._internal_limiter: CapacityLimiter | None = None + self.total_tokens = total_tokens + + @property + def _limiter(self) -> CapacityLimiter: + if self._internal_limiter is None: + self._internal_limiter = get_async_backend().create_capacity_limiter( + self._total_tokens + ) + + return self._internal_limiter + + async def __aenter__(self) -> None: + await self._limiter.__aenter__() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + return await self._limiter.__aexit__(exc_type, exc_val, exc_tb) + + @property + def total_tokens(self) -> float: + if self._internal_limiter is None: + return self._total_tokens + + return self._internal_limiter.total_tokens + + @total_tokens.setter + def total_tokens(self, value: float) -> None: + if not isinstance(value, int) and value is not math.inf: + raise TypeError("total_tokens must be an int or math.inf") + elif value < 1: + raise ValueError("total_tokens must be >= 1") + + if self._internal_limiter is None: + self._total_tokens = value + return + + self._limiter.total_tokens = value + + @property + def borrowed_tokens(self) -> int: + if self._internal_limiter is None: + return 0 + + return self._internal_limiter.borrowed_tokens + + @property + def available_tokens(self) -> float: + if self._internal_limiter is None: + return self._total_tokens + + return self._internal_limiter.available_tokens + + def acquire_nowait(self) -> None: + self._limiter.acquire_nowait() + + def acquire_on_behalf_of_nowait(self, borrower: object) -> None: + self._limiter.acquire_on_behalf_of_nowait(borrower) + + async def acquire(self) -> None: + await self._limiter.acquire() + + async def acquire_on_behalf_of(self, borrower: object) -> None: + await self._limiter.acquire_on_behalf_of(borrower) + + def release(self) -> None: + self._limiter.release() + + def release_on_behalf_of(self, borrower: object) -> None: + self._limiter.release_on_behalf_of(borrower) + + def statistics(self) -> CapacityLimiterStatistics: + if self._internal_limiter is None: + return CapacityLimiterStatistics( + borrowed_tokens=0, + total_tokens=self.total_tokens, + borrowers=(), + tasks_waiting=0, + ) + + return self._internal_limiter.statistics() + + +class ResourceGuard: + """ + A context manager for ensuring that a resource is only used by a single task at a + time. + + Entering this context manager while the previous has not exited it yet will trigger + :exc:`BusyResourceError`. + + :param action: the action to guard against (visible in the :exc:`BusyResourceError` + when triggered, e.g. "Another task is already {action} this resource") + + .. versionadded:: 4.1 + """ + + __slots__ = "__weakref__", "action", "_guarded" + + def __init__(self, action: str = "using"): + self.action: str = action + self._guarded = False + + def __enter__(self) -> None: + if self._guarded: + raise BusyResourceError(self.action) + + self._guarded = True + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self._guarded = False diff --git a/server/venv/Lib/site-packages/anyio/_core/_tasks.py b/server/venv/Lib/site-packages/anyio/_core/_tasks.py new file mode 100644 index 0000000..108393e --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_tasks.py @@ -0,0 +1,415 @@ +from __future__ import annotations + +import math +import sys +from collections.abc import ( + Coroutine, + Generator, +) +from contextlib import ( + contextmanager, +) +from contextvars import ContextVar +from enum import Enum, auto +from inspect import iscoroutine +from types import TracebackType +from typing import Any, Generic, final + +from ..abc import TaskGroup, TaskStatus +from ._eventloop import get_async_backend, get_cancelled_exc_class +from ._exceptions import TaskCancelled, TaskFailed, TaskNotFinished + +if sys.version_info >= (3, 13): + from typing import TypeVar +else: + from typing_extensions import TypeVar + +if sys.version_info >= (3, 11): + from typing import Never, TypeVarTuple +else: + from typing_extensions import Never, TypeVarTuple + +T = TypeVar("T") +T_co = TypeVar("T_co", covariant=True) +T_startval = TypeVar("T_startval", covariant=True, default=Never) +PosArgsT = TypeVarTuple("PosArgsT") + +_current_task_handle: ContextVar[TaskHandle] = ContextVar("_current_task_handle") + + +class _IgnoredTaskStatus(TaskStatus[object]): + def started(self, value: object = None) -> None: + pass + + +TASK_STATUS_IGNORED = _IgnoredTaskStatus() + + +class CancelScope: + """ + Wraps a unit of work that can be made separately cancellable. + + :param deadline: The time (clock value) when this scope is cancelled automatically + :param shield: ``True`` to shield the cancel scope from external cancellation + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + """ + + __slots__ = ("__weakref__",) + + def __new__( + cls, *, deadline: float = math.inf, shield: bool = False + ) -> CancelScope: + return get_async_backend().create_cancel_scope(shield=shield, deadline=deadline) + + def cancel(self, reason: str | None = None) -> None: + """ + Cancel this scope immediately. + + :param reason: a message describing the reason for the cancellation + + """ + raise NotImplementedError + + @property + def deadline(self) -> float: + """ + The time (clock value) when this scope is cancelled automatically. + + Will be ``float('inf')`` if no timeout has been set. + + """ + raise NotImplementedError + + @deadline.setter + def deadline(self, value: float) -> None: + raise NotImplementedError + + @property + def cancel_called(self) -> bool: + """``True`` if :meth:`cancel` has been called.""" + raise NotImplementedError + + @property + def cancelled_caught(self) -> bool: + """ + ``True`` if this scope suppressed a cancellation exception it itself raised. + + This is typically used to check if any work was interrupted, or to see if the + scope was cancelled due to its deadline being reached. The value will, however, + only be ``True`` if the cancellation was triggered by the scope itself (and not + an outer scope). + + """ + raise NotImplementedError + + @property + def shield(self) -> bool: + """ + ``True`` if this scope is shielded from external cancellation. + + While a scope is shielded, it will not receive cancellations from outside. + + """ + raise NotImplementedError + + @shield.setter + def shield(self, value: bool) -> None: + raise NotImplementedError + + def __enter__(self) -> CancelScope: + raise NotImplementedError + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + raise NotImplementedError + + +@contextmanager +def fail_after( + delay: float | None, shield: bool = False +) -> Generator[CancelScope, None, None]: + """ + Create a context manager which raises a :class:`TimeoutError` if does not finish in + time. + + :param delay: maximum allowed time (in seconds) before raising the exception, or + ``None`` to disable the timeout + :param shield: ``True`` to shield the cancel scope from external cancellation + :return: a context manager that yields a cancel scope + :rtype: :class:`~typing.ContextManager`\\[:class:`~anyio.CancelScope`\\] + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + current_time = get_async_backend().current_time + deadline = (current_time() + delay) if delay is not None else math.inf + with get_async_backend().create_cancel_scope( + deadline=deadline, shield=shield + ) as cancel_scope: + yield cancel_scope + + if cancel_scope.cancelled_caught and current_time() >= cancel_scope.deadline: + raise TimeoutError + + +def move_on_after(delay: float | None, shield: bool = False) -> CancelScope: + """ + Create a cancel scope with a deadline that expires after the given delay. + + :param delay: maximum allowed time (in seconds) before exiting the context block, or + ``None`` to disable the timeout + :param shield: ``True`` to shield the cancel scope from external cancellation + :return: a cancel scope + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + deadline = ( + (get_async_backend().current_time() + delay) if delay is not None else math.inf + ) + return get_async_backend().create_cancel_scope(deadline=deadline, shield=shield) + + +def current_effective_deadline() -> float: + """ + Return the nearest deadline among all the cancel scopes effective for the current + task. + + :return: a clock value from the event loop's internal clock (or ``float('inf')`` if + there is no deadline in effect, or ``float('-inf')`` if the current scope has + been cancelled) + :rtype: float + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().current_effective_deadline() + + +def create_task_group() -> TaskGroup: + """ + Create a task group. + + :return: a task group + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().create_task_group() + + +@final +class TaskHandle(Generic[T_co, T_startval]): + """ + Returned from the task-spawning methods of :class:`TaskGroup`. Can be awaited on to + get the return value of the task (or the raised exception). If the task was + terminated by a :exc:`BaseException`, :exc:`TaskFailed` will be raised (or its + subclass :exc:`TaskCancelled` if the task was cancelled). + + .. versionadded:: 4.14.0 + """ + + class Status(Enum): + """ + The status of a task handle. + + .. attribute:: PENDING + + The task has not finished yet. + .. attribute:: FINISHED + + The task has finished with a return value. + .. attribute:: CANCELLING + + The task has been cancelled but has not finished yet. + .. attribute:: CANCELLED + + The task was cancelled and has finished since. + .. attribute:: FAILED + + The task raised an exception. + """ + + PENDING = auto() + FINISHED = auto() + CANCELLING = auto() + CANCELLED = auto() + FAILED = auto() + + __slots__ = ( + "__weakref__", + "_coro", + "_name", + "_cancel_scope", + "_finished_event", + "_return_value", + "_start_value", + "_exception", + ) + + _return_value: T_co + _start_value: T_startval + + def __init__(self, coro: Coroutine[Any, Any, T_co], name: object) -> None: + from ._synchronization import Event + + self._coro = coro + self._cancel_scope = CancelScope() + self._finished_event = Event() + self._exception: BaseException | None = None + + if name is not None: + self._name = str(name) + elif iscoroutine(coro): + self._name = coro.__qualname__ + else: + self._name = str(coro) # coroutine-like object (e.g. asend() objects) + + async def _run_coro(self) -> None: + __tracebackhide__ = True + + with self._cancel_scope: + try: + retval = await self._coro + except BaseException as exc: + self._exception = exc + raise + else: + self._return_value = retval + finally: + self._finished_event.set() + del self # Break the reference cycle + + def cancel(self) -> None: + """ + Set the task to a cancelled state. + + This will interrupt any interruptible asynchronous operation, and will cause + any further awaits on this task to get immediately cancelled, unless done in + a shielded cancel scope. + + If the task has already finished, this method has no effect. + """ + if not self._finished_event.is_set(): + self._cancel_scope.cancel() + + @property + def coro(self) -> Coroutine[Any, Any, T_co]: + """ + The coroutine object that was passed to one of the task-spawning methods in + :class:`TaskGroup`. + """ + return self._coro + + @property + def status(self) -> TaskHandle.Status: + """ + The current status of the task. + + Every task starts in the :attr:`~TaskHandle.Status.PENDING` state. + If a task is cancelled while in this state, it will transition to the + :attr:`~TaskHandle.Status.CANCELLING` state. When the task finishes, it will + transition to one of the three final states ( + :attr:`~TaskHandle.Status.FINISHED`, :attr:`~TaskHandle.Status.FAILED`, or + :attr:`~TaskHandle.Status.CANCELLING`) depending on the exception the task + raised, if any. No other status transitions will happen. + """ + if not self._finished_event.is_set(): + if self._cancel_scope.cancel_called: + return TaskHandle.Status.CANCELLING + else: + return TaskHandle.Status.PENDING + elif self._exception is not None: + if isinstance(self._exception, get_cancelled_exc_class()): + return TaskHandle.Status.CANCELLED + else: + return TaskHandle.Status.FAILED + else: + return TaskHandle.Status.FINISHED + + @property + def name(self) -> str: + """The name of the task.""" + return self._name + + @property + def exception(self) -> BaseException | None: + """ + The exception raised by the task, or ``None`` if it finished without raising. + + :raises TaskNotFinished: if the task has not finished yet + :raises TaskCancelled: if the task was cancelled + + """ + match self.status: + case TaskHandle.Status.PENDING: + raise TaskNotFinished("the task has not finished yet") + case TaskHandle.Status.FINISHED: + return None + case TaskHandle.Status.CANCELLING: + raise TaskCancelled("the task was cancelled") + case TaskHandle.Status.CANCELLED: + raise TaskCancelled("the task was cancelled") from self._exception + case TaskHandle.Status.FAILED: + return self._exception + + @property + def return_value(self) -> T_co: + """ + The return value of the task. + + :raises TaskNotFinished: if the task has not finished yet + :raises TaskCancelled: if the task was cancelled + :raises TaskFailed: if the task raised an exception + + """ + match self.status: + case TaskHandle.Status.PENDING: + raise TaskNotFinished("the task has not finished yet") + case TaskHandle.Status.FINISHED: + return self._return_value + case TaskHandle.Status.CANCELLING: + raise TaskCancelled("the task was cancelled") + case TaskHandle.Status.CANCELLED: + raise TaskCancelled("the task was cancelled") from self._exception + case TaskHandle.Status.FAILED: + raise TaskFailed("the task raised an exception") from self._exception + + @property + def start_value(self) -> T_startval: + """ + The value passed to :meth:`task_status.started() <.abc.TaskStatus.started>`, + + :raises RuntimeError: if the task was not started with :meth:`TaskGroup.start() + <.abc.TaskGroup.start>` + """ + try: + return self._start_value + except AttributeError: + raise RuntimeError( + "the task was not started with TaskGroup.start()" + ) from None + + async def wait(self) -> None: + """ + Wait for the task to finish. + + This method will return as soon as the task has finished, no matter how it + happened. + """ + await self._finished_event.wait() + + def __await__(self) -> Generator[Any, Any, T_co]: + yield from self._finished_event.wait().__await__() + return self.return_value + + def __repr__(self) -> str: + return ( + f"<{self.__class__.__name__} {self.status.name.lower()} " + f"name={self._name!r} coro={self._coro!r}>" + ) diff --git a/server/venv/Lib/site-packages/anyio/_core/_tempfile.py b/server/venv/Lib/site-packages/anyio/_core/_tempfile.py new file mode 100644 index 0000000..75a09f7 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_tempfile.py @@ -0,0 +1,613 @@ +from __future__ import annotations + +import os +import sys +import tempfile +from collections.abc import Iterable +from io import BytesIO, TextIOWrapper +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + AnyStr, + Generic, + overload, +) + +from .. import to_thread +from .._core._fileio import AsyncFile +from ..lowlevel import checkpoint_if_cancelled + +if TYPE_CHECKING: + from _typeshed import OpenBinaryMode, OpenTextMode, ReadableBuffer, WriteableBuffer + + +class TemporaryFile(Generic[AnyStr]): + """ + An asynchronous temporary file that is automatically created and cleaned up. + + This class provides an asynchronous context manager interface to a temporary file. + The file is created using Python's standard `tempfile.TemporaryFile` function in a + background thread, and is wrapped as an asynchronous file using `AsyncFile`. + + :param mode: The mode in which the file is opened. Defaults to "w+b". + :param buffering: The buffering policy (-1 means the default buffering). + :param encoding: The encoding used to decode or encode the file. Only applicable in + text mode. + :param newline: Controls how universal newlines mode works (only applicable in text + mode). + :param suffix: The suffix for the temporary file name. + :param prefix: The prefix for the temporary file name. + :param dir: The directory in which the temporary file is created. + :param errors: The error handling scheme used for encoding/decoding errors. + """ + + _async_file: AsyncFile[AnyStr] + + @overload + def __init__( + self: TemporaryFile[bytes], + mode: OpenBinaryMode = ..., + buffering: int = ..., + encoding: str | None = ..., + newline: str | None = ..., + suffix: str | None = ..., + prefix: str | None = ..., + dir: str | None = ..., + *, + errors: str | None = ..., + ): ... + @overload + def __init__( + self: TemporaryFile[str], + mode: OpenTextMode, + buffering: int = ..., + encoding: str | None = ..., + newline: str | None = ..., + suffix: str | None = ..., + prefix: str | None = ..., + dir: str | None = ..., + *, + errors: str | None = ..., + ): ... + + def __init__( + self, + mode: OpenTextMode | OpenBinaryMode = "w+b", + buffering: int = -1, + encoding: str | None = None, + newline: str | None = None, + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, + *, + errors: str | None = None, + ) -> None: + self.mode = mode + self.buffering = buffering + self.encoding = encoding + self.newline = newline + self.suffix: str | None = suffix + self.prefix: str | None = prefix + self.dir: str | None = dir + self.errors = errors + + async def __aenter__(self) -> AsyncFile[AnyStr]: + fp = await to_thread.run_sync( + lambda: tempfile.TemporaryFile( + self.mode, + self.buffering, + self.encoding, + self.newline, + self.suffix, + self.prefix, + self.dir, + errors=self.errors, + ) + ) + self._async_file = AsyncFile(fp) + return self._async_file + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self._async_file.aclose() + + +class NamedTemporaryFile(Generic[AnyStr]): + """ + An asynchronous named temporary file that is automatically created and cleaned up. + + This class provides an asynchronous context manager for a temporary file with a + visible name in the file system. It uses Python's standard + :func:`~tempfile.NamedTemporaryFile` function and wraps the file object with + :class:`AsyncFile` for asynchronous operations. + + :param mode: The mode in which the file is opened. Defaults to "w+b". + :param buffering: The buffering policy (-1 means the default buffering). + :param encoding: The encoding used to decode or encode the file. Only applicable in + text mode. + :param newline: Controls how universal newlines mode works (only applicable in text + mode). + :param suffix: The suffix for the temporary file name. + :param prefix: The prefix for the temporary file name. + :param dir: The directory in which the temporary file is created. + :param delete: Whether to delete the file when it is closed. + :param errors: The error handling scheme used for encoding/decoding errors. + :param delete_on_close: (Python 3.12+) Whether to delete the file on close. + """ + + _async_file: AsyncFile[AnyStr] + + @overload + def __init__( + self: NamedTemporaryFile[bytes], + mode: OpenBinaryMode = ..., + buffering: int = ..., + encoding: str | None = ..., + newline: str | None = ..., + suffix: str | None = ..., + prefix: str | None = ..., + dir: str | None = ..., + delete: bool = ..., + *, + errors: str | None = ..., + delete_on_close: bool = ..., + ): ... + @overload + def __init__( + self: NamedTemporaryFile[str], + mode: OpenTextMode, + buffering: int = ..., + encoding: str | None = ..., + newline: str | None = ..., + suffix: str | None = ..., + prefix: str | None = ..., + dir: str | None = ..., + delete: bool = ..., + *, + errors: str | None = ..., + delete_on_close: bool = ..., + ): ... + + def __init__( + self, + mode: OpenBinaryMode | OpenTextMode = "w+b", + buffering: int = -1, + encoding: str | None = None, + newline: str | None = None, + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, + delete: bool = True, + *, + errors: str | None = None, + delete_on_close: bool = True, + ) -> None: + self._params: dict[str, Any] = { + "mode": mode, + "buffering": buffering, + "encoding": encoding, + "newline": newline, + "suffix": suffix, + "prefix": prefix, + "dir": dir, + "delete": delete, + "errors": errors, + } + if sys.version_info >= (3, 12): + self._params["delete_on_close"] = delete_on_close + + async def __aenter__(self) -> AsyncFile[AnyStr]: + fp = await to_thread.run_sync( + lambda: tempfile.NamedTemporaryFile(**self._params) + ) + self._async_file = AsyncFile(fp) + return self._async_file + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self._async_file.aclose() + + +class SpooledTemporaryFile(AsyncFile[AnyStr]): + """ + An asynchronous spooled temporary file that starts in memory and is spooled to disk. + + This class provides an asynchronous interface to a spooled temporary file, much like + Python's standard :class:`~tempfile.SpooledTemporaryFile`. It supports asynchronous + write operations and provides a method to force a rollover to disk. + + :param max_size: Maximum size in bytes before the file is rolled over to disk. + :param mode: The mode in which the file is opened. Defaults to "w+b". + :param buffering: The buffering policy (-1 means the default buffering). + :param encoding: The encoding used to decode or encode the file (text mode only). + :param newline: Controls how universal newlines mode works (text mode only). + :param suffix: The suffix for the temporary file name. + :param prefix: The prefix for the temporary file name. + :param dir: The directory in which the temporary file is created. + :param errors: The error handling scheme used for encoding/decoding errors. + """ + + _rolled: bool = False + + @overload + def __init__( + self: SpooledTemporaryFile[bytes], + max_size: int = ..., + mode: OpenBinaryMode = ..., + buffering: int = ..., + encoding: str | None = ..., + newline: str | None = ..., + suffix: str | None = ..., + prefix: str | None = ..., + dir: str | None = ..., + *, + errors: str | None = ..., + ): ... + @overload + def __init__( + self: SpooledTemporaryFile[str], + max_size: int = ..., + mode: OpenTextMode = ..., + buffering: int = ..., + encoding: str | None = ..., + newline: str | None = ..., + suffix: str | None = ..., + prefix: str | None = ..., + dir: str | None = ..., + *, + errors: str | None = ..., + ): ... + + def __init__( + self, + max_size: int = 0, + mode: OpenBinaryMode | OpenTextMode = "w+b", + buffering: int = -1, + encoding: str | None = None, + newline: str | None = None, + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, + *, + errors: str | None = None, + ) -> None: + self._tempfile_params: dict[str, Any] = { + "mode": mode, + "buffering": buffering, + "encoding": encoding, + "newline": newline, + "suffix": suffix, + "prefix": prefix, + "dir": dir, + "errors": errors, + } + self._max_size = max_size + if "b" in mode: + super().__init__(BytesIO()) # type: ignore[arg-type] + else: + super().__init__( + TextIOWrapper( # type: ignore[arg-type] + BytesIO(), + encoding=encoding, + errors=errors, + newline=newline, + write_through=True, + ) + ) + + async def aclose(self) -> None: + if not self._rolled: + self._fp.close() + return + + await super().aclose() + + async def _check(self) -> None: + if self._rolled or self._fp.tell() <= self._max_size: + return + + await self.rollover() + + async def rollover(self) -> None: + if self._rolled: + return + + self._rolled = True + buffer = self._fp + buffer.seek(0) + self._fp = await to_thread.run_sync( + lambda: tempfile.TemporaryFile(**self._tempfile_params) + ) + await self.write(buffer.read()) + buffer.close() + + @property + def closed(self) -> bool: + return self._fp.closed + + async def read(self, size: int = -1) -> AnyStr: + if not self._rolled: + await checkpoint_if_cancelled() + return self._fp.read(size) + + return await super().read(size) # type: ignore[return-value] + + async def read1(self: SpooledTemporaryFile[bytes], size: int = -1) -> bytes: + if not self._rolled: + await checkpoint_if_cancelled() + return self._fp.read1(size) + + return await super().read1(size) + + async def readline(self) -> AnyStr: + if not self._rolled: + await checkpoint_if_cancelled() + return self._fp.readline() + + return await super().readline() # type: ignore[return-value] + + async def readlines(self) -> list[AnyStr]: + if not self._rolled: + await checkpoint_if_cancelled() + return self._fp.readlines() + + return await super().readlines() # type: ignore[return-value] + + async def readinto(self: SpooledTemporaryFile[bytes], b: WriteableBuffer) -> int: + if not self._rolled: + await checkpoint_if_cancelled() + self._fp.readinto(b) + + return await super().readinto(b) + + async def readinto1(self: SpooledTemporaryFile[bytes], b: WriteableBuffer) -> int: + if not self._rolled: + await checkpoint_if_cancelled() + self._fp.readinto(b) + + return await super().readinto1(b) + + async def seek(self, offset: int, whence: int | None = os.SEEK_SET) -> int: + if not self._rolled: + await checkpoint_if_cancelled() + return self._fp.seek(offset, whence) + + return await super().seek(offset, whence) + + async def tell(self) -> int: + if not self._rolled: + await checkpoint_if_cancelled() + return self._fp.tell() + + return await super().tell() + + async def truncate(self, size: int | None = None) -> int: + if not self._rolled: + await checkpoint_if_cancelled() + return self._fp.truncate(size) + + return await super().truncate(size) + + @overload + async def write(self: SpooledTemporaryFile[bytes], b: ReadableBuffer) -> int: ... + @overload + async def write(self: SpooledTemporaryFile[str], b: str) -> int: ... + + async def write(self, b: ReadableBuffer | str) -> int: + """ + Asynchronously write data to the spooled temporary file. + + If the file has not yet been rolled over, the data is written synchronously, + and a rollover is triggered if the size exceeds the maximum size. + + :param s: The data to write. + :return: The number of bytes written. + :raises RuntimeError: If the underlying file is not initialized. + + """ + if not self._rolled: + await checkpoint_if_cancelled() + result = self._fp.write(b) + await self._check() + return result + + return await super().write(b) # type: ignore[misc] + + @overload + async def writelines( + self: SpooledTemporaryFile[bytes], lines: Iterable[ReadableBuffer] + ) -> None: ... + @overload + async def writelines( + self: SpooledTemporaryFile[str], lines: Iterable[str] + ) -> None: ... + + async def writelines(self, lines: Iterable[str] | Iterable[ReadableBuffer]) -> None: + """ + Asynchronously write a list of lines to the spooled temporary file. + + If the file has not yet been rolled over, the lines are written synchronously, + and a rollover is triggered if the size exceeds the maximum size. + + :param lines: An iterable of lines to write. + :raises RuntimeError: If the underlying file is not initialized. + + """ + if not self._rolled: + await checkpoint_if_cancelled() + result = self._fp.writelines(lines) + await self._check() + return result + + return await super().writelines(lines) # type: ignore[misc] + + +class TemporaryDirectory(Generic[AnyStr]): + """ + An asynchronous temporary directory that is created and cleaned up automatically. + + This class provides an asynchronous context manager for creating a temporary + directory. It wraps Python's standard :class:`~tempfile.TemporaryDirectory` to + perform directory creation and cleanup operations in a background thread. + + :param suffix: Suffix to be added to the temporary directory name. + :param prefix: Prefix to be added to the temporary directory name. + :param dir: The parent directory where the temporary directory is created. + :param ignore_cleanup_errors: Whether to ignore errors during cleanup + :param delete: Whether to delete the directory upon closing (Python 3.12+). + """ + + def __init__( + self, + suffix: AnyStr | None = None, + prefix: AnyStr | None = None, + dir: AnyStr | None = None, + *, + ignore_cleanup_errors: bool = False, + delete: bool = True, + ) -> None: + self.suffix: AnyStr | None = suffix + self.prefix: AnyStr | None = prefix + self.dir: AnyStr | None = dir + self.ignore_cleanup_errors = ignore_cleanup_errors + self.delete = delete + + self._tempdir: tempfile.TemporaryDirectory | None = None + + async def __aenter__(self) -> str: + params: dict[str, Any] = { + "suffix": self.suffix, + "prefix": self.prefix, + "dir": self.dir, + "ignore_cleanup_errors": self.ignore_cleanup_errors, + } + if sys.version_info >= (3, 12): + params["delete"] = self.delete + + self._tempdir = await to_thread.run_sync( + lambda: tempfile.TemporaryDirectory(**params) + ) + return await to_thread.run_sync(self._tempdir.__enter__) + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if self._tempdir is not None: + await to_thread.run_sync( + self._tempdir.__exit__, exc_type, exc_value, traceback + ) + + async def cleanup(self) -> None: + if self._tempdir is not None: + await to_thread.run_sync(self._tempdir.cleanup) + + +@overload +async def mkstemp( + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, + text: bool = False, +) -> tuple[int, str]: ... + + +@overload +async def mkstemp( + suffix: bytes | None = None, + prefix: bytes | None = None, + dir: bytes | None = None, + text: bool = False, +) -> tuple[int, bytes]: ... + + +async def mkstemp( + suffix: AnyStr | None = None, + prefix: AnyStr | None = None, + dir: AnyStr | None = None, + text: bool = False, +) -> tuple[int, str | bytes]: + """ + Asynchronously create a temporary file and return an OS-level handle and the file + name. + + This function wraps `tempfile.mkstemp` and executes it in a background thread. + + :param suffix: Suffix to be added to the file name. + :param prefix: Prefix to be added to the file name. + :param dir: Directory in which the temporary file is created. + :param text: Whether the file is opened in text mode. + :return: A tuple containing the file descriptor and the file name. + + """ + return await to_thread.run_sync(tempfile.mkstemp, suffix, prefix, dir, text) + + +@overload +async def mkdtemp( + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, +) -> str: ... + + +@overload +async def mkdtemp( + suffix: bytes | None = None, + prefix: bytes | None = None, + dir: bytes | None = None, +) -> bytes: ... + + +async def mkdtemp( + suffix: AnyStr | None = None, + prefix: AnyStr | None = None, + dir: AnyStr | None = None, +) -> str | bytes: + """ + Asynchronously create a temporary directory and return its path. + + This function wraps `tempfile.mkdtemp` and executes it in a background thread. + + :param suffix: Suffix to be added to the directory name. + :param prefix: Prefix to be added to the directory name. + :param dir: Parent directory where the temporary directory is created. + :return: The path of the created temporary directory. + + """ + return await to_thread.run_sync(tempfile.mkdtemp, suffix, prefix, dir) + + +async def gettempdir() -> str: + """ + Asynchronously return the name of the directory used for temporary files. + + This function wraps `tempfile.gettempdir` and executes it in a background thread. + + :return: The path of the temporary directory as a string. + + """ + return await to_thread.run_sync(tempfile.gettempdir) + + +async def gettempdirb() -> bytes: + """ + Asynchronously return the name of the directory used for temporary files in bytes. + + This function wraps `tempfile.gettempdirb` and executes it in a background thread. + + :return: The path of the temporary directory as bytes. + + """ + return await to_thread.run_sync(tempfile.gettempdirb) diff --git a/server/venv/Lib/site-packages/anyio/_core/_testing.py b/server/venv/Lib/site-packages/anyio/_core/_testing.py new file mode 100644 index 0000000..369e65c --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_testing.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Generator +from typing import Any, cast + +from ._eventloop import get_async_backend + + +class TaskInfo: + """ + Represents an asynchronous task. + + :ivar int id: the unique identifier of the task + :ivar parent_id: the identifier of the parent task, if any + :vartype parent_id: Optional[int] + :ivar str name: the description of the task (if any) + :ivar ~collections.abc.Coroutine coro: the coroutine object of the task + """ + + __slots__ = "_name", "id", "parent_id", "name", "coro" + + def __init__( + self, + id: int, + parent_id: int | None, + name: str | None, + coro: Generator[Any, Any, Any] | Awaitable[Any], + ): + func = get_current_task + self._name = f"{func.__module__}.{func.__qualname__}" + self.id: int = id + self.parent_id: int | None = parent_id + self.name: str | None = name + self.coro: Generator[Any, Any, Any] | Awaitable[Any] = coro + + def __eq__(self, other: object) -> bool: + if isinstance(other, TaskInfo): + return self.id == other.id + + return NotImplemented + + def __hash__(self) -> int: + return hash(self.id) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(id={self.id!r}, name={self.name!r})" + + def has_pending_cancellation(self) -> bool: + """ + Return ``True`` if the task has a cancellation pending, ``False`` otherwise. + + """ + return False + + +def get_current_task() -> TaskInfo: + """ + Return the current task. + + :return: a representation of the current task + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().get_current_task() + + +def get_running_tasks() -> list[TaskInfo]: + """ + Return a list of running tasks in the current event loop. + + :return: a list of task info objects + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return cast("list[TaskInfo]", get_async_backend().get_running_tasks()) + + +async def wait_all_tasks_blocked() -> None: + """Wait until all other tasks are waiting for something.""" + await get_async_backend().wait_all_tasks_blocked() diff --git a/server/venv/Lib/site-packages/anyio/_core/_typedattr.py b/server/venv/Lib/site-packages/anyio/_core/_typedattr.py new file mode 100644 index 0000000..f358a44 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/_core/_typedattr.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import Any, TypeVar, final, overload + +from ._exceptions import TypedAttributeLookupError + +T_Attr = TypeVar("T_Attr") +T_Default = TypeVar("T_Default") +undefined = object() + + +def typed_attribute() -> Any: + """Return a unique object, used to mark typed attributes.""" + return object() + + +class TypedAttributeSet: + """ + Superclass for typed attribute collections. + + Checks that every public attribute of every subclass has a type annotation. + """ + + def __init_subclass__(cls) -> None: + annotations: dict[str, Any] = getattr(cls, "__annotations__", {}) + for attrname in dir(cls): + if not attrname.startswith("_") and attrname not in annotations: + raise TypeError( + f"Attribute {attrname!r} is missing its type annotation" + ) + + super().__init_subclass__() + + +class TypedAttributeProvider: + """Base class for classes that wish to provide typed extra attributes.""" + + @property + def extra_attributes(self) -> Mapping[T_Attr, Callable[[], T_Attr]]: + """ + A mapping of the extra attributes to callables that return the corresponding + values. + + If the provider wraps another provider, the attributes from that wrapper should + also be included in the returned mapping (but the wrapper may override the + callables from the wrapped instance). + + """ + return {} + + @overload + def extra(self, attribute: T_Attr) -> T_Attr: ... + + @overload + def extra(self, attribute: T_Attr, default: T_Default) -> T_Attr | T_Default: ... + + @final + def extra(self, attribute: Any, default: object = undefined) -> object: + """ + extra(attribute, default=undefined) + + Return the value of the given typed extra attribute. + + :param attribute: the attribute (member of a :class:`~TypedAttributeSet`) to + look for + :param default: the value that should be returned if no value is found for the + attribute + :raises ~anyio.TypedAttributeLookupError: if the search failed and no default + value was given + + """ + try: + getter = self.extra_attributes[attribute] + except KeyError: + if default is undefined: + raise TypedAttributeLookupError("Attribute not found") from None + else: + return default + + return getter() diff --git a/server/venv/Lib/site-packages/anyio/abc/__init__.py b/server/venv/Lib/site-packages/anyio/abc/__init__.py new file mode 100644 index 0000000..d560ce3 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/__init__.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from ._eventloop import AsyncBackend as AsyncBackend +from ._resources import AsyncResource as AsyncResource +from ._sockets import ConnectedUDPSocket as ConnectedUDPSocket +from ._sockets import ConnectedUNIXDatagramSocket as ConnectedUNIXDatagramSocket +from ._sockets import IPAddressType as IPAddressType +from ._sockets import IPSockAddrType as IPSockAddrType +from ._sockets import SocketAttribute as SocketAttribute +from ._sockets import SocketListener as SocketListener +from ._sockets import SocketStream as SocketStream +from ._sockets import UDPPacketType as UDPPacketType +from ._sockets import UDPSocket as UDPSocket +from ._sockets import UNIXDatagramPacketType as UNIXDatagramPacketType +from ._sockets import UNIXDatagramSocket as UNIXDatagramSocket +from ._sockets import UNIXSocketStream as UNIXSocketStream +from ._streams import AnyByteReceiveStream as AnyByteReceiveStream +from ._streams import AnyByteSendStream as AnyByteSendStream +from ._streams import AnyByteStream as AnyByteStream +from ._streams import AnyByteStreamConnectable as AnyByteStreamConnectable +from ._streams import AnyUnreliableByteReceiveStream as AnyUnreliableByteReceiveStream +from ._streams import AnyUnreliableByteSendStream as AnyUnreliableByteSendStream +from ._streams import AnyUnreliableByteStream as AnyUnreliableByteStream +from ._streams import ByteReceiveStream as ByteReceiveStream +from ._streams import ByteSendStream as ByteSendStream +from ._streams import ByteStream as ByteStream +from ._streams import ByteStreamConnectable as ByteStreamConnectable +from ._streams import Listener as Listener +from ._streams import ObjectReceiveStream as ObjectReceiveStream +from ._streams import ObjectSendStream as ObjectSendStream +from ._streams import ObjectStream as ObjectStream +from ._streams import ObjectStreamConnectable as ObjectStreamConnectable +from ._streams import UnreliableObjectReceiveStream as UnreliableObjectReceiveStream +from ._streams import UnreliableObjectSendStream as UnreliableObjectSendStream +from ._streams import UnreliableObjectStream as UnreliableObjectStream +from ._subprocesses import Process as Process +from ._tasks import TaskGroup as TaskGroup +from ._tasks import TaskStatus as TaskStatus +from ._testing import TestRunner as TestRunner + +# Re-exported here, for backwards compatibility +# isort: off +from .._core._synchronization import ( + CapacityLimiter as CapacityLimiter, + Condition as Condition, + Event as Event, + Lock as Lock, + Semaphore as Semaphore, +) +from .._core._tasks import CancelScope as CancelScope +from ..from_thread import BlockingPortal as BlockingPortal + +# 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.abc."): + __value.__module__ = __name__ + +del __value diff --git a/server/venv/Lib/site-packages/anyio/abc/_eventloop.py b/server/venv/Lib/site-packages/anyio/abc/_eventloop.py new file mode 100644 index 0000000..cad3fa7 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/_eventloop.py @@ -0,0 +1,410 @@ +from __future__ import annotations + +import math +import sys +from abc import ABCMeta, abstractmethod +from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Sequence +from contextlib import AbstractContextManager +from os import PathLike +from signal import Signals +from socket import AddressFamily, SocketKind, socket +from typing import ( + IO, + TYPE_CHECKING, + Any, + TypeAlias, + TypeVar, + overload, +) + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +if TYPE_CHECKING: + from _typeshed import FileDescriptorLike + + from .._core._synchronization import CapacityLimiter, Event, Lock, Semaphore + from .._core._tasks import CancelScope + from .._core._testing import TaskInfo + from ._sockets import ( + ConnectedUDPSocket, + ConnectedUNIXDatagramSocket, + IPSockAddrType, + SocketListener, + SocketStream, + UDPSocket, + UNIXDatagramSocket, + UNIXSocketStream, + ) + from ._subprocesses import Process + from ._tasks import TaskGroup + from ._testing import TestRunner + +T_Retval = TypeVar("T_Retval") +T_co = TypeVar("T_co", covariant=True) +PosArgsT = TypeVarTuple("PosArgsT") +StrOrBytesPath: TypeAlias = str | bytes | PathLike[str] | PathLike[bytes] + + +class AsyncBackend(metaclass=ABCMeta): + @classmethod + @abstractmethod + def run( + cls, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], + args: tuple[Unpack[PosArgsT]], + kwargs: dict[str, Any], + options: dict[str, Any], + ) -> T_Retval: + """ + Run the given coroutine function in an asynchronous event loop. + + The current thread must not be already running an event loop. + + :param func: a coroutine function + :param args: positional arguments to ``func`` + :param kwargs: positional arguments to ``func`` + :param options: keyword arguments to call the backend ``run()`` implementation + with + :return: the return value of the coroutine function + """ + + @classmethod + @abstractmethod + def current_token(cls) -> object: + """ + Return an object that allows other threads to run code inside the event loop. + + :return: a token object, specific to the event loop running in the current + thread + """ + + @classmethod + @abstractmethod + def current_time(cls) -> float: + """ + Return the current value of the event loop's internal clock. + + :return: the clock value (seconds) + """ + + @classmethod + @abstractmethod + def cancelled_exception_class(cls) -> type[BaseException]: + """Return the exception class that is raised in a task if it's cancelled.""" + + @classmethod + @abstractmethod + async def checkpoint(cls) -> None: + """ + Check if the task has been cancelled, and allow rescheduling of other tasks. + + This is effectively the same as running :meth:`checkpoint_if_cancelled` and then + :meth:`cancel_shielded_checkpoint`. + """ + + @classmethod + async def checkpoint_if_cancelled(cls) -> None: + """ + Check if the current task group has been cancelled. + + This will check if the task has been cancelled, but will not allow other tasks + to be scheduled if not. + + """ + if cls.current_effective_deadline() == -math.inf: + await cls.checkpoint() + + @classmethod + async def cancel_shielded_checkpoint(cls) -> None: + """ + Allow the rescheduling of other tasks. + + This will give other tasks the opportunity to run, but without checking if the + current task group has been cancelled, unlike with :meth:`checkpoint`. + + """ + with cls.create_cancel_scope(shield=True): + await cls.sleep(0) + + @classmethod + @abstractmethod + async def sleep(cls, delay: float) -> None: + """ + Pause the current task for the specified duration. + + :param delay: the duration, in seconds + """ + + @classmethod + @abstractmethod + def create_cancel_scope( + cls, *, deadline: float = math.inf, shield: bool = False + ) -> CancelScope: + pass + + @classmethod + @abstractmethod + def current_effective_deadline(cls) -> float: + """ + Return the nearest deadline among all the cancel scopes effective for the + current task. + + :return: + - a clock value from the event loop's internal clock + - ``inf`` if there is no deadline in effect + - ``-inf`` if the current scope has been cancelled + :rtype: float + """ + + @classmethod + @abstractmethod + def create_task_group(cls) -> TaskGroup: + pass + + @classmethod + @abstractmethod + def create_event(cls) -> Event: + pass + + @classmethod + @abstractmethod + def create_lock(cls, *, fast_acquire: bool) -> Lock: + pass + + @classmethod + @abstractmethod + def create_semaphore( + cls, + initial_value: int, + *, + max_value: int | None = None, + fast_acquire: bool = False, + ) -> Semaphore: + pass + + @classmethod + @abstractmethod + def create_capacity_limiter(cls, total_tokens: float) -> CapacityLimiter: + pass + + @classmethod + @abstractmethod + async def run_sync_in_worker_thread( + cls, + func: Callable[[Unpack[PosArgsT]], T_Retval], + args: tuple[Unpack[PosArgsT]], + abandon_on_cancel: bool = False, + limiter: CapacityLimiter | None = None, + ) -> T_Retval: + pass + + @classmethod + @abstractmethod + def check_cancelled(cls) -> None: + pass + + @classmethod + @abstractmethod + def run_async_from_thread( + cls, + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + args: tuple[Unpack[PosArgsT]], + token: object, + ) -> T_co: + pass + + @classmethod + @abstractmethod + def run_sync_from_thread( + cls, + func: Callable[[Unpack[PosArgsT]], T_Retval], + args: tuple[Unpack[PosArgsT]], + token: object, + ) -> T_Retval: + pass + + @classmethod + @abstractmethod + async def open_process( + cls, + command: StrOrBytesPath | Sequence[StrOrBytesPath], + *, + stdin: int | IO[Any] | None, + stdout: int | IO[Any] | None, + stderr: int | IO[Any] | None, + **kwargs: Any, + ) -> Process: + pass + + @classmethod + @abstractmethod + def setup_process_pool_exit_at_shutdown(cls, workers: set[Process]) -> None: + pass + + @classmethod + @abstractmethod + async def connect_tcp( + cls, host: str, port: int, local_address: IPSockAddrType | None = None + ) -> SocketStream: + pass + + @classmethod + @abstractmethod + async def connect_unix(cls, path: str | bytes) -> UNIXSocketStream: + pass + + @classmethod + @abstractmethod + def create_tcp_listener(cls, sock: socket) -> SocketListener: + pass + + @classmethod + @abstractmethod + def create_unix_listener(cls, sock: socket) -> SocketListener: + pass + + @classmethod + @abstractmethod + async def create_udp_socket( + cls, + family: AddressFamily, + local_address: IPSockAddrType | None, + remote_address: IPSockAddrType | None, + reuse_port: bool, + ) -> UDPSocket | ConnectedUDPSocket: + pass + + @classmethod + @overload + async def create_unix_datagram_socket( + cls, raw_socket: socket, remote_path: None + ) -> UNIXDatagramSocket: ... + + @classmethod + @overload + async def create_unix_datagram_socket( + cls, raw_socket: socket, remote_path: str | bytes + ) -> ConnectedUNIXDatagramSocket: ... + + @classmethod + @abstractmethod + async def create_unix_datagram_socket( + cls, raw_socket: socket, remote_path: str | bytes | None + ) -> UNIXDatagramSocket | ConnectedUNIXDatagramSocket: + pass + + @classmethod + @abstractmethod + async def getaddrinfo( + cls, + host: bytes | str | None, + port: str | int | None, + *, + family: int | AddressFamily = 0, + type: int | SocketKind = 0, + proto: int = 0, + flags: int = 0, + ) -> Sequence[ + tuple[ + AddressFamily, + SocketKind, + int, + str, + tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes], + ] + ]: + pass + + @classmethod + @abstractmethod + async def getnameinfo( + cls, sockaddr: IPSockAddrType, flags: int = 0 + ) -> tuple[str, str]: + pass + + @classmethod + @abstractmethod + async def wait_readable(cls, obj: FileDescriptorLike) -> None: + pass + + @classmethod + @abstractmethod + async def wait_writable(cls, obj: FileDescriptorLike) -> None: + pass + + @classmethod + @abstractmethod + def notify_closing(cls, obj: FileDescriptorLike) -> None: + pass + + @classmethod + @abstractmethod + async def wrap_listener_socket(cls, sock: socket) -> SocketListener: + pass + + @classmethod + @abstractmethod + async def wrap_stream_socket(cls, sock: socket) -> SocketStream: + pass + + @classmethod + @abstractmethod + async def wrap_unix_stream_socket(cls, sock: socket) -> UNIXSocketStream: + pass + + @classmethod + @abstractmethod + async def wrap_udp_socket(cls, sock: socket) -> UDPSocket: + pass + + @classmethod + @abstractmethod + async def wrap_connected_udp_socket(cls, sock: socket) -> ConnectedUDPSocket: + pass + + @classmethod + @abstractmethod + async def wrap_unix_datagram_socket(cls, sock: socket) -> UNIXDatagramSocket: + pass + + @classmethod + @abstractmethod + async def wrap_connected_unix_datagram_socket( + cls, sock: socket + ) -> ConnectedUNIXDatagramSocket: + pass + + @classmethod + @abstractmethod + def current_default_thread_limiter(cls) -> CapacityLimiter: + pass + + @classmethod + @abstractmethod + def open_signal_receiver( + cls, *signals: Signals + ) -> AbstractContextManager[AsyncIterator[Signals]]: + pass + + @classmethod + @abstractmethod + def get_current_task(cls) -> TaskInfo: + pass + + @classmethod + @abstractmethod + def get_running_tasks(cls) -> Sequence[TaskInfo]: + pass + + @classmethod + @abstractmethod + async def wait_all_tasks_blocked(cls) -> None: + pass + + @classmethod + @abstractmethod + def create_test_runner(cls, options: dict[str, Any]) -> TestRunner: + pass diff --git a/server/venv/Lib/site-packages/anyio/abc/_resources.py b/server/venv/Lib/site-packages/anyio/abc/_resources.py new file mode 100644 index 0000000..10df115 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/_resources.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from types import TracebackType +from typing import TypeVar + +T = TypeVar("T") + + +class AsyncResource(metaclass=ABCMeta): + """ + Abstract base class for all closeable asynchronous resources. + + Works as an asynchronous context manager which returns the instance itself on enter, + and calls :meth:`aclose` on exit. + """ + + __slots__ = () + + async def __aenter__(self: T) -> T: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.aclose() + + @abstractmethod + async def aclose(self) -> None: + """Close the resource.""" diff --git a/server/venv/Lib/site-packages/anyio/abc/_sockets.py b/server/venv/Lib/site-packages/anyio/abc/_sockets.py new file mode 100644 index 0000000..feb26bd --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/_sockets.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import errno +import socket +from abc import abstractmethod +from collections.abc import Callable, Collection, Mapping +from contextlib import AsyncExitStack +from io import IOBase +from ipaddress import IPv4Address, IPv6Address +from socket import AddressFamily +from typing import Any, TypeAlias, TypeVar + +from .._core._eventloop import get_async_backend +from .._core._typedattr import ( + TypedAttributeProvider, + TypedAttributeSet, + typed_attribute, +) +from ._streams import ByteStream, Listener, UnreliableObjectStream +from ._tasks import TaskGroup + +IPAddressType: TypeAlias = str | IPv4Address | IPv6Address +IPSockAddrType: TypeAlias = tuple[str, int] +SockAddrType: TypeAlias = IPSockAddrType | str +UDPPacketType: TypeAlias = tuple[bytes, IPSockAddrType] +UNIXDatagramPacketType: TypeAlias = tuple[bytes, str] +T_Retval = TypeVar("T_Retval") + + +def _validate_socket( + sock_or_fd: socket.socket | int, + sock_type: socket.SocketKind, + addr_family: socket.AddressFamily = socket.AF_UNSPEC, + *, + require_connected: bool = False, + require_bound: bool = False, +) -> socket.socket: + if isinstance(sock_or_fd, int): + try: + sock = socket.socket(fileno=sock_or_fd) + except OSError as exc: + if exc.errno == errno.ENOTSOCK: + raise ValueError( + "the file descriptor does not refer to a socket" + ) from exc + elif require_connected: + raise ValueError("the socket must be connected") from exc + elif require_bound: + raise ValueError("the socket must be bound to a local address") from exc + else: + raise + elif isinstance(sock_or_fd, socket.socket): + sock = sock_or_fd + else: + raise TypeError( + f"expected an int or socket, got {type(sock_or_fd).__qualname__} instead" + ) + + try: + if require_connected: + try: + sock.getpeername() + except OSError as exc: + raise ValueError("the socket must be connected") from exc + + if require_bound: + try: + if sock.family in (socket.AF_INET, socket.AF_INET6): + bound_addr = sock.getsockname()[1] + else: + bound_addr = sock.getsockname() + except OSError: + bound_addr = None + + if not bound_addr: + raise ValueError("the socket must be bound to a local address") + + if addr_family != socket.AF_UNSPEC and sock.family != addr_family: + raise ValueError( + f"address family mismatch: expected {addr_family.name}, got " + f"{sock.family.name}" + ) + + if sock.type != sock_type: + raise ValueError( + f"socket type mismatch: expected {sock_type.name}, got {sock.type.name}" + ) + except BaseException: + # Avoid ResourceWarning from the locally constructed socket object + if isinstance(sock_or_fd, int): + sock.detach() + + raise + + sock.setblocking(False) + return sock + + +class SocketAttribute(TypedAttributeSet): + """ + .. attribute:: family + :type: socket.AddressFamily + + the address family of the underlying socket + + .. attribute:: local_address + :type: tuple[str, int] | str + + the local address the underlying socket is connected to + + .. attribute:: local_port + :type: int + + for IP based sockets, the local port the underlying socket is bound to + + .. attribute:: raw_socket + :type: socket.socket + + the underlying stdlib socket object + + .. attribute:: remote_address + :type: tuple[str, int] | str + + the remote address the underlying socket is connected to + + .. attribute:: remote_port + :type: int + + for IP based sockets, the remote port the underlying socket is connected to + """ + + family: AddressFamily = typed_attribute() + local_address: SockAddrType = typed_attribute() + local_port: int = typed_attribute() + raw_socket: socket.socket = typed_attribute() + remote_address: SockAddrType = typed_attribute() + remote_port: int = typed_attribute() + + +class _SocketProvider(TypedAttributeProvider): + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + from .._core._sockets import convert_ipv6_sockaddr as convert + + attributes: dict[Any, Callable[[], Any]] = { + SocketAttribute.family: lambda: self._raw_socket.family, + SocketAttribute.local_address: lambda: convert( + self._raw_socket.getsockname() + ), + SocketAttribute.raw_socket: lambda: self._raw_socket, + } + try: + peername: tuple[str, int] | None = convert(self._raw_socket.getpeername()) + except OSError: + peername = None + + # Provide the remote address for connected sockets + if peername is not None: + attributes[SocketAttribute.remote_address] = lambda: peername + + # Provide local and remote ports for IP based sockets + if self._raw_socket.family in (AddressFamily.AF_INET, AddressFamily.AF_INET6): + attributes[SocketAttribute.local_port] = lambda: ( + self._raw_socket.getsockname()[1] + ) + if peername is not None: + remote_port = peername[1] + attributes[SocketAttribute.remote_port] = lambda: remote_port + + return attributes + + @property + @abstractmethod + def _raw_socket(self) -> socket.socket: + pass + + +class SocketStream(ByteStream, _SocketProvider): + """ + Transports bytes over a socket. + + Supports all relevant extra attributes from :class:`~SocketAttribute`. + """ + + @classmethod + async def from_socket(cls, sock_or_fd: socket.socket | int) -> SocketStream: + """ + Wrap an existing socket object or file descriptor as a socket stream. + + The newly created socket wrapper takes ownership of the socket being passed in. + The existing socket must already be connected. + + :param sock_or_fd: a socket object or file descriptor + :return: a socket stream + + """ + sock = _validate_socket(sock_or_fd, socket.SOCK_STREAM, require_connected=True) + return await get_async_backend().wrap_stream_socket(sock) + + +class UNIXSocketStream(SocketStream): + @classmethod + async def from_socket(cls, sock_or_fd: socket.socket | int) -> UNIXSocketStream: + """ + Wrap an existing socket object or file descriptor as a UNIX socket stream. + + The newly created socket wrapper takes ownership of the socket being passed in. + The existing socket must already be connected. + + :param sock_or_fd: a socket object or file descriptor + :return: a UNIX socket stream + + """ + sock = _validate_socket( + sock_or_fd, socket.SOCK_STREAM, socket.AF_UNIX, require_connected=True + ) + return await get_async_backend().wrap_unix_stream_socket(sock) + + @abstractmethod + async def send_fds(self, message: bytes, fds: Collection[int | IOBase]) -> None: + """ + Send file descriptors along with a message to the peer. + + :param message: a non-empty bytestring + :param fds: a collection of files (either numeric file descriptors or open file + or socket objects) + """ + + @abstractmethod + async def receive_fds(self, msglen: int, maxfds: int) -> tuple[bytes, list[int]]: + """ + Receive file descriptors along with a message from the peer. + + :param msglen: length of the message to expect from the peer + :param maxfds: maximum number of file descriptors to expect from the peer + :return: a tuple of (message, file descriptors) + """ + + +class SocketListener(Listener[SocketStream], _SocketProvider): + """ + Listens to incoming socket connections. + + Supports all relevant extra attributes from :class:`~SocketAttribute`. + """ + + @classmethod + async def from_socket( + cls, + sock_or_fd: socket.socket | int, + ) -> SocketListener: + """ + Wrap an existing socket object or file descriptor as a socket listener. + + The newly created listener takes ownership of the socket being passed in. + + :param sock_or_fd: a socket object or file descriptor + :return: a socket listener + + """ + sock = _validate_socket(sock_or_fd, socket.SOCK_STREAM, require_bound=True) + return await get_async_backend().wrap_listener_socket(sock) + + @abstractmethod + async def accept(self) -> SocketStream: + """Accept an incoming connection.""" + + async def serve( + self, + handler: Callable[[SocketStream], Any], + task_group: TaskGroup | None = None, + ) -> None: + from .. import create_task_group + + async with AsyncExitStack() as stack: + if task_group is None: + task_group = await stack.enter_async_context(create_task_group()) + + while True: + stream = await self.accept() + task_group.start_soon(handler, stream) + + +class UDPSocket(UnreliableObjectStream[UDPPacketType], _SocketProvider): + """ + Represents an unconnected UDP socket. + + Supports all relevant extra attributes from :class:`~SocketAttribute`. + """ + + @classmethod + async def from_socket(cls, sock_or_fd: socket.socket | int) -> UDPSocket: + """ + Wrap an existing socket object or file descriptor as a UDP socket. + + The newly created socket wrapper takes ownership of the socket being passed in. + The existing socket must be bound to a local address. + + :param sock_or_fd: a socket object or file descriptor + :return: a UDP socket + + """ + sock = _validate_socket(sock_or_fd, socket.SOCK_DGRAM, require_bound=True) + return await get_async_backend().wrap_udp_socket(sock) + + async def sendto(self, data: bytes, host: str, port: int) -> None: + """ + Alias for :meth:`~.UnreliableObjectSendStream.send` ((data, (host, port))). + + """ + return await self.send((data, (host, port))) + + +class ConnectedUDPSocket(UnreliableObjectStream[bytes], _SocketProvider): + """ + Represents an connected UDP socket. + + Supports all relevant extra attributes from :class:`~SocketAttribute`. + """ + + @classmethod + async def from_socket(cls, sock_or_fd: socket.socket | int) -> ConnectedUDPSocket: + """ + Wrap an existing socket object or file descriptor as a connected UDP socket. + + The newly created socket wrapper takes ownership of the socket being passed in. + The existing socket must already be connected. + + :param sock_or_fd: a socket object or file descriptor + :return: a connected UDP socket + + """ + sock = _validate_socket( + sock_or_fd, + socket.SOCK_DGRAM, + require_connected=True, + ) + return await get_async_backend().wrap_connected_udp_socket(sock) + + +class UNIXDatagramSocket( + UnreliableObjectStream[UNIXDatagramPacketType], _SocketProvider +): + """ + Represents an unconnected Unix datagram socket. + + Supports all relevant extra attributes from :class:`~SocketAttribute`. + """ + + @classmethod + async def from_socket( + cls, + sock_or_fd: socket.socket | int, + ) -> UNIXDatagramSocket: + """ + Wrap an existing socket object or file descriptor as a UNIX datagram + socket. + + The newly created socket wrapper takes ownership of the socket being passed in. + + :param sock_or_fd: a socket object or file descriptor + :return: a UNIX datagram socket + + """ + sock = _validate_socket(sock_or_fd, socket.SOCK_DGRAM, socket.AF_UNIX) + return await get_async_backend().wrap_unix_datagram_socket(sock) + + async def sendto(self, data: bytes, path: str) -> None: + """Alias for :meth:`~.UnreliableObjectSendStream.send` ((data, path)).""" + return await self.send((data, path)) + + +class ConnectedUNIXDatagramSocket(UnreliableObjectStream[bytes], _SocketProvider): + """ + Represents a connected Unix datagram socket. + + Supports all relevant extra attributes from :class:`~SocketAttribute`. + """ + + @classmethod + async def from_socket( + cls, + sock_or_fd: socket.socket | int, + ) -> ConnectedUNIXDatagramSocket: + """ + Wrap an existing socket object or file descriptor as a connected UNIX datagram + socket. + + The newly created socket wrapper takes ownership of the socket being passed in. + The existing socket must already be connected. + + :param sock_or_fd: a socket object or file descriptor + :return: a connected UNIX datagram socket + + """ + sock = _validate_socket( + sock_or_fd, socket.SOCK_DGRAM, socket.AF_UNIX, require_connected=True + ) + return await get_async_backend().wrap_connected_unix_datagram_socket(sock) diff --git a/server/venv/Lib/site-packages/anyio/abc/_streams.py b/server/venv/Lib/site-packages/anyio/abc/_streams.py new file mode 100644 index 0000000..186e3f5 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/_streams.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from collections.abc import Callable +from typing import Any, Generic, TypeAlias, TypeVar + +from .._core._exceptions import EndOfStream +from .._core._typedattr import TypedAttributeProvider +from ._resources import AsyncResource +from ._tasks import TaskGroup + +T_Item = TypeVar("T_Item") +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + + +class UnreliableObjectReceiveStream( + Generic[T_co], AsyncResource, TypedAttributeProvider +): + """ + An interface for receiving objects. + + This interface makes no guarantees that the received messages arrive in the order in + which they were sent, or that no messages are missed. + + Asynchronously iterating over objects of this type will yield objects matching the + given type parameter. + """ + + def __aiter__(self) -> UnreliableObjectReceiveStream[T_co]: + return self + + async def __anext__(self) -> T_co: + try: + return await self.receive() + except EndOfStream: + raise StopAsyncIteration from None + + @abstractmethod + async def receive(self) -> T_co: + """ + Receive the next item. + + :raises ~anyio.ClosedResourceError: if the receive stream has been explicitly + closed + :raises ~anyio.EndOfStream: if this stream has been closed from the other end + :raises ~anyio.BrokenResourceError: if this stream has been rendered unusable + due to external causes + """ + + +class UnreliableObjectSendStream( + Generic[T_contra], AsyncResource, TypedAttributeProvider +): + """ + An interface for sending objects. + + This interface makes no guarantees that the messages sent will reach the + recipient(s) in the same order in which they were sent, or at all. + """ + + @abstractmethod + async def send(self, item: T_contra) -> None: + """ + Send an item to the peer(s). + + :param item: the item to send + :raises ~anyio.ClosedResourceError: if the send stream has been explicitly + closed + :raises ~anyio.BrokenResourceError: if this stream has been rendered unusable + due to external causes + """ + + +class UnreliableObjectStream( + UnreliableObjectReceiveStream[T_Item], UnreliableObjectSendStream[T_Item] +): + """ + A bidirectional message stream which does not guarantee the order or reliability of + message delivery. + """ + + +class ObjectReceiveStream(UnreliableObjectReceiveStream[T_co]): + """ + A receive message stream which guarantees that messages are received in the same + order in which they were sent, and that no messages are missed. + """ + + +class ObjectSendStream(UnreliableObjectSendStream[T_contra]): + """ + A send message stream which guarantees that messages are delivered in the same order + in which they were sent, without missing any messages in the middle. + """ + + +class ObjectStream( + ObjectReceiveStream[T_Item], + ObjectSendStream[T_Item], + UnreliableObjectStream[T_Item], +): + """ + A bidirectional message stream which guarantees the order and reliability of message + delivery. + """ + + @abstractmethod + async def send_eof(self) -> None: + """ + Send an end-of-file indication to the peer. + + You should not try to send any further data to this stream after calling this + method. This method is idempotent (does nothing on successive calls). + """ + + +class ByteReceiveStream(AsyncResource, TypedAttributeProvider): + """ + An interface for receiving bytes from a single peer. + + Iterating this byte stream will yield a byte string of arbitrary length, but no more + than 65536 bytes. + """ + + def __aiter__(self) -> ByteReceiveStream: + return self + + async def __anext__(self) -> bytes: + try: + return await self.receive() + except EndOfStream: + raise StopAsyncIteration from None + + @abstractmethod + async def receive(self, max_bytes: int = 65536) -> bytes: + """ + Receive at most ``max_bytes`` bytes from the peer. + + .. note:: Implementers of this interface should not return an empty + :class:`bytes` object, and users should ignore them. + + :param max_bytes: maximum number of bytes to receive + :return: the received bytes + :raises ~anyio.EndOfStream: if this stream has been closed from the other end + """ + + +class ByteSendStream(AsyncResource, TypedAttributeProvider): + """An interface for sending bytes to a single peer.""" + + @abstractmethod + async def send(self, item: bytes) -> None: + """ + Send the given bytes to the peer. + + :param item: the bytes to send + """ + + +class ByteStream(ByteReceiveStream, ByteSendStream): + """A bidirectional byte stream.""" + + @abstractmethod + async def send_eof(self) -> None: + """ + Send an end-of-file indication to the peer. + + You should not try to send any further data to this stream after calling this + method. This method is idempotent (does nothing on successive calls). + """ + + +#: Type alias for all unreliable bytes-oriented receive streams. +AnyUnreliableByteReceiveStream: TypeAlias = ( + UnreliableObjectReceiveStream[bytes] | ByteReceiveStream +) +#: Type alias for all unreliable bytes-oriented send streams. +AnyUnreliableByteSendStream: TypeAlias = ( + UnreliableObjectSendStream[bytes] | ByteSendStream +) +#: Type alias for all unreliable bytes-oriented streams. +AnyUnreliableByteStream: TypeAlias = UnreliableObjectStream[bytes] | ByteStream +#: Type alias for all bytes-oriented receive streams. +AnyByteReceiveStream: TypeAlias = ObjectReceiveStream[bytes] | ByteReceiveStream +#: Type alias for all bytes-oriented send streams. +AnyByteSendStream: TypeAlias = ObjectSendStream[bytes] | ByteSendStream +#: Type alias for all bytes-oriented streams. +AnyByteStream: TypeAlias = ObjectStream[bytes] | ByteStream + + +class Listener(Generic[T_co], AsyncResource, TypedAttributeProvider): + """An interface for objects that let you accept incoming connections.""" + + @abstractmethod + async def serve( + self, handler: Callable[[T_co], Any], task_group: TaskGroup | None = None + ) -> None: + """ + Accept incoming connections as they come in and start tasks to handle them. + + :param handler: a callable that will be used to handle each accepted connection + :param task_group: the task group that will be used to start tasks for handling + each accepted connection (if omitted, an ad-hoc task group will be created) + """ + + +class ObjectStreamConnectable(Generic[T_co], metaclass=ABCMeta): + @abstractmethod + async def connect(self) -> ObjectStream[T_co]: + """ + Connect to the remote endpoint. + + :return: an object stream connected to the remote end + :raises ConnectionFailed: if the connection fails + """ + + +class ByteStreamConnectable(metaclass=ABCMeta): + @abstractmethod + async def connect(self) -> ByteStream: + """ + Connect to the remote endpoint. + + :return: a bytestream connected to the remote end + :raises ConnectionFailed: if the connection fails + """ + + +#: Type alias for all connectables returning bytestreams or bytes-oriented object streams +AnyByteStreamConnectable: TypeAlias = ( + ObjectStreamConnectable[bytes] | ByteStreamConnectable +) diff --git a/server/venv/Lib/site-packages/anyio/abc/_subprocesses.py b/server/venv/Lib/site-packages/anyio/abc/_subprocesses.py new file mode 100644 index 0000000..ce0564c --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/_subprocesses.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from abc import abstractmethod +from signal import Signals + +from ._resources import AsyncResource +from ._streams import ByteReceiveStream, ByteSendStream + + +class Process(AsyncResource): + """An asynchronous version of :class:`subprocess.Popen`.""" + + @abstractmethod + async def wait(self) -> int: + """ + Wait until the process exits. + + :return: the exit code of the process + """ + + @abstractmethod + def terminate(self) -> None: + """ + Terminates the process, gracefully if possible. + + On Windows, this calls ``TerminateProcess()``. + On POSIX systems, this sends ``SIGTERM`` to the process. + + .. seealso:: :meth:`subprocess.Popen.terminate` + """ + + @abstractmethod + def kill(self) -> None: + """ + Kills the process. + + On Windows, this calls ``TerminateProcess()``. + On POSIX systems, this sends ``SIGKILL`` to the process. + + .. seealso:: :meth:`subprocess.Popen.kill` + """ + + @abstractmethod + def send_signal(self, signal: Signals) -> None: + """ + Send a signal to the subprocess. + + .. seealso:: :meth:`subprocess.Popen.send_signal` + + :param signal: the signal number (e.g. :data:`signal.SIGHUP`) + """ + + @property + @abstractmethod + def pid(self) -> int: + """The process ID of the process.""" + + @property + @abstractmethod + def returncode(self) -> int | None: + """ + The return code of the process. If the process has not yet terminated, this will + be ``None``. + """ + + @property + @abstractmethod + def stdin(self) -> ByteSendStream | None: + """The stream for the standard input of the process.""" + + @property + @abstractmethod + def stdout(self) -> ByteReceiveStream | None: + """The stream for the standard output of the process.""" + + @property + @abstractmethod + def stderr(self) -> ByteReceiveStream | None: + """The stream for the standard error output of the process.""" diff --git a/server/venv/Lib/site-packages/anyio/abc/_tasks.py b/server/venv/Lib/site-packages/anyio/abc/_tasks.py new file mode 100644 index 0000000..44ee3a7 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/_tasks.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import sys +from abc import ABCMeta, abstractmethod +from collections.abc import Callable, Coroutine +from contextvars import Context +from types import TracebackType +from typing import TYPE_CHECKING, Any, Literal, Protocol, final, overload + +if sys.version_info >= (3, 13): + from typing import TypeVar +else: + from typing_extensions import TypeVar + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +if TYPE_CHECKING: + from .._core._tasks import CancelScope, TaskHandle + +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True, default=None) +PosArgsT = TypeVarTuple("PosArgsT") + + +def get_callable_name(func: Callable, override: object = None) -> str: + if override is not None: + return str(override) + + module = getattr(func, "__module__", None) + qualname = getattr(func, "__qualname__", None) + return ".".join([x for x in (module, qualname) if x]) + + +def call_for_coroutine( + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + args: tuple[Unpack[PosArgsT]], + **kwargs: Any, +) -> Coroutine[Any, Any, T_co]: + """ + Call the given function with the given positional and keyword arguments. + + :return: the resulting coroutine + :raises TypeError: if the return value was not a coroutine object + + """ + coro = func(*args, **kwargs) + if not isinstance(coro, Coroutine): + prefix = f"{func.__module__}." if hasattr(func, "__module__") else "" + raise TypeError( + f"Expected {prefix}{func.__qualname__}() to return a coroutine, but " + f"the return value ({coro!r}) is not a coroutine object" + ) + + return coro + + +class TaskStatus(Protocol[T_contra]): + @overload + def started(self: TaskStatus[None]) -> None: ... + + @overload + def started(self, value: T_contra) -> None: ... + + def started(self, value: T_contra | None = None) -> None: + """ + Signal that the task has started. + + :param value: object passed back to the starter of the task + """ + + +class TaskGroup(metaclass=ABCMeta): + """ + Groups several asynchronous tasks together. + + :ivar cancel_scope: the cancel scope inherited by all child tasks + :vartype cancel_scope: CancelScope + + .. note:: On asyncio, support for eager task factories is considered to be + **experimental**. In particular, they don't follow the usual semantics of new + tasks being scheduled on the next iteration of the event loop, and may thus + cause unexpected behavior in code that wasn't written with such semantics in + mind. + """ + + cancel_scope: CancelScope + + def cancel(self, reason: str | None = None) -> None: + """ + Cancel this task group's cancel scope immediately. + + This is a shortcut for calling ``.cancel_scope.cancel()`` on the task group. + + :param reason: a message describing the reason for the cancellation + + .. versionadded:: 4.14.0 + + """ + self.cancel_scope.cancel(reason) + + @abstractmethod + def create_task( + self, + coro: Coroutine[Any, Any, T_co], + *, + name: object = None, + context: Context | None = None, + ) -> TaskHandle[T_co]: + """ + Create a new task from a coroutine object and schedule it to run. + + :param coro: a coroutine object + :param name: optional name to give the task + :param context: optional context to run the task in + :return: a task handle + + .. versionadded:: 4.14.0 + """ + + @final + def start_soon( + self, + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + *args: Unpack[PosArgsT], + name: object = None, + ) -> TaskHandle[T_co]: + """ + Start a new task in this task group. + + :param func: a coroutine function + :param args: positional arguments to call the function with + :param name: name of the task, for the purposes of introspection and debugging + :return: a task handle + + .. versionadded:: 3.0 + .. versionchanged:: 4.14.0 + This method now returns a task handle. + + """ + final_name = get_callable_name(func, name) + return self.create_task(call_for_coroutine(func, args), name=final_name) + + @overload + async def start( + self, + func: Callable[..., Coroutine[Any, Any, T_co]], + *args: object, + name: object = None, + return_handle: Literal[False] = ..., + ) -> Any: ... + + @overload + async def start( + self, + func: Callable[..., Coroutine[Any, Any, T_co]], + *args: object, + name: object = None, + return_handle: Literal[True], + ) -> TaskHandle[T_co, Any]: ... + + @abstractmethod + async def start( + self, + func: Callable[..., Coroutine[Any, Any, T_co]], + *args: object, + name: object = None, + return_handle: Literal[False] | Literal[True] = False, + ) -> Any: + """ + Start a new task and wait until it signals for readiness. + + The target callable must accept a keyword argument ``task_status`` (of type + :class:`TaskStatus`). Awaiting on this method will return whatever was passed to + ``task_status.started()`` (``None`` by default). + + .. note:: The :class:`TaskStatus` class is generic, and the type argument should + indicate the type of the value that will be passed to + ``task_status.started()``. + + :param func: a coroutine function that accepts the ``task_status`` keyword + argument + :param args: positional arguments to call the function with + :param name: an optional name for the task, for introspection and debugging + :param return_handle: if ``True``, return a :class:`TaskHandle` which also + contains the start value in ``start_value`` + :return: the value passed to ``task_status.started()`` + :raises RuntimeError: if the task finishes without calling + ``task_status.started()`` + + .. seealso:: :ref:`start_initialize` + + .. versionadded:: 3.0 + """ + + @abstractmethod + async def __aenter__(self) -> TaskGroup: + """Enter the task group context and allow starting new tasks.""" + + @abstractmethod + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + """Exit the task group context waiting for all tasks to finish.""" diff --git a/server/venv/Lib/site-packages/anyio/abc/_testing.py b/server/venv/Lib/site-packages/anyio/abc/_testing.py new file mode 100644 index 0000000..2a93fb7 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/abc/_testing.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import types +from abc import ABCMeta, abstractmethod +from collections.abc import AsyncGenerator, Callable, Coroutine, Iterable +from typing import Any, TypeVar + +_T = TypeVar("_T") + + +class TestRunner(metaclass=ABCMeta): + """ + Encapsulates a running event loop. Every call made through this object will use the + same event loop. + """ + + def __enter__(self) -> TestRunner: + return self + + @abstractmethod + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> bool | None: ... + + @abstractmethod + def run_asyncgen_fixture( + self, + fixture_func: Callable[..., AsyncGenerator[_T, Any]], + kwargs: dict[str, Any], + ) -> Iterable[_T]: + """ + Run an async generator fixture. + + :param fixture_func: the fixture function + :param kwargs: keyword arguments to call the fixture function with + :return: an iterator yielding the value yielded from the async generator + """ + + @abstractmethod + def run_fixture( + self, + fixture_func: Callable[..., Coroutine[Any, Any, _T]], + kwargs: dict[str, Any], + ) -> _T: + """ + Run an async fixture. + + :param fixture_func: the fixture function + :param kwargs: keyword arguments to call the fixture function with + :return: the return value of the fixture function + """ + + @abstractmethod + def run_test( + self, test_func: Callable[..., Coroutine[Any, Any, Any]], kwargs: dict[str, Any] + ) -> None: + """ + Run an async test function. + + :param test_func: the test function + :param kwargs: keyword arguments to call the test function with + """ + + @abstractmethod + def is_running(self) -> bool: + """ + Check if the test runner is running. + + :return: ``True`` if the coroutine is currently being run, ``False`` otherwise. + """ diff --git a/server/venv/Lib/site-packages/anyio/from_thread.py b/server/venv/Lib/site-packages/anyio/from_thread.py new file mode 100644 index 0000000..8c7914c --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/from_thread.py @@ -0,0 +1,582 @@ +from __future__ import annotations + +__all__ = ( + "BlockingPortal", + "BlockingPortalProvider", + "check_cancelled", + "run", + "run_sync", + "start_blocking_portal", +) + +import sys +from collections.abc import Awaitable, Callable, Coroutine, Generator +from concurrent.futures import Future +from contextlib import ( + AbstractAsyncContextManager, + AbstractContextManager, + contextmanager, +) +from dataclasses import dataclass, field +from functools import partial +from inspect import isawaitable +from threading import Lock, Thread, current_thread, get_ident +from types import TracebackType +from typing import ( + Any, + Generic, + TypeVar, + cast, + overload, +) + +from ._core._eventloop import ( + get_cancelled_exc_class, + threadlocals, +) +from ._core._eventloop import run as run_eventloop +from ._core._exceptions import NoEventLoopError +from ._core._synchronization import Event +from ._core._tasks import CancelScope, create_task_group +from .abc._tasks import TaskStatus +from .lowlevel import EventLoopToken, current_token + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +T_Retval = TypeVar("T_Retval") +T_co = TypeVar("T_co", covariant=True) +PosArgsT = TypeVarTuple("PosArgsT") + + +def _token_or_error(token: EventLoopToken | None) -> EventLoopToken: + if token is not None: + return token + + try: + return threadlocals.current_token + except AttributeError: + raise NoEventLoopError( + "Not running inside an AnyIO worker thread, and no event loop token was " + "provided" + ) from None + + +def run( + func: Callable[[Unpack[PosArgsT]], Coroutine[Any, Any, T_co]], + *args: Unpack[PosArgsT], + token: EventLoopToken | None = None, +) -> T_co: + """ + Call a coroutine function from a worker thread. + + :param func: a coroutine function + :param args: positional arguments for the callable + :param token: an event loop token to use to get back to the event loop thread + (required if calling this function from outside an AnyIO worker thread) + :return: the return value of the coroutine function + :raises MissingTokenError: if no token was provided and called from outside an + AnyIO worker thread + :raises RunFinishedError: if the event loop tied to ``token`` is no longer running + + .. versionchanged:: 4.11.0 + Added the ``token`` parameter. + + """ + explicit_token = token is not None + token = _token_or_error(token) + return token.backend_class.run_async_from_thread( + func, args, token=token.native_token if explicit_token else None + ) + + +def run_sync( + func: Callable[[Unpack[PosArgsT]], T_Retval], + *args: Unpack[PosArgsT], + token: EventLoopToken | None = None, +) -> T_Retval: + """ + Call a function in the event loop thread from a worker thread. + + :param func: a callable + :param args: positional arguments for the callable + :param token: an event loop token to use to get back to the event loop thread + (required if calling this function from outside an AnyIO worker thread) + :return: the return value of the callable + :raises MissingTokenError: if no token was provided and called from outside an + AnyIO worker thread + :raises RunFinishedError: if the event loop tied to ``token`` is no longer running + + .. versionchanged:: 4.11.0 + Added the ``token`` parameter. + + """ + explicit_token = token is not None + token = _token_or_error(token) + return token.backend_class.run_sync_from_thread( + func, args, token=token.native_token if explicit_token else None + ) + + +class _BlockingAsyncContextManager(Generic[T_co], AbstractContextManager): + _enter_future: Future[T_co] + _exit_future: Future[bool | None] + _exit_event: Event + _exit_exc_info: tuple[ + type[BaseException] | None, BaseException | None, TracebackType | None + ] = (None, None, None) + + def __init__( + self, async_cm: AbstractAsyncContextManager[T_co], portal: BlockingPortal + ): + self._async_cm = async_cm + self._portal = portal + + async def run_async_cm(self) -> bool | None: + try: + self._exit_event = Event() + value = await self._async_cm.__aenter__() + except BaseException as exc: + self._enter_future.set_exception(exc) + raise + else: + self._enter_future.set_result(value) + + try: + # Wait for the sync context manager to exit. + # This next statement can raise `get_cancelled_exc_class()` if + # something went wrong in a task group in this async context + # manager. + await self._exit_event.wait() + finally: + # In case of cancellation, it could be that we end up here before + # `_BlockingAsyncContextManager.__exit__` is called, and an + # `_exit_exc_info` has been set. + result = await self._async_cm.__aexit__(*self._exit_exc_info) + + return result + + def __enter__(self) -> T_co: + self._enter_future = Future() + self._exit_future = self._portal.start_task_soon(self.run_async_cm) + return self._enter_future.result() + + def __exit__( + self, + __exc_type: type[BaseException] | None, + __exc_value: BaseException | None, + __traceback: TracebackType | None, + ) -> bool | None: + self._exit_exc_info = __exc_type, __exc_value, __traceback + self._portal.call(self._exit_event.set) + return self._exit_future.result() + + +class _BlockingPortalTaskStatus(TaskStatus): + def __init__(self, future: Future): + self._future = future + + def started(self, value: object = None) -> None: + self._future.set_result(value) + + +class BlockingPortal: + """ + An object that lets external threads run code in an asynchronous event loop. + + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + """ + + def __init__(self) -> None: + self._token = current_token() + self._event_loop_thread_id: int | None = get_ident() + self._stop_event = Event() + self._task_group = create_task_group() + + async def __aenter__(self) -> BlockingPortal: + await self._task_group.__aenter__() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool: + await self.stop() + return await self._task_group.__aexit__(exc_type, exc_val, exc_tb) + + def _check_running(self) -> None: + if self._event_loop_thread_id is None: + raise RuntimeError("This portal is not running") + if self._event_loop_thread_id == get_ident(): + raise RuntimeError( + "This method cannot be called from the event loop thread" + ) + + async def sleep_until_stopped(self) -> None: + """Sleep until :meth:`stop` is called.""" + await self._stop_event.wait() + + async def stop(self, cancel_remaining: bool = False) -> None: + """ + Signal the portal to shut down. + + This marks the portal as no longer accepting new calls and exits from + :meth:`sleep_until_stopped`. + + :param cancel_remaining: ``True`` to cancel all the remaining tasks, ``False`` + to let them finish before returning + + """ + self._event_loop_thread_id = None + self._stop_event.set() + if cancel_remaining: + self._task_group.cancel_scope.cancel("the blocking portal is shutting down") + + async def _call_func( + self, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval], + args: tuple[Unpack[PosArgsT]], + kwargs: dict[str, Any], + future: Future[T_Retval], + ) -> None: + event_loop_thread_id = self._event_loop_thread_id + + def callback(f: Future[T_Retval]) -> None: + if f.cancelled(): + if event_loop_thread_id == get_ident(): + scope.cancel("the future was cancelled") + elif event_loop_thread_id is not None: + run_sync( + scope.cancel, "the future was cancelled", token=self._token + ) + + try: + retval_or_awaitable = func(*args, **kwargs) + if isawaitable(retval_or_awaitable): + with CancelScope() as scope: + future.add_done_callback(callback) + retval = await retval_or_awaitable + else: + retval = retval_or_awaitable + except get_cancelled_exc_class(): + future.cancel() + future.set_running_or_notify_cancel() + except BaseException as exc: + if not future.cancelled(): + future.set_exception(exc) + + # Let base exceptions fall through + if not isinstance(exc, Exception): + raise + else: + if not future.cancelled(): + future.set_result(retval) + finally: + scope = None # type: ignore[assignment] + + def _spawn_task_from_thread( + self, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval], + args: tuple[Unpack[PosArgsT]], + kwargs: dict[str, Any], + name: object, + future: Future[T_Retval], + ) -> None: + """ + Spawn a new task using the given callable. + + :param func: a callable + :param args: positional arguments to be passed to the callable + :param kwargs: keyword arguments to be passed to the callable + :param name: name of the task (will be coerced to a string if not ``None``) + :param future: a future that will resolve to the return value of the callable, + or the exception raised during its execution + + """ + run_sync( + partial(self._task_group.start_soon, name=name), + self._call_func, + func, + args, + kwargs, + future, + token=self._token, + ) + + @overload + def call( + self, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], + *args: Unpack[PosArgsT], + ) -> T_Retval: ... + + @overload + def call( + self, func: Callable[[Unpack[PosArgsT]], T_Retval], *args: Unpack[PosArgsT] + ) -> T_Retval: ... + + def call( + self, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval], + *args: Unpack[PosArgsT], + ) -> T_Retval: + """ + Call the given function in the event loop thread. + + If the callable returns a coroutine object, it is awaited on. + + :param func: any callable + :raises RuntimeError: if the portal is not running or if this method is called + from within the event loop thread + + """ + return cast(T_Retval, self.start_task_soon(func, *args).result()) + + @overload + def start_task_soon( + self, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], + *args: Unpack[PosArgsT], + name: object = None, + ) -> Future[T_Retval]: ... + + @overload + def start_task_soon( + self, + func: Callable[[Unpack[PosArgsT]], T_Retval], + *args: Unpack[PosArgsT], + name: object = None, + ) -> Future[T_Retval]: ... + + def start_task_soon( + self, + func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval] | T_Retval], + *args: Unpack[PosArgsT], + name: object = None, + ) -> Future[T_Retval]: + """ + Start a task in the portal's task group. + + The task will be run inside a cancel scope which can be cancelled by cancelling + the returned future. + + :param func: the target function + :param args: positional arguments passed to ``func`` + :param name: name of the task (will be coerced to a string if not ``None``) + :return: a future that resolves with the return value of the callable if the + task completes successfully, or with the exception raised in the task + :raises RuntimeError: if the portal is not running or if this method is called + from within the event loop thread + :rtype: concurrent.futures.Future[T_Retval] + + .. versionadded:: 3.0 + + """ + self._check_running() + f: Future[T_Retval] = Future() + self._spawn_task_from_thread(func, args, {}, name, f) + return f + + def start_task( + self, + func: Callable[..., Awaitable[T_Retval]], + *args: object, + name: object = None, + ) -> tuple[Future[T_Retval], Any]: + """ + Start a task in the portal's task group and wait until it signals for readiness. + + This method works the same way as :meth:`.abc.TaskGroup.start`. + + :param func: the target function + :param args: positional arguments passed to ``func`` + :param name: name of the task (will be coerced to a string if not ``None``) + :return: a tuple of (future, task_status_value) where the ``task_status_value`` + is the value passed to ``task_status.started()`` from within the target + function + :rtype: tuple[concurrent.futures.Future[T_Retval], Any] + + .. versionadded:: 3.0 + + """ + + def task_done(future: Future[T_Retval]) -> None: + if not task_status_future.done(): + if future.cancelled(): + task_status_future.cancel() + elif future.exception(): + task_status_future.set_exception(future.exception()) + else: + exc = RuntimeError( + "Task exited without calling task_status.started()" + ) + task_status_future.set_exception(exc) + + self._check_running() + task_status_future: Future = Future() + task_status = _BlockingPortalTaskStatus(task_status_future) + f: Future = Future() + f.add_done_callback(task_done) + self._spawn_task_from_thread(func, args, {"task_status": task_status}, name, f) + return f, task_status_future.result() + + def wrap_async_context_manager( + self, cm: AbstractAsyncContextManager[T_co] + ) -> AbstractContextManager[T_co]: + """ + Wrap an async context manager as a synchronous context manager via this portal. + + Spawns a task that will call both ``__aenter__()`` and ``__aexit__()``, stopping + in the middle until the synchronous context manager exits. + + :param cm: an asynchronous context manager + :return: a synchronous context manager + + .. versionadded:: 2.1 + + """ + return _BlockingAsyncContextManager(cm, self) + + +@dataclass +class BlockingPortalProvider: + """ + A manager for a blocking portal. Used as a context manager. The first thread to + enter this context manager causes a blocking portal to be started with the specific + parameters, and the last thread to exit causes the portal to be shut down. Thus, + there will be exactly one blocking portal running in this context as long as at + least one thread has entered this context manager. + + The parameters are the same as for :func:`~anyio.run`. + + :param backend: name of the backend + :param backend_options: backend options + + .. versionadded:: 4.4 + """ + + backend: str = "asyncio" + backend_options: dict[str, Any] | None = None + _lock: Lock = field(init=False, default_factory=Lock) + _leases: int = field(init=False, default=0) + _portal: BlockingPortal = field(init=False) + _portal_cm: AbstractContextManager[BlockingPortal] | None = field( + init=False, default=None + ) + + def __enter__(self) -> BlockingPortal: + with self._lock: + if self._portal_cm is None: + self._portal_cm = start_blocking_portal( + self.backend, self.backend_options + ) + self._portal = self._portal_cm.__enter__() + + self._leases += 1 + return self._portal + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + portal_cm: AbstractContextManager[BlockingPortal] | None = None + with self._lock: + assert self._portal_cm + assert self._leases > 0 + self._leases -= 1 + if not self._leases: + portal_cm = self._portal_cm + self._portal_cm = None + del self._portal + + if portal_cm: + portal_cm.__exit__(None, None, None) + + +@contextmanager +def start_blocking_portal( + backend: str = "asyncio", + backend_options: dict[str, Any] | None = None, + *, + name: str | None = None, +) -> Generator[BlockingPortal, Any, None]: + """ + Start a new event loop in a new thread and run a blocking portal in its main task. + + The parameters are the same as for :func:`~anyio.run`. + + :param backend: name of the backend + :param backend_options: backend options + :param name: name of the thread + :return: a context manager that yields a blocking portal + + .. versionchanged:: 3.0 + Usage as a context manager is now required. + + """ + + async def run_portal() -> None: + async with BlockingPortal() as portal_: + if name is None: + current_thread().name = f"{backend}-portal-{id(portal_):x}" + + future.set_result(portal_) + await portal_.sleep_until_stopped() + + def run_blocking_portal() -> None: + if future.set_running_or_notify_cancel(): + try: + run_eventloop( + run_portal, backend=backend, backend_options=backend_options + ) + except BaseException as exc: + if not future.done(): + future.set_exception(exc) + + future: Future[BlockingPortal] = Future() + thread = Thread(target=run_blocking_portal, daemon=True, name=name) + thread.start() + try: + cancel_remaining_tasks = False + portal = future.result() + try: + yield portal + except BaseException: + cancel_remaining_tasks = True + raise + finally: + try: + portal.call(portal.stop, cancel_remaining_tasks) + except RuntimeError: + pass + finally: + thread.join() + + +def check_cancelled() -> None: + """ + Check if the cancel scope of the host task's running the current worker thread has + been cancelled. + + If the host task's current cancel scope has indeed been cancelled, the + backend-specific cancellation exception will be raised. + + :raises RuntimeError: if the current thread was not spawned by + :func:`.to_thread.run_sync` + + """ + try: + token: EventLoopToken = threadlocals.current_token + except AttributeError: + raise NoEventLoopError( + "This function can only be called inside an AnyIO worker thread" + ) from None + + token.backend_class.check_cancelled() diff --git a/server/venv/Lib/site-packages/anyio/functools.py b/server/venv/Lib/site-packages/anyio/functools.py new file mode 100644 index 0000000..b0bdfb4 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/functools.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +__all__ = ( + "AsyncCacheInfo", + "AsyncCacheParameters", + "AsyncLRUCacheWrapper", + "cache", + "lru_cache", + "reduce", +) + +import functools +from collections import OrderedDict +from collections.abc import ( + AsyncIterable, + Awaitable, + Callable, + Coroutine, + Hashable, + Iterable, +) +from functools import update_wrapper +from inspect import iscoroutinefunction +from typing import ( + Any, + Generic, + NamedTuple, + ParamSpec, + TypedDict, + TypeVar, + cast, + final, + overload, +) +from weakref import WeakKeyDictionary + +from ._core._eventloop import current_time +from ._core._synchronization import Lock +from .lowlevel import RunVar, checkpoint + +T = TypeVar("T") +S = TypeVar("S") +P = ParamSpec("P") +lru_cache_items: RunVar[ + WeakKeyDictionary[ + AsyncLRUCacheWrapper[Any, Any], + OrderedDict[ + Hashable, + tuple[_InitialMissingType, Lock, float | None] + | tuple[Any, None, float | None], + ], + ] +] = RunVar("lru_cache_items") + + +class _InitialMissingType: + pass + + +initial_missing: _InitialMissingType = _InitialMissingType() + + +class AsyncCacheInfo(NamedTuple): + hits: int + misses: int + maxsize: int | None + currsize: int + ttl: int | None + + +class AsyncCacheParameters(TypedDict): + maxsize: int | None + typed: bool + always_checkpoint: bool + ttl: int | None + + +class _LRUMethodWrapper(Generic[T]): + def __init__(self, wrapper: AsyncLRUCacheWrapper[..., T], instance: object): + self.__wrapper = wrapper + self.__instance = instance + + def cache_info(self) -> AsyncCacheInfo: + return self.__wrapper.cache_info() + + def cache_parameters(self) -> AsyncCacheParameters: + return self.__wrapper.cache_parameters() + + def cache_clear(self) -> None: + self.__wrapper.cache_clear() + + async def __call__(self, *args: Any, **kwargs: Any) -> T: + if self.__instance is None: + return await self.__wrapper(*args, **kwargs) + + return await self.__wrapper(self.__instance, *args, **kwargs) + + +@final +class AsyncLRUCacheWrapper(Generic[P, T]): + def __init__( + self, + func: Callable[P, Awaitable[T]], + maxsize: int | None, + typed: bool, + always_checkpoint: bool, + ttl: int | None, + ): + self.__wrapped__ = func + self._hits: int = 0 + self._misses: int = 0 + self._maxsize = max(maxsize, 0) if maxsize is not None else None + self._currsize: int = 0 + self._typed = typed + self._always_checkpoint = always_checkpoint + self._ttl = ttl + update_wrapper(self, func) + + def cache_info(self) -> AsyncCacheInfo: + return AsyncCacheInfo( + self._hits, self._misses, self._maxsize, self._currsize, self._ttl + ) + + def cache_parameters(self) -> AsyncCacheParameters: + return { + "maxsize": self._maxsize, + "typed": self._typed, + "always_checkpoint": self._always_checkpoint, + "ttl": self._ttl, + } + + def cache_clear(self) -> None: + if cache := lru_cache_items.get(None): + cache.pop(self, None) + self._hits = self._misses = self._currsize = 0 + + async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: + # Easy case first: if maxsize == 0, no caching is done + if self._maxsize == 0: + value = await self.__wrapped__(*args, **kwargs) + self._misses += 1 + return value + + # The key is constructed as a flat tuple to avoid memory overhead + key: tuple[Any, ...] = args + if kwargs: + # initial_missing is used as a separator + key += (initial_missing,) + sum(kwargs.items(), ()) + + if self._typed: + key += tuple(type(arg) for arg in args) + if kwargs: + key += (initial_missing,) + tuple(type(val) for val in kwargs.values()) + + try: + cache = lru_cache_items.get() + except LookupError: + cache = WeakKeyDictionary() + lru_cache_items.set(cache) + + try: + cache_entry = cache[self] + except KeyError: + cache_entry = cache[self] = OrderedDict() + + cached_value: T | _InitialMissingType + try: + cached_value, lock, expires_at = cache_entry[key] + except KeyError: + # We're the first task to call this function + cached_value, lock, expires_at = ( + initial_missing, + Lock(fast_acquire=not self._always_checkpoint), + None, + ) + cache_entry[key] = cached_value, lock, expires_at + + if lock is None: + if expires_at is not None and current_time() >= expires_at: + self._currsize -= 1 + cached_value, lock, expires_at = ( + initial_missing, + Lock(fast_acquire=not self._always_checkpoint), + None, + ) + cache_entry[key] = cached_value, lock, expires_at + else: + # The value was already cached + self._hits += 1 + cache_entry.move_to_end(key) + if self._always_checkpoint: + await checkpoint() + + return cast(T, cached_value) + + async with lock: + # Check if another task filled the cache while we acquired the lock + if (cached_value := cache_entry[key][0]) is initial_missing: + self._misses += 1 + if self._maxsize is not None and self._currsize >= self._maxsize: + cache_entry.popitem(last=False) + else: + self._currsize += 1 + + value = await self.__wrapped__(*args, **kwargs) + expires_at = ( + current_time() + self._ttl if self._ttl is not None else None + ) + cache_entry[key] = value, None, expires_at + else: + # Another task filled the cache while we were waiting for the lock + self._hits += 1 + cache_entry.move_to_end(key) + value = cast(T, cached_value) + + return value + + def __get__( + self, instance: object, owner: type | None = None + ) -> _LRUMethodWrapper[T]: + wrapper = _LRUMethodWrapper(self, instance) + update_wrapper(wrapper, self.__wrapped__) + return wrapper + + +class _LRUCacheWrapper: + def __init__( + self, maxsize: int | None, typed: bool, always_checkpoint: bool, ttl: int | None + ): + self._maxsize = maxsize + self._typed = typed + self._always_checkpoint = always_checkpoint + self._ttl = ttl + + @overload + def __call__( # type: ignore[overload-overlap] + self, func: Callable[P, Coroutine[Any, Any, T]], / + ) -> AsyncLRUCacheWrapper[P, T]: ... + + @overload + def __call__( + self, func: Callable[..., T], / + ) -> functools._lru_cache_wrapper[T]: ... + + def __call__( + self, f: Callable[P, Coroutine[Any, Any, T]] | Callable[..., T], / + ) -> AsyncLRUCacheWrapper[P, T] | functools._lru_cache_wrapper[T]: + if iscoroutinefunction(f): + return AsyncLRUCacheWrapper( + f, self._maxsize, self._typed, self._always_checkpoint, self._ttl + ) + + return functools.lru_cache(maxsize=self._maxsize, typed=self._typed)(f) # type: ignore[arg-type] + + +@overload +def cache( # type: ignore[overload-overlap] + func: Callable[P, Coroutine[Any, Any, T]], / +) -> AsyncLRUCacheWrapper[P, T]: ... + + +@overload +def cache(func: Callable[..., T], /) -> functools._lru_cache_wrapper[T]: ... + + +def cache(func: Callable[..., Any] | Callable[P, Coroutine[Any, Any, Any]], /) -> Any: + """ + A convenient shortcut for :func:`lru_cache` with ``maxsize=None``. + + This is the asynchronous equivalent to :func:`functools.cache`. + + """ + return lru_cache(maxsize=None)(func) + + +@overload +def lru_cache( + *, + maxsize: int | None = ..., + typed: bool = ..., + always_checkpoint: bool = ..., + ttl: int | None = ..., +) -> _LRUCacheWrapper: ... + + +@overload +def lru_cache( # type: ignore[overload-overlap] + func: Callable[P, Coroutine[Any, Any, T]], / +) -> AsyncLRUCacheWrapper[P, T]: ... + + +@overload +def lru_cache(func: Callable[..., T], /) -> functools._lru_cache_wrapper[T]: ... + + +def lru_cache( + func: Callable[..., Coroutine[Any, Any, Any]] | Callable[..., Any] | None = None, + /, + *, + maxsize: int | None = 128, + typed: bool = False, + always_checkpoint: bool = False, + ttl: int | None = None, +) -> Any: + """ + An asynchronous version of :func:`functools.lru_cache`. + + If a synchronous function is passed, the standard library + :func:`functools.lru_cache` is applied instead. + + :param always_checkpoint: if ``True``, every call to the cached function will be + guaranteed to yield control to the event loop at least once + :param ttl: time in seconds after which to invalidate cache entries + + .. note:: Caches and locks are managed on a per-event loop basis. + + """ + if func is None: + return _LRUCacheWrapper(maxsize, typed, always_checkpoint, ttl) + + if not callable(func): + raise TypeError("the first argument must be callable") + + return _LRUCacheWrapper(maxsize, typed, always_checkpoint, ttl)(func) + + +@overload +async def reduce( + function: Callable[[T, S], Awaitable[T]], + iterable: Iterable[S] | AsyncIterable[S], + /, + initial: T, +) -> T: ... + + +@overload +async def reduce( + function: Callable[[T, T], Awaitable[T]], + iterable: Iterable[T] | AsyncIterable[T], + /, +) -> T: ... + + +async def reduce( # type: ignore[misc] + function: Callable[[T, T], Awaitable[T]] | Callable[[T, S], Awaitable[T]], + iterable: Iterable[T] | Iterable[S] | AsyncIterable[T] | AsyncIterable[S], + /, + initial: T | _InitialMissingType = initial_missing, +) -> T: + """ + Asynchronous version of :func:`functools.reduce`. + + :param function: a coroutine function that takes two arguments: the accumulated + value and the next element from the iterable + :param iterable: an iterable or async iterable + :param initial: the initial value (if missing, the first element of the iterable is + used as the initial value) + + """ + element: Any + function_called = False + if isinstance(iterable, AsyncIterable): + async_it = iterable.__aiter__() + if initial is initial_missing: + try: + value = cast(T, await async_it.__anext__()) + except StopAsyncIteration: + raise TypeError( + "reduce() of empty sequence with no initial value" + ) from None + else: + value = cast(T, initial) + + async for element in async_it: + value = await function(value, element) + function_called = True + elif isinstance(iterable, Iterable): + it = iter(iterable) + if initial is initial_missing: + try: + value = cast(T, next(it)) + except StopIteration: + raise TypeError( + "reduce() of empty sequence with no initial value" + ) from None + else: + value = cast(T, initial) + + for element in it: + value = await function(value, element) + function_called = True + else: + raise TypeError("reduce() argument 2 must be an iterable or async iterable") + + # Make sure there is at least one checkpoint, even if an empty iterable and an + # initial value were given + if not function_called: + await checkpoint() + + return value diff --git a/server/venv/Lib/site-packages/anyio/itertools.py b/server/venv/Lib/site-packages/anyio/itertools.py new file mode 100644 index 0000000..7e5248e --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/itertools.py @@ -0,0 +1,626 @@ +from __future__ import annotations + +__all__ = ( + "accumulate", + "batched", + "Chain", + "combinations", + "combinations_with_replacement", + "compress", + "count", + "cycle", + "dropwhile", + "filterfalse", + "groupby", + "islice", + "pairwise", + "permutations", + "product", + "repeat", + "starmap", + "tee", + "takewhile", + "zip_longest", +) + +import itertools +import operator +import sys +from collections.abc import ( + AsyncGenerator, + AsyncIterable, + AsyncIterator, + Awaitable, + Callable, + Iterable, + Iterator, +) +from dataclasses import dataclass, field +from typing import Any, Generic, TypeVar, cast, overload + +from ._core._synchronization import Lock +from ._core._tasks import CancelScope +from .lowlevel import cancel_shielded_checkpoint, checkpoint, checkpoint_if_cancelled + +T = TypeVar("T") +R = TypeVar("R") +_tee_end = object() + + +@dataclass(eq=False) +class _IterableAsyncIterator(AsyncIterator[T]): + iterator: Iterator[T] + + async def __anext__(self) -> T: + await checkpoint_if_cancelled() + try: + result = next(self.iterator) + except StopIteration: + await cancel_shielded_checkpoint() + raise StopAsyncIteration from None + + await cancel_shielded_checkpoint() + return result + + +def _iterate(iterable: Iterable[T] | AsyncIterable[T]) -> AsyncIterator[T]: + if isinstance(iterable, AsyncIterator): + return iterable + + if isinstance(iterable, AsyncIterable): + return iterable.__aiter__() + + return _IterableAsyncIterator(iter(iterable)) + + +@dataclass(eq=False) +class _TeeLink(Generic[T]): + value: object | None = None + next: _TeeLink[T] | None = None + filled: bool = False + + +@dataclass(eq=False) +class _TeeState(Generic[T]): + iterator: AsyncIterator[T] + lock: Lock = field(default_factory=Lock) + + async def fill(self, link: _TeeLink[T]) -> bool: + if link.filled: + return False + + async with self.lock: + if link.filled: + return True + + link.value = await anext(self.iterator, _tee_end) + if link.value is not _tee_end: + link.next = _TeeLink() + + link.filled = True + return True + + +class _TeeAsyncIterator(AsyncIterator[T]): + _state: _TeeState[T] + _link: _TeeLink[T] + _element_yielded: bool + + def __init__( + self, iterable: Iterable[T] | AsyncIterable[T] | _TeeAsyncIterator[T] + ) -> None: + if isinstance(iterable, _TeeAsyncIterator): + self._state = iterable._state + self._link = iterable._link + else: + self._state = _TeeState(_iterate(iterable)) + self._link = _TeeLink() + + self._element_yielded = False + + async def __anext__(self) -> T: + had_yieldpoint = await self._state.fill(self._link) + if self._link.value is _tee_end: + if not self._element_yielded: + await checkpoint() + + raise StopAsyncIteration + + if not had_yieldpoint: + await checkpoint_if_cancelled() + + self._element_yielded = True + value = cast(T, self._link.value) + next_link = self._link.next + assert next_link is not None + self._link = next_link + if not had_yieldpoint: + await cancel_shielded_checkpoint() + + return value + + +async def _operator_add(x: T, y: T) -> T: + return operator.add(x, y) + + +async def accumulate( + iterable: Iterable[T] | AsyncIterable[T], + function: Callable[[T, T], Awaitable[T]] = _operator_add, + *, + initial: T | None = None, +) -> AsyncGenerator[T, None]: + iterator = _iterate(iterable) + if initial is None: + try: + total = await anext(iterator) + except StopAsyncIteration: + await checkpoint() + return + else: + await checkpoint_if_cancelled() + total = initial + await cancel_shielded_checkpoint() + + yield total + + async for element in iterator: + total = await function(total, element) + yield total + + +async def batched( + iterable: Iterable[T] | AsyncIterable[T], n: int, *, strict: bool = False +) -> AsyncGenerator[tuple[T, ...], None]: + if n < 1: + raise ValueError("n must be at least one") + + iterator = _iterate(iterable) + + while True: + batch: list[T] = [] + for _ in range(n): + try: + batch.append(await anext(iterator)) + except StopAsyncIteration: + if not batch: + await checkpoint() + return + if strict: + raise ValueError("batched(): incomplete batch") from None + + yield tuple(batch) + return + + yield tuple(batch) + + +class Chain: + def __call__( + self, *iterables: Iterable[T] | AsyncIterable[T] + ) -> AsyncGenerator[T, None]: + return self.from_iterable(iterables) + + async def from_iterable( + self, + iterables: ( + Iterable[Iterable[T] | AsyncIterable[T]] + | AsyncIterable[Iterable[T] | AsyncIterable[T]] + ), + ) -> AsyncGenerator[T, None]: + element_yielded = False + outer_iter = _iterate(iterables) + + try: + async for iterable in outer_iter: + async for element in _iterate(iterable): + element_yielded = True + yield element + finally: + aclose = getattr(outer_iter, "aclose", None) + if aclose is not None: + with CancelScope(shield=True): + await aclose() + + if not element_yielded: + await checkpoint() + + +chain: Chain = Chain() + + +async def combinations( + iterable: Iterable[T] | AsyncIterable[T], r: int +) -> AsyncGenerator[tuple[T, ...], None]: + pool: list[T] = [element async for element in _iterate(iterable)] + async for combination in _iterate(itertools.combinations(pool, r)): + yield combination + + +async def combinations_with_replacement( + iterable: Iterable[T] | AsyncIterable[T], r: int +) -> AsyncGenerator[tuple[T, ...], None]: + pool: list[T] = [element async for element in _iterate(iterable)] + async for combination in _iterate(itertools.combinations_with_replacement(pool, r)): + yield combination + + +async def compress( + data: Iterable[T] | AsyncIterable[T], + selectors: Iterable[object] | AsyncIterable[object], +) -> AsyncGenerator[T, None]: + data_iterator = _iterate(data) + selector_iterator = _iterate(selectors) + element_yielded = False + + while True: + try: + datum = await anext(data_iterator) + selector = await anext(selector_iterator) + except StopAsyncIteration: + if not element_yielded: + await checkpoint() + + return + + if selector: + element_yielded = True + yield datum + + +async def count(start: int = 0, step: int = 1) -> AsyncGenerator[int, None]: + n = start + while True: + await checkpoint_if_cancelled() + value = n + n += step + await cancel_shielded_checkpoint() + yield value + + +async def cycle( + iterable: Iterable[T] | AsyncIterable[T], +) -> AsyncGenerator[T, None]: + saved: list[T] = [] + async for element in _iterate(iterable): + saved.append(element) + yield element + + if not saved: + await checkpoint() + return + + while True: + for element in saved: + await checkpoint() + yield element + + +async def dropwhile( + predicate: Callable[[T], Awaitable[object]], + iterable: Iterable[T] | AsyncIterable[T], +) -> AsyncGenerator[T, None]: + element_yielded = False + dropping = True + + async for element in _iterate(iterable): + if dropping and await predicate(element): + continue + + dropping = False + element_yielded = True + yield element + + if not element_yielded: + await checkpoint() + + +async def filterfalse( + predicate: Callable[[T], Awaitable[object]], + iterable: Iterable[T] | AsyncIterable[T], +) -> AsyncGenerator[T, None]: + element_yielded = False + + async for element in _iterate(iterable): + if not await predicate(element): + element_yielded = True + yield element + + if not element_yielded: + await checkpoint() + + +@overload +def groupby( + iterable: Iterable[T] | AsyncIterable[T], +) -> AsyncGenerator[tuple[T, list[T]], None]: ... + + +@overload +def groupby( + iterable: Iterable[T] | AsyncIterable[T], + key: Callable[[T], Awaitable[R]], +) -> AsyncGenerator[tuple[R, list[T]], None]: ... + + +async def groupby( + iterable: Iterable[T] | AsyncIterable[T], + key: Callable[[T], Awaitable[object]] | None = None, +) -> AsyncGenerator[tuple[object, list[T]], None]: + iterator = _iterate(iterable) + try: + element = await anext(iterator) + except StopAsyncIteration: + await checkpoint() + return + + group_key = element if key is None else await key(element) + values = [element] + + async for element in iterator: + next_key = element if key is None else await key(element) + if next_key != group_key: + completed_group = group_key, values + group_key = next_key + values = [element] + yield completed_group + else: + values.append(element) + + yield group_key, values + + +@overload +def islice( + iterable: Iterable[T] | AsyncIterable[T], + stop: int | None, + /, +) -> AsyncGenerator[T, None]: ... + + +@overload +def islice( + iterable: Iterable[T] | AsyncIterable[T], + start: int | None, + stop: int | None, + step: int | None = 1, + /, +) -> AsyncGenerator[T, None]: ... + + +async def islice( + iterable: Iterable[T] | AsyncIterable[T], + *args: int | None, +) -> AsyncGenerator[T, None]: + if not args: + raise TypeError("islice expected at least 2 arguments, got 1") + if len(args) > 3: + raise TypeError(f"islice expected at most 4 arguments, got {len(args) + 1}") + + slice_args = slice(*args) + + start_message = ( + "Indices for islice() must be None or an integer: 0 <= x <= sys.maxsize." + ) + stop_message = ( + "Stop argument for islice() must be None or an integer: 0 <= x <= sys.maxsize." + ) + step_message = "Step for islice() must be a positive integer or None." + + def normalize_index(value: object, message: str) -> int: + try: + index = operator.index(cast(Any, value)) + except TypeError: + raise ValueError(message) from None + + if index < 0 or index > sys.maxsize: + raise ValueError(message) + + return index + + start = ( + 0 + if slice_args.start is None + else normalize_index(slice_args.start, start_message) + ) + stop = ( + None + if slice_args.stop is None + else normalize_index(slice_args.stop, stop_message) + ) + step = ( + 1 if slice_args.step is None else normalize_index(slice_args.step, step_message) + ) + + if step <= 0: + raise ValueError(step_message) + + if stop == 0 or start == stop: + await checkpoint() + return + + iterator = _iterate(iterable) + index = 0 + element_yielded = False + + while stop is None or index < stop: + try: + element = await anext(iterator) + except StopAsyncIteration: + if not element_yielded: + await checkpoint() + + return + + if index >= start and (index - start) % step == 0: + index += 1 + element_yielded = True + yield element + else: + index += 1 + + if not element_yielded: + await checkpoint() + + +async def pairwise( + iterable: Iterable[T] | AsyncIterable[T], +) -> AsyncGenerator[tuple[T, T], None]: + iterator = _iterate(iterable) + try: + previous = await anext(iterator) + except StopAsyncIteration: + await checkpoint() + return + + element_yielded = False + async for element in iterator: + element_yielded = True + pair = (previous, element) + previous = element + yield pair + + if not element_yielded: + await checkpoint() + + +async def permutations( + iterable: Iterable[T] | AsyncIterable[T], r: int | None = None +) -> AsyncGenerator[tuple[T, ...], None]: + pool: list[T] = [element async for element in _iterate(iterable)] + n = len(pool) + if r is None: + r = n + elif not isinstance(r, int): + raise TypeError("Expected int as r") + elif r < 0: + raise ValueError("r must be non-negative") + + async for permutation in _iterate(itertools.permutations(pool, r)): + yield permutation + + +async def product( + *iterables: Iterable[T] | AsyncIterable[T], repeat: int = 1 +) -> AsyncGenerator[tuple[T, ...], None]: + repeat = operator.index(repeat) + if repeat < 0: + raise ValueError("repeat argument cannot be negative") + + pools: list[tuple[T, ...]] = [] + for iterable in iterables: + pool: list[T] = [element async for element in _iterate(iterable)] + pools.append(tuple(pool)) + + async for value in _iterate(itertools.product(*pools, repeat=repeat)): + yield value + + +async def repeat(element: T, times: int | None = None) -> AsyncGenerator[T, None]: + if times is None: + while True: + await checkpoint() + yield element + + remaining = operator.index(cast(Any, times)) + if remaining <= 0: + await checkpoint() + return + + while remaining > 0: + await checkpoint_if_cancelled() + remaining -= 1 + await cancel_shielded_checkpoint() + yield element + + +async def starmap( + function: Callable[..., Awaitable[R]], + iterable: ( + Iterable[Iterable[object] | AsyncIterable[object]] + | AsyncIterable[Iterable[object] | AsyncIterable[object]] + ), +) -> AsyncGenerator[R, None]: + result_yielded = False + + async for args_iterable in _iterate(iterable): + args = [element async for element in _iterate(args_iterable)] + result_yielded = True + yield await function(*args) + + if not result_yielded: + await checkpoint() + + +def tee( + iterable: Iterable[T] | AsyncIterable[T], n: int = 2 +) -> tuple[AsyncIterator[T], ...]: + n = operator.index(cast(Any, n)) + if n < 0: + raise ValueError("n must be >= 0") + if n == 0: + return () + + iterator = _TeeAsyncIterator(iterable) + iterators: list[AsyncIterator[T]] = [iterator] + iterators.extend(_TeeAsyncIterator(iterator) for _ in range(n - 1)) + return tuple(iterators) + + +async def takewhile( + predicate: Callable[[T], Awaitable[object]], + iterable: Iterable[T] | AsyncIterable[T], +) -> AsyncGenerator[T, None]: + element_yielded = False + + async for element in _iterate(iterable): + if not await predicate(element): + if not element_yielded: + await checkpoint() + + return + + element_yielded = True + yield element + + if not element_yielded: + await checkpoint() + + +async def zip_longest( + *iterables: Iterable[object] | AsyncIterable[object], + fillvalue: object = None, +) -> AsyncGenerator[tuple[object, ...], None]: + iterators = [_iterate(iterable) for iterable in iterables] + num_active = len(iterators) + if not num_active: + await checkpoint() + return + + active = [True] * num_active + tuple_yielded = False + + while True: + values: list[object] = [] + for index, iterator in enumerate(iterators): + if not active[index]: + values.append(fillvalue) + continue + + try: + value = await anext(iterator) + except StopAsyncIteration: + active[index] = False + num_active -= 1 + if not num_active: + if not tuple_yielded: + await checkpoint() + + return + + value = fillvalue + + values.append(value) + + tuple_yielded = True + yield tuple(values) diff --git a/server/venv/Lib/site-packages/anyio/lowlevel.py b/server/venv/Lib/site-packages/anyio/lowlevel.py new file mode 100644 index 0000000..d045791 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/lowlevel.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +__all__ = ( + "EventLoopToken", + "RunvarToken", + "RunVar", + "checkpoint", + "checkpoint_if_cancelled", + "cancel_shielded_checkpoint", + "current_token", +) + +import enum +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Generic, Literal, TypeVar, final, overload +from weakref import WeakKeyDictionary + +from ._core._eventloop import get_async_backend +from .abc import AsyncBackend + +T = TypeVar("T") +D = TypeVar("D") + + +async def checkpoint() -> None: + """ + Check for cancellation and allow the scheduler to switch to another task. + + Equivalent to (but more efficient than):: + + await checkpoint_if_cancelled() + await cancel_shielded_checkpoint() + + .. versionadded:: 3.0 + + """ + await get_async_backend().checkpoint() + + +async def checkpoint_if_cancelled() -> None: + """ + Enter a checkpoint if the enclosing cancel scope has been cancelled. + + This does not allow the scheduler to switch to a different task. + + .. versionadded:: 3.0 + + """ + await get_async_backend().checkpoint_if_cancelled() + + +async def cancel_shielded_checkpoint() -> None: + """ + Allow the scheduler to switch to another task but without checking for cancellation. + + Equivalent to (but potentially more efficient than):: + + with CancelScope(shield=True): + await checkpoint() + + .. versionadded:: 3.0 + + """ + await get_async_backend().cancel_shielded_checkpoint() + + +@final +@dataclass(frozen=True, repr=False) +class EventLoopToken: + """ + An opaque object that holds a reference to an event loop. + + .. versionadded:: 4.11.0 + """ + + backend_class: type[AsyncBackend] + native_token: object + + +def current_token() -> EventLoopToken: + """ + Return a token object that can be used to call code in the current event loop from + another thread. + + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + .. versionadded:: 4.11.0 + + """ + backend_class = get_async_backend() + raw_token = backend_class.current_token() + return EventLoopToken(backend_class, raw_token) + + +_run_vars: WeakKeyDictionary[object, dict[RunVar[Any], Any]] = WeakKeyDictionary() + + +class _NoValueSet(enum.Enum): + NO_VALUE_SET = enum.auto() + + +class RunvarToken(Generic[T]): + """ + A token that can be used to restore a :class:`RunVar` to its previous value. + + Returned by :meth:`RunVar.set`. Can be used as a context manager to automatically + reset the variable on exit, or passed directly to :meth:`RunVar.reset`. + """ + + __slots__ = "_var", "_value", "_redeemed" + + def __init__(self, var: RunVar[T], value: T | Literal[_NoValueSet.NO_VALUE_SET]): + self._var = var + self._value: T | Literal[_NoValueSet.NO_VALUE_SET] = value + self._redeemed = False + + def __enter__(self) -> RunvarToken[T]: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self._var.reset(self) + + +class RunVar(Generic[T]): + """ + Like a :class:`~contextvars.ContextVar`, except scoped to the running event loop. + + Can be used as a context manager, Just like :class:`~contextvars.ContextVar`, that + will reset the variable to its previous value when the context block is exited. + """ + + __slots__ = "_name", "_default" + + NO_VALUE_SET: Literal[_NoValueSet.NO_VALUE_SET] = _NoValueSet.NO_VALUE_SET + + def __init__( + self, name: str, default: T | Literal[_NoValueSet.NO_VALUE_SET] = NO_VALUE_SET + ): + self._name = name + self._default = default + + @property + def _current_vars(self) -> dict[RunVar[T], T]: + native_token = current_token().native_token + try: + return _run_vars[native_token] + except KeyError: + run_vars = _run_vars[native_token] = {} + return run_vars + + @overload + def get(self, default: D) -> T | D: ... + + @overload + def get(self) -> T: ... + + def get( + self, default: D | Literal[_NoValueSet.NO_VALUE_SET] = NO_VALUE_SET + ) -> T | D: + """ + Return the current value of this run variable. + + :param default: a fallback value to return if no value has been set + :return: the current value, the provided default, or the variable's own default + :raises LookupError: if no value is set and no default is available + + """ + try: + return self._current_vars[self] + except KeyError: + if default is not RunVar.NO_VALUE_SET: + return default + elif self._default is not RunVar.NO_VALUE_SET: + return self._default + + raise LookupError( + f'Run variable "{self._name}" has no value and no default set' + ) + + def set(self, value: T) -> RunvarToken[T]: + """ + Set the value of this run variable for the current event loop. + + :param value: the new value + :return: a token that can be used to restore the previous value + + """ + current_vars = self._current_vars + token = RunvarToken(self, current_vars.get(self, RunVar.NO_VALUE_SET)) + current_vars[self] = value + return token + + def reset(self, token: RunvarToken[T]) -> None: + """ + Restore this run variable to the value it held before the matching :meth:`set`. + + :param token: the token returned by :meth:`set` + :raises ValueError: if the token belongs to a different :class:`RunVar` or the token + has already been used + + """ + if token._var is not self: + raise ValueError("This token does not belong to this RunVar") + + if token._redeemed: + raise ValueError("This token has already been used") + + if token._value is _NoValueSet.NO_VALUE_SET: + try: + del self._current_vars[self] + except KeyError: + pass + else: + self._current_vars[self] = token._value + + token._redeemed = True + + def __repr__(self) -> str: + return f"" diff --git a/server/venv/Lib/site-packages/anyio/py.typed b/server/venv/Lib/site-packages/anyio/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/anyio/pytest_plugin.py b/server/venv/Lib/site-packages/anyio/pytest_plugin.py new file mode 100644 index 0000000..5c66759 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/pytest_plugin.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import dataclasses +import socket +import sys +from collections.abc import Callable, Generator, Iterator +from contextlib import ExitStack, contextmanager +from inspect import isasyncgenfunction, iscoroutinefunction, ismethod +from typing import Any, cast + +import pytest +from _pytest.fixtures import FuncFixtureInfo, SubRequest +from _pytest.outcomes import Exit +from _pytest.python import CallSpec2 +from _pytest.scope import Scope + +from . import get_available_backends +from ._core._eventloop import ( + current_async_library, + get_async_backend, + reset_current_async_library, + set_current_async_library, +) +from ._core._exceptions import iterate_exceptions +from .abc import TestRunner + +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + +_current_runner: TestRunner | None = None +_runner_stack: ExitStack | None = None +_runner_leases = 0 + + +def extract_backend_and_options(backend: object) -> tuple[str, dict[str, Any]]: + if isinstance(backend, str): + return backend, {} + elif isinstance(backend, tuple) and len(backend) == 2: + if isinstance(backend[0], str) and isinstance(backend[1], dict): + return cast(tuple[str, dict[str, Any]], backend) + + raise TypeError("anyio_backend must be either a string or tuple of (string, dict)") + + +@contextmanager +def get_runner( + backend_name: str, backend_options: dict[str, Any] +) -> Iterator[TestRunner]: + global _current_runner, _runner_leases, _runner_stack + if _current_runner is None: + asynclib = get_async_backend(backend_name) + _runner_stack = ExitStack() + if current_async_library() is None: + # Since we're in control of the event loop, we can cache the name of the + # async library + token = set_current_async_library(backend_name) + _runner_stack.callback(reset_current_async_library, token) + + backend_options = backend_options or {} + _current_runner = _runner_stack.enter_context( + asynclib.create_test_runner(backend_options) + ) + + _runner_leases += 1 + try: + yield _current_runner + finally: + _runner_leases -= 1 + if not _runner_leases: + assert _runner_stack is not None + _runner_stack.close() + _runner_stack = _current_runner = None + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addini( + "anyio_mode", + default="strict", + help='AnyIO plugin mode (either "strict" or "auto")', + ) + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "anyio: mark the (coroutine function) test to be run asynchronously via anyio.", + ) + if ( + config.getini("anyio_mode") == "auto" + and config.pluginmanager.has_plugin("asyncio") + and config.getini("asyncio_mode") == "auto" + ): + config.issue_config_time_warning( + pytest.PytestConfigWarning( + "AnyIO auto mode has been enabled together with pytest-asyncio auto " + "mode. This may cause unexpected behavior." + ), + 1, + ) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_fixture_setup(fixturedef: Any, request: Any) -> Generator[Any]: + def wrapper(anyio_backend: Any, request: SubRequest, **kwargs: Any) -> Any: + # Rebind any fixture methods to the request instance + if ( + request.instance + and ismethod(func) + and type(func.__self__) is type(request.instance) + ): + local_func = func.__func__.__get__(request.instance) + else: + local_func = func + + backend_name, backend_options = extract_backend_and_options(anyio_backend) + if has_backend_arg: + kwargs["anyio_backend"] = anyio_backend + + if has_request_arg: + kwargs["request"] = request + + with get_runner(backend_name, backend_options) as runner: + # re-entrant call into the test runner detected. this happens when an async fixture + # is dynamically requested via request.getfixturevalue() from inside a running async + # test or fixture. on asyncio this raises RuntimeError: This event loop is already + # running, on trio the runner deadlocks - the host loop blocks waiting for the + # coroutine to return, but the coroutine is waiting for the host loop. raising here + # prevents the hang and gives a consistent error across backends. + if runner.is_running(): + raise RuntimeError( + "Cannot schedule a coroutine in the test runner while another is already running; " + "likely caused by request.getfixturevalue() on an async fixture." + ) + + if isasyncgenfunction(local_func): + yield from runner.run_asyncgen_fixture(local_func, kwargs) + else: + yield runner.run_fixture(local_func, kwargs) + + # Only apply this to coroutine functions and async generator functions in requests + # that involve the anyio_backend fixture + func = fixturedef.func + if isasyncgenfunction(func) or iscoroutinefunction(func): + if "anyio_backend" in request.fixturenames: + fixturedef.func = wrapper + original_argname = fixturedef.argnames + + if not (has_backend_arg := "anyio_backend" in fixturedef.argnames): + fixturedef.argnames += ("anyio_backend",) + + if not (has_request_arg := "request" in fixturedef.argnames): + fixturedef.argnames += ("request",) + + try: + return (yield) + finally: + fixturedef.func = func + fixturedef.argnames = original_argname + + return (yield) + + +@pytest.hookimpl(tryfirst=True) +def pytest_pycollect_makeitem( + collector: pytest.Module | pytest.Class, name: str, obj: object +) -> None: + if collector.istestfunction(obj, name): + inner_func = obj.hypothesis.inner_test if hasattr(obj, "hypothesis") else obj + if iscoroutinefunction(inner_func): + anyio_auto_mode = collector.config.getini("anyio_mode") == "auto" + marker = collector.get_closest_marker("anyio") + own_markers = getattr(obj, "pytestmark", ()) + if ( + anyio_auto_mode + or marker + or any(marker.name == "anyio" for marker in own_markers) + ): + pytest.mark.usefixtures("anyio_backend")(obj) + + +def pytest_collection_finish(session: pytest.Session) -> None: + for i, item in reversed(list(enumerate(session.items))): + if ( + isinstance(item, pytest.Function) + and iscoroutinefunction(item.function) + and item.get_closest_marker("anyio") is not None + and "anyio_backend" not in item.fixturenames + ): + new_items = [] + try: + cs_fields = {f.name for f in dataclasses.fields(CallSpec2)} + except TypeError: + cs_fields = set() + + for param_index, backend in enumerate(get_available_backends()): + if "_arg2scope" in cs_fields: # pytest >= 8 + callspec = CallSpec2( + params={"anyio_backend": backend}, + indices={"anyio_backend": param_index}, + _arg2scope={"anyio_backend": Scope.Module}, + _idlist=[backend], + marks=[], + ) + else: # pytest 7.x + callspec = CallSpec2( # type: ignore[call-arg] + funcargs={}, + params={"anyio_backend": backend}, + indices={"anyio_backend": param_index}, + arg2scope={"anyio_backend": Scope.Module}, + idlist=[backend], + marks=[], + ) + + fi = item._fixtureinfo + new_names_closure = list(fi.names_closure) + if "anyio_backend" not in new_names_closure: + new_names_closure.append("anyio_backend") + + new_fixtureinfo = FuncFixtureInfo( + argnames=fi.argnames, + initialnames=fi.initialnames, + names_closure=new_names_closure, + name2fixturedefs=fi.name2fixturedefs, + ) + new_item = pytest.Function.from_parent( + item.parent, + name=f"{item.originalname}[{backend}]", + callspec=callspec, + callobj=item.obj, + fixtureinfo=new_fixtureinfo, + keywords=item.keywords, + originalname=item.originalname, + ) + new_items.append(new_item) + + session.items[i : i + 1] = new_items + + +@pytest.hookimpl(tryfirst=True) +def pytest_pyfunc_call(pyfuncitem: Any) -> bool | None: + def run_with_hypothesis(**kwargs: Any) -> None: + with get_runner(backend_name, backend_options) as runner: + runner.run_test(original_func, kwargs) + + backend = pyfuncitem.funcargs.get("anyio_backend") + if backend: + backend_name, backend_options = extract_backend_and_options(backend) + + if hasattr(pyfuncitem.obj, "hypothesis"): + # Wrap the inner test function unless it's already wrapped + original_func = pyfuncitem.obj.hypothesis.inner_test + if original_func.__qualname__ != run_with_hypothesis.__qualname__: + if iscoroutinefunction(original_func): + pyfuncitem.obj.hypothesis.inner_test = run_with_hypothesis + + return None + + if iscoroutinefunction(pyfuncitem.obj): + funcargs = pyfuncitem.funcargs + testargs = {arg: funcargs[arg] for arg in pyfuncitem._fixtureinfo.argnames} + with get_runner(backend_name, backend_options) as runner: + try: + runner.run_test(pyfuncitem.obj, testargs) + except ExceptionGroup as excgrp: + for exc in iterate_exceptions(excgrp): + if isinstance(exc, (Exit, KeyboardInterrupt, SystemExit)): + raise exc from excgrp + + raise + + return True + + return None + + +@pytest.fixture(scope="module", params=get_available_backends()) +def anyio_backend(request: Any) -> Any: + return request.param + + +@pytest.fixture +def anyio_backend_name(anyio_backend: Any) -> str: + if isinstance(anyio_backend, str): + return anyio_backend + else: + return anyio_backend[0] + + +@pytest.fixture +def anyio_backend_options(anyio_backend: Any) -> dict[str, Any]: + if isinstance(anyio_backend, str): + return {} + else: + return anyio_backend[1] + + +class FreePortFactory: + """ + Manages port generation based on specified socket kind, ensuring no duplicate + ports are generated. + + This class provides functionality for generating available free ports on the + system. It is initialized with a specific socket kind and can generate ports + for given address families while avoiding reuse of previously generated ports. + + Users should not instantiate this class directly, but use the + ``free_tcp_port_factory`` and ``free_udp_port_factory`` fixtures instead. For simple + uses cases, ``free_tcp_port`` and ``free_udp_port`` can be used instead. + """ + + def __init__(self, kind: socket.SocketKind) -> None: + self._kind = kind + self._generated = set[int]() + + @property + def kind(self) -> socket.SocketKind: + """ + The type of socket connection (e.g., :data:`~socket.SOCK_STREAM` or + :data:`~socket.SOCK_DGRAM`) used to bind for checking port availability + + """ + return self._kind + + def __call__(self, family: socket.AddressFamily | None = None) -> int: + """ + Return an unbound port for the given address family. + + :param family: if omitted, both IPv4 and IPv6 addresses will be tried + :return: a port number + + """ + if family is not None: + families = [family] + else: + families = [socket.AF_INET] + if socket.has_ipv6: + families.append(socket.AF_INET6) + + while True: + port = 0 + with ExitStack() as stack: + for family in families: + sock = stack.enter_context(socket.socket(family, self._kind)) + addr = "::1" if family == socket.AF_INET6 else "127.0.0.1" + try: + sock.bind((addr, port)) + except OSError: + break + + if not port: + port = sock.getsockname()[1] + else: + if port not in self._generated: + self._generated.add(port) + return port + + +@pytest.fixture(scope="session") +def free_tcp_port_factory() -> FreePortFactory: + return FreePortFactory(socket.SOCK_STREAM) + + +@pytest.fixture(scope="session") +def free_udp_port_factory() -> FreePortFactory: + return FreePortFactory(socket.SOCK_DGRAM) + + +@pytest.fixture +def free_tcp_port(free_tcp_port_factory: Callable[[], int]) -> int: + return free_tcp_port_factory() + + +@pytest.fixture +def free_udp_port(free_udp_port_factory: Callable[[], int]) -> int: + return free_udp_port_factory() diff --git a/server/venv/Lib/site-packages/anyio/streams/__init__.py b/server/venv/Lib/site-packages/anyio/streams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/anyio/streams/buffered.py b/server/venv/Lib/site-packages/anyio/streams/buffered.py new file mode 100644 index 0000000..acf312b --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/streams/buffered.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +__all__ = ( + "BufferedByteReceiveStream", + "BufferedByteStream", + "BufferedConnectable", +) + +import sys +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any, SupportsIndex + +from .. import ClosedResourceError, DelimiterNotFound, EndOfStream, IncompleteRead +from ..abc import ( + AnyByteReceiveStream, + AnyByteStream, + AnyByteStreamConnectable, + ByteReceiveStream, + ByteStream, + ByteStreamConnectable, +) + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + + +@dataclass(eq=False) +class BufferedByteReceiveStream(ByteReceiveStream): + """ + Wraps any bytes-based receive stream and uses a buffer to provide sophisticated + receiving capabilities in the form of a byte stream. + """ + + receive_stream: AnyByteReceiveStream + _buffer: bytearray = field(init=False, default_factory=bytearray) + _closed: bool = field(init=False, default=False) + + async def aclose(self) -> None: + await self.receive_stream.aclose() + self._closed = True + + @property + def buffer(self) -> bytes: + """The bytes currently in the buffer.""" + return bytes(self._buffer) + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return self.receive_stream.extra_attributes + + def feed_data(self, data: Iterable[SupportsIndex], /) -> None: + """ + Append data directly into the buffer. + + Any data in the buffer will be consumed by receive operations before receiving + anything from the wrapped stream. + + :param data: the data to append to the buffer (can be bytes or anything else + that supports ``__index__()``) + + """ + self._buffer.extend(data) + + async def receive(self, max_bytes: int = 65536) -> bytes: + if self._closed: + raise ClosedResourceError + + if self._buffer: + chunk = bytes(self._buffer[:max_bytes]) + del self._buffer[:max_bytes] + return chunk + elif isinstance(self.receive_stream, ByteReceiveStream): + return await self.receive_stream.receive(max_bytes) + else: + # With a bytes-oriented object stream, we need to handle any surplus bytes + # we get from the receive() call + chunk = await self.receive_stream.receive() + if len(chunk) > max_bytes: + # Save the surplus bytes in the buffer + self._buffer.extend(chunk[max_bytes:]) + return chunk[:max_bytes] + else: + return chunk + + async def receive_exactly(self, nbytes: int) -> bytes: + """ + Read exactly the given amount of bytes from the stream. + + :param nbytes: the number of bytes to read + :return: the bytes read + :raises ~anyio.IncompleteRead: if the stream was closed before the requested + amount of bytes could be read from the stream + + """ + while True: + remaining = nbytes - len(self._buffer) + if remaining <= 0: + retval = self._buffer[:nbytes] + del self._buffer[:nbytes] + return bytes(retval) + + try: + if isinstance(self.receive_stream, ByteReceiveStream): + chunk = await self.receive_stream.receive(remaining) + else: + chunk = await self.receive_stream.receive() + except EndOfStream as exc: + raise IncompleteRead from exc + + self._buffer.extend(chunk) + + async def receive_until(self, delimiter: bytes, max_bytes: int) -> bytes: + """ + Read from the stream until the delimiter is found or max_bytes have been read. + + :param delimiter: the marker to look for in the stream + :param max_bytes: maximum number of bytes that will be read before raising + :exc:`~anyio.DelimiterNotFound` + :return: the bytes read (not including the delimiter) + :raises ~anyio.IncompleteRead: if the stream was closed before the delimiter + was found + :raises ~anyio.DelimiterNotFound: if the delimiter is not found within the + bytes read up to the maximum allowed + + """ + delimiter_size = len(delimiter) + offset = 0 + while True: + # Check if the delimiter can be found in the current buffer + index = self._buffer.find(delimiter, offset) + if index >= 0: + found = self._buffer[:index] + del self._buffer[: index + len(delimiter) :] + return bytes(found) + + # Check if the buffer is already at or over the limit + if len(self._buffer) >= max_bytes: + raise DelimiterNotFound(max_bytes) + + # Read more data into the buffer from the socket + try: + data = await self.receive_stream.receive() + except EndOfStream as exc: + raise IncompleteRead from exc + + # Move the offset forward and add the new data to the buffer + offset = max(len(self._buffer) - delimiter_size + 1, 0) + self._buffer.extend(data) + + +class BufferedByteStream(BufferedByteReceiveStream, ByteStream): + """ + A full-duplex variant of :class:`BufferedByteReceiveStream`. All writes are passed + through to the wrapped stream as-is. + """ + + def __init__(self, stream: AnyByteStream): + """ + :param stream: the stream to be wrapped + + """ + super().__init__(stream) + self._stream = stream + + @override + async def send_eof(self) -> None: + await self._stream.send_eof() + + @override + async def send(self, item: bytes) -> None: + await self._stream.send(item) + + +class BufferedConnectable(ByteStreamConnectable): + """ + Wraps a byte stream connectable to produce :class:`BufferedByteStream` connections. + + Use this when you want the streams returned by :meth:`connect` to have the buffered + receive API (e.g. :meth:`~BufferedByteReceiveStream.receive_exactly` and + :meth:`~BufferedByteReceiveStream.receive_until`). + + :param connectable: the byte stream connectable to wrap + """ + + def __init__(self, connectable: AnyByteStreamConnectable): + """ + :param connectable: the connectable to wrap + + """ + self.connectable = connectable + + @override + async def connect(self) -> BufferedByteStream: + stream = await self.connectable.connect() + return BufferedByteStream(stream) diff --git a/server/venv/Lib/site-packages/anyio/streams/file.py b/server/venv/Lib/site-packages/anyio/streams/file.py new file mode 100644 index 0000000..79c3d50 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/streams/file.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +__all__ = ( + "FileReadStream", + "FileStreamAttribute", + "FileWriteStream", +) + +from collections.abc import Callable, Mapping +from io import SEEK_SET, UnsupportedOperation +from os import PathLike +from pathlib import Path +from typing import IO, Any + +from .. import ( + BrokenResourceError, + ClosedResourceError, + EndOfStream, + TypedAttributeSet, + to_thread, + typed_attribute, +) +from ..abc import ByteReceiveStream, ByteSendStream + + +class FileStreamAttribute(TypedAttributeSet): + #: the open file descriptor + file: IO[bytes] = typed_attribute() + #: the path of the file on the file system, if available (file must be a real file) + path: Path = typed_attribute() + #: the file number, if available (file must be a real file or a TTY) + fileno: int = typed_attribute() + + +class _BaseFileStream: + def __init__(self, file: IO[bytes]): + self._file = file + + async def aclose(self) -> None: + await to_thread.run_sync(self._file.close) + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + attributes: dict[Any, Callable[[], Any]] = { + FileStreamAttribute.file: lambda: self._file, + } + + if hasattr(self._file, "name"): + attributes[FileStreamAttribute.path] = lambda: Path(self._file.name) + + try: + self._file.fileno() + except UnsupportedOperation: + pass + else: + attributes[FileStreamAttribute.fileno] = lambda: self._file.fileno() + + return attributes + + +class FileReadStream(_BaseFileStream, ByteReceiveStream): + """ + A byte stream that reads from a file in the file system. + + :param file: a file that has been opened for reading in binary mode + + .. versionadded:: 3.0 + """ + + @classmethod + async def from_path(cls, path: str | PathLike[str]) -> FileReadStream: + """ + Create a file read stream by opening the given file. + + :param path: path of the file to read from + + """ + file = await to_thread.run_sync(Path(path).open, "rb") + return cls(file) + + async def receive(self, max_bytes: int = 65536) -> bytes: + try: + data = await to_thread.run_sync(self._file.read, max_bytes) + except ValueError: + raise ClosedResourceError from None + except OSError as exc: + raise BrokenResourceError from exc + + if data: + return data + else: + raise EndOfStream + + async def seek(self, position: int, whence: int = SEEK_SET) -> int: + """ + Seek the file to the given position. + + .. seealso:: :meth:`io.IOBase.seek` + + .. note:: Not all file descriptors are seekable. + + :param position: position to seek the file to + :param whence: controls how ``position`` is interpreted + :return: the new absolute position + :raises OSError: if the file is not seekable + + """ + return await to_thread.run_sync(self._file.seek, position, whence) + + async def tell(self) -> int: + """ + Return the current stream position. + + .. note:: Not all file descriptors are seekable. + + :return: the current absolute position + :raises OSError: if the file is not seekable + + """ + return await to_thread.run_sync(self._file.tell) + + +class FileWriteStream(_BaseFileStream, ByteSendStream): + """ + A byte stream that writes to a file in the file system. + + :param file: a file that has been opened for writing in binary mode + + .. versionadded:: 3.0 + """ + + @classmethod + async def from_path( + cls, path: str | PathLike[str], append: bool = False + ) -> FileWriteStream: + """ + Create a file write stream by opening the given file for writing. + + :param path: path of the file to write to + :param append: if ``True``, open the file for appending; if ``False``, any + existing file at the given path will be truncated + + """ + mode = "ab" if append else "wb" + file = await to_thread.run_sync(Path(path).open, mode) + return cls(file) + + async def send(self, item: bytes) -> None: + try: + await to_thread.run_sync(self._file.write, item) + except ValueError: + raise ClosedResourceError from None + except OSError as exc: + raise BrokenResourceError from exc diff --git a/server/venv/Lib/site-packages/anyio/streams/memory.py b/server/venv/Lib/site-packages/anyio/streams/memory.py new file mode 100644 index 0000000..a3fa0c3 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/streams/memory.py @@ -0,0 +1,325 @@ +from __future__ import annotations + +__all__ = ( + "MemoryObjectReceiveStream", + "MemoryObjectSendStream", + "MemoryObjectStreamStatistics", +) + +import warnings +from collections import OrderedDict, deque +from dataclasses import dataclass, field +from types import TracebackType +from typing import Generic, NamedTuple, TypeVar + +from .. import ( + BrokenResourceError, + ClosedResourceError, + EndOfStream, + WouldBlock, +) +from .._core._testing import TaskInfo, get_current_task +from ..abc import Event, ObjectReceiveStream, ObjectSendStream +from ..lowlevel import checkpoint + +T_Item = TypeVar("T_Item") +T_co = TypeVar("T_co", covariant=True) +T_contra = TypeVar("T_contra", contravariant=True) + + +class MemoryObjectStreamStatistics(NamedTuple): + current_buffer_used: int #: number of items stored in the buffer + #: maximum number of items that can be stored on this stream (or :data:`math.inf`) + max_buffer_size: float + open_send_streams: int #: number of unclosed clones of the send stream + open_receive_streams: int #: number of unclosed clones of the receive stream + #: number of tasks blocked on :meth:`MemoryObjectSendStream.send` + tasks_waiting_send: int + #: number of tasks blocked on :meth:`MemoryObjectReceiveStream.receive` + tasks_waiting_receive: int + + +@dataclass(eq=False) +class _MemoryObjectItemReceiver(Generic[T_Item]): + task_info: TaskInfo = field(init=False, default_factory=get_current_task) + item: T_Item = field(init=False) + + def __repr__(self) -> str: + # When item is not defined, we get following error with default __repr__: + # AttributeError: 'MemoryObjectItemReceiver' object has no attribute 'item' + item = getattr(self, "item", None) + return f"{self.__class__.__name__}(task_info={self.task_info}, item={item!r})" + + +@dataclass(eq=False) +class _MemoryObjectStreamState(Generic[T_Item]): + max_buffer_size: float = field() + buffer: deque[T_Item] = field(init=False, default_factory=deque) + open_send_channels: int = field(init=False, default=0) + open_receive_channels: int = field(init=False, default=0) + waiting_receivers: OrderedDict[Event, _MemoryObjectItemReceiver[T_Item]] = field( + init=False, default_factory=OrderedDict + ) + waiting_senders: OrderedDict[Event, T_Item] = field( + init=False, default_factory=OrderedDict + ) + + def statistics(self) -> MemoryObjectStreamStatistics: + return MemoryObjectStreamStatistics( + len(self.buffer), + self.max_buffer_size, + self.open_send_channels, + self.open_receive_channels, + len(self.waiting_senders), + len(self.waiting_receivers), + ) + + +@dataclass(eq=False) +class MemoryObjectReceiveStream(Generic[T_co], ObjectReceiveStream[T_co]): + _state: _MemoryObjectStreamState[T_co] + _closed: bool = field(init=False, default=False) + + def __post_init__(self) -> None: + self._state.open_receive_channels += 1 + + def receive_nowait(self) -> T_co: + """ + Receive the next item if it can be done without waiting. + + :return: the received item + :raises ~anyio.ClosedResourceError: if this send stream has been closed + :raises ~anyio.EndOfStream: if the buffer is empty and this stream has been + closed from the sending end + :raises ~anyio.WouldBlock: if there are no items in the buffer and no tasks + waiting to send + + """ + if self._closed: + raise ClosedResourceError + + if self._state.waiting_senders: + # Get the item from the next sender + send_event, item = self._state.waiting_senders.popitem(last=False) + self._state.buffer.append(item) + send_event.set() + + if self._state.buffer: + return self._state.buffer.popleft() + elif not self._state.open_send_channels: + raise EndOfStream + + raise WouldBlock + + async def receive(self) -> T_co: + await checkpoint() + try: + return self.receive_nowait() + except WouldBlock: + # Add ourselves in the queue + receive_event = Event() + receiver = _MemoryObjectItemReceiver[T_co]() + self._state.waiting_receivers[receive_event] = receiver + + try: + await receive_event.wait() + finally: + self._state.waiting_receivers.pop(receive_event, None) + + try: + return receiver.item + except AttributeError: + raise EndOfStream from None + + def clone(self) -> MemoryObjectReceiveStream[T_co]: + """ + Create a clone of this receive stream. + + Each clone can be closed separately. Only when all clones have been closed will + the receiving end of the memory stream be considered closed by the sending ends. + + :return: the cloned stream + + """ + if self._closed: + raise ClosedResourceError + + return MemoryObjectReceiveStream(_state=self._state) + + def close(self) -> None: + """ + Close the stream. + + This works the exact same way as :meth:`aclose`, but is provided as a special + case for the benefit of synchronous callbacks. + + """ + if not self._closed: + self._closed = True + self._state.open_receive_channels -= 1 + if self._state.open_receive_channels == 0: + send_events = list(self._state.waiting_senders.keys()) + for event in send_events: + event.set() + + async def aclose(self) -> None: + self.close() + + def statistics(self) -> MemoryObjectStreamStatistics: + """ + Return statistics about the current state of this stream. + + .. versionadded:: 3.0 + """ + return self._state.statistics() + + def __enter__(self) -> MemoryObjectReceiveStream[T_co]: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def __del__(self) -> None: + if not self._closed: + warnings.warn( + f"Unclosed <{self.__class__.__name__} at {id(self):x}>", + ResourceWarning, + stacklevel=1, + source=self, + ) + + +@dataclass(eq=False) +class MemoryObjectSendStream(Generic[T_contra], ObjectSendStream[T_contra]): + _state: _MemoryObjectStreamState[T_contra] + _closed: bool = field(init=False, default=False) + + def __post_init__(self) -> None: + self._state.open_send_channels += 1 + + def send_nowait(self, item: T_contra) -> None: + """ + Send an item immediately if it can be done without waiting. + + :param item: the item to send + :raises ~anyio.ClosedResourceError: if this send stream has been closed + :raises ~anyio.BrokenResourceError: if the stream has been closed from the + receiving end + :raises ~anyio.WouldBlock: if the buffer is full and there are no tasks waiting + to receive + + """ + if self._closed: + raise ClosedResourceError + if not self._state.open_receive_channels: + raise BrokenResourceError + + while self._state.waiting_receivers: + receive_event, receiver = self._state.waiting_receivers.popitem(last=False) + if not receiver.task_info.has_pending_cancellation(): + receiver.item = item + receive_event.set() + return + + if len(self._state.buffer) < self._state.max_buffer_size: + self._state.buffer.append(item) + else: + raise WouldBlock + + async def send(self, item: T_contra) -> None: + """ + Send an item to the stream. + + If the buffer is full, this method blocks until there is again room in the + buffer or the item can be sent directly to a receiver. + + :param item: the item to send + :raises ~anyio.ClosedResourceError: if this send stream has been closed + :raises ~anyio.BrokenResourceError: if the stream has been closed from the + receiving end + + """ + await checkpoint() + try: + self.send_nowait(item) + except WouldBlock: + # Wait until there's someone on the receiving end + send_event = Event() + self._state.waiting_senders[send_event] = item + try: + await send_event.wait() + except BaseException: + self._state.waiting_senders.pop(send_event, None) + raise + + if send_event in self._state.waiting_senders: + del self._state.waiting_senders[send_event] + raise BrokenResourceError from None + + def clone(self) -> MemoryObjectSendStream[T_contra]: + """ + Create a clone of this send stream. + + Each clone can be closed separately. Only when all clones have been closed will + the sending end of the memory stream be considered closed by the receiving ends. + + :return: the cloned stream + + """ + if self._closed: + raise ClosedResourceError + + return MemoryObjectSendStream(_state=self._state) + + def close(self) -> None: + """ + Close the stream. + + This works the exact same way as :meth:`aclose`, but is provided as a special + case for the benefit of synchronous callbacks. + + """ + if not self._closed: + self._closed = True + self._state.open_send_channels -= 1 + if self._state.open_send_channels == 0: + receive_events = list(self._state.waiting_receivers.keys()) + self._state.waiting_receivers.clear() + for event in receive_events: + event.set() + + async def aclose(self) -> None: + self.close() + + def statistics(self) -> MemoryObjectStreamStatistics: + """ + Return statistics about the current state of this stream. + + .. versionadded:: 3.0 + """ + return self._state.statistics() + + def __enter__(self) -> MemoryObjectSendStream[T_contra]: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() + + def __del__(self) -> None: + if not self._closed: + warnings.warn( + f"Unclosed <{self.__class__.__name__} at {id(self):x}>", + ResourceWarning, + stacklevel=1, + source=self, + ) diff --git a/server/venv/Lib/site-packages/anyio/streams/stapled.py b/server/venv/Lib/site-packages/anyio/streams/stapled.py new file mode 100644 index 0000000..9248b68 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/streams/stapled.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +__all__ = ( + "MultiListener", + "StapledByteStream", + "StapledObjectStream", +) + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +from ..abc import ( + ByteReceiveStream, + ByteSendStream, + ByteStream, + Listener, + ObjectReceiveStream, + ObjectSendStream, + ObjectStream, + TaskGroup, +) + +T_Item = TypeVar("T_Item") +T_Stream = TypeVar("T_Stream") + + +@dataclass(eq=False) +class StapledByteStream(ByteStream): + """ + Combines two byte streams into a single, bidirectional byte stream. + + Extra attributes will be provided from both streams, with the receive stream + providing the values in case of a conflict. + + :param ByteSendStream send_stream: the sending byte stream + :param ByteReceiveStream receive_stream: the receiving byte stream + """ + + send_stream: ByteSendStream + receive_stream: ByteReceiveStream + + async def receive(self, max_bytes: int = 65536) -> bytes: + return await self.receive_stream.receive(max_bytes) + + async def send(self, item: bytes) -> None: + await self.send_stream.send(item) + + async def send_eof(self) -> None: + await self.send_stream.aclose() + + async def aclose(self) -> None: + await self.send_stream.aclose() + await self.receive_stream.aclose() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return { + **self.send_stream.extra_attributes, + **self.receive_stream.extra_attributes, + } + + +@dataclass(eq=False) +class StapledObjectStream(Generic[T_Item], ObjectStream[T_Item]): + """ + Combines two object streams into a single, bidirectional object stream. + + Extra attributes will be provided from both streams, with the receive stream + providing the values in case of a conflict. + + :param ObjectSendStream send_stream: the sending object stream + :param ObjectReceiveStream receive_stream: the receiving object stream + """ + + send_stream: ObjectSendStream[T_Item] + receive_stream: ObjectReceiveStream[T_Item] + + async def receive(self) -> T_Item: + return await self.receive_stream.receive() + + async def send(self, item: T_Item) -> None: + await self.send_stream.send(item) + + async def send_eof(self) -> None: + await self.send_stream.aclose() + + async def aclose(self) -> None: + await self.send_stream.aclose() + await self.receive_stream.aclose() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return { + **self.send_stream.extra_attributes, + **self.receive_stream.extra_attributes, + } + + +@dataclass(eq=False) +class MultiListener(Generic[T_Stream], Listener[T_Stream]): + """ + Combines multiple listeners into one, serving connections from all of them at once. + + Any MultiListeners in the given collection of listeners will have their listeners + moved into this one. + + Extra attributes are provided from each listener, with each successive listener + overriding any conflicting attributes from the previous one. + + :param listeners: listeners to serve + :type listeners: Sequence[Listener[T_Stream]] + """ + + listeners: Sequence[Listener[T_Stream]] + + def __post_init__(self) -> None: + listeners: list[Listener[T_Stream]] = [] + for listener in self.listeners: + if isinstance(listener, MultiListener): + listeners.extend(listener.listeners) + del listener.listeners[:] # type: ignore[attr-defined] + else: + listeners.append(listener) + + self.listeners = listeners + + async def serve( + self, handler: Callable[[T_Stream], Any], task_group: TaskGroup | None = None + ) -> None: + from .. import create_task_group + + async with create_task_group() as tg: + for listener in self.listeners: + tg.start_soon(listener.serve, handler, task_group) + + async def aclose(self) -> None: + for listener in self.listeners: + await listener.aclose() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + attributes: dict = {} + for listener in self.listeners: + attributes.update(listener.extra_attributes) + + return attributes diff --git a/server/venv/Lib/site-packages/anyio/streams/text.py b/server/venv/Lib/site-packages/anyio/streams/text.py new file mode 100644 index 0000000..296cd25 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/streams/text.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +__all__ = ( + "TextConnectable", + "TextReceiveStream", + "TextSendStream", + "TextStream", +) + +import codecs +import sys +from collections.abc import Callable, Mapping +from dataclasses import InitVar, dataclass, field +from typing import Any + +from ..abc import ( + AnyByteReceiveStream, + AnyByteSendStream, + AnyByteStream, + AnyByteStreamConnectable, + ObjectReceiveStream, + ObjectSendStream, + ObjectStream, + ObjectStreamConnectable, +) + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + + +@dataclass(eq=False) +class TextReceiveStream(ObjectReceiveStream[str]): + """ + Stream wrapper that decodes bytes to strings using the given encoding. + + Decoding is done using :class:`~codecs.IncrementalDecoder` which returns any + completely received unicode characters as soon as they come in. + + :param transport_stream: any bytes-based receive stream + :param encoding: character encoding to use for decoding bytes to strings (defaults + to ``utf-8``) + :param errors: handling scheme for decoding errors (defaults to ``strict``; see the + `codecs module documentation`_ for a comprehensive list of options) + + .. _codecs module documentation: + https://docs.python.org/3/library/codecs.html#codec-objects + """ + + transport_stream: AnyByteReceiveStream + encoding: InitVar[str] = "utf-8" + errors: InitVar[str] = "strict" + _decoder: codecs.IncrementalDecoder = field(init=False) + + def __post_init__(self, encoding: str, errors: str) -> None: + decoder_class = codecs.getincrementaldecoder(encoding) + self._decoder = decoder_class(errors=errors) + + async def receive(self) -> str: + while True: + chunk = await self.transport_stream.receive() + decoded = self._decoder.decode(chunk) + if decoded: + return decoded + + async def aclose(self) -> None: + await self.transport_stream.aclose() + self._decoder.reset() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return self.transport_stream.extra_attributes + + +@dataclass(eq=False) +class TextSendStream(ObjectSendStream[str]): + """ + Sends strings to the wrapped stream as bytes using the given encoding. + + :param AnyByteSendStream transport_stream: any bytes-based send stream + :param str encoding: character encoding to use for encoding strings to bytes + (defaults to ``utf-8``) + :param str errors: handling scheme for encoding errors (defaults to ``strict``; see + the `codecs module documentation`_ for a comprehensive list of options) + + .. _codecs module documentation: + https://docs.python.org/3/library/codecs.html#codec-objects + """ + + transport_stream: AnyByteSendStream + encoding: InitVar[str] = "utf-8" + errors: str = "strict" + _encoder: Callable[..., tuple[bytes, int]] = field(init=False) + + def __post_init__(self, encoding: str) -> None: + self._encoder = codecs.getencoder(encoding) + + async def send(self, item: str) -> None: + encoded = self._encoder(item, self.errors)[0] + await self.transport_stream.send(encoded) + + async def aclose(self) -> None: + await self.transport_stream.aclose() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return self.transport_stream.extra_attributes + + +@dataclass(eq=False) +class TextStream(ObjectStream[str]): + """ + A bidirectional stream that decodes bytes to strings on receive and encodes strings + to bytes on send. + + Extra attributes will be provided from both streams, with the receive stream + providing the values in case of a conflict. + + :param AnyByteStream transport_stream: any bytes-based stream + :param str encoding: character encoding to use for encoding/decoding strings to/from + bytes (defaults to ``utf-8``) + :param str errors: handling scheme for encoding errors (defaults to ``strict``; see + the `codecs module documentation`_ for a comprehensive list of options) + + .. _codecs module documentation: + https://docs.python.org/3/library/codecs.html#codec-objects + """ + + transport_stream: AnyByteStream + encoding: InitVar[str] = "utf-8" + errors: InitVar[str] = "strict" + _receive_stream: TextReceiveStream = field(init=False) + _send_stream: TextSendStream = field(init=False) + + def __post_init__(self, encoding: str, errors: str) -> None: + self._receive_stream = TextReceiveStream( + self.transport_stream, encoding=encoding, errors=errors + ) + self._send_stream = TextSendStream( + self.transport_stream, encoding=encoding, errors=errors + ) + + async def receive(self) -> str: + return await self._receive_stream.receive() + + async def send(self, item: str) -> None: + await self._send_stream.send(item) + + async def send_eof(self) -> None: + await self.transport_stream.send_eof() + + async def aclose(self) -> None: + await self._send_stream.aclose() + await self._receive_stream.aclose() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return { + **self._send_stream.extra_attributes, + **self._receive_stream.extra_attributes, + } + + +class TextConnectable(ObjectStreamConnectable[str]): + def __init__(self, connectable: AnyByteStreamConnectable): + """ + :param connectable: the bytestream endpoint to wrap + + """ + self.connectable = connectable + + @override + async def connect(self) -> TextStream: + stream = await self.connectable.connect() + return TextStream(stream) diff --git a/server/venv/Lib/site-packages/anyio/streams/tls.py b/server/venv/Lib/site-packages/anyio/streams/tls.py new file mode 100644 index 0000000..e2a7ca5 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/streams/tls.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +__all__ = ( + "TLSAttribute", + "TLSConnectable", + "TLSListener", + "TLSStream", +) + +import logging +import re +import ssl +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from functools import wraps +from ssl import SSLContext +from typing import Any, TypeAlias, TypeVar + +from .. import ( + BrokenResourceError, + EndOfStream, + aclose_forcefully, + get_cancelled_exc_class, + to_thread, +) +from .._core._typedattr import TypedAttributeSet, typed_attribute +from ..abc import ( + AnyByteStream, + AnyByteStreamConnectable, + ByteStream, + ByteStreamConnectable, + Listener, + TaskGroup, +) + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +T_Retval = TypeVar("T_Retval") +PosArgsT = TypeVarTuple("PosArgsT") +_PCTRTT: TypeAlias = tuple[tuple[str, str], ...] +_PCTRTTT: TypeAlias = tuple[_PCTRTT, ...] + + +class TLSAttribute(TypedAttributeSet): + """Contains Transport Layer Security related attributes.""" + + #: the selected ALPN protocol + alpn_protocol: str | None = typed_attribute() + #: the channel binding for type ``tls-unique`` + channel_binding_tls_unique: bytes = typed_attribute() + #: the selected cipher + cipher: tuple[str, str, int] = typed_attribute() + #: the peer certificate in dictionary form (see :meth:`ssl.SSLSocket.getpeercert` + # for more information) + peer_certificate: None | (dict[str, str | _PCTRTTT | _PCTRTT]) = typed_attribute() + #: the peer certificate in binary form + peer_certificate_binary: bytes | None = typed_attribute() + #: ``True`` if this is the server side of the connection + server_side: bool = typed_attribute() + #: ciphers shared by the client during the TLS handshake (``None`` if this is the + #: client side) + shared_ciphers: list[tuple[str, str, int]] | None = typed_attribute() + #: the :class:`~ssl.SSLObject` used for encryption + ssl_object: ssl.SSLObject = typed_attribute() + #: ``True`` if this stream does (and expects) a closing TLS handshake when the + #: stream is being closed + standard_compatible: bool = typed_attribute() + #: the TLS protocol version (e.g. ``TLSv1.2``) + tls_version: str = typed_attribute() + + +@dataclass(eq=False) +class TLSStream(ByteStream): + """ + A stream wrapper that encrypts all sent data and decrypts received data. + + This class has no public initializer; use :meth:`wrap` instead. + All extra attributes from :class:`~TLSAttribute` are supported. + + :var AnyByteStream transport_stream: the wrapped stream + + """ + + transport_stream: AnyByteStream + standard_compatible: bool + _ssl_object: ssl.SSLObject + _read_bio: ssl.MemoryBIO + _write_bio: ssl.MemoryBIO + + @classmethod + async def wrap( + cls, + transport_stream: AnyByteStream, + *, + server_side: bool | None = None, + hostname: str | None = None, + ssl_context: ssl.SSLContext | None = None, + standard_compatible: bool = True, + ) -> TLSStream: + """ + Wrap an existing stream with Transport Layer Security. + + This performs a TLS handshake with the peer. + + :param transport_stream: a bytes-transporting stream to wrap + :param server_side: ``True`` if this is the server side of the connection, + ``False`` if this is the client side (if omitted, will be set to ``False`` + if ``hostname`` has been provided, ``False`` otherwise). Used only to create + a default context when an explicit context has not been provided. + :param hostname: host name of the peer (if host name checking is desired) + :param ssl_context: the SSLContext object to use (if not provided, a secure + default will be created) + :param standard_compatible: if ``False``, skip the closing handshake when + closing the connection, and don't raise an exception if the peer does the + same + :raises ~ssl.SSLError: if the TLS handshake fails + + """ + if server_side is None: + server_side = not hostname + + if not ssl_context: + purpose = ( + ssl.Purpose.CLIENT_AUTH if server_side else ssl.Purpose.SERVER_AUTH + ) + ssl_context = ssl.create_default_context(purpose) + + # Re-enable detection of unexpected EOFs if it was disabled by Python + if hasattr(ssl, "OP_IGNORE_UNEXPECTED_EOF"): + ssl_context.options &= ~ssl.OP_IGNORE_UNEXPECTED_EOF + + bio_in = ssl.MemoryBIO() + bio_out = ssl.MemoryBIO() + + # External SSLContext implementations may do blocking I/O in wrap_bio(), + # but the standard library implementation won't + if type(ssl_context) is ssl.SSLContext: + ssl_object = ssl_context.wrap_bio( + bio_in, bio_out, server_side=server_side, server_hostname=hostname + ) + else: + ssl_object = await to_thread.run_sync( + ssl_context.wrap_bio, + bio_in, + bio_out, + server_side, + hostname, + None, + ) + + wrapper = cls( + transport_stream=transport_stream, + standard_compatible=standard_compatible, + _ssl_object=ssl_object, + _read_bio=bio_in, + _write_bio=bio_out, + ) + await wrapper._call_sslobject_method(ssl_object.do_handshake) + return wrapper + + async def _call_sslobject_method( + self, func: Callable[[Unpack[PosArgsT]], T_Retval], *args: Unpack[PosArgsT] + ) -> T_Retval: + while True: + try: + result = func(*args) + except ssl.SSLWantReadError: + try: + # Flush any pending writes first + if self._write_bio.pending: + await self.transport_stream.send(self._write_bio.read()) + + data = await self.transport_stream.receive() + except EndOfStream: + self._read_bio.write_eof() + except OSError as exc: + self._read_bio.write_eof() + self._write_bio.write_eof() + raise BrokenResourceError from exc + else: + self._read_bio.write(data) + except ssl.SSLWantWriteError: + await self.transport_stream.send(self._write_bio.read()) + except ssl.SSLSyscallError as exc: + self._read_bio.write_eof() + self._write_bio.write_eof() + raise BrokenResourceError from exc + except ssl.SSLError as exc: + self._read_bio.write_eof() + self._write_bio.write_eof() + if isinstance(exc, ssl.SSLEOFError) or ( + exc.strerror and "UNEXPECTED_EOF_WHILE_READING" in exc.strerror + ): + if self.standard_compatible: + raise BrokenResourceError from exc + else: + raise EndOfStream from None + + raise + else: + # Flush any pending writes first + if self._write_bio.pending: + await self.transport_stream.send(self._write_bio.read()) + + return result + + async def unwrap(self) -> tuple[AnyByteStream, bytes]: + """ + Does the TLS closing handshake. + + :return: a tuple of (wrapped byte stream, bytes left in the read buffer) + + """ + await self._call_sslobject_method(self._ssl_object.unwrap) + self._read_bio.write_eof() + self._write_bio.write_eof() + return self.transport_stream, self._read_bio.read() + + async def aclose(self) -> None: + if self.standard_compatible: + try: + await self.unwrap() + except BaseException: + await aclose_forcefully(self.transport_stream) + raise + + await self.transport_stream.aclose() + + async def receive(self, max_bytes: int = 65536) -> bytes: + data = await self._call_sslobject_method(self._ssl_object.read, max_bytes) + if not data: + raise EndOfStream + + return data + + async def send(self, item: bytes) -> None: + await self._call_sslobject_method(self._ssl_object.write, item) + + async def send_eof(self) -> None: + tls_version = self.extra(TLSAttribute.tls_version) + match = re.match(r"TLSv(\d+)(?:\.(\d+))?", tls_version) + if match: + major, minor = int(match.group(1)), int(match.group(2) or 0) + if (major, minor) < (1, 3): + raise NotImplementedError( + f"send_eof() requires at least TLSv1.3; current " + f"session uses {tls_version}" + ) + + raise NotImplementedError( + "send_eof() has not yet been implemented for TLS streams" + ) + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return { + **self.transport_stream.extra_attributes, + TLSAttribute.alpn_protocol: self._ssl_object.selected_alpn_protocol, + TLSAttribute.channel_binding_tls_unique: ( + self._ssl_object.get_channel_binding + ), + TLSAttribute.cipher: self._ssl_object.cipher, + TLSAttribute.peer_certificate: lambda: self._ssl_object.getpeercert(False), + TLSAttribute.peer_certificate_binary: lambda: self._ssl_object.getpeercert( + True + ), + TLSAttribute.server_side: lambda: self._ssl_object.server_side, + TLSAttribute.shared_ciphers: lambda: ( + self._ssl_object.shared_ciphers() + if self._ssl_object.server_side + else None + ), + TLSAttribute.standard_compatible: lambda: self.standard_compatible, + TLSAttribute.ssl_object: lambda: self._ssl_object, + TLSAttribute.tls_version: self._ssl_object.version, + } + + +@dataclass(eq=False) +class TLSListener(Listener[TLSStream]): + """ + A convenience listener that wraps another listener and auto-negotiates a TLS session + on every accepted connection. + + If the TLS handshake times out or raises an exception, + :meth:`handle_handshake_error` is called to do whatever post-mortem processing is + deemed necessary. + + Supports only the :attr:`~TLSAttribute.standard_compatible` extra attribute. + + :param Listener listener: the listener to wrap + :param ssl_context: the SSL context object + :param standard_compatible: a flag passed through to :meth:`TLSStream.wrap` + :param handshake_timeout: time limit for the TLS handshake + (passed to :func:`~anyio.fail_after`) + """ + + listener: Listener[Any] + ssl_context: ssl.SSLContext + standard_compatible: bool = True + handshake_timeout: float = 30 + + @staticmethod + async def handle_handshake_error(exc: BaseException, stream: AnyByteStream) -> None: + """ + Handle an exception raised during the TLS handshake. + + This method does 3 things: + + #. Forcefully closes the original stream + #. Logs the exception (unless it was a cancellation exception) using the + ``anyio.streams.tls`` logger + #. Reraises the exception if it was a base exception or a cancellation exception + + :param exc: the exception + :param stream: the original stream + + """ + await aclose_forcefully(stream) + + # Log all except cancellation exceptions + if not isinstance(exc, get_cancelled_exc_class()): + # CPython (as of 3.11.5) returns incorrect `sys.exc_info()` here when using + # any asyncio implementation, so we explicitly pass the exception to log + # (https://github.com/python/cpython/issues/108668). Trio does not have this + # issue because it works around the CPython bug. + logging.getLogger(__name__).exception( + "Error during TLS handshake", exc_info=exc + ) + + # Only reraise base exceptions and cancellation exceptions + if not isinstance(exc, Exception) or isinstance(exc, get_cancelled_exc_class()): + raise + + async def serve( + self, + handler: Callable[[TLSStream], Any], + task_group: TaskGroup | None = None, + ) -> None: + @wraps(handler) + async def handler_wrapper(stream: AnyByteStream) -> None: + from .. import fail_after + + try: + with fail_after(self.handshake_timeout): + wrapped_stream = await TLSStream.wrap( + stream, + ssl_context=self.ssl_context, + standard_compatible=self.standard_compatible, + ) + except BaseException as exc: + await self.handle_handshake_error(exc, stream) + else: + await handler(wrapped_stream) + + await self.listener.serve(handler_wrapper, task_group) + + async def aclose(self) -> None: + await self.listener.aclose() + + @property + def extra_attributes(self) -> Mapping[Any, Callable[[], Any]]: + return { + TLSAttribute.standard_compatible: lambda: self.standard_compatible, + } + + +class TLSConnectable(ByteStreamConnectable): + """ + Wraps another connectable and does TLS negotiation after a successful connection. + + :param connectable: the connectable to wrap + :param hostname: host name of the server (if host name checking is desired) + :param ssl_context: the SSLContext object to use (if not provided, a secure default + will be created) + :param standard_compatible: if ``False``, skip the closing handshake when closing + the connection, and don't raise an exception if the server does the same + """ + + def __init__( + self, + connectable: AnyByteStreamConnectable, + *, + hostname: str | None = None, + ssl_context: ssl.SSLContext | None = None, + standard_compatible: bool = True, + ) -> None: + self.connectable = connectable + self.ssl_context: SSLContext = ssl_context or ssl.create_default_context( + ssl.Purpose.SERVER_AUTH + ) + if not isinstance(self.ssl_context, ssl.SSLContext): + raise TypeError( + "ssl_context must be an instance of ssl.SSLContext, not " + f"{type(self.ssl_context).__name__}" + ) + self.hostname = hostname + self.standard_compatible = standard_compatible + + @override + async def connect(self) -> TLSStream: + stream = await self.connectable.connect() + try: + return await TLSStream.wrap( + stream, + hostname=self.hostname, + ssl_context=self.ssl_context, + standard_compatible=self.standard_compatible, + ) + except BaseException: + await aclose_forcefully(stream) + raise diff --git a/server/venv/Lib/site-packages/anyio/to_interpreter.py b/server/venv/Lib/site-packages/anyio/to_interpreter.py new file mode 100644 index 0000000..694dbe7 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/to_interpreter.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +__all__ = ( + "run_sync", + "current_default_interpreter_limiter", +) + +import atexit +import os +import sys +from collections import deque +from collections.abc import Callable +from typing import Any, Final, TypeVar + +from . import current_time, to_thread +from ._core._exceptions import BrokenWorkerInterpreter +from ._core._synchronization import CapacityLimiter +from .lowlevel import RunVar + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +if sys.version_info >= (3, 14): + from concurrent.interpreters import ExecutionFailed, create + + def _interp_call( + func: Callable[..., Any], args: tuple[Any, ...] + ) -> tuple[Any, bool]: + try: + retval = func(*args) + except BaseException as exc: + return exc, True + else: + return retval, False + + class _Worker: + last_used: float = 0 + + def __init__(self) -> None: + self._interpreter = create() + + def destroy(self) -> None: + self._interpreter.close() + + def call( + self, + func: Callable[..., T_Retval], + args: tuple[Any, ...], + ) -> T_Retval: + try: + res, is_exception = self._interpreter.call(_interp_call, func, args) + except ExecutionFailed as exc: + raise BrokenWorkerInterpreter(exc.excinfo) from exc + + if is_exception: + raise res + + return res +elif sys.version_info >= (3, 13): + import _interpqueues + import _interpreters + + UNBOUND: Final = 2 # I have no clue how this works, but it was used in the stdlib + FMT_UNPICKLED: Final = 0 + FMT_PICKLED: Final = 1 + QUEUE_PICKLE_ARGS: Final = (FMT_PICKLED, UNBOUND) + QUEUE_UNPICKLE_ARGS: Final = (FMT_UNPICKLED, UNBOUND) + + _run_func = compile( + """ +import _interpqueues +from _interpreters import NotShareableError +from pickle import loads, dumps, HIGHEST_PROTOCOL + +QUEUE_PICKLE_ARGS = (1, 2) +QUEUE_UNPICKLE_ARGS = (0, 2) + +item = _interpqueues.get(queue_id)[0] +try: + func, args = loads(item) + retval = func(*args) +except BaseException as exc: + is_exception = True + retval = exc +else: + is_exception = False + +try: + _interpqueues.put(queue_id, (retval, is_exception), *QUEUE_UNPICKLE_ARGS) +except NotShareableError: + retval = dumps(retval, HIGHEST_PROTOCOL) + _interpqueues.put(queue_id, (retval, is_exception), *QUEUE_PICKLE_ARGS) + """, + "", + "exec", + ) + + class _Worker: + last_used: float = 0 + + def __init__(self) -> None: + self._interpreter_id = _interpreters.create() + self._queue_id = _interpqueues.create(1, *QUEUE_UNPICKLE_ARGS) + _interpreters.set___main___attrs( + self._interpreter_id, {"queue_id": self._queue_id} + ) + + def destroy(self) -> None: + _interpqueues.destroy(self._queue_id) + _interpreters.destroy(self._interpreter_id) + + def call( + self, + func: Callable[..., T_Retval], + args: tuple[Any, ...], + ) -> T_Retval: + import pickle + + item = pickle.dumps((func, args), pickle.HIGHEST_PROTOCOL) + _interpqueues.put(self._queue_id, item, *QUEUE_PICKLE_ARGS) + exc_info = _interpreters.exec(self._interpreter_id, _run_func) + if exc_info: + raise BrokenWorkerInterpreter(exc_info) + + res = _interpqueues.get(self._queue_id) + (res, is_exception), fmt = res[:2] + if fmt == FMT_PICKLED: + res = pickle.loads(res) + + if is_exception: + raise res + + return res +else: + + class _Worker: + last_used: float = 0 + + def __init__(self) -> None: + raise RuntimeError("subinterpreters require at least Python 3.13") + + def call( + self, + func: Callable[..., T_Retval], + args: tuple[Any, ...], + ) -> T_Retval: + raise NotImplementedError + + def destroy(self) -> None: + pass + + +DEFAULT_CPU_COUNT: Final = 8 # this is just an arbitrarily selected value +MAX_WORKER_IDLE_TIME = ( + 30 # seconds a subinterpreter can be idle before becoming eligible for pruning +) + +T_Retval = TypeVar("T_Retval") +PosArgsT = TypeVarTuple("PosArgsT") + +_idle_workers = RunVar[deque[_Worker]]("_available_workers") +_default_interpreter_limiter = RunVar[CapacityLimiter]("_default_interpreter_limiter") + + +def _stop_workers(workers: deque[_Worker]) -> None: + for worker in workers: + worker.destroy() + + workers.clear() + + +async def run_sync( + func: Callable[[Unpack[PosArgsT]], T_Retval], + *args: Unpack[PosArgsT], + limiter: CapacityLimiter | None = None, +) -> T_Retval: + """ + Call the given function with the given arguments in a subinterpreter. + + .. warning:: On Python 3.13, the :mod:`concurrent.interpreters` module was not yet + available, so the code path for that Python version relies on an undocumented, + private API. As such, it is recommended to not rely on this function for anything + mission-critical on Python 3.13. + + :param func: a callable + :param args: the positional arguments for the callable + :param limiter: capacity limiter to use to limit the total number of subinterpreters + running (if omitted, the default limiter is used) + :return: the result of the call + :raises BrokenWorkerInterpreter: if there's an internal error in a subinterpreter + + """ + if limiter is None: + limiter = current_default_interpreter_limiter() + + try: + idle_workers = _idle_workers.get() + except LookupError: + idle_workers = deque() + _idle_workers.set(idle_workers) + atexit.register(_stop_workers, idle_workers) + + async with limiter: + try: + worker = idle_workers.pop() + except IndexError: + worker = _Worker() + + try: + return await to_thread.run_sync( + worker.call, + func, + args, + limiter=limiter, + ) + finally: + # Prune workers that have been idle for too long + now = current_time() + while idle_workers: + if now - idle_workers[0].last_used <= MAX_WORKER_IDLE_TIME: + break + + await to_thread.run_sync(idle_workers.popleft().destroy, limiter=limiter) + + worker.last_used = current_time() + idle_workers.append(worker) + + +def current_default_interpreter_limiter() -> CapacityLimiter: + """ + Return the capacity limiter used by default to limit the number of concurrently + running subinterpreters. + + Defaults to the number of CPU cores. + + :return: a capacity limiter object + + """ + try: + return _default_interpreter_limiter.get() + except LookupError: + limiter = CapacityLimiter(os.cpu_count() or DEFAULT_CPU_COUNT) + _default_interpreter_limiter.set(limiter) + return limiter diff --git a/server/venv/Lib/site-packages/anyio/to_process.py b/server/venv/Lib/site-packages/anyio/to_process.py new file mode 100644 index 0000000..fd65b18 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/to_process.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +__all__ = ( + "current_default_process_limiter", + "process_worker", + "run_sync", +) + +import os +import pickle +import runpy +import subprocess +import sys +from collections import deque +from collections.abc import Callable +from types import ModuleType +from typing import TypeVar, cast + +from ._core._eventloop import current_time, get_async_backend, get_cancelled_exc_class +from ._core._exceptions import BrokenWorkerProcess +from ._core._subprocesses import open_process +from ._core._synchronization import CapacityLimiter +from ._core._tasks import CancelScope, fail_after +from .abc import ByteReceiveStream, ByteSendStream, Process +from .lowlevel import RunVar, checkpoint_if_cancelled +from .streams.buffered import BufferedByteReceiveStream + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +WORKER_MAX_IDLE_TIME = 300 # 5 minutes + +T_Retval = TypeVar("T_Retval") +PosArgsT = TypeVarTuple("PosArgsT") + +_process_pool_workers: RunVar[set[Process]] = RunVar("_process_pool_workers") +_process_pool_idle_workers: RunVar[deque[tuple[Process, float]]] = RunVar( + "_process_pool_idle_workers" +) +_default_process_limiter: RunVar[CapacityLimiter] = RunVar("_default_process_limiter") + + +async def run_sync( # type: ignore[return] + func: Callable[[Unpack[PosArgsT]], T_Retval], + *args: Unpack[PosArgsT], + cancellable: bool = False, + limiter: CapacityLimiter | None = None, +) -> T_Retval: + """ + Call the given function with the given arguments in a worker process. + + If the ``cancellable`` option is enabled and the task waiting for its completion is + cancelled, the worker process running it will be abruptly terminated using SIGKILL + (or ``terminateProcess()`` on Windows). + + :param func: a callable + :param args: positional arguments for the callable + :param cancellable: ``True`` to allow cancellation of the operation while it's + running + :param limiter: capacity limiter to use to limit the total amount of processes + running (if omitted, the default limiter is used) + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + :return: an awaitable that yields the return value of the function. + + """ + + async def send_raw_command(pickled_cmd: bytes) -> object: + try: + await stdin.send(pickled_cmd) + response = await buffered.receive_until(b"\n", 50) + status, length = response.split(b" ") + if status not in (b"RETURN", b"EXCEPTION"): + raise RuntimeError( + f"Worker process returned unexpected response: {response!r}" + ) + + pickled_response = await buffered.receive_exactly(int(length)) + except BaseException as exc: + workers.discard(process) + try: + process.kill() + with CancelScope(shield=True): + await process.aclose() + except ProcessLookupError: + pass + + if isinstance(exc, get_cancelled_exc_class()): + raise + else: + raise BrokenWorkerProcess from exc + + retval = pickle.loads(pickled_response) + if status == b"EXCEPTION": + assert isinstance(retval, BaseException) + raise retval + else: + return retval + + # First pickle the request before trying to reserve a worker process + await checkpoint_if_cancelled() + request = pickle.dumps(("run", func, args), protocol=pickle.HIGHEST_PROTOCOL) + + # If this is the first run in this event loop thread, set up the necessary variables + try: + workers = _process_pool_workers.get() + idle_workers = _process_pool_idle_workers.get() + except LookupError: + workers = set() + idle_workers = deque() + _process_pool_workers.set(workers) + _process_pool_idle_workers.set(idle_workers) + get_async_backend().setup_process_pool_exit_at_shutdown(workers) + + async with limiter or current_default_process_limiter(): + # Pop processes from the pool (starting from the most recently used) until we + # find one that hasn't exited yet + process: Process + while idle_workers: + process, idle_since = idle_workers.pop() + if process.returncode is None: + stdin = cast(ByteSendStream, process.stdin) + buffered = BufferedByteReceiveStream( + cast(ByteReceiveStream, process.stdout) + ) + + # Prune any other workers that have been idle for WORKER_MAX_IDLE_TIME + # seconds or longer + now = current_time() + killed_processes: list[Process] = [] + while idle_workers: + if now - idle_workers[0][1] < WORKER_MAX_IDLE_TIME: + break + + process_to_kill, idle_since = idle_workers.popleft() + process_to_kill.kill() + workers.remove(process_to_kill) + killed_processes.append(process_to_kill) + + with CancelScope(shield=True): + for killed_process in killed_processes: + await killed_process.aclose() + + break + + workers.remove(process) + else: + command = [sys.executable, "-u", "-m", __name__] + process = await open_process( + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE + ) + try: + stdin = cast(ByteSendStream, process.stdin) + buffered = BufferedByteReceiveStream( + cast(ByteReceiveStream, process.stdout) + ) + with fail_after(20): + message = await buffered.receive(6) + + if message != b"READY\n": + raise BrokenWorkerProcess( + f"Worker process returned unexpected response: {message!r}" + ) + + main_module_path = getattr(sys.modules["__main__"], "__file__", None) + pickled = pickle.dumps( + ("init", sys.path, main_module_path), + protocol=pickle.HIGHEST_PROTOCOL, + ) + await send_raw_command(pickled) + except (BrokenWorkerProcess, get_cancelled_exc_class()): + raise + except BaseException as exc: + process.kill() + raise BrokenWorkerProcess( + "Error during worker process initialization" + ) from exc + + workers.add(process) + + with CancelScope(shield=not cancellable): + try: + return cast(T_Retval, await send_raw_command(request)) + finally: + if process in workers: + idle_workers.append((process, current_time())) + + +def current_default_process_limiter() -> CapacityLimiter: + """ + Return the capacity limiter that is used by default to limit the number of worker + processes. + + :return: a capacity limiter object + + """ + try: + return _default_process_limiter.get() + except LookupError: + limiter = CapacityLimiter(os.cpu_count() or 2) + _default_process_limiter.set(limiter) + return limiter + + +def process_worker() -> None: + # Redirect standard streams to os.devnull so that user code won't interfere with the + # parent-worker communication + stdin = sys.stdin + stdout = sys.stdout + sys.stdin = open(os.devnull) + sys.stdout = open(os.devnull, "w") + + stdout.buffer.write(b"READY\n") + while True: + retval = exception = None + try: + command, *args = pickle.load(stdin.buffer) + except EOFError: + return + except BaseException as exc: + exception = exc + else: + if command == "run": + func, args = args + try: + retval = func(*args) + except BaseException as exc: + exception = exc + elif command == "init": + main_module_path: str | None + sys.path, main_module_path = args + del sys.modules["__main__"] + if main_module_path and os.path.isfile(main_module_path): + # Load the parent's main module but as __mp_main__ instead of + # __main__ (like multiprocessing does) to avoid infinite recursion + try: + main = ModuleType("__mp_main__") + main_content = runpy.run_path( + main_module_path, run_name="__mp_main__" + ) + main.__dict__.update(main_content) + sys.modules["__main__"] = sys.modules["__mp_main__"] = main + except BaseException as exc: + exception = exc + try: + if exception is not None: + status = b"EXCEPTION" + pickled = pickle.dumps(exception, pickle.HIGHEST_PROTOCOL) + else: + status = b"RETURN" + pickled = pickle.dumps(retval, pickle.HIGHEST_PROTOCOL) + except BaseException as exc: + exception = exc + status = b"EXCEPTION" + pickled = pickle.dumps(exc, pickle.HIGHEST_PROTOCOL) + + stdout.buffer.write(b"%s %d\n" % (status, len(pickled))) + stdout.buffer.write(pickled) + + # Respect SIGTERM + if isinstance(exception, SystemExit): + raise exception + + +if __name__ == "__main__": + process_worker() diff --git a/server/venv/Lib/site-packages/anyio/to_thread.py b/server/venv/Lib/site-packages/anyio/to_thread.py new file mode 100644 index 0000000..83c79d1 --- /dev/null +++ b/server/venv/Lib/site-packages/anyio/to_thread.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +__all__ = ( + "run_sync", + "current_default_thread_limiter", +) + +import sys +from collections.abc import Callable +from typing import TypeVar +from warnings import warn + +from ._core._eventloop import get_async_backend +from .abc import CapacityLimiter + +if sys.version_info >= (3, 11): + from typing import TypeVarTuple, Unpack +else: + from typing_extensions import TypeVarTuple, Unpack + +T_Retval = TypeVar("T_Retval") +PosArgsT = TypeVarTuple("PosArgsT") + + +async def run_sync( + func: Callable[[Unpack[PosArgsT]], T_Retval], + *args: Unpack[PosArgsT], + abandon_on_cancel: bool = False, + cancellable: bool | None = None, + limiter: CapacityLimiter | None = None, +) -> T_Retval: + """ + Call the given function with the given arguments in a worker thread. + + If the ``abandon_on_cancel`` option is enabled and the task waiting for its + completion is cancelled, the thread will still run its course but its + return value (or any raised exception) will be ignored. + + :param func: a callable + :param args: positional arguments for the callable + :param abandon_on_cancel: ``True`` to abandon the thread (leaving it to run + unchecked on own) if the host task is cancelled, ``False`` to ignore + cancellations in the host task until the operation has completed in the worker + thread + :param cancellable: deprecated alias of ``abandon_on_cancel``; will override + ``abandon_on_cancel`` if both parameters are passed + :param limiter: capacity limiter to use to limit the total amount of threads running + (if omitted, the default limiter is used) + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + :return: an awaitable that yields the return value of the function. + + """ + if cancellable is not None: + abandon_on_cancel = cancellable + warn( + "The `cancellable=` keyword argument to `anyio.to_thread.run_sync` is " + "deprecated since AnyIO 4.1.0; use `abandon_on_cancel=` instead", + DeprecationWarning, + stacklevel=2, + ) + + return await get_async_backend().run_sync_in_worker_thread( + func, args, abandon_on_cancel=abandon_on_cancel, limiter=limiter + ) + + +def current_default_thread_limiter() -> CapacityLimiter: + """ + Return the capacity limiter that is used by default to limit the number of + concurrent threads. + + :return: a capacity limiter object + :raises NoEventLoopError: if no supported asynchronous event loop is running in the + current thread + + """ + return get_async_backend().current_default_thread_limiter() diff --git a/server/venv/Lib/site-packages/click-8.4.1.dist-info/INSTALLER b/server/venv/Lib/site-packages/click-8.4.1.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/click-8.4.1.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/click-8.4.1.dist-info/METADATA b/server/venv/Lib/site-packages/click-8.4.1.dist-info/METADATA new file mode 100644 index 0000000..21d7a0c --- /dev/null +++ b/server/venv/Lib/site-packages/click-8.4.1.dist-info/METADATA @@ -0,0 +1,84 @@ +Metadata-Version: 2.4 +Name: click +Version: 8.4.1 +Summary: Composable command line interface toolkit +Maintainer-email: Pallets +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +License-Expression: BSD-3-Clause +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Typing :: Typed +License-File: LICENSE.txt +Requires-Dist: colorama; platform_system == 'Windows' +Project-URL: Changes, https://click.palletsprojects.com/page/changes/ +Project-URL: Chat, https://discord.gg/pallets +Project-URL: Documentation, https://click.palletsprojects.com/ +Project-URL: Donate, https://palletsprojects.com/donate +Project-URL: Source, https://github.com/pallets/click/ + +
+ +# Click + +Click is a Python package for creating beautiful command line interfaces +in a composable way with as little code as necessary. It's the "Command +Line Interface Creation Kit". It's highly configurable but comes with +sensible defaults out of the box. + +It aims to make the process of writing command line tools quick and fun +while also preventing any frustration caused by the inability to +implement an intended CLI API. + +Click in three points: + +- Arbitrary nesting of commands +- Automatic help page generation +- Supports lazy loading of subcommands at runtime + + +## A Simple Example + +```python +import click + +@click.command() +@click.option("--count", default=1, help="Number of greetings.") +@click.option("--name", prompt="Your name", help="The person to greet.") +def hello(count, name): + """Simple program that greets NAME for a total of COUNT times.""" + for _ in range(count): + click.echo(f"Hello, {name}!") + +if __name__ == '__main__': + hello() +``` + +``` +$ python hello.py --count=3 +Your name: Click +Hello, Click! +Hello, Click! +Hello, Click! +``` + + +## Donate + +The Pallets organization develops and supports Click and other popular +packages. In order to grow the community of contributors and users, and +allow the maintainers to devote more time to the projects, [please +donate today][]. + +[please donate today]: https://palletsprojects.com/donate + +## Contributing + +See our [detailed contributing documentation][contrib] for many ways to +contribute, including reporting issues, requesting features, asking or answering +questions, and making PRs. + +[contrib]: https://palletsprojects.com/contributing/ + diff --git a/server/venv/Lib/site-packages/click-8.4.1.dist-info/RECORD b/server/venv/Lib/site-packages/click-8.4.1.dist-info/RECORD new file mode 100644 index 0000000..e84fda1 --- /dev/null +++ b/server/venv/Lib/site-packages/click-8.4.1.dist-info/RECORD @@ -0,0 +1,40 @@ +click-8.4.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +click-8.4.1.dist-info/METADATA,sha256=uWALc7Y-Ixjj35al3B2qNsyXk-0tf578YgIsL1Vbn98,2621 +click-8.4.1.dist-info/RECORD,, +click-8.4.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82 +click-8.4.1.dist-info/licenses/LICENSE.txt,sha256=morRBqOU6FO_4h9C9OctWSgZoigF2ZG18ydQKSkrZY0,1475 +click/__init__.py,sha256=FId2fXCSJB3yeWD-e2uON-mBhFa2Yc9MvXGmHu8OXG0,4634 +click/__pycache__/__init__.cpython-313.pyc,, +click/__pycache__/_compat.cpython-313.pyc,, +click/__pycache__/_termui_impl.cpython-313.pyc,, +click/__pycache__/_textwrap.cpython-313.pyc,, +click/__pycache__/_utils.cpython-313.pyc,, +click/__pycache__/_winconsole.cpython-313.pyc,, +click/__pycache__/core.cpython-313.pyc,, +click/__pycache__/decorators.cpython-313.pyc,, +click/__pycache__/exceptions.cpython-313.pyc,, +click/__pycache__/formatting.cpython-313.pyc,, +click/__pycache__/globals.cpython-313.pyc,, +click/__pycache__/parser.cpython-313.pyc,, +click/__pycache__/shell_completion.cpython-313.pyc,, +click/__pycache__/termui.cpython-313.pyc,, +click/__pycache__/testing.cpython-313.pyc,, +click/__pycache__/types.cpython-313.pyc,, +click/__pycache__/utils.cpython-313.pyc,, +click/_compat.py,sha256=XK1woqBSPRb1KNAwWwVsSMfEm8ZlfDwBCjceWzSGmEY,18910 +click/_termui_impl.py,sha256=VNyWY27pm0JUq12WK__pJNYxeUoDyfNwYEgE6vz_cnM,30411 +click/_textwrap.py,sha256=7Z0N7Vmn-66TNSTUwp6OXJbcUXRmYET9h9c2ucD8oQQ,6270 +click/_utils.py,sha256=eCZCtwJtsYD5QYkkNWJ8MY_8ABIjy8MczgMMyVY32rQ,996 +click/_winconsole.py,sha256=KSxfNbMlYRa6GOJuCLgsg2Pb3dVkgJNPqLJPae-Pa10,8543 +click/core.py,sha256=CQaJ0ALw4zyyVp6E7gP6a_G88eiuXE9mBzFnIJQ-igY,137917 +click/decorators.py,sha256=xgR8vZAClhWVkzitP22k6jETFRhKralh81zvA69Csd0,18467 +click/exceptions.py,sha256=FJitDd6MSZLj6CtKQWpkQzHnfsUw3UypxDU0t76bn0c,11294 +click/formatting.py,sha256=r6kGLpPyF0Cva6uSTwSZ_8ONRE7jp93I1C0JVRYQEqE,10370 +click/globals.py,sha256=gM-Nh6A4M0HB_SgkaF5M4ncGGMDHc_flHXu9_oh4GEU,1923 +click/parser.py,sha256=oJ-fU_3mvxugIuNtHaCATZ56lgEmHRggjJiSqEgYrjA,19052 +click/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +click/shell_completion.py,sha256=QxeFb3RapNSP8ZwvK2BDVI2jrEKS1RbJaeuFMrHJAnU,21748 +click/termui.py,sha256=hflZ_tK7P4WUv_ef_RVVRL7n0nRPhTD0wN0pkHE7hDo,33000 +click/testing.py,sha256=0k_v5By_5ZO2McjBT7lTqQ57W8QJuW0_5Sx7d2ZLKec,25717 +click/types.py,sha256=UiVEx0yoQtrWO2wH22NgME4LfDxMNQi8j1qtJupYIpw,42783 +click/utils.py,sha256=YsTNrChEfwDXl39CTC_yWnQ-ZVkZxYjFESRCfEgClY4,20386 diff --git a/server/venv/Lib/site-packages/click-8.4.1.dist-info/WHEEL b/server/venv/Lib/site-packages/click-8.4.1.dist-info/WHEEL new file mode 100644 index 0000000..d8b9936 --- /dev/null +++ b/server/venv/Lib/site-packages/click-8.4.1.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: flit 3.12.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/server/venv/Lib/site-packages/click-8.4.1.dist-info/licenses/LICENSE.txt b/server/venv/Lib/site-packages/click-8.4.1.dist-info/licenses/LICENSE.txt new file mode 100644 index 0000000..d12a849 --- /dev/null +++ b/server/venv/Lib/site-packages/click-8.4.1.dist-info/licenses/LICENSE.txt @@ -0,0 +1,28 @@ +Copyright 2014 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/server/venv/Lib/site-packages/click/__init__.py b/server/venv/Lib/site-packages/click/__init__.py new file mode 100644 index 0000000..64be7e0 --- /dev/null +++ b/server/venv/Lib/site-packages/click/__init__.py @@ -0,0 +1,126 @@ +""" +Click is a simple Python module inspired by the stdlib optparse to make +writing command line scripts fun. Unlike other modules, it's based +around a simple API that does not come with too much magic and is +composable. +""" + +from __future__ import annotations + +from .core import Argument as Argument +from .core import Command as Command +from .core import CommandCollection as CommandCollection +from .core import Context as Context +from .core import Group as Group +from .core import Option as Option +from .core import Parameter as Parameter +from .core import ParameterSource as ParameterSource +from .decorators import argument as argument +from .decorators import command as command +from .decorators import confirmation_option as confirmation_option +from .decorators import group as group +from .decorators import help_option as help_option +from .decorators import make_pass_decorator as make_pass_decorator +from .decorators import option as option +from .decorators import pass_context as pass_context +from .decorators import pass_obj as pass_obj +from .decorators import password_option as password_option +from .decorators import version_option as version_option +from .exceptions import Abort as Abort +from .exceptions import BadArgumentUsage as BadArgumentUsage +from .exceptions import BadOptionUsage as BadOptionUsage +from .exceptions import BadParameter as BadParameter +from .exceptions import ClickException as ClickException +from .exceptions import FileError as FileError +from .exceptions import MissingParameter as MissingParameter +from .exceptions import NoSuchCommand as NoSuchCommand +from .exceptions import NoSuchOption as NoSuchOption +from .exceptions import UsageError as UsageError +from .formatting import HelpFormatter as HelpFormatter +from .formatting import wrap_text as wrap_text +from .globals import get_current_context as get_current_context +from .termui import clear as clear +from .termui import confirm as confirm +from .termui import echo_via_pager as echo_via_pager +from .termui import edit as edit +from .termui import get_pager_file as get_pager_file +from .termui import getchar as getchar +from .termui import launch as launch +from .termui import pause as pause +from .termui import progressbar as progressbar +from .termui import prompt as prompt +from .termui import secho as secho +from .termui import style as style +from .termui import unstyle as unstyle +from .types import BOOL as BOOL +from .types import Choice as Choice +from .types import DateTime as DateTime +from .types import File as File +from .types import FLOAT as FLOAT +from .types import FloatRange as FloatRange +from .types import INT as INT +from .types import IntRange as IntRange +from .types import ParamType as ParamType +from .types import Path as Path +from .types import STRING as STRING +from .types import Tuple as Tuple +from .types import UNPROCESSED as UNPROCESSED +from .types import UUID as UUID +from .utils import echo as echo +from .utils import format_filename as format_filename +from .utils import get_app_dir as get_app_dir +from .utils import get_binary_stream as get_binary_stream +from .utils import get_text_stream as get_text_stream +from .utils import open_file as open_file + + +def __getattr__(name: str) -> object: + import warnings + + if name == "BaseCommand": + from .core import _BaseCommand + + warnings.warn( + "'BaseCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Command' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _BaseCommand + + if name == "MultiCommand": + from .core import _MultiCommand + + warnings.warn( + "'MultiCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Group' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _MultiCommand + + if name == "OptionParser": + from .parser import _OptionParser + + warnings.warn( + "'OptionParser' is deprecated and will be removed in Click 9.0. The" + " old parser is available in 'optparse'.", + DeprecationWarning, + stacklevel=2, + ) + return _OptionParser + + if name == "__version__": + import importlib.metadata + import warnings + + warnings.warn( + "The '__version__' attribute is deprecated and will be removed in" + " Click 9.1. Use feature detection or" + " 'importlib.metadata.version(\"click\")' instead.", + DeprecationWarning, + stacklevel=2, + ) + return importlib.metadata.version("click") + + raise AttributeError(name) diff --git a/server/venv/Lib/site-packages/click/_compat.py b/server/venv/Lib/site-packages/click/_compat.py new file mode 100644 index 0000000..36c0e53 --- /dev/null +++ b/server/venv/Lib/site-packages/click/_compat.py @@ -0,0 +1,627 @@ +from __future__ import annotations + +import codecs +import collections.abc as cabc +import io +import os +import re +import sys +import typing as t +from types import TracebackType +from weakref import WeakKeyDictionary + +CYGWIN = sys.platform.startswith("cygwin") +WIN = sys.platform.startswith("win") +MAC = sys.platform == "darwin" +auto_wrap_for_ansi: t.Callable[[t.TextIO], t.TextIO] | None = None +_ansi_re = re.compile(r"\033\[[;?0-9]*[a-zA-Z]") + + +def _make_text_stream( + stream: t.BinaryIO, + encoding: str | None, + errors: str | None, + force_readable: bool = False, + force_writable: bool = False, +) -> t.TextIO: + if encoding is None: + encoding = get_best_encoding(stream) + if errors is None: + errors = "replace" + return _NonClosingTextIOWrapper( + stream, + encoding, + errors, + line_buffering=True, + force_readable=force_readable, + force_writable=force_writable, + ) + + +def is_ascii_encoding(encoding: str) -> bool: + """Checks if a given encoding is ascii.""" + try: + return codecs.lookup(encoding).name == "ascii" + except LookupError: + return False + + +def get_best_encoding(stream: t.IO[t.Any]) -> str: + """Returns the default stream encoding if not found.""" + rv = getattr(stream, "encoding", None) or sys.getdefaultencoding() + if is_ascii_encoding(rv): + return "utf-8" + return rv + + +class _NonClosingTextIOWrapper(io.TextIOWrapper): + def __init__( + self, + stream: t.BinaryIO, + encoding: str | None, + errors: str | None, + force_readable: bool = False, + force_writable: bool = False, + **extra: t.Any, + ) -> None: + self._stream = stream = t.cast( + t.BinaryIO, _FixupStream(stream, force_readable, force_writable) + ) + super().__init__(stream, encoding, errors, **extra) + + def __del__(self) -> None: + try: + self.detach() + except Exception: + pass + + def isatty(self) -> bool: + # https://bitbucket.org/pypy/pypy/issue/1803 + return self._stream.isatty() + + +class _FixupStream: + """The new io interface needs more from streams than streams + traditionally implement. As such, this fix-up code is necessary in + some circumstances. + + The forcing of readable and writable flags are there because some tools + put badly patched objects on sys (one such offender are certain version + of jupyter notebook). + """ + + def __init__( + self, + stream: t.BinaryIO, + force_readable: bool = False, + force_writable: bool = False, + ): + self._stream = stream + self._force_readable = force_readable + self._force_writable = force_writable + + def __getattr__(self, name: str) -> t.Any: + return getattr(self._stream, name) + + def read1(self, size: int) -> bytes: + f = getattr(self._stream, "read1", None) + + if f is not None: + return t.cast(bytes, f(size)) + + return self._stream.read(size) + + def readable(self) -> bool: + if self._force_readable: + return True + x = getattr(self._stream, "readable", None) + if x is not None: + return t.cast(bool, x()) + try: + self._stream.read(0) + except Exception: + return False + return True + + def writable(self) -> bool: + if self._force_writable: + return True + x = getattr(self._stream, "writable", None) + if x is not None: + return t.cast(bool, x()) + try: + self._stream.write(b"") + except Exception: + try: + self._stream.write(b"") + except Exception: + return False + return True + + def seekable(self) -> bool: + x = getattr(self._stream, "seekable", None) + if x is not None: + return t.cast(bool, x()) + try: + self._stream.seek(self._stream.tell()) + except Exception: + return False + return True + + +def _is_binary_reader(stream: t.IO[t.Any], default: bool = False) -> bool: + try: + return isinstance(stream.read(0), bytes) + except Exception: + return default + # This happens in some cases where the stream was already + # closed. In this case, we assume the default. + + +def _is_binary_writer(stream: t.IO[t.Any], default: bool = False) -> bool: + try: + stream.write(b"") + except Exception: + try: + stream.write("") + return False + except Exception: + pass + return default + return True + + +def _find_binary_reader(stream: t.IO[t.Any]) -> t.BinaryIO | None: + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_reader(stream, False): + return t.cast(t.BinaryIO, stream) + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_reader(buf, True): + return t.cast(t.BinaryIO, buf) + + return None + + +def _find_binary_writer(stream: t.IO[t.Any]) -> t.BinaryIO | None: + # We need to figure out if the given stream is already binary. + # This can happen because the official docs recommend detaching + # the streams to get binary streams. Some code might do this, so + # we need to deal with this case explicitly. + if _is_binary_writer(stream, False): + return t.cast(t.BinaryIO, stream) + + buf = getattr(stream, "buffer", None) + + # Same situation here; this time we assume that the buffer is + # actually binary in case it's closed. + if buf is not None and _is_binary_writer(buf, True): + return t.cast(t.BinaryIO, buf) + + return None + + +def _stream_is_misconfigured(stream: t.TextIO) -> bool: + """A stream is misconfigured if its encoding is ASCII.""" + # If the stream does not have an encoding set, we assume it's set + # to ASCII. This appears to happen in certain unittest + # environments. It's not quite clear what the correct behavior is + # but this at least will force Click to recover somehow. + return is_ascii_encoding(getattr(stream, "encoding", None) or "ascii") + + +def _is_compat_stream_attr(stream: t.TextIO, attr: str, value: str | None) -> bool: + """A stream attribute is compatible if it is equal to the + desired value or the desired value is unset and the attribute + has a value. + """ + stream_value = getattr(stream, attr, None) + return stream_value == value or (value is None and stream_value is not None) + + +def _is_compatible_text_stream( + stream: t.TextIO, encoding: str | None, errors: str | None +) -> bool: + """Check if a stream's encoding and errors attributes are + compatible with the desired values. + """ + return _is_compat_stream_attr( + stream, "encoding", encoding + ) and _is_compat_stream_attr(stream, "errors", errors) + + +def _force_correct_text_stream( + text_stream: t.IO[t.Any], + encoding: str | None, + errors: str | None, + is_binary: t.Callable[[t.IO[t.Any], bool], bool], + find_binary: t.Callable[[t.IO[t.Any]], t.BinaryIO | None], + force_readable: bool = False, + force_writable: bool = False, +) -> t.TextIO: + if is_binary(text_stream, False): + binary_reader = t.cast(t.BinaryIO, text_stream) + else: + text_stream = t.cast(t.TextIO, text_stream) + # If the stream looks compatible, and won't default to a + # misconfigured ascii encoding, return it as-is. + if _is_compatible_text_stream(text_stream, encoding, errors) and not ( + encoding is None and _stream_is_misconfigured(text_stream) + ): + return text_stream + + # Otherwise, get the underlying binary reader. + possible_binary_reader = find_binary(text_stream) + + # If that's not possible, silently use the original reader + # and get mojibake instead of exceptions. + if possible_binary_reader is None: + return text_stream + + binary_reader = possible_binary_reader + + # Default errors to replace instead of strict in order to get + # something that works. + if errors is None: + errors = "replace" + + # Wrap the binary stream in a text stream with the correct + # encoding parameters. + return _make_text_stream( + binary_reader, + encoding, + errors, + force_readable=force_readable, + force_writable=force_writable, + ) + + +def _force_correct_text_reader( + text_reader: t.IO[t.Any], + encoding: str | None, + errors: str | None, + force_readable: bool = False, +) -> t.TextIO: + return _force_correct_text_stream( + text_reader, + encoding, + errors, + _is_binary_reader, + _find_binary_reader, + force_readable=force_readable, + ) + + +def _force_correct_text_writer( + text_writer: t.IO[t.Any], + encoding: str | None, + errors: str | None, + force_writable: bool = False, +) -> t.TextIO: + return _force_correct_text_stream( + text_writer, + encoding, + errors, + _is_binary_writer, + _find_binary_writer, + force_writable=force_writable, + ) + + +def get_binary_stdin() -> t.BinaryIO: + reader = _find_binary_reader(sys.stdin) + if reader is None: + raise RuntimeError("Was not able to determine binary stream for sys.stdin.") + return reader + + +def get_binary_stdout() -> t.BinaryIO: + writer = _find_binary_writer(sys.stdout) + if writer is None: + raise RuntimeError("Was not able to determine binary stream for sys.stdout.") + return writer + + +def get_binary_stderr() -> t.BinaryIO: + writer = _find_binary_writer(sys.stderr) + if writer is None: + raise RuntimeError("Was not able to determine binary stream for sys.stderr.") + return writer + + +def get_text_stdin(encoding: str | None = None, errors: str | None = None) -> t.TextIO: + rv = _get_windows_console_stream(sys.stdin, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_reader(sys.stdin, encoding, errors, force_readable=True) + + +def get_text_stdout(encoding: str | None = None, errors: str | None = None) -> t.TextIO: + rv = _get_windows_console_stream(sys.stdout, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer(sys.stdout, encoding, errors, force_writable=True) + + +def get_text_stderr(encoding: str | None = None, errors: str | None = None) -> t.TextIO: + rv = _get_windows_console_stream(sys.stderr, encoding, errors) + if rv is not None: + return rv + return _force_correct_text_writer(sys.stderr, encoding, errors, force_writable=True) + + +def _wrap_io_open( + file: str | os.PathLike[str] | int, + mode: str, + encoding: str | None, + errors: str | None, +) -> t.IO[t.Any]: + """Handles not passing ``encoding`` and ``errors`` in binary mode.""" + if "b" in mode: + return open(file, mode) + + return open(file, mode, encoding=encoding, errors=errors) + + +def open_stream( + filename: str | os.PathLike[str], + mode: str = "r", + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, +) -> tuple[t.IO[t.Any], bool]: + binary = "b" in mode + filename = os.fspath(filename) + + # Standard streams first. These are simple because they ignore the + # atomic flag. Use fsdecode to handle Path("-"). + if os.fsdecode(filename) == "-": + if any(m in mode for m in ["w", "a", "x"]): + if binary: + return get_binary_stdout(), False + return get_text_stdout(encoding=encoding, errors=errors), False + if binary: + return get_binary_stdin(), False + return get_text_stdin(encoding=encoding, errors=errors), False + + # Non-atomic writes directly go out through the regular open functions. + if not atomic: + return _wrap_io_open(filename, mode, encoding, errors), True + + # Some usability stuff for atomic writes + if "a" in mode: + raise ValueError( + "Appending to an existing file is not supported, because that" + " would involve an expensive `copy`-operation to a temporary" + " file. Open the file in normal `w`-mode and copy explicitly" + " if that's what you're after." + ) + if "x" in mode: + raise ValueError("Use the `overwrite`-parameter instead.") + if "w" not in mode: + raise ValueError("Atomic writes only make sense with `w`-mode.") + + # Atomic writes are more complicated. They work by opening a file + # as a proxy in the same folder and then using the fdopen + # functionality to wrap it in a Python file. Then we wrap it in an + # atomic file that moves the file over on close. + import errno + import random + + try: + perm: int | None = os.stat(filename).st_mode + except OSError: + perm = None + + flags = os.O_RDWR | os.O_CREAT | os.O_EXCL + + if binary: + flags |= getattr(os, "O_BINARY", 0) + + while True: + tmp_filename = os.path.join( + os.path.dirname(filename), + f".__atomic-write{random.randrange(1 << 32):08x}", + ) + try: + fd = os.open(tmp_filename, flags, 0o666 if perm is None else perm) + break + except OSError as e: + if e.errno == errno.EEXIST or ( + os.name == "nt" + and e.errno == errno.EACCES + and os.path.isdir(e.filename) + and os.access(e.filename, os.W_OK) + ): + continue + raise + + if perm is not None: + os.chmod(tmp_filename, perm) # in case perm includes bits in umask + + f = _wrap_io_open(fd, mode, encoding, errors) + af = _AtomicFile(f, tmp_filename, os.path.realpath(filename)) + return t.cast(t.IO[t.Any], af), True + + +class _AtomicFile: + def __init__(self, f: t.IO[t.Any], tmp_filename: str, real_filename: str) -> None: + self._f = f + self._tmp_filename = tmp_filename + self._real_filename = real_filename + self.closed = False + + @property + def name(self) -> str: + return self._real_filename + + def close(self, delete: bool = False) -> None: + if self.closed: + return + self._f.close() + os.replace(self._tmp_filename, self._real_filename) + self.closed = True + + def __getattr__(self, name: str) -> t.Any: + return getattr(self._f, name) + + def __enter__(self) -> _AtomicFile: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close(delete=exc_type is not None) + + def __repr__(self) -> str: + return repr(self._f) + + +def strip_ansi(value: str) -> str: + return _ansi_re.sub("", value) + + +def _is_jupyter_kernel_output(stream: t.IO[t.Any]) -> bool: + while isinstance(stream, (_FixupStream, _NonClosingTextIOWrapper)): + stream = stream._stream + + return stream.__class__.__module__.startswith("ipykernel.") + + +def should_strip_ansi( + stream: t.IO[t.Any] | None = None, color: bool | None = None +) -> bool: + if color is None: + if stream is None: + stream = sys.stdin + elif hasattr(stream, "color"): + # ._termui_impl.MaybeStripAnsi handles stripping ansi itself, + # so we don't need to strip it here + return False + return not isatty(stream) and not _is_jupyter_kernel_output(stream) + return not color + + +# On Windows, wrap the output streams with colorama to support ANSI +# color codes. +# NOTE: double check is needed so mypy does not analyze this on Linux +if sys.platform.startswith("win") and WIN: + from ._winconsole import _get_windows_console_stream + + def _get_argv_encoding() -> str: + import locale + + return locale.getpreferredencoding() + + _ansi_stream_wrappers: cabc.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() + + def auto_wrap_for_ansi(stream: t.TextIO, color: bool | None = None) -> t.TextIO: + """Support ANSI color and style codes on Windows by wrapping a + stream with colorama. + """ + try: + cached = _ansi_stream_wrappers.get(stream) + except Exception: + cached = None + + if cached is not None: + return cached + + import colorama + + strip = should_strip_ansi(stream, color) + ansi_wrapper = colorama.AnsiToWin32(stream, strip=strip) + rv = t.cast(t.TextIO, ansi_wrapper.stream) + _write = rv.write + + def _safe_write(s: str) -> int: + try: + return _write(s) + except BaseException: + ansi_wrapper.reset_all() + raise + + rv.write = _safe_write # type: ignore[method-assign] + + try: + _ansi_stream_wrappers[stream] = rv + except Exception: + pass + + return rv + +else: + + def _get_argv_encoding() -> str: + return getattr(sys.stdin, "encoding", None) or sys.getfilesystemencoding() + + def _get_windows_console_stream( + f: t.TextIO, encoding: str | None, errors: str | None + ) -> t.TextIO | None: + return None + + +def term_len(x: str) -> int: + return len(strip_ansi(x)) + + +def isatty(stream: t.IO[t.Any]) -> bool: + try: + return stream.isatty() + except Exception: + return False + + +def _make_cached_stream_func( + src_func: t.Callable[[], t.TextIO | None], + wrapper_func: t.Callable[[], t.TextIO], +) -> t.Callable[[], t.TextIO | None]: + cache: cabc.MutableMapping[t.TextIO, t.TextIO] = WeakKeyDictionary() + + def func() -> t.TextIO | None: + stream = src_func() + + if stream is None: + return None + + try: + rv = cache.get(stream) + except Exception: + rv = None + if rv is not None: + return rv + rv = wrapper_func() + try: + cache[stream] = rv + except Exception: + pass + return rv + + return func + + +_default_text_stdin = _make_cached_stream_func(lambda: sys.stdin, get_text_stdin) +_default_text_stdout = _make_cached_stream_func(lambda: sys.stdout, get_text_stdout) +_default_text_stderr = _make_cached_stream_func(lambda: sys.stderr, get_text_stderr) + + +binary_streams: cabc.Mapping[str, t.Callable[[], t.BinaryIO]] = { + "stdin": get_binary_stdin, + "stdout": get_binary_stdout, + "stderr": get_binary_stderr, +} + +text_streams: cabc.Mapping[str, t.Callable[[str | None, str | None], t.TextIO]] = { + "stdin": get_text_stdin, + "stdout": get_text_stdout, + "stderr": get_text_stderr, +} diff --git a/server/venv/Lib/site-packages/click/_termui_impl.py b/server/venv/Lib/site-packages/click/_termui_impl.py new file mode 100644 index 0000000..76113e9 --- /dev/null +++ b/server/venv/Lib/site-packages/click/_termui_impl.py @@ -0,0 +1,929 @@ +""" +This module contains implementations for the termui module. To keep the +import time of Click down, some infrequently used functionality is +placed in this module and only imported as needed. +""" + +from __future__ import annotations + +import collections.abc as cabc +import contextlib +import io +import math +import os +import shlex +import sys +import time +import typing as t +from gettext import gettext as _ +from io import StringIO +from pathlib import Path +from types import TracebackType + +from ._compat import _default_text_stdout +from ._compat import CYGWIN +from ._compat import get_best_encoding +from ._compat import isatty +from ._compat import strip_ansi +from ._compat import term_len +from ._compat import WIN +from .exceptions import ClickException +from .utils import echo + +V = t.TypeVar("V") + + +class _BufferedTextPagerStream(t.Protocol): + buffer: t.BinaryIO + + +def _has_binary_buffer( + stream: t.BinaryIO | t.TextIO, +) -> t.TypeGuard[_BufferedTextPagerStream]: + # TextIO is wider than TextIOWrapper; text-only streams such as StringIO + # are valid TextIO values but do not expose a binary buffer to wrap. + return getattr(stream, "buffer", None) is not None + + +if os.name == "nt": + BEFORE_BAR = "\r" + AFTER_BAR = "\n" +else: + BEFORE_BAR = "\r\033[?25l" + AFTER_BAR = "\033[?25h\n" + + +class ProgressBar(t.Generic[V]): + def __init__( + self, + iterable: cabc.Iterable[V] | None, + length: int | None = None, + fill_char: str = "#", + empty_char: str = " ", + bar_template: str = "%(bar)s", + info_sep: str = " ", + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + item_show_func: t.Callable[[V | None], str | None] | None = None, + label: str | None = None, + file: t.TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, + width: int = 30, + ) -> None: + self.fill_char = fill_char + self.empty_char = empty_char + self.bar_template = bar_template + self.info_sep = info_sep + self.hidden = hidden + self.show_eta = show_eta + self.show_percent = show_percent + self.show_pos = show_pos + self.item_show_func = item_show_func + self.label: str = label or "" + + if file is None: + file = _default_text_stdout() + + # There are no standard streams attached to write to. For example, + # pythonw on Windows. + if file is None: + file = StringIO() + + self.file = file + self.color = color + self.update_min_steps = update_min_steps + self._completed_intervals = 0 + self.width: int = width + self.autowidth: bool = width == 0 + + if length is None: + from operator import length_hint + + length = length_hint(iterable, -1) + + if length == -1: + length = None + if iterable is None: + if length is None: + raise TypeError("iterable or length is required") + iterable = t.cast("cabc.Iterable[V]", range(length)) + self.iter: cabc.Iterable[V] = iter(iterable) + self.length = length + self.pos: int = 0 + self.avg: list[float] = [] + self.last_eta: float + self.start: float + self.start = self.last_eta = time.time() + self.eta_known: bool = False + self.finished: bool = False + self.max_width: int | None = None + self.entered: bool = False + self.current_item: V | None = None + self._is_atty = isatty(self.file) + self._last_line: str | None = None + + def __enter__(self) -> ProgressBar[V]: + self.entered = True + self.render_progress() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.render_finish() + + def __iter__(self) -> cabc.Iterator[V]: + if not self.entered: + raise RuntimeError("You need to use progress bars in a with block.") + self.render_progress() + return self.generator() + + def __next__(self) -> V: + # Iteration is defined in terms of a generator function, + # returned by iter(self); use that to define next(). This works + # because `self.iter` is an iterable consumed by that generator, + # so it is re-entry safe. Calling `next(self.generator())` + # twice works and does "what you want". + return next(iter(self)) + + def render_finish(self) -> None: + if self.hidden or not self._is_atty: + return + self.file.write(AFTER_BAR) + self.file.flush() + + @property + def pct(self) -> float: + if self.finished: + return 1.0 + return min(self.pos / (float(self.length or 1) or 1), 1.0) + + @property + def time_per_iteration(self) -> float: + if not self.avg: + return 0.0 + return sum(self.avg) / float(len(self.avg)) + + @property + def eta(self) -> float: + if self.length is not None and not self.finished: + return self.time_per_iteration * (self.length - self.pos) + return 0.0 + + def format_eta(self) -> str: + if self.eta_known: + t = int(self.eta) + seconds = t % 60 + t //= 60 + minutes = t % 60 + t //= 60 + hours = t % 24 + t //= 24 + if t > 0: + return "{d}{day_label} {h:02}:{m:02}:{s:02}".format( + d=t, + day_label=_("d"), + h=hours, + m=minutes, + s=seconds, + ) + else: + return f"{hours:02}:{minutes:02}:{seconds:02}" + return "" + + def format_pos(self) -> str: + pos = str(self.pos) + if self.length is not None: + pos += f"/{self.length}" + return pos + + def format_pct(self) -> str: + return f"{int(self.pct * 100): 4}%"[1:] + + def format_bar(self) -> str: + if self.length is not None: + bar_length = int(self.pct * self.width) + bar = self.fill_char * bar_length + bar += self.empty_char * (self.width - bar_length) + elif self.finished: + bar = self.fill_char * self.width + else: + chars = list(self.empty_char * (self.width or 1)) + if self.time_per_iteration != 0: + chars[ + int( + (math.cos(self.pos * self.time_per_iteration) / 2.0 + 0.5) + * self.width + ) + ] = self.fill_char + bar = "".join(chars) + return bar + + def format_progress_line(self) -> str: + show_percent = self.show_percent + + info_bits = [] + if self.length is not None and show_percent is None: + show_percent = not self.show_pos + + if self.show_pos: + info_bits.append(self.format_pos()) + if show_percent: + info_bits.append(self.format_pct()) + if self.show_eta and self.eta_known and not self.finished: + info_bits.append(self.format_eta()) + if self.item_show_func is not None: + item_info = self.item_show_func(self.current_item) + if item_info is not None: + info_bits.append(item_info) + + return ( + self.bar_template + % { + "label": self.label, + "bar": self.format_bar(), + "info": self.info_sep.join(info_bits), + } + ).rstrip() + + def render_progress(self) -> None: + if self.hidden: + return + + if not self._is_atty: + # Only output the label once if the output is not a TTY. + if self._last_line != self.label: + self._last_line = self.label + echo(self.label, file=self.file, color=self.color) + return + + buf = [] + # Update width in case the terminal has been resized + if self.autowidth: + import shutil + + old_width = self.width + self.width = 0 + clutter_length = term_len(self.format_progress_line()) + new_width = max(0, shutil.get_terminal_size().columns - clutter_length) + if new_width < old_width and self.max_width is not None: + buf.append(BEFORE_BAR) + buf.append(" " * self.max_width) + self.max_width = new_width + self.width = new_width + + clear_width = self.width + if self.max_width is not None: + clear_width = self.max_width + + buf.append(BEFORE_BAR) + line = self.format_progress_line() + line_len = term_len(line) + if self.max_width is None or self.max_width < line_len: + self.max_width = line_len + + buf.append(line) + buf.append(" " * (clear_width - line_len)) + line = "".join(buf) + # Render the line only if it changed. + + if line != self._last_line: + self._last_line = line + echo(line, file=self.file, color=self.color, nl=False) + self.file.flush() + + def make_step(self, n_steps: int) -> None: + self.pos += n_steps + if self.length is not None and self.pos >= self.length: + self.finished = True + + if (time.time() - self.last_eta) < 1.0: + return + + self.last_eta = time.time() + + # self.avg is a rolling list of length <= 7 of steps where steps are + # defined as time elapsed divided by the total progress through + # self.length. + if self.pos: + step = (time.time() - self.start) / self.pos + else: + step = time.time() - self.start + + self.avg = self.avg[-6:] + [step] + + self.eta_known = self.length is not None + + def update(self, n_steps: int, current_item: V | None = None) -> None: + """Update the progress bar by advancing a specified number of + steps, and optionally set the ``current_item`` for this new + position. + + :param n_steps: Number of steps to advance. + :param current_item: Optional item to set as ``current_item`` + for the updated position. + + .. versionchanged:: 8.0 + Added the ``current_item`` optional parameter. + + .. versionchanged:: 8.0 + Only render when the number of steps meets the + ``update_min_steps`` threshold. + """ + if current_item is not None: + self.current_item = current_item + + self._completed_intervals += n_steps + + if self._completed_intervals >= self.update_min_steps: + self.make_step(self._completed_intervals) + self.render_progress() + self._completed_intervals = 0 + + def finish(self) -> None: + self.eta_known = False + self.current_item = None + self.finished = True + + def generator(self) -> cabc.Iterator[V]: + """Return a generator which yields the items added to the bar + during construction, and updates the progress bar *after* the + yielded block returns. + """ + # WARNING: the iterator interface for `ProgressBar` relies on + # this and only works because this is a simple generator which + # doesn't create or manage additional state. If this function + # changes, the impact should be evaluated both against + # `iter(bar)` and `next(bar)`. `next()` in particular may call + # `self.generator()` repeatedly, and this must remain safe in + # order for that interface to work. + if not self.entered: + raise RuntimeError("You need to use progress bars in a with block.") + + if not self._is_atty: + yield from self.iter + else: + for rv in self.iter: + self.current_item = rv + + # This allows show_item_func to be updated before the + # item is processed. Only trigger at the beginning of + # the update interval. + if self._completed_intervals == 0: + self.render_progress() + + yield rv + self.update(1) + + self.finish() + self.render_progress() + + +class MaybeStripAnsi(io.TextIOWrapper): + def __init__(self, stream: t.IO[bytes], *, color: bool, **kwargs: t.Any): + super().__init__(stream, **kwargs) + self.color = color + + def write(self, text: str) -> int: + if not self.color: + text = strip_ansi(text) + return super().write(text) + + +def _pager_contextmanager( + color: bool | None = None, +) -> t.ContextManager[tuple[t.BinaryIO | t.TextIO, str, bool]]: + """Decide what method to use for paging through text.""" + stdout = _default_text_stdout() + + # There are no standard streams attached to write to. For example, + # pythonw on Windows. + if stdout is None: + stdout = StringIO() + + if not isatty(sys.stdin) or not isatty(stdout): + return _nullpager(stdout, color) + + # Split using POSIX mode (the default) so that quote characters are + # stripped from tokens and quoted Windows paths are preserved. + # Non-POSIX mode retains quotes in tokens, and wrapping tokens + # with shlex.quote re-introduces quoting issues on Windows. + pager_cmd_parts = shlex.split(os.environ.get("PAGER", "")) + if pager_cmd_parts: + if WIN: + return _tempfilepager(pager_cmd_parts, color) + return _pipepager(pager_cmd_parts, color) + + if os.environ.get("TERM") in ("dumb", "emacs"): + return _nullpager(stdout, color) + if WIN or sys.platform.startswith("os2"): + return _tempfilepager(["more"], color) + return _pipepager(["less"], color) + + +@contextlib.contextmanager +def get_pager_file(color: bool | None = None) -> t.Generator[t.TextIO, None, None]: + """Context manager. + Yields a writable file-like object which can be used as an output pager. + .. versionadded:: 8.4 + :param color: controls if the pager supports ANSI colors or not. The + default is autodetection. + """ + with _pager_contextmanager(color=color) as (stream, encoding, color): + # Split streams by capabilities rather than the abstract TextIO / + # BinaryIO annotations: buffered text streams can be unwrapped to bytes, + # while text-only streams are yielded as-is. + if _has_binary_buffer(stream): + # Text stream backed by a binary buffer. + stream = MaybeStripAnsi(stream.buffer, color=color, encoding=encoding) + elif isinstance(stream, t.BinaryIO): + # Binary stream + stream = MaybeStripAnsi(stream, color=color, encoding=encoding) + try: + yield stream + finally: + stream.flush() + + +@contextlib.contextmanager +def _pipepager( + cmd_parts: list[str], color: bool | None = None +) -> t.Iterator[tuple[t.BinaryIO | t.TextIO, str, bool]]: + """Page through text by feeding it to another program. + + Invokes the pager via :class:`subprocess.Popen` with an ``argv`` list + produced by :func:`shlex.split`. The command is resolved to an absolute + path with :func:`shutil.which` as recommended by the + :mod:`subprocess` docs for Windows compatibility. + + Invoking a pager through this might support colors: if piping to + ``less`` and the user hasn't decided on colors, ``LESS=-R`` is set + automatically. + """ + # Split the command into the invoked CLI and its parameters. + if not cmd_parts: + stdout = _default_text_stdout() or StringIO() + yield stdout, "utf-8", False + return + + import shutil + + cmd = cmd_parts[0] + cmd_params = cmd_parts[1:] + + cmd_filepath = shutil.which(cmd) + if not cmd_filepath: + stdout = _default_text_stdout() or StringIO() + yield stdout, "utf-8", False + return + + # Produces a normalized absolute path string. + # multi-call binaries such as busybox derive their identity from the symlink + # less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox) + cmd_path = Path(cmd_filepath).absolute() + cmd_name = cmd_path.name + + import subprocess + + # Make a local copy of the environment to not affect the global one. + env = dict(os.environ) + + # If we're piping to less and the user hasn't decided on colors, we enable + # them by default we find the -R flag in the command line arguments. + if color is None and cmd_name == "less": + less_flags = f"{os.environ.get('LESS', '')}{' '.join(cmd_params)}" + if not less_flags: + env["LESS"] = "-R" + color = True + elif "r" in less_flags or "R" in less_flags: + color = True + + if color is None: + color = False + + c = subprocess.Popen( + [str(cmd_path)] + cmd_params, + shell=False, + stdin=subprocess.PIPE, + env=env, + errors="replace", + text=True, + ) + stdin = t.cast(t.BinaryIO, c.stdin) + encoding = get_best_encoding(stdin) + try: + yield stdin, encoding, color + except BrokenPipeError: + # In case the pager exited unexpectedly, ignore the broken pipe error. + pass + except Exception as e: + # In case there is an exception we want to close the pager immediately + # and let the caller handle it. + # Otherwise the pager will keep running, and the user may not notice + # the error message, or worse yet it may leave the terminal in a broken state. + c.terminate() + raise e + finally: + # We must close stdin and wait for the pager to exit before we continue + try: + stdin.close() + # Close implies flush, so it might throw a BrokenPipeError if the pager + # process exited already. + except BrokenPipeError: + pass + + # Less doesn't respect ^C, but catches it for its own UI purposes (aborting + # search or other commands inside less). + # + # That means when the user hits ^C, the parent process (click) terminates, + # but less is still alive, paging the output and messing up the terminal. + # + # If the user wants to make the pager exit on ^C, they should set + # `LESS='-K'`. It's not our decision to make. + while True: + try: + c.wait() + except KeyboardInterrupt: + pass + else: + break + + +@contextlib.contextmanager +def _tempfilepager( + cmd_parts: list[str], color: bool | None = None +) -> t.Iterator[tuple[t.BinaryIO | t.TextIO, str, bool]]: + """Page through text by invoking a program on a temporary file. + + Used as the primary pager strategy on Windows (where piping to + ``more`` adds spurious ``\\r\\n``), and as a fallback on other + platforms. The command is resolved to an absolute path with + :func:`shutil.which`. + """ + # Split the command into the invoked CLI and its parameters. + if not cmd_parts: + stdout = _default_text_stdout() or StringIO() + yield stdout, "utf-8", False + return + + import shutil + import subprocess + + cmd = cmd_parts[0] + + cmd_filepath = shutil.which(cmd) + if not cmd_filepath: + stdout = _default_text_stdout() or StringIO() + yield stdout, "utf-8", False + return + + # Produces a normalized absolute path string. + # multi-call binaries such as busybox derive their identity from the symlink + # less -> busybox. resolve() causes them to misbehave. (eg. less becomes busybox) + cmd_path = Path(cmd_filepath).absolute() + + import tempfile + + encoding = get_best_encoding(sys.stdout) + if color is None: + color = False + # On Windows, NamedTemporaryFile cannot be opened by another process + # while Python still has it open, so we use delete=False and clean up manually + # rather than using a contextmanager here. + f = tempfile.NamedTemporaryFile(mode="wb", delete=False) + try: + yield t.cast(t.BinaryIO, f), encoding, color + f.flush() + f.close() + subprocess.call([str(cmd_path), f.name]) + finally: + os.unlink(f.name) + + +class _SkipClose: + def __init__(self, stream: t.IO[t.Any]) -> None: + self.stream = stream + + def __getattr__(self, name: str) -> t.Any: + return getattr(self.stream, name) + + @property + def buffer(self) -> t.BinaryIO: + return _SkipClose(self.stream.buffer) # type: ignore[attr-defined, return-value] + + def close(self) -> None: + pass + + +@contextlib.contextmanager +def _nullpager( + stream: t.TextIO, color: bool | None = None +) -> t.Iterator[tuple[t.TextIO, str, bool]]: + """Simply print unformatted text. This is the ultimate fallback. Don't close the + output stream in this case, since it's coming from elsewhere rather than our + internal helpers. + """ + encoding = get_best_encoding(stream) + + if color is None: + color = False + + yield _SkipClose(stream), encoding, color # type: ignore[misc] + + +class Editor: + def __init__( + self, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = True, + extension: str = ".txt", + ) -> None: + self.editor = editor + self.env = env + self.require_save = require_save + self.extension = extension + + def get_editor(self) -> str: + if self.editor is not None: + return self.editor + for key in "VISUAL", "EDITOR": + rv = os.environ.get(key) + if rv: + return rv + if WIN: + return "notepad" + + from shutil import which + + for editor in "sensible-editor", "vim", "nano": + if which(editor) is not None: + return editor + return "vi" + + def edit_files(self, filenames: cabc.Iterable[str]) -> None: + """Open files in the user's editor.""" + import shlex + import subprocess + + editor = self.get_editor() + environ: dict[str, str] | None = None + + if self.env: + environ = os.environ.copy() + environ.update(self.env) + + try: + # Split in POSIX mode (the default) for the same reasons as + # in pager(): strips quotes from tokens and preserves quoted + # Windows paths. + c = subprocess.Popen( + args=shlex.split(editor) + list(filenames), + env=environ, + ) + exit_code = c.wait() + if exit_code != 0: + raise ClickException( + _("{editor}: Editing failed").format(editor=editor) + ) + except OSError as e: + raise ClickException( + _("{editor}: Editing failed: {e}").format(editor=editor, e=e) + ) from e + + @t.overload + def edit(self, text: bytes | bytearray) -> bytes | None: ... + + # We cannot know whether or not the type expected is str or bytes when None + # is passed, so str is returned as that was what was done before. + @t.overload + def edit(self, text: str | None) -> str | None: ... + + def edit(self, text: str | bytes | bytearray | None) -> str | bytes | None: + import tempfile + + if text is None: + data: bytes | bytearray = b"" + elif isinstance(text, (bytes, bytearray)): + data = text + else: + if text and not text.endswith("\n"): + text += "\n" + + if WIN: + data = text.replace("\n", "\r\n").encode("utf-8-sig") + else: + data = text.encode("utf-8") + + fd, name = tempfile.mkstemp(prefix="editor-", suffix=self.extension) + f: t.BinaryIO + + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + + # If the filesystem resolution is 1 second, like Mac OS + # 10.12 Extended, or 2 seconds, like FAT32, and the editor + # closes very fast, require_save can fail. Set the modified + # time to be 2 seconds in the past to work around this. + os.utime(name, (os.path.getatime(name), os.path.getmtime(name) - 2)) + # Depending on the resolution, the exact value might not be + # recorded, so get the new recorded value. + timestamp = os.path.getmtime(name) + + self.edit_files((name,)) + + if self.require_save and os.path.getmtime(name) == timestamp: + return None + + with open(name, "rb") as f: + rv = f.read() + + if isinstance(text, (bytes, bytearray)): + return rv + + return rv.decode("utf-8-sig").replace("\r\n", "\n") + finally: + os.unlink(name) + + +def open_url(url: str, wait: bool = False, locate: bool = False) -> int: + import subprocess + + def _unquote_file(url: str) -> str: + from urllib.parse import unquote + + if url.startswith("file://"): + url = unquote(url[7:]) + + return url + + if sys.platform == "darwin": + args = ["open"] + if wait: + args.append("-W") + if locate: + args.append("-R") + args.append(_unquote_file(url)) + null = open("/dev/null", "w") + try: + return subprocess.Popen(args, stderr=null).wait() + finally: + null.close() + elif WIN: + if locate: + url = _unquote_file(url) + args = ["explorer", "/select,", url] + try: + return subprocess.call(args) + except OSError: + return 127 + else: + try: + os.startfile(url) # type: ignore[attr-defined] + except OSError: + return 127 + return 0 + elif CYGWIN: + if locate: + url = _unquote_file(url) + args = ["cygstart", os.path.dirname(url)] + else: + args = ["cygstart"] + if wait: + args.append("-w") + args.append(url) + try: + return subprocess.call(args) + except OSError: + # Command not found + return 127 + + try: + if locate: + url = os.path.dirname(_unquote_file(url)) or "." + else: + url = _unquote_file(url) + c = subprocess.Popen(["xdg-open", url]) + if wait: + return c.wait() + return 0 + except OSError: + if url.startswith(("http://", "https://")) and not locate and not wait: + import webbrowser + + webbrowser.open(url) + return 0 + return 1 + + +def _translate_ch_to_exc(ch: str) -> None: + if ch == "\x03": + raise KeyboardInterrupt() + + if ch == "\x04" and not WIN: # Unix-like, Ctrl+D + raise EOFError() + + if ch == "\x1a" and WIN: # Windows, Ctrl+Z + raise EOFError() + + +if sys.platform == "win32": + import msvcrt + + @contextlib.contextmanager + def raw_terminal() -> cabc.Iterator[int]: + yield -1 + + def getchar(echo: bool) -> str: + # The function `getch` will return a bytes object corresponding to + # the pressed character. Since Windows 10 build 1803, it will also + # return \x00 when called a second time after pressing a regular key. + # + # `getwch` does not share this probably-bugged behavior. Moreover, it + # returns a Unicode object by default, which is what we want. + # + # Either of these functions will return \x00 or \xe0 to indicate + # a special key, and you need to call the same function again to get + # the "rest" of the code. The fun part is that \u00e0 is + # "latin small letter a with grave", so if you type that on a French + # keyboard, you _also_ get a \xe0. + # E.g., consider the Up arrow. This returns \xe0 and then \x48. The + # resulting Unicode string reads as "a with grave" + "capital H". + # This is indistinguishable from when the user actually types + # "a with grave" and then "capital H". + # + # When \xe0 is returned, we assume it's part of a special-key sequence + # and call `getwch` again, but that means that when the user types + # the \u00e0 character, `getchar` doesn't return until a second + # character is typed. + # The alternative is returning immediately, but that would mess up + # cross-platform handling of arrow keys and others that start with + # \xe0. Another option is using `getch`, but then we can't reliably + # read non-ASCII characters, because return values of `getch` are + # limited to the current 8-bit codepage. + # + # Anyway, Click doesn't claim to do this Right(tm), and using `getwch` + # is doing the right thing in more situations than with `getch`. + + if echo: + func = t.cast(t.Callable[[], str], msvcrt.getwche) + else: + func = t.cast(t.Callable[[], str], msvcrt.getwch) + + rv = func() + + if rv in ("\x00", "\xe0"): + # \x00 and \xe0 are control characters that indicate special key, + # see above. + rv += func() + + _translate_ch_to_exc(rv) + return rv + +else: + import termios + import tty + + @contextlib.contextmanager + def raw_terminal() -> cabc.Iterator[int]: + f: t.TextIO | None + fd: int + + if not isatty(sys.stdin): + f = open("/dev/tty") + fd = f.fileno() + else: + fd = sys.stdin.fileno() + f = None + + try: + old_settings = termios.tcgetattr(fd) + + try: + tty.setraw(fd) + yield fd + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + sys.stdout.flush() + + if f is not None: + f.close() + except termios.error: + pass + + def getchar(echo: bool) -> str: + with raw_terminal() as fd: + ch = os.read(fd, 32).decode(get_best_encoding(sys.stdin), "replace") + + if echo and isatty(sys.stdout): + sys.stdout.write(ch) + + _translate_ch_to_exc(ch) + return ch diff --git a/server/venv/Lib/site-packages/click/_textwrap.py b/server/venv/Lib/site-packages/click/_textwrap.py new file mode 100644 index 0000000..82840f2 --- /dev/null +++ b/server/venv/Lib/site-packages/click/_textwrap.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import collections.abc as cabc +import textwrap +from contextlib import contextmanager + +from ._compat import _ansi_re +from ._compat import term_len + + +def _truncate_visible(text: str, n: int) -> str: + """Return the longest prefix of ``text`` containing at most ``n`` visible + characters. + + ANSI escape sequences inside the prefix are kept intact and do not count + toward the visible width. A cut is never placed inside an escape sequence. + """ + if n <= 0: + return "" + + visible = 0 + i = 0 + cut = 0 + end = len(text) + while i < end: + m = _ansi_re.match(text, i) + if m is not None: + i = m.end() + continue + visible += 1 + i += 1 + cut = i + if visible >= n: + break + return text[:cut] + + +class TextWrapper(textwrap.TextWrapper): + """``textwrap.TextWrapper`` variant that measures widths by visible + character count. + + ANSI escape sequences embedded in chunks, indents, or the placeholder are + excluded from the width budget. Without this, styled help text (a styled + ``Usage:`` prefix, a colorized option name, ...) would be wrapped earlier + than its visible length warrants and tokens would split mid-word. + """ + + def _handle_long_word( + self, + reversed_chunks: list[str], + cur_line: list[str], + cur_len: int, + width: int, + ) -> None: + space_left = max(width - cur_len, 1) + + if self.break_long_words: + last = reversed_chunks[-1] + cut = _truncate_visible(last, space_left) + res = last[len(cut) :] + cur_line.append(cut) + reversed_chunks[-1] = res + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + def _wrap_chunks(self, chunks: list[str]) -> list[str]: + """Wrap chunks counting widths in visible characters. + + Mirrors the algorithm of :meth:`textwrap.TextWrapper._wrap_chunks` + with every width measurement routed through + :func:`click._compat.term_len` instead of :func:`len`, so ANSI escape + bytes in chunks, indents, or the placeholder do not inflate the count. + + .. seealso:: + :class:`textwrap.TextWrapper` in the Python standard library documentation: + https://docs.python.org/3/library/textwrap.html#textwrap.TextWrapper + + Reference implementation in CPython: + https://github.com/python/cpython/blob/main/Lib/textwrap.py + """ + lines: list[str] = [] + if self.width <= 0: + raise ValueError(f"invalid width {self.width!r} (must be > 0)") + if self.max_lines is not None: + if self.max_lines > 1: + indent = self.subsequent_indent + else: + indent = self.initial_indent + if term_len(indent) + term_len(self.placeholder.lstrip()) > self.width: + raise ValueError("placeholder too large for max width") + + chunks.reverse() + + while chunks: + cur_line: list[str] = [] + cur_len = 0 + + if lines: + indent = self.subsequent_indent + else: + indent = self.initial_indent + + width = self.width - term_len(indent) + + if self.drop_whitespace and chunks[-1].strip() == "" and lines: + del chunks[-1] + + while chunks: + n = term_len(chunks[-1]) + + if cur_len + n <= width: + cur_line.append(chunks.pop()) + cur_len += n + + else: + break + + if chunks and term_len(chunks[-1]) > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + cur_len = sum(map(term_len, cur_line)) + + if self.drop_whitespace and cur_line and cur_line[-1].strip() == "": + cur_len -= term_len(cur_line[-1]) + del cur_line[-1] + + if cur_line: + if ( + self.max_lines is None + or len(lines) + 1 < self.max_lines + or ( + not chunks + or self.drop_whitespace + and len(chunks) == 1 + and not chunks[0].strip() + ) + and cur_len <= width + ): + lines.append(indent + "".join(cur_line)) + else: + while cur_line: + if ( + cur_line[-1].strip() + and cur_len + term_len(self.placeholder) <= width + ): + cur_line.append(self.placeholder) + lines.append(indent + "".join(cur_line)) + break + cur_len -= term_len(cur_line[-1]) + del cur_line[-1] + else: + if lines: + prev_line = lines[-1].rstrip() + if ( + term_len(prev_line) + term_len(self.placeholder) + <= self.width + ): + lines[-1] = prev_line + self.placeholder + break + lines.append(indent + self.placeholder.lstrip()) + break + + return lines + + @contextmanager + def extra_indent(self, indent: str) -> cabc.Iterator[None]: + old_initial_indent = self.initial_indent + old_subsequent_indent = self.subsequent_indent + self.initial_indent += indent + self.subsequent_indent += indent + + try: + yield + finally: + self.initial_indent = old_initial_indent + self.subsequent_indent = old_subsequent_indent + + def indent_only(self, text: str) -> str: + rv = [] + + for idx, line in enumerate(text.splitlines()): + indent = self.initial_indent + + if idx > 0: + indent = self.subsequent_indent + + rv.append(f"{indent}{line}") + + return "\n".join(rv) diff --git a/server/venv/Lib/site-packages/click/_utils.py b/server/venv/Lib/site-packages/click/_utils.py new file mode 100644 index 0000000..05ee2e9 --- /dev/null +++ b/server/venv/Lib/site-packages/click/_utils.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import enum +import typing as t + + +class Sentinel(enum.Enum): + """Enum used to define sentinel values. + + .. seealso:: + + `PEP 661 - Sentinel Values `_. + """ + + UNSET = object() + FLAG_NEEDS_VALUE = object() + + def __repr__(self) -> str: + return f"{self.__class__.__name__}.{self.name}" + + +UNSET: t.Literal[Sentinel.UNSET] = Sentinel.UNSET +"""Sentinel used to indicate that a value is not set.""" + +FLAG_NEEDS_VALUE: t.Literal[Sentinel.FLAG_NEEDS_VALUE] = Sentinel.FLAG_NEEDS_VALUE +"""Sentinel used to indicate an option was passed as a flag without a +value but is not a flag option. + +``Option.consume_value`` uses this to prompt or use the ``flag_value``. +""" + +T_UNSET: t.TypeAlias = t.Literal[Sentinel.UNSET] +"""Type hint for the :data:`UNSET` sentinel value.""" + +T_FLAG_NEEDS_VALUE: t.TypeAlias = t.Literal[Sentinel.FLAG_NEEDS_VALUE] +"""Type hint for the :data:`FLAG_NEEDS_VALUE` sentinel value.""" diff --git a/server/venv/Lib/site-packages/click/_winconsole.py b/server/venv/Lib/site-packages/click/_winconsole.py new file mode 100644 index 0000000..d25178d --- /dev/null +++ b/server/venv/Lib/site-packages/click/_winconsole.py @@ -0,0 +1,297 @@ +# This module is based on the excellent work by Adam Bartoš who +# provided a lot of what went into the implementation here in +# the discussion to issue1602 in the Python bug tracker. +# +# There are some general differences in regards to how this works +# compared to the original patches as we do not need to patch +# the entire interpreter but just work in our little world of +# echo and prompt. +from __future__ import annotations + +import collections.abc as cabc +import io +import sys +import time +import typing as t +from ctypes import Array +from ctypes import byref +from ctypes import c_char +from ctypes import c_char_p +from ctypes import c_int +from ctypes import c_ssize_t +from ctypes import c_ulong +from ctypes import c_void_p +from ctypes import POINTER +from ctypes import py_object +from ctypes import Structure +from ctypes.wintypes import DWORD +from ctypes.wintypes import HANDLE +from ctypes.wintypes import LPCWSTR +from ctypes.wintypes import LPWSTR +from gettext import gettext as _ + +from ._compat import _NonClosingTextIOWrapper + +assert sys.platform == "win32" +import msvcrt # noqa: E402 +from ctypes import windll # noqa: E402 +from ctypes import WINFUNCTYPE # noqa: E402 + +c_ssize_p = POINTER(c_ssize_t) + +kernel32 = windll.kernel32 +GetStdHandle = kernel32.GetStdHandle +ReadConsoleW = kernel32.ReadConsoleW +WriteConsoleW = kernel32.WriteConsoleW +GetConsoleMode = kernel32.GetConsoleMode +GetLastError = kernel32.GetLastError +GetCommandLineW = WINFUNCTYPE(LPWSTR)(("GetCommandLineW", windll.kernel32)) +CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int))( + ("CommandLineToArgvW", windll.shell32) +) +LocalFree = WINFUNCTYPE(c_void_p, c_void_p)(("LocalFree", windll.kernel32)) + +STDIN_HANDLE = GetStdHandle(-10) +STDOUT_HANDLE = GetStdHandle(-11) +STDERR_HANDLE = GetStdHandle(-12) + +PyBUF_SIMPLE = 0 +PyBUF_WRITABLE = 1 + +ERROR_SUCCESS = 0 +ERROR_NOT_ENOUGH_MEMORY = 8 +ERROR_OPERATION_ABORTED = 995 + +STDIN_FILENO = 0 +STDOUT_FILENO = 1 +STDERR_FILENO = 2 + +EOF = b"\x1a" +MAX_BYTES_WRITTEN = 32767 + +if t.TYPE_CHECKING: + try: + # Using `typing_extensions.Buffer` instead of `collections.abc` + # on Windows for some reason does not have `Sized` implemented. + from collections.abc import Buffer # type: ignore + except ImportError: + from typing_extensions import Buffer + +try: + from ctypes import pythonapi +except ImportError: + # On PyPy we cannot get buffers so our ability to operate here is + # severely limited. + get_buffer = None +else: + + class Py_buffer(Structure): + _fields_ = [ # noqa: RUF012 + ("buf", c_void_p), + ("obj", py_object), + ("len", c_ssize_t), + ("itemsize", c_ssize_t), + ("readonly", c_int), + ("ndim", c_int), + ("format", c_char_p), + ("shape", c_ssize_p), + ("strides", c_ssize_p), + ("suboffsets", c_ssize_p), + ("internal", c_void_p), + ] + + PyObject_GetBuffer = pythonapi.PyObject_GetBuffer + PyBuffer_Release = pythonapi.PyBuffer_Release + + def get_buffer(obj: Buffer, writable: bool = False) -> Array[c_char]: + buf = Py_buffer() + flags: int = PyBUF_WRITABLE if writable else PyBUF_SIMPLE + PyObject_GetBuffer(py_object(obj), byref(buf), flags) + + try: + buffer_type = c_char * buf.len + out: Array[c_char] = buffer_type.from_address(buf.buf) + return out + finally: + PyBuffer_Release(byref(buf)) + + +class _WindowsConsoleRawIOBase(io.RawIOBase): + def __init__(self, handle: int | None) -> None: + self.handle = handle + + def isatty(self) -> t.Literal[True]: + super().isatty() + return True + + +class _WindowsConsoleReader(_WindowsConsoleRawIOBase): + def readable(self) -> t.Literal[True]: + return True + + def readinto(self, b: Buffer) -> int: + bytes_to_be_read = len(b) + if not bytes_to_be_read: + return 0 + elif bytes_to_be_read % 2: + raise ValueError( + "cannot read odd number of bytes from UTF-16-LE encoded console" + ) + + buffer = get_buffer(b, writable=True) + code_units_to_be_read = bytes_to_be_read // 2 + code_units_read = c_ulong() + + rv = ReadConsoleW( + HANDLE(self.handle), + buffer, + code_units_to_be_read, + byref(code_units_read), + None, + ) + if GetLastError() == ERROR_OPERATION_ABORTED: + # wait for KeyboardInterrupt + time.sleep(0.1) + if not rv: + raise OSError(_("Windows error: {error}").format(error=GetLastError())) + + if buffer[0] == EOF: + return 0 + return 2 * code_units_read.value + + +class _WindowsConsoleWriter(_WindowsConsoleRawIOBase): + def writable(self) -> t.Literal[True]: + return True + + @staticmethod + def _get_error_message(errno: int) -> str: + if errno == ERROR_SUCCESS: + return "ERROR_SUCCESS" + elif errno == ERROR_NOT_ENOUGH_MEMORY: + return "ERROR_NOT_ENOUGH_MEMORY" + return _("Windows error: {error}").format(error=errno) + + def write(self, b: Buffer) -> int: + bytes_to_be_written = len(b) + buf = get_buffer(b) + code_units_to_be_written = min(bytes_to_be_written, MAX_BYTES_WRITTEN) // 2 + code_units_written = c_ulong() + + WriteConsoleW( + HANDLE(self.handle), + buf, + code_units_to_be_written, + byref(code_units_written), + None, + ) + bytes_written = 2 * code_units_written.value + + if bytes_written == 0 and bytes_to_be_written > 0: + raise OSError(self._get_error_message(GetLastError())) + return bytes_written + + +class ConsoleStream: + def __init__(self, text_stream: t.TextIO, byte_stream: t.BinaryIO) -> None: + self._text_stream = text_stream + self.buffer = byte_stream + + @property + def name(self) -> str: + return self.buffer.name + + def write(self, x: t.AnyStr) -> int: + if isinstance(x, str): + return self._text_stream.write(x) + try: + self.flush() + except Exception: + pass + return self.buffer.write(x) + + def writelines(self, lines: cabc.Iterable[t.AnyStr]) -> None: + for line in lines: + self.write(line) + + def __getattr__(self, name: str) -> t.Any: + return getattr(self._text_stream, name) + + def isatty(self) -> bool: + return self.buffer.isatty() + + def __repr__(self) -> str: + return f"" + + +def _get_text_stdin(buffer_stream: t.BinaryIO) -> t.TextIO: + text_stream = _NonClosingTextIOWrapper( + io.BufferedReader(_WindowsConsoleReader(STDIN_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) + + +def _get_text_stdout(buffer_stream: t.BinaryIO) -> t.TextIO: + text_stream = _NonClosingTextIOWrapper( + io.BufferedWriter(_WindowsConsoleWriter(STDOUT_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) + + +def _get_text_stderr(buffer_stream: t.BinaryIO) -> t.TextIO: + text_stream = _NonClosingTextIOWrapper( + io.BufferedWriter(_WindowsConsoleWriter(STDERR_HANDLE)), + "utf-16-le", + "strict", + line_buffering=True, + ) + return t.cast(t.TextIO, ConsoleStream(text_stream, buffer_stream)) + + +_stream_factories: cabc.Mapping[int, t.Callable[[t.BinaryIO], t.TextIO]] = { + 0: _get_text_stdin, + 1: _get_text_stdout, + 2: _get_text_stderr, +} + + +def _is_console(f: t.TextIO) -> bool: + if not hasattr(f, "fileno"): + return False + + try: + fileno = f.fileno() + except (OSError, io.UnsupportedOperation): + return False + + handle = msvcrt.get_osfhandle(fileno) + return bool(GetConsoleMode(handle, byref(DWORD()))) + + +def _get_windows_console_stream( + f: t.TextIO, encoding: str | None, errors: str | None +) -> t.TextIO | None: + if ( + get_buffer is None + or encoding not in {"utf-16-le", None} + or errors not in {"strict", None} + or not _is_console(f) + ): + return None + + func = _stream_factories.get(f.fileno()) + if func is None: + return None + + b = getattr(f, "buffer", None) + + if b is None: + return None + + return func(b) diff --git a/server/venv/Lib/site-packages/click/core.py b/server/venv/Lib/site-packages/click/core.py new file mode 100644 index 0000000..bc04f64 --- /dev/null +++ b/server/venv/Lib/site-packages/click/core.py @@ -0,0 +1,3542 @@ +from __future__ import annotations + +import collections.abc as cabc +import enum +import errno +import inspect +import os +import sys +import typing as t +from abc import ABC +from abc import abstractmethod +from collections import abc +from collections import Counter +from contextlib import AbstractContextManager +from contextlib import contextmanager +from contextlib import ExitStack +from functools import update_wrapper +from gettext import gettext as _ +from gettext import ngettext +from itertools import repeat +from types import TracebackType + +from . import types +from ._utils import FLAG_NEEDS_VALUE +from ._utils import UNSET +from .exceptions import Abort +from .exceptions import BadParameter +from .exceptions import ClickException +from .exceptions import Exit +from .exceptions import MissingParameter +from .exceptions import NoArgsIsHelpError +from .exceptions import NoSuchCommand +from .exceptions import UsageError +from .formatting import HelpFormatter +from .formatting import join_options +from .globals import pop_context +from .globals import push_context +from .parser import _OptionParser +from .parser import _split_opt +from .termui import confirm +from .termui import prompt +from .termui import style +from .utils import _detect_program_name +from .utils import _expand_args +from .utils import echo +from .utils import make_default_short_help +from .utils import make_str +from .utils import PacifyFlushWrapper + +if t.TYPE_CHECKING: + from .shell_completion import CompletionItem + +F = t.TypeVar("F", bound="t.Callable[..., t.Any]") +V = t.TypeVar("V") + + +def _complete_visible_commands( + ctx: Context, incomplete: str +) -> cabc.Iterator[tuple[str, Command]]: + """List all the subcommands of a group that start with the + incomplete value and aren't hidden. + + :param ctx: Invocation context for the group. + :param incomplete: Value being completed. May be empty. + """ + multi = t.cast(Group, ctx.command) + + for name in multi.list_commands(ctx): + if name.startswith(incomplete): + command = multi.get_command(ctx, name) + + if command is not None and not command.hidden: + yield name, command + + +def _check_nested_chain( + base_command: Group, cmd_name: str, cmd: Command, register: bool = False +) -> None: + if not base_command.chain or not isinstance(cmd, Group): + return + + if register: + message = ( + f"It is not possible to add the group {cmd_name!r} to another" + f" group {base_command.name!r} that is in chain mode." + ) + else: + message = ( + f"Found the group {cmd_name!r} as subcommand to another group " + f" {base_command.name!r} that is in chain mode. This is not supported." + ) + + raise RuntimeError(message) + + +def _format_deprecated_label(deprecated: bool | str) -> str: + """Return the parenthesized deprecation label shown in help text.""" + label = _("deprecated").upper() + if isinstance(deprecated, str): + return f"({label}: {deprecated})" + return f"({label})" + + +def _format_deprecated_suffix(deprecated: bool | str) -> str: + """Return the trailing reason for a ``DeprecationWarning`` message, + prefixed with a space, or an empty string when no reason was given. + """ + if isinstance(deprecated, str): + return f" {deprecated}" + return "" + + +def batch(iterable: cabc.Iterable[V], batch_size: int) -> list[tuple[V, ...]]: + return list(zip(*repeat(iter(iterable), batch_size), strict=False)) + + +@contextmanager +def augment_usage_errors( + ctx: Context, param: Parameter | None = None +) -> cabc.Iterator[None]: + """Context manager that attaches extra information to exceptions.""" + try: + yield + except BadParameter as e: + if e.ctx is None: + e.ctx = ctx + if param is not None and e.param is None: + e.param = param + raise + except UsageError as e: + if e.ctx is None: + e.ctx = ctx + raise + + +def iter_params_for_processing( + invocation_order: cabc.Sequence[Parameter], + declaration_order: cabc.Sequence[Parameter], +) -> list[Parameter]: + """Returns all declared parameters in the order they should be processed. + + The declared parameters are re-shuffled depending on the order in which + they were invoked, as well as the eagerness of each parameters. + + The invocation order takes precedence over the declaration order. I.e. the + order in which the user provided them to the CLI is respected. + + This behavior and its effect on callback evaluation is detailed at: + https://click.palletsprojects.com/en/stable/advanced/#callback-evaluation-order + """ + + def sort_key(item: Parameter) -> tuple[bool, float]: + try: + idx: float = invocation_order.index(item) + except ValueError: + idx = float("inf") + + return not item.is_eager, idx + + return sorted(declaration_order, key=sort_key) + + +class ParameterSource(enum.IntEnum): + """This is an :class:`~enum.IntEnum` that indicates the source of a + parameter's value. + + Use :meth:`click.Context.get_parameter_source` to get the + source for a parameter by name. + + Members are ordered from most explicit to least explicit source. + This allows comparison to check if a value was explicitly provided: + + .. code-block:: python + + source = ctx.get_parameter_source("port") + if source < click.ParameterSource.DEFAULT_MAP: + ... # value was explicitly set + + .. versionchanged:: 8.3.3 + Use :class:`~enum.IntEnum` and reorder members from most to + least explicit. Supports comparison operators. + + .. versionchanged:: 8.0 + Use :class:`~enum.Enum` and drop the ``validate`` method. + + .. versionchanged:: 8.0 + Added the ``PROMPT`` value. + """ + + PROMPT = enum.auto() + """Used a prompt to confirm a default or provide a value.""" + COMMANDLINE = enum.auto() + """The value was provided by the command line args.""" + ENVIRONMENT = enum.auto() + """The value was provided with an environment variable.""" + DEFAULT_MAP = enum.auto() + """Used a default provided by :attr:`Context.default_map`.""" + DEFAULT = enum.auto() + """Used the default specified by the parameter.""" + + +class Context: + """The context is a special internal object that holds state relevant + for the script execution at every single level. It's normally invisible + to commands unless they opt-in to getting access to it. + + The context is useful as it can pass internal objects around and can + control special execution features such as reading data from + environment variables. + + A context can be used as context manager in which case it will call + :meth:`close` on teardown. + + :param command: the command class for this context. + :param parent: the parent context. + :param info_name: the info name for this invocation. Generally this + is the most descriptive name for the script or + command. For the toplevel script it is usually + the name of the script, for commands below that it's + the name of the script. + :param obj: an arbitrary object of user data. + :param auto_envvar_prefix: the prefix to use for automatic environment + variables. If this is `None` then reading + from environment variables is disabled. This + does not affect manually set environment + variables which are always read. + :param default_map: a dictionary (like object) with default values + for parameters. + :param terminal_width: the width of the terminal. The default is + inherit from parent context. If no context + defines the terminal width then auto + detection will be applied. + :param max_content_width: the maximum width for content rendered by + Click (this currently only affects help + pages). This defaults to 80 characters if + not overridden. In other words: even if the + terminal is larger than that, Click will not + format things wider than 80 characters by + default. In addition to that, formatters might + add some safety mapping on the right. + :param resilient_parsing: if this flag is enabled then Click will + parse without any interactivity or callback + invocation. Default values will also be + ignored. This is useful for implementing + things such as completion support. + :param allow_extra_args: if this is set to `True` then extra arguments + at the end will not raise an error and will be + kept on the context. The default is to inherit + from the command. + :param allow_interspersed_args: if this is set to `False` then options + and arguments cannot be mixed. The + default is to inherit from the command. + :param ignore_unknown_options: instructs click to ignore options it does + not know and keeps them for later + processing. + :param help_option_names: optionally a list of strings that define how + the default help parameter is named. The + default is ``['--help']``. + :param token_normalize_func: an optional function that is used to + normalize tokens (options, choices, + etc.). This for instance can be used to + implement case insensitive behavior. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are used in texts that Click prints which is by + default not the case. This for instance would affect + help output. + :param show_default: Show the default value for commands. If this + value is not set, it defaults to the value from the parent + context. ``Command.show_default`` overrides this default for the + specific command. + + .. versionchanged:: 8.2 + The ``protected_args`` attribute is deprecated and will be removed in + Click 9.0. ``args`` will contain remaining unparsed tokens. + + .. versionchanged:: 8.1 + The ``show_default`` parameter is overridden by + ``Command.show_default``, instead of the other way around. + + .. versionchanged:: 8.0 + The ``show_default`` parameter defaults to the value from the + parent context. + + .. versionchanged:: 7.1 + Added the ``show_default`` parameter. + + .. versionchanged:: 4.0 + Added the ``color``, ``ignore_unknown_options``, and + ``max_content_width`` parameters. + + .. versionchanged:: 3.0 + Added the ``allow_extra_args`` and ``allow_interspersed_args`` + parameters. + + .. versionchanged:: 2.0 + Added the ``resilient_parsing``, ``help_option_names``, and + ``token_normalize_func`` parameters. + """ + + #: The formatter class to create with :meth:`make_formatter`. + #: + #: .. versionadded:: 8.0 + formatter_class: type[HelpFormatter] = HelpFormatter + + def __init__( + self, + command: Command, + parent: Context | None = None, + info_name: str | None = None, + obj: t.Any | None = None, + auto_envvar_prefix: str | None = None, + default_map: cabc.MutableMapping[str, t.Any] | None = None, + terminal_width: int | None = None, + max_content_width: int | None = None, + resilient_parsing: bool = False, + allow_extra_args: bool | None = None, + allow_interspersed_args: bool | None = None, + ignore_unknown_options: bool | None = None, + help_option_names: list[str] | None = None, + token_normalize_func: t.Callable[[str], str] | None = None, + color: bool | None = None, + show_default: bool | None = None, + ) -> None: + #: the parent context or `None` if none exists. + self.parent = parent + #: the :class:`Command` for this context. + self.command = command + #: the descriptive information name + self.info_name = info_name + #: Map of parameter names to their parsed values. Parameters + #: with ``expose_value=False`` are not stored. + self.params: dict[str, t.Any] = {} + #: the leftover arguments. + self.args: list[str] = [] + #: protected arguments. These are arguments that are prepended + #: to `args` when certain parsing scenarios are encountered but + #: must be never propagated to another arguments. This is used + #: to implement nested parsing. + self._protected_args: list[str] = [] + #: the collected prefixes of the command's options. + self._opt_prefixes: set[str] = set(parent._opt_prefixes) if parent else set() + + if obj is None and parent is not None: + obj = parent.obj + + #: the user object stored. + self.obj: t.Any = obj + self._meta: dict[str, t.Any] = getattr(parent, "meta", {}) + + #: A dictionary (-like object) with defaults for parameters. + if ( + default_map is None + and info_name is not None + and parent is not None + and parent.default_map is not None + ): + default_map = parent.default_map.get(info_name) + + self.default_map: cabc.MutableMapping[str, t.Any] | None = default_map + + #: This flag indicates if a subcommand is going to be executed. A + #: group callback can use this information to figure out if it's + #: being executed directly or because the execution flow passes + #: onwards to a subcommand. By default it's None, but it can be + #: the name of the subcommand to execute. + #: + #: If chaining is enabled this will be set to ``'*'`` in case + #: any commands are executed. It is however not possible to + #: figure out which ones. If you require this knowledge you + #: should use a :func:`result_callback`. + self.invoked_subcommand: str | None = None + + if terminal_width is None and parent is not None: + terminal_width = parent.terminal_width + + #: The width of the terminal (None is autodetection). + self.terminal_width: int | None = terminal_width + + if max_content_width is None and parent is not None: + max_content_width = parent.max_content_width + + #: The maximum width of formatted content (None implies a sensible + #: default which is 80 for most things). + self.max_content_width: int | None = max_content_width + + if allow_extra_args is None: + allow_extra_args = command.allow_extra_args + + #: Indicates if the context allows extra args or if it should + #: fail on parsing. + #: + #: .. versionadded:: 3.0 + self.allow_extra_args = allow_extra_args + + if allow_interspersed_args is None: + allow_interspersed_args = command.allow_interspersed_args + + #: Indicates if the context allows mixing of arguments and + #: options or not. + #: + #: .. versionadded:: 3.0 + self.allow_interspersed_args: bool = allow_interspersed_args + + if ignore_unknown_options is None: + ignore_unknown_options = command.ignore_unknown_options + + #: Instructs click to ignore options that a command does not + #: understand and will store it on the context for later + #: processing. This is primarily useful for situations where you + #: want to call into external programs. Generally this pattern is + #: strongly discouraged because it's not possibly to losslessly + #: forward all arguments. + #: + #: .. versionadded:: 4.0 + self.ignore_unknown_options: bool = ignore_unknown_options + + if help_option_names is None: + if parent is not None: + help_option_names = parent.help_option_names + else: + help_option_names = ["--help"] + + #: The names for the help options. + self.help_option_names: list[str] = help_option_names + + if token_normalize_func is None and parent is not None: + token_normalize_func = parent.token_normalize_func + + #: An optional normalization function for tokens. This is + #: options, choices, commands etc. + self.token_normalize_func: t.Callable[[str], str] | None = token_normalize_func + + #: Indicates if resilient parsing is enabled. In that case Click + #: will do its best to not cause any failures and default values + #: will be ignored. Useful for completion. + self.resilient_parsing: bool = resilient_parsing + + # If there is no envvar prefix yet, but the parent has one and + # the command on this level has a name, we can expand the envvar + # prefix automatically. + if auto_envvar_prefix is None: + if ( + parent is not None + and parent.auto_envvar_prefix is not None + and self.info_name is not None + ): + auto_envvar_prefix = ( + f"{parent.auto_envvar_prefix}_{self.info_name.upper()}" + ) + else: + auto_envvar_prefix = auto_envvar_prefix.upper() + + if auto_envvar_prefix is not None: + auto_envvar_prefix = auto_envvar_prefix.replace("-", "_") + + self.auto_envvar_prefix: str | None = auto_envvar_prefix + + if color is None and parent is not None: + color = parent.color + + #: Controls if styling output is wanted or not. + self.color: bool | None = color + + if show_default is None and parent is not None: + show_default = parent.show_default + + #: Show option default values when formatting help text. + self.show_default: bool | None = show_default + + self._close_callbacks: list[t.Callable[[], t.Any]] = [] + self._depth = 0 + self._parameter_source: dict[str, ParameterSource] = {} + # Tracks whether the option that currently owns each parameter slot in + # :attr:`params` had its ``default`` set explicitly by the user. Used + # to tie-break feature-switch groups where multiple options share a + # parameter name and both fall back to their default value. + # Refs: https://github.com/pallets/click/issues/3403 + self._param_default_explicit: dict[str, bool] = {} + self._exit_stack = ExitStack() + + @property + def protected_args(self) -> list[str]: + import warnings + + warnings.warn( + "'protected_args' is deprecated and will be removed in Click 9.0." + " 'args' will contain remaining unparsed tokens.", + DeprecationWarning, + stacklevel=2, + ) + return self._protected_args + + def to_info_dict(self) -> dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. This traverses the entire CLI + structure. + + .. code-block:: python + + with Context(cli) as ctx: + info = ctx.to_info_dict() + + .. versionadded:: 8.0 + """ + return { + "command": self.command.to_info_dict(self), + "info_name": self.info_name, + "allow_extra_args": self.allow_extra_args, + "allow_interspersed_args": self.allow_interspersed_args, + "ignore_unknown_options": self.ignore_unknown_options, + "auto_envvar_prefix": self.auto_envvar_prefix, + } + + def __enter__(self) -> Context: + self._depth += 1 + push_context(self) + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool | None: + self._depth -= 1 + exit_result: bool | None = None + if self._depth == 0: + exit_result = self._close_with_exception_info(exc_type, exc_value, tb) + pop_context() + + return exit_result + + @contextmanager + def scope(self, cleanup: bool = True) -> cabc.Iterator[Context]: + """This helper method can be used with the context object to promote + it to the current thread local (see :func:`get_current_context`). + The default behavior of this is to invoke the cleanup functions which + can be disabled by setting `cleanup` to `False`. The cleanup + functions are typically used for things such as closing file handles. + + If the cleanup is intended the context object can also be directly + used as a context manager. + + Example usage:: + + with ctx.scope(): + assert get_current_context() is ctx + + This is equivalent:: + + with ctx: + assert get_current_context() is ctx + + .. versionadded:: 5.0 + + :param cleanup: controls if the cleanup functions should be run or + not. The default is to run these functions. In + some situations the context only wants to be + temporarily pushed in which case this can be disabled. + Nested pushes automatically defer the cleanup. + """ + if not cleanup: + self._depth += 1 + try: + with self as rv: + yield rv + finally: + if not cleanup: + self._depth -= 1 + + @property + def meta(self) -> dict[str, t.Any]: + """This is a dictionary which is shared with all the contexts + that are nested. It exists so that click utilities can store some + state here if they need to. It is however the responsibility of + that code to manage this dictionary well. + + The keys are supposed to be unique dotted strings. For instance + module paths are a good choice for it. What is stored in there is + irrelevant for the operation of click. However what is important is + that code that places data here adheres to the general semantics of + the system. + + Example usage:: + + LANG_KEY = f'{__name__}.lang' + + def set_language(value): + ctx = get_current_context() + ctx.meta[LANG_KEY] = value + + def get_language(): + return get_current_context().meta.get(LANG_KEY, 'en_US') + + .. versionadded:: 5.0 + """ + return self._meta + + def make_formatter(self) -> HelpFormatter: + """Creates the :class:`~click.HelpFormatter` for the help and + usage output. + + To quickly customize the formatter class used without overriding + this method, set the :attr:`formatter_class` attribute. + + .. versionchanged:: 8.0 + Added the :attr:`formatter_class` attribute. + """ + return self.formatter_class( + width=self.terminal_width, max_width=self.max_content_width + ) + + def with_resource(self, context_manager: AbstractContextManager[V]) -> V: + """Register a resource as if it were used in a ``with`` + statement. The resource will be cleaned up when the context is + popped. + + Uses :meth:`contextlib.ExitStack.enter_context`. It calls the + resource's ``__enter__()`` method and returns the result. When + the context is popped, it closes the stack, which calls the + resource's ``__exit__()`` method. + + To register a cleanup function for something that isn't a + context manager, use :meth:`call_on_close`. Or use something + from :mod:`contextlib` to turn it into a context manager first. + + .. code-block:: python + + @click.group() + @click.option("--name") + @click.pass_context + def cli(ctx): + ctx.obj = ctx.with_resource(connect_db(name)) + + :param context_manager: The context manager to enter. + :return: Whatever ``context_manager.__enter__()`` returns. + + .. versionadded:: 8.0 + """ + return self._exit_stack.enter_context(context_manager) + + def call_on_close(self, f: t.Callable[..., t.Any]) -> t.Callable[..., t.Any]: + """Register a function to be called when the context tears down. + + This can be used to close resources opened during the script + execution. Resources that support Python's context manager + protocol which would be used in a ``with`` statement should be + registered with :meth:`with_resource` instead. + + :param f: The function to execute on teardown. + """ + return self._exit_stack.callback(f) + + def close(self) -> None: + """Invoke all close callbacks registered with + :meth:`call_on_close`, and exit all context managers entered + with :meth:`with_resource`. + """ + self._close_with_exception_info(None, None, None) + + def _close_with_exception_info( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> bool | None: + """Unwind the exit stack by calling its :meth:`__exit__` providing the exception + information to allow for exception handling by the various resources registered + using :meth;`with_resource` + + :return: Whatever ``exit_stack.__exit__()`` returns. + """ + exit_result = self._exit_stack.__exit__(exc_type, exc_value, tb) + # In case the context is reused, create a new exit stack. + self._exit_stack = ExitStack() + + return exit_result + + @property + def command_path(self) -> str: + """The computed command path. This is used for the ``usage`` + information on the help page. It's automatically created by + combining the info names of the chain of contexts to the root. + """ + rv = "" + if self.info_name is not None: + rv = self.info_name + if self.parent is not None: + parent_command_path = [self.parent.command_path] + + if isinstance(self.parent.command, Command): + for param in self.parent.command.get_params(self): + parent_command_path.extend(param.get_usage_pieces(self)) + + rv = f"{' '.join(parent_command_path)} {rv}" + return rv.lstrip() + + def find_root(self) -> Context: + """Finds the outermost context.""" + node = self + while node.parent is not None: + node = node.parent + return node + + def find_object(self, object_type: type[V]) -> V | None: + """Finds the closest object of a given type.""" + node: Context | None = self + + while node is not None: + if isinstance(node.obj, object_type): + return node.obj + + node = node.parent + + return None + + def ensure_object(self, object_type: type[V]) -> V: + """Like :meth:`find_object` but sets the innermost object to a + new instance of `object_type` if it does not exist. + """ + rv = self.find_object(object_type) + if rv is None: + self.obj = rv = object_type() + return rv + + def _default_map_has(self, name: str | None) -> bool: + """Check if :attr:`default_map` contains a real value for ``name``. + + Returns ``False`` when the key is absent, the map is ``None``, + ``name`` is ``None``, or the stored value is the internal + :data:`UNSET` sentinel. + """ + return ( + name is not None + and self.default_map is not None + and name in self.default_map + and self.default_map[name] is not UNSET + ) + + @t.overload + def lookup_default( + self, name: str, call: t.Literal[True] = True + ) -> t.Any | None: ... + + @t.overload + def lookup_default( + self, name: str, call: t.Literal[False] = ... + ) -> t.Any | t.Callable[[], t.Any] | None: ... + + def lookup_default(self, name: str, call: bool = True) -> t.Any | None: + """Get the default for a parameter from :attr:`default_map`. + + :param name: Name of the parameter. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. + """ + if not self._default_map_has(name): + return None + + # Assert to make the type checker happy. + assert self.default_map is not None + value = self.default_map[name] + + if call and callable(value): + return value() + + return value + + def fail(self, message: str) -> t.NoReturn: + """Aborts the execution of the program with a specific error + message. + + :param message: the error message to fail with. + """ + raise UsageError(message, self) + + def abort(self) -> t.NoReturn: + """Aborts the script.""" + raise Abort() + + def exit(self, code: int = 0) -> t.NoReturn: + """Exits the application with a given exit code. + + .. versionchanged:: 8.2 + Callbacks and context managers registered with :meth:`call_on_close` + and :meth:`with_resource` are closed before exiting. + """ + self.close() + raise Exit(code) + + def get_usage(self) -> str: + """Helper method to get formatted usage string for the current + context and command. + """ + return self.command.get_usage(self) + + def get_help(self) -> str: + """Helper method to get formatted help page for the current + context and command. + """ + return self.command.get_help(self) + + def _make_sub_context(self, command: Command) -> Context: + """Create a new context of the same type as this context, but + for a new command. + + :meta private: + """ + return type(self)(command, info_name=command.name, parent=self) + + @t.overload + def invoke( + self, callback: t.Callable[..., V], /, *args: t.Any, **kwargs: t.Any + ) -> V: ... + + @t.overload + def invoke(self, callback: Command, /, *args: t.Any, **kwargs: t.Any) -> t.Any: ... + + def invoke( + self, callback: Command | t.Callable[..., V], /, *args: t.Any, **kwargs: t.Any + ) -> t.Any | V: + """Invokes a command callback in exactly the way it expects. There + are two ways to invoke this method: + + 1. the first argument can be a callback and all other arguments and + keyword arguments are forwarded directly to the function. + 2. the first argument is a click command object. In that case all + arguments are forwarded as well but proper click parameters + (options and click arguments) must be keyword arguments and Click + will fill in defaults. + + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if :meth:`forward` is called at multiple levels. + + .. versionchanged:: 3.2 + A new context is created, and missing arguments use default values. + """ + if isinstance(callback, Command): + other_cmd = callback + + if other_cmd.callback is None: + raise TypeError( + "The given command does not have a callback that can be invoked." + ) + else: + callback = t.cast("t.Callable[..., V]", other_cmd.callback) + + ctx = self._make_sub_context(other_cmd) + + for param in other_cmd.params: + if param.name not in kwargs and param.expose_value: + default_value = param.get_default(ctx) + # We explicitly hide the :attr:`UNSET` value to the user, as we + # choose to make it an implementation detail. And because ``invoke`` + # has been designed as part of Click public API, we return ``None`` + # instead. Refs: + # https://github.com/pallets/click/issues/3066 + # https://github.com/pallets/click/issues/3065 + # https://github.com/pallets/click/pull/3068 + if default_value is UNSET: + default_value = None + kwargs[param.name] = param.type_cast_value(ctx, default_value) + + # Track all kwargs as params, so that forward() will pass + # them on in subsequent calls. + ctx.params.update(kwargs) + else: + ctx = self + + with augment_usage_errors(self): + with ctx: + return callback(*args, **kwargs) + + def forward(self, cmd: Command, /, *args: t.Any, **kwargs: t.Any) -> t.Any: + """Similar to :meth:`invoke` but fills in default keyword + arguments from the current context if the other command expects + it. This cannot invoke callbacks directly, only other commands. + + .. versionchanged:: 8.0 + All ``kwargs`` are tracked in :attr:`params` so they will be + passed if ``forward`` is called at multiple levels. + """ + # Can only forward to other commands, not direct callbacks. + if not isinstance(cmd, Command): + raise TypeError("Callback is not a command.") + + for param in self.params: + if param not in kwargs: + kwargs[param] = self.params[param] + + return self.invoke(cmd, *args, **kwargs) + + def set_parameter_source(self, name: str, source: ParameterSource) -> None: + """Set the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + + :param name: The name of the parameter. + :param source: A member of :class:`~click.core.ParameterSource`. + """ + self._parameter_source[name] = source + + def get_parameter_source(self, name: str) -> ParameterSource | None: + """Get the source of a parameter. This indicates the location + from which the value of the parameter was obtained. + + This can be useful for determining when a user specified a value + on the command line that is the same as the default value. It + will be :attr:`~click.core.ParameterSource.DEFAULT` only if the + value was actually taken from the default. + + :param name: The name of the parameter. + :rtype: ParameterSource + + .. versionchanged:: 8.0 + Returns ``None`` if the parameter was not provided from any + source. + """ + return self._parameter_source.get(name) + + +class Command: + """Commands are the basic building block of command line interfaces in + Click. A basic command handles command line parsing and might dispatch + more parsing to commands nested below it. + + :param name: the name of the command to use unless a group overrides it. + :param context_settings: an optional dictionary with defaults that are + passed to the context object. + :param callback: the callback to invoke. This is optional. + :param params: the parameters to register with this command. This can + be either :class:`Option` or :class:`Argument` objects. + :param help: the help string to use for this command. + :param epilog: like the help string but it's printed at the end of the + help page after everything else. + :param short_help: the short help to use for this command. This is + shown on the command listing of the parent command. + :param add_help_option: by default each command registers a ``--help`` + option. This can be disabled by this parameter. + :param no_args_is_help: this controls what happens if no arguments are + provided. This option is disabled by default. + If enabled this will add ``--help`` as argument + if no arguments are passed + :param hidden: hide this command from help outputs. + :param deprecated: If ``True`` or non-empty string, issues a message + indicating that the command is deprecated and highlights + its deprecation in --help. The message can be customized + by using a string as the value. + + .. versionchanged:: 8.2 + This is the base class for all commands, not ``BaseCommand``. + ``deprecated`` can be set to a string as well to customize the + deprecation message. + + .. versionchanged:: 8.1 + ``help``, ``epilog``, and ``short_help`` are stored unprocessed, + all formatting is done when outputting help text, not at init, + and is done even if not using the ``@command`` decorator. + + .. versionchanged:: 8.0 + Added a ``repr`` showing the command name. + + .. versionchanged:: 7.1 + Added the ``no_args_is_help`` parameter. + + .. versionchanged:: 2.0 + Added the ``context_settings`` parameter. + """ + + #: The context class to create with :meth:`make_context`. + #: + #: .. versionadded:: 8.0 + context_class: type[Context] = Context + + #: the default for the :attr:`Context.allow_extra_args` flag. + allow_extra_args = False + + #: the default for the :attr:`Context.allow_interspersed_args` flag. + allow_interspersed_args = True + + #: the default for the :attr:`Context.ignore_unknown_options` flag. + ignore_unknown_options = False + + def __init__( + self, + name: str | None, + context_settings: cabc.MutableMapping[str, t.Any] | None = None, + callback: t.Callable[..., t.Any] | None = None, + params: list[Parameter] | None = None, + help: str | None = None, + epilog: str | None = None, + short_help: str | None = None, + options_metavar: str | None = "[OPTIONS]", + add_help_option: bool = True, + no_args_is_help: bool = False, + hidden: bool = False, + deprecated: bool | str = False, + ) -> None: + #: the name the command thinks it has. Upon registering a command + #: on a :class:`Group` the group will default the command name + #: with this information. You should instead use the + #: :class:`Context`\'s :attr:`~Context.info_name` attribute. + self.name = name + + if context_settings is None: + context_settings = {} + + #: an optional dictionary with defaults passed to the context. + self.context_settings: cabc.MutableMapping[str, t.Any] = context_settings + + #: the callback to execute when the command fires. This might be + #: `None` in which case nothing happens. + self.callback = callback + #: the list of parameters for this command in the order they + #: should show up in the help page and execute. Eager parameters + #: will automatically be handled before non eager ones. + self.params: list[Parameter] = params or [] + self.help = help + self.epilog = epilog + self.options_metavar = options_metavar + self.short_help = short_help + self.add_help_option = add_help_option + self._help_option = None + self.no_args_is_help = no_args_is_help + self.hidden = hidden + self.deprecated = deprecated + + def to_info_dict(self, ctx: Context) -> dict[str, t.Any]: + return { + "name": self.name, + "params": [param.to_info_dict() for param in self.get_params(ctx)], + "help": self.help, + "epilog": self.epilog, + "short_help": self.short_help, + "hidden": self.hidden, + "deprecated": self.deprecated, + } + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + + def get_usage(self, ctx: Context) -> str: + """Formats the usage line into a string and returns it. + + Calls :meth:`format_usage` internally. + """ + formatter = ctx.make_formatter() + self.format_usage(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_params(self, ctx: Context) -> list[Parameter]: + params = self.params + help_option = self.get_help_option(ctx) + + if help_option is not None: + params = [*params, help_option] + + if __debug__: + import warnings + + opts = [opt for param in params for opt in param.opts] + opts_counter = Counter(opts) + duplicate_opts = (opt for opt, count in opts_counter.items() if count > 1) + + for duplicate_opt in duplicate_opts: + warnings.warn( + ( + f"The parameter {duplicate_opt} is used more than once. " + "Remove its duplicate as parameters should be unique." + ), + stacklevel=3, + ) + + return params + + def format_usage(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the usage line into the formatter. + + This is a low-level method called by :meth:`get_usage`. + """ + pieces = self.collect_usage_pieces(ctx) + formatter.write_usage(ctx.command_path, " ".join(pieces)) + + def collect_usage_pieces(self, ctx: Context) -> list[str]: + """Returns all the pieces that go into the usage line and returns + it as a list of strings. + """ + rv = [self.options_metavar] if self.options_metavar else [] + + for param in self.get_params(ctx): + rv.extend(param.get_usage_pieces(ctx)) + + return rv + + def get_help_option_names(self, ctx: Context) -> list[str]: + """Returns the names for the help option.""" + all_names = set(ctx.help_option_names) + for param in self.params: + all_names.difference_update(param.opts) + all_names.difference_update(param.secondary_opts) + return list(all_names) + + def get_help_option(self, ctx: Context) -> Option | None: + """Returns the help option object. + + Skipped if :attr:`add_help_option` is ``False``. + + .. versionchanged:: 8.1.8 + The help option is now cached to avoid creating it multiple times. + """ + help_option_names = self.get_help_option_names(ctx) + + if not help_option_names or not self.add_help_option: + return None + + # Cache the help option object in private _help_option attribute to + # avoid creating it multiple times. Not doing this will break the + # callback ordering by iter_params_for_processing(), which relies on + # object comparison. + if self._help_option is None: + # Avoid circular import. + from .decorators import help_option + + # Apply help_option decorator and pop resulting option + help_option(*help_option_names)(self) + self._help_option = self.params.pop() # type: ignore[assignment] + + return self._help_option + + def make_parser(self, ctx: Context) -> _OptionParser: + """Creates the underlying option parser for this command.""" + parser = _OptionParser(ctx) + for param in self.get_params(ctx): + param.add_to_parser(parser, ctx) + return parser + + def get_help(self, ctx: Context) -> str: + """Formats the help into a string and returns it. + + Calls :meth:`format_help` internally. + """ + formatter = ctx.make_formatter() + self.format_help(ctx, formatter) + return formatter.getvalue().rstrip("\n") + + def get_short_help_str(self, limit: int = 45) -> str: + """Gets short help for the command or makes it by shortening the + long help string. + """ + if self.short_help: + text = inspect.cleandoc(self.short_help) + elif self.help: + text = make_default_short_help(self.help, limit) + else: + text = "" + + if self.deprecated: + text = f"{_(text)} {_format_deprecated_label(self.deprecated)}" + + return text.strip() + + def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help into the formatter if it exists. + + This is a low-level method called by :meth:`get_help`. + + This calls the following methods: + + - :meth:`format_usage` + - :meth:`format_help_text` + - :meth:`format_options` + - :meth:`format_epilog` + """ + self.format_usage(ctx, formatter) + self.format_help_text(ctx, formatter) + self.format_options(ctx, formatter) + self.format_epilog(ctx, formatter) + + def format_help_text(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the help text to the formatter if it exists.""" + if self.help is not None: + # truncate the help text to the first form feed + text = inspect.cleandoc(self.help).partition("\f")[0] + else: + text = "" + + if self.deprecated: + text = f"{_(text)} {_format_deprecated_label(self.deprecated)}" + + if text: + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(text) + + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes all the options into the formatter if they exist.""" + opts = [] + for param in self.get_params(ctx): + rv = param.get_help_record(ctx) + if rv is not None: + opts.append(rv) + + if opts: + with formatter.section(_("Options")): + formatter.write_dl(opts) + + def format_epilog(self, ctx: Context, formatter: HelpFormatter) -> None: + """Writes the epilog into the formatter if it exists.""" + if self.epilog: + epilog = inspect.cleandoc(self.epilog) + formatter.write_paragraph() + + with formatter.indentation(): + formatter.write_text(epilog) + + def make_context( + self, + info_name: str | None, + args: list[str], + parent: Context | None = None, + **extra: t.Any, + ) -> Context: + """This function when given an info name and arguments will kick + off the parsing and create a new :class:`Context`. It does not + invoke the actual command callback though. + + To quickly customize the context class used without overriding + this method, set the :attr:`context_class` attribute. + + :param info_name: the info name for this invocation. Generally this + is the most descriptive name for the script or + command. For the toplevel script it's usually + the name of the script, for commands below it's + the name of the command. + :param args: the arguments to parse as list of strings. + :param parent: the parent context if available. + :param extra: extra keyword arguments forwarded to the context + constructor. + + .. versionchanged:: 8.0 + Added the :attr:`context_class` attribute. + """ + for key, value in self.context_settings.items(): + if key not in extra: + extra[key] = value + + ctx = self.context_class(self, info_name=info_name, parent=parent, **extra) + + with ctx.scope(cleanup=False): + self.parse_args(ctx, args) + return ctx + + def parse_args(self, ctx: Context, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + raise NoArgsIsHelpError(ctx) + + parser = self.make_parser(ctx) + opts, args, param_order = parser.parse_args(args=args) + + for param in iter_params_for_processing(param_order, self.get_params(ctx)): + _, args = param.handle_parse_result(ctx, opts, args) + + # We now have all parameters' values into `ctx.params`, but the data may contain + # the `UNSET` sentinel. + # Convert `UNSET` to `None` to ensure that the user doesn't see `UNSET`. + # + # Waiting until after the initial parse to convert allows us to treat `UNSET` + # more like a missing value when multiple params use the same name. + # Refs: + # https://github.com/pallets/click/issues/3071 + # https://github.com/pallets/click/pull/3079 + for name, value in ctx.params.items(): + if value is UNSET: + ctx.params[name] = None + + if args and not ctx.allow_extra_args and not ctx.resilient_parsing: + ctx.fail( + ngettext( + "Got unexpected extra argument ({args})", + "Got unexpected extra arguments ({args})", + len(args), + ).format(args=" ".join(map(str, args))) + ) + + ctx.args = args + ctx._opt_prefixes.update(parser._opt_prefixes) + return args + + def invoke(self, ctx: Context) -> t.Any: + """Given a context, this invokes the attached callback (if it exists) + in the right way. + """ + if self.deprecated: + message = _( + "DeprecationWarning: The command {name!r} is deprecated.{extra_message}" + ).format( + name=self.name, + extra_message=_format_deprecated_suffix(self.deprecated), + ) + echo(style(message, fg="red"), err=True) + + if self.callback is not None: + return ctx.invoke(self.callback, **ctx.params) + + def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: + """Return a list of completions for the incomplete value. Looks + at the names of options and chained multi-commands. + + Any command could be part of a chained multi-command, so sibling + commands are valid at any point during command completion. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results: list[CompletionItem] = [] + + if incomplete and not incomplete[0].isalnum(): + for param in self.get_params(ctx): + if ( + not isinstance(param, Option) + or param.hidden + or ( + not param.multiple + and ctx.get_parameter_source(param.name) + is ParameterSource.COMMANDLINE + ) + ): + continue + + results.extend( + CompletionItem(name, help=param.help) + for name in [*param.opts, *param.secondary_opts] + if name.startswith(incomplete) + ) + + while ctx.parent is not None: + ctx = ctx.parent + + if isinstance(ctx.command, Group) and ctx.command.chain: + results.extend( + CompletionItem(name, help=command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) + if name not in ctx._protected_args + ) + + return results + + @t.overload + def main( + self, + args: cabc.Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, + standalone_mode: t.Literal[True] = True, + **extra: t.Any, + ) -> t.NoReturn: ... + + @t.overload + def main( + self, + args: cabc.Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, + standalone_mode: bool = ..., + **extra: t.Any, + ) -> t.Any: ... + + def main( + self, + args: cabc.Sequence[str] | None = None, + prog_name: str | None = None, + complete_var: str | None = None, + standalone_mode: bool = True, + windows_expand_args: bool = True, + **extra: t.Any, + ) -> t.Any: + """This is the way to invoke a script with all the bells and + whistles as a command line application. This will always terminate + the application after a call. If this is not wanted, ``SystemExit`` + needs to be caught. + + This method is also available by directly calling the instance of + a :class:`Command`. + + :param args: the arguments that should be used for parsing. If not + provided, ``sys.argv[1:]`` is used. + :param prog_name: the program name that should be used. By default + the program name is constructed by taking the file + name from ``sys.argv[0]``. + :param complete_var: the environment variable that controls the + bash completion support. The default is + ``"__COMPLETE"`` with prog_name in + uppercase. + :param standalone_mode: the default behavior is to invoke the script + in standalone mode. Click will then + handle exceptions and convert them into + error messages and the function will never + return but shut down the interpreter. If + this is set to `False` they will be + propagated to the caller and the return + value of this function is the return value + of :meth:`invoke`. + :param windows_expand_args: Expand glob patterns, user dir, and + env vars in command line args on Windows. + :param extra: extra keyword arguments are forwarded to the context + constructor. See :class:`Context` for more information. + + .. versionchanged:: 8.0.1 + Added the ``windows_expand_args`` parameter to allow + disabling command line arg expansion on Windows. + + .. versionchanged:: 8.0 + When taking arguments from ``sys.argv`` on Windows, glob + patterns, user dir, and env vars are expanded. + + .. versionchanged:: 3.0 + Added the ``standalone_mode`` parameter. + """ + if args is None: + args = sys.argv[1:] + + if os.name == "nt" and windows_expand_args: + args = _expand_args(args) + else: + args = list(args) + + if prog_name is None: + prog_name = _detect_program_name() + + # Process shell completion requests and exit early. + self._main_shell_completion(extra, prog_name, complete_var) + + try: + try: + with self.make_context(prog_name, args, **extra) as ctx: + rv = self.invoke(ctx) + if not standalone_mode: + return rv + # it's not safe to `ctx.exit(rv)` here! + # note that `rv` may actually contain data like "1" which + # has obvious effects + # more subtle case: `rv=[None, None]` can come out of + # chained commands which all returned `None` -- so it's not + # even always obvious that `rv` indicates success/failure + # by its truthiness/falsiness + ctx.exit() + except (EOFError, KeyboardInterrupt) as e: + echo(file=sys.stderr) + raise Abort() from e + except ClickException as e: + if not standalone_mode: + raise + e.show() + sys.exit(e.exit_code) + except OSError as e: + if e.errno == errno.EPIPE: + sys.stdout = t.cast(t.TextIO, PacifyFlushWrapper(sys.stdout)) + sys.stderr = t.cast(t.TextIO, PacifyFlushWrapper(sys.stderr)) + sys.exit(1) + else: + raise + except Exit as e: + if standalone_mode: + sys.exit(e.exit_code) + else: + # in non-standalone mode, return the exit code + # note that this is only reached if `self.invoke` above raises + # an Exit explicitly -- thus bypassing the check there which + # would return its result + # the results of non-standalone execution may therefore be + # somewhat ambiguous: if there are codepaths which lead to + # `ctx.exit(1)` and to `return 1`, the caller won't be able to + # tell the difference between the two + return e.exit_code + except Abort: + if not standalone_mode: + raise + echo(_("Aborted!"), file=sys.stderr) + sys.exit(1) + + def _main_shell_completion( + self, + ctx_args: cabc.MutableMapping[str, t.Any], + prog_name: str, + complete_var: str | None = None, + ) -> None: + """Check if the shell is asking for tab completion, process + that, then exit early. Called from :meth:`main` before the + program is invoked. + + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. Defaults to + ``_{PROG_NAME}_COMPLETE``. + + .. versionchanged:: 8.2.0 + Dots (``.``) in ``prog_name`` are replaced with underscores (``_``). + """ + if complete_var is None: + complete_name = prog_name.replace("-", "_").replace(".", "_") + complete_var = f"_{complete_name}_COMPLETE".upper() + + instruction = os.environ.get(complete_var) + + if not instruction: + return + + from .shell_completion import shell_complete + + rv = shell_complete(self, ctx_args, prog_name, complete_var, instruction) + sys.exit(rv) + + def __call__(self, *args: t.Any, **kwargs: t.Any) -> t.Any: + """Alias for :meth:`main`.""" + return self.main(*args, **kwargs) + + +class _FakeSubclassCheck(type): + def __subclasscheck__(cls, subclass: type) -> bool: + return issubclass(subclass, cls.__bases__[0]) + + def __instancecheck__(cls, instance: t.Any) -> bool: + return isinstance(instance, cls.__bases__[0]) + + +class _BaseCommand(Command, metaclass=_FakeSubclassCheck): + """ + .. deprecated:: 8.2 + Will be removed in Click 9.0. Use ``Command`` instead. + """ + + +class Group(Command): + """A group is a command that nests other commands (or more groups). + + :param name: The name of the group command. + :param commands: Map names to :class:`Command` objects. Can be a list, which + will use :attr:`Command.name` as the keys. + :param invoke_without_command: Invoke the group's callback even if a + subcommand is not given. + :param no_args_is_help: If no arguments are given, show the group's help and + exit. Defaults to the opposite of ``invoke_without_command``. + :param subcommand_metavar: How to represent the subcommand argument in help. + The default will represent whether ``chain`` is set or not. + :param chain: Allow passing more than one subcommand argument. After parsing + a command's arguments, if any arguments remain another command will be + matched, and so on. + :param result_callback: A function to call after the group's and + subcommand's callbacks. The value returned by the subcommand is passed. + If ``chain`` is enabled, the value will be a list of values returned by + all the commands. If ``invoke_without_command`` is enabled, the value + will be the value returned by the group's callback, or an empty list if + ``chain`` is enabled. + :param kwargs: Other arguments passed to :class:`Command`. + + .. versionchanged:: 8.0 + The ``commands`` argument can be a list of command objects. + + .. versionchanged:: 8.2 + Merged with and replaces the ``MultiCommand`` base class. + """ + + allow_extra_args = True + allow_interspersed_args = False + + #: If set, this is used by the group's :meth:`command` decorator + #: as the default :class:`Command` class. This is useful to make all + #: subcommands use a custom command class. + #: + #: .. versionadded:: 8.0 + command_class: type[Command] | None = None + + #: If set, this is used by the group's :meth:`group` decorator + #: as the default :class:`Group` class. This is useful to make all + #: subgroups use a custom group class. + #: + #: If set to the special value :class:`type` (literally + #: ``group_class = type``), this group's class will be used as the + #: default class. This makes a custom group class continue to make + #: custom groups. + #: + #: .. versionadded:: 8.0 + group_class: type[Group] | type[type] | None = None + # Literal[type] isn't valid, so use Type[type] + + def __init__( + self, + name: str | None = None, + commands: cabc.MutableMapping[str, Command] + | cabc.Sequence[Command] + | None = None, + invoke_without_command: bool = False, + no_args_is_help: bool | None = None, + subcommand_metavar: str | None = None, + chain: bool = False, + result_callback: t.Callable[..., t.Any] | None = None, + **kwargs: t.Any, + ) -> None: + super().__init__(name, **kwargs) + + if commands is None: + commands = {} + elif isinstance(commands, abc.Sequence): + commands = {c.name: c for c in commands if c.name is not None} + + #: The registered subcommands by their exported names. + self.commands: cabc.MutableMapping[str, Command] = commands + + if no_args_is_help is None: + no_args_is_help = not invoke_without_command + + self.no_args_is_help = no_args_is_help + self.invoke_without_command = invoke_without_command + + if subcommand_metavar is None: + if chain: + subcommand_metavar = "COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]..." + else: + subcommand_metavar = "COMMAND [ARGS]..." + + self.subcommand_metavar = subcommand_metavar + self.chain = chain + # The result callback that is stored. This can be set or + # overridden with the :func:`result_callback` decorator. + self._result_callback = result_callback + + if self.chain: + for param in self.params: + if isinstance(param, Argument) and not param.required: + raise RuntimeError( + "A group in chain mode cannot have optional arguments." + ) + + def to_info_dict(self, ctx: Context) -> dict[str, t.Any]: + info_dict = super().to_info_dict(ctx) + commands = {} + + for name in self.list_commands(ctx): + command = self.get_command(ctx, name) + + if command is None: + continue + + sub_ctx = ctx._make_sub_context(command) + + with sub_ctx.scope(cleanup=False): + commands[name] = command.to_info_dict(sub_ctx) + + info_dict.update(commands=commands, chain=self.chain) + return info_dict + + def add_command(self, cmd: Command, name: str | None = None) -> None: + """Registers another :class:`Command` with this group. If the name + is not provided, the name of the command is used. + """ + name = name or cmd.name + if name is None: + raise TypeError("Command has no name.") + _check_nested_chain(self, name, cmd, register=True) + self.commands[name] = cmd + + @t.overload + def command(self, __func: t.Callable[..., t.Any]) -> Command: ... + + @t.overload + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Command]: ... + + def command( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Command] | Command: + """A shortcut decorator for declaring and attaching a command to + the group. This takes the same arguments as :func:`command` and + immediately registers the created command with this group by + calling :meth:`add_command`. + + To customize the command class used, set the + :attr:`command_class` attribute. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + + .. versionchanged:: 8.0 + Added the :attr:`command_class` attribute. + """ + from .decorators import command + + func: t.Callable[..., t.Any] | None = None + + if args and callable(args[0]): + assert len(args) == 1 and not kwargs, ( + "Use 'command(**kwargs)(callable)' to provide arguments." + ) + (func,) = args + args = () + + if self.command_class and kwargs.get("cls") is None: + kwargs["cls"] = self.command_class + + def decorator(f: t.Callable[..., t.Any]) -> Command: + cmd: Command = command(*args, **kwargs)(f) + self.add_command(cmd) + return cmd + + if func is not None: + return decorator(func) + + return decorator + + @t.overload + def group(self, __func: t.Callable[..., t.Any]) -> Group: ... + + @t.overload + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Group]: ... + + def group( + self, *args: t.Any, **kwargs: t.Any + ) -> t.Callable[[t.Callable[..., t.Any]], Group] | Group: + """A shortcut decorator for declaring and attaching a group to + the group. This takes the same arguments as :func:`group` and + immediately registers the created group with this group by + calling :meth:`add_command`. + + To customize the group class used, set the :attr:`group_class` + attribute. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + + .. versionchanged:: 8.0 + Added the :attr:`group_class` attribute. + """ + from .decorators import group + + func: t.Callable[..., t.Any] | None = None + + if args and callable(args[0]): + assert len(args) == 1 and not kwargs, ( + "Use 'group(**kwargs)(callable)' to provide arguments." + ) + (func,) = args + args = () + + if self.group_class is not None and kwargs.get("cls") is None: + if self.group_class is type: + kwargs["cls"] = type(self) + else: + kwargs["cls"] = self.group_class + + def decorator(f: t.Callable[..., t.Any]) -> Group: + cmd: Group = group(*args, **kwargs)(f) + self.add_command(cmd) + return cmd + + if func is not None: + return decorator(func) + + return decorator + + def result_callback(self, replace: bool = False) -> t.Callable[[F], F]: + """Adds a result callback to the command. By default if a + result callback is already registered this will chain them but + this can be disabled with the `replace` parameter. The result + callback is invoked with the return value of the subcommand + (or the list of return values from all subcommands if chaining + is enabled) as well as the parameters as they would be passed + to the main callback. + + Example:: + + @click.group() + @click.option('-i', '--input', default=23) + def cli(input): + return 42 + + @cli.result_callback() + def process_result(result, input): + return result + input + + :param replace: if set to `True` an already existing result + callback will be removed. + + .. versionchanged:: 8.0 + Renamed from ``resultcallback``. + + .. versionadded:: 3.0 + """ + + def decorator(f: F) -> F: + old_callback = self._result_callback + + if old_callback is None or replace: + self._result_callback = f + return f + + def function(value: t.Any, /, *args: t.Any, **kwargs: t.Any) -> t.Any: + inner = old_callback(value, *args, **kwargs) + return f(inner, *args, **kwargs) + + self._result_callback = rv = update_wrapper(t.cast(F, function), f) + return rv # type: ignore[return-value] + + return decorator + + def get_command(self, ctx: Context, cmd_name: str) -> Command | None: + """Given a context and a command name, this returns a :class:`Command` + object if it exists or returns ``None``. + """ + return self.commands.get(cmd_name) + + def list_commands(self, ctx: Context) -> list[str]: + """Returns a list of subcommand names in the order they should appear.""" + return sorted(self.commands) + + def collect_usage_pieces(self, ctx: Context) -> list[str]: + rv = super().collect_usage_pieces(ctx) + rv.append(self.subcommand_metavar) + return rv + + def format_options(self, ctx: Context, formatter: HelpFormatter) -> None: + super().format_options(ctx, formatter) + self.format_commands(ctx, formatter) + + def format_commands(self, ctx: Context, formatter: HelpFormatter) -> None: + """Extra format methods for multi methods that adds all the commands + after the options. + """ + commands = [] + for subcommand in self.list_commands(ctx): + cmd = self.get_command(ctx, subcommand) + # What is this, the tool lied about a command. Ignore it + if cmd is None: + continue + if cmd.hidden: + continue + + commands.append((subcommand, cmd)) + + # allow for 3 times the default spacing + if len(commands): + limit = formatter.width - 6 - max(len(cmd[0]) for cmd in commands) + + rows = [] + for subcommand, cmd in commands: + help = cmd.get_short_help_str(limit) + rows.append((subcommand, help)) + + if rows: + with formatter.section(_("Commands")): + formatter.write_dl(rows) + + def parse_args(self, ctx: Context, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and not ctx.resilient_parsing: + raise NoArgsIsHelpError(ctx) + + rest = super().parse_args(ctx, args) + + if self.chain: + ctx._protected_args = rest + ctx.args = [] + elif rest: + ctx._protected_args, ctx.args = rest[:1], rest[1:] + + return ctx.args + + def invoke(self, ctx: Context) -> t.Any: + def _process_result(value: t.Any) -> t.Any: + if self._result_callback is not None: + value = ctx.invoke(self._result_callback, value, **ctx.params) + return value + + if not ctx._protected_args: + if self.invoke_without_command: + # No subcommand was invoked, so the result callback is + # invoked with the group return value for regular + # groups, or an empty list for chained groups. + with ctx: + rv = super().invoke(ctx) + return _process_result([] if self.chain else rv) + ctx.fail(_("Missing command.")) + + # Fetch args back out + args = [*ctx._protected_args, *ctx.args] + ctx.args = [] + ctx._protected_args = [] + + # If we're not in chain mode, we only allow the invocation of a + # single command but we also inform the current context about the + # name of the command to invoke. + if not self.chain: + # Make sure the context is entered so we do not clean up + # resources until the result processor has worked. + with ctx: + cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None + ctx.invoked_subcommand = cmd_name + super().invoke(ctx) + sub_ctx = cmd.make_context(cmd_name, args, parent=ctx) + with sub_ctx: + return _process_result(sub_ctx.command.invoke(sub_ctx)) + + # In chain mode we create the contexts step by step, but after the + # base command has been invoked. Because at that point we do not + # know the subcommands yet, the invoked subcommand attribute is + # set to ``*`` to inform the command that subcommands are executed + # but nothing else. + with ctx: + ctx.invoked_subcommand = "*" if args else None + super().invoke(ctx) + + # Otherwise we make every single context and invoke them in a + # chain. In that case the return value to the result processor + # is the list of all invoked subcommand's results. + contexts = [] + while args: + cmd_name, cmd, args = self.resolve_command(ctx, args) + assert cmd is not None + sub_ctx = cmd.make_context( + cmd_name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + ) + contexts.append(sub_ctx) + args, sub_ctx.args = sub_ctx.args, [] + + rv = [] + for sub_ctx in contexts: + with sub_ctx: + rv.append(sub_ctx.command.invoke(sub_ctx)) + return _process_result(rv) + + def resolve_command( + self, ctx: Context, args: list[str] + ) -> tuple[str | None, Command | None, list[str]]: + cmd_name = make_str(args[0]) + + # Get the command + cmd = self.get_command(ctx, cmd_name) + + # If we can't find the command but there is a normalization + # function available, we try with that one. + if cmd is None and ctx.token_normalize_func is not None: + cmd_name = ctx.token_normalize_func(cmd_name) + cmd = self.get_command(ctx, cmd_name) + + # If we don't find the command we want to show an error message + # to the user that it was not provided. However, there is + # something else we should do: if the first argument looks like + # an option we want to kick off parsing again for arguments to + # resolve things like --help which now should go to the main + # place. + if cmd is None and not ctx.resilient_parsing: + if _split_opt(cmd_name)[0]: + self.parse_args(ctx, args) + raise NoSuchCommand(cmd_name, possibilities=self.commands, ctx=ctx) + return cmd_name if cmd else None, cmd, args[1:] + + def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: + """Return a list of completions for the incomplete value. Looks + at the names of options, subcommands, and chained + multi-commands. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + results = [ + CompletionItem(name, help=command.get_short_help_str()) + for name, command in _complete_visible_commands(ctx, incomplete) + ] + results.extend(super().shell_complete(ctx, incomplete)) + return results + + +class _MultiCommand(Group, metaclass=_FakeSubclassCheck): + """ + .. deprecated:: 8.2 + Will be removed in Click 9.0. Use ``Group`` instead. + """ + + +class CommandCollection(Group): + """A :class:`Group` that looks up subcommands on other groups. If a command + is not found on this group, each registered source is checked in order. + Parameters on a source are not added to this group, and a source's callback + is not invoked when invoking its commands. In other words, this "flattens" + commands in many groups into this one group. + + :param name: The name of the group command. + :param sources: A list of :class:`Group` objects to look up commands from. + :param kwargs: Other arguments passed to :class:`Group`. + + .. versionchanged:: 8.2 + This is a subclass of ``Group``. Commands are looked up first on this + group, then each of its sources. + """ + + def __init__( + self, + name: str | None = None, + sources: list[Group] | None = None, + **kwargs: t.Any, + ) -> None: + super().__init__(name, **kwargs) + #: The list of registered groups. + self.sources: list[Group] = sources or [] + + def add_source(self, group: Group) -> None: + """Add a group as a source of commands.""" + self.sources.append(group) + + def get_command(self, ctx: Context, cmd_name: str) -> Command | None: + rv = super().get_command(ctx, cmd_name) + + if rv is not None: + return rv + + for source in self.sources: + rv = source.get_command(ctx, cmd_name) + + if rv is not None: + if self.chain: + _check_nested_chain(self, cmd_name, rv) + + return rv + + return None + + def list_commands(self, ctx: Context) -> list[str]: + rv: set[str] = set(super().list_commands(ctx)) + + for source in self.sources: + rv.update(source.list_commands(ctx)) + + return sorted(rv) + + +def _check_iter(value: t.Any) -> cabc.Iterator[t.Any]: + """Check if the value is iterable but not a string. Raises a type + error, or return an iterator over the value. + """ + if isinstance(value, str): + raise TypeError + + return iter(value) + + +class Parameter(ABC): + r"""A parameter to a command comes in two versions: they are either + :class:`Option`\s or :class:`Argument`\s. Other subclasses are currently + not supported by design as some of the internals for parsing are + intentionally not finalized. + + Some settings are supported by both options and arguments. + + :param param_decls: the parameter declarations for this option or + argument. This is a list of flags or argument + names. + :param type: the type that should be used. Either a :class:`ParamType` + or a Python type. The latter is converted into the former + automatically if supported. + :param required: controls if this is optional or not. + :param default: the default value if omitted. This can also be a callable, + in which case it's invoked when the default is needed + without any arguments. + :param callback: A function to further process or validate the value + after type conversion. It is called as ``f(ctx, param, value)`` + and must return the value. It is called for all sources, + including prompts. + :param nargs: the number of arguments to match. If not ``1`` the return + value is a tuple instead of single value. The default for + nargs is ``1`` (except if the type is a tuple, then it's + the arity of the tuple). If ``nargs=-1``, all remaining + parameters are collected. + :param metavar: how the value is represented in the help page. + :param expose_value: if this is `True` then the value is passed onwards + to the command callback and stored on the context, + otherwise it's skipped. + :param is_eager: eager values are processed before non eager ones. This + should not be set for arguments or it will inverse the + order of processing. + :param envvar: environment variable(s) that are used to provide a default value for + this parameter. This can be a string or a sequence of strings. If a sequence is + given, only the first non-empty environment variable is used for the parameter. + :param shell_complete: A function that returns custom shell + completions. Used instead of the param's type completion if + given. Takes ``ctx, param, incomplete`` and must return a list + of :class:`~click.shell_completion.CompletionItem` or a list of + strings. + :param deprecated: If ``True`` or non-empty string, issues a message + indicating that the argument is deprecated and highlights + its deprecation in --help. The message can be customized + by using a string as the value. A deprecated parameter + cannot be required, a ValueError will be raised otherwise. + + .. versionchanged:: 8.2.0 + Introduction of ``deprecated``. + + .. versionchanged:: 8.2 + Adding duplicate parameter names to a :class:`~click.core.Command` will + result in a ``UserWarning`` being shown. + + .. versionchanged:: 8.2 + Adding duplicate parameter names to a :class:`~click.core.Command` will + result in a ``UserWarning`` being shown. + + .. versionchanged:: 8.0 + ``process_value`` validates required parameters and bounded + ``nargs``, and invokes the parameter callback before returning + the value. This allows the callback to validate prompts. + ``full_process_value`` is removed. + + .. versionchanged:: 8.0 + ``autocompletion`` is renamed to ``shell_complete`` and has new + semantics described above. The old name is deprecated and will + be removed in 8.1, until then it will be wrapped to match the + new requirements. + + .. versionchanged:: 8.0 + For ``multiple=True, nargs>1``, the default must be a list of + tuples. + + .. versionchanged:: 8.0 + Setting a default is no longer required for ``nargs>1``, it will + default to ``None``. ``multiple=True`` or ``nargs=-1`` will + default to ``()``. + + .. versionchanged:: 7.1 + Empty environment variables are ignored rather than taking the + empty string value. This makes it possible for scripts to clear + variables if they can't unset them. + + .. versionchanged:: 2.0 + Changed signature for parameter callback to also be passed the + parameter. The old callback format will still work, but it will + raise a warning to give you a chance to migrate the code easier. + """ + + param_type_name = "parameter" + + def __init__( + self, + param_decls: cabc.Sequence[str] | None = None, + type: types.ParamType[t.Any] | t.Any | None = None, + required: bool = False, + # XXX The default historically embed two concepts: + # - the declaration of a Parameter object carrying the default (handy to + # arbitrage the default value of coupled Parameters sharing the same + # self.name, like flag options), + # - and the actual value of the default. + # It is confusing and is the source of many issues discussed in: + # https://github.com/pallets/click/pull/3030 + # In the future, we might think of splitting it in two, not unlike + # Option.is_flag and Option.flag_value: we could have something like + # Parameter.is_default and Parameter.default_value. + default: t.Any | t.Callable[[], t.Any] | None = UNSET, + callback: t.Callable[[Context, Parameter, t.Any], t.Any] | None = None, + nargs: int | None = None, + multiple: bool = False, + metavar: str | None = None, + expose_value: bool = True, + is_eager: bool = False, + envvar: str | cabc.Sequence[str] | None = None, + shell_complete: t.Callable[ + [Context, Parameter, str], list[CompletionItem] | list[str] + ] + | None = None, + deprecated: bool | str = False, + ) -> None: + self.name: str + self.opts: list[str] + self.secondary_opts: list[str] + self.name, self.opts, self.secondary_opts = self._parse_decls( + param_decls or (), expose_value + ) + self.type: types.ParamType[t.Any] = types.convert_type(type, default) + + # Default nargs to what the type tells us if we have that + # information available. + if nargs is None: + if self.type.is_composite: + nargs = self.type.arity + else: + nargs = 1 + + self.required = required + self.callback = callback + self.nargs = nargs + self.multiple = multiple + self.expose_value = expose_value + self.default: t.Any | t.Callable[[], t.Any] | None = default + # Whether the user passed ``default`` explicitly to the constructor. + # Captured before any auto-derived default (like ``False`` for boolean + # flags in :class:`Option`) replaces the :data:`UNSET` sentinel, so it + # remains ``False`` when the default was inferred rather than chosen. + # Refs: https://github.com/pallets/click/issues/3403 + self._default_explicit: bool = default is not UNSET + self.is_eager = is_eager + self.metavar = metavar + self.envvar = envvar + self._custom_shell_complete = shell_complete + self.deprecated = deprecated + + if __debug__: + if self.type.is_composite and nargs != self.type.arity: + raise ValueError( + f"'nargs' must be {self.type.arity} (or None) for" + f" type {self.type!r}, but it was {nargs}." + ) + + if required and deprecated: + raise ValueError( + f"The {self.param_type_name} '{self.human_readable_name}' " + "is deprecated and still required. A deprecated " + f"{self.param_type_name} cannot be required." + ) + + def to_info_dict(self) -> dict[str, t.Any]: + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionchanged:: 8.3.0 + Returns ``None`` for the :attr:`default` if it was not set. + + .. versionadded:: 8.0 + """ + return { + "name": self.name, + "param_type_name": self.param_type_name, + "opts": self.opts, + "secondary_opts": self.secondary_opts, + "type": self.type.to_info_dict(), + "required": self.required, + "nargs": self.nargs, + "multiple": self.multiple, + # We explicitly hide the :attr:`UNSET` value to the user, as we choose to + # make it an implementation detail. And because ``to_info_dict`` has been + # designed for documentation purposes, we return ``None`` instead. + "default": self.default if self.default is not UNSET else None, + "envvar": self.envvar, + } + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} {self.name}>" + + @abstractmethod + def _parse_decls( + self, decls: cabc.Sequence[str], expose_value: bool + ) -> tuple[str, list[str], list[str]]: ... + + @property + def human_readable_name(self) -> str: + """Returns the human readable name of this parameter. This is the + same as the name for options, but the metavar for arguments. + """ + return self.name + + def make_metavar(self, ctx: Context) -> str: + if self.metavar is not None: + return self.metavar + + metavar = self.type.get_metavar(param=self, ctx=ctx) + + if metavar is None: + metavar = self.type.name.upper() + + if self.nargs != 1: + metavar += "..." + + return metavar + + @t.overload + def get_default( + self, ctx: Context, call: t.Literal[True] = True + ) -> t.Any | None: ... + + @t.overload + def get_default( + self, ctx: Context, call: bool = ... + ) -> t.Any | t.Callable[[], t.Any] | None: ... + + def get_default( + self, ctx: Context, call: bool = True + ) -> t.Any | t.Callable[[], t.Any] | None: + """Get the default for the parameter. Tries + :meth:`Context.lookup_default` first, then the local default. + + :param ctx: Current context. + :param call: If the default is a callable, call it. Disable to + return the callable instead. + + .. versionchanged:: 8.0.2 + Type casting is no longer performed when getting a default. + + .. versionchanged:: 8.0.1 + Type casting can fail in resilient parsing mode. Invalid + defaults will not prevent showing help text. + + .. versionchanged:: 8.0 + Looks at ``ctx.default_map`` first. + + .. versionchanged:: 8.0 + Added the ``call`` parameter. + """ + value = ctx.lookup_default(self.name, call=False) + + if value is None and not ctx._default_map_has(self.name): + value = self.default + + if call and callable(value): + value = value() + + return value + + @abstractmethod + def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: ... + + def consume_value( + self, ctx: Context, opts: cabc.Mapping[str, t.Any] + ) -> tuple[t.Any, ParameterSource]: + """Returns the parameter value produced by the parser. + + If the parser did not produce a value from user input, the value is either + sourced from the environment variable, the default map, or the parameter's + default value. In that order of precedence. + + If no value is found, an internal sentinel value is returned. + + :meta private: + """ + # Collect from the parse the value passed by the user to the CLI. + value = opts.get(self.name, UNSET) + # If the value is set, it means it was sourced from the command line by the + # parser, otherwise it left unset by default. + source = ( + ParameterSource.COMMANDLINE + if value is not UNSET + else ParameterSource.DEFAULT + ) + + if value is UNSET: + envvar_value = self.value_from_envvar(ctx) + if envvar_value is not None: + value = envvar_value + source = ParameterSource.ENVIRONMENT + + if value is UNSET: + default_map_value = ctx.lookup_default(self.name) + if default_map_value is not None or ctx._default_map_has(self.name): + value = default_map_value + source = ParameterSource.DEFAULT_MAP + + # A string from default_map must be split for multi-value + # parameters, matching value_from_envvar behavior. + if isinstance(value, str) and self.nargs != 1: + value = self.type.split_envvar_value(value) + + if value is UNSET: + default_value = self.get_default(ctx) + if default_value is not UNSET: + value = default_value + source = ParameterSource.DEFAULT + + return value, source + + def type_cast_value(self, ctx: Context, value: t.Any) -> t.Any: + """Convert and validate a value against the parameter's + :attr:`type`, :attr:`multiple`, and :attr:`nargs`. + """ + if value is None: + if self.multiple or self.nargs == -1: + return () + else: + return value + + def check_iter(value: t.Any) -> cabc.Iterator[t.Any]: + try: + return _check_iter(value) + except TypeError: + # This should only happen when passing in args manually, + # the parser should construct an iterable when parsing + # the command line. + raise BadParameter( + _("Value must be an iterable."), ctx=ctx, param=self + ) from None + + # Define the conversion function based on nargs and type. + + if self.nargs == 1 or self.type.is_composite: + + def convert(value: t.Any) -> t.Any: + return self.type(value, param=self, ctx=ctx) + + elif self.nargs == -1: + + def convert(value: t.Any) -> t.Any: # tuple[t.Any, ...] + return tuple(self.type(x, self, ctx) for x in check_iter(value)) + + else: # nargs > 1 + + def convert(value: t.Any) -> t.Any: # tuple[t.Any, ...] + value = tuple(check_iter(value)) + + if len(value) != self.nargs: + raise BadParameter( + ngettext( + "Takes {nargs} values but 1 was given.", + "Takes {nargs} values but {len} were given.", + len(value), + ).format(nargs=self.nargs, len=len(value)), + ctx=ctx, + param=self, + ) + + return tuple(self.type(x, self, ctx) for x in value) + + if self.multiple: + return tuple(convert(x) for x in check_iter(value)) + + return convert(value) + + def value_is_missing(self, value: t.Any) -> bool: + """A value is considered missing if: + + - it is :attr:`UNSET`, + - or if it is an empty sequence while the parameter is suppose to have + non-single value (i.e. :attr:`nargs` is not ``1`` or :attr:`multiple` is + set). + + :meta private: + """ + if value is UNSET: + return True + + if (self.nargs != 1 or self.multiple) and value == (): + return True + + return False + + def process_value(self, ctx: Context, value: t.Any) -> t.Any: + """Process the value of this parameter: + + 1. Type cast the value using :meth:`type_cast_value`. + 2. Check if the value is missing (see: :meth:`value_is_missing`), and raise + :exc:`MissingParameter` if it is required. + 3. If a :attr:`callback` is set, call it to have the value replaced by the + result of the callback. If the value was not set, the callback receive + ``None``. This keep the legacy behavior as it was before the introduction of + the :attr:`UNSET` sentinel. + + :meta private: + """ + # shelter `type_cast_value` from ever seeing an `UNSET` value by handling the + # cases in which `UNSET` gets special treatment explicitly at this layer + # + # Refs: + # https://github.com/pallets/click/issues/3069 + if value is UNSET: + if self.multiple or self.nargs == -1: + value = () + else: + value = self.type_cast_value(ctx, value) + + if self.required and self.value_is_missing(value): + raise MissingParameter(ctx=ctx, param=self) + + if self.callback is not None: + # Legacy case: UNSET is not exposed directly to the callback, but converted + # to None. + if value is UNSET: + value = None + + # Search for parameters with UNSET values in the context. + unset_keys = {k: None for k, v in ctx.params.items() if v is UNSET} + # No UNSET values, call the callback as usual. + if not unset_keys: + value = self.callback(ctx, self, value) + + # Legacy case: provide a temporarily manipulated context to the callback + # to hide UNSET values as None. + # + # Refs: + # https://github.com/pallets/click/issues/3136 + # https://github.com/pallets/click/pull/3137 + else: + # Add another layer to the context stack to clearly hint that the + # context is temporarily modified. + with ctx: + # Update the context parameters to replace UNSET with None. + ctx.params.update(unset_keys) + # Feed these fake context parameters to the callback. + value = self.callback(ctx, self, value) + # Restore the UNSET values in the context parameters. + ctx.params.update( + { + k: UNSET + for k in unset_keys + # Only restore keys that are present and still None, in case + # the callback modified other parameters. + if k in ctx.params and ctx.params[k] is None + } + ) + + return value + + def resolve_envvar_value(self, ctx: Context) -> str | None: + """Returns the value found in the environment variable(s) attached to this + parameter. + + Environment variables values are `always returned as strings + `_. + + This method returns ``None`` if: + + - the :attr:`envvar` property is not set on the :class:`Parameter`, + - the environment variable is not found in the environment, + - the variable is found in the environment but its value is empty (i.e. the + environment variable is present but has an empty string). + + If :attr:`envvar` is setup with multiple environment variables, + then only the first non-empty value is returned. + + .. caution:: + + The raw value extracted from the environment is not normalized and is + returned as-is. Any normalization or reconciliation is performed later by + the :class:`Parameter`'s :attr:`type`. + + :meta private: + """ + if not self.envvar: + return None + + if isinstance(self.envvar, str): + rv = os.environ.get(self.envvar) + + if rv: + return rv + else: + for envvar in self.envvar: + rv = os.environ.get(envvar) + + # Return the first non-empty value of the list of environment variables. + if rv: + return rv + # Else, absence of value is interpreted as an environment variable that + # is not set, so proceed to the next one. + + return None + + def value_from_envvar(self, ctx: Context) -> str | cabc.Sequence[str] | None: + """Process the raw environment variable string for this parameter. + + Returns the string as-is or splits it into a sequence of strings if the + parameter is expecting multiple values (i.e. its :attr:`nargs` property is set + to a value other than ``1``). + + :meta private: + """ + rv = self.resolve_envvar_value(ctx) + + if rv is not None and self.nargs != 1: + return self.type.split_envvar_value(rv) + + return rv + + def handle_parse_result( + self, ctx: Context, opts: cabc.Mapping[str, t.Any], args: list[str] + ) -> tuple[t.Any, list[str]]: + """Process the value produced by the parser from user input. + + Always process the value through the Parameter's :attr:`type`, wherever it + comes from. + + If the parameter is deprecated, this method warn the user about it. But only if + the value has been explicitly set by the user (and as such, is not coming from + a default). + + :meta private: + """ + # Capture the slot's existing state before we mutate + # ``_parameter_source`` so the write decision below can compare our + # incoming source against the source of the option that already wrote + # the slot (if any). + existing_value = ctx.params.get(self.name, UNSET) + existing_source = ctx.get_parameter_source(self.name) + existing_default_explicit = ctx._param_default_explicit.get(self.name, False) + + with augment_usage_errors(ctx, param=self): + value, source = self.consume_value(ctx, opts) + + # Record the source before processing so eager callbacks and type + # conversion can inspect it. Restored after arbitration if this + # option loses a feature-switch group. + ctx.set_parameter_source(self.name, source) + + # Display a deprecation warning if necessary. + if ( + self.deprecated + and value is not UNSET + and source < ParameterSource.DEFAULT_MAP + ): + message = _( + "DeprecationWarning: The {param_type} {name!r} is deprecated." + "{extra_message}" + ).format( + param_type=self.param_type_name, + name=self.human_readable_name, + extra_message=_format_deprecated_suffix(self.deprecated), + ) + echo(style(message, fg="red"), err=True) + + # Process the value through the parameter's type. + try: + value = self.process_value(ctx, value) + except Exception: + if not ctx.resilient_parsing: + raise + # In resilient parsing mode, we do not want to fail the command if the + # value is incompatible with the parameter type, so we reset the value + # to UNSET, which will be interpreted as a missing value. + value = UNSET + + # Arbitrate the slot when several parameters target the same variable + # name (feature-switch groups). See: https://github.com/pallets/click/issues/3403 + slot_empty = existing_value is UNSET + more_explicit = existing_source is not None and source < existing_source + same_source = existing_source is not None and source == existing_source + auto_would_downgrade_explicit = ( + same_source + and source == ParameterSource.DEFAULT + and existing_default_explicit + and not self._default_explicit + ) + is_winner = ( + slot_empty + or more_explicit + or (same_source and not auto_would_downgrade_explicit) + ) + + if is_winner: + if self.expose_value: + ctx.params[self.name] = value + ctx._param_default_explicit[self.name] = self._default_explicit + elif existing_source is not None: + # Lost arbitration; restore the winning option's source. + ctx.set_parameter_source(self.name, existing_source) + # else: ctx.params[self.name] was populated by code that bypassed + # handle_parse_result (from another option's callback for example). Keep + # the provisional source recorded before process_value so downstream + # lookups don't return ``None``. + + return value, args + + def get_help_record(self, ctx: Context) -> tuple[str, str] | None: + return None + + def get_usage_pieces(self, ctx: Context) -> list[str]: + return [] + + def get_error_hint(self, ctx: Context | None) -> str: + """Get a stringified version of the param for use in error messages to + indicate which param caused the error. + + .. versionchanged:: 8.4.0 + ``ctx`` can be ``None``. + """ + hint_list = self.opts or [self.human_readable_name] + return " / ".join(f"'{x}'" for x in hint_list) + + def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: + """Return a list of completions for the incomplete value. If a + ``shell_complete`` function was given during init, it is used. + Otherwise, the :attr:`type` + :meth:`~click.types.ParamType[t.Any].shell_complete` function is used. + + :param ctx: Invocation context for this command. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + if self._custom_shell_complete is not None: + results = self._custom_shell_complete(ctx, self, incomplete) + + if results and isinstance(results[0], str): + from click.shell_completion import CompletionItem + + results = [CompletionItem(c) for c in results] + + return t.cast("list[CompletionItem]", results) + + return self.type.shell_complete(ctx, self, incomplete) + + +class Option(Parameter): + """Options are usually optional values on the command line and + have some extra features that arguments don't have. + + All other parameters are passed onwards to the parameter constructor. + + :param show_default: Show the default value for this option in its + help text. Values are not shown by default, unless + :attr:`Context.show_default` is ``True``. If this value is a + string, it shows that string in parentheses instead of the + actual value. This is particularly useful for dynamic options. + For single option boolean flags, the default remains hidden if + its value is ``False``. + :param show_envvar: Controls if an environment variable should be + shown on the help page and error messages. + Normally, environment variables are not shown. + :param prompt: If set to ``True`` or a non empty string then the + user will be prompted for input. If set to ``True`` the prompt + will be the option name capitalized. A deprecated option cannot be + prompted. + :param confirmation_prompt: Prompt a second time to confirm the + value if it was prompted for. Can be set to a string instead of + ``True`` to customize the message. + :param prompt_required: If set to ``False``, the user will be + prompted for input only when the option was specified as a flag + without a value. + :param hide_input: If this is ``True`` then the input on the prompt + will be hidden from the user. This is useful for password input. + :param is_flag: forces this option to act as a flag. The default is + auto detection. + :param flag_value: which value should be used for this flag if it's + enabled. This is set to a boolean automatically if + the option string contains a slash to mark two options. + :param multiple: if this is set to `True` then the argument is accepted + multiple times and recorded. This is similar to ``nargs`` + in how it works but supports arbitrary number of + arguments. + :param count: this flag makes an option increment an integer. + :param allow_from_autoenv: if this is enabled then the value of this + parameter will be pulled from an environment + variable in case a prefix is defined on the + context. + :param help: the help string. + :param hidden: hide this option from help outputs. + :param attrs: Other command arguments described in :class:`Parameter`. + + .. versionchanged:: 8.4.0 + Non-basic ``flag_value`` types (not ``str``, ``int``, ``float``, or + ``bool``) are passed through unchanged instead of being stringified. + Previously, ``type=click.UNPROCESSED`` was required to preserve them. + + .. versionchanged:: 8.2 + ``envvar`` used with ``flag_value`` will always use the ``flag_value``, + previously it would use the value of the environment variable. + + .. versionchanged:: 8.1 + Help text indentation is cleaned here instead of only in the + ``@option`` decorator. + + .. versionchanged:: 8.1 + The ``show_default`` parameter overrides + ``Context.show_default``. + + .. versionchanged:: 8.1 + The default of a single option boolean flag is not shown if the + default value is ``False``. + + .. versionchanged:: 8.0.1 + ``type`` is detected from ``flag_value`` if given, for basic Python + types (``str``, ``int``, ``float``, ``bool``). + """ + + param_type_name = "option" + + def __init__( + self, + param_decls: cabc.Sequence[str] | None = None, + show_default: bool | str | None = None, + prompt: bool | str = False, + confirmation_prompt: bool | str = False, + prompt_required: bool = True, + hide_input: bool = False, + is_flag: bool | None = None, + flag_value: t.Any = UNSET, + multiple: bool = False, + count: bool = False, + allow_from_autoenv: bool = True, + type: types.ParamType[t.Any] | t.Any | None = None, + help: str | None = None, + hidden: bool = False, + show_choices: bool = True, + show_envvar: bool = False, + deprecated: bool | str = False, + **attrs: t.Any, + ) -> None: + if help: + help = inspect.cleandoc(help) + + super().__init__( + param_decls, type=type, multiple=multiple, deprecated=deprecated, **attrs + ) + + if prompt is True: + if not self.name: + raise TypeError("'name' is required with 'prompt=True'.") + + prompt_text: str | None = self.name.replace("_", " ").capitalize() + elif prompt is False: + prompt_text = None + else: + prompt_text = prompt + + if deprecated: + label = _format_deprecated_label(deprecated) + help = f"{help} {label}" if help is not None else label + + self.prompt = prompt_text + self.confirmation_prompt = confirmation_prompt + self.prompt_required = prompt_required + self.hide_input = hide_input + self.hidden = hidden + + # The _flag_needs_value property tells the parser that this option is a flag + # that cannot be used standalone and needs a value. With this information, the + # parser can determine whether to consider the next user-provided argument in + # the CLI as a value for this flag or as a new option. + # If prompt is enabled but not required, then it opens the possibility for the + # option to gets its value from the user. + self._flag_needs_value = self.prompt is not None and not self.prompt_required + + # Auto-detect if this is a flag or not. + if is_flag is None: + # Implicitly a flag because flag_value was set. + if flag_value is not UNSET: + is_flag = True + # Not a flag, but when used as a flag it shows a prompt. + elif self._flag_needs_value: + is_flag = False + # Implicitly a flag because secondary options names were given. + elif self.secondary_opts: + is_flag = True + + # The option is explicitly not a flag, but to determine whether or not it needs + # value, we need to check if `flag_value` or `default` was set. Either one is + # sufficient. + # Ref: https://github.com/pallets/click/issues/3084 + elif is_flag is False and not self._flag_needs_value: + self._flag_needs_value = flag_value is not UNSET or self.default is UNSET + + if is_flag: + # Set missing default for flags if not explicitly required or prompted. + if self.default is UNSET and not self.required and not self.prompt: + if multiple: + self.default = () + + # Auto-detect the type of the flag based on the flag_value. + if type is None: + # A flag without a flag_value is a boolean flag. + if flag_value is UNSET: + self.type: types.ParamType[t.Any] = types.BoolParamType() + # If the flag value is a boolean, use BoolParamType. + elif isinstance(flag_value, bool): + self.type = types.BoolParamType() + # Otherwise, guess the type from the flag value. + else: + guessed = types.convert_type(None, flag_value) + if ( + isinstance(guessed, types.StringParamType) + and not isinstance(flag_value, str) + and flag_value is not None + ): + # The flag_value type couldn't be auto-detected + # (not str, int, float, or bool). Since flag_value + # is a programmer-provided Python object, not CLI + # input, pass it through unchanged instead of + # stringifying it. + self.type = types.UNPROCESSED + else: + self.type = guessed + + self.is_flag: bool = bool(is_flag) + self.is_bool_flag: bool = bool( + is_flag and isinstance(self.type, types.BoolParamType) + ) + self.flag_value: t.Any = flag_value + + # Set boolean flag default to False if unset and not required. + if self.is_bool_flag: + if self.default is UNSET and not self.required: + self.default = False + + # The alignment of default to the flag_value is resolved lazily in + # get_default() to prevent callable flag_values (like classes) from + # being instantiated. Refs: + # https://github.com/pallets/click/issues/3121 + # https://github.com/pallets/click/issues/3024#issuecomment-3146199461 + # https://github.com/pallets/click/pull/3030/commits/06847da + + # Set the default flag_value if it is not set. + if self.flag_value is UNSET: + if self.is_flag: + self.flag_value = True + else: + self.flag_value = None + + # Counting. + self.count = count + if count: + if type is None: + self.type = types.IntRange(min=0) + if self.default is UNSET: + self.default = 0 + + self.allow_from_autoenv = allow_from_autoenv + self.help = help + self.show_default = show_default + self.show_choices = show_choices + self.show_envvar = show_envvar + + if __debug__: + if deprecated and prompt: + raise ValueError("`deprecated` options cannot use `prompt`.") + + if self.nargs == -1: + raise TypeError("nargs=-1 is not supported for options.") + + if not self.is_bool_flag and self.secondary_opts: + raise TypeError("Secondary flag is not valid for non-boolean flag.") + + if self.is_bool_flag and self.hide_input and self.prompt is not None: + raise TypeError( + "'prompt' with 'hide_input' is not valid for boolean flag." + ) + + if self.count: + if self.multiple: + raise TypeError("'count' is not valid with 'multiple'.") + + if self.is_flag: + raise TypeError("'count' is not valid with 'is_flag'.") + + def to_info_dict(self) -> dict[str, t.Any]: + """ + .. versionchanged:: 8.3.0 + Returns ``None`` for the :attr:`flag_value` if it was not set. + """ + info_dict = super().to_info_dict() + info_dict.update( + help=self.help, + prompt=self.prompt, + is_flag=self.is_flag, + # We explicitly hide the :attr:`UNSET` value to the user, as we choose to + # make it an implementation detail. And because ``to_info_dict`` has been + # designed for documentation purposes, we return ``None`` instead. + flag_value=self.flag_value if self.flag_value is not UNSET else None, + count=self.count, + hidden=self.hidden, + ) + return info_dict + + def get_default( + self, ctx: Context, call: bool = True + ) -> t.Any | t.Callable[[], t.Any] | None: + """Return the default value for this option. + + For non-boolean flag options, ``default=True`` is treated as a sentinel + meaning "activate this flag by default" and is resolved to + :attr:`flag_value`. For example, with ``--upper/--lower`` feature + switches where ``flag_value="upper"`` and ``default=True``, the default + resolves to ``"upper"``. + + .. caution:: + This substitution only applies to non-boolean flags + (:attr:`is_bool_flag` is ``False``). For boolean flags, ``True`` is + a legitimate Python value and ``default=True`` is returned as-is. + + .. versionchanged:: 8.3.3 + ``default=True`` is no longer substituted with ``flag_value`` for + boolean flags, fixing negative boolean flags like + ``flag_value=False, default=True``. + """ + value = super().get_default(ctx, call=False) + + # Resolve default=True to flag_value lazily (here instead of + # __init__) to prevent callable flag_values (like classes) from + # being instantiated by the callable check below. + if value is True and self.is_flag and not self.is_bool_flag: + value = self.flag_value + elif call and callable(value): + value = value() + + return value + + def get_error_hint(self, ctx: Context | None) -> str: + result = super().get_error_hint(ctx) + if self.show_envvar and self.envvar is not None: + result += f" (env var: '{self.envvar}')" + return result + + def _parse_decls( + self, decls: cabc.Sequence[str], expose_value: bool + ) -> tuple[str, list[str], list[str]]: + opts = [] + secondary_opts = [] + name = None + possible_names = [] + + for decl in decls: + if decl.isidentifier(): + if name is not None: + raise TypeError(_("Name '{name}' defined twice").format(name=name)) + name = decl + else: + split_char = ";" if decl[:1] == "/" else "/" + if split_char in decl: + first, second = decl.split(split_char, 1) + first = first.rstrip() + if first: + possible_names.append(_split_opt(first)) + opts.append(first) + second = second.lstrip() + if second: + secondary_opts.append(second.lstrip()) + if first == second: + raise ValueError( + _( + "Boolean option {decl!r} cannot use the" + " same flag for true/false." + ).format(decl=decl) + ) + else: + possible_names.append(_split_opt(decl)) + opts.append(decl) + + if name is None and possible_names: + possible_names.sort(key=lambda x: -len(x[0])) # group long options first + name = possible_names[0][1].replace("-", "_").lower() + if not name.isidentifier(): + name = None + + if name is None: + if not expose_value: + return "", opts, secondary_opts + raise TypeError( + _( + "Could not determine name for option with declarations {decls!r}" + ).format(decls=decls) + ) + + if not opts and not secondary_opts: + raise TypeError( + _( + "No options defined but a name was passed ({name})." + " Did you mean to declare an argument instead? Did" + " you mean to pass '--{name}'?" + ).format(name=name) + ) + + return name, opts, secondary_opts + + def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: + if self.multiple: + action = "append" + elif self.count: + action = "count" + else: + action = "store" + + if self.is_flag: + action = f"{action}_const" + + if self.is_bool_flag and self.secondary_opts: + parser.add_option( + obj=self, opts=self.opts, dest=self.name, action=action, const=True + ) + parser.add_option( + obj=self, + opts=self.secondary_opts, + dest=self.name, + action=action, + const=False, + ) + else: + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + const=self.flag_value, + ) + else: + parser.add_option( + obj=self, + opts=self.opts, + dest=self.name, + action=action, + nargs=self.nargs, + ) + + def get_help_record(self, ctx: Context) -> tuple[str, str] | None: + if self.hidden: + return None + + any_prefix_is_slash = False + + def _write_opts(opts: cabc.Sequence[str]) -> str: + nonlocal any_prefix_is_slash + + rv, any_slashes = join_options(opts) + + if any_slashes: + any_prefix_is_slash = True + + if not self.is_flag and not self.count: + rv += f" {self.make_metavar(ctx=ctx)}" + + return rv + + rv = [_write_opts(self.opts)] + + if self.secondary_opts: + rv.append(_write_opts(self.secondary_opts)) + + help = self.help or "" + + extra = self.get_help_extra(ctx) + extra_items = [] + if "envvars" in extra: + extra_items.append( + _("env var: {var}").format(var=", ".join(extra["envvars"])) + ) + if "default" in extra: + extra_items.append(_("default: {default}").format(default=extra["default"])) + if "range" in extra: + extra_items.append(extra["range"]) + if "required" in extra: + extra_items.append(_(extra["required"])) + + if extra_items: + extra_str = "; ".join(extra_items) + help = f"{help} [{extra_str}]" if help else f"[{extra_str}]" + + return ("; " if any_prefix_is_slash else " / ").join(rv), help + + def get_help_extra(self, ctx: Context) -> types.OptionHelpExtra: + extra: types.OptionHelpExtra = {} + + if self.show_envvar: + envvar = self.envvar + + if envvar is None: + if ( + self.allow_from_autoenv + and ctx.auto_envvar_prefix is not None + and self.name + ): + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + + if envvar is not None: + if isinstance(envvar, str): + extra["envvars"] = (envvar,) + else: + extra["envvars"] = tuple(str(d) for d in envvar) + + # Temporarily enable resilient parsing to avoid type casting + # failing for the default. Might be possible to extend this to + # help formatting in general. + resilient = ctx.resilient_parsing + ctx.resilient_parsing = True + + try: + default_value = self.get_default(ctx, call=False) + finally: + ctx.resilient_parsing = resilient + + show_default = False + show_default_is_str = False + + if self.show_default is not None: + if isinstance(self.show_default, str): + show_default_is_str = show_default = True + else: + show_default = self.show_default + elif ctx.show_default is not None: + show_default = ctx.show_default + + if show_default_is_str or ( + show_default and (default_value not in (None, UNSET)) + ): + if show_default_is_str: + default_string = f"({self.show_default})" + elif isinstance(default_value, (list, tuple)): + default_string = ", ".join(str(d) for d in default_value) + elif isinstance(default_value, enum.Enum): + default_string = default_value.name + elif inspect.isfunction(default_value): + default_string = _("(dynamic)") + elif self.is_bool_flag and self.secondary_opts: + # For boolean flags that have distinct True/False opts, + # use the opt without prefix instead of the value. + default_string = _split_opt( + (self.opts if default_value else self.secondary_opts)[0] + )[1] + elif self.is_bool_flag and not self.secondary_opts and not default_value: + default_string = "" + elif isinstance(default_value, str) and default_value == "": + default_string = '""' + else: + default_string = str(default_value) + + if default_string: + extra["default"] = default_string + + if ( + isinstance(self.type, types._NumberRangeBase) + # skip count with default range type + and not (self.count and self.type.min == 0 and self.type.max is None) + ): + range_str = self.type._describe_range() + + if range_str: + extra["range"] = range_str + + if self.required: + extra["required"] = "required" + + return extra + + def prompt_for_value(self, ctx: Context) -> t.Any: + """This is an alternative flow that can be activated in the full + value processing if a value does not exist. It will prompt the + user until a valid value exists and then returns the processed + value as result. + """ + assert self.prompt is not None + + # Calculate the default before prompting anything to lock in the value before + # attempting any user interaction. + default = self.get_default(ctx) + + # A boolean flag can use a simplified [y/n] confirmation prompt. + if self.is_bool_flag: + # If we have no boolean default, we force the user to explicitly provide + # one. + if default in (UNSET, None): + default = None + # Nothing prevent you to declare an option that is simultaneously: + # 1) auto-detected as a boolean flag, + # 2) allowed to prompt, and + # 3) still declare a non-boolean default. + # This forced casting into a boolean is necessary to align any non-boolean + # default to the prompt, which is going to be a [y/n]-style confirmation + # because the option is still a boolean flag. That way, instead of [y/n], + # we get [Y/n] or [y/N] depending on the truthy value of the default. + # Refs: https://github.com/pallets/click/pull/3030#discussion_r2289180249 + else: + default = bool(default) + return confirm(self.prompt, default) + + # If show_default is given, provide this to `prompt` as well, + # otherwise we use `prompt`'s default behavior + prompt_kwargs: t.Any = {} + if self.show_default is not None: + prompt_kwargs["show_default"] = self.show_default + + return prompt( + self.prompt, + # Use ``None`` to inform the prompt() function to reiterate until a valid + # value is provided by the user if we have no default. + default=None if default is UNSET else default, + type=self.type, + hide_input=self.hide_input, + show_choices=self.show_choices, + confirmation_prompt=self.confirmation_prompt, + value_proc=lambda x: self.process_value(ctx, x), + **prompt_kwargs, + ) + + def resolve_envvar_value(self, ctx: Context) -> str | None: + """:class:`Option` resolves its environment variable the same way as + :func:`Parameter.resolve_envvar_value`, but it also supports + :attr:`Context.auto_envvar_prefix`. If we could not find an environment from + the :attr:`envvar` property, we fallback on :attr:`Context.auto_envvar_prefix` + to build dynamiccaly the environment variable name using the + :python:`{ctx.auto_envvar_prefix}_{self.name.upper()}` template. + + :meta private: + """ + rv = super().resolve_envvar_value(ctx) + + if rv is not None: + return rv + + if self.allow_from_autoenv and ctx.auto_envvar_prefix is not None and self.name: + envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}" + rv = os.environ.get(envvar) + + if rv: + return rv + + return None + + def value_from_envvar(self, ctx: Context) -> t.Any: + """For :class:`Option`, this method processes the raw environment variable + string the same way as :func:`Parameter.value_from_envvar` does. + + But in the case of non-boolean flags, the value is analyzed to determine if the + flag is activated or not, and returns a boolean of its activation, or the + :attr:`flag_value` if the latter is set. + + This method also takes care of repeated options (i.e. options with + :attr:`multiple` set to ``True``). + + :meta private: + """ + rv = self.resolve_envvar_value(ctx) + + # Absent environment variable or an empty string is interpreted as unset. + if rv is None: + return None + + # Non-boolean flags are more liberal in what they accept. But a flag being a + # flag, its envvar value still needs to be analyzed to determine if the flag is + # activated or not. + if self.is_flag and not self.is_bool_flag: + # If the flag_value is set and match the envvar value, return it + # directly. + if self.flag_value is not UNSET and rv == self.flag_value: + return self.flag_value + # Analyze the envvar value as a boolean to know if the flag is + # activated or not. + return types.BoolParamType.str_to_bool(rv) + + # Split the envvar value if it is allowed to be repeated. + value_depth = (self.nargs != 1) + bool(self.multiple) + if value_depth > 0: + multi_rv = self.type.split_envvar_value(rv) + if self.multiple and self.nargs != 1: + multi_rv = batch(multi_rv, self.nargs) # type: ignore[assignment] + + return multi_rv + + return rv + + def consume_value( + self, ctx: Context, opts: cabc.Mapping[str, Parameter] + ) -> tuple[t.Any, ParameterSource]: + """For :class:`Option`, the value can be collected from an interactive prompt + if the option is a flag that needs a value (and the :attr:`prompt` property is + set). + + Additionally, this method handles flag option that are activated without a + value, in which case the :attr:`flag_value` is returned. + + :meta private: + """ + value, source = super().consume_value(ctx, opts) + + # The parser will emit a sentinel value if the option is allowed to as a flag + # without a value. + if value is FLAG_NEEDS_VALUE: + # If the option allows for a prompt, we start an interaction with the user. + if self.prompt is not None and not ctx.resilient_parsing: + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT + # Else the flag takes its flag_value as value. + else: + value = self.flag_value + source = ParameterSource.COMMANDLINE + + # A flag which is activated always returns the flag value, unless the value + # comes from the explicitly sets default. + elif ( + self.is_flag + and value is True + and not self.is_bool_flag + and source < ParameterSource.DEFAULT_MAP + ): + value = self.flag_value + + # Re-interpret a multiple option which has been sent as-is by the parser. + # Here we replace each occurrence of value-less flags (marked by the + # FLAG_NEEDS_VALUE sentinel) with the flag_value. + elif ( + self.multiple + and value is not UNSET + and isinstance(value, cabc.Iterable) + and source < ParameterSource.DEFAULT_MAP + and any(v is FLAG_NEEDS_VALUE for v in value) + ): + value = [self.flag_value if v is FLAG_NEEDS_VALUE else v for v in value] + source = ParameterSource.COMMANDLINE + + # The value wasn't set, or used the param's default, prompt for one to the user + # if prompting is enabled. + elif ( + (value is UNSET or source >= ParameterSource.DEFAULT_MAP) + and self.prompt is not None + and (self.required or self.prompt_required) + and not ctx.resilient_parsing + ): + value = self.prompt_for_value(ctx) + source = ParameterSource.PROMPT + + return value, source + + def process_value(self, ctx: Context, value: t.Any) -> t.Any: + # process_value has to be overridden on Options in order to capture + # `value == UNSET` cases before `type_cast_value()` gets called. + # + # Refs: + # https://github.com/pallets/click/issues/3069 + if self.is_flag and not self.required and self.is_bool_flag and value is UNSET: + value = False + + if self.callback is not None: + value = self.callback(ctx, self, value) + + return value + + # in the normal case, rely on Parameter.process_value + return super().process_value(ctx, value) + + +class Argument(Parameter): + """Arguments are positional parameters to a command. They generally + provide fewer features than options but can have infinite ``nargs`` + and are required by default. + + All parameters are passed onwards to the constructor of :class:`Parameter`. + """ + + param_type_name = "argument" + + def __init__( + self, + param_decls: cabc.Sequence[str], + required: bool | None = None, + **attrs: t.Any, + ) -> None: + # Auto-detect the requirement status of the argument if not explicitly set. + if required is None: + # The argument gets automatically required if it has no explicit default + # value set and is setup to match at least one value. + if attrs.get("default", UNSET) is UNSET: + required = attrs.get("nargs", 1) > 0 + # If the argument has a default value, it is not required. + else: + required = False + + if "multiple" in attrs: + raise TypeError("__init__() got an unexpected keyword argument 'multiple'.") + + super().__init__(param_decls, required=required, **attrs) + + @property + def human_readable_name(self) -> str: + if self.metavar is not None: + return self.metavar + return self.name.upper() + + def make_metavar(self, ctx: Context) -> str: + if self.metavar is not None: + return self.metavar + var = self.type.get_metavar(param=self, ctx=ctx) + if not var: + var = self.name.upper() + if self.deprecated: + var += "!" + if not self.required: + var = f"[{var}]" + if self.nargs != 1: + var += "..." + return var + + def _parse_decls( + self, decls: cabc.Sequence[str], expose_value: bool + ) -> tuple[str, list[str], list[str]]: + if not decls: + if not expose_value: + return "", [], [] + raise TypeError("Argument is marked as exposed, but does not have a name.") + if len(decls) == 1: + name = arg = decls[0] + name = name.replace("-", "_").lower() + else: + raise TypeError( + _( + "Arguments take exactly one parameter declaration, got" + " {length}: {decls}." + ).format(length=len(decls), decls=decls) + ) + return name, [arg], [] + + def get_usage_pieces(self, ctx: Context) -> list[str]: + return [self.make_metavar(ctx)] + + def get_error_hint(self, ctx: Context | None) -> str: + if ctx is not None: + return f"'{self.make_metavar(ctx)}'" + return f"'{self.human_readable_name}'" + + def add_to_parser(self, parser: _OptionParser, ctx: Context) -> None: + parser.add_argument(dest=self.name, nargs=self.nargs, obj=self) + + +def __getattr__(name: str) -> object: + import warnings + + if name == "BaseCommand": + warnings.warn( + "'BaseCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Command' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _BaseCommand + + if name == "MultiCommand": + warnings.warn( + "'MultiCommand' is deprecated and will be removed in Click 9.0. Use" + " 'Group' instead.", + DeprecationWarning, + stacklevel=2, + ) + return _MultiCommand + + raise AttributeError(name) diff --git a/server/venv/Lib/site-packages/click/decorators.py b/server/venv/Lib/site-packages/click/decorators.py new file mode 100644 index 0000000..14aee42 --- /dev/null +++ b/server/venv/Lib/site-packages/click/decorators.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +import inspect +import typing as t +from functools import update_wrapper +from gettext import gettext as _ + +from .core import Argument +from .core import Command +from .core import Context +from .core import Group +from .core import Option +from .core import Parameter +from .globals import get_current_context +from .utils import echo + +if t.TYPE_CHECKING: + import typing_extensions as te + + P = te.ParamSpec("P") + +R = t.TypeVar("R") +T = t.TypeVar("T") +_AnyCallable = t.Callable[..., t.Any] +FC = t.TypeVar("FC", bound="_AnyCallable | Command") + + +def pass_context(f: t.Callable[te.Concatenate[Context, P], R]) -> t.Callable[P, R]: + """Marks a callback as wanting to receive the current context + object as first argument. + """ + + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: + return f(get_current_context(), *args, **kwargs) + + return update_wrapper(new_func, f) + + +def pass_obj(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]: + """Similar to :func:`pass_context`, but only pass the object on the + context onwards (:attr:`Context.obj`). This is useful if that object + represents the state of a nested system. + """ + + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: + return f(get_current_context().obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + +def make_pass_decorator( + object_type: type[T], ensure: bool = False +) -> t.Callable[[t.Callable[te.Concatenate[T, P], R]], t.Callable[P, R]]: + """Given an object type this creates a decorator that will work + similar to :func:`pass_obj` but instead of passing the object of the + current context, it will find the innermost context of type + :func:`object_type`. + + This generates a decorator that works roughly like this:: + + from functools import update_wrapper + + def decorator(f): + @pass_context + def new_func(ctx, *args, **kwargs): + obj = ctx.find_object(object_type) + return ctx.invoke(f, obj, *args, **kwargs) + return update_wrapper(new_func, f) + return decorator + + :param object_type: the type of the object to pass. + :param ensure: if set to `True`, a new object will be created and + remembered on the context if it's not there yet. + """ + + def decorator(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]: + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: + ctx = get_current_context() + + obj: T | None + if ensure: + obj = ctx.ensure_object(object_type) + else: + obj = ctx.find_object(object_type) + + if obj is None: + raise RuntimeError( + "Managed to invoke callback without a context" + f" object of type {object_type.__name__!r}" + " existing." + ) + + return ctx.invoke(f, obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + return decorator + + +def pass_meta_key( + key: str, *, doc_description: str | None = None +) -> t.Callable[[t.Callable[te.Concatenate[T, P], R]], t.Callable[P, R]]: + """Create a decorator that passes a key from + :attr:`click.Context.meta` as the first argument to the decorated + function. + + :param key: Key in ``Context.meta`` to pass. + :param doc_description: Description of the object being passed, + inserted into the decorator's docstring. Defaults to "the 'key' + key from Context.meta". + + .. versionadded:: 8.0 + """ + + def decorator(f: t.Callable[te.Concatenate[T, P], R]) -> t.Callable[P, R]: + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: + ctx = get_current_context() + obj = ctx.meta[key] + return ctx.invoke(f, obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + if doc_description is None: + doc_description = f"the {key!r} key from :attr:`click.Context.meta`" + + decorator.__doc__ = ( + f"Decorator that passes {doc_description} as the first argument" + " to the decorated function." + ) + return decorator + + +CmdType = t.TypeVar("CmdType", bound=Command) + + +# variant: no call, directly as decorator for a function. +@t.overload +def command(name: _AnyCallable) -> Command: ... + + +# variant: with positional name and with positional or keyword cls argument: +# @command(namearg, CommandCls, ...) or @command(namearg, cls=CommandCls, ...) +@t.overload +def command( + name: str | None, + cls: type[CmdType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], CmdType]: ... + + +# variant: name omitted, cls _must_ be a keyword argument, @command(cls=CommandCls, ...) +@t.overload +def command( + name: None = None, + *, + cls: type[CmdType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], CmdType]: ... + + +# variant: with optional string name, no cls argument provided. +@t.overload +def command( + name: str | None = ..., cls: None = None, **attrs: t.Any +) -> t.Callable[[_AnyCallable], Command]: ... + + +def command( + name: str | _AnyCallable | None = None, + cls: type[CmdType] | None = None, + **attrs: t.Any, +) -> Command | t.Callable[[_AnyCallable], Command | CmdType]: + r"""Creates a new :class:`Command` and uses the decorated function as + callback. This will also automatically attach all decorated + :func:`option`\s and :func:`argument`\s as parameters to the command. + + The name of the command defaults to the name of the function, converted to + lowercase, with underscores ``_`` replaced by dashes ``-``, and the suffixes + ``_command``, ``_cmd``, ``_group``, and ``_grp`` are removed. For example, + ``init_data_command`` becomes ``init-data``. + + All keyword arguments are forwarded to the underlying command class. + For the ``params`` argument, any decorated params are appended to + the end of the list. + + Once decorated the function turns into a :class:`Command` instance + that can be invoked as a command line utility or be attached to a + command :class:`Group`. + + :param name: The name of the command. Defaults to modifying the function's + name as described above. + :param cls: The command class to create. Defaults to :class:`Command`. + + .. versionchanged:: 8.2 + The suffixes ``_command``, ``_cmd``, ``_group``, and ``_grp`` are + removed when generating the name. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + + .. versionchanged:: 8.1 + The ``params`` argument can be used. Decorated params are + appended to the end of the list. + """ + + func: t.Callable[[_AnyCallable], t.Any] | None = None + + if callable(name): + func = name + name = None + assert cls is None, "Use 'command(cls=cls)(callable)' to specify a class." + assert not attrs, "Use 'command(**kwargs)(callable)' to provide arguments." + + if cls is None: + cls = t.cast("type[CmdType]", Command) + + def decorator(f: _AnyCallable) -> CmdType: + if isinstance(f, Command): + raise TypeError("Attempted to convert a callback into a command twice.") + + attr_params = attrs.pop("params", None) + params = attr_params if attr_params is not None else [] + + try: + decorator_params = f.__click_params__ # type: ignore + except AttributeError: + pass + else: + del f.__click_params__ # type: ignore + params.extend(reversed(decorator_params)) + + if attrs.get("help") is None: + attrs["help"] = f.__doc__ + + if t.TYPE_CHECKING: + assert cls is not None + assert not callable(name) + + if name is not None: + cmd_name = name + else: + cmd_name = f.__name__.lower().replace("_", "-") + cmd_left, sep, suffix = cmd_name.rpartition("-") + + if sep and suffix in {"command", "cmd", "group", "grp"}: + cmd_name = cmd_left + + cmd = cls(name=cmd_name, callback=f, params=params, **attrs) + cmd.__doc__ = f.__doc__ + return cmd + + if func is not None: + return decorator(func) + + return decorator + + +GrpType = t.TypeVar("GrpType", bound=Group) + + +# variant: no call, directly as decorator for a function. +@t.overload +def group(name: _AnyCallable) -> Group: ... + + +# variant: with positional name and with positional or keyword cls argument: +# @group(namearg, GroupCls, ...) or @group(namearg, cls=GroupCls, ...) +@t.overload +def group( + name: str | None, + cls: type[GrpType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], GrpType]: ... + + +# variant: name omitted, cls _must_ be a keyword argument, @group(cmd=GroupCls, ...) +@t.overload +def group( + name: None = None, + *, + cls: type[GrpType], + **attrs: t.Any, +) -> t.Callable[[_AnyCallable], GrpType]: ... + + +# variant: with optional string name, no cls argument provided. +@t.overload +def group( + name: str | None = ..., cls: None = None, **attrs: t.Any +) -> t.Callable[[_AnyCallable], Group]: ... + + +def group( + name: str | _AnyCallable | None = None, + cls: type[GrpType] | None = None, + **attrs: t.Any, +) -> Group | t.Callable[[_AnyCallable], Group | GrpType]: + """Creates a new :class:`Group` with a function as callback. This + works otherwise the same as :func:`command` just that the `cls` + parameter is set to :class:`Group`. + + .. versionchanged:: 8.1 + This decorator can be applied without parentheses. + """ + if cls is None: + cls = t.cast("type[GrpType]", Group) + + if callable(name): + return command(cls=cls, **attrs)(name) + + return command(name, cls, **attrs) + + +def _param_memo(f: t.Callable[..., t.Any], param: Parameter) -> None: + if isinstance(f, Command): + f.params.append(param) + else: + if not hasattr(f, "__click_params__"): + f.__click_params__ = [] # type: ignore + + f.__click_params__.append(param) # type: ignore + + +def argument( + *param_decls: str, cls: type[Argument] | None = None, **attrs: t.Any +) -> t.Callable[[FC], FC]: + """Attaches an argument to the command. All positional arguments are + passed as parameter declarations to :class:`Argument`; all keyword + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Argument` instance manually + and attaching it to the :attr:`Command.params` list. + + For the default argument class, refer to :class:`Argument` and + :class:`Parameter` for descriptions of parameters. + + :param cls: the argument class to instantiate. This defaults to + :class:`Argument`. + :param param_decls: Passed as positional arguments to the constructor of + ``cls``. + :param attrs: Passed as keyword arguments to the constructor of ``cls``. + """ + if cls is None: + cls = Argument + + def decorator(f: FC) -> FC: + _param_memo(f, cls(param_decls, **attrs)) + return f + + return decorator + + +def option( + *param_decls: str, cls: type[Option] | None = None, **attrs: t.Any +) -> t.Callable[[FC], FC]: + """Attaches an option to the command. All positional arguments are + passed as parameter declarations to :class:`Option`; all keyword + arguments are forwarded unchanged (except ``cls``). + This is equivalent to creating an :class:`Option` instance manually + and attaching it to the :attr:`Command.params` list. + + For the default option class, refer to :class:`Option` and + :class:`Parameter` for descriptions of parameters. + + :param cls: the option class to instantiate. This defaults to + :class:`Option`. + :param param_decls: Passed as positional arguments to the constructor of + ``cls``. + :param attrs: Passed as keyword arguments to the constructor of ``cls``. + """ + if cls is None: + cls = Option + + def decorator(f: FC) -> FC: + _param_memo(f, cls(param_decls, **attrs)) + return f + + return decorator + + +def confirmation_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--yes`` option which shows a prompt before continuing if + not passed. If the prompt is declined, the program will exit. + + :param param_decls: One or more option names. Defaults to the single + value ``"--yes"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ + + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value: + ctx.abort() + + if not param_decls: + param_decls = ("--yes",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("callback", callback) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("prompt", _("Do you want to continue?")) + kwargs.setdefault("help", _("Confirm the action without prompting.")) + return option(*param_decls, **kwargs) + + +def password_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Add a ``--password`` option which prompts for a password, hiding + input and asking to enter the value again for confirmation. + + :param param_decls: One or more option names. Defaults to the single + value ``"--password"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ + if not param_decls: + param_decls = ("--password",) + + kwargs.setdefault("prompt", True) + kwargs.setdefault("confirmation_prompt", True) + kwargs.setdefault("hide_input", True) + return option(*param_decls, **kwargs) + + +def version_option( + version: str | None = None, + *param_decls: str, + package_name: str | None = None, + prog_name: str | None = None, + message: str | None = None, + **kwargs: t.Any, +) -> t.Callable[[FC], FC]: + """Add a ``--version`` option which immediately prints the version + number and exits the program. + + If ``version`` is not provided, Click will try to detect it using + :func:`importlib.metadata.version` to get the version for the + ``package_name``. + + If ``package_name`` is not provided, Click will try to detect it by + inspecting the stack frames. This will be used to detect the + version, so it must match the name of the installed package. + + :param version: The version number to show. If not provided, Click + will try to detect it. + :param param_decls: One or more option names. Defaults to the single + value ``"--version"``. + :param package_name: The package name to detect the version from. If + not provided, Click will try to detect it. + :param prog_name: The name of the CLI to show in the message. If not + provided, it will be detected from the command. + :param message: The message to show. The values ``%(prog)s``, + ``%(package)s``, and ``%(version)s`` are available. Defaults to + ``"%(prog)s, version %(version)s"``. + :param kwargs: Extra arguments are passed to :func:`option`. + :raise RuntimeError: ``version`` could not be detected. + + .. versionchanged:: 8.0 + Add the ``package_name`` parameter, and the ``%(package)s`` + value for messages. + + .. versionchanged:: 8.0 + Use :mod:`importlib.metadata` instead of ``pkg_resources``. The + version is detected based on the package name, not the entry + point name. The Python package name must match the installed + package name, or be passed with ``package_name=``. + """ + if message is None: + message = _("%(prog)s, version %(version)s") + + if version is None and package_name is None: + frame = inspect.currentframe() + f_back = frame.f_back if frame is not None else None + f_globals = f_back.f_globals if f_back is not None else None + # break reference cycle + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + del frame + + if f_globals is not None: + package_name = f_globals.get("__name__") + + if package_name == "__main__": + package_name = f_globals.get("__package__") + + if package_name: + package_name = package_name.partition(".")[0] + + def callback(ctx: Context, param: Parameter, value: bool) -> None: + if not value or ctx.resilient_parsing: + return + + nonlocal prog_name + nonlocal version + + if prog_name is None: + prog_name = ctx.find_root().info_name + + if version is None and package_name is not None: + import importlib.metadata + + try: + version = importlib.metadata.version(package_name) + except importlib.metadata.PackageNotFoundError: + raise RuntimeError( + f"{package_name!r} is not installed. Try passing" + " 'package_name' instead." + ) from None + + if version is None: + raise RuntimeError( + f"Could not determine the version for {package_name!r} automatically." + ) + + echo( + message % {"prog": prog_name, "package": package_name, "version": version}, + color=ctx.color, + ) + ctx.exit() + + if not param_decls: + param_decls = ("--version",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", _("Show the version and exit.")) + kwargs["callback"] = callback + return option(*param_decls, **kwargs) + + +def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]: + """Pre-configured ``--help`` option which immediately prints the help page + and exits the program. + + :param param_decls: One or more option names. Defaults to the single + value ``"--help"``. + :param kwargs: Extra arguments are passed to :func:`option`. + """ + + def show_help(ctx: Context, param: Parameter, value: bool) -> None: + """Callback that print the help page on ```` and exits.""" + if value and not ctx.resilient_parsing: + echo(ctx.get_help(), color=ctx.color) + ctx.exit() + + if not param_decls: + param_decls = ("--help",) + + kwargs.setdefault("is_flag", True) + kwargs.setdefault("expose_value", False) + kwargs.setdefault("is_eager", True) + kwargs.setdefault("help", _("Show this message and exit.")) + kwargs.setdefault("callback", show_help) + + return option(*param_decls, **kwargs) diff --git a/server/venv/Lib/site-packages/click/exceptions.py b/server/venv/Lib/site-packages/click/exceptions.py new file mode 100644 index 0000000..68c2806 --- /dev/null +++ b/server/venv/Lib/site-packages/click/exceptions.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import collections.abc as cabc +import typing as t +from gettext import gettext as _ +from gettext import ngettext + +from ._compat import get_text_stderr +from .globals import resolve_color_default +from .utils import echo +from .utils import format_filename + +if t.TYPE_CHECKING: + from .core import Command + from .core import Context + from .core import Parameter + + +def _join_param_hints(param_hint: cabc.Sequence[str] | str | None) -> str | None: + if param_hint is not None and not isinstance(param_hint, str): + return " / ".join(repr(x) for x in param_hint) + + return param_hint + + +def _format_possibilities(possibilities: list[str]) -> str: + possibility_str = ", ".join(repr(p) for p in sorted(possibilities)) + return ngettext( + "Did you mean {possibility}?", + "(Did you mean one of: {possibilities}?)", + len(possibilities), + ).format(possibility=possibility_str, possibilities=possibility_str) + + +class ClickException(Exception): + """An exception that Click can handle and show to the user.""" + + #: The exit code for this exception. + exit_code = 1 + + def __init__(self, message: str) -> None: + super().__init__(message) + # The context will be removed by the time we print the message, so cache + # the color settings here to be used later on (in `show`) + self.show_color: bool | None = resolve_color_default() + self.message = message + + def format_message(self) -> str: + return self.message + + def __str__(self) -> str: + return self.message + + def show(self, file: t.IO[t.Any] | None = None) -> None: + if file is None: + file = get_text_stderr() + + echo( + _("Error: {message}").format(message=self.format_message()), + file=file, + color=self.show_color, + ) + + +class UsageError(ClickException): + """An internal exception that signals a usage error. This typically + aborts any further handling. + + :param message: the error message to display. + :param ctx: optionally the context that caused this error. Click will + fill in the context automatically in some situations. + """ + + exit_code = 2 + + def __init__(self, message: str, ctx: Context | None = None) -> None: + super().__init__(message) + self.ctx = ctx + self.cmd: Command | None = self.ctx.command if self.ctx else None + + def show(self, file: t.IO[t.Any] | None = None) -> None: + if file is None: + file = get_text_stderr() + color = None + hint = "" + if ( + self.ctx is not None + and self.ctx.command.get_help_option(self.ctx) is not None + ): + help_names = self.ctx.command.get_help_option_names(self.ctx) + # Pick the longest name (like ``--help`` over ``-h``) for + # readability in error messages. + hint = _("Try '{command} {option}' for help.").format( + command=self.ctx.command_path, + option=max(help_names, key=len), + ) + hint = f"{hint}\n" + if self.ctx is not None: + color = self.ctx.color + echo(f"{self.ctx.get_usage()}\n{hint}", file=file, color=color) + echo( + _("Error: {message}").format(message=self.format_message()), + file=file, + color=color, + ) + + +class BadParameter(UsageError): + """An exception that formats out a standardized error message for a + bad parameter. This is useful when thrown from a callback or type as + Click will attach contextual information to it (for instance, which + parameter it is). + + .. versionadded:: 2.0 + + :param param: the parameter object that caused this error. This can + be left out, and Click will attach this info itself + if possible. + :param param_hint: a string that shows up as parameter name. This + can be used as alternative to `param` in cases + where custom validation should happen. If it is + a string it's used as such, if it's a list then + each item is quoted and separated. + """ + + def __init__( + self, + message: str, + ctx: Context | None = None, + param: Parameter | None = None, + param_hint: cabc.Sequence[str] | str | None = None, + ) -> None: + super().__init__(message, ctx) + self.param = param + self.param_hint = param_hint + + def format_message(self) -> str: + if self.param_hint is not None: + param_hint = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) + else: + return _("Invalid value: {message}").format(message=self.message) + + return _("Invalid value for {param_hint}: {message}").format( + param_hint=_join_param_hints(param_hint), message=self.message + ) + + +class MissingParameter(BadParameter): + """Raised if click required an option or argument but it was not + provided when invoking the script. + + .. versionadded:: 4.0 + + :param param_type: a string that indicates the type of the parameter. + The default is to inherit the parameter type from + the given `param`. Valid values are ``'parameter'``, + ``'option'`` or ``'argument'``. + """ + + def __init__( + self, + message: str | None = None, + ctx: Context | None = None, + param: Parameter | None = None, + param_hint: cabc.Sequence[str] | str | None = None, + param_type: str | None = None, + ) -> None: + super().__init__(message or "", ctx, param, param_hint) + self.param_type = param_type + + def format_message(self) -> str: + if self.param_hint is not None: + param_hint: cabc.Sequence[str] | str | None = self.param_hint + elif self.param is not None: + param_hint = self.param.get_error_hint(self.ctx) + else: + param_hint = None + + param_hint = _join_param_hints(param_hint) + param_hint = f" {param_hint}" if param_hint else "" + + param_type = self.param_type + if param_type is None and self.param is not None: + param_type = self.param.param_type_name + + msg = self.message + if self.param is not None: + msg_extra = self.param.type.get_missing_message( + param=self.param, ctx=self.ctx + ) + if msg_extra: + if msg: + msg += f". {msg_extra}" + else: + msg = msg_extra + + msg = f" {msg}" if msg else "" + + # Translate param_type for known types. + if param_type == "argument": + missing = _("Missing argument") + elif param_type == "option": + missing = _("Missing option") + elif param_type == "parameter": + missing = _("Missing parameter") + else: + missing = _("Missing {param_type}").format(param_type=param_type) + + return f"{missing}{param_hint}.{msg}" + + def __str__(self) -> str: + if not self.message: + param_name = self.param.name if self.param else None + return _("Missing parameter: {param_name}").format(param_name=param_name) + else: + return self.message + + +class NoSuchOption(UsageError): + """Raised if Click attempted to handle an option that does not exist. + + .. versionadded:: 4.0 + """ + + def __init__( + self, + option_name: str, + message: str | None = None, + possibilities: cabc.Iterable[str] | None = None, + ctx: Context | None = None, + ) -> None: + if message is None: + message = _("No such option {name!r}.").format(name=option_name) + + super().__init__(message, ctx) + self.option_name = option_name + self.possibilities: list[str] | None = None + if possibilities: + from difflib import get_close_matches + + self.possibilities = get_close_matches(option_name, possibilities) + + def format_message(self) -> str: + if not self.possibilities: + return self.message + return f"{self.message} {_format_possibilities(self.possibilities)}" + + +class NoSuchCommand(UsageError): + """Raised if Click attempted to handle a command that does not exist. + + .. versionadded:: 8.4.0 + """ + + def __init__( + self, + command_name: str, + message: str | None = None, + possibilities: cabc.Iterable[str] | None = None, + ctx: Context | None = None, + ) -> None: + if message is None: + message = _("No such command {name!r}.").format(name=command_name) + + super().__init__(message, ctx) + self.command_name = command_name + self.possibilities: list[str] | None = None + if possibilities: + from difflib import get_close_matches + + self.possibilities = get_close_matches(command_name, possibilities) + + def format_message(self) -> str: + if not self.possibilities: + return self.message + return f"{self.message} {_format_possibilities(self.possibilities)}" + + +class BadOptionUsage(UsageError): + """Raised if an option is generally supplied but the use of the option + was incorrect. This is for instance raised if the number of arguments + for an option is not correct. + + .. versionadded:: 4.0 + + :param option_name: the name of the option being used incorrectly. + """ + + def __init__( + self, option_name: str, message: str, ctx: Context | None = None + ) -> None: + super().__init__(message, ctx) + self.option_name = option_name + + +class BadArgumentUsage(UsageError): + """Raised if an argument is generally supplied but the use of the argument + was incorrect. This is for instance raised if the number of values + for an argument is not correct. + + .. versionadded:: 6.0 + """ + + +class NoArgsIsHelpError(UsageError): + def __init__(self, ctx: Context) -> None: + self.ctx: Context + super().__init__(ctx.get_help(), ctx=ctx) + + def show(self, file: t.IO[t.Any] | None = None) -> None: + echo(self.format_message(), file=file, err=True, color=self.ctx.color) + + +class FileError(ClickException): + """Raised if a file cannot be opened.""" + + def __init__(self, filename: str, hint: str | None = None) -> None: + if hint is None: + hint = _("unknown error") + + super().__init__(hint) + self.ui_filename: str = format_filename(filename) + self.filename = filename + + def format_message(self) -> str: + return _("Could not open file {filename!r}: {message}").format( + filename=self.ui_filename, message=self.message + ) + + +class Abort(RuntimeError): + """An internal signalling exception that signals Click to abort.""" + + +class Exit(RuntimeError): + """An exception that indicates that the application should exit with some + status code. + + :param code: the status code to exit with. + """ + + __slots__ = ("exit_code",) + + def __init__(self, code: int = 0) -> None: + self.exit_code: int = code diff --git a/server/venv/Lib/site-packages/click/formatting.py b/server/venv/Lib/site-packages/click/formatting.py new file mode 100644 index 0000000..886a4ad --- /dev/null +++ b/server/venv/Lib/site-packages/click/formatting.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import collections.abc as cabc +from contextlib import contextmanager +from gettext import gettext as _ + +from ._compat import term_len +from .parser import _split_opt + +# Can force a width. This is used by the test system +FORCED_WIDTH: int | None = None + + +def measure_table(rows: cabc.Iterable[tuple[str, str]]) -> tuple[int, ...]: + widths: dict[int, int] = {} + + for row in rows: + for idx, col in enumerate(row): + widths[idx] = max(widths.get(idx, 0), term_len(col)) + + return tuple(y for x, y in sorted(widths.items())) + + +def iter_rows( + rows: cabc.Iterable[tuple[str, str]], col_count: int +) -> cabc.Iterator[tuple[str, ...]]: + for row in rows: + yield row + ("",) * (col_count - len(row)) + + +def wrap_text( + text: str, + width: int = 78, + initial_indent: str = "", + subsequent_indent: str = "", + preserve_paragraphs: bool = False, +) -> str: + """A helper function that intelligently wraps text. By default, it + assumes that it operates on a single paragraph of text but if the + `preserve_paragraphs` parameter is provided it will intelligently + handle paragraphs (defined by two empty lines). + + If paragraphs are handled, a paragraph can be prefixed with an empty + line containing the ``\\b`` character (``\\x08``) to indicate that + no rewrapping should happen in that block. + + :param text: the text that should be rewrapped. + :param width: the maximum width for the text. + :param initial_indent: the initial indent that should be placed on the + first line as a string. + :param subsequent_indent: the indent string that should be placed on + each consecutive line. + :param preserve_paragraphs: if this flag is set then the wrapping will + intelligently handle paragraphs. + + .. versionchanged:: 8.4.0 + Width is measured in visible characters. ANSI escape sequences in + ``text``, ``initial_indent``, or ``subsequent_indent`` no longer + count toward the width budget, so styled input wraps based on what + the user sees instead of raw byte length. + """ + from ._textwrap import TextWrapper + + text = text.expandtabs() + wrapper = TextWrapper( + width, + initial_indent=initial_indent, + subsequent_indent=subsequent_indent, + replace_whitespace=False, + ) + if not preserve_paragraphs: + return wrapper.fill(text) + + p: list[tuple[int, bool, str]] = [] + buf: list[str] = [] + indent = None + + def _flush_par() -> None: + if not buf: + return + if buf[0].strip() == "\b": + p.append((indent or 0, True, "\n".join(buf[1:]))) + else: + p.append((indent or 0, False, " ".join(buf))) + del buf[:] + + for line in text.splitlines(): + if not line: + _flush_par() + indent = None + else: + if indent is None: + orig_len = term_len(line) + line = line.lstrip() + indent = orig_len - term_len(line) + buf.append(line) + _flush_par() + + rv = [] + for indent, raw, text in p: + with wrapper.extra_indent(" " * indent): + if raw: + rv.append(wrapper.indent_only(text)) + else: + rv.append(wrapper.fill(text)) + + return "\n\n".join(rv) + + +class HelpFormatter: + """This class helps with formatting text-based help pages. It's + usually just needed for very special internal cases, but it's also + exposed so that developers can write their own fancy outputs. + + At present, it always writes into memory. + + :param indent_increment: the additional increment for each level. + :param width: the width for the text. This defaults to the terminal + width clamped to a maximum of 78. + """ + + def __init__( + self, + indent_increment: int = 2, + width: int | None = None, + max_width: int | None = None, + ) -> None: + self.indent_increment = indent_increment + if max_width is None: + max_width = 80 + if width is None: + import shutil + + width = FORCED_WIDTH + if width is None: + width = max(min(shutil.get_terminal_size().columns, max_width) - 2, 50) + self.width = width + self.current_indent: int = 0 + self.buffer: list[str] = [] + + def write(self, string: str) -> None: + """Writes a unicode string into the internal buffer.""" + self.buffer.append(string) + + def indent(self) -> None: + """Increases the indentation.""" + self.current_indent += self.indent_increment + + def dedent(self) -> None: + """Decreases the indentation.""" + self.current_indent -= self.indent_increment + + def write_usage(self, prog: str, args: str = "", prefix: str | None = None) -> None: + """Writes a usage line into the buffer. + + :param prog: the program name. + :param args: whitespace separated list of arguments. + :param prefix: The prefix for the first line. Defaults to + ``"Usage: "``. + """ + if prefix is None: + prefix = "{usage} ".format(usage=_("Usage:")) + + usage_prefix = f"{prefix:>{self.current_indent}}{prog} " + text_width = self.width - self.current_indent + + if not args: + # Without args, the prefix's trailing space and the wrap_text + # call that would normally place args on the line are both + # unnecessary. Emit just the prefix line. + self.write(usage_prefix.rstrip(" ")) + self.write("\n") + return + + if text_width >= (term_len(usage_prefix) + 20): + # The arguments will fit to the right of the prefix. + indent = " " * term_len(usage_prefix) + self.write( + wrap_text( + args, + text_width, + initial_indent=usage_prefix, + subsequent_indent=indent, + ) + ) + else: + # The prefix is too long, put the arguments on the next line. + self.write(usage_prefix) + self.write("\n") + indent = " " * (max(self.current_indent, term_len(prefix)) + 4) + self.write( + wrap_text( + args, text_width, initial_indent=indent, subsequent_indent=indent + ) + ) + + self.write("\n") + + def write_heading(self, heading: str) -> None: + """Writes a heading into the buffer.""" + self.write(f"{'':>{self.current_indent}}{heading}:\n") + + def write_paragraph(self) -> None: + """Writes a paragraph into the buffer.""" + if self.buffer: + self.write("\n") + + def write_text(self, text: str) -> None: + """Writes re-indented text into the buffer. This rewraps and + preserves paragraphs. + """ + indent = " " * self.current_indent + self.write( + wrap_text( + text, + self.width, + initial_indent=indent, + subsequent_indent=indent, + preserve_paragraphs=True, + ) + ) + self.write("\n") + + def write_dl( + self, + rows: cabc.Sequence[tuple[str, str]], + col_max: int = 30, + col_spacing: int = 2, + ) -> None: + """Writes a definition list into the buffer. This is how options + and commands are usually formatted. + + :param rows: a list of two item tuples for the terms and values. + :param col_max: the maximum width of the first column. + :param col_spacing: the number of spaces between the first and + second column. + """ + rows = list(rows) + widths = measure_table(rows) + if len(widths) != 2: + raise TypeError("Expected two columns for definition list") + + first_col = min(widths[0], col_max) + col_spacing + + for first, second in iter_rows(rows, len(widths)): + self.write(f"{'':>{self.current_indent}}{first}") + if not second: + self.write("\n") + continue + if term_len(first) <= first_col - col_spacing: + self.write(" " * (first_col - term_len(first))) + else: + self.write("\n") + self.write(" " * (first_col + self.current_indent)) + + text_width = max(self.width - first_col - 2, 10) + wrapped_text = wrap_text(second, text_width, preserve_paragraphs=True) + lines = wrapped_text.splitlines() + + if lines: + self.write(f"{lines[0]}\n") + + for line in lines[1:]: + self.write(f"{'':>{first_col + self.current_indent}}{line}\n") + else: + self.write("\n") + + @contextmanager + def section(self, name: str) -> cabc.Iterator[None]: + """Helpful context manager that writes a paragraph, a heading, + and the indents. + + :param name: the section name that is written as heading. + """ + self.write_paragraph() + self.write_heading(name) + self.indent() + try: + yield + finally: + self.dedent() + + @contextmanager + def indentation(self) -> cabc.Iterator[None]: + """A context manager that increases the indentation.""" + self.indent() + try: + yield + finally: + self.dedent() + + def getvalue(self) -> str: + """Returns the buffer contents.""" + return "".join(self.buffer) + + +def join_options(options: cabc.Sequence[str]) -> tuple[str, bool]: + """Given a list of option strings this joins them in the most appropriate + way and returns them in the form ``(formatted_string, + any_prefix_is_slash)`` where the second item in the tuple is a flag that + indicates if any of the option prefixes was a slash. + """ + rv = [] + any_prefix_is_slash = False + + for opt in options: + prefix = _split_opt(opt)[0] + + if prefix == "/": + any_prefix_is_slash = True + + rv.append((len(prefix), opt)) + + rv.sort(key=lambda x: x[0]) + return ", ".join(x[1] for x in rv), any_prefix_is_slash diff --git a/server/venv/Lib/site-packages/click/globals.py b/server/venv/Lib/site-packages/click/globals.py new file mode 100644 index 0000000..a2f9172 --- /dev/null +++ b/server/venv/Lib/site-packages/click/globals.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import typing as t +from threading import local + +if t.TYPE_CHECKING: + from .core import Context + +_local = local() + + +@t.overload +def get_current_context(silent: t.Literal[False] = False) -> Context: ... + + +@t.overload +def get_current_context(silent: bool = ...) -> Context | None: ... + + +def get_current_context(silent: bool = False) -> Context | None: + """Returns the current click context. This can be used as a way to + access the current context object from anywhere. This is a more implicit + alternative to the :func:`pass_context` decorator. This function is + primarily useful for helpers such as :func:`echo` which might be + interested in changing its behavior based on the current context. + + To push the current context, :meth:`Context.scope` can be used. + + .. versionadded:: 5.0 + + :param silent: if set to `True` the return value is `None` if no context + is available. The default behavior is to raise a + :exc:`RuntimeError`. + """ + try: + return t.cast("Context", _local.stack[-1]) + except (AttributeError, IndexError) as e: + if not silent: + raise RuntimeError("There is no active click context.") from e + + return None + + +def push_context(ctx: Context) -> None: + """Pushes a new context to the current stack.""" + _local.__dict__.setdefault("stack", []).append(ctx) + + +def pop_context() -> None: + """Removes the top level from the stack.""" + _local.stack.pop() + + +def resolve_color_default(color: bool | None = None) -> bool | None: + """Internal helper to get the default value of the color flag. If a + value is passed it's returned unchanged, otherwise it's looked up from + the current context. + """ + if color is not None: + return color + + ctx = get_current_context(silent=True) + + if ctx is not None: + return ctx.color + + return None diff --git a/server/venv/Lib/site-packages/click/parser.py b/server/venv/Lib/site-packages/click/parser.py new file mode 100644 index 0000000..4fcbf7c --- /dev/null +++ b/server/venv/Lib/site-packages/click/parser.py @@ -0,0 +1,533 @@ +""" +This module started out as largely a copy paste from the stdlib's +optparse module with the features removed that we do not need from +optparse because we implement them in Click on a higher level (for +instance type handling, help formatting and a lot more). + +The plan is to remove more and more from here over time. + +The reason this is a different module and not optparse from the stdlib +is that there are differences in 2.x and 3.x about the error messages +generated and optparse in the stdlib uses gettext for no good reason +and might cause us issues. + +Click uses parts of optparse written by Gregory P. Ward and maintained +by the Python Software Foundation. This is limited to code in parser.py. + +Copyright 2001-2006 Gregory P. Ward. All rights reserved. +Copyright 2002-2006 Python Software Foundation. All rights reserved. +""" + +# This code uses parts of optparse written by Gregory P. Ward and +# maintained by the Python Software Foundation. +# Copyright 2001-2006 Gregory P. Ward +# Copyright 2002-2006 Python Software Foundation +from __future__ import annotations + +import collections.abc as cabc +import typing as t +from collections import deque +from gettext import gettext as _ +from gettext import ngettext + +from ._utils import FLAG_NEEDS_VALUE +from ._utils import UNSET +from .exceptions import BadArgumentUsage +from .exceptions import BadOptionUsage +from .exceptions import NoSuchOption +from .exceptions import UsageError + +if t.TYPE_CHECKING: + from ._utils import T_FLAG_NEEDS_VALUE + from ._utils import T_UNSET + from .core import Argument as CoreArgument + from .core import Context + from .core import Option as CoreOption + from .core import Parameter as CoreParameter + +V = t.TypeVar("V") + + +def _unpack_args( + args: cabc.Sequence[str], nargs_spec: cabc.Sequence[int] +) -> tuple[cabc.Sequence[str | cabc.Sequence[str | T_UNSET] | T_UNSET], list[str]]: + """Given an iterable of arguments and an iterable of nargs specifications, + it returns a tuple with all the unpacked arguments at the first index + and all remaining arguments as the second. + + The nargs specification is the number of arguments that should be consumed + or `-1` to indicate that this position should eat up all the remainders. + + Missing items are filled with ``UNSET``. + """ + args = deque(args) + nargs_spec = deque(nargs_spec) + rv: list[str | tuple[str | T_UNSET, ...] | T_UNSET] = [] + spos: int | None = None + + def _fetch(c: deque[str]) -> str | T_UNSET: + try: + if spos is None: + return c.popleft() + else: + return c.pop() + except IndexError: + return UNSET + + while nargs_spec: + if spos is None: + nargs = nargs_spec.popleft() + else: + nargs = nargs_spec.pop() + + if nargs == 1: + rv.append(_fetch(args)) + elif nargs > 1: + x: list[str | T_UNSET] = [_fetch(args) for _ in range(nargs)] + + # If we're reversed, we're pulling in the arguments in reverse, + # so we need to turn them around. + if spos is not None: + x.reverse() + + rv.append(tuple(x)) + elif nargs < 0: + if spos is not None: + raise TypeError("Cannot have two nargs < 0") + + spos = len(rv) + rv.append(UNSET) + + # spos is the position of the wildcard (star). If it's not `None`, + # we fill it with the remainder. + if spos is not None: + rv[spos] = tuple(args) + args = [] + rv[spos + 1 :] = reversed(rv[spos + 1 :]) + + return tuple(rv), list(args) + + +def _split_opt(opt: str) -> tuple[str, str]: + first = opt[:1] + if first.isalnum(): + return "", opt + if opt[1:2] == first: + return opt[:2], opt[2:] + return first, opt[1:] + + +def _normalize_opt(opt: str, ctx: Context | None) -> str: + if ctx is None or ctx.token_normalize_func is None: + return opt + prefix, opt = _split_opt(opt) + return f"{prefix}{ctx.token_normalize_func(opt)}" + + +class _Option: + def __init__( + self, + obj: CoreOption, + opts: cabc.Sequence[str], + dest: str | None, + action: str | None = None, + nargs: int = 1, + const: t.Any | None = None, + ): + self._short_opts = [] + self._long_opts = [] + self.prefixes: set[str] = set() + + for opt in opts: + prefix, value = _split_opt(opt) + if not prefix: + raise ValueError( + _("Invalid start character for option ({option})").format( + option=opt + ) + ) + self.prefixes.add(prefix[0]) + if len(prefix) == 1 and len(value) == 1: + self._short_opts.append(opt) + else: + self._long_opts.append(opt) + self.prefixes.add(prefix) + + if action is None: + action = "store" + + self.dest = dest + self.action = action + self.nargs = nargs + self.const = const + self.obj = obj + + @property + def takes_value(self) -> bool: + return self.action in ("store", "append") + + def process(self, value: t.Any, state: _ParsingState) -> None: + if self.action == "store": + state.opts[self.dest] = value # type: ignore + elif self.action == "store_const": + state.opts[self.dest] = self.const # type: ignore + elif self.action == "append": + state.opts.setdefault(self.dest, []).append(value) # type: ignore + elif self.action == "append_const": + state.opts.setdefault(self.dest, []).append(self.const) # type: ignore + elif self.action == "count": + state.opts[self.dest] = state.opts.get(self.dest, 0) + 1 # type: ignore + else: + raise ValueError(f"unknown action '{self.action}'") + state.order.append(self.obj) + + +class _Argument: + def __init__(self, obj: CoreArgument, dest: str | None, nargs: int = 1): + self.dest = dest + self.nargs = nargs + self.obj = obj + + def process( + self, + value: str | cabc.Sequence[str | T_UNSET] | T_UNSET, + state: _ParsingState, + ) -> None: + if self.nargs > 1: + assert isinstance(value, cabc.Sequence) + holes = sum(x is UNSET for x in value) + if holes == len(value): + value = UNSET + elif holes != 0: + raise BadArgumentUsage( + _("Argument {name!r} takes {nargs} values.").format( + name=self.dest, nargs=self.nargs + ) + ) + + # We failed to collect any argument value so we consider the argument as unset. + if value == (): + value = UNSET + + state.opts[self.dest] = value # type: ignore + state.order.append(self.obj) + + +class _ParsingState: + def __init__(self, rargs: list[str]) -> None: + self.opts: dict[str, t.Any] = {} + self.largs: list[str] = [] + self.rargs = rargs + self.order: list[CoreParameter] = [] + + +class _OptionParser: + """The option parser is an internal class that is ultimately used to + parse options and arguments. It's modelled after optparse and brings + a similar but vastly simplified API. It should generally not be used + directly as the high level Click classes wrap it for you. + + It's not nearly as extensible as optparse or argparse as it does not + implement features that are implemented on a higher level (such as + types or defaults). + + :param ctx: optionally the :class:`~click.Context` where this parser + should go with. + + .. deprecated:: 8.2 + Will be removed in Click 9.0. + """ + + def __init__(self, ctx: Context | None = None) -> None: + #: The :class:`~click.Context` for this parser. This might be + #: `None` for some advanced use cases. + self.ctx = ctx + #: This controls how the parser deals with interspersed arguments. + #: If this is set to `False`, the parser will stop on the first + #: non-option. Click uses this to implement nested subcommands + #: safely. + self.allow_interspersed_args: bool = True + #: This tells the parser how to deal with unknown options. By + #: default it will error out (which is sensible), but there is a + #: second mode where it will ignore it and continue processing + #: after shifting all the unknown options into the resulting args. + self.ignore_unknown_options: bool = False + + if ctx is not None: + self.allow_interspersed_args = ctx.allow_interspersed_args + self.ignore_unknown_options = ctx.ignore_unknown_options + + self._short_opt: dict[str, _Option] = {} + self._long_opt: dict[str, _Option] = {} + self._opt_prefixes = {"-", "--"} + self._args: list[_Argument] = [] + + def add_option( + self, + obj: CoreOption, + opts: cabc.Sequence[str], + dest: str | None, + action: str | None = None, + nargs: int = 1, + const: t.Any | None = None, + ) -> None: + """Adds a new option named `dest` to the parser. The destination + is not inferred (unlike with optparse) and needs to be explicitly + provided. Action can be any of ``store``, ``store_const``, + ``append``, ``append_const`` or ``count``. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + opts = [_normalize_opt(opt, self.ctx) for opt in opts] + option = _Option(obj, opts, dest, action=action, nargs=nargs, const=const) + self._opt_prefixes.update(option.prefixes) + for opt in option._short_opts: + self._short_opt[opt] = option + for opt in option._long_opts: + self._long_opt[opt] = option + + def add_argument(self, obj: CoreArgument, dest: str | None, nargs: int = 1) -> None: + """Adds a positional argument named `dest` to the parser. + + The `obj` can be used to identify the option in the order list + that is returned from the parser. + """ + self._args.append(_Argument(obj, dest=dest, nargs=nargs)) + + def parse_args( + self, args: list[str] + ) -> tuple[dict[str, t.Any], list[str], list[CoreParameter]]: + """Parses positional arguments and returns ``(values, args, order)`` + for the parsed options and arguments as well as the leftover + arguments if there are any. The order is a list of objects as they + appear on the command line. If arguments appear multiple times they + will be memorized multiple times as well. + """ + state = _ParsingState(args) + try: + self._process_args_for_options(state) + self._process_args_for_args(state) + except UsageError: + if self.ctx is None or not self.ctx.resilient_parsing: + raise + return state.opts, state.largs, state.order + + def _process_args_for_args(self, state: _ParsingState) -> None: + pargs, args = _unpack_args( + state.largs + state.rargs, [x.nargs for x in self._args] + ) + + for idx, arg in enumerate(self._args): + arg.process(pargs[idx], state) + + state.largs = args + state.rargs = [] + + def _process_args_for_options(self, state: _ParsingState) -> None: + while state.rargs: + arg = state.rargs.pop(0) + arglen = len(arg) + # Double dashes always handled explicitly regardless of what + # prefixes are valid. + if arg == "--": + return + elif arg[:1] in self._opt_prefixes and arglen > 1: + self._process_opts(arg, state) + elif self.allow_interspersed_args: + state.largs.append(arg) + else: + state.rargs.insert(0, arg) + return + + # Say this is the original argument list: + # [arg0, arg1, ..., arg(i-1), arg(i), arg(i+1), ..., arg(N-1)] + # ^ + # (we are about to process arg(i)). + # + # Then rargs is [arg(i), ..., arg(N-1)] and largs is a *subset* of + # [arg0, ..., arg(i-1)] (any options and their arguments will have + # been removed from largs). + # + # The while loop will usually consume 1 or more arguments per pass. + # If it consumes 1 (eg. arg is an option that takes no arguments), + # then after _process_arg() is done the situation is: + # + # largs = subset of [arg0, ..., arg(i)] + # rargs = [arg(i+1), ..., arg(N-1)] + # + # If allow_interspersed_args is false, largs will always be + # *empty* -- still a subset of [arg0, ..., arg(i-1)], but + # not a very interesting subset! + + def _match_long_opt( + self, opt: str, explicit_value: str | None, state: _ParsingState + ) -> None: + if opt not in self._long_opt: + raise NoSuchOption(opt, possibilities=self._long_opt, ctx=self.ctx) + + option = self._long_opt[opt] + if option.takes_value: + # At this point it's safe to modify rargs by injecting the + # explicit value, because no exception is raised in this + # branch. This means that the inserted value will be fully + # consumed. + if explicit_value is not None: + state.rargs.insert(0, explicit_value) + + value = self._get_value_from_state(opt, option, state) + + elif explicit_value is not None: + raise BadOptionUsage( + opt, _("Option {name!r} does not take a value.").format(name=opt) + ) + + else: + value = UNSET + + option.process(value, state) + + def _match_short_opt(self, arg: str, state: _ParsingState) -> None: + stop = False + i = 1 + prefix = arg[0] + unknown_options = [] + + for ch in arg[1:]: + opt = _normalize_opt(f"{prefix}{ch}", self.ctx) + option = self._short_opt.get(opt) + i += 1 + + if not option: + if self.ignore_unknown_options: + unknown_options.append(ch) + continue + raise NoSuchOption(opt, ctx=self.ctx) + if option.takes_value: + # Any characters left in arg? Pretend they're the + # next arg, and stop consuming characters of arg. + if i < len(arg): + state.rargs.insert(0, arg[i:]) + stop = True + + value = self._get_value_from_state(opt, option, state) + + else: + value = UNSET + + option.process(value, state) + + if stop: + break + + # If we got any unknown options we recombine the string of the + # remaining options and re-attach the prefix, then report that + # to the state as new large. This way there is basic combinatorics + # that can be achieved while still ignoring unknown arguments. + if self.ignore_unknown_options and unknown_options: + state.largs.append(f"{prefix}{''.join(unknown_options)}") + + def _get_value_from_state( + self, option_name: str, option: _Option, state: _ParsingState + ) -> str | cabc.Sequence[str] | T_UNSET | T_FLAG_NEEDS_VALUE: + nargs = option.nargs + + value: str | cabc.Sequence[str] | T_UNSET | T_FLAG_NEEDS_VALUE + + if len(state.rargs) < nargs: + if option.obj._flag_needs_value: + # Option allows omitting the value. + value = FLAG_NEEDS_VALUE + else: + raise BadOptionUsage( + option_name, + ngettext( + "Option {name!r} requires an argument.", + "Option {name!r} requires {nargs} arguments.", + nargs, + ).format(name=option_name, nargs=nargs), + ) + elif nargs == 1: + next_rarg = state.rargs[0] + + if ( + option.obj._flag_needs_value + and isinstance(next_rarg, str) + and next_rarg[:1] in self._opt_prefixes + and len(next_rarg) > 1 + ): + # The next arg looks like the start of an option, don't + # use it as the value if omitting the value is allowed. + value = FLAG_NEEDS_VALUE + else: + value = state.rargs.pop(0) + else: + value = tuple(state.rargs[:nargs]) + del state.rargs[:nargs] + + return value + + def _process_opts(self, arg: str, state: _ParsingState) -> None: + explicit_value = None + # Long option handling happens in two parts. The first part is + # supporting explicitly attached values. In any case, we will try + # to long match the option first. + if "=" in arg: + long_opt, explicit_value = arg.split("=", 1) + else: + long_opt = arg + norm_long_opt = _normalize_opt(long_opt, self.ctx) + + # At this point we will match the (assumed) long option through + # the long option matching code. Note that this allows options + # like "-foo" to be matched as long options. + try: + self._match_long_opt(norm_long_opt, explicit_value, state) + except NoSuchOption: + # At this point the long option matching failed, and we need + # to try with short options. However there is a special rule + # which says, that if we have a two character options prefix + # (applies to "--foo" for instance), we do not dispatch to the + # short option code and will instead raise the no option + # error. + if arg[:2] not in self._opt_prefixes: + self._match_short_opt(arg, state) + return + + if not self.ignore_unknown_options: + raise + + state.largs.append(arg) + + +def __getattr__(name: str) -> object: + import warnings + + if name in { + "OptionParser", + "Argument", + "Option", + "split_opt", + "normalize_opt", + "ParsingState", + }: + warnings.warn( + f"'parser.{name}' is deprecated and will be removed in Click 9.0." + " The old parser is available in 'optparse'.", + DeprecationWarning, + stacklevel=2, + ) + return globals()[f"_{name}"] + + if name == "split_arg_string": + from .shell_completion import split_arg_string + + warnings.warn( + "Importing 'parser.split_arg_string' is deprecated, it will only be" + " available in 'shell_completion' in Click 9.0.", + DeprecationWarning, + stacklevel=2, + ) + return split_arg_string + + raise AttributeError(name) diff --git a/server/venv/Lib/site-packages/click/py.typed b/server/venv/Lib/site-packages/click/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/click/shell_completion.py b/server/venv/Lib/site-packages/click/shell_completion.py new file mode 100644 index 0000000..b01e290 --- /dev/null +++ b/server/venv/Lib/site-packages/click/shell_completion.py @@ -0,0 +1,680 @@ +from __future__ import annotations + +import collections.abc as cabc +import os +import re +import typing as t +from gettext import gettext as _ + +from .core import Argument +from .core import Command +from .core import Context +from .core import Group +from .core import Option +from .core import Parameter +from .core import ParameterSource +from .utils import echo + + +def shell_complete( + cli: Command, + ctx_args: cabc.MutableMapping[str, t.Any], + prog_name: str, + complete_var: str, + instruction: str, +) -> int: + """Perform shell completion for the given CLI program. + + :param cli: Command being called. + :param ctx_args: Extra arguments to pass to + ``cli.make_context``. + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. + :param instruction: Value of ``complete_var`` with the completion + instruction and shell, in the form ``instruction_shell``. + :return: Status code to exit with. + """ + shell, _, instruction = instruction.partition("_") + comp_cls = get_completion_class(shell) + + if comp_cls is None: + return 1 + + comp = comp_cls(cli, ctx_args, prog_name, complete_var) + + # Write bytes, otherwise Windows text stdout translates LF to CRLF and breaks. + if instruction == "source": + echo(comp.source().encode(), nl=False) + return 0 + + if instruction == "complete": + echo(comp.complete().encode()) + return 0 + + return 1 + + +class CompletionItem: + """Represents a completion value and metadata about the value. The + default metadata is ``type`` to indicate special shell handling, + and ``help`` if a shell supports showing a help string next to the + value. + + Arbitrary parameters can be passed when creating the object, and + accessed using ``item.attr``. If an attribute wasn't passed, + accessing it returns ``None``. + + :param value: The completion suggestion. + :param type: Tells the shell script to provide special completion + support for the type. Click uses ``"dir"`` and ``"file"``. + :param help: String shown next to the value if supported. + :param kwargs: Arbitrary metadata. The built-in implementations + don't use this, but custom type completions paired with custom + shell support could use it. + """ + + __slots__ = ("value", "type", "help", "_info") + + def __init__( + self, + value: t.Any, + type: str = "plain", + help: str | None = None, + **kwargs: t.Any, + ) -> None: + self.value: t.Any = value + self.type: str = type + self.help: str | None = help + self._info = kwargs + + def __getattr__(self, name: str) -> t.Any: + return self._info.get(name) + + +# Only Bash >= 4.4 has the nosort option. +_SOURCE_BASH = """\ +%(complete_func)s() { + local IFS=$'\\n' + local response + + response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD \ +%(complete_var)s=bash_complete $1) + + for completion in $response; do + IFS=',' read type value <<< "$completion" + + if [[ $type == 'dir' ]]; then + COMPREPLY=() + compopt -o dirnames + elif [[ $type == 'file' ]]; then + COMPREPLY=() + compopt -o default + elif [[ $type == 'plain' ]]; then + COMPREPLY+=($value) + fi + done + + return 0 +} + +%(complete_func)s_setup() { + complete -o nosort -F %(complete_func)s %(prog_name)s +} + +%(complete_func)s_setup; +""" + +# See ZshComplete.format_completion below, and issue #2703, before +# changing this script. +# +# (TL;DR: _describe is picky about the format, but this Zsh script snippet +# is already widely deployed. So freeze this script, and use clever-ish +# handling of colons in ZshComplet.format_completion.) +_SOURCE_ZSH = """\ +#compdef %(prog_name)s + +%(complete_func)s() { + local -a completions + local -a completions_with_descriptions + local -a response + (( ! $+commands[%(prog_name)s] )) && return 1 + + response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \ +%(complete_var)s=zsh_complete %(prog_name)s)}") + + for type key descr in ${response}; do + if [[ "$type" == "plain" ]]; then + if [[ "$descr" == "_" ]]; then + completions+=("$key") + else + completions_with_descriptions+=("$key":"$descr") + fi + elif [[ "$type" == "dir" ]]; then + _path_files -/ + elif [[ "$type" == "file" ]]; then + _path_files -f + fi + done + + if [ -n "$completions_with_descriptions" ]; then + _describe -V unsorted completions_with_descriptions -U + fi + + if [ -n "$completions" ]; then + compadd -U -V unsorted -a completions + fi +} + +if [[ $zsh_eval_context[-1] == loadautofunc ]]; then + # autoload from fpath, call function directly + %(complete_func)s "$@" +else + # eval/source/. command, register function for later + compdef %(complete_func)s %(prog_name)s +fi +""" + +_SOURCE_FISH = """\ +function %(complete_func)s; + set -l response (env %(complete_var)s=fish_complete COMP_WORDS=(commandline -cp) \ +COMP_CWORD=(commandline -t) %(prog_name)s); + + for completion in $response; + set -l metadata (string split \n $completion); + + if test $metadata[1] = "dir"; + __fish_complete_directories $metadata[2]; + else if test $metadata[1] = "file"; + __fish_complete_path $metadata[2]; + else if test $metadata[1] = "plain"; + if test $metadata[3] != "_"; + echo $metadata[2]\t$metadata[3]; + else; + echo $metadata[2]; + end; + end; + end; +end; + +complete --no-files --command %(prog_name)s --arguments \ +"(%(complete_func)s)"; +""" + + +class ShellComplete: + """Base class for providing shell completion support. A subclass for + a given shell will override attributes and methods to implement the + completion instructions (``source`` and ``complete``). + + :param cli: Command being called. + :param prog_name: Name of the executable in the shell. + :param complete_var: Name of the environment variable that holds + the completion instruction. + + .. versionadded:: 8.0 + """ + + name: t.ClassVar[str] + """Name to register the shell as with :func:`add_completion_class`. + This is used in completion instructions (``{name}_source`` and + ``{name}_complete``). + """ + + source_template: t.ClassVar[str] + """Completion script template formatted by :meth:`source`. This must + be provided by subclasses. + """ + + def __init__( + self, + cli: Command, + ctx_args: cabc.MutableMapping[str, t.Any], + prog_name: str, + complete_var: str, + ) -> None: + self.cli = cli + self.ctx_args = ctx_args + self.prog_name = prog_name + self.complete_var = complete_var + + @property + def func_name(self) -> str: + """The name of the shell function defined by the completion + script. + """ + safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), flags=re.ASCII) + return f"_{safe_name}_completion" + + def source_vars(self) -> dict[str, t.Any]: + """Vars for formatting :attr:`source_template`. + + By default this provides ``complete_func``, ``complete_var``, + and ``prog_name``. + """ + return { + "complete_func": self.func_name, + "complete_var": self.complete_var, + "prog_name": self.prog_name, + } + + def source(self) -> str: + """Produce the shell script that defines the completion + function. By default this ``%``-style formats + :attr:`source_template` with the dict returned by + :meth:`source_vars`. + """ + return self.source_template % self.source_vars() + + def get_completion_args(self) -> tuple[list[str], str]: + """Use the env vars defined by the shell script to return a + tuple of ``args, incomplete``. This must be implemented by + subclasses. + """ + raise NotImplementedError + + def get_completions(self, args: list[str], incomplete: str) -> list[CompletionItem]: + """Determine the context and last complete command or parameter + from the complete args. Call that object's ``shell_complete`` + method to get the completions for the incomplete value. + + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + """ + ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args) + obj, incomplete = _resolve_incomplete(ctx, args, incomplete) + return obj.shell_complete(ctx, incomplete) + + def format_completion(self, item: CompletionItem) -> str: + """Format a completion item into the form recognized by the + shell script. This must be implemented by subclasses. + + :param item: Completion item to format. + """ + raise NotImplementedError + + def complete(self) -> str: + """Produce the completion data to send back to the shell. + + By default this calls :meth:`get_completion_args`, gets the + completions, then calls :meth:`format_completion` for each + completion. + """ + args, incomplete = self.get_completion_args() + completions = self.get_completions(args, incomplete) + out = [self.format_completion(item) for item in completions] + return "\n".join(out) + + +class BashComplete(ShellComplete): + """Shell completion for Bash.""" + + name = "bash" + source_template = _SOURCE_BASH + + @staticmethod + def _check_version() -> None: + import shutil + import subprocess + + bash_exe = shutil.which("bash") + + if bash_exe is None: + match = None + else: + output = subprocess.run( + [bash_exe, "--norc", "-c", 'echo "${BASH_VERSION}"'], + stdout=subprocess.PIPE, + ) + match = re.search(r"^(\d+)\.(\d+)\.\d+", output.stdout.decode()) + + if match is not None: + major, minor = match.groups() + + if major < "4" or major == "4" and minor < "4": + echo( + _( + "Shell completion is not supported for Bash" + " versions older than 4.4." + ), + err=True, + ) + else: + echo( + _("Couldn't detect Bash version, shell completion is not supported."), + err=True, + ) + + def source(self) -> str: + self._check_version() + return super().source() + + def get_completion_args(self) -> tuple[list[str], str]: + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item: CompletionItem) -> str: + return f"{item.type},{item.value}" + + +class ZshComplete(ShellComplete): + """Shell completion for Zsh.""" + + name = "zsh" + source_template = _SOURCE_ZSH + + def get_completion_args(self) -> tuple[list[str], str]: + cwords = split_arg_string(os.environ["COMP_WORDS"]) + cword = int(os.environ["COMP_CWORD"]) + args = cwords[1:cword] + + try: + incomplete = cwords[cword] + except IndexError: + incomplete = "" + + return args, incomplete + + def format_completion(self, item: CompletionItem) -> str: + help_ = item.help or "_" + # The zsh completion script uses `_describe` on items with help + # texts (which splits the item help from the item value at the + # first unescaped colon) and `compadd` on items without help + # text (which uses the item value as-is and does not support + # colon escaping). So escape colons in the item value if and + # only if the item help is not the sentinel "_" value, as used + # by the completion script. + # + # (The zsh completion script is potentially widely deployed, and + # thus harder to fix than this method.) + # + # See issue #1812 and issue #2703 for further context. + value = item.value.replace(":", r"\:") if help_ != "_" else item.value + return f"{item.type}\n{value}\n{help_}" + + +class FishComplete(ShellComplete): + """Shell completion for Fish.""" + + name = "fish" + source_template = _SOURCE_FISH + + def get_completion_args(self) -> tuple[list[str], str]: + cwords = split_arg_string(os.environ["COMP_WORDS"]) + incomplete = os.environ["COMP_CWORD"] + if incomplete: + incomplete = split_arg_string(incomplete)[0] + args = cwords[1:] + + # Fish stores the partial word in both COMP_WORDS and + # COMP_CWORD, remove it from complete args. + if incomplete and args and args[-1] == incomplete: + args.pop() + + return args, incomplete + + def format_completion(self, item: CompletionItem) -> str: + """ + .. versionchanged:: 8.4.0 + Escape newlines in value and help to fix completion errors with + multi-line help strings. + """ + # The fish completion script splits each response line on literal + # newlines, so any newline in the value or help would corrupt the + # frame. Replace them with the two-character escape "\n" so the text + # round-trips through fish without breaking the format. The "_" + # sentinel for missing help mirrors :class:`ZshComplete`. + help_ = item.help or "_" + value = item.value.replace("\n", r"\n") + help_escaped = help_.replace("\n", r"\n") + return f"{item.type}\n{value}\n{help_escaped}" + + +ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]") + + +_available_shells: dict[str, type[ShellComplete]] = { + "bash": BashComplete, + "fish": FishComplete, + "zsh": ZshComplete, +} + + +def add_completion_class( + cls: ShellCompleteType, name: str | None = None +) -> ShellCompleteType: + """Register a :class:`ShellComplete` subclass under the given name. + The name will be provided by the completion instruction environment + variable during completion. + + :param cls: The completion class that will handle completion for the + shell. + :param name: Name to register the class under. Defaults to the + class's ``name`` attribute. + """ + if name is None: + name = cls.name + + _available_shells[name] = cls + + return cls + + +def get_completion_class(shell: str) -> type[ShellComplete] | None: + """Look up a registered :class:`ShellComplete` subclass by the name + provided by the completion instruction environment variable. If the + name isn't registered, returns ``None``. + + :param shell: Name the class is registered under. + """ + return _available_shells.get(shell) + + +def split_arg_string(string: str) -> list[str]: + """Split an argument string as with :func:`shlex.split`, but don't + fail if the string is incomplete. Ignores a missing closing quote or + incomplete escape sequence and uses the partial token as-is. + + .. code-block:: python + + split_arg_string("example 'my file") + ["example", "my file"] + + split_arg_string("example my\\") + ["example", "my"] + + :param string: String to split. + + .. versionchanged:: 8.2 + Moved to ``shell_completion`` from ``parser``. + """ + import shlex + + lex = shlex.shlex(string, posix=True) + lex.whitespace_split = True + lex.commenters = "" + out = [] + + try: + for token in lex: + out.append(token) + except ValueError: + # Raised when end-of-string is reached in an invalid state. Use + # the partial token as-is. The quote or escape character is in + # lex.state, not lex.token. + out.append(lex.token) + + return out + + +def _is_incomplete_argument(ctx: Context, param: Parameter) -> bool: + """Determine if the given parameter is an argument that can still + accept values. + + :param ctx: Invocation context for the command represented by the + parsed complete args. + :param param: Argument object being checked. + """ + if not isinstance(param, Argument): + return False + + value = ctx.params.get(param.name) + return ( + param.nargs == -1 + or ctx.get_parameter_source(param.name) is not ParameterSource.COMMANDLINE + or ( + param.nargs > 1 + and isinstance(value, (tuple, list)) + and len(value) < param.nargs + ) + ) + + +def _start_of_option(ctx: Context, value: str) -> bool: + """Check if the value looks like the start of an option.""" + if not value: + return False + + c = value[0] + return c in ctx._opt_prefixes + + +def _is_incomplete_option(ctx: Context, args: list[str], param: Parameter) -> bool: + """Determine if the given parameter is an option that needs a value. + + :param args: List of complete args before the incomplete value. + :param param: Option object being checked. + """ + if not isinstance(param, Option): + return False + + if param.is_flag or param.count: + return False + + last_option = None + + for index, arg in enumerate(reversed(args)): + if index + 1 > param.nargs: + break + + if _start_of_option(ctx, arg): + last_option = arg + break + + return last_option is not None and last_option in param.opts + + +def _resolve_context( + cli: Command, + ctx_args: cabc.MutableMapping[str, t.Any], + prog_name: str, + args: list[str], +) -> Context: + """Produce the context hierarchy starting with the command and + traversing the complete arguments. This only follows the commands, + it doesn't trigger input prompts or callbacks. + + :param cli: Command being called. + :param prog_name: Name of the executable in the shell. + :param args: List of complete args before the incomplete value. + """ + ctx_args["resilient_parsing"] = True + with cli.make_context(prog_name, args.copy(), **ctx_args) as ctx: + args = ctx._protected_args + ctx.args + + while args: + command = ctx.command + + if isinstance(command, Group): + if not command.chain: + name, cmd, args = command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + with cmd.make_context( + name, args, parent=ctx, resilient_parsing=True + ) as sub_ctx: + ctx = sub_ctx + args = ctx._protected_args + ctx.args + else: + sub_ctx = ctx + + while args: + name, cmd, args = command.resolve_command(ctx, args) + + if cmd is None: + return ctx + + with cmd.make_context( + name, + args, + parent=ctx, + allow_extra_args=True, + allow_interspersed_args=False, + resilient_parsing=True, + ) as sub_sub_ctx: + sub_ctx = sub_sub_ctx + args = sub_ctx.args + + ctx = sub_ctx + args = [*sub_ctx._protected_args, *sub_ctx.args] + else: + break + + return ctx + + +def _resolve_incomplete( + ctx: Context, args: list[str], incomplete: str +) -> tuple[Command | Parameter, str]: + """Find the Click object that will handle the completion of the + incomplete value. Return the object and the incomplete value. + + :param ctx: Invocation context for the command represented by + the parsed complete args. + :param args: List of complete args before the incomplete value. + :param incomplete: Value being completed. May be empty. + """ + # Different shells treat an "=" between a long option name and + # value differently. Might keep the value joined, return the "=" + # as a separate item, or return the split name and value. Always + # split and discard the "=" to make completion easier. + if incomplete == "=": + incomplete = "" + elif "=" in incomplete and _start_of_option(ctx, incomplete): + name, _, incomplete = incomplete.partition("=") + args.append(name) + + # The "--" marker tells Click to stop treating values as options + # even if they start with the option character. If it hasn't been + # given and the incomplete arg looks like an option, the current + # command will provide option name completions. + if "--" not in args and _start_of_option(ctx, incomplete): + return ctx.command, incomplete + + params = ctx.command.get_params(ctx) + + # If the last complete arg is an option name with an incomplete + # value, the option will provide value completions. + for param in params: + if _is_incomplete_option(ctx, args, param): + return param, incomplete + + # It's not an option name or value. The first argument without a + # parsed value will provide value completions. + for param in params: + if _is_incomplete_argument(ctx, param): + return param, incomplete + + # There were no unparsed arguments, the command may be a group that + # will provide command name completions. + return ctx.command, incomplete diff --git a/server/venv/Lib/site-packages/click/termui.py b/server/venv/Lib/site-packages/click/termui.py new file mode 100644 index 0000000..91ef6e1 --- /dev/null +++ b/server/venv/Lib/site-packages/click/termui.py @@ -0,0 +1,941 @@ +from __future__ import annotations + +import collections.abc as cabc +import inspect +import io +import itertools +import re +import sys +import typing as t +from contextlib import AbstractContextManager +from contextlib import redirect_stdout +from gettext import gettext as _ + +from ._compat import isatty +from ._compat import strip_ansi +from ._compat import WIN +from .exceptions import Abort +from .exceptions import UsageError +from .globals import resolve_color_default +from .types import Choice +from .types import convert_type +from .types import ParamType +from .utils import echo +from .utils import LazyFile + +if t.TYPE_CHECKING: + from ._termui_impl import ProgressBar + +V = t.TypeVar("V") + +# The prompt functions to use. The doc tools currently override these +# functions to customize how they work. +visible_prompt_func: t.Callable[[str], str] = input + +_ansi_colors = { + "black": 30, + "red": 31, + "green": 32, + "yellow": 33, + "blue": 34, + "magenta": 35, + "cyan": 36, + "white": 37, + "reset": 39, + "bright_black": 90, + "bright_red": 91, + "bright_green": 92, + "bright_yellow": 93, + "bright_blue": 94, + "bright_magenta": 95, + "bright_cyan": 96, + "bright_white": 97, +} +_ansi_reset_all = "\033[0m" + + +_HIDDEN_INPUT_MASK = "'***'" + + +def _mask_hidden_input(message: str, value: str) -> str: + """Replace occurrences of ``value`` in ``message`` with a fixed mask. + + Both ``repr(value)`` (the form built-in :class:`ParamType` errors use + via ``{value!r}``) and the raw value are masked. The raw-value pass + uses word-boundary lookarounds so a substring like ``"1"`` does not + match inside ``"10"``, and ``"ent"`` does not match inside + ``"Authentication"``. The empty string is skipped to avoid matching + at every boundary. + """ + message = message.replace(repr(value), _HIDDEN_INPUT_MASK) + if value: + message = re.sub( + rf"(? str: + import getpass + + return getpass.getpass(prompt) + + +def _readline_prompt(func: t.Callable[[str], str], text: str, err: bool) -> str: + """Call a prompt function, passing the full prompt on non-Windows so + readline can handle line editing and cursor positioning correctly. + + On Windows the prompt is written separately via :func:`echo` for + colorama support, with only the last character passed to *func*. + """ + if WIN: + # Write the prompt separately so that we get nice coloring + # through colorama on Windows. + echo(text[:-1], nl=False, err=err) + # Echo the last character to stdout to work around an issue + # where readline causes backspace to clear the whole line. + return func(text[-1:]) + if err: + with redirect_stdout(sys.stderr): + return func(text) + return func(text) + + +def _build_prompt( + text: str, + suffix: str, + show_default: bool | str = False, + default: t.Any | None = None, + show_choices: bool = True, + type: ParamType[t.Any] | None = None, +) -> str: + prompt = text + if type is not None and show_choices and isinstance(type, Choice): + prompt += f" ({', '.join(map(str, type.choices))})" + if isinstance(show_default, str): + default = f"({show_default})" + if default is not None and show_default: + prompt = f"{prompt} [{_format_default(default)}]" + return f"{prompt}{suffix}" + + +def _format_default(default: t.Any) -> t.Any: + if isinstance(default, (io.IOBase, LazyFile)) and hasattr(default, "name"): + return default.name + + return default + + +def prompt( + text: str, + default: t.Any | None = None, + hide_input: bool = False, + confirmation_prompt: bool | str = False, + type: ParamType[t.Any] | t.Any | None = None, + value_proc: t.Callable[[str], t.Any] | None = None, + prompt_suffix: str = ": ", + show_default: bool | str = True, + err: bool = False, + show_choices: bool = True, +) -> t.Any: + """Prompts a user for input. This is a convenience function that can + be used to prompt a user for input later. + + If the user aborts the input by sending an interrupt signal, this + function will catch it and raise a :exc:`Abort` exception. + + :param text: the text to show for the prompt. + :param default: the default value to use if no input happens. If this + is not given it will prompt until it's aborted. + :param hide_input: if this is set to true then the input value will + be hidden. + :param confirmation_prompt: Prompt a second time to confirm the + value. Can be set to a string instead of ``True`` to customize + the message. + :param type: the type to use to check the value against. + :param value_proc: if this parameter is provided it's a function that + is invoked instead of the type conversion to + convert a value. + :param prompt_suffix: a suffix that should be added to the prompt. + :param show_default: shows or hides the default value in the prompt. + If this value is a string, it shows that string + in parentheses instead of the actual value. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. + :param show_choices: Show or hide choices if the passed type is a Choice. + For example if type is a Choice of either day or week, + show_choices is true and text is "Group by" then the + prompt will be "Group by (day, week): ". + + .. versionchanged:: 8.3.3 + ``show_default`` can be a string to show a custom value instead + of the actual default, matching the help text behavior. + + .. versionchanged:: 8.3.1 + A space is no longer appended to the prompt. + + .. versionadded:: 8.0 + ``confirmation_prompt`` can be a custom string. + + .. versionadded:: 7.0 + Added the ``show_choices`` parameter. + + .. versionadded:: 6.0 + Added unicode support for cmd.exe on Windows. + + .. versionadded:: 4.0 + Added the `err` parameter. + + """ + + def prompt_func(text: str) -> str: + f = hidden_prompt_func if hide_input else visible_prompt_func + try: + return _readline_prompt(f, text, err) + except (KeyboardInterrupt, EOFError): + # getpass doesn't print a newline if the user aborts input with ^C. + # Allegedly this behavior is inherited from getpass(3). + # A doc bug has been filed at https://bugs.python.org/issue24711 + if hide_input: + echo(None, err=err) + raise Abort() from None + + if value_proc is None: + value_proc = convert_type(type, default) + + prompt = _build_prompt( + text, prompt_suffix, show_default, default, show_choices, type + ) + + if confirmation_prompt: + if confirmation_prompt is True: + confirmation_prompt = _("Repeat for confirmation") + + confirmation_prompt = _build_prompt(confirmation_prompt, prompt_suffix) + + while True: + while True: + value = prompt_func(prompt) + if value: + break + elif default is not None: + value = default + break + try: + result = value_proc(value) + except UsageError as e: + message = _mask_hidden_input(e.message, value) if hide_input else e.message + echo(_("Error: {message}").format(message=message), err=err) + continue + if not confirmation_prompt: + return result + while True: + value2 = prompt_func(confirmation_prompt) + is_empty = not value and not value2 + if value2 or is_empty: + break + if value == value2: + return result + echo(_("Error: The two entered values do not match."), err=err) + + +def confirm( + text: str, + default: bool | None = False, + abort: bool = False, + prompt_suffix: str = ": ", + show_default: bool = True, + err: bool = False, +) -> bool: + """Prompts for confirmation (yes/no question). + + If the user aborts the input by sending a interrupt signal this + function will catch it and raise a :exc:`Abort` exception. + + :param text: the question to ask. + :param default: The default value to use when no input is given. If + ``None``, repeat until input is given. + :param abort: if this is set to `True` a negative answer aborts the + exception by raising :exc:`Abort`. + :param prompt_suffix: a suffix that should be added to the prompt. + :param show_default: shows or hides the default value in the prompt. + :param err: if set to true the file defaults to ``stderr`` instead of + ``stdout``, the same as with echo. + + .. versionchanged:: 8.3.1 + A space is no longer appended to the prompt. + + .. versionchanged:: 8.0 + Repeat until input is given if ``default`` is ``None``. + + .. versionadded:: 4.0 + Added the ``err`` parameter. + """ + prompt = _build_prompt( + text, + prompt_suffix, + show_default, + "y/n" if default is None else ("Y/n" if default else "y/N"), + ) + + while True: + try: + value = _readline_prompt(visible_prompt_func, prompt, err).lower().strip() + except (KeyboardInterrupt, EOFError): + raise Abort() from None + if value in ("y", "yes"): + rv = True + elif value in ("n", "no"): + rv = False + elif default is not None and value == "": + rv = default + else: + echo(_("Error: invalid input"), err=err) + continue + break + if abort and not rv: + raise Abort() + return rv + + +def get_pager_file( + color: bool | None = None, +) -> t.ContextManager[t.TextIO]: + """Context manager. + + Yields a writable file-like object which can be used as an output pager. + + .. versionadded:: 8.4.0 + + :param color: controls if the pager supports ANSI colors or not. The + default is autodetection. + """ + from ._termui_impl import get_pager_file + + color = resolve_color_default(color) + + return get_pager_file(color=color) + + +def echo_via_pager( + text_or_generator: cabc.Iterable[str] | t.Callable[[], cabc.Iterable[str]] | str, + color: bool | None = None, +) -> None: + """This function takes a text and shows it via an environment specific + pager on stdout. + + .. versionchanged:: 3.0 + Added the `color` flag. + + :param text_or_generator: the text to page, or alternatively, a + generator emitting the text to page. + :param color: controls if the pager supports ANSI colors or not. The + default is autodetection. + """ + + if inspect.isgeneratorfunction(text_or_generator): + i = t.cast("t.Callable[[], cabc.Iterable[str]]", text_or_generator)() + elif isinstance(text_or_generator, str): + i = [text_or_generator] + else: + i = iter(t.cast("cabc.Iterable[str]", text_or_generator)) + + # convert every element of i to a text type if necessary + text_generator = (el if isinstance(el, str) else str(el) for el in i) + + with get_pager_file(color=color) as pager: + for text in itertools.chain(text_generator, "\n"): + pager.write(text) + + +@t.overload +def progressbar( + *, + length: int, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> ProgressBar[int]: ... + + +@t.overload +def progressbar( + iterable: cabc.Iterable[V] | None = None, + length: int | None = None, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + item_show_func: t.Callable[[V | None], str | None] | None = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> ProgressBar[V]: ... + + +def progressbar( + iterable: cabc.Iterable[V] | None = None, + length: int | None = None, + label: str | None = None, + hidden: bool = False, + show_eta: bool = True, + show_percent: bool | None = None, + show_pos: bool = False, + item_show_func: t.Callable[[V | None], str | None] | None = None, + fill_char: str = "#", + empty_char: str = "-", + bar_template: str = "%(label)s [%(bar)s] %(info)s", + info_sep: str = " ", + width: int = 36, + file: t.TextIO | None = None, + color: bool | None = None, + update_min_steps: int = 1, +) -> ProgressBar[V]: + """This function creates an iterable context manager that can be used + to iterate over something while showing a progress bar. It will + either iterate over the `iterable` or `length` items (that are counted + up). While iteration happens, this function will print a rendered + progress bar to the given `file` (defaults to stdout) and will attempt + to calculate remaining time and more. By default, this progress bar + will not be rendered if the file is not a terminal. + + The context manager creates the progress bar. When the context + manager is entered the progress bar is already created. With every + iteration over the progress bar, the iterable passed to the bar is + advanced and the bar is updated. When the context manager exits, + a newline is printed and the progress bar is finalized on screen. + + Note: The progress bar is currently designed for use cases where the + total progress can be expected to take at least several seconds. + Because of this, the ProgressBar class object won't display + progress that is considered too fast, and progress where the time + between steps is less than a second. + + No printing must happen or the progress bar will be unintentionally + destroyed. + + Example usage:: + + with progressbar(items) as bar: + for item in bar: + do_something_with(item) + + Alternatively, if no iterable is specified, one can manually update the + progress bar through the `update()` method instead of directly + iterating over the progress bar. The update method accepts the number + of steps to increment the bar with:: + + with progressbar(length=chunks.total_bytes) as bar: + for chunk in chunks: + process_chunk(chunk) + bar.update(chunks.bytes) + + The ``update()`` method also takes an optional value specifying the + ``current_item`` at the new position. This is useful when used + together with ``item_show_func`` to customize the output for each + manual step:: + + with click.progressbar( + length=total_size, + label='Unzipping archive', + item_show_func=lambda a: a.filename + ) as bar: + for archive in zip_file: + archive.extract() + bar.update(archive.size, archive) + + :param iterable: an iterable to iterate over. If not provided the length + is required. + :param length: the number of items to iterate over. By default the + progressbar will attempt to ask the iterator about its + length, which might or might not work. If an iterable is + also provided this parameter can be used to override the + length. If an iterable is not provided the progress bar + will iterate over a range of that length. + :param label: the label to show next to the progress bar. + :param hidden: hide the progressbar. Defaults to ``False``. When no tty is + detected, it will only print the progressbar label. Setting this to + ``False`` also disables that. + :param show_eta: enables or disables the estimated time display. This is + automatically disabled if the length cannot be + determined. + :param show_percent: enables or disables the percentage display. The + default is `True` if the iterable has a length or + `False` if not. + :param show_pos: enables or disables the absolute position display. The + default is `False`. + :param item_show_func: A function called with the current item which + can return a string to show next to the progress bar. If the + function returns ``None`` nothing is shown. The current item can + be ``None``, such as when entering and exiting the bar. + :param fill_char: the character to use to show the filled part of the + progress bar. + :param empty_char: the character to use to show the non-filled part of + the progress bar. + :param bar_template: the format string to use as template for the bar. + The parameters in it are ``label`` for the label, + ``bar`` for the progress bar and ``info`` for the + info section. + :param info_sep: the separator between multiple info items (eta etc.) + :param width: the width of the progress bar in characters, 0 means full + terminal width + :param file: The file to write to. If this is not a terminal then + only the label is printed. + :param color: controls if the terminal supports ANSI colors or not. The + default is autodetection. This is only needed if ANSI + codes are included anywhere in the progress bar output + which is not the case by default. + :param update_min_steps: Render only when this many updates have + completed. This allows tuning for very fast iterators. + + .. versionadded:: 8.2 + The ``hidden`` argument. + + .. versionchanged:: 8.0 + Output is shown even if execution time is less than 0.5 seconds. + + .. versionchanged:: 8.0 + ``item_show_func`` shows the current item, not the previous one. + + .. versionchanged:: 8.0 + Labels are echoed if the output is not a TTY. Reverts a change + in 7.0 that removed all output. + + .. versionadded:: 8.0 + The ``update_min_steps`` parameter. + + .. versionadded:: 4.0 + The ``color`` parameter and ``update`` method. + + .. versionadded:: 2.0 + """ + from ._termui_impl import ProgressBar + + color = resolve_color_default(color) + return ProgressBar( + iterable=iterable, + length=length, + hidden=hidden, + show_eta=show_eta, + show_percent=show_percent, + show_pos=show_pos, + item_show_func=item_show_func, + fill_char=fill_char, + empty_char=empty_char, + bar_template=bar_template, + info_sep=info_sep, + file=file, + label=label, + width=width, + color=color, + update_min_steps=update_min_steps, + ) + + +def clear() -> None: + """Clears the terminal screen. This will have the effect of clearing + the whole visible space of the terminal and moving the cursor to the + top left. This does not do anything if not connected to a terminal. + + .. versionadded:: 2.0 + """ + if not isatty(sys.stdout): + return + + # ANSI escape \033[2J clears the screen, \033[1;1H moves the cursor + echo("\033[2J\033[1;1H", nl=False) + + +def _interpret_color(color: int | tuple[int, int, int] | str, offset: int = 0) -> str: + if isinstance(color, int): + return f"{38 + offset};5;{color:d}" + + if isinstance(color, (tuple, list)): + r, g, b = color + return f"{38 + offset};2;{r:d};{g:d};{b:d}" + + return str(_ansi_colors[color] + offset) + + +def style( + text: t.Any, + fg: int | tuple[int, int, int] | str | None = None, + bg: int | tuple[int, int, int] | str | None = None, + bold: bool | None = None, + dim: bool | None = None, + underline: bool | None = None, + overline: bool | None = None, + italic: bool | None = None, + blink: bool | None = None, + reverse: bool | None = None, + strikethrough: bool | None = None, + reset: bool = True, +) -> str: + """Styles a text with ANSI styles and returns the new string. By + default the styling is self contained which means that at the end + of the string a reset code is issued. This can be prevented by + passing ``reset=False``. + + Examples:: + + click.echo(click.style('Hello World!', fg='green')) + click.echo(click.style('ATTENTION!', blink=True)) + click.echo(click.style('Some things', reverse=True, fg='cyan')) + click.echo(click.style('More colors', fg=(255, 12, 128), bg=117)) + + Supported color names: + + * ``black`` (might be a gray) + * ``red`` + * ``green`` + * ``yellow`` (might be an orange) + * ``blue`` + * ``magenta`` + * ``cyan`` + * ``white`` (might be light gray) + * ``bright_black`` + * ``bright_red`` + * ``bright_green`` + * ``bright_yellow`` + * ``bright_blue`` + * ``bright_magenta`` + * ``bright_cyan`` + * ``bright_white`` + * ``reset`` (reset the color code only) + + If the terminal supports it, color may also be specified as: + + - An integer in the interval [0, 255]. The terminal must support + 8-bit/256-color mode. + - An RGB tuple of three integers in [0, 255]. The terminal must + support 24-bit/true-color mode. + + See https://en.wikipedia.org/wiki/ANSI_color and + https://gist.github.com/XVilka/8346728 for more information. + + :param text: the string to style with ansi codes. + :param fg: if provided this will become the foreground color. + :param bg: if provided this will become the background color. + :param bold: if provided this will enable or disable bold mode. + :param dim: if provided this will enable or disable dim mode. This is + badly supported. + :param underline: if provided this will enable or disable underline. + :param overline: if provided this will enable or disable overline. + :param italic: if provided this will enable or disable italic. + :param blink: if provided this will enable or disable blinking. + :param reverse: if provided this will enable or disable inverse + rendering (foreground becomes background and the + other way round). + :param strikethrough: if provided this will enable or disable + striking through text. + :param reset: by default a reset-all code is added at the end of the + string which means that styles do not carry over. This + can be disabled to compose styles. + + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. + + .. versionchanged:: 8.0 + Added support for 256 and RGB color codes. + + .. versionchanged:: 8.0 + Added the ``strikethrough``, ``italic``, and ``overline`` + parameters. + + .. versionchanged:: 7.0 + Added support for bright colors. + + .. versionadded:: 2.0 + """ + if not isinstance(text, str): + text = str(text) + + bits = [] + + if fg: + try: + bits.append(f"\033[{_interpret_color(fg)}m") + except KeyError: + raise TypeError(_("Unknown color {colour!r}").format(colour=fg)) from None + + if bg: + try: + bits.append(f"\033[{_interpret_color(bg, 10)}m") + except KeyError: + raise TypeError(_("Unknown color {colour!r}").format(colour=bg)) from None + + if bold is not None: + bits.append(f"\033[{1 if bold else 22}m") + if dim is not None: + bits.append(f"\033[{2 if dim else 22}m") + if underline is not None: + bits.append(f"\033[{4 if underline else 24}m") + if overline is not None: + bits.append(f"\033[{53 if overline else 55}m") + if italic is not None: + bits.append(f"\033[{3 if italic else 23}m") + if blink is not None: + bits.append(f"\033[{5 if blink else 25}m") + if reverse is not None: + bits.append(f"\033[{7 if reverse else 27}m") + if strikethrough is not None: + bits.append(f"\033[{9 if strikethrough else 29}m") + bits.append(text) + if reset: + bits.append(_ansi_reset_all) + return "".join(bits) + + +def unstyle(text: str) -> str: + """Removes ANSI styling information from a string. Usually it's not + necessary to use this function as Click's echo function will + automatically remove styling if necessary. + + .. versionadded:: 2.0 + + :param text: the text to remove style information from. + """ + return strip_ansi(text) + + +def secho( + message: t.Any | None = None, + file: t.IO[t.AnyStr] | None = None, + nl: bool = True, + err: bool = False, + color: bool | None = None, + **styles: t.Any, +) -> None: + """This function combines :func:`echo` and :func:`style` into one + call. As such the following two calls are the same:: + + click.secho('Hello World!', fg='green') + click.echo(click.style('Hello World!', fg='green')) + + All keyword arguments are forwarded to the underlying functions + depending on which one they go with. + + Non-string types will be converted to :class:`str`. However, + :class:`bytes` are passed directly to :meth:`echo` without applying + style. If you want to style bytes that represent text, call + :meth:`bytes.decode` first. + + .. versionchanged:: 8.0 + A non-string ``message`` is converted to a string. Bytes are + passed through without style applied. + + .. versionadded:: 2.0 + """ + if message is not None and not isinstance(message, (bytes, bytearray)): + message = style(message, **styles) + + return echo(message, file=file, nl=nl, err=err, color=color) + + +@t.overload +def edit( + text: bytes | bytearray, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = False, + extension: str = ".txt", +) -> bytes | None: ... + + +@t.overload +def edit( + text: str, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = True, + extension: str = ".txt", +) -> str | None: ... + + +@t.overload +def edit( + text: None = None, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = True, + extension: str = ".txt", + filename: str | cabc.Iterable[str] | None = None, +) -> None: ... + + +def edit( + text: str | bytes | bytearray | None = None, + editor: str | None = None, + env: cabc.Mapping[str, str] | None = None, + require_save: bool = True, + extension: str = ".txt", + filename: str | cabc.Iterable[str] | None = None, +) -> str | bytes | bytearray | None: + r"""Edits the given text in the defined editor. If an editor is given + (should be the full path to the executable but the regular operating + system search path is used for finding the executable) it overrides + the detected editor. Optionally, some environment variables can be + used. If the editor is closed without changes, `None` is returned. In + case a file is edited directly the return value is always `None` and + `require_save` and `extension` are ignored. + + If the editor cannot be opened a :exc:`UsageError` is raised. + + Note for Windows: to simplify cross-platform usage, the newlines are + automatically converted from POSIX to Windows and vice versa. As such, + the message here will have ``\n`` as newline markers. + + :param text: the text to edit. + :param editor: optionally the editor to use. Defaults to automatic + detection. + :param env: environment variables to forward to the editor. + :param require_save: if this is true, then not saving in the editor + will make the return value become `None`. + :param extension: the extension to tell the editor about. This defaults + to `.txt` but changing this might change syntax + highlighting. + :param filename: if provided it will edit this file instead of the + provided text contents. It will not use a temporary + file as an indirection in that case. If the editor supports + editing multiple files at once, a sequence of files may be + passed as well. Invoke `click.file` once per file instead + if multiple files cannot be managed at once or editing the + files serially is desired. + + .. versionchanged:: 8.2.0 + ``filename`` now accepts any ``Iterable[str]`` in addition to a ``str`` + if the ``editor`` supports editing multiple files at once. + + """ + from ._termui_impl import Editor + + ed = Editor(editor=editor, env=env, require_save=require_save, extension=extension) + + if filename is None: + return ed.edit(text) + + if isinstance(filename, str): + filename = (filename,) + + ed.edit_files(filenames=filename) + return None + + +def launch(url: str, wait: bool = False, locate: bool = False) -> int: + """This function launches the given URL (or filename) in the default + viewer application for this file type. If this is an executable, it + might launch the executable in a new session. The return value is + the exit code of the launched application. Usually, ``0`` indicates + success. + + Examples:: + + click.launch('https://click.palletsprojects.com/') + click.launch('/my/downloaded/file', locate=True) + + .. versionadded:: 2.0 + + :param url: URL or filename of the thing to launch. + :param wait: Wait for the program to exit before returning. This + only works if the launched program blocks. In particular, + ``xdg-open`` on Linux does not block. + :param locate: if this is set to `True` then instead of launching the + application associated with the URL it will attempt to + launch a file manager with the file located. This + might have weird effects if the URL does not point to + the filesystem. + """ + from ._termui_impl import open_url + + return open_url(url, wait=wait, locate=locate) + + +# If this is provided, getchar() calls into this instead. This is used +# for unittesting purposes. +_getchar: t.Callable[[bool], str] | None = None + + +def getchar(echo: bool = False) -> str: + """Fetches a single character from the terminal and returns it. This + will always return a unicode character and under certain rare + circumstances this might return more than one character. The + situations which more than one character is returned is when for + whatever reason multiple characters end up in the terminal buffer or + standard input was not actually a terminal. + + Note that this will always read from the terminal, even if something + is piped into the standard input. + + Note for Windows: in rare cases when typing non-ASCII characters, this + function might wait for a second character and then return both at once. + This is because certain Unicode characters look like special-key markers. + + .. versionadded:: 2.0 + + :param echo: if set to `True`, the character read will also show up on + the terminal. The default is to not show it. + """ + global _getchar + + if _getchar is None: + from ._termui_impl import getchar as f + + _getchar = f + + return _getchar(echo) + + +def raw_terminal() -> AbstractContextManager[int]: + from ._termui_impl import raw_terminal as f + + return f() + + +def pause(info: str | None = None, err: bool = False) -> None: + """This command stops execution and waits for the user to press any + key to continue. This is similar to the Windows batch "pause" + command. If the program is not run through a terminal, this command + will instead do nothing. + + .. versionadded:: 2.0 + + .. versionadded:: 4.0 + Added the `err` parameter. + + :param info: The message to print before pausing. Defaults to + ``"Press any key to continue..."``. + :param err: if set to message goes to ``stderr`` instead of + ``stdout``, the same as with echo. + """ + if not isatty(sys.stdin) or not isatty(sys.stdout): + return + + if info is None: + info = _("Press any key to continue...") + + try: + if info: + echo(info, nl=False, err=err) + try: + getchar() + except (KeyboardInterrupt, EOFError): + pass + finally: + if info: + echo(err=err) diff --git a/server/venv/Lib/site-packages/click/testing.py b/server/venv/Lib/site-packages/click/testing.py new file mode 100644 index 0000000..1eb81d2 --- /dev/null +++ b/server/venv/Lib/site-packages/click/testing.py @@ -0,0 +1,736 @@ +from __future__ import annotations + +import collections.abc as cabc +import contextlib +import io +import os +import pdb +import shlex +import sys +import tempfile +import typing as t +from types import TracebackType + +from . import _compat +from . import formatting +from . import termui +from . import utils +from ._compat import _find_binary_reader + +if t.TYPE_CHECKING: + from _typeshed import ReadableBuffer + + from .core import Command + +CaptureMode = t.Literal["sys", "fd"] + + +class EchoingStdin: + def __init__(self, input: t.BinaryIO, output: t.BinaryIO) -> None: + self._input = input + self._output = output + self._paused = False + + def __getattr__(self, x: str) -> t.Any: + return getattr(self._input, x) + + def _echo(self, rv: bytes) -> bytes: + if not self._paused: + self._output.write(rv) + + return rv + + def read(self, n: int = -1) -> bytes: + return self._echo(self._input.read(n)) + + def read1(self, n: int = -1) -> bytes: + return self._echo(self._input.read1(n)) # type: ignore + + def readline(self, n: int = -1) -> bytes: + return self._echo(self._input.readline(n)) + + def readlines(self) -> list[bytes]: + return [self._echo(x) for x in self._input.readlines()] + + def __iter__(self) -> cabc.Iterator[bytes]: + return iter(self._echo(x) for x in self._input) + + def __repr__(self) -> str: + return repr(self._input) + + +@contextlib.contextmanager +def _pause_echo(stream: EchoingStdin | None) -> cabc.Iterator[None]: + if stream is None: + yield + else: + stream._paused = True + yield + stream._paused = False + + +class _FDCapture: + """Redirect a file descriptor to a temporary file for capture. + + Saves the current target of *targetfd* via :func:`os.dup`, then + redirects it to a temporary file via :func:`os.dup2`. On + :meth:`stop`, restores the original ``fd`` and returns the captured + bytes. Inspired by Pytest's ``FDCapture``. + + .. versionadded:: 8.4.0 + """ + + def __init__(self, targetfd: int) -> None: + self._targetfd = targetfd + self.saved_fd: int = -1 + self._tmpfile: t.BinaryIO | None = None + + def start(self) -> None: + self.saved_fd = os.dup(self._targetfd) + self._tmpfile = tempfile.TemporaryFile(buffering=0) + os.dup2(self._tmpfile.fileno(), self._targetfd) + + def stop(self) -> bytes: + assert self._tmpfile is not None, "_FDCapture.start() was not called" + os.dup2(self.saved_fd, self._targetfd) + os.close(self.saved_fd) + self.saved_fd = -1 + self._tmpfile.seek(0) + data = self._tmpfile.read() + self._tmpfile.close() + self._tmpfile = None + return data + + +class BytesIOCopy(io.BytesIO): + """Patch ``io.BytesIO`` to let the written stream be copied to another. + + .. versionadded:: 8.2 + """ + + def __init__(self, copy_to: io.BytesIO) -> None: + super().__init__() + self.copy_to = copy_to + + def flush(self) -> None: + super().flush() + self.copy_to.flush() + + def write(self, b: ReadableBuffer) -> int: + self.copy_to.write(b) + return super().write(b) + + +class StreamMixer: + """Mixes `` and `` streams. + + The result is available in the ``output`` attribute. + + .. versionadded:: 8.2 + """ + + def __init__(self) -> None: + self.output: io.BytesIO = io.BytesIO() + self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output) + self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output) + + +class _NamedTextIOWrapper(io.TextIOWrapper): + """A :class:`~io.TextIOWrapper` with custom ``name`` and ``mode`` + that does not close its underlying buffer. + + When ``CliRunner`` runs in ``fd`` mode, ``_original_fd`` is patched to + point at the saved (pre-redirection) ``fd``, so C-level consumers that call + :meth:`fileno` (like ``faulthandler`` or ``subprocess``) keep working. In + the default ``sys`` mode ``_original_fd`` stays at ``-1`` and + :meth:`fileno` raises :exc:`io.UnsupportedOperation`, matching the + pre-``8.3.3`` behavior. + """ + + def __init__( + self, + buffer: t.BinaryIO, + name: str, + mode: str, + **kwargs: t.Any, + ) -> None: + super().__init__(buffer, **kwargs) + self._name = name + self._mode = mode + self._original_fd: int = -1 + + def close(self) -> None: + """The buffer this object contains belongs to some other object, + so prevent the default ``__del__`` implementation from closing + that buffer. + + .. versionadded:: 8.3.2 + """ + + def fileno(self) -> int: + """Return the file descriptor of the saved original stream when + ``CliRunner`` runs in ``fd`` mode. Otherwise delegate to + :class:`~io.TextIOWrapper`, which raises + :exc:`io.UnsupportedOperation` for a ``BytesIO``-backed buffer. + """ + if self._original_fd >= 0: + return self._original_fd + return super().fileno() + + @property + def name(self) -> str: + return self._name + + @property + def mode(self) -> str: + return self._mode + + +def make_input_stream( + input: str | bytes | t.IO[t.Any] | None, charset: str +) -> t.BinaryIO: + # Is already an input stream. + if hasattr(input, "read"): + rv = _find_binary_reader(t.cast("t.IO[t.Any]", input)) + + if rv is not None: + return rv + + raise TypeError("Could not find binary reader for input stream.") + + if input is None: + input = b"" + elif isinstance(input, str): + input = input.encode(charset) + + return io.BytesIO(input) + + +class Result: + """Holds the captured result of an invoked CLI script. + + :param runner: The runner that created the result + :param stdout_bytes: The standard output as bytes. + :param stderr_bytes: The standard error as bytes. + :param output_bytes: A mix of ``stdout_bytes`` and ``stderr_bytes``, as the + user would see it in its terminal. + :param return_value: The value returned from the invoked command. + :param exit_code: The exit code as integer. + :param exception: The exception that happened if one did. + :param exc_info: Exception information (exception type, exception instance, + traceback type). + + .. versionchanged:: 8.2 + ``stderr_bytes`` no longer optional, ``output_bytes`` introduced and + ``mix_stderr`` has been removed. + + .. versionadded:: 8.0 + Added ``return_value``. + """ + + def __init__( + self, + runner: CliRunner, + stdout_bytes: bytes, + stderr_bytes: bytes, + output_bytes: bytes, + return_value: t.Any, + exit_code: int, + exception: BaseException | None, + exc_info: tuple[type[BaseException], BaseException, TracebackType] + | None = None, + ): + self.runner = runner + self.stdout_bytes = stdout_bytes + self.stderr_bytes = stderr_bytes + self.output_bytes = output_bytes + self.return_value = return_value + self.exit_code = exit_code + self.exception = exception + self.exc_info = exc_info + + @property + def output(self) -> str: + """The terminal output as unicode string, as the user would see it. + + .. versionchanged:: 8.2 + No longer a proxy for ``self.stdout``. Now has its own independent stream + that is mixing `` and ``, in the order they were written. + """ + return self.output_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + @property + def stdout(self) -> str: + """The standard output as unicode string.""" + return self.stdout_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + @property + def stderr(self) -> str: + """The standard error as unicode string. + + .. versionchanged:: 8.2 + No longer raise an exception, always returns the `` string. + """ + return self.stderr_bytes.decode(self.runner.charset, "replace").replace( + "\r\n", "\n" + ) + + def __repr__(self) -> str: + exc_str = repr(self.exception) if self.exception else "okay" + return f"<{type(self).__name__} {exc_str}>" + + +class CliRunner: + """The CLI runner provides functionality to invoke a Click command line + script for unittesting purposes in a isolated environment. This only + works in single-threaded systems without any concurrency as it changes the + global interpreter state. + + :param charset: the character set for the input and output data. + :param env: a dictionary with environment variables for overriding. + :param echo_stdin: if this is set to `True`, then reading from `` writes + to ``. This is useful for showing examples in + some circumstances. Note that regular prompts + will automatically echo the input. + :param catch_exceptions: Whether to catch any exceptions other than + ``SystemExit`` when running :meth:`~CliRunner.invoke`. + :param capture: Selects the output capture strategy. ``sys`` (default) + captures Python-level writes only and leaves + :meth:`sys.stdout.fileno` raising :exc:`io.UnsupportedOperation`, so + user code that calls :func:`os.dup2` on ``sys.stdout.fileno()`` cannot + clobber the host runner's stdout. ``fd`` redirects file descriptors + ``1`` and ``2`` via :func:`os.dup2` to a temporary file, also catching + output from stale stream references, C extensions, and subprocesses. + ``fd`` is not supported on Windows. + + .. versionchanged:: 8.4.0 + Added the ``capture`` parameter. The default ``sys`` mode no longer + exposes the original fd through :meth:`fileno`, reverting the change + introduced in ``8.3.3`` that broke Pytest's ``fd``-level capture + teardown. Use ``capture="fd"`` to restore that behavior with proper + isolation. :issue:`3384` + + .. versionchanged:: 8.2 + Added the ``catch_exceptions`` parameter. + + .. versionchanged:: 8.2 + ``mix_stderr`` parameter has been removed. + """ + + def __init__( + self, + charset: str = "utf-8", + env: cabc.Mapping[str, str | None] | None = None, + echo_stdin: bool = False, + catch_exceptions: bool = True, + capture: CaptureMode = "sys", + ) -> None: + if capture not in {"sys", "fd"}: + raise ValueError( + f"capture={capture!r} is not valid. Choose from 'sys' or 'fd'." + ) + if capture == "fd" and sys.platform == "win32": + raise ValueError( + f"capture={capture!r} is not supported on Windows. Use 'sys'." + ) + self.charset = charset + self.env: cabc.Mapping[str, str | None] = env or {} + self.echo_stdin = echo_stdin + self.catch_exceptions = catch_exceptions + self.capture: CaptureMode = capture + + def get_default_prog_name(self, cli: Command) -> str: + """Given a command object it will return the default program name + for it. The default is the `name` attribute or ``"root"`` if not + set. + """ + return cli.name or "root" + + def make_env( + self, overrides: cabc.Mapping[str, str | None] | None = None + ) -> cabc.Mapping[str, str | None]: + """Returns the environment overrides for invoking a script.""" + rv = dict(self.env) + if overrides: + rv.update(overrides) + return rv + + @contextlib.contextmanager + def isolation( + self, + input: str | bytes | t.IO[t.Any] | None = None, + env: cabc.Mapping[str, str | None] | None = None, + color: bool = False, + ) -> cabc.Iterator[tuple[io.BytesIO, io.BytesIO, io.BytesIO]]: + """A context manager that sets up the isolation for invoking of a + command line tool. This sets up `` with the given input data + and `os.environ` with the overrides from the given dictionary. + This also rebinds some internals in Click to be mocked (like the + prompt functionality). + + This is automatically done in the :meth:`invoke` method. + + :param input: the input stream to put into `sys.stdin`. + :param env: the environment overrides as dictionary. + :param color: whether the output should contain color codes. The + application can still override this explicitly. + + .. versionadded:: 8.2 + An additional output stream is returned, which is a mix of + `` and `` streams. + + .. versionchanged:: 8.2 + Always returns the `` stream. + + .. versionchanged:: 8.0 + `` is opened with ``errors="backslashreplace"`` + instead of the default ``"strict"``. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. + """ + bytes_input = make_input_stream(input, self.charset) + echo_input = None + + old_stdin = sys.stdin + old_stdout = sys.stdout + old_stderr = sys.stderr + old_forced_width = formatting.FORCED_WIDTH + formatting.FORCED_WIDTH = 80 + + env = self.make_env(env) + + stream_mixer = StreamMixer() + + if self.echo_stdin: + bytes_input = echo_input = t.cast( + t.BinaryIO, EchoingStdin(bytes_input, stream_mixer.stdout) + ) + + sys.stdin = text_input = _NamedTextIOWrapper( + bytes_input, encoding=self.charset, name="", mode="r" + ) + + if self.echo_stdin: + # Force unbuffered reads, otherwise TextIOWrapper reads a + # large chunk which is echoed early. + text_input._CHUNK_SIZE = 1 # type: ignore + + sys.stdout = _NamedTextIOWrapper( + stream_mixer.stdout, + encoding=self.charset, + name="", + mode="w", + ) + + sys.stderr = _NamedTextIOWrapper( + stream_mixer.stderr, + encoding=self.charset, + name="", + mode="w", + errors="backslashreplace", + ) + + @_pause_echo(echo_input) # type: ignore + def visible_input(prompt: str | None = None) -> str: + sys.stdout.write(prompt or "") + try: + val = next(text_input).rstrip("\r\n") + except StopIteration as e: + raise EOFError() from e + sys.stdout.write(f"{val}\n") + sys.stdout.flush() + return val + + @_pause_echo(echo_input) # type: ignore + def hidden_input(prompt: str | None = None) -> str: + sys.stdout.write(f"{prompt or ''}\n") + sys.stdout.flush() + try: + return next(text_input).rstrip("\r\n") + except StopIteration as e: + raise EOFError() from e + + @_pause_echo(echo_input) # type: ignore + def _getchar(echo: bool) -> str: + char = sys.stdin.read(1) + + if echo: + sys.stdout.write(char) + + sys.stdout.flush() + return char + + default_color = color + + def should_strip_ansi( + stream: t.IO[t.Any] | None = None, color: bool | None = None + ) -> bool: + if color is None: + return not default_color + return not color + + old_visible_prompt_func = termui.visible_prompt_func + old_hidden_prompt_func = termui.hidden_prompt_func + old__getchar_func = termui._getchar + old_should_strip_ansi = utils.should_strip_ansi # type: ignore + old__compat_should_strip_ansi = _compat.should_strip_ansi + old_pdb_init = pdb.Pdb.__init__ + termui.visible_prompt_func = visible_input + termui.hidden_prompt_func = hidden_input + termui._getchar = _getchar + utils.should_strip_ansi = should_strip_ansi # type: ignore + _compat.should_strip_ansi = should_strip_ansi + + def _patched_pdb_init( + self: pdb.Pdb, + completekey: str = "tab", + stdin: t.IO[str] | None = None, + stdout: t.IO[str] | None = None, + **kwargs: t.Any, + ) -> None: + """Default ``pdb.Pdb`` to real terminal streams during + ``CliRunner`` isolation. + + Without this patch, ``pdb.Pdb.__init__`` inherits from + ``cmd.Cmd`` which falls back to ``sys.stdin``/``sys.stdout`` + when no explicit streams are provided. During isolation + those are ``BytesIO``-backed wrappers, so the debugger + reads from an empty buffer and writes to captured output, + making interactive debugging impossible. + + By defaulting to ``sys.__stdin__``/``sys.__stdout__`` (the + original terminal streams Python preserves regardless of + redirection), debuggers can interact with the user while + ``click.echo`` output is still captured normally. + + This covers ``pdb.set_trace()``, ``breakpoint()``, + ``pdb.post_mortem()``, and debuggers that subclass + ``pdb.Pdb`` (ipdb, pdbpp). Explicit ``stdin``/``stdout`` + arguments are honored and not overridden. Debuggers that + do not subclass ``pdb.Pdb`` (pudb, debugpy) are not + covered. + """ + if stdin is None: + stdin = sys.__stdin__ + if stdout is None: + stdout = sys.__stdout__ + old_pdb_init( + self, completekey=completekey, stdin=stdin, stdout=stdout, **kwargs + ) + + pdb.Pdb.__init__ = _patched_pdb_init # type: ignore[assignment] + + old_env = {} + try: + for key, value in env.items(): + old_env[key] = os.environ.get(key) + if value is None: + try: + del os.environ[key] + except Exception: + pass + else: + os.environ[key] = value + yield (stream_mixer.stdout, stream_mixer.stderr, stream_mixer.output) + finally: + for key, value in old_env.items(): + if value is None: + try: + del os.environ[key] + except Exception: + pass + else: + os.environ[key] = value + sys.stdout = old_stdout + sys.stderr = old_stderr + sys.stdin = old_stdin + termui.visible_prompt_func = old_visible_prompt_func + termui.hidden_prompt_func = old_hidden_prompt_func + termui._getchar = old__getchar_func + utils.should_strip_ansi = old_should_strip_ansi # type: ignore + _compat.should_strip_ansi = old__compat_should_strip_ansi + formatting.FORCED_WIDTH = old_forced_width + pdb.Pdb.__init__ = old_pdb_init # type: ignore[method-assign] + + def invoke( + self, + cli: Command, + args: str | cabc.Sequence[str] | None = None, + input: str | bytes | t.IO[t.Any] | None = None, + env: cabc.Mapping[str, str | None] | None = None, + catch_exceptions: bool | None = None, + color: bool = False, + **extra: t.Any, + ) -> Result: + """Invokes a command in an isolated environment. The arguments are + forwarded directly to the command line script, the `extra` keyword + arguments are passed to the :meth:`~clickpkg.Command.main` function of + the command. + + This returns a :class:`Result` object. + + :param cli: the command to invoke + :param args: the arguments to invoke. It may be given as an iterable + or a string. When given as string it will be interpreted + as a Unix shell command. More details at + :func:`shlex.split`. + :param input: the input data for `sys.stdin`. + :param env: the environment overrides. + :param catch_exceptions: Whether to catch any other exceptions than + ``SystemExit``. If :data:`None`, the value + from :class:`CliRunner` is used. + :param extra: the keyword arguments to pass to :meth:`main`. + :param color: whether the output should contain color codes. The + application can still override this explicitly. + + .. versionadded:: 8.2 + The result object has the ``output_bytes`` attribute with + the mix of ``stdout_bytes`` and ``stderr_bytes``, as the user would + see it in its terminal. + + .. versionchanged:: 8.2 + The result object always returns the ``stderr_bytes`` stream. + + .. versionchanged:: 8.0 + The result object has the ``return_value`` attribute with + the value returned from the invoked command. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. + + .. versionchanged:: 3.0 + Added the ``catch_exceptions`` parameter. + + .. versionchanged:: 3.0 + The result object has the ``exc_info`` attribute with the + traceback if available. + """ + exc_info = None + if catch_exceptions is None: + catch_exceptions = self.catch_exceptions + + # Set up fd capture before isolation replaces sys.stdout and sys.stderr. + cap_out: _FDCapture | None = None + cap_err: _FDCapture | None = None + + if self.capture == "fd": + cap_out = _FDCapture(1) + cap_err = _FDCapture(2) + try: + cap_out.start() + cap_err.start() + except OSError: + cap_out = cap_err = None + + with self.isolation(input=input, env=env, color=color) as outstreams: + # Point the captured streams' fileno() at the saved (original) + # fd so that C-level consumers like faulthandler keep working + # while fd 1/2 are redirected to the capture tmpfile. + if cap_out is not None and cap_err is not None: + sys.stdout._original_fd = cap_out.saved_fd # type: ignore[union-attr] + sys.stderr._original_fd = cap_err.saved_fd # type: ignore[union-attr] + + return_value = None + exception: BaseException | None = None + exit_code = 0 + + if isinstance(args, str): + args = shlex.split(args) + + try: + prog_name = extra.pop("prog_name") + except KeyError: + prog_name = self.get_default_prog_name(cli) + + try: + return_value = cli.main(args=args or (), prog_name=prog_name, **extra) + except SystemExit as e: + exc_info = sys.exc_info() + e_code = t.cast("int | t.Any | None", e.code) + + if e_code is None: + e_code = 0 + + if e_code != 0: + exception = e + + if not isinstance(e_code, int): + sys.stdout.write(str(e_code)) + sys.stdout.write("\n") + e_code = 1 + + exit_code = e_code + + except Exception as e: + if not catch_exceptions: + raise + exception = e + exit_code = 1 + exc_info = sys.exc_info() + finally: + sys.stdout.flush() + sys.stderr.flush() + + # Stop fd capture and merge the captured bytes into + # the stdout/stderr BytesIO streams. BytesIOCopy mirrors + # those writes into outstreams[2] automatically. + if cap_out is not None and cap_err is not None: + fd_out = cap_out.stop() + fd_err = cap_err.stop() + if fd_out: + outstreams[0].write(fd_out) + if fd_err: + outstreams[1].write(fd_err) + + stdout = outstreams[0].getvalue() + stderr = outstreams[1].getvalue() + output = outstreams[2].getvalue() + + return Result( + runner=self, + stdout_bytes=stdout, + stderr_bytes=stderr, + output_bytes=output, + return_value=return_value, + exit_code=exit_code, + exception=exception, + exc_info=exc_info, # type: ignore + ) + + @contextlib.contextmanager + def isolated_filesystem( + self, temp_dir: str | os.PathLike[str] | None = None + ) -> cabc.Iterator[str]: + """A context manager that creates a temporary directory and + changes the current working directory to it. This isolates tests + that affect the contents of the CWD to prevent them from + interfering with each other. + + :param temp_dir: Create the temporary directory under this + directory. If given, the created directory is not removed + when exiting. + + .. versionchanged:: 8.0 + Added the ``temp_dir`` parameter. + """ + cwd = os.getcwd() + dt = tempfile.mkdtemp(dir=temp_dir) + os.chdir(dt) + + try: + yield dt + finally: + os.chdir(cwd) + + if temp_dir is None: + import shutil + + try: + shutil.rmtree(dt) + except OSError: + pass diff --git a/server/venv/Lib/site-packages/click/types.py b/server/venv/Lib/site-packages/click/types.py new file mode 100644 index 0000000..98c2d61 --- /dev/null +++ b/server/venv/Lib/site-packages/click/types.py @@ -0,0 +1,1309 @@ +from __future__ import annotations + +import abc +import collections.abc as cabc +import enum +import os +import stat +import sys +import typing as t +import uuid +from datetime import datetime +from gettext import gettext as _ +from gettext import ngettext + +from ._compat import _get_argv_encoding +from ._compat import open_stream +from .exceptions import BadParameter +from .utils import format_filename +from .utils import LazyFile +from .utils import safecall + +if t.TYPE_CHECKING: + import typing_extensions as te + + from .core import Context + from .core import Parameter + from .shell_completion import CompletionItem + +ParamTypeValue = t.TypeVar("ParamTypeValue") + + +class ParamTypeInfoDict(t.TypedDict): + param_type: str + name: str + + +class ParamType(t.Generic[ParamTypeValue], abc.ABC): + """Represents the type of a parameter. Validates and converts values + from the command line or Python into the correct type. + + To implement a custom type, subclass and implement at least the + following: + + - The :attr:`name` class attribute must be set. + - Calling an instance of the type with ``None`` must return + ``None``. This is already implemented by default. + - :meth:`convert` must convert string values to the correct type. + - :meth:`convert` must accept values that are already the correct + type. + - It must be able to convert a value if the ``ctx`` and ``param`` + arguments are ``None``. This can occur when converting prompt + input. + + .. versionchanged:: 8.4.0 + Now a generic abstract base class. Parameterize with the + converted value type (``ParamType[int]`` for an integer-returning + type) so that :meth:`convert` and downstream consumers carry the + narrowed return type. + """ + + is_composite: t.ClassVar[bool] = False + arity: t.ClassVar[int] = 1 + + #: the descriptive name of this type + name: str + + #: if a list of this type is expected and the value is pulled from a + #: string environment variable, this is what splits it up. `None` + #: means any whitespace. For all parameters the general rule is that + #: whitespace splits them up. The exception are paths and files which + #: are split by ``os.path.pathsep`` by default (":" on Unix and ";" on + #: Windows). + envvar_list_splitter: t.ClassVar[str | None] = None + + def to_info_dict(self) -> ParamTypeInfoDict: + """Gather information that could be useful for a tool generating + user-facing documentation. + + Use :meth:`click.Context.to_info_dict` to traverse the entire + CLI structure. + + .. versionadded:: 8.0 + """ + # The class name without the "ParamType" suffix. + param_type = type(self).__name__.partition("ParamType")[0] + param_type = param_type.partition("ParameterType")[0] + + # Custom subclasses might not remember to set a name. + if hasattr(self, "name"): + name = self.name + else: + name = param_type + + return {"param_type": param_type, "name": name} + + def __call__( + self, + value: t.Any, + param: Parameter | None = None, + ctx: Context | None = None, + ) -> ParamTypeValue | None: + if value is not None: + return self.convert(value, param, ctx) + return None + + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: + """Returns the metavar default for this param if it provides one.""" + + def get_missing_message(self, param: Parameter, ctx: Context | None) -> str | None: + """Optionally might return extra information about a missing + parameter. + + .. versionadded:: 2.0 + """ + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> ParamTypeValue: + """Convert the value to the correct type. This is not called if + the value is ``None`` (the missing value). + + This must accept string values from the command line, as well as + values that are already the correct type. It may also convert + other compatible types. + + The ``param`` and ``ctx`` arguments may be ``None`` in certain + situations, such as when converting prompt input. + + If the value cannot be converted, call :meth:`fail` with a + descriptive message. + + :param value: The value to convert. + :param param: The parameter that is using this type to convert + its value. May be ``None``. + :param ctx: The current context that arrived at this value. May + be ``None``. + """ + # The default returns the value as-is so subclasses that only customize + # metadata are not forced to redeclare ``convert``. + return t.cast("ParamTypeValue", value) + + def split_envvar_value(self, rv: str) -> cabc.Sequence[str]: + """Given a value from an environment variable this splits it up + into small chunks depending on the defined envvar list splitter. + + If the splitter is set to `None`, which means that whitespace splits, + then leading and trailing whitespace is ignored. Otherwise, leading + and trailing splitters usually lead to empty items being included. + """ + return (rv or "").split(self.envvar_list_splitter) + + def fail( + self, + message: str, + param: Parameter | None = None, + ctx: Context | None = None, + ) -> t.NoReturn: + """Helper method to fail with an invalid value message.""" + raise BadParameter(message, ctx=ctx, param=param) + + def shell_complete( + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: + """Return a list of + :class:`~click.shell_completion.CompletionItem` objects for the + incomplete value. Most types do not provide completions, but + some do, and this allows custom types to provide custom + completions as well. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + return [] + + +class CompositeParamType(ParamType[ParamTypeValue]): + is_composite = True + + @property + @abc.abstractmethod + def arity(self) -> int: ... # type: ignore[override] + + +class FuncParamTypeInfoDict(ParamTypeInfoDict): + func: t.Callable[[t.Any], t.Any] + + +class FuncParamType(ParamType[ParamTypeValue]): + def __init__(self, func: t.Callable[[t.Any], ParamTypeValue]) -> None: + self.name: str = func.__name__ + self.func = func + + def to_info_dict(self) -> FuncParamTypeInfoDict: + return {"func": self.func, **super().to_info_dict()} + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> ParamTypeValue: + try: + return self.func(value) + except ValueError as exc: + message = str(exc) + + if not message: + try: + message = str(value) + except UnicodeError: + message = value.decode("utf-8", "replace") + + self.fail(message, param, ctx) + + +class UnprocessedParamType(ParamType[t.Any]): + name = "text" + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> t.Any: + return value + + def __repr__(self) -> str: + return "UNPROCESSED" + + +class StringParamType(ParamType[str]): + name = "text" + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> str: + if isinstance(value, bytes): + enc = _get_argv_encoding() + try: + value = value.decode(enc) + except UnicodeError: + fs_enc = sys.getfilesystemencoding() + if fs_enc != enc: + try: + value = value.decode(fs_enc) + except UnicodeError: + value = value.decode("utf-8", "replace") + else: + value = value.decode("utf-8", "replace") + return value # type: ignore[no-any-return] + return str(value) + + def __repr__(self) -> str: + return "STRING" + + +class ChoiceInfoDict(ParamTypeInfoDict): + choices: cabc.Sequence[t.Any] + case_sensitive: bool + + +class Choice(ParamType[ParamTypeValue], t.Generic[ParamTypeValue]): + """The choice type allows a value to be checked against a fixed set + of supported values. + + You may pass any iterable value which will be converted to a tuple + and thus will only be iterated once. + + The resulting value will always be one of the originally passed choices. + See :meth:`normalize_choice` for more info on the mapping of strings + to choices. See :ref:`choice-opts` for an example. + + :param case_sensitive: Set to false to make choices case + insensitive. Defaults to true. + + .. versionchanged:: 8.4.0 + Now generic in the choice value type. Parameterize with the type of + the choice values (``Choice[HashType]`` for an enum, ``Choice[str]`` + for plain strings) to enable type-checked consumers. + + .. versionchanged:: 8.2.0 + Non-``str`` ``choices`` are now supported. It can additionally be any + iterable. Before you were not recommended to pass anything but a list or + tuple. + + .. versionadded:: 8.2.0 + Choice normalization can be overridden via :meth:`normalize_choice`. + """ + + name = "choice" + + def __init__( + self, choices: cabc.Iterable[ParamTypeValue], case_sensitive: bool = True + ) -> None: + self.choices: cabc.Sequence[ParamTypeValue] = tuple(choices) + self.case_sensitive = case_sensitive + + def to_info_dict(self) -> ChoiceInfoDict: + return { + "choices": self.choices, + "case_sensitive": self.case_sensitive, + **super().to_info_dict(), + } + + def _normalized_mapping( + self, ctx: Context | None = None + ) -> cabc.Mapping[ParamTypeValue, str]: + """ + Returns mapping where keys are the original choices and the values are + the normalized values that are accepted via the command line. + + This is a simple wrapper around :meth:`normalize_choice`, use that + instead which is supported. + """ + return { + choice: self.normalize_choice( + choice=choice, + ctx=ctx, + ) + for choice in self.choices + } + + def normalize_choice(self, choice: ParamTypeValue, ctx: Context | None) -> str: + """ + Normalize a choice value, used to map a passed string to a choice. + Each choice must have a unique normalized value. + + By default uses :meth:`Context.token_normalize_func` and if not case + sensitive, convert it to a casefolded value. + + .. versionadded:: 8.2.0 + """ + normed_value = choice.name if isinstance(choice, enum.Enum) else str(choice) + + if ctx is not None and ctx.token_normalize_func is not None: + normed_value = ctx.token_normalize_func(normed_value) + + if not self.case_sensitive: + normed_value = normed_value.casefold() + + return normed_value + + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: + if param.param_type_name == "option" and not param.show_choices: # type: ignore + choice_metavars = [ + convert_type(type(choice)).name.upper() for choice in self.choices + ] + choices_str = "|".join([*dict.fromkeys(choice_metavars)]) + else: + choices_str = "|".join( + [str(i) for i in self._normalized_mapping(ctx=ctx).values()] + ) + + # Use curly braces to indicate a required argument. + if param.required and param.param_type_name == "argument": + return f"{{{choices_str}}}" + + # Use square braces to indicate an option or optional argument. + return f"[{choices_str}]" + + def get_missing_message(self, param: Parameter, ctx: Context | None) -> str: + """ + Message shown when no choice is passed. + + .. versionchanged:: 8.2.0 Added ``ctx`` argument. + """ + return _("Choose from:\n\t{choices}").format( + choices=",\n\t".join(self._normalized_mapping(ctx=ctx).values()) + ) + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> ParamTypeValue: + """ + For a given value from the parser, normalize it and find its + matching normalized value in the list of choices. Then return the + matched "original" choice. + """ + normed_value = self.normalize_choice(choice=value, ctx=ctx) + normalized_mapping = self._normalized_mapping(ctx=ctx) + + try: + return next( + original + for original, normalized in normalized_mapping.items() + if normalized == normed_value + ) + except StopIteration: + self.fail( + self.get_invalid_choice_message(value=value, ctx=ctx), + param=param, + ctx=ctx, + ) + + def get_invalid_choice_message(self, value: t.Any, ctx: Context | None) -> str: + """Get the error message when the given choice is invalid. + + :param value: The invalid value. + + .. versionadded:: 8.2 + """ + choices_str = ", ".join(map(repr, self._normalized_mapping(ctx=ctx).values())) + return ngettext( + "{value!r} is not {choice}.", + "{value!r} is not one of {choices}.", + len(self.choices), + ).format(value=value, choice=choices_str, choices=choices_str) + + def __repr__(self) -> str: + return _("Choice({choices})").format(choices=list(self.choices)) + + def shell_complete( + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: + """Complete choices that start with the incomplete value. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + str_choices = [self.normalize_choice(choice, ctx) for choice in self.choices] + if self.case_sensitive: + matched = (c for c in str_choices if c.startswith(incomplete)) + else: + incomplete = incomplete.lower() + matched = (c for c in str_choices if c.lower().startswith(incomplete)) + + return [CompletionItem(c) for c in matched] + + +class DateTimeInfoDict(ParamTypeInfoDict): + formats: cabc.Sequence[str] + + +class DateTime(ParamType[datetime]): + """The DateTime type converts date strings into `datetime` objects. + + The format strings which are checked are configurable, but default to some + common (non-timezone aware) ISO 8601 formats. + + When specifying *DateTime* formats, you should only pass a list or a tuple. + Other iterables, like generators, may lead to surprising results. + + The format strings are processed using ``datetime.strptime``, and this + consequently defines the format strings which are allowed. + + Parsing is tried using each format, in order, and the first format which + parses successfully is used. + + :param formats: A list or tuple of date format strings, in the order in + which they should be tried. Defaults to + ``'%Y-%m-%d'``, ``'%Y-%m-%dT%H:%M:%S'``, + ``'%Y-%m-%d %H:%M:%S'``. + """ + + name = "datetime" + + def __init__(self, formats: cabc.Sequence[str] | None = None): + self.formats: cabc.Sequence[str] = formats or [ + "%Y-%m-%d", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + ] + + def to_info_dict(self) -> DateTimeInfoDict: + return {"formats": self.formats, **super().to_info_dict()} + + def get_metavar(self, param: Parameter, ctx: Context) -> str | None: + return f"[{'|'.join(self.formats)}]" + + def _try_to_convert_date(self, value: t.Any, format: str) -> datetime | None: + try: + return datetime.strptime(value, format) + except ValueError: + return None + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> datetime: + if isinstance(value, datetime): + return value + + for format in self.formats: + converted = self._try_to_convert_date(value, format) + + if converted is not None: + return converted + + formats_str = ", ".join(map(repr, self.formats)) + self.fail( + ngettext( + "{value!r} does not match the format {format}.", + "{value!r} does not match the formats {formats}.", + len(self.formats), + ).format(value=value, format=formats_str, formats=formats_str), + param, + ctx, + ) + + def __repr__(self) -> str: + return "DateTime" + + +class _NumberParamTypeBase(ParamType[ParamTypeValue]): + _number_class: t.Callable[[t.Any], ParamTypeValue] + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> ParamTypeValue: + try: + return self._number_class(value) + except ValueError: + self.fail( + _("{value!r} is not a valid {number_type}.").format( + value=value, number_type=self.name + ), + param, + ctx, + ) + + +class NumberRangeInfoDict(ParamTypeInfoDict): + min: float | None + max: float | None + min_open: bool + max_open: bool + clamp: bool + + +class _NumberRangeBase(_NumberParamTypeBase[ParamTypeValue]): + def __init__( + self, + min: float | None = None, + max: float | None = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + self.min = min + self.max = max + self.min_open = min_open + self.max_open = max_open + self.clamp = clamp + + def to_info_dict(self) -> NumberRangeInfoDict: + return { + "min": self.min, + "max": self.max, + "min_open": self.min_open, + "max_open": self.max_open, + "clamp": self.clamp, + **super().to_info_dict(), + } + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> ParamTypeValue: + import operator + + rv = super().convert(value, param, ctx) + min = self.min + max = self.max + lt_min: bool = min is not None and ( + operator.le if self.min_open else operator.lt + )(rv, min) # type: ignore[arg-type] + gt_max: bool = max is not None and ( + operator.ge if self.max_open else operator.gt + )(rv, max) # type: ignore[arg-type] + + if self.clamp: + if min is not None and lt_min: + return self._clamp(min, 1, self.min_open) # type: ignore[arg-type] + + if max is not None and gt_max: + return self._clamp(max, -1, self.max_open) # type: ignore[arg-type] + + if lt_min or gt_max: + self.fail( + _("{value} is not in the range {range}.").format( + value=rv, range=self._describe_range() + ), + param, + ctx, + ) + + return rv + + @abc.abstractmethod + def _clamp( + self, bound: ParamTypeValue, dir: t.Literal[1, -1], open: bool + ) -> ParamTypeValue: + """Find the valid value to clamp to bound in the given + direction. + + :param bound: The boundary value. + :param dir: 1 or -1 indicating the direction to move. + :param open: If true, the range does not include the bound. + """ + ... + + def _describe_range(self) -> str: + """Describe the range for use in help text.""" + if self.min is None: + op = "<" if self.max_open else "<=" + return f"x{op}{self.max}" + + if self.max is None: + op = ">" if self.min_open else ">=" + return f"x{op}{self.min}" + + lop = "<" if self.min_open else "<=" + rop = "<" if self.max_open else "<=" + return f"{self.min}{lop}x{rop}{self.max}" + + def __repr__(self) -> str: + clamp = " clamped" if self.clamp else "" + return f"<{type(self).__name__} {self._describe_range()}{clamp}>" + + +class IntParamType(_NumberParamTypeBase[int]): + name = "integer" + _number_class = int + + def __repr__(self) -> str: + return "INT" + + +class IntRange(_NumberRangeBase[int], IntParamType): + """Restrict an :data:`click.INT` value to a range of accepted + values. See :ref:`ranges`. + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. + """ + + name = "integer range" + + def _clamp(self, bound: int, dir: t.Literal[1, -1], open: bool) -> int: + if not open: + return bound + + return bound + dir + + +class FloatParamType(_NumberParamTypeBase[float]): + name = "float" + _number_class = float + + def __repr__(self) -> str: + return "FLOAT" + + +class FloatRange(_NumberRangeBase[float], FloatParamType): + """Restrict a :data:`click.FLOAT` value to a range of accepted + values. See :ref:`ranges`. + + If ``min`` or ``max`` are not passed, any value is accepted in that + direction. If ``min_open`` or ``max_open`` are enabled, the + corresponding boundary is not included in the range. + + If ``clamp`` is enabled, a value outside the range is clamped to the + boundary instead of failing. This is not supported if either + boundary is marked ``open``. + + .. versionchanged:: 8.0 + Added the ``min_open`` and ``max_open`` parameters. + """ + + name = "float range" + + def __init__( + self, + min: float | None = None, + max: float | None = None, + min_open: bool = False, + max_open: bool = False, + clamp: bool = False, + ) -> None: + super().__init__( + min=min, max=max, min_open=min_open, max_open=max_open, clamp=clamp + ) + + if (min_open or max_open) and clamp: + raise TypeError("Clamping is not supported for open bounds.") + + def _clamp(self, bound: float, dir: t.Literal[1, -1], open: bool) -> float: + if not open: + return bound + + # Could use math.nextafter here, but clamping an + # open float range doesn't seem to be particularly useful. It's + # left up to the user to write a callback to do it if needed. + raise RuntimeError("Clamping is not supported for open bounds.") + + +class BoolParamType(ParamType[bool]): + name = "boolean" + + bool_states: dict[str, bool] = { + "1": True, + "0": False, + "yes": True, + "no": False, + "true": True, + "false": False, + "on": True, + "off": False, + "t": True, + "f": False, + "y": True, + "n": False, + # Absence of value is considered False. + "": False, + } + """A mapping of string values to boolean states. + + Mapping is inspired by :py:attr:`configparser.ConfigParser.BOOLEAN_STATES` + and extends it. + + .. caution:: + String values are lower-cased, as the ``str_to_bool`` comparison function + below is case-insensitive. + + .. warning:: + The mapping is not exhaustive, and does not cover all possible boolean strings + representations. It will remains as it is to avoid endless bikeshedding. + + Future work my be considered to make this mapping user-configurable from public + API. + """ + + @staticmethod + def str_to_bool(value: str | bool) -> bool | None: + """Convert a string to a boolean value. + + If the value is already a boolean, it is returned as-is. If the value is a + string, it is stripped of whitespaces and lower-cased, then checked against + the known boolean states pre-defined in the `BoolParamType.bool_states` mapping + above. + + Returns `None` if the value does not match any known boolean state. + """ + if isinstance(value, bool): + return value + return BoolParamType.bool_states.get(value.strip().lower()) + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> bool: + normalized = self.str_to_bool(value) + if normalized is None: + self.fail( + _( + "{value!r} is not a valid boolean. Recognized values: {states}" + ).format(value=value, states=", ".join(sorted(self.bool_states))), + param, + ctx, + ) + return normalized + + def __repr__(self) -> str: + return "BOOL" + + +class UUIDParameterType(ParamType[uuid.UUID]): + name = "uuid" + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> uuid.UUID: + if isinstance(value, uuid.UUID): + return value + + value = value.strip() + + try: + return uuid.UUID(value) + except ValueError: + self.fail( + _("{value!r} is not a valid UUID.").format(value=value), param, ctx + ) + + def __repr__(self) -> str: + return "UUID" + + +class FileInfoDict(ParamTypeInfoDict): + mode: str + encoding: str | None + + +class File(ParamType[t.IO[t.Any]]): + """Declares a parameter to be a file for reading or writing. The file + is automatically closed once the context tears down (after the command + finished working). + + Files can be opened for reading or writing. The special value ``-`` + indicates stdin or stdout depending on the mode. + + By default, the file is opened for reading text data, but it can also be + opened in binary mode or for writing. The encoding parameter can be used + to force a specific encoding. + + The `lazy` flag controls if the file should be opened immediately or upon + first IO. The default is to be non-lazy for standard input and output + streams as well as files opened for reading, `lazy` otherwise. When opening a + file lazily for reading, it is still opened temporarily for validation, but + will not be held open until first IO. lazy is mainly useful when opening + for writing to avoid creating the file until it is needed. + + Files can also be opened atomically in which case all writes go into a + separate file in the same folder and upon completion the file will + be moved over to the original location. This is useful if a file + regularly read by other users is modified. + + See :ref:`file-args` for more information. + + .. versionchanged:: 2.0 + Added the ``atomic`` parameter. + """ + + name = "filename" + envvar_list_splitter: t.ClassVar[str] = os.path.pathsep + + def __init__( + self, + mode: str = "r", + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool | None = None, + atomic: bool = False, + ) -> None: + self.mode = mode + self.encoding = encoding + self.errors = errors + self.lazy = lazy + self.atomic = atomic + + def to_info_dict(self) -> FileInfoDict: + return { + "mode": self.mode, + "encoding": self.encoding, + **super().to_info_dict(), + } + + def resolve_lazy_flag(self, value: str | os.PathLike[str]) -> bool: + if self.lazy is not None: + return self.lazy + if os.fspath(value) == "-": + return False + elif "w" in self.mode: + return True + return False + + def convert( + self, + value: str | os.PathLike[str] | t.IO[t.Any], + param: Parameter | None, + ctx: Context | None, + ) -> t.IO[t.Any]: + if _is_file_like(value): + return value + + value = t.cast("str | os.PathLike[str]", value) + + try: + lazy = self.resolve_lazy_flag(value) + + if lazy: + lf = LazyFile( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + + if ctx is not None: + ctx.call_on_close(lf.close_intelligently) + + return t.cast("t.IO[t.Any]", lf) + + f, should_close = open_stream( + value, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + + # If a context is provided, we automatically close the file + # at the end of the context execution (or flush out). If a + # context does not exist, it's the caller's responsibility to + # properly close the file. This for instance happens when the + # type is used with prompts. + if ctx is not None: + if should_close: + ctx.call_on_close(safecall(f.close)) + else: + ctx.call_on_close(safecall(f.flush)) + + return f + except OSError as e: + self.fail( + f"'{format_filename(value)}': {e.strerror}", + param, + ctx, + ) + + def shell_complete( + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: + """Return a special completion marker that tells the completion + system to use the shell to provide file path completions. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + return [CompletionItem(incomplete, type="file")] + + +def _is_file_like(value: t.Any) -> te.TypeGuard[t.IO[t.Any]]: + return hasattr(value, "read") or hasattr(value, "write") + + +class PathInfoDict(ParamTypeInfoDict): + exists: bool + file_okay: bool + dir_okay: bool + writable: bool + readable: bool + allow_dash: bool + + +class Path(ParamType[str | bytes | os.PathLike[str]]): + """The ``Path`` type is similar to the :class:`File` type, but + returns the filename instead of an open file. Various checks can be + enabled to validate the type of file and permissions. + + :param exists: The file or directory needs to exist for the value to + be valid. If this is not set to ``True``, and the file does not + exist, then all further checks are silently skipped. + :param file_okay: Allow a file as a value. + :param dir_okay: Allow a directory as a value. + :param readable: if true, a readable check is performed. + :param writable: if true, a writable check is performed. + :param executable: if true, an executable check is performed. + :param resolve_path: Make the value absolute and resolve any + symlinks. A ``~`` is not expanded, as this is supposed to be + done by the shell only. + :param allow_dash: Allow a single dash as a value, which indicates + a standard stream (but does not open it). Use + :func:`~click.open_file` to handle opening this value. + :param path_type: Convert the incoming path value to this type. If + ``None``, keep Python's default, which is ``str``. Useful to + convert to :class:`pathlib.Path`. + + .. versionchanged:: 8.1 + Added the ``executable`` parameter. + + .. versionchanged:: 8.0 + Allow passing ``path_type=pathlib.Path``. + + .. versionchanged:: 6.0 + Added the ``allow_dash`` parameter. + """ + + envvar_list_splitter: t.ClassVar[str] = os.path.pathsep + + def __init__( + self, + exists: bool = False, + file_okay: bool = True, + dir_okay: bool = True, + writable: bool = False, + readable: bool = True, + resolve_path: bool = False, + allow_dash: bool = False, + path_type: type[t.Any] | None = None, + executable: bool = False, + ): + self.exists = exists + self.file_okay = file_okay + self.dir_okay = dir_okay + self.readable = readable + self.writable = writable + self.executable = executable + self.resolve_path = resolve_path + self.allow_dash = allow_dash + self.type = path_type + + if self.file_okay and not self.dir_okay: + self.name: str = _("file") + elif self.dir_okay and not self.file_okay: + self.name = _("directory") + else: + self.name = _("path") + + def to_info_dict(self) -> PathInfoDict: + return { + "exists": self.exists, + "file_okay": self.file_okay, + "dir_okay": self.dir_okay, + "writable": self.writable, + "readable": self.readable, + "allow_dash": self.allow_dash, + **super().to_info_dict(), + } + + def coerce_path_result( + self, value: str | os.PathLike[str] + ) -> str | bytes | os.PathLike[str]: + if self.type is not None and not isinstance(value, self.type): + if self.type is str: + return os.fsdecode(value) + elif self.type is bytes: + return os.fsencode(value) + else: + return t.cast("os.PathLike[str]", self.type(value)) + + return value + + def convert( + self, + value: str | os.PathLike[str], + param: Parameter | None, + ctx: Context | None, + ) -> str | bytes | os.PathLike[str]: + rv = value + + is_dash = self.file_okay and self.allow_dash and rv in (b"-", "-") + + if not is_dash: + if self.resolve_path: + rv = os.path.realpath(rv) + + try: + st = os.stat(rv) + except OSError: + if not self.exists: + return self.coerce_path_result(rv) + self.fail( + _("{name} {filename!r} does not exist.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if not self.file_okay and stat.S_ISREG(st.st_mode): + self.fail( + _("{name} {filename!r} is a file.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + if not self.dir_okay and stat.S_ISDIR(st.st_mode): + self.fail( + _("{name} {filename!r} is a directory.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if self.readable and not os.access(rv, os.R_OK): + self.fail( + _("{name} {filename!r} is not readable.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if self.writable and not os.access(rv, os.W_OK): + self.fail( + _("{name} {filename!r} is not writable.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + if self.executable and not os.access(value, os.X_OK): + self.fail( + _("{name} {filename!r} is not executable.").format( + name=self.name.title(), filename=format_filename(value) + ), + param, + ctx, + ) + + return self.coerce_path_result(rv) + + def shell_complete( + self, ctx: Context, param: Parameter, incomplete: str + ) -> list[CompletionItem]: + """Return a special completion marker that tells the completion + system to use the shell to provide path completions for only + directories or any paths. + + :param ctx: Invocation context for this command. + :param param: The parameter that is requesting completion. + :param incomplete: Value being completed. May be empty. + + .. versionadded:: 8.0 + """ + from click.shell_completion import CompletionItem + + type = "dir" if self.dir_okay and not self.file_okay else "file" + return [CompletionItem(incomplete, type=type)] + + +class TupleInfoDict(ParamTypeInfoDict): + types: cabc.Sequence[ParamTypeInfoDict] + + +class Tuple(CompositeParamType[tuple[t.Any, ...]]): + """The default behavior of Click is to apply a type on a value directly. + This works well in most cases, except for when `nargs` is set to a fixed + count and different types should be used for different items. In this + case the :class:`Tuple` type can be used. This type can only be used + if `nargs` is set to a fixed number. + + For more information see :ref:`tuple-type`. + + This can be selected by using a Python tuple literal as a type. + + :param types: a list of types that should be used for the tuple items. + """ + + def __init__(self, types: cabc.Sequence[type[t.Any] | ParamType[t.Any]]) -> None: + self.types: cabc.Sequence[ParamType[t.Any]] = [convert_type(ty) for ty in types] + + def to_info_dict(self) -> TupleInfoDict: + return { + "types": [ty.to_info_dict() for ty in self.types], + **super().to_info_dict(), + } + + @property + def name(self) -> str: # type: ignore[override] + return f"<{' '.join(ty.name for ty in self.types)}>" + + @property + def arity(self) -> int: # type: ignore[override] + return len(self.types) + + def convert( + self, value: t.Any, param: Parameter | None, ctx: Context | None + ) -> tuple[t.Any, ...]: + len_type = len(self.types) + len_value = len(value) + + if len_value != len_type: + self.fail( + ngettext( + "{len_type} values are required, but {len_value} was given.", + "{len_type} values are required, but {len_value} were given.", + len_value, + ).format(len_type=len_type, len_value=len_value), + param=param, + ctx=ctx, + ) + + return tuple( + ty(x, param, ctx) for ty, x in zip(self.types, value, strict=False) + ) + + +def _guess_type( + ty: type[t.Any] | ParamType[t.Any] | None, + default: t.Any | None, +) -> type[t.Any] | tuple[type[t.Any], ...] | ParamType[t.Any] | None: + """Infer a type from *ty* or *default*. + + Returns *ty* unchanged when it is not ``None``. Otherwise inspects + *default* to produce a ``type``, a ``tuple`` of types (for tuple + defaults), or ``None``. + """ + if ty is not None: + return ty + + if default is None: + return None + + if not isinstance(default, (tuple, list)): + return type(default) + + # If the default is empty, return None so convert_type falls + # through to STRING. + if not default: + return None + + item = default[0] + + # A sequence of iterables needs to detect the inner types. + # Can't call convert_type recursively because that would + # incorrectly unwind the tuple to a single type. + if isinstance(item, (tuple, list)): + return tuple(map(type, item)) + + return type(item) + + +@t.overload +def convert_type(ty: None, default: None = None) -> StringParamType: ... + + +@t.overload +def convert_type( + ty: type[t.Any] | ParamType[t.Any], default: t.Any | None = None +) -> ParamType[t.Any]: ... + + +@t.overload +def convert_type( + ty: t.Any | None, default: t.Any | None = None +) -> ParamType[t.Any]: ... + + +def convert_type( + ty: t.Any | None = None, default: t.Any | None = None +) -> ParamType[t.Any]: + """Find the most appropriate :class:`ParamType` for the given Python + type. If the type isn't provided, it can be inferred from a default + value. + """ + guessed = _guess_type(ty, default) + is_guessed = guessed is not ty + + if isinstance(guessed, tuple): + return Tuple(guessed) + + if isinstance(guessed, ParamType): + return guessed + + if guessed is str or guessed is None: + return STRING + + if guessed is int: + return INT + + if guessed is float: + return FLOAT + + if guessed is bool: + return BOOL + + if is_guessed: + return STRING + + if __debug__: + try: + if issubclass(guessed, ParamType): + raise AssertionError( + f"Attempted to use an uninstantiated parameter type ({guessed})." + ) + except TypeError: + # guessed is an instance (correct), so issubclass fails. + pass + + return FuncParamType(guessed) + + +#: A dummy parameter type that just does nothing. From a user's +#: perspective this appears to just be the same as `STRING` but +#: internally no string conversion takes place if the input was bytes. +#: This is usually useful when working with file paths as they can +#: appear in bytes and unicode. +#: +#: For path related uses the :class:`Path` type is a better choice but +#: there are situations where an unprocessed type is useful which is why +#: it is provided. +#: +#: .. versionadded:: 4.0 +UNPROCESSED = UnprocessedParamType() + +#: A unicode string parameter type which is the implicit default. This +#: can also be selected by using ``str`` as type. +STRING = StringParamType() + +#: An integer parameter. This can also be selected by using ``int`` as +#: type. +INT = IntParamType() + +#: A floating point value parameter. This can also be selected by using +#: ``float`` as type. +FLOAT = FloatParamType() + +#: A boolean parameter. This is the default for boolean flags. This can +#: also be selected by using ``bool`` as a type. +BOOL = BoolParamType() + +#: A UUID parameter. +UUID = UUIDParameterType() + + +class OptionHelpExtra(t.TypedDict, total=False): + envvars: tuple[str, ...] + default: str + range: str + required: str diff --git a/server/venv/Lib/site-packages/click/utils.py b/server/venv/Lib/site-packages/click/utils.py new file mode 100644 index 0000000..67922b8 --- /dev/null +++ b/server/venv/Lib/site-packages/click/utils.py @@ -0,0 +1,641 @@ +from __future__ import annotations + +import collections.abc as cabc +import os +import re +import sys +import typing as t +from functools import update_wrapper +from gettext import gettext as _ +from types import ModuleType +from types import TracebackType + +from ._compat import _default_text_stderr +from ._compat import _default_text_stdout +from ._compat import _find_binary_writer +from ._compat import auto_wrap_for_ansi +from ._compat import binary_streams +from ._compat import open_stream +from ._compat import should_strip_ansi +from ._compat import strip_ansi +from ._compat import text_streams +from ._compat import WIN +from .globals import resolve_color_default + +if t.TYPE_CHECKING: + import typing_extensions as te + + P = te.ParamSpec("P") + +R = t.TypeVar("R") + + +def _posixify(name: str) -> str: + return "-".join(name.split()).lower() + + +def safecall(func: t.Callable[P, R]) -> t.Callable[P, R | None]: + """Wraps a function so that it swallows exceptions.""" + + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: + try: + return func(*args, **kwargs) + except Exception: + pass + return None + + return update_wrapper(wrapper, func) + + +def make_str(value: t.Any) -> str: + """Converts a value into a valid string.""" + if isinstance(value, bytes): + try: + return value.decode(sys.getfilesystemencoding()) + except UnicodeError: + return value.decode("utf-8", "replace") + return str(value) + + +def make_default_short_help(help: str, max_length: int = 45) -> str: + """Returns a condensed version of help string. + + :meta private: + """ + # Consider only the first paragraph. + paragraph_end = help.find("\n\n") + + if paragraph_end != -1: + help = help[:paragraph_end] + + # Collapse newlines, tabs, and spaces. + words = help.split() + + if not words: + return "" + + # The first paragraph started with a "no rewrap" marker, ignore it. + if words[0] == "\b": + words = words[1:] + + total_length = 0 + last_index = len(words) - 1 + + for i, word in enumerate(words): + total_length += len(word) + (i > 0) + + if total_length > max_length: # too long, truncate + break + + if word[-1] == ".": # sentence end, truncate without "..." + return " ".join(words[: i + 1]) + + if total_length == max_length and i != last_index: + break # not at sentence end, truncate with "..." + else: + return " ".join(words) # no truncation needed + + # Account for the length of the suffix. + total_length += len("...") + + # remove words until the length is short enough + while i > 0: + total_length -= len(words[i]) + (i > 0) + + if total_length <= max_length: + break + + i -= 1 + + return " ".join(words[:i]) + "..." + + +class LazyFile: + """A lazy file works like a regular file but it does not fully open + the file but it does perform some basic checks early to see if the + filename parameter does make sense. This is useful for safely opening + files for writing. + """ + + name: str + mode: str + encoding: str | None + errors: str | None + atomic: bool + _f: t.IO[t.Any] | None + should_close: bool + + def __init__( + self, + filename: str | os.PathLike[str], + mode: str = "r", + encoding: str | None = None, + errors: str | None = "strict", + atomic: bool = False, + ) -> None: + self.name = os.fspath(filename) + self.mode = mode + self.encoding = encoding + self.errors = errors + self.atomic = atomic + + if self.name == "-": + self._f, self.should_close = open_stream(filename, mode, encoding, errors) + else: + if "r" in mode: + # Open and close the file in case we're opening it for + # reading so that we can catch at least some errors in + # some cases early. + open(filename, mode).close() + self._f = None + self.should_close = True + + def __getattr__(self, name: str) -> t.Any: + return getattr(self.open(), name) + + def __repr__(self) -> str: + if self._f is not None: + return repr(self._f) + return f"" + + def open(self) -> t.IO[t.Any]: + """Opens the file if it's not yet open. This call might fail with + a :exc:`FileError`. Not handling this error will produce an error + that Click shows. + """ + if self._f is not None: + return self._f + try: + rv, self.should_close = open_stream( + self.name, self.mode, self.encoding, self.errors, atomic=self.atomic + ) + except OSError as e: + from .exceptions import FileError + + raise FileError(self.name, hint=e.strerror) from e + self._f = rv + return rv + + def close(self) -> None: + """Closes the underlying file, no matter what.""" + if self._f is not None: + self._f.close() + + def close_intelligently(self) -> None: + """This function only closes the file if it was opened by the lazy + file wrapper. For instance this will never close stdin. + """ + if self.should_close: + self.close() + + def __enter__(self) -> LazyFile: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + self.close_intelligently() + + def __iter__(self) -> cabc.Iterator[t.AnyStr]: + self.open() + return iter(self._f) # type: ignore + + +class KeepOpenFile: + _file: t.IO[t.Any] + + def __init__(self, file: t.IO[t.Any]) -> None: + self._file = file + + def __getattr__(self, name: str) -> t.Any: + return getattr(self._file, name) + + def __enter__(self) -> KeepOpenFile: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + tb: TracebackType | None, + ) -> None: + pass + + def __repr__(self) -> str: + return repr(self._file) + + def __iter__(self) -> cabc.Iterator[t.AnyStr]: + return iter(self._file) + + +def echo( + message: object = None, + file: t.IO[t.Any] | None = None, + nl: bool = True, + err: bool = False, + color: bool | None = None, +) -> None: + """Print a message and newline to stdout or a file. This should be + used instead of :func:`print` because it provides better support + for different data, files, and environments. + + Compared to :func:`print`, this does the following: + + - Ensures that the output encoding is not misconfigured on Linux. + - Supports Unicode in the Windows console. + - Supports writing to binary outputs, and supports writing bytes + to text outputs. + - Supports colors and styles on Windows. + - Removes ANSI color and style codes if the output does not look + like an interactive terminal. + - Always flushes the output. + + :param message: The string or bytes to output. Other objects are + converted to strings. + :param file: The file to write to. Defaults to ``stdout``. + :param err: Write to ``stderr`` instead of ``stdout``. + :param nl: Print a newline after the message. Enabled by default. + :param color: Force showing or hiding colors and other styles. By + default Click will remove color if the output does not look like + an interactive terminal. + + .. versionchanged:: 6.0 + Support Unicode output on the Windows console. Click does not + modify ``sys.stdout``, so ``sys.stdout.write()`` and ``print()`` + will still not support Unicode. + + .. versionchanged:: 4.0 + Added the ``color`` parameter. + + .. versionadded:: 3.0 + Added the ``err`` parameter. + + .. versionchanged:: 2.0 + Support colors on Windows if colorama is installed. + """ + if file is None: + if err: + file = _default_text_stderr() + else: + file = _default_text_stdout() + + # There are no standard streams attached to write to. For example, + # pythonw on Windows. + if file is None: + return + + match message: + case str() | bytes() | bytearray(): + out = message + case None: + out = "" + case _: + out = str(message) + + if nl: + if isinstance(out, str): + out += "\n" + else: + out += b"\n" + + if not out: + file.flush() + return + + # If there is a message and the value looks like bytes, we manually + # need to find the binary stream and write the message in there. + # This is done separately so that most stream types will work as you + # would expect. Eg: you can write to StringIO for other cases. + if isinstance(out, (bytes, bytearray)): + binary_file = _find_binary_writer(file) + if binary_file is not None: + file.flush() + binary_file.write(out) + binary_file.flush() + return + + # ANSI style code support. For no message or bytes, nothing happens. + # When outputting to a file instead of a terminal, strip codes. + else: + color = resolve_color_default(color) + + if should_strip_ansi(file, color): + out = strip_ansi(out) + elif WIN: + if auto_wrap_for_ansi is not None: + file = auto_wrap_for_ansi(file, color) # type: ignore + elif not color: + out = strip_ansi(out) + + file.write(out) # type: ignore + file.flush() + + +def get_binary_stream(name: t.Literal["stdin", "stdout", "stderr"]) -> t.BinaryIO: + """Returns a system stream for byte processing. + + :param name: the name of the stream to open. Valid names are ``'stdin'``, + ``'stdout'`` and ``'stderr'`` + """ + opener = binary_streams.get(name) + if opener is None: + raise TypeError(_("Unknown standard stream '{name}'").format(name=name)) + return opener() + + +def get_text_stream( + name: t.Literal["stdin", "stdout", "stderr"], + encoding: str | None = None, + errors: str | None = "strict", +) -> t.TextIO: + """Returns a system stream for text processing. This usually returns + a wrapped stream around a binary stream returned from + :func:`get_binary_stream` but it also can take shortcuts for already + correctly configured streams. + + :param name: the name of the stream to open. Valid names are ``'stdin'``, + ``'stdout'`` and ``'stderr'`` + :param encoding: overrides the detected default encoding. + :param errors: overrides the default error mode. + """ + opener = text_streams.get(name) + if opener is None: + raise TypeError(_("Unknown standard stream '{name}'").format(name=name)) + return opener(encoding, errors) + + +def open_file( + filename: str | os.PathLike[str], + mode: str = "r", + encoding: str | None = None, + errors: str | None = "strict", + lazy: bool = False, + atomic: bool = False, +) -> t.IO[t.Any]: + """Open a file, with extra behavior to handle ``'-'`` to indicate + a standard stream, lazy open on write, and atomic write. Similar to + the behavior of the :class:`~click.File` param type. + + If ``'-'`` is given to open ``stdout`` or ``stdin``, the stream is + wrapped so that using it in a context manager will not close it. + This makes it possible to use the function without accidentally + closing a standard stream: + + .. code-block:: python + + with open_file(filename) as f: + ... + + :param filename: The name or Path of the file to open, or ``'-'`` for + ``stdin``/``stdout``. + :param mode: The mode in which to open the file. + :param encoding: The encoding to decode or encode a file opened in + text mode. + :param errors: The error handling mode. + :param lazy: Wait to open the file until it is accessed. For read + mode, the file is temporarily opened to raise access errors + early, then closed until it is read again. + :param atomic: Write to a temporary file and replace the given file + on close. + + .. versionadded:: 3.0 + """ + if lazy: + return t.cast( + "t.IO[t.Any]", LazyFile(filename, mode, encoding, errors, atomic=atomic) + ) + + f, should_close = open_stream(filename, mode, encoding, errors, atomic=atomic) + + if not should_close: + f = t.cast("t.IO[t.Any]", KeepOpenFile(f)) + + return f + + +def format_filename( + filename: str | bytes | os.PathLike[str] | os.PathLike[bytes], + shorten: bool = False, +) -> str: + """Format a filename as a string for display. Ensures the filename can be + displayed by replacing any invalid bytes or surrogate escapes in the name + with the replacement character ``�``. + + Invalid bytes or surrogate escapes will raise an error when written to a + stream with ``errors="strict"``. This will typically happen with ``stdout`` + when the locale is something like ``en_GB.UTF-8``. + + Many scenarios *are* safe to write surrogates though, due to PEP 538 and + PEP 540, including: + + - Writing to ``stderr``, which uses ``errors="backslashreplace"``. + - The system has ``LANG=C.UTF-8``, ``C``, or ``POSIX``. Python opens + stdout and stderr with ``errors="surrogateescape"``. + - None of ``LANG/LC_*`` are set. Python assumes ``LANG=C.UTF-8``. + - Python is started in UTF-8 mode with ``PYTHONUTF8=1`` or ``-X utf8``. + Python opens stdout and stderr with ``errors="surrogateescape"``. + + :param filename: formats a filename for UI display. This will also convert + the filename into unicode without failing. + :param shorten: this optionally shortens the filename to strip of the + path that leads up to it. + """ + if shorten: + filename = os.path.basename(filename) + else: + filename = os.fspath(filename) + + if isinstance(filename, bytes): + filename = filename.decode(sys.getfilesystemencoding(), "replace") + else: + filename = filename.encode("utf-8", "surrogateescape").decode( + "utf-8", "replace" + ) + + return filename + + +def get_app_dir(app_name: str, roaming: bool = True, force_posix: bool = False) -> str: + r"""Returns the config folder for the application. The default behavior + is to return whatever is most appropriate for the operating system. + + To give you an idea, for an app called ``"Foo Bar"``, something like + the following folders could be returned: + + Mac OS X: + ``~/Library/Application Support/Foo Bar`` + Mac OS X (POSIX): + ``~/.foo-bar`` + Unix: + ``~/.config/foo-bar`` + Unix (POSIX): + ``~/.foo-bar`` + Windows (roaming): + ``C:\Users\\AppData\Roaming\Foo Bar`` + Windows (not roaming): + ``C:\Users\\AppData\Local\Foo Bar`` + + .. versionadded:: 2.0 + + :param app_name: the application name. This should be properly capitalized + and can contain whitespace. + :param roaming: controls if the folder should be roaming or not on Windows. + Has no effect otherwise. + :param force_posix: if this is set to `True` then on any POSIX system the + folder will be stored in the home folder with a leading + dot instead of the XDG config home or darwin's + application support folder. + """ + if WIN: + key = "APPDATA" if roaming else "LOCALAPPDATA" + folder = os.environ.get(key) + if folder is None: + folder = os.path.expanduser("~") + return os.path.join(folder, app_name) + if force_posix: + return os.path.join(os.path.expanduser(f"~/.{_posixify(app_name)}")) + if sys.platform == "darwin": + return os.path.join( + os.path.expanduser("~/Library/Application Support"), app_name + ) + return os.path.join( + os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), + _posixify(app_name), + ) + + +class PacifyFlushWrapper: + """This wrapper is used to catch and suppress BrokenPipeErrors resulting + from ``.flush()`` being called on broken pipe during the shutdown/final-GC + of the Python interpreter. Notably ``.flush()`` is always called on + ``sys.stdout`` and ``sys.stderr``. So as to have minimal impact on any + other cleanup code, and the case where the underlying file is not a broken + pipe, all calls and attributes are proxied. + """ + + wrapped: t.IO[t.Any] + + def __init__(self, wrapped: t.IO[t.Any]) -> None: + self.wrapped = wrapped + + def flush(self) -> None: + try: + self.wrapped.flush() + except OSError as e: + import errno + + if e.errno != errno.EPIPE: + raise + + def __getattr__(self, attr: str) -> t.Any: + return getattr(self.wrapped, attr) + + +def _detect_program_name( + path: str | None = None, _main: ModuleType | None = None +) -> str: + """Determine the command used to run the program, for use in help + text. If a file or entry point was executed, the file name is + returned. If ``python -m`` was used to execute a module or package, + ``python -m name`` is returned. + + This doesn't try to be too precise, the goal is to give a concise + name for help text. Files are only shown as their name without the + path. ``python`` is only shown for modules, and the full path to + ``sys.executable`` is not shown. + + :param path: The Python file being executed. Python puts this in + ``sys.argv[0]``, which is used by default. + :param _main: The ``__main__`` module. This should only be passed + during internal testing. + + .. versionadded:: 8.0 + Based on command args detection in the Werkzeug reloader. + + :meta private: + """ + if _main is None: + _main = sys.modules["__main__"] + + if not path: + path = sys.argv[0] + + # The value of __package__ indicates how Python was called. It may + # not exist if a setuptools script is installed as an egg. It may be + # set incorrectly for entry points created with pip on Windows. + # It is set to "" inside a Shiv or PEX zipapp. + if getattr(_main, "__package__", None) in {None, ""} or ( + os.name == "nt" + and _main.__package__ == "" + and not os.path.exists(path) + and os.path.exists(f"{path}.exe") + ): + # Executed a file, like "python app.py". + return os.path.basename(path) + + # Executed a module, like "python -m example". + # Rewritten by Python from "-m script" to "/path/to/script.py". + # Need to look at main module to determine how it was executed. + py_module = t.cast(str, _main.__package__) + name = os.path.splitext(os.path.basename(path))[0] + + # A submodule like "example.cli". + if name != "__main__": + py_module = f"{py_module}.{name}" + + return f"python -m {py_module.lstrip('.')}" + + +def _expand_args( + args: cabc.Iterable[str], + *, + user: bool = True, + env: bool = True, + glob_recursive: bool = True, +) -> list[str]: + """Simulate Unix shell expansion with Python functions. + + See :func:`glob.glob`, :func:`os.path.expanduser`, and + :func:`os.path.expandvars`. + + This is intended for use on Windows, where the shell does not do any + expansion. It may not exactly match what a Unix shell would do. + + :param args: List of command line arguments to expand. + :param user: Expand user home directory. + :param env: Expand environment variables. + :param glob_recursive: ``**`` matches directories recursively. + + .. versionchanged:: 8.1 + Invalid glob patterns are treated as empty expansions rather + than raising an error. + + .. versionadded:: 8.0 + + :meta private: + """ + from glob import glob + + out = [] + + for arg in args: + if user: + arg = os.path.expanduser(arg) + + if env: + arg = os.path.expandvars(arg) + + try: + matches = glob(arg, recursive=glob_recursive) + except re.error: + matches = [] + + if not matches: + out.append(arg) + else: + out.extend(matches) + + return out diff --git a/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/INSTALLER b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/METADATA b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/METADATA new file mode 100644 index 0000000..a1b5c57 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/METADATA @@ -0,0 +1,441 @@ +Metadata-Version: 2.1 +Name: colorama +Version: 0.4.6 +Summary: Cross-platform colored terminal text. +Project-URL: Homepage, https://github.com/tartley/colorama +Author-email: Jonathan Hartley +License-File: LICENSE.txt +Keywords: ansi,color,colour,crossplatform,terminal,text,windows,xplatform +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Console +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 2 +Classifier: Programming Language :: Python :: 2.7 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Terminals +Requires-Python: !=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7 +Description-Content-Type: text/x-rst + +.. image:: https://img.shields.io/pypi/v/colorama.svg + :target: https://pypi.org/project/colorama/ + :alt: Latest Version + +.. image:: https://img.shields.io/pypi/pyversions/colorama.svg + :target: https://pypi.org/project/colorama/ + :alt: Supported Python versions + +.. image:: https://github.com/tartley/colorama/actions/workflows/test.yml/badge.svg + :target: https://github.com/tartley/colorama/actions/workflows/test.yml + :alt: Build Status + +Colorama +======== + +Makes ANSI escape character sequences (for producing colored terminal text and +cursor positioning) work under MS Windows. + +.. |donate| image:: https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif + :target: https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=2MZ9D2GMLYCUJ&item_name=Colorama¤cy_code=USD + :alt: Donate with Paypal + +`PyPI for releases `_ | +`Github for source `_ | +`Colorama for enterprise on Tidelift `_ + +If you find Colorama useful, please |donate| to the authors. Thank you! + +Installation +------------ + +Tested on CPython 2.7, 3.7, 3.8, 3.9 and 3.10 and Pypy 2.7 and 3.8. + +No requirements other than the standard library. + +.. code-block:: bash + + pip install colorama + # or + conda install -c anaconda colorama + +Description +----------- + +ANSI escape character sequences have long been used to produce colored terminal +text and cursor positioning on Unix and Macs. Colorama makes this work on +Windows, too, by wrapping ``stdout``, stripping ANSI sequences it finds (which +would appear as gobbledygook in the output), and converting them into the +appropriate win32 calls to modify the state of the terminal. On other platforms, +Colorama does nothing. + +This has the upshot of providing a simple cross-platform API for printing +colored terminal text from Python, and has the happy side-effect that existing +applications or libraries which use ANSI sequences to produce colored output on +Linux or Macs can now also work on Windows, simply by calling +``colorama.just_fix_windows_console()`` (since v0.4.6) or ``colorama.init()`` +(all versions, but may have other side-effects – see below). + +An alternative approach is to install ``ansi.sys`` on Windows machines, which +provides the same behaviour for all applications running in terminals. Colorama +is intended for situations where that isn't easy (e.g., maybe your app doesn't +have an installer.) + +Demo scripts in the source code repository print some colored text using +ANSI sequences. Compare their output under Gnome-terminal's built in ANSI +handling, versus on Windows Command-Prompt using Colorama: + +.. image:: https://github.com/tartley/colorama/raw/master/screenshots/ubuntu-demo.png + :width: 661 + :height: 357 + :alt: ANSI sequences on Ubuntu under gnome-terminal. + +.. image:: https://github.com/tartley/colorama/raw/master/screenshots/windows-demo.png + :width: 668 + :height: 325 + :alt: Same ANSI sequences on Windows, using Colorama. + +These screenshots show that, on Windows, Colorama does not support ANSI 'dim +text'; it looks the same as 'normal text'. + +Usage +----- + +Initialisation +.............. + +If the only thing you want from Colorama is to get ANSI escapes to work on +Windows, then run: + +.. code-block:: python + + from colorama import just_fix_windows_console + just_fix_windows_console() + +If you're on a recent version of Windows 10 or better, and your stdout/stderr +are pointing to a Windows console, then this will flip the magic configuration +switch to enable Windows' built-in ANSI support. + +If you're on an older version of Windows, and your stdout/stderr are pointing to +a Windows console, then this will wrap ``sys.stdout`` and/or ``sys.stderr`` in a +magic file object that intercepts ANSI escape sequences and issues the +appropriate Win32 calls to emulate them. + +In all other circumstances, it does nothing whatsoever. Basically the idea is +that this makes Windows act like Unix with respect to ANSI escape handling. + +It's safe to call this function multiple times. It's safe to call this function +on non-Windows platforms, but it won't do anything. It's safe to call this +function when one or both of your stdout/stderr are redirected to a file – it +won't do anything to those streams. + +Alternatively, you can use the older interface with more features (but also more +potential footguns): + +.. code-block:: python + + from colorama import init + init() + +This does the same thing as ``just_fix_windows_console``, except for the +following differences: + +- It's not safe to call ``init`` multiple times; you can end up with multiple + layers of wrapping and broken ANSI support. + +- Colorama will apply a heuristic to guess whether stdout/stderr support ANSI, + and if it thinks they don't, then it will wrap ``sys.stdout`` and + ``sys.stderr`` in a magic file object that strips out ANSI escape sequences + before printing them. This happens on all platforms, and can be convenient if + you want to write your code to emit ANSI escape sequences unconditionally, and + let Colorama decide whether they should actually be output. But note that + Colorama's heuristic is not particularly clever. + +- ``init`` also accepts explicit keyword args to enable/disable various + functionality – see below. + +To stop using Colorama before your program exits, simply call ``deinit()``. +This will restore ``stdout`` and ``stderr`` to their original values, so that +Colorama is disabled. To resume using Colorama again, call ``reinit()``; it is +cheaper than calling ``init()`` again (but does the same thing). + +Most users should depend on ``colorama >= 0.4.6``, and use +``just_fix_windows_console``. The old ``init`` interface will be supported +indefinitely for backwards compatibility, but we don't plan to fix any issues +with it, also for backwards compatibility. + +Colored Output +.............. + +Cross-platform printing of colored text can then be done using Colorama's +constant shorthand for ANSI escape sequences. These are deliberately +rudimentary, see below. + +.. code-block:: python + + from colorama import Fore, Back, Style + print(Fore.RED + 'some red text') + print(Back.GREEN + 'and with a green background') + print(Style.DIM + 'and in dim text') + print(Style.RESET_ALL) + print('back to normal now') + +...or simply by manually printing ANSI sequences from your own code: + +.. code-block:: python + + print('\033[31m' + 'some red text') + print('\033[39m') # and reset to default color + +...or, Colorama can be used in conjunction with existing ANSI libraries +such as the venerable `Termcolor `_ +the fabulous `Blessings `_, +or the incredible `_Rich `_. + +If you wish Colorama's Fore, Back and Style constants were more capable, +then consider using one of the above highly capable libraries to generate +colors, etc, and use Colorama just for its primary purpose: to convert +those ANSI sequences to also work on Windows: + +SIMILARLY, do not send PRs adding the generation of new ANSI types to Colorama. +We are only interested in converting ANSI codes to win32 API calls, not +shortcuts like the above to generate ANSI characters. + +.. code-block:: python + + from colorama import just_fix_windows_console + from termcolor import colored + + # use Colorama to make Termcolor work on Windows too + just_fix_windows_console() + + # then use Termcolor for all colored text output + print(colored('Hello, World!', 'green', 'on_red')) + +Available formatting constants are:: + + Fore: BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, RESET. + Back: BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE, RESET. + Style: DIM, NORMAL, BRIGHT, RESET_ALL + +``Style.RESET_ALL`` resets foreground, background, and brightness. Colorama will +perform this reset automatically on program exit. + +These are fairly well supported, but not part of the standard:: + + Fore: LIGHTBLACK_EX, LIGHTRED_EX, LIGHTGREEN_EX, LIGHTYELLOW_EX, LIGHTBLUE_EX, LIGHTMAGENTA_EX, LIGHTCYAN_EX, LIGHTWHITE_EX + Back: LIGHTBLACK_EX, LIGHTRED_EX, LIGHTGREEN_EX, LIGHTYELLOW_EX, LIGHTBLUE_EX, LIGHTMAGENTA_EX, LIGHTCYAN_EX, LIGHTWHITE_EX + +Cursor Positioning +.................. + +ANSI codes to reposition the cursor are supported. See ``demos/demo06.py`` for +an example of how to generate them. + +Init Keyword Args +................. + +``init()`` accepts some ``**kwargs`` to override default behaviour. + +init(autoreset=False): + If you find yourself repeatedly sending reset sequences to turn off color + changes at the end of every print, then ``init(autoreset=True)`` will + automate that: + + .. code-block:: python + + from colorama import init + init(autoreset=True) + print(Fore.RED + 'some red text') + print('automatically back to default color again') + +init(strip=None): + Pass ``True`` or ``False`` to override whether ANSI codes should be + stripped from the output. The default behaviour is to strip if on Windows + or if output is redirected (not a tty). + +init(convert=None): + Pass ``True`` or ``False`` to override whether to convert ANSI codes in the + output into win32 calls. The default behaviour is to convert if on Windows + and output is to a tty (terminal). + +init(wrap=True): + On Windows, Colorama works by replacing ``sys.stdout`` and ``sys.stderr`` + with proxy objects, which override the ``.write()`` method to do their work. + If this wrapping causes you problems, then this can be disabled by passing + ``init(wrap=False)``. The default behaviour is to wrap if ``autoreset`` or + ``strip`` or ``convert`` are True. + + When wrapping is disabled, colored printing on non-Windows platforms will + continue to work as normal. To do cross-platform colored output, you can + use Colorama's ``AnsiToWin32`` proxy directly: + + .. code-block:: python + + import sys + from colorama import init, AnsiToWin32 + init(wrap=False) + stream = AnsiToWin32(sys.stderr).stream + + # Python 2 + print >>stream, Fore.BLUE + 'blue text on stderr' + + # Python 3 + print(Fore.BLUE + 'blue text on stderr', file=stream) + +Recognised ANSI Sequences +......................... + +ANSI sequences generally take the form:: + + ESC [ ; ... + +Where ```` is an integer, and ```` is a single letter. Zero or +more params are passed to a ````. If no params are passed, it is +generally synonymous with passing a single zero. No spaces exist in the +sequence; they have been inserted here simply to read more easily. + +The only ANSI sequences that Colorama converts into win32 calls are:: + + ESC [ 0 m # reset all (colors and brightness) + ESC [ 1 m # bright + ESC [ 2 m # dim (looks same as normal brightness) + ESC [ 22 m # normal brightness + + # FOREGROUND: + ESC [ 30 m # black + ESC [ 31 m # red + ESC [ 32 m # green + ESC [ 33 m # yellow + ESC [ 34 m # blue + ESC [ 35 m # magenta + ESC [ 36 m # cyan + ESC [ 37 m # white + ESC [ 39 m # reset + + # BACKGROUND + ESC [ 40 m # black + ESC [ 41 m # red + ESC [ 42 m # green + ESC [ 43 m # yellow + ESC [ 44 m # blue + ESC [ 45 m # magenta + ESC [ 46 m # cyan + ESC [ 47 m # white + ESC [ 49 m # reset + + # cursor positioning + ESC [ y;x H # position cursor at x across, y down + ESC [ y;x f # position cursor at x across, y down + ESC [ n A # move cursor n lines up + ESC [ n B # move cursor n lines down + ESC [ n C # move cursor n characters forward + ESC [ n D # move cursor n characters backward + + # clear the screen + ESC [ mode J # clear the screen + + # clear the line + ESC [ mode K # clear the line + +Multiple numeric params to the ``'m'`` command can be combined into a single +sequence:: + + ESC [ 36 ; 45 ; 1 m # bright cyan text on magenta background + +All other ANSI sequences of the form ``ESC [ ; ... `` +are silently stripped from the output on Windows. + +Any other form of ANSI sequence, such as single-character codes or alternative +initial characters, are not recognised or stripped. It would be cool to add +them though. Let me know if it would be useful for you, via the Issues on +GitHub. + +Status & Known Problems +----------------------- + +I've personally only tested it on Windows XP (CMD, Console2), Ubuntu +(gnome-terminal, xterm), and OS X. + +Some valid ANSI sequences aren't recognised. + +If you're hacking on the code, see `README-hacking.md`_. ESPECIALLY, see the +explanation there of why we do not want PRs that allow Colorama to generate new +types of ANSI codes. + +See outstanding issues and wish-list: +https://github.com/tartley/colorama/issues + +If anything doesn't work for you, or doesn't do what you expected or hoped for, +I'd love to hear about it on that issues list, would be delighted by patches, +and would be happy to grant commit access to anyone who submits a working patch +or two. + +.. _README-hacking.md: README-hacking.md + +License +------- + +Copyright Jonathan Hartley & Arnon Yaari, 2013-2020. BSD 3-Clause license; see +LICENSE file. + +Professional support +-------------------- + +.. |tideliftlogo| image:: https://cdn2.hubspot.net/hubfs/4008838/website/logos/logos_for_download/Tidelift_primary-shorthand-logo.png + :alt: Tidelift + :target: https://tidelift.com/subscription/pkg/pypi-colorama?utm_source=pypi-colorama&utm_medium=referral&utm_campaign=readme + +.. list-table:: + :widths: 10 100 + + * - |tideliftlogo| + - Professional support for colorama is available as part of the + `Tidelift Subscription`_. + Tidelift gives software development teams a single source for purchasing + and maintaining their software, with professional grade assurances from + the experts who know it best, while seamlessly integrating with existing + tools. + +.. _Tidelift Subscription: https://tidelift.com/subscription/pkg/pypi-colorama?utm_source=pypi-colorama&utm_medium=referral&utm_campaign=readme + +Thanks +------ + +See the CHANGELOG for more thanks! + +* Marc Schlaich (schlamar) for a ``setup.py`` fix for Python2.5. +* Marc Abramowitz, reported & fixed a crash on exit with closed ``stdout``, + providing a solution to issue #7's setuptools/distutils debate, + and other fixes. +* User 'eryksun', for guidance on correctly instantiating ``ctypes.windll``. +* Matthew McCormick for politely pointing out a longstanding crash on non-Win. +* Ben Hoyt, for a magnificent fix under 64-bit Windows. +* Jesse at Empty Square for submitting a fix for examples in the README. +* User 'jamessp', an observant documentation fix for cursor positioning. +* User 'vaal1239', Dave Mckee & Lackner Kristof for a tiny but much-needed Win7 + fix. +* Julien Stuyck, for wisely suggesting Python3 compatible updates to README. +* Daniel Griffith for multiple fabulous patches. +* Oscar Lesta for a valuable fix to stop ANSI chars being sent to non-tty + output. +* Roger Binns, for many suggestions, valuable feedback, & bug reports. +* Tim Golden for thought and much appreciated feedback on the initial idea. +* User 'Zearin' for updates to the README file. +* John Szakmeister for adding support for light colors +* Charles Merriam for adding documentation to demos +* Jurko for a fix on 64-bit Windows CPython2.5 w/o ctypes +* Florian Bruhin for a fix when stdout or stderr are None +* Thomas Weininger for fixing ValueError on Windows +* Remi Rampin for better Github integration and fixes to the README file +* Simeon Visser for closing a file handle using 'with' and updating classifiers + to include Python 3.3 and 3.4 +* Andy Neff for fixing RESET of LIGHT_EX colors. +* Jonathan Hartley for the initial idea and implementation. diff --git a/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/RECORD b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/RECORD new file mode 100644 index 0000000..b85b5b7 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/RECORD @@ -0,0 +1,31 @@ +colorama-0.4.6.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +colorama-0.4.6.dist-info/METADATA,sha256=e67SnrUMOym9sz_4TjF3vxvAV4T3aF7NyqRHHH3YEMw,17158 +colorama-0.4.6.dist-info/RECORD,, +colorama-0.4.6.dist-info/WHEEL,sha256=cdcF4Fbd0FPtw2EMIOwH-3rSOTUdTCeOSXRMD1iLUb8,105 +colorama-0.4.6.dist-info/licenses/LICENSE.txt,sha256=ysNcAmhuXQSlpxQL-zs25zrtSWZW6JEQLkKIhteTAxg,1491 +colorama/__init__.py,sha256=wePQA4U20tKgYARySLEC047ucNX-g8pRLpYBuiHlLb8,266 +colorama/__pycache__/__init__.cpython-313.pyc,, +colorama/__pycache__/ansi.cpython-313.pyc,, +colorama/__pycache__/ansitowin32.cpython-313.pyc,, +colorama/__pycache__/initialise.cpython-313.pyc,, +colorama/__pycache__/win32.cpython-313.pyc,, +colorama/__pycache__/winterm.cpython-313.pyc,, +colorama/ansi.py,sha256=Top4EeEuaQdBWdteKMEcGOTeKeF19Q-Wo_6_Cj5kOzQ,2522 +colorama/ansitowin32.py,sha256=vPNYa3OZbxjbuFyaVo0Tmhmy1FZ1lKMWCnT7odXpItk,11128 +colorama/initialise.py,sha256=-hIny86ClXo39ixh5iSCfUIa2f_h_bgKRDW7gqs-KLU,3325 +colorama/tests/__init__.py,sha256=MkgPAEzGQd-Rq0w0PZXSX2LadRWhUECcisJY8lSrm4Q,75 +colorama/tests/__pycache__/__init__.cpython-313.pyc,, +colorama/tests/__pycache__/ansi_test.cpython-313.pyc,, +colorama/tests/__pycache__/ansitowin32_test.cpython-313.pyc,, +colorama/tests/__pycache__/initialise_test.cpython-313.pyc,, +colorama/tests/__pycache__/isatty_test.cpython-313.pyc,, +colorama/tests/__pycache__/utils.cpython-313.pyc,, +colorama/tests/__pycache__/winterm_test.cpython-313.pyc,, +colorama/tests/ansi_test.py,sha256=FeViDrUINIZcr505PAxvU4AjXz1asEiALs9GXMhwRaE,2839 +colorama/tests/ansitowin32_test.py,sha256=RN7AIhMJ5EqDsYaCjVo-o4u8JzDD4ukJbmevWKS70rY,10678 +colorama/tests/initialise_test.py,sha256=BbPy-XfyHwJ6zKozuQOvNvQZzsx9vdb_0bYXn7hsBTc,6741 +colorama/tests/isatty_test.py,sha256=Pg26LRpv0yQDB5Ac-sxgVXG7hsA1NYvapFgApZfYzZg,1866 +colorama/tests/utils.py,sha256=1IIRylG39z5-dzq09R_ngufxyPZxgldNbrxKxUGwGKE,1079 +colorama/tests/winterm_test.py,sha256=qoWFPEjym5gm2RuMwpf3pOis3a5r_PJZFCzK254JL8A,3709 +colorama/win32.py,sha256=YQOKwMTwtGBbsY4dL5HYTvwTeP9wIQra5MvPNddpxZs,6181 +colorama/winterm.py,sha256=XCQFDHjPi6AHYNdZwy0tA02H-Jh48Jp-HvCjeLeLp3U,7134 diff --git a/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/WHEEL b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/WHEEL new file mode 100644 index 0000000..d79189f --- /dev/null +++ b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.11.1 +Root-Is-Purelib: true +Tag: py2-none-any +Tag: py3-none-any diff --git a/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/licenses/LICENSE.txt b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/licenses/LICENSE.txt new file mode 100644 index 0000000..3105888 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama-0.4.6.dist-info/licenses/LICENSE.txt @@ -0,0 +1,27 @@ +Copyright (c) 2010 Jonathan Hartley +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holders, nor those of its contributors + may be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/server/venv/Lib/site-packages/colorama/__init__.py b/server/venv/Lib/site-packages/colorama/__init__.py new file mode 100644 index 0000000..383101c --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/__init__.py @@ -0,0 +1,7 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +from .initialise import init, deinit, reinit, colorama_text, just_fix_windows_console +from .ansi import Fore, Back, Style, Cursor +from .ansitowin32 import AnsiToWin32 + +__version__ = '0.4.6' + diff --git a/server/venv/Lib/site-packages/colorama/ansi.py b/server/venv/Lib/site-packages/colorama/ansi.py new file mode 100644 index 0000000..11ec695 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/ansi.py @@ -0,0 +1,102 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +''' +This module generates ANSI character codes to printing colors to terminals. +See: http://en.wikipedia.org/wiki/ANSI_escape_code +''' + +CSI = '\033[' +OSC = '\033]' +BEL = '\a' + + +def code_to_chars(code): + return CSI + str(code) + 'm' + +def set_title(title): + return OSC + '2;' + title + BEL + +def clear_screen(mode=2): + return CSI + str(mode) + 'J' + +def clear_line(mode=2): + return CSI + str(mode) + 'K' + + +class AnsiCodes(object): + def __init__(self): + # the subclasses declare class attributes which are numbers. + # Upon instantiation we define instance attributes, which are the same + # as the class attributes but wrapped with the ANSI escape sequence + for name in dir(self): + if not name.startswith('_'): + value = getattr(self, name) + setattr(self, name, code_to_chars(value)) + + +class AnsiCursor(object): + def UP(self, n=1): + return CSI + str(n) + 'A' + def DOWN(self, n=1): + return CSI + str(n) + 'B' + def FORWARD(self, n=1): + return CSI + str(n) + 'C' + def BACK(self, n=1): + return CSI + str(n) + 'D' + def POS(self, x=1, y=1): + return CSI + str(y) + ';' + str(x) + 'H' + + +class AnsiFore(AnsiCodes): + BLACK = 30 + RED = 31 + GREEN = 32 + YELLOW = 33 + BLUE = 34 + MAGENTA = 35 + CYAN = 36 + WHITE = 37 + RESET = 39 + + # These are fairly well supported, but not part of the standard. + LIGHTBLACK_EX = 90 + LIGHTRED_EX = 91 + LIGHTGREEN_EX = 92 + LIGHTYELLOW_EX = 93 + LIGHTBLUE_EX = 94 + LIGHTMAGENTA_EX = 95 + LIGHTCYAN_EX = 96 + LIGHTWHITE_EX = 97 + + +class AnsiBack(AnsiCodes): + BLACK = 40 + RED = 41 + GREEN = 42 + YELLOW = 43 + BLUE = 44 + MAGENTA = 45 + CYAN = 46 + WHITE = 47 + RESET = 49 + + # These are fairly well supported, but not part of the standard. + LIGHTBLACK_EX = 100 + LIGHTRED_EX = 101 + LIGHTGREEN_EX = 102 + LIGHTYELLOW_EX = 103 + LIGHTBLUE_EX = 104 + LIGHTMAGENTA_EX = 105 + LIGHTCYAN_EX = 106 + LIGHTWHITE_EX = 107 + + +class AnsiStyle(AnsiCodes): + BRIGHT = 1 + DIM = 2 + NORMAL = 22 + RESET_ALL = 0 + +Fore = AnsiFore() +Back = AnsiBack() +Style = AnsiStyle() +Cursor = AnsiCursor() diff --git a/server/venv/Lib/site-packages/colorama/ansitowin32.py b/server/venv/Lib/site-packages/colorama/ansitowin32.py new file mode 100644 index 0000000..abf209e --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/ansitowin32.py @@ -0,0 +1,277 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +import re +import sys +import os + +from .ansi import AnsiFore, AnsiBack, AnsiStyle, Style, BEL +from .winterm import enable_vt_processing, WinTerm, WinColor, WinStyle +from .win32 import windll, winapi_test + + +winterm = None +if windll is not None: + winterm = WinTerm() + + +class StreamWrapper(object): + ''' + Wraps a stream (such as stdout), acting as a transparent proxy for all + attribute access apart from method 'write()', which is delegated to our + Converter instance. + ''' + def __init__(self, wrapped, converter): + # double-underscore everything to prevent clashes with names of + # attributes on the wrapped stream object. + self.__wrapped = wrapped + self.__convertor = converter + + def __getattr__(self, name): + return getattr(self.__wrapped, name) + + def __enter__(self, *args, **kwargs): + # special method lookup bypasses __getattr__/__getattribute__, see + # https://stackoverflow.com/questions/12632894/why-doesnt-getattr-work-with-exit + # thus, contextlib magic methods are not proxied via __getattr__ + return self.__wrapped.__enter__(*args, **kwargs) + + def __exit__(self, *args, **kwargs): + return self.__wrapped.__exit__(*args, **kwargs) + + def __setstate__(self, state): + self.__dict__ = state + + def __getstate__(self): + return self.__dict__ + + def write(self, text): + self.__convertor.write(text) + + def isatty(self): + stream = self.__wrapped + if 'PYCHARM_HOSTED' in os.environ: + if stream is not None and (stream is sys.__stdout__ or stream is sys.__stderr__): + return True + try: + stream_isatty = stream.isatty + except AttributeError: + return False + else: + return stream_isatty() + + @property + def closed(self): + stream = self.__wrapped + try: + return stream.closed + # AttributeError in the case that the stream doesn't support being closed + # ValueError for the case that the stream has already been detached when atexit runs + except (AttributeError, ValueError): + return True + + +class AnsiToWin32(object): + ''' + Implements a 'write()' method which, on Windows, will strip ANSI character + sequences from the text, and if outputting to a tty, will convert them into + win32 function calls. + ''' + ANSI_CSI_RE = re.compile('\001?\033\\[((?:\\d|;)*)([a-zA-Z])\002?') # Control Sequence Introducer + ANSI_OSC_RE = re.compile('\001?\033\\]([^\a]*)(\a)\002?') # Operating System Command + + def __init__(self, wrapped, convert=None, strip=None, autoreset=False): + # The wrapped stream (normally sys.stdout or sys.stderr) + self.wrapped = wrapped + + # should we reset colors to defaults after every .write() + self.autoreset = autoreset + + # create the proxy wrapping our output stream + self.stream = StreamWrapper(wrapped, self) + + on_windows = os.name == 'nt' + # We test if the WinAPI works, because even if we are on Windows + # we may be using a terminal that doesn't support the WinAPI + # (e.g. Cygwin Terminal). In this case it's up to the terminal + # to support the ANSI codes. + conversion_supported = on_windows and winapi_test() + try: + fd = wrapped.fileno() + except Exception: + fd = -1 + system_has_native_ansi = not on_windows or enable_vt_processing(fd) + have_tty = not self.stream.closed and self.stream.isatty() + need_conversion = conversion_supported and not system_has_native_ansi + + # should we strip ANSI sequences from our output? + if strip is None: + strip = need_conversion or not have_tty + self.strip = strip + + # should we should convert ANSI sequences into win32 calls? + if convert is None: + convert = need_conversion and have_tty + self.convert = convert + + # dict of ansi codes to win32 functions and parameters + self.win32_calls = self.get_win32_calls() + + # are we wrapping stderr? + self.on_stderr = self.wrapped is sys.stderr + + def should_wrap(self): + ''' + True if this class is actually needed. If false, then the output + stream will not be affected, nor will win32 calls be issued, so + wrapping stdout is not actually required. This will generally be + False on non-Windows platforms, unless optional functionality like + autoreset has been requested using kwargs to init() + ''' + return self.convert or self.strip or self.autoreset + + def get_win32_calls(self): + if self.convert and winterm: + return { + AnsiStyle.RESET_ALL: (winterm.reset_all, ), + AnsiStyle.BRIGHT: (winterm.style, WinStyle.BRIGHT), + AnsiStyle.DIM: (winterm.style, WinStyle.NORMAL), + AnsiStyle.NORMAL: (winterm.style, WinStyle.NORMAL), + AnsiFore.BLACK: (winterm.fore, WinColor.BLACK), + AnsiFore.RED: (winterm.fore, WinColor.RED), + AnsiFore.GREEN: (winterm.fore, WinColor.GREEN), + AnsiFore.YELLOW: (winterm.fore, WinColor.YELLOW), + AnsiFore.BLUE: (winterm.fore, WinColor.BLUE), + AnsiFore.MAGENTA: (winterm.fore, WinColor.MAGENTA), + AnsiFore.CYAN: (winterm.fore, WinColor.CYAN), + AnsiFore.WHITE: (winterm.fore, WinColor.GREY), + AnsiFore.RESET: (winterm.fore, ), + AnsiFore.LIGHTBLACK_EX: (winterm.fore, WinColor.BLACK, True), + AnsiFore.LIGHTRED_EX: (winterm.fore, WinColor.RED, True), + AnsiFore.LIGHTGREEN_EX: (winterm.fore, WinColor.GREEN, True), + AnsiFore.LIGHTYELLOW_EX: (winterm.fore, WinColor.YELLOW, True), + AnsiFore.LIGHTBLUE_EX: (winterm.fore, WinColor.BLUE, True), + AnsiFore.LIGHTMAGENTA_EX: (winterm.fore, WinColor.MAGENTA, True), + AnsiFore.LIGHTCYAN_EX: (winterm.fore, WinColor.CYAN, True), + AnsiFore.LIGHTWHITE_EX: (winterm.fore, WinColor.GREY, True), + AnsiBack.BLACK: (winterm.back, WinColor.BLACK), + AnsiBack.RED: (winterm.back, WinColor.RED), + AnsiBack.GREEN: (winterm.back, WinColor.GREEN), + AnsiBack.YELLOW: (winterm.back, WinColor.YELLOW), + AnsiBack.BLUE: (winterm.back, WinColor.BLUE), + AnsiBack.MAGENTA: (winterm.back, WinColor.MAGENTA), + AnsiBack.CYAN: (winterm.back, WinColor.CYAN), + AnsiBack.WHITE: (winterm.back, WinColor.GREY), + AnsiBack.RESET: (winterm.back, ), + AnsiBack.LIGHTBLACK_EX: (winterm.back, WinColor.BLACK, True), + AnsiBack.LIGHTRED_EX: (winterm.back, WinColor.RED, True), + AnsiBack.LIGHTGREEN_EX: (winterm.back, WinColor.GREEN, True), + AnsiBack.LIGHTYELLOW_EX: (winterm.back, WinColor.YELLOW, True), + AnsiBack.LIGHTBLUE_EX: (winterm.back, WinColor.BLUE, True), + AnsiBack.LIGHTMAGENTA_EX: (winterm.back, WinColor.MAGENTA, True), + AnsiBack.LIGHTCYAN_EX: (winterm.back, WinColor.CYAN, True), + AnsiBack.LIGHTWHITE_EX: (winterm.back, WinColor.GREY, True), + } + return dict() + + def write(self, text): + if self.strip or self.convert: + self.write_and_convert(text) + else: + self.wrapped.write(text) + self.wrapped.flush() + if self.autoreset: + self.reset_all() + + + def reset_all(self): + if self.convert: + self.call_win32('m', (0,)) + elif not self.strip and not self.stream.closed: + self.wrapped.write(Style.RESET_ALL) + + + def write_and_convert(self, text): + ''' + Write the given text to our wrapped stream, stripping any ANSI + sequences from the text, and optionally converting them into win32 + calls. + ''' + cursor = 0 + text = self.convert_osc(text) + for match in self.ANSI_CSI_RE.finditer(text): + start, end = match.span() + self.write_plain_text(text, cursor, start) + self.convert_ansi(*match.groups()) + cursor = end + self.write_plain_text(text, cursor, len(text)) + + + def write_plain_text(self, text, start, end): + if start < end: + self.wrapped.write(text[start:end]) + self.wrapped.flush() + + + def convert_ansi(self, paramstring, command): + if self.convert: + params = self.extract_params(command, paramstring) + self.call_win32(command, params) + + + def extract_params(self, command, paramstring): + if command in 'Hf': + params = tuple(int(p) if len(p) != 0 else 1 for p in paramstring.split(';')) + while len(params) < 2: + # defaults: + params = params + (1,) + else: + params = tuple(int(p) for p in paramstring.split(';') if len(p) != 0) + if len(params) == 0: + # defaults: + if command in 'JKm': + params = (0,) + elif command in 'ABCD': + params = (1,) + + return params + + + def call_win32(self, command, params): + if command == 'm': + for param in params: + if param in self.win32_calls: + func_args = self.win32_calls[param] + func = func_args[0] + args = func_args[1:] + kwargs = dict(on_stderr=self.on_stderr) + func(*args, **kwargs) + elif command in 'J': + winterm.erase_screen(params[0], on_stderr=self.on_stderr) + elif command in 'K': + winterm.erase_line(params[0], on_stderr=self.on_stderr) + elif command in 'Hf': # cursor position - absolute + winterm.set_cursor_position(params, on_stderr=self.on_stderr) + elif command in 'ABCD': # cursor position - relative + n = params[0] + # A - up, B - down, C - forward, D - back + x, y = {'A': (0, -n), 'B': (0, n), 'C': (n, 0), 'D': (-n, 0)}[command] + winterm.cursor_adjust(x, y, on_stderr=self.on_stderr) + + + def convert_osc(self, text): + for match in self.ANSI_OSC_RE.finditer(text): + start, end = match.span() + text = text[:start] + text[end:] + paramstring, command = match.groups() + if command == BEL: + if paramstring.count(";") == 1: + params = paramstring.split(";") + # 0 - change title and icon (we will only change title) + # 1 - change icon (we don't support this) + # 2 - change title + if params[0] in '02': + winterm.set_title(params[1]) + return text + + + def flush(self): + self.wrapped.flush() diff --git a/server/venv/Lib/site-packages/colorama/initialise.py b/server/venv/Lib/site-packages/colorama/initialise.py new file mode 100644 index 0000000..d5fd4b7 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/initialise.py @@ -0,0 +1,121 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +import atexit +import contextlib +import sys + +from .ansitowin32 import AnsiToWin32 + + +def _wipe_internal_state_for_tests(): + global orig_stdout, orig_stderr + orig_stdout = None + orig_stderr = None + + global wrapped_stdout, wrapped_stderr + wrapped_stdout = None + wrapped_stderr = None + + global atexit_done + atexit_done = False + + global fixed_windows_console + fixed_windows_console = False + + try: + # no-op if it wasn't registered + atexit.unregister(reset_all) + except AttributeError: + # python 2: no atexit.unregister. Oh well, we did our best. + pass + + +def reset_all(): + if AnsiToWin32 is not None: # Issue #74: objects might become None at exit + AnsiToWin32(orig_stdout).reset_all() + + +def init(autoreset=False, convert=None, strip=None, wrap=True): + + if not wrap and any([autoreset, convert, strip]): + raise ValueError('wrap=False conflicts with any other arg=True') + + global wrapped_stdout, wrapped_stderr + global orig_stdout, orig_stderr + + orig_stdout = sys.stdout + orig_stderr = sys.stderr + + if sys.stdout is None: + wrapped_stdout = None + else: + sys.stdout = wrapped_stdout = \ + wrap_stream(orig_stdout, convert, strip, autoreset, wrap) + if sys.stderr is None: + wrapped_stderr = None + else: + sys.stderr = wrapped_stderr = \ + wrap_stream(orig_stderr, convert, strip, autoreset, wrap) + + global atexit_done + if not atexit_done: + atexit.register(reset_all) + atexit_done = True + + +def deinit(): + if orig_stdout is not None: + sys.stdout = orig_stdout + if orig_stderr is not None: + sys.stderr = orig_stderr + + +def just_fix_windows_console(): + global fixed_windows_console + + if sys.platform != "win32": + return + if fixed_windows_console: + return + if wrapped_stdout is not None or wrapped_stderr is not None: + # Someone already ran init() and it did stuff, so we won't second-guess them + return + + # On newer versions of Windows, AnsiToWin32.__init__ will implicitly enable the + # native ANSI support in the console as a side-effect. We only need to actually + # replace sys.stdout/stderr if we're in the old-style conversion mode. + new_stdout = AnsiToWin32(sys.stdout, convert=None, strip=None, autoreset=False) + if new_stdout.convert: + sys.stdout = new_stdout + new_stderr = AnsiToWin32(sys.stderr, convert=None, strip=None, autoreset=False) + if new_stderr.convert: + sys.stderr = new_stderr + + fixed_windows_console = True + +@contextlib.contextmanager +def colorama_text(*args, **kwargs): + init(*args, **kwargs) + try: + yield + finally: + deinit() + + +def reinit(): + if wrapped_stdout is not None: + sys.stdout = wrapped_stdout + if wrapped_stderr is not None: + sys.stderr = wrapped_stderr + + +def wrap_stream(stream, convert, strip, autoreset, wrap): + if wrap: + wrapper = AnsiToWin32(stream, + convert=convert, strip=strip, autoreset=autoreset) + if wrapper.should_wrap(): + stream = wrapper.stream + return stream + + +# Use this for initial setup as well, to reduce code duplication +_wipe_internal_state_for_tests() diff --git a/server/venv/Lib/site-packages/colorama/tests/__init__.py b/server/venv/Lib/site-packages/colorama/tests/__init__.py new file mode 100644 index 0000000..8c5661e --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/tests/__init__.py @@ -0,0 +1 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. diff --git a/server/venv/Lib/site-packages/colorama/tests/ansi_test.py b/server/venv/Lib/site-packages/colorama/tests/ansi_test.py new file mode 100644 index 0000000..0a20c80 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/tests/ansi_test.py @@ -0,0 +1,76 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +import sys +from unittest import TestCase, main + +from ..ansi import Back, Fore, Style +from ..ansitowin32 import AnsiToWin32 + +stdout_orig = sys.stdout +stderr_orig = sys.stderr + + +class AnsiTest(TestCase): + + def setUp(self): + # sanity check: stdout should be a file or StringIO object. + # It will only be AnsiToWin32 if init() has previously wrapped it + self.assertNotEqual(type(sys.stdout), AnsiToWin32) + self.assertNotEqual(type(sys.stderr), AnsiToWin32) + + def tearDown(self): + sys.stdout = stdout_orig + sys.stderr = stderr_orig + + + def testForeAttributes(self): + self.assertEqual(Fore.BLACK, '\033[30m') + self.assertEqual(Fore.RED, '\033[31m') + self.assertEqual(Fore.GREEN, '\033[32m') + self.assertEqual(Fore.YELLOW, '\033[33m') + self.assertEqual(Fore.BLUE, '\033[34m') + self.assertEqual(Fore.MAGENTA, '\033[35m') + self.assertEqual(Fore.CYAN, '\033[36m') + self.assertEqual(Fore.WHITE, '\033[37m') + self.assertEqual(Fore.RESET, '\033[39m') + + # Check the light, extended versions. + self.assertEqual(Fore.LIGHTBLACK_EX, '\033[90m') + self.assertEqual(Fore.LIGHTRED_EX, '\033[91m') + self.assertEqual(Fore.LIGHTGREEN_EX, '\033[92m') + self.assertEqual(Fore.LIGHTYELLOW_EX, '\033[93m') + self.assertEqual(Fore.LIGHTBLUE_EX, '\033[94m') + self.assertEqual(Fore.LIGHTMAGENTA_EX, '\033[95m') + self.assertEqual(Fore.LIGHTCYAN_EX, '\033[96m') + self.assertEqual(Fore.LIGHTWHITE_EX, '\033[97m') + + + def testBackAttributes(self): + self.assertEqual(Back.BLACK, '\033[40m') + self.assertEqual(Back.RED, '\033[41m') + self.assertEqual(Back.GREEN, '\033[42m') + self.assertEqual(Back.YELLOW, '\033[43m') + self.assertEqual(Back.BLUE, '\033[44m') + self.assertEqual(Back.MAGENTA, '\033[45m') + self.assertEqual(Back.CYAN, '\033[46m') + self.assertEqual(Back.WHITE, '\033[47m') + self.assertEqual(Back.RESET, '\033[49m') + + # Check the light, extended versions. + self.assertEqual(Back.LIGHTBLACK_EX, '\033[100m') + self.assertEqual(Back.LIGHTRED_EX, '\033[101m') + self.assertEqual(Back.LIGHTGREEN_EX, '\033[102m') + self.assertEqual(Back.LIGHTYELLOW_EX, '\033[103m') + self.assertEqual(Back.LIGHTBLUE_EX, '\033[104m') + self.assertEqual(Back.LIGHTMAGENTA_EX, '\033[105m') + self.assertEqual(Back.LIGHTCYAN_EX, '\033[106m') + self.assertEqual(Back.LIGHTWHITE_EX, '\033[107m') + + + def testStyleAttributes(self): + self.assertEqual(Style.DIM, '\033[2m') + self.assertEqual(Style.NORMAL, '\033[22m') + self.assertEqual(Style.BRIGHT, '\033[1m') + + +if __name__ == '__main__': + main() diff --git a/server/venv/Lib/site-packages/colorama/tests/ansitowin32_test.py b/server/venv/Lib/site-packages/colorama/tests/ansitowin32_test.py new file mode 100644 index 0000000..91ca551 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/tests/ansitowin32_test.py @@ -0,0 +1,294 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +from io import StringIO, TextIOWrapper +from unittest import TestCase, main +try: + from contextlib import ExitStack +except ImportError: + # python 2 + from contextlib2 import ExitStack + +try: + from unittest.mock import MagicMock, Mock, patch +except ImportError: + from mock import MagicMock, Mock, patch + +from ..ansitowin32 import AnsiToWin32, StreamWrapper +from ..win32 import ENABLE_VIRTUAL_TERMINAL_PROCESSING +from .utils import osname + + +class StreamWrapperTest(TestCase): + + def testIsAProxy(self): + mockStream = Mock() + wrapper = StreamWrapper(mockStream, None) + self.assertTrue( wrapper.random_attr is mockStream.random_attr ) + + def testDelegatesWrite(self): + mockStream = Mock() + mockConverter = Mock() + wrapper = StreamWrapper(mockStream, mockConverter) + wrapper.write('hello') + self.assertTrue(mockConverter.write.call_args, (('hello',), {})) + + def testDelegatesContext(self): + mockConverter = Mock() + s = StringIO() + with StreamWrapper(s, mockConverter) as fp: + fp.write(u'hello') + self.assertTrue(s.closed) + + def testProxyNoContextManager(self): + mockStream = MagicMock() + mockStream.__enter__.side_effect = AttributeError() + mockConverter = Mock() + with self.assertRaises(AttributeError) as excinfo: + with StreamWrapper(mockStream, mockConverter) as wrapper: + wrapper.write('hello') + + def test_closed_shouldnt_raise_on_closed_stream(self): + stream = StringIO() + stream.close() + wrapper = StreamWrapper(stream, None) + self.assertEqual(wrapper.closed, True) + + def test_closed_shouldnt_raise_on_detached_stream(self): + stream = TextIOWrapper(StringIO()) + stream.detach() + wrapper = StreamWrapper(stream, None) + self.assertEqual(wrapper.closed, True) + +class AnsiToWin32Test(TestCase): + + def testInit(self): + mockStdout = Mock() + auto = Mock() + stream = AnsiToWin32(mockStdout, autoreset=auto) + self.assertEqual(stream.wrapped, mockStdout) + self.assertEqual(stream.autoreset, auto) + + @patch('colorama.ansitowin32.winterm', None) + @patch('colorama.ansitowin32.winapi_test', lambda *_: True) + def testStripIsTrueOnWindows(self): + with osname('nt'): + mockStdout = Mock() + stream = AnsiToWin32(mockStdout) + self.assertTrue(stream.strip) + + def testStripIsFalseOffWindows(self): + with osname('posix'): + mockStdout = Mock(closed=False) + stream = AnsiToWin32(mockStdout) + self.assertFalse(stream.strip) + + def testWriteStripsAnsi(self): + mockStdout = Mock() + stream = AnsiToWin32(mockStdout) + stream.wrapped = Mock() + stream.write_and_convert = Mock() + stream.strip = True + + stream.write('abc') + + self.assertFalse(stream.wrapped.write.called) + self.assertEqual(stream.write_and_convert.call_args, (('abc',), {})) + + def testWriteDoesNotStripAnsi(self): + mockStdout = Mock() + stream = AnsiToWin32(mockStdout) + stream.wrapped = Mock() + stream.write_and_convert = Mock() + stream.strip = False + stream.convert = False + + stream.write('abc') + + self.assertFalse(stream.write_and_convert.called) + self.assertEqual(stream.wrapped.write.call_args, (('abc',), {})) + + def assert_autoresets(self, convert, autoreset=True): + stream = AnsiToWin32(Mock()) + stream.convert = convert + stream.reset_all = Mock() + stream.autoreset = autoreset + stream.winterm = Mock() + + stream.write('abc') + + self.assertEqual(stream.reset_all.called, autoreset) + + def testWriteAutoresets(self): + self.assert_autoresets(convert=True) + self.assert_autoresets(convert=False) + self.assert_autoresets(convert=True, autoreset=False) + self.assert_autoresets(convert=False, autoreset=False) + + def testWriteAndConvertWritesPlainText(self): + stream = AnsiToWin32(Mock()) + stream.write_and_convert( 'abc' ) + self.assertEqual( stream.wrapped.write.call_args, (('abc',), {}) ) + + def testWriteAndConvertStripsAllValidAnsi(self): + stream = AnsiToWin32(Mock()) + stream.call_win32 = Mock() + data = [ + 'abc\033[mdef', + 'abc\033[0mdef', + 'abc\033[2mdef', + 'abc\033[02mdef', + 'abc\033[002mdef', + 'abc\033[40mdef', + 'abc\033[040mdef', + 'abc\033[0;1mdef', + 'abc\033[40;50mdef', + 'abc\033[50;30;40mdef', + 'abc\033[Adef', + 'abc\033[0Gdef', + 'abc\033[1;20;128Hdef', + ] + for datum in data: + stream.wrapped.write.reset_mock() + stream.write_and_convert( datum ) + self.assertEqual( + [args[0] for args in stream.wrapped.write.call_args_list], + [ ('abc',), ('def',) ] + ) + + def testWriteAndConvertSkipsEmptySnippets(self): + stream = AnsiToWin32(Mock()) + stream.call_win32 = Mock() + stream.write_and_convert( '\033[40m\033[41m' ) + self.assertFalse( stream.wrapped.write.called ) + + def testWriteAndConvertCallsWin32WithParamsAndCommand(self): + stream = AnsiToWin32(Mock()) + stream.convert = True + stream.call_win32 = Mock() + stream.extract_params = Mock(return_value='params') + data = { + 'abc\033[adef': ('a', 'params'), + 'abc\033[;;bdef': ('b', 'params'), + 'abc\033[0cdef': ('c', 'params'), + 'abc\033[;;0;;Gdef': ('G', 'params'), + 'abc\033[1;20;128Hdef': ('H', 'params'), + } + for datum, expected in data.items(): + stream.call_win32.reset_mock() + stream.write_and_convert( datum ) + self.assertEqual( stream.call_win32.call_args[0], expected ) + + def test_reset_all_shouldnt_raise_on_closed_orig_stdout(self): + stream = StringIO() + converter = AnsiToWin32(stream) + stream.close() + + converter.reset_all() + + def test_wrap_shouldnt_raise_on_closed_orig_stdout(self): + stream = StringIO() + stream.close() + with \ + patch("colorama.ansitowin32.os.name", "nt"), \ + patch("colorama.ansitowin32.winapi_test", lambda: True): + converter = AnsiToWin32(stream) + self.assertTrue(converter.strip) + self.assertFalse(converter.convert) + + def test_wrap_shouldnt_raise_on_missing_closed_attr(self): + with \ + patch("colorama.ansitowin32.os.name", "nt"), \ + patch("colorama.ansitowin32.winapi_test", lambda: True): + converter = AnsiToWin32(object()) + self.assertTrue(converter.strip) + self.assertFalse(converter.convert) + + def testExtractParams(self): + stream = AnsiToWin32(Mock()) + data = { + '': (0,), + ';;': (0,), + '2': (2,), + ';;002;;': (2,), + '0;1': (0, 1), + ';;003;;456;;': (3, 456), + '11;22;33;44;55': (11, 22, 33, 44, 55), + } + for datum, expected in data.items(): + self.assertEqual(stream.extract_params('m', datum), expected) + + def testCallWin32UsesLookup(self): + listener = Mock() + stream = AnsiToWin32(listener) + stream.win32_calls = { + 1: (lambda *_, **__: listener(11),), + 2: (lambda *_, **__: listener(22),), + 3: (lambda *_, **__: listener(33),), + } + stream.call_win32('m', (3, 1, 99, 2)) + self.assertEqual( + [a[0][0] for a in listener.call_args_list], + [33, 11, 22] ) + + def test_osc_codes(self): + mockStdout = Mock() + stream = AnsiToWin32(mockStdout, convert=True) + with patch('colorama.ansitowin32.winterm') as winterm: + data = [ + '\033]0\x07', # missing arguments + '\033]0;foo\x08', # wrong OSC command + '\033]0;colorama_test_title\x07', # should work + '\033]1;colorama_test_title\x07', # wrong set command + '\033]2;colorama_test_title\x07', # should work + '\033]' + ';' * 64 + '\x08', # see issue #247 + ] + for code in data: + stream.write(code) + self.assertEqual(winterm.set_title.call_count, 2) + + def test_native_windows_ansi(self): + with ExitStack() as stack: + def p(a, b): + stack.enter_context(patch(a, b, create=True)) + # Pretend to be on Windows + p("colorama.ansitowin32.os.name", "nt") + p("colorama.ansitowin32.winapi_test", lambda: True) + p("colorama.win32.winapi_test", lambda: True) + p("colorama.winterm.win32.windll", "non-None") + p("colorama.winterm.get_osfhandle", lambda _: 1234) + + # Pretend that our mock stream has native ANSI support + p( + "colorama.winterm.win32.GetConsoleMode", + lambda _: ENABLE_VIRTUAL_TERMINAL_PROCESSING, + ) + SetConsoleMode = Mock() + p("colorama.winterm.win32.SetConsoleMode", SetConsoleMode) + + stdout = Mock() + stdout.closed = False + stdout.isatty.return_value = True + stdout.fileno.return_value = 1 + + # Our fake console says it has native vt support, so AnsiToWin32 should + # enable that support and do nothing else. + stream = AnsiToWin32(stdout) + SetConsoleMode.assert_called_with(1234, ENABLE_VIRTUAL_TERMINAL_PROCESSING) + self.assertFalse(stream.strip) + self.assertFalse(stream.convert) + self.assertFalse(stream.should_wrap()) + + # Now let's pretend we're on an old Windows console, that doesn't have + # native ANSI support. + p("colorama.winterm.win32.GetConsoleMode", lambda _: 0) + SetConsoleMode = Mock() + p("colorama.winterm.win32.SetConsoleMode", SetConsoleMode) + + stream = AnsiToWin32(stdout) + SetConsoleMode.assert_called_with(1234, ENABLE_VIRTUAL_TERMINAL_PROCESSING) + self.assertTrue(stream.strip) + self.assertTrue(stream.convert) + self.assertTrue(stream.should_wrap()) + + +if __name__ == '__main__': + main() diff --git a/server/venv/Lib/site-packages/colorama/tests/initialise_test.py b/server/venv/Lib/site-packages/colorama/tests/initialise_test.py new file mode 100644 index 0000000..89f9b07 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/tests/initialise_test.py @@ -0,0 +1,189 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +import sys +from unittest import TestCase, main, skipUnless + +try: + from unittest.mock import patch, Mock +except ImportError: + from mock import patch, Mock + +from ..ansitowin32 import StreamWrapper +from ..initialise import init, just_fix_windows_console, _wipe_internal_state_for_tests +from .utils import osname, replace_by + +orig_stdout = sys.stdout +orig_stderr = sys.stderr + + +class InitTest(TestCase): + + @skipUnless(sys.stdout.isatty(), "sys.stdout is not a tty") + def setUp(self): + # sanity check + self.assertNotWrapped() + + def tearDown(self): + _wipe_internal_state_for_tests() + sys.stdout = orig_stdout + sys.stderr = orig_stderr + + def assertWrapped(self): + self.assertIsNot(sys.stdout, orig_stdout, 'stdout should be wrapped') + self.assertIsNot(sys.stderr, orig_stderr, 'stderr should be wrapped') + self.assertTrue(isinstance(sys.stdout, StreamWrapper), + 'bad stdout wrapper') + self.assertTrue(isinstance(sys.stderr, StreamWrapper), + 'bad stderr wrapper') + + def assertNotWrapped(self): + self.assertIs(sys.stdout, orig_stdout, 'stdout should not be wrapped') + self.assertIs(sys.stderr, orig_stderr, 'stderr should not be wrapped') + + @patch('colorama.initialise.reset_all') + @patch('colorama.ansitowin32.winapi_test', lambda *_: True) + @patch('colorama.ansitowin32.enable_vt_processing', lambda *_: False) + def testInitWrapsOnWindows(self, _): + with osname("nt"): + init() + self.assertWrapped() + + @patch('colorama.initialise.reset_all') + @patch('colorama.ansitowin32.winapi_test', lambda *_: False) + def testInitDoesntWrapOnEmulatedWindows(self, _): + with osname("nt"): + init() + self.assertNotWrapped() + + def testInitDoesntWrapOnNonWindows(self): + with osname("posix"): + init() + self.assertNotWrapped() + + def testInitDoesntWrapIfNone(self): + with replace_by(None): + init() + # We can't use assertNotWrapped here because replace_by(None) + # changes stdout/stderr already. + self.assertIsNone(sys.stdout) + self.assertIsNone(sys.stderr) + + def testInitAutoresetOnWrapsOnAllPlatforms(self): + with osname("posix"): + init(autoreset=True) + self.assertWrapped() + + def testInitWrapOffDoesntWrapOnWindows(self): + with osname("nt"): + init(wrap=False) + self.assertNotWrapped() + + def testInitWrapOffIncompatibleWithAutoresetOn(self): + self.assertRaises(ValueError, lambda: init(autoreset=True, wrap=False)) + + @patch('colorama.win32.SetConsoleTextAttribute') + @patch('colorama.initialise.AnsiToWin32') + def testAutoResetPassedOn(self, mockATW32, _): + with osname("nt"): + init(autoreset=True) + self.assertEqual(len(mockATW32.call_args_list), 2) + self.assertEqual(mockATW32.call_args_list[1][1]['autoreset'], True) + self.assertEqual(mockATW32.call_args_list[0][1]['autoreset'], True) + + @patch('colorama.initialise.AnsiToWin32') + def testAutoResetChangeable(self, mockATW32): + with osname("nt"): + init() + + init(autoreset=True) + self.assertEqual(len(mockATW32.call_args_list), 4) + self.assertEqual(mockATW32.call_args_list[2][1]['autoreset'], True) + self.assertEqual(mockATW32.call_args_list[3][1]['autoreset'], True) + + init() + self.assertEqual(len(mockATW32.call_args_list), 6) + self.assertEqual( + mockATW32.call_args_list[4][1]['autoreset'], False) + self.assertEqual( + mockATW32.call_args_list[5][1]['autoreset'], False) + + + @patch('colorama.initialise.atexit.register') + def testAtexitRegisteredOnlyOnce(self, mockRegister): + init() + self.assertTrue(mockRegister.called) + mockRegister.reset_mock() + init() + self.assertFalse(mockRegister.called) + + +class JustFixWindowsConsoleTest(TestCase): + def _reset(self): + _wipe_internal_state_for_tests() + sys.stdout = orig_stdout + sys.stderr = orig_stderr + + def tearDown(self): + self._reset() + + @patch("colorama.ansitowin32.winapi_test", lambda: True) + def testJustFixWindowsConsole(self): + if sys.platform != "win32": + # just_fix_windows_console should be a no-op + just_fix_windows_console() + self.assertIs(sys.stdout, orig_stdout) + self.assertIs(sys.stderr, orig_stderr) + else: + def fake_std(): + # Emulate stdout=not a tty, stderr=tty + # to check that we handle both cases correctly + stdout = Mock() + stdout.closed = False + stdout.isatty.return_value = False + stdout.fileno.return_value = 1 + sys.stdout = stdout + + stderr = Mock() + stderr.closed = False + stderr.isatty.return_value = True + stderr.fileno.return_value = 2 + sys.stderr = stderr + + for native_ansi in [False, True]: + with patch( + 'colorama.ansitowin32.enable_vt_processing', + lambda *_: native_ansi + ): + self._reset() + fake_std() + + # Regular single-call test + prev_stdout = sys.stdout + prev_stderr = sys.stderr + just_fix_windows_console() + self.assertIs(sys.stdout, prev_stdout) + if native_ansi: + self.assertIs(sys.stderr, prev_stderr) + else: + self.assertIsNot(sys.stderr, prev_stderr) + + # second call without resetting is always a no-op + prev_stdout = sys.stdout + prev_stderr = sys.stderr + just_fix_windows_console() + self.assertIs(sys.stdout, prev_stdout) + self.assertIs(sys.stderr, prev_stderr) + + self._reset() + fake_std() + + # If init() runs first, just_fix_windows_console should be a no-op + init() + prev_stdout = sys.stdout + prev_stderr = sys.stderr + just_fix_windows_console() + self.assertIs(prev_stdout, sys.stdout) + self.assertIs(prev_stderr, sys.stderr) + + +if __name__ == '__main__': + main() diff --git a/server/venv/Lib/site-packages/colorama/tests/isatty_test.py b/server/venv/Lib/site-packages/colorama/tests/isatty_test.py new file mode 100644 index 0000000..0f84e4b --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/tests/isatty_test.py @@ -0,0 +1,57 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +import sys +from unittest import TestCase, main + +from ..ansitowin32 import StreamWrapper, AnsiToWin32 +from .utils import pycharm, replace_by, replace_original_by, StreamTTY, StreamNonTTY + + +def is_a_tty(stream): + return StreamWrapper(stream, None).isatty() + +class IsattyTest(TestCase): + + def test_TTY(self): + tty = StreamTTY() + self.assertTrue(is_a_tty(tty)) + with pycharm(): + self.assertTrue(is_a_tty(tty)) + + def test_nonTTY(self): + non_tty = StreamNonTTY() + self.assertFalse(is_a_tty(non_tty)) + with pycharm(): + self.assertFalse(is_a_tty(non_tty)) + + def test_withPycharm(self): + with pycharm(): + self.assertTrue(is_a_tty(sys.stderr)) + self.assertTrue(is_a_tty(sys.stdout)) + + def test_withPycharmTTYOverride(self): + tty = StreamTTY() + with pycharm(), replace_by(tty): + self.assertTrue(is_a_tty(tty)) + + def test_withPycharmNonTTYOverride(self): + non_tty = StreamNonTTY() + with pycharm(), replace_by(non_tty): + self.assertFalse(is_a_tty(non_tty)) + + def test_withPycharmNoneOverride(self): + with pycharm(): + with replace_by(None), replace_original_by(None): + self.assertFalse(is_a_tty(None)) + self.assertFalse(is_a_tty(StreamNonTTY())) + self.assertTrue(is_a_tty(StreamTTY())) + + def test_withPycharmStreamWrapped(self): + with pycharm(): + self.assertTrue(AnsiToWin32(StreamTTY()).stream.isatty()) + self.assertFalse(AnsiToWin32(StreamNonTTY()).stream.isatty()) + self.assertTrue(AnsiToWin32(sys.stdout).stream.isatty()) + self.assertTrue(AnsiToWin32(sys.stderr).stream.isatty()) + + +if __name__ == '__main__': + main() diff --git a/server/venv/Lib/site-packages/colorama/tests/utils.py b/server/venv/Lib/site-packages/colorama/tests/utils.py new file mode 100644 index 0000000..472fafb --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/tests/utils.py @@ -0,0 +1,49 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +from contextlib import contextmanager +from io import StringIO +import sys +import os + + +class StreamTTY(StringIO): + def isatty(self): + return True + +class StreamNonTTY(StringIO): + def isatty(self): + return False + +@contextmanager +def osname(name): + orig = os.name + os.name = name + yield + os.name = orig + +@contextmanager +def replace_by(stream): + orig_stdout = sys.stdout + orig_stderr = sys.stderr + sys.stdout = stream + sys.stderr = stream + yield + sys.stdout = orig_stdout + sys.stderr = orig_stderr + +@contextmanager +def replace_original_by(stream): + orig_stdout = sys.__stdout__ + orig_stderr = sys.__stderr__ + sys.__stdout__ = stream + sys.__stderr__ = stream + yield + sys.__stdout__ = orig_stdout + sys.__stderr__ = orig_stderr + +@contextmanager +def pycharm(): + os.environ["PYCHARM_HOSTED"] = "1" + non_tty = StreamNonTTY() + with replace_by(non_tty), replace_original_by(non_tty): + yield + del os.environ["PYCHARM_HOSTED"] diff --git a/server/venv/Lib/site-packages/colorama/tests/winterm_test.py b/server/venv/Lib/site-packages/colorama/tests/winterm_test.py new file mode 100644 index 0000000..d0955f9 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/tests/winterm_test.py @@ -0,0 +1,131 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +import sys +from unittest import TestCase, main, skipUnless + +try: + from unittest.mock import Mock, patch +except ImportError: + from mock import Mock, patch + +from ..winterm import WinColor, WinStyle, WinTerm + + +class WinTermTest(TestCase): + + @patch('colorama.winterm.win32') + def testInit(self, mockWin32): + mockAttr = Mock() + mockAttr.wAttributes = 7 + 6 * 16 + 8 + mockWin32.GetConsoleScreenBufferInfo.return_value = mockAttr + term = WinTerm() + self.assertEqual(term._fore, 7) + self.assertEqual(term._back, 6) + self.assertEqual(term._style, 8) + + @skipUnless(sys.platform.startswith("win"), "requires Windows") + def testGetAttrs(self): + term = WinTerm() + + term._fore = 0 + term._back = 0 + term._style = 0 + self.assertEqual(term.get_attrs(), 0) + + term._fore = WinColor.YELLOW + self.assertEqual(term.get_attrs(), WinColor.YELLOW) + + term._back = WinColor.MAGENTA + self.assertEqual( + term.get_attrs(), + WinColor.YELLOW + WinColor.MAGENTA * 16) + + term._style = WinStyle.BRIGHT + self.assertEqual( + term.get_attrs(), + WinColor.YELLOW + WinColor.MAGENTA * 16 + WinStyle.BRIGHT) + + @patch('colorama.winterm.win32') + def testResetAll(self, mockWin32): + mockAttr = Mock() + mockAttr.wAttributes = 1 + 2 * 16 + 8 + mockWin32.GetConsoleScreenBufferInfo.return_value = mockAttr + term = WinTerm() + + term.set_console = Mock() + term._fore = -1 + term._back = -1 + term._style = -1 + + term.reset_all() + + self.assertEqual(term._fore, 1) + self.assertEqual(term._back, 2) + self.assertEqual(term._style, 8) + self.assertEqual(term.set_console.called, True) + + @skipUnless(sys.platform.startswith("win"), "requires Windows") + def testFore(self): + term = WinTerm() + term.set_console = Mock() + term._fore = 0 + + term.fore(5) + + self.assertEqual(term._fore, 5) + self.assertEqual(term.set_console.called, True) + + @skipUnless(sys.platform.startswith("win"), "requires Windows") + def testBack(self): + term = WinTerm() + term.set_console = Mock() + term._back = 0 + + term.back(5) + + self.assertEqual(term._back, 5) + self.assertEqual(term.set_console.called, True) + + @skipUnless(sys.platform.startswith("win"), "requires Windows") + def testStyle(self): + term = WinTerm() + term.set_console = Mock() + term._style = 0 + + term.style(22) + + self.assertEqual(term._style, 22) + self.assertEqual(term.set_console.called, True) + + @patch('colorama.winterm.win32') + def testSetConsole(self, mockWin32): + mockAttr = Mock() + mockAttr.wAttributes = 0 + mockWin32.GetConsoleScreenBufferInfo.return_value = mockAttr + term = WinTerm() + term.windll = Mock() + + term.set_console() + + self.assertEqual( + mockWin32.SetConsoleTextAttribute.call_args, + ((mockWin32.STDOUT, term.get_attrs()), {}) + ) + + @patch('colorama.winterm.win32') + def testSetConsoleOnStderr(self, mockWin32): + mockAttr = Mock() + mockAttr.wAttributes = 0 + mockWin32.GetConsoleScreenBufferInfo.return_value = mockAttr + term = WinTerm() + term.windll = Mock() + + term.set_console(on_stderr=True) + + self.assertEqual( + mockWin32.SetConsoleTextAttribute.call_args, + ((mockWin32.STDERR, term.get_attrs()), {}) + ) + + +if __name__ == '__main__': + main() diff --git a/server/venv/Lib/site-packages/colorama/win32.py b/server/venv/Lib/site-packages/colorama/win32.py new file mode 100644 index 0000000..841b0e2 --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/win32.py @@ -0,0 +1,180 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. + +# from winbase.h +STDOUT = -11 +STDERR = -12 + +ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 + +try: + import ctypes + from ctypes import LibraryLoader + windll = LibraryLoader(ctypes.WinDLL) + from ctypes import wintypes +except (AttributeError, ImportError): + windll = None + SetConsoleTextAttribute = lambda *_: None + winapi_test = lambda *_: None +else: + from ctypes import byref, Structure, c_char, POINTER + + COORD = wintypes._COORD + + class CONSOLE_SCREEN_BUFFER_INFO(Structure): + """struct in wincon.h.""" + _fields_ = [ + ("dwSize", COORD), + ("dwCursorPosition", COORD), + ("wAttributes", wintypes.WORD), + ("srWindow", wintypes.SMALL_RECT), + ("dwMaximumWindowSize", COORD), + ] + def __str__(self): + return '(%d,%d,%d,%d,%d,%d,%d,%d,%d,%d,%d)' % ( + self.dwSize.Y, self.dwSize.X + , self.dwCursorPosition.Y, self.dwCursorPosition.X + , self.wAttributes + , self.srWindow.Top, self.srWindow.Left, self.srWindow.Bottom, self.srWindow.Right + , self.dwMaximumWindowSize.Y, self.dwMaximumWindowSize.X + ) + + _GetStdHandle = windll.kernel32.GetStdHandle + _GetStdHandle.argtypes = [ + wintypes.DWORD, + ] + _GetStdHandle.restype = wintypes.HANDLE + + _GetConsoleScreenBufferInfo = windll.kernel32.GetConsoleScreenBufferInfo + _GetConsoleScreenBufferInfo.argtypes = [ + wintypes.HANDLE, + POINTER(CONSOLE_SCREEN_BUFFER_INFO), + ] + _GetConsoleScreenBufferInfo.restype = wintypes.BOOL + + _SetConsoleTextAttribute = windll.kernel32.SetConsoleTextAttribute + _SetConsoleTextAttribute.argtypes = [ + wintypes.HANDLE, + wintypes.WORD, + ] + _SetConsoleTextAttribute.restype = wintypes.BOOL + + _SetConsoleCursorPosition = windll.kernel32.SetConsoleCursorPosition + _SetConsoleCursorPosition.argtypes = [ + wintypes.HANDLE, + COORD, + ] + _SetConsoleCursorPosition.restype = wintypes.BOOL + + _FillConsoleOutputCharacterA = windll.kernel32.FillConsoleOutputCharacterA + _FillConsoleOutputCharacterA.argtypes = [ + wintypes.HANDLE, + c_char, + wintypes.DWORD, + COORD, + POINTER(wintypes.DWORD), + ] + _FillConsoleOutputCharacterA.restype = wintypes.BOOL + + _FillConsoleOutputAttribute = windll.kernel32.FillConsoleOutputAttribute + _FillConsoleOutputAttribute.argtypes = [ + wintypes.HANDLE, + wintypes.WORD, + wintypes.DWORD, + COORD, + POINTER(wintypes.DWORD), + ] + _FillConsoleOutputAttribute.restype = wintypes.BOOL + + _SetConsoleTitleW = windll.kernel32.SetConsoleTitleW + _SetConsoleTitleW.argtypes = [ + wintypes.LPCWSTR + ] + _SetConsoleTitleW.restype = wintypes.BOOL + + _GetConsoleMode = windll.kernel32.GetConsoleMode + _GetConsoleMode.argtypes = [ + wintypes.HANDLE, + POINTER(wintypes.DWORD) + ] + _GetConsoleMode.restype = wintypes.BOOL + + _SetConsoleMode = windll.kernel32.SetConsoleMode + _SetConsoleMode.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD + ] + _SetConsoleMode.restype = wintypes.BOOL + + def _winapi_test(handle): + csbi = CONSOLE_SCREEN_BUFFER_INFO() + success = _GetConsoleScreenBufferInfo( + handle, byref(csbi)) + return bool(success) + + def winapi_test(): + return any(_winapi_test(h) for h in + (_GetStdHandle(STDOUT), _GetStdHandle(STDERR))) + + def GetConsoleScreenBufferInfo(stream_id=STDOUT): + handle = _GetStdHandle(stream_id) + csbi = CONSOLE_SCREEN_BUFFER_INFO() + success = _GetConsoleScreenBufferInfo( + handle, byref(csbi)) + return csbi + + def SetConsoleTextAttribute(stream_id, attrs): + handle = _GetStdHandle(stream_id) + return _SetConsoleTextAttribute(handle, attrs) + + def SetConsoleCursorPosition(stream_id, position, adjust=True): + position = COORD(*position) + # If the position is out of range, do nothing. + if position.Y <= 0 or position.X <= 0: + return + # Adjust for Windows' SetConsoleCursorPosition: + # 1. being 0-based, while ANSI is 1-based. + # 2. expecting (x,y), while ANSI uses (y,x). + adjusted_position = COORD(position.Y - 1, position.X - 1) + if adjust: + # Adjust for viewport's scroll position + sr = GetConsoleScreenBufferInfo(STDOUT).srWindow + adjusted_position.Y += sr.Top + adjusted_position.X += sr.Left + # Resume normal processing + handle = _GetStdHandle(stream_id) + return _SetConsoleCursorPosition(handle, adjusted_position) + + def FillConsoleOutputCharacter(stream_id, char, length, start): + handle = _GetStdHandle(stream_id) + char = c_char(char.encode()) + length = wintypes.DWORD(length) + num_written = wintypes.DWORD(0) + # Note that this is hard-coded for ANSI (vs wide) bytes. + success = _FillConsoleOutputCharacterA( + handle, char, length, start, byref(num_written)) + return num_written.value + + def FillConsoleOutputAttribute(stream_id, attr, length, start): + ''' FillConsoleOutputAttribute( hConsole, csbi.wAttributes, dwConSize, coordScreen, &cCharsWritten )''' + handle = _GetStdHandle(stream_id) + attribute = wintypes.WORD(attr) + length = wintypes.DWORD(length) + num_written = wintypes.DWORD(0) + # Note that this is hard-coded for ANSI (vs wide) bytes. + return _FillConsoleOutputAttribute( + handle, attribute, length, start, byref(num_written)) + + def SetConsoleTitle(title): + return _SetConsoleTitleW(title) + + def GetConsoleMode(handle): + mode = wintypes.DWORD() + success = _GetConsoleMode(handle, byref(mode)) + if not success: + raise ctypes.WinError() + return mode.value + + def SetConsoleMode(handle, mode): + success = _SetConsoleMode(handle, mode) + if not success: + raise ctypes.WinError() diff --git a/server/venv/Lib/site-packages/colorama/winterm.py b/server/venv/Lib/site-packages/colorama/winterm.py new file mode 100644 index 0000000..aad867e --- /dev/null +++ b/server/venv/Lib/site-packages/colorama/winterm.py @@ -0,0 +1,195 @@ +# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file. +try: + from msvcrt import get_osfhandle +except ImportError: + def get_osfhandle(_): + raise OSError("This isn't windows!") + + +from . import win32 + +# from wincon.h +class WinColor(object): + BLACK = 0 + BLUE = 1 + GREEN = 2 + CYAN = 3 + RED = 4 + MAGENTA = 5 + YELLOW = 6 + GREY = 7 + +# from wincon.h +class WinStyle(object): + NORMAL = 0x00 # dim text, dim background + BRIGHT = 0x08 # bright text, dim background + BRIGHT_BACKGROUND = 0x80 # dim text, bright background + +class WinTerm(object): + + def __init__(self): + self._default = win32.GetConsoleScreenBufferInfo(win32.STDOUT).wAttributes + self.set_attrs(self._default) + self._default_fore = self._fore + self._default_back = self._back + self._default_style = self._style + # In order to emulate LIGHT_EX in windows, we borrow the BRIGHT style. + # So that LIGHT_EX colors and BRIGHT style do not clobber each other, + # we track them separately, since LIGHT_EX is overwritten by Fore/Back + # and BRIGHT is overwritten by Style codes. + self._light = 0 + + def get_attrs(self): + return self._fore + self._back * 16 + (self._style | self._light) + + def set_attrs(self, value): + self._fore = value & 7 + self._back = (value >> 4) & 7 + self._style = value & (WinStyle.BRIGHT | WinStyle.BRIGHT_BACKGROUND) + + def reset_all(self, on_stderr=None): + self.set_attrs(self._default) + self.set_console(attrs=self._default) + self._light = 0 + + def fore(self, fore=None, light=False, on_stderr=False): + if fore is None: + fore = self._default_fore + self._fore = fore + # Emulate LIGHT_EX with BRIGHT Style + if light: + self._light |= WinStyle.BRIGHT + else: + self._light &= ~WinStyle.BRIGHT + self.set_console(on_stderr=on_stderr) + + def back(self, back=None, light=False, on_stderr=False): + if back is None: + back = self._default_back + self._back = back + # Emulate LIGHT_EX with BRIGHT_BACKGROUND Style + if light: + self._light |= WinStyle.BRIGHT_BACKGROUND + else: + self._light &= ~WinStyle.BRIGHT_BACKGROUND + self.set_console(on_stderr=on_stderr) + + def style(self, style=None, on_stderr=False): + if style is None: + style = self._default_style + self._style = style + self.set_console(on_stderr=on_stderr) + + def set_console(self, attrs=None, on_stderr=False): + if attrs is None: + attrs = self.get_attrs() + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + win32.SetConsoleTextAttribute(handle, attrs) + + def get_position(self, handle): + position = win32.GetConsoleScreenBufferInfo(handle).dwCursorPosition + # Because Windows coordinates are 0-based, + # and win32.SetConsoleCursorPosition expects 1-based. + position.X += 1 + position.Y += 1 + return position + + def set_cursor_position(self, position=None, on_stderr=False): + if position is None: + # I'm not currently tracking the position, so there is no default. + # position = self.get_position() + return + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + win32.SetConsoleCursorPosition(handle, position) + + def cursor_adjust(self, x, y, on_stderr=False): + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + position = self.get_position(handle) + adjusted_position = (position.Y + y, position.X + x) + win32.SetConsoleCursorPosition(handle, adjusted_position, adjust=False) + + def erase_screen(self, mode=0, on_stderr=False): + # 0 should clear from the cursor to the end of the screen. + # 1 should clear from the cursor to the beginning of the screen. + # 2 should clear the entire screen, and move cursor to (1,1) + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + csbi = win32.GetConsoleScreenBufferInfo(handle) + # get the number of character cells in the current buffer + cells_in_screen = csbi.dwSize.X * csbi.dwSize.Y + # get number of character cells before current cursor position + cells_before_cursor = csbi.dwSize.X * csbi.dwCursorPosition.Y + csbi.dwCursorPosition.X + if mode == 0: + from_coord = csbi.dwCursorPosition + cells_to_erase = cells_in_screen - cells_before_cursor + elif mode == 1: + from_coord = win32.COORD(0, 0) + cells_to_erase = cells_before_cursor + elif mode == 2: + from_coord = win32.COORD(0, 0) + cells_to_erase = cells_in_screen + else: + # invalid mode + return + # fill the entire screen with blanks + win32.FillConsoleOutputCharacter(handle, ' ', cells_to_erase, from_coord) + # now set the buffer's attributes accordingly + win32.FillConsoleOutputAttribute(handle, self.get_attrs(), cells_to_erase, from_coord) + if mode == 2: + # put the cursor where needed + win32.SetConsoleCursorPosition(handle, (1, 1)) + + def erase_line(self, mode=0, on_stderr=False): + # 0 should clear from the cursor to the end of the line. + # 1 should clear from the cursor to the beginning of the line. + # 2 should clear the entire line. + handle = win32.STDOUT + if on_stderr: + handle = win32.STDERR + csbi = win32.GetConsoleScreenBufferInfo(handle) + if mode == 0: + from_coord = csbi.dwCursorPosition + cells_to_erase = csbi.dwSize.X - csbi.dwCursorPosition.X + elif mode == 1: + from_coord = win32.COORD(0, csbi.dwCursorPosition.Y) + cells_to_erase = csbi.dwCursorPosition.X + elif mode == 2: + from_coord = win32.COORD(0, csbi.dwCursorPosition.Y) + cells_to_erase = csbi.dwSize.X + else: + # invalid mode + return + # fill the entire screen with blanks + win32.FillConsoleOutputCharacter(handle, ' ', cells_to_erase, from_coord) + # now set the buffer's attributes accordingly + win32.FillConsoleOutputAttribute(handle, self.get_attrs(), cells_to_erase, from_coord) + + def set_title(self, title): + win32.SetConsoleTitle(title) + + +def enable_vt_processing(fd): + if win32.windll is None or not win32.winapi_test(): + return False + + try: + handle = get_osfhandle(fd) + mode = win32.GetConsoleMode(handle) + win32.SetConsoleMode( + handle, + mode | win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING, + ) + + mode = win32.GetConsoleMode(handle) + if mode & win32.ENABLE_VIRTUAL_TERMINAL_PROCESSING: + return True + # Can get TypeError in testsuite where 'fd' is a Mock() + except (OSError, TypeError): + return False diff --git a/server/venv/Lib/site-packages/comerr64.dll b/server/venv/Lib/site-packages/comerr64.dll new file mode 100644 index 0000000..a067ea5 Binary files /dev/null and b/server/venv/Lib/site-packages/comerr64.dll differ diff --git a/server/venv/Lib/site-packages/dotenv/__init__.py b/server/venv/Lib/site-packages/dotenv/__init__.py new file mode 100644 index 0000000..dde24a0 --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/__init__.py @@ -0,0 +1,51 @@ +from typing import Any, Optional + +from .main import dotenv_values, find_dotenv, get_key, load_dotenv, set_key, unset_key + + +def load_ipython_extension(ipython: Any) -> None: + from .ipython import load_ipython_extension + + load_ipython_extension(ipython) + + +def get_cli_string( + path: Optional[str] = None, + action: Optional[str] = None, + key: Optional[str] = None, + value: Optional[str] = None, + quote: Optional[str] = None, +): + """Returns a string suitable for running as a shell script. + + Useful for converting a arguments passed to a fabric task + to be passed to a `local` or `run` command. + """ + command = ["dotenv"] + if quote: + command.append(f"-q {quote}") + if path: + command.append(f"-f {path}") + if action: + command.append(action) + if key: + command.append(key) + if value: + if " " in value: + command.append(f'"{value}"') + else: + command.append(value) + + return " ".join(command).strip() + + +__all__ = [ + "get_cli_string", + "load_dotenv", + "dotenv_values", + "get_key", + "set_key", + "unset_key", + "find_dotenv", + "load_ipython_extension", +] diff --git a/server/venv/Lib/site-packages/dotenv/__main__.py b/server/venv/Lib/site-packages/dotenv/__main__.py new file mode 100644 index 0000000..3977f55 --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for cli, enables execution with `python -m dotenv`""" + +from .cli import cli + +if __name__ == "__main__": + cli() diff --git a/server/venv/Lib/site-packages/dotenv/cli.py b/server/venv/Lib/site-packages/dotenv/cli.py new file mode 100644 index 0000000..47eec04 --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/cli.py @@ -0,0 +1,236 @@ +import json +import os +import shlex +import sys +from contextlib import contextmanager +from typing import IO, Any, Dict, Iterator, List, Optional + +if sys.platform == "win32": + from subprocess import Popen + +try: + import click +except ImportError: + sys.stderr.write( + "It seems python-dotenv is not installed with cli option. \n" + 'Run pip install "python-dotenv[cli]" to fix this.' + ) + sys.exit(1) + +from .main import dotenv_values, set_key, unset_key +from .version import __version__ + + +def enumerate_env() -> Optional[str]: + """ + Return a path for the ${pwd}/.env file. + + If pwd does not exist, return None. + """ + try: + cwd = os.getcwd() + except FileNotFoundError: + return None + path = os.path.join(cwd, ".env") + return path + + +@click.group() +@click.option( + "-f", + "--file", + default=enumerate_env(), + type=click.Path(file_okay=True), + help="Location of the .env file, defaults to .env file in current working directory.", +) +@click.option( + "-q", + "--quote", + default="always", + type=click.Choice(["always", "never", "auto"]), + help="Whether to quote or not the variable values. Default mode is always. This does not affect parsing.", +) +@click.option( + "-e", + "--export", + default=False, + type=click.BOOL, + help="Whether to write the dot file as an executable bash script.", +) +@click.version_option(version=__version__) +@click.pass_context +def cli(ctx: click.Context, file: Any, quote: Any, export: Any) -> None: + """This script is used to set, get or unset values from a .env file.""" + ctx.obj = {"QUOTE": quote, "EXPORT": export, "FILE": file} + + +@contextmanager +def stream_file(path: os.PathLike) -> Iterator[IO[str]]: + """ + Open a file and yield the corresponding (decoded) stream. + + Exits with error code 2 if the file cannot be opened. + """ + + try: + with open(path) as stream: + yield stream + except OSError as exc: + print(f"Error opening env file: {exc}", file=sys.stderr) + sys.exit(2) + + +@cli.command(name="list") +@click.pass_context +@click.option( + "--format", + "output_format", + default="simple", + type=click.Choice(["simple", "json", "shell", "export"]), + help="The format in which to display the list. Default format is simple, " + "which displays name=value without quotes.", +) +def list_values(ctx: click.Context, output_format: str) -> None: + """Display all the stored key/value.""" + file = ctx.obj["FILE"] + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + if output_format == "json": + click.echo(json.dumps(values, indent=2, sort_keys=True)) + else: + prefix = "export " if output_format == "export" else "" + for k in sorted(values): + v = values[k] + if v is not None: + if output_format in ("export", "shell"): + v = shlex.quote(v) + click.echo(f"{prefix}{k}={v}") + + +@cli.command(name="set") +@click.pass_context +@click.argument("key", required=True) +@click.argument("value", required=True) +def set_value(ctx: click.Context, key: Any, value: Any) -> None: + """ + Store the given key/value. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ + + file = ctx.obj["FILE"] + quote = ctx.obj["QUOTE"] + export = ctx.obj["EXPORT"] + success, key, value = set_key(file, key, value, quote, export) + if success: + click.echo(f"{key}={value}") + else: + sys.exit(1) + + +@cli.command() +@click.pass_context +@click.argument("key", required=True) +def get(ctx: click.Context, key: Any) -> None: + """Retrieve the value for the given key.""" + file = ctx.obj["FILE"] + + with stream_file(file) as stream: + values = dotenv_values(stream=stream) + + stored_value = values.get(key) + if stored_value: + click.echo(stored_value) + else: + sys.exit(1) + + +@cli.command() +@click.pass_context +@click.argument("key", required=True) +def unset(ctx: click.Context, key: Any) -> None: + """ + Removes the given key. + + This doesn't follow symlinks, to avoid accidentally modifying a file at a + potentially untrusted path. + """ + file = ctx.obj["FILE"] + quote = ctx.obj["QUOTE"] + success, key = unset_key(file, key, quote) + if success: + click.echo(f"Successfully removed {key}") + else: + sys.exit(1) + + +@cli.command( + context_settings={ + "allow_extra_args": True, + "allow_interspersed_args": False, + "ignore_unknown_options": True, + } +) +@click.pass_context +@click.option( + "--override/--no-override", + default=True, + help="Override variables from the environment file with those from the .env file.", +) +@click.argument("commandline", nargs=-1, type=click.UNPROCESSED) +def run(ctx: click.Context, override: bool, commandline: tuple[str, ...]) -> None: + """Run command with environment variables present.""" + file = ctx.obj["FILE"] + if not os.path.isfile(file): + raise click.BadParameter( + f"Invalid value for '-f' \"{file}\" does not exist.", ctx=ctx + ) + dotenv_as_dict = { + k: v + for (k, v) in dotenv_values(file).items() + if v is not None and (override or k not in os.environ) + } + + if not commandline: + click.echo("No command given.") + sys.exit(1) + + run_command([*commandline, *ctx.args], dotenv_as_dict) + + +def run_command(command: List[str], env: Dict[str, str]) -> None: + """Replace the current process with the specified command. + + Replaces the current process with the specified command and the variables from `env` + added in the current environment variables. + + Parameters + ---------- + command: List[str] + The command and it's parameters + env: Dict + The additional environment variables + + Returns + ------- + None + This function does not return any value. It replaces the current process with the new one. + + """ + # copy the current environment variables and add the vales from + # `env` + cmd_env = os.environ.copy() + cmd_env.update(env) + + if sys.platform == "win32": + # execvpe on Windows returns control immediately + # rather than once the command has finished. + p = Popen(command, universal_newlines=True, bufsize=0, shell=False, env=cmd_env) + _, _ = p.communicate() + + sys.exit(p.returncode) + else: + os.execvpe(command[0], args=command, env=cmd_env) diff --git a/server/venv/Lib/site-packages/dotenv/ipython.py b/server/venv/Lib/site-packages/dotenv/ipython.py new file mode 100644 index 0000000..4e7edbb --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/ipython.py @@ -0,0 +1,50 @@ +from IPython.core.magic import Magics, line_magic, magics_class # type: ignore +from IPython.core.magic_arguments import ( + argument, + magic_arguments, + parse_argstring, +) # type: ignore + +from .main import find_dotenv, load_dotenv + + +@magics_class +class IPythonDotEnv(Magics): + @magic_arguments() + @argument( + "-o", + "--override", + action="store_true", + help="Indicate to override existing variables", + ) + @argument( + "-v", + "--verbose", + action="store_true", + help="Indicate function calls to be verbose", + ) + @argument( + "dotenv_path", + nargs="?", + type=str, + default=".env", + help="Search in increasingly higher folders for the `dotenv_path`", + ) + @line_magic + def dotenv(self, line): + args = parse_argstring(self.dotenv, line) + # Locate the .env file + dotenv_path = args.dotenv_path + try: + dotenv_path = find_dotenv(dotenv_path, True, True) + except IOError: + print("cannot find .env file") + return + + # Load the .env file + load_dotenv(dotenv_path, verbose=args.verbose, override=args.override) + + +def load_ipython_extension(ipython): + """Register the %dotenv magic.""" + ipython.register_magics(IPythonDotEnv) diff --git a/server/venv/Lib/site-packages/dotenv/main.py b/server/venv/Lib/site-packages/dotenv/main.py new file mode 100644 index 0000000..48e5245 --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/main.py @@ -0,0 +1,480 @@ +import io +import logging +import os +import pathlib +import stat +import sys +import tempfile +from collections import OrderedDict +from contextlib import contextmanager +from typing import IO, Dict, Iterable, Iterator, Mapping, Optional, Tuple, Union + +from .parser import Binding, parse_stream +from .variables import parse_variables + +# A type alias for a string path to be used for the paths in this file. +# These paths may flow to `open()` and `os.replace()`. +StrPath = Union[str, "os.PathLike[str]"] + +logger = logging.getLogger(__name__) + + +def _load_dotenv_disabled() -> bool: + """ + Determine if dotenv loading has been disabled. + """ + if "PYTHON_DOTENV_DISABLED" not in os.environ: + return False + value = os.environ["PYTHON_DOTENV_DISABLED"].casefold() + return value in {"1", "true", "t", "yes", "y"} + + +def with_warn_for_invalid_lines(mappings: Iterator[Binding]) -> Iterator[Binding]: + for mapping in mappings: + if mapping.error: + logger.warning( + "python-dotenv could not parse statement starting at line %s", + mapping.original.line, + ) + yield mapping + + +class DotEnv: + def __init__( + self, + dotenv_path: Optional[StrPath], + stream: Optional[IO[str]] = None, + verbose: bool = False, + encoding: Optional[str] = None, + interpolate: bool = True, + override: bool = True, + ) -> None: + self.dotenv_path: Optional[StrPath] = dotenv_path + self.stream: Optional[IO[str]] = stream + self._dict: Optional[Dict[str, Optional[str]]] = None + self.verbose: bool = verbose + self.encoding: Optional[str] = encoding + self.interpolate: bool = interpolate + self.override: bool = override + + @contextmanager + def _get_stream(self) -> Iterator[IO[str]]: + if self.dotenv_path and _is_file_or_fifo(self.dotenv_path): + with open(self.dotenv_path, encoding=self.encoding) as stream: + yield stream + elif self.stream is not None: + yield self.stream + else: + if self.verbose: + logger.info( + "python-dotenv could not find configuration file %s.", + self.dotenv_path or ".env", + ) + yield io.StringIO("") + + def dict(self) -> Dict[str, Optional[str]]: + """Return dotenv as dict""" + if self._dict: + return self._dict + + raw_values = self.parse() + + if self.interpolate: + self._dict = OrderedDict( + resolve_variables(raw_values, override=self.override) + ) + else: + self._dict = OrderedDict(raw_values) + + return self._dict + + def parse(self) -> Iterator[Tuple[str, Optional[str]]]: + with self._get_stream() as stream: + for mapping in with_warn_for_invalid_lines(parse_stream(stream)): + if mapping.key is not None: + yield mapping.key, mapping.value + + def set_as_environment_variables(self) -> bool: + """ + Load the current dotenv as system environment variable. + """ + if not self.dict(): + return False + + for k, v in self.dict().items(): + if k in os.environ and not self.override: + continue + if v is not None: + os.environ[k] = v + + return True + + def get(self, key: str) -> Optional[str]: + """ """ + data = self.dict() + + if key in data: + return data[key] + + if self.verbose: + logger.warning("Key %s not found in %s.", key, self.dotenv_path) + + return None + + +def get_key( + dotenv_path: StrPath, + key_to_get: str, + encoding: Optional[str] = "utf-8", +) -> Optional[str]: + """ + Get the value of a given key from the given .env. + + Returns `None` if the key isn't found or doesn't have a value. + """ + return DotEnv(dotenv_path, verbose=True, encoding=encoding).get(key_to_get) + + +@contextmanager +def rewrite( + path: StrPath, + encoding: Optional[str], + follow_symlinks: bool = False, +) -> Iterator[Tuple[IO[str], IO[str]]]: + if follow_symlinks: + path = os.path.realpath(path) + + try: + source: IO[str] = open(path, encoding=encoding) + try: + path_stat = os.lstat(path) + original_mode: Optional[int] = ( + stat.S_IMODE(path_stat.st_mode) + if stat.S_ISREG(path_stat.st_mode) + else None + ) + except BaseException: + source.close() + raise + except FileNotFoundError: + source = io.StringIO("") + original_mode = None + + with tempfile.NamedTemporaryFile( + mode="w", + encoding=encoding, + delete=False, + prefix=".tmp_", + dir=os.path.dirname(os.path.abspath(path)), + ) as dest: + dest_path = pathlib.Path(dest.name) + error = None + + try: + with source: + yield (source, dest) + except BaseException as err: + error = err + + if error is None: + try: + if original_mode is not None: + os.chmod(dest_path, original_mode) + + os.replace(dest_path, path) + except BaseException: + dest_path.unlink(missing_ok=True) + raise + else: + dest_path.unlink(missing_ok=True) + raise error from None + + +def set_key( + dotenv_path: StrPath, + key_to_set: str, + value_to_set: str, + quote_mode: str = "always", + export: bool = False, + encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, +) -> Tuple[Optional[bool], str, str]: + """ + Adds or Updates a key/value to the given .env + + The target .env file is created if it doesn't exist. + + This function doesn't follow symlinks by default, to avoid accidentally + modifying a file at a potentially untrusted path. If you don't need this + protection and need symlinks to be followed, use `follow_symlinks`. + """ + if quote_mode not in ("always", "auto", "never"): + raise ValueError(f"Unknown quote_mode: {quote_mode}") + + quote = quote_mode == "always" or ( + quote_mode == "auto" and not value_to_set.isalnum() + ) + + if quote: + value_out = "'{}'".format(value_to_set.replace("'", "\\'")) + else: + value_out = value_to_set + if export: + line_out = f"export {key_to_set}={value_out}\n" + else: + line_out = f"{key_to_set}={value_out}\n" + + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): + replaced = False + missing_newline = False + for mapping in with_warn_for_invalid_lines(parse_stream(source)): + if mapping.key == key_to_set: + dest.write(line_out) + replaced = True + else: + dest.write(mapping.original.string) + missing_newline = not mapping.original.string.endswith("\n") + if not replaced: + if missing_newline: + dest.write("\n") + dest.write(line_out) + + return True, key_to_set, value_to_set + + +def unset_key( + dotenv_path: StrPath, + key_to_unset: str, + quote_mode: str = "always", + encoding: Optional[str] = "utf-8", + follow_symlinks: bool = False, +) -> Tuple[Optional[bool], str]: + """ + Removes a given key from the given `.env` file. + + If the .env path given doesn't exist, fails. + If the given key doesn't exist in the .env, fails. + + This function doesn't follow symlinks by default, to avoid accidentally + modifying a file at a potentially untrusted path. If you don't need this + protection and need symlinks to be followed, use `follow_symlinks`. + """ + if not os.path.exists(dotenv_path): + logger.warning("Can't delete from %s - it doesn't exist.", dotenv_path) + return None, key_to_unset + + removed = False + with rewrite(dotenv_path, encoding=encoding, follow_symlinks=follow_symlinks) as ( + source, + dest, + ): + for mapping in with_warn_for_invalid_lines(parse_stream(source)): + if mapping.key == key_to_unset: + removed = True + else: + dest.write(mapping.original.string) + + if not removed: + logger.warning( + "Key %s not removed from %s - key doesn't exist.", key_to_unset, dotenv_path + ) + return None, key_to_unset + + return removed, key_to_unset + + +def resolve_variables( + values: Iterable[Tuple[str, Optional[str]]], + override: bool, +) -> Mapping[str, Optional[str]]: + new_values: Dict[str, Optional[str]] = {} + + for name, value in values: + if value is None: + result = None + else: + atoms = parse_variables(value) + env: Dict[str, Optional[str]] = {} + if override: + env.update(os.environ) # type: ignore + env.update(new_values) + else: + env.update(new_values) + env.update(os.environ) # type: ignore + result = "".join(atom.resolve(env) for atom in atoms) + + new_values[name] = result + + return new_values + + +def _walk_to_root(path: str) -> Iterator[str]: + """ + Yield directories starting from the given directory up to the root + """ + if not os.path.exists(path): + raise IOError("Starting path not found") + + if os.path.isfile(path): + path = os.path.dirname(path) + + last_dir = None + current_dir = os.path.abspath(path) + while last_dir != current_dir: + yield current_dir + parent_dir = os.path.abspath(os.path.join(current_dir, os.path.pardir)) + last_dir, current_dir = current_dir, parent_dir + + +def find_dotenv( + filename: str = ".env", + raise_error_if_not_found: bool = False, + usecwd: bool = False, +) -> str: + """ + Search in increasingly higher folders for the given file + + Returns path to the file if found, or an empty string otherwise + """ + + def _is_interactive(): + """Decide whether this is running in a REPL or IPython notebook""" + if hasattr(sys, "ps1") or hasattr(sys, "ps2"): + return True + try: + main = __import__("__main__", None, None, fromlist=["__file__"]) + except ModuleNotFoundError: + return False + return not hasattr(main, "__file__") + + def _is_debugger(): + return sys.gettrace() is not None + + if usecwd or _is_interactive() or _is_debugger() or getattr(sys, "frozen", False): + # Should work without __file__, e.g. in REPL or IPython notebook. + path = os.getcwd() + else: + # will work for .py files + frame = sys._getframe() + current_file = __file__ + + while frame.f_code.co_filename == current_file or not os.path.exists( + frame.f_code.co_filename + ): + assert frame.f_back is not None + frame = frame.f_back + frame_filename = frame.f_code.co_filename + path = os.path.dirname(os.path.abspath(frame_filename)) + + for dirname in _walk_to_root(path): + check_path = os.path.join(dirname, filename) + if _is_file_or_fifo(check_path): + return check_path + + if raise_error_if_not_found: + raise IOError("File not found") + + return "" + + +def load_dotenv( + dotenv_path: Optional[StrPath] = None, + stream: Optional[IO[str]] = None, + verbose: bool = False, + override: bool = False, + interpolate: bool = True, + encoding: Optional[str] = "utf-8", +) -> bool: + """Parse a .env file and then load all the variables found as environment variables. + + Parameters: + dotenv_path: Absolute or relative path to .env file. + stream: Text stream (such as `io.StringIO`) with .env content, used if + `dotenv_path` is `None`. + verbose: Whether to output a warning the .env file is missing. + override: Whether to override the system environment variables with the variables + from the `.env` file. + encoding: Encoding to be used to read the file. + Returns: + Bool: True if at least one environment variable is set else False + + If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the + .env file with it's default parameters. If you need to change the default parameters + of `find_dotenv()`, you can explicitly call `find_dotenv()` and pass the result + to this function as `dotenv_path`. + + If the environment variable `PYTHON_DOTENV_DISABLED` is set to a truthy value, + .env loading is disabled. + """ + if _load_dotenv_disabled(): + logger.debug( + "python-dotenv: .env loading disabled by PYTHON_DOTENV_DISABLED environment variable" + ) + return False + + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + + dotenv = DotEnv( + dotenv_path=dotenv_path, + stream=stream, + verbose=verbose, + interpolate=interpolate, + override=override, + encoding=encoding, + ) + return dotenv.set_as_environment_variables() + + +def dotenv_values( + dotenv_path: Optional[StrPath] = None, + stream: Optional[IO[str]] = None, + verbose: bool = False, + interpolate: bool = True, + encoding: Optional[str] = "utf-8", +) -> Dict[str, Optional[str]]: + """ + Parse a .env file and return its content as a dict. + + The returned dict will have `None` values for keys without values in the .env file. + For example, `foo=bar` results in `{"foo": "bar"}` whereas `foo` alone results in + `{"foo": None}` + + Parameters: + dotenv_path: Absolute or relative path to the .env file. + stream: `StringIO` object with .env content, used if `dotenv_path` is `None`. + verbose: Whether to output a warning if the .env file is missing. + encoding: Encoding to be used to read the file. + + If both `dotenv_path` and `stream` are `None`, `find_dotenv()` is used to find the + .env file. + """ + if dotenv_path is None and stream is None: + dotenv_path = find_dotenv() + + return DotEnv( + dotenv_path=dotenv_path, + stream=stream, + verbose=verbose, + interpolate=interpolate, + override=True, + encoding=encoding, + ).dict() + + +def _is_file_or_fifo(path: StrPath) -> bool: + """ + Return True if `path` exists and is either a regular file or a FIFO. + """ + if os.path.isfile(path): + return True + + try: + st = os.stat(path) + except (FileNotFoundError, OSError): + return False + + return stat.S_ISFIFO(st.st_mode) diff --git a/server/venv/Lib/site-packages/dotenv/parser.py b/server/venv/Lib/site-packages/dotenv/parser.py new file mode 100644 index 0000000..eb100b4 --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/parser.py @@ -0,0 +1,182 @@ +import codecs +import re +from typing import ( + IO, + Iterator, + Match, + NamedTuple, + Optional, + Pattern, + Sequence, +) + + +def make_regex(string: str, extra_flags: int = 0) -> Pattern[str]: + return re.compile(string, re.UNICODE | extra_flags) + + +_newline = make_regex(r"(\r\n|\n|\r)") +_multiline_whitespace = make_regex(r"\s*", extra_flags=re.MULTILINE) +_whitespace = make_regex(r"[^\S\r\n]*") +_export = make_regex(r"(?:export[^\S\r\n]+)?") +_single_quoted_key = make_regex(r"'([^']+)'") +_unquoted_key = make_regex(r"([^=\#\s]+)") +_equal_sign = make_regex(r"(=[^\S\r\n]*)") +_single_quoted_value = make_regex(r"'((?:\\'|[^'])*)'") +_double_quoted_value = make_regex(r'"((?:\\"|[^"])*)"') +_unquoted_value = make_regex(r"([^\r\n]*)") +_comment = make_regex(r"(?:[^\S\r\n]*#[^\r\n]*)?") +_end_of_line = make_regex(r"[^\S\r\n]*(?:\r\n|\n|\r|$)") +_rest_of_line = make_regex(r"[^\r\n]*(?:\r|\n|\r\n)?") +_double_quote_escapes = make_regex(r"\\[\\'\"abfnrtv]") +_single_quote_escapes = make_regex(r"\\[\\']") + + +class Original(NamedTuple): + string: str + line: int + + +class Binding(NamedTuple): + key: Optional[str] + value: Optional[str] + original: Original + error: bool + + +class Position: + def __init__(self, chars: int, line: int) -> None: + self.chars = chars + self.line = line + + @classmethod + def start(cls) -> "Position": + return cls(chars=0, line=1) + + def set(self, other: "Position") -> None: + self.chars = other.chars + self.line = other.line + + def advance(self, string: str) -> None: + self.chars += len(string) + self.line += len(re.findall(_newline, string)) + + +class Error(Exception): + pass + + +class Reader: + def __init__(self, stream: IO[str]) -> None: + self.string = stream.read() + self.position = Position.start() + self.mark = Position.start() + + def has_next(self) -> bool: + return self.position.chars < len(self.string) + + def set_mark(self) -> None: + self.mark.set(self.position) + + def get_marked(self) -> Original: + return Original( + string=self.string[self.mark.chars : self.position.chars], + line=self.mark.line, + ) + + def peek(self, count: int) -> str: + return self.string[self.position.chars : self.position.chars + count] + + def read(self, count: int) -> str: + result = self.string[self.position.chars : self.position.chars + count] + if len(result) < count: + raise Error("read: End of string") + self.position.advance(result) + return result + + def read_regex(self, regex: Pattern[str]) -> Sequence[str]: + match = regex.match(self.string, self.position.chars) + if match is None: + raise Error("read_regex: Pattern not found") + self.position.advance(self.string[match.start() : match.end()]) + return match.groups() + + +def decode_escapes(regex: Pattern[str], string: str) -> str: + def decode_match(match: Match[str]) -> str: + return codecs.decode(match.group(0), "unicode-escape") # type: ignore + + return regex.sub(decode_match, string) + + +def parse_key(reader: Reader) -> Optional[str]: + char = reader.peek(1) + if char == "#": + return None + elif char == "'": + (key,) = reader.read_regex(_single_quoted_key) + else: + (key,) = reader.read_regex(_unquoted_key) + return key + + +def parse_unquoted_value(reader: Reader) -> str: + (part,) = reader.read_regex(_unquoted_value) + return re.sub(r"\s+#.*", "", part).rstrip() + + +def parse_value(reader: Reader) -> str: + char = reader.peek(1) + if char == "'": + (value,) = reader.read_regex(_single_quoted_value) + return decode_escapes(_single_quote_escapes, value) + elif char == '"': + (value,) = reader.read_regex(_double_quoted_value) + return decode_escapes(_double_quote_escapes, value) + elif char in ("", "\n", "\r"): + return "" + else: + return parse_unquoted_value(reader) + + +def parse_binding(reader: Reader) -> Binding: + reader.set_mark() + try: + reader.read_regex(_multiline_whitespace) + if not reader.has_next(): + return Binding( + key=None, + value=None, + original=reader.get_marked(), + error=False, + ) + reader.read_regex(_export) + key = parse_key(reader) + reader.read_regex(_whitespace) + if reader.peek(1) == "=": + reader.read_regex(_equal_sign) + value: Optional[str] = parse_value(reader) + else: + value = None + reader.read_regex(_comment) + reader.read_regex(_end_of_line) + return Binding( + key=key, + value=value, + original=reader.get_marked(), + error=False, + ) + except Error: + reader.read_regex(_rest_of_line) + return Binding( + key=None, + value=None, + original=reader.get_marked(), + error=True, + ) + + +def parse_stream(stream: IO[str]) -> Iterator[Binding]: + reader = Reader(stream) + while reader.has_next(): + yield parse_binding(reader) diff --git a/server/venv/Lib/site-packages/dotenv/py.typed b/server/venv/Lib/site-packages/dotenv/py.typed new file mode 100644 index 0000000..7632ecf --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 diff --git a/server/venv/Lib/site-packages/dotenv/variables.py b/server/venv/Lib/site-packages/dotenv/variables.py new file mode 100644 index 0000000..667f2f2 --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/variables.py @@ -0,0 +1,86 @@ +import re +from abc import ABCMeta, abstractmethod +from typing import Iterator, Mapping, Optional, Pattern + +_posix_variable: Pattern[str] = re.compile( + r""" + \$\{ + (?P[^\}:]*) + (?::- + (?P[^\}]*) + )? + \} + """, + re.VERBOSE, +) + + +class Atom(metaclass=ABCMeta): + def __ne__(self, other: object) -> bool: + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + return not result + + @abstractmethod + def resolve(self, env: Mapping[str, Optional[str]]) -> str: ... + + +class Literal(Atom): + def __init__(self, value: str) -> None: + self.value = value + + def __repr__(self) -> str: + return f"Literal(value={self.value})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return NotImplemented + return self.value == other.value + + def __hash__(self) -> int: + return hash((self.__class__, self.value)) + + def resolve(self, env: Mapping[str, Optional[str]]) -> str: + return self.value + + +class Variable(Atom): + def __init__(self, name: str, default: Optional[str]) -> None: + self.name = name + self.default = default + + def __repr__(self) -> str: + return f"Variable(name={self.name}, default={self.default})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, self.__class__): + return NotImplemented + return (self.name, self.default) == (other.name, other.default) + + def __hash__(self) -> int: + return hash((self.__class__, self.name, self.default)) + + def resolve(self, env: Mapping[str, Optional[str]]) -> str: + default = self.default if self.default is not None else "" + result = env.get(self.name, default) + return result if result is not None else "" + + +def parse_variables(value: str) -> Iterator[Atom]: + cursor = 0 + + for match in _posix_variable.finditer(value): + (start, end) = match.span() + name = match["name"] + default = match["default"] + + if start > cursor: + yield Literal(value=value[cursor:start]) + + yield Variable(name=name, default=default) + cursor = end + + length = len(value) + if cursor < length: + yield Literal(value=value[cursor:length]) diff --git a/server/venv/Lib/site-packages/dotenv/version.py b/server/venv/Lib/site-packages/dotenv/version.py new file mode 100644 index 0000000..bc86c94 --- /dev/null +++ b/server/venv/Lib/site-packages/dotenv/version.py @@ -0,0 +1 @@ +__version__ = "1.2.2" diff --git a/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/INSTALLER b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/METADATA b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/METADATA new file mode 100644 index 0000000..bf3259f --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/METADATA @@ -0,0 +1,616 @@ +Metadata-Version: 2.4 +Name: fastapi +Version: 0.138.0 +Summary: FastAPI framework, high performance, easy to learn, fast to code, ready for production +Author-Email: =?utf-8?q?Sebasti=C3=A1n_Ram=C3=ADrez?= +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: Environment :: Web Environment +Classifier: Framework :: AsyncIO +Classifier: Framework :: FastAPI +Classifier: Framework :: Pydantic +Classifier: Framework :: Pydantic :: 2 +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 :: Only +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: Topic :: Internet :: WWW/HTTP :: HTTP Servers +Classifier: Topic :: Internet :: WWW/HTTP +Project-URL: Homepage, https://github.com/fastapi/fastapi +Project-URL: Documentation, https://fastapi.tiangolo.com/ +Project-URL: Repository, https://github.com/fastapi/fastapi +Project-URL: Issues, https://github.com/fastapi/fastapi/issues +Project-URL: Changelog, https://fastapi.tiangolo.com/release-notes/ +Requires-Python: >=3.10 +Requires-Dist: starlette>=0.46.0 +Requires-Dist: pydantic>=2.9.0 +Requires-Dist: typing-extensions>=4.8.0 +Requires-Dist: typing-inspection>=0.4.2 +Requires-Dist: annotated-doc>=0.0.2 +Provides-Extra: standard +Requires-Dist: fastapi-cli[standard]>=0.0.8; extra == "standard" +Requires-Dist: fastar>=0.9.0; extra == "standard" +Requires-Dist: httpx<1.0.0,>=0.23.0; extra == "standard" +Requires-Dist: jinja2>=3.1.5; extra == "standard" +Requires-Dist: python-multipart>=0.0.18; extra == "standard" +Requires-Dist: email-validator>=2.0.0; extra == "standard" +Requires-Dist: uvicorn[standard]>=0.12.0; extra == "standard" +Requires-Dist: pydantic-settings>=2.0.0; extra == "standard" +Requires-Dist: pydantic-extra-types>=2.0.0; extra == "standard" +Provides-Extra: standard-no-fastapi-cloud-cli +Requires-Dist: fastapi-cli[standard-no-fastapi-cloud-cli]>=0.0.8; extra == "standard-no-fastapi-cloud-cli" +Requires-Dist: httpx<1.0.0,>=0.23.0; extra == "standard-no-fastapi-cloud-cli" +Requires-Dist: jinja2>=3.1.5; extra == "standard-no-fastapi-cloud-cli" +Requires-Dist: python-multipart>=0.0.18; extra == "standard-no-fastapi-cloud-cli" +Requires-Dist: email-validator>=2.0.0; extra == "standard-no-fastapi-cloud-cli" +Requires-Dist: uvicorn[standard]>=0.12.0; extra == "standard-no-fastapi-cloud-cli" +Requires-Dist: pydantic-settings>=2.0.0; extra == "standard-no-fastapi-cloud-cli" +Requires-Dist: pydantic-extra-types>=2.0.0; extra == "standard-no-fastapi-cloud-cli" +Provides-Extra: all +Requires-Dist: fastapi-cli[standard]>=0.0.8; extra == "all" +Requires-Dist: httpx<1.0.0,>=0.23.0; extra == "all" +Requires-Dist: jinja2>=3.1.5; extra == "all" +Requires-Dist: python-multipart>=0.0.18; extra == "all" +Requires-Dist: itsdangerous>=1.1.0; extra == "all" +Requires-Dist: pyyaml>=5.3.1; extra == "all" +Requires-Dist: email-validator>=2.0.0; extra == "all" +Requires-Dist: uvicorn[standard]>=0.12.0; extra == "all" +Requires-Dist: pydantic-settings>=2.0.0; extra == "all" +Requires-Dist: pydantic-extra-types>=2.0.0; extra == "all" +Description-Content-Type: text/markdown + +

+ FastAPI +

+

+ FastAPI framework, high performance, easy to learn, fast to code, ready for production +

+

+ + Test + + + Coverage + + + Package version + + + Supported Python versions + +

+ +--- + +**Documentation**: [https://fastapi.tiangolo.com](https://fastapi.tiangolo.com) + +**Source Code**: [https://github.com/fastapi/fastapi](https://github.com/fastapi/fastapi) + +--- + +FastAPI is a modern, fast (high-performance), web framework for building APIs with Python based on standard Python type hints. + +The key features are: + +* **Fast**: Very high performance, on par with **NodeJS** and **Go** (thanks to Starlette and Pydantic). [One of the fastest Python frameworks available](#performance). +* **Fast to code**: Increase the speed to develop features by about 200% to 300%. * +* **Fewer bugs**: Reduce about 40% of human (developer) induced errors. * +* **Intuitive**: Great editor support. Completion everywhere. Less time debugging. +* **Easy**: Designed to be easy to use and learn. Less time reading docs. +* **Short**: Minimize code duplication. Multiple features from each parameter declaration. Fewer bugs. +* **Robust**: Get production-ready code. With automatic interactive documentation. +* **Standards-based**: Based on (and fully compatible with) the open standards for APIs: [OpenAPI](https://github.com/OAI/OpenAPI-Specification) (previously known as Swagger) and [JSON Schema](https://json-schema.org/). + +* estimation based on tests conducted by an internal development team, building production applications. + +## Sponsors + + +### Keystone Sponsor + + + +### Gold Sponsors + + + + + + + + + + +### Silver Sponsors + + + + + + + + + + + +[Other sponsors](https://fastapi.tiangolo.com/fastapi-people/#sponsors) + +## Opinions + + + +
+ +"_[...] I'm using **FastAPI** a ton these days. [...] I'm actually planning to use it for all of my team's **ML services at Microsoft**. Some of them are getting integrated into the core **Windows** product and some **Office** products._" + +
Kabir Khan - Microsoft (ref)
+ +--- + +"_We adopted the **FastAPI** library to spawn a **REST** server that can be queried to obtain **predictions**. [for Ludwig]_" + +
Piero Molino, Yaroslav Dudin, and Sai Sumanth Miryala - Uber (ref)
+ +--- + +"_**Netflix** is pleased to announce the open-source release of our **crisis management** orchestration framework: **Dispatch**! [built with **FastAPI**]_" + +
Kevin Glisson, Marc Vilanova, Forest Monsen - Netflix (ref)
+ +--- + +"_If anyone is looking to build a production Python API, I would highly recommend **FastAPI**. It is **beautifully designed**, **simple to use** and **highly scalable**, it has become a **key component** in our API first development strategy and is driving many automations and services such as our Virtual TAC Engineer._" + +
Deon Pillsbury - Cisco (ref)
+ +--- + +
+ +## FastAPI Conf + +[**FastAPI Conf '26**](https://fastapiconf.com) is happening on **October 28, 2026** in **Amsterdam, NL**. All about FastAPI, right from the source. 🎤 + +FastAPI Conf '26 - October 28, 2026 - Amsterdam, NL + +## FastAPI mini documentary + +There's a [FastAPI mini documentary](https://www.youtube.com/watch?v=mpR8ngthqiE) released at the end of 2025, you can watch it online: + +FastAPI Mini Documentary + +## **Typer**, the FastAPI of CLIs + + + +If you are building a CLI app to be used in the terminal instead of a web API, check out [**Typer**](https://typer.tiangolo.com/). + +**Typer** is FastAPI's little sibling. And it's intended to be the **FastAPI of CLIs**. ⌨️ 🚀 + +## Requirements + +FastAPI stands on the shoulders of giants: + +* [Starlette](https://www.starlette.dev/) for the web parts. +* [Pydantic](https://docs.pydantic.dev/) for the data parts. + +## Installation + +Create and activate a [virtual environment](https://fastapi.tiangolo.com/virtual-environments/) and then install FastAPI: + +
+ +```console +$ pip install "fastapi[standard]" + +---> 100% +``` + +
+ +**Note**: Make sure you put `"fastapi[standard]"` in quotes to ensure it works in all terminals. + +## Example + +### Create it + +Create a file `main.py` with: + +```Python +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"Hello": "World"} + + +@app.get("/items/{item_id}") +def read_item(item_id: int, q: str | None = None): + return {"item_id": item_id, "q": q} +``` + +
+Or use async def... + +If your code uses `async` / `await`, use `async def`: + +```Python hl_lines="7 12" +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +async def read_root(): + return {"Hello": "World"} + + +@app.get("/items/{item_id}") +async def read_item(item_id: int, q: str | None = None): + return {"item_id": item_id, "q": q} +``` + +**Note**: + +If you don't know, check the _"In a hurry?"_ section about [`async` and `await` in the docs](https://fastapi.tiangolo.com/async/#in-a-hurry). + +
+ +### Run it + +Run the server with: + +
+ +```console +$ fastapi dev + + ╭────────── FastAPI CLI - Development mode ───────────╮ + │ │ + │ Serving at: http://127.0.0.1:8000 │ + │ │ + │ API docs: http://127.0.0.1:8000/docs │ + │ │ + │ Running in development mode, for production use: │ + │ │ + │ fastapi run │ + │ │ + ╰─────────────────────────────────────────────────────╯ + +INFO: Will watch for changes in these directories: ['/home/user/code/awesomeapp'] +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [2248755] using WatchFiles +INFO: Started server process [2248757] +INFO: Waiting for application startup. +INFO: Application startup complete. +``` + +
+ +
+About the command fastapi dev... + +The command `fastapi dev` reads your `main.py` file automatically, detects the **FastAPI** app in it, and starts a server using [Uvicorn](https://www.uvicorn.dev). + +By default, `fastapi dev` will start with auto-reload enabled for local development. + +You can read more about it in the [FastAPI CLI docs](https://fastapi.tiangolo.com/fastapi-cli/). + +
+ +### Check it + +Open your browser at [http://127.0.0.1:8000/items/5?q=somequery](http://127.0.0.1:8000/items/5?q=somequery). + +You will see the JSON response as: + +```JSON +{"item_id": 5, "q": "somequery"} +``` + +You already created an API that: + +* Receives HTTP requests in the _paths_ `/` and `/items/{item_id}`. +* Both _paths_ take `GET` operations (also known as HTTP _methods_). +* The _path_ `/items/{item_id}` has a _path parameter_ `item_id` that should be an `int`. +* The _path_ `/items/{item_id}` has an optional `str` _query parameter_ `q`. + +### Interactive API docs + +Now go to [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs). + +You will see the automatic interactive API documentation (provided by [Swagger UI](https://github.com/swagger-api/swagger-ui)): + +![Swagger UI](https://fastapi.tiangolo.com/img/index/index-01-swagger-ui-simple.png) + +### Alternative API docs + +And now, go to [http://127.0.0.1:8000/redoc](http://127.0.0.1:8000/redoc). + +You will see the alternative automatic documentation (provided by [ReDoc](https://github.com/Rebilly/ReDoc)): + +![ReDoc](https://fastapi.tiangolo.com/img/index/index-02-redoc-simple.png) + +## Example upgrade + +Now modify the file `main.py` to receive a body from a `PUT` request. + +Declare the body using standard Python types, thanks to Pydantic. + +```Python hl_lines="2 7-10 23-25" +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + price: float + is_offer: bool | None = None + + +@app.get("/") +def read_root(): + return {"Hello": "World"} + + +@app.get("/items/{item_id}") +def read_item(item_id: int, q: str | None = None): + return {"item_id": item_id, "q": q} + + +@app.put("/items/{item_id}") +def update_item(item_id: int, item: Item): + return {"item_name": item.name, "item_id": item_id} +``` + +The `fastapi dev` server should reload automatically. + +### Interactive API docs upgrade + +Now go to [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs). + +* The interactive API documentation will be automatically updated, including the new body: + +![Swagger UI](https://fastapi.tiangolo.com/img/index/index-03-swagger-02.png) + +* Click on the button "Try it out", it allows you to fill the parameters and directly interact with the API: + +![Swagger UI interaction](https://fastapi.tiangolo.com/img/index/index-04-swagger-03.png) + +* Then click on the "Execute" button, the user interface will communicate with your API, send the parameters, get the results and show them on the screen: + +![Swagger UI interaction](https://fastapi.tiangolo.com/img/index/index-05-swagger-04.png) + +### Alternative API docs upgrade + +And now, go to [http://127.0.0.1:8000/redoc](http://127.0.0.1:8000/redoc). + +* The alternative documentation will also reflect the new query parameter and body: + +![ReDoc](https://fastapi.tiangolo.com/img/index/index-06-redoc-02.png) + +### Recap + +In summary, you declare **once** the types of parameters, body, etc. as function parameters. + +You do that with standard modern Python types. + +You don't have to learn a new syntax, the methods or classes of a specific library, etc. + +Just standard **Python**. + +For example, for an `int`: + +```Python +item_id: int +``` + +or for a more complex `Item` model: + +```Python +item: Item +``` + +...and with that single declaration you get: + +* Editor support, including: + * Completion. + * Type checks. +* Validation of data: + * Automatic and clear errors when the data is invalid. + * Validation even for deeply nested JSON objects. +* Conversion of input data: coming from the network to Python data and types. Reading from: + * JSON. + * Path parameters. + * Query parameters. + * Cookies. + * Headers. + * Forms. + * Files. +* Conversion of output data: converting from Python data and types to network data (as JSON): + * Convert Python types (`str`, `int`, `float`, `bool`, `list`, etc). + * `datetime` objects. + * `UUID` objects. + * Database models. + * ...and many more. +* Automatic interactive API documentation, including 2 alternative user interfaces: + * Swagger UI. + * ReDoc. + +--- + +Coming back to the previous code example, **FastAPI** will: + +* Validate that there is an `item_id` in the path for `GET` and `PUT` requests. +* Validate that the `item_id` is of type `int` for `GET` and `PUT` requests. + * If it is not, the client will see a useful, clear error. +* Check if there is an optional query parameter named `q` (as in `http://127.0.0.1:8000/items/foo?q=somequery`) for `GET` requests. + * As the `q` parameter is declared with `= None`, it is optional. + * Without the `None` it would be required (as is the body in the case with `PUT`). +* For `PUT` requests to `/items/{item_id}`, read the body as JSON: + * Check that it has a required attribute `name` that should be a `str`. + * Check that it has a required attribute `price` that has to be a `float`. + * Check that it has an optional attribute `is_offer`, that should be a `bool`, if present. + * All this would also work for deeply nested JSON objects. +* Convert from and to JSON automatically. +* Document everything with OpenAPI, that can be used by: + * Interactive documentation systems. + * Automatic client code generation systems, for many languages. +* Provide 2 interactive documentation web interfaces directly. + +--- + +We just scratched the surface, but you already get the idea of how it all works. + +Try changing the line with: + +```Python + return {"item_name": item.name, "item_id": item_id} +``` + +...from: + +```Python + ... "item_name": item.name ... +``` + +...to: + +```Python + ... "item_price": item.price ... +``` + +...and see how your editor will auto-complete the attributes and know their types: + +![editor support](https://fastapi.tiangolo.com/img/vscode-completion.png) + +For a more complete example including more features, see the Tutorial - User Guide. + +**Spoiler alert**: the tutorial - user guide includes: + +* Declaration of **parameters** from other different places such as: **headers**, **cookies**, **form fields** and **files**. +* How to set **validation constraints** such as `maximum_length` or `regex`. +* A very powerful and easy to use **Dependency Injection** system. +* Security and authentication, including support for **OAuth2** with **JWT tokens** and **HTTP Basic** auth. +* More advanced (but equally easy) techniques for declaring **deeply nested JSON models** (thanks to Pydantic). +* **GraphQL** integration with [Strawberry](https://strawberry.rocks) and other libraries. +* Many extra features (thanks to Starlette) such as: + * **WebSockets** + * extremely easy tests based on HTTPX and `pytest` + * **CORS** + * **Cookie Sessions** + * ...and more. + +### Deploy your app (optional) + +You can optionally deploy your FastAPI app to [FastAPI Cloud](https://fastapicloud.com) with a single command. 🚀 + +
+ +```console +$ fastapi deploy + +Deploying to FastAPI Cloud... + +✅ Deployment successful! + +🐔 Ready the chicken! Your app is ready at https://myapp.fastapicloud.dev +``` + +
+ +The CLI will automatically detect your FastAPI application and deploy it to the cloud. If you are not logged in, your browser will open to complete the authentication process. + +That's it! Now you can access your app at that URL. ✨ + +#### About FastAPI Cloud + +**[FastAPI Cloud](https://fastapicloud.com)** is built by the same author and team behind **FastAPI**. + +It streamlines the process of **building**, **deploying**, and **accessing** an API with minimal effort. + +It brings the same **developer experience** of building apps with FastAPI to **deploying** them to the cloud. 🎉 + +FastAPI Cloud is the primary sponsor and funding provider for the *FastAPI and friends* open source projects. ✨ + +#### Deploy to other cloud providers + +FastAPI is open source and based on standards. You can deploy FastAPI apps to any cloud provider you choose. + +Follow your cloud provider's guides to deploy FastAPI apps with them. 🤓 + +## Performance + +Independent TechEmpower benchmarks show **FastAPI** applications running under Uvicorn as [one of the fastest Python frameworks available](https://www.techempower.com/benchmarks/#section=test&runid=7464e520-0dc2-473d-bd34-dbdfd7e85911&hw=ph&test=query&l=zijzen-7), only below Starlette and Uvicorn themselves (used internally by FastAPI). (*) + +To understand more about it, see the section [Benchmarks](https://fastapi.tiangolo.com/benchmarks/). + +## Dependencies + +FastAPI depends on Pydantic and Starlette. + +### `standard` Dependencies + +When you install FastAPI with `pip install "fastapi[standard]"` it comes with the `standard` group of optional dependencies: + +Used by Pydantic: + +* [`email-validator`](https://github.com/JoshData/python-email-validator) - for email validation. + +Used by Starlette: + +* [`httpx`](https://www.python-httpx.org) - Required if you want to use the `TestClient`. +* [`jinja2`](https://jinja.palletsprojects.com) - Required if you want to use the default template configuration. +* [`python-multipart`](https://github.com/Kludex/python-multipart) - Required if you want to support form "parsing", with `request.form()`. + +Used by FastAPI: + +* [`uvicorn`](https://www.uvicorn.dev) - for the server that loads and serves your application. This includes `uvicorn[standard]`, which includes some dependencies (e.g. `uvloop`) needed for high performance serving. +* `fastapi-cli[standard]` - to provide the `fastapi` command. + * This includes `fastapi-cloud-cli`, which allows you to deploy your FastAPI application to [FastAPI Cloud](https://fastapicloud.com). + +### Without `standard` Dependencies + +If you don't want to include the `standard` optional dependencies, you can install with `pip install fastapi` instead of `pip install "fastapi[standard]"`. + +### Without `fastapi-cloud-cli` + +If you want to install FastAPI with the standard dependencies but without the `fastapi-cloud-cli`, you can install with `pip install "fastapi[standard-no-fastapi-cloud-cli]"`. + +### Additional Optional Dependencies + +There are some additional dependencies you might want to install. + +Additional optional Pydantic dependencies: + +* [`pydantic-settings`](https://docs.pydantic.dev/latest/usage/pydantic_settings/) - for settings management. +* [`pydantic-extra-types`](https://docs.pydantic.dev/latest/usage/types/extra_types/extra_types/) - for extra types to be used with Pydantic. + +Additional optional FastAPI dependencies: + +* [`orjson`](https://github.com/ijl/orjson) - Required if you want to use `ORJSONResponse`. +* [`ujson`](https://github.com/esnme/ultrajson) - Required if you want to use `UJSONResponse`. + +## License + +This project is licensed under the terms of the MIT license. diff --git a/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/RECORD b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/RECORD new file mode 100644 index 0000000..ef7e49e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/RECORD @@ -0,0 +1,109 @@ +../../Scripts/fastapi.exe,sha256=5j15NkpabsAZ8L1Pep_hF7JB3hy_Dy0jiCVi5MDNunU,108459 +fastapi-0.138.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +fastapi-0.138.0.dist-info/METADATA,sha256=Ljq5wAm9GugY3jIBZNrFULS04Z8bKjJr1a9G5OVUPtY,27208 +fastapi-0.138.0.dist-info/RECORD,, +fastapi-0.138.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +fastapi-0.138.0.dist-info/WHEEL,sha256=VP-D4TPS230sME9Z3vb3INXvo1yt0924YRm5AOsk_dE,90 +fastapi-0.138.0.dist-info/entry_points.txt,sha256=GCf-WbIZxyGT4MUmrPGj1cOHYZoGsNPHAvNkT6hnGeA,61 +fastapi-0.138.0.dist-info/licenses/LICENSE,sha256=Tsif_IFIW5f-xYSy1KlhAy7v_oNEU4lP2cEnSQbMdE4,1086 +fastapi/.agents/skills/fastapi/SKILL.md,sha256=sJ7wiSY8WOd9YhhMd9nd2iGJWtIjox_JpaYxHV1HB1M,11240 +fastapi/.agents/skills/fastapi/references/dependencies.md,sha256=i2txoD-hRoCQWoH1PxiDuQwqt6xl7vp3wmZMLQON5Gk,3268 +fastapi/.agents/skills/fastapi/references/other-tools.md,sha256=oskZlYkCdGgezS9mIfqW5kImCGtOsgPUa46Sd81H-P0,1527 +fastapi/.agents/skills/fastapi/references/streaming.md,sha256=bHaIKnwbTkd7TUVQm_uxapUnAlEG4rdvALV9koD5ypI,2581 +fastapi/__init__.py,sha256=Q2cJRNvOpEZdE7ZHIgqtAS1bEvOw6pDaE8d3lC3EGXs,1081 +fastapi/__main__.py,sha256=bKePXLdO4SsVSM6r9SVoLickJDcR2c0cTOxZRKq26YQ,37 +fastapi/__pycache__/__init__.cpython-313.pyc,, +fastapi/__pycache__/__main__.cpython-313.pyc,, +fastapi/__pycache__/applications.cpython-313.pyc,, +fastapi/__pycache__/background.cpython-313.pyc,, +fastapi/__pycache__/cli.cpython-313.pyc,, +fastapi/__pycache__/concurrency.cpython-313.pyc,, +fastapi/__pycache__/datastructures.cpython-313.pyc,, +fastapi/__pycache__/encoders.cpython-313.pyc,, +fastapi/__pycache__/exception_handlers.cpython-313.pyc,, +fastapi/__pycache__/exceptions.cpython-313.pyc,, +fastapi/__pycache__/logger.cpython-313.pyc,, +fastapi/__pycache__/param_functions.cpython-313.pyc,, +fastapi/__pycache__/params.cpython-313.pyc,, +fastapi/__pycache__/requests.cpython-313.pyc,, +fastapi/__pycache__/responses.cpython-313.pyc,, +fastapi/__pycache__/routing.cpython-313.pyc,, +fastapi/__pycache__/sse.cpython-313.pyc,, +fastapi/__pycache__/staticfiles.cpython-313.pyc,, +fastapi/__pycache__/templating.cpython-313.pyc,, +fastapi/__pycache__/testclient.cpython-313.pyc,, +fastapi/__pycache__/types.cpython-313.pyc,, +fastapi/__pycache__/utils.cpython-313.pyc,, +fastapi/__pycache__/websockets.cpython-313.pyc,, +fastapi/_compat/__init__.py,sha256=PYOR-8vJ5va4Qjl810FcQmJbqmpyyeQMoZ9R86CeE2U,2095 +fastapi/_compat/__pycache__/__init__.cpython-313.pyc,, +fastapi/_compat/__pycache__/shared.cpython-313.pyc,, +fastapi/_compat/__pycache__/v2.cpython-313.pyc,, +fastapi/_compat/shared.py,sha256=XJl8cVYOTSgfK-1fiJHLeHHeSGLfC00H7MrtpyYpQgU,7118 +fastapi/_compat/v2.py,sha256=sDGyi1iKSFW9LuJ7n4B61-1yrQI1NHRSwsFRRLuFJ8k,17601 +fastapi/applications.py,sha256=hf4vGBWMU5nEkHmeqQutYBIK3AsnhEwcVF2wIvegekc,183452 +fastapi/background.py,sha256=TADzAethOAaqpVvckYuTT3c4O9N1HaFQysFCPt0MsgU,1820 +fastapi/cli.py,sha256=OYhZb0NR_deuT5ofyPF2NoNBzZDNOP8Salef2nk-HqA,418 +fastapi/concurrency.py,sha256=xHGDEOQAA6cvFEDX46oq3r2t1Zd4sVvreaRgdIE4juM,1489 +fastapi/datastructures.py,sha256=XPugnojHc4N07eF5Xp1TTndP2gMv1fG4k-0L4Vdr7Kc,5321 +fastapi/dependencies/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +fastapi/dependencies/__pycache__/__init__.cpython-313.pyc,, +fastapi/dependencies/__pycache__/models.cpython-313.pyc,, +fastapi/dependencies/__pycache__/utils.cpython-313.pyc,, +fastapi/dependencies/models.py,sha256=XPIkEaukHaTaFz9LoeI9f_T-3otOAVkoOC113TpFbWI,7250 +fastapi/dependencies/utils.py,sha256=XIpRML7ttx5EhmchgWz6R5CMsBuepx7KHdmi38F_e_s,39728 +fastapi/encoders.py,sha256=m2OGnq9k94Alcd6UQM8r7iwn-cjvJdpOKRVJuQR68T8,11603 +fastapi/exception_handlers.py,sha256=YVcT8Zy021VYYeecgdyh5YEUjEIHKcLspbkSf4OfbJI,1275 +fastapi/exceptions.py,sha256=fUNOBRdIsULU0TnO8aNBg3HoJeyJxXp9UV6D8wYu9lI,7453 +fastapi/logger.py,sha256=I9NNi3ov8AcqbsbC9wl1X-hdItKgYt2XTrx1f99Zpl4,54 +fastapi/middleware/__init__.py,sha256=oQDxiFVcc1fYJUOIFvphnK7pTT5kktmfL32QXpBFvvo,58 +fastapi/middleware/__pycache__/__init__.cpython-313.pyc,, +fastapi/middleware/__pycache__/asyncexitstack.cpython-313.pyc,, +fastapi/middleware/__pycache__/cors.cpython-313.pyc,, +fastapi/middleware/__pycache__/gzip.cpython-313.pyc,, +fastapi/middleware/__pycache__/httpsredirect.cpython-313.pyc,, +fastapi/middleware/__pycache__/trustedhost.cpython-313.pyc,, +fastapi/middleware/__pycache__/wsgi.cpython-313.pyc,, +fastapi/middleware/asyncexitstack.py,sha256=RKGlQpGzg3GLosqVhrxBy_NCZ9qJS7zQeNHt5Y3x-00,637 +fastapi/middleware/cors.py,sha256=ynwjWQZoc_vbhzZ3_ZXceoaSrslHFHPdoM52rXr0WUU,79 +fastapi/middleware/gzip.py,sha256=xM5PcsH8QlAimZw4VDvcmTnqQamslThsfe3CVN2voa0,79 +fastapi/middleware/httpsredirect.py,sha256=rL8eXMnmLijwVkH7_400zHri1AekfeBd6D6qs8ix950,115 +fastapi/middleware/trustedhost.py,sha256=eE5XGRxGa7c5zPnMJDGp3BxaL25k5iVQlhnv-Pk0Pss,109 +fastapi/middleware/wsgi.py,sha256=a_FMDoeTwcdig9wdAGumIH82oDFfuj4pxtQxLbAw2Ns,107 +fastapi/openapi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +fastapi/openapi/__pycache__/__init__.cpython-313.pyc,, +fastapi/openapi/__pycache__/constants.cpython-313.pyc,, +fastapi/openapi/__pycache__/docs.cpython-313.pyc,, +fastapi/openapi/__pycache__/models.cpython-313.pyc,, +fastapi/openapi/__pycache__/utils.cpython-313.pyc,, +fastapi/openapi/constants.py,sha256=adGzmis1L1HJRTE3kJ5fmHS_Noq6tIY6pWv_SFzoFDU,153 +fastapi/openapi/docs.py,sha256=PcjH0Sn-yp97O0exXincG8epsY7ATyJC5662bK1DRao,12425 +fastapi/openapi/models.py,sha256=twjJWGf6lcKRJZgaZCcnQ0Ten1hDhcyCLgbr7tXIP30,14608 +fastapi/openapi/utils.py,sha256=-Haf16aXydAE-IVwloo5_MVBp0l0fi_F9er5wKeKbZ8,26916 +fastapi/param_functions.py,sha256=4fTCVlvEDbAQr7gvWYkOX8hktdl2Iwk-kv4FaTz7P8w,69596 +fastapi/params.py,sha256=1fNNSK5J7PM5Fw-F5_9uOysA2NgC-61mb3LlrBWpqJM,26205 +fastapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +fastapi/requests.py,sha256=zayepKFcienBllv3snmWI20Gk0oHNVLU4DDhqXBb4LU,142 +fastapi/responses.py,sha256=BM7JtiZ_G4j2Z7QvHUzV1KtZPnjOoCfX35RqYXlZDh0,4144 +fastapi/routing.py,sha256=OgQMs5UPY3b1wG8P1BcVa1MrX_ZzUcWV4tEbd12nAKY,246593 +fastapi/security/__init__.py,sha256=bO8pNmxqVRXUjfl2mOKiVZLn0FpBQ61VUYVjmppnbJw,881 +fastapi/security/__pycache__/__init__.cpython-313.pyc,, +fastapi/security/__pycache__/api_key.cpython-313.pyc,, +fastapi/security/__pycache__/base.cpython-313.pyc,, +fastapi/security/__pycache__/http.cpython-313.pyc,, +fastapi/security/__pycache__/oauth2.cpython-313.pyc,, +fastapi/security/__pycache__/open_id_connect_url.cpython-313.pyc,, +fastapi/security/__pycache__/utils.cpython-313.pyc,, +fastapi/security/api_key.py,sha256=4CNLNVAStOsMhytH9C5EOUEOZrtLg_IpMQS_HcRDP4M,9793 +fastapi/security/base.py,sha256=dl4pvbC-RxjfbWgPtCWd8MVU-7CB2SZ22rJDXVCXO6c,141 +fastapi/security/http.py,sha256=Z0xALDqwgJZRAaDs40Sa68rAnjFzEL99UEmO5PJzTKA,13410 +fastapi/security/oauth2.py,sha256=sSqW4tbvoHaWNld46TYatta5rgFfjod4LhxmedXzkI8,24178 +fastapi/security/open_id_connect_url.py,sha256=V8WLPEsEq_WJlIjPwJO2vCoJWGLu-VTt1-N2H7aV-D4,3136 +fastapi/security/utils.py,sha256=E9YIoez-H2k1oBLEdxqJEi8sV1umunJYKjQHvZG3FRY,261 +fastapi/sse.py,sha256=adtwP03ePndMQOuRCNRHrTfFQxMpRwrSUoRMHyB8OvA,6848 +fastapi/staticfiles.py,sha256=iirGIt3sdY2QZXd36ijs3Cj-T0FuGFda3cd90kM9Ikw,69 +fastapi/templating.py,sha256=4zsuTWgcjcEainMJFAlW6-gnslm6AgOS1SiiDWfmQxk,76 +fastapi/testclient.py,sha256=nBvaAmX66YldReJNZXPOk1sfuo2Q6hs8bOvIaCep6LQ,66 +fastapi/types.py,sha256=g2tD842BUHC2C3_P8P06albQ4MhCb9RybrSmp5rODgU,438 +fastapi/utils.py,sha256=DX0VrnMwfVsZxRz8IitQ42c2---fDzmFZkeRMZR2UMo,4341 +fastapi/websockets.py,sha256=419uncYObEKZG0YcrXscfQQYLSWoE10jqxVMetGdR98,222 diff --git a/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/REQUESTED b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/REQUESTED new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/WHEEL b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/WHEEL new file mode 100644 index 0000000..e651d8e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: pdm-backend (2.4.9) +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/entry_points.txt b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/entry_points.txt new file mode 100644 index 0000000..b81849e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/entry_points.txt @@ -0,0 +1,5 @@ +[console_scripts] +fastapi = fastapi.cli:main + +[gui_scripts] + diff --git a/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/licenses/LICENSE b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000..3e92463 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi-0.138.0.dist-info/licenses/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2018 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. diff --git a/server/venv/Lib/site-packages/fastapi/__init__.py b/server/venv/Lib/site-packages/fastapi/__init__.py new file mode 100644 index 0000000..d7b99b5 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/__init__.py @@ -0,0 +1,25 @@ +"""FastAPI framework, high performance, easy to learn, fast to code, ready for production""" + +__version__ = "0.138.0" + +from starlette import status as status + +from .applications import FastAPI as FastAPI +from .background import BackgroundTasks as BackgroundTasks +from .datastructures import UploadFile as UploadFile +from .exceptions import HTTPException as HTTPException +from .exceptions import WebSocketException as WebSocketException +from .param_functions import Body as Body +from .param_functions import Cookie as Cookie +from .param_functions import Depends as Depends +from .param_functions import File as File +from .param_functions import Form as Form +from .param_functions import Header as Header +from .param_functions import Path as Path +from .param_functions import Query as Query +from .param_functions import Security as Security +from .requests import Request as Request +from .responses import Response as Response +from .routing import APIRouter as APIRouter +from .websockets import WebSocket as WebSocket +from .websockets import WebSocketDisconnect as WebSocketDisconnect diff --git a/server/venv/Lib/site-packages/fastapi/__main__.py b/server/venv/Lib/site-packages/fastapi/__main__.py new file mode 100644 index 0000000..fc36465 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/__main__.py @@ -0,0 +1,3 @@ +from fastapi.cli import main + +main() diff --git a/server/venv/Lib/site-packages/fastapi/_compat/__init__.py b/server/venv/Lib/site-packages/fastapi/_compat/__init__.py new file mode 100644 index 0000000..4581c38 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/_compat/__init__.py @@ -0,0 +1,40 @@ +from .shared import PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE +from .shared import annotation_is_pydantic_v1 as annotation_is_pydantic_v1 +from .shared import field_annotation_is_scalar as field_annotation_is_scalar +from .shared import ( + field_annotation_is_scalar_sequence as field_annotation_is_scalar_sequence, +) +from .shared import field_annotation_is_sequence as field_annotation_is_sequence +from .shared import ( + is_bytes_or_nonable_bytes_annotation as is_bytes_or_nonable_bytes_annotation, +) +from .shared import is_bytes_sequence_annotation as is_bytes_sequence_annotation +from .shared import is_pydantic_v1_model_instance as is_pydantic_v1_model_instance +from .shared import ( + is_uploadfile_or_nonable_uploadfile_annotation as is_uploadfile_or_nonable_uploadfile_annotation, +) +from .shared import ( + is_uploadfile_sequence_annotation as is_uploadfile_sequence_annotation, +) +from .shared import lenient_issubclass as lenient_issubclass +from .shared import sequence_types as sequence_types +from .shared import value_is_sequence as value_is_sequence +from .v2 import ModelField as ModelField +from .v2 import PydanticSchemaGenerationError as PydanticSchemaGenerationError +from .v2 import RequiredParam as RequiredParam +from .v2 import Undefined as Undefined +from .v2 import Url as Url +from .v2 import copy_field_info as copy_field_info +from .v2 import create_body_model as create_body_model +from .v2 import evaluate_forwardref as evaluate_forwardref +from .v2 import get_cached_model_fields as get_cached_model_fields +from .v2 import get_definitions as get_definitions +from .v2 import get_flat_models_from_fields as get_flat_models_from_fields +from .v2 import get_missing_field_error as get_missing_field_error +from .v2 import get_model_name_map as get_model_name_map +from .v2 import get_schema_from_model_field as get_schema_from_model_field +from .v2 import is_scalar_field as is_scalar_field +from .v2 import serialize_sequence_value as serialize_sequence_value +from .v2 import ( + with_info_plain_validator_function as with_info_plain_validator_function, +) diff --git a/server/venv/Lib/site-packages/fastapi/_compat/shared.py b/server/venv/Lib/site-packages/fastapi/_compat/shared.py new file mode 100644 index 0000000..bd38c55 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/_compat/shared.py @@ -0,0 +1,214 @@ +import types +import typing +import warnings +from collections import deque +from collections.abc import Mapping, Sequence +from dataclasses import is_dataclass +from typing import ( + Annotated, + Any, + TypeGuard, + TypeVar, + Union, + get_args, + get_origin, +) + +from fastapi.types import UnionType +from pydantic import BaseModel +from pydantic.version import VERSION as PYDANTIC_VERSION +from starlette.datastructures import UploadFile + +_T = TypeVar("_T") + +# Copy from Pydantic: pydantic/_internal/_typing_extra.py +WithArgsTypes: tuple[Any, ...] = ( + typing._GenericAlias, # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + types.GenericAlias, + types.UnionType, +) # pyright: ignore[reportAttributeAccessIssue] + +PYDANTIC_VERSION_MINOR_TUPLE = tuple(int(x) for x in PYDANTIC_VERSION.split(".")[:2]) + + +sequence_annotation_to_type = { + Sequence: list, + list: list, + tuple: tuple, + set: set, + frozenset: frozenset, + deque: deque, +} + +sequence_types: tuple[type[Any], ...] = tuple(sequence_annotation_to_type.keys()) + + +# Copy of Pydantic: pydantic/_internal/_utils.py with added TypeGuard +def lenient_issubclass( + cls: Any, class_or_tuple: type[_T] | tuple[type[_T], ...] | None +) -> TypeGuard[type[_T]]: + try: + return isinstance(cls, type) and issubclass(cls, class_or_tuple) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + except TypeError: # pragma: no cover + if isinstance(cls, WithArgsTypes): + return False + raise # pragma: no cover + + +def _annotation_is_sequence(annotation: type[Any] | None) -> bool: + if lenient_issubclass(annotation, (str, bytes)): + return False + return lenient_issubclass(annotation, sequence_types) + + +def field_annotation_is_sequence(annotation: type[Any] | None) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + for arg in get_args(annotation): + if field_annotation_is_sequence(arg): + return True + return False + return _annotation_is_sequence(annotation) or _annotation_is_sequence( + get_origin(annotation) + ) + + +def value_is_sequence(value: Any) -> bool: + return isinstance(value, sequence_types) and not isinstance(value, (str, bytes)) + + +def _annotation_is_complex(annotation: type[Any] | None) -> bool: + return ( + lenient_issubclass(annotation, (BaseModel, Mapping, UploadFile)) + or _annotation_is_sequence(annotation) + or is_dataclass(annotation) + ) + + +def field_annotation_is_complex(annotation: type[Any] | None) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + return any(field_annotation_is_complex(arg) for arg in get_args(annotation)) + + if origin is Annotated: + return field_annotation_is_complex(get_args(annotation)[0]) + + return ( + _annotation_is_complex(annotation) + or _annotation_is_complex(origin) + or hasattr(origin, "__pydantic_core_schema__") + or hasattr(origin, "__get_pydantic_core_schema__") + ) + + +def field_annotation_is_scalar(annotation: Any) -> bool: + # handle Ellipsis here to make tuple[int, ...] work nicely + return annotation is Ellipsis or not field_annotation_is_complex(annotation) + + +def field_annotation_is_scalar_sequence(annotation: type[Any] | None) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + at_least_one_scalar_sequence = False + for arg in get_args(annotation): + if field_annotation_is_scalar_sequence(arg): + at_least_one_scalar_sequence = True + continue + elif not field_annotation_is_scalar(arg): + return False + return at_least_one_scalar_sequence + return field_annotation_is_sequence(annotation) and all( + field_annotation_is_scalar(sub_annotation) + for sub_annotation in get_args(annotation) + ) + + +def is_bytes_or_nonable_bytes_annotation(annotation: Any) -> bool: + if lenient_issubclass(annotation, bytes): + return True + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + for arg in get_args(annotation): + if lenient_issubclass(arg, bytes): + return True + return False + + +def is_uploadfile_or_nonable_uploadfile_annotation(annotation: Any) -> bool: + if lenient_issubclass(annotation, UploadFile): + return True + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + for arg in get_args(annotation): + if lenient_issubclass(arg, UploadFile): + return True + return False + + +def is_bytes_sequence_annotation(annotation: Any) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + at_least_one = False + for arg in get_args(annotation): + if is_bytes_sequence_annotation(arg): + at_least_one = True + continue + return at_least_one + return field_annotation_is_sequence(annotation) and all( + is_bytes_or_nonable_bytes_annotation(sub_annotation) + for sub_annotation in get_args(annotation) + ) + + +def is_uploadfile_sequence_annotation(annotation: Any) -> bool: + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + at_least_one = False + for arg in get_args(annotation): + if is_uploadfile_sequence_annotation(arg): + at_least_one = True + continue + return at_least_one + return field_annotation_is_sequence(annotation) and all( + is_uploadfile_or_nonable_uploadfile_annotation(sub_annotation) + for sub_annotation in get_args(annotation) + ) + + +def is_pydantic_v1_model_instance(obj: Any) -> bool: + # TODO: remove this function once the required version of Pydantic fully + # removes pydantic.v1 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + from pydantic import v1 + except ImportError: # pragma: no cover + return False + return isinstance(obj, v1.BaseModel) + + +def is_pydantic_v1_model_class(cls: Any) -> bool: + # TODO: remove this function once the required version of Pydantic fully + # removes pydantic.v1 + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + from pydantic import v1 + except ImportError: # pragma: no cover + return False + return lenient_issubclass(cls, v1.BaseModel) + + +def annotation_is_pydantic_v1(annotation: Any) -> bool: + if is_pydantic_v1_model_class(annotation): + return True + origin = get_origin(annotation) + if origin is Union or origin is UnionType: + for arg in get_args(annotation): + if is_pydantic_v1_model_class(arg): + return True + if field_annotation_is_sequence(annotation): + for sub_annotation in get_args(annotation): + if annotation_is_pydantic_v1(sub_annotation): + return True + return False diff --git a/server/venv/Lib/site-packages/fastapi/_compat/v2.py b/server/venv/Lib/site-packages/fastapi/_compat/v2.py new file mode 100644 index 0000000..7be686d --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/_compat/v2.py @@ -0,0 +1,493 @@ +import re +import warnings +from collections.abc import Sequence +from copy import copy +from dataclasses import dataclass, is_dataclass +from enum import Enum +from functools import lru_cache +from typing import ( + Annotated, + Any, + Literal, + Union, + cast, + get_args, + get_origin, +) + +from fastapi._compat import lenient_issubclass, shared +from fastapi.openapi.constants import REF_TEMPLATE +from fastapi.types import IncEx, ModelNameMap, UnionType +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model +from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError +from pydantic import PydanticUndefinedAnnotation as PydanticUndefinedAnnotation +from pydantic import ValidationError as ValidationError +from pydantic._internal import _typing_extra as _pydantic_typing_extra +from pydantic._internal._schema_generation_shared import ( # type: ignore[attr-defined] + GetJsonSchemaHandler as GetJsonSchemaHandler, +) +from pydantic.fields import FieldInfo as FieldInfo +from pydantic.json_schema import GenerateJsonSchema as _GenerateJsonSchema +from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue +from pydantic_core import CoreSchema as CoreSchema +from pydantic_core import PydanticUndefined +from pydantic_core import Url as Url +from pydantic_core.core_schema import ( + with_info_plain_validator_function as with_info_plain_validator_function, +) + +RequiredParam = PydanticUndefined +Undefined = PydanticUndefined + + +def evaluate_forwardref( + value: Any, + globalns: dict[str, Any] | None = None, + localns: dict[str, Any] | None = None, +) -> Any: + # eval_type_lenient has been deprecated since Pydantic v2.10.0b1 (PR #10530) + try_eval_type = getattr(_pydantic_typing_extra, "try_eval_type", None) + if try_eval_type is not None: + return try_eval_type(value, globalns, localns)[0] + return _pydantic_typing_extra.eval_type_lenient( # ty: ignore[deprecated] + value, globalns, localns + ) + + +class GenerateJsonSchema(_GenerateJsonSchema): + # TODO: remove when this is merged (or equivalent): https://github.com/pydantic/pydantic/pull/12841 + # and dropping support for any version of Pydantic before that one (so, in a very long time) + def bytes_schema(self, schema: CoreSchema) -> JsonSchemaValue: + json_schema = {"type": "string", "contentMediaType": "application/octet-stream"} + bytes_mode = ( + self._config.ser_json_bytes + if self.mode == "serialization" + else self._config.val_json_bytes + ) + if bytes_mode == "base64": + json_schema["contentEncoding"] = "base64" + self.update_with_validations(json_schema, schema, self.ValidationsMapping.bytes) + return json_schema + + +# TODO: remove when dropping support for Pydantic < v2.12.3 +_Attrs = { + "default": ..., + "default_factory": None, + "alias": None, + "alias_priority": None, + "validation_alias": None, + "serialization_alias": None, + "title": None, + "field_title_generator": None, + "description": None, + "examples": None, + "exclude": None, + "exclude_if": None, + "discriminator": None, + "deprecated": None, + "json_schema_extra": None, + "frozen": None, + "validate_default": None, + "repr": True, + "init": None, + "init_var": None, + "kw_only": None, +} + + +# TODO: remove when dropping support for Pydantic < v2.12.3 +def asdict(field_info: FieldInfo) -> dict[str, Any]: + attributes = {} + for attr in _Attrs: + value = getattr(field_info, attr, Undefined) + if value is not Undefined: + attributes[attr] = value + return { + "annotation": field_info.annotation, + "metadata": field_info.metadata, + "attributes": attributes, + } + + +@dataclass +class ModelField: + field_info: FieldInfo + name: str + mode: Literal["validation", "serialization"] = "validation" + config: ConfigDict | None = None + + @property + def alias(self) -> str: + a = self.field_info.alias + return a if a is not None else self.name + + @property + def validation_alias(self) -> str | None: + va = self.field_info.validation_alias + if isinstance(va, str) and va: + return va + return None + + @property + def serialization_alias(self) -> str | None: + sa = self.field_info.serialization_alias + return sa or None + + @property + def default(self) -> Any: + return self.get_default() + + def __post_init__(self) -> None: + with warnings.catch_warnings(): + # Pydantic >= 2.12.0 warns about field specific metadata that is unused + # (e.g. `TypeAdapter(Annotated[int, Field(alias='b')])`). In some cases, we + # end up building the type adapter from a model field annotation so we + # need to ignore the warning: + if shared.PYDANTIC_VERSION_MINOR_TUPLE >= (2, 12): + from pydantic.warnings import UnsupportedFieldAttributeWarning + + warnings.simplefilter( + "ignore", category=UnsupportedFieldAttributeWarning + ) + # TODO: remove after setting the min Pydantic to v2.12.3 + # that adds asdict(), and use self.field_info.asdict() instead + field_dict = asdict(self.field_info) + annotated_args = ( + field_dict["annotation"], + *field_dict["metadata"], + # this FieldInfo needs to be created again so that it doesn't include + # the old field info metadata and only the rest of the attributes + Field(**field_dict["attributes"]), + ) + self._type_adapter: TypeAdapter[Any] = TypeAdapter( + Annotated[annotated_args], # ty: ignore[invalid-type-form] + config=self.config, + ) + + def get_default(self) -> Any: + if self.field_info.is_required(): + return Undefined + return self.field_info.get_default(call_default_factory=True) + + def validate( + self, + value: Any, + values: dict[str, Any] = {}, # noqa: B006 + *, + loc: tuple[int | str, ...] = (), + ) -> tuple[Any, list[dict[str, Any]]]: + try: + return ( + self._type_adapter.validate_python(value, from_attributes=True), + [], + ) + except ValidationError as exc: + return None, _regenerate_error_with_loc( + errors=exc.errors(include_url=False), loc_prefix=loc + ) + + def serialize( + self, + value: Any, + *, + mode: Literal["json", "python"] = "json", + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + ) -> Any: + # What calls this code passes a value that already called + # self._type_adapter.validate_python(value) + return self._type_adapter.dump_python( + value, + mode=mode, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + def serialize_json( + self, + value: Any, + *, + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + ) -> bytes: + # What calls this code passes a value that already called + # self._type_adapter.validate_python(value) + # This uses Pydantic's dump_json() which serializes directly to JSON + # bytes in one pass (via Rust), avoiding the intermediate Python dict + # step of dump_python(mode="json") + json.dumps(). + return self._type_adapter.dump_json( + value, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + def __hash__(self) -> int: + # Each ModelField is unique for our purposes, to allow making a dict from + # ModelField to its JSON Schema. + return id(self) + + +def _has_computed_fields(field: ModelField) -> bool: + computed_fields = field._type_adapter.core_schema.get("schema", {}).get( + "computed_fields", [] + ) + return len(computed_fields) > 0 + + +def get_schema_from_model_field( + *, + field: ModelField, + model_name_map: ModelNameMap, + field_mapping: dict[ + tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue + ], + separate_input_output_schemas: bool = True, +) -> dict[str, Any]: + override_mode: Literal["validation"] | None = ( + None + if (separate_input_output_schemas or _has_computed_fields(field)) + else "validation" + ) + field_alias = ( + (field.validation_alias or field.alias) + if field.mode == "validation" + else (field.serialization_alias or field.alias) + ) + + # This expects that GenerateJsonSchema was already used to generate the definitions + json_schema = field_mapping[(field, override_mode or field.mode)] + if "$ref" not in json_schema: + # TODO remove when deprecating Pydantic v1 + # Ref: https://github.com/pydantic/pydantic/blob/d61792cc42c80b13b23e3ffa74bc37ec7c77f7d1/pydantic/schema.py#L207 + json_schema["title"] = field.field_info.title or field_alias.title().replace( + "_", " " + ) + return json_schema + + +def get_definitions( + *, + fields: Sequence[ModelField], + model_name_map: ModelNameMap, + separate_input_output_schemas: bool = True, +) -> tuple[ + dict[tuple[ModelField, Literal["validation", "serialization"]], JsonSchemaValue], + dict[str, dict[str, Any]], +]: + schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE) + validation_fields = [field for field in fields if field.mode == "validation"] + serialization_fields = [field for field in fields if field.mode == "serialization"] + flat_validation_models = get_flat_models_from_fields( + validation_fields, known_models=set() + ) + flat_serialization_models = get_flat_models_from_fields( + serialization_fields, known_models=set() + ) + flat_validation_model_fields = [ + ModelField( + field_info=FieldInfo(annotation=model), + name=model.__name__, + mode="validation", + ) + for model in flat_validation_models + ] + flat_serialization_model_fields = [ + ModelField( + field_info=FieldInfo(annotation=model), + name=model.__name__, + mode="serialization", + ) + for model in flat_serialization_models + ] + flat_model_fields = flat_validation_model_fields + flat_serialization_model_fields + input_types = {f.field_info.annotation for f in fields} + unique_flat_model_fields = { + f for f in flat_model_fields if f.field_info.annotation not in input_types + } + inputs = [ + ( + field, + ( + field.mode + if (separate_input_output_schemas or _has_computed_fields(field)) + else "validation" + ), + field._type_adapter.core_schema, + ) + for field in list(fields) + list(unique_flat_model_fields) + ] + field_mapping, definitions = schema_generator.generate_definitions(inputs=inputs) + for item_def in cast(dict[str, dict[str, Any]], definitions).values(): + if "description" in item_def: + item_description = cast(str, item_def["description"]).split("\f")[0] + item_def["description"] = item_description + # definitions: dict[DefsRef, dict[str, Any]] + # but mypy complains about general str in other places that are not declared as + # DefsRef, although DefsRef is just str: + # DefsRef = NewType('DefsRef', str) + # So, a cast to simplify the types here + return field_mapping, cast(dict[str, dict[str, Any]], definitions) + + +def is_scalar_field(field: ModelField) -> bool: + from fastapi import params + + return shared.field_annotation_is_scalar( + field.field_info.annotation + ) and not isinstance(field.field_info, params.Body) + + +def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo: + cls = type(field_info) + merged_field_info = cls.from_annotation(annotation) + new_field_info = copy(field_info) + new_field_info.metadata = merged_field_info.metadata + new_field_info.annotation = merged_field_info.annotation + return new_field_info + + +def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: + origin_type = get_origin(field.field_info.annotation) or field.field_info.annotation + if origin_type is Union or origin_type is UnionType: # Handle optional sequences + union_args = get_args(field.field_info.annotation) + for union_arg in union_args: + if union_arg is type(None): + continue + origin_type = get_origin(union_arg) or union_arg + break + assert issubclass(origin_type, shared.sequence_types) # type: ignore[arg-type] # ty: ignore[invalid-argument-type] + return shared.sequence_annotation_to_type[origin_type](value) # type: ignore[no-any-return,index] # ty: ignore[invalid-return-type] + + +def get_missing_field_error(loc: tuple[int | str, ...]) -> dict[str, Any]: + error = ValidationError.from_exception_data( + "Field required", [{"type": "missing", "loc": loc, "input": {}}] + ).errors(include_url=False)[0] + error["input"] = None + return error # type: ignore[return-value] # ty: ignore[invalid-return-type] + + +def create_body_model( + *, fields: Sequence[ModelField], model_name: str +) -> type[BaseModel]: + field_params = {f.name: (f.field_info.annotation, f.field_info) for f in fields} + BodyModel: type[BaseModel] = create_model(model_name, **field_params) # type: ignore[call-overload] # ty: ignore[no-matching-overload] + return BodyModel + + +def get_model_fields(model: type[BaseModel]) -> list[ModelField]: + model_fields: list[ModelField] = [] + for name, field_info in model.model_fields.items(): + type_ = field_info.annotation + if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_): + model_config = None + else: + model_config = model.model_config + model_fields.append( + ModelField( + field_info=field_info, + name=name, + config=model_config, + ) + ) + return model_fields + + +@lru_cache +def get_cached_model_fields(model: type[BaseModel]) -> list[ModelField]: + return get_model_fields(model) + + +# Duplicate of several schema functions from Pydantic v1 to make them compatible with +# Pydantic v2 and allow mixing the models + +TypeModelOrEnum = type["BaseModel"] | type[Enum] +TypeModelSet = set[TypeModelOrEnum] + + +def normalize_name(name: str) -> str: + return re.sub(r"[^a-zA-Z0-9.\-_]", "_", name) + + +def get_model_name_map(unique_models: TypeModelSet) -> dict[TypeModelOrEnum, str]: + name_model_map = {} + for model in unique_models: + model_name = normalize_name(model.__name__) + name_model_map[model_name] = model + return {v: k for k, v in name_model_map.items()} + + +def get_flat_models_from_model( + model: type["BaseModel"], known_models: TypeModelSet | None = None +) -> TypeModelSet: + known_models = known_models or set() + fields = get_model_fields(model) + get_flat_models_from_fields(fields, known_models=known_models) + return known_models + + +def get_flat_models_from_annotation( + annotation: Any, known_models: TypeModelSet +) -> TypeModelSet: + origin = get_origin(annotation) + if origin is not None: + for arg in get_args(annotation): + if lenient_issubclass(arg, (BaseModel, Enum)): + if arg not in known_models: + known_models.add(arg) # type: ignore[arg-type] + if lenient_issubclass(arg, BaseModel): + get_flat_models_from_model(arg, known_models=known_models) + else: + get_flat_models_from_annotation(arg, known_models=known_models) + return known_models + + +def get_flat_models_from_field( + field: ModelField, known_models: TypeModelSet +) -> TypeModelSet: + field_type = field.field_info.annotation + if lenient_issubclass(field_type, BaseModel): + if field_type in known_models: + return known_models + known_models.add(field_type) + get_flat_models_from_model(field_type, known_models=known_models) + elif lenient_issubclass(field_type, Enum): + known_models.add(field_type) + else: + get_flat_models_from_annotation(field_type, known_models=known_models) + return known_models + + +def get_flat_models_from_fields( + fields: Sequence[ModelField], known_models: TypeModelSet +) -> TypeModelSet: + for field in fields: + get_flat_models_from_field(field, known_models=known_models) + return known_models + + +def _regenerate_error_with_loc( + *, errors: Sequence[Any], loc_prefix: tuple[str | int, ...] +) -> list[dict[str, Any]]: + updated_loc_errors: list[Any] = [ + {**err, "loc": loc_prefix + err.get("loc", ())} for err in errors + ] + + return updated_loc_errors diff --git a/server/venv/Lib/site-packages/fastapi/applications.py b/server/venv/Lib/site-packages/fastapi/applications.py new file mode 100644 index 0000000..56e1a3e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/applications.py @@ -0,0 +1,4768 @@ +import os +from collections.abc import Awaitable, Callable, Coroutine, Sequence +from enum import Enum +from typing import Annotated, Any, Literal, TypeVar + +from annotated_doc import Doc +from fastapi import routing +from fastapi.datastructures import Default, DefaultPlaceholder +from fastapi.exception_handlers import ( + http_exception_handler, + request_validation_exception_handler, + websocket_request_validation_exception_handler, +) +from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError +from fastapi.logger import logger +from fastapi.middleware.asyncexitstack import AsyncExitStackMiddleware +from fastapi.openapi.docs import ( + get_redoc_html, + get_swagger_ui_html, + get_swagger_ui_oauth2_redirect_html, +) +from fastapi.openapi.utils import get_openapi +from fastapi.params import Depends +from fastapi.types import DecoratedCallable, IncEx +from fastapi.utils import generate_unique_id +from starlette.applications import Starlette +from starlette.datastructures import State +from starlette.exceptions import HTTPException +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware.errors import ServerErrorMiddleware +from starlette.middleware.exceptions import ExceptionMiddleware +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, Response +from starlette.routing import BaseRoute +from starlette.types import ASGIApp, ExceptionHandler, Lifespan, Receive, Scope, Send +from typing_extensions import deprecated + +AppType = TypeVar("AppType", bound="FastAPI") + + +class FastAPI(Starlette): + """ + `FastAPI` app class, the main entrypoint to use FastAPI. + + Read more in the + [FastAPI docs for First Steps](https://fastapi.tiangolo.com/tutorial/first-steps/). + + ## Example + + ```python + from fastapi import FastAPI + + app = FastAPI() + ``` + """ + + def __init__( + self: AppType, + *, + debug: Annotated[ + bool, + Doc( + """ + Boolean indicating if debug tracebacks should be returned on server + errors. + + Read more in the + [Starlette docs for Applications](https://www.starlette.dev/applications/#instantiating-the-application). + """ + ), + ] = False, + routes: Annotated[ + list[BaseRoute] | None, + Doc( + """ + **Note**: you probably shouldn't use this parameter, it is inherited + from Starlette and supported for compatibility. + + --- + + A list of routes to serve incoming HTTP and WebSocket requests. + """ + ), + deprecated( + """ + You normally wouldn't use this parameter with FastAPI, it is inherited + from Starlette and supported for compatibility. + + In FastAPI, you normally would use the *path operation methods*, + like `app.get()`, `app.post()`, etc. + """ + ), + ] = None, + title: Annotated[ + str, + Doc( + """ + The title of the API. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(title="ChimichangApp") + ``` + """ + ), + ] = "FastAPI", + summary: Annotated[ + str | None, + Doc( + """ + A short summary of the API. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(summary="Deadpond's favorite app. Nuff said.") + ``` + """ + ), + ] = None, + description: Annotated[ + str, + Doc( + ''' + A description of the API. Supports Markdown (using + [CommonMark syntax](https://commonmark.org/)). + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI( + description=""" + ChimichangApp API helps you do awesome stuff. 🚀 + + ## Items + + You can **read items**. + + ## Users + + You will be able to: + + * **Create users** (_not implemented_). + * **Read users** (_not implemented_). + + """ + ) + ``` + ''' + ), + ] = "", + version: Annotated[ + str, + Doc( + """ + The version of the API. + + **Note** This is the version of your application, not the version of + the OpenAPI specification nor the version of FastAPI being used. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(version="0.0.1") + ``` + """ + ), + ] = "0.1.0", + openapi_url: Annotated[ + str | None, + Doc( + """ + The URL where the OpenAPI schema will be served from. + + If you set it to `None`, no OpenAPI schema will be served publicly, and + the default automatic endpoints `/docs` and `/redoc` will also be + disabled. + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#openapi-url). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(openapi_url="/api/v1/openapi.json") + ``` + """ + ), + ] = "/openapi.json", + openapi_tags: Annotated[ + list[dict[str, Any]] | None, + Doc( + """ + A list of tags used by OpenAPI, these are the same `tags` you can set + in the *path operations*, like: + + * `@app.get("/users/", tags=["users"])` + * `@app.get("/items/", tags=["items"])` + + The order of the tags can be used to specify the order shown in + tools like Swagger UI, used in the automatic path `/docs`. + + It's not required to specify all the tags used. + + The tags that are not declared MAY be organized randomly or based + on the tools' logic. Each tag name in the list MUST be unique. + + The value of each item is a `dict` containing: + + * `name`: The name of the tag. + * `description`: A short description of the tag. + [CommonMark syntax](https://commonmark.org/) MAY be used for rich + text representation. + * `externalDocs`: Additional external documentation for this tag. If + provided, it would contain a `dict` with: + * `description`: A short description of the target documentation. + [CommonMark syntax](https://commonmark.org/) MAY be used for + rich text representation. + * `url`: The URL for the target documentation. Value MUST be in + the form of a URL. + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-tags). + + **Example** + + ```python + from fastapi import FastAPI + + tags_metadata = [ + { + "name": "users", + "description": "Operations with users. The **login** logic is also here.", + }, + { + "name": "items", + "description": "Manage items. So _fancy_ they have their own docs.", + "externalDocs": { + "description": "Items external docs", + "url": "https://fastapi.tiangolo.com/", + }, + }, + ] + + app = FastAPI(openapi_tags=tags_metadata) + ``` + """ + ), + ] = None, + servers: Annotated[ + list[dict[str, str | Any]] | None, + Doc( + """ + A `list` of `dict`s with connectivity information to a target server. + + You would use it, for example, if your application is served from + different domains and you want to use the same Swagger UI in the + browser to interact with each of them (instead of having multiple + browser tabs open). Or if you want to leave fixed the possible URLs. + + If the servers `list` is not provided, or is an empty `list`, the + `servers` property in the generated OpenAPI will be: + + * a `dict` with a `url` value of the application's mounting point + (`root_path`) if it's different from `/`. + * otherwise, the `servers` property will be omitted from the OpenAPI + schema. + + Each item in the `list` is a `dict` containing: + + * `url`: A URL to the target host. This URL supports Server Variables + and MAY be relative, to indicate that the host location is relative + to the location where the OpenAPI document is being served. Variable + substitutions will be made when a variable is named in `{`brackets`}`. + * `description`: An optional string describing the host designated by + the URL. [CommonMark syntax](https://commonmark.org/) MAY be used for + rich text representation. + * `variables`: A `dict` between a variable name and its value. The value + is used for substitution in the server's URL template. + + Read more in the + [FastAPI docs for Behind a Proxy](https://fastapi.tiangolo.com/advanced/behind-a-proxy/#additional-servers). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI( + servers=[ + {"url": "https://stag.example.com", "description": "Staging environment"}, + {"url": "https://prod.example.com", "description": "Production environment"}, + ] + ) + ``` + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of global dependencies, they will be applied to each + *path operation*, including in sub-routers. + + Read more about it in the + [FastAPI docs for Global Dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/global-dependencies/). + + **Example** + + ```python + from fastapi import Depends, FastAPI + + from .dependencies import func_dep_1, func_dep_2 + + app = FastAPI(dependencies=[Depends(func_dep_1), Depends(func_dep_2)]) + ``` + """ + ), + ] = None, + default_response_class: Annotated[ + type[Response], + Doc( + """ + The default response class to be used. + + Read more in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#default-response-class). + + **Example** + + ```python + from fastapi import FastAPI + from fastapi.responses import ORJSONResponse + + app = FastAPI(default_response_class=ORJSONResponse) + ``` + """ + ), + ] = Default(JSONResponse), + redirect_slashes: Annotated[ + bool, + Doc( + """ + Whether to detect and redirect slashes in URLs when the client doesn't + use the same format. + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(redirect_slashes=True) # the default + + @app.get("/items/") + async def read_items(): + return [{"item_id": "Foo"}] + ``` + + With this app, if a client goes to `/items` (without a trailing slash), + they will be automatically redirected with an HTTP status code of 307 + to `/items/`. + """ + ), + ] = True, + docs_url: Annotated[ + str | None, + Doc( + """ + The path to the automatic interactive API documentation. + It is handled in the browser by Swagger UI. + + The default URL is `/docs`. You can disable it by setting it to `None`. + + If `openapi_url` is set to `None`, this will be automatically disabled. + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#docs-urls). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(docs_url="/documentation", redoc_url=None) + ``` + """ + ), + ] = "/docs", + redoc_url: Annotated[ + str | None, + Doc( + """ + The path to the alternative automatic interactive API documentation + provided by ReDoc. + + The default URL is `/redoc`. You can disable it by setting it to `None`. + + If `openapi_url` is set to `None`, this will be automatically disabled. + + Read more in the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#docs-urls). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(docs_url="/documentation", redoc_url="redocumentation") + ``` + """ + ), + ] = "/redoc", + swagger_ui_oauth2_redirect_url: Annotated[ + str | None, + Doc( + """ + The OAuth2 redirect endpoint for the Swagger UI. + + By default it is `/docs/oauth2-redirect`. + + This is only used if you use OAuth2 (with the "Authorize" button) + with Swagger UI. + """ + ), + ] = "/docs/oauth2-redirect", + swagger_ui_init_oauth: Annotated[ + dict[str, Any] | None, + Doc( + """ + OAuth2 configuration for the Swagger UI, by default shown at `/docs`. + + Read more about the available configuration options in the + [Swagger UI docs](https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/). + """ + ), + ] = None, + middleware: Annotated[ + Sequence[Middleware] | None, + Doc( + """ + List of middleware to be added when creating the application. + + In FastAPI you would normally do this with `app.add_middleware()` + instead. + + Read more in the + [FastAPI docs for Middleware](https://fastapi.tiangolo.com/tutorial/middleware/). + """ + ), + ] = None, + exception_handlers: Annotated[ + dict[ + int | type[Exception], + Callable[[Request, Any], Coroutine[Any, Any, Response]], + ] + | None, + Doc( + """ + A dictionary with handlers for exceptions. + + In FastAPI, you would normally use the decorator + `@app.exception_handler()`. + + Read more in the + [FastAPI docs for Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/). + """ + ), + ] = None, + on_startup: Annotated[ + Sequence[Callable[[], Any]] | None, + Doc( + """ + A list of startup event handler functions. + + You should instead use the `lifespan` handlers. + + Read more in the [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + on_shutdown: Annotated[ + Sequence[Callable[[], Any]] | None, + Doc( + """ + A list of shutdown event handler functions. + + You should instead use the `lifespan` handlers. + + Read more in the + [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + lifespan: Annotated[ + Lifespan[AppType] | None, + Doc( + """ + A `Lifespan` context manager handler. This replaces `startup` and + `shutdown` functions with a single context manager. + + Read more in the + [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + terms_of_service: Annotated[ + str | None, + Doc( + """ + A URL to the Terms of Service for your API. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more at the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api). + + **Example** + + ```python + app = FastAPI(terms_of_service="http://example.com/terms/") + ``` + """ + ), + ] = None, + contact: Annotated[ + dict[str, str | Any] | None, + Doc( + """ + A dictionary with the contact information for the exposed API. + + It can contain several fields. + + * `name`: (`str`) The name of the contact person/organization. + * `url`: (`str`) A URL pointing to the contact information. MUST be in + the format of a URL. + * `email`: (`str`) The email address of the contact person/organization. + MUST be in the format of an email address. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more at the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api). + + **Example** + + ```python + app = FastAPI( + contact={ + "name": "Deadpoolio the Amazing", + "url": "http://x-force.example.com/contact/", + "email": "dp@x-force.example.com", + } + ) + ``` + """ + ), + ] = None, + license_info: Annotated[ + dict[str, str | Any] | None, + Doc( + """ + A dictionary with the license information for the exposed API. + + It can contain several fields. + + * `name`: (`str`) **REQUIRED** (if a `license_info` is set). The + license name used for the API. + * `identifier`: (`str`) An [SPDX](https://spdx.dev/) license expression + for the API. The `identifier` field is mutually exclusive of the `url` + field. Available since OpenAPI 3.1.0, FastAPI 0.99.0. + * `url`: (`str`) A URL to the license used for the API. This MUST be + the format of a URL. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more at the + [FastAPI docs for Metadata and Docs URLs](https://fastapi.tiangolo.com/tutorial/metadata/#metadata-for-api). + + **Example** + + ```python + app = FastAPI( + license_info={ + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html", + } + ) + ``` + """ + ), + ] = None, + openapi_prefix: Annotated[ + str, + Doc( + """ + A URL prefix for the OpenAPI URL. + """ + ), + deprecated( + """ + "openapi_prefix" has been deprecated in favor of "root_path", which + follows more closely the ASGI standard, is simpler, and more + automatic. + """ + ), + ] = "", + root_path: Annotated[ + str, + Doc( + """ + A path prefix handled by a proxy that is not seen by the application + but is seen by external clients, which affects things like Swagger UI. + + Read more about it at the + [FastAPI docs for Behind a Proxy](https://fastapi.tiangolo.com/advanced/behind-a-proxy/). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(root_path="/api/v1") + ``` + """ + ), + ] = "", + root_path_in_servers: Annotated[ + bool, + Doc( + """ + To disable automatically generating the URLs in the `servers` field + in the autogenerated OpenAPI using the `root_path`. + + Read more about it in the + [FastAPI docs for Behind a Proxy](https://fastapi.tiangolo.com/advanced/behind-a-proxy/#disable-automatic-server-from-root-path). + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI(root_path_in_servers=False) + ``` + """ + ), + ] = True, + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses to be shown in OpenAPI. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Additional Responses in OpenAPI](https://fastapi.tiangolo.com/advanced/additional-responses/). + + And in the + [FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + OpenAPI callbacks that should apply to all *path operations*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + webhooks: Annotated[ + routing.APIRouter | None, + Doc( + """ + Add OpenAPI webhooks. This is similar to `callbacks` but it doesn't + depend on specific *path operations*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + **Note**: This is available since OpenAPI 3.1.0, FastAPI 0.99.0. + + Read more about it in the + [FastAPI docs for OpenAPI Webhooks](https://fastapi.tiangolo.com/advanced/openapi-webhooks/). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark all *path operations* as deprecated. You probably don't need it, + but it's available. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#deprecate-a-path-operation). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) all the *path operations* in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + swagger_ui_parameters: Annotated[ + dict[str, Any] | None, + Doc( + """ + Parameters to configure Swagger UI, the autogenerated interactive API + documentation (by default at `/docs`). + + Read more about it in the + [FastAPI docs about how to Configure Swagger UI](https://fastapi.tiangolo.com/how-to/configure-swagger-ui/). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + separate_input_output_schemas: Annotated[ + bool, + Doc( + """ + Whether to generate separate OpenAPI schemas for request body and + response body when the results would be more precise. + + This is particularly useful when automatically generating clients. + + For example, if you have a model like: + + ```python + from pydantic import BaseModel + + class Item(BaseModel): + name: str + tags: list[str] = [] + ``` + + When `Item` is used for input, a request body, `tags` is not required, + the client doesn't have to provide it. + + But when using `Item` for output, for a response body, `tags` is always + available because it has a default value, even if it's just an empty + list. So, the client should be able to always expect it. + + In this case, there would be two different schemas, one for input and + another one for output. + + Read more about it in the + [FastAPI docs about how to separate schemas for input and output](https://fastapi.tiangolo.com/how-to/separate-openapi-schemas) + """ + ), + ] = True, + openapi_external_docs: Annotated[ + dict[str, Any] | None, + Doc( + """ + This field allows you to provide additional external documentation links. + If provided, it must be a dictionary containing: + + * `description`: A brief description of the external documentation. + * `url`: The URL pointing to the external documentation. The value **MUST** + be a valid URL format. + + **Example**: + + ```python + from fastapi import FastAPI + + external_docs = { + "description": "Detailed API Reference", + "url": "https://example.com/api-docs", + } + + app = FastAPI(openapi_external_docs=external_docs) + ``` + """ + ), + ] = None, + strict_content_type: Annotated[ + bool, + Doc( + """ + Enable strict checking for request Content-Type headers. + + When `True` (the default), requests with a body that do not include + a `Content-Type` header will **not** be parsed as JSON. + + This prevents potential cross-site request forgery (CSRF) attacks + that exploit the browser's ability to send requests without a + Content-Type header, bypassing CORS preflight checks. In particular + applicable for apps that need to be run locally (in localhost). + + When `False`, requests without a `Content-Type` header will have + their body parsed as JSON, which maintains compatibility with + certain clients that don't send `Content-Type` headers. + + Read more about it in the + [FastAPI docs for Strict Content-Type](https://fastapi.tiangolo.com/advanced/strict-content-type/). + """ + ), + ] = True, + **extra: Annotated[ + Any, + Doc( + """ + Extra keyword arguments to be stored in the app, not used by FastAPI + anywhere. + """ + ), + ], + ) -> None: + self.debug = debug + self.title = title + self.summary = summary + self.description = description + self.version = version + self.terms_of_service = terms_of_service + self.contact = contact + self.license_info = license_info + self.openapi_url = openapi_url + self.openapi_tags = openapi_tags + self.root_path_in_servers = root_path_in_servers + self.docs_url = docs_url + self.redoc_url = redoc_url + self.swagger_ui_oauth2_redirect_url = swagger_ui_oauth2_redirect_url + self.swagger_ui_init_oauth = swagger_ui_init_oauth + self.swagger_ui_parameters = swagger_ui_parameters + self.servers = servers or [] + self.separate_input_output_schemas = separate_input_output_schemas + self.openapi_external_docs = openapi_external_docs + self.extra = extra + self.openapi_version: Annotated[ + str, + Doc( + """ + The version string of OpenAPI. + + FastAPI will generate OpenAPI version 3.1.0, and will output that as + the OpenAPI version. But some tools, even though they might be + compatible with OpenAPI 3.1.0, might not recognize it as a valid. + + So you could override this value to trick those tools into using + the generated OpenAPI. Have in mind that this is a hack. But if you + avoid using features added in OpenAPI 3.1.0, it might work for your + use case. + + This is not passed as a parameter to the `FastAPI` class to avoid + giving the false idea that FastAPI would generate a different OpenAPI + schema. It is only available as an attribute. + + **Example** + + ```python + from fastapi import FastAPI + + app = FastAPI() + + app.openapi_version = "3.0.2" + ``` + """ + ), + ] = "3.1.0" + self.openapi_schema: dict[str, Any] | None = None + self._openapi_routes_version: int | None = None + if self.openapi_url: + assert self.title, "A title must be provided for OpenAPI, e.g.: 'My API'" + assert self.version, "A version must be provided for OpenAPI, e.g.: '2.1.0'" + # TODO: remove when discarding the openapi_prefix parameter + if openapi_prefix: + logger.warning( + '"openapi_prefix" has been deprecated in favor of "root_path", which ' + "follows more closely the ASGI standard, is simpler, and more " + "automatic. Check the docs at " + "https://fastapi.tiangolo.com/advanced/sub-applications/" + ) + self.webhooks: Annotated[ + routing.APIRouter, + Doc( + """ + The `app.webhooks` attribute is an `APIRouter` with the *path + operations* that will be used just for documentation of webhooks. + + Read more about it in the + [FastAPI docs for OpenAPI Webhooks](https://fastapi.tiangolo.com/advanced/openapi-webhooks/). + """ + ), + ] = webhooks or routing.APIRouter() + self.root_path = root_path or openapi_prefix + self.state: Annotated[ + State, + Doc( + """ + A state object for the application. This is the same object for the + entire application, it doesn't change from request to request. + + You normally wouldn't use this in FastAPI, for most of the cases you + would instead use FastAPI dependencies. + + This is simply inherited from Starlette. + + Read more about it in the + [Starlette docs for Applications](https://www.starlette.dev/applications/#storing-state-on-the-app-instance). + """ + ), + ] = State() + self.dependency_overrides: Annotated[ + dict[Callable[..., Any], Callable[..., Any]], + Doc( + """ + A dictionary with overrides for the dependencies. + + Each key is the original dependency callable, and the value is the + actual dependency that should be called. + + This is for testing, to replace expensive dependencies with testing + versions. + + Read more about it in the + [FastAPI docs for Testing Dependencies with Overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/). + """ + ), + ] = {} + self.router: routing.APIRouter = routing.APIRouter( + routes=routes, + redirect_slashes=redirect_slashes, + dependency_overrides_provider=self, + on_startup=on_startup, + on_shutdown=on_shutdown, + lifespan=lifespan, + default_response_class=default_response_class, + dependencies=dependencies, + callbacks=callbacks, + deprecated=deprecated, + include_in_schema=include_in_schema, + responses=responses, + generate_unique_id_function=generate_unique_id_function, + strict_content_type=strict_content_type, + ) + self.exception_handlers: dict[ + Any, Callable[[Request, Any], Response | Awaitable[Response]] + ] = {} if exception_handlers is None else dict(exception_handlers) + self.exception_handlers.setdefault(HTTPException, http_exception_handler) + self.exception_handlers.setdefault( + RequestValidationError, request_validation_exception_handler + ) + + # Starlette still has incorrect type specification for the handlers + self.exception_handlers.setdefault( + WebSocketRequestValidationError, + websocket_request_validation_exception_handler, # type: ignore[arg-type] + ) # ty: ignore[no-matching-overload] + + self.user_middleware: list[Middleware] = ( + [] if middleware is None else list(middleware) + ) + self.middleware_stack: ASGIApp | None = None + self.setup() + + def build_middleware_stack(self) -> ASGIApp: + # Duplicate/override from Starlette to add AsyncExitStackMiddleware + # inside of ExceptionMiddleware, inside of custom user middlewares + debug = self.debug + error_handler = None + exception_handlers: dict[Any, ExceptionHandler] = {} + + for key, value in self.exception_handlers.items(): + if key in (500, Exception): + error_handler = value + else: + exception_handlers[key] = value + + middleware = ( + [Middleware(ServerErrorMiddleware, handler=error_handler, debug=debug)] + + self.user_middleware + + [ + Middleware( + ExceptionMiddleware, + handlers=exception_handlers, + debug=debug, + ), + # Add FastAPI-specific AsyncExitStackMiddleware for closing files. + # Before this was also used for closing dependencies with yield but + # those now have their own AsyncExitStack, to properly support + # streaming responses while keeping compatibility with the previous + # versions (as of writing 0.117.1) that allowed doing + # except HTTPException inside a dependency with yield. + # This needs to happen after user middlewares because those create a + # new contextvars context copy by using a new AnyIO task group. + # This AsyncExitStack preserves the context for contextvars, not + # strictly necessary for closing files but it was one of the original + # intentions. + # If the AsyncExitStack lived outside of the custom middlewares and + # contextvars were set, for example in a dependency with 'yield' + # in that internal contextvars context, the values would not be + # available in the outer context of the AsyncExitStack. + # By placing the middleware and the AsyncExitStack here, inside all + # user middlewares, the same context is used. + # This is currently not needed, only for closing files, but used to be + # important when dependencies with yield were closed here. + Middleware(AsyncExitStackMiddleware), + ] + ) + + app = self.router + for cls, args, kwargs in reversed(middleware): + app = cls(app, *args, **kwargs) + return app + + def openapi(self) -> dict[str, Any]: + """ + Generate the OpenAPI schema of the application. This is called by FastAPI + internally. + + The first time it is called it stores the result in the attribute + `app.openapi_schema`, and next times it is called, it just returns that same + result. To avoid the cost of generating the schema every time. + + If you need to modify the generated OpenAPI schema, you could modify it. + + Read more in the + [FastAPI docs for OpenAPI](https://fastapi.tiangolo.com/how-to/extending-openapi/). + """ + routes_version = self.router._get_routes_version() + if not self.openapi_schema or self._openapi_routes_version != routes_version: + self.openapi_schema = get_openapi( + title=self.title, + version=self.version, + openapi_version=self.openapi_version, + summary=self.summary, + description=self.description, + terms_of_service=self.terms_of_service, + contact=self.contact, + license_info=self.license_info, + routes=self.routes, + webhooks=self.webhooks.routes, + tags=self.openapi_tags, + servers=self.servers, + separate_input_output_schemas=self.separate_input_output_schemas, + external_docs=self.openapi_external_docs, + ) + self._openapi_routes_version = routes_version + return self.openapi_schema + + def setup(self) -> None: + if self.openapi_url: + + async def openapi(req: Request) -> JSONResponse: + root_path = req.scope.get("root_path", "").rstrip("/") + schema = self.openapi() + if root_path and self.root_path_in_servers: + server_urls = {s.get("url") for s in schema.get("servers", [])} + if root_path not in server_urls: + schema = dict(schema) + schema["servers"] = [{"url": root_path}] + schema.get( + "servers", [] + ) + return JSONResponse(schema) + + self.add_route(self.openapi_url, openapi, include_in_schema=False) + if self.openapi_url and self.docs_url: + + async def swagger_ui_html(req: Request) -> HTMLResponse: + root_path = req.scope.get("root_path", "").rstrip("/") + openapi_url = root_path + self.openapi_url + oauth2_redirect_url = self.swagger_ui_oauth2_redirect_url + if oauth2_redirect_url: + oauth2_redirect_url = root_path + oauth2_redirect_url + return get_swagger_ui_html( + openapi_url=openapi_url, + title=f"{self.title} - Swagger UI", + oauth2_redirect_url=oauth2_redirect_url, + init_oauth=self.swagger_ui_init_oauth, + swagger_ui_parameters=self.swagger_ui_parameters, + ) + + self.add_route(self.docs_url, swagger_ui_html, include_in_schema=False) + + if self.swagger_ui_oauth2_redirect_url: + + async def swagger_ui_redirect(req: Request) -> HTMLResponse: + return get_swagger_ui_oauth2_redirect_html() + + self.add_route( + self.swagger_ui_oauth2_redirect_url, + swagger_ui_redirect, + include_in_schema=False, + ) + if self.openapi_url and self.redoc_url: + + async def redoc_html(req: Request) -> HTMLResponse: + root_path = req.scope.get("root_path", "").rstrip("/") + openapi_url = root_path + self.openapi_url + return get_redoc_html( + openapi_url=openapi_url, title=f"{self.title} - ReDoc" + ) + + self.add_route(self.redoc_url, redoc_html, include_in_schema=False) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if self.root_path: + scope["root_path"] = self.root_path + await super().__call__(scope, receive, send) + + def add_api_route( + self, + path: str, + endpoint: Callable[..., Any], + *, + response_model: Any = Default(None), + status_code: int | None = None, + tags: list[str | Enum] | None = None, + dependencies: Sequence[Depends] | None = None, + summary: str | None = None, + description: str | None = None, + response_description: str = "Successful Response", + responses: dict[int | str, dict[str, Any]] | None = None, + deprecated: bool | None = None, + methods: list[str] | None = None, + operation_id: str | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + include_in_schema: bool = True, + response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), + name: str | None = None, + openapi_extra: dict[str, Any] | None = None, + generate_unique_id_function: Callable[[routing.APIRoute], str] = Default( + generate_unique_id + ), + ) -> None: + self.router.add_api_route( + path, + endpoint=endpoint, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=methods, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def frontend( + self, + path: Annotated[ + str, + Doc( + """ + The URL path prefix where the frontend build should be served. + """ + ), + ], + *, + directory: Annotated[ + str | os.PathLike[str], + Doc( + """ + The directory containing the static frontend build output. + """ + ), + ], + fallback: Annotated[ + Literal["auto", "index.html", "404.html"] | None, + Doc( + """ + The fallback file behavior for missing frontend paths. + """ + ), + ] = "auto", + check_dir: Annotated[ + bool, + Doc( + """ + Check that the frontend directory exists when the app is created. + """ + ), + ] = True, + ) -> None: + """ + Serve a static frontend build as low-priority routes. + + Use this for frontend tools that build static files into a directory, + such as `dist`. **FastAPI** path operations are checked first, and + the frontend files are checked only if no normal route matched. + + A typical project could look like this: + + ```text + . + ├── pyproject.toml + ├── app + │ ├── __init__.py + │ └── main.py + └── dist + ├── index.html + └── assets + └── app.js + ``` + + Then in `app/main.py`: + + ```python + from fastapi import FastAPI + + app = FastAPI() + app.frontend("/", directory="dist") + ``` + """ + self.router.frontend( + path, + directory=directory, + fallback=fallback, + check_dir=check_dir, + ) + + def api_route( + self, + path: str, + *, + response_model: Any = Default(None), + status_code: int | None = None, + tags: list[str | Enum] | None = None, + dependencies: Sequence[Depends] | None = None, + summary: str | None = None, + description: str | None = None, + response_description: str = "Successful Response", + responses: dict[int | str, dict[str, Any]] | None = None, + deprecated: bool | None = None, + methods: list[str] | None = None, + operation_id: str | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + include_in_schema: bool = True, + response_class: type[Response] = Default(JSONResponse), + name: str | None = None, + openapi_extra: dict[str, Any] | None = None, + generate_unique_id_function: Callable[[routing.APIRoute], str] = Default( + generate_unique_id + ), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.router.add_api_route( + path, + func, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=methods, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + return func + + return decorator + + def add_api_websocket_route( + self, + path: str, + endpoint: Callable[..., Any], + name: str | None = None, + *, + dependencies: Sequence[Depends] | None = None, + ) -> None: + self.router.add_api_websocket_route( + path, + endpoint, + name=name, + dependencies=dependencies, + ) + + def websocket( + self, + path: Annotated[ + str, + Doc( + """ + WebSocket path. + """ + ), + ], + name: Annotated[ + str | None, + Doc( + """ + A name for the WebSocket. Only used internally. + """ + ), + ] = None, + *, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be used for this + WebSocket. + + Read more about it in the + [FastAPI docs for WebSockets](https://fastapi.tiangolo.com/advanced/websockets/). + """ + ), + ] = None, + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Decorate a WebSocket function. + + Read more about it in the + [FastAPI docs for WebSockets](https://fastapi.tiangolo.com/advanced/websockets/). + + **Example** + + ```python + from fastapi import FastAPI, WebSocket + + app = FastAPI() + + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + while True: + data = await websocket.receive_text() + await websocket.send_text(f"Message text was: {data}") + ``` + """ + + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_api_websocket_route( + path, + func, + name=name, + dependencies=dependencies, + ) + return func + + return decorator + + def include_router( + self, + router: Annotated[routing.APIRouter, Doc("The `APIRouter` to include.")], + *, + prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to all the *path operations* in this + router. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to all the + *path operations* in this router. + + Read more about it in the + [FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + + **Example** + + ```python + from fastapi import Depends, FastAPI + + from .dependencies import get_token_header + from .internal import admin + + app = FastAPI() + + app.include_router( + admin.router, + dependencies=[Depends(get_token_header)], + ) + ``` + """ + ), + ] = None, + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses to be shown in OpenAPI. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Additional Responses in OpenAPI](https://fastapi.tiangolo.com/advanced/additional-responses/). + + And in the + [FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark all the *path operations* in this router as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + **Example** + + ```python + from fastapi import FastAPI + + from .internal import old_api + + app = FastAPI() + + app.include_router( + old_api.router, + deprecated=True, + ) + ``` + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include (or not) all the *path operations* in this router in the + generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + **Example** + + ```python + from fastapi import FastAPI + + from .internal import old_api + + app = FastAPI() + + app.include_router( + old_api.router, + include_in_schema=False, + ) + ``` + """ + ), + ] = True, + default_response_class: Annotated[ + type[Response], + Doc( + """ + Default response class to be used for the *path operations* in this + router. + + Read more in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#default-response-class). + + **Example** + + ```python + from fastapi import FastAPI + from fastapi.responses import ORJSONResponse + + from .internal import old_api + + app = FastAPI() + + app.include_router( + old_api.router, + default_response_class=ORJSONResponse, + ) + ``` + """ + ), + ] = Default(JSONResponse), + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> None: + """ + Include an `APIRouter` in the same app. + + Read more about it in the + [FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/). + + ## Example + + ```python + from fastapi import FastAPI + + from .users import users_router + + app = FastAPI() + + app.include_router(users_router) + ``` + """ + self.router.include_router( + router, + prefix=prefix, + tags=tags, + dependencies=dependencies, + responses=responses, + deprecated=deprecated, + include_in_schema=include_in_schema, + default_response_class=default_response_class, + callbacks=callbacks, + generate_unique_id_function=generate_unique_id_function, + ) + + def get( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP GET operation. + + ## Example + + ```python + from fastapi import FastAPI + + app = FastAPI() + + @app.get("/items/") + def read_items(): + return [{"name": "Empanada"}, {"name": "Arepa"}] + ``` + """ + return self.router.get( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def put( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP PUT operation. + + ## Example + + ```python + from fastapi import FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + + @app.put("/items/{item_id}") + def replace_item(item_id: str, item: Item): + return {"message": "Item replaced", "id": item_id} + ``` + """ + return self.router.put( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def post( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP POST operation. + + ## Example + + ```python + from fastapi import FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + + @app.post("/items/") + def create_item(item: Item): + return {"message": "Item created"} + ``` + """ + return self.router.post( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def delete( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP DELETE operation. + + ## Example + + ```python + from fastapi import FastAPI + + app = FastAPI() + + @app.delete("/items/{item_id}") + def delete_item(item_id: str): + return {"message": "Item deleted"} + ``` + """ + return self.router.delete( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def options( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP OPTIONS operation. + + ## Example + + ```python + from fastapi import FastAPI + + app = FastAPI() + + @app.options("/items/") + def get_item_options(): + return {"additions": ["Aji", "Guacamole"]} + ``` + """ + return self.router.options( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def head( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP HEAD operation. + + ## Example + + ```python + from fastapi import FastAPI, Response + + app = FastAPI() + + @app.head("/items/", status_code=204) + def get_items_headers(response: Response): + response.headers["X-Cat-Dog"] = "Alone in the world" + ``` + """ + return self.router.head( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def patch( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP PATCH operation. + + ## Example + + ```python + from fastapi import FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + + @app.patch("/items/") + def update_item(item: Item): + return {"message": "Item updated in place"} + ``` + """ + return self.router.patch( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def trace( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[routing.APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP TRACE operation. + + ## Example + + ```python + from fastapi import FastAPI + + app = FastAPI() + + @app.trace("/items/{item_id}") + def trace_item(item_id: str): + return None + ``` + """ + return self.router.trace( + path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def websocket_route( + self, path: str, name: str | None = None + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.router.add_websocket_route(path, func, name=name) + return func + + return decorator + + @deprecated( + """ + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + """ + ) + def on_event( + self, + event_type: Annotated[ + str, + Doc( + """ + The type of event. `startup` or `shutdown`. + """ + ), + ], + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add an event handler for the application. + + `on_event` is deprecated, use `lifespan` event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/#alternative-events-deprecated). + """ + return self.router.on_event(event_type) # ty: ignore[deprecated] + + def middleware( + self, + middleware_type: Annotated[ + str, + Doc( + """ + The type of middleware. Currently only supports `http`. + """ + ), + ], + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a middleware to the application. + + Read more about it in the + [FastAPI docs for Middleware](https://fastapi.tiangolo.com/tutorial/middleware/). + + ## Example + + ```python + import time + from typing import Awaitable, Callable + + from fastapi import FastAPI, Request, Response + + app = FastAPI() + + + @app.middleware("http") + async def add_process_time_header( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + start_time = time.time() + response = await call_next(request) + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + return response + ``` + """ + + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_middleware(BaseHTTPMiddleware, dispatch=func) + return func + + return decorator + + def exception_handler( + self, + exc_class_or_status_code: Annotated[ + int | type[Exception], + Doc( + """ + The Exception class this would handle, or a status code. + """ + ), + ], + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add an exception handler to the app. + + Read more about it in the + [FastAPI docs for Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/). + + ## Example + + ```python + from fastapi import FastAPI, Request + from fastapi.responses import JSONResponse + + + class UnicornException(Exception): + def __init__(self, name: str): + self.name = name + + + app = FastAPI() + + + @app.exception_handler(UnicornException) + async def unicorn_exception_handler(request: Request, exc: UnicornException): + return JSONResponse( + status_code=418, + content={"message": f"Oops! {exc.name} did something. There goes a rainbow..."}, + ) + ``` + """ + + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_exception_handler(exc_class_or_status_code, func) + return func + + return decorator diff --git a/server/venv/Lib/site-packages/fastapi/background.py b/server/venv/Lib/site-packages/fastapi/background.py new file mode 100644 index 0000000..7677058 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/background.py @@ -0,0 +1,61 @@ +from collections.abc import Callable +from typing import Annotated, Any + +from annotated_doc import Doc +from starlette.background import BackgroundTasks as StarletteBackgroundTasks +from typing_extensions import ParamSpec + +P = ParamSpec("P") + + +class BackgroundTasks(StarletteBackgroundTasks): + """ + A collection of background tasks that will be called after a response has been + sent to the client. + + Read more about it in the + [FastAPI docs for Background Tasks](https://fastapi.tiangolo.com/tutorial/background-tasks/). + + ## Example + + ```python + from fastapi import BackgroundTasks, FastAPI + + app = FastAPI() + + + def write_notification(email: str, message=""): + with open("log.txt", mode="w") as email_file: + content = f"notification for {email}: {message}" + email_file.write(content) + + + @app.post("/send-notification/{email}") + async def send_notification(email: str, background_tasks: BackgroundTasks): + background_tasks.add_task(write_notification, email, message="some notification") + return {"message": "Notification sent in the background"} + ``` + """ + + def add_task( + self, + func: Annotated[ + Callable[P, Any], + Doc( + """ + The function to call after the response is sent. + + It can be a regular `def` function or an `async def` function. + """ + ), + ], + *args: P.args, + **kwargs: P.kwargs, + ) -> None: + """ + Add a function to be called in the background after the response is sent. + + Read more about it in the + [FastAPI docs for Background Tasks](https://fastapi.tiangolo.com/tutorial/background-tasks/). + """ + return super().add_task(func, *args, **kwargs) diff --git a/server/venv/Lib/site-packages/fastapi/cli.py b/server/venv/Lib/site-packages/fastapi/cli.py new file mode 100644 index 0000000..8d3301e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/cli.py @@ -0,0 +1,13 @@ +try: + from fastapi_cli.cli import main as cli_main + +except ImportError: # pragma: no cover + cli_main = None # type: ignore + + +def main() -> None: + if not cli_main: # type: ignore[truthy-function] + message = 'To use the fastapi command, please install "fastapi[standard]":\n\n\tpip install "fastapi[standard]"\n' + print(message) + raise RuntimeError(message) # noqa: B904 + cli_main() diff --git a/server/venv/Lib/site-packages/fastapi/concurrency.py b/server/venv/Lib/site-packages/fastapi/concurrency.py new file mode 100644 index 0000000..76a5a2e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/concurrency.py @@ -0,0 +1,41 @@ +from collections.abc import AsyncGenerator +from contextlib import AbstractContextManager +from contextlib import asynccontextmanager as asynccontextmanager +from typing import TypeVar + +import anyio.to_thread +from anyio import CapacityLimiter +from starlette.concurrency import iterate_in_threadpool as iterate_in_threadpool # noqa +from starlette.concurrency import run_in_threadpool as run_in_threadpool # noqa +from starlette.concurrency import ( # noqa + run_until_first_complete as run_until_first_complete, +) + +_T = TypeVar("_T") + + +@asynccontextmanager +async def contextmanager_in_threadpool( + cm: AbstractContextManager[_T], +) -> AsyncGenerator[_T, None]: + # blocking __exit__ from running waiting on a free thread + # can create race conditions/deadlocks if the context manager itself + # has its own internal pool (e.g. a database connection pool) + # to avoid this we let __exit__ run without a capacity limit + # since we're creating a new limiter for each call, any non-zero limit + # works (1 is arbitrary) + exit_limiter = CapacityLimiter(1) + try: + yield await run_in_threadpool(cm.__enter__) + except Exception as e: + ok = bool( + await anyio.to_thread.run_sync( + cm.__exit__, type(e), e, e.__traceback__, limiter=exit_limiter + ) + ) + if not ok: + raise e + else: + await anyio.to_thread.run_sync( + cm.__exit__, None, None, None, limiter=exit_limiter + ) diff --git a/server/venv/Lib/site-packages/fastapi/datastructures.py b/server/venv/Lib/site-packages/fastapi/datastructures.py new file mode 100644 index 0000000..1da784c --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/datastructures.py @@ -0,0 +1,186 @@ +from collections.abc import Callable, Mapping +from typing import ( + Annotated, + Any, + BinaryIO, + TypeVar, + cast, +) + +from annotated_doc import Doc +from pydantic import GetJsonSchemaHandler +from starlette.datastructures import URL as URL # noqa: F401 +from starlette.datastructures import Address as Address # noqa: F401 +from starlette.datastructures import FormData as FormData # noqa: F401 +from starlette.datastructures import Headers as Headers # noqa: F401 +from starlette.datastructures import QueryParams as QueryParams # noqa: F401 +from starlette.datastructures import State as State # noqa: F401 +from starlette.datastructures import UploadFile as StarletteUploadFile + + +class UploadFile(StarletteUploadFile): + """ + A file uploaded in a request. + + Define it as a *path operation function* (or dependency) parameter. + + If you are using a regular `def` function, you can use the `upload_file.file` + attribute to access the raw standard Python file (blocking, not async), useful and + needed for non-async code. + + Read more about it in the + [FastAPI docs for Request Files](https://fastapi.tiangolo.com/tutorial/request-files/). + + ## Example + + ```python + from typing import Annotated + + from fastapi import FastAPI, File, UploadFile + + app = FastAPI() + + + @app.post("/files/") + async def create_file(file: Annotated[bytes, File()]): + return {"file_size": len(file)} + + + @app.post("/uploadfile/") + async def create_upload_file(file: UploadFile): + return {"filename": file.filename} + ``` + """ + + file: Annotated[ + BinaryIO, + Doc("The standard Python file object (non-async)."), + ] + filename: Annotated[str | None, Doc("The original file name.")] + size: Annotated[int | None, Doc("The size of the file in bytes.")] + headers: Annotated[Headers, Doc("The headers of the request.")] + content_type: Annotated[ + str | None, Doc("The content type of the request, from the headers.") + ] + + async def write( + self, + data: Annotated[ + bytes, + Doc( + """ + The bytes to write to the file. + """ + ), + ], + ) -> None: + """ + Write some bytes to the file. + + You normally wouldn't use this from a file you read in a request. + + To be awaitable, compatible with async, this is run in threadpool. + """ + return await super().write(data) + + async def read( + self, + size: Annotated[ + int, + Doc( + """ + The number of bytes to read from the file. + """ + ), + ] = -1, + ) -> bytes: + """ + Read some bytes from the file. + + To be awaitable, compatible with async, this is run in threadpool. + """ + return await super().read(size) + + async def seek( + self, + offset: Annotated[ + int, + Doc( + """ + The position in bytes to seek to in the file. + """ + ), + ], + ) -> None: + """ + Move to a position in the file. + + Any next read or write will be done from that position. + + To be awaitable, compatible with async, this is run in threadpool. + """ + return await super().seek(offset) + + async def close(self) -> None: + """ + Close the file. + + To be awaitable, compatible with async, this is run in threadpool. + """ + return await super().close() + + @classmethod + def _validate(cls, __input_value: Any, _: Any) -> "UploadFile": + if not isinstance(__input_value, StarletteUploadFile): + raise ValueError(f"Expected UploadFile, received: {type(__input_value)}") + return cast(UploadFile, __input_value) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: Mapping[str, Any], handler: GetJsonSchemaHandler + ) -> dict[str, Any]: + return {"type": "string", "contentMediaType": "application/octet-stream"} + + @classmethod + def __get_pydantic_core_schema__( + cls, source: type[Any], handler: Callable[[Any], Mapping[str, Any]] + ) -> Mapping[str, Any]: + from ._compat.v2 import with_info_plain_validator_function + + return with_info_plain_validator_function(cls._validate) + + +class DefaultPlaceholder: + """ + You shouldn't use this class directly. + + It's used internally to recognize when a default value has been overwritten, even + if the overridden default value was truthy. + """ + + def __init__(self, value: Any): + self.value = value + + def __bool__(self) -> bool: + return bool(self.value) + + def __eq__(self, o: object) -> bool: + return isinstance(o, DefaultPlaceholder) and o.value == self.value + + +DefaultType = TypeVar("DefaultType") + + +def Default(value: DefaultType) -> DefaultType: + """ + You shouldn't use this function directly. + + It's used internally to recognize when a default value has been overwritten, even + if the overridden default value was truthy. + """ + return DefaultPlaceholder(value) # type: ignore + + +# Sentinel for "parameter not provided" in Param/FieldInfo. +# Typed as None to satisfy ty +_Unset = Default(None) diff --git a/server/venv/Lib/site-packages/fastapi/dependencies/__init__.py b/server/venv/Lib/site-packages/fastapi/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/fastapi/dependencies/models.py b/server/venv/Lib/site-packages/fastapi/dependencies/models.py new file mode 100644 index 0000000..25ffb0d --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/dependencies/models.py @@ -0,0 +1,193 @@ +import inspect +import sys +from collections.abc import Callable +from dataclasses import dataclass, field +from functools import cached_property, partial +from typing import Any, Literal + +from fastapi._compat import ModelField +from fastapi.security.base import SecurityBase +from fastapi.types import DependencyCacheKey + +if sys.version_info >= (3, 13): # pragma: no cover + from inspect import iscoroutinefunction +else: # pragma: no cover + from asyncio import iscoroutinefunction + + +def _unwrapped_call(call: Callable[..., Any] | None) -> Any: + if call is None: + return call # pragma: no cover + unwrapped = inspect.unwrap(_impartial(call)) + return unwrapped + + +def _impartial(func: Callable[..., Any]) -> Callable[..., Any]: + while isinstance(func, partial): + func = func.func + return func + + +@dataclass +class Dependant: + path_params: list[ModelField] = field(default_factory=list) + query_params: list[ModelField] = field(default_factory=list) + header_params: list[ModelField] = field(default_factory=list) + cookie_params: list[ModelField] = field(default_factory=list) + body_params: list[ModelField] = field(default_factory=list) + dependencies: list["Dependant"] = field(default_factory=list) + name: str | None = None + call: Callable[..., Any] | None = None + request_param_name: str | None = None + websocket_param_name: str | None = None + http_connection_param_name: str | None = None + response_param_name: str | None = None + background_tasks_param_name: str | None = None + security_scopes_param_name: str | None = None + own_oauth_scopes: list[str] | None = None + parent_oauth_scopes: list[str] | None = None + use_cache: bool = True + path: str | None = None + scope: Literal["function", "request"] | None = None + + @cached_property + def oauth_scopes(self) -> list[str]: + scopes = self.parent_oauth_scopes.copy() if self.parent_oauth_scopes else [] + # This doesn't use a set to preserve order, just in case + for scope in self.own_oauth_scopes or []: + if scope not in scopes: + scopes.append(scope) + return scopes + + @cached_property + def cache_key(self) -> DependencyCacheKey: + scopes_for_cache = ( + tuple(sorted(set(self.oauth_scopes or []))) if self._uses_scopes else () + ) + return ( + self.call, + scopes_for_cache, + self.computed_scope or "", + ) + + @cached_property + def _uses_scopes(self) -> bool: + if self.own_oauth_scopes: + return True + if self.security_scopes_param_name is not None: + return True + if self._is_security_scheme: + return True + for sub_dep in self.dependencies: + if sub_dep._uses_scopes: + return True + return False + + @cached_property + def _is_security_scheme(self) -> bool: + if self.call is None: + return False # pragma: no cover + unwrapped = _unwrapped_call(self.call) + return isinstance(unwrapped, SecurityBase) + + # Mainly to get the type of SecurityBase, but it's the same self.call + @cached_property + def _security_scheme(self) -> SecurityBase: + unwrapped = _unwrapped_call(self.call) + assert isinstance(unwrapped, SecurityBase) + return unwrapped + + @cached_property + def _security_dependencies(self) -> list["Dependant"]: + security_deps = [dep for dep in self.dependencies if dep._is_security_scheme] + return security_deps + + @cached_property + def is_gen_callable(self) -> bool: + if self.call is None: + return False # pragma: no cover + if inspect.isgeneratorfunction( + _impartial(self.call) + ) or inspect.isgeneratorfunction(_unwrapped_call(self.call)): + return True + if inspect.isclass(_unwrapped_call(self.call)): + return False + dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 + if dunder_call is None: + return False # pragma: no cover + if inspect.isgeneratorfunction( + _impartial(dunder_call) + ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_call)): + return True + dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None) # noqa: B004 + if dunder_unwrapped_call is None: + return False # pragma: no cover + if inspect.isgeneratorfunction( + _impartial(dunder_unwrapped_call) + ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_unwrapped_call)): + return True + return False + + @cached_property + def is_async_gen_callable(self) -> bool: + if self.call is None: + return False # pragma: no cover + if inspect.isasyncgenfunction( + _impartial(self.call) + ) or inspect.isasyncgenfunction(_unwrapped_call(self.call)): + return True + if inspect.isclass(_unwrapped_call(self.call)): + return False + dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 + if dunder_call is None: + return False # pragma: no cover + if inspect.isasyncgenfunction( + _impartial(dunder_call) + ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_call)): + return True + dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None) # noqa: B004 + if dunder_unwrapped_call is None: + return False # pragma: no cover + if inspect.isasyncgenfunction( + _impartial(dunder_unwrapped_call) + ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_unwrapped_call)): + return True + return False + + @cached_property + def is_coroutine_callable(self) -> bool: + if self.call is None: + return False # pragma: no cover + if inspect.isroutine(_impartial(self.call)) and iscoroutinefunction( + _impartial(self.call) + ): + return True + if inspect.isroutine(_unwrapped_call(self.call)) and iscoroutinefunction( + _unwrapped_call(self.call) + ): + return True + if inspect.isclass(_unwrapped_call(self.call)): + return False + dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 + if dunder_call is None: + return False # pragma: no cover + if iscoroutinefunction(_impartial(dunder_call)) or iscoroutinefunction( + _unwrapped_call(dunder_call) + ): + return True + dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None) # noqa: B004 + if dunder_unwrapped_call is None: + return False # pragma: no cover + if iscoroutinefunction( + _impartial(dunder_unwrapped_call) + ) or iscoroutinefunction(_unwrapped_call(dunder_unwrapped_call)): + return True + return False + + @cached_property + def computed_scope(self) -> str | None: + if self.scope: + return self.scope + if self.is_gen_callable or self.is_async_gen_callable: + return "request" + return None diff --git a/server/venv/Lib/site-packages/fastapi/dependencies/utils.py b/server/venv/Lib/site-packages/fastapi/dependencies/utils.py new file mode 100644 index 0000000..40dffba --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/dependencies/utils.py @@ -0,0 +1,1061 @@ +import dataclasses +import inspect +import sys +from collections.abc import ( + AsyncGenerator, + AsyncIterable, + AsyncIterator, + Callable, + Generator, + Iterable, + Iterator, + Mapping, + Sequence, +) +from contextlib import AsyncExitStack, contextmanager +from copy import copy, deepcopy +from dataclasses import dataclass +from typing import ( + Annotated, + Any, + ForwardRef, + Literal, + Union, + cast, + get_args, + get_origin, +) + +from fastapi import params +from fastapi._compat import ( + ModelField, + RequiredParam, + Undefined, + copy_field_info, + create_body_model, + evaluate_forwardref, + field_annotation_is_scalar, + field_annotation_is_scalar_sequence, + field_annotation_is_sequence, + get_cached_model_fields, + get_missing_field_error, + is_bytes_or_nonable_bytes_annotation, + is_bytes_sequence_annotation, + is_scalar_field, + is_uploadfile_or_nonable_uploadfile_annotation, + is_uploadfile_sequence_annotation, + lenient_issubclass, + sequence_types, + serialize_sequence_value, + value_is_sequence, +) +from fastapi.background import BackgroundTasks +from fastapi.concurrency import ( + asynccontextmanager, + contextmanager_in_threadpool, +) +from fastapi.dependencies.models import Dependant +from fastapi.exceptions import DependencyScopeError +from fastapi.logger import logger +from fastapi.security.oauth2 import SecurityScopes +from fastapi.types import DependencyCacheKey +from fastapi.utils import create_model_field, get_path_param_names +from pydantic import BaseModel, Json +from pydantic.fields import FieldInfo +from starlette.background import BackgroundTasks as StarletteBackgroundTasks +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import ( + FormData, + Headers, + ImmutableMultiDict, + QueryParams, + UploadFile, +) +from starlette.requests import HTTPConnection, Request +from starlette.responses import Response +from starlette.websockets import WebSocket +from typing_inspection.typing_objects import is_typealiastype + +multipart_not_installed_error = ( + 'Form data requires "python-multipart" to be installed. \n' + 'You can install "python-multipart" with: \n\n' + "pip install python-multipart\n" +) +multipart_incorrect_install_error = ( + 'Form data requires "python-multipart" to be installed. ' + 'It seems you installed "multipart" instead. \n' + 'You can remove "multipart" with: \n\n' + "pip uninstall multipart\n\n" + 'And then install "python-multipart" with: \n\n' + "pip install python-multipart\n" +) + + +def ensure_multipart_is_installed() -> None: + try: + from python_multipart import __version__ + + # Import an attribute that can be mocked/deleted in testing + assert __version__ > "0.0.12" + except (ImportError, AssertionError): + try: + # __version__ is available in both multiparts, and can be mocked + from multipart import ( # type: ignore[no-redef,import-untyped] + __version__, + ) + + assert __version__ + try: + # parse_options_header is only available in the right multipart + from multipart.multipart import ( # type: ignore[import-untyped] + parse_options_header, + ) + + assert parse_options_header + except ImportError: + logger.error(multipart_incorrect_install_error) + raise RuntimeError(multipart_incorrect_install_error) from None + except ImportError: + logger.error(multipart_not_installed_error) + raise RuntimeError(multipart_not_installed_error) from None + + +def get_parameterless_sub_dependant(*, depends: params.Depends, path: str) -> Dependant: + assert callable(depends.dependency), ( + "A parameter-less dependency must have a callable dependency" + ) + own_oauth_scopes: list[str] = [] + if isinstance(depends, params.Security) and depends.scopes: + own_oauth_scopes.extend(depends.scopes) + return get_dependant( + path=path, + call=depends.dependency, + scope=depends.scope, + own_oauth_scopes=own_oauth_scopes, + ) + + +def get_flat_dependant( + dependant: Dependant, + *, + skip_repeats: bool = False, + visited: list[DependencyCacheKey] | None = None, + parent_oauth_scopes: list[str] | None = None, +) -> Dependant: + if visited is None: + visited = [] + visited.append(dependant.cache_key) + use_parent_oauth_scopes = (parent_oauth_scopes or []) + ( + dependant.oauth_scopes or [] + ) + + flat_dependant = Dependant( + path_params=dependant.path_params.copy(), + query_params=dependant.query_params.copy(), + header_params=dependant.header_params.copy(), + cookie_params=dependant.cookie_params.copy(), + body_params=dependant.body_params.copy(), + name=dependant.name, + call=dependant.call, + request_param_name=dependant.request_param_name, + websocket_param_name=dependant.websocket_param_name, + http_connection_param_name=dependant.http_connection_param_name, + response_param_name=dependant.response_param_name, + background_tasks_param_name=dependant.background_tasks_param_name, + security_scopes_param_name=dependant.security_scopes_param_name, + own_oauth_scopes=dependant.own_oauth_scopes, + parent_oauth_scopes=use_parent_oauth_scopes, + use_cache=dependant.use_cache, + path=dependant.path, + scope=dependant.scope, + ) + for sub_dependant in dependant.dependencies: + if skip_repeats and sub_dependant.cache_key in visited: + continue + flat_sub = get_flat_dependant( + sub_dependant, + skip_repeats=skip_repeats, + visited=visited, + parent_oauth_scopes=flat_dependant.oauth_scopes, + ) + flat_dependant.dependencies.append(flat_sub) + flat_dependant.path_params.extend(flat_sub.path_params) + flat_dependant.query_params.extend(flat_sub.query_params) + flat_dependant.header_params.extend(flat_sub.header_params) + flat_dependant.cookie_params.extend(flat_sub.cookie_params) + flat_dependant.body_params.extend(flat_sub.body_params) + flat_dependant.dependencies.extend(flat_sub.dependencies) + + return flat_dependant + + +def _get_flat_fields_from_params(fields: list[ModelField]) -> list[ModelField]: + if not fields: + return fields + first_field = fields[0] + if len(fields) == 1 and lenient_issubclass( + first_field.field_info.annotation, BaseModel + ): + fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) + return fields_to_extract + return fields + + +def get_flat_params(dependant: Dependant) -> list[ModelField]: + flat_dependant = get_flat_dependant(dependant, skip_repeats=True) + path_params = _get_flat_fields_from_params(flat_dependant.path_params) + query_params = _get_flat_fields_from_params(flat_dependant.query_params) + header_params = _get_flat_fields_from_params(flat_dependant.header_params) + cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params) + return path_params + query_params + header_params + cookie_params + + +def _get_signature(call: Callable[..., Any]) -> inspect.Signature: + try: + signature = inspect.signature(call, eval_str=True) + except NameError: + # Handle type annotations with if TYPE_CHECKING, not used by FastAPI + # e.g. dependency return types + if sys.version_info >= (3, 14): + from annotationlib import Format + + signature = inspect.signature(call, annotation_format=Format.FORWARDREF) + else: + signature = inspect.signature(call) + return signature + + +def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: + signature = _get_signature(call) + unwrapped = inspect.unwrap(call) + globalns = getattr(unwrapped, "__globals__", {}) + typed_params = [ + inspect.Parameter( + name=param.name, + kind=param.kind, + default=param.default, + annotation=get_typed_annotation(param.annotation, globalns), + ) + for param in signature.parameters.values() + ] + typed_signature = inspect.Signature(typed_params) + return typed_signature + + +def get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any: + if isinstance(annotation, str): + annotation = ForwardRef(annotation) + annotation = evaluate_forwardref(annotation, globalns, globalns) + if annotation is type(None): + return None + return annotation + + +def get_typed_return_annotation(call: Callable[..., Any]) -> Any: + signature = _get_signature(call) + unwrapped = inspect.unwrap(call) + annotation = signature.return_annotation + + if annotation is inspect.Signature.empty: + return None + + globalns = getattr(unwrapped, "__globals__", {}) + return get_typed_annotation(annotation, globalns) + + +_STREAM_ORIGINS = { + AsyncIterable, + AsyncIterator, + AsyncGenerator, + Iterable, + Iterator, + Generator, +} + + +def get_stream_item_type(annotation: Any) -> Any | None: + origin = get_origin(annotation) + if origin is not None and origin in _STREAM_ORIGINS: + type_args = get_args(annotation) + if type_args: + return type_args[0] + return Any + return None + + +def get_dependant( + *, + path: str, + call: Callable[..., Any], + name: str | None = None, + own_oauth_scopes: list[str] | None = None, + parent_oauth_scopes: list[str] | None = None, + use_cache: bool = True, + scope: Literal["function", "request"] | None = None, +) -> Dependant: + dependant = Dependant( + call=call, + name=name, + path=path, + use_cache=use_cache, + scope=scope, + own_oauth_scopes=own_oauth_scopes, + parent_oauth_scopes=parent_oauth_scopes, + ) + current_scopes = (parent_oauth_scopes or []) + (own_oauth_scopes or []) + path_param_names = get_path_param_names(path) + endpoint_signature = get_typed_signature(call) + signature_params = endpoint_signature.parameters + for param_name, param in signature_params.items(): + is_path_param = param_name in path_param_names + param_details = analyze_param( + param_name=param_name, + annotation=param.annotation, + value=param.default, + is_path_param=is_path_param, + ) + if param_details.depends is not None: + assert param_details.depends.dependency + if ( + (dependant.is_gen_callable or dependant.is_async_gen_callable) + and dependant.computed_scope == "request" + and param_details.depends.scope == "function" + ): + assert dependant.call + call_name = getattr(dependant.call, "__name__", "") + raise DependencyScopeError( + f'The dependency "{call_name}" has a scope of ' + '"request", it cannot depend on dependencies with scope "function".' + ) + sub_own_oauth_scopes: list[str] = [] + if isinstance(param_details.depends, params.Security): + if param_details.depends.scopes: + sub_own_oauth_scopes = list(param_details.depends.scopes) + sub_dependant = get_dependant( + path=path, + call=param_details.depends.dependency, + name=param_name, + own_oauth_scopes=sub_own_oauth_scopes, + parent_oauth_scopes=current_scopes, + use_cache=param_details.depends.use_cache, + scope=param_details.depends.scope, + ) + dependant.dependencies.append(sub_dependant) + continue + if add_non_field_param_to_dependency( + param_name=param_name, + type_annotation=param_details.type_annotation, + dependant=dependant, + ): + assert param_details.field is None, ( + f"Cannot specify multiple FastAPI annotations for {param_name!r}" + ) + continue + assert param_details.field is not None + if isinstance(param_details.field.field_info, params.Body): + dependant.body_params.append(param_details.field) + else: + add_param_to_fields(field=param_details.field, dependant=dependant) + return dependant + + +def add_non_field_param_to_dependency( + *, param_name: str, type_annotation: Any, dependant: Dependant +) -> bool | None: + if lenient_issubclass(type_annotation, Request): + dependant.request_param_name = param_name + return True + elif lenient_issubclass(type_annotation, WebSocket): + dependant.websocket_param_name = param_name + return True + elif lenient_issubclass(type_annotation, HTTPConnection): + dependant.http_connection_param_name = param_name + return True + elif lenient_issubclass(type_annotation, Response): + dependant.response_param_name = param_name + return True + elif lenient_issubclass(type_annotation, StarletteBackgroundTasks): + dependant.background_tasks_param_name = param_name + return True + elif lenient_issubclass(type_annotation, SecurityScopes): + dependant.security_scopes_param_name = param_name + return True + return None + + +@dataclass +class ParamDetails: + type_annotation: Any + depends: params.Depends | None + field: ModelField | None + + +def analyze_param( + *, + param_name: str, + annotation: Any, + value: Any, + is_path_param: bool, +) -> ParamDetails: + field_info = None + depends = None + type_annotation: Any = Any + use_annotation: Any = Any + if is_typealiastype(annotation): + # unpack in case PEP 695 type syntax is used + annotation = annotation.__value__ + if annotation is not inspect.Signature.empty: + use_annotation = annotation + type_annotation = annotation + # Extract Annotated info + if get_origin(use_annotation) is Annotated: + annotated_args = get_args(annotation) + type_annotation = annotated_args[0] + fastapi_annotations = [ + arg + for arg in annotated_args[1:] + if isinstance(arg, (FieldInfo, params.Depends)) + ] + fastapi_specific_annotations = [ + arg + for arg in fastapi_annotations + if isinstance( + arg, + ( + params.Param, + params.Body, + params.Depends, + ), + ) + ] + if fastapi_specific_annotations: + fastapi_annotation: FieldInfo | params.Depends | None = ( + fastapi_specific_annotations[-1] + ) + else: + fastapi_annotation = None + # Set default for Annotated FieldInfo + if isinstance(fastapi_annotation, FieldInfo): + # Copy `field_info` because we mutate `field_info.default` below. + field_info = copy_field_info( + field_info=fastapi_annotation, + annotation=use_annotation, + ) + assert ( + field_info.default == Undefined or field_info.default == RequiredParam + ), ( + f"`{field_info.__class__.__name__}` default value cannot be set in" + f" `Annotated` for {param_name!r}. Set the default value with `=` instead." + ) + if value is not inspect.Signature.empty: + assert not is_path_param, "Path parameters cannot have default values" + field_info.default = value + else: + field_info.default = RequiredParam + # Get Annotated Depends + elif isinstance(fastapi_annotation, params.Depends): + depends = fastapi_annotation + # Get Depends from default value + if isinstance(value, params.Depends): + assert depends is None, ( + "Cannot specify `Depends` in `Annotated` and default value" + f" together for {param_name!r}" + ) + assert field_info is None, ( + "Cannot specify a FastAPI annotation in `Annotated` and `Depends` as a" + f" default value together for {param_name!r}" + ) + depends = value + # Get FieldInfo from default value + elif isinstance(value, FieldInfo): + assert field_info is None, ( + "Cannot specify FastAPI annotations in `Annotated` and default value" + f" together for {param_name!r}" + ) + field_info = value + if isinstance(field_info, FieldInfo): + field_info.annotation = type_annotation + + # Get Depends from type annotation + if depends is not None and depends.dependency is None: + # Copy `depends` before mutating it + depends = copy(depends) + depends = dataclasses.replace(depends, dependency=type_annotation) + + # Handle non-param type annotations like Request + # Only apply special handling when there's no explicit Depends - if there's a Depends, + # the dependency will be called and its return value used instead of the special injection + if depends is None and lenient_issubclass( + type_annotation, + ( + Request, + WebSocket, + HTTPConnection, + Response, + StarletteBackgroundTasks, + SecurityScopes, + ), + ): + assert field_info is None, ( + f"Cannot specify FastAPI annotation for type {type_annotation!r}" + ) + # Handle default assignations, neither field_info nor depends was not found in Annotated nor default value + elif field_info is None and depends is None: + default_value = value if value is not inspect.Signature.empty else RequiredParam + if is_path_param: + # We might check here that `default_value is RequiredParam`, but the fact is that the same + # parameter might sometimes be a path parameter and sometimes not. See + # `tests/test_infer_param_optionality.py` for an example. + field_info = params.Path(annotation=use_annotation) + elif is_uploadfile_or_nonable_uploadfile_annotation( + type_annotation + ) or is_uploadfile_sequence_annotation(type_annotation): + field_info = params.File(annotation=use_annotation, default=default_value) + elif not field_annotation_is_scalar(annotation=type_annotation): + field_info = params.Body(annotation=use_annotation, default=default_value) + else: + field_info = params.Query(annotation=use_annotation, default=default_value) + + field = None + # It's a field_info, not a dependency + if field_info is not None: + # Handle field_info.in_ + if is_path_param: + assert isinstance(field_info, params.Path), ( + f"Cannot use `{field_info.__class__.__name__}` for path param" + f" {param_name!r}" + ) + elif ( + isinstance(field_info, params.Param) + and getattr(field_info, "in_", None) is None + ): + field_info.in_ = params.ParamTypes.query + use_annotation_from_field_info = use_annotation + if isinstance(field_info, params.Form): + ensure_multipart_is_installed() + if not field_info.alias and getattr(field_info, "convert_underscores", None): + alias = param_name.replace("_", "-") + else: + alias = field_info.alias or param_name + field_info.alias = alias + field = create_model_field( + name=param_name, + type_=use_annotation_from_field_info, + default=field_info.default, + alias=alias, + field_info=field_info, + ) + if is_path_param: + assert is_scalar_field(field=field), ( + "Path params must be of one of the supported types" + ) + elif isinstance(field_info, params.Query): + assert ( + is_scalar_field(field) + or field_annotation_is_scalar_sequence(field.field_info.annotation) + or lenient_issubclass(field.field_info.annotation, BaseModel) + ), f"Query parameter {param_name!r} must be one of the supported types" + + return ParamDetails(type_annotation=type_annotation, depends=depends, field=field) + + +def add_param_to_fields(*, field: ModelField, dependant: Dependant) -> None: + field_info = field.field_info + field_info_in = getattr(field_info, "in_", None) + if field_info_in == params.ParamTypes.path: + dependant.path_params.append(field) + elif field_info_in == params.ParamTypes.query: + dependant.query_params.append(field) + elif field_info_in == params.ParamTypes.header: + dependant.header_params.append(field) + else: + assert field_info_in == params.ParamTypes.cookie, ( + f"non-body parameters must be in path, query, header or cookie: {field.name}" + ) + dependant.cookie_params.append(field) + + +async def _solve_generator( + *, dependant: Dependant, stack: AsyncExitStack, sub_values: dict[str, Any] +) -> Any: + assert dependant.call + if dependant.is_async_gen_callable: + cm = asynccontextmanager(dependant.call)(**sub_values) + elif dependant.is_gen_callable: + cm = contextmanager_in_threadpool(contextmanager(dependant.call)(**sub_values)) + return await stack.enter_async_context(cm) + + +@dataclass +class SolvedDependency: + values: dict[str, Any] + errors: list[Any] + background_tasks: StarletteBackgroundTasks | None + response: Response + dependency_cache: dict[DependencyCacheKey, Any] + + +async def solve_dependencies( + *, + request: Request | WebSocket, + dependant: Dependant, + body: dict[str, Any] | FormData | bytes | None = None, + background_tasks: StarletteBackgroundTasks | None = None, + response: Response | None = None, + dependency_overrides_provider: Any | None = None, + dependency_cache: dict[DependencyCacheKey, Any] | None = None, + # TODO: remove this parameter later, no longer used, not removing it yet as some + # people might be monkey patching this function (although that's not supported) + async_exit_stack: AsyncExitStack, + embed_body_fields: bool, +) -> SolvedDependency: + request_astack = request.scope.get("fastapi_inner_astack") + assert isinstance(request_astack, AsyncExitStack), ( + "fastapi_inner_astack not found in request scope" + ) + function_astack = request.scope.get("fastapi_function_astack") + assert isinstance(function_astack, AsyncExitStack), ( + "fastapi_function_astack not found in request scope" + ) + values: dict[str, Any] = {} + errors: list[Any] = [] + if response is None: + response = Response() + del response.headers["content-length"] + response.status_code = None # type: ignore + if dependency_cache is None: + dependency_cache = {} + for sub_dependant in dependant.dependencies: + sub_dependant.call = cast(Callable[..., Any], sub_dependant.call) + call = sub_dependant.call + use_sub_dependant = sub_dependant + if ( + dependency_overrides_provider + and dependency_overrides_provider.dependency_overrides + ): + original_call = sub_dependant.call + call = getattr( + dependency_overrides_provider, "dependency_overrides", {} + ).get(original_call, original_call) + use_path: str = sub_dependant.path # type: ignore + use_sub_dependant = get_dependant( + path=use_path, + call=call, + name=sub_dependant.name, + parent_oauth_scopes=sub_dependant.oauth_scopes, + scope=sub_dependant.scope, + ) + + solved_result = await solve_dependencies( + request=request, + dependant=use_sub_dependant, + body=body, + background_tasks=background_tasks, + response=response, + dependency_overrides_provider=dependency_overrides_provider, + dependency_cache=dependency_cache, + async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, + ) + background_tasks = solved_result.background_tasks + if solved_result.errors: + errors.extend(solved_result.errors) + continue + if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache: + solved = dependency_cache[sub_dependant.cache_key] + elif ( + use_sub_dependant.is_gen_callable or use_sub_dependant.is_async_gen_callable + ): + use_astack = request_astack + if sub_dependant.scope == "function": + use_astack = function_astack + solved = await _solve_generator( + dependant=use_sub_dependant, + stack=use_astack, + sub_values=solved_result.values, + ) + elif use_sub_dependant.is_coroutine_callable: + solved = await call(**solved_result.values) + else: + solved = await run_in_threadpool(call, **solved_result.values) + if sub_dependant.name is not None: + values[sub_dependant.name] = solved + if sub_dependant.cache_key not in dependency_cache: + dependency_cache[sub_dependant.cache_key] = solved + path_values, path_errors = request_params_to_args( + dependant.path_params, request.path_params + ) + query_values, query_errors = request_params_to_args( + dependant.query_params, request.query_params + ) + header_values, header_errors = request_params_to_args( + dependant.header_params, request.headers + ) + cookie_values, cookie_errors = request_params_to_args( + dependant.cookie_params, request.cookies + ) + values.update(path_values) + values.update(query_values) + values.update(header_values) + values.update(cookie_values) + errors += path_errors + query_errors + header_errors + cookie_errors + if dependant.body_params: + ( + body_values, + body_errors, + ) = await request_body_to_args( # body_params checked above + body_fields=dependant.body_params, + received_body=body, + embed_body_fields=embed_body_fields, + ) + values.update(body_values) + errors.extend(body_errors) + if dependant.http_connection_param_name: + values[dependant.http_connection_param_name] = request + if dependant.request_param_name and isinstance(request, Request): + values[dependant.request_param_name] = request + elif dependant.websocket_param_name and isinstance(request, WebSocket): + values[dependant.websocket_param_name] = request + if dependant.background_tasks_param_name: + if background_tasks is None: + background_tasks = BackgroundTasks() + values[dependant.background_tasks_param_name] = background_tasks + if dependant.response_param_name: + values[dependant.response_param_name] = response + if dependant.security_scopes_param_name: + values[dependant.security_scopes_param_name] = SecurityScopes( + scopes=dependant.oauth_scopes + ) + return SolvedDependency( + values=values, + errors=errors, + background_tasks=background_tasks, + response=response, + dependency_cache=dependency_cache, + ) + + +def _validate_value_with_model_field( + *, field: ModelField, value: Any, values: dict[str, Any], loc: tuple[str, ...] +) -> tuple[Any, list[Any]]: + if value is None: + if field.field_info.is_required(): + return None, [get_missing_field_error(loc=loc)] + else: + return deepcopy(field.default), [] + return field.validate(value, values, loc=loc) + + +def _is_json_field(field: ModelField) -> bool: + return any(type(item) is Json for item in field.field_info.metadata) + + +def _get_multidict_value( + field: ModelField, values: Mapping[str, Any], alias: str | None = None +) -> Any: + alias = alias or get_validation_alias(field) + if ( + (not _is_json_field(field)) + and field_annotation_is_sequence(field.field_info.annotation) + and isinstance(values, (ImmutableMultiDict, Headers)) + ): + value = values.getlist(alias) + else: + value = values.get(alias, None) + if ( + value is None + or ( + isinstance(field.field_info, params.Form) + and isinstance(value, str) # For type checks + and value == "" + ) + or ( + field_annotation_is_sequence(field.field_info.annotation) + and len(value) == 0 + ) + ): + if field.field_info.is_required(): + return + else: + return deepcopy(field.default) + return value + + +def request_params_to_args( + fields: Sequence[ModelField], + received_params: Mapping[str, Any] | QueryParams | Headers, +) -> tuple[dict[str, Any], list[Any]]: + values: dict[str, Any] = {} + errors: list[dict[str, Any]] = [] + + if not fields: + return values, errors + + first_field = fields[0] + fields_to_extract = fields + single_not_embedded_field = False + default_convert_underscores = True + if len(fields) == 1 and lenient_issubclass( + first_field.field_info.annotation, BaseModel + ): + fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) + single_not_embedded_field = True + # If headers are in a Pydantic model, the way to disable convert_underscores + # would be with Header(convert_underscores=False) at the Pydantic model level + default_convert_underscores = getattr( + first_field.field_info, "convert_underscores", True + ) + + params_to_process: dict[str, Any] = {} + + processed_keys = set() + + for field in fields_to_extract: + alias = None + if isinstance(received_params, Headers): + # Handle fields extracted from a Pydantic Model for a header, each field + # doesn't have a FieldInfo of type Header with the default convert_underscores=True + convert_underscores = getattr( + field.field_info, "convert_underscores", default_convert_underscores + ) + if convert_underscores: + alias = get_validation_alias(field) + if alias == field.name: + alias = alias.replace("_", "-") + value = _get_multidict_value(field, received_params, alias=alias) + if value is not None: + params_to_process[get_validation_alias(field)] = value + processed_keys.add(alias or get_validation_alias(field)) + # For headers with convert_underscores=True, mark both the converted + # header name and the original field alias as processed to avoid + # accepting the original alias as an extra header. + processed_keys.add(get_validation_alias(field)) + + for key in received_params.keys(): + if key not in processed_keys: + if isinstance(received_params, (ImmutableMultiDict, Headers)): + value = received_params.getlist(key) + if isinstance(value, list) and (len(value) == 1): + params_to_process[key] = value[0] + else: + params_to_process[key] = value + else: + params_to_process[key] = received_params.get(key) + + if single_not_embedded_field: + field_info = first_field.field_info + assert isinstance(field_info, params.Param), ( + "Params must be subclasses of Param" + ) + loc: tuple[str, ...] = (field_info.in_.value,) + v_, errors_ = _validate_value_with_model_field( + field=first_field, value=params_to_process, values=values, loc=loc + ) + return {first_field.name: v_}, errors_ + + for field in fields: + value = _get_multidict_value(field, received_params) + field_info = field.field_info + assert isinstance(field_info, params.Param), ( + "Params must be subclasses of Param" + ) + loc = (field_info.in_.value, get_validation_alias(field)) + v_, errors_ = _validate_value_with_model_field( + field=field, value=value, values=values, loc=loc + ) + if errors_: + errors.extend(errors_) + else: + values[field.name] = v_ + return values, errors + + +def is_union_of_base_models(field_type: Any) -> bool: + """Check if field type is a Union where all members are BaseModel subclasses.""" + from fastapi.types import UnionType + + origin = get_origin(field_type) + + # Check if it's a Union type (covers both typing.Union and types.UnionType in Python 3.10+) + if origin is not Union and origin is not UnionType: + return False + + union_args = get_args(field_type) + + for arg in union_args: + if not lenient_issubclass(arg, BaseModel): + return False + + return True + + +def _should_embed_body_fields(fields: list[ModelField]) -> bool: + if not fields: + return False + # More than one dependency could have the same field, it would show up as multiple + # fields but it's the same one, so count them by name + body_param_names_set = {field.name for field in fields} + # A top level field has to be a single field, not multiple + if len(body_param_names_set) > 1: + return True + first_field = fields[0] + # If it explicitly specifies it is embedded, it has to be embedded + if getattr(first_field.field_info, "embed", None): + return True + # If it's a Form (or File) field, it has to be a BaseModel (or a union of BaseModels) to be top level + # otherwise it has to be embedded, so that the key value pair can be extracted + if ( + isinstance(first_field.field_info, params.Form) + and not lenient_issubclass(first_field.field_info.annotation, BaseModel) + and not is_union_of_base_models(first_field.field_info.annotation) + ): + return True + return False + + +async def _extract_form_body( + body_fields: list[ModelField], + received_body: FormData, +) -> dict[str, Any]: + values = {} + + for field in body_fields: + value = _get_multidict_value(field, received_body) + field_info = field.field_info + if ( + isinstance(field_info, params.File) + and is_bytes_or_nonable_bytes_annotation(field.field_info.annotation) + and isinstance(value, UploadFile) + ): + value = await value.read() + elif ( + is_bytes_sequence_annotation(field.field_info.annotation) + and isinstance(field_info, params.File) + and value_is_sequence(value) + ): + # For types + assert isinstance(value, sequence_types) + results: list[bytes | str] = [] + for sub_value in value: + results.append(await sub_value.read()) + value = serialize_sequence_value(field=field, value=results) + if value is not None: + values[get_validation_alias(field)] = value + field_aliases = {get_validation_alias(field) for field in body_fields} + for key in received_body.keys(): + if key not in field_aliases: + param_values = received_body.getlist(key) + if len(param_values) == 1: + values[key] = param_values[0] + else: + values[key] = param_values + return values + + +async def request_body_to_args( + body_fields: list[ModelField], + received_body: dict[str, Any] | FormData | bytes | None, + embed_body_fields: bool, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + values: dict[str, Any] = {} + errors: list[dict[str, Any]] = [] + assert body_fields, "request_body_to_args() should be called with fields" + single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields + first_field = body_fields[0] + body_to_process = received_body + + fields_to_extract: list[ModelField] = body_fields + + if ( + single_not_embedded_field + and lenient_issubclass(first_field.field_info.annotation, BaseModel) + and isinstance(received_body, FormData) + ): + fields_to_extract = get_cached_model_fields(first_field.field_info.annotation) + + if isinstance(received_body, FormData): + body_to_process = await _extract_form_body(fields_to_extract, received_body) + + if single_not_embedded_field: + loc: tuple[str, ...] = ("body",) + v_, errors_ = _validate_value_with_model_field( + field=first_field, value=body_to_process, values=values, loc=loc + ) + return {first_field.name: v_}, errors_ + for field in body_fields: + loc = ("body", get_validation_alias(field)) + value: Any | None = None + if body_to_process is not None and not isinstance(body_to_process, bytes): + try: + value = body_to_process.get(get_validation_alias(field)) + # If the received body is a list, not a dict + except AttributeError: + errors.append(get_missing_field_error(loc)) + continue + v_, errors_ = _validate_value_with_model_field( + field=field, value=value, values=values, loc=loc + ) + if errors_: + errors.extend(errors_) + else: + values[field.name] = v_ + return values, errors + + +def get_body_field( + *, flat_dependant: Dependant, name: str, embed_body_fields: bool +) -> ModelField | None: + """ + Get a ModelField representing the request body for a path operation, combining + all body parameters into a single field if necessary. + + Used to check if it's form data (with `isinstance(body_field, params.Form)`) + or JSON and to generate the JSON Schema for a request body. + + This is **not** used to validate/parse the request body, that's done with each + individual body parameter. + """ + if not flat_dependant.body_params: + return None + first_param = flat_dependant.body_params[0] + if not embed_body_fields: + return first_param + model_name = "Body_" + name + BodyModel = create_body_model( + fields=flat_dependant.body_params, model_name=model_name + ) + required = any( + True for f in flat_dependant.body_params if f.field_info.is_required() + ) + BodyFieldInfo_kwargs: dict[str, Any] = { + "annotation": BodyModel, + "alias": "body", + } + if not required: + BodyFieldInfo_kwargs["default"] = None + if any(isinstance(f.field_info, params.File) for f in flat_dependant.body_params): + BodyFieldInfo: type[params.Body] = params.File + elif any(isinstance(f.field_info, params.Form) for f in flat_dependant.body_params): + BodyFieldInfo = params.Form + else: + BodyFieldInfo = params.Body + + body_param_media_types = [ + f.field_info.media_type + for f in flat_dependant.body_params + if isinstance(f.field_info, params.Body) + ] + if len(set(body_param_media_types)) == 1: + BodyFieldInfo_kwargs["media_type"] = body_param_media_types[0] + final_field = create_model_field( + name="body", + type_=BodyModel, + alias="body", + field_info=BodyFieldInfo(**BodyFieldInfo_kwargs), + ) + return final_field + + +def get_validation_alias(field: ModelField) -> str: + va = getattr(field, "validation_alias", None) + return va or field.alias diff --git a/server/venv/Lib/site-packages/fastapi/encoders.py b/server/venv/Lib/site-packages/fastapi/encoders.py new file mode 100644 index 0000000..c9f882d --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/encoders.py @@ -0,0 +1,364 @@ +import dataclasses +import datetime +from collections import defaultdict, deque +from collections.abc import Callable +from decimal import Decimal +from enum import Enum +from ipaddress import ( + IPv4Address, + IPv4Interface, + IPv4Network, + IPv6Address, + IPv6Interface, + IPv6Network, +) +from pathlib import Path, PurePath +from re import Pattern +from types import GeneratorType +from typing import Annotated, Any +from uuid import UUID + +from annotated_doc import Doc +from fastapi.exceptions import PydanticV1NotSupportedError +from fastapi.types import IncEx +from pydantic import BaseModel +from pydantic.networks import AnyUrl, NameEmail +from pydantic.types import SecretBytes, SecretStr +from pydantic_core import PydanticUndefinedType + +from ._compat import ( + Url, + is_pydantic_v1_model_instance, +) + +try: + # pydantic.color.Color is deprecated since v2.0b3, but supporting for bwd-compat + from pydantic.color import Color # ty: ignore[deprecated] +except ImportError: # pragma: no cover + + class Color: # type: ignore[no-redef] + pass + + +try: + # Supporting the new Color format for newer versions of Pydantic + from pydantic_extra_types.color import Color as PyExtraColor +except ImportError: # pragma: no cover + + class PyExtraColor: # type: ignore[no-redef] + pass + + +# Taken from Pydantic v1 as is +def isoformat(o: datetime.date | datetime.time) -> str: + return o.isoformat() + + +# Adapted from Pydantic v1 +# TODO: pv2 should this return strings instead? +def decimal_encoder(dec_value: Decimal) -> int | float: + """ + Encodes a Decimal as int if there's no exponent, otherwise float + + This is useful when we use ConstrainedDecimal to represent Numeric(x,0) + where an integer (but not int typed) is used. Encoding this as a float + results in failed round-tripping between encode and parse. + Our Id type is a prime example of this. + + >>> decimal_encoder(Decimal("1.0")) + 1.0 + + >>> decimal_encoder(Decimal("1")) + 1 + + >>> decimal_encoder(Decimal("NaN")) + nan + """ + exponent = dec_value.as_tuple().exponent + if isinstance(exponent, int) and exponent >= 0: + return int(dec_value) + else: + return float(dec_value) + + +ENCODERS_BY_TYPE: dict[type[Any], Callable[[Any], Any]] = { + bytes: lambda o: o.decode(), + Color: str, + PyExtraColor: str, + datetime.date: isoformat, + datetime.datetime: isoformat, + datetime.time: isoformat, + datetime.timedelta: lambda td: td.total_seconds(), + Decimal: decimal_encoder, + Enum: lambda o: o.value, + frozenset: list, + deque: list, + GeneratorType: list, + IPv4Address: str, + IPv4Interface: str, + IPv4Network: str, + IPv6Address: str, + IPv6Interface: str, + IPv6Network: str, + NameEmail: str, + Path: str, + Pattern: lambda o: o.pattern, + SecretBytes: str, + SecretStr: str, + set: list, + UUID: str, + Url: str, + AnyUrl: str, +} + + +def generate_encoders_by_class_tuples( + type_encoder_map: dict[Any, Callable[[Any], Any]], +) -> dict[Callable[[Any], Any], tuple[Any, ...]]: + encoders_by_class_tuples: dict[Callable[[Any], Any], tuple[Any, ...]] = defaultdict( + tuple + ) + for type_, encoder in type_encoder_map.items(): + encoders_by_class_tuples[encoder] += (type_,) + return encoders_by_class_tuples + + +encoders_by_class_tuples = generate_encoders_by_class_tuples(ENCODERS_BY_TYPE) + + +def jsonable_encoder( + obj: Annotated[ + Any, + Doc( + """ + The input object to convert to JSON. + """ + ), + ], + include: Annotated[ + IncEx | None, + Doc( + """ + Pydantic's `include` parameter, passed to Pydantic models to set the + fields to include. + """ + ), + ] = None, + exclude: Annotated[ + IncEx | None, + Doc( + """ + Pydantic's `exclude` parameter, passed to Pydantic models to set the + fields to exclude. + """ + ), + ] = None, + by_alias: Annotated[ + bool, + Doc( + """ + Pydantic's `by_alias` parameter, passed to Pydantic models to define if + the output should use the alias names (when provided) or the Python + attribute names. In an API, if you set an alias, it's probably because you + want to use it in the result, so you probably want to leave this set to + `True`. + """ + ), + ] = True, + exclude_unset: Annotated[ + bool, + Doc( + """ + Pydantic's `exclude_unset` parameter, passed to Pydantic models to define + if it should exclude from the output the fields that were not explicitly + set (and that only had their default values). + """ + ), + ] = False, + exclude_defaults: Annotated[ + bool, + Doc( + """ + Pydantic's `exclude_defaults` parameter, passed to Pydantic models to define + if it should exclude from the output the fields that had the same default + value, even when they were explicitly set. + """ + ), + ] = False, + exclude_none: Annotated[ + bool, + Doc( + """ + Pydantic's `exclude_none` parameter, passed to Pydantic models to define + if it should exclude from the output any fields that have a `None` value. + """ + ), + ] = False, + custom_encoder: Annotated[ + dict[Any, Callable[[Any], Any]] | None, + Doc( + """ + Pydantic's `custom_encoder` parameter, passed to Pydantic models to define + a custom encoder. + """ + ), + ] = None, + sqlalchemy_safe: Annotated[ + bool, + Doc( + """ + Exclude from the output any fields that start with the name `_sa`. + + This is mainly a hack for compatibility with SQLAlchemy objects, they + store internal SQLAlchemy-specific state in attributes named with `_sa`, + and those objects can't (and shouldn't be) serialized to JSON. + """ + ), + ] = True, +) -> Any: + """ + Convert any object to something that can be encoded in JSON. + + This is used internally by FastAPI to make sure anything you return can be + encoded as JSON before it is sent to the client. + + You can also use it yourself, for example to convert objects before saving them + in a database that supports only JSON. + + Read more about it in the + [FastAPI docs for JSON Compatible Encoder](https://fastapi.tiangolo.com/tutorial/encoder/). + """ + custom_encoder = custom_encoder or {} + if custom_encoder: + if type(obj) in custom_encoder: + return custom_encoder[type(obj)](obj) + else: + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder_instance(obj) + if include is not None and not isinstance(include, (set, dict)): + include = set(include) # type: ignore[assignment] # ty: ignore[invalid-assignment] + if exclude is not None and not isinstance(exclude, (set, dict)): + exclude = set(exclude) # type: ignore[assignment] # ty: ignore[invalid-assignment] + if isinstance(obj, BaseModel): + obj_dict = obj.model_dump( + mode="json", + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + ) + return jsonable_encoder( + obj_dict, + exclude_none=exclude_none, + exclude_defaults=exclude_defaults, + sqlalchemy_safe=sqlalchemy_safe, + ) + if dataclasses.is_dataclass(obj): + assert not isinstance(obj, type) + obj_dict = dataclasses.asdict(obj) + return jsonable_encoder( + obj_dict, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + if isinstance(obj, Enum): + return obj.value + if isinstance(obj, PurePath): + return str(obj) + if isinstance(obj, (str, int, float, type(None))): + return obj + if isinstance(obj, PydanticUndefinedType): + return None + if isinstance(obj, dict): + encoded_dict = {} + allowed_keys = set(obj.keys()) + if include is not None: + allowed_keys &= set(include) + if exclude is not None: + allowed_keys -= set(exclude) + for key, value in obj.items(): + if ( + ( + not sqlalchemy_safe + or (not isinstance(key, str)) + or (not key.startswith("_sa")) + ) + and (value is not None or not exclude_none) + and key in allowed_keys + ): + encoded_key = jsonable_encoder( + key, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + encoded_value = jsonable_encoder( + value, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + encoded_dict[encoded_key] = encoded_value + return encoded_dict + if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): + encoded_list = [] + for item in obj: + encoded_list.append( + jsonable_encoder( + item, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) + ) + return encoded_list + + if type(obj) in ENCODERS_BY_TYPE: + return ENCODERS_BY_TYPE[type(obj)](obj) + for encoder, classes_tuple in encoders_by_class_tuples.items(): + if isinstance(obj, classes_tuple): + return encoder(obj) + if is_pydantic_v1_model_instance(obj): + raise PydanticV1NotSupportedError( + "pydantic.v1 models are no longer supported by FastAPI." + f" Please update the model {obj!r}." + ) + try: + data = dict(obj) + except Exception as e: + errors: list[Exception] = [] + errors.append(e) + try: + data = vars(obj) + except Exception as e: + errors.append(e) + raise ValueError(errors) from e + return jsonable_encoder( + data, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, + ) diff --git a/server/venv/Lib/site-packages/fastapi/exception_handlers.py b/server/venv/Lib/site-packages/fastapi/exception_handlers.py new file mode 100644 index 0000000..475dd7b --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/exception_handlers.py @@ -0,0 +1,34 @@ +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import RequestValidationError, WebSocketRequestValidationError +from fastapi.utils import is_body_allowed_for_status_code +from fastapi.websockets import WebSocket +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.status import WS_1008_POLICY_VIOLATION + + +async def http_exception_handler(request: Request, exc: HTTPException) -> Response: + headers = getattr(exc, "headers", None) + if not is_body_allowed_for_status_code(exc.status_code): + return Response(status_code=exc.status_code, headers=headers) + return JSONResponse( + {"detail": exc.detail}, status_code=exc.status_code, headers=headers + ) + + +async def request_validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + return JSONResponse( + status_code=422, + content={"detail": jsonable_encoder(exc.errors())}, + ) + + +async def websocket_request_validation_exception_handler( + websocket: WebSocket, exc: WebSocketRequestValidationError +) -> None: + await websocket.close( + code=WS_1008_POLICY_VIOLATION, reason=jsonable_encoder(exc.errors()) + ) diff --git a/server/venv/Lib/site-packages/fastapi/exceptions.py b/server/venv/Lib/site-packages/fastapi/exceptions.py new file mode 100644 index 0000000..d7065c5 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/exceptions.py @@ -0,0 +1,256 @@ +from collections.abc import Mapping, Sequence +from typing import Annotated, Any, TypedDict + +from annotated_doc import Doc +from pydantic import BaseModel, create_model +from starlette.exceptions import HTTPException as StarletteHTTPException +from starlette.exceptions import WebSocketException as StarletteWebSocketException + + +class EndpointContext(TypedDict, total=False): + function: str + path: str + file: str + line: int + + +class HTTPException(StarletteHTTPException): + """ + An HTTP exception you can raise in your own code to show errors to the client. + + This is for client errors, invalid authentication, invalid data, etc. Not for server + errors in your code. + + Read more about it in the + [FastAPI docs for Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/). + + ## Example + + ```python + from fastapi import FastAPI, HTTPException + + app = FastAPI() + + items = {"foo": "The Foo Wrestlers"} + + + @app.get("/items/{item_id}") + async def read_item(item_id: str): + if item_id not in items: + raise HTTPException(status_code=404, detail="Item not found") + return {"item": items[item_id]} + ``` + """ + + def __init__( + self, + status_code: Annotated[ + int, + Doc( + """ + HTTP status code to send to the client. + + Read more about it in the + [FastAPI docs for Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/#use-httpexception) + """ + ), + ], + detail: Annotated[ + Any, + Doc( + """ + Any data to be sent to the client in the `detail` key of the JSON + response. + + Read more about it in the + [FastAPI docs for Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/#use-httpexception) + """ + ), + ] = None, + headers: Annotated[ + Mapping[str, str] | None, + Doc( + """ + Any headers to send to the client in the response. + + Read more about it in the + [FastAPI docs for Handling Errors](https://fastapi.tiangolo.com/tutorial/handling-errors/#add-custom-headers) + + """ + ), + ] = None, + ) -> None: + super().__init__(status_code=status_code, detail=detail, headers=headers) + + +class WebSocketException(StarletteWebSocketException): + """ + A WebSocket exception you can raise in your own code to show errors to the client. + + This is for client errors, invalid authentication, invalid data, etc. Not for server + errors in your code. + + Read more about it in the + [FastAPI docs for WebSockets](https://fastapi.tiangolo.com/advanced/websockets/). + + ## Example + + ```python + from typing import Annotated + + from fastapi import ( + Cookie, + FastAPI, + WebSocket, + WebSocketException, + status, + ) + + app = FastAPI() + + @app.websocket("/items/{item_id}/ws") + async def websocket_endpoint( + *, + websocket: WebSocket, + session: Annotated[str | None, Cookie()] = None, + item_id: str, + ): + if session is None: + raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION) + await websocket.accept() + while True: + data = await websocket.receive_text() + await websocket.send_text(f"Session cookie is: {session}") + await websocket.send_text(f"Message text was: {data}, for item ID: {item_id}") + ``` + """ + + def __init__( + self, + code: Annotated[ + int, + Doc( + """ + A closing code from the + [valid codes defined in the specification](https://datatracker.ietf.org/doc/html/rfc6455#section-7.4.1). + """ + ), + ], + reason: Annotated[ + str | None, + Doc( + """ + The reason to close the WebSocket connection. + + It is UTF-8-encoded data. The interpretation of the reason is up to the + application, it is not specified by the WebSocket specification. + + It could contain text that could be human-readable or interpretable + by the client code, etc. + """ + ), + ] = None, + ) -> None: + super().__init__(code=code, reason=reason) + + +RequestErrorModel: type[BaseModel] = create_model("Request") +WebSocketErrorModel: type[BaseModel] = create_model("WebSocket") + + +class FastAPIError(RuntimeError): + """ + A generic, FastAPI-specific error. + """ + + +class DependencyScopeError(FastAPIError): + """ + A dependency declared that it depends on another dependency with an invalid + (narrower) scope. + """ + + +class ValidationException(Exception): + def __init__( + self, + errors: Sequence[Any], + *, + endpoint_ctx: EndpointContext | None = None, + ) -> None: + self._errors = errors + self.endpoint_ctx = endpoint_ctx + + ctx = endpoint_ctx or {} + self.endpoint_function = ctx.get("function") + self.endpoint_path = ctx.get("path") + self.endpoint_file = ctx.get("file") + self.endpoint_line = ctx.get("line") + + def errors(self) -> Sequence[Any]: + return self._errors + + def _format_endpoint_context(self) -> str: + if not (self.endpoint_file and self.endpoint_line and self.endpoint_function): + if self.endpoint_path: + return f"\n Endpoint: {self.endpoint_path}" + return "" + + context = f'\n File "{self.endpoint_file}", line {self.endpoint_line}, in {self.endpoint_function}' + if self.endpoint_path: + context += f"\n {self.endpoint_path}" + return context + + def __str__(self) -> str: + message = f"{len(self._errors)} validation error{'s' if len(self._errors) != 1 else ''}:\n" + for err in self._errors: + message += f" {err}\n" + message += self._format_endpoint_context() + return message.rstrip() + + +class RequestValidationError(ValidationException): + def __init__( + self, + errors: Sequence[Any], + *, + body: Any = None, + endpoint_ctx: EndpointContext | None = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) + self.body = body + + +class WebSocketRequestValidationError(ValidationException): + def __init__( + self, + errors: Sequence[Any], + *, + endpoint_ctx: EndpointContext | None = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) + + +class ResponseValidationError(ValidationException): + def __init__( + self, + errors: Sequence[Any], + *, + body: Any = None, + endpoint_ctx: EndpointContext | None = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) + self.body = body + + +class PydanticV1NotSupportedError(FastAPIError): + """ + A pydantic.v1 model is used, which is no longer supported. + """ + + +class FastAPIDeprecationWarning(UserWarning): + """ + A custom deprecation warning as DeprecationWarning is ignored + Ref: https://sethmlarson.dev/deprecations-via-warnings-dont-work-for-python-libraries + """ diff --git a/server/venv/Lib/site-packages/fastapi/logger.py b/server/venv/Lib/site-packages/fastapi/logger.py new file mode 100644 index 0000000..5b2c4ad --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/logger.py @@ -0,0 +1,3 @@ +import logging + +logger = logging.getLogger("fastapi") diff --git a/server/venv/Lib/site-packages/fastapi/middleware/__init__.py b/server/venv/Lib/site-packages/fastapi/middleware/__init__.py new file mode 100644 index 0000000..620296d --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/middleware/__init__.py @@ -0,0 +1 @@ +from starlette.middleware import Middleware as Middleware diff --git a/server/venv/Lib/site-packages/fastapi/middleware/asyncexitstack.py b/server/venv/Lib/site-packages/fastapi/middleware/asyncexitstack.py new file mode 100644 index 0000000..4ce3f5a --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/middleware/asyncexitstack.py @@ -0,0 +1,18 @@ +from contextlib import AsyncExitStack + +from starlette.types import ASGIApp, Receive, Scope, Send + + +# Used mainly to close files after the request is done, dependencies are closed +# in their own AsyncExitStack +class AsyncExitStackMiddleware: + def __init__( + self, app: ASGIApp, context_name: str = "fastapi_middleware_astack" + ) -> None: + self.app = app + self.context_name = context_name + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + async with AsyncExitStack() as stack: + scope[self.context_name] = stack + await self.app(scope, receive, send) diff --git a/server/venv/Lib/site-packages/fastapi/middleware/cors.py b/server/venv/Lib/site-packages/fastapi/middleware/cors.py new file mode 100644 index 0000000..8dfaad0 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/middleware/cors.py @@ -0,0 +1 @@ +from starlette.middleware.cors import CORSMiddleware as CORSMiddleware # noqa diff --git a/server/venv/Lib/site-packages/fastapi/middleware/gzip.py b/server/venv/Lib/site-packages/fastapi/middleware/gzip.py new file mode 100644 index 0000000..bbeb2cc --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/middleware/gzip.py @@ -0,0 +1 @@ +from starlette.middleware.gzip import GZipMiddleware as GZipMiddleware # noqa diff --git a/server/venv/Lib/site-packages/fastapi/middleware/httpsredirect.py b/server/venv/Lib/site-packages/fastapi/middleware/httpsredirect.py new file mode 100644 index 0000000..b7a3d8e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/middleware/httpsredirect.py @@ -0,0 +1,3 @@ +from starlette.middleware.httpsredirect import ( # noqa + HTTPSRedirectMiddleware as HTTPSRedirectMiddleware, +) diff --git a/server/venv/Lib/site-packages/fastapi/middleware/trustedhost.py b/server/venv/Lib/site-packages/fastapi/middleware/trustedhost.py new file mode 100644 index 0000000..08d7e03 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/middleware/trustedhost.py @@ -0,0 +1,3 @@ +from starlette.middleware.trustedhost import ( # noqa + TrustedHostMiddleware as TrustedHostMiddleware, +) diff --git a/server/venv/Lib/site-packages/fastapi/middleware/wsgi.py b/server/venv/Lib/site-packages/fastapi/middleware/wsgi.py new file mode 100644 index 0000000..69e4dca --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/middleware/wsgi.py @@ -0,0 +1,3 @@ +from starlette.middleware.wsgi import ( + WSGIMiddleware as WSGIMiddleware, +) # pragma: no cover # noqa diff --git a/server/venv/Lib/site-packages/fastapi/openapi/__init__.py b/server/venv/Lib/site-packages/fastapi/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/fastapi/openapi/constants.py b/server/venv/Lib/site-packages/fastapi/openapi/constants.py new file mode 100644 index 0000000..d724ee3 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/openapi/constants.py @@ -0,0 +1,3 @@ +METHODS_WITH_BODY = {"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"} +REF_PREFIX = "#/components/schemas/" +REF_TEMPLATE = "#/components/schemas/{model}" diff --git a/server/venv/Lib/site-packages/fastapi/openapi/docs.py b/server/venv/Lib/site-packages/fastapi/openapi/docs.py new file mode 100644 index 0000000..0d9242f --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/openapi/docs.py @@ -0,0 +1,389 @@ +import json +from typing import Annotated, Any + +from annotated_doc import Doc +from fastapi.encoders import jsonable_encoder +from starlette.responses import HTMLResponse + + +def _html_safe_json(value: Any) -> str: + """Serialize a value to JSON with HTML special characters escaped. + + This prevents injection when the JSON is embedded inside a + + + + + """ + return HTMLResponse(html) + + +def get_redoc_html( + *, + openapi_url: Annotated[ + str, + Doc( + """ + The OpenAPI URL that ReDoc should load and use. + + This is normally done automatically by FastAPI using the default URL + `/openapi.json`. + + Read more about it in the + [FastAPI docs for Conditional OpenAPI](https://fastapi.tiangolo.com/how-to/conditional-openapi/#conditional-openapi-from-settings-and-env-vars) + """ + ), + ], + title: Annotated[ + str, + Doc( + """ + The HTML `` content, normally shown in the browser tab. + + Read more about it in the + [FastAPI docs for Custom Docs UI Static Assets](https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/) + """ + ), + ], + redoc_js_url: Annotated[ + str, + Doc( + """ + The URL to use to load the ReDoc JavaScript. + + It is normally set to a CDN URL. + + Read more about it in the + [FastAPI docs for Custom Docs UI Static Assets](https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/) + """ + ), + ] = "https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js", + redoc_favicon_url: Annotated[ + str, + Doc( + """ + The URL of the favicon to use. It is normally shown in the browser tab. + """ + ), + ] = "https://fastapi.tiangolo.com/img/favicon.png", + with_google_fonts: Annotated[ + bool, + Doc( + """ + Load and use Google Fonts. + """ + ), + ] = True, +) -> HTMLResponse: + """ + Generate and return the HTML response that loads ReDoc for the alternative + API docs (normally served at `/redoc`). + + You would only call this function yourself if you needed to override some parts, + for example the URLs to use to load ReDoc's JavaScript and CSS. + + Read more about it in the + [FastAPI docs for Custom Docs UI Static Assets (Self-Hosting)](https://fastapi.tiangolo.com/how-to/custom-docs-ui-assets/). + """ + html = f""" + <!DOCTYPE html> + <html> + <head> + <title>{title} + + + + """ + if with_google_fonts: + html += """ + + """ + html += f""" + + + + + + + + + + + """ + return HTMLResponse(html) + + +def get_swagger_ui_oauth2_redirect_html() -> HTMLResponse: + """ + Generate the HTML response with the OAuth2 redirection for Swagger UI. + + You normally don't need to use or change this. + """ + # copied from https://github.com/swagger-api/swagger-ui/blob/v4.14.0/dist/oauth2-redirect.html + html = """ + + + + Swagger UI: OAuth2 Redirect + + + + + + """ + return HTMLResponse(content=html) diff --git a/server/venv/Lib/site-packages/fastapi/openapi/models.py b/server/venv/Lib/site-packages/fastapi/openapi/models.py new file mode 100644 index 0000000..ca26bf9 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/openapi/models.py @@ -0,0 +1,435 @@ +from collections.abc import Callable, Iterable, Mapping +from enum import Enum +from typing import Annotated, Any, Literal, Optional, Union + +from fastapi._compat import with_info_plain_validator_function +from fastapi.logger import logger +from pydantic import ( + AnyUrl, + BaseModel, + Field, + GetJsonSchemaHandler, +) +from typing_extensions import TypedDict +from typing_extensions import deprecated as typing_deprecated + +try: + import email_validator + + assert email_validator # make autoflake ignore the unused import + from pydantic import EmailStr +except ImportError: # pragma: no cover + + class EmailStr(str): # type: ignore[no-redef] + @classmethod + def __get_validators__(cls) -> Iterable[Callable[..., Any]]: + yield cls.validate + + @classmethod + def validate(cls, v: Any) -> str: + logger.warning( + "email-validator not installed, email fields will be treated as str.\n" + "To install, run: pip install email-validator" + ) + return str(v) + + @classmethod + def _validate(cls, __input_value: Any, _: Any) -> str: + logger.warning( + "email-validator not installed, email fields will be treated as str.\n" + "To install, run: pip install email-validator" + ) + return str(__input_value) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: Mapping[str, Any], handler: GetJsonSchemaHandler + ) -> dict[str, Any]: + return {"type": "string", "format": "email"} + + @classmethod + def __get_pydantic_core_schema__( + cls, source: type[Any], handler: Callable[[Any], Mapping[str, Any]] + ) -> Mapping[str, Any]: + return with_info_plain_validator_function(cls._validate) + + +class BaseModelWithConfig(BaseModel): + model_config = {"extra": "allow"} + + +class Contact(BaseModelWithConfig): + name: str | None = None + url: AnyUrl | None = None + email: EmailStr | None = None + + +class License(BaseModelWithConfig): + name: str + identifier: str | None = None + url: AnyUrl | None = None + + +class Info(BaseModelWithConfig): + title: str + summary: str | None = None + description: str | None = None + termsOfService: str | None = None + contact: Contact | None = None + license: License | None = None + version: str + + +class ServerVariable(BaseModelWithConfig): + enum: Annotated[list[str] | None, Field(min_length=1)] = None + default: str + description: str | None = None + + +class Server(BaseModelWithConfig): + url: AnyUrl | str + description: str | None = None + variables: dict[str, ServerVariable] | None = None + + +class Reference(BaseModel): + ref: str = Field(alias="$ref") + + +class Discriminator(BaseModel): + propertyName: str + mapping: dict[str, str] | None = None + + +class XML(BaseModelWithConfig): + name: str | None = None + namespace: str | None = None + prefix: str | None = None + attribute: bool | None = None + wrapped: bool | None = None + + +class ExternalDocumentation(BaseModelWithConfig): + description: str | None = None + url: AnyUrl + + +# Ref JSON Schema 2020-12: https://json-schema.org/draft/2020-12/json-schema-validation#name-type +SchemaType = Literal[ + "array", "boolean", "integer", "null", "number", "object", "string" +] + + +class Schema(BaseModelWithConfig): + # Ref: JSON Schema 2020-12: https://json-schema.org/draft/2020-12/json-schema-core.html#name-the-json-schema-core-vocabu + # Core Vocabulary + schema_: str | None = Field(default=None, alias="$schema") + vocabulary: str | None = Field(default=None, alias="$vocabulary") + id: str | None = Field(default=None, alias="$id") + anchor: str | None = Field(default=None, alias="$anchor") + dynamicAnchor: str | None = Field(default=None, alias="$dynamicAnchor") + ref: str | None = Field(default=None, alias="$ref") + dynamicRef: str | None = Field(default=None, alias="$dynamicRef") + defs: dict[str, "SchemaOrBool"] | None = Field(default=None, alias="$defs") + comment: str | None = Field(default=None, alias="$comment") + # Ref: JSON Schema 2020-12: https://json-schema.org/draft/2020-12/json-schema-core.html#name-a-vocabulary-for-applying-s + # A Vocabulary for Applying Subschemas + allOf: list["SchemaOrBool"] | None = None + anyOf: list["SchemaOrBool"] | None = None + oneOf: list["SchemaOrBool"] | None = None + not_: Optional["SchemaOrBool"] = Field(default=None, alias="not") + if_: Optional["SchemaOrBool"] = Field(default=None, alias="if") + then: Optional["SchemaOrBool"] = None + else_: Optional["SchemaOrBool"] = Field(default=None, alias="else") + dependentSchemas: dict[str, "SchemaOrBool"] | None = None + prefixItems: list["SchemaOrBool"] | None = None + items: Optional["SchemaOrBool"] = None + contains: Optional["SchemaOrBool"] = None + properties: dict[str, "SchemaOrBool"] | None = None + patternProperties: dict[str, "SchemaOrBool"] | None = None + additionalProperties: Optional["SchemaOrBool"] = None + propertyNames: Optional["SchemaOrBool"] = None + unevaluatedItems: Optional["SchemaOrBool"] = None + unevaluatedProperties: Optional["SchemaOrBool"] = None + # Ref: JSON Schema Validation 2020-12: https://json-schema.org/draft/2020-12/json-schema-validation.html#name-a-vocabulary-for-structural + # A Vocabulary for Structural Validation + type: SchemaType | list[SchemaType] | None = None + enum: list[Any] | None = None + const: Any | None = None + multipleOf: float | None = Field(default=None, gt=0) + maximum: float | None = None + exclusiveMaximum: float | None = None + minimum: float | None = None + exclusiveMinimum: float | None = None + maxLength: int | None = Field(default=None, ge=0) + minLength: int | None = Field(default=None, ge=0) + pattern: str | None = None + maxItems: int | None = Field(default=None, ge=0) + minItems: int | None = Field(default=None, ge=0) + uniqueItems: bool | None = None + maxContains: int | None = Field(default=None, ge=0) + minContains: int | None = Field(default=None, ge=0) + maxProperties: int | None = Field(default=None, ge=0) + minProperties: int | None = Field(default=None, ge=0) + required: list[str] | None = None + dependentRequired: dict[str, set[str]] | None = None + # Ref: JSON Schema Validation 2020-12: https://json-schema.org/draft/2020-12/json-schema-validation.html#name-vocabularies-for-semantic-c + # Vocabularies for Semantic Content With "format" + format: str | None = None + # Ref: JSON Schema Validation 2020-12: https://json-schema.org/draft/2020-12/json-schema-validation.html#name-a-vocabulary-for-the-conten + # A Vocabulary for the Contents of String-Encoded Data + contentEncoding: str | None = None + contentMediaType: str | None = None + contentSchema: Optional["SchemaOrBool"] = None + # Ref: JSON Schema Validation 2020-12: https://json-schema.org/draft/2020-12/json-schema-validation.html#name-a-vocabulary-for-basic-meta + # A Vocabulary for Basic Meta-Data Annotations + title: str | None = None + description: str | None = None + default: Any | None = None + deprecated: bool | None = None + readOnly: bool | None = None + writeOnly: bool | None = None + examples: list[Any] | None = None + # Ref: OpenAPI 3.1.0: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#schema-object + # Schema Object + discriminator: Discriminator | None = None + xml: XML | None = None + externalDocs: ExternalDocumentation | None = None + example: Annotated[ + Any | None, + typing_deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = None + + +# Ref: https://json-schema.org/draft/2020-12/json-schema-core.html#name-json-schema-documents +# A JSON Schema MUST be an object or a boolean. +SchemaOrBool = Schema | bool + + +class Example(TypedDict, total=False): + summary: str | None + description: str | None + value: Any | None + externalValue: AnyUrl | None + + __pydantic_config__ = {"extra": "allow"} # type: ignore[misc] # ty: ignore[invalid-typed-dict-statement] + + +class ParameterInType(Enum): + query = "query" + header = "header" + path = "path" + cookie = "cookie" + + +class Encoding(BaseModelWithConfig): + contentType: str | None = None + headers: dict[str, Union["Header", Reference]] | None = None + style: str | None = None + explode: bool | None = None + allowReserved: bool | None = None + + +class MediaType(BaseModelWithConfig): + schema_: Schema | Reference | None = Field(default=None, alias="schema") + example: Any | None = None + examples: dict[str, Example | Reference] | None = None + encoding: dict[str, Encoding] | None = None + + +class ParameterBase(BaseModelWithConfig): + description: str | None = None + required: bool | None = None + deprecated: bool | None = None + # Serialization rules for simple scenarios + style: str | None = None + explode: bool | None = None + allowReserved: bool | None = None + schema_: Schema | Reference | None = Field(default=None, alias="schema") + example: Any | None = None + examples: dict[str, Example | Reference] | None = None + # Serialization rules for more complex scenarios + content: dict[str, MediaType] | None = None + + +class Parameter(ParameterBase): + name: str + in_: ParameterInType = Field(alias="in") + + +class Header(ParameterBase): + pass + + +class RequestBody(BaseModelWithConfig): + description: str | None = None + content: dict[str, MediaType] + required: bool | None = None + + +class Link(BaseModelWithConfig): + operationRef: str | None = None + operationId: str | None = None + parameters: dict[str, Any | str] | None = None + requestBody: Any | str | None = None + description: str | None = None + server: Server | None = None + + +class Response(BaseModelWithConfig): + description: str + headers: dict[str, Header | Reference] | None = None + content: dict[str, MediaType] | None = None + links: dict[str, Link | Reference] | None = None + + +class Operation(BaseModelWithConfig): + tags: list[str] | None = None + summary: str | None = None + description: str | None = None + externalDocs: ExternalDocumentation | None = None + operationId: str | None = None + parameters: list[Parameter | Reference] | None = None + requestBody: RequestBody | Reference | None = None + # Using Any for Specification Extensions + responses: dict[str, Response | Any] | None = None + callbacks: dict[str, dict[str, "PathItem"] | Reference] | None = None + deprecated: bool | None = None + security: list[dict[str, list[str]]] | None = None + servers: list[Server] | None = None + + +class PathItem(BaseModelWithConfig): + ref: str | None = Field(default=None, alias="$ref") + summary: str | None = None + description: str | None = None + get: Operation | None = None + put: Operation | None = None + post: Operation | None = None + delete: Operation | None = None + options: Operation | None = None + head: Operation | None = None + patch: Operation | None = None + trace: Operation | None = None + servers: list[Server] | None = None + parameters: list[Parameter | Reference] | None = None + + +class SecuritySchemeType(Enum): + apiKey = "apiKey" + http = "http" + oauth2 = "oauth2" + openIdConnect = "openIdConnect" + + +class SecurityBase(BaseModelWithConfig): + type_: SecuritySchemeType = Field(alias="type") + description: str | None = None + + +class APIKeyIn(Enum): + query = "query" + header = "header" + cookie = "cookie" + + +class APIKey(SecurityBase): + type_: SecuritySchemeType = Field(default=SecuritySchemeType.apiKey, alias="type") + in_: APIKeyIn = Field(alias="in") + name: str + + +class HTTPBase(SecurityBase): + type_: SecuritySchemeType = Field(default=SecuritySchemeType.http, alias="type") + scheme: str + + +class HTTPBearer(HTTPBase): + scheme: Literal["bearer"] = "bearer" + bearerFormat: str | None = None + + +class OAuthFlow(BaseModelWithConfig): + refreshUrl: str | None = None + scopes: dict[str, str] = {} + + +class OAuthFlowImplicit(OAuthFlow): + authorizationUrl: str + + +class OAuthFlowPassword(OAuthFlow): + tokenUrl: str + + +class OAuthFlowClientCredentials(OAuthFlow): + tokenUrl: str + + +class OAuthFlowAuthorizationCode(OAuthFlow): + authorizationUrl: str + tokenUrl: str + + +class OAuthFlows(BaseModelWithConfig): + implicit: OAuthFlowImplicit | None = None + password: OAuthFlowPassword | None = None + clientCredentials: OAuthFlowClientCredentials | None = None + authorizationCode: OAuthFlowAuthorizationCode | None = None + + +class OAuth2(SecurityBase): + type_: SecuritySchemeType = Field(default=SecuritySchemeType.oauth2, alias="type") + flows: OAuthFlows + + +class OpenIdConnect(SecurityBase): + type_: SecuritySchemeType = Field( + default=SecuritySchemeType.openIdConnect, alias="type" + ) + openIdConnectUrl: str + + +SecurityScheme = APIKey | HTTPBase | OAuth2 | OpenIdConnect | HTTPBearer + + +class Components(BaseModelWithConfig): + schemas: dict[str, Schema | Reference] | None = None + responses: dict[str, Response | Reference] | None = None + parameters: dict[str, Parameter | Reference] | None = None + examples: dict[str, Example | Reference] | None = None + requestBodies: dict[str, RequestBody | Reference] | None = None + headers: dict[str, Header | Reference] | None = None + securitySchemes: dict[str, SecurityScheme | Reference] | None = None + links: dict[str, Link | Reference] | None = None + # Using Any for Specification Extensions + callbacks: dict[str, dict[str, PathItem] | Reference | Any] | None = None + pathItems: dict[str, PathItem | Reference] | None = None + + +class Tag(BaseModelWithConfig): + name: str + description: str | None = None + externalDocs: ExternalDocumentation | None = None + + +class OpenAPI(BaseModelWithConfig): + openapi: str + info: Info + jsonSchemaDialect: str | None = None + servers: list[Server] | None = None + # Using Any for Specification Extensions + paths: dict[str, PathItem | Any] | None = None + webhooks: dict[str, PathItem | Reference] | None = None + components: Components | None = None + security: list[dict[str, list[str]]] | None = None + tags: list[Tag] | None = None + externalDocs: ExternalDocumentation | None = None + + +Schema.model_rebuild() +Operation.model_rebuild() +Encoding.model_rebuild() diff --git a/server/venv/Lib/site-packages/fastapi/openapi/utils.py b/server/venv/Lib/site-packages/fastapi/openapi/utils.py new file mode 100644 index 0000000..2e0aca1 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/openapi/utils.py @@ -0,0 +1,617 @@ +import copy +import http.client +import inspect +import warnings +from collections.abc import Sequence +from typing import Any, Literal, cast + +from fastapi import routing +from fastapi._compat import ( + ModelField, + get_definitions, + get_flat_models_from_fields, + get_model_name_map, + get_schema_from_model_field, + lenient_issubclass, +) +from fastapi.datastructures import DefaultPlaceholder, _Unset +from fastapi.dependencies.models import Dependant +from fastapi.dependencies.utils import ( + _get_flat_fields_from_params, + get_flat_dependant, + get_flat_params, + get_validation_alias, +) +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import FastAPIDeprecationWarning +from fastapi.openapi.constants import METHODS_WITH_BODY, REF_PREFIX +from fastapi.openapi.models import OpenAPI +from fastapi.params import Body, ParamTypes +from fastapi.responses import Response +from fastapi.sse import _SSE_EVENT_SCHEMA +from fastapi.types import ModelNameMap +from fastapi.utils import ( + deep_dict_update, + generate_operation_id_for_path, + is_body_allowed_for_status_code, +) +from pydantic import BaseModel +from starlette.responses import JSONResponse +from starlette.routing import BaseRoute + +validation_error_definition = { + "title": "ValidationError", + "type": "object", + "properties": { + "loc": { + "title": "Location", + "type": "array", + "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]}, + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + "input": {"title": "Input"}, + "ctx": {"title": "Context", "type": "object"}, + }, + "required": ["loc", "msg", "type"], +} + +validation_error_response_definition = { + "title": "HTTPValidationError", + "type": "object", + "properties": { + "detail": { + "title": "Detail", + "type": "array", + "items": {"$ref": REF_PREFIX + "ValidationError"}, + } + }, +} + +status_code_ranges: dict[str, str] = { + "1XX": "Information", + "2XX": "Success", + "3XX": "Redirection", + "4XX": "Client Error", + "5XX": "Server Error", + "DEFAULT": "Default Response", +} + + +def get_openapi_security_definitions( + flat_dependant: Dependant, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + security_definitions = {} + # Use a dict to merge scopes for same security scheme + operation_security_dict: dict[str, list[str]] = {} + for security_dependency in flat_dependant._security_dependencies: + security_definition = jsonable_encoder( + security_dependency._security_scheme.model, + by_alias=True, + exclude_none=True, + ) + security_name = security_dependency._security_scheme.scheme_name + security_definitions[security_name] = security_definition + # Merge scopes for the same security scheme + if security_name not in operation_security_dict: + operation_security_dict[security_name] = [] + for scope in security_dependency.oauth_scopes or []: + if scope not in operation_security_dict[security_name]: + operation_security_dict[security_name].append(scope) + operation_security = [ + {name: scopes} for name, scopes in operation_security_dict.items() + ] + return security_definitions, operation_security + + +def _get_openapi_operation_parameters( + *, + dependant: Dependant, + model_name_map: ModelNameMap, + field_mapping: dict[ + tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any] + ], + separate_input_output_schemas: bool = True, +) -> list[dict[str, Any]]: + parameters = [] + flat_dependant = get_flat_dependant(dependant, skip_repeats=True) + path_params = _get_flat_fields_from_params(flat_dependant.path_params) + query_params = _get_flat_fields_from_params(flat_dependant.query_params) + header_params = _get_flat_fields_from_params(flat_dependant.header_params) + cookie_params = _get_flat_fields_from_params(flat_dependant.cookie_params) + parameter_groups = [ + (ParamTypes.path, path_params), + (ParamTypes.query, query_params), + (ParamTypes.header, header_params), + (ParamTypes.cookie, cookie_params), + ] + default_convert_underscores = True + if len(flat_dependant.header_params) == 1: + first_field = flat_dependant.header_params[0] + if lenient_issubclass(first_field.field_info.annotation, BaseModel): + default_convert_underscores = getattr( + first_field.field_info, "convert_underscores", True + ) + for param_type, param_group in parameter_groups: + for param in param_group: + field_info = param.field_info + # field_info = cast(Param, field_info) + if not getattr(field_info, "include_in_schema", True): + continue + param_schema = get_schema_from_model_field( + field=param, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + name = get_validation_alias(param) + convert_underscores = getattr( + param.field_info, + "convert_underscores", + default_convert_underscores, + ) + if ( + param_type == ParamTypes.header + and name == param.name + and convert_underscores + ): + name = param.name.replace("_", "-") + + parameter = { + "name": name, + "in": param_type.value, + "required": param.field_info.is_required(), + "schema": param_schema, + } + if field_info.description: + parameter["description"] = field_info.description + openapi_examples = getattr(field_info, "openapi_examples", None) + example = getattr(field_info, "example", None) + if openapi_examples: + parameter["examples"] = jsonable_encoder(openapi_examples) + elif example is not _Unset: + parameter["example"] = jsonable_encoder(example) + if getattr(field_info, "deprecated", None): + parameter["deprecated"] = True + parameters.append(parameter) + return parameters + + +def get_openapi_operation_request_body( + *, + body_field: ModelField | None, + model_name_map: ModelNameMap, + field_mapping: dict[ + tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any] + ], + separate_input_output_schemas: bool = True, +) -> dict[str, Any] | None: + if not body_field: + return None + assert isinstance(body_field, ModelField) + body_schema = get_schema_from_model_field( + field=body_field, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + field_info = cast(Body, body_field.field_info) + request_media_type = field_info.media_type + required = body_field.field_info.is_required() + request_body_oai: dict[str, Any] = {} + if required: + request_body_oai["required"] = required + request_media_content: dict[str, Any] = {"schema": body_schema} + if field_info.openapi_examples: + request_media_content["examples"] = jsonable_encoder( + field_info.openapi_examples + ) + elif field_info.example is not _Unset: + request_media_content["example"] = jsonable_encoder(field_info.example) + request_body_oai["content"] = {request_media_type: request_media_content} + return request_body_oai + + +def generate_operation_id( + *, route: routing._APIRouteLike, method: str +) -> str: # pragma: nocover + warnings.warn( + message="fastapi.openapi.utils.generate_operation_id() was deprecated, " + "it is not used internally, and will be removed soon", + category=FastAPIDeprecationWarning, + stacklevel=2, + ) + if route.operation_id: + return route.operation_id + path: str = route.path_format + return generate_operation_id_for_path(name=route.name, path=path, method=method) + + +def generate_operation_summary(*, route: routing._APIRouteLike, method: str) -> str: + if route.summary: + return route.summary + return route.name.replace("_", " ").title() + + +def get_openapi_operation_metadata( + *, route: routing._APIRouteLike, method: str, operation_ids: set[str] +) -> dict[str, Any]: + operation: dict[str, Any] = {} + if route.tags: + operation["tags"] = route.tags + operation["summary"] = generate_operation_summary(route=route, method=method) + if route.description: + operation["description"] = route.description + operation_id = route.operation_id or route.unique_id + if operation_id in operation_ids: + endpoint_name = getattr(route.endpoint, "__name__", "") + message = f"Duplicate Operation ID {operation_id} for function {endpoint_name}" + file_name = getattr(route.endpoint, "__globals__", {}).get("__file__") + if file_name: + message += f" at {file_name}" + warnings.warn(message, stacklevel=1) + operation_ids.add(operation_id) + operation["operationId"] = operation_id + if route.deprecated: + operation["deprecated"] = route.deprecated + return operation + + +def get_openapi_path( + *, + route: routing._APIRouteLike, + operation_ids: set[str], + model_name_map: ModelNameMap, + field_mapping: dict[ + tuple[ModelField, Literal["validation", "serialization"]], dict[str, Any] + ], + separate_input_output_schemas: bool = True, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + path = {} + security_schemes: dict[str, Any] = {} + definitions: dict[str, Any] = {} + assert route.methods is not None, "Methods must be a list" + if isinstance(route.response_class, DefaultPlaceholder): + current_response_class: type[Response] = route.response_class.value + else: + current_response_class = route.response_class + assert current_response_class, "A response class is needed to generate OpenAPI" + route_response_media_type: str | None = current_response_class.media_type + if route.include_in_schema: + for method in route.methods: + operation = get_openapi_operation_metadata( + route=route, method=method, operation_ids=operation_ids + ) + parameters: list[dict[str, Any]] = [] + flat_dependant = get_flat_dependant(route.dependant, skip_repeats=True) + security_definitions, operation_security = get_openapi_security_definitions( + flat_dependant=flat_dependant + ) + if operation_security: + operation.setdefault("security", []).extend(operation_security) + if security_definitions: + security_schemes.update(security_definitions) + operation_parameters = _get_openapi_operation_parameters( + dependant=route.dependant, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + parameters.extend(operation_parameters) + if parameters: + all_parameters = { + (param["in"], param["name"]): param for param in parameters + } + required_parameters = { + (param["in"], param["name"]): param + for param in parameters + if param.get("required") + } + # Make sure required definitions of the same parameter take precedence + # over non-required definitions + all_parameters.update(required_parameters) + operation["parameters"] = list(all_parameters.values()) + if method in METHODS_WITH_BODY: + request_body_oai = get_openapi_operation_request_body( + body_field=route.body_field, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + if request_body_oai: + operation["requestBody"] = request_body_oai + if route.callbacks: + callbacks = {} + for callback in route.callbacks: + if isinstance(callback, routing.APIRoute): + ( + cb_path, + cb_security_schemes, + cb_definitions, + ) = get_openapi_path( + route=cast(routing._APIRouteLike, callback), + operation_ids=operation_ids, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + callbacks[callback.name] = {callback.path: cb_path} + operation["callbacks"] = callbacks + if route.status_code is not None: + status_code = str(route.status_code) + else: + # It would probably make more sense for all response classes to have an + # explicit default status_code, and to extract it from them, instead of + # doing this inspection tricks, that would probably be in the future + # TODO: probably make status_code a default class attribute for all + # responses in Starlette + response_signature = inspect.signature(current_response_class.__init__) + status_code_param = response_signature.parameters.get("status_code") + if status_code_param is not None: + if isinstance(status_code_param.default, int): + status_code = str(status_code_param.default) + operation.setdefault("responses", {}).setdefault(status_code, {})[ + "description" + ] = route.response_description + if is_body_allowed_for_status_code(route.status_code): + # Check for JSONL streaming (generator endpoints) + if route.is_json_stream: + jsonl_content: dict[str, Any] = {} + if route.stream_item_field: + item_schema = get_schema_from_model_field( + field=route.stream_item_field, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + jsonl_content["itemSchema"] = item_schema + else: + jsonl_content["itemSchema"] = {} + operation.setdefault("responses", {}).setdefault( + status_code, {} + ).setdefault("content", {})["application/jsonl"] = jsonl_content + elif route.is_sse_stream: + sse_content: dict[str, Any] = {} + item_schema = copy.deepcopy(_SSE_EVENT_SCHEMA) + if route.stream_item_field: + content_schema = get_schema_from_model_field( + field=route.stream_item_field, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + item_schema["required"] = ["data"] + item_schema["properties"]["data"] = { + "type": "string", + "contentMediaType": "application/json", + "contentSchema": content_schema, + } + sse_content["itemSchema"] = item_schema + operation.setdefault("responses", {}).setdefault( + status_code, {} + ).setdefault("content", {})["text/event-stream"] = sse_content + elif route_response_media_type: + response_schema = {"type": "string"} + if lenient_issubclass(current_response_class, JSONResponse): + if route.response_field: + response_schema = get_schema_from_model_field( + field=route.response_field, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + else: + response_schema = {} + operation.setdefault("responses", {}).setdefault( + status_code, {} + ).setdefault("content", {}).setdefault( + route_response_media_type, {} + )["schema"] = response_schema + if route.responses: + operation_responses = operation.setdefault("responses", {}) + for ( + additional_status_code, + additional_response, + ) in route.responses.items(): + process_response = copy.deepcopy(additional_response) + process_response.pop("model", None) + status_code_key = str(additional_status_code).upper() + if status_code_key == "DEFAULT": + status_code_key = "default" + openapi_response = operation_responses.setdefault( + status_code_key, {} + ) + assert isinstance(process_response, dict), ( + "An additional response must be a dict" + ) + field = route.response_fields.get(additional_status_code) + additional_field_schema: dict[str, Any] | None = None + if field: + additional_field_schema = get_schema_from_model_field( + field=field, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + media_type = route_response_media_type or "application/json" + additional_schema = ( + process_response.setdefault("content", {}) + .setdefault(media_type, {}) + .setdefault("schema", {}) + ) + deep_dict_update(additional_schema, additional_field_schema) + status_text: str | None = status_code_ranges.get( + str(additional_status_code).upper() + ) or http.client.responses.get(int(additional_status_code)) + description = ( + process_response.get("description") + or openapi_response.get("description") + or status_text + or "Additional Response" + ) + deep_dict_update(openapi_response, process_response) + openapi_response["description"] = description + http422 = "422" + all_route_params = get_flat_params(route.dependant) + if (all_route_params or route.body_field) and not any( + status in operation["responses"] + for status in [http422, "4XX", "default"] + ): + operation["responses"][http422] = { + "description": "Validation Error", + "content": { + "application/json": { + "schema": {"$ref": REF_PREFIX + "HTTPValidationError"} + } + }, + } + if "ValidationError" not in definitions: + definitions.update( + { + "ValidationError": validation_error_definition, + "HTTPValidationError": validation_error_response_definition, + } + ) + if route.openapi_extra: + deep_dict_update(operation, route.openapi_extra) + path[method.lower()] = operation + return path, security_schemes, definitions + + +def _get_api_route_for_openapi( + route_context: routing.RouteContext, +) -> routing._APIRouteLike | None: + if isinstance(route_context.original_route, routing.APIRoute): + return cast(routing._APIRouteLike, route_context) + return None + + +def get_fields_from_routes( + routes: Sequence[BaseRoute | routing.RouteContext], +) -> list[ModelField]: + body_fields_from_routes: list[ModelField] = [] + responses_from_routes: list[ModelField] = [] + request_fields_from_routes: list[ModelField] = [] + callback_flat_models: list[ModelField] = [] + for route_context in routing.iter_route_contexts(routes): + api_route = _get_api_route_for_openapi(route_context) + if api_route is None: + continue + if api_route.include_in_schema: + if api_route.body_field: + assert isinstance(api_route.body_field, ModelField), ( + "A request body must be a Pydantic Field" + ) + body_fields_from_routes.append(api_route.body_field) + if api_route.response_field: + responses_from_routes.append(api_route.response_field) + if api_route.response_fields: + responses_from_routes.extend(api_route.response_fields.values()) + if api_route.stream_item_field: + responses_from_routes.append(api_route.stream_item_field) + if api_route.callbacks: + callback_flat_models.extend(get_fields_from_routes(api_route.callbacks)) + params = get_flat_params(api_route.dependant) + request_fields_from_routes.extend(params) + + flat_models = callback_flat_models + list( + body_fields_from_routes + responses_from_routes + request_fields_from_routes + ) + return flat_models + + +def get_openapi( + *, + title: str, + version: str, + openapi_version: str = "3.1.0", + summary: str | None = None, + description: str | None = None, + routes: Sequence[BaseRoute | routing.RouteContext], + webhooks: Sequence[BaseRoute | routing.RouteContext] | None = None, + tags: list[dict[str, Any]] | None = None, + servers: list[dict[str, str | Any]] | None = None, + terms_of_service: str | None = None, + contact: dict[str, str | Any] | None = None, + license_info: dict[str, str | Any] | None = None, + separate_input_output_schemas: bool = True, + external_docs: dict[str, Any] | None = None, +) -> dict[str, Any]: + info: dict[str, Any] = {"title": title, "version": version} + if summary: + info["summary"] = summary + if description: + info["description"] = description + if terms_of_service: + info["termsOfService"] = terms_of_service + if contact: + info["contact"] = contact + if license_info: + info["license"] = license_info + output: dict[str, Any] = {"openapi": openapi_version, "info": info} + if servers: + output["servers"] = servers + components: dict[str, dict[str, Any]] = {} + paths: dict[str, dict[str, Any]] = {} + webhook_paths: dict[str, dict[str, Any]] = {} + operation_ids: set[str] = set() + all_fields = get_fields_from_routes(list(routes) + list(webhooks or [])) + flat_models = get_flat_models_from_fields(all_fields, known_models=set()) + model_name_map = get_model_name_map(flat_models) + field_mapping, definitions = get_definitions( + fields=all_fields, + model_name_map=model_name_map, + separate_input_output_schemas=separate_input_output_schemas, + ) + for route_context in routing.iter_route_contexts(routes): + api_route = _get_api_route_for_openapi(route_context) + if api_route is not None: + result = get_openapi_path( + route=api_route, + operation_ids=operation_ids, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + if result: + path, security_schemes, path_definitions = result + if path: + paths.setdefault(api_route.path_format, {}).update(path) + if security_schemes: + components.setdefault("securitySchemes", {}).update( + security_schemes + ) + if path_definitions: + definitions.update(path_definitions) + for webhook_context in routing.iter_route_contexts(webhooks or []): + api_webhook = _get_api_route_for_openapi(webhook_context) + if api_webhook is not None: + result = get_openapi_path( + route=api_webhook, + operation_ids=operation_ids, + model_name_map=model_name_map, + field_mapping=field_mapping, + separate_input_output_schemas=separate_input_output_schemas, + ) + if result: + path, security_schemes, path_definitions = result + if path: + webhook_paths.setdefault(api_webhook.path_format, {}).update(path) + if security_schemes: + components.setdefault("securitySchemes", {}).update( + security_schemes + ) + if path_definitions: + definitions.update(path_definitions) + if definitions: + components["schemas"] = {k: definitions[k] for k in sorted(definitions)} + if components: + output["components"] = components + output["paths"] = paths + if webhook_paths: + output["webhooks"] = webhook_paths + if tags: + output["tags"] = tags + if external_docs: + output["externalDocs"] = external_docs + return jsonable_encoder(OpenAPI(**output), by_alias=True, exclude_none=True) # type: ignore[no-any-return] diff --git a/server/venv/Lib/site-packages/fastapi/param_functions.py b/server/venv/Lib/site-packages/fastapi/param_functions.py new file mode 100644 index 0000000..1856178 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/param_functions.py @@ -0,0 +1,2460 @@ +from collections.abc import Callable, Sequence +from typing import Annotated, Any, Literal + +from annotated_doc import Doc +from fastapi import params +from fastapi._compat import Undefined +from fastapi.datastructures import _Unset +from fastapi.openapi.models import Example +from pydantic import AliasChoices, AliasPath +from typing_extensions import deprecated + + +def Path( # noqa: N802 + default: Annotated[ + Any, + Doc( + """ + Default value if the parameter field is not set. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = ..., + *, + default_factory: Annotated[ + Callable[[], Any] | None, + Doc( + """ + A callable to generate the default value. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = _Unset, + alias: Annotated[ + str | None, + Doc( + """ + An alternative name for the parameter field. + + This will be used to extract the data and for the generated OpenAPI. + It is particularly useful when you can't use the name you want because it + is a Python reserved keyword or similar. + """ + ), + ] = None, + alias_priority: Annotated[ + int | None, + Doc( + """ + Priority of the alias. This affects whether an alias generator is used. + """ + ), + ] = _Unset, + validation_alias: Annotated[ + str | AliasPath | AliasChoices | None, + Doc( + """ + 'Whitelist' validation step. The parameter field will be the single one + allowed by the alias or set of aliases defined. + """ + ), + ] = None, + serialization_alias: Annotated[ + str | None, + Doc( + """ + 'Blacklist' validation step. The vanilla parameter field will be the + single one of the alias' or set of aliases' fields and all the other + fields will be ignored at serialization time. + """ + ), + ] = None, + title: Annotated[ + str | None, + Doc( + """ + Human-readable title. + + Read more about it in the + [FastAPI docs for Path Parameters and Numeric Validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#declare-metadata) + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Human-readable description. + """ + ), + ] = None, + gt: Annotated[ + float | None, + Doc( + """ + Greater than. If set, value must be greater than this. Only applicable to + numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + ge: Annotated[ + float | None, + Doc( + """ + Greater than or equal. If set, value must be greater than or equal to + this. Only applicable to numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + lt: Annotated[ + float | None, + Doc( + """ + Less than. If set, value must be less than this. Only applicable to numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + le: Annotated[ + float | None, + Doc( + """ + Less than or equal. If set, value must be less than or equal to this. + Only applicable to numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + min_length: Annotated[ + int | None, + Doc( + """ + Minimum length for strings. + """ + ), + ] = None, + max_length: Annotated[ + int | None, + Doc( + """ + Maximum length for strings. + """ + ), + ] = None, + pattern: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + ] = None, + regex: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: Annotated[ + str | None, + Doc( + """ + Parameter field name for discriminating the type in a tagged union. + """ + ), + ] = None, + strict: Annotated[ + bool | None, + Doc( + """ + If `True`, strict validation is applied to the field. + """ + ), + ] = _Unset, + multiple_of: Annotated[ + float | None, + Doc( + """ + Value must be a multiple of this. Only applicable to numbers. + """ + ), + ] = _Unset, + allow_inf_nan: Annotated[ + bool | None, + Doc( + """ + Allow `inf`, `-inf`, `nan`. Only applicable to numbers. + """ + ), + ] = _Unset, + max_digits: Annotated[ + int | None, + Doc( + """ + Maximum number of digits allowed for decimal values. + """ + ), + ] = _Unset, + decimal_places: Annotated[ + int | None, + Doc( + """ + Maximum number of decimal places allowed for decimal values. + """ + ), + ] = _Unset, + examples: Annotated[ + list[Any] | None, + Doc( + """ + Example values for this field. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/) + """ + ), + ] = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: Annotated[ + dict[str, Example] | None, + Doc( + """ + OpenAPI-specific examples. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Swagger UI (that provides the `/docs` interface) has better support for the + OpenAPI-specific examples than the JSON Schema `examples`, that's the main + use case for this. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/#using-the-openapi_examples-parameter). + """ + ), + ] = None, + deprecated: Annotated[ + deprecated | str | bool | None, + Doc( + """ + Mark this parameter field as deprecated. + + It will affect the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) this parameter field in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = True, + json_schema_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Any additional JSON schema data. + """ + ), + ] = None, + **extra: Annotated[ + Any, + Doc( + """ + Include extra fields used by the JSON Schema. + """ + ), + deprecated( + """ + The `extra` kwargs is deprecated. Use `json_schema_extra` instead. + """ + ), + ], +) -> Any: + """ + Declare a path parameter for a *path operation*. + + Read more about it in the + [FastAPI docs for Path Parameters and Numeric Validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/). + + ```python + from typing import Annotated + + from fastapi import FastAPI, Path + + app = FastAPI() + + + @app.get("/items/{item_id}") + async def read_items( + item_id: Annotated[int, Path(title="The ID of the item to get")], + ): + return {"item_id": item_id} + ``` + """ + return params.Path( + default=default, + default_factory=default_factory, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + example=example, + examples=examples, + openapi_examples=openapi_examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +def Query( # noqa: N802 + default: Annotated[ + Any, + Doc( + """ + Default value if the parameter field is not set. + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#alternative-old-query-as-the-default-value) + """ + ), + ] = Undefined, + *, + default_factory: Annotated[ + Callable[[], Any] | None, + Doc( + """ + A callable to generate the default value. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = _Unset, + alias: Annotated[ + str | None, + Doc( + """ + An alternative name for the parameter field. + + This will be used to extract the data and for the generated OpenAPI. + It is particularly useful when you can't use the name you want because it + is a Python reserved keyword or similar. + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#alias-parameters) + """ + ), + ] = None, + alias_priority: Annotated[ + int | None, + Doc( + """ + Priority of the alias. This affects whether an alias generator is used. + """ + ), + ] = _Unset, + validation_alias: Annotated[ + str | AliasPath | AliasChoices | None, + Doc( + """ + 'Whitelist' validation step. The parameter field will be the single one + allowed by the alias or set of aliases defined. + """ + ), + ] = None, + serialization_alias: Annotated[ + str | None, + Doc( + """ + 'Blacklist' validation step. The vanilla parameter field will be the + single one of the alias' or set of aliases' fields and all the other + fields will be ignored at serialization time. + """ + ), + ] = None, + title: Annotated[ + str | None, + Doc( + """ + Human-readable title. + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#declare-more-metadata) + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Human-readable description. + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#declare-more-metadata) + """ + ), + ] = None, + gt: Annotated[ + float | None, + Doc( + """ + Greater than. If set, value must be greater than this. Only applicable to + numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + ge: Annotated[ + float | None, + Doc( + """ + Greater than or equal. If set, value must be greater than or equal to + this. Only applicable to numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + lt: Annotated[ + float | None, + Doc( + """ + Less than. If set, value must be less than this. Only applicable to numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + le: Annotated[ + float | None, + Doc( + """ + Less than or equal. If set, value must be less than or equal to this. + Only applicable to numbers. + + Read more about it in the + [FastAPI docs about Path parameters numeric validations](https://fastapi.tiangolo.com/tutorial/path-params-numeric-validations/#number-validations-greater-than-and-less-than-or-equal) + """ + ), + ] = None, + min_length: Annotated[ + int | None, + Doc( + """ + Minimum length for strings. + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/) + """ + ), + ] = None, + max_length: Annotated[ + int | None, + Doc( + """ + Maximum length for strings. + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/) + """ + ), + ] = None, + pattern: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#add-regular-expressions + """ + ), + ] = None, + regex: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: Annotated[ + str | None, + Doc( + """ + Parameter field name for discriminating the type in a tagged union. + """ + ), + ] = None, + strict: Annotated[ + bool | None, + Doc( + """ + If `True`, strict validation is applied to the field. + """ + ), + ] = _Unset, + multiple_of: Annotated[ + float | None, + Doc( + """ + Value must be a multiple of this. Only applicable to numbers. + """ + ), + ] = _Unset, + allow_inf_nan: Annotated[ + bool | None, + Doc( + """ + Allow `inf`, `-inf`, `nan`. Only applicable to numbers. + """ + ), + ] = _Unset, + max_digits: Annotated[ + int | None, + Doc( + """ + Maximum number of digits allowed for decimal values. + """ + ), + ] = _Unset, + decimal_places: Annotated[ + int | None, + Doc( + """ + Maximum number of decimal places allowed for decimal values. + """ + ), + ] = _Unset, + examples: Annotated[ + list[Any] | None, + Doc( + """ + Example values for this field. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/) + """ + ), + ] = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: Annotated[ + dict[str, Example] | None, + Doc( + """ + OpenAPI-specific examples. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Swagger UI (that provides the `/docs` interface) has better support for the + OpenAPI-specific examples than the JSON Schema `examples`, that's the main + use case for this. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/#using-the-openapi_examples-parameter). + """ + ), + ] = None, + deprecated: Annotated[ + deprecated | str | bool | None, + Doc( + """ + Mark this parameter field as deprecated. + + It will affect the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#deprecating-parameters) + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) this parameter field in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs about Query parameters](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi + """ + ), + ] = True, + json_schema_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Any additional JSON schema data. + """ + ), + ] = None, + **extra: Annotated[ + Any, + Doc( + """ + Include extra fields used by the JSON Schema. + """ + ), + deprecated( + """ + The `extra` kwargs is deprecated. Use `json_schema_extra` instead. + """ + ), + ], +) -> Any: + return params.Query( + default=default, + default_factory=default_factory, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + example=example, + examples=examples, + openapi_examples=openapi_examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +def Header( # noqa: N802 + default: Annotated[ + Any, + Doc( + """ + Default value if the parameter field is not set. + """ + ), + ] = Undefined, + *, + default_factory: Annotated[ + Callable[[], Any] | None, + Doc( + """ + A callable to generate the default value. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = _Unset, + alias: Annotated[ + str | None, + Doc( + """ + An alternative name for the parameter field. + + This will be used to extract the data and for the generated OpenAPI. + It is particularly useful when you can't use the name you want because it + is a Python reserved keyword or similar. + """ + ), + ] = None, + alias_priority: Annotated[ + int | None, + Doc( + """ + Priority of the alias. This affects whether an alias generator is used. + """ + ), + ] = _Unset, + validation_alias: Annotated[ + str | AliasPath | AliasChoices | None, + Doc( + """ + 'Whitelist' validation step. The parameter field will be the single one + allowed by the alias or set of aliases defined. + """ + ), + ] = None, + serialization_alias: Annotated[ + str | None, + Doc( + """ + 'Blacklist' validation step. The vanilla parameter field will be the + single one of the alias' or set of aliases' fields and all the other + fields will be ignored at serialization time. + """ + ), + ] = None, + convert_underscores: Annotated[ + bool, + Doc( + """ + Automatically convert underscores to hyphens in the parameter field name. + + Read more about it in the + [FastAPI docs for Header Parameters](https://fastapi.tiangolo.com/tutorial/header-params/#automatic-conversion) + """ + ), + ] = True, + title: Annotated[ + str | None, + Doc( + """ + Human-readable title. + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Human-readable description. + """ + ), + ] = None, + gt: Annotated[ + float | None, + Doc( + """ + Greater than. If set, value must be greater than this. Only applicable to + numbers. + """ + ), + ] = None, + ge: Annotated[ + float | None, + Doc( + """ + Greater than or equal. If set, value must be greater than or equal to + this. Only applicable to numbers. + """ + ), + ] = None, + lt: Annotated[ + float | None, + Doc( + """ + Less than. If set, value must be less than this. Only applicable to numbers. + """ + ), + ] = None, + le: Annotated[ + float | None, + Doc( + """ + Less than or equal. If set, value must be less than or equal to this. + Only applicable to numbers. + """ + ), + ] = None, + min_length: Annotated[ + int | None, + Doc( + """ + Minimum length for strings. + """ + ), + ] = None, + max_length: Annotated[ + int | None, + Doc( + """ + Maximum length for strings. + """ + ), + ] = None, + pattern: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + ] = None, + regex: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: Annotated[ + str | None, + Doc( + """ + Parameter field name for discriminating the type in a tagged union. + """ + ), + ] = None, + strict: Annotated[ + bool | None, + Doc( + """ + If `True`, strict validation is applied to the field. + """ + ), + ] = _Unset, + multiple_of: Annotated[ + float | None, + Doc( + """ + Value must be a multiple of this. Only applicable to numbers. + """ + ), + ] = _Unset, + allow_inf_nan: Annotated[ + bool | None, + Doc( + """ + Allow `inf`, `-inf`, `nan`. Only applicable to numbers. + """ + ), + ] = _Unset, + max_digits: Annotated[ + int | None, + Doc( + """ + Maximum number of digits allowed for decimal values. + """ + ), + ] = _Unset, + decimal_places: Annotated[ + int | None, + Doc( + """ + Maximum number of decimal places allowed for decimal values. + """ + ), + ] = _Unset, + examples: Annotated[ + list[Any] | None, + Doc( + """ + Example values for this field. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/) + """ + ), + ] = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: Annotated[ + dict[str, Example] | None, + Doc( + """ + OpenAPI-specific examples. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Swagger UI (that provides the `/docs` interface) has better support for the + OpenAPI-specific examples than the JSON Schema `examples`, that's the main + use case for this. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/#using-the-openapi_examples-parameter). + """ + ), + ] = None, + deprecated: Annotated[ + deprecated | str | bool | None, + Doc( + """ + Mark this parameter field as deprecated. + + It will affect the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) this parameter field in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = True, + json_schema_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Any additional JSON schema data. + """ + ), + ] = None, + **extra: Annotated[ + Any, + Doc( + """ + Include extra fields used by the JSON Schema. + """ + ), + deprecated( + """ + The `extra` kwargs is deprecated. Use `json_schema_extra` instead. + """ + ), + ], +) -> Any: + return params.Header( + default=default, + default_factory=default_factory, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + convert_underscores=convert_underscores, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + example=example, + examples=examples, + openapi_examples=openapi_examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +def Cookie( # noqa: N802 + default: Annotated[ + Any, + Doc( + """ + Default value if the parameter field is not set. + """ + ), + ] = Undefined, + *, + default_factory: Annotated[ + Callable[[], Any] | None, + Doc( + """ + A callable to generate the default value. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = _Unset, + alias: Annotated[ + str | None, + Doc( + """ + An alternative name for the parameter field. + + This will be used to extract the data and for the generated OpenAPI. + It is particularly useful when you can't use the name you want because it + is a Python reserved keyword or similar. + """ + ), + ] = None, + alias_priority: Annotated[ + int | None, + Doc( + """ + Priority of the alias. This affects whether an alias generator is used. + """ + ), + ] = _Unset, + validation_alias: Annotated[ + str | AliasPath | AliasChoices | None, + Doc( + """ + 'Whitelist' validation step. The parameter field will be the single one + allowed by the alias or set of aliases defined. + """ + ), + ] = None, + serialization_alias: Annotated[ + str | None, + Doc( + """ + 'Blacklist' validation step. The vanilla parameter field will be the + single one of the alias' or set of aliases' fields and all the other + fields will be ignored at serialization time. + """ + ), + ] = None, + title: Annotated[ + str | None, + Doc( + """ + Human-readable title. + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Human-readable description. + """ + ), + ] = None, + gt: Annotated[ + float | None, + Doc( + """ + Greater than. If set, value must be greater than this. Only applicable to + numbers. + """ + ), + ] = None, + ge: Annotated[ + float | None, + Doc( + """ + Greater than or equal. If set, value must be greater than or equal to + this. Only applicable to numbers. + """ + ), + ] = None, + lt: Annotated[ + float | None, + Doc( + """ + Less than. If set, value must be less than this. Only applicable to numbers. + """ + ), + ] = None, + le: Annotated[ + float | None, + Doc( + """ + Less than or equal. If set, value must be less than or equal to this. + Only applicable to numbers. + """ + ), + ] = None, + min_length: Annotated[ + int | None, + Doc( + """ + Minimum length for strings. + """ + ), + ] = None, + max_length: Annotated[ + int | None, + Doc( + """ + Maximum length for strings. + """ + ), + ] = None, + pattern: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + ] = None, + regex: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: Annotated[ + str | None, + Doc( + """ + Parameter field name for discriminating the type in a tagged union. + """ + ), + ] = None, + strict: Annotated[ + bool | None, + Doc( + """ + If `True`, strict validation is applied to the field. + """ + ), + ] = _Unset, + multiple_of: Annotated[ + float | None, + Doc( + """ + Value must be a multiple of this. Only applicable to numbers. + """ + ), + ] = _Unset, + allow_inf_nan: Annotated[ + bool | None, + Doc( + """ + Allow `inf`, `-inf`, `nan`. Only applicable to numbers. + """ + ), + ] = _Unset, + max_digits: Annotated[ + int | None, + Doc( + """ + Maximum number of digits allowed for decimal values. + """ + ), + ] = _Unset, + decimal_places: Annotated[ + int | None, + Doc( + """ + Maximum number of decimal places allowed for decimal values. + """ + ), + ] = _Unset, + examples: Annotated[ + list[Any] | None, + Doc( + """ + Example values for this field. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/) + """ + ), + ] = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: Annotated[ + dict[str, Example] | None, + Doc( + """ + OpenAPI-specific examples. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Swagger UI (that provides the `/docs` interface) has better support for the + OpenAPI-specific examples than the JSON Schema `examples`, that's the main + use case for this. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/#using-the-openapi_examples-parameter). + """ + ), + ] = None, + deprecated: Annotated[ + deprecated | str | bool | None, + Doc( + """ + Mark this parameter field as deprecated. + + It will affect the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) this parameter field in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = True, + json_schema_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Any additional JSON schema data. + """ + ), + ] = None, + **extra: Annotated[ + Any, + Doc( + """ + Include extra fields used by the JSON Schema. + """ + ), + deprecated( + """ + The `extra` kwargs is deprecated. Use `json_schema_extra` instead. + """ + ), + ], +) -> Any: + return params.Cookie( + default=default, + default_factory=default_factory, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + example=example, + examples=examples, + openapi_examples=openapi_examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +def Body( # noqa: N802 + default: Annotated[ + Any, + Doc( + """ + Default value if the parameter field is not set. + """ + ), + ] = Undefined, + *, + default_factory: Annotated[ + Callable[[], Any] | None, + Doc( + """ + A callable to generate the default value. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = _Unset, + embed: Annotated[ + bool | None, + Doc( + """ + When `embed` is `True`, the parameter will be expected in a JSON body as a + key instead of being the JSON body itself. + + This happens automatically when more than one `Body` parameter is declared. + + Read more about it in the + [FastAPI docs for Body - Multiple Parameters](https://fastapi.tiangolo.com/tutorial/body-multiple-params/#embed-a-single-body-parameter). + """ + ), + ] = None, + media_type: Annotated[ + str, + Doc( + """ + The media type of this parameter field. Changing it would affect the + generated OpenAPI, but currently it doesn't affect the parsing of the data. + """ + ), + ] = "application/json", + alias: Annotated[ + str | None, + Doc( + """ + An alternative name for the parameter field. + + This will be used to extract the data and for the generated OpenAPI. + It is particularly useful when you can't use the name you want because it + is a Python reserved keyword or similar. + """ + ), + ] = None, + alias_priority: Annotated[ + int | None, + Doc( + """ + Priority of the alias. This affects whether an alias generator is used. + """ + ), + ] = _Unset, + validation_alias: Annotated[ + str | AliasPath | AliasChoices | None, + Doc( + """ + 'Whitelist' validation step. The parameter field will be the single one + allowed by the alias or set of aliases defined. + """ + ), + ] = None, + serialization_alias: Annotated[ + str | None, + Doc( + """ + 'Blacklist' validation step. The vanilla parameter field will be the + single one of the alias' or set of aliases' fields and all the other + fields will be ignored at serialization time. + """ + ), + ] = None, + title: Annotated[ + str | None, + Doc( + """ + Human-readable title. + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Human-readable description. + """ + ), + ] = None, + gt: Annotated[ + float | None, + Doc( + """ + Greater than. If set, value must be greater than this. Only applicable to + numbers. + """ + ), + ] = None, + ge: Annotated[ + float | None, + Doc( + """ + Greater than or equal. If set, value must be greater than or equal to + this. Only applicable to numbers. + """ + ), + ] = None, + lt: Annotated[ + float | None, + Doc( + """ + Less than. If set, value must be less than this. Only applicable to numbers. + """ + ), + ] = None, + le: Annotated[ + float | None, + Doc( + """ + Less than or equal. If set, value must be less than or equal to this. + Only applicable to numbers. + """ + ), + ] = None, + min_length: Annotated[ + int | None, + Doc( + """ + Minimum length for strings. + """ + ), + ] = None, + max_length: Annotated[ + int | None, + Doc( + """ + Maximum length for strings. + """ + ), + ] = None, + pattern: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + ] = None, + regex: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: Annotated[ + str | None, + Doc( + """ + Parameter field name for discriminating the type in a tagged union. + """ + ), + ] = None, + strict: Annotated[ + bool | None, + Doc( + """ + If `True`, strict validation is applied to the field. + """ + ), + ] = _Unset, + multiple_of: Annotated[ + float | None, + Doc( + """ + Value must be a multiple of this. Only applicable to numbers. + """ + ), + ] = _Unset, + allow_inf_nan: Annotated[ + bool | None, + Doc( + """ + Allow `inf`, `-inf`, `nan`. Only applicable to numbers. + """ + ), + ] = _Unset, + max_digits: Annotated[ + int | None, + Doc( + """ + Maximum number of digits allowed for decimal values. + """ + ), + ] = _Unset, + decimal_places: Annotated[ + int | None, + Doc( + """ + Maximum number of decimal places allowed for decimal values. + """ + ), + ] = _Unset, + examples: Annotated[ + list[Any] | None, + Doc( + """ + Example values for this field. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/) + """ + ), + ] = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: Annotated[ + dict[str, Example] | None, + Doc( + """ + OpenAPI-specific examples. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Swagger UI (that provides the `/docs` interface) has better support for the + OpenAPI-specific examples than the JSON Schema `examples`, that's the main + use case for this. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/#using-the-openapi_examples-parameter). + """ + ), + ] = None, + deprecated: Annotated[ + deprecated | str | bool | None, + Doc( + """ + Mark this parameter field as deprecated. + + It will affect the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) this parameter field in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = True, + json_schema_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Any additional JSON schema data. + """ + ), + ] = None, + **extra: Annotated[ + Any, + Doc( + """ + Include extra fields used by the JSON Schema. + """ + ), + deprecated( + """ + The `extra` kwargs is deprecated. Use `json_schema_extra` instead. + """ + ), + ], +) -> Any: + return params.Body( + default=default, + default_factory=default_factory, + embed=embed, + media_type=media_type, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + example=example, + examples=examples, + openapi_examples=openapi_examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +def Form( # noqa: N802 + default: Annotated[ + Any, + Doc( + """ + Default value if the parameter field is not set. + """ + ), + ] = Undefined, + *, + default_factory: Annotated[ + Callable[[], Any] | None, + Doc( + """ + A callable to generate the default value. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = _Unset, + media_type: Annotated[ + str, + Doc( + """ + The media type of this parameter field. Changing it would affect the + generated OpenAPI, but currently it doesn't affect the parsing of the data. + """ + ), + ] = "application/x-www-form-urlencoded", + alias: Annotated[ + str | None, + Doc( + """ + An alternative name for the parameter field. + + This will be used to extract the data and for the generated OpenAPI. + It is particularly useful when you can't use the name you want because it + is a Python reserved keyword or similar. + """ + ), + ] = None, + alias_priority: Annotated[ + int | None, + Doc( + """ + Priority of the alias. This affects whether an alias generator is used. + """ + ), + ] = _Unset, + validation_alias: Annotated[ + str | AliasPath | AliasChoices | None, + Doc( + """ + 'Whitelist' validation step. The parameter field will be the single one + allowed by the alias or set of aliases defined. + """ + ), + ] = None, + serialization_alias: Annotated[ + str | None, + Doc( + """ + 'Blacklist' validation step. The vanilla parameter field will be the + single one of the alias' or set of aliases' fields and all the other + fields will be ignored at serialization time. + """ + ), + ] = None, + title: Annotated[ + str | None, + Doc( + """ + Human-readable title. + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Human-readable description. + """ + ), + ] = None, + gt: Annotated[ + float | None, + Doc( + """ + Greater than. If set, value must be greater than this. Only applicable to + numbers. + """ + ), + ] = None, + ge: Annotated[ + float | None, + Doc( + """ + Greater than or equal. If set, value must be greater than or equal to + this. Only applicable to numbers. + """ + ), + ] = None, + lt: Annotated[ + float | None, + Doc( + """ + Less than. If set, value must be less than this. Only applicable to numbers. + """ + ), + ] = None, + le: Annotated[ + float | None, + Doc( + """ + Less than or equal. If set, value must be less than or equal to this. + Only applicable to numbers. + """ + ), + ] = None, + min_length: Annotated[ + int | None, + Doc( + """ + Minimum length for strings. + """ + ), + ] = None, + max_length: Annotated[ + int | None, + Doc( + """ + Maximum length for strings. + """ + ), + ] = None, + pattern: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + ] = None, + regex: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: Annotated[ + str | None, + Doc( + """ + Parameter field name for discriminating the type in a tagged union. + """ + ), + ] = None, + strict: Annotated[ + bool | None, + Doc( + """ + If `True`, strict validation is applied to the field. + """ + ), + ] = _Unset, + multiple_of: Annotated[ + float | None, + Doc( + """ + Value must be a multiple of this. Only applicable to numbers. + """ + ), + ] = _Unset, + allow_inf_nan: Annotated[ + bool | None, + Doc( + """ + Allow `inf`, `-inf`, `nan`. Only applicable to numbers. + """ + ), + ] = _Unset, + max_digits: Annotated[ + int | None, + Doc( + """ + Maximum number of digits allowed for decimal values. + """ + ), + ] = _Unset, + decimal_places: Annotated[ + int | None, + Doc( + """ + Maximum number of decimal places allowed for decimal values. + """ + ), + ] = _Unset, + examples: Annotated[ + list[Any] | None, + Doc( + """ + Example values for this field. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/) + """ + ), + ] = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: Annotated[ + dict[str, Example] | None, + Doc( + """ + OpenAPI-specific examples. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Swagger UI (that provides the `/docs` interface) has better support for the + OpenAPI-specific examples than the JSON Schema `examples`, that's the main + use case for this. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/#using-the-openapi_examples-parameter). + """ + ), + ] = None, + deprecated: Annotated[ + deprecated | str | bool | None, + Doc( + """ + Mark this parameter field as deprecated. + + It will affect the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) this parameter field in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = True, + json_schema_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Any additional JSON schema data. + """ + ), + ] = None, + **extra: Annotated[ + Any, + Doc( + """ + Include extra fields used by the JSON Schema. + """ + ), + deprecated( + """ + The `extra` kwargs is deprecated. Use `json_schema_extra` instead. + """ + ), + ], +) -> Any: + return params.Form( + default=default, + default_factory=default_factory, + media_type=media_type, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + example=example, + examples=examples, + openapi_examples=openapi_examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +def File( # noqa: N802 + default: Annotated[ + Any, + Doc( + """ + Default value if the parameter field is not set. + """ + ), + ] = Undefined, + *, + default_factory: Annotated[ + Callable[[], Any] | None, + Doc( + """ + A callable to generate the default value. + + This doesn't affect `Path` parameters as the value is always required. + The parameter is available only for compatibility. + """ + ), + ] = _Unset, + media_type: Annotated[ + str, + Doc( + """ + The media type of this parameter field. Changing it would affect the + generated OpenAPI, but currently it doesn't affect the parsing of the data. + """ + ), + ] = "multipart/form-data", + alias: Annotated[ + str | None, + Doc( + """ + An alternative name for the parameter field. + + This will be used to extract the data and for the generated OpenAPI. + It is particularly useful when you can't use the name you want because it + is a Python reserved keyword or similar. + """ + ), + ] = None, + alias_priority: Annotated[ + int | None, + Doc( + """ + Priority of the alias. This affects whether an alias generator is used. + """ + ), + ] = _Unset, + validation_alias: Annotated[ + str | AliasPath | AliasChoices | None, + Doc( + """ + 'Whitelist' validation step. The parameter field will be the single one + allowed by the alias or set of aliases defined. + """ + ), + ] = None, + serialization_alias: Annotated[ + str | None, + Doc( + """ + 'Blacklist' validation step. The vanilla parameter field will be the + single one of the alias' or set of aliases' fields and all the other + fields will be ignored at serialization time. + """ + ), + ] = None, + title: Annotated[ + str | None, + Doc( + """ + Human-readable title. + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Human-readable description. + """ + ), + ] = None, + gt: Annotated[ + float | None, + Doc( + """ + Greater than. If set, value must be greater than this. Only applicable to + numbers. + """ + ), + ] = None, + ge: Annotated[ + float | None, + Doc( + """ + Greater than or equal. If set, value must be greater than or equal to + this. Only applicable to numbers. + """ + ), + ] = None, + lt: Annotated[ + float | None, + Doc( + """ + Less than. If set, value must be less than this. Only applicable to numbers. + """ + ), + ] = None, + le: Annotated[ + float | None, + Doc( + """ + Less than or equal. If set, value must be less than or equal to this. + Only applicable to numbers. + """ + ), + ] = None, + min_length: Annotated[ + int | None, + Doc( + """ + Minimum length for strings. + """ + ), + ] = None, + max_length: Annotated[ + int | None, + Doc( + """ + Maximum length for strings. + """ + ), + ] = None, + pattern: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + ] = None, + regex: Annotated[ + str | None, + Doc( + """ + RegEx pattern for strings. + """ + ), + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: Annotated[ + str | None, + Doc( + """ + Parameter field name for discriminating the type in a tagged union. + """ + ), + ] = None, + strict: Annotated[ + bool | None, + Doc( + """ + If `True`, strict validation is applied to the field. + """ + ), + ] = _Unset, + multiple_of: Annotated[ + float | None, + Doc( + """ + Value must be a multiple of this. Only applicable to numbers. + """ + ), + ] = _Unset, + allow_inf_nan: Annotated[ + bool | None, + Doc( + """ + Allow `inf`, `-inf`, `nan`. Only applicable to numbers. + """ + ), + ] = _Unset, + max_digits: Annotated[ + int | None, + Doc( + """ + Maximum number of digits allowed for decimal values. + """ + ), + ] = _Unset, + decimal_places: Annotated[ + int | None, + Doc( + """ + Maximum number of decimal places allowed for decimal values. + """ + ), + ] = _Unset, + examples: Annotated[ + list[Any] | None, + Doc( + """ + Example values for this field. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/) + """ + ), + ] = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: Annotated[ + dict[str, Example] | None, + Doc( + """ + OpenAPI-specific examples. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Swagger UI (that provides the `/docs` interface) has better support for the + OpenAPI-specific examples than the JSON Schema `examples`, that's the main + use case for this. + + Read more about it in the + [FastAPI docs for Declare Request Example Data](https://fastapi.tiangolo.com/tutorial/schema-extra-example/#using-the-openapi_examples-parameter). + """ + ), + ] = None, + deprecated: Annotated[ + deprecated | str | bool | None, + Doc( + """ + Mark this parameter field as deprecated. + + It will affect the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) this parameter field in the generated OpenAPI. + You probably don't need it, but it's available. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = True, + json_schema_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Any additional JSON schema data. + """ + ), + ] = None, + **extra: Annotated[ + Any, + Doc( + """ + Include extra fields used by the JSON Schema. + """ + ), + deprecated( + """ + The `extra` kwargs is deprecated. Use `json_schema_extra` instead. + """ + ), + ], +) -> Any: + return params.File( + default=default, + default_factory=default_factory, + media_type=media_type, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + example=example, + examples=examples, + openapi_examples=openapi_examples, + deprecated=deprecated, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +def Depends( # noqa: N802 + dependency: Annotated[ + Callable[..., Any] | None, + Doc( + """ + A "dependable" callable (like a function). + + Don't call it directly, FastAPI will call it for you, just pass the object + directly. + + Read more about it in the + [FastAPI docs for Dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/) + """ + ), + ] = None, + *, + use_cache: Annotated[ + bool, + Doc( + """ + By default, after a dependency is called the first time in a request, if + the dependency is declared again for the rest of the request (for example + if the dependency is needed by several dependencies), the value will be + re-used for the rest of the request. + + Set `use_cache` to `False` to disable this behavior and ensure the + dependency is called again (if declared more than once) in the same request. + + Read more about it in the + [FastAPI docs about sub-dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/sub-dependencies/#using-the-same-dependency-multiple-times) + """ + ), + ] = True, + scope: Annotated[ + Literal["function", "request"] | None, + Doc( + """ + Mainly for dependencies with `yield`, define when the dependency function + should start (the code before `yield`) and when it should end (the code + after `yield`). + + * `"function"`: start the dependency before the *path operation function* + that handles the request, end the dependency after the *path operation + function* ends, but **before** the response is sent back to the client. + So, the dependency function will be executed **around** the *path operation + **function***. + * `"request"`: start the dependency before the *path operation function* + that handles the request (similar to when using `"function"`), but end + **after** the response is sent back to the client. So, the dependency + function will be executed **around** the **request** and response cycle. + + Read more about it in the + [FastAPI docs for FastAPI Dependencies with yield](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#early-exit-and-scope) + """ + ), + ] = None, +) -> Any: + """ + Declare a FastAPI dependency. + + It takes a single "dependable" callable (like a function). + + Don't call it directly, FastAPI will call it for you. + + Read more about it in the + [FastAPI docs for Dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/). + + **Example** + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + + app = FastAPI() + + + async def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100): + return {"q": q, "skip": skip, "limit": limit} + + + @app.get("/items/") + async def read_items(commons: Annotated[dict, Depends(common_parameters)]): + return commons + ``` + """ + return params.Depends(dependency=dependency, use_cache=use_cache, scope=scope) + + +def Security( # noqa: N802 + dependency: Annotated[ + Callable[..., Any] | None, + Doc( + """ + A "dependable" callable (like a function). + + Don't call it directly, FastAPI will call it for you, just pass the object + directly. + + Read more about it in the + [FastAPI docs for Dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/) + """ + ), + ] = None, + *, + scopes: Annotated[ + Sequence[str] | None, + Doc( + """ + OAuth2 scopes required for the *path operation* that uses this Security + dependency. + + The term "scope" comes from the OAuth2 specification, it seems to be + intentionally vague and interpretable. It normally refers to permissions, + in cases to roles. + + These scopes are integrated with OpenAPI (and the API docs at `/docs`). + So they are visible in the OpenAPI specification. + + Read more about it in the + [FastAPI docs about OAuth2 scopes](https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/) + """ + ), + ] = None, + use_cache: Annotated[ + bool, + Doc( + """ + By default, after a dependency is called the first time in a request, if + the dependency is declared again for the rest of the request (for example + if the dependency is needed by several dependencies), the value will be + re-used for the rest of the request. + + Set `use_cache` to `False` to disable this behavior and ensure the + dependency is called again (if declared more than once) in the same request. + + Read more about it in the + [FastAPI docs about sub-dependencies](https://fastapi.tiangolo.com/tutorial/dependencies/sub-dependencies/#using-the-same-dependency-multiple-times) + """ + ), + ] = True, +) -> Any: + """ + Declare a FastAPI Security dependency. + + The only difference with a regular dependency is that it can declare OAuth2 + scopes that will be integrated with OpenAPI and the automatic UI docs (by default + at `/docs`). + + It takes a single "dependable" callable (like a function). + + Don't call it directly, FastAPI will call it for you. + + Read more about it in the + [FastAPI docs for Security](https://fastapi.tiangolo.com/tutorial/security/) and + in the + [FastAPI docs for OAuth2 scopes](https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/). + + **Example** + + ```python + from typing import Annotated + + from fastapi import Security, FastAPI + + from .db import User + from .security import get_current_active_user + + app = FastAPI() + + @app.get("/users/me/items/") + async def read_own_items( + current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])] + ): + return [{"item_id": "Foo", "owner": current_user.username}] + ``` + """ + return params.Security(dependency=dependency, scopes=scopes, use_cache=use_cache) diff --git a/server/venv/Lib/site-packages/fastapi/params.py b/server/venv/Lib/site-packages/fastapi/params.py new file mode 100644 index 0000000..d3f2ae1 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/params.py @@ -0,0 +1,754 @@ +import warnings +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from enum import Enum +from typing import Annotated, Any, Literal + +from fastapi.exceptions import FastAPIDeprecationWarning +from fastapi.openapi.models import Example +from pydantic import AliasChoices, AliasPath +from pydantic.fields import FieldInfo +from typing_extensions import deprecated + +from ._compat import ( + Undefined, +) +from .datastructures import _Unset + + +class ParamTypes(Enum): + query = "query" + header = "header" + path = "path" + cookie = "cookie" + + +class Param(FieldInfo): # type: ignore[misc] # ty: ignore[subclass-of-final-class] + in_: ParamTypes + + def __init__( + self, + default: Any = Undefined, + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + if example is not _Unset: + warnings.warn( + "`example` has been deprecated, please use `examples` instead", + category=FastAPIDeprecationWarning, + stacklevel=4, + ) + self.example = example + self.include_in_schema = include_in_schema + self.openapi_examples = openapi_examples + kwargs = dict( + default=default, + default_factory=default_factory, + alias=alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + discriminator=discriminator, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + **extra, + ) + if examples is not None: + kwargs["examples"] = examples + if regex is not None: + warnings.warn( + "`regex` has been deprecated, please use `pattern` instead", + category=FastAPIDeprecationWarning, + stacklevel=4, + ) + current_json_schema_extra = json_schema_extra or extra + kwargs["deprecated"] = deprecated + + if serialization_alias in (_Unset, None) and isinstance(alias, str): + serialization_alias = alias + if validation_alias in (_Unset, None): + validation_alias = alias + kwargs.update( + { + "annotation": annotation, + "alias_priority": alias_priority, + "validation_alias": validation_alias, + "serialization_alias": serialization_alias, + "strict": strict, + "json_schema_extra": current_json_schema_extra, + } + ) + kwargs["pattern"] = pattern or regex + + use_kwargs = {k: v for k, v in kwargs.items() if v is not _Unset} + + super().__init__(**use_kwargs) # ty: ignore[invalid-argument-type] + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.default})" + + +class Path(Param): # type: ignore[misc] + in_ = ParamTypes.path + + def __init__( + self, + default: Any = ..., + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + assert default is ..., "Path parameters cannot have a default value" + self.in_ = self.in_ + super().__init__( + default=default, + default_factory=default_factory, + annotation=annotation, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + deprecated=deprecated, + example=example, + examples=examples, + openapi_examples=openapi_examples, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +class Query(Param): # type: ignore[misc] + in_ = ParamTypes.query + + def __init__( + self, + default: Any = Undefined, + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + super().__init__( + default=default, + default_factory=default_factory, + annotation=annotation, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + deprecated=deprecated, + example=example, + examples=examples, + openapi_examples=openapi_examples, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +class Header(Param): # type: ignore[misc] + in_ = ParamTypes.header + + def __init__( + self, + default: Any = Undefined, + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + convert_underscores: bool = True, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + self.convert_underscores = convert_underscores + super().__init__( + default=default, + default_factory=default_factory, + annotation=annotation, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + deprecated=deprecated, + example=example, + examples=examples, + openapi_examples=openapi_examples, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +class Cookie(Param): # type: ignore[misc] + in_ = ParamTypes.cookie + + def __init__( + self, + default: Any = Undefined, + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + super().__init__( + default=default, + default_factory=default_factory, + annotation=annotation, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + deprecated=deprecated, + example=example, + examples=examples, + openapi_examples=openapi_examples, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +class Body(FieldInfo): # type: ignore[misc] # ty: ignore[subclass-of-final-class] + def __init__( + self, + default: Any = Undefined, + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + embed: bool | None = None, + media_type: str = "application/json", + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + self.embed = embed + self.media_type = media_type + if example is not _Unset: + warnings.warn( + "`example` has been deprecated, please use `examples` instead", + category=FastAPIDeprecationWarning, + stacklevel=4, + ) + self.example = example + self.include_in_schema = include_in_schema + self.openapi_examples = openapi_examples + kwargs = dict( + default=default, + default_factory=default_factory, + alias=alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + discriminator=discriminator, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + **extra, + ) + if examples is not None: + kwargs["examples"] = examples + if regex is not None: + warnings.warn( + "`regex` has been deprecated, please use `pattern` instead", + category=FastAPIDeprecationWarning, + stacklevel=4, + ) + current_json_schema_extra = json_schema_extra or extra + kwargs["deprecated"] = deprecated + if serialization_alias in (_Unset, None) and isinstance(alias, str): + serialization_alias = alias + if validation_alias in (_Unset, None): + validation_alias = alias + kwargs.update( + { + "annotation": annotation, + "alias_priority": alias_priority, + "validation_alias": validation_alias, + "serialization_alias": serialization_alias, + "strict": strict, + "json_schema_extra": current_json_schema_extra, + } + ) + kwargs["pattern"] = pattern or regex + + use_kwargs = {k: v for k, v in kwargs.items() if v is not _Unset} + + super().__init__(**use_kwargs) # ty: ignore[invalid-argument-type] + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.default})" + + +class Form(Body): # type: ignore[misc] + def __init__( + self, + default: Any = Undefined, + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + media_type: str = "application/x-www-form-urlencoded", + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + super().__init__( + default=default, + default_factory=default_factory, + annotation=annotation, + media_type=media_type, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + deprecated=deprecated, + example=example, + examples=examples, + openapi_examples=openapi_examples, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +class File(Form): # type: ignore[misc] + def __init__( + self, + default: Any = Undefined, + *, + default_factory: Callable[[], Any] | None = _Unset, + annotation: Any | None = None, + media_type: str = "multipart/form-data", + alias: str | None = None, + alias_priority: int | None = _Unset, + validation_alias: str | AliasPath | AliasChoices | None = None, + serialization_alias: str | None = None, + title: str | None = None, + description: str | None = None, + gt: float | None = None, + ge: float | None = None, + lt: float | None = None, + le: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + regex: Annotated[ + str | None, + deprecated( + "Deprecated in FastAPI 0.100.0 and Pydantic v2, use `pattern` instead." + ), + ] = None, + discriminator: str | None = None, + strict: bool | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + examples: list[Any] | None = None, + example: Annotated[ + Any | None, + deprecated( + "Deprecated in OpenAPI 3.1.0 that now uses JSON Schema 2020-12, " + "although still supported. Use examples instead." + ), + ] = _Unset, + openapi_examples: dict[str, Example] | None = None, + deprecated: deprecated | str | bool | None = None, + include_in_schema: bool = True, + json_schema_extra: dict[str, Any] | None = None, + **extra: Any, + ): + super().__init__( + default=default, + default_factory=default_factory, + annotation=annotation, + media_type=media_type, + alias=alias, + alias_priority=alias_priority, + validation_alias=validation_alias, + serialization_alias=serialization_alias, + title=title, + description=description, + gt=gt, + ge=ge, + lt=lt, + le=le, + min_length=min_length, + max_length=max_length, + pattern=pattern, + regex=regex, + discriminator=discriminator, + strict=strict, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + deprecated=deprecated, + example=example, + examples=examples, + openapi_examples=openapi_examples, + include_in_schema=include_in_schema, + json_schema_extra=json_schema_extra, + **extra, + ) + + +@dataclass(frozen=True) +class Depends: + dependency: Callable[..., Any] | None = None + use_cache: bool = True + scope: Literal["function", "request"] | None = None + + +@dataclass(frozen=True) +class Security(Depends): + scopes: Sequence[str] | None = None diff --git a/server/venv/Lib/site-packages/fastapi/py.typed b/server/venv/Lib/site-packages/fastapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/fastapi/requests.py b/server/venv/Lib/site-packages/fastapi/requests.py new file mode 100644 index 0000000..d16552c --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/requests.py @@ -0,0 +1,2 @@ +from starlette.requests import HTTPConnection as HTTPConnection # noqa: F401 +from starlette.requests import Request as Request # noqa: F401 diff --git a/server/venv/Lib/site-packages/fastapi/responses.py b/server/venv/Lib/site-packages/fastapi/responses.py new file mode 100644 index 0000000..29df4b7 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/responses.py @@ -0,0 +1,98 @@ +import importlib +from typing import Any, Protocol, cast + +from fastapi.exceptions import FastAPIDeprecationWarning +from fastapi.sse import EventSourceResponse as EventSourceResponse # noqa +from starlette.responses import FileResponse as FileResponse # noqa +from starlette.responses import HTMLResponse as HTMLResponse # noqa +from starlette.responses import JSONResponse as JSONResponse # noqa +from starlette.responses import PlainTextResponse as PlainTextResponse # noqa +from starlette.responses import RedirectResponse as RedirectResponse # noqa +from starlette.responses import Response as Response # noqa +from starlette.responses import StreamingResponse as StreamingResponse # noqa +from typing_extensions import deprecated + + +class _UjsonModule(Protocol): + def dumps(self, __obj: Any, *, ensure_ascii: bool = ...) -> str: ... + + +class _OrjsonModule(Protocol): + OPT_NON_STR_KEYS: int + OPT_SERIALIZE_NUMPY: int + + def dumps(self, __obj: Any, *, option: int = ...) -> bytes: ... + + +try: + ujson = cast(_UjsonModule, importlib.import_module("ujson")) +except ModuleNotFoundError: # pragma: nocover + ujson = None # type: ignore[assignment] + + +try: + orjson = cast(_OrjsonModule, importlib.import_module("orjson")) +except ModuleNotFoundError: # pragma: nocover + orjson = None # type: ignore[assignment] + + +@deprecated( + "UJSONResponse is deprecated, FastAPI now serializes data directly to JSON " + "bytes via Pydantic when a return type or response model is set, which is " + "faster and doesn't need a custom response class. Read more in the FastAPI " + "docs: https://fastapi.tiangolo.com/advanced/custom-response/#orjson-or-response-model " + "and https://fastapi.tiangolo.com/tutorial/response-model/", + category=FastAPIDeprecationWarning, + stacklevel=2, +) +class UJSONResponse(JSONResponse): + """JSON response using the ujson library to serialize data to JSON. + + **Deprecated**: `UJSONResponse` is deprecated. FastAPI now serializes data + directly to JSON bytes via Pydantic when a return type or response model is + set, which is faster and doesn't need a custom response class. + + Read more in the + [FastAPI docs for Custom Response](https://fastapi.tiangolo.com/advanced/custom-response/#orjson-or-response-model) + and the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + + **Note**: `ujson` is not included with FastAPI and must be installed + separately, e.g. `pip install ujson`. + """ + + def render(self, content: Any) -> bytes: + assert ujson is not None, "ujson must be installed to use UJSONResponse" + return ujson.dumps(content, ensure_ascii=False).encode("utf-8") + + +@deprecated( + "ORJSONResponse is deprecated, FastAPI now serializes data directly to JSON " + "bytes via Pydantic when a return type or response model is set, which is " + "faster and doesn't need a custom response class. Read more in the FastAPI " + "docs: https://fastapi.tiangolo.com/advanced/custom-response/#orjson-or-response-model " + "and https://fastapi.tiangolo.com/tutorial/response-model/", + category=FastAPIDeprecationWarning, + stacklevel=2, +) +class ORJSONResponse(JSONResponse): + """JSON response using the orjson library to serialize data to JSON. + + **Deprecated**: `ORJSONResponse` is deprecated. FastAPI now serializes data + directly to JSON bytes via Pydantic when a return type or response model is + set, which is faster and doesn't need a custom response class. + + Read more in the + [FastAPI docs for Custom Response](https://fastapi.tiangolo.com/advanced/custom-response/#orjson-or-response-model) + and the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + + **Note**: `orjson` is not included with FastAPI and must be installed + separately, e.g. `pip install orjson`. + """ + + def render(self, content: Any) -> bytes: + assert orjson is not None, "orjson must be installed to use ORJSONResponse" + return orjson.dumps( + content, option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SERIALIZE_NUMPY + ) diff --git a/server/venv/Lib/site-packages/fastapi/routing.py b/server/venv/Lib/site-packages/fastapi/routing.py new file mode 100644 index 0000000..3a2d754 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/routing.py @@ -0,0 +1,6231 @@ +import contextlib +import copy +import email.message +import errno +import functools +import inspect +import json +import os +import stat +import types +from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Collection, + Coroutine, + Generator, + Iterator, + Mapping, + Sequence, +) +from contextlib import ( + AbstractAsyncContextManager, + AbstractContextManager, + AsyncExitStack, + asynccontextmanager, +) +from contextvars import ContextVar +from dataclasses import dataclass, field +from enum import Enum, IntEnum +from typing import ( + Annotated, + Any, + Literal, + Protocol, + TypeVar, + cast, +) + +import anyio +from annotated_doc import Doc +from anyio.abc import ObjectReceiveStream +from fastapi import params +from fastapi._compat import ( + ModelField, + Undefined, + lenient_issubclass, +) +from fastapi.datastructures import Default, DefaultPlaceholder +from fastapi.dependencies.models import Dependant +from fastapi.dependencies.utils import ( + _should_embed_body_fields, + get_body_field, + get_dependant, + get_flat_dependant, + get_parameterless_sub_dependant, + get_stream_item_type, + get_typed_return_annotation, + solve_dependencies, +) +from fastapi.encoders import jsonable_encoder +from fastapi.exceptions import ( + EndpointContext, + FastAPIError, + RequestValidationError, + ResponseValidationError, + WebSocketRequestValidationError, +) +from fastapi.sse import ( + _PING_INTERVAL, + KEEPALIVE_COMMENT, + EventSourceResponse, + ServerSentEvent, + format_sse_event, +) +from fastapi.types import DecoratedCallable, IncEx +from fastapi.utils import ( + create_model_field, + generate_unique_id, + get_value_or_default, + is_body_allowed_for_status_code, +) +from starlette import routing +from starlette._exception_handler import wrap_app_handling_exceptions +from starlette._utils import get_route_path, is_async_callable +from starlette.concurrency import iterate_in_threadpool, run_in_threadpool +from starlette.datastructures import URL, FormData, URLPath +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import ( + JSONResponse, + PlainTextResponse, + RedirectResponse, + Response, + StreamingResponse, +) +from starlette.routing import ( + BaseRoute, + Match, + NoMatchFound, + compile_path, + get_name, +) +from starlette.routing import Mount as Mount # noqa +from starlette.staticfiles import StaticFiles +from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send +from starlette.websockets import WebSocket +from typing_extensions import deprecated + + +# Copy of starlette.routing.request_response modified to include the +# dependencies' AsyncExitStack +def request_response( + func: Callable[[Request], Awaitable[Response] | Response], +) -> ASGIApp: + """ + Takes a function or coroutine `func(request) -> response`, + and returns an ASGI application. + """ + f: Callable[[Request], Awaitable[Response]] = ( + func # type: ignore[assignment] + if is_async_callable(func) + else functools.partial(run_in_threadpool, func) # type: ignore[call-arg] + ) # ty: ignore[invalid-assignment] + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + request = Request(scope, receive, send) + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + # Starts customization + response_awaited = False + async with AsyncExitStack() as request_stack: + scope["fastapi_inner_astack"] = request_stack + async with AsyncExitStack() as function_stack: + scope["fastapi_function_astack"] = function_stack + response = await f(request) + await response(scope, receive, send) + # Continues customization + response_awaited = True + if not response_awaited: + raise FastAPIError( + "Response not awaited. There's a high chance that the " + "application code is raising an exception and a dependency with yield " + "has a block with a bare except, or a block with except Exception, " + "and is not raising the exception again. Read more about it in the " + "docs: https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#dependencies-with-yield-and-except" + ) + + # Same as in Starlette + await wrap_app_handling_exceptions(app, request)(scope, receive, send) + + return app + + +# Copy of starlette.routing.websocket_session modified to include the +# dependencies' AsyncExitStack +def websocket_session( + func: Callable[[WebSocket], Awaitable[None]], +) -> ASGIApp: + """ + Takes a coroutine `func(session)`, and returns an ASGI application. + """ + # assert asyncio.iscoroutinefunction(func), "WebSocket endpoints must be async" + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + session = WebSocket(scope, receive=receive, send=send) + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + async with AsyncExitStack() as request_stack: + scope["fastapi_inner_astack"] = request_stack + async with AsyncExitStack() as function_stack: + scope["fastapi_function_astack"] = function_stack + await func(session) + + # Same as in Starlette + await wrap_app_handling_exceptions(app, session)(scope, receive, send) + + return app + + +_T = TypeVar("_T") + + +# Vendored from starlette.routing to avoid importing private symbols +class _AsyncLiftContextManager(AbstractAsyncContextManager[_T]): + """ + Wraps a synchronous context manager to make it async. + + This is vendored from Starlette to avoid importing private symbols. + """ + + def __init__(self, cm: AbstractContextManager[_T]) -> None: + self._cm = cm + + async def __aenter__(self) -> _T: + return self._cm.__enter__() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: types.TracebackType | None, + ) -> bool | None: + return self._cm.__exit__(exc_type, exc_value, traceback) + + +# Vendored from starlette.routing to avoid importing private symbols +def _wrap_gen_lifespan_context( + lifespan_context: Callable[[Any], Generator[Any, Any, Any]], +) -> Callable[[Any], AbstractAsyncContextManager[Any]]: + """ + Wrap a generator-based lifespan context into an async context manager. + + This is vendored from Starlette to avoid importing private symbols. + """ + cmgr = contextlib.contextmanager(lifespan_context) + + @functools.wraps(cmgr) + def wrapper(app: Any) -> _AsyncLiftContextManager[Any]: + return _AsyncLiftContextManager(cmgr(app)) + + return wrapper + + +def _merge_lifespan_context( + original_context: Lifespan[Any], nested_context: Lifespan[Any] +) -> Lifespan[Any]: + @asynccontextmanager + async def merged_lifespan( + app: AppType, + ) -> AsyncIterator[Mapping[str, Any] | None]: + async with original_context(app) as maybe_original_state: + async with nested_context(app) as maybe_nested_state: + if maybe_nested_state is None and maybe_original_state is None: + yield None # old ASGI compatibility + else: + yield {**(maybe_nested_state or {}), **(maybe_original_state or {})} + + return merged_lifespan # type: ignore[return-value] # ty: ignore[invalid-return-type] + + +class _DefaultLifespan: + """ + Default lifespan context manager that runs on_startup and on_shutdown handlers. + + This is a copy of the Starlette _DefaultLifespan class that was removed + in Starlette. FastAPI keeps it to maintain backward compatibility with + on_startup and on_shutdown event handlers. + + Ref: https://github.com/Kludex/starlette/pull/3117 + """ + + def __init__(self, router: "APIRouter") -> None: + self._router = router + + async def __aenter__(self) -> None: + await self._router._startup() + + async def __aexit__(self, *exc_info: object) -> None: + await self._router._shutdown() + + def __call__(self: _T, app: object) -> _T: + return self + + +# Cache for endpoint context to avoid re-extracting on every request +_endpoint_context_cache: dict[int, EndpointContext] = {} + + +def _extract_endpoint_context(func: Any) -> EndpointContext: + """Extract endpoint context with caching to avoid repeated file I/O.""" + func_id = id(func) + + if func_id in _endpoint_context_cache: + return _endpoint_context_cache[func_id] + + try: + ctx: EndpointContext = {} + + if (source_file := inspect.getsourcefile(func)) is not None: + ctx["file"] = source_file + if (line_number := inspect.getsourcelines(func)[1]) is not None: + ctx["line"] = line_number + if (func_name := getattr(func, "__name__", None)) is not None: + ctx["function"] = func_name + except Exception: + ctx = EndpointContext() + + _endpoint_context_cache[func_id] = ctx + return ctx + + +async def serialize_response( + *, + field: ModelField | None = None, + response_content: Any, + include: IncEx | None = None, + exclude: IncEx | None = None, + by_alias: bool = True, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + is_coroutine: bool = True, + endpoint_ctx: EndpointContext | None = None, + dump_json: bool = False, +) -> Any: + if field: + if is_coroutine: + value, errors = field.validate(response_content, {}, loc=("response",)) + else: + value, errors = await run_in_threadpool( + field.validate, response_content, {}, loc=("response",) + ) + if errors: + ctx = endpoint_ctx or EndpointContext() + raise ResponseValidationError( + errors=errors, + body=response_content, + endpoint_ctx=ctx, + ) + serializer = field.serialize_json if dump_json else field.serialize + return serializer( + value, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + ) + + else: + return jsonable_encoder(response_content) + + +async def run_endpoint_function( + *, dependant: Dependant, values: dict[str, Any], is_coroutine: bool +) -> Any: + # Only called by get_request_handler. Has been split into its own function to + # facilitate profiling endpoints, since inner functions are harder to profile. + assert dependant.call is not None, "dependant.call must be a function" + + if is_coroutine: + return await dependant.call(**values) + else: + return await run_in_threadpool(dependant.call, **values) + + +def _build_response_args( + *, status_code: int | None, solved_result: Any +) -> dict[str, Any]: + response_args: dict[str, Any] = { + "background": solved_result.background_tasks, + } + # If status_code was set, use it, otherwise use the default from the + # response class, in the case of redirect it's 307 + current_status_code = ( + status_code if status_code else solved_result.response.status_code + ) + if current_status_code is not None: + response_args["status_code"] = current_status_code + if solved_result.response.status_code: + response_args["status_code"] = solved_result.response.status_code + return response_args + + +def get_request_handler( + dependant: Dependant, + body_field: ModelField | None = None, + status_code: int | None = None, + response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), + response_field: ModelField | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + dependency_overrides_provider: Any | None = None, + embed_body_fields: bool = False, + strict_content_type: bool | DefaultPlaceholder = Default(True), + stream_item_field: ModelField | None = None, + is_json_stream: bool = False, +) -> Callable[[Request], Coroutine[Any, Any, Response]]: + assert dependant.call is not None, "dependant.call must be a function" + is_coroutine = dependant.is_coroutine_callable + is_body_form = body_field and isinstance(body_field.field_info, params.Form) + if isinstance(response_class, DefaultPlaceholder): + actual_response_class: type[Response] = response_class.value + else: + actual_response_class = response_class + is_sse_stream = lenient_issubclass(actual_response_class, EventSourceResponse) + if isinstance(strict_content_type, DefaultPlaceholder): + actual_strict_content_type: bool = strict_content_type.value + else: + actual_strict_content_type = strict_content_type + + async def app(request: Request) -> Response: + response: Response | None = None + file_stack = request.scope.get("fastapi_middleware_astack") + assert isinstance(file_stack, AsyncExitStack), ( + "fastapi_middleware_astack not found in request scope" + ) + + # Extract endpoint context for error messages + endpoint_ctx = ( + _extract_endpoint_context(dependant.call) + if dependant.call + else EndpointContext() + ) + + if dependant.path: + # For mounted sub-apps, include the mount path prefix + mount_path = request.scope.get("root_path", "").rstrip("/") + endpoint_ctx["path"] = f"{request.method} {mount_path}{dependant.path}" + + # Read body and auto-close files + try: + body: Any = None + if body_field: + if is_body_form: + body = await request.form() + file_stack.push_async_callback(body.close) + else: + body_bytes = await request.body() + if body_bytes: + json_body: Any = Undefined + content_type_value = request.headers.get("content-type") + if not content_type_value: + if not actual_strict_content_type: + json_body = await request.json() + else: + message = email.message.Message() + message["content-type"] = content_type_value + if message.get_content_maintype() == "application": + subtype = message.get_content_subtype() + if subtype == "json" or subtype.endswith("+json"): + json_body = await request.json() + if json_body != Undefined: + body = json_body + else: + body = body_bytes + except json.JSONDecodeError as e: + validation_error = RequestValidationError( + [ + { + "type": "json_invalid", + "loc": ("body", e.pos), + "msg": "JSON decode error", + "input": {}, + "ctx": {"error": e.msg}, + } + ], + body=e.doc, + endpoint_ctx=endpoint_ctx, + ) + raise validation_error from e + except HTTPException: + # If a middleware raises an HTTPException, it should be raised again + raise + except Exception as e: + http_error = HTTPException( + status_code=400, detail="There was an error parsing the body" + ) + raise http_error from e + + # Solve dependencies and run path operation function, auto-closing dependencies + errors: list[Any] = [] + async_exit_stack = request.scope.get("fastapi_inner_astack") + assert isinstance(async_exit_stack, AsyncExitStack), ( + "fastapi_inner_astack not found in request scope" + ) + solved_result = await solve_dependencies( + request=request, + dependant=dependant, + body=cast(dict[str, Any] | FormData | bytes | None, body), + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, + ) + errors = solved_result.errors + assert dependant.call # For types + if not errors: + # Shared serializer for stream items (JSONL and SSE). + # Validates against stream_item_field when set, then + # serializes to JSON bytes. + def _serialize_data(data: Any) -> bytes: + if stream_item_field: + value, errors_ = stream_item_field.validate( + data, {}, loc=("response",) + ) + if errors_: + ctx = endpoint_ctx or EndpointContext() + raise ResponseValidationError( + errors=errors_, + body=data, + endpoint_ctx=ctx, + ) + return stream_item_field.serialize_json( + value, + include=response_model_include, + exclude=response_model_exclude, + by_alias=response_model_by_alias, + exclude_unset=response_model_exclude_unset, + exclude_defaults=response_model_exclude_defaults, + exclude_none=response_model_exclude_none, + ) + else: + data = jsonable_encoder(data) + return json.dumps(data).encode("utf-8") + + if is_sse_stream: + # Generator endpoint: stream as Server-Sent Events + gen = dependant.call(**solved_result.values) + + def _serialize_sse_item(item: Any) -> bytes: + if isinstance(item, ServerSentEvent): + # User controls the event structure. + # Serialize the data payload if present. + # For ServerSentEvent items we skip stream_item_field + # validation (the user may mix types intentionally). + if item.raw_data is not None: + data_str: str | None = item.raw_data + elif item.data is not None: + if hasattr(item.data, "model_dump_json"): + data_str = item.data.model_dump_json() + else: + data_str = json.dumps(jsonable_encoder(item.data)) + else: + data_str = None + return format_sse_event( + data_str=data_str, + event=item.event, + id=item.id, + retry=item.retry, + comment=item.comment, + ) + else: + # Plain object: validate + serialize via + # stream_item_field (if set) and wrap in data field + return format_sse_event( + data_str=_serialize_data(item).decode("utf-8") + ) + + if dependant.is_async_gen_callable: + sse_aiter: AsyncIterator[Any] = gen.__aiter__() + else: + sse_aiter = iterate_in_threadpool(gen) + + @asynccontextmanager + async def _sse_producer_cm() -> AsyncIterator[ + ObjectReceiveStream[bytes] + ]: + # Use a memory stream to decouple generator iteration + # from the keepalive timer. A producer task pulls items + # from the generator independently, so + # `anyio.fail_after` never wraps the generator's + # `__anext__` directly - avoiding CancelledError that + # would finalize the generator and also working for sync + # generators running in a thread pool. + # + # This context manager is entered on the request-scoped + # AsyncExitStack so its __aexit__ (which cancels the + # task group) is called by the exit stack after the + # streaming response completes — not by async generator + # finalization via GeneratorExit. + # Ref: https://peps.python.org/pep-0789/ + send_stream, receive_stream = anyio.create_memory_object_stream[ + bytes + ](max_buffer_size=1) + + async def _producer() -> None: + async with send_stream: + async for raw_item in sse_aiter: + await send_stream.send(_serialize_sse_item(raw_item)) + + send_keepalive, receive_keepalive = ( + anyio.create_memory_object_stream[bytes](max_buffer_size=1) + ) + + async def _keepalive_inserter() -> None: + """Read from the producer and forward to the output, + inserting keepalive comments on timeout.""" + async with send_keepalive, receive_stream: + try: + while True: + try: + with anyio.fail_after(_PING_INTERVAL): + data = await receive_stream.receive() + await send_keepalive.send(data) + except TimeoutError: + await send_keepalive.send(KEEPALIVE_COMMENT) + except anyio.EndOfStream: + pass + + async with anyio.create_task_group() as tg: + tg.start_soon(_producer) + tg.start_soon(_keepalive_inserter) + yield receive_keepalive + tg.cancel_scope.cancel() + + # Enter the SSE context manager on the request-scoped + # exit stack. The stack outlives the streaming response, + # so __aexit__ runs via proper structured teardown, not + # via GeneratorExit thrown into an async generator. + sse_receive_stream = await async_exit_stack.enter_async_context( + _sse_producer_cm() + ) + # Ensure the receive stream is closed when the exit stack + # unwinds, preventing ResourceWarning from __del__. + async_exit_stack.push_async_callback(sse_receive_stream.aclose) + + async def _sse_with_checkpoints( + stream: ObjectReceiveStream[bytes], + ) -> AsyncIterator[bytes]: + async for data in stream: + yield data + # Guarantee a checkpoint so cancellation can be + # delivered even when the producer is faster than + # the consumer and receive() never suspends. + await anyio.sleep(0) + + sse_stream_content: AsyncIterator[bytes] | Iterator[bytes] = ( + _sse_with_checkpoints(sse_receive_stream) + ) + + response = StreamingResponse( + sse_stream_content, + media_type="text/event-stream", + background=solved_result.background_tasks, + ) + response.headers["Cache-Control"] = "no-cache" + # For Nginx proxies to not buffer server sent events + response.headers["X-Accel-Buffering"] = "no" + response.headers.raw.extend(solved_result.response.headers.raw) + elif is_json_stream: + # Generator endpoint: stream as JSONL + gen = dependant.call(**solved_result.values) + + def _serialize_item(item: Any) -> bytes: + return _serialize_data(item) + b"\n" + + if dependant.is_async_gen_callable: + + async def _async_stream_jsonl() -> AsyncIterator[bytes]: + async for item in gen: + yield _serialize_item(item) + # To allow for cancellation to trigger + # Ref: https://github.com/fastapi/fastapi/issues/14680 + await anyio.sleep(0) + + jsonl_stream_content: AsyncIterator[bytes] | Iterator[bytes] = ( + _async_stream_jsonl() + ) + else: + + def _sync_stream_jsonl() -> Iterator[bytes]: + for item in gen: # ty: ignore[not-iterable] + yield _serialize_item(item) + + jsonl_stream_content = _sync_stream_jsonl() + + response = StreamingResponse( + jsonl_stream_content, + media_type="application/jsonl", + background=solved_result.background_tasks, + ) + response.headers.raw.extend(solved_result.response.headers.raw) + elif dependant.is_async_gen_callable or dependant.is_gen_callable: + # Raw streaming with explicit response_class (e.g. StreamingResponse) + gen = dependant.call(**solved_result.values) + if dependant.is_async_gen_callable: + + async def _async_stream_raw( + async_gen: AsyncIterator[Any], + ) -> AsyncIterator[Any]: + async for chunk in async_gen: + yield chunk + # To allow for cancellation to trigger + # Ref: https://github.com/fastapi/fastapi/issues/14680 + await anyio.sleep(0) + + gen = _async_stream_raw(gen) + response_args = _build_response_args( + status_code=status_code, solved_result=solved_result + ) + response = actual_response_class(content=gen, **response_args) + response.headers.raw.extend(solved_result.response.headers.raw) + else: + raw_response = await run_endpoint_function( + dependant=dependant, + values=solved_result.values, + is_coroutine=is_coroutine, + ) + if isinstance(raw_response, Response): + if raw_response.background is None: + raw_response.background = solved_result.background_tasks + response = raw_response + else: + response_args = _build_response_args( + status_code=status_code, solved_result=solved_result + ) + # Use the fast path (dump_json) when no custom response + # class was set and a response field with a TypeAdapter + # exists. Serializes directly to JSON bytes via Pydantic's + # Rust core, skipping the intermediate Python dict + + # json.dumps() step. + use_dump_json = response_field is not None and isinstance( + response_class, DefaultPlaceholder + ) + content = await serialize_response( + field=response_field, + response_content=raw_response, + include=response_model_include, + exclude=response_model_exclude, + by_alias=response_model_by_alias, + exclude_unset=response_model_exclude_unset, + exclude_defaults=response_model_exclude_defaults, + exclude_none=response_model_exclude_none, + is_coroutine=is_coroutine, + endpoint_ctx=endpoint_ctx, + dump_json=use_dump_json, + ) + if use_dump_json: + response = Response( + content=content, + media_type="application/json", + **response_args, + ) + else: + response = actual_response_class(content, **response_args) + if not is_body_allowed_for_status_code(response.status_code): + response.body = b"" + response.headers.raw.extend(solved_result.response.headers.raw) + if errors: + validation_error = RequestValidationError( + errors, body=body, endpoint_ctx=endpoint_ctx + ) + raise validation_error + + # Return response + assert response + return response + + return app + + +def get_websocket_app( + dependant: Dependant, + dependency_overrides_provider: Any | None = None, + embed_body_fields: bool = False, +) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: + async def app(websocket: WebSocket) -> None: + endpoint_ctx = ( + _extract_endpoint_context(dependant.call) + if dependant.call + else EndpointContext() + ) + if dependant.path: + # For mounted sub-apps, include the mount path prefix + mount_path = websocket.scope.get("root_path", "").rstrip("/") + endpoint_ctx["path"] = f"WS {mount_path}{dependant.path}" + async_exit_stack = websocket.scope.get("fastapi_inner_astack") + assert isinstance(async_exit_stack, AsyncExitStack), ( + "fastapi_inner_astack not found in request scope" + ) + solved_result = await solve_dependencies( + request=websocket, + dependant=dependant, + dependency_overrides_provider=dependency_overrides_provider, + async_exit_stack=async_exit_stack, + embed_body_fields=embed_body_fields, + ) + if solved_result.errors: + raise WebSocketRequestValidationError( + solved_result.errors, + endpoint_ctx=endpoint_ctx, + ) + assert dependant.call is not None, "dependant.call must be a function" + await dependant.call(**solved_result.values) + + return app + + +class APIWebSocketRoute(routing.WebSocketRoute): + def __init__( + self, + path: str, + endpoint: Callable[..., Any], + *, + name: str | None = None, + dependencies: Sequence[params.Depends] | None = None, + dependency_overrides_provider: Any | None = None, + ) -> None: + self.path = path + self.endpoint = endpoint + self.name = get_name(endpoint) if name is None else name + self.dependencies = list(dependencies or []) + self.path_regex, self.path_format, self.param_convertors = compile_path(path) + self.dependant = get_dependant( + path=self.path_format, call=self.endpoint, scope="function" + ) + for depends in self.dependencies[::-1]: + self.dependant.dependencies.insert( + 0, + get_parameterless_sub_dependant(depends=depends, path=self.path_format), + ) + self._flat_dependant = get_flat_dependant(self.dependant) + self._embed_body_fields = _should_embed_body_fields( + self._flat_dependant.body_params + ) + self.app = websocket_session( + get_websocket_app( + dependant=self.dependant, + dependency_overrides_provider=dependency_overrides_provider, + embed_body_fields=self._embed_body_fields, + ) + ) + + def matches(self, scope: Scope) -> tuple[Match, Scope]: + match, child_scope = super().matches(scope) + if match != Match.NONE: + child_scope["route"] = self + return match, child_scope + + +_FASTAPI_SCOPE_KEY = "fastapi" +_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY = "effective_route_context" +_FASTAPI_FRONTEND_PATH_KEY = "frontend_path" +_FASTAPI_INCLUDED_ROUTER_KEY = "included_router" +_effective_route_context_var: ContextVar[Any | None] = ContextVar( + "fastapi_effective_route_context", default=None +) +_SCOPE_MISSING = object() + + +class _RouteWithPath(Protocol): + path: str + + +def _get_fastapi_scope(scope: Scope) -> dict[str, Any]: + fastapi_scope = scope.setdefault(_FASTAPI_SCOPE_KEY, {}) + assert isinstance(fastapi_scope, dict) + return fastapi_scope + + +def _update_scope(scope: Scope, child_scope: Scope) -> None: + fastapi_child_scope = child_scope.get(_FASTAPI_SCOPE_KEY) + for key, value in child_scope.items(): + if key != _FASTAPI_SCOPE_KEY: + scope[key] = value + if isinstance(fastapi_child_scope, dict): + _get_fastapi_scope(scope).update(fastapi_child_scope) + + +def _get_scope_effective_route_context(scope: Scope) -> Any | None: + return scope.get(_FASTAPI_SCOPE_KEY, {}).get(_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY) + + +def _get_scope_included_router(scope: Scope) -> Any | None: + return scope.get(_FASTAPI_SCOPE_KEY, {}).get(_FASTAPI_INCLUDED_ROUTER_KEY) + + +def _restore_fastapi_scope_key(scope: Scope, key: str, previous: Any) -> None: + fastapi_scope = scope.get(_FASTAPI_SCOPE_KEY) + if not isinstance(fastapi_scope, dict): + return + if previous is _SCOPE_MISSING: + fastapi_scope.pop(key, None) + else: + fastapi_scope[key] = previous + + +class _APIRouteLike(Protocol): + path: str + endpoint: Callable[..., Any] + stream_item_type: Any | None + response_model: Any + summary: str | None + response_description: str + deprecated: bool | None + operation_id: str | None + response_model_include: IncEx | None + response_model_exclude: IncEx | None + response_model_by_alias: bool + response_model_exclude_unset: bool + response_model_exclude_defaults: bool + response_model_exclude_none: bool + include_in_schema: bool + response_class: type[Response] | DefaultPlaceholder + dependency_overrides_provider: Any | None + callbacks: list[BaseRoute] | None + openapi_extra: dict[str, Any] | None + generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder + strict_content_type: bool | DefaultPlaceholder + tags: list[str | Enum] + responses: dict[int | str, dict[str, Any]] + name: str + path_regex: Any + path_format: str + param_convertors: dict[str, Any] + methods: set[str] + unique_id: str + status_code: int | None + response_field: ModelField | None + stream_item_field: ModelField | None + dependencies: list[params.Depends] + description: str + response_fields: dict[int | str, ModelField] + dependant: Dependant + _flat_dependant: Dependant + _embed_body_fields: bool + body_field: ModelField | None + is_sse_stream: bool + is_json_stream: bool + + +def _populate_api_route_state( + route: _APIRouteLike, + path: str, + endpoint: Callable[..., Any], + *, + response_model: Any = Default(None), + status_code: int | None = None, + tags: list[str | Enum] | None = None, + dependencies: Sequence[params.Depends] | None = None, + summary: str | None = None, + description: str | None = None, + response_description: str = "Successful Response", + responses: dict[int | str, dict[str, Any]] | None = None, + deprecated: bool | None = None, + name: str | None = None, + methods: set[str] | list[str] | None = None, + operation_id: str | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + include_in_schema: bool = True, + response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), + dependency_overrides_provider: Any | None = None, + callbacks: list[BaseRoute] | None = None, + openapi_extra: dict[str, Any] | None = None, + generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = Default( + generate_unique_id + ), + strict_content_type: bool | DefaultPlaceholder = Default(True), +) -> None: + route.path = path + route.endpoint = endpoint + route.stream_item_type = None + if isinstance(response_model, DefaultPlaceholder): + return_annotation = get_typed_return_annotation(endpoint) + if lenient_issubclass(return_annotation, Response): + response_model = None + else: + stream_item = get_stream_item_type(return_annotation) + if stream_item is not None: + # Extract item type for JSONL or SSE streaming when + # response_class is DefaultPlaceholder (JSONL) or + # EventSourceResponse (SSE). + # ServerSentEvent is excluded: it's a transport + # wrapper, not a data model, so it shouldn't feed + # into validation or OpenAPI schema generation. + if ( + isinstance(response_class, DefaultPlaceholder) + or lenient_issubclass(response_class, EventSourceResponse) + ) and not lenient_issubclass(stream_item, ServerSentEvent): + route.stream_item_type = stream_item + response_model = None + else: + response_model = return_annotation + route.response_model = response_model + route.summary = summary + route.response_description = response_description + route.deprecated = deprecated + route.operation_id = operation_id + route.response_model_include = response_model_include + route.response_model_exclude = response_model_exclude + route.response_model_by_alias = response_model_by_alias + route.response_model_exclude_unset = response_model_exclude_unset + route.response_model_exclude_defaults = response_model_exclude_defaults + route.response_model_exclude_none = response_model_exclude_none + route.include_in_schema = include_in_schema + route.response_class = response_class + route.dependency_overrides_provider = dependency_overrides_provider + route.callbacks = callbacks + route.openapi_extra = openapi_extra + route.generate_unique_id_function = generate_unique_id_function + route.strict_content_type = strict_content_type + route.tags = tags or [] + route.responses = responses or {} + route.name = get_name(endpoint) if name is None else name + route.path_regex, route.path_format, route.param_convertors = compile_path(path) + if methods is None: + methods = ["GET"] + route.methods = {method.upper() for method in methods} + if isinstance(generate_unique_id_function, DefaultPlaceholder): + current_generate_unique_id: Callable[[Any], str] = ( + generate_unique_id_function.value + ) + else: + current_generate_unique_id = generate_unique_id_function + route.unique_id = route.operation_id or current_generate_unique_id(route) + # normalize enums e.g. http.HTTPStatus + if isinstance(status_code, IntEnum): + status_code = int(status_code) + route.status_code = status_code + if route.response_model: + assert is_body_allowed_for_status_code(status_code), ( + f"Status code {status_code} must not have a response body" + ) + response_name = "Response_" + route.unique_id + route.response_field = create_model_field( + name=response_name, + type_=route.response_model, + mode="serialization", + ) + else: + route.response_field = None + if route.stream_item_type: + stream_item_name = "StreamItem_" + route.unique_id + route.stream_item_field = create_model_field( + name=stream_item_name, + type_=route.stream_item_type, + mode="serialization", + ) + else: + route.stream_item_field = None + route.dependencies = list(dependencies or []) + route.description = description or inspect.cleandoc(route.endpoint.__doc__ or "") + # if a "form feed" character (page break) is found in the description text, + # truncate description text to the content preceding the first "form feed" + route.description = route.description.split("\f")[0].strip() + response_fields = {} + for additional_status_code, response in route.responses.items(): + assert isinstance(response, dict), "An additional response must be a dict" + model = response.get("model") + if model: + assert is_body_allowed_for_status_code(additional_status_code), ( + f"Status code {additional_status_code} must not have a response body" + ) + response_name = f"Response_{additional_status_code}_{route.unique_id}" + response_field = create_model_field( + name=response_name, type_=model, mode="serialization" + ) + response_fields[additional_status_code] = response_field + if response_fields: + route.response_fields = response_fields + else: + route.response_fields = {} + + assert callable(endpoint), "An endpoint must be a callable" + route.dependant = get_dependant( + path=route.path_format, call=route.endpoint, scope="function" + ) + for depends in route.dependencies[::-1]: + route.dependant.dependencies.insert( + 0, + get_parameterless_sub_dependant(depends=depends, path=route.path_format), + ) + route._flat_dependant = get_flat_dependant(route.dependant) + route._embed_body_fields = _should_embed_body_fields( + route._flat_dependant.body_params + ) + route.body_field = get_body_field( + flat_dependant=route._flat_dependant, + name=route.unique_id, + embed_body_fields=route._embed_body_fields, + ) + # Detect generator endpoints that should stream as JSONL or SSE + is_generator = ( + route.dependant.is_async_gen_callable or route.dependant.is_gen_callable + ) + route.is_sse_stream = is_generator and lenient_issubclass( + response_class, EventSourceResponse + ) + route.is_json_stream = is_generator and isinstance( + response_class, DefaultPlaceholder + ) + + +class APIRoute(routing.Route): + stream_item_type: Any | None + response_model: Any + summary: str | None + response_description: str + deprecated: bool | None + operation_id: str | None + response_model_include: IncEx | None + response_model_exclude: IncEx | None + response_model_by_alias: bool + response_model_exclude_unset: bool + response_model_exclude_defaults: bool + response_model_exclude_none: bool + include_in_schema: bool + response_class: type[Response] | DefaultPlaceholder + dependency_overrides_provider: Any | None + callbacks: list[BaseRoute] | None + openapi_extra: dict[str, Any] | None + generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder + strict_content_type: bool | DefaultPlaceholder + tags: list[str | Enum] + responses: dict[int | str, dict[str, Any]] + unique_id: str + status_code: int | None + response_field: ModelField | None + stream_item_field: ModelField | None + dependencies: list[params.Depends] + description: str + response_fields: dict[int | str, ModelField] + dependant: Dependant + _flat_dependant: Dependant + _embed_body_fields: bool + body_field: ModelField | None + is_sse_stream: bool + is_json_stream: bool + + def __init__( + self, + path: str, + endpoint: Callable[..., Any], + *, + response_model: Any = Default(None), + status_code: int | None = None, + tags: list[str | Enum] | None = None, + dependencies: Sequence[params.Depends] | None = None, + summary: str | None = None, + description: str | None = None, + response_description: str = "Successful Response", + responses: dict[int | str, dict[str, Any]] | None = None, + deprecated: bool | None = None, + name: str | None = None, + methods: set[str] | list[str] | None = None, + operation_id: str | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + include_in_schema: bool = True, + response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), + dependency_overrides_provider: Any | None = None, + callbacks: list[BaseRoute] | None = None, + openapi_extra: dict[str, Any] | None = None, + generate_unique_id_function: Callable[["APIRoute"], str] + | DefaultPlaceholder = Default(generate_unique_id), + strict_content_type: bool | DefaultPlaceholder = Default(True), + ) -> None: + _populate_api_route_state( + cast(_APIRouteLike, self), + path, + endpoint, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + name=name, + methods=methods, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + dependency_overrides_provider=dependency_overrides_provider, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + strict_content_type=strict_content_type, + ) + self.app = request_response(self.get_route_handler()) + + def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]: + route = cast(_APIRouteLike, self) + # TODO: Replace or deprecate this no-scope hook so included-route + # effective context can be passed explicitly instead of via ContextVar. + effective_context = _effective_route_context_var.get() + if effective_context is not None and effective_context.original_route is self: + route = cast(_APIRouteLike, effective_context) + return get_request_handler( + dependant=route.dependant, + body_field=route.body_field, + status_code=route.status_code, + response_class=route.response_class, + response_field=route.response_field, + response_model_include=route.response_model_include, + response_model_exclude=route.response_model_exclude, + response_model_by_alias=route.response_model_by_alias, + response_model_exclude_unset=route.response_model_exclude_unset, + response_model_exclude_defaults=route.response_model_exclude_defaults, + response_model_exclude_none=route.response_model_exclude_none, + dependency_overrides_provider=route.dependency_overrides_provider, + embed_body_fields=route._embed_body_fields, + strict_content_type=route.strict_content_type, + stream_item_field=route.stream_item_field, + is_json_stream=route.is_json_stream, + ) + + def matches(self, scope: Scope) -> tuple[Match, Scope]: + effective_context = _get_scope_effective_route_context(scope) + if effective_context is not None and effective_context.original_route is self: + match, child_scope = effective_context.matches(scope) + else: + match, child_scope = super().matches(scope) + if match != Match.NONE: + child_scope["route"] = self + return match, child_scope + + async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: + effective_context = _get_scope_effective_route_context(scope) + if effective_context is not None and effective_context.original_route is self: + methods = effective_context.methods + if methods and scope["method"] not in methods: + headers = {"Allow": ", ".join(methods)} + if "app" in scope: + raise HTTPException(status_code=405, headers=headers) + response = PlainTextResponse( + "Method Not Allowed", status_code=405, headers=headers + ) + await response(scope, receive, send) + return + token = _effective_route_context_var.set(effective_context) + try: + app = request_response(self.get_route_handler()) + finally: + _effective_route_context_var.reset(token) + await app(scope, receive, send) + return + await super().handle(scope, receive, send) + + +@dataclass +class _RouterIncludeContext: + included_router: "APIRouter" + prefix: str = "" + tags: list[str | Enum] = field(default_factory=list) + dependencies: list[params.Depends] = field(default_factory=list) + default_response_class: type[Response] | DefaultPlaceholder = field( + default_factory=lambda: Default(JSONResponse) + ) + responses: dict[int | str, dict[str, Any]] = field(default_factory=dict) + callbacks: list[BaseRoute] = field(default_factory=list) + deprecated: bool | None = None + include_in_schema: bool = True + generate_unique_id_function: Callable[[APIRoute], str] | DefaultPlaceholder = field( + default_factory=lambda: Default(generate_unique_id) + ) + strict_content_type: bool | DefaultPlaceholder = field( + default_factory=lambda: Default(True) + ) + dependency_overrides_provider: Any | None = None + + @classmethod + def for_include( + cls, + *, + parent_router: "APIRouter", + included_router: "APIRouter", + prefix: str = "", + tags: list[str | Enum] | None = None, + dependencies: Sequence[params.Depends] | None = None, + default_response_class: type[Response] | DefaultPlaceholder = Default( + JSONResponse + ), + responses: dict[int | str, dict[str, Any]] | None = None, + callbacks: list[BaseRoute] | None = None, + deprecated: bool | None = None, + include_in_schema: bool = True, + generate_unique_id_function: Callable[[APIRoute], str] + | DefaultPlaceholder = Default(generate_unique_id), + ) -> "_RouterIncludeContext": + return cls( + included_router=included_router, + prefix=parent_router.prefix + prefix, + tags=[*parent_router.tags, *(tags or [])], + dependencies=[*parent_router.dependencies, *(dependencies or [])], + default_response_class=get_value_or_default( + default_response_class, parent_router.default_response_class + ), + responses={**parent_router.responses, **(responses or {})}, + callbacks=[*parent_router.callbacks, *(callbacks or [])], + deprecated=deprecated or parent_router.deprecated, + include_in_schema=parent_router.include_in_schema and include_in_schema, + generate_unique_id_function=get_value_or_default( + generate_unique_id_function, parent_router.generate_unique_id_function + ), + strict_content_type=parent_router.strict_content_type, + dependency_overrides_provider=parent_router.dependency_overrides_provider, + ) + + def combine( + self, child_context: "_RouterIncludeContext" + ) -> "_RouterIncludeContext": + return _RouterIncludeContext( + included_router=child_context.included_router, + prefix=self.prefix + child_context.prefix, + tags=[*self.tags, *child_context.tags], + dependencies=[*self.dependencies, *child_context.dependencies], + default_response_class=get_value_or_default( + child_context.default_response_class, self.default_response_class + ), + responses={**self.responses, **child_context.responses}, + callbacks=[*self.callbacks, *child_context.callbacks], + deprecated=self.deprecated or child_context.deprecated, + include_in_schema=self.include_in_schema + and child_context.include_in_schema, + generate_unique_id_function=get_value_or_default( + child_context.generate_unique_id_function, + self.generate_unique_id_function, + ), + strict_content_type=get_value_or_default( + child_context.strict_content_type, self.strict_content_type + ), + dependency_overrides_provider=self.dependency_overrides_provider, + ) + + def path_for(self, route: _RouteWithPath) -> str: + return self.prefix + route.path + + +@dataclass +class _EffectiveRouteContext: + original_route: BaseRoute + starlette_route: BaseRoute | None = None + path: str = "" + endpoint: Callable[..., Any] | None = None + stream_item_type: Any | None = None + response_model: Any = None + summary: str | None = None + response_description: str = "Successful Response" + deprecated: bool | None = None + operation_id: str | None = None + response_model_include: IncEx | None = None + response_model_exclude: IncEx | None = None + response_model_by_alias: bool = True + response_model_exclude_unset: bool = False + response_model_exclude_defaults: bool = False + response_model_exclude_none: bool = False + include_in_schema: bool = True + response_class: type[Response] | DefaultPlaceholder = field( + default_factory=lambda: Default(JSONResponse) + ) + dependency_overrides_provider: Any | None = None + callbacks: list[BaseRoute] | None = None + openapi_extra: dict[str, Any] | None = None + generate_unique_id_function: Callable[[Any], str] | DefaultPlaceholder = field( + default_factory=lambda: Default(generate_unique_id) + ) + strict_content_type: bool | DefaultPlaceholder = field( + default_factory=lambda: Default(True) + ) + tags: list[str | Enum] = field(default_factory=list) + responses: dict[int | str, dict[str, Any]] = field(default_factory=dict) + name: str = "" + path_regex: Any = None + path_format: str = "" + param_convertors: dict[str, Any] = field(default_factory=dict) + methods: set[str] = field(default_factory=set) + unique_id: str = "" + status_code: int | None = None + response_field: ModelField | None = None + stream_item_field: ModelField | None = None + dependencies: list[params.Depends] = field(default_factory=list) + description: str = "" + response_fields: dict[int | str, ModelField] = field(default_factory=dict) + dependant: Dependant | None = None + _flat_dependant: Dependant | None = None + _embed_body_fields: bool = False + body_field: ModelField | None = None + is_sse_stream: bool = False + is_json_stream: bool = False + + @classmethod + def from_api_route( + cls, + *, + original_route: APIRoute, + include_context: _RouterIncludeContext, + ) -> "_EffectiveRouteContext": + route = cast(_APIRouteLike, original_route) + context = cls(original_route=original_route) + _populate_api_route_state( + cast(_APIRouteLike, context), + include_context.path_for(original_route), + route.endpoint, + response_model=route.response_model, + status_code=route.status_code, + tags=[*include_context.tags, *route.tags], + dependencies=[*include_context.dependencies, *route.dependencies], + summary=route.summary, + description=route.description, + response_description=route.response_description, + responses={**include_context.responses, **route.responses}, + deprecated=route.deprecated or include_context.deprecated, + methods=route.methods, + operation_id=route.operation_id, + response_model_include=route.response_model_include, + response_model_exclude=route.response_model_exclude, + response_model_by_alias=route.response_model_by_alias, + response_model_exclude_unset=route.response_model_exclude_unset, + response_model_exclude_defaults=route.response_model_exclude_defaults, + response_model_exclude_none=route.response_model_exclude_none, + include_in_schema=route.include_in_schema + and include_context.include_in_schema, + response_class=get_value_or_default( + route.response_class, + include_context.included_router.default_response_class, + include_context.default_response_class, + ), + name=route.name, + dependency_overrides_provider=include_context.dependency_overrides_provider, + callbacks=[*include_context.callbacks, *(route.callbacks or [])], + openapi_extra=route.openapi_extra, + generate_unique_id_function=get_value_or_default( + route.generate_unique_id_function, + include_context.included_router.generate_unique_id_function, + include_context.generate_unique_id_function, + ), + strict_content_type=get_value_or_default( + route.strict_content_type, + include_context.included_router.strict_content_type, + include_context.strict_content_type, + ), + ) + return context + + def matches(self, scope: Scope) -> tuple[Match, Scope]: + if not isinstance(self.original_route, APIRoute): + assert self.starlette_route is not None + return self.starlette_route.matches(scope) + if scope["type"] != "http": + return Match.NONE, {} + route_path = get_route_path(scope) + match = self.path_regex.match(route_path) + if not match: + return Match.NONE, {} + matched_params = match.groupdict() + for key, value in matched_params.items(): + matched_params[key] = self.param_convertors[key].convert(value) + path_params = dict(scope.get("path_params", {})) + path_params.update(matched_params) + child_scope = {"endpoint": self.endpoint, "path_params": path_params} + methods = self.methods + if methods and scope["method"] not in methods: + return Match.PARTIAL, child_scope + return Match.FULL, child_scope + + def url_path_for(self, name: str, /, **path_params: Any) -> Any: + if not isinstance(self.original_route, APIRoute): + assert self.starlette_route is not None + return self.starlette_route.url_path_for(name, **path_params) + seen_params = set(path_params.keys()) + param_convertors = self.param_convertors + expected_params = set(param_convertors.keys()) + if name != self.name or seen_params != expected_params: + raise routing.NoMatchFound(name, path_params) + path, remaining_params = routing.replace_params( + self.path_format, param_convertors, path_params + ) + assert not remaining_params + return URLPath(path=path, protocol="http") + + +@dataclass(frozen=True) +class RouteContext: + route: BaseRoute + _route_context: _EffectiveRouteContext | None = field(default=None, repr=False) + + @property + def original_route(self) -> BaseRoute: + if self._route_context is not None: + return self._route_context.original_route + return self.route + + @property + def _effective_route(self) -> BaseRoute | _EffectiveRouteContext: + if self._route_context is not None: + return self._route_context + return self.route + + @property + def path(self) -> str | None: + return getattr(self._effective_route, "path", None) + + @property + def path_format(self) -> str | None: + return getattr(self._effective_route, "path_format", None) + + @property + def name(self) -> str | None: + return getattr(self._effective_route, "name", None) + + @property + def methods(self) -> set[str] | None: + return getattr(self._effective_route, "methods", None) + + @property + def endpoint(self) -> Callable[..., Any] | None: + return getattr(self._effective_route, "endpoint", None) + + def __getattr__(self, name: str) -> Any: + return getattr(self._effective_route, name) + + +@dataclass +class _IncludedRouter(BaseRoute): + original_router: "APIRouter" + include_context: _RouterIncludeContext + _effective_candidates: list["_EffectiveRouteContext | _IncludedRouter"] = field( + default_factory=list + ) + _effective_candidates_version: int | None = None + _effective_low_priority_routes: list["_EffectiveRouteContext"] = field( + default_factory=list + ) + _effective_low_priority_routes_version: int | None = None + + def effective_candidates(self) -> list["_EffectiveRouteContext | _IncludedRouter"]: + routes_version = self.original_router._get_routes_version() + if routes_version == self._effective_candidates_version: + return self._effective_candidates + self._effective_candidates = [] + candidates = self.original_router.routes + for route in candidates: + if isinstance(route, _IncludedRouter): + child_context = self.include_context.combine(route.include_context) + child_branch = _IncludedRouter( + original_router=route.original_router, + include_context=child_context, + ) + self._effective_candidates.append(child_branch) + continue + route_context = self._build_effective_context(route) + if route_context is not None: + self._effective_candidates.append(route_context) + self._effective_candidates_version = routes_version + return self._effective_candidates + + def effective_low_priority_routes(self) -> list["_EffectiveRouteContext"]: + routes_version = self.original_router._get_routes_version() + if routes_version == self._effective_low_priority_routes_version: + return self._effective_low_priority_routes + self._effective_low_priority_routes = [] + for route in self.original_router._low_priority_routes: + route_context = self._build_effective_context(route) + if route_context is not None: + self._effective_low_priority_routes.append(route_context) + for route in self.original_router.routes: + if isinstance(route, _IncludedRouter): + child_context = self.include_context.combine(route.include_context) + child_branch = _IncludedRouter( + original_router=route.original_router, + include_context=child_context, + ) + self._effective_low_priority_routes.extend( + child_branch.effective_low_priority_routes() + ) + self._effective_low_priority_routes_version = routes_version + return self._effective_low_priority_routes + + def _build_effective_context( + self, route: BaseRoute + ) -> _EffectiveRouteContext | None: + if isinstance(route, APIRoute): + return _EffectiveRouteContext.from_api_route( + original_route=route, + include_context=self.include_context, + ) + if isinstance(route, _FrontendRouteGroup): + return _EffectiveRouteContext( + original_route=route, + starlette_route=route.with_prefix(self.include_context.prefix), + ) + if isinstance(route, routing.Route): + starlette_route: BaseRoute = routing.Route( + self.include_context.path_for(route), + endpoint=route.endpoint, + methods=list(route.methods or []), + name=route.name, + include_in_schema=route.include_in_schema, + ) + return _EffectiveRouteContext( + original_route=route, + starlette_route=starlette_route, + ) + if isinstance(route, APIWebSocketRoute): + starlette_route = APIWebSocketRoute( + self.include_context.path_for(route), + endpoint=route.endpoint, + name=route.name, + dependencies=[*self.include_context.dependencies, *route.dependencies], + dependency_overrides_provider=( + self.include_context.dependency_overrides_provider + ), + ) + return _EffectiveRouteContext( + original_route=route, + starlette_route=starlette_route, + ) + if isinstance(route, routing.WebSocketRoute): + starlette_route = routing.WebSocketRoute( + self.include_context.path_for(route), route.endpoint, name=route.name + ) + return _EffectiveRouteContext( + original_route=route, + starlette_route=starlette_route, + ) + if isinstance(route, routing.Mount): + starlette_route = copy.copy(route) + starlette_route.path = self.include_context.path_for(route).rstrip("/") + ( + starlette_route.path_regex, + starlette_route.path_format, + starlette_route.param_convertors, + ) = compile_path(starlette_route.path + "/{path:path}") + return _EffectiveRouteContext( + original_route=route, + starlette_route=starlette_route, + ) + if isinstance(route, routing.Host): + if self.include_context.prefix: + prefixed_app: ASGIApp = routing.Router( + routes=[routing.Mount(self.include_context.prefix, app=route.app)] + ) + else: + prefixed_app = route.app + starlette_route = routing.Host( + route.host, app=prefixed_app, name=route.name + ) + return _EffectiveRouteContext( + original_route=route, + starlette_route=starlette_route, + ) + return None + + def _match( + self, scope: Scope + ) -> tuple[Match, Scope, BaseRoute | None, _EffectiveRouteContext | None]: + partial: tuple[Scope, BaseRoute, _EffectiveRouteContext | None] | None = None + for candidate in self.effective_candidates(): + if isinstance(candidate, _IncludedRouter): + match, child_scope = candidate.matches(scope) + route: BaseRoute = candidate + route_context = None + elif isinstance(candidate.original_route, APIRoute): + route_context = candidate + fastapi_scope = _get_fastapi_scope(scope) + previous_context = fastapi_scope.get( + _FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY, _SCOPE_MISSING + ) + fastapi_scope[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = route_context + try: + match, child_scope = candidate.original_route.matches(scope) + finally: + _restore_fastapi_scope_key( + scope, _FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY, previous_context + ) + route = candidate.original_route + else: + route_context = candidate + match, child_scope = candidate.matches(scope) + route = candidate.starlette_route or candidate.original_route + if match == Match.FULL: + return match, child_scope, route, route_context + if match == Match.PARTIAL and partial is None: + partial = (child_scope, route, route_context) + if partial is not None: + child_scope, route, route_context = partial + return Match.PARTIAL, child_scope, route, route_context + return Match.NONE, {}, None, None + + def matches(self, scope: Scope) -> tuple[Match, Scope]: + fastapi_scope = _get_fastapi_scope(scope) + previous_router = fastapi_scope.get( + _FASTAPI_INCLUDED_ROUTER_KEY, _SCOPE_MISSING + ) + fastapi_scope[_FASTAPI_INCLUDED_ROUTER_KEY] = self + try: + match, _ = self.original_router.matches(scope) + return match, {} + finally: + _restore_fastapi_scope_key( + scope, _FASTAPI_INCLUDED_ROUTER_KEY, previous_router + ) + + async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: + _get_fastapi_scope(scope)[_FASTAPI_INCLUDED_ROUTER_KEY] = self + await self.original_router.handle(scope, receive, send) + + async def _handle_selected( + self, scope: Scope, receive: Receive, send: Send + ) -> None: + match, child_scope, route, effective_context = self._match(scope) + if match == Match.NONE or route is None: + await self.original_router.default(scope, receive, send) + return + scope.update(child_scope) + if isinstance(route, _IncludedRouter): + await route.handle(scope, receive, send) + return + if effective_context is not None: + _get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = ( + effective_context + ) + original_route = effective_context.original_route + if isinstance(original_route, APIRoute): + scope["route"] = original_route + await original_route.handle(scope, receive, send) + return + await route.handle(scope, receive, send) + + def effective_route_contexts(self) -> Iterator[_EffectiveRouteContext]: + for candidate in self.effective_candidates(): + if isinstance(candidate, _IncludedRouter): + yield from candidate.effective_route_contexts() + else: + yield candidate + + def url_path_for(self, name: str, /, **path_params: Any) -> Any: + for route_context in self.effective_route_contexts(): + try: + return route_context.url_path_for(name, **path_params) + except routing.NoMatchFound: + pass + raise routing.NoMatchFound(name, path_params) + + +def _iter_included_route_candidates(routes: Sequence[BaseRoute]) -> Iterator[BaseRoute]: + for route, route_context in _iter_routes_with_context(routes): + if route_context is not None and route_context.starlette_route is not None: + yield route_context.starlette_route + else: + yield route + + +def iter_route_contexts( + routes: Sequence[BaseRoute | RouteContext], +) -> Iterator[RouteContext]: + for route in routes: + if isinstance(route, RouteContext): + yield route + continue + for original_route, route_context in _iter_routes_with_context([route]): + if route_context is None: + yield RouteContext(original_route) + else: + yield RouteContext(original_route, route_context) + + +def _iter_routes_with_context( + routes: Sequence[BaseRoute], +) -> Iterator[tuple[BaseRoute, _EffectiveRouteContext | None]]: + for route in routes: + if isinstance(route, _IncludedRouter): + for route_context in route.effective_route_contexts(): + yield route_context.original_route, route_context + else: + yield route, None + + +def _normalize_frontend_path(path: str) -> str: + if not path: + raise AssertionError("A frontend path cannot be empty") + if not path.startswith("/"): + raise AssertionError("A frontend path must start with '/'") + if path != "/": + path = path.rstrip("/") + return path + + +def _join_frontend_paths(prefix: str, path: str) -> str: + if not prefix: + return path + if path == "/": + return prefix + return prefix + path + + +def _frontend_path_specificity(path: str) -> int: + if path == "/": + return 0 + return len(path) + + +def _get_resolved_absolute_path(path: str | os.PathLike[str]) -> str: + return os.path.realpath(os.fspath(path)) + + +class _FrontendStaticFiles(StaticFiles): + def __init__( + self, + *, + directory: str | os.PathLike[str], + fallback: Literal["auto", "index.html", "404.html"] | None, + check_dir: bool = True, + ) -> None: + self.fallback = fallback + if check_dir and not os.path.isdir(directory): + raise RuntimeError( + f"Frontend directory '{directory}' does not exist. " + f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'" + ) + super().__init__( + directory=directory, + html=True, + check_dir=check_dir, + follow_symlink=False, + ) + if check_dir and fallback in {"index.html", "404.html"}: + self._check_fallback_file(fallback) + + def _check_fallback_file(self, fallback: str) -> None: + _, stat_result = self.lookup_path(fallback) + if stat_result is None or not stat.S_ISREG(stat_result.st_mode): + raise RuntimeError( + f"Frontend fallback file '{fallback}' does not exist in " + f"directory '{self.directory}'. Resolved absolute directory: " + f"'{self._get_resolved_directory()}'" + ) + + def _get_resolved_directory(self) -> str: + assert self.directory is not None + return _get_resolved_absolute_path(self.directory) + + def get_path(self, scope: Scope) -> str: + path = _get_fastapi_scope(scope).get(_FASTAPI_FRONTEND_PATH_KEY, "") + assert isinstance(path, str) + return os.path.normpath(os.path.join(*path.split("/"))) + + async def get_response(self, path: str, scope: Scope) -> Response: + if scope["method"] not in ("GET", "HEAD"): + raise HTTPException(status_code=405) + + try: + full_path, stat_result = await run_in_threadpool(self.lookup_path, path) + except PermissionError: + raise HTTPException(status_code=401) from None + except OSError as exc: + if exc.errno == errno.ENAMETOOLONG: + raise HTTPException(status_code=404) from None + raise exc + except ValueError: + raise HTTPException(status_code=404) from None + + if stat_result and stat.S_ISREG(stat_result.st_mode): + return self.file_response(full_path, stat_result, scope) + + if stat_result and stat.S_ISDIR(stat_result.st_mode): + index_path = os.path.join(path, "index.html") + full_path, stat_result = await run_in_threadpool( + self.lookup_path, index_path + ) + if stat_result is not None and stat.S_ISREG(stat_result.st_mode): + if not scope["path"].endswith("/"): + url = URL(scope=scope) + url = url.replace(path=url.path + "/") + return RedirectResponse(url=url) + return self.file_response(full_path, stat_result, scope) + + if self.fallback == "404.html" or ( + self.fallback == "auto" and self._fallback_file_exists("404.html") + ): + return await self._fallback_response("404.html", scope, status_code=404) + + if ( + self.fallback == "index.html" + or (self.fallback == "auto" and self._fallback_file_exists("index.html")) + ) and _is_frontend_navigation_request(scope): + return await self._fallback_response("index.html", scope, status_code=200) + + raise HTTPException(status_code=404) + + def _fallback_file_exists(self, fallback: str) -> bool: + _, stat_result = self.lookup_path(fallback) + return stat_result is not None and stat.S_ISREG(stat_result.st_mode) + + async def _fallback_response( + self, fallback: str, scope: Scope, *, status_code: int + ) -> Response: + full_path, stat_result = await run_in_threadpool(self.lookup_path, fallback) + if stat_result is None or not stat.S_ISREG(stat_result.st_mode): + raise RuntimeError( + f"Frontend fallback file '{fallback}' does not exist in " + f"directory '{self.directory}'. Resolved absolute directory: " + f"'{self._get_resolved_directory()}'" + ) + return self.file_response( + full_path, stat_result, scope, status_code=status_code + ) + + +def _iter_accept_media_types(accept: str) -> Iterator[tuple[str, float]]: + for raw_value in accept.split(","): + message = email.message.Message() + message["content-type"] = raw_value.strip() + q = message.get_param("q") + quality = 1.0 + if isinstance(q, str): + try: + quality = float(q) + except ValueError: + pass + yield ( + f"{message.get_content_maintype()}/{message.get_content_subtype()}", + quality, + ) + + +def _is_frontend_navigation_request(scope: Scope) -> bool: + route_path = get_route_path(scope) + final_segment = route_path.rsplit("/", 1)[-1] + if os.path.splitext(final_segment)[1]: + return False + request = Request(scope) + wildcard_accepted = False + html_rejected = False + for media_type, quality in _iter_accept_media_types( + request.headers.get("accept", "") + ): + if media_type in {"text/html", "application/xhtml+xml"}: + if quality == 0: + html_rejected = True + else: + return True + elif media_type == "*/*" and quality != 0: + wildcard_accepted = True + return wildcard_accepted and not html_rejected + + +class _FrontendRoute(BaseRoute): + def __init__( + self, + path: str, + *, + directory: str | os.PathLike[str], + fallback: Literal["auto", "index.html", "404.html"] | None = "auto", + check_dir: bool = True, + ) -> None: + if fallback not in {"auto", "index.html", "404.html", None}: + raise AssertionError( + "fallback must be 'auto', 'index.html', '404.html', or None" + ) + self.path = _normalize_frontend_path(path) + self.methods = {"GET", "HEAD"} + self.app = _FrontendStaticFiles( + directory=directory, fallback=fallback, check_dir=check_dir + ) + + def with_path(self, path: str) -> "_FrontendRoute": + route = copy.copy(self) + route.path = _normalize_frontend_path(path) + return route + + def matches(self, scope: Scope) -> tuple[Match, Scope]: + if scope["type"] != "http": + return Match.NONE, {} + frontend_path = self._get_frontend_path(get_route_path(scope)) + if frontend_path is None: + return Match.NONE, {} + child_scope = {_FASTAPI_SCOPE_KEY: {_FASTAPI_FRONTEND_PATH_KEY: frontend_path}} + if scope["method"] not in self.methods: + return Match.PARTIAL, child_scope + return Match.FULL, child_scope + + def _get_frontend_path(self, route_path: str) -> str | None: + if self.path == "/": + return route_path.lstrip("/") + if route_path == self.path: + return "" + prefix = self.path + "/" + if route_path.startswith(prefix): + return route_path[len(prefix) :] + return None + + async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: + await self.app(scope, receive, send) + + def url_path_for(self, name: str, /, **path_params: Any) -> URLPath: + raise NoMatchFound(name, path_params) + + +class _FrontendRouteGroup(BaseRoute): + def __init__(self) -> None: + self.routes: list[_FrontendRoute] = [] + + def add_frontend_route( + self, + path: str, + *, + directory: str | os.PathLike[str], + fallback: Literal["auto", "index.html", "404.html"] | None = "auto", + check_dir: bool = True, + ) -> None: + self.routes.append( + _FrontendRoute( + path, + directory=directory, + fallback=fallback, + check_dir=check_dir, + ) + ) + + def with_prefix(self, prefix: str) -> "_FrontendRouteGroup": + route_group = copy.copy(self) + route_group.routes = [ + route.with_path(_join_frontend_paths(prefix, route.path)) + for route in self.routes + ] + return route_group + + def matches(self, scope: Scope) -> tuple[Match, Scope]: + match, child_scope, _ = self._match(scope) + return match, child_scope + + def _match(self, scope: Scope) -> tuple[Match, Scope, _FrontendRoute | None]: + full: tuple[Scope, _FrontendRoute] | None = None + partial: tuple[Scope, _FrontendRoute] | None = None + for route in self.routes: + match, child_scope = route.matches(scope) + if match == Match.FULL: + if full is None or _frontend_path_specificity( + route.path + ) > _frontend_path_specificity(full[1].path): + full = (child_scope, route) + elif match == Match.PARTIAL: + if partial is None or _frontend_path_specificity( + route.path + ) > _frontend_path_specificity(partial[1].path): + partial = (child_scope, route) + if full is not None: + child_scope, route = full + return Match.FULL, child_scope, route + if partial is not None: + child_scope, route = partial + return Match.PARTIAL, child_scope, route + return Match.NONE, {}, None + + async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: + match, child_scope, route = self._match(scope) + if match == Match.NONE or route is None: + raise HTTPException(status_code=404) + _update_scope(scope, child_scope) + await route.handle(scope, receive, send) + + def url_path_for(self, name: str, /, **path_params: Any) -> URLPath: + raise NoMatchFound(name, path_params) + + +class APIRouter(routing.Router): + """ + `APIRouter` class, used to group *path operations*, for example to structure + an app in multiple files. It would then be included in the `FastAPI` app, or + in another `APIRouter` (ultimately included in the app). + + Read more about it in the + [FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/). + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + + app = FastAPI() + router = APIRouter() + + + @router.get("/users/", tags=["users"]) + async def read_users(): + return [{"username": "Rick"}, {"username": "Morty"}] + + + app.include_router(router) + ``` + """ + + def __init__( + self, + *, + prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to all the *path operations* in this + router. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to all the + *path operations* in this router. + + Read more about it in the + [FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + default_response_class: Annotated[ + type[Response], + Doc( + """ + The default response class to be used. + + Read more in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#default-response-class). + """ + ), + ] = Default(JSONResponse), + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses to be shown in OpenAPI. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Additional Responses in OpenAPI](https://fastapi.tiangolo.com/advanced/additional-responses/). + + And in the + [FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + OpenAPI callbacks that should apply to all *path operations* in this + router. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + routes: Annotated[ + list[BaseRoute] | None, + Doc( + """ + **Note**: you probably shouldn't use this parameter, it is inherited + from Starlette and supported for compatibility. + + --- + + A list of routes to serve incoming HTTP and WebSocket requests. + """ + ), + deprecated( + """ + You normally wouldn't use this parameter with FastAPI, it is inherited + from Starlette and supported for compatibility. + + In FastAPI, you normally would use the *path operation methods*, + like `router.get()`, `router.post()`, etc. + """ + ), + ] = None, + redirect_slashes: Annotated[ + bool, + Doc( + """ + Whether to detect and redirect slashes in URLs when the client doesn't + use the same format. + """ + ), + ] = True, + default: Annotated[ + ASGIApp | None, + Doc( + """ + Default function handler for this router. Used to handle + 404 Not Found errors. + """ + ), + ] = None, + dependency_overrides_provider: Annotated[ + Any | None, + Doc( + """ + Only used internally by FastAPI to handle dependency overrides. + + You shouldn't need to use it. It normally points to the `FastAPI` app + object. + """ + ), + ] = None, + route_class: Annotated[ + type[APIRoute], + Doc( + """ + Custom route (*path operation*) class to be used by this router. + + Read more about it in the + [FastAPI docs for Custom Request and APIRoute class](https://fastapi.tiangolo.com/how-to/custom-request-and-route/#custom-apiroute-class-in-a-router). + """ + ), + ] = APIRoute, + on_startup: Annotated[ + Sequence[Callable[[], Any]] | None, + Doc( + """ + A list of startup event handler functions. + + You should instead use the `lifespan` handlers. + + Read more in the [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + on_shutdown: Annotated[ + Sequence[Callable[[], Any]] | None, + Doc( + """ + A list of shutdown event handler functions. + + You should instead use the `lifespan` handlers. + + Read more in the + [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + # the generic to Lifespan[AppType] is the type of the top level application + # which the router cannot know statically, so we use typing.Any + lifespan: Annotated[ + Lifespan[Any] | None, + Doc( + """ + A `Lifespan` context manager handler. This replaces `startup` and + `shutdown` functions with a single context manager. + + Read more in the + [FastAPI docs for `lifespan`](https://fastapi.tiangolo.com/advanced/events/). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark all *path operations* in this router as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + To include (or not) all the *path operations* in this router in the + generated OpenAPI. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + strict_content_type: Annotated[ + bool, + Doc( + """ + Enable strict checking for request Content-Type headers. + + When `True` (the default), requests with a body that do not include + a `Content-Type` header will **not** be parsed as JSON. + + This prevents potential cross-site request forgery (CSRF) attacks + that exploit the browser's ability to send requests without a + Content-Type header, bypassing CORS preflight checks. In particular + applicable for apps that need to be run locally (in localhost). + + When `False`, requests without a `Content-Type` header will have + their body parsed as JSON, which maintains compatibility with + certain clients that don't send `Content-Type` headers. + + Read more about it in the + [FastAPI docs for Strict Content-Type](https://fastapi.tiangolo.com/advanced/strict-content-type/). + """ + ), + ] = Default(True), + ) -> None: + # Determine the lifespan context to use + if lifespan is None: + # Use the default lifespan that runs on_startup/on_shutdown handlers + lifespan_context: Lifespan[Any] = _DefaultLifespan(self) + elif inspect.isasyncgenfunction(lifespan): + lifespan_context = asynccontextmanager(lifespan) + elif inspect.isgeneratorfunction(lifespan): + lifespan_context = _wrap_gen_lifespan_context(lifespan) + else: + lifespan_context = lifespan + self.lifespan_context = lifespan_context + + super().__init__( + routes=routes, + redirect_slashes=redirect_slashes, + default=default, + lifespan=lifespan_context, + ) + if prefix: + assert prefix.startswith("/"), "A path prefix must start with '/'" + assert not prefix.endswith("/"), ( + "A path prefix must not end with '/', as the routes will start with '/'" + ) + + # Handle on_startup/on_shutdown locally since Starlette removed support + # Ref: https://github.com/Kludex/starlette/pull/3117 + # TODO: deprecate this once the lifespan (or alternative) interface is improved + self.on_startup: list[Callable[[], Any]] = ( + [] if on_startup is None else list(on_startup) + ) + self.on_shutdown: list[Callable[[], Any]] = ( + [] if on_shutdown is None else list(on_shutdown) + ) + + self.prefix = prefix + self.tags: list[str | Enum] = tags or [] + self.dependencies = list(dependencies or []) + self.deprecated = deprecated + self.include_in_schema = include_in_schema + self.responses = responses or {} + self.callbacks = callbacks or [] + self.dependency_overrides_provider = dependency_overrides_provider + self.route_class = route_class + self.default_response_class = default_response_class + self.generate_unique_id_function = generate_unique_id_function + self.strict_content_type = strict_content_type + self._routes_version = 0 + self._low_priority_routes: list[BaseRoute] = [] + self._frontend_routes: _FrontendRouteGroup | None = None + + def _mark_routes_changed(self) -> None: + self._routes_version += 1 + + def _get_routes_version(self, seen: set[int] | None = None) -> int: + if seen is None: + seen = set() + router_id = id(self) + if router_id in seen: + return self._routes_version + seen.add(router_id) + version = self._routes_version + for route in self.routes: + if isinstance(route, _IncludedRouter): + version += route.original_router._get_routes_version(seen) + return version + + def _contains_router( + self, router: "APIRouter", seen: set[int] | None = None + ) -> bool: + if seen is None: + seen = set() + router_id = id(self) + if router_id in seen: + return False + seen.add(router_id) + for route in self.routes: + if not isinstance(route, _IncludedRouter): + continue + if route.original_router is router: + return True + if route.original_router._contains_router(router, seen): + return True + return False + + def add_route( + self, + path: str, + endpoint: Callable[[Request], Awaitable[Response] | Response], + methods: Collection[str] | None = None, + name: str | None = None, + include_in_schema: bool = True, + ) -> None: + super().add_route( + path, + endpoint, + methods=methods, + name=name, + include_in_schema=include_in_schema, + ) + self._mark_routes_changed() + + def add_websocket_route( + self, + path: str, + endpoint: Callable[[WebSocket], Awaitable[None]], + name: str | None = None, + ) -> None: + super().add_websocket_route(path, endpoint, name=name) + self._mark_routes_changed() + + def frontend( + self, + path: Annotated[ + str, + Doc( + """ + The URL path prefix where the frontend build should be served. + """ + ), + ], + *, + directory: Annotated[ + str | os.PathLike[str], + Doc( + """ + The directory containing the static frontend build output. + """ + ), + ], + fallback: Annotated[ + Literal["auto", "index.html", "404.html"] | None, + Doc( + """ + The fallback file behavior for missing frontend paths. + """ + ), + ] = "auto", + check_dir: Annotated[ + bool, + Doc( + """ + Check that the frontend directory exists when the app is created. + """ + ), + ] = True, + ) -> None: + """ + Serve a static frontend build as low-priority routes. + + Use this for frontend tools that build static files into a directory, + such as `dist`. **FastAPI** path operations are checked first, and + the frontend files are checked only if no normal route matched. + + A typical project could look like this: + + ```text + . + ├── pyproject.toml + ├── app + │ ├── __init__.py + │ └── main.py + └── dist + ├── index.html + └── assets + └── app.js + ``` + + Then in `app/main.py`: + + ```python + from fastapi import APIRouter, FastAPI + + app = FastAPI() + router = APIRouter() + router.frontend("/", directory="dist") + app.include_router(router) + ``` + """ + normalized_path = _normalize_frontend_path(path) + if self._frontend_routes is None: + self._frontend_routes = _FrontendRouteGroup() + self._low_priority_routes.append(self._frontend_routes) + self._frontend_routes.add_frontend_route( + _join_frontend_paths(self.prefix, normalized_path), + directory=directory, + fallback=fallback, + check_dir=check_dir, + ) + self._mark_routes_changed() + + async def app(self, scope: Scope, receive: Receive, send: Send) -> None: + assert scope["type"] in ("http", "websocket", "lifespan") + + if "router" not in scope: + scope["router"] = self + + if scope["type"] == "lifespan": + await self.lifespan(scope, receive, send) + return + + partial: tuple[BaseRoute, Scope] | None = None + for route in self.routes: + match, child_scope = route.matches(scope) + if match == Match.FULL: + scope.update(child_scope) + await route.handle(scope, receive, send) + return + if match == Match.PARTIAL and partial is None: + partial = (route, child_scope) + + if partial is not None: + route, child_scope = partial + scope.update(child_scope) + await route.handle(scope, receive, send) + return + + route_path = get_route_path(scope) + if scope["type"] == "http" and self.redirect_slashes and route_path != "/": + redirect_scope = dict(scope) + if route_path.endswith("/"): + redirect_scope["path"] = redirect_scope["path"].rstrip("/") + else: + redirect_scope["path"] = redirect_scope["path"] + "/" + + for route in self.routes: + match, _ = route.matches(redirect_scope) + if match != Match.NONE: + redirect_url = URL(scope=redirect_scope) + response = RedirectResponse(url=str(redirect_url)) + await response(scope, receive, send) + return + + ( + low_priority_match, + low_priority_scope, + low_priority_route, + low_priority_context, + ) = self._match_low_priority(scope) + if low_priority_match != Match.NONE and low_priority_route is not None: + _update_scope(scope, low_priority_scope) + if low_priority_context is not None: + _get_fastapi_scope(scope)[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = ( + low_priority_context + ) + original_route = low_priority_context.original_route + if isinstance(original_route, APIRoute): + scope["route"] = original_route + await original_route.handle(scope, receive, send) + return + await low_priority_route.handle(scope, receive, send) + return + + await self.default(scope, receive, send) + + async def handle(self, scope: Scope, receive: Receive, send: Send) -> None: + included_router = _get_scope_included_router(scope) + if ( + isinstance(included_router, _IncludedRouter) + and included_router.original_router is self + ): + await included_router._handle_selected(scope, receive, send) + return + await self.app(scope, receive, send) + + def matches(self, scope: Scope) -> tuple[Match, Scope]: + included_router = _get_scope_included_router(scope) + if ( + isinstance(included_router, _IncludedRouter) + and included_router.original_router is self + ): + match, child_scope, _, _ = included_router._match(scope) + return match, child_scope + return Match.NONE, {} + + def _iter_low_priority_routes( + self, + ) -> Iterator[BaseRoute | _EffectiveRouteContext]: + yield from self._low_priority_routes + for route in self.routes: + if isinstance(route, _IncludedRouter): + yield from route.effective_low_priority_routes() + + def _match_low_priority( + self, scope: Scope + ) -> tuple[Match, Scope, BaseRoute | None, _EffectiveRouteContext | None]: + full: tuple[Scope, BaseRoute, _EffectiveRouteContext | None] | None = None + partial: tuple[Scope, BaseRoute, _EffectiveRouteContext | None] | None = None + for candidate in self._iter_low_priority_routes(): + route: BaseRoute + if isinstance(candidate, _EffectiveRouteContext): + route_context: _EffectiveRouteContext | None = candidate + original_route = candidate.original_route + if isinstance(original_route, APIRoute): + fastapi_scope = _get_fastapi_scope(scope) + previous_context = fastapi_scope.get( + _FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY, _SCOPE_MISSING + ) + fastapi_scope[_FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY] = route_context + try: + match, child_scope = original_route.matches(scope) + finally: + _restore_fastapi_scope_key( + scope, + _FASTAPI_EFFECTIVE_ROUTE_CONTEXT_KEY, + previous_context, + ) + route = original_route + else: + match, child_scope = candidate.matches(scope) + route = candidate.starlette_route or original_route + else: + route_context = None + match, child_scope = candidate.matches(scope) + route = candidate + if match == Match.FULL: + if full is None: + full = (child_scope, route, route_context) + elif match == Match.PARTIAL: + if partial is None: + partial = (child_scope, route, route_context) + if full is not None: + child_scope, route, route_context = full + return Match.FULL, child_scope, route, route_context + if partial is not None: + child_scope, route, route_context = partial + return Match.PARTIAL, child_scope, route, route_context + return Match.NONE, {}, None, None + + def route( + self, + path: str, + methods: Collection[str] | None = None, + name: str | None = None, + include_in_schema: bool = True, + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_route( + path, + func, + methods=methods, + name=name, + include_in_schema=include_in_schema, + ) + return func + + return decorator + + def add_api_route( + self, + path: str, + endpoint: Callable[..., Any], + *, + response_model: Any = Default(None), + status_code: int | None = None, + tags: list[str | Enum] | None = None, + dependencies: Sequence[params.Depends] | None = None, + summary: str | None = None, + description: str | None = None, + response_description: str = "Successful Response", + responses: dict[int | str, dict[str, Any]] | None = None, + deprecated: bool | None = None, + methods: set[str] | list[str] | None = None, + operation_id: str | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + include_in_schema: bool = True, + response_class: type[Response] | DefaultPlaceholder = Default(JSONResponse), + name: str | None = None, + route_class_override: type[APIRoute] | None = None, + callbacks: list[BaseRoute] | None = None, + openapi_extra: dict[str, Any] | None = None, + generate_unique_id_function: Callable[[APIRoute], str] + | DefaultPlaceholder = Default(generate_unique_id), + strict_content_type: bool | DefaultPlaceholder = Default(True), + ) -> None: + route_class = route_class_override or self.route_class + responses = responses or {} + combined_responses = {**self.responses, **responses} + current_response_class = get_value_or_default( + response_class, self.default_response_class + ) + current_tags = self.tags.copy() + if tags: + current_tags.extend(tags) + current_dependencies = self.dependencies.copy() + if dependencies: + current_dependencies.extend(dependencies) + current_callbacks = self.callbacks.copy() + if callbacks: + current_callbacks.extend(callbacks) + current_generate_unique_id = get_value_or_default( + generate_unique_id_function, self.generate_unique_id_function + ) + route = route_class( + self.prefix + path, + endpoint=endpoint, + response_model=response_model, + status_code=status_code, + tags=current_tags, + dependencies=current_dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=combined_responses, + deprecated=deprecated or self.deprecated, + methods=methods, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema and self.include_in_schema, + response_class=current_response_class, + name=name, + dependency_overrides_provider=self.dependency_overrides_provider, + callbacks=current_callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=current_generate_unique_id, + strict_content_type=get_value_or_default( + strict_content_type, self.strict_content_type + ), + ) + self.routes.append(route) + self._mark_routes_changed() + + def api_route( + self, + path: str, + *, + response_model: Any = Default(None), + status_code: int | None = None, + tags: list[str | Enum] | None = None, + dependencies: Sequence[params.Depends] | None = None, + summary: str | None = None, + description: str | None = None, + response_description: str = "Successful Response", + responses: dict[int | str, dict[str, Any]] | None = None, + deprecated: bool | None = None, + methods: list[str] | None = None, + operation_id: str | None = None, + response_model_include: IncEx | None = None, + response_model_exclude: IncEx | None = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + include_in_schema: bool = True, + response_class: type[Response] = Default(JSONResponse), + name: str | None = None, + callbacks: list[BaseRoute] | None = None, + openapi_extra: dict[str, Any] | None = None, + generate_unique_id_function: Callable[[APIRoute], str] = Default( + generate_unique_id + ), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_api_route( + path, + func, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=methods, + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + return func + + return decorator + + def add_api_websocket_route( + self, + path: str, + endpoint: Callable[..., Any], + name: str | None = None, + *, + dependencies: Sequence[params.Depends] | None = None, + ) -> None: + current_dependencies = self.dependencies.copy() + if dependencies: + current_dependencies.extend(dependencies) + + route = APIWebSocketRoute( + self.prefix + path, + endpoint=endpoint, + name=name, + dependencies=current_dependencies, + dependency_overrides_provider=self.dependency_overrides_provider, + ) + self.routes.append(route) + self._mark_routes_changed() + + def websocket( + self, + path: Annotated[ + str, + Doc( + """ + WebSocket path. + """ + ), + ], + name: Annotated[ + str | None, + Doc( + """ + A name for the WebSocket. Only used internally. + """ + ), + ] = None, + *, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be used for this + WebSocket. + + Read more about it in the + [FastAPI docs for WebSockets](https://fastapi.tiangolo.com/advanced/websockets/). + """ + ), + ] = None, + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Decorate a WebSocket function. + + Read more about it in the + [FastAPI docs for WebSockets](https://fastapi.tiangolo.com/advanced/websockets/). + + **Example** + + ## Example + + ```python + from fastapi import APIRouter, FastAPI, WebSocket + + app = FastAPI() + router = APIRouter() + + @router.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + await websocket.accept() + while True: + data = await websocket.receive_text() + await websocket.send_text(f"Message text was: {data}") + + app.include_router(router) + ``` + """ + + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_api_websocket_route( + path, func, name=name, dependencies=dependencies + ) + return func + + return decorator + + def websocket_route( + self, path: str, name: str | None = None + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_websocket_route(path, func, name=name) + return func + + return decorator + + def include_router( + self, + router: Annotated["APIRouter", Doc("The `APIRouter` to include.")], + *, + prefix: Annotated[str, Doc("An optional path prefix for the router.")] = "", + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to all the *path operations* in this + router. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to all the + *path operations* in this router. + + Read more about it in the + [FastAPI docs for Bigger Applications - Multiple Files](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + default_response_class: Annotated[ + type[Response], + Doc( + """ + The default response class to be used. + + Read more in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#default-response-class). + """ + ), + ] = Default(JSONResponse), + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses to be shown in OpenAPI. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Additional Responses in OpenAPI](https://fastapi.tiangolo.com/advanced/additional-responses/). + + And in the + [FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/#include-an-apirouter-with-a-custom-prefix-tags-responses-and-dependencies). + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + OpenAPI callbacks that should apply to all *path operations* in this + router. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark all *path operations* in this router as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include (or not) all the *path operations* in this router in the + generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = True, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> None: + """ + Include another `APIRouter` in the same current `APIRouter`. + + Read more about it in the + [FastAPI docs for Bigger Applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/). + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + + app = FastAPI() + internal_router = APIRouter() + users_router = APIRouter() + + @users_router.get("/users/") + def read_users(): + return [{"name": "Rick"}, {"name": "Morty"}] + + internal_router.include_router(users_router) + app.include_router(internal_router) + ``` + """ + assert self is not router, ( + "Cannot include the same APIRouter instance into itself. " + "Did you mean to include a different router?" + ) + assert not router._contains_router(self), ( + "Cannot include an APIRouter instance that already includes this router. " + "Did you mean to include a different router?" + ) + if prefix: + assert prefix.startswith("/"), "A path prefix must start with '/'" + assert not prefix.endswith("/"), ( + "A path prefix must not end with '/', as the routes will start with '/'" + ) + else: + for route, route_context in _iter_routes_with_context(router.routes): + if route_context is None: + path = getattr(route, "path", None) + name = getattr(route, "name", "unknown") + elif route_context.starlette_route is not None: + path = getattr(route_context.starlette_route, "path", None) + name = getattr(route_context.starlette_route, "name", "unknown") + else: + path = route_context.path + name = route_context.name + if path is not None and not path: + raise FastAPIError( + f"Prefix and path cannot be both empty (path operation: {name})" + ) + include_context = _RouterIncludeContext.for_include( + parent_router=self, + included_router=router, + prefix=prefix, + tags=tags, + dependencies=dependencies, + default_response_class=default_response_class, + responses=responses, + callbacks=callbacks, + deprecated=deprecated, + include_in_schema=include_in_schema, + generate_unique_id_function=generate_unique_id_function, + ) + self.routes.append( + _IncludedRouter(original_router=router, include_context=include_context) + ) + self._mark_routes_changed() + for handler in router.on_startup: + self.add_event_handler("startup", handler) + for handler in router.on_shutdown: + self.add_event_handler("shutdown", handler) + self.lifespan_context = _merge_lifespan_context( + self.lifespan_context, + router.lifespan_context, + ) + + def get( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP GET operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + + app = FastAPI() + router = APIRouter() + + @router.get("/items/") + def read_items(): + return [{"name": "Empanada"}, {"name": "Arepa"}] + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["GET"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def put( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP PUT operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + router = APIRouter() + + @router.put("/items/{item_id}") + def replace_item(item_id: str, item: Item): + return {"message": "Item replaced", "id": item_id} + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["PUT"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def post( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP POST operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + router = APIRouter() + + @router.post("/items/") + def create_item(item: Item): + return {"message": "Item created"} + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["POST"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def delete( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP DELETE operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + + app = FastAPI() + router = APIRouter() + + @router.delete("/items/{item_id}") + def delete_item(item_id: str): + return {"message": "Item deleted"} + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["DELETE"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def options( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP OPTIONS operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + + app = FastAPI() + router = APIRouter() + + @router.options("/items/") + def get_item_options(): + return {"additions": ["Aji", "Guacamole"]} + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["OPTIONS"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def head( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP HEAD operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + router = APIRouter() + + @router.head("/items/", status_code=204) + def get_items_headers(response: Response): + response.headers["X-Cat-Dog"] = "Alone in the world" + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["HEAD"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def patch( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP PATCH operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + router = APIRouter() + + @router.patch("/items/") + def update_item(item: Item): + return {"message": "Item updated in place"} + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["PATCH"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + def trace( + self, + path: Annotated[ + str, + Doc( + """ + The URL path to be used for this *path operation*. + + For example, in `http://example.com/items`, the path is `/items`. + """ + ), + ], + *, + response_model: Annotated[ + Any, + Doc( + """ + The type to use for the response. + + It could be any valid Pydantic *field* type. So, it doesn't have to + be a Pydantic model, it could be other things, like a `list`, `dict`, + etc. + + It will be used for: + + * Documentation: the generated OpenAPI (and the UI at `/docs`) will + show it as the response (JSON Schema). + * Serialization: you could return an arbitrary object and the + `response_model` would be used to serialize that object into the + corresponding JSON. + * Filtering: the JSON sent to the client will only contain the data + (fields) defined in the `response_model`. If you returned an object + that contains an attribute `password` but the `response_model` does + not include that field, the JSON sent to the client would not have + that `password`. + * Validation: whatever you return will be serialized with the + `response_model`, converting any data as necessary to generate the + corresponding JSON. But if the data in the object returned is not + valid, that would mean a violation of the contract with the client, + so it's an error from the API developer. So, FastAPI will raise an + error and return a 500 error code (Internal Server Error). + + Read more about it in the + [FastAPI docs for Response Model](https://fastapi.tiangolo.com/tutorial/response-model/). + """ + ), + ] = Default(None), + status_code: Annotated[ + int | None, + Doc( + """ + The default status code to be used for the response. + + You could override the status code by returning a response directly. + + Read more about it in the + [FastAPI docs for Response Status Code](https://fastapi.tiangolo.com/tutorial/response-status-code/). + """ + ), + ] = None, + tags: Annotated[ + list[str | Enum] | None, + Doc( + """ + A list of tags to be applied to the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/#tags). + """ + ), + ] = None, + dependencies: Annotated[ + Sequence[params.Depends] | None, + Doc( + """ + A list of dependencies (using `Depends()`) to be applied to the + *path operation*. + + Read more about it in the + [FastAPI docs for Dependencies in path operation decorators](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-in-path-operation-decorators/). + """ + ), + ] = None, + summary: Annotated[ + str | None, + Doc( + """ + A summary for the *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + A description for the *path operation*. + + If not provided, it will be extracted automatically from the docstring + of the *path operation function*. + + It can contain Markdown. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Path Operation Configuration](https://fastapi.tiangolo.com/tutorial/path-operation-configuration/). + """ + ), + ] = None, + response_description: Annotated[ + str, + Doc( + """ + The description for the default response. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = "Successful Response", + responses: Annotated[ + dict[int | str, dict[str, Any]] | None, + Doc( + """ + Additional responses that could be returned by this *path operation*. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + deprecated: Annotated[ + bool | None, + Doc( + """ + Mark this *path operation* as deprecated. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + operation_id: Annotated[ + str | None, + Doc( + """ + Custom operation ID to be used by this *path operation*. + + By default, it is generated automatically. + + If you provide a custom operation ID, you need to make sure it is + unique for the whole API. + + You can customize the + operation ID generation with the parameter + `generate_unique_id_function` in the `FastAPI` class. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = None, + response_model_include: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to include only certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_exclude: Annotated[ + IncEx | None, + Doc( + """ + Configuration passed to Pydantic to exclude certain fields in the + response data. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = None, + response_model_by_alias: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response model + should be serialized by alias when an alias is used. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_include-and-response_model_exclude). + """ + ), + ] = True, + response_model_exclude_unset: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that were not set and + have their default values. This is different from + `response_model_exclude_defaults` in that if the fields are set, + they will be included in the response, even if the value is the same + as the default. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_defaults: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data + should have all the fields, including the ones that have the same value + as the default. This is different from `response_model_exclude_unset` + in that if the fields are set but contain the same default values, + they will be excluded from the response. + + When `True`, default values are omitted from the response. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#use-the-response_model_exclude_unset-parameter). + """ + ), + ] = False, + response_model_exclude_none: Annotated[ + bool, + Doc( + """ + Configuration passed to Pydantic to define if the response data should + exclude fields set to `None`. + + This is much simpler (less smart) than `response_model_exclude_unset` + and `response_model_exclude_defaults`. You probably want to use one of + those two instead of this one, as those allow returning `None` values + when it makes sense. + + Read more about it in the + [FastAPI docs for Response Model - Return Type](https://fastapi.tiangolo.com/tutorial/response-model/#response_model_exclude_none). + """ + ), + ] = False, + include_in_schema: Annotated[ + bool, + Doc( + """ + Include this *path operation* in the generated OpenAPI schema. + + This affects the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for Query Parameters and String Validations](https://fastapi.tiangolo.com/tutorial/query-params-str-validations/#exclude-parameters-from-openapi). + """ + ), + ] = True, + response_class: Annotated[ + type[Response], + Doc( + """ + Response class to be used for this *path operation*. + + This will not be used if you return a response directly. + + Read more about it in the + [FastAPI docs for Custom Response - HTML, Stream, File, others](https://fastapi.tiangolo.com/advanced/custom-response/#redirectresponse). + """ + ), + ] = Default(JSONResponse), + name: Annotated[ + str | None, + Doc( + """ + Name for this *path operation*. Only used internally. + """ + ), + ] = None, + callbacks: Annotated[ + list[BaseRoute] | None, + Doc( + """ + List of *path operations* that will be used as OpenAPI callbacks. + + This is only for OpenAPI documentation, the callbacks won't be used + directly. + + It will be added to the generated OpenAPI (e.g. visible at `/docs`). + + Read more about it in the + [FastAPI docs for OpenAPI Callbacks](https://fastapi.tiangolo.com/advanced/openapi-callbacks/). + """ + ), + ] = None, + openapi_extra: Annotated[ + dict[str, Any] | None, + Doc( + """ + Extra metadata to be included in the OpenAPI schema for this *path + operation*. + + Read more about it in the + [FastAPI docs for Path Operation Advanced Configuration](https://fastapi.tiangolo.com/advanced/path-operation-advanced-configuration/#custom-openapi-path-operation-schema). + """ + ), + ] = None, + generate_unique_id_function: Annotated[ + Callable[[APIRoute], str], + Doc( + """ + Customize the function used to generate unique IDs for the *path + operations* shown in the generated OpenAPI. + + This is particularly useful when automatically generating clients or + SDKs for your API. + + Read more about it in the + [FastAPI docs about how to Generate Clients](https://fastapi.tiangolo.com/advanced/generate-clients/#custom-generate-unique-id-function). + """ + ), + ] = Default(generate_unique_id), + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add a *path operation* using an HTTP TRACE operation. + + ## Example + + ```python + from fastapi import APIRouter, FastAPI + from pydantic import BaseModel + + class Item(BaseModel): + name: str + description: str | None = None + + app = FastAPI() + router = APIRouter() + + @router.trace("/items/{item_id}") + def trace_item(item_id: str): + return None + + app.include_router(router) + ``` + """ + return self.api_route( + path=path, + response_model=response_model, + status_code=status_code, + tags=tags, + dependencies=dependencies, + summary=summary, + description=description, + response_description=response_description, + responses=responses, + deprecated=deprecated, + methods=["TRACE"], + operation_id=operation_id, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + include_in_schema=include_in_schema, + response_class=response_class, + name=name, + callbacks=callbacks, + openapi_extra=openapi_extra, + generate_unique_id_function=generate_unique_id_function, + ) + + # TODO: remove this once the lifespan (or alternative) interface is improved + async def _startup(self) -> None: + """ + Run any `.on_startup` event handlers. + + This method is kept for backward compatibility after Starlette removed + support for on_startup/on_shutdown handlers. + + Ref: https://github.com/Kludex/starlette/pull/3117 + """ + for handler in self.on_startup: + if is_async_callable(handler): + await handler() + else: + handler() + + # TODO: remove this once the lifespan (or alternative) interface is improved + async def _shutdown(self) -> None: + """ + Run any `.on_shutdown` event handlers. + + This method is kept for backward compatibility after Starlette removed + support for on_startup/on_shutdown handlers. + + Ref: https://github.com/Kludex/starlette/pull/3117 + """ + for handler in self.on_shutdown: + if is_async_callable(handler): + await handler() + else: + handler() + + # TODO: remove this once the lifespan (or alternative) interface is improved + def add_event_handler( + self, + event_type: str, + func: Callable[[], Any], + ) -> None: + """ + Add an event handler function for startup or shutdown. + + This method is kept for backward compatibility after Starlette removed + support for on_startup/on_shutdown handlers. + + Ref: https://github.com/Kludex/starlette/pull/3117 + """ + assert event_type in ("startup", "shutdown") + if event_type == "startup": + self.on_startup.append(func) + else: + self.on_shutdown.append(func) + + @deprecated( + """ + on_event is deprecated, use lifespan event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/). + """ + ) + def on_event( + self, + event_type: Annotated[ + str, + Doc( + """ + The type of event. `startup` or `shutdown`. + """ + ), + ], + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """ + Add an event handler for the router. + + `on_event` is deprecated, use `lifespan` event handlers instead. + + Read more about it in the + [FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/#alternative-events-deprecated). + """ + + def decorator(func: DecoratedCallable) -> DecoratedCallable: + self.add_event_handler(event_type, func) + return func + + return decorator diff --git a/server/venv/Lib/site-packages/fastapi/security/__init__.py b/server/venv/Lib/site-packages/fastapi/security/__init__.py new file mode 100644 index 0000000..3aa6bf2 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/security/__init__.py @@ -0,0 +1,15 @@ +from .api_key import APIKeyCookie as APIKeyCookie +from .api_key import APIKeyHeader as APIKeyHeader +from .api_key import APIKeyQuery as APIKeyQuery +from .http import HTTPAuthorizationCredentials as HTTPAuthorizationCredentials +from .http import HTTPBasic as HTTPBasic +from .http import HTTPBasicCredentials as HTTPBasicCredentials +from .http import HTTPBearer as HTTPBearer +from .http import HTTPDigest as HTTPDigest +from .oauth2 import OAuth2 as OAuth2 +from .oauth2 import OAuth2AuthorizationCodeBearer as OAuth2AuthorizationCodeBearer +from .oauth2 import OAuth2PasswordBearer as OAuth2PasswordBearer +from .oauth2 import OAuth2PasswordRequestForm as OAuth2PasswordRequestForm +from .oauth2 import OAuth2PasswordRequestFormStrict as OAuth2PasswordRequestFormStrict +from .oauth2 import SecurityScopes as SecurityScopes +from .open_id_connect_url import OpenIdConnect as OpenIdConnect diff --git a/server/venv/Lib/site-packages/fastapi/security/api_key.py b/server/venv/Lib/site-packages/fastapi/security/api_key.py new file mode 100644 index 0000000..83a4585 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/security/api_key.py @@ -0,0 +1,320 @@ +from typing import Annotated + +from annotated_doc import Doc +from fastapi.openapi.models import APIKey, APIKeyIn +from fastapi.security.base import SecurityBase +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED + + +class APIKeyBase(SecurityBase): + model: APIKey + + def __init__( + self, + location: APIKeyIn, + name: str, + description: str | None, + scheme_name: str | None, + auto_error: bool, + ): + self.auto_error = auto_error + + self.model: APIKey = APIKey( + **{"in": location}, # ty: ignore[invalid-argument-type] + name=name, + description=description, + ) + self.scheme_name = scheme_name or self.__class__.__name__ + + def make_not_authenticated_error(self) -> HTTPException: + """ + The WWW-Authenticate header is not standardized for API Key authentication but + the HTTP specification requires that an error of 401 "Unauthorized" must + include a WWW-Authenticate header. + + Ref: https://datatracker.ietf.org/doc/html/rfc9110#name-401-unauthorized + + For this, this method sends a custom challenge `APIKey`. + """ + return HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "APIKey"}, + ) + + def check_api_key(self, api_key: str | None) -> str | None: + if not api_key: + if self.auto_error: + raise self.make_not_authenticated_error() + return None + return api_key + + +class APIKeyQuery(APIKeyBase): + """ + API key authentication using a query parameter. + + This defines the name of the query parameter that should be provided in the request + with the API key and integrates that into the OpenAPI documentation. It extracts + the key value sent in the query parameter automatically and provides it as the + dependency result. But it doesn't define how to send that API key to the client. + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be a string containing the key value. + + ## Example + + ```python + from fastapi import Depends, FastAPI + from fastapi.security import APIKeyQuery + + app = FastAPI() + + query_scheme = APIKeyQuery(name="api_key") + + + @app.get("/items/") + async def read_items(api_key: str = Depends(query_scheme)): + return {"api_key": api_key} + ``` + """ + + def __init__( + self, + *, + name: Annotated[ + str, + Doc("Query parameter name."), + ], + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the query parameter is not provided, `APIKeyQuery` will + automatically cancel the request and send the client an error. + + If `auto_error` is set to `False`, when the query parameter is not + available, instead of erroring out, the dependency result will be + `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in a query + parameter or in an HTTP Bearer token). + """ + ), + ] = True, + ): + super().__init__( + location=APIKeyIn.query, + name=name, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> str | None: + api_key = request.query_params.get(self.model.name) + return self.check_api_key(api_key) + + +class APIKeyHeader(APIKeyBase): + """ + API key authentication using a header. + + This defines the name of the header that should be provided in the request with + the API key and integrates that into the OpenAPI documentation. It extracts + the key value sent in the header automatically and provides it as the dependency + result. But it doesn't define how to send that key to the client. + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be a string containing the key value. + + ## Example + + ```python + from fastapi import Depends, FastAPI + from fastapi.security import APIKeyHeader + + app = FastAPI() + + header_scheme = APIKeyHeader(name="x-key") + + + @app.get("/items/") + async def read_items(key: str = Depends(header_scheme)): + return {"key": key} + ``` + """ + + def __init__( + self, + *, + name: Annotated[str, Doc("Header name.")], + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the header is not provided, `APIKeyHeader` will + automatically cancel the request and send the client an error. + + If `auto_error` is set to `False`, when the header is not available, + instead of erroring out, the dependency result will be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in a header or + in an HTTP Bearer token). + """ + ), + ] = True, + ): + super().__init__( + location=APIKeyIn.header, + name=name, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> str | None: + api_key = request.headers.get(self.model.name) + return self.check_api_key(api_key) + + +class APIKeyCookie(APIKeyBase): + """ + API key authentication using a cookie. + + This defines the name of the cookie that should be provided in the request with + the API key and integrates that into the OpenAPI documentation. It extracts + the key value sent in the cookie automatically and provides it as the dependency + result. But it doesn't define how to set that cookie. + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be a string containing the key value. + + ## Example + + ```python + from fastapi import Depends, FastAPI + from fastapi.security import APIKeyCookie + + app = FastAPI() + + cookie_scheme = APIKeyCookie(name="session") + + + @app.get("/items/") + async def read_items(session: str = Depends(cookie_scheme)): + return {"session": session} + ``` + """ + + def __init__( + self, + *, + name: Annotated[str, Doc("Cookie name.")], + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the cookie is not provided, `APIKeyCookie` will + automatically cancel the request and send the client an error. + + If `auto_error` is set to `False`, when the cookie is not available, + instead of erroring out, the dependency result will be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in a cookie or + in an HTTP Bearer token). + """ + ), + ] = True, + ): + super().__init__( + location=APIKeyIn.cookie, + name=name, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> str | None: + api_key = request.cookies.get(self.model.name) + return self.check_api_key(api_key) diff --git a/server/venv/Lib/site-packages/fastapi/security/base.py b/server/venv/Lib/site-packages/fastapi/security/base.py new file mode 100644 index 0000000..c43555d --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/security/base.py @@ -0,0 +1,6 @@ +from fastapi.openapi.models import SecurityBase as SecurityBaseModel + + +class SecurityBase: + model: SecurityBaseModel + scheme_name: str diff --git a/server/venv/Lib/site-packages/fastapi/security/http.py b/server/venv/Lib/site-packages/fastapi/security/http.py new file mode 100644 index 0000000..a32948e --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/security/http.py @@ -0,0 +1,417 @@ +import binascii +from base64 import b64decode +from typing import Annotated + +from annotated_doc import Doc +from fastapi.exceptions import HTTPException +from fastapi.openapi.models import HTTPBase as HTTPBaseModel +from fastapi.openapi.models import HTTPBearer as HTTPBearerModel +from fastapi.security.base import SecurityBase +from fastapi.security.utils import get_authorization_scheme_param +from pydantic import BaseModel +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED + + +class HTTPBasicCredentials(BaseModel): + """ + The HTTP Basic credentials given as the result of using `HTTPBasic` in a + dependency. + + Read more about it in the + [FastAPI docs for HTTP Basic Auth](https://fastapi.tiangolo.com/advanced/security/http-basic-auth/). + """ + + username: Annotated[str, Doc("The HTTP Basic username.")] + password: Annotated[str, Doc("The HTTP Basic password.")] + + +class HTTPAuthorizationCredentials(BaseModel): + """ + The HTTP authorization credentials in the result of using `HTTPBearer` or + `HTTPDigest` in a dependency. + + The HTTP authorization header value is split by the first space. + + The first part is the `scheme`, the second part is the `credentials`. + + For example, in an HTTP Bearer token scheme, the client will send a header + like: + + ``` + Authorization: Bearer deadbeef12346 + ``` + + In this case: + + * `scheme` will have the value `"Bearer"` + * `credentials` will have the value `"deadbeef12346"` + """ + + scheme: Annotated[ + str, + Doc( + """ + The HTTP authorization scheme extracted from the header value. + """ + ), + ] + credentials: Annotated[ + str, + Doc( + """ + The HTTP authorization credentials extracted from the header value. + """ + ), + ] + + +class HTTPBase(SecurityBase): + model: HTTPBaseModel + + def __init__( + self, + *, + scheme: str, + scheme_name: str | None = None, + description: str | None = None, + auto_error: bool = True, + ): + self.model = HTTPBaseModel(scheme=scheme, description=description) + self.scheme_name = scheme_name or self.__class__.__name__ + self.auto_error = auto_error + + def make_authenticate_headers(self) -> dict[str, str]: + return {"WWW-Authenticate": f"{self.model.scheme.title()}"} + + def make_not_authenticated_error(self) -> HTTPException: + return HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers=self.make_authenticate_headers(), + ) + + async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None: + authorization = request.headers.get("Authorization") + scheme, credentials = get_authorization_scheme_param(authorization) + if not (authorization and scheme and credentials): + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) + + +class HTTPBasic(HTTPBase): + """ + HTTP Basic authentication. + + Ref: https://datatracker.ietf.org/doc/html/rfc7617 + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be an `HTTPBasicCredentials` object containing the + `username` and the `password`. + + Read more about it in the + [FastAPI docs for HTTP Basic Auth](https://fastapi.tiangolo.com/advanced/security/http-basic-auth/). + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import HTTPBasic, HTTPBasicCredentials + + app = FastAPI() + + security = HTTPBasic() + + + @app.get("/users/me") + def read_current_user(credentials: Annotated[HTTPBasicCredentials, Depends(security)]): + return {"username": credentials.username, "password": credentials.password} + ``` + """ + + def __init__( + self, + *, + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + realm: Annotated[ + str | None, + Doc( + """ + HTTP Basic authentication realm. + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the HTTP Basic authentication is not provided (a + header), `HTTPBasic` will automatically cancel the request and send the + client an error. + + If `auto_error` is set to `False`, when the HTTP Basic authentication + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in HTTP Basic + authentication or in an HTTP Bearer token). + """ + ), + ] = True, + ): + self.model = HTTPBaseModel(scheme="basic", description=description) + self.scheme_name = scheme_name or self.__class__.__name__ + self.realm = realm + self.auto_error = auto_error + + def make_authenticate_headers(self) -> dict[str, str]: + if self.realm: + return {"WWW-Authenticate": f'Basic realm="{self.realm}"'} + return {"WWW-Authenticate": "Basic"} + + async def __call__( # type: ignore + self, request: Request + ) -> HTTPBasicCredentials | None: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "basic": + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + try: + data = b64decode(param).decode("ascii") + except (ValueError, UnicodeDecodeError, binascii.Error) as e: + raise self.make_not_authenticated_error() from e + username, separator, password = data.partition(":") + if not separator: + raise self.make_not_authenticated_error() + return HTTPBasicCredentials(username=username, password=password) + + +class HTTPBearer(HTTPBase): + """ + HTTP Bearer token authentication. + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be an `HTTPAuthorizationCredentials` object containing + the `scheme` and the `credentials`. + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + + app = FastAPI() + + security = HTTPBearer() + + + @app.get("/users/me") + def read_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] + ): + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` + """ + + def __init__( + self, + *, + bearerFormat: Annotated[str | None, Doc("Bearer token format.")] = None, + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the HTTP Bearer token is not provided (in an + `Authorization` header), `HTTPBearer` will automatically cancel the + request and send the client an error. + + If `auto_error` is set to `False`, when the HTTP Bearer token + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in an HTTP + Bearer token or in a cookie). + """ + ), + ] = True, + ): + self.model = HTTPBearerModel(bearerFormat=bearerFormat, description=description) + self.scheme_name = scheme_name or self.__class__.__name__ + self.auto_error = auto_error + + async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None: + authorization = request.headers.get("Authorization") + scheme, credentials = get_authorization_scheme_param(authorization) + if not (authorization and scheme and credentials): + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + if scheme.lower() != "bearer": + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) + + +class HTTPDigest(HTTPBase): + """ + HTTP Digest authentication. + + **Warning**: this is only a stub to connect the components with OpenAPI in FastAPI, + but it doesn't implement the full Digest scheme, you would need to subclass it + and implement it in your code. + + Ref: https://datatracker.ietf.org/doc/html/rfc7616 + + ## Usage + + Create an instance object and use that object as the dependency in `Depends()`. + + The dependency result will be an `HTTPAuthorizationCredentials` object containing + the `scheme` and the `credentials`. + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import HTTPAuthorizationCredentials, HTTPDigest + + app = FastAPI() + + security = HTTPDigest() + + + @app.get("/users/me") + def read_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)] + ): + return {"scheme": credentials.scheme, "credentials": credentials.credentials} + ``` + """ + + def __init__( + self, + *, + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if the HTTP Digest is not provided, `HTTPDigest` will + automatically cancel the request and send the client an error. + + If `auto_error` is set to `False`, when the HTTP Digest is not + available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, in HTTP + Digest or in a cookie). + """ + ), + ] = True, + ): + self.model = HTTPBaseModel(scheme="digest", description=description) + self.scheme_name = scheme_name or self.__class__.__name__ + self.auto_error = auto_error + + async def __call__(self, request: Request) -> HTTPAuthorizationCredentials | None: + authorization = request.headers.get("Authorization") + scheme, credentials = get_authorization_scheme_param(authorization) + if not (authorization and scheme and credentials): + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + if scheme.lower() != "digest": + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + return HTTPAuthorizationCredentials(scheme=scheme, credentials=credentials) diff --git a/server/venv/Lib/site-packages/fastapi/security/oauth2.py b/server/venv/Lib/site-packages/fastapi/security/oauth2.py new file mode 100644 index 0000000..3fd9e41 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/security/oauth2.py @@ -0,0 +1,693 @@ +from typing import Annotated, Any, cast + +from annotated_doc import Doc +from fastapi.exceptions import HTTPException +from fastapi.openapi.models import OAuth2 as OAuth2Model +from fastapi.openapi.models import OAuthFlows as OAuthFlowsModel +from fastapi.param_functions import Form +from fastapi.security.base import SecurityBase +from fastapi.security.utils import get_authorization_scheme_param +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED + + +class OAuth2PasswordRequestForm: + """ + This is a dependency class to collect the `username` and `password` as form data + for an OAuth2 password flow. + + The OAuth2 specification dictates that for a password flow the data should be + collected using form data (instead of JSON) and that it should have the specific + fields `username` and `password`. + + All the initialization parameters are extracted from the request. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import OAuth2PasswordRequestForm + + app = FastAPI() + + + @app.post("/login") + def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]): + data = {} + data["scopes"] = [] + for scope in form_data.scopes: + data["scopes"].append(scope) + if form_data.client_id: + data["client_id"] = form_data.client_id + if form_data.client_secret: + data["client_secret"] = form_data.client_secret + return data + ``` + + Note that for OAuth2 the scope `items:read` is a single scope in an opaque string. + You could have custom internal logic to separate it by colon characters (`:`) or + similar, and get the two parts `items` and `read`. Many applications do that to + group and organize permissions, you could do it as well in your application, just + know that it is application specific, it's not part of the specification. + """ + + def __init__( + self, + *, + grant_type: Annotated[ + str | None, + Form(pattern="^password$"), + Doc( + """ + The OAuth2 spec says it is required and MUST be the fixed string + "password". Nevertheless, this dependency class is permissive and + allows not passing it. If you want to enforce it, use instead the + `OAuth2PasswordRequestFormStrict` dependency. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ] = None, + username: Annotated[ + str, + Form(), + Doc( + """ + `username` string. The OAuth2 spec requires the exact field name + `username`. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ], + password: Annotated[ + str, + Form(json_schema_extra={"format": "password"}), + Doc( + """ + `password` string. The OAuth2 spec requires the exact field name + `password`. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ], + scope: Annotated[ + str, + Form(), + Doc( + """ + A single string with actually several scopes separated by spaces. Each + scope is also a string. + + For example, a single string with: + + ```python + "items:read items:write users:read profile openid" + ```` + + would represent the scopes: + + * `items:read` + * `items:write` + * `users:read` + * `profile` + * `openid` + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ] = "", + client_id: Annotated[ + str | None, + Form(), + Doc( + """ + If there's a `client_id`, it can be sent as part of the form fields. + But the OAuth2 specification recommends sending the `client_id` and + `client_secret` (if any) using HTTP Basic auth. + """ + ), + ] = None, + client_secret: Annotated[ + str | None, + Form(json_schema_extra={"format": "password"}), + Doc( + """ + If there's a `client_secret` (and a `client_id`), they can be sent + as part of the form fields. But the OAuth2 specification recommends + sending the `client_id` and `client_secret` (if any) using HTTP Basic + auth. + """ + ), + ] = None, + ): + self.grant_type = grant_type + self.username = username + self.password = password + self.scopes = scope.split() + self.client_id = client_id + self.client_secret = client_secret + + +class OAuth2PasswordRequestFormStrict(OAuth2PasswordRequestForm): + """ + This is a dependency class to collect the `username` and `password` as form data + for an OAuth2 password flow. + + The OAuth2 specification dictates that for a password flow the data should be + collected using form data (instead of JSON) and that it should have the specific + fields `username` and `password`. + + All the initialization parameters are extracted from the request. + + The only difference between `OAuth2PasswordRequestFormStrict` and + `OAuth2PasswordRequestForm` is that `OAuth2PasswordRequestFormStrict` requires the + client to send the form field `grant_type` with the value `"password"`, which + is required in the OAuth2 specification (it seems that for no particular reason), + while for `OAuth2PasswordRequestForm` `grant_type` is optional. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + + ## Example + + ```python + from typing import Annotated + + from fastapi import Depends, FastAPI + from fastapi.security import OAuth2PasswordRequestForm + + app = FastAPI() + + + @app.post("/login") + def login(form_data: Annotated[OAuth2PasswordRequestFormStrict, Depends()]): + data = {} + data["scopes"] = [] + for scope in form_data.scopes: + data["scopes"].append(scope) + if form_data.client_id: + data["client_id"] = form_data.client_id + if form_data.client_secret: + data["client_secret"] = form_data.client_secret + return data + ``` + + Note that for OAuth2 the scope `items:read` is a single scope in an opaque string. + You could have custom internal logic to separate it by colon characters (`:`) or + similar, and get the two parts `items` and `read`. Many applications do that to + group and organize permissions, you could do it as well in your application, just + know that it is application specific, it's not part of the specification. + + + grant_type: the OAuth2 spec says it is required and MUST be the fixed string "password". + This dependency is strict about it. If you want to be permissive, use instead the + OAuth2PasswordRequestForm dependency class. + username: username string. The OAuth2 spec requires the exact field name "username". + password: password string. The OAuth2 spec requires the exact field name "password". + scope: Optional string. Several scopes (each one a string) separated by spaces. E.g. + "items:read items:write users:read profile openid" + client_id: optional string. OAuth2 recommends sending the client_id and client_secret (if any) + using HTTP Basic auth, as: client_id:client_secret + client_secret: optional string. OAuth2 recommends sending the client_id and client_secret (if any) + using HTTP Basic auth, as: client_id:client_secret + """ + + def __init__( + self, + grant_type: Annotated[ + str, + Form(pattern="^password$"), + Doc( + """ + The OAuth2 spec says it is required and MUST be the fixed string + "password". This dependency is strict about it. If you want to be + permissive, use instead the `OAuth2PasswordRequestForm` dependency + class. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ], + username: Annotated[ + str, + Form(), + Doc( + """ + `username` string. The OAuth2 spec requires the exact field name + `username`. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ], + password: Annotated[ + str, + Form(), + Doc( + """ + `password` string. The OAuth2 spec requires the exact field name + `password`. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ], + scope: Annotated[ + str, + Form(), + Doc( + """ + A single string with actually several scopes separated by spaces. Each + scope is also a string. + + For example, a single string with: + + ```python + "items:read items:write users:read profile openid" + ```` + + would represent the scopes: + + * `items:read` + * `items:write` + * `users:read` + * `profile` + * `openid` + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ] = "", + client_id: Annotated[ + str | None, + Form(), + Doc( + """ + If there's a `client_id`, it can be sent as part of the form fields. + But the OAuth2 specification recommends sending the `client_id` and + `client_secret` (if any) using HTTP Basic auth. + """ + ), + ] = None, + client_secret: Annotated[ + str | None, + Form(), + Doc( + """ + If there's a `client_secret` (and a `client_id`), they can be sent + as part of the form fields. But the OAuth2 specification recommends + sending the `client_id` and `client_secret` (if any) using HTTP Basic + auth. + """ + ), + ] = None, + ): + super().__init__( + grant_type=grant_type, + username=username, + password=password, + scope=scope, + client_id=client_id, + client_secret=client_secret, + ) + + +class OAuth2(SecurityBase): + """ + This is the base class for OAuth2 authentication, an instance of it would be used + as a dependency. All other OAuth2 classes inherit from it and customize it for + each OAuth2 flow. + + You normally would not create a new class inheriting from it but use one of the + existing subclasses, and maybe compose them if you want to support multiple flows. + + Read more about it in the + [FastAPI docs for Security](https://fastapi.tiangolo.com/tutorial/security/). + """ + + def __init__( + self, + *, + flows: Annotated[ + OAuthFlowsModel | dict[str, dict[str, Any]], + Doc( + """ + The dictionary of OAuth2 flows. + """ + ), + ] = OAuthFlowsModel(), + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if no HTTP Authorization header is provided, required for + OAuth2 authentication, it will automatically cancel the request and + send the client an error. + + If `auto_error` is set to `False`, when the HTTP Authorization header + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, with OAuth2 + or in a cookie). + """ + ), + ] = True, + ): + self.model = OAuth2Model( + flows=cast(OAuthFlowsModel, flows), description=description + ) + self.scheme_name = scheme_name or self.__class__.__name__ + self.auto_error = auto_error + + def make_not_authenticated_error(self) -> HTTPException: + """ + The OAuth 2 specification doesn't define the challenge that should be used, + because a `Bearer` token is not really the only option to authenticate. + + But declaring any other authentication challenge would be application-specific + as it's not defined in the specification. + + For practical reasons, this method uses the `Bearer` challenge by default, as + it's probably the most common one. + + If you are implementing an OAuth2 authentication scheme other than the provided + ones in FastAPI (based on bearer tokens), you might want to override this. + + Ref: https://datatracker.ietf.org/doc/html/rfc6749 + """ + return HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + async def __call__(self, request: Request) -> str | None: + authorization = request.headers.get("Authorization") + if not authorization: + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + return authorization + + +class OAuth2PasswordBearer(OAuth2): + """ + OAuth2 flow for authentication using a bearer token obtained with a password. + An instance of it would be used as a dependency. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + + def __init__( + self, + tokenUrl: Annotated[ + str, + Doc( + """ + The URL to obtain the OAuth2 token. This would be the *path operation* + that has `OAuth2PasswordRequestForm` as a dependency. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ], + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + scopes: Annotated[ + dict[str, str] | None, + Doc( + """ + The OAuth2 scopes that would be required by the *path operations* that + use this dependency. + + Read more about it in the + [FastAPI docs for Simple OAuth2 with Password and Bearer](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if no HTTP Authorization header is provided, required for + OAuth2 authentication, it will automatically cancel the request and + send the client an error. + + If `auto_error` is set to `False`, when the HTTP Authorization header + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, with OAuth2 + or in a cookie). + """ + ), + ] = True, + refreshUrl: Annotated[ + str | None, + Doc( + """ + The URL to refresh the token and obtain a new one. + """ + ), + ] = None, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel( + password=cast( + Any, + { + "tokenUrl": tokenUrl, + "refreshUrl": refreshUrl, + "scopes": scopes, + }, + ) + ) + super().__init__( + flows=flows, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> str | None: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + return param + + +class OAuth2AuthorizationCodeBearer(OAuth2): + """ + OAuth2 flow for authentication using a bearer token obtained with an OAuth2 code + flow. An instance of it would be used as a dependency. + """ + + def __init__( + self, + authorizationUrl: str, + tokenUrl: Annotated[ + str, + Doc( + """ + The URL to obtain the OAuth2 token. + """ + ), + ], + refreshUrl: Annotated[ + str | None, + Doc( + """ + The URL to refresh the token and obtain a new one. + """ + ), + ] = None, + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + scopes: Annotated[ + dict[str, str] | None, + Doc( + """ + The OAuth2 scopes that would be required by the *path operations* that + use this dependency. + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if no HTTP Authorization header is provided, required for + OAuth2 authentication, it will automatically cancel the request and + send the client an error. + + If `auto_error` is set to `False`, when the HTTP Authorization header + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, with OAuth2 + or in a cookie). + """ + ), + ] = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlowsModel( + authorizationCode=cast( + Any, + { + "authorizationUrl": authorizationUrl, + "tokenUrl": tokenUrl, + "refreshUrl": refreshUrl, + "scopes": scopes, + }, + ) + ) + super().__init__( + flows=flows, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> str | None: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None # pragma: nocover + return param + + +class SecurityScopes: + """ + This is a special class that you can define in a parameter in a dependency to + obtain the OAuth2 scopes required by all the dependencies in the same chain. + + This way, multiple dependencies can have different scopes, even when used in the + same *path operation*. And with this, you can access all the scopes required in + all those dependencies in a single place. + + Read more about it in the + [FastAPI docs for OAuth2 scopes](https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/). + """ + + def __init__( + self, + scopes: Annotated[ + list[str] | None, + Doc( + """ + This will be filled by FastAPI. + """ + ), + ] = None, + ): + self.scopes: Annotated[ + list[str], + Doc( + """ + The list of all the scopes required by dependencies. + """ + ), + ] = scopes or [] + self.scope_str: Annotated[ + str, + Doc( + """ + All the scopes required by all the dependencies in a single string + separated by spaces, as defined in the OAuth2 specification. + """ + ), + ] = " ".join(self.scopes) diff --git a/server/venv/Lib/site-packages/fastapi/security/open_id_connect_url.py b/server/venv/Lib/site-packages/fastapi/security/open_id_connect_url.py new file mode 100644 index 0000000..125a819 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/security/open_id_connect_url.py @@ -0,0 +1,94 @@ +from typing import Annotated + +from annotated_doc import Doc +from fastapi.openapi.models import OpenIdConnect as OpenIdConnectModel +from fastapi.security.base import SecurityBase +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED + + +class OpenIdConnect(SecurityBase): + """ + OpenID Connect authentication class. An instance of it would be used as a + dependency. + + **Warning**: this is only a stub to connect the components with OpenAPI in FastAPI, + but it doesn't implement the full OpenIdConnect scheme, for example, it doesn't use + the OpenIDConnect URL. You would need to subclass it and implement it in your + code. + """ + + def __init__( + self, + *, + openIdConnectUrl: Annotated[ + str, + Doc( + """ + The OpenID Connect URL. + """ + ), + ], + scheme_name: Annotated[ + str | None, + Doc( + """ + Security scheme name. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + description: Annotated[ + str | None, + Doc( + """ + Security scheme description. + + It will be included in the generated OpenAPI (e.g. visible at `/docs`). + """ + ), + ] = None, + auto_error: Annotated[ + bool, + Doc( + """ + By default, if no HTTP Authorization header is provided, required for + OpenID Connect authentication, it will automatically cancel the request + and send the client an error. + + If `auto_error` is set to `False`, when the HTTP Authorization header + is not available, instead of erroring out, the dependency result will + be `None`. + + This is useful when you want to have optional authentication. + + It is also useful when you want to have authentication that can be + provided in one of multiple optional ways (for example, with OpenID + Connect or in a cookie). + """ + ), + ] = True, + ): + self.model = OpenIdConnectModel( + openIdConnectUrl=openIdConnectUrl, description=description + ) + self.scheme_name = scheme_name or self.__class__.__name__ + self.auto_error = auto_error + + def make_not_authenticated_error(self) -> HTTPException: + return HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + async def __call__(self, request: Request) -> str | None: + authorization = request.headers.get("Authorization") + if not authorization: + if self.auto_error: + raise self.make_not_authenticated_error() + else: + return None + return authorization diff --git a/server/venv/Lib/site-packages/fastapi/security/utils.py b/server/venv/Lib/site-packages/fastapi/security/utils.py new file mode 100644 index 0000000..8ee66fd --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/security/utils.py @@ -0,0 +1,7 @@ +def get_authorization_scheme_param( + authorization_header_value: str | None, +) -> tuple[str, str]: + if not authorization_header_value: + return "", "" + scheme, _, param = authorization_header_value.partition(" ") + return scheme, param.strip() diff --git a/server/venv/Lib/site-packages/fastapi/sse.py b/server/venv/Lib/site-packages/fastapi/sse.py new file mode 100644 index 0000000..1e2bd86 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/sse.py @@ -0,0 +1,235 @@ +from typing import Annotated, Any + +from annotated_doc import Doc +from pydantic import AfterValidator, BaseModel, Field, model_validator +from starlette.responses import StreamingResponse + +# Canonical SSE event schema matching the OpenAPI 3.2 spec +# (Section 4.14.4 "Special Considerations for Server-Sent Events") +_SSE_EVENT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "data": {"type": "string"}, + "event": {"type": "string"}, + "id": {"type": "string"}, + "retry": {"type": "integer", "minimum": 0}, + }, +} + + +class EventSourceResponse(StreamingResponse): + """Streaming response with `text/event-stream` media type. + + Use as `response_class=EventSourceResponse` on a *path operation* that uses `yield` + to enable Server Sent Events (SSE) responses. + + Works with **any HTTP method** (`GET`, `POST`, etc.), which makes it compatible + with protocols like MCP that stream SSE over `POST`. + + The actual encoding logic lives in the FastAPI routing layer. This class + serves mainly as a marker and sets the correct `Content-Type`. + """ + + media_type = "text/event-stream" + + +def _check_single_line(v: str | None, field_name: str) -> str | None: + if v is not None and ("\r" in v or "\n" in v): + raise ValueError(f"SSE '{field_name}' must be a single line") + return v + + +def _check_event_single_line(v: str | None) -> str | None: + return _check_single_line(v, "event") + + +def _check_id_valid(v: str | None) -> str | None: + if v is not None and "\0" in v: + raise ValueError("SSE 'id' must not contain null characters") + return _check_single_line(v, "id") + + +class ServerSentEvent(BaseModel): + """Represents a single Server-Sent Event. + + When `yield`ed from a *path operation function* that uses + `response_class=EventSourceResponse`, each `ServerSentEvent` is encoded + into the [SSE wire format](https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream) + (`text/event-stream`). + + If you yield a plain object (dict, Pydantic model, etc.) instead, it is + automatically JSON-encoded and sent as the `data:` field. + + All `data` values **including plain strings** are JSON-serialized. + + For example, `data="hello"` produces `data: "hello"` on the wire (with + quotes). + """ + + data: Annotated[ + Any, + Doc( + """ + The event payload. + + Can be any JSON-serializable value: a Pydantic model, dict, list, + string, number, etc. It is **always** serialized to JSON: strings + are quoted (`"hello"` becomes `data: "hello"` on the wire). + + Mutually exclusive with `raw_data`. + """ + ), + ] = None + raw_data: Annotated[ + str | None, + Doc( + """ + Raw string to send as the `data:` field **without** JSON encoding. + + Use this when you need to send pre-formatted text, HTML fragments, + CSV lines, or any non-JSON payload. The string is placed directly + into the `data:` field as-is. + + Mutually exclusive with `data`. + """ + ), + ] = None + event: Annotated[ + str | None, + AfterValidator(_check_event_single_line), + Doc( + """ + Optional event type name. + + Maps to `addEventListener(event, ...)` on the browser. When omitted, + the browser dispatches on the generic `message` event. Must be a + single line. + """ + ), + ] = None + id: Annotated[ + str | None, + AfterValidator(_check_id_valid), + Doc( + """ + Optional event ID. + + The browser sends this value back as the `Last-Event-ID` header on + automatic reconnection. **Must be a single line** and must not contain + null (`\\0`) characters. + """ + ), + ] = None + retry: Annotated[ + int | None, + Field(ge=0), + Doc( + """ + Optional reconnection time in **milliseconds**. + + Tells the browser how long to wait before reconnecting after the + connection is lost. Must be a non-negative integer. + """ + ), + ] = None + comment: Annotated[ + str | None, + Doc( + """ + Optional comment line(s). + + Comment lines start with `:` in the SSE wire format and are ignored by + `EventSource` clients. Useful for keep-alive pings to prevent + proxy/load-balancer timeouts. + """ + ), + ] = None + + @model_validator(mode="after") + def _check_data_exclusive(self) -> "ServerSentEvent": + if self.data is not None and self.raw_data is not None: + raise ValueError( + "Cannot set both 'data' and 'raw_data' on the same " + "ServerSentEvent. Use 'data' for JSON-serialized payloads " + "or 'raw_data' for pre-formatted strings." + ) + return self + + +def format_sse_event( + *, + data_str: Annotated[ + str | None, + Doc( + """ + Pre-serialized data string to use as the `data:` field. + """ + ), + ] = None, + event: Annotated[ + str | None, + Doc( + """ + Optional event type name (`event:` field). + """ + ), + ] = None, + id: Annotated[ + str | None, + Doc( + """ + Optional event ID (`id:` field). + """ + ), + ] = None, + retry: Annotated[ + int | None, + Doc( + """ + Optional reconnection time in milliseconds (`retry:` field). + """ + ), + ] = None, + comment: Annotated[ + str | None, + Doc( + """ + Optional comment line(s) (`:` prefix). + """ + ), + ] = None, +) -> bytes: + """Build SSE wire-format bytes from **pre-serialized** data. + + The result always ends with `\n\n` (the event terminator). + """ + lines: list[str] = [] + + if comment is not None: + for line in comment.splitlines(): + lines.append(f": {line}") + + if event is not None: + lines.append(f"event: {event}") + + if data_str is not None: + for line in data_str.splitlines(): + lines.append(f"data: {line}") + + if id is not None: + lines.append(f"id: {id}") + + if retry is not None: + lines.append(f"retry: {retry}") + + lines.append("") + lines.append("") + return "\n".join(lines).encode("utf-8") + + +# Keep-alive comment, per the SSE spec recommendation +KEEPALIVE_COMMENT = b": ping\n\n" + +# Seconds between keep-alive pings when a generator is idle. +# Private but importable so tests can monkeypatch it. +_PING_INTERVAL: float = 15.0 diff --git a/server/venv/Lib/site-packages/fastapi/staticfiles.py b/server/venv/Lib/site-packages/fastapi/staticfiles.py new file mode 100644 index 0000000..299015d --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/staticfiles.py @@ -0,0 +1 @@ +from starlette.staticfiles import StaticFiles as StaticFiles # noqa diff --git a/server/venv/Lib/site-packages/fastapi/templating.py b/server/venv/Lib/site-packages/fastapi/templating.py new file mode 100644 index 0000000..0cb8684 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/templating.py @@ -0,0 +1 @@ +from starlette.templating import Jinja2Templates as Jinja2Templates # noqa diff --git a/server/venv/Lib/site-packages/fastapi/testclient.py b/server/venv/Lib/site-packages/fastapi/testclient.py new file mode 100644 index 0000000..4012406 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/testclient.py @@ -0,0 +1 @@ +from starlette.testclient import TestClient as TestClient # noqa diff --git a/server/venv/Lib/site-packages/fastapi/types.py b/server/venv/Lib/site-packages/fastapi/types.py new file mode 100644 index 0000000..1fb86e1 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/types.py @@ -0,0 +1,12 @@ +import types +from collections.abc import Callable +from enum import Enum +from typing import Any, TypeVar, Union + +from pydantic import BaseModel +from pydantic.main import IncEx as IncEx + +DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any]) +UnionType = getattr(types, "UnionType", Union) +ModelNameMap = dict[type[BaseModel] | type[Enum], str] +DependencyCacheKey = tuple[Callable[..., Any] | None, tuple[str, ...], str] diff --git a/server/venv/Lib/site-packages/fastapi/utils.py b/server/venv/Lib/site-packages/fastapi/utils.py new file mode 100644 index 0000000..12eaa2b --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/utils.py @@ -0,0 +1,136 @@ +import re +import warnings +from typing import ( + TYPE_CHECKING, + Any, + Literal, +) + +import fastapi +from fastapi._compat import ( + ModelField, + PydanticSchemaGenerationError, + Undefined, + annotation_is_pydantic_v1, +) +from fastapi.datastructures import DefaultPlaceholder, DefaultType +from fastapi.exceptions import FastAPIDeprecationWarning, PydanticV1NotSupportedError +from pydantic.fields import FieldInfo + +from ._compat import v2 + +if TYPE_CHECKING: # pragma: nocover + from .routing import APIRoute + + +def is_body_allowed_for_status_code(status_code: int | str | None) -> bool: + if status_code is None: + return True + # Ref: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#patterned-fields-1 + if status_code in { + "default", + "1XX", + "2XX", + "3XX", + "4XX", + "5XX", + }: + return True + current_status_code = int(status_code) + return not (current_status_code < 200 or current_status_code in {204, 205, 304}) + + +def get_path_param_names(path: str) -> set[str]: + return set(re.findall("{(.*?)}", path)) + + +_invalid_args_message = ( + "Invalid args for response field! Hint: " + "check that {type_} is a valid Pydantic field type. " + "If you are using a return type annotation that is not a valid Pydantic " + "field (e.g. Union[Response, dict, None]) you can disable generating the " + "response model from the type annotation with the path operation decorator " + "parameter response_model=None. Read more: " + "https://fastapi.tiangolo.com/tutorial/response-model/" +) + + +def create_model_field( + name: str, + type_: Any, + default: Any | None = Undefined, + field_info: FieldInfo | None = None, + alias: str | None = None, + mode: Literal["validation", "serialization"] = "validation", +) -> ModelField: + if annotation_is_pydantic_v1(type_): + raise PydanticV1NotSupportedError( + "pydantic.v1 models are no longer supported by FastAPI." + f" Please update the response model {type_!r}." + ) + field_info = field_info or FieldInfo(annotation=type_, default=default, alias=alias) + try: + return v2.ModelField(mode=mode, name=name, field_info=field_info) + except PydanticSchemaGenerationError: + raise fastapi.exceptions.FastAPIError( + _invalid_args_message.format(type_=type_) + ) from None + + +def generate_operation_id_for_path( + *, name: str, path: str, method: str +) -> str: # pragma: nocover + warnings.warn( + message="fastapi.utils.generate_operation_id_for_path() was deprecated, " + "it is not used internally, and will be removed soon", + category=FastAPIDeprecationWarning, + stacklevel=2, + ) + operation_id = f"{name}{path}" + operation_id = re.sub(r"\W", "_", operation_id) + operation_id = f"{operation_id}_{method.lower()}" + return operation_id + + +def generate_unique_id(route: "APIRoute") -> str: + operation_id = f"{route.name}{route.path_format}" + operation_id = re.sub(r"\W", "_", operation_id) + assert route.methods + operation_id = f"{operation_id}_{list(route.methods)[0].lower()}" + return operation_id + + +def deep_dict_update(main_dict: dict[Any, Any], update_dict: dict[Any, Any]) -> None: + for key, value in update_dict.items(): + if ( + key in main_dict + and isinstance(main_dict[key], dict) + and isinstance(value, dict) + ): + deep_dict_update(main_dict[key], value) + elif ( + key in main_dict + and isinstance(main_dict[key], list) + and isinstance(update_dict[key], list) + ): + main_dict[key] = main_dict[key] + update_dict[key] + else: + main_dict[key] = value + + +def get_value_or_default( + first_item: DefaultPlaceholder | DefaultType, + *extra_items: DefaultPlaceholder | DefaultType, +) -> DefaultPlaceholder | DefaultType: + """ + Pass items or `DefaultPlaceholder`s by descending priority. + + The first one to _not_ be a `DefaultPlaceholder` will be returned. + + Otherwise, the first item (a `DefaultPlaceholder`) will be returned. + """ + items = (first_item,) + extra_items + for item in items: + if not isinstance(item, DefaultPlaceholder): + return item + return first_item diff --git a/server/venv/Lib/site-packages/fastapi/websockets.py b/server/venv/Lib/site-packages/fastapi/websockets.py new file mode 100644 index 0000000..55a4ac4 --- /dev/null +++ b/server/venv/Lib/site-packages/fastapi/websockets.py @@ -0,0 +1,3 @@ +from starlette.websockets import WebSocket as WebSocket # noqa +from starlette.websockets import WebSocketDisconnect as WebSocketDisconnect # noqa +from starlette.websockets import WebSocketState as WebSocketState # noqa diff --git a/server/venv/Lib/site-packages/gssapi64.dll b/server/venv/Lib/site-packages/gssapi64.dll new file mode 100644 index 0000000..408ffb2 Binary files /dev/null and b/server/venv/Lib/site-packages/gssapi64.dll differ diff --git a/server/venv/Lib/site-packages/h11-0.16.0.dist-info/INSTALLER b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/h11-0.16.0.dist-info/METADATA b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/METADATA new file mode 100644 index 0000000..8a2f639 --- /dev/null +++ b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/METADATA @@ -0,0 +1,202 @@ +Metadata-Version: 2.4 +Name: h11 +Version: 0.16.0 +Summary: A pure-Python, bring-your-own-I/O implementation of HTTP/1.1 +Home-page: https://github.com/python-hyper/h11 +Author: Nathaniel J. Smith +Author-email: njs@pobox.com +License: MIT +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Programming Language :: Python :: 3 +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 :: Internet :: WWW/HTTP +Classifier: Topic :: System :: Networking +Requires-Python: >=3.8 +License-File: LICENSE.txt +Dynamic: author +Dynamic: author-email +Dynamic: classifier +Dynamic: description +Dynamic: home-page +Dynamic: license +Dynamic: license-file +Dynamic: requires-python +Dynamic: summary + +h11 +=== + +.. image:: https://travis-ci.org/python-hyper/h11.svg?branch=master + :target: https://travis-ci.org/python-hyper/h11 + :alt: Automated test status + +.. image:: https://codecov.io/gh/python-hyper/h11/branch/master/graph/badge.svg + :target: https://codecov.io/gh/python-hyper/h11 + :alt: Test coverage + +.. image:: https://readthedocs.org/projects/h11/badge/?version=latest + :target: http://h11.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status + +This is a little HTTP/1.1 library written from scratch in Python, +heavily inspired by `hyper-h2 `_. + +It's a "bring-your-own-I/O" library; h11 contains no IO code +whatsoever. This means you can hook h11 up to your favorite network +API, and that could be anything you want: synchronous, threaded, +asynchronous, or your own implementation of `RFC 6214 +`_ -- h11 won't judge you. +(Compare this to the current state of the art, where every time a `new +network API `_ comes along then someone +gets to start over reimplementing the entire HTTP protocol from +scratch.) Cory Benfield made an `excellent blog post describing the +benefits of this approach +`_, or if you like video +then here's his `PyCon 2016 talk on the same theme +`_. + +This also means that h11 is not immediately useful out of the box: +it's a toolkit for building programs that speak HTTP, not something +that could directly replace ``requests`` or ``twisted.web`` or +whatever. But h11 makes it much easier to implement something like +``requests`` or ``twisted.web``. + +At a high level, working with h11 goes like this: + +1) First, create an ``h11.Connection`` object to track the state of a + single HTTP/1.1 connection. + +2) When you read data off the network, pass it to + ``conn.receive_data(...)``; you'll get back a list of objects + representing high-level HTTP "events". + +3) When you want to send a high-level HTTP event, create the + corresponding "event" object and pass it to ``conn.send(...)``; + this will give you back some bytes that you can then push out + through the network. + +For example, a client might instantiate and then send a +``h11.Request`` object, then zero or more ``h11.Data`` objects for the +request body (e.g., if this is a POST), and then a +``h11.EndOfMessage`` to indicate the end of the message. Then the +server would then send back a ``h11.Response``, some ``h11.Data``, and +its own ``h11.EndOfMessage``. If either side violates the protocol, +you'll get a ``h11.ProtocolError`` exception. + +h11 is suitable for implementing both servers and clients, and has a +pleasantly symmetric API: the events you send as a client are exactly +the ones that you receive as a server and vice-versa. + +`Here's an example of a tiny HTTP client +`_ + +It also has `a fine manual `_. + +FAQ +--- + +*Whyyyyy?* + +I wanted to play with HTTP in `Curio +`__ and `Trio +`__, which at the time didn't have any +HTTP libraries. So I thought, no big deal, Python has, like, a dozen +different implementations of HTTP, surely I can find one that's +reusable. I didn't find one, but I did find Cory's call-to-arms +blog-post. So I figured, well, fine, if I have to implement HTTP from +scratch, at least I can make sure no-one *else* has to ever again. + +*Should I use it?* + +Maybe. You should be aware that it's a very young project. But, it's +feature complete and has an exhaustive test-suite and complete docs, +so the next step is for people to try using it and see how it goes +:-). If you do then please let us know -- if nothing else we'll want +to talk to you before making any incompatible changes! + +*What are the features/limitations?* + +Roughly speaking, it's trying to be a robust, complete, and non-hacky +implementation of the first "chapter" of the HTTP/1.1 spec: `RFC 7230: +HTTP/1.1 Message Syntax and Routing +`_. That is, it mostly focuses on +implementing HTTP at the level of taking bytes on and off the wire, +and the headers related to that, and tries to be anal about spec +conformance. It doesn't know about higher-level concerns like URL +routing, conditional GETs, cross-origin cookie policies, or content +negotiation. But it does know how to take care of framing, +cross-version differences in keep-alive handling, and the "obsolete +line folding" rule, so you can focus your energies on the hard / +interesting parts for your application, and it tries to support the +full specification in the sense that any useful HTTP/1.1 conformant +application should be able to use h11. + +It's pure Python, and has no dependencies outside of the standard +library. + +It has a test suite with 100.0% coverage for both statements and +branches. + +Currently it supports Python 3 (testing on 3.8-3.12) and PyPy 3. +The last Python 2-compatible version was h11 0.11.x. +(Originally it had a Cython wrapper for `http-parser +`_ and a beautiful nested state +machine implemented with ``yield from`` to postprocess the output. But +I had to take these out -- the new *parser* needs fewer lines-of-code +than the old *parser wrapper*, is written in pure Python, uses no +exotic language syntax, and has more features. It's sad, really; that +old state machine was really slick. I just need a few sentences here +to mourn that.) + +I don't know how fast it is. I haven't benchmarked or profiled it yet, +so it's probably got a few pointless hot spots, and I've been trying +to err on the side of simplicity and robustness instead of +micro-optimization. But at the architectural level I tried hard to +avoid fundamentally bad decisions, e.g., I believe that all the +parsing algorithms remain linear-time even in the face of pathological +input like slowloris, and there are no byte-by-byte loops. (I also +believe that it maintains bounded memory usage in the face of +arbitrary/pathological input.) + +The whole library is ~800 lines-of-code. You can read and understand +the whole thing in less than an hour. Most of the energy invested in +this so far has been spent on trying to keep things simple by +minimizing special-cases and ad hoc state manipulation; even though it +is now quite small and simple, I'm still annoyed that I haven't +figured out how to make it even smaller and simpler. (Unfortunately, +HTTP does not lend itself to simplicity.) + +The API is ~feature complete and I don't expect the general outlines +to change much, but you can't judge an API's ergonomics until you +actually document and use it, so I'd expect some changes in the +details. + +*How do I try it?* + +.. code-block:: sh + + $ pip install h11 + $ git clone git@github.com:python-hyper/h11 + $ cd h11/examples + $ python basic-client.py + +and go from there. + +*License?* + +MIT + +*Code of conduct?* + +Contributors are requested to follow our `code of conduct +`_ in +all project spaces. diff --git a/server/venv/Lib/site-packages/h11-0.16.0.dist-info/RECORD b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/RECORD new file mode 100644 index 0000000..d64dc2d --- /dev/null +++ b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/RECORD @@ -0,0 +1,29 @@ +h11-0.16.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +h11-0.16.0.dist-info/METADATA,sha256=KPMmCYrAn8unm48YD5YIfIQf4kViFct7hyqcfVzRnWQ,8348 +h11-0.16.0.dist-info/RECORD,, +h11-0.16.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91 +h11-0.16.0.dist-info/licenses/LICENSE.txt,sha256=N9tbuFkm2yikJ6JYZ_ELEjIAOuob5pzLhRE4rbjm82E,1124 +h11-0.16.0.dist-info/top_level.txt,sha256=F7dC4jl3zeh8TGHEPaWJrMbeuoWbS379Gwdi-Yvdcis,4 +h11/__init__.py,sha256=iO1KzkSO42yZ6ffg-VMgbx_ZVTWGUY00nRYEWn-s3kY,1507 +h11/__pycache__/__init__.cpython-313.pyc,, +h11/__pycache__/_abnf.cpython-313.pyc,, +h11/__pycache__/_connection.cpython-313.pyc,, +h11/__pycache__/_events.cpython-313.pyc,, +h11/__pycache__/_headers.cpython-313.pyc,, +h11/__pycache__/_readers.cpython-313.pyc,, +h11/__pycache__/_receivebuffer.cpython-313.pyc,, +h11/__pycache__/_state.cpython-313.pyc,, +h11/__pycache__/_util.cpython-313.pyc,, +h11/__pycache__/_version.cpython-313.pyc,, +h11/__pycache__/_writers.cpython-313.pyc,, +h11/_abnf.py,sha256=ybixr0xsupnkA6GFAyMubuXF6Tc1lb_hF890NgCsfNc,4815 +h11/_connection.py,sha256=k9YRVf6koZqbttBW36xSWaJpWdZwa-xQVU9AHEo9DuI,26863 +h11/_events.py,sha256=I97aXoal1Wu7dkL548BANBUCkOIbe-x5CioYA9IBY14,11792 +h11/_headers.py,sha256=P7D-lBNxHwdLZPLimmYwrPG-9ZkjElvvJZJdZAgSP-4,10412 +h11/_readers.py,sha256=a4RypORUCC3d0q_kxPuBIM7jTD8iLt5X91TH0FsduN4,8590 +h11/_receivebuffer.py,sha256=xrspsdsNgWFxRfQcTXxR8RrdjRXXTK0Io5cQYWpJ1Ws,5252 +h11/_state.py,sha256=_5LG_BGR8FCcFQeBPH-TMHgm_-B-EUcWCnQof_9XjFE,13231 +h11/_util.py,sha256=LWkkjXyJaFlAy6Lt39w73UStklFT5ovcvo0TkY7RYuk,4888 +h11/_version.py,sha256=GVSsbPSPDcOuF6ptfIiXnVJoaEm3ygXbMnqlr_Giahw,686 +h11/_writers.py,sha256=oFKm6PtjeHfbj4RLX7VB7KDc1gIY53gXG3_HR9ltmTA,5081 +h11/py.typed,sha256=sow9soTwP9T_gEAQSVh7Gb8855h04Nwmhs2We-JRgZM,7 diff --git a/server/venv/Lib/site-packages/h11-0.16.0.dist-info/WHEEL b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/WHEEL new file mode 100644 index 0000000..1eb3c49 --- /dev/null +++ b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: setuptools (78.1.0) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/server/venv/Lib/site-packages/h11-0.16.0.dist-info/licenses/LICENSE.txt b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/licenses/LICENSE.txt new file mode 100644 index 0000000..8f080ea --- /dev/null +++ b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/licenses/LICENSE.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2016 Nathaniel J. Smith and other 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. diff --git a/server/venv/Lib/site-packages/h11-0.16.0.dist-info/top_level.txt b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/top_level.txt new file mode 100644 index 0000000..0d24def --- /dev/null +++ b/server/venv/Lib/site-packages/h11-0.16.0.dist-info/top_level.txt @@ -0,0 +1 @@ +h11 diff --git a/server/venv/Lib/site-packages/h11/__init__.py b/server/venv/Lib/site-packages/h11/__init__.py new file mode 100644 index 0000000..989e92c --- /dev/null +++ b/server/venv/Lib/site-packages/h11/__init__.py @@ -0,0 +1,62 @@ +# A highish-level implementation of the HTTP/1.1 wire protocol (RFC 7230), +# containing no networking code at all, loosely modelled on hyper-h2's generic +# implementation of HTTP/2 (and in particular the h2.connection.H2Connection +# class). There's still a bunch of subtle details you need to get right if you +# want to make this actually useful, because it doesn't implement all the +# semantics to check that what you're asking to write to the wire is sensible, +# but at least it gets you out of dealing with the wire itself. + +from h11._connection import Connection, NEED_DATA, PAUSED +from h11._events import ( + ConnectionClosed, + Data, + EndOfMessage, + Event, + InformationalResponse, + Request, + Response, +) +from h11._state import ( + CLIENT, + CLOSED, + DONE, + ERROR, + IDLE, + MIGHT_SWITCH_PROTOCOL, + MUST_CLOSE, + SEND_BODY, + SEND_RESPONSE, + SERVER, + SWITCHED_PROTOCOL, +) +from h11._util import LocalProtocolError, ProtocolError, RemoteProtocolError +from h11._version import __version__ + +PRODUCT_ID = "python-h11/" + __version__ + + +__all__ = ( + "Connection", + "NEED_DATA", + "PAUSED", + "ConnectionClosed", + "Data", + "EndOfMessage", + "Event", + "InformationalResponse", + "Request", + "Response", + "CLIENT", + "CLOSED", + "DONE", + "ERROR", + "IDLE", + "MUST_CLOSE", + "SEND_BODY", + "SEND_RESPONSE", + "SERVER", + "SWITCHED_PROTOCOL", + "ProtocolError", + "LocalProtocolError", + "RemoteProtocolError", +) diff --git a/server/venv/Lib/site-packages/h11/_abnf.py b/server/venv/Lib/site-packages/h11/_abnf.py new file mode 100644 index 0000000..933587f --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_abnf.py @@ -0,0 +1,132 @@ +# We use native strings for all the re patterns, to take advantage of string +# formatting, and then convert to bytestrings when compiling the final re +# objects. + +# https://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7230.html#whitespace +# OWS = *( SP / HTAB ) +# ; optional whitespace +OWS = r"[ \t]*" + +# https://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7230.html#rule.token.separators +# token = 1*tchar +# +# tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" +# / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" +# / DIGIT / ALPHA +# ; any VCHAR, except delimiters +token = r"[-!#$%&'*+.^_`|~0-9a-zA-Z]+" + +# https://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7230.html#header.fields +# field-name = token +field_name = token + +# The standard says: +# +# field-value = *( field-content / obs-fold ) +# field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] +# field-vchar = VCHAR / obs-text +# obs-fold = CRLF 1*( SP / HTAB ) +# ; obsolete line folding +# ; see Section 3.2.4 +# +# https://tools.ietf.org/html/rfc5234#appendix-B.1 +# +# VCHAR = %x21-7E +# ; visible (printing) characters +# +# https://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7230.html#rule.quoted-string +# obs-text = %x80-FF +# +# However, the standard definition of field-content is WRONG! It disallows +# fields containing a single visible character surrounded by whitespace, +# e.g. "foo a bar". +# +# See: https://www.rfc-editor.org/errata_search.php?rfc=7230&eid=4189 +# +# So our definition of field_content attempts to fix it up... +# +# Also, we allow lots of control characters, because apparently people assume +# that they're legal in practice (e.g., google analytics makes cookies with +# \x01 in them!): +# https://github.com/python-hyper/h11/issues/57 +# We still don't allow NUL or whitespace, because those are often treated as +# meta-characters and letting them through can lead to nasty issues like SSRF. +vchar = r"[\x21-\x7e]" +vchar_or_obs_text = r"[^\x00\s]" +field_vchar = vchar_or_obs_text +field_content = r"{field_vchar}+(?:[ \t]+{field_vchar}+)*".format(**globals()) + +# We handle obs-fold at a different level, and our fixed-up field_content +# already grows to swallow the whole value, so ? instead of * +field_value = r"({field_content})?".format(**globals()) + +# header-field = field-name ":" OWS field-value OWS +header_field = ( + r"(?P{field_name})" + r":" + r"{OWS}" + r"(?P{field_value})" + r"{OWS}".format(**globals()) +) + +# https://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7230.html#request.line +# +# request-line = method SP request-target SP HTTP-version CRLF +# method = token +# HTTP-version = HTTP-name "/" DIGIT "." DIGIT +# HTTP-name = %x48.54.54.50 ; "HTTP", case-sensitive +# +# request-target is complicated (see RFC 7230 sec 5.3) -- could be path, full +# URL, host+port (for connect), or even "*", but in any case we are guaranteed +# that it contists of the visible printing characters. +method = token +request_target = r"{vchar}+".format(**globals()) +http_version = r"HTTP/(?P[0-9]\.[0-9])" +request_line = ( + r"(?P{method})" + r" " + r"(?P{request_target})" + r" " + r"{http_version}".format(**globals()) +) + +# https://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7230.html#status.line +# +# status-line = HTTP-version SP status-code SP reason-phrase CRLF +# status-code = 3DIGIT +# reason-phrase = *( HTAB / SP / VCHAR / obs-text ) +status_code = r"[0-9]{3}" +reason_phrase = r"([ \t]|{vchar_or_obs_text})*".format(**globals()) +status_line = ( + r"{http_version}" + r" " + r"(?P{status_code})" + # However, there are apparently a few too many servers out there that just + # leave out the reason phrase: + # https://github.com/scrapy/scrapy/issues/345#issuecomment-281756036 + # https://github.com/seanmonstar/httparse/issues/29 + # so make it optional. ?: is a non-capturing group. + r"(?: (?P{reason_phrase}))?".format(**globals()) +) + +HEXDIG = r"[0-9A-Fa-f]" +# Actually +# +# chunk-size = 1*HEXDIG +# +# but we impose an upper-limit to avoid ridiculosity. len(str(2**64)) == 20 +chunk_size = r"({HEXDIG}){{1,20}}".format(**globals()) +# Actually +# +# chunk-ext = *( ";" chunk-ext-name [ "=" chunk-ext-val ] ) +# +# but we aren't parsing the things so we don't really care. +chunk_ext = r";.*" +chunk_header = ( + r"(?P{chunk_size})" + r"(?P{chunk_ext})?" + r"{OWS}\r\n".format( + **globals() + ) # Even though the specification does not allow for extra whitespaces, + # we are lenient with trailing whitespaces because some servers on the wild use it. +) diff --git a/server/venv/Lib/site-packages/h11/_connection.py b/server/venv/Lib/site-packages/h11/_connection.py new file mode 100644 index 0000000..e37d82a --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_connection.py @@ -0,0 +1,659 @@ +# This contains the main Connection class. Everything in h11 revolves around +# this. +from typing import ( + Any, + Callable, + cast, + Dict, + List, + Optional, + overload, + Tuple, + Type, + Union, +) + +from ._events import ( + ConnectionClosed, + Data, + EndOfMessage, + Event, + InformationalResponse, + Request, + Response, +) +from ._headers import get_comma_header, has_expect_100_continue, set_comma_header +from ._readers import READERS, ReadersType +from ._receivebuffer import ReceiveBuffer +from ._state import ( + _SWITCH_CONNECT, + _SWITCH_UPGRADE, + CLIENT, + ConnectionState, + DONE, + ERROR, + MIGHT_SWITCH_PROTOCOL, + SEND_BODY, + SERVER, + SWITCHED_PROTOCOL, +) +from ._util import ( # Import the internal things we need + LocalProtocolError, + RemoteProtocolError, + Sentinel, +) +from ._writers import WRITERS, WritersType + +# Everything in __all__ gets re-exported as part of the h11 public API. +__all__ = ["Connection", "NEED_DATA", "PAUSED"] + + +class NEED_DATA(Sentinel, metaclass=Sentinel): + pass + + +class PAUSED(Sentinel, metaclass=Sentinel): + pass + + +# If we ever have this much buffered without it making a complete parseable +# event, we error out. The only time we really buffer is when reading the +# request/response line + headers together, so this is effectively the limit on +# the size of that. +# +# Some precedents for defaults: +# - node.js: 80 * 1024 +# - tomcat: 8 * 1024 +# - IIS: 16 * 1024 +# - Apache: <8 KiB per line> +DEFAULT_MAX_INCOMPLETE_EVENT_SIZE = 16 * 1024 + + +# RFC 7230's rules for connection lifecycles: +# - If either side says they want to close the connection, then the connection +# must close. +# - HTTP/1.1 defaults to keep-alive unless someone says Connection: close +# - HTTP/1.0 defaults to close unless both sides say Connection: keep-alive +# (and even this is a mess -- e.g. if you're implementing a proxy then +# sending Connection: keep-alive is forbidden). +# +# We simplify life by simply not supporting keep-alive with HTTP/1.0 peers. So +# our rule is: +# - If someone says Connection: close, we will close +# - If someone uses HTTP/1.0, we will close. +def _keep_alive(event: Union[Request, Response]) -> bool: + connection = get_comma_header(event.headers, b"connection") + if b"close" in connection: + return False + if getattr(event, "http_version", b"1.1") < b"1.1": + return False + return True + + +def _body_framing( + request_method: bytes, event: Union[Request, Response] +) -> Tuple[str, Union[Tuple[()], Tuple[int]]]: + # Called when we enter SEND_BODY to figure out framing information for + # this body. + # + # These are the only two events that can trigger a SEND_BODY state: + assert type(event) in (Request, Response) + # Returns one of: + # + # ("content-length", count) + # ("chunked", ()) + # ("http/1.0", ()) + # + # which are (lookup key, *args) for constructing body reader/writer + # objects. + # + # Reference: https://tools.ietf.org/html/rfc7230#section-3.3.3 + # + # Step 1: some responses always have an empty body, regardless of what the + # headers say. + if type(event) is Response: + if ( + event.status_code in (204, 304) + or request_method == b"HEAD" + or (request_method == b"CONNECT" and 200 <= event.status_code < 300) + ): + return ("content-length", (0,)) + # Section 3.3.3 also lists another case -- responses with status_code + # < 200. For us these are InformationalResponses, not Responses, so + # they can't get into this function in the first place. + assert event.status_code >= 200 + + # Step 2: check for Transfer-Encoding (T-E beats C-L): + transfer_encodings = get_comma_header(event.headers, b"transfer-encoding") + if transfer_encodings: + assert transfer_encodings == [b"chunked"] + return ("chunked", ()) + + # Step 3: check for Content-Length + content_lengths = get_comma_header(event.headers, b"content-length") + if content_lengths: + return ("content-length", (int(content_lengths[0]),)) + + # Step 4: no applicable headers; fallback/default depends on type + if type(event) is Request: + return ("content-length", (0,)) + else: + return ("http/1.0", ()) + + +################################################################ +# +# The main Connection class +# +################################################################ + + +class Connection: + """An object encapsulating the state of an HTTP connection. + + Args: + our_role: If you're implementing a client, pass :data:`h11.CLIENT`. If + you're implementing a server, pass :data:`h11.SERVER`. + + max_incomplete_event_size (int): + The maximum number of bytes we're willing to buffer of an + incomplete event. In practice this mostly sets a limit on the + maximum size of the request/response line + headers. If this is + exceeded, then :meth:`next_event` will raise + :exc:`RemoteProtocolError`. + + """ + + def __init__( + self, + our_role: Type[Sentinel], + max_incomplete_event_size: int = DEFAULT_MAX_INCOMPLETE_EVENT_SIZE, + ) -> None: + self._max_incomplete_event_size = max_incomplete_event_size + # State and role tracking + if our_role not in (CLIENT, SERVER): + raise ValueError(f"expected CLIENT or SERVER, not {our_role!r}") + self.our_role = our_role + self.their_role: Type[Sentinel] + if our_role is CLIENT: + self.their_role = SERVER + else: + self.their_role = CLIENT + self._cstate = ConnectionState() + + # Callables for converting data->events or vice-versa given the + # current state + self._writer = self._get_io_object(self.our_role, None, WRITERS) + self._reader = self._get_io_object(self.their_role, None, READERS) + + # Holds any unprocessed received data + self._receive_buffer = ReceiveBuffer() + # If this is true, then it indicates that the incoming connection was + # closed *after* the end of whatever's in self._receive_buffer: + self._receive_buffer_closed = False + + # Extra bits of state that don't fit into the state machine. + # + # These two are only used to interpret framing headers for figuring + # out how to read/write response bodies. their_http_version is also + # made available as a convenient public API. + self.their_http_version: Optional[bytes] = None + self._request_method: Optional[bytes] = None + # This is pure flow-control and doesn't at all affect the set of legal + # transitions, so no need to bother ConnectionState with it: + self.client_is_waiting_for_100_continue = False + + @property + def states(self) -> Dict[Type[Sentinel], Type[Sentinel]]: + """A dictionary like:: + + {CLIENT: , SERVER: } + + See :ref:`state-machine` for details. + + """ + return dict(self._cstate.states) + + @property + def our_state(self) -> Type[Sentinel]: + """The current state of whichever role we are playing. See + :ref:`state-machine` for details. + """ + return self._cstate.states[self.our_role] + + @property + def their_state(self) -> Type[Sentinel]: + """The current state of whichever role we are NOT playing. See + :ref:`state-machine` for details. + """ + return self._cstate.states[self.their_role] + + @property + def they_are_waiting_for_100_continue(self) -> bool: + return self.their_role is CLIENT and self.client_is_waiting_for_100_continue + + def start_next_cycle(self) -> None: + """Attempt to reset our connection state for a new request/response + cycle. + + If both client and server are in :data:`DONE` state, then resets them + both to :data:`IDLE` state in preparation for a new request/response + cycle on this same connection. Otherwise, raises a + :exc:`LocalProtocolError`. + + See :ref:`keepalive-and-pipelining`. + + """ + old_states = dict(self._cstate.states) + self._cstate.start_next_cycle() + self._request_method = None + # self.their_http_version gets left alone, since it presumably lasts + # beyond a single request/response cycle + assert not self.client_is_waiting_for_100_continue + self._respond_to_state_changes(old_states) + + def _process_error(self, role: Type[Sentinel]) -> None: + old_states = dict(self._cstate.states) + self._cstate.process_error(role) + self._respond_to_state_changes(old_states) + + def _server_switch_event(self, event: Event) -> Optional[Type[Sentinel]]: + if type(event) is InformationalResponse and event.status_code == 101: + return _SWITCH_UPGRADE + if type(event) is Response: + if ( + _SWITCH_CONNECT in self._cstate.pending_switch_proposals + and 200 <= event.status_code < 300 + ): + return _SWITCH_CONNECT + return None + + # All events go through here + def _process_event(self, role: Type[Sentinel], event: Event) -> None: + # First, pass the event through the state machine to make sure it + # succeeds. + old_states = dict(self._cstate.states) + if role is CLIENT and type(event) is Request: + if event.method == b"CONNECT": + self._cstate.process_client_switch_proposal(_SWITCH_CONNECT) + if get_comma_header(event.headers, b"upgrade"): + self._cstate.process_client_switch_proposal(_SWITCH_UPGRADE) + server_switch_event = None + if role is SERVER: + server_switch_event = self._server_switch_event(event) + self._cstate.process_event(role, type(event), server_switch_event) + + # Then perform the updates triggered by it. + + if type(event) is Request: + self._request_method = event.method + + if role is self.their_role and type(event) in ( + Request, + Response, + InformationalResponse, + ): + event = cast(Union[Request, Response, InformationalResponse], event) + self.their_http_version = event.http_version + + # Keep alive handling + # + # RFC 7230 doesn't really say what one should do if Connection: close + # shows up on a 1xx InformationalResponse. I think the idea is that + # this is not supposed to happen. In any case, if it does happen, we + # ignore it. + if type(event) in (Request, Response) and not _keep_alive( + cast(Union[Request, Response], event) + ): + self._cstate.process_keep_alive_disabled() + + # 100-continue + if type(event) is Request and has_expect_100_continue(event): + self.client_is_waiting_for_100_continue = True + if type(event) in (InformationalResponse, Response): + self.client_is_waiting_for_100_continue = False + if role is CLIENT and type(event) in (Data, EndOfMessage): + self.client_is_waiting_for_100_continue = False + + self._respond_to_state_changes(old_states, event) + + def _get_io_object( + self, + role: Type[Sentinel], + event: Optional[Event], + io_dict: Union[ReadersType, WritersType], + ) -> Optional[Callable[..., Any]]: + # event may be None; it's only used when entering SEND_BODY + state = self._cstate.states[role] + if state is SEND_BODY: + # Special case: the io_dict has a dict of reader/writer factories + # that depend on the request/response framing. + framing_type, args = _body_framing( + cast(bytes, self._request_method), cast(Union[Request, Response], event) + ) + return io_dict[SEND_BODY][framing_type](*args) # type: ignore[index] + else: + # General case: the io_dict just has the appropriate reader/writer + # for this state + return io_dict.get((role, state)) # type: ignore[return-value] + + # This must be called after any action that might have caused + # self._cstate.states to change. + def _respond_to_state_changes( + self, + old_states: Dict[Type[Sentinel], Type[Sentinel]], + event: Optional[Event] = None, + ) -> None: + # Update reader/writer + if self.our_state != old_states[self.our_role]: + self._writer = self._get_io_object(self.our_role, event, WRITERS) + if self.their_state != old_states[self.their_role]: + self._reader = self._get_io_object(self.their_role, event, READERS) + + @property + def trailing_data(self) -> Tuple[bytes, bool]: + """Data that has been received, but not yet processed, represented as + a tuple with two elements, where the first is a byte-string containing + the unprocessed data itself, and the second is a bool that is True if + the receive connection was closed. + + See :ref:`switching-protocols` for discussion of why you'd want this. + """ + return (bytes(self._receive_buffer), self._receive_buffer_closed) + + def receive_data(self, data: bytes) -> None: + """Add data to our internal receive buffer. + + This does not actually do any processing on the data, just stores + it. To trigger processing, you have to call :meth:`next_event`. + + Args: + data (:term:`bytes-like object`): + The new data that was just received. + + Special case: If *data* is an empty byte-string like ``b""``, + then this indicates that the remote side has closed the + connection (end of file). Normally this is convenient, because + standard Python APIs like :meth:`file.read` or + :meth:`socket.recv` use ``b""`` to indicate end-of-file, while + other failures to read are indicated using other mechanisms + like raising :exc:`TimeoutError`. When using such an API you + can just blindly pass through whatever you get from ``read`` + to :meth:`receive_data`, and everything will work. + + But, if you have an API where reading an empty string is a + valid non-EOF condition, then you need to be aware of this and + make sure to check for such strings and avoid passing them to + :meth:`receive_data`. + + Returns: + Nothing, but after calling this you should call :meth:`next_event` + to parse the newly received data. + + Raises: + RuntimeError: + Raised if you pass an empty *data*, indicating EOF, and then + pass a non-empty *data*, indicating more data that somehow + arrived after the EOF. + + (Calling ``receive_data(b"")`` multiple times is fine, + and equivalent to calling it once.) + + """ + if data: + if self._receive_buffer_closed: + raise RuntimeError("received close, then received more data?") + self._receive_buffer += data + else: + self._receive_buffer_closed = True + + def _extract_next_receive_event( + self, + ) -> Union[Event, Type[NEED_DATA], Type[PAUSED]]: + state = self.their_state + # We don't pause immediately when they enter DONE, because even in + # DONE state we can still process a ConnectionClosed() event. But + # if we have data in our buffer, then we definitely aren't getting + # a ConnectionClosed() immediately and we need to pause. + if state is DONE and self._receive_buffer: + return PAUSED + if state is MIGHT_SWITCH_PROTOCOL or state is SWITCHED_PROTOCOL: + return PAUSED + assert self._reader is not None + event = self._reader(self._receive_buffer) + if event is None: + if not self._receive_buffer and self._receive_buffer_closed: + # In some unusual cases (basically just HTTP/1.0 bodies), EOF + # triggers an actual protocol event; in that case, we want to + # return that event, and then the state will change and we'll + # get called again to generate the actual ConnectionClosed(). + if hasattr(self._reader, "read_eof"): + event = self._reader.read_eof() + else: + event = ConnectionClosed() + if event is None: + event = NEED_DATA + return event # type: ignore[no-any-return] + + def next_event(self) -> Union[Event, Type[NEED_DATA], Type[PAUSED]]: + """Parse the next event out of our receive buffer, update our internal + state, and return it. + + This is a mutating operation -- think of it like calling :func:`next` + on an iterator. + + Returns: + : One of three things: + + 1) An event object -- see :ref:`events`. + + 2) The special constant :data:`NEED_DATA`, which indicates that + you need to read more data from your socket and pass it to + :meth:`receive_data` before this method will be able to return + any more events. + + 3) The special constant :data:`PAUSED`, which indicates that we + are not in a state where we can process incoming data (usually + because the peer has finished their part of the current + request/response cycle, and you have not yet called + :meth:`start_next_cycle`). See :ref:`flow-control` for details. + + Raises: + RemoteProtocolError: + The peer has misbehaved. You should close the connection + (possibly after sending some kind of 4xx response). + + Once this method returns :class:`ConnectionClosed` once, then all + subsequent calls will also return :class:`ConnectionClosed`. + + If this method raises any exception besides :exc:`RemoteProtocolError` + then that's a bug -- if it happens please file a bug report! + + If this method raises any exception then it also sets + :attr:`Connection.their_state` to :data:`ERROR` -- see + :ref:`error-handling` for discussion. + + """ + + if self.their_state is ERROR: + raise RemoteProtocolError("Can't receive data when peer state is ERROR") + try: + event = self._extract_next_receive_event() + if event not in [NEED_DATA, PAUSED]: + self._process_event(self.their_role, cast(Event, event)) + if event is NEED_DATA: + if len(self._receive_buffer) > self._max_incomplete_event_size: + # 431 is "Request header fields too large" which is pretty + # much the only situation where we can get here + raise RemoteProtocolError( + "Receive buffer too long", error_status_hint=431 + ) + if self._receive_buffer_closed: + # We're still trying to complete some event, but that's + # never going to happen because no more data is coming + raise RemoteProtocolError("peer unexpectedly closed connection") + return event + except BaseException as exc: + self._process_error(self.their_role) + if isinstance(exc, LocalProtocolError): + exc._reraise_as_remote_protocol_error() + else: + raise + + @overload + def send(self, event: ConnectionClosed) -> None: + ... + + @overload + def send( + self, event: Union[Request, InformationalResponse, Response, Data, EndOfMessage] + ) -> bytes: + ... + + @overload + def send(self, event: Event) -> Optional[bytes]: + ... + + def send(self, event: Event) -> Optional[bytes]: + """Convert a high-level event into bytes that can be sent to the peer, + while updating our internal state machine. + + Args: + event: The :ref:`event ` to send. + + Returns: + If ``type(event) is ConnectionClosed``, then returns + ``None``. Otherwise, returns a :term:`bytes-like object`. + + Raises: + LocalProtocolError: + Sending this event at this time would violate our + understanding of the HTTP/1.1 protocol. + + If this method raises any exception then it also sets + :attr:`Connection.our_state` to :data:`ERROR` -- see + :ref:`error-handling` for discussion. + + """ + data_list = self.send_with_data_passthrough(event) + if data_list is None: + return None + else: + return b"".join(data_list) + + def send_with_data_passthrough(self, event: Event) -> Optional[List[bytes]]: + """Identical to :meth:`send`, except that in situations where + :meth:`send` returns a single :term:`bytes-like object`, this instead + returns a list of them -- and when sending a :class:`Data` event, this + list is guaranteed to contain the exact object you passed in as + :attr:`Data.data`. See :ref:`sendfile` for discussion. + + """ + if self.our_state is ERROR: + raise LocalProtocolError("Can't send data when our state is ERROR") + try: + if type(event) is Response: + event = self._clean_up_response_headers_for_sending(event) + # We want to call _process_event before calling the writer, + # because if someone tries to do something invalid then this will + # give a sensible error message, while our writers all just assume + # they will only receive valid events. But, _process_event might + # change self._writer. So we have to do a little dance: + writer = self._writer + self._process_event(self.our_role, event) + if type(event) is ConnectionClosed: + return None + else: + # In any situation where writer is None, process_event should + # have raised ProtocolError + assert writer is not None + data_list: List[bytes] = [] + writer(event, data_list.append) + return data_list + except: + self._process_error(self.our_role) + raise + + def send_failed(self) -> None: + """Notify the state machine that we failed to send the data it gave + us. + + This causes :attr:`Connection.our_state` to immediately become + :data:`ERROR` -- see :ref:`error-handling` for discussion. + + """ + self._process_error(self.our_role) + + # When sending a Response, we take responsibility for a few things: + # + # - Sometimes you MUST set Connection: close. We take care of those + # times. (You can also set it yourself if you want, and if you do then + # we'll respect that and close the connection at the right time. But you + # don't have to worry about that unless you want to.) + # + # - The user has to set Content-Length if they want it. Otherwise, for + # responses that have bodies (e.g. not HEAD), then we will automatically + # select the right mechanism for streaming a body of unknown length, + # which depends on depending on the peer's HTTP version. + # + # This function's *only* responsibility is making sure headers are set up + # right -- everything downstream just looks at the headers. There are no + # side channels. + def _clean_up_response_headers_for_sending(self, response: Response) -> Response: + assert type(response) is Response + + headers = response.headers + need_close = False + + # HEAD requests need some special handling: they always act like they + # have Content-Length: 0, and that's how _body_framing treats + # them. But their headers are supposed to match what we would send if + # the request was a GET. (Technically there is one deviation allowed: + # we're allowed to leave out the framing headers -- see + # https://tools.ietf.org/html/rfc7231#section-4.3.2 . But it's just as + # easy to get them right.) + method_for_choosing_headers = cast(bytes, self._request_method) + if method_for_choosing_headers == b"HEAD": + method_for_choosing_headers = b"GET" + framing_type, _ = _body_framing(method_for_choosing_headers, response) + if framing_type in ("chunked", "http/1.0"): + # This response has a body of unknown length. + # If our peer is HTTP/1.1, we use Transfer-Encoding: chunked + # If our peer is HTTP/1.0, we use no framing headers, and close the + # connection afterwards. + # + # Make sure to clear Content-Length (in principle user could have + # set both and then we ignored Content-Length b/c + # Transfer-Encoding overwrote it -- this would be naughty of them, + # but the HTTP spec says that if our peer does this then we have + # to fix it instead of erroring out, so we'll accord the user the + # same respect). + headers = set_comma_header(headers, b"content-length", []) + if self.their_http_version is None or self.their_http_version < b"1.1": + # Either we never got a valid request and are sending back an + # error (their_http_version is None), so we assume the worst; + # or else we did get a valid HTTP/1.0 request, so we know that + # they don't understand chunked encoding. + headers = set_comma_header(headers, b"transfer-encoding", []) + # This is actually redundant ATM, since currently we + # unconditionally disable keep-alive when talking to HTTP/1.0 + # peers. But let's be defensive just in case we add + # Connection: keep-alive support later: + if self._request_method != b"HEAD": + need_close = True + else: + headers = set_comma_header(headers, b"transfer-encoding", [b"chunked"]) + + if not self._cstate.keep_alive or need_close: + # Make sure Connection: close is set + connection = set(get_comma_header(headers, b"connection")) + connection.discard(b"keep-alive") + connection.add(b"close") + headers = set_comma_header(headers, b"connection", sorted(connection)) + + return Response( + headers=headers, + status_code=response.status_code, + http_version=response.http_version, + reason=response.reason, + ) diff --git a/server/venv/Lib/site-packages/h11/_events.py b/server/venv/Lib/site-packages/h11/_events.py new file mode 100644 index 0000000..ca1c3ad --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_events.py @@ -0,0 +1,369 @@ +# High level events that make up HTTP/1.1 conversations. Loosely inspired by +# the corresponding events in hyper-h2: +# +# http://python-hyper.org/h2/en/stable/api.html#events +# +# Don't subclass these. Stuff will break. + +import re +from abc import ABC +from dataclasses import dataclass +from typing import List, Tuple, Union + +from ._abnf import method, request_target +from ._headers import Headers, normalize_and_validate +from ._util import bytesify, LocalProtocolError, validate + +# Everything in __all__ gets re-exported as part of the h11 public API. +__all__ = [ + "Event", + "Request", + "InformationalResponse", + "Response", + "Data", + "EndOfMessage", + "ConnectionClosed", +] + +method_re = re.compile(method.encode("ascii")) +request_target_re = re.compile(request_target.encode("ascii")) + + +class Event(ABC): + """ + Base class for h11 events. + """ + + __slots__ = () + + +@dataclass(init=False, frozen=True) +class Request(Event): + """The beginning of an HTTP request. + + Fields: + + .. attribute:: method + + An HTTP method, e.g. ``b"GET"`` or ``b"POST"``. Always a byte + string. :term:`Bytes-like objects ` and native + strings containing only ascii characters will be automatically + converted to byte strings. + + .. attribute:: target + + The target of an HTTP request, e.g. ``b"/index.html"``, or one of the + more exotic formats described in `RFC 7320, section 5.3 + `_. Always a byte + string. :term:`Bytes-like objects ` and native + strings containing only ascii characters will be automatically + converted to byte strings. + + .. attribute:: headers + + Request headers, represented as a list of (name, value) pairs. See + :ref:`the header normalization rules ` for details. + + .. attribute:: http_version + + The HTTP protocol version, represented as a byte string like + ``b"1.1"``. See :ref:`the HTTP version normalization rules + ` for details. + + """ + + __slots__ = ("method", "headers", "target", "http_version") + + method: bytes + headers: Headers + target: bytes + http_version: bytes + + def __init__( + self, + *, + method: Union[bytes, str], + headers: Union[Headers, List[Tuple[bytes, bytes]], List[Tuple[str, str]]], + target: Union[bytes, str], + http_version: Union[bytes, str] = b"1.1", + _parsed: bool = False, + ) -> None: + super().__init__() + if isinstance(headers, Headers): + object.__setattr__(self, "headers", headers) + else: + object.__setattr__( + self, "headers", normalize_and_validate(headers, _parsed=_parsed) + ) + if not _parsed: + object.__setattr__(self, "method", bytesify(method)) + object.__setattr__(self, "target", bytesify(target)) + object.__setattr__(self, "http_version", bytesify(http_version)) + else: + object.__setattr__(self, "method", method) + object.__setattr__(self, "target", target) + object.__setattr__(self, "http_version", http_version) + + # "A server MUST respond with a 400 (Bad Request) status code to any + # HTTP/1.1 request message that lacks a Host header field and to any + # request message that contains more than one Host header field or a + # Host header field with an invalid field-value." + # -- https://tools.ietf.org/html/rfc7230#section-5.4 + host_count = 0 + for name, value in self.headers: + if name == b"host": + host_count += 1 + if self.http_version == b"1.1" and host_count == 0: + raise LocalProtocolError("Missing mandatory Host: header") + if host_count > 1: + raise LocalProtocolError("Found multiple Host: headers") + + validate(method_re, self.method, "Illegal method characters") + validate(request_target_re, self.target, "Illegal target characters") + + # This is an unhashable type. + __hash__ = None # type: ignore + + +@dataclass(init=False, frozen=True) +class _ResponseBase(Event): + __slots__ = ("headers", "http_version", "reason", "status_code") + + headers: Headers + http_version: bytes + reason: bytes + status_code: int + + def __init__( + self, + *, + headers: Union[Headers, List[Tuple[bytes, bytes]], List[Tuple[str, str]]], + status_code: int, + http_version: Union[bytes, str] = b"1.1", + reason: Union[bytes, str] = b"", + _parsed: bool = False, + ) -> None: + super().__init__() + if isinstance(headers, Headers): + object.__setattr__(self, "headers", headers) + else: + object.__setattr__( + self, "headers", normalize_and_validate(headers, _parsed=_parsed) + ) + if not _parsed: + object.__setattr__(self, "reason", bytesify(reason)) + object.__setattr__(self, "http_version", bytesify(http_version)) + if not isinstance(status_code, int): + raise LocalProtocolError("status code must be integer") + # Because IntEnum objects are instances of int, but aren't + # duck-compatible (sigh), see gh-72. + object.__setattr__(self, "status_code", int(status_code)) + else: + object.__setattr__(self, "reason", reason) + object.__setattr__(self, "http_version", http_version) + object.__setattr__(self, "status_code", status_code) + + self.__post_init__() + + def __post_init__(self) -> None: + pass + + # This is an unhashable type. + __hash__ = None # type: ignore + + +@dataclass(init=False, frozen=True) +class InformationalResponse(_ResponseBase): + """An HTTP informational response. + + Fields: + + .. attribute:: status_code + + The status code of this response, as an integer. For an + :class:`InformationalResponse`, this is always in the range [100, + 200). + + .. attribute:: headers + + Request headers, represented as a list of (name, value) pairs. See + :ref:`the header normalization rules ` for + details. + + .. attribute:: http_version + + The HTTP protocol version, represented as a byte string like + ``b"1.1"``. See :ref:`the HTTP version normalization rules + ` for details. + + .. attribute:: reason + + The reason phrase of this response, as a byte string. For example: + ``b"OK"``, or ``b"Not Found"``. + + """ + + def __post_init__(self) -> None: + if not (100 <= self.status_code < 200): + raise LocalProtocolError( + "InformationalResponse status_code should be in range " + "[100, 200), not {}".format(self.status_code) + ) + + # This is an unhashable type. + __hash__ = None # type: ignore + + +@dataclass(init=False, frozen=True) +class Response(_ResponseBase): + """The beginning of an HTTP response. + + Fields: + + .. attribute:: status_code + + The status code of this response, as an integer. For an + :class:`Response`, this is always in the range [200, + 1000). + + .. attribute:: headers + + Request headers, represented as a list of (name, value) pairs. See + :ref:`the header normalization rules ` for details. + + .. attribute:: http_version + + The HTTP protocol version, represented as a byte string like + ``b"1.1"``. See :ref:`the HTTP version normalization rules + ` for details. + + .. attribute:: reason + + The reason phrase of this response, as a byte string. For example: + ``b"OK"``, or ``b"Not Found"``. + + """ + + def __post_init__(self) -> None: + if not (200 <= self.status_code < 1000): + raise LocalProtocolError( + "Response status_code should be in range [200, 1000), not {}".format( + self.status_code + ) + ) + + # This is an unhashable type. + __hash__ = None # type: ignore + + +@dataclass(init=False, frozen=True) +class Data(Event): + """Part of an HTTP message body. + + Fields: + + .. attribute:: data + + A :term:`bytes-like object` containing part of a message body. Or, if + using the ``combine=False`` argument to :meth:`Connection.send`, then + any object that your socket writing code knows what to do with, and for + which calling :func:`len` returns the number of bytes that will be + written -- see :ref:`sendfile` for details. + + .. attribute:: chunk_start + + A marker that indicates whether this data object is from the start of a + chunked transfer encoding chunk. This field is ignored when when a Data + event is provided to :meth:`Connection.send`: it is only valid on + events emitted from :meth:`Connection.next_event`. You probably + shouldn't use this attribute at all; see + :ref:`chunk-delimiters-are-bad` for details. + + .. attribute:: chunk_end + + A marker that indicates whether this data object is the last for a + given chunked transfer encoding chunk. This field is ignored when when + a Data event is provided to :meth:`Connection.send`: it is only valid + on events emitted from :meth:`Connection.next_event`. You probably + shouldn't use this attribute at all; see + :ref:`chunk-delimiters-are-bad` for details. + + """ + + __slots__ = ("data", "chunk_start", "chunk_end") + + data: bytes + chunk_start: bool + chunk_end: bool + + def __init__( + self, data: bytes, chunk_start: bool = False, chunk_end: bool = False + ) -> None: + object.__setattr__(self, "data", data) + object.__setattr__(self, "chunk_start", chunk_start) + object.__setattr__(self, "chunk_end", chunk_end) + + # This is an unhashable type. + __hash__ = None # type: ignore + + +# XX FIXME: "A recipient MUST ignore (or consider as an error) any fields that +# are forbidden to be sent in a trailer, since processing them as if they were +# present in the header section might bypass external security filters." +# https://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7230.html#chunked.trailer.part +# Unfortunately, the list of forbidden fields is long and vague :-/ +@dataclass(init=False, frozen=True) +class EndOfMessage(Event): + """The end of an HTTP message. + + Fields: + + .. attribute:: headers + + Default value: ``[]`` + + Any trailing headers attached to this message, represented as a list of + (name, value) pairs. See :ref:`the header normalization rules + ` for details. + + Must be empty unless ``Transfer-Encoding: chunked`` is in use. + + """ + + __slots__ = ("headers",) + + headers: Headers + + def __init__( + self, + *, + headers: Union[ + Headers, List[Tuple[bytes, bytes]], List[Tuple[str, str]], None + ] = None, + _parsed: bool = False, + ) -> None: + super().__init__() + if headers is None: + headers = Headers([]) + elif not isinstance(headers, Headers): + headers = normalize_and_validate(headers, _parsed=_parsed) + + object.__setattr__(self, "headers", headers) + + # This is an unhashable type. + __hash__ = None # type: ignore + + +@dataclass(frozen=True) +class ConnectionClosed(Event): + """This event indicates that the sender has closed their outgoing + connection. + + Note that this does not necessarily mean that they can't *receive* further + data, because TCP connections are composed to two one-way channels which + can be closed independently. See :ref:`closing` for details. + + No fields. + """ + + pass diff --git a/server/venv/Lib/site-packages/h11/_headers.py b/server/venv/Lib/site-packages/h11/_headers.py new file mode 100644 index 0000000..31da3e2 --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_headers.py @@ -0,0 +1,282 @@ +import re +from typing import AnyStr, cast, List, overload, Sequence, Tuple, TYPE_CHECKING, Union + +from ._abnf import field_name, field_value +from ._util import bytesify, LocalProtocolError, validate + +if TYPE_CHECKING: + from ._events import Request + +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal # type: ignore + +CONTENT_LENGTH_MAX_DIGITS = 20 # allow up to 1 billion TB - 1 + + +# Facts +# ----- +# +# Headers are: +# keys: case-insensitive ascii +# values: mixture of ascii and raw bytes +# +# "Historically, HTTP has allowed field content with text in the ISO-8859-1 +# charset [ISO-8859-1], supporting other charsets only through use of +# [RFC2047] encoding. In practice, most HTTP header field values use only a +# subset of the US-ASCII charset [USASCII]. Newly defined header fields SHOULD +# limit their field values to US-ASCII octets. A recipient SHOULD treat other +# octets in field content (obs-text) as opaque data." +# And it deprecates all non-ascii values +# +# Leading/trailing whitespace in header names is forbidden +# +# Values get leading/trailing whitespace stripped +# +# Content-Disposition actually needs to contain unicode semantically; to +# accomplish this it has a terrifically weird way of encoding the filename +# itself as ascii (and even this still has lots of cross-browser +# incompatibilities) +# +# Order is important: +# "a proxy MUST NOT change the order of these field values when forwarding a +# message" +# (and there are several headers where the order indicates a preference) +# +# Multiple occurences of the same header: +# "A sender MUST NOT generate multiple header fields with the same field name +# in a message unless either the entire field value for that header field is +# defined as a comma-separated list [or the header is Set-Cookie which gets a +# special exception]" - RFC 7230. (cookies are in RFC 6265) +# +# So every header aside from Set-Cookie can be merged by b", ".join if it +# occurs repeatedly. But, of course, they can't necessarily be split by +# .split(b","), because quoting. +# +# Given all this mess (case insensitive, duplicates allowed, order is +# important, ...), there doesn't appear to be any standard way to handle +# headers in Python -- they're almost like dicts, but... actually just +# aren't. For now we punt and just use a super simple representation: headers +# are a list of pairs +# +# [(name1, value1), (name2, value2), ...] +# +# where all entries are bytestrings, names are lowercase and have no +# leading/trailing whitespace, and values are bytestrings with no +# leading/trailing whitespace. Searching and updating are done via naive O(n) +# methods. +# +# Maybe a dict-of-lists would be better? + +_content_length_re = re.compile(rb"[0-9]+") +_field_name_re = re.compile(field_name.encode("ascii")) +_field_value_re = re.compile(field_value.encode("ascii")) + + +class Headers(Sequence[Tuple[bytes, bytes]]): + """ + A list-like interface that allows iterating over headers as byte-pairs + of (lowercased-name, value). + + Internally we actually store the representation as three-tuples, + including both the raw original casing, in order to preserve casing + over-the-wire, and the lowercased name, for case-insensitive comparisions. + + r = Request( + method="GET", + target="/", + headers=[("Host", "example.org"), ("Connection", "keep-alive")], + http_version="1.1", + ) + assert r.headers == [ + (b"host", b"example.org"), + (b"connection", b"keep-alive") + ] + assert r.headers.raw_items() == [ + (b"Host", b"example.org"), + (b"Connection", b"keep-alive") + ] + """ + + __slots__ = "_full_items" + + def __init__(self, full_items: List[Tuple[bytes, bytes, bytes]]) -> None: + self._full_items = full_items + + def __bool__(self) -> bool: + return bool(self._full_items) + + def __eq__(self, other: object) -> bool: + return list(self) == list(other) # type: ignore + + def __len__(self) -> int: + return len(self._full_items) + + def __repr__(self) -> str: + return "" % repr(list(self)) + + def __getitem__(self, idx: int) -> Tuple[bytes, bytes]: # type: ignore[override] + _, name, value = self._full_items[idx] + return (name, value) + + def raw_items(self) -> List[Tuple[bytes, bytes]]: + return [(raw_name, value) for raw_name, _, value in self._full_items] + + +HeaderTypes = Union[ + List[Tuple[bytes, bytes]], + List[Tuple[bytes, str]], + List[Tuple[str, bytes]], + List[Tuple[str, str]], +] + + +@overload +def normalize_and_validate(headers: Headers, _parsed: Literal[True]) -> Headers: + ... + + +@overload +def normalize_and_validate(headers: HeaderTypes, _parsed: Literal[False]) -> Headers: + ... + + +@overload +def normalize_and_validate( + headers: Union[Headers, HeaderTypes], _parsed: bool = False +) -> Headers: + ... + + +def normalize_and_validate( + headers: Union[Headers, HeaderTypes], _parsed: bool = False +) -> Headers: + new_headers = [] + seen_content_length = None + saw_transfer_encoding = False + for name, value in headers: + # For headers coming out of the parser, we can safely skip some steps, + # because it always returns bytes and has already run these regexes + # over the data: + if not _parsed: + name = bytesify(name) + value = bytesify(value) + validate(_field_name_re, name, "Illegal header name {!r}", name) + validate(_field_value_re, value, "Illegal header value {!r}", value) + assert isinstance(name, bytes) + assert isinstance(value, bytes) + + raw_name = name + name = name.lower() + if name == b"content-length": + lengths = {length.strip() for length in value.split(b",")} + if len(lengths) != 1: + raise LocalProtocolError("conflicting Content-Length headers") + value = lengths.pop() + validate(_content_length_re, value, "bad Content-Length") + if len(value) > CONTENT_LENGTH_MAX_DIGITS: + raise LocalProtocolError("bad Content-Length") + if seen_content_length is None: + seen_content_length = value + new_headers.append((raw_name, name, value)) + elif seen_content_length != value: + raise LocalProtocolError("conflicting Content-Length headers") + elif name == b"transfer-encoding": + # "A server that receives a request message with a transfer coding + # it does not understand SHOULD respond with 501 (Not + # Implemented)." + # https://tools.ietf.org/html/rfc7230#section-3.3.1 + if saw_transfer_encoding: + raise LocalProtocolError( + "multiple Transfer-Encoding headers", error_status_hint=501 + ) + # "All transfer-coding names are case-insensitive" + # -- https://tools.ietf.org/html/rfc7230#section-4 + value = value.lower() + if value != b"chunked": + raise LocalProtocolError( + "Only Transfer-Encoding: chunked is supported", + error_status_hint=501, + ) + saw_transfer_encoding = True + new_headers.append((raw_name, name, value)) + else: + new_headers.append((raw_name, name, value)) + return Headers(new_headers) + + +def get_comma_header(headers: Headers, name: bytes) -> List[bytes]: + # Should only be used for headers whose value is a list of + # comma-separated, case-insensitive values. + # + # The header name `name` is expected to be lower-case bytes. + # + # Connection: meets these criteria (including cast insensitivity). + # + # Content-Length: technically is just a single value (1*DIGIT), but the + # standard makes reference to implementations that do multiple values, and + # using this doesn't hurt. Ditto, case insensitivity doesn't things either + # way. + # + # Transfer-Encoding: is more complex (allows for quoted strings), so + # splitting on , is actually wrong. For example, this is legal: + # + # Transfer-Encoding: foo; options="1,2", chunked + # + # and should be parsed as + # + # foo; options="1,2" + # chunked + # + # but this naive function will parse it as + # + # foo; options="1 + # 2" + # chunked + # + # However, this is okay because the only thing we are going to do with + # any Transfer-Encoding is reject ones that aren't just "chunked", so + # both of these will be treated the same anyway. + # + # Expect: the only legal value is the literal string + # "100-continue". Splitting on commas is harmless. Case insensitive. + # + out: List[bytes] = [] + for _, found_name, found_raw_value in headers._full_items: + if found_name == name: + found_raw_value = found_raw_value.lower() + for found_split_value in found_raw_value.split(b","): + found_split_value = found_split_value.strip() + if found_split_value: + out.append(found_split_value) + return out + + +def set_comma_header(headers: Headers, name: bytes, new_values: List[bytes]) -> Headers: + # The header name `name` is expected to be lower-case bytes. + # + # Note that when we store the header we use title casing for the header + # names, in order to match the conventional HTTP header style. + # + # Simply calling `.title()` is a blunt approach, but it's correct + # here given the cases where we're using `set_comma_header`... + # + # Connection, Content-Length, Transfer-Encoding. + new_headers: List[Tuple[bytes, bytes]] = [] + for found_raw_name, found_name, found_raw_value in headers._full_items: + if found_name != name: + new_headers.append((found_raw_name, found_raw_value)) + for new_value in new_values: + new_headers.append((name.title(), new_value)) + return normalize_and_validate(new_headers) + + +def has_expect_100_continue(request: "Request") -> bool: + # https://tools.ietf.org/html/rfc7231#section-5.1.1 + # "A server that receives a 100-continue expectation in an HTTP/1.0 request + # MUST ignore that expectation." + if request.http_version < b"1.1": + return False + expect = get_comma_header(request.headers, b"expect") + return b"100-continue" in expect diff --git a/server/venv/Lib/site-packages/h11/_readers.py b/server/venv/Lib/site-packages/h11/_readers.py new file mode 100644 index 0000000..576804c --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_readers.py @@ -0,0 +1,250 @@ +# Code to read HTTP data +# +# Strategy: each reader is a callable which takes a ReceiveBuffer object, and +# either: +# 1) consumes some of it and returns an Event +# 2) raises a LocalProtocolError (for consistency -- e.g. we call validate() +# and it might raise a LocalProtocolError, so simpler just to always use +# this) +# 3) returns None, meaning "I need more data" +# +# If they have a .read_eof attribute, then this will be called if an EOF is +# received -- but this is optional. Either way, the actual ConnectionClosed +# event will be generated afterwards. +# +# READERS is a dict describing how to pick a reader. It maps states to either: +# - a reader +# - or, for body readers, a dict of per-framing reader factories + +import re +from typing import Any, Callable, Dict, Iterable, NoReturn, Optional, Tuple, Type, Union + +from ._abnf import chunk_header, header_field, request_line, status_line +from ._events import Data, EndOfMessage, InformationalResponse, Request, Response +from ._receivebuffer import ReceiveBuffer +from ._state import ( + CLIENT, + CLOSED, + DONE, + IDLE, + MUST_CLOSE, + SEND_BODY, + SEND_RESPONSE, + SERVER, +) +from ._util import LocalProtocolError, RemoteProtocolError, Sentinel, validate + +__all__ = ["READERS"] + +header_field_re = re.compile(header_field.encode("ascii")) +obs_fold_re = re.compile(rb"[ \t]+") + + +def _obsolete_line_fold(lines: Iterable[bytes]) -> Iterable[bytes]: + it = iter(lines) + last: Optional[bytes] = None + for line in it: + match = obs_fold_re.match(line) + if match: + if last is None: + raise LocalProtocolError("continuation line at start of headers") + if not isinstance(last, bytearray): + # Cast to a mutable type, avoiding copy on append to ensure O(n) time + last = bytearray(last) + last += b" " + last += line[match.end() :] + else: + if last is not None: + yield last + last = line + if last is not None: + yield last + + +def _decode_header_lines( + lines: Iterable[bytes], +) -> Iterable[Tuple[bytes, bytes]]: + for line in _obsolete_line_fold(lines): + matches = validate(header_field_re, line, "illegal header line: {!r}", line) + yield (matches["field_name"], matches["field_value"]) + + +request_line_re = re.compile(request_line.encode("ascii")) + + +def maybe_read_from_IDLE_client(buf: ReceiveBuffer) -> Optional[Request]: + lines = buf.maybe_extract_lines() + if lines is None: + if buf.is_next_line_obviously_invalid_request_line(): + raise LocalProtocolError("illegal request line") + return None + if not lines: + raise LocalProtocolError("no request line received") + matches = validate( + request_line_re, lines[0], "illegal request line: {!r}", lines[0] + ) + return Request( + headers=list(_decode_header_lines(lines[1:])), _parsed=True, **matches + ) + + +status_line_re = re.compile(status_line.encode("ascii")) + + +def maybe_read_from_SEND_RESPONSE_server( + buf: ReceiveBuffer, +) -> Union[InformationalResponse, Response, None]: + lines = buf.maybe_extract_lines() + if lines is None: + if buf.is_next_line_obviously_invalid_request_line(): + raise LocalProtocolError("illegal request line") + return None + if not lines: + raise LocalProtocolError("no response line received") + matches = validate(status_line_re, lines[0], "illegal status line: {!r}", lines[0]) + http_version = ( + b"1.1" if matches["http_version"] is None else matches["http_version"] + ) + reason = b"" if matches["reason"] is None else matches["reason"] + status_code = int(matches["status_code"]) + class_: Union[Type[InformationalResponse], Type[Response]] = ( + InformationalResponse if status_code < 200 else Response + ) + return class_( + headers=list(_decode_header_lines(lines[1:])), + _parsed=True, + status_code=status_code, + reason=reason, + http_version=http_version, + ) + + +class ContentLengthReader: + def __init__(self, length: int) -> None: + self._length = length + self._remaining = length + + def __call__(self, buf: ReceiveBuffer) -> Union[Data, EndOfMessage, None]: + if self._remaining == 0: + return EndOfMessage() + data = buf.maybe_extract_at_most(self._remaining) + if data is None: + return None + self._remaining -= len(data) + return Data(data=data) + + def read_eof(self) -> NoReturn: + raise RemoteProtocolError( + "peer closed connection without sending complete message body " + "(received {} bytes, expected {})".format( + self._length - self._remaining, self._length + ) + ) + + +chunk_header_re = re.compile(chunk_header.encode("ascii")) + + +class ChunkedReader: + def __init__(self) -> None: + self._bytes_in_chunk = 0 + # After reading a chunk, we have to throw away the trailing \r\n. + # This tracks the bytes that we need to match and throw away. + self._bytes_to_discard = b"" + self._reading_trailer = False + + def __call__(self, buf: ReceiveBuffer) -> Union[Data, EndOfMessage, None]: + if self._reading_trailer: + lines = buf.maybe_extract_lines() + if lines is None: + return None + return EndOfMessage(headers=list(_decode_header_lines(lines))) + if self._bytes_to_discard: + data = buf.maybe_extract_at_most(len(self._bytes_to_discard)) + if data is None: + return None + if data != self._bytes_to_discard[: len(data)]: + raise LocalProtocolError( + f"malformed chunk footer: {data!r} (expected {self._bytes_to_discard!r})" + ) + self._bytes_to_discard = self._bytes_to_discard[len(data) :] + if self._bytes_to_discard: + return None + # else, fall through and read some more + assert self._bytes_to_discard == b"" + if self._bytes_in_chunk == 0: + # We need to refill our chunk count + chunk_header = buf.maybe_extract_next_line() + if chunk_header is None: + return None + matches = validate( + chunk_header_re, + chunk_header, + "illegal chunk header: {!r}", + chunk_header, + ) + # XX FIXME: we discard chunk extensions. Does anyone care? + self._bytes_in_chunk = int(matches["chunk_size"], base=16) + if self._bytes_in_chunk == 0: + self._reading_trailer = True + return self(buf) + chunk_start = True + else: + chunk_start = False + assert self._bytes_in_chunk > 0 + data = buf.maybe_extract_at_most(self._bytes_in_chunk) + if data is None: + return None + self._bytes_in_chunk -= len(data) + if self._bytes_in_chunk == 0: + self._bytes_to_discard = b"\r\n" + chunk_end = True + else: + chunk_end = False + return Data(data=data, chunk_start=chunk_start, chunk_end=chunk_end) + + def read_eof(self) -> NoReturn: + raise RemoteProtocolError( + "peer closed connection without sending complete message body " + "(incomplete chunked read)" + ) + + +class Http10Reader: + def __call__(self, buf: ReceiveBuffer) -> Optional[Data]: + data = buf.maybe_extract_at_most(999999999) + if data is None: + return None + return Data(data=data) + + def read_eof(self) -> EndOfMessage: + return EndOfMessage() + + +def expect_nothing(buf: ReceiveBuffer) -> None: + if buf: + raise LocalProtocolError("Got data when expecting EOF") + return None + + +ReadersType = Dict[ + Union[Type[Sentinel], Tuple[Type[Sentinel], Type[Sentinel]]], + Union[Callable[..., Any], Dict[str, Callable[..., Any]]], +] + +READERS: ReadersType = { + (CLIENT, IDLE): maybe_read_from_IDLE_client, + (SERVER, IDLE): maybe_read_from_SEND_RESPONSE_server, + (SERVER, SEND_RESPONSE): maybe_read_from_SEND_RESPONSE_server, + (CLIENT, DONE): expect_nothing, + (CLIENT, MUST_CLOSE): expect_nothing, + (CLIENT, CLOSED): expect_nothing, + (SERVER, DONE): expect_nothing, + (SERVER, MUST_CLOSE): expect_nothing, + (SERVER, CLOSED): expect_nothing, + SEND_BODY: { + "chunked": ChunkedReader, + "content-length": ContentLengthReader, + "http/1.0": Http10Reader, + }, +} diff --git a/server/venv/Lib/site-packages/h11/_receivebuffer.py b/server/venv/Lib/site-packages/h11/_receivebuffer.py new file mode 100644 index 0000000..e5c4e08 --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_receivebuffer.py @@ -0,0 +1,153 @@ +import re +import sys +from typing import List, Optional, Union + +__all__ = ["ReceiveBuffer"] + + +# Operations we want to support: +# - find next \r\n or \r\n\r\n (\n or \n\n are also acceptable), +# or wait until there is one +# - read at-most-N bytes +# Goals: +# - on average, do this fast +# - worst case, do this in O(n) where n is the number of bytes processed +# Plan: +# - store bytearray, offset, how far we've searched for a separator token +# - use the how-far-we've-searched data to avoid rescanning +# - while doing a stream of uninterrupted processing, advance offset instead +# of constantly copying +# WARNING: +# - I haven't benchmarked or profiled any of this yet. +# +# Note that starting in Python 3.4, deleting the initial n bytes from a +# bytearray is amortized O(n), thanks to some excellent work by Antoine +# Martin: +# +# https://bugs.python.org/issue19087 +# +# This means that if we only supported 3.4+, we could get rid of the code here +# involving self._start and self.compress, because it's doing exactly the same +# thing that bytearray now does internally. +# +# BUT unfortunately, we still support 2.7, and reading short segments out of a +# long buffer MUST be O(bytes read) to avoid DoS issues, so we can't actually +# delete this code. Yet: +# +# https://pythonclock.org/ +# +# (Two things to double-check first though: make sure PyPy also has the +# optimization, and benchmark to make sure it's a win, since we do have a +# slightly clever thing where we delay calling compress() until we've +# processed a whole event, which could in theory be slightly more efficient +# than the internal bytearray support.) +blank_line_regex = re.compile(b"\n\r?\n", re.MULTILINE) + + +class ReceiveBuffer: + def __init__(self) -> None: + self._data = bytearray() + self._next_line_search = 0 + self._multiple_lines_search = 0 + + def __iadd__(self, byteslike: Union[bytes, bytearray]) -> "ReceiveBuffer": + self._data += byteslike + return self + + def __bool__(self) -> bool: + return bool(len(self)) + + def __len__(self) -> int: + return len(self._data) + + # for @property unprocessed_data + def __bytes__(self) -> bytes: + return bytes(self._data) + + def _extract(self, count: int) -> bytearray: + # extracting an initial slice of the data buffer and return it + out = self._data[:count] + del self._data[:count] + + self._next_line_search = 0 + self._multiple_lines_search = 0 + + return out + + def maybe_extract_at_most(self, count: int) -> Optional[bytearray]: + """ + Extract a fixed number of bytes from the buffer. + """ + out = self._data[:count] + if not out: + return None + + return self._extract(count) + + def maybe_extract_next_line(self) -> Optional[bytearray]: + """ + Extract the first line, if it is completed in the buffer. + """ + # Only search in buffer space that we've not already looked at. + search_start_index = max(0, self._next_line_search - 1) + partial_idx = self._data.find(b"\r\n", search_start_index) + + if partial_idx == -1: + self._next_line_search = len(self._data) + return None + + # + 2 is to compensate len(b"\r\n") + idx = partial_idx + 2 + + return self._extract(idx) + + def maybe_extract_lines(self) -> Optional[List[bytearray]]: + """ + Extract everything up to the first blank line, and return a list of lines. + """ + # Handle the case where we have an immediate empty line. + if self._data[:1] == b"\n": + self._extract(1) + return [] + + if self._data[:2] == b"\r\n": + self._extract(2) + return [] + + # Only search in buffer space that we've not already looked at. + match = blank_line_regex.search(self._data, self._multiple_lines_search) + if match is None: + self._multiple_lines_search = max(0, len(self._data) - 2) + return None + + # Truncate the buffer and return it. + idx = match.span(0)[-1] + out = self._extract(idx) + lines = out.split(b"\n") + + for line in lines: + if line.endswith(b"\r"): + del line[-1] + + assert lines[-2] == lines[-1] == b"" + + del lines[-2:] + + return lines + + # In theory we should wait until `\r\n` before starting to validate + # incoming data. However it's interesting to detect (very) invalid data + # early given they might not even contain `\r\n` at all (hence only + # timeout will get rid of them). + # This is not a 100% effective detection but more of a cheap sanity check + # allowing for early abort in some useful cases. + # This is especially interesting when peer is messing up with HTTPS and + # sent us a TLS stream where we were expecting plain HTTP given all + # versions of TLS so far start handshake with a 0x16 message type code. + def is_next_line_obviously_invalid_request_line(self) -> bool: + try: + # HTTP header line must not contain non-printable characters + # and should not start with a space + return self._data[0] < 0x21 + except IndexError: + return False diff --git a/server/venv/Lib/site-packages/h11/_state.py b/server/venv/Lib/site-packages/h11/_state.py new file mode 100644 index 0000000..3ad444b --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_state.py @@ -0,0 +1,365 @@ +################################################################ +# The core state machine +################################################################ +# +# Rule 1: everything that affects the state machine and state transitions must +# live here in this file. As much as possible goes into the table-based +# representation, but for the bits that don't quite fit, the actual code and +# state must nonetheless live here. +# +# Rule 2: this file does not know about what role we're playing; it only knows +# about HTTP request/response cycles in the abstract. This ensures that we +# don't cheat and apply different rules to local and remote parties. +# +# +# Theory of operation +# =================== +# +# Possibly the simplest way to think about this is that we actually have 5 +# different state machines here. Yes, 5. These are: +# +# 1) The client state, with its complicated automaton (see the docs) +# 2) The server state, with its complicated automaton (see the docs) +# 3) The keep-alive state, with possible states {True, False} +# 4) The SWITCH_CONNECT state, with possible states {False, True} +# 5) The SWITCH_UPGRADE state, with possible states {False, True} +# +# For (3)-(5), the first state listed is the initial state. +# +# (1)-(3) are stored explicitly in member variables. The last +# two are stored implicitly in the pending_switch_proposals set as: +# (state of 4) == (_SWITCH_CONNECT in pending_switch_proposals) +# (state of 5) == (_SWITCH_UPGRADE in pending_switch_proposals) +# +# And each of these machines has two different kinds of transitions: +# +# a) Event-triggered +# b) State-triggered +# +# Event triggered is the obvious thing that you'd think it is: some event +# happens, and if it's the right event at the right time then a transition +# happens. But there are somewhat complicated rules for which machines can +# "see" which events. (As a rule of thumb, if a machine "sees" an event, this +# means two things: the event can affect the machine, and if the machine is +# not in a state where it expects that event then it's an error.) These rules +# are: +# +# 1) The client machine sees all h11.events objects emitted by the client. +# +# 2) The server machine sees all h11.events objects emitted by the server. +# +# It also sees the client's Request event. +# +# And sometimes, server events are annotated with a _SWITCH_* event. For +# example, we can have a (Response, _SWITCH_CONNECT) event, which is +# different from a regular Response event. +# +# 3) The keep-alive machine sees the process_keep_alive_disabled() event +# (which is derived from Request/Response events), and this event +# transitions it from True -> False, or from False -> False. There's no way +# to transition back. +# +# 4&5) The _SWITCH_* machines transition from False->True when we get a +# Request that proposes the relevant type of switch (via +# process_client_switch_proposals), and they go from True->False when we +# get a Response that has no _SWITCH_* annotation. +# +# So that's event-triggered transitions. +# +# State-triggered transitions are less standard. What they do here is couple +# the machines together. The way this works is, when certain *joint* +# configurations of states are achieved, then we automatically transition to a +# new *joint* state. So, for example, if we're ever in a joint state with +# +# client: DONE +# keep-alive: False +# +# then the client state immediately transitions to: +# +# client: MUST_CLOSE +# +# This is fundamentally different from an event-based transition, because it +# doesn't matter how we arrived at the {client: DONE, keep-alive: False} state +# -- maybe the client transitioned SEND_BODY -> DONE, or keep-alive +# transitioned True -> False. Either way, once this precondition is satisfied, +# this transition is immediately triggered. +# +# What if two conflicting state-based transitions get enabled at the same +# time? In practice there's only one case where this arises (client DONE -> +# MIGHT_SWITCH_PROTOCOL versus DONE -> MUST_CLOSE), and we resolve it by +# explicitly prioritizing the DONE -> MIGHT_SWITCH_PROTOCOL transition. +# +# Implementation +# -------------- +# +# The event-triggered transitions for the server and client machines are all +# stored explicitly in a table. Ditto for the state-triggered transitions that +# involve just the server and client state. +# +# The transitions for the other machines, and the state-triggered transitions +# that involve the other machines, are written out as explicit Python code. +# +# It'd be nice if there were some cleaner way to do all this. This isn't +# *too* terrible, but I feel like it could probably be better. +# +# WARNING +# ------- +# +# The script that generates the state machine diagrams for the docs knows how +# to read out the EVENT_TRIGGERED_TRANSITIONS and STATE_TRIGGERED_TRANSITIONS +# tables. But it can't automatically read the transitions that are written +# directly in Python code. So if you touch those, you need to also update the +# script to keep it in sync! +from typing import cast, Dict, Optional, Set, Tuple, Type, Union + +from ._events import * +from ._util import LocalProtocolError, Sentinel + +# Everything in __all__ gets re-exported as part of the h11 public API. +__all__ = [ + "CLIENT", + "SERVER", + "IDLE", + "SEND_RESPONSE", + "SEND_BODY", + "DONE", + "MUST_CLOSE", + "CLOSED", + "MIGHT_SWITCH_PROTOCOL", + "SWITCHED_PROTOCOL", + "ERROR", +] + + +class CLIENT(Sentinel, metaclass=Sentinel): + pass + + +class SERVER(Sentinel, metaclass=Sentinel): + pass + + +# States +class IDLE(Sentinel, metaclass=Sentinel): + pass + + +class SEND_RESPONSE(Sentinel, metaclass=Sentinel): + pass + + +class SEND_BODY(Sentinel, metaclass=Sentinel): + pass + + +class DONE(Sentinel, metaclass=Sentinel): + pass + + +class MUST_CLOSE(Sentinel, metaclass=Sentinel): + pass + + +class CLOSED(Sentinel, metaclass=Sentinel): + pass + + +class ERROR(Sentinel, metaclass=Sentinel): + pass + + +# Switch types +class MIGHT_SWITCH_PROTOCOL(Sentinel, metaclass=Sentinel): + pass + + +class SWITCHED_PROTOCOL(Sentinel, metaclass=Sentinel): + pass + + +class _SWITCH_UPGRADE(Sentinel, metaclass=Sentinel): + pass + + +class _SWITCH_CONNECT(Sentinel, metaclass=Sentinel): + pass + + +EventTransitionType = Dict[ + Type[Sentinel], + Dict[ + Type[Sentinel], + Dict[Union[Type[Event], Tuple[Type[Event], Type[Sentinel]]], Type[Sentinel]], + ], +] + +EVENT_TRIGGERED_TRANSITIONS: EventTransitionType = { + CLIENT: { + IDLE: {Request: SEND_BODY, ConnectionClosed: CLOSED}, + SEND_BODY: {Data: SEND_BODY, EndOfMessage: DONE}, + DONE: {ConnectionClosed: CLOSED}, + MUST_CLOSE: {ConnectionClosed: CLOSED}, + CLOSED: {ConnectionClosed: CLOSED}, + MIGHT_SWITCH_PROTOCOL: {}, + SWITCHED_PROTOCOL: {}, + ERROR: {}, + }, + SERVER: { + IDLE: { + ConnectionClosed: CLOSED, + Response: SEND_BODY, + # Special case: server sees client Request events, in this form + (Request, CLIENT): SEND_RESPONSE, + }, + SEND_RESPONSE: { + InformationalResponse: SEND_RESPONSE, + Response: SEND_BODY, + (InformationalResponse, _SWITCH_UPGRADE): SWITCHED_PROTOCOL, + (Response, _SWITCH_CONNECT): SWITCHED_PROTOCOL, + }, + SEND_BODY: {Data: SEND_BODY, EndOfMessage: DONE}, + DONE: {ConnectionClosed: CLOSED}, + MUST_CLOSE: {ConnectionClosed: CLOSED}, + CLOSED: {ConnectionClosed: CLOSED}, + SWITCHED_PROTOCOL: {}, + ERROR: {}, + }, +} + +StateTransitionType = Dict[ + Tuple[Type[Sentinel], Type[Sentinel]], Dict[Type[Sentinel], Type[Sentinel]] +] + +# NB: there are also some special-case state-triggered transitions hard-coded +# into _fire_state_triggered_transitions below. +STATE_TRIGGERED_TRANSITIONS: StateTransitionType = { + # (Client state, Server state) -> new states + # Protocol negotiation + (MIGHT_SWITCH_PROTOCOL, SWITCHED_PROTOCOL): {CLIENT: SWITCHED_PROTOCOL}, + # Socket shutdown + (CLOSED, DONE): {SERVER: MUST_CLOSE}, + (CLOSED, IDLE): {SERVER: MUST_CLOSE}, + (ERROR, DONE): {SERVER: MUST_CLOSE}, + (DONE, CLOSED): {CLIENT: MUST_CLOSE}, + (IDLE, CLOSED): {CLIENT: MUST_CLOSE}, + (DONE, ERROR): {CLIENT: MUST_CLOSE}, +} + + +class ConnectionState: + def __init__(self) -> None: + # Extra bits of state that don't quite fit into the state model. + + # If this is False then it enables the automatic DONE -> MUST_CLOSE + # transition. Don't set this directly; call .keep_alive_disabled() + self.keep_alive = True + + # This is a subset of {UPGRADE, CONNECT}, containing the proposals + # made by the client for switching protocols. + self.pending_switch_proposals: Set[Type[Sentinel]] = set() + + self.states: Dict[Type[Sentinel], Type[Sentinel]] = {CLIENT: IDLE, SERVER: IDLE} + + def process_error(self, role: Type[Sentinel]) -> None: + self.states[role] = ERROR + self._fire_state_triggered_transitions() + + def process_keep_alive_disabled(self) -> None: + self.keep_alive = False + self._fire_state_triggered_transitions() + + def process_client_switch_proposal(self, switch_event: Type[Sentinel]) -> None: + self.pending_switch_proposals.add(switch_event) + self._fire_state_triggered_transitions() + + def process_event( + self, + role: Type[Sentinel], + event_type: Type[Event], + server_switch_event: Optional[Type[Sentinel]] = None, + ) -> None: + _event_type: Union[Type[Event], Tuple[Type[Event], Type[Sentinel]]] = event_type + if server_switch_event is not None: + assert role is SERVER + if server_switch_event not in self.pending_switch_proposals: + raise LocalProtocolError( + "Received server _SWITCH_UPGRADE event without a pending proposal" + ) + _event_type = (event_type, server_switch_event) + if server_switch_event is None and _event_type is Response: + self.pending_switch_proposals = set() + self._fire_event_triggered_transitions(role, _event_type) + # Special case: the server state does get to see Request + # events. + if _event_type is Request: + assert role is CLIENT + self._fire_event_triggered_transitions(SERVER, (Request, CLIENT)) + self._fire_state_triggered_transitions() + + def _fire_event_triggered_transitions( + self, + role: Type[Sentinel], + event_type: Union[Type[Event], Tuple[Type[Event], Type[Sentinel]]], + ) -> None: + state = self.states[role] + try: + new_state = EVENT_TRIGGERED_TRANSITIONS[role][state][event_type] + except KeyError: + event_type = cast(Type[Event], event_type) + raise LocalProtocolError( + "can't handle event type {} when role={} and state={}".format( + event_type.__name__, role, self.states[role] + ) + ) from None + self.states[role] = new_state + + def _fire_state_triggered_transitions(self) -> None: + # We apply these rules repeatedly until converging on a fixed point + while True: + start_states = dict(self.states) + + # It could happen that both these special-case transitions are + # enabled at the same time: + # + # DONE -> MIGHT_SWITCH_PROTOCOL + # DONE -> MUST_CLOSE + # + # For example, this will always be true of a HTTP/1.0 client + # requesting CONNECT. If this happens, the protocol switch takes + # priority. From there the client will either go to + # SWITCHED_PROTOCOL, in which case it's none of our business when + # they close the connection, or else the server will deny the + # request, in which case the client will go back to DONE and then + # from there to MUST_CLOSE. + if self.pending_switch_proposals: + if self.states[CLIENT] is DONE: + self.states[CLIENT] = MIGHT_SWITCH_PROTOCOL + + if not self.pending_switch_proposals: + if self.states[CLIENT] is MIGHT_SWITCH_PROTOCOL: + self.states[CLIENT] = DONE + + if not self.keep_alive: + for role in (CLIENT, SERVER): + if self.states[role] is DONE: + self.states[role] = MUST_CLOSE + + # Tabular state-triggered transitions + joint_state = (self.states[CLIENT], self.states[SERVER]) + changes = STATE_TRIGGERED_TRANSITIONS.get(joint_state, {}) + self.states.update(changes) + + if self.states == start_states: + # Fixed point reached + return + + def start_next_cycle(self) -> None: + if self.states != {CLIENT: DONE, SERVER: DONE}: + raise LocalProtocolError( + f"not in a reusable state. self.states={self.states}" + ) + # Can't reach DONE/DONE with any of these active, but still, let's be + # sure. + assert self.keep_alive + assert not self.pending_switch_proposals + self.states = {CLIENT: IDLE, SERVER: IDLE} diff --git a/server/venv/Lib/site-packages/h11/_util.py b/server/venv/Lib/site-packages/h11/_util.py new file mode 100644 index 0000000..6718445 --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_util.py @@ -0,0 +1,135 @@ +from typing import Any, Dict, NoReturn, Pattern, Tuple, Type, TypeVar, Union + +__all__ = [ + "ProtocolError", + "LocalProtocolError", + "RemoteProtocolError", + "validate", + "bytesify", +] + + +class ProtocolError(Exception): + """Exception indicating a violation of the HTTP/1.1 protocol. + + This as an abstract base class, with two concrete base classes: + :exc:`LocalProtocolError`, which indicates that you tried to do something + that HTTP/1.1 says is illegal, and :exc:`RemoteProtocolError`, which + indicates that the remote peer tried to do something that HTTP/1.1 says is + illegal. See :ref:`error-handling` for details. + + In addition to the normal :exc:`Exception` features, it has one attribute: + + .. attribute:: error_status_hint + + This gives a suggestion as to what status code a server might use if + this error occurred as part of a request. + + For a :exc:`RemoteProtocolError`, this is useful as a suggestion for + how you might want to respond to a misbehaving peer, if you're + implementing a server. + + For a :exc:`LocalProtocolError`, this can be taken as a suggestion for + how your peer might have responded to *you* if h11 had allowed you to + continue. + + The default is 400 Bad Request, a generic catch-all for protocol + violations. + + """ + + def __init__(self, msg: str, error_status_hint: int = 400) -> None: + if type(self) is ProtocolError: + raise TypeError("tried to directly instantiate ProtocolError") + Exception.__init__(self, msg) + self.error_status_hint = error_status_hint + + +# Strategy: there are a number of public APIs where a LocalProtocolError can +# be raised (send(), all the different event constructors, ...), and only one +# public API where RemoteProtocolError can be raised +# (receive_data()). Therefore we always raise LocalProtocolError internally, +# and then receive_data will translate this into a RemoteProtocolError. +# +# Internally: +# LocalProtocolError is the generic "ProtocolError". +# Externally: +# LocalProtocolError is for local errors and RemoteProtocolError is for +# remote errors. +class LocalProtocolError(ProtocolError): + def _reraise_as_remote_protocol_error(self) -> NoReturn: + # After catching a LocalProtocolError, use this method to re-raise it + # as a RemoteProtocolError. This method must be called from inside an + # except: block. + # + # An easy way to get an equivalent RemoteProtocolError is just to + # modify 'self' in place. + self.__class__ = RemoteProtocolError # type: ignore + # But the re-raising is somewhat non-trivial -- you might think that + # now that we've modified the in-flight exception object, that just + # doing 'raise' to re-raise it would be enough. But it turns out that + # this doesn't work, because Python tracks the exception type + # (exc_info[0]) separately from the exception object (exc_info[1]), + # and we only modified the latter. So we really do need to re-raise + # the new type explicitly. + # On py3, the traceback is part of the exception object, so our + # in-place modification preserved it and we can just re-raise: + raise self + + +class RemoteProtocolError(ProtocolError): + pass + + +def validate( + regex: Pattern[bytes], data: bytes, msg: str = "malformed data", *format_args: Any +) -> Dict[str, bytes]: + match = regex.fullmatch(data) + if not match: + if format_args: + msg = msg.format(*format_args) + raise LocalProtocolError(msg) + return match.groupdict() + + +# Sentinel values +# +# - Inherit identity-based comparison and hashing from object +# - Have a nice repr +# - Have a *bonus property*: type(sentinel) is sentinel +# +# The bonus property is useful if you want to take the return value from +# next_event() and do some sort of dispatch based on type(event). + +_T_Sentinel = TypeVar("_T_Sentinel", bound="Sentinel") + + +class Sentinel(type): + def __new__( + cls: Type[_T_Sentinel], + name: str, + bases: Tuple[type, ...], + namespace: Dict[str, Any], + **kwds: Any + ) -> _T_Sentinel: + assert bases == (Sentinel,) + v = super().__new__(cls, name, bases, namespace, **kwds) + v.__class__ = v # type: ignore + return v + + def __repr__(self) -> str: + return self.__name__ + + +# Used for methods, request targets, HTTP versions, header names, and header +# values. Accepts ascii-strings, or bytes/bytearray/memoryview/..., and always +# returns bytes. +def bytesify(s: Union[bytes, bytearray, memoryview, int, str]) -> bytes: + # Fast-path: + if type(s) is bytes: + return s + if isinstance(s, str): + s = s.encode("ascii") + if isinstance(s, int): + raise TypeError("expected bytes-like object, not int") + return bytes(s) diff --git a/server/venv/Lib/site-packages/h11/_version.py b/server/venv/Lib/site-packages/h11/_version.py new file mode 100644 index 0000000..76e7327 --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_version.py @@ -0,0 +1,16 @@ +# This file must be kept very simple, because it is consumed from several +# places -- it is imported by h11/__init__.py, execfile'd by setup.py, etc. + +# We use a simple scheme: +# 1.0.0 -> 1.0.0+dev -> 1.1.0 -> 1.1.0+dev +# where the +dev versions are never released into the wild, they're just what +# we stick into the VCS in between releases. +# +# This is compatible with PEP 440: +# http://legacy.python.org/dev/peps/pep-0440/ +# via the use of the "local suffix" "+dev", which is disallowed on index +# servers and causes 1.0.0+dev to sort after plain 1.0.0, which is what we +# want. (Contrast with the special suffix 1.0.0.dev, which sorts *before* +# 1.0.0.) + +__version__ = "0.16.0" diff --git a/server/venv/Lib/site-packages/h11/_writers.py b/server/venv/Lib/site-packages/h11/_writers.py new file mode 100644 index 0000000..939cdb9 --- /dev/null +++ b/server/venv/Lib/site-packages/h11/_writers.py @@ -0,0 +1,145 @@ +# Code to read HTTP data +# +# Strategy: each writer takes an event + a write-some-bytes function, which is +# calls. +# +# WRITERS is a dict describing how to pick a reader. It maps states to either: +# - a writer +# - or, for body writers, a dict of framin-dependent writer factories + +from typing import Any, Callable, Dict, List, Tuple, Type, Union + +from ._events import Data, EndOfMessage, Event, InformationalResponse, Request, Response +from ._headers import Headers +from ._state import CLIENT, IDLE, SEND_BODY, SEND_RESPONSE, SERVER +from ._util import LocalProtocolError, Sentinel + +__all__ = ["WRITERS"] + +Writer = Callable[[bytes], Any] + + +def write_headers(headers: Headers, write: Writer) -> None: + # "Since the Host field-value is critical information for handling a + # request, a user agent SHOULD generate Host as the first header field + # following the request-line." - RFC 7230 + raw_items = headers._full_items + for raw_name, name, value in raw_items: + if name == b"host": + write(b"%s: %s\r\n" % (raw_name, value)) + for raw_name, name, value in raw_items: + if name != b"host": + write(b"%s: %s\r\n" % (raw_name, value)) + write(b"\r\n") + + +def write_request(request: Request, write: Writer) -> None: + if request.http_version != b"1.1": + raise LocalProtocolError("I only send HTTP/1.1") + write(b"%s %s HTTP/1.1\r\n" % (request.method, request.target)) + write_headers(request.headers, write) + + +# Shared between InformationalResponse and Response +def write_any_response( + response: Union[InformationalResponse, Response], write: Writer +) -> None: + if response.http_version != b"1.1": + raise LocalProtocolError("I only send HTTP/1.1") + status_bytes = str(response.status_code).encode("ascii") + # We don't bother sending ascii status messages like "OK"; they're + # optional and ignored by the protocol. (But the space after the numeric + # status code is mandatory.) + # + # XX FIXME: could at least make an effort to pull out the status message + # from stdlib's http.HTTPStatus table. Or maybe just steal their enums + # (either by import or copy/paste). We already accept them as status codes + # since they're of type IntEnum < int. + write(b"HTTP/1.1 %s %s\r\n" % (status_bytes, response.reason)) + write_headers(response.headers, write) + + +class BodyWriter: + def __call__(self, event: Event, write: Writer) -> None: + if type(event) is Data: + self.send_data(event.data, write) + elif type(event) is EndOfMessage: + self.send_eom(event.headers, write) + else: # pragma: no cover + assert False + + def send_data(self, data: bytes, write: Writer) -> None: + pass + + def send_eom(self, headers: Headers, write: Writer) -> None: + pass + + +# +# These are all careful not to do anything to 'data' except call len(data) and +# write(data). This allows us to transparently pass-through funny objects, +# like placeholder objects referring to files on disk that will be sent via +# sendfile(2). +# +class ContentLengthWriter(BodyWriter): + def __init__(self, length: int) -> None: + self._length = length + + def send_data(self, data: bytes, write: Writer) -> None: + self._length -= len(data) + if self._length < 0: + raise LocalProtocolError("Too much data for declared Content-Length") + write(data) + + def send_eom(self, headers: Headers, write: Writer) -> None: + if self._length != 0: + raise LocalProtocolError("Too little data for declared Content-Length") + if headers: + raise LocalProtocolError("Content-Length and trailers don't mix") + + +class ChunkedWriter(BodyWriter): + def send_data(self, data: bytes, write: Writer) -> None: + # if we encoded 0-length data in the naive way, it would look like an + # end-of-message. + if not data: + return + write(b"%x\r\n" % len(data)) + write(data) + write(b"\r\n") + + def send_eom(self, headers: Headers, write: Writer) -> None: + write(b"0\r\n") + write_headers(headers, write) + + +class Http10Writer(BodyWriter): + def send_data(self, data: bytes, write: Writer) -> None: + write(data) + + def send_eom(self, headers: Headers, write: Writer) -> None: + if headers: + raise LocalProtocolError("can't send trailers to HTTP/1.0 client") + # no need to close the socket ourselves, that will be taken care of by + # Connection: close machinery + + +WritersType = Dict[ + Union[Tuple[Type[Sentinel], Type[Sentinel]], Type[Sentinel]], + Union[ + Dict[str, Type[BodyWriter]], + Callable[[Union[InformationalResponse, Response], Writer], None], + Callable[[Request, Writer], None], + ], +] + +WRITERS: WritersType = { + (CLIENT, IDLE): write_request, + (SERVER, IDLE): write_any_response, + (SERVER, SEND_RESPONSE): write_any_response, + SEND_BODY: { + "chunked": ChunkedWriter, + "content-length": ContentLengthWriter, + "http/1.0": Http10Writer, + }, +} diff --git a/server/venv/Lib/site-packages/h11/py.typed b/server/venv/Lib/site-packages/h11/py.typed new file mode 100644 index 0000000..f5642f7 --- /dev/null +++ b/server/venv/Lib/site-packages/h11/py.typed @@ -0,0 +1 @@ +Marker diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/INSTALLER b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/METADATA b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/METADATA new file mode 100644 index 0000000..5a74ddb --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/METADATA @@ -0,0 +1,131 @@ +Metadata-Version: 2.4 +Name: httptools +Version: 0.8.0 +Summary: A collection of framework independent HTTP protocol utils. +Author-email: Yury Selivanov +License-Expression: MIT +Project-URL: Homepage, https://github.com/MagicStack/httptools +Platform: macOS +Platform: POSIX +Platform: Windows +Classifier: Intended Audience :: Developers +Classifier: Programming Language :: Python :: 3 +Classifier: Operating System :: POSIX +Classifier: Operating System :: MacOS :: MacOS X +Classifier: Environment :: Web Environment +Classifier: Development Status :: 5 - Production/Stable +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +License-File: LICENSE +License-File: vendor/http-parser/LICENSE-MIT +License-File: vendor/llhttp/LICENSE +Dynamic: license-file +Dynamic: platform + +![Tests](https://github.com/MagicStack/httptools/workflows/Tests/badge.svg) + +httptools is a Python binding for the nodejs HTTP parser. + +The package is available on PyPI: `pip install httptools`. + + +# APIs + +httptools contains two classes `httptools.HttpRequestParser`, +`httptools.HttpResponseParser` (fulfilled through +[llhttp](https://github.com/nodejs/llhttp)) and a function for +parsing URLs `httptools.parse_url` (through +[http-parse](https://github.com/nodejs/http-parser) for now). +See unittests for examples. + + +```python + +class HttpRequestParser: + + def __init__(self, protocol): + """HttpRequestParser + + protocol -- a Python object with the following methods + (all optional): + + - on_message_begin() + - on_url(url: bytes) + - on_header(name: bytes, value: bytes) + - on_headers_complete() + - on_body(body: bytes) + - on_message_complete() + - on_chunk_header() + - on_chunk_complete() + - on_status(status: bytes) + """ + + def get_http_version(self) -> str: + """Return an HTTP protocol version.""" + + def should_keep_alive(self) -> bool: + """Return ``True`` if keep-alive mode is preferred.""" + + def should_upgrade(self) -> bool: + """Return ``True`` if the parsed request is a valid Upgrade request. + The method exposes a flag set just before on_headers_complete. + Calling this method earlier will only yield `False`. + """ + + def feed_data(self, data: bytes): + """Feed data to the parser. + + Will eventually trigger callbacks on the ``protocol`` + object. + + On HTTP upgrade, this method will raise an + ``HttpParserUpgrade`` exception, with its sole argument + set to the offset of the non-HTTP data in ``data``. + """ + + def get_method(self) -> bytes: + """Return HTTP request method (GET, HEAD, etc)""" + + +class HttpResponseParser: + + """Has all methods except ``get_method()`` that + HttpRequestParser has.""" + + def get_status_code(self) -> int: + """Return the status code of the HTTP response""" + + +def parse_url(url: bytes): + """Parse URL strings into a structured Python object. + + Returns an instance of ``httptools.URL`` class with the + following attributes: + + - schema: bytes + - host: bytes + - port: int + - path: bytes + - query: bytes + - fragment: bytes + - userinfo: bytes + """ +``` + + +# Development + +1. Clone this repository with + `git clone --recursive git@github.com:MagicStack/httptools.git` + +2. Create a virtual environment with Python 3: + `python3 -m venv envname` + +3. Activate the environment with `source envname/bin/activate` + +4. Run `make` and `make test`. + + +# License + +MIT. diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/RECORD b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/RECORD new file mode 100644 index 0000000..995a598 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/RECORD @@ -0,0 +1,28 @@ +httptools-0.8.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +httptools-0.8.0.dist-info/METADATA,sha256=4rBEKetR3NrzjzwCpmMX5YI0eTFD7h6tybgZiCEEAOY,3663 +httptools-0.8.0.dist-info/RECORD,, +httptools-0.8.0.dist-info/WHEEL,sha256=x5Wpw_tLx5PQKiWdxpqvs0e7Sg-SO0mTWdEADYDGPGA,101 +httptools-0.8.0.dist-info/licenses/LICENSE,sha256=HAKLBZ16WOFMzaOMq87WmMWR_mFP--T3IUMERuhsjWk,1114 +httptools-0.8.0.dist-info/licenses/vendor/http-parser/LICENSE-MIT,sha256=XNG2gsDMusDLMbhqmhEEw-LnsTd6KCuBadJpS0ANt8I,1096 +httptools-0.8.0.dist-info/licenses/vendor/llhttp/LICENSE,sha256=J5AS4CoQrP1Zo_LY8TpJczJTXYccKyfImYiYWwajpDg,1091 +httptools-0.8.0.dist-info/top_level.txt,sha256=APjJKTbZcj0OQ4fdgf2eTCk82nK1n2BFXOD7ky41MPY,10 +httptools/__init__.py,sha256=4jrSwXhVO8VMsFde5pRFIRc9IZgT7IqtRHlMJtc1jJw,759 +httptools/__pycache__/__init__.cpython-313.pyc,, +httptools/__pycache__/_version.cpython-313.pyc,, +httptools/_version.py,sha256=N1JaBh8KG5nLIetvXAKqMVo3dslEx-oJoHf65BywhrU,588 +httptools/parser/__init__.py,sha256=XounAcwcwAKDNLWlEdcPv5EKAgMXgpXV7o7A0-twbp8,704 +httptools/parser/__pycache__/__init__.cpython-313.pyc,, +httptools/parser/__pycache__/errors.cpython-313.pyc,, +httptools/parser/__pycache__/protocol.cpython-313.pyc,, +httptools/parser/cparser.pxd,sha256=XOczyt3PFtYc1r-GVKecEkN5zvBD78NXfd2JhyKOKBg,5144 +httptools/parser/errors.py,sha256=fQeEGiZ6AQH7SIa-7uzMs5QggoXr0A8YBPVHmafSw5Q,596 +httptools/parser/parser.cp313-win_amd64.pyd,sha256=nQCFUQZUoH6krlrR_54ArkC86Grx9d68nHx4xCXtQ1I,123904 +httptools/parser/parser.pyi,sha256=ZRhM6dEoqyJNTdMfgw9TVbkUOQCgkgVBlA3bfApV3dE,2215 +httptools/parser/parser.pyx,sha256=jgDH58PbHrXpT4wGDptP02tGsceOo9qHYSJBdpK5ZEs,15576 +httptools/parser/protocol.py,sha256=_NTcrPAY9tFe4tE--Noa9-mEGc8aBnWamFkPDGGKNiU,601 +httptools/parser/python.pxd,sha256=-pIXNLe1u3u3X51ORUUoHRBMsntzRlQa3z2iBqljTHU,144 +httptools/parser/url_cparser.pxd,sha256=rvPJV7S96tTqKMT0Bk4jHgMy2U0rNY5_NsPa7KG5lio,810 +httptools/parser/url_parser.cp313-win_amd64.pyd,sha256=w_f6JYhVyY72JO3GUPDf2ipx58kQ2d2NBj53QRDvZ0k,50688 +httptools/parser/url_parser.pyi,sha256=hu_X-VBNWIoxf6kOnF0B7Cju0n1VdPwxdkY-Jr4vPRM,305 +httptools/parser/url_parser.pyx,sha256=jzmxvNHzzKTyEgzQZwtx_hhjPXPoENkri-08zwXTkCA,4353 +httptools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/WHEEL b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/WHEEL new file mode 100644 index 0000000..f787649 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: setuptools (82.0.1) +Root-Is-Purelib: false +Tag: cp313-cp313-win_amd64 + diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/LICENSE b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/LICENSE new file mode 100644 index 0000000..79a03ca --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2015 MagicStack Inc. http://magic.io + +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. diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/vendor/http-parser/LICENSE-MIT b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/vendor/http-parser/LICENSE-MIT new file mode 100644 index 0000000..1ec0ab4 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/vendor/http-parser/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright Joyent, Inc. and other Node 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. diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/vendor/llhttp/LICENSE b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/vendor/llhttp/LICENSE new file mode 100644 index 0000000..23682c0 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/licenses/vendor/llhttp/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright © 2018 Fedor Indutny + +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. diff --git a/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/top_level.txt b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/top_level.txt new file mode 100644 index 0000000..bef3b40 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools-0.8.0.dist-info/top_level.txt @@ -0,0 +1 @@ +httptools diff --git a/server/venv/Lib/site-packages/httptools/__init__.py b/server/venv/Lib/site-packages/httptools/__init__.py new file mode 100644 index 0000000..8b7b327 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/__init__.py @@ -0,0 +1,35 @@ +from . import parser +from .parser import ( + HTTPProtocol, + HttpRequestParser, + HttpResponseParser, + HttpParserError, + HttpParserCallbackError, + HttpParserInvalidStatusError, + HttpParserInvalidMethodError, + HttpParserInvalidURLError, + HttpParserUpgrade, + parse_url, +) + +from ._version import __version__ + +__all__ = ( + "parser", + # protocol + "HTTPProtocol", + # parser + "HttpRequestParser", + "HttpResponseParser", + # errors + "HttpParserError", + "HttpParserCallbackError", + "HttpParserInvalidStatusError", + "HttpParserInvalidMethodError", + "HttpParserInvalidURLError", + "HttpParserUpgrade", + # url parser + "parse_url", + # version + "__version__", +) diff --git a/server/venv/Lib/site-packages/httptools/_version.py b/server/venv/Lib/site-packages/httptools/_version.py new file mode 100644 index 0000000..b8b0ad2 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/_version.py @@ -0,0 +1,13 @@ +# This file MUST NOT contain anything but the __version__ assignment. +# +# When making a release, change the value of __version__ +# to an appropriate value, and open a pull request against +# the correct branch (master if making a new feature release). +# The commit message MUST contain a properly formatted release +# log, and the commit must be signed. +# +# The release automation will: build and test the packages for the +# supported platforms, publish the packages on PyPI, merge the PR +# to the target branch, create a Git tag pointing to the commit. + +__version__ = '0.8.0' diff --git a/server/venv/Lib/site-packages/httptools/parser/__init__.py b/server/venv/Lib/site-packages/httptools/parser/__init__.py new file mode 100644 index 0000000..6f57517 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/__init__.py @@ -0,0 +1,28 @@ +from .protocol import HTTPProtocol +from .parser import HttpRequestParser, HttpResponseParser # NoQA +from .errors import ( + HttpParserError, + HttpParserCallbackError, + HttpParserInvalidStatusError, + HttpParserInvalidMethodError, + HttpParserInvalidURLError, + HttpParserUpgrade, +) +from .url_parser import parse_url + +__all__ = ( + # protocol + "HTTPProtocol", + # parser + "HttpRequestParser", + "HttpResponseParser", + # errors + "HttpParserError", + "HttpParserCallbackError", + "HttpParserInvalidStatusError", + "HttpParserInvalidMethodError", + "HttpParserInvalidURLError", + "HttpParserUpgrade", + # url_parser + "parse_url", +) diff --git a/server/venv/Lib/site-packages/httptools/parser/cparser.pxd b/server/venv/Lib/site-packages/httptools/parser/cparser.pxd new file mode 100644 index 0000000..3281864 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/cparser.pxd @@ -0,0 +1,167 @@ +from libc.stdint cimport int32_t, uint8_t, uint16_t, uint64_t + + +cdef extern from "llhttp.h": + struct llhttp__internal_s: + int32_t _index + void *_span_pos0 + void *_span_cb0 + int32_t error + const char *reason + const char *error_pos + void *data + void *_current + uint64_t content_length + uint8_t type + uint8_t method + uint8_t http_major + uint8_t http_minor + uint8_t header_state + uint16_t flags + uint8_t upgrade + uint16_t status_code + uint8_t finish + void *settings + ctypedef llhttp__internal_s llhttp__internal_t + ctypedef llhttp__internal_t llhttp_t + + ctypedef int (*llhttp_data_cb) (llhttp_t*, + const char *at, + size_t length) except -1 + + ctypedef int (*llhttp_cb) (llhttp_t*) except -1 + + struct llhttp_settings_s: + llhttp_cb on_message_begin + llhttp_data_cb on_url + llhttp_data_cb on_status + llhttp_data_cb on_header_field + llhttp_data_cb on_header_value + llhttp_cb on_headers_complete + llhttp_data_cb on_body + llhttp_cb on_message_complete + llhttp_cb on_chunk_header + llhttp_cb on_chunk_complete + ctypedef llhttp_settings_s llhttp_settings_t + + enum llhttp_type: + HTTP_BOTH, + HTTP_REQUEST, + HTTP_RESPONSE + ctypedef llhttp_type llhttp_type_t + + enum llhttp_errno: + HPE_OK, + HPE_INTERNAL, + HPE_STRICT, + HPE_LF_EXPECTED, + HPE_UNEXPECTED_CONTENT_LENGTH, + HPE_CLOSED_CONNECTION, + HPE_INVALID_METHOD, + HPE_INVALID_URL, + HPE_INVALID_CONSTANT, + HPE_INVALID_VERSION, + HPE_INVALID_HEADER_TOKEN, + HPE_INVALID_CONTENT_LENGTH, + HPE_INVALID_CHUNK_SIZE, + HPE_INVALID_STATUS, + HPE_INVALID_EOF_STATE, + HPE_INVALID_TRANSFER_ENCODING, + HPE_CB_MESSAGE_BEGIN, + HPE_CB_HEADERS_COMPLETE, + HPE_CB_MESSAGE_COMPLETE, + HPE_CB_CHUNK_HEADER, + HPE_CB_CHUNK_COMPLETE, + HPE_PAUSED, + HPE_PAUSED_UPGRADE, + HPE_USER + ctypedef llhttp_errno llhttp_errno_t + + enum llhttp_flags: + F_CONNECTION_KEEP_ALIVE, + F_CONNECTION_CLOSE, + F_CONNECTION_UPGRADE, + F_CHUNKED, + F_UPGRADE, + F_CONTENT_LENGTH, + F_SKIPBODY, + F_TRAILING, + F_LENIENT, + F_TRANSFER_ENCODING + ctypedef llhttp_flags llhttp_flags_t + + enum llhttp_method: + HTTP_DELETE, + HTTP_GET, + HTTP_HEAD, + HTTP_POST, + HTTP_PUT, + HTTP_CONNECT, + HTTP_OPTIONS, + HTTP_TRACE, + HTTP_COPY, + HTTP_LOCK, + HTTP_MKCOL, + HTTP_MOVE, + HTTP_PROPFIND, + HTTP_PROPPATCH, + HTTP_SEARCH, + HTTP_UNLOCK, + HTTP_BIND, + HTTP_REBIND, + HTTP_UNBIND, + HTTP_ACL, + HTTP_REPORT, + HTTP_MKACTIVITY, + HTTP_CHECKOUT, + HTTP_MERGE, + HTTP_MSEARCH, + HTTP_NOTIFY, + HTTP_SUBSCRIBE, + HTTP_UNSUBSCRIBE, + HTTP_PATCH, + HTTP_PURGE, + HTTP_MKCALENDAR, + HTTP_LINK, + HTTP_UNLINK, + HTTP_SOURCE, + HTTP_PRI, + HTTP_DESCRIBE, + HTTP_ANNOUNCE, + HTTP_SETUP, + HTTP_PLAY, + HTTP_PAUSE, + HTTP_TEARDOWN, + HTTP_GET_PARAMETER, + HTTP_SET_PARAMETER, + HTTP_REDIRECT, + HTTP_RECORD, + HTTP_FLUSH + ctypedef llhttp_method llhttp_method_t + + void llhttp_init(llhttp_t* parser, llhttp_type_t type, const llhttp_settings_t* settings) + + void llhttp_settings_init(llhttp_settings_t* settings) + + llhttp_errno_t llhttp_execute(llhttp_t* parser, const char* data, size_t len) + + void llhttp_resume_after_upgrade(llhttp_t* parser) + + int llhttp_should_keep_alive(const llhttp_t* parser) + + const char* llhttp_get_error_pos(const llhttp_t* parser) + const char* llhttp_get_error_reason(const llhttp_t* parser) + const char* llhttp_method_name(llhttp_method_t method) + + void llhttp_set_error_reason(llhttp_t* parser, const char* reason); + + void llhttp_set_lenient_headers(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_chunked_length(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_keep_alive(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_transfer_encoding(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_version(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_data_after_close(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_optional_lf_after_cr(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_optional_cr_before_lf(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_optional_crlf_after_chunk(llhttp_t* parser, bint enabled); + void llhttp_set_lenient_spaces_after_chunk_size(llhttp_t* parser, bint enabled); diff --git a/server/venv/Lib/site-packages/httptools/parser/errors.py b/server/venv/Lib/site-packages/httptools/parser/errors.py new file mode 100644 index 0000000..bc24c46 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/errors.py @@ -0,0 +1,30 @@ +__all__ = ('HttpParserError', + 'HttpParserCallbackError', + 'HttpParserInvalidStatusError', + 'HttpParserInvalidMethodError', + 'HttpParserInvalidURLError', + 'HttpParserUpgrade') + + +class HttpParserError(Exception): + pass + + +class HttpParserCallbackError(HttpParserError): + pass + + +class HttpParserInvalidStatusError(HttpParserError): + pass + + +class HttpParserInvalidMethodError(HttpParserError): + pass + + +class HttpParserInvalidURLError(HttpParserError): + pass + + +class HttpParserUpgrade(Exception): + pass diff --git a/server/venv/Lib/site-packages/httptools/parser/parser.cp313-win_amd64.pyd b/server/venv/Lib/site-packages/httptools/parser/parser.cp313-win_amd64.pyd new file mode 100644 index 0000000..d17bfea Binary files /dev/null and b/server/venv/Lib/site-packages/httptools/parser/parser.cp313-win_amd64.pyd differ diff --git a/server/venv/Lib/site-packages/httptools/parser/parser.pyi b/server/venv/Lib/site-packages/httptools/parser/parser.pyi new file mode 100644 index 0000000..8afd006 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/parser.pyi @@ -0,0 +1,58 @@ +from array import array +from .protocol import HTTPProtocol + +class HttpParser: + def __init__(self, protocol: HTTPProtocol | object) -> None: + """The HTTP parser. + + Args: + protocol (HTTPProtocol): Callback interface for the parser. + """ + + def set_dangerous_leniencies( + self, + lenient_headers: bool | None = None, + lenient_chunked_length: bool | None = None, + lenient_keep_alive: bool | None = None, + lenient_transfer_encoding: bool | None = None, + lenient_version: bool | None = None, + lenient_data_after_close: bool | None = None, + lenient_optional_lf_after_cr: bool | None = None, + lenient_optional_cr_before_lf: bool | None = None, + lenient_optional_crlf_after_chunk: bool | None = None, + lenient_spaces_after_chunk_size: bool | None = None, + ) -> None: + """Set dangerous leniencies for the parser.""" + + def get_http_version(self) -> str: + """Retrieve the HTTP protocol version e.g. "1.1".""" + + def should_keep_alive(self) -> bool: + """Return `True` if keep-alive mode is preferred.""" + + def should_upgrade(self) -> bool: + """Return `True` if the parsed request is a valid Upgrade request. + The method exposes a flag set just before on_headers_complete. + Calling this method earlier will only yield `False`.""" + + def feed_data(self, data: bytes | bytearray | memoryview | array[int]) -> None: + """Feed data to the parser. + + Will eventually trigger callbacks on the ``protocol`` object. + + On HTTP upgrade, this method will raise an + ``HttpParserUpgrade`` exception, with its sole argument + set to the offset of the non-HTTP data in ``data``. + """ + +class HttpRequestParser(HttpParser): + """Used for parsing http requests from the server side.""" + + def get_method(self) -> bytes: + """Retrieve the HTTP method of the request.""" + +class HttpResponseParser(HttpParser): + """Used for parsing http responses from the client side.""" + + def get_status_code(self) -> int: + """Retrieve the status code of the HTTP response.""" diff --git a/server/venv/Lib/site-packages/httptools/parser/parser.pyx b/server/venv/Lib/site-packages/httptools/parser/parser.pyx new file mode 100644 index 0000000..2fa5026 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/parser.pyx @@ -0,0 +1,436 @@ +#cython: language_level=3 + +from __future__ import print_function +from typing import Optional + +from cpython.mem cimport PyMem_Malloc, PyMem_Free +from cpython cimport PyObject_GetBuffer, PyBuffer_Release, PyBUF_SIMPLE, \ + Py_buffer, PyBytes_AsString + +from .python cimport PyMemoryView_Check, PyMemoryView_GET_BUFFER + + +from .errors import (HttpParserError, + HttpParserCallbackError, + HttpParserInvalidStatusError, + HttpParserInvalidMethodError, + HttpParserInvalidURLError, + HttpParserUpgrade) + +cimport cython +from . cimport cparser + + +__all__ = ('HttpRequestParser', 'HttpResponseParser') + + +@cython.internal +cdef class HttpParser: + + cdef: + cparser.llhttp_t* _cparser + cparser.llhttp_settings_t* _csettings + + bytes _current_header_name + bytes _current_header_value + + _proto_on_url, _proto_on_status, _proto_on_body, \ + _proto_on_header, _proto_on_headers_complete, \ + _proto_on_message_complete, _proto_on_chunk_header, \ + _proto_on_chunk_complete, _proto_on_message_begin + + object _last_error + + Py_buffer py_buf + + def __cinit__(self): + self._cparser = \ + PyMem_Malloc(sizeof(cparser.llhttp_t)) + if self._cparser is NULL: + raise MemoryError() + + self._csettings = \ + PyMem_Malloc(sizeof(cparser.llhttp_settings_t)) + if self._csettings is NULL: + raise MemoryError() + + def __dealloc__(self): + PyMem_Free(self._cparser) + PyMem_Free(self._csettings) + + cdef _init(self, protocol, cparser.llhttp_type_t mode): + cparser.llhttp_settings_init(self._csettings) + + cparser.llhttp_init(self._cparser, mode, self._csettings) + self._cparser.data = self + + self._current_header_name = None + self._current_header_value = None + + self._proto_on_header = getattr(protocol, 'on_header', None) + if self._proto_on_header is not None: + self._csettings.on_header_field = cb_on_header_field + self._csettings.on_header_value = cb_on_header_value + self._proto_on_headers_complete = getattr( + protocol, 'on_headers_complete', None) + self._csettings.on_headers_complete = cb_on_headers_complete + + self._proto_on_body = getattr(protocol, 'on_body', None) + if self._proto_on_body is not None: + self._csettings.on_body = cb_on_body + + self._proto_on_message_begin = getattr( + protocol, 'on_message_begin', None) + if self._proto_on_message_begin is not None: + self._csettings.on_message_begin = cb_on_message_begin + + self._proto_on_message_complete = getattr( + protocol, 'on_message_complete', None) + if self._proto_on_message_complete is not None: + self._csettings.on_message_complete = cb_on_message_complete + + self._proto_on_chunk_header = getattr( + protocol, 'on_chunk_header', None) + self._csettings.on_chunk_header = cb_on_chunk_header + + self._proto_on_chunk_complete = getattr( + protocol, 'on_chunk_complete', None) + self._csettings.on_chunk_complete = cb_on_chunk_complete + + self._last_error = None + + cdef _maybe_call_on_header(self): + if self._current_header_value is not None: + current_header_name = self._current_header_name + current_header_value = self._current_header_value + + self._current_header_name = self._current_header_value = None + + if self._proto_on_header is not None: + self._proto_on_header(current_header_name, + current_header_value) + + cdef _on_header_field(self, bytes field): + self._maybe_call_on_header() + if self._current_header_name is None: + self._current_header_name = field + else: + self._current_header_name += field + + cdef _on_header_value(self, bytes val): + if self._current_header_value is None: + self._current_header_value = val + else: + # This is unlikely, as mostly HTTP headers are one-line + self._current_header_value += val + + cdef _on_headers_complete(self): + self._maybe_call_on_header() + + if self._proto_on_headers_complete is not None: + self._proto_on_headers_complete() + + cdef _on_chunk_header(self): + if (self._current_header_value is not None or + self._current_header_name is not None): + raise HttpParserError('invalid headers state') + + if self._proto_on_chunk_header is not None: + self._proto_on_chunk_header() + + cdef _on_chunk_complete(self): + self._maybe_call_on_header() + + if self._proto_on_chunk_complete is not None: + self._proto_on_chunk_complete() + + ### Public API ### + + def set_dangerous_leniencies( + self, + lenient_headers: Optional[bool] = None, + lenient_chunked_length: Optional[bool] = None, + lenient_keep_alive: Optional[bool] = None, + lenient_transfer_encoding: Optional[bool] = None, + lenient_version: Optional[bool] = None, + lenient_data_after_close: Optional[bool] = None, + lenient_optional_lf_after_cr: Optional[bool] = None, + lenient_optional_cr_before_lf: Optional[bool] = None, + lenient_optional_crlf_after_chunk: Optional[bool] = None, + lenient_spaces_after_chunk_size: Optional[bool] = None, + ): + cdef cparser.llhttp_t* parser = self._cparser + if lenient_headers is not None: + cparser.llhttp_set_lenient_headers( + parser, lenient_headers) + if lenient_chunked_length is not None: + cparser.llhttp_set_lenient_chunked_length( + parser, lenient_chunked_length) + if lenient_keep_alive is not None: + cparser.llhttp_set_lenient_keep_alive( + parser, lenient_keep_alive) + if lenient_transfer_encoding is not None: + cparser.llhttp_set_lenient_transfer_encoding( + parser, lenient_transfer_encoding) + if lenient_version is not None: + cparser.llhttp_set_lenient_version( + parser, lenient_version) + if lenient_data_after_close is not None: + cparser.llhttp_set_lenient_data_after_close( + parser, lenient_data_after_close) + if lenient_optional_lf_after_cr is not None: + cparser.llhttp_set_lenient_optional_lf_after_cr( + parser, lenient_optional_lf_after_cr) + if lenient_optional_cr_before_lf is not None: + cparser.llhttp_set_lenient_optional_cr_before_lf( + parser, lenient_optional_cr_before_lf) + if lenient_optional_crlf_after_chunk is not None: + cparser.llhttp_set_lenient_optional_crlf_after_chunk( + parser, lenient_optional_crlf_after_chunk) + if lenient_spaces_after_chunk_size is not None: + cparser.llhttp_set_lenient_spaces_after_chunk_size( + parser, lenient_spaces_after_chunk_size) + + def get_http_version(self): + cdef cparser.llhttp_t* parser = self._cparser + return '{}.{}'.format(parser.http_major, parser.http_minor) + + def should_keep_alive(self): + return bool(cparser.llhttp_should_keep_alive(self._cparser)) + + def should_upgrade(self): + cdef cparser.llhttp_t* parser = self._cparser + return bool(parser.upgrade) + + def feed_data(self, data): + cdef: + size_t data_len + cparser.llhttp_errno_t err + Py_buffer *buf + bint owning_buf = False + const char* err_pos + + if PyMemoryView_Check(data): + buf = PyMemoryView_GET_BUFFER(data) + data_len = buf.len + err = cparser.llhttp_execute( + self._cparser, + buf.buf, + data_len) + + else: + buf = &self.py_buf + PyObject_GetBuffer(data, buf, PyBUF_SIMPLE) + owning_buf = True + data_len = buf.len + + err = cparser.llhttp_execute( + self._cparser, + buf.buf, + data_len) + + try: + if self._cparser.upgrade == 1 and err == cparser.HPE_PAUSED_UPGRADE: + err_pos = cparser.llhttp_get_error_pos(self._cparser) + + # Immediately free the parser from "error" state, simulating + # http-parser behavior here because 1) we never had the API to + # allow users manually "resume after upgrade", and 2) the use + # case for resuming parsing is very rare. + cparser.llhttp_resume_after_upgrade(self._cparser) + + # The err_pos here is specific for the input buf. So if we ever + # switch to the llhttp behavior (re-raise HttpParserUpgrade for + # successive calls to feed_data() until resume_after_upgrade is + # called), we have to store the result and keep our own state. + raise HttpParserUpgrade(err_pos - buf.buf) + finally: + if owning_buf: + PyBuffer_Release(buf) + + if err != cparser.HPE_OK: + ex = parser_error_from_errno( + self._cparser, + self._cparser.error) + if isinstance(ex, HttpParserCallbackError): + if self._last_error is not None: + ex.__context__ = self._last_error + self._last_error = None + raise ex + + +cdef class HttpRequestParser(HttpParser): + + def __init__(self, protocol): + self._init(protocol, cparser.HTTP_REQUEST) + + self._proto_on_url = getattr(protocol, 'on_url', None) + if self._proto_on_url is not None: + self._csettings.on_url = cb_on_url + + def get_method(self): + cdef cparser.llhttp_t* parser = self._cparser + return cparser.llhttp_method_name( parser.method) + + +cdef class HttpResponseParser(HttpParser): + + def __init__(self, protocol): + self._init(protocol, cparser.HTTP_RESPONSE) + + self._proto_on_status = getattr(protocol, 'on_status', None) + if self._proto_on_status is not None: + self._csettings.on_status = cb_on_status + + def get_status_code(self): + cdef cparser.llhttp_t* parser = self._cparser + return parser.status_code + + +cdef int cb_on_message_begin(cparser.llhttp_t* parser) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._proto_on_message_begin() + except BaseException as ex: + pyparser._last_error = ex + return -1 + else: + return 0 + + +cdef int cb_on_url(cparser.llhttp_t* parser, + const char *at, size_t length) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._proto_on_url(at[:length]) + except BaseException as ex: + cparser.llhttp_set_error_reason(parser, "`on_url` callback error") + pyparser._last_error = ex + return cparser.HPE_USER + else: + return 0 + + +cdef int cb_on_status(cparser.llhttp_t* parser, + const char *at, size_t length) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._proto_on_status(at[:length]) + except BaseException as ex: + cparser.llhttp_set_error_reason(parser, "`on_status` callback error") + pyparser._last_error = ex + return cparser.HPE_USER + else: + return 0 + + +cdef int cb_on_header_field(cparser.llhttp_t* parser, + const char *at, size_t length) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._on_header_field(at[:length]) + except BaseException as ex: + cparser.llhttp_set_error_reason(parser, "`on_header_field` callback error") + pyparser._last_error = ex + return cparser.HPE_USER + else: + return 0 + + +cdef int cb_on_header_value(cparser.llhttp_t* parser, + const char *at, size_t length) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._on_header_value(at[:length]) + except BaseException as ex: + cparser.llhttp_set_error_reason(parser, "`on_header_value` callback error") + pyparser._last_error = ex + return cparser.HPE_USER + else: + return 0 + + +cdef int cb_on_headers_complete(cparser.llhttp_t* parser) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._on_headers_complete() + except BaseException as ex: + pyparser._last_error = ex + return -1 + else: + if pyparser._cparser.upgrade: + return 1 + else: + return 0 + + +cdef int cb_on_body(cparser.llhttp_t* parser, + const char *at, size_t length) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._proto_on_body(at[:length]) + except BaseException as ex: + cparser.llhttp_set_error_reason(parser, "`on_body` callback error") + pyparser._last_error = ex + return cparser.HPE_USER + else: + return 0 + + +cdef int cb_on_message_complete(cparser.llhttp_t* parser) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._proto_on_message_complete() + except BaseException as ex: + pyparser._last_error = ex + return -1 + else: + return 0 + + +cdef int cb_on_chunk_header(cparser.llhttp_t* parser) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._on_chunk_header() + except BaseException as ex: + pyparser._last_error = ex + return -1 + else: + return 0 + + +cdef int cb_on_chunk_complete(cparser.llhttp_t* parser) except -1: + cdef HttpParser pyparser = parser.data + try: + pyparser._on_chunk_complete() + except BaseException as ex: + pyparser._last_error = ex + return -1 + else: + return 0 + + +cdef parser_error_from_errno(cparser.llhttp_t* parser, cparser.llhttp_errno_t errno): + cdef bytes reason = cparser.llhttp_get_error_reason(parser) + + if errno in (cparser.HPE_CB_MESSAGE_BEGIN, + cparser.HPE_CB_HEADERS_COMPLETE, + cparser.HPE_CB_MESSAGE_COMPLETE, + cparser.HPE_CB_CHUNK_HEADER, + cparser.HPE_CB_CHUNK_COMPLETE, + cparser.HPE_USER): + cls = HttpParserCallbackError + + elif errno == cparser.HPE_INVALID_STATUS: + cls = HttpParserInvalidStatusError + + elif errno == cparser.HPE_INVALID_METHOD: + cls = HttpParserInvalidMethodError + + elif errno == cparser.HPE_INVALID_URL: + cls = HttpParserInvalidURLError + + else: + cls = HttpParserError + + return cls(reason.decode('latin-1')) diff --git a/server/venv/Lib/site-packages/httptools/parser/protocol.py b/server/venv/Lib/site-packages/httptools/parser/protocol.py new file mode 100644 index 0000000..ae00523 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/protocol.py @@ -0,0 +1,15 @@ +from typing import Protocol + + +class HTTPProtocol(Protocol): + """Used for providing static type-checking when parsing through the http protocol""" + + def on_message_begin(self) -> None: ... + def on_url(self, url: bytes) -> None: ... + def on_header(self, name: bytes, value: bytes) -> None: ... + def on_headers_complete(self) -> None: ... + def on_body(self, body: bytes) -> None: ... + def on_message_complete(self) -> None: ... + def on_chunk_header(self) -> None: ... + def on_chunk_complete(self) -> None: ... + def on_status(self, status: bytes) -> None: ... diff --git a/server/venv/Lib/site-packages/httptools/parser/python.pxd b/server/venv/Lib/site-packages/httptools/parser/python.pxd new file mode 100644 index 0000000..8e95925 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/python.pxd @@ -0,0 +1,6 @@ +cimport cpython + + +cdef extern from "Python.h": + cpython.Py_buffer* PyMemoryView_GET_BUFFER(object) + bint PyMemoryView_Check(object) diff --git a/server/venv/Lib/site-packages/httptools/parser/url_cparser.pxd b/server/venv/Lib/site-packages/httptools/parser/url_cparser.pxd new file mode 100644 index 0000000..ab9265a --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/url_cparser.pxd @@ -0,0 +1,31 @@ +from libc.stdint cimport uint16_t + + +cdef extern from "http_parser.h": + # URL Parser + + enum http_parser_url_fields: + UF_SCHEMA = 0, + UF_HOST = 1, + UF_PORT = 2, + UF_PATH = 3, + UF_QUERY = 4, + UF_FRAGMENT = 5, + UF_USERINFO = 6, + UF_MAX = 7 + + struct http_parser_url_field_data: + uint16_t off + uint16_t len + + struct http_parser_url: + uint16_t field_set + uint16_t port + http_parser_url_field_data[UF_MAX] field_data + + void http_parser_url_init(http_parser_url *u) + + int http_parser_parse_url(const char *buf, + size_t buflen, + int is_connect, + http_parser_url *u) diff --git a/server/venv/Lib/site-packages/httptools/parser/url_parser.cp313-win_amd64.pyd b/server/venv/Lib/site-packages/httptools/parser/url_parser.cp313-win_amd64.pyd new file mode 100644 index 0000000..20812ed Binary files /dev/null and b/server/venv/Lib/site-packages/httptools/parser/url_parser.cp313-win_amd64.pyd differ diff --git a/server/venv/Lib/site-packages/httptools/parser/url_parser.pyi b/server/venv/Lib/site-packages/httptools/parser/url_parser.pyi new file mode 100644 index 0000000..5f04847 --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/url_parser.pyi @@ -0,0 +1,13 @@ +from array import array + +class URL: + schema: bytes + host: bytes + port: int + path: bytes + query: bytes + fragment: bytes + userinfo: bytes + +def parse_url(url: bytes | bytearray | memoryview | array[int]) -> URL: + """Parse a URL string into a structured Python object.""" diff --git a/server/venv/Lib/site-packages/httptools/parser/url_parser.pyx b/server/venv/Lib/site-packages/httptools/parser/url_parser.pyx new file mode 100644 index 0000000..e855b5f --- /dev/null +++ b/server/venv/Lib/site-packages/httptools/parser/url_parser.pyx @@ -0,0 +1,118 @@ +#cython: language_level=3 + +from __future__ import print_function +from cpython.mem cimport PyMem_Malloc, PyMem_Free +from cpython cimport PyObject_GetBuffer, PyBuffer_Release, PyBUF_SIMPLE, \ + Py_buffer + +from .errors import HttpParserInvalidURLError + +cimport cython +from . cimport url_cparser as uparser + +__all__ = ('parse_url',) + +DEF MAX_URL_LENGTH = (1 << 16) - 1 + +@cython.freelist(250) +cdef class URL: + cdef readonly bytes schema + cdef readonly bytes host + cdef readonly object port + cdef readonly bytes path + cdef readonly bytes query + cdef readonly bytes fragment + cdef readonly bytes userinfo + + def __cinit__(self, bytes schema, bytes host, object port, bytes path, + bytes query, bytes fragment, bytes userinfo): + + self.schema = schema + self.host = host + self.port = port + self.path = path + self.query = query + self.fragment = fragment + self.userinfo = userinfo + + def __repr__(self): + return ('' + .format(self.schema, self.host, self.port, self.path, + self.query, self.fragment, self.userinfo)) + + +def parse_url(url): + cdef: + Py_buffer py_buf + char* buf_data + uparser.http_parser_url* parsed + int res + bytes schema = None + bytes host = None + object port = None + bytes path = None + bytes query = None + bytes fragment = None + bytes userinfo = None + object result = None + int off + int ln + + parsed = \ + PyMem_Malloc(sizeof(uparser.http_parser_url)) + uparser.http_parser_url_init(parsed) + + PyObject_GetBuffer(url, &py_buf, PyBUF_SIMPLE) + try: + if py_buf.len > MAX_URL_LENGTH: + # http_parser stores URL field offsets/lengths as uint16_t, + # so URLs longer than this will cause silent truncation. + # See https://github.com/MagicStack/httptools/issues/142 + raise HttpParserInvalidURLError( + "url is too long: url length of {} bytes exceeds the " + "maximum of {} bytes".format(py_buf.len, MAX_URL_LENGTH)) + + buf_data = py_buf.buf + res = uparser.http_parser_parse_url(buf_data, py_buf.len, 0, parsed) + + if res == 0: + if parsed.field_set & (1 << uparser.UF_SCHEMA): + off = parsed.field_data[uparser.UF_SCHEMA].off + ln = parsed.field_data[uparser.UF_SCHEMA].len + schema = buf_data[off:off+ln] + + if parsed.field_set & (1 << uparser.UF_HOST): + off = parsed.field_data[uparser.UF_HOST].off + ln = parsed.field_data[uparser.UF_HOST].len + host = buf_data[off:off+ln] + + if parsed.field_set & (1 << uparser.UF_PORT): + port = parsed.port + + if parsed.field_set & (1 << uparser.UF_PATH): + off = parsed.field_data[uparser.UF_PATH].off + ln = parsed.field_data[uparser.UF_PATH].len + path = buf_data[off:off+ln] + + if parsed.field_set & (1 << uparser.UF_QUERY): + off = parsed.field_data[uparser.UF_QUERY].off + ln = parsed.field_data[uparser.UF_QUERY].len + query = buf_data[off:off+ln] + + if parsed.field_set & (1 << uparser.UF_FRAGMENT): + off = parsed.field_data[uparser.UF_FRAGMENT].off + ln = parsed.field_data[uparser.UF_FRAGMENT].len + fragment = buf_data[off:off+ln] + + if parsed.field_set & (1 << uparser.UF_USERINFO): + off = parsed.field_data[uparser.UF_USERINFO].off + ln = parsed.field_data[uparser.UF_USERINFO].len + userinfo = buf_data[off:off+ln] + + return URL(schema, host, port, path, query, fragment, userinfo) + else: + raise HttpParserInvalidURLError("invalid url {!r}".format(url)) + finally: + PyBuffer_Release(&py_buf) + PyMem_Free(parsed) diff --git a/server/venv/Lib/site-packages/httptools/py.typed b/server/venv/Lib/site-packages/httptools/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/idna-3.18.dist-info/INSTALLER b/server/venv/Lib/site-packages/idna-3.18.dist-info/INSTALLER new file mode 100644 index 0000000..a1b589e --- /dev/null +++ b/server/venv/Lib/site-packages/idna-3.18.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/server/venv/Lib/site-packages/idna-3.18.dist-info/METADATA b/server/venv/Lib/site-packages/idna-3.18.dist-info/METADATA new file mode 100644 index 0000000..6c4bf89 --- /dev/null +++ b/server/venv/Lib/site-packages/idna-3.18.dist-info/METADATA @@ -0,0 +1,155 @@ +Metadata-Version: 2.4 +Name: idna +Version: 3.18 +Summary: Internationalized Domain Names in Applications (IDNA) +Author-email: Kim Davies +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +License-Expression: BSD-3-Clause +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: System Administrators +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +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 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Internet :: Name Service (DNS) +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Topic :: Utilities +License-File: LICENSE.md +Requires-Dist: ruff >= 0.6.2 ; extra == "all" +Requires-Dist: mypy >= 1.11.2 ; extra == "all" +Requires-Dist: pytest >= 8.3.2 ; extra == "all" +Project-URL: Changelog, https://github.com/kjd/idna/blob/master/HISTORY.md +Project-URL: Issue tracker, https://github.com/kjd/idna/issues +Project-URL: Source, https://github.com/kjd/idna +Provides-Extra: all + +# Internationalized Domain Names in Applications (IDNA) + +Support for [Internationalized Domain Names in Applications +(IDNA)](https://tools.ietf.org/html/rfc5891) and [Unicode IDNA +Compatibility Processing](https://unicode.org/reports/tr46/). It +supersedes the standard library's `encodings.idna`, which only +implements the 2003 specification, offering broader script coverage and +limiting domains with known security vulnerabilities. + +## Usage + +Package may be installed from [PyPI](https://pypi.org/project/idna/) via +the typical methods (e.g. `python3 -m pip install idna`) + +For typical usage, the `encode` and `decode` functions will take a +domain name argument and perform a conversion to ASCII-compatible encoding +(known as A-labels), or to Unicode strings (known as U-labels) +respectively. + +```pycon +>>> import idna +>>> idna.encode('ドメイン.テスト') +b'xn--eckwd4c7c.xn--zckzah' +>>> print(idna.decode('xn--eckwd4c7c.xn--zckzah')) +ドメイン.テスト +``` + +Conversions can be applied at a per-label basis using the `ulabel` or +`alabel` functions for specialized use cases. + + +### Compatibility Mapping (UTS #46) + +This library provides support for [Unicode IDNA Compatibility +Processing](https://unicode.org/reports/tr46/) which normalizes input from +different potential ways a user may input a domain prior to performing the IDNA +conversion operations. This functionality, known as a +[mapping](https://tools.ietf.org/html/rfc5895), is considered by the +specification to be a local user-interface issue distinct from IDNA +conversion functionality. + +For example, "Königsgäßchen" is not a permissible label as capital letters +are not allowed. UTS 46 will convert this into lower case prior to applying +the IDNA conversion. + +```pycon +>>> import idna +>>> idna.encode('Königsgäßchen') +... +idna.core.InvalidCodepoint: Codepoint U+004B at position 1 of 'Königsgäßchen' not allowed +>>> idna.encode('Königsgäßchen', uts46=True) +b'xn--knigsgchen-b4a3dun' +>>> idna.decode('xn--knigsgchen-b4a3dun') +'königsgäßchen' +``` + +When performing a decode operation for display purposes, `decode()` +accepts a `display=True` argument that leaves any `xn--` label that +fails to decode unchanged. This is useful for user interface display +where a domain is in use, the A-label form can be presented when it +is not a valid IDN. + + +## Exceptions + +All errors raised during conversion derive from the `idna.IDNAError` +base class. The more specific exceptions are: + +* `idna.IDNABidiError` — raised when a label contains an illegal + combination of left-to-right and right-to-left characters. +* `idna.InvalidCodepoint` — raised when a label contains a codepoint + that is INVALID for IDNA. +* `idna.InvalidCodepointContext` — raised when a CONTEXTO or CONTEXTJ + codepoint appears in a position whose contextual requirements are + not satisfied. + + +## Command-line tool + +The package supports command-line usage to convert domain names +between their Unicode and ASCII-compatible forms. It can be run either +as a module (`python3 -m idna`) or, once installed (such as with `uv +tool` or `pipx`), via the `idna` script: + +```bash +$ uv tool install idna +$ idna xn--e1afmkfd.xn--p1ai +пример.рф +$ idna пример.рф +xn--e1afmkfd.xn--p1ai +``` + +Mode can be specified with `-e`/`--encode` or `-d`/`--decode`, otherwise +it will be chosen automatically based on the first input. Multiple +domains can be supplied either as arguments or through standard input. +UTS #46 mapping is applied by default, which lets the tool accept +inputs that aren't strictly valid IDNA 2008 by normalising them first, +pass `--strict` to disable UTS #46. + +Conversion failures are reported on stderr together with the +offending input; processing continues with the remaining domains and +the tool exits with a non-zero status if any conversion failed. + + +## Additional Notes + +* **Version support**. This library supports Python 3.9 and higher. + As this library serves as a low-level toolkit for a variety of + applications, we strive to support all versions of Python that are + not beyond end-of-life. + +* **Emoji**. It is an occasional request to support emoji domains in + this library. Encoding of symbols like emoji is expressly prohibited by + the IDNA technical standard, and emoji domains are broadly phased + out across the domain industry due to associated security risks. + +* **Regenerating lookup tables**. The IDNA and UTS 46 functionality + relies upon pre-calculated lookup tables, generated using the + `idna-data` script in [`tools/`](tools/README.md). + diff --git a/server/venv/Lib/site-packages/idna-3.18.dist-info/RECORD b/server/venv/Lib/site-packages/idna-3.18.dist-info/RECORD new file mode 100644 index 0000000..65173f1 --- /dev/null +++ b/server/venv/Lib/site-packages/idna-3.18.dist-info/RECORD @@ -0,0 +1,28 @@ +../../Scripts/idna.exe,sha256=b-QI5CoT6rEnYZqQDLY0kAPswE2RSMumI2L1O1f4n8Y,108456 +idna-3.18.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +idna-3.18.dist-info/METADATA,sha256=Rt_m5axGLQ9oDs2avPZugptqIzSCS02eOXmzETXK8oE,6119 +idna-3.18.dist-info/RECORD,, +idna-3.18.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82 +idna-3.18.dist-info/entry_points.txt,sha256=7H3nGOHap3jnLE5e7q7Ywr9Vq8axB7WIj5-C_4N2vhw,38 +idna-3.18.dist-info/licenses/LICENSE.md,sha256=GppPDj1HmickDd1ZqRN6ZqtKD539yMphiMwL_YUYfwQ,1541 +idna/__init__.py,sha256=MPqNDLZbXqGaNdXxAFhiqFPKEQXju2jNQhCey6-5eJM,868 +idna/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83 +idna/__pycache__/__init__.cpython-313.pyc,, +idna/__pycache__/__main__.cpython-313.pyc,, +idna/__pycache__/cli.cpython-313.pyc,, +idna/__pycache__/codec.cpython-313.pyc,, +idna/__pycache__/compat.cpython-313.pyc,, +idna/__pycache__/core.cpython-313.pyc,, +idna/__pycache__/idnadata.cpython-313.pyc,, +idna/__pycache__/intranges.cpython-313.pyc,, +idna/__pycache__/package_data.cpython-313.pyc,, +idna/__pycache__/uts46data.cpython-313.pyc,, +idna/cli.py,sha256=swqJLMNc8Uzs60KziNpbWnHuqlG3WRQwJSbo4n8xDAo,4139 +idna/codec.py,sha256=JRbo-f7pEkLdWeiH89Z72UR4VBYhvKDFrQBeNX6sRDE,5040 +idna/compat.py,sha256=AepA39ceRHxkfHP41-FvKW5Ki-f4PfUZ90RUMlCNdmo,1353 +idna/core.py,sha256=SfOr1xO3PoE0RDYx7bMciAnjiyjJPbPw_93AB5IUYOw,24685 +idna/idnadata.py,sha256=Af-mo8WBmkhAK6TyXKOQH88OX0mQNDKtdL7UWtQpppk,44862 +idna/intranges.py,sha256=g49scLSkqJtAhLmOODa7hVHriSjmb60tiTsEoocJdBI,1851 +idna/package_data.py,sha256=TeI94EqAFAFaXfBJwsOPUMLn2969uirPa-DaeaceAyU,21 +idna/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +idna/uts46data.py,sha256=jujNz5QqWMcJf-XYLv4X1jBvb5FlI0t6-e1mILsgbPk,234325 diff --git a/server/venv/Lib/site-packages/idna-3.18.dist-info/WHEEL b/server/venv/Lib/site-packages/idna-3.18.dist-info/WHEEL new file mode 100644 index 0000000..d8b9936 --- /dev/null +++ b/server/venv/Lib/site-packages/idna-3.18.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: flit 3.12.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/server/venv/Lib/site-packages/idna-3.18.dist-info/entry_points.txt b/server/venv/Lib/site-packages/idna-3.18.dist-info/entry_points.txt new file mode 100644 index 0000000..59ca7ac --- /dev/null +++ b/server/venv/Lib/site-packages/idna-3.18.dist-info/entry_points.txt @@ -0,0 +1,3 @@ +[console_scripts] +idna=idna.cli:main + diff --git a/server/venv/Lib/site-packages/idna-3.18.dist-info/licenses/LICENSE.md b/server/venv/Lib/site-packages/idna-3.18.dist-info/licenses/LICENSE.md new file mode 100644 index 0000000..f706835 --- /dev/null +++ b/server/venv/Lib/site-packages/idna-3.18.dist-info/licenses/LICENSE.md @@ -0,0 +1,31 @@ +BSD 3-Clause License + +Copyright (c) 2013-2026, Kim Davies and contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/server/venv/Lib/site-packages/idna/__init__.py b/server/venv/Lib/site-packages/idna/__init__.py new file mode 100644 index 0000000..cfdc030 --- /dev/null +++ b/server/venv/Lib/site-packages/idna/__init__.py @@ -0,0 +1,45 @@ +from .core import ( + IDNABidiError, + IDNAError, + InvalidCodepoint, + InvalidCodepointContext, + alabel, + check_bidi, + check_hyphen_ok, + check_initial_combiner, + check_label, + check_nfc, + decode, + encode, + ulabel, + uts46_remap, + valid_contextj, + valid_contexto, + valid_label_length, + valid_string_length, +) +from .intranges import intranges_contain +from .package_data import __version__ + +__all__ = [ + "__version__", + "IDNABidiError", + "IDNAError", + "InvalidCodepoint", + "InvalidCodepointContext", + "alabel", + "check_bidi", + "check_hyphen_ok", + "check_initial_combiner", + "check_label", + "check_nfc", + "decode", + "encode", + "intranges_contain", + "ulabel", + "uts46_remap", + "valid_contextj", + "valid_contexto", + "valid_label_length", + "valid_string_length", +] diff --git a/server/venv/Lib/site-packages/idna/__main__.py b/server/venv/Lib/site-packages/idna/__main__.py new file mode 100644 index 0000000..dbdd066 --- /dev/null +++ b/server/venv/Lib/site-packages/idna/__main__.py @@ -0,0 +1,6 @@ +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/venv/Lib/site-packages/idna/cli.py b/server/venv/Lib/site-packages/idna/cli.py new file mode 100644 index 0000000..4acda2c --- /dev/null +++ b/server/venv/Lib/site-packages/idna/cli.py @@ -0,0 +1,128 @@ +"""Command-line interface for the :mod:`idna` package. + +Invoked via ``python -m idna``. See :func:`main` for the entry point. +""" + +import argparse +import sys +from collections.abc import Iterable +from itertools import chain +from typing import IO, Optional + +from . import IDNAError, decode, encode +from .core import _alabel_prefix, _unicode_dots_re +from .package_data import __version__ + + +def _looks_like_alabel(s: str) -> bool: + """Return True if any label in ``s`` carries the ``xn--`` ACE prefix.""" + prefix = _alabel_prefix.decode("ascii") + return any(label.lower().startswith(prefix) for label in _unicode_dots_re.split(s)) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="python -m idna", + description=( + "Convert a domain name between its Unicode (U-label) and " + "ASCII-compatible (A-label) forms. With no mode flag, the " + "direction is chosen from the first input — if it contains " + "an xn-- label the stream is decoded, otherwise it is " + "encoded — and the same mode is applied to every remaining " + "input. UTS #46 mapping is applied by default; pass " + "--strict to disable it. When no domains are given on the " + "command line and stdin is piped, one domain per line is " + "read from stdin." + ), + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "-e", + "--encode", + dest="mode", + action="store_const", + const="encode", + help="Encode the input to its ASCII A-label form.", + ) + mode.add_argument( + "-d", + "--decode", + dest="mode", + action="store_const", + const="decode", + help="Decode the input from its ASCII A-label form.", + ) + parser.add_argument( + "--strict", + action="store_true", + help="Disable the default UTS #46 mapping and apply IDNA 2008 rules verbatim.", + ) + parser.add_argument( + "--version", + action="version", + version=f"idna {__version__}", + ) + parser.add_argument( + "domain", + nargs="*", + help="One or more domain names to convert. Omit to read from stdin.", + ) + return parser + + +def _iter_stdin(stream: IO[str]) -> Iterable[str]: + """Yield non-empty stripped lines from ``stream``, ignoring blanks.""" + for line in stream: + stripped = line.strip() + if stripped: + yield stripped + + +def _convert_one(domain: str, mode: str, uts46: bool) -> bool: + """Convert ``domain`` and write the result; return ``False`` on failure.""" + try: + if mode == "decode": + print(decode(domain, uts46=uts46)) + else: + print(encode(domain, uts46=uts46).decode("ascii")) + except IDNAError as err: + print(f"idna: {mode} failed for {domain!r}: {err}", file=sys.stderr) + return False + return True + + +def main(argv: Optional[list[str]] = None) -> int: + """Entry point for ``python -m idna``. + + When more than one domain is supplied (via positional arguments or + piped stdin) and no mode flag is given, the first input determines + the direction and that mode is applied uniformly to the rest. + + :param argv: Argument list excluding the program name. Defaults to + :data:`sys.argv` when ``None``. + :returns: ``0`` on success, ``1`` if any conversion fails. + """ + parser = _build_parser() + args = parser.parse_args(argv) + uts46 = not args.strict + + if args.domain: + domains: Iterable[str] = args.domain + elif not sys.stdin.isatty(): + domains = _iter_stdin(sys.stdin) + else: + parser.error("a domain argument is required when stdin is a terminal") + + iterator = iter(domains) + first = next(iterator, None) + if first is None: + return 0 + + mode = args.mode or ("decode" if _looks_like_alabel(first) else "encode") + + results = [_convert_one(domain, mode, uts46) for domain in chain([first], iterator)] + return 0 if all(results) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/server/venv/Lib/site-packages/idna/codec.py b/server/venv/Lib/site-packages/idna/codec.py new file mode 100644 index 0000000..83b42fe --- /dev/null +++ b/server/venv/Lib/site-packages/idna/codec.py @@ -0,0 +1,159 @@ +import codecs +from typing import Any, Optional + +from .core import IDNAError, _unicode_dots_re, alabel, decode, encode, ulabel + + +class Codec(codecs.Codec): + """Stateless IDNA 2008 codec. + + Implements the :class:`codecs.Codec` protocol so that the whole-domain + encoder (:func:`idna.encode`) and decoder (:func:`idna.decode`) are + accessible through the standard codec machinery as ``"idna2008"``. + + Only the ``"strict"`` error handler is supported; any other handler + raises :exc:`~idna.IDNAError`. + """ + + def encode(self, data: str, errors: str = "strict") -> tuple[bytes, int]: # ty: ignore[invalid-method-override] + if errors != "strict": + raise IDNAError(f'Unsupported error handling "{errors}"') + + if not data: + return b"", 0 + + return encode(data), len(data) + + def decode(self, data: bytes, errors: str = "strict") -> tuple[str, int]: # ty: ignore[invalid-method-override] + if errors != "strict": + raise IDNAError(f'Unsupported error handling "{errors}"') + + if not data: + return "", 0 + + return decode(data), len(data) + + +class IncrementalEncoder(codecs.BufferedIncrementalEncoder): + """Incremental IDNA 2008 encoder. + + Buffers a partial trailing label across calls until either the next + label separator is seen or ``final=True``, so that streamed input is + encoded one whole label at a time. Any of the four Unicode label + separators (``U+002E``, ``U+3002``, ``U+FF0E``, ``U+FF61``) ends a + label; the result always uses ``U+002E`` as the separator. + + Only the ``"strict"`` error handler is supported. + """ + + def _buffer_encode(self, data: str, errors: str, final: bool) -> tuple[bytes, int]: # ty: ignore[invalid-method-override] + if errors != "strict": + raise IDNAError(f'Unsupported error handling "{errors}"') + + if not data: + return b"", 0 + + labels = _unicode_dots_re.split(data) + trailing_dot = b"" + if labels: + if not labels[-1]: + trailing_dot = b"." + del labels[-1] + elif not final: + # Keep potentially unfinished label until the next call + del labels[-1] + if labels: + trailing_dot = b"." + + result = [] + size = 0 + for label in labels: + result.append(alabel(label)) + if size: + size += 1 + size += len(label) + + # Join with U+002E + result_bytes = b".".join(result) + trailing_dot + size += len(trailing_dot) + return result_bytes, size + + +class IncrementalDecoder(codecs.BufferedIncrementalDecoder): + """Incremental IDNA 2008 decoder. + + Buffers a partial trailing label across calls until either the next + label separator is seen or ``final=True``, so that streamed input is + decoded one whole label at a time. + + Only the ``"strict"`` error handler is supported. + """ + + def _buffer_decode(self, data: Any, errors: str, final: bool) -> tuple[str, int]: # ty: ignore[invalid-method-override] + if errors != "strict": + raise IDNAError(f'Unsupported error handling "{errors}"') + + if not data: + return ("", 0) + + if not isinstance(data, str): + data = str(data, "ascii") + + labels = _unicode_dots_re.split(data) + trailing_dot = "" + if labels: + if not labels[-1]: + trailing_dot = "." + del labels[-1] + elif not final: + # Keep potentially unfinished label until the next call + del labels[-1] + if labels: + trailing_dot = "." + + result = [] + size = 0 + for label in labels: + result.append(ulabel(label)) + if size: + size += 1 + size += len(label) + + result_str = ".".join(result) + trailing_dot + size += len(trailing_dot) + return (result_str, size) + + +class StreamWriter(Codec, codecs.StreamWriter): + pass + + +class StreamReader(Codec, codecs.StreamReader): + pass + + +def search_function(name: str) -> Optional[codecs.CodecInfo]: + """Codec search function registered with :mod:`codecs`. + + Returns a :class:`codecs.CodecInfo` for the ``"idna2008"`` codec name + so that ``str.encode("idna2008")`` and ``bytes.decode("idna2008")`` + invoke the IDNA 2008 codec defined in this module. + + :param name: The codec name being looked up. + :returns: A :class:`codecs.CodecInfo` instance if ``name`` is + ``"idna2008"``, otherwise ``None``. + """ + if name != "idna2008": + return None + return codecs.CodecInfo( + name=name, + encode=Codec().encode, + decode=Codec().decode, # type: ignore + incrementalencoder=IncrementalEncoder, + incrementaldecoder=IncrementalDecoder, + streamwriter=StreamWriter, + streamreader=StreamReader, + ) + + +codecs.register(search_function) diff --git a/server/venv/Lib/site-packages/idna/compat.py b/server/venv/Lib/site-packages/idna/compat.py new file mode 100644 index 0000000..1d01e3d --- /dev/null +++ b/server/venv/Lib/site-packages/idna/compat.py @@ -0,0 +1,41 @@ +from typing import Any, Union + +from .core import decode, encode + + +def ToASCII(label: str) -> bytes: + """Compatibility shim for :rfc:`3490` ``ToASCII``. + + Delegates to :func:`idna.encode` (IDNA 2008). Provided to ease porting + of code written against the legacy :mod:`encodings.idna` API; new code + should call :func:`idna.encode` directly. + + :param label: The label or domain to encode. + :returns: The encoded form as ASCII :class:`bytes`. + """ + return encode(label) + + +def ToUnicode(label: Union[bytes, bytearray]) -> str: + """Compatibility shim for :rfc:`3490` ``ToUnicode``. + + Delegates to :func:`idna.decode` (IDNA 2008). Provided to ease porting + of code written against the legacy :mod:`encodings.idna` API; new code + should call :func:`idna.decode` directly. + + :param label: The label or domain to decode. + :returns: The decoded Unicode form. + """ + return decode(label) + + +def nameprep(s: Any) -> None: + """Stub for :rfc:`3491` Nameprep, which is not used by IDNA 2008. + + IDNA 2008 (:rfc:`5891`) replaces Nameprep with the per-codepoint + validity classes from :rfc:`5892`; this function exists only to + return a clear error if legacy code attempts to call it. + + :raises NotImplementedError: Always. + """ + raise NotImplementedError("IDNA 2008 does not utilise nameprep protocol") diff --git a/server/venv/Lib/site-packages/idna/core.py b/server/venv/Lib/site-packages/idna/core.py new file mode 100644 index 0000000..1ccbd1f --- /dev/null +++ b/server/venv/Lib/site-packages/idna/core.py @@ -0,0 +1,648 @@ +import bisect +import re +import unicodedata +import warnings +from typing import Optional, Union + +from . import idnadata +from .intranges import intranges_contain + +_virama_combining_class = 9 +_alabel_prefix = b"xn--" +_max_input_length = 1024 +_unicode_dots_re = re.compile("[\u002e\u3002\uff0e\uff61]") + + +# Bidi category sets from RFC 5893, hoisted out of the per-codepoint loop +_bidi_rtl_first = frozenset({"R", "AL"}) +_bidi_rtl_categories = frozenset({"R", "AL", "AN"}) +_bidi_rtl_allowed = frozenset({"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}) +_bidi_rtl_valid_ending = frozenset({"R", "AL", "EN", "AN"}) +_bidi_rtl_numeric = frozenset({"AN", "EN"}) +_bidi_ltr_allowed = frozenset({"L", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}) +_bidi_ltr_valid_ending = frozenset({"L", "EN"}) +_bidi_joiner_l_or_d = frozenset({"L", "D"}) +_bidi_joiner_r_or_d = frozenset({"R", "D"}) + + +def _joining_type(cp: int) -> Optional[str]: + for jt, ranges in idnadata.joining_types.items(): + if intranges_contain(cp, ranges): + return jt + return None + + +class IDNAError(UnicodeError): + """Base exception for all IDNA-encoding related problems""" + + +class IDNABidiError(IDNAError): + """Exception when bidirectional requirements are not satisfied""" + + +class InvalidCodepoint(IDNAError): + """Exception when a disallowed or unallocated codepoint is used""" + + +class InvalidCodepointContext(IDNAError): + """Exception when the codepoint is not valid in the context it is used""" + + +def _combining_class(cp: int) -> int: + v = unicodedata.combining(chr(cp)) + if v == 0 and not unicodedata.name(chr(cp)): + raise ValueError("Unknown character in unicodedata") + return v + + +def _is_script(cp: str, script: str) -> bool: + return intranges_contain(ord(cp), idnadata.scripts[script]) + + +def _punycode(s: str) -> bytes: + return s.encode("punycode") + + +def _unot(s: int) -> str: + return f"U+{s:04X}" + + +def valid_label_length(label: Union[bytes, str]) -> bool: + """Check that a label does not exceed the maximum permitted length. + + Per :rfc:`1035` (and :rfc:`5891` §4.2.4) a DNS label must not exceed + 63 octets. The argument may be either a :class:`str` (a U-label, where + length is measured in characters) or :class:`bytes` (an A-label, where + length is measured in octets). + + :param label: The label to check. + :returns: ``True`` if the label is within the length limit, otherwise + ``False``. + """ + return len(label) <= 63 + + +def valid_string_length(domain: Union[bytes, str], trailing_dot: bool) -> bool: + """Check that a full domain name does not exceed the maximum length. + + Per :rfc:`1035`, a domain name is limited to 253 octets when no trailing + dot is present, or 254 octets when one is included. + + :param domain: The full (possibly multi-label) domain name. + :param trailing_dot: ``True`` if ``domain`` includes a trailing ``.``. + :returns: ``True`` if the domain is within the length limit, otherwise + ``False``. + """ + return len(domain) <= (254 if trailing_dot else 253) + + +def check_bidi(label: str, check_ltr: bool = False) -> bool: + """Validate the Bidi Rule from :rfc:`5893` for a single label. + + The Bidi Rule constrains how bidirectional characters (Hebrew, Arabic, + etc.) may appear within a label. By default the check is only applied + when the label contains at least one right-to-left character (Unicode + bidirectional categories ``R``, ``AL``, or ``AN``); set ``check_ltr`` + to ``True`` to apply it to LTR-only labels as well. + + :param label: The label to validate, as a Unicode string. + :param check_ltr: If ``True``, apply the rules even when the label + contains no RTL characters. + :returns: ``True`` if the label satisfies the Bidi Rule. + :raises IDNABidiError: If any of Bidi Rule conditions 1-6 are violated, + or if the directional category of a codepoint cannot be determined. + """ + if len(label) > _max_input_length: + raise IDNAError("Label too long") + # Bidi rules should only be applied if string contains RTL characters + bidi_label = False + for idx, cp in enumerate(label, 1): + direction = unicodedata.bidirectional(cp) + if direction == "": + # String likely comes from a newer version of Unicode + raise IDNABidiError(f"Unknown directionality in label {label!r} at position {idx}") + if direction in _bidi_rtl_categories: + bidi_label = True + if not bidi_label and not check_ltr: + return True + + # Bidi rule 1 + direction = unicodedata.bidirectional(label[0]) + if direction in _bidi_rtl_first: + rtl = True + elif direction == "L": + rtl = False + else: + raise IDNABidiError(f"First codepoint in label {label!r} must be directionality L, R or AL") + + valid_ending = False + number_type: Optional[str] = None + for idx, cp in enumerate(label, 1): + direction = unicodedata.bidirectional(cp) + + if rtl: + # Bidi rule 2 + if direction not in _bidi_rtl_allowed: + raise IDNABidiError(f"Invalid direction for codepoint at position {idx} in a right-to-left label") + # Bidi rule 3 + if direction in _bidi_rtl_valid_ending: + valid_ending = True + elif direction != "NSM": + valid_ending = False + # Bidi rule 4 + if direction in _bidi_rtl_numeric: + if not number_type: + number_type = direction + elif number_type != direction: + raise IDNABidiError("Can not mix numeral types in a right-to-left label") + else: + # Bidi rule 5 + if direction not in _bidi_ltr_allowed: + raise IDNABidiError(f"Invalid direction for codepoint at position {idx} in a left-to-right label") + # Bidi rule 6 + if direction in _bidi_ltr_valid_ending: + valid_ending = True + elif direction != "NSM": + valid_ending = False + + if not valid_ending: + raise IDNABidiError("Label ends with illegal codepoint directionality") + + return True + + +def check_initial_combiner(label: str) -> bool: + """Reject labels that begin with a combining mark. + + Per :rfc:`5891` §4.2.3.2 a label must not start with a character of + Unicode general category ``M`` (Mark). + + :param label: The label to check. + :returns: ``True`` if the first character is not a combining mark. + :raises IDNAError: If the label begins with a combining character. + """ + if unicodedata.category(label[0])[0] == "M": + raise IDNAError("Label begins with an illegal combining character") + return True + + +def check_hyphen_ok(label: str) -> bool: + """Validate the hyphen restrictions for a label. + + Per :rfc:`5891` §4.2.3.1 a label must not start or end with a hyphen + (``U+002D``), and must not have hyphens in both the third and fourth + positions (the prefix reserved for A-labels). + + :param label: The label to check. + :returns: ``True`` if the hyphen restrictions are satisfied. + :raises IDNAError: If any of the hyphen restrictions are violated. + """ + if label[2:4] == "--": + raise IDNAError("Label has disallowed hyphens in 3rd and 4th position") + if label[0] == "-" or label[-1] == "-": + raise IDNAError("Label must not start or end with a hyphen") + return True + + +def check_nfc(label: str) -> None: + """Require that a label is in Unicode Normalization Form C. + + :param label: The label to check. + :raises IDNAError: If ``label`` differs from its NFC normalisation. + """ + if len(label) > _max_input_length: + raise IDNAError("Label too long") + if unicodedata.normalize("NFC", label) != label: + raise IDNAError("Label must be in Normalization Form C") + + +def valid_contextj(label: str, pos: int) -> bool: + """Validate the CONTEXTJ rules from :rfc:`5892` Appendix A. + + These rules govern the contextual use of the joiner codepoints + ``U+200C`` (ZERO WIDTH NON-JOINER, Appendix A.1) and ``U+200D`` + (ZERO WIDTH JOINER, Appendix A.2) within a label. + + :param label: The label containing the codepoint. + :param pos: Index of the joiner codepoint within ``label``. + :returns: ``True`` if the codepoint at ``pos`` satisfies its CONTEXTJ + rule, ``False`` otherwise (including when the codepoint at + ``pos`` is not a recognised joiner). + :raises ValueError: If an adjacent codepoint has no Unicode name when + determining its combining class. + :raises IDNAError: If ``label`` exceeds the defensive input length limit. + """ + if len(label) > _max_input_length: + raise IDNAError("Label too long") + cp_value = ord(label[pos]) + + if cp_value == 0x200C: + if pos > 0 and _combining_class(ord(label[pos - 1])) == _virama_combining_class: + return True + + ok = False + for i in range(pos - 1, -1, -1): + joining_type = _joining_type(ord(label[i])) + if joining_type == "T": + continue + if joining_type in _bidi_joiner_l_or_d: + ok = True + break + break + + if not ok: + return False + + ok = False + for i in range(pos + 1, len(label)): + joining_type = _joining_type(ord(label[i])) + if joining_type == "T": + continue + if joining_type in _bidi_joiner_r_or_d: + ok = True + break + break + return ok + + if cp_value == 0x200D: + return pos > 0 and _combining_class(ord(label[pos - 1])) == _virama_combining_class + + return False + + +def valid_contexto(label: str, pos: int, exception: bool = False) -> bool: + """Validate the CONTEXTO rules from :rfc:`5892` Appendix A. + + Covers the contextual rules for codepoints such as MIDDLE DOT + (``U+00B7``), Greek lower numeral sign, Hebrew punctuation, Katakana + middle dot, and the Arabic-Indic / Extended Arabic-Indic digit ranges. + + :param label: The label containing the codepoint. + :param pos: Index of the codepoint within ``label``. + :param exception: Reserved for forward compatibility; currently unused. + :returns: ``True`` if the codepoint at ``pos`` satisfies its CONTEXTO + rule, ``False`` otherwise (including when the codepoint is not a + recognised CONTEXTO codepoint). + :raises IDNAError: If ``label`` exceeds the defensive input length limit. + """ + if len(label) > _max_input_length: + raise IDNAError("Label too long") + cp_value = ord(label[pos]) + + if cp_value == 0x00B7: + return 0 < pos < len(label) - 1 and ord(label[pos - 1]) == 0x006C and ord(label[pos + 1]) == 0x006C + + if cp_value == 0x0375: + if pos < len(label) - 1 and len(label) > 1: + return _is_script(label[pos + 1], "Greek") + return False + + if cp_value in {0x05F3, 0x05F4}: + if pos > 0: + return _is_script(label[pos - 1], "Hebrew") + return False + + if cp_value == 0x30FB: + for cp in label: + if cp == "\u30fb": + continue + if _is_script(cp, "Hiragana") or _is_script(cp, "Katakana") or _is_script(cp, "Han"): + return True + return False + + if 0x660 <= cp_value <= 0x669: + return not any(0x6F0 <= ord(cp) <= 0x06F9 for cp in label) + + if 0x6F0 <= cp_value <= 0x6F9: + return not any(0x660 <= ord(cp) <= 0x0669 for cp in label) + + return False + + +def check_label(label: Union[str, bytes, bytearray]) -> None: + """Run the full set of IDNA 2008 validity checks on a single label. + + Applies, in order: NFC normalisation (:func:`check_nfc`), hyphen + restrictions (:func:`check_hyphen_ok`), the no-leading-combiner rule + (:func:`check_initial_combiner`), per-codepoint validity (PVALID, + CONTEXTJ, CONTEXTO classes from :rfc:`5892`), and the Bidi Rule + (:func:`check_bidi`). + + :param label: The label to validate. ``bytes`` or ``bytearray`` input + is decoded as UTF-8 first. + :raises IDNAError: If the label is empty or fails a structural rule. + :raises InvalidCodepoint: If the label contains a DISALLOWED or + UNASSIGNED codepoint. + :raises InvalidCodepointContext: If a CONTEXTJ or CONTEXTO codepoint + is not valid in its context. + :raises IDNABidiError: If the Bidi Rule is violated. + """ + if len(label) > _max_input_length: + raise IDNAError("Label too long") + if isinstance(label, (bytes, bytearray)): + label = label.decode("utf-8") + if len(label) == 0: + raise IDNAError("Empty Label") + + # Reject on domain length rather than label length so support some UTS 46 + # use cases, still reducing processing of label contextual rules + if not valid_string_length(label, trailing_dot=True): + raise IDNAError("Label too long") + + check_nfc(label) + check_hyphen_ok(label) + check_initial_combiner(label) + + for pos, cp in enumerate(label): + cp_value = ord(cp) + if intranges_contain(cp_value, idnadata.codepoint_classes["PVALID"]): + continue + if intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTJ"]): + try: + if not valid_contextj(label, pos): + raise InvalidCodepointContext(f"Joiner {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}") + except ValueError as err: + raise IDNAError( + f"Unknown codepoint adjacent to joiner {_unot(cp_value)} at position {pos + 1} in {label!r}" + ) from err + elif intranges_contain(cp_value, idnadata.codepoint_classes["CONTEXTO"]): + if not valid_contexto(label, pos): + raise InvalidCodepointContext(f"Codepoint {_unot(cp_value)} not allowed at position {pos + 1} in {label!r}") + else: + raise InvalidCodepoint(f"Codepoint {_unot(cp_value)} at position {pos + 1} of {label!r} not allowed") + + check_bidi(label) + + +def alabel(label: str) -> bytes: + """Convert a single U-label into its A-label form. + + The result is the ASCII-Compatible Encoding (ACE) form per :rfc:`5891` + §4: the label is validated, Punycode-encoded, and prefixed with + ``xn--``. Pure ASCII labels that are already valid IDNA labels are + returned unchanged (as :class:`bytes`). + + :param label: The label to convert, as a Unicode string. + :returns: The A-label as ASCII-encoded :class:`bytes`. + :raises IDNAError: If the label is invalid or the resulting A-label + exceeds 63 octets. + """ + if len(label) > _max_input_length: + raise IDNAError("Label too long") + try: + label_bytes = label.encode("ascii") + except UnicodeEncodeError: + pass + else: + ulabel(label_bytes) + if not valid_label_length(label_bytes): + raise IDNAError("Label too long") + return label_bytes + + check_label(label) + label_bytes = _alabel_prefix + _punycode(label) + + if not valid_label_length(label_bytes): + raise IDNAError("Label too long") + + return label_bytes + + +def ulabel(label: Union[str, bytes, bytearray]) -> str: + """Convert a single A-label into its U-label form. + + Performs the inverse of :func:`alabel`: an ``xn--``-prefixed label is + Punycode-decoded and validated. Labels that are already Unicode (or + plain ASCII without the ACE prefix) are validated and returned as a + Unicode string. + + :param label: The label to convert. ``bytes`` or ``bytearray`` input + is treated as ASCII. + :returns: The U-label as a Unicode string. + :raises IDNAError: If the label is malformed or fails validation. + """ + if len(label) > _max_input_length: + raise IDNAError("Label too long") + if not isinstance(label, (bytes, bytearray)): + try: + label_bytes = label.encode("ascii") + except UnicodeEncodeError: + check_label(label) + return label + else: + label_bytes = bytes(label) + + label_bytes = label_bytes.lower() + if label_bytes.startswith(_alabel_prefix): + label_bytes = label_bytes[len(_alabel_prefix) :] + if not label_bytes: + raise IDNAError("Malformed A-label, no Punycode eligible content found") + if label_bytes.endswith(b"-"): + raise IDNAError("A-label must not end with a hyphen") + else: + check_label(label_bytes) + return label_bytes.decode("ascii") + + try: + label = label_bytes.decode("punycode") + except UnicodeError as err: + raise IDNAError("Invalid A-label") from err + check_label(label) + return label + + +def uts46_remap(domain: str, std3_rules: bool = True, transitional: bool = False) -> str: + """Apply the UTS #46 character mapping to a domain string. + + Implements the mapping table from `UTS #46 §4 + `_: each character is kept, + replaced, or rejected based on its status (``V``, ``M``, ``D``, ``3``, + ``I``). The result is returned in Normalisation Form C. + + :param domain: The full domain name to remap. + :param std3_rules: If ``True``, apply the stricter STD3 ASCII rules + (status ``3`` codepoints raise instead of being kept or mapped). + :param transitional: If ``True``, use transitional processing (status + ``D`` codepoints are mapped instead of kept). Transitional + processing has been removed from UTS #46 and this option is + retained only for backwards compatibility. + :returns: The remapped domain, in Normalisation Form C. + :raises InvalidCodepoint: If the domain contains a disallowed + codepoint under the chosen rules. + :raises IDNAError: If ``domain`` exceeds the defensive input length limit. + """ + if len(domain) > _max_input_length: + raise IDNAError("Domain too long") + from .uts46data import uts46_replacements, uts46_starts, uts46_statuses + + output = "" + + for pos, char in enumerate(domain): + code_point = ord(char) + i = code_point if code_point < 256 else bisect.bisect_right(uts46_starts, code_point) - 1 + status = chr(uts46_statuses[i]) + replacement: Optional[str] = uts46_replacements[i] + + # UTS #46 §4: V is always valid, D is deviation (kept unless transitional), + # 3 is disallowed-STD3 (kept unmapped if std3_rules is off and no mapping). + keep_as_is = ( + status == "V" or (status == "D" and not transitional) or (status == "3" and not std3_rules and replacement is None) + ) + # M is mapped, 3-with-replacement and transitional D fall through to the + # same replacement output path. + use_replacement = replacement is not None and ( + status == "M" or (status == "3" and not std3_rules) or (status == "D" and transitional) + ) + + if keep_as_is: + output += char + elif use_replacement: + assert replacement is not None # narrowed by use_replacement + output += replacement + elif status == "I": + continue + else: + raise InvalidCodepoint(f"Codepoint {_unot(code_point)} not allowed at position {pos + 1} in {domain!r}") + + return unicodedata.normalize("NFC", output) + + +def encode( + s: Union[str, bytes, bytearray], + strict: bool = False, + uts46: bool = False, + std3_rules: bool = False, + transitional: bool = False, +) -> bytes: + """Encode a Unicode domain name into its ASCII (A-label) form. + + Splits the input on label separators (only ``U+002E`` if ``strict`` is + set; otherwise also IDEOGRAPHIC FULL STOP ``U+3002``, FULLWIDTH FULL + STOP ``U+FF0E``, and HALFWIDTH IDEOGRAPHIC FULL STOP ``U+FF61``), + encodes each label with :func:`alabel`, and rejoins them with ``.``. + Optionally pre-processes the input through :func:`uts46_remap`. + + :param s: The domain name to encode. + :param strict: If ``True``, only ``U+002E`` is recognised as a label + separator. + :param uts46: If ``True``, apply UTS #46 mapping before encoding. + :param std3_rules: Forwarded to :func:`uts46_remap` when ``uts46`` is + ``True``. + :param transitional: Forwarded to :func:`uts46_remap` when ``uts46`` + is ``True``. Deprecated: emits a :class:`DeprecationWarning` and + will be removed in a future version. + :returns: The encoded domain as ASCII :class:`bytes`. + :raises IDNAError: If the domain is empty, contains an invalid label, + or exceeds the maximum domain length. + """ + if transitional: + warnings.warn( + "Transitional processing has been removed from UTS #46. " + "The transitional argument will be removed in a future version.", + DeprecationWarning, + stacklevel=2, + ) + if not isinstance(s, str): + try: + s = str(s, "ascii") + except (UnicodeDecodeError, TypeError) as err: + raise IDNAError("should pass a unicode string to the function rather than a byte string.") from err + if len(s) > _max_input_length: + raise IDNAError("Domain too long") + if uts46: + s = uts46_remap(s, std3_rules, transitional) + + # Reject inputs that exceed the maximum DNS domain length up-front + # to avoid expensive computation on long inputs. + if not valid_string_length(s, trailing_dot=True): + raise IDNAError("Domain too long") + + trailing_dot = False + result = [] + labels = s.split(".") if strict else _unicode_dots_re.split(s) + if not labels or labels == [""]: + raise IDNAError("Empty domain") + if labels[-1] == "": + del labels[-1] + trailing_dot = True + for label in labels: + s = alabel(label) + if s: + result.append(s) + else: + raise IDNAError("Empty label") + if trailing_dot: + result.append(b"") + s = b".".join(result) + if not valid_string_length(s, trailing_dot): + raise IDNAError("Domain too long") + return s + + +def decode( + s: Union[str, bytes, bytearray], + strict: bool = False, + uts46: bool = False, + std3_rules: bool = False, + display: bool = False, +) -> str: + """Decode an A-label-encoded domain name back to Unicode. + + Splits the input on label separators (see :func:`encode` for the + rules), decodes each label with :func:`ulabel`, and rejoins them + with ``.``. Optionally pre-processes the input through + :func:`uts46_remap`. + + :param s: The domain name to decode. + :param strict: If ``True``, only ``U+002E`` is recognised as a label + separator. + :param uts46: If ``True``, apply UTS #46 mapping before decoding. + :param std3_rules: Forwarded to :func:`uts46_remap` when ``uts46`` is + ``True``. + :param display: If ``True``, any ``xn--`` label that fails IDNA + validation is passed through unchanged (lowercased) rather than + aborting the whole call. Intended for "decode for display" + consumers (e.g. URL libraries, HTTP clients) that want to show + the user the label as it appears on the wire when it cannot be + rendered as Unicode. Matches the per-label recovery prescribed + by UTS #46 §4 and the WHATWG URL "domain to Unicode" algorithm. + :returns: The decoded domain as a Unicode string. + :raises IDNAError: If the input is not valid ASCII, contains an + invalid label, or is empty. + """ + if not isinstance(s, str): + try: + s = str(s, "ascii") + except (UnicodeDecodeError, TypeError) as err: + raise IDNAError("Invalid ASCII in A-label") from err + if len(s) > _max_input_length: + raise IDNAError("Domain too long") + if uts46: + s = uts46_remap(s, std3_rules, False) + # Reject inputs that exceed the maximum DNS domain length up-front + # to avoid expensive computation on long inputs. + if not valid_string_length(s, trailing_dot=True): + raise IDNAError("Domain too long") + trailing_dot = False + result = [] + labels = s.split(".") if strict else _unicode_dots_re.split(s) + if not labels or labels == [""]: + raise IDNAError("Empty domain") + if not labels[-1]: + del labels[-1] + trailing_dot = True + for label in labels: + try: + u = ulabel(label) + except IDNAError: + if display and label[:4].lower() == "xn--": + u = label.lower() + else: + raise + if u: + result.append(u) + else: + raise IDNAError("Empty label") + if trailing_dot: + result.append("") + return ".".join(result) diff --git a/server/venv/Lib/site-packages/idna/idnadata.py b/server/venv/Lib/site-packages/idna/idnadata.py new file mode 100644 index 0000000..f2ab388 --- /dev/null +++ b/server/venv/Lib/site-packages/idna/idnadata.py @@ -0,0 +1,1897 @@ +# This file is automatically generated by tools/idna-data + +__version__ = "17.0.0" + +scripts = { + "Greek": ( + 0x37000000374, + 0x37500000378, + 0x37A0000037E, + 0x37F00000380, + 0x38400000385, + 0x38600000387, + 0x3880000038B, + 0x38C0000038D, + 0x38E000003A2, + 0x3A3000003E2, + 0x3F000000400, + 0x1D2600001D2B, + 0x1D5D00001D62, + 0x1D6600001D6B, + 0x1DBF00001DC0, + 0x1F0000001F16, + 0x1F1800001F1E, + 0x1F2000001F46, + 0x1F4800001F4E, + 0x1F5000001F58, + 0x1F5900001F5A, + 0x1F5B00001F5C, + 0x1F5D00001F5E, + 0x1F5F00001F7E, + 0x1F8000001FB5, + 0x1FB600001FC5, + 0x1FC600001FD4, + 0x1FD600001FDC, + 0x1FDD00001FF0, + 0x1FF200001FF5, + 0x1FF600001FFF, + 0x212600002127, + 0xAB650000AB66, + 0x101400001018F, + 0x101A0000101A1, + 0x1D2000001D246, + ), + "Han": ( + 0x2E8000002E9A, + 0x2E9B00002EF4, + 0x2F0000002FD6, + 0x300500003006, + 0x300700003008, + 0x30210000302A, + 0x30380000303C, + 0x340000004DC0, + 0x4E000000A000, + 0xF9000000FA6E, + 0xFA700000FADA, + 0x16FE200016FE4, + 0x16FF000016FF7, + 0x200000002A6E0, + 0x2A7000002B81E, + 0x2B8200002CEAE, + 0x2CEB00002EBE1, + 0x2EBF00002EE5E, + 0x2F8000002FA1E, + 0x300000003134B, + 0x313500003347A, + ), + "Hebrew": ( + 0x591000005C8, + 0x5D0000005EB, + 0x5EF000005F5, + 0xFB1D0000FB37, + 0xFB380000FB3D, + 0xFB3E0000FB3F, + 0xFB400000FB42, + 0xFB430000FB45, + 0xFB460000FB50, + ), + "Hiragana": ( + 0x304100003097, + 0x309D000030A0, + 0x1B0010001B120, + 0x1B1320001B133, + 0x1B1500001B153, + 0x1F2000001F201, + ), + "Katakana": ( + 0x30A1000030FB, + 0x30FD00003100, + 0x31F000003200, + 0x32D0000032FF, + 0x330000003358, + 0xFF660000FF70, + 0xFF710000FF9E, + 0x1AFF00001AFF4, + 0x1AFF50001AFFC, + 0x1AFFD0001AFFF, + 0x1B0000001B001, + 0x1B1200001B123, + 0x1B1550001B156, + 0x1B1640001B168, + ), +} + + +joining_types = { + "C": ( + 0x64000000641, + 0x7FA000007FB, + 0x88300000886, + 0x180A0000180B, + 0x200D0000200E, + ), + "D": ( + 0x62000000621, + 0x62600000627, + 0x62800000629, + 0x62A0000062F, + 0x63300000640, + 0x64100000648, + 0x6490000064B, + 0x66E00000670, + 0x67800000688, + 0x69A000006C0, + 0x6C1000006C3, + 0x6CC000006CD, + 0x6CE000006CF, + 0x6D0000006D2, + 0x6FA000006FD, + 0x6FF00000700, + 0x71200000715, + 0x71A0000071E, + 0x71F00000728, + 0x7290000072A, + 0x72B0000072C, + 0x72D0000072F, + 0x74E00000759, + 0x75C0000076B, + 0x76D00000771, + 0x77200000773, + 0x77500000778, + 0x77A00000780, + 0x7CA000007EB, + 0x84100000846, + 0x84800000849, + 0x84A00000854, + 0x85500000856, + 0x86000000861, + 0x86200000866, + 0x86800000869, + 0x88600000887, + 0x8890000088E, + 0x88F00000890, + 0x8A0000008AA, + 0x8AF000008B1, + 0x8B3000008B9, + 0x8BA000008C9, + 0x180700001808, + 0x182000001879, + 0x1887000018A9, + 0x18AA000018AB, + 0xA8400000A872, + 0x10AC000010AC5, + 0x10AD300010AD7, + 0x10AD800010ADD, + 0x10ADE00010AE1, + 0x10AEB00010AEF, + 0x10B8000010B81, + 0x10B8200010B83, + 0x10B8600010B89, + 0x10B8A00010B8C, + 0x10B8D00010B8E, + 0x10B9000010B91, + 0x10BAD00010BAF, + 0x10D0100010D22, + 0x10D2300010D24, + 0x10EC300010EC5, + 0x10EC600010EC8, + 0x10F3000010F33, + 0x10F3400010F45, + 0x10F5100010F54, + 0x10F7000010F74, + 0x10F7600010F82, + 0x10FB000010FB1, + 0x10FB200010FB4, + 0x10FB800010FB9, + 0x10FBB00010FBD, + 0x10FBE00010FC0, + 0x10FC100010FC2, + 0x10FC400010FC5, + 0x10FCA00010FCB, + 0x1E9000001E944, + ), + "L": ( + 0xA8720000A873, + 0x10ACD00010ACE, + 0x10AD700010AD8, + 0x10D0000010D01, + 0x10FCB00010FCC, + ), + "R": ( + 0x62200000626, + 0x62700000628, + 0x6290000062A, + 0x62F00000633, + 0x64800000649, + 0x67100000674, + 0x67500000678, + 0x6880000069A, + 0x6C0000006C1, + 0x6C3000006CC, + 0x6CD000006CE, + 0x6CF000006D0, + 0x6D2000006D4, + 0x6D5000006D6, + 0x6EE000006F0, + 0x71000000711, + 0x7150000071A, + 0x71E0000071F, + 0x72800000729, + 0x72A0000072B, + 0x72C0000072D, + 0x72F00000730, + 0x74D0000074E, + 0x7590000075C, + 0x76B0000076D, + 0x77100000772, + 0x77300000775, + 0x7780000077A, + 0x84000000841, + 0x84600000848, + 0x8490000084A, + 0x85400000855, + 0x85600000859, + 0x86700000868, + 0x8690000086B, + 0x87000000883, + 0x88E0000088F, + 0x8AA000008AD, + 0x8AE000008AF, + 0x8B1000008B3, + 0x8B9000008BA, + 0x10AC500010AC6, + 0x10AC700010AC8, + 0x10AC900010ACB, + 0x10ACE00010AD3, + 0x10ADD00010ADE, + 0x10AE100010AE2, + 0x10AE400010AE5, + 0x10AEF00010AF0, + 0x10B8100010B82, + 0x10B8300010B86, + 0x10B8900010B8A, + 0x10B8C00010B8D, + 0x10B8E00010B90, + 0x10B9100010B92, + 0x10BA900010BAD, + 0x10D2200010D23, + 0x10EC200010EC3, + 0x10F3300010F34, + 0x10F5400010F55, + 0x10F7400010F76, + 0x10FB400010FB7, + 0x10FB900010FBB, + 0x10FBD00010FBE, + 0x10FC200010FC4, + 0x10FC900010FCA, + ), + "T": ( + 0xAD000000AE, + 0x30000000370, + 0x4830000048A, + 0x591000005BE, + 0x5BF000005C0, + 0x5C1000005C3, + 0x5C4000005C6, + 0x5C7000005C8, + 0x6100000061B, + 0x61C0000061D, + 0x64B00000660, + 0x67000000671, + 0x6D6000006DD, + 0x6DF000006E5, + 0x6E7000006E9, + 0x6EA000006EE, + 0x70F00000710, + 0x71100000712, + 0x7300000074B, + 0x7A6000007B1, + 0x7EB000007F4, + 0x7FD000007FE, + 0x8160000081A, + 0x81B00000824, + 0x82500000828, + 0x8290000082E, + 0x8590000085C, + 0x897000008A0, + 0x8CA000008E2, + 0x8E300000903, + 0x93A0000093B, + 0x93C0000093D, + 0x94100000949, + 0x94D0000094E, + 0x95100000958, + 0x96200000964, + 0x98100000982, + 0x9BC000009BD, + 0x9C1000009C5, + 0x9CD000009CE, + 0x9E2000009E4, + 0x9FE000009FF, + 0xA0100000A03, + 0xA3C00000A3D, + 0xA4100000A43, + 0xA4700000A49, + 0xA4B00000A4E, + 0xA5100000A52, + 0xA7000000A72, + 0xA7500000A76, + 0xA8100000A83, + 0xABC00000ABD, + 0xAC100000AC6, + 0xAC700000AC9, + 0xACD00000ACE, + 0xAE200000AE4, + 0xAFA00000B00, + 0xB0100000B02, + 0xB3C00000B3D, + 0xB3F00000B40, + 0xB4100000B45, + 0xB4D00000B4E, + 0xB5500000B57, + 0xB6200000B64, + 0xB8200000B83, + 0xBC000000BC1, + 0xBCD00000BCE, + 0xC0000000C01, + 0xC0400000C05, + 0xC3C00000C3D, + 0xC3E00000C41, + 0xC4600000C49, + 0xC4A00000C4E, + 0xC5500000C57, + 0xC6200000C64, + 0xC8100000C82, + 0xCBC00000CBD, + 0xCBF00000CC0, + 0xCC600000CC7, + 0xCCC00000CCE, + 0xCE200000CE4, + 0xD0000000D02, + 0xD3B00000D3D, + 0xD4100000D45, + 0xD4D00000D4E, + 0xD6200000D64, + 0xD8100000D82, + 0xDCA00000DCB, + 0xDD200000DD5, + 0xDD600000DD7, + 0xE3100000E32, + 0xE3400000E3B, + 0xE4700000E4F, + 0xEB100000EB2, + 0xEB400000EBD, + 0xEC800000ECF, + 0xF1800000F1A, + 0xF3500000F36, + 0xF3700000F38, + 0xF3900000F3A, + 0xF7100000F7F, + 0xF8000000F85, + 0xF8600000F88, + 0xF8D00000F98, + 0xF9900000FBD, + 0xFC600000FC7, + 0x102D00001031, + 0x103200001038, + 0x10390000103B, + 0x103D0000103F, + 0x10580000105A, + 0x105E00001061, + 0x107100001075, + 0x108200001083, + 0x108500001087, + 0x108D0000108E, + 0x109D0000109E, + 0x135D00001360, + 0x171200001715, + 0x173200001734, + 0x175200001754, + 0x177200001774, + 0x17B4000017B6, + 0x17B7000017BE, + 0x17C6000017C7, + 0x17C9000017D4, + 0x17DD000017DE, + 0x180B0000180E, + 0x180F00001810, + 0x188500001887, + 0x18A9000018AA, + 0x192000001923, + 0x192700001929, + 0x193200001933, + 0x19390000193C, + 0x1A1700001A19, + 0x1A1B00001A1C, + 0x1A5600001A57, + 0x1A5800001A5F, + 0x1A6000001A61, + 0x1A6200001A63, + 0x1A6500001A6D, + 0x1A7300001A7D, + 0x1A7F00001A80, + 0x1AB000001ADE, + 0x1AE000001AEC, + 0x1B0000001B04, + 0x1B3400001B35, + 0x1B3600001B3B, + 0x1B3C00001B3D, + 0x1B4200001B43, + 0x1B6B00001B74, + 0x1B8000001B82, + 0x1BA200001BA6, + 0x1BA800001BAA, + 0x1BAB00001BAE, + 0x1BE600001BE7, + 0x1BE800001BEA, + 0x1BED00001BEE, + 0x1BEF00001BF2, + 0x1C2C00001C34, + 0x1C3600001C38, + 0x1CD000001CD3, + 0x1CD400001CE1, + 0x1CE200001CE9, + 0x1CED00001CEE, + 0x1CF400001CF5, + 0x1CF800001CFA, + 0x1DC000001E00, + 0x200B0000200C, + 0x200E00002010, + 0x202A0000202F, + 0x206000002065, + 0x206A00002070, + 0x20D0000020F1, + 0x2CEF00002CF2, + 0x2D7F00002D80, + 0x2DE000002E00, + 0x302A0000302E, + 0x30990000309B, + 0xA66F0000A673, + 0xA6740000A67E, + 0xA69E0000A6A0, + 0xA6F00000A6F2, + 0xA8020000A803, + 0xA8060000A807, + 0xA80B0000A80C, + 0xA8250000A827, + 0xA82C0000A82D, + 0xA8C40000A8C6, + 0xA8E00000A8F2, + 0xA8FF0000A900, + 0xA9260000A92E, + 0xA9470000A952, + 0xA9800000A983, + 0xA9B30000A9B4, + 0xA9B60000A9BA, + 0xA9BC0000A9BE, + 0xA9E50000A9E6, + 0xAA290000AA2F, + 0xAA310000AA33, + 0xAA350000AA37, + 0xAA430000AA44, + 0xAA4C0000AA4D, + 0xAA7C0000AA7D, + 0xAAB00000AAB1, + 0xAAB20000AAB5, + 0xAAB70000AAB9, + 0xAABE0000AAC0, + 0xAAC10000AAC2, + 0xAAEC0000AAEE, + 0xAAF60000AAF7, + 0xABE50000ABE6, + 0xABE80000ABE9, + 0xABED0000ABEE, + 0xFB1E0000FB1F, + 0xFE000000FE10, + 0xFE200000FE30, + 0xFEFF0000FF00, + 0xFFF90000FFFC, + 0x101FD000101FE, + 0x102E0000102E1, + 0x103760001037B, + 0x10A0100010A04, + 0x10A0500010A07, + 0x10A0C00010A10, + 0x10A3800010A3B, + 0x10A3F00010A40, + 0x10AE500010AE7, + 0x10D2400010D28, + 0x10D6900010D6E, + 0x10EAB00010EAD, + 0x10EFA00010F00, + 0x10F4600010F51, + 0x10F8200010F86, + 0x1100100011002, + 0x1103800011047, + 0x1107000011071, + 0x1107300011075, + 0x1107F00011082, + 0x110B3000110B7, + 0x110B9000110BB, + 0x110C2000110C3, + 0x1110000011103, + 0x111270001112C, + 0x1112D00011135, + 0x1117300011174, + 0x1118000011182, + 0x111B6000111BF, + 0x111C9000111CD, + 0x111CF000111D0, + 0x1122F00011232, + 0x1123400011235, + 0x1123600011238, + 0x1123E0001123F, + 0x1124100011242, + 0x112DF000112E0, + 0x112E3000112EB, + 0x1130000011302, + 0x1133B0001133D, + 0x1134000011341, + 0x113660001136D, + 0x1137000011375, + 0x113BB000113C1, + 0x113CE000113CF, + 0x113D0000113D1, + 0x113D2000113D3, + 0x113E1000113E3, + 0x1143800011440, + 0x1144200011445, + 0x1144600011447, + 0x1145E0001145F, + 0x114B3000114B9, + 0x114BA000114BB, + 0x114BF000114C1, + 0x114C2000114C4, + 0x115B2000115B6, + 0x115BC000115BE, + 0x115BF000115C1, + 0x115DC000115DE, + 0x116330001163B, + 0x1163D0001163E, + 0x1163F00011641, + 0x116AB000116AC, + 0x116AD000116AE, + 0x116B0000116B6, + 0x116B7000116B8, + 0x1171D0001171E, + 0x1171F00011720, + 0x1172200011726, + 0x117270001172C, + 0x1182F00011838, + 0x118390001183B, + 0x1193B0001193D, + 0x1193E0001193F, + 0x1194300011944, + 0x119D4000119D8, + 0x119DA000119DC, + 0x119E0000119E1, + 0x11A0100011A0B, + 0x11A3300011A39, + 0x11A3B00011A3F, + 0x11A4700011A48, + 0x11A5100011A57, + 0x11A5900011A5C, + 0x11A8A00011A97, + 0x11A9800011A9A, + 0x11B6000011B61, + 0x11B6200011B65, + 0x11B6600011B67, + 0x11C3000011C37, + 0x11C3800011C3E, + 0x11C3F00011C40, + 0x11C9200011CA8, + 0x11CAA00011CB1, + 0x11CB200011CB4, + 0x11CB500011CB7, + 0x11D3100011D37, + 0x11D3A00011D3B, + 0x11D3C00011D3E, + 0x11D3F00011D46, + 0x11D4700011D48, + 0x11D9000011D92, + 0x11D9500011D96, + 0x11D9700011D98, + 0x11EF300011EF5, + 0x11F0000011F02, + 0x11F3600011F3B, + 0x11F4000011F41, + 0x11F4200011F43, + 0x11F5A00011F5B, + 0x1343000013441, + 0x1344700013456, + 0x1611E0001612A, + 0x1612D00016130, + 0x16AF000016AF5, + 0x16B3000016B37, + 0x16F4F00016F50, + 0x16F8F00016F93, + 0x16FE400016FE5, + 0x1BC9D0001BC9F, + 0x1BCA00001BCA4, + 0x1CF000001CF2E, + 0x1CF300001CF47, + 0x1D1670001D16A, + 0x1D1730001D183, + 0x1D1850001D18C, + 0x1D1AA0001D1AE, + 0x1D2420001D245, + 0x1DA000001DA37, + 0x1DA3B0001DA6D, + 0x1DA750001DA76, + 0x1DA840001DA85, + 0x1DA9B0001DAA0, + 0x1DAA10001DAB0, + 0x1E0000001E007, + 0x1E0080001E019, + 0x1E01B0001E022, + 0x1E0230001E025, + 0x1E0260001E02B, + 0x1E08F0001E090, + 0x1E1300001E137, + 0x1E2AE0001E2AF, + 0x1E2EC0001E2F0, + 0x1E4EC0001E4F0, + 0x1E5EE0001E5F0, + 0x1E6E30001E6E4, + 0x1E6E60001E6E7, + 0x1E6EE0001E6F0, + 0x1E6F50001E6F6, + 0x1E8D00001E8D7, + 0x1E9440001E94C, + 0xE0001000E0002, + 0xE0020000E0080, + 0xE0100000E01F0, + ), +} + + +codepoint_classes = { + "PVALID": ( + 0x2D0000002E, + 0x300000003A, + 0x610000007B, + 0xDF000000F7, + 0xF800000100, + 0x10100000102, + 0x10300000104, + 0x10500000106, + 0x10700000108, + 0x1090000010A, + 0x10B0000010C, + 0x10D0000010E, + 0x10F00000110, + 0x11100000112, + 0x11300000114, + 0x11500000116, + 0x11700000118, + 0x1190000011A, + 0x11B0000011C, + 0x11D0000011E, + 0x11F00000120, + 0x12100000122, + 0x12300000124, + 0x12500000126, + 0x12700000128, + 0x1290000012A, + 0x12B0000012C, + 0x12D0000012E, + 0x12F00000130, + 0x13100000132, + 0x13500000136, + 0x13700000139, + 0x13A0000013B, + 0x13C0000013D, + 0x13E0000013F, + 0x14200000143, + 0x14400000145, + 0x14600000147, + 0x14800000149, + 0x14B0000014C, + 0x14D0000014E, + 0x14F00000150, + 0x15100000152, + 0x15300000154, + 0x15500000156, + 0x15700000158, + 0x1590000015A, + 0x15B0000015C, + 0x15D0000015E, + 0x15F00000160, + 0x16100000162, + 0x16300000164, + 0x16500000166, + 0x16700000168, + 0x1690000016A, + 0x16B0000016C, + 0x16D0000016E, + 0x16F00000170, + 0x17100000172, + 0x17300000174, + 0x17500000176, + 0x17700000178, + 0x17A0000017B, + 0x17C0000017D, + 0x17E0000017F, + 0x18000000181, + 0x18300000184, + 0x18500000186, + 0x18800000189, + 0x18C0000018E, + 0x19200000193, + 0x19500000196, + 0x1990000019C, + 0x19E0000019F, + 0x1A1000001A2, + 0x1A3000001A4, + 0x1A5000001A6, + 0x1A8000001A9, + 0x1AA000001AC, + 0x1AD000001AE, + 0x1B0000001B1, + 0x1B4000001B5, + 0x1B6000001B7, + 0x1B9000001BC, + 0x1BD000001C4, + 0x1CE000001CF, + 0x1D0000001D1, + 0x1D2000001D3, + 0x1D4000001D5, + 0x1D6000001D7, + 0x1D8000001D9, + 0x1DA000001DB, + 0x1DC000001DE, + 0x1DF000001E0, + 0x1E1000001E2, + 0x1E3000001E4, + 0x1E5000001E6, + 0x1E7000001E8, + 0x1E9000001EA, + 0x1EB000001EC, + 0x1ED000001EE, + 0x1EF000001F1, + 0x1F5000001F6, + 0x1F9000001FA, + 0x1FB000001FC, + 0x1FD000001FE, + 0x1FF00000200, + 0x20100000202, + 0x20300000204, + 0x20500000206, + 0x20700000208, + 0x2090000020A, + 0x20B0000020C, + 0x20D0000020E, + 0x20F00000210, + 0x21100000212, + 0x21300000214, + 0x21500000216, + 0x21700000218, + 0x2190000021A, + 0x21B0000021C, + 0x21D0000021E, + 0x21F00000220, + 0x22100000222, + 0x22300000224, + 0x22500000226, + 0x22700000228, + 0x2290000022A, + 0x22B0000022C, + 0x22D0000022E, + 0x22F00000230, + 0x23100000232, + 0x2330000023A, + 0x23C0000023D, + 0x23F00000241, + 0x24200000243, + 0x24700000248, + 0x2490000024A, + 0x24B0000024C, + 0x24D0000024E, + 0x24F000002B0, + 0x2B9000002C2, + 0x2C6000002D2, + 0x2EC000002ED, + 0x2EE000002EF, + 0x30000000340, + 0x34200000343, + 0x3460000034F, + 0x35000000370, + 0x37100000372, + 0x37300000374, + 0x37700000378, + 0x37B0000037E, + 0x39000000391, + 0x3AC000003CF, + 0x3D7000003D8, + 0x3D9000003DA, + 0x3DB000003DC, + 0x3DD000003DE, + 0x3DF000003E0, + 0x3E1000003E2, + 0x3E3000003E4, + 0x3E5000003E6, + 0x3E7000003E8, + 0x3E9000003EA, + 0x3EB000003EC, + 0x3ED000003EE, + 0x3EF000003F0, + 0x3F3000003F4, + 0x3F8000003F9, + 0x3FB000003FD, + 0x43000000460, + 0x46100000462, + 0x46300000464, + 0x46500000466, + 0x46700000468, + 0x4690000046A, + 0x46B0000046C, + 0x46D0000046E, + 0x46F00000470, + 0x47100000472, + 0x47300000474, + 0x47500000476, + 0x47700000478, + 0x4790000047A, + 0x47B0000047C, + 0x47D0000047E, + 0x47F00000480, + 0x48100000482, + 0x48300000488, + 0x48B0000048C, + 0x48D0000048E, + 0x48F00000490, + 0x49100000492, + 0x49300000494, + 0x49500000496, + 0x49700000498, + 0x4990000049A, + 0x49B0000049C, + 0x49D0000049E, + 0x49F000004A0, + 0x4A1000004A2, + 0x4A3000004A4, + 0x4A5000004A6, + 0x4A7000004A8, + 0x4A9000004AA, + 0x4AB000004AC, + 0x4AD000004AE, + 0x4AF000004B0, + 0x4B1000004B2, + 0x4B3000004B4, + 0x4B5000004B6, + 0x4B7000004B8, + 0x4B9000004BA, + 0x4BB000004BC, + 0x4BD000004BE, + 0x4BF000004C0, + 0x4C2000004C3, + 0x4C4000004C5, + 0x4C6000004C7, + 0x4C8000004C9, + 0x4CA000004CB, + 0x4CC000004CD, + 0x4CE000004D0, + 0x4D1000004D2, + 0x4D3000004D4, + 0x4D5000004D6, + 0x4D7000004D8, + 0x4D9000004DA, + 0x4DB000004DC, + 0x4DD000004DE, + 0x4DF000004E0, + 0x4E1000004E2, + 0x4E3000004E4, + 0x4E5000004E6, + 0x4E7000004E8, + 0x4E9000004EA, + 0x4EB000004EC, + 0x4ED000004EE, + 0x4EF000004F0, + 0x4F1000004F2, + 0x4F3000004F4, + 0x4F5000004F6, + 0x4F7000004F8, + 0x4F9000004FA, + 0x4FB000004FC, + 0x4FD000004FE, + 0x4FF00000500, + 0x50100000502, + 0x50300000504, + 0x50500000506, + 0x50700000508, + 0x5090000050A, + 0x50B0000050C, + 0x50D0000050E, + 0x50F00000510, + 0x51100000512, + 0x51300000514, + 0x51500000516, + 0x51700000518, + 0x5190000051A, + 0x51B0000051C, + 0x51D0000051E, + 0x51F00000520, + 0x52100000522, + 0x52300000524, + 0x52500000526, + 0x52700000528, + 0x5290000052A, + 0x52B0000052C, + 0x52D0000052E, + 0x52F00000530, + 0x5590000055A, + 0x56000000587, + 0x58800000589, + 0x591000005BE, + 0x5BF000005C0, + 0x5C1000005C3, + 0x5C4000005C6, + 0x5C7000005C8, + 0x5D0000005EB, + 0x5EF000005F3, + 0x6100000061B, + 0x62000000640, + 0x64100000660, + 0x66E00000675, + 0x679000006D4, + 0x6D5000006DD, + 0x6DF000006E9, + 0x6EA000006F0, + 0x6FA00000700, + 0x7100000074B, + 0x74D000007B2, + 0x7C0000007F6, + 0x7FD000007FE, + 0x8000000082E, + 0x8400000085C, + 0x8600000086B, + 0x87000000888, + 0x88900000890, + 0x897000008E2, + 0x8E300000958, + 0x96000000964, + 0x96600000970, + 0x97100000984, + 0x9850000098D, + 0x98F00000991, + 0x993000009A9, + 0x9AA000009B1, + 0x9B2000009B3, + 0x9B6000009BA, + 0x9BC000009C5, + 0x9C7000009C9, + 0x9CB000009CF, + 0x9D7000009D8, + 0x9E0000009E4, + 0x9E6000009F2, + 0x9FC000009FD, + 0x9FE000009FF, + 0xA0100000A04, + 0xA0500000A0B, + 0xA0F00000A11, + 0xA1300000A29, + 0xA2A00000A31, + 0xA3200000A33, + 0xA3500000A36, + 0xA3800000A3A, + 0xA3C00000A3D, + 0xA3E00000A43, + 0xA4700000A49, + 0xA4B00000A4E, + 0xA5100000A52, + 0xA5C00000A5D, + 0xA6600000A76, + 0xA8100000A84, + 0xA8500000A8E, + 0xA8F00000A92, + 0xA9300000AA9, + 0xAAA00000AB1, + 0xAB200000AB4, + 0xAB500000ABA, + 0xABC00000AC6, + 0xAC700000ACA, + 0xACB00000ACE, + 0xAD000000AD1, + 0xAE000000AE4, + 0xAE600000AF0, + 0xAF900000B00, + 0xB0100000B04, + 0xB0500000B0D, + 0xB0F00000B11, + 0xB1300000B29, + 0xB2A00000B31, + 0xB3200000B34, + 0xB3500000B3A, + 0xB3C00000B45, + 0xB4700000B49, + 0xB4B00000B4E, + 0xB5500000B58, + 0xB5F00000B64, + 0xB6600000B70, + 0xB7100000B72, + 0xB8200000B84, + 0xB8500000B8B, + 0xB8E00000B91, + 0xB9200000B96, + 0xB9900000B9B, + 0xB9C00000B9D, + 0xB9E00000BA0, + 0xBA300000BA5, + 0xBA800000BAB, + 0xBAE00000BBA, + 0xBBE00000BC3, + 0xBC600000BC9, + 0xBCA00000BCE, + 0xBD000000BD1, + 0xBD700000BD8, + 0xBE600000BF0, + 0xC0000000C0D, + 0xC0E00000C11, + 0xC1200000C29, + 0xC2A00000C3A, + 0xC3C00000C45, + 0xC4600000C49, + 0xC4A00000C4E, + 0xC5500000C57, + 0xC5800000C5B, + 0xC5C00000C5E, + 0xC6000000C64, + 0xC6600000C70, + 0xC8000000C84, + 0xC8500000C8D, + 0xC8E00000C91, + 0xC9200000CA9, + 0xCAA00000CB4, + 0xCB500000CBA, + 0xCBC00000CC5, + 0xCC600000CC9, + 0xCCA00000CCE, + 0xCD500000CD7, + 0xCDC00000CDF, + 0xCE000000CE4, + 0xCE600000CF0, + 0xCF100000CF4, + 0xD0000000D0D, + 0xD0E00000D11, + 0xD1200000D45, + 0xD4600000D49, + 0xD4A00000D4F, + 0xD5400000D58, + 0xD5F00000D64, + 0xD6600000D70, + 0xD7A00000D80, + 0xD8100000D84, + 0xD8500000D97, + 0xD9A00000DB2, + 0xDB300000DBC, + 0xDBD00000DBE, + 0xDC000000DC7, + 0xDCA00000DCB, + 0xDCF00000DD5, + 0xDD600000DD7, + 0xDD800000DE0, + 0xDE600000DF0, + 0xDF200000DF4, + 0xE0100000E33, + 0xE3400000E3B, + 0xE4000000E4F, + 0xE5000000E5A, + 0xE8100000E83, + 0xE8400000E85, + 0xE8600000E8B, + 0xE8C00000EA4, + 0xEA500000EA6, + 0xEA700000EB3, + 0xEB400000EBE, + 0xEC000000EC5, + 0xEC600000EC7, + 0xEC800000ECF, + 0xED000000EDA, + 0xEDE00000EE0, + 0xF0000000F01, + 0xF0B00000F0C, + 0xF1800000F1A, + 0xF2000000F2A, + 0xF3500000F36, + 0xF3700000F38, + 0xF3900000F3A, + 0xF3E00000F43, + 0xF4400000F48, + 0xF4900000F4D, + 0xF4E00000F52, + 0xF5300000F57, + 0xF5800000F5C, + 0xF5D00000F69, + 0xF6A00000F6D, + 0xF7100000F73, + 0xF7400000F75, + 0xF7A00000F81, + 0xF8200000F85, + 0xF8600000F93, + 0xF9400000F98, + 0xF9900000F9D, + 0xF9E00000FA2, + 0xFA300000FA7, + 0xFA800000FAC, + 0xFAD00000FB9, + 0xFBA00000FBD, + 0xFC600000FC7, + 0x10000000104A, + 0x10500000109E, + 0x10D0000010FB, + 0x10FD00001100, + 0x120000001249, + 0x124A0000124E, + 0x125000001257, + 0x125800001259, + 0x125A0000125E, + 0x126000001289, + 0x128A0000128E, + 0x1290000012B1, + 0x12B2000012B6, + 0x12B8000012BF, + 0x12C0000012C1, + 0x12C2000012C6, + 0x12C8000012D7, + 0x12D800001311, + 0x131200001316, + 0x13180000135B, + 0x135D00001360, + 0x138000001390, + 0x13A0000013F6, + 0x14010000166D, + 0x166F00001680, + 0x16810000169B, + 0x16A0000016EB, + 0x16F1000016F9, + 0x170000001716, + 0x171F00001735, + 0x174000001754, + 0x17600000176D, + 0x176E00001771, + 0x177200001774, + 0x1780000017B4, + 0x17B6000017D4, + 0x17D7000017D8, + 0x17DC000017DE, + 0x17E0000017EA, + 0x18100000181A, + 0x182000001879, + 0x1880000018AB, + 0x18B0000018F6, + 0x19000000191F, + 0x19200000192C, + 0x19300000193C, + 0x19460000196E, + 0x197000001975, + 0x1980000019AC, + 0x19B0000019CA, + 0x19D0000019DA, + 0x1A0000001A1C, + 0x1A2000001A5F, + 0x1A6000001A7D, + 0x1A7F00001A8A, + 0x1A9000001A9A, + 0x1AA700001AA8, + 0x1AB000001ABE, + 0x1ABF00001ADE, + 0x1AE000001AEC, + 0x1B0000001B4D, + 0x1B5000001B5A, + 0x1B6B00001B74, + 0x1B8000001BF4, + 0x1C0000001C38, + 0x1C4000001C4A, + 0x1C4D00001C7E, + 0x1C8A00001C8B, + 0x1CD000001CD3, + 0x1CD400001CFB, + 0x1D0000001D2C, + 0x1D2F00001D30, + 0x1D3B00001D3C, + 0x1D4E00001D4F, + 0x1D6B00001D78, + 0x1D7900001D9B, + 0x1DC000001E00, + 0x1E0100001E02, + 0x1E0300001E04, + 0x1E0500001E06, + 0x1E0700001E08, + 0x1E0900001E0A, + 0x1E0B00001E0C, + 0x1E0D00001E0E, + 0x1E0F00001E10, + 0x1E1100001E12, + 0x1E1300001E14, + 0x1E1500001E16, + 0x1E1700001E18, + 0x1E1900001E1A, + 0x1E1B00001E1C, + 0x1E1D00001E1E, + 0x1E1F00001E20, + 0x1E2100001E22, + 0x1E2300001E24, + 0x1E2500001E26, + 0x1E2700001E28, + 0x1E2900001E2A, + 0x1E2B00001E2C, + 0x1E2D00001E2E, + 0x1E2F00001E30, + 0x1E3100001E32, + 0x1E3300001E34, + 0x1E3500001E36, + 0x1E3700001E38, + 0x1E3900001E3A, + 0x1E3B00001E3C, + 0x1E3D00001E3E, + 0x1E3F00001E40, + 0x1E4100001E42, + 0x1E4300001E44, + 0x1E4500001E46, + 0x1E4700001E48, + 0x1E4900001E4A, + 0x1E4B00001E4C, + 0x1E4D00001E4E, + 0x1E4F00001E50, + 0x1E5100001E52, + 0x1E5300001E54, + 0x1E5500001E56, + 0x1E5700001E58, + 0x1E5900001E5A, + 0x1E5B00001E5C, + 0x1E5D00001E5E, + 0x1E5F00001E60, + 0x1E6100001E62, + 0x1E6300001E64, + 0x1E6500001E66, + 0x1E6700001E68, + 0x1E6900001E6A, + 0x1E6B00001E6C, + 0x1E6D00001E6E, + 0x1E6F00001E70, + 0x1E7100001E72, + 0x1E7300001E74, + 0x1E7500001E76, + 0x1E7700001E78, + 0x1E7900001E7A, + 0x1E7B00001E7C, + 0x1E7D00001E7E, + 0x1E7F00001E80, + 0x1E8100001E82, + 0x1E8300001E84, + 0x1E8500001E86, + 0x1E8700001E88, + 0x1E8900001E8A, + 0x1E8B00001E8C, + 0x1E8D00001E8E, + 0x1E8F00001E90, + 0x1E9100001E92, + 0x1E9300001E94, + 0x1E9500001E9A, + 0x1E9C00001E9E, + 0x1E9F00001EA0, + 0x1EA100001EA2, + 0x1EA300001EA4, + 0x1EA500001EA6, + 0x1EA700001EA8, + 0x1EA900001EAA, + 0x1EAB00001EAC, + 0x1EAD00001EAE, + 0x1EAF00001EB0, + 0x1EB100001EB2, + 0x1EB300001EB4, + 0x1EB500001EB6, + 0x1EB700001EB8, + 0x1EB900001EBA, + 0x1EBB00001EBC, + 0x1EBD00001EBE, + 0x1EBF00001EC0, + 0x1EC100001EC2, + 0x1EC300001EC4, + 0x1EC500001EC6, + 0x1EC700001EC8, + 0x1EC900001ECA, + 0x1ECB00001ECC, + 0x1ECD00001ECE, + 0x1ECF00001ED0, + 0x1ED100001ED2, + 0x1ED300001ED4, + 0x1ED500001ED6, + 0x1ED700001ED8, + 0x1ED900001EDA, + 0x1EDB00001EDC, + 0x1EDD00001EDE, + 0x1EDF00001EE0, + 0x1EE100001EE2, + 0x1EE300001EE4, + 0x1EE500001EE6, + 0x1EE700001EE8, + 0x1EE900001EEA, + 0x1EEB00001EEC, + 0x1EED00001EEE, + 0x1EEF00001EF0, + 0x1EF100001EF2, + 0x1EF300001EF4, + 0x1EF500001EF6, + 0x1EF700001EF8, + 0x1EF900001EFA, + 0x1EFB00001EFC, + 0x1EFD00001EFE, + 0x1EFF00001F08, + 0x1F1000001F16, + 0x1F2000001F28, + 0x1F3000001F38, + 0x1F4000001F46, + 0x1F5000001F58, + 0x1F6000001F68, + 0x1F7000001F71, + 0x1F7200001F73, + 0x1F7400001F75, + 0x1F7600001F77, + 0x1F7800001F79, + 0x1F7A00001F7B, + 0x1F7C00001F7D, + 0x1FB000001FB2, + 0x1FB600001FB7, + 0x1FC600001FC7, + 0x1FD000001FD3, + 0x1FD600001FD8, + 0x1FE000001FE3, + 0x1FE400001FE8, + 0x1FF600001FF7, + 0x214E0000214F, + 0x218400002185, + 0x2C3000002C60, + 0x2C6100002C62, + 0x2C6500002C67, + 0x2C6800002C69, + 0x2C6A00002C6B, + 0x2C6C00002C6D, + 0x2C7100002C72, + 0x2C7300002C75, + 0x2C7600002C7C, + 0x2C8100002C82, + 0x2C8300002C84, + 0x2C8500002C86, + 0x2C8700002C88, + 0x2C8900002C8A, + 0x2C8B00002C8C, + 0x2C8D00002C8E, + 0x2C8F00002C90, + 0x2C9100002C92, + 0x2C9300002C94, + 0x2C9500002C96, + 0x2C9700002C98, + 0x2C9900002C9A, + 0x2C9B00002C9C, + 0x2C9D00002C9E, + 0x2C9F00002CA0, + 0x2CA100002CA2, + 0x2CA300002CA4, + 0x2CA500002CA6, + 0x2CA700002CA8, + 0x2CA900002CAA, + 0x2CAB00002CAC, + 0x2CAD00002CAE, + 0x2CAF00002CB0, + 0x2CB100002CB2, + 0x2CB300002CB4, + 0x2CB500002CB6, + 0x2CB700002CB8, + 0x2CB900002CBA, + 0x2CBB00002CBC, + 0x2CBD00002CBE, + 0x2CBF00002CC0, + 0x2CC100002CC2, + 0x2CC300002CC4, + 0x2CC500002CC6, + 0x2CC700002CC8, + 0x2CC900002CCA, + 0x2CCB00002CCC, + 0x2CCD00002CCE, + 0x2CCF00002CD0, + 0x2CD100002CD2, + 0x2CD300002CD4, + 0x2CD500002CD6, + 0x2CD700002CD8, + 0x2CD900002CDA, + 0x2CDB00002CDC, + 0x2CDD00002CDE, + 0x2CDF00002CE0, + 0x2CE100002CE2, + 0x2CE300002CE5, + 0x2CEC00002CED, + 0x2CEE00002CF2, + 0x2CF300002CF4, + 0x2D0000002D26, + 0x2D2700002D28, + 0x2D2D00002D2E, + 0x2D3000002D68, + 0x2D7F00002D97, + 0x2DA000002DA7, + 0x2DA800002DAF, + 0x2DB000002DB7, + 0x2DB800002DBF, + 0x2DC000002DC7, + 0x2DC800002DCF, + 0x2DD000002DD7, + 0x2DD800002DDF, + 0x2DE000002E00, + 0x2E2F00002E30, + 0x300500003008, + 0x302A0000302E, + 0x303C0000303D, + 0x304100003097, + 0x30990000309B, + 0x309D0000309F, + 0x30A1000030FB, + 0x30FC000030FF, + 0x310500003130, + 0x31A0000031C0, + 0x31F000003200, + 0x340000004DC0, + 0x4E000000A48D, + 0xA4D00000A4FE, + 0xA5000000A60D, + 0xA6100000A62C, + 0xA6410000A642, + 0xA6430000A644, + 0xA6450000A646, + 0xA6470000A648, + 0xA6490000A64A, + 0xA64B0000A64C, + 0xA64D0000A64E, + 0xA64F0000A650, + 0xA6510000A652, + 0xA6530000A654, + 0xA6550000A656, + 0xA6570000A658, + 0xA6590000A65A, + 0xA65B0000A65C, + 0xA65D0000A65E, + 0xA65F0000A660, + 0xA6610000A662, + 0xA6630000A664, + 0xA6650000A666, + 0xA6670000A668, + 0xA6690000A66A, + 0xA66B0000A66C, + 0xA66D0000A670, + 0xA6740000A67E, + 0xA67F0000A680, + 0xA6810000A682, + 0xA6830000A684, + 0xA6850000A686, + 0xA6870000A688, + 0xA6890000A68A, + 0xA68B0000A68C, + 0xA68D0000A68E, + 0xA68F0000A690, + 0xA6910000A692, + 0xA6930000A694, + 0xA6950000A696, + 0xA6970000A698, + 0xA6990000A69A, + 0xA69B0000A69C, + 0xA69E0000A6E6, + 0xA6F00000A6F2, + 0xA7170000A720, + 0xA7230000A724, + 0xA7250000A726, + 0xA7270000A728, + 0xA7290000A72A, + 0xA72B0000A72C, + 0xA72D0000A72E, + 0xA72F0000A732, + 0xA7330000A734, + 0xA7350000A736, + 0xA7370000A738, + 0xA7390000A73A, + 0xA73B0000A73C, + 0xA73D0000A73E, + 0xA73F0000A740, + 0xA7410000A742, + 0xA7430000A744, + 0xA7450000A746, + 0xA7470000A748, + 0xA7490000A74A, + 0xA74B0000A74C, + 0xA74D0000A74E, + 0xA74F0000A750, + 0xA7510000A752, + 0xA7530000A754, + 0xA7550000A756, + 0xA7570000A758, + 0xA7590000A75A, + 0xA75B0000A75C, + 0xA75D0000A75E, + 0xA75F0000A760, + 0xA7610000A762, + 0xA7630000A764, + 0xA7650000A766, + 0xA7670000A768, + 0xA7690000A76A, + 0xA76B0000A76C, + 0xA76D0000A76E, + 0xA76F0000A770, + 0xA7710000A779, + 0xA77A0000A77B, + 0xA77C0000A77D, + 0xA77F0000A780, + 0xA7810000A782, + 0xA7830000A784, + 0xA7850000A786, + 0xA7870000A789, + 0xA78C0000A78D, + 0xA78E0000A790, + 0xA7910000A792, + 0xA7930000A796, + 0xA7970000A798, + 0xA7990000A79A, + 0xA79B0000A79C, + 0xA79D0000A79E, + 0xA79F0000A7A0, + 0xA7A10000A7A2, + 0xA7A30000A7A4, + 0xA7A50000A7A6, + 0xA7A70000A7A8, + 0xA7A90000A7AA, + 0xA7AF0000A7B0, + 0xA7B50000A7B6, + 0xA7B70000A7B8, + 0xA7B90000A7BA, + 0xA7BB0000A7BC, + 0xA7BD0000A7BE, + 0xA7BF0000A7C0, + 0xA7C10000A7C2, + 0xA7C30000A7C4, + 0xA7C80000A7C9, + 0xA7CA0000A7CB, + 0xA7CD0000A7CE, + 0xA7CF0000A7D0, + 0xA7D10000A7D2, + 0xA7D30000A7D4, + 0xA7D50000A7D6, + 0xA7D70000A7D8, + 0xA7D90000A7DA, + 0xA7DB0000A7DC, + 0xA7F60000A7F8, + 0xA7FA0000A828, + 0xA82C0000A82D, + 0xA8400000A874, + 0xA8800000A8C6, + 0xA8D00000A8DA, + 0xA8E00000A8F8, + 0xA8FB0000A8FC, + 0xA8FD0000A92E, + 0xA9300000A954, + 0xA9800000A9C1, + 0xA9CF0000A9DA, + 0xA9E00000A9FF, + 0xAA000000AA37, + 0xAA400000AA4E, + 0xAA500000AA5A, + 0xAA600000AA77, + 0xAA7A0000AAC3, + 0xAADB0000AADE, + 0xAAE00000AAF0, + 0xAAF20000AAF7, + 0xAB010000AB07, + 0xAB090000AB0F, + 0xAB110000AB17, + 0xAB200000AB27, + 0xAB280000AB2F, + 0xAB300000AB5B, + 0xAB600000AB69, + 0xABC00000ABEB, + 0xABEC0000ABEE, + 0xABF00000ABFA, + 0xAC000000D7A4, + 0xFA0E0000FA10, + 0xFA110000FA12, + 0xFA130000FA15, + 0xFA1F0000FA20, + 0xFA210000FA22, + 0xFA230000FA25, + 0xFA270000FA2A, + 0xFB1E0000FB1F, + 0xFE200000FE30, + 0xFE730000FE74, + 0x100000001000C, + 0x1000D00010027, + 0x100280001003B, + 0x1003C0001003E, + 0x1003F0001004E, + 0x100500001005E, + 0x10080000100FB, + 0x101FD000101FE, + 0x102800001029D, + 0x102A0000102D1, + 0x102E0000102E1, + 0x1030000010320, + 0x1032D00010341, + 0x103420001034A, + 0x103500001037B, + 0x103800001039E, + 0x103A0000103C4, + 0x103C8000103D0, + 0x104280001049E, + 0x104A0000104AA, + 0x104D8000104FC, + 0x1050000010528, + 0x1053000010564, + 0x10597000105A2, + 0x105A3000105B2, + 0x105B3000105BA, + 0x105BB000105BD, + 0x105C0000105F4, + 0x1060000010737, + 0x1074000010756, + 0x1076000010768, + 0x1078000010781, + 0x1080000010806, + 0x1080800010809, + 0x1080A00010836, + 0x1083700010839, + 0x1083C0001083D, + 0x1083F00010856, + 0x1086000010877, + 0x108800001089F, + 0x108E0000108F3, + 0x108F4000108F6, + 0x1090000010916, + 0x109200001093A, + 0x109400001095A, + 0x10980000109B8, + 0x109BE000109C0, + 0x10A0000010A04, + 0x10A0500010A07, + 0x10A0C00010A14, + 0x10A1500010A18, + 0x10A1900010A36, + 0x10A3800010A3B, + 0x10A3F00010A40, + 0x10A6000010A7D, + 0x10A8000010A9D, + 0x10AC000010AC8, + 0x10AC900010AE7, + 0x10B0000010B36, + 0x10B4000010B56, + 0x10B6000010B73, + 0x10B8000010B92, + 0x10C0000010C49, + 0x10CC000010CF3, + 0x10D0000010D28, + 0x10D3000010D3A, + 0x10D4000010D50, + 0x10D6900010D6E, + 0x10D6F00010D86, + 0x10E8000010EAA, + 0x10EAB00010EAD, + 0x10EB000010EB2, + 0x10EC200010EC8, + 0x10EFA00010F1D, + 0x10F2700010F28, + 0x10F3000010F51, + 0x10F7000010F86, + 0x10FB000010FC5, + 0x10FE000010FF7, + 0x1100000011047, + 0x1106600011076, + 0x1107F000110BB, + 0x110C2000110C3, + 0x110D0000110E9, + 0x110F0000110FA, + 0x1110000011135, + 0x1113600011140, + 0x1114400011148, + 0x1115000011174, + 0x1117600011177, + 0x11180000111C5, + 0x111C9000111CD, + 0x111CE000111DB, + 0x111DC000111DD, + 0x1120000011212, + 0x1121300011238, + 0x1123E00011242, + 0x1128000011287, + 0x1128800011289, + 0x1128A0001128E, + 0x1128F0001129E, + 0x1129F000112A9, + 0x112B0000112EB, + 0x112F0000112FA, + 0x1130000011304, + 0x113050001130D, + 0x1130F00011311, + 0x1131300011329, + 0x1132A00011331, + 0x1133200011334, + 0x113350001133A, + 0x1133B00011345, + 0x1134700011349, + 0x1134B0001134E, + 0x1135000011351, + 0x1135700011358, + 0x1135D00011364, + 0x113660001136D, + 0x1137000011375, + 0x113800001138A, + 0x1138B0001138C, + 0x1138E0001138F, + 0x11390000113B6, + 0x113B7000113C1, + 0x113C2000113C3, + 0x113C5000113C6, + 0x113C7000113CB, + 0x113CC000113D4, + 0x113E1000113E3, + 0x114000001144B, + 0x114500001145A, + 0x1145E00011462, + 0x11480000114C6, + 0x114C7000114C8, + 0x114D0000114DA, + 0x11580000115B6, + 0x115B8000115C1, + 0x115D8000115DE, + 0x1160000011641, + 0x1164400011645, + 0x116500001165A, + 0x11680000116B9, + 0x116C0000116CA, + 0x116D0000116E4, + 0x117000001171B, + 0x1171D0001172C, + 0x117300001173A, + 0x1174000011747, + 0x118000001183B, + 0x118C0000118EA, + 0x118FF00011907, + 0x119090001190A, + 0x1190C00011914, + 0x1191500011917, + 0x1191800011936, + 0x1193700011939, + 0x1193B00011944, + 0x119500001195A, + 0x119A0000119A8, + 0x119AA000119D8, + 0x119DA000119E2, + 0x119E3000119E5, + 0x11A0000011A3F, + 0x11A4700011A48, + 0x11A5000011A9A, + 0x11A9D00011A9E, + 0x11AB000011AF9, + 0x11B6000011B68, + 0x11BC000011BE1, + 0x11BF000011BFA, + 0x11C0000011C09, + 0x11C0A00011C37, + 0x11C3800011C41, + 0x11C5000011C5A, + 0x11C7200011C90, + 0x11C9200011CA8, + 0x11CA900011CB7, + 0x11D0000011D07, + 0x11D0800011D0A, + 0x11D0B00011D37, + 0x11D3A00011D3B, + 0x11D3C00011D3E, + 0x11D3F00011D48, + 0x11D5000011D5A, + 0x11D6000011D66, + 0x11D6700011D69, + 0x11D6A00011D8F, + 0x11D9000011D92, + 0x11D9300011D99, + 0x11DA000011DAA, + 0x11DB000011DDC, + 0x11DE000011DEA, + 0x11EE000011EF7, + 0x11F0000011F11, + 0x11F1200011F3B, + 0x11F3E00011F43, + 0x11F5000011F5B, + 0x11FB000011FB1, + 0x120000001239A, + 0x1248000012544, + 0x12F9000012FF1, + 0x1300000013430, + 0x1344000013456, + 0x13460000143FB, + 0x1440000014647, + 0x161000001613A, + 0x1680000016A39, + 0x16A4000016A5F, + 0x16A6000016A6A, + 0x16A7000016ABF, + 0x16AC000016ACA, + 0x16AD000016AEE, + 0x16AF000016AF5, + 0x16B0000016B37, + 0x16B4000016B44, + 0x16B5000016B5A, + 0x16B6300016B78, + 0x16B7D00016B90, + 0x16D4000016D6D, + 0x16D7000016D7A, + 0x16E6000016E80, + 0x16EBB00016ED4, + 0x16F0000016F4B, + 0x16F4F00016F88, + 0x16F8F00016FA0, + 0x16FE000016FE2, + 0x16FE300016FE5, + 0x16FF000016FF4, + 0x1700000018CD6, + 0x18CFF00018D1F, + 0x18D8000018DF3, + 0x1AFF00001AFF4, + 0x1AFF50001AFFC, + 0x1AFFD0001AFFF, + 0x1B0000001B123, + 0x1B1320001B133, + 0x1B1500001B153, + 0x1B1550001B156, + 0x1B1640001B168, + 0x1B1700001B2FC, + 0x1BC000001BC6B, + 0x1BC700001BC7D, + 0x1BC800001BC89, + 0x1BC900001BC9A, + 0x1BC9D0001BC9F, + 0x1CF000001CF2E, + 0x1CF300001CF47, + 0x1DA000001DA37, + 0x1DA3B0001DA6D, + 0x1DA750001DA76, + 0x1DA840001DA85, + 0x1DA9B0001DAA0, + 0x1DAA10001DAB0, + 0x1DF000001DF1F, + 0x1DF250001DF2B, + 0x1E0000001E007, + 0x1E0080001E019, + 0x1E01B0001E022, + 0x1E0230001E025, + 0x1E0260001E02B, + 0x1E08F0001E090, + 0x1E1000001E12D, + 0x1E1300001E13E, + 0x1E1400001E14A, + 0x1E14E0001E14F, + 0x1E2900001E2AF, + 0x1E2C00001E2FA, + 0x1E4D00001E4FA, + 0x1E5D00001E5FB, + 0x1E6C00001E6DF, + 0x1E6E00001E6F6, + 0x1E6FE0001E700, + 0x1E7E00001E7E7, + 0x1E7E80001E7EC, + 0x1E7ED0001E7EF, + 0x1E7F00001E7FF, + 0x1E8000001E8C5, + 0x1E8D00001E8D7, + 0x1E9220001E94C, + 0x1E9500001E95A, + 0x200000002A6E0, + 0x2A7000002B81E, + 0x2B8200002CEAE, + 0x2CEB00002EBE1, + 0x2EBF00002EE5E, + 0x300000003134B, + 0x313500003347A, + ), + "CONTEXTJ": (0x200C0000200E,), + "CONTEXTO": ( + 0xB7000000B8, + 0x37500000376, + 0x5F3000005F5, + 0x6600000066A, + 0x6F0000006FA, + 0x30FB000030FC, + ), +} diff --git a/server/venv/Lib/site-packages/idna/intranges.py b/server/venv/Lib/site-packages/idna/intranges.py new file mode 100644 index 0000000..19d7781 --- /dev/null +++ b/server/venv/Lib/site-packages/idna/intranges.py @@ -0,0 +1,55 @@ +""" +Given a list of integers, made up of (hopefully) a small number of long runs +of consecutive integers, compute a representation of the form +((start1, end1), (start2, end2) ...). Then answer the question "was x present +in the original list?" in time O(log(# runs)). +""" + +import bisect + + +def intranges_from_list(list_: list[int]) -> tuple[int, ...]: + """Represent a list of integers as a sequence of ranges: + ((start_0, end_0), (start_1, end_1), ...), such that the original + integers are exactly those x such that start_i <= x < end_i for some i. + + Ranges are encoded as single integers (start << 32 | end), not as tuples. + """ + + sorted_list = sorted(list_) + ranges = [] + last_write = -1 + for i in range(len(sorted_list)): + if i + 1 < len(sorted_list) and sorted_list[i] == sorted_list[i + 1] - 1: + continue + current_range = sorted_list[last_write + 1 : i + 1] + ranges.append(_encode_range(current_range[0], current_range[-1] + 1)) + last_write = i + + return tuple(ranges) + + +def _encode_range(start: int, end: int) -> int: + return (start << 32) | end + + +def _decode_range(r: int) -> tuple[int, int]: + return (r >> 32), (r & ((1 << 32) - 1)) + + +def intranges_contain(int_: int, ranges: tuple[int, ...]) -> bool: + """Determine if `int_` falls into one of the ranges in `ranges`.""" + tuple_ = _encode_range(int_, 0) + pos = bisect.bisect_left(ranges, tuple_) + # we could be immediately ahead of a tuple (start, end) + # with start < int_ <= end + if pos > 0: + left, right = _decode_range(ranges[pos - 1]) + if left <= int_ < right: + return True + # or we could be immediately behind a tuple (int_, end) + if pos < len(ranges): + left, _ = _decode_range(ranges[pos]) + if left == int_: + return True + return False diff --git a/server/venv/Lib/site-packages/idna/package_data.py b/server/venv/Lib/site-packages/idna/package_data.py new file mode 100644 index 0000000..94e4039 --- /dev/null +++ b/server/venv/Lib/site-packages/idna/package_data.py @@ -0,0 +1 @@ +__version__ = "3.18" diff --git a/server/venv/Lib/site-packages/idna/py.typed b/server/venv/Lib/site-packages/idna/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/venv/Lib/site-packages/idna/uts46data.py b/server/venv/Lib/site-packages/idna/uts46data.py new file mode 100644 index 0000000..f2d931f --- /dev/null +++ b/server/venv/Lib/site-packages/idna/uts46data.py @@ -0,0 +1,16896 @@ +# This file is automatically generated by tools/idna-data + +from array import array +from typing import Optional + +"""IDNA Mapping Table from UTS46.""" + + +__version__ = "17.0.0" + +uts46_starts: "array[int]" = array( + "I", + ( + 0x0, + 0x1, + 0x2, + 0x3, + 0x4, + 0x5, + 0x6, + 0x7, + 0x8, + 0x9, + 0xA, + 0xB, + 0xC, + 0xD, + 0xE, + 0xF, + 0x10, + 0x11, + 0x12, + 0x13, + 0x14, + 0x15, + 0x16, + 0x17, + 0x18, + 0x19, + 0x1A, + 0x1B, + 0x1C, + 0x1D, + 0x1E, + 0x1F, + 0x20, + 0x21, + 0x22, + 0x23, + 0x24, + 0x25, + 0x26, + 0x27, + 0x28, + 0x29, + 0x2A, + 0x2B, + 0x2C, + 0x2D, + 0x2E, + 0x2F, + 0x30, + 0x31, + 0x32, + 0x33, + 0x34, + 0x35, + 0x36, + 0x37, + 0x38, + 0x39, + 0x3A, + 0x3B, + 0x3C, + 0x3D, + 0x3E, + 0x3F, + 0x40, + 0x41, + 0x42, + 0x43, + 0x44, + 0x45, + 0x46, + 0x47, + 0x48, + 0x49, + 0x4A, + 0x4B, + 0x4C, + 0x4D, + 0x4E, + 0x4F, + 0x50, + 0x51, + 0x52, + 0x53, + 0x54, + 0x55, + 0x56, + 0x57, + 0x58, + 0x59, + 0x5A, + 0x5B, + 0x5C, + 0x5D, + 0x5E, + 0x5F, + 0x60, + 0x61, + 0x62, + 0x63, + 0x64, + 0x65, + 0x66, + 0x67, + 0x68, + 0x69, + 0x6A, + 0x6B, + 0x6C, + 0x6D, + 0x6E, + 0x6F, + 0x70, + 0x71, + 0x72, + 0x73, + 0x74, + 0x75, + 0x76, + 0x77, + 0x78, + 0x79, + 0x7A, + 0x7B, + 0x7C, + 0x7D, + 0x7E, + 0x7F, + 0x80, + 0x81, + 0x82, + 0x83, + 0x84, + 0x85, + 0x86, + 0x87, + 0x88, + 0x89, + 0x8A, + 0x8B, + 0x8C, + 0x8D, + 0x8E, + 0x8F, + 0x90, + 0x91, + 0x92, + 0x93, + 0x94, + 0x95, + 0x96, + 0x97, + 0x98, + 0x99, + 0x9A, + 0x9B, + 0x9C, + 0x9D, + 0x9E, + 0x9F, + 0xA0, + 0xA1, + 0xA2, + 0xA3, + 0xA4, + 0xA5, + 0xA6, + 0xA7, + 0xA8, + 0xA9, + 0xAA, + 0xAB, + 0xAC, + 0xAD, + 0xAE, + 0xAF, + 0xB0, + 0xB1, + 0xB2, + 0xB3, + 0xB4, + 0xB5, + 0xB6, + 0xB7, + 0xB8, + 0xB9, + 0xBA, + 0xBB, + 0xBC, + 0xBD, + 0xBE, + 0xBF, + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC4, + 0xC5, + 0xC6, + 0xC7, + 0xC8, + 0xC9, + 0xCA, + 0xCB, + 0xCC, + 0xCD, + 0xCE, + 0xCF, + 0xD0, + 0xD1, + 0xD2, + 0xD3, + 0xD4, + 0xD5, + 0xD6, + 0xD7, + 0xD8, + 0xD9, + 0xDA, + 0xDB, + 0xDC, + 0xDD, + 0xDE, + 0xDF, + 0xE0, + 0xE1, + 0xE2, + 0xE3, + 0xE4, + 0xE5, + 0xE6, + 0xE7, + 0xE8, + 0xE9, + 0xEA, + 0xEB, + 0xEC, + 0xED, + 0xEE, + 0xEF, + 0xF0, + 0xF1, + 0xF2, + 0xF3, + 0xF4, + 0xF5, + 0xF6, + 0xF7, + 0xF8, + 0xF9, + 0xFA, + 0xFB, + 0xFC, + 0xFD, + 0xFE, + 0xFF, + 0x100, + 0x101, + 0x102, + 0x103, + 0x104, + 0x105, + 0x106, + 0x107, + 0x108, + 0x109, + 0x10A, + 0x10B, + 0x10C, + 0x10D, + 0x10E, + 0x10F, + 0x110, + 0x111, + 0x112, + 0x113, + 0x114, + 0x115, + 0x116, + 0x117, + 0x118, + 0x119, + 0x11A, + 0x11B, + 0x11C, + 0x11D, + 0x11E, + 0x11F, + 0x120, + 0x121, + 0x122, + 0x123, + 0x124, + 0x125, + 0x126, + 0x127, + 0x128, + 0x129, + 0x12A, + 0x12B, + 0x12C, + 0x12D, + 0x12E, + 0x12F, + 0x130, + 0x131, + 0x132, + 0x134, + 0x135, + 0x136, + 0x137, + 0x139, + 0x13A, + 0x13B, + 0x13C, + 0x13D, + 0x13E, + 0x13F, + 0x141, + 0x142, + 0x143, + 0x144, + 0x145, + 0x146, + 0x147, + 0x148, + 0x149, + 0x14A, + 0x14B, + 0x14C, + 0x14D, + 0x14E, + 0x14F, + 0x150, + 0x151, + 0x152, + 0x153, + 0x154, + 0x155, + 0x156, + 0x157, + 0x158, + 0x159, + 0x15A, + 0x15B, + 0x15C, + 0x15D, + 0x15E, + 0x15F, + 0x160, + 0x161, + 0x162, + 0x163, + 0x164, + 0x165, + 0x166, + 0x167, + 0x168, + 0x169, + 0x16A, + 0x16B, + 0x16C, + 0x16D, + 0x16E, + 0x16F, + 0x170, + 0x171, + 0x172, + 0x173, + 0x174, + 0x175, + 0x176, + 0x177, + 0x178, + 0x179, + 0x17A, + 0x17B, + 0x17C, + 0x17D, + 0x17E, + 0x17F, + 0x180, + 0x181, + 0x182, + 0x183, + 0x184, + 0x185, + 0x186, + 0x187, + 0x188, + 0x189, + 0x18A, + 0x18B, + 0x18C, + 0x18E, + 0x18F, + 0x190, + 0x191, + 0x192, + 0x193, + 0x194, + 0x195, + 0x196, + 0x197, + 0x198, + 0x199, + 0x19C, + 0x19D, + 0x19E, + 0x19F, + 0x1A0, + 0x1A1, + 0x1A2, + 0x1A3, + 0x1A4, + 0x1A5, + 0x1A6, + 0x1A7, + 0x1A8, + 0x1A9, + 0x1AA, + 0x1AC, + 0x1AD, + 0x1AE, + 0x1AF, + 0x1B0, + 0x1B1, + 0x1B2, + 0x1B3, + 0x1B4, + 0x1B5, + 0x1B6, + 0x1B7, + 0x1B8, + 0x1B9, + 0x1BC, + 0x1BD, + 0x1C4, + 0x1C7, + 0x1CA, + 0x1CD, + 0x1CE, + 0x1CF, + 0x1D0, + 0x1D1, + 0x1D2, + 0x1D3, + 0x1D4, + 0x1D5, + 0x1D6, + 0x1D7, + 0x1D8, + 0x1D9, + 0x1DA, + 0x1DB, + 0x1DC, + 0x1DE, + 0x1DF, + 0x1E0, + 0x1E1, + 0x1E2, + 0x1E3, + 0x1E4, + 0x1E5, + 0x1E6, + 0x1E7, + 0x1E8, + 0x1E9, + 0x1EA, + 0x1EB, + 0x1EC, + 0x1ED, + 0x1EE, + 0x1EF, + 0x1F1, + 0x1F4, + 0x1F5, + 0x1F6, + 0x1F7, + 0x1F8, + 0x1F9, + 0x1FA, + 0x1FB, + 0x1FC, + 0x1FD, + 0x1FE, + 0x1FF, + 0x200, + 0x201, + 0x202, + 0x203, + 0x204, + 0x205, + 0x206, + 0x207, + 0x208, + 0x209, + 0x20A, + 0x20B, + 0x20C, + 0x20D, + 0x20E, + 0x20F, + 0x210, + 0x211, + 0x212, + 0x213, + 0x214, + 0x215, + 0x216, + 0x217, + 0x218, + 0x219, + 0x21A, + 0x21B, + 0x21C, + 0x21D, + 0x21E, + 0x21F, + 0x220, + 0x221, + 0x222, + 0x223, + 0x224, + 0x225, + 0x226, + 0x227, + 0x228, + 0x229, + 0x22A, + 0x22B, + 0x22C, + 0x22D, + 0x22E, + 0x22F, + 0x230, + 0x231, + 0x232, + 0x233, + 0x23A, + 0x23B, + 0x23C, + 0x23D, + 0x23E, + 0x23F, + 0x241, + 0x242, + 0x243, + 0x244, + 0x245, + 0x246, + 0x247, + 0x248, + 0x249, + 0x24A, + 0x24B, + 0x24C, + 0x24D, + 0x24E, + 0x24F, + 0x2B0, + 0x2B1, + 0x2B2, + 0x2B3, + 0x2B4, + 0x2B5, + 0x2B6, + 0x2B7, + 0x2B8, + 0x2B9, + 0x2D8, + 0x2D9, + 0x2DA, + 0x2DB, + 0x2DC, + 0x2DD, + 0x2DE, + 0x2E0, + 0x2E1, + 0x2E2, + 0x2E3, + 0x2E4, + 0x2E5, + 0x340, + 0x341, + 0x342, + 0x343, + 0x344, + 0x345, + 0x346, + 0x34F, + 0x350, + 0x370, + 0x371, + 0x372, + 0x373, + 0x374, + 0x375, + 0x376, + 0x377, + 0x378, + 0x37A, + 0x37B, + 0x37E, + 0x37F, + 0x380, + 0x384, + 0x385, + 0x386, + 0x387, + 0x388, + 0x389, + 0x38A, + 0x38B, + 0x38C, + 0x38D, + 0x38E, + 0x38F, + 0x390, + 0x391, + 0x392, + 0x393, + 0x394, + 0x395, + 0x396, + 0x397, + 0x398, + 0x399, + 0x39A, + 0x39B, + 0x39C, + 0x39D, + 0x39E, + 0x39F, + 0x3A0, + 0x3A1, + 0x3A2, + 0x3A3, + 0x3A4, + 0x3A5, + 0x3A6, + 0x3A7, + 0x3A8, + 0x3A9, + 0x3AA, + 0x3AB, + 0x3AC, + 0x3C2, + 0x3C3, + 0x3CF, + 0x3D0, + 0x3D1, + 0x3D2, + 0x3D3, + 0x3D4, + 0x3D5, + 0x3D6, + 0x3D7, + 0x3D8, + 0x3D9, + 0x3DA, + 0x3DB, + 0x3DC, + 0x3DD, + 0x3DE, + 0x3DF, + 0x3E0, + 0x3E1, + 0x3E2, + 0x3E3, + 0x3E4, + 0x3E5, + 0x3E6, + 0x3E7, + 0x3E8, + 0x3E9, + 0x3EA, + 0x3EB, + 0x3EC, + 0x3ED, + 0x3EE, + 0x3EF, + 0x3F0, + 0x3F1, + 0x3F2, + 0x3F3, + 0x3F4, + 0x3F5, + 0x3F6, + 0x3F7, + 0x3F8, + 0x3F9, + 0x3FA, + 0x3FB, + 0x3FD, + 0x3FE, + 0x3FF, + 0x400, + 0x401, + 0x402, + 0x403, + 0x404, + 0x405, + 0x406, + 0x407, + 0x408, + 0x409, + 0x40A, + 0x40B, + 0x40C, + 0x40D, + 0x40E, + 0x40F, + 0x410, + 0x411, + 0x412, + 0x413, + 0x414, + 0x415, + 0x416, + 0x417, + 0x418, + 0x419, + 0x41A, + 0x41B, + 0x41C, + 0x41D, + 0x41E, + 0x41F, + 0x420, + 0x421, + 0x422, + 0x423, + 0x424, + 0x425, + 0x426, + 0x427, + 0x428, + 0x429, + 0x42A, + 0x42B, + 0x42C, + 0x42D, + 0x42E, + 0x42F, + 0x430, + 0x460, + 0x461, + 0x462, + 0x463, + 0x464, + 0x465, + 0x466, + 0x467, + 0x468, + 0x469, + 0x46A, + 0x46B, + 0x46C, + 0x46D, + 0x46E, + 0x46F, + 0x470, + 0x471, + 0x472, + 0x473, + 0x474, + 0x475, + 0x476, + 0x477, + 0x478, + 0x479, + 0x47A, + 0x47B, + 0x47C, + 0x47D, + 0x47E, + 0x47F, + 0x480, + 0x481, + 0x48A, + 0x48B, + 0x48C, + 0x48D, + 0x48E, + 0x48F, + 0x490, + 0x491, + 0x492, + 0x493, + 0x494, + 0x495, + 0x496, + 0x497, + 0x498, + 0x499, + 0x49A, + 0x49B, + 0x49C, + 0x49D, + 0x49E, + 0x49F, + 0x4A0, + 0x4A1, + 0x4A2, + 0x4A3, + 0x4A4, + 0x4A5, + 0x4A6, + 0x4A7, + 0x4A8, + 0x4A9, + 0x4AA, + 0x4AB, + 0x4AC, + 0x4AD, + 0x4AE, + 0x4AF, + 0x4B0, + 0x4B1, + 0x4B2, + 0x4B3, + 0x4B4, + 0x4B5, + 0x4B6, + 0x4B7, + 0x4B8, + 0x4B9, + 0x4BA, + 0x4BB, + 0x4BC, + 0x4BD, + 0x4BE, + 0x4BF, + 0x4C0, + 0x4C1, + 0x4C2, + 0x4C3, + 0x4C4, + 0x4C5, + 0x4C6, + 0x4C7, + 0x4C8, + 0x4C9, + 0x4CA, + 0x4CB, + 0x4CC, + 0x4CD, + 0x4CE, + 0x4D0, + 0x4D1, + 0x4D2, + 0x4D3, + 0x4D4, + 0x4D5, + 0x4D6, + 0x4D7, + 0x4D8, + 0x4D9, + 0x4DA, + 0x4DB, + 0x4DC, + 0x4DD, + 0x4DE, + 0x4DF, + 0x4E0, + 0x4E1, + 0x4E2, + 0x4E3, + 0x4E4, + 0x4E5, + 0x4E6, + 0x4E7, + 0x4E8, + 0x4E9, + 0x4EA, + 0x4EB, + 0x4EC, + 0x4ED, + 0x4EE, + 0x4EF, + 0x4F0, + 0x4F1, + 0x4F2, + 0x4F3, + 0x4F4, + 0x4F5, + 0x4F6, + 0x4F7, + 0x4F8, + 0x4F9, + 0x4FA, + 0x4FB, + 0x4FC, + 0x4FD, + 0x4FE, + 0x4FF, + 0x500, + 0x501, + 0x502, + 0x503, + 0x504, + 0x505, + 0x506, + 0x507, + 0x508, + 0x509, + 0x50A, + 0x50B, + 0x50C, + 0x50D, + 0x50E, + 0x50F, + 0x510, + 0x511, + 0x512, + 0x513, + 0x514, + 0x515, + 0x516, + 0x517, + 0x518, + 0x519, + 0x51A, + 0x51B, + 0x51C, + 0x51D, + 0x51E, + 0x51F, + 0x520, + 0x521, + 0x522, + 0x523, + 0x524, + 0x525, + 0x526, + 0x527, + 0x528, + 0x529, + 0x52A, + 0x52B, + 0x52C, + 0x52D, + 0x52E, + 0x52F, + 0x530, + 0x531, + 0x532, + 0x533, + 0x534, + 0x535, + 0x536, + 0x537, + 0x538, + 0x539, + 0x53A, + 0x53B, + 0x53C, + 0x53D, + 0x53E, + 0x53F, + 0x540, + 0x541, + 0x542, + 0x543, + 0x544, + 0x545, + 0x546, + 0x547, + 0x548, + 0x549, + 0x54A, + 0x54B, + 0x54C, + 0x54D, + 0x54E, + 0x54F, + 0x550, + 0x551, + 0x552, + 0x553, + 0x554, + 0x555, + 0x556, + 0x557, + 0x559, + 0x587, + 0x588, + 0x58B, + 0x58D, + 0x590, + 0x591, + 0x5C8, + 0x5D0, + 0x5EB, + 0x5EF, + 0x5F5, + 0x606, + 0x61C, + 0x61D, + 0x675, + 0x676, + 0x677, + 0x678, + 0x679, + 0x6DD, + 0x6DE, + 0x70E, + 0x710, + 0x74B, + 0x74D, + 0x7B2, + 0x7C0, + 0x7FB, + 0x7FD, + 0x82E, + 0x830, + 0x83F, + 0x840, + 0x85C, + 0x85E, + 0x85F, + 0x860, + 0x86B, + 0x870, + 0x890, + 0x897, + 0x8E2, + 0x8E3, + 0x958, + 0x959, + 0x95A, + 0x95B, + 0x95C, + 0x95D, + 0x95E, + 0x95F, + 0x960, + 0x984, + 0x985, + 0x98D, + 0x98F, + 0x991, + 0x993, + 0x9A9, + 0x9AA, + 0x9B1, + 0x9B2, + 0x9B3, + 0x9B6, + 0x9BA, + 0x9BC, + 0x9C5, + 0x9C7, + 0x9C9, + 0x9CB, + 0x9CF, + 0x9D7, + 0x9D8, + 0x9DC, + 0x9DD, + 0x9DE, + 0x9DF, + 0x9E0, + 0x9E4, + 0x9E6, + 0x9FF, + 0xA01, + 0xA04, + 0xA05, + 0xA0B, + 0xA0F, + 0xA11, + 0xA13, + 0xA29, + 0xA2A, + 0xA31, + 0xA32, + 0xA33, + 0xA34, + 0xA35, + 0xA36, + 0xA37, + 0xA38, + 0xA3A, + 0xA3C, + 0xA3D, + 0xA3E, + 0xA43, + 0xA47, + 0xA49, + 0xA4B, + 0xA4E, + 0xA51, + 0xA52, + 0xA59, + 0xA5A, + 0xA5B, + 0xA5C, + 0xA5D, + 0xA5E, + 0xA5F, + 0xA66, + 0xA77, + 0xA81, + 0xA84, + 0xA85, + 0xA8E, + 0xA8F, + 0xA92, + 0xA93, + 0xAA9, + 0xAAA, + 0xAB1, + 0xAB2, + 0xAB4, + 0xAB5, + 0xABA, + 0xABC, + 0xAC6, + 0xAC7, + 0xACA, + 0xACB, + 0xACE, + 0xAD0, + 0xAD1, + 0xAE0, + 0xAE4, + 0xAE6, + 0xAF2, + 0xAF9, + 0xB00, + 0xB01, + 0xB04, + 0xB05, + 0xB0D, + 0xB0F, + 0xB11, + 0xB13, + 0xB29, + 0xB2A, + 0xB31, + 0xB32, + 0xB34, + 0xB35, + 0xB3A, + 0xB3C, + 0xB45, + 0xB47, + 0xB49, + 0xB4B, + 0xB4E, + 0xB55, + 0xB58, + 0xB5C, + 0xB5D, + 0xB5E, + 0xB5F, + 0xB64, + 0xB66, + 0xB78, + 0xB82, + 0xB84, + 0xB85, + 0xB8B, + 0xB8E, + 0xB91, + 0xB92, + 0xB96, + 0xB99, + 0xB9B, + 0xB9C, + 0xB9D, + 0xB9E, + 0xBA0, + 0xBA3, + 0xBA5, + 0xBA8, + 0xBAB, + 0xBAE, + 0xBBA, + 0xBBE, + 0xBC3, + 0xBC6, + 0xBC9, + 0xBCA, + 0xBCE, + 0xBD0, + 0xBD1, + 0xBD7, + 0xBD8, + 0xBE6, + 0xBFB, + 0xC00, + 0xC0D, + 0xC0E, + 0xC11, + 0xC12, + 0xC29, + 0xC2A, + 0xC3A, + 0xC3C, + 0xC45, + 0xC46, + 0xC49, + 0xC4A, + 0xC4E, + 0xC55, + 0xC57, + 0xC58, + 0xC5B, + 0xC5C, + 0xC5E, + 0xC60, + 0xC64, + 0xC66, + 0xC70, + 0xC77, + 0xC8D, + 0xC8E, + 0xC91, + 0xC92, + 0xCA9, + 0xCAA, + 0xCB4, + 0xCB5, + 0xCBA, + 0xCBC, + 0xCC5, + 0xCC6, + 0xCC9, + 0xCCA, + 0xCCE, + 0xCD5, + 0xCD7, + 0xCDC, + 0xCDF, + 0xCE0, + 0xCE4, + 0xCE6, + 0xCF0, + 0xCF1, + 0xCF4, + 0xD00, + 0xD0D, + 0xD0E, + 0xD11, + 0xD12, + 0xD45, + 0xD46, + 0xD49, + 0xD4A, + 0xD50, + 0xD54, + 0xD64, + 0xD66, + 0xD80, + 0xD81, + 0xD84, + 0xD85, + 0xD97, + 0xD9A, + 0xDB2, + 0xDB3, + 0xDBC, + 0xDBD, + 0xDBE, + 0xDC0, + 0xDC7, + 0xDCA, + 0xDCB, + 0xDCF, + 0xDD5, + 0xDD6, + 0xDD7, + 0xDD8, + 0xDE0, + 0xDE6, + 0xDF0, + 0xDF2, + 0xDF5, + 0xE01, + 0xE33, + 0xE34, + 0xE3B, + 0xE3F, + 0xE5C, + 0xE81, + 0xE83, + 0xE84, + 0xE85, + 0xE86, + 0xE8B, + 0xE8C, + 0xEA4, + 0xEA5, + 0xEA6, + 0xEA7, + 0xEB3, + 0xEB4, + 0xEBE, + 0xEC0, + 0xEC5, + 0xEC6, + 0xEC7, + 0xEC8, + 0xECF, + 0xED0, + 0xEDA, + 0xEDC, + 0xEDD, + 0xEDE, + 0xEE0, + 0xF00, + 0xF0C, + 0xF0D, + 0xF43, + 0xF44, + 0xF48, + 0xF49, + 0xF4D, + 0xF4E, + 0xF52, + 0xF53, + 0xF57, + 0xF58, + 0xF5C, + 0xF5D, + 0xF69, + 0xF6A, + 0xF6D, + 0xF71, + 0xF73, + 0xF74, + 0xF75, + 0xF76, + 0xF77, + 0xF78, + 0xF79, + 0xF7A, + 0xF81, + 0xF82, + 0xF93, + 0xF94, + 0xF98, + 0xF99, + 0xF9D, + 0xF9E, + 0xFA2, + 0xFA3, + 0xFA7, + 0xFA8, + 0xFAC, + 0xFAD, + 0xFB9, + 0xFBA, + 0xFBD, + 0xFBE, + 0xFCD, + 0xFCE, + 0xFDB, + 0x1000, + 0x10A0, + 0x10A1, + 0x10A2, + 0x10A3, + 0x10A4, + 0x10A5, + 0x10A6, + 0x10A7, + 0x10A8, + 0x10A9, + 0x10AA, + 0x10AB, + 0x10AC, + 0x10AD, + 0x10AE, + 0x10AF, + 0x10B0, + 0x10B1, + 0x10B2, + 0x10B3, + 0x10B4, + 0x10B5, + 0x10B6, + 0x10B7, + 0x10B8, + 0x10B9, + 0x10BA, + 0x10BB, + 0x10BC, + 0x10BD, + 0x10BE, + 0x10BF, + 0x10C0, + 0x10C1, + 0x10C2, + 0x10C3, + 0x10C4, + 0x10C5, + 0x10C6, + 0x10C7, + 0x10C8, + 0x10CD, + 0x10CE, + 0x10D0, + 0x10FC, + 0x10FD, + 0x115F, + 0x1161, + 0x1249, + 0x124A, + 0x124E, + 0x1250, + 0x1257, + 0x1258, + 0x1259, + 0x125A, + 0x125E, + 0x1260, + 0x1289, + 0x128A, + 0x128E, + 0x1290, + 0x12B1, + 0x12B2, + 0x12B6, + 0x12B8, + 0x12BF, + 0x12C0, + 0x12C1, + 0x12C2, + 0x12C6, + 0x12C8, + 0x12D7, + 0x12D8, + 0x1311, + 0x1312, + 0x1316, + 0x1318, + 0x135B, + 0x135D, + 0x137D, + 0x1380, + 0x139A, + 0x13A0, + 0x13F6, + 0x13F8, + 0x13F9, + 0x13FA, + 0x13FB, + 0x13FC, + 0x13FD, + 0x13FE, + 0x1400, + 0x1680, + 0x1681, + 0x169D, + 0x16A0, + 0x16F9, + 0x1700, + 0x1716, + 0x171F, + 0x1737, + 0x1740, + 0x1754, + 0x1760, + 0x176D, + 0x176E, + 0x1771, + 0x1772, + 0x1774, + 0x1780, + 0x17B4, + 0x17B6, + 0x17DE, + 0x17E0, + 0x17EA, + 0x17F0, + 0x17FA, + 0x1800, + 0x180B, + 0x1810, + 0x181A, + 0x1820, + 0x1879, + 0x1880, + 0x18AB, + 0x18B0, + 0x18F6, + 0x1900, + 0x191F, + 0x1920, + 0x192C, + 0x1930, + 0x193C, + 0x1940, + 0x1941, + 0x1944, + 0x196E, + 0x1970, + 0x1975, + 0x1980, + 0x19AC, + 0x19B0, + 0x19CA, + 0x19D0, + 0x19DB, + 0x19DE, + 0x1A1C, + 0x1A1E, + 0x1A5F, + 0x1A60, + 0x1A7D, + 0x1A7F, + 0x1A8A, + 0x1A90, + 0x1A9A, + 0x1AA0, + 0x1AAE, + 0x1AB0, + 0x1ADE, + 0x1AE0, + 0x1AEC, + 0x1B00, + 0x1B4D, + 0x1B4E, + 0x1BF4, + 0x1BFC, + 0x1C38, + 0x1C3B, + 0x1C4A, + 0x1C4D, + 0x1C80, + 0x1C81, + 0x1C82, + 0x1C83, + 0x1C84, + 0x1C86, + 0x1C87, + 0x1C88, + 0x1C89, + 0x1C8A, + 0x1C8B, + 0x1C90, + 0x1C91, + 0x1C92, + 0x1C93, + 0x1C94, + 0x1C95, + 0x1C96, + 0x1C97, + 0x1C98, + 0x1C99, + 0x1C9A, + 0x1C9B, + 0x1C9C, + 0x1C9D, + 0x1C9E, + 0x1C9F, + 0x1CA0, + 0x1CA1, + 0x1CA2, + 0x1CA3, + 0x1CA4, + 0x1CA5, + 0x1CA6, + 0x1CA7, + 0x1CA8, + 0x1CA9, + 0x1CAA, + 0x1CAB, + 0x1CAC, + 0x1CAD, + 0x1CAE, + 0x1CAF, + 0x1CB0, + 0x1CB1, + 0x1CB2, + 0x1CB3, + 0x1CB4, + 0x1CB5, + 0x1CB6, + 0x1CB7, + 0x1CB8, + 0x1CB9, + 0x1CBA, + 0x1CBB, + 0x1CBD, + 0x1CBE, + 0x1CBF, + 0x1CC0, + 0x1CC8, + 0x1CD0, + 0x1CFB, + 0x1D00, + 0x1D2C, + 0x1D2D, + 0x1D2E, + 0x1D2F, + 0x1D30, + 0x1D31, + 0x1D32, + 0x1D33, + 0x1D34, + 0x1D35, + 0x1D36, + 0x1D37, + 0x1D38, + 0x1D39, + 0x1D3A, + 0x1D3B, + 0x1D3C, + 0x1D3D, + 0x1D3E, + 0x1D3F, + 0x1D40, + 0x1D41, + 0x1D42, + 0x1D43, + 0x1D44, + 0x1D45, + 0x1D46, + 0x1D47, + 0x1D48, + 0x1D49, + 0x1D4A, + 0x1D4B, + 0x1D4C, + 0x1D4D, + 0x1D4E, + 0x1D4F, + 0x1D50, + 0x1D51, + 0x1D52, + 0x1D53, + 0x1D54, + 0x1D55, + 0x1D56, + 0x1D57, + 0x1D58, + 0x1D59, + 0x1D5A, + 0x1D5B, + 0x1D5C, + 0x1D5D, + 0x1D5E, + 0x1D5F, + 0x1D60, + 0x1D61, + 0x1D62, + 0x1D63, + 0x1D64, + 0x1D65, + 0x1D66, + 0x1D67, + 0x1D68, + 0x1D69, + 0x1D6A, + 0x1D6B, + 0x1D78, + 0x1D79, + 0x1D9B, + 0x1D9C, + 0x1D9D, + 0x1D9E, + 0x1D9F, + 0x1DA0, + 0x1DA1, + 0x1DA2, + 0x1DA3, + 0x1DA4, + 0x1DA5, + 0x1DA6, + 0x1DA7, + 0x1DA8, + 0x1DA9, + 0x1DAA, + 0x1DAB, + 0x1DAC, + 0x1DAD, + 0x1DAE, + 0x1DAF, + 0x1DB0, + 0x1DB1, + 0x1DB2, + 0x1DB3, + 0x1DB4, + 0x1DB5, + 0x1DB6, + 0x1DB7, + 0x1DB8, + 0x1DB9, + 0x1DBA, + 0x1DBB, + 0x1DBC, + 0x1DBD, + 0x1DBE, + 0x1DBF, + 0x1DC0, + 0x1E00, + 0x1E01, + 0x1E02, + 0x1E03, + 0x1E04, + 0x1E05, + 0x1E06, + 0x1E07, + 0x1E08, + 0x1E09, + 0x1E0A, + 0x1E0B, + 0x1E0C, + 0x1E0D, + 0x1E0E, + 0x1E0F, + 0x1E10, + 0x1E11, + 0x1E12, + 0x1E13, + 0x1E14, + 0x1E15, + 0x1E16, + 0x1E17, + 0x1E18, + 0x1E19, + 0x1E1A, + 0x1E1B, + 0x1E1C, + 0x1E1D, + 0x1E1E, + 0x1E1F, + 0x1E20, + 0x1E21, + 0x1E22, + 0x1E23, + 0x1E24, + 0x1E25, + 0x1E26, + 0x1E27, + 0x1E28, + 0x1E29, + 0x1E2A, + 0x1E2B, + 0x1E2C, + 0x1E2D, + 0x1E2E, + 0x1E2F, + 0x1E30, + 0x1E31, + 0x1E32, + 0x1E33, + 0x1E34, + 0x1E35, + 0x1E36, + 0x1E37, + 0x1E38, + 0x1E39, + 0x1E3A, + 0x1E3B, + 0x1E3C, + 0x1E3D, + 0x1E3E, + 0x1E3F, + 0x1E40, + 0x1E41, + 0x1E42, + 0x1E43, + 0x1E44, + 0x1E45, + 0x1E46, + 0x1E47, + 0x1E48, + 0x1E49, + 0x1E4A, + 0x1E4B, + 0x1E4C, + 0x1E4D, + 0x1E4E, + 0x1E4F, + 0x1E50, + 0x1E51, + 0x1E52, + 0x1E53, + 0x1E54, + 0x1E55, + 0x1E56, + 0x1E57, + 0x1E58, + 0x1E59, + 0x1E5A, + 0x1E5B, + 0x1E5C, + 0x1E5D, + 0x1E5E, + 0x1E5F, + 0x1E60, + 0x1E61, + 0x1E62, + 0x1E63, + 0x1E64, + 0x1E65, + 0x1E66, + 0x1E67, + 0x1E68, + 0x1E69, + 0x1E6A, + 0x1E6B, + 0x1E6C, + 0x1E6D, + 0x1E6E, + 0x1E6F, + 0x1E70, + 0x1E71, + 0x1E72, + 0x1E73, + 0x1E74, + 0x1E75, + 0x1E76, + 0x1E77, + 0x1E78, + 0x1E79, + 0x1E7A, + 0x1E7B, + 0x1E7C, + 0x1E7D, + 0x1E7E, + 0x1E7F, + 0x1E80, + 0x1E81, + 0x1E82, + 0x1E83, + 0x1E84, + 0x1E85, + 0x1E86, + 0x1E87, + 0x1E88, + 0x1E89, + 0x1E8A, + 0x1E8B, + 0x1E8C, + 0x1E8D, + 0x1E8E, + 0x1E8F, + 0x1E90, + 0x1E91, + 0x1E92, + 0x1E93, + 0x1E94, + 0x1E95, + 0x1E9A, + 0x1E9B, + 0x1E9C, + 0x1E9E, + 0x1E9F, + 0x1EA0, + 0x1EA1, + 0x1EA2, + 0x1EA3, + 0x1EA4, + 0x1EA5, + 0x1EA6, + 0x1EA7, + 0x1EA8, + 0x1EA9, + 0x1EAA, + 0x1EAB, + 0x1EAC, + 0x1EAD, + 0x1EAE, + 0x1EAF, + 0x1EB0, + 0x1EB1, + 0x1EB2, + 0x1EB3, + 0x1EB4, + 0x1EB5, + 0x1EB6, + 0x1EB7, + 0x1EB8, + 0x1EB9, + 0x1EBA, + 0x1EBB, + 0x1EBC, + 0x1EBD, + 0x1EBE, + 0x1EBF, + 0x1EC0, + 0x1EC1, + 0x1EC2, + 0x1EC3, + 0x1EC4, + 0x1EC5, + 0x1EC6, + 0x1EC7, + 0x1EC8, + 0x1EC9, + 0x1ECA, + 0x1ECB, + 0x1ECC, + 0x1ECD, + 0x1ECE, + 0x1ECF, + 0x1ED0, + 0x1ED1, + 0x1ED2, + 0x1ED3, + 0x1ED4, + 0x1ED5, + 0x1ED6, + 0x1ED7, + 0x1ED8, + 0x1ED9, + 0x1EDA, + 0x1EDB, + 0x1EDC, + 0x1EDD, + 0x1EDE, + 0x1EDF, + 0x1EE0, + 0x1EE1, + 0x1EE2, + 0x1EE3, + 0x1EE4, + 0x1EE5, + 0x1EE6, + 0x1EE7, + 0x1EE8, + 0x1EE9, + 0x1EEA, + 0x1EEB, + 0x1EEC, + 0x1EED, + 0x1EEE, + 0x1EEF, + 0x1EF0, + 0x1EF1, + 0x1EF2, + 0x1EF3, + 0x1EF4, + 0x1EF5, + 0x1EF6, + 0x1EF7, + 0x1EF8, + 0x1EF9, + 0x1EFA, + 0x1EFB, + 0x1EFC, + 0x1EFD, + 0x1EFE, + 0x1EFF, + 0x1F08, + 0x1F09, + 0x1F0A, + 0x1F0B, + 0x1F0C, + 0x1F0D, + 0x1F0E, + 0x1F0F, + 0x1F10, + 0x1F16, + 0x1F18, + 0x1F19, + 0x1F1A, + 0x1F1B, + 0x1F1C, + 0x1F1D, + 0x1F1E, + 0x1F20, + 0x1F28, + 0x1F29, + 0x1F2A, + 0x1F2B, + 0x1F2C, + 0x1F2D, + 0x1F2E, + 0x1F2F, + 0x1F30, + 0x1F38, + 0x1F39, + 0x1F3A, + 0x1F3B, + 0x1F3C, + 0x1F3D, + 0x1F3E, + 0x1F3F, + 0x1F40, + 0x1F46, + 0x1F48, + 0x1F49, + 0x1F4A, + 0x1F4B, + 0x1F4C, + 0x1F4D, + 0x1F4E, + 0x1F50, + 0x1F58, + 0x1F59, + 0x1F5A, + 0x1F5B, + 0x1F5C, + 0x1F5D, + 0x1F5E, + 0x1F5F, + 0x1F60, + 0x1F68, + 0x1F69, + 0x1F6A, + 0x1F6B, + 0x1F6C, + 0x1F6D, + 0x1F6E, + 0x1F6F, + 0x1F70, + 0x1F71, + 0x1F72, + 0x1F73, + 0x1F74, + 0x1F75, + 0x1F76, + 0x1F77, + 0x1F78, + 0x1F79, + 0x1F7A, + 0x1F7B, + 0x1F7C, + 0x1F7D, + 0x1F7E, + 0x1F80, + 0x1F81, + 0x1F82, + 0x1F83, + 0x1F84, + 0x1F85, + 0x1F86, + 0x1F87, + 0x1F88, + 0x1F89, + 0x1F8A, + 0x1F8B, + 0x1F8C, + 0x1F8D, + 0x1F8E, + 0x1F8F, + 0x1F90, + 0x1F91, + 0x1F92, + 0x1F93, + 0x1F94, + 0x1F95, + 0x1F96, + 0x1F97, + 0x1F98, + 0x1F99, + 0x1F9A, + 0x1F9B, + 0x1F9C, + 0x1F9D, + 0x1F9E, + 0x1F9F, + 0x1FA0, + 0x1FA1, + 0x1FA2, + 0x1FA3, + 0x1FA4, + 0x1FA5, + 0x1FA6, + 0x1FA7, + 0x1FA8, + 0x1FA9, + 0x1FAA, + 0x1FAB, + 0x1FAC, + 0x1FAD, + 0x1FAE, + 0x1FAF, + 0x1FB0, + 0x1FB2, + 0x1FB3, + 0x1FB4, + 0x1FB5, + 0x1FB6, + 0x1FB7, + 0x1FB8, + 0x1FB9, + 0x1FBA, + 0x1FBB, + 0x1FBC, + 0x1FBD, + 0x1FBE, + 0x1FBF, + 0x1FC0, + 0x1FC1, + 0x1FC2, + 0x1FC3, + 0x1FC4, + 0x1FC5, + 0x1FC6, + 0x1FC7, + 0x1FC8, + 0x1FC9, + 0x1FCA, + 0x1FCB, + 0x1FCC, + 0x1FCD, + 0x1FCE, + 0x1FCF, + 0x1FD0, + 0x1FD3, + 0x1FD4, + 0x1FD6, + 0x1FD8, + 0x1FD9, + 0x1FDA, + 0x1FDB, + 0x1FDC, + 0x1FDD, + 0x1FDE, + 0x1FDF, + 0x1FE0, + 0x1FE3, + 0x1FE4, + 0x1FE8, + 0x1FE9, + 0x1FEA, + 0x1FEB, + 0x1FEC, + 0x1FED, + 0x1FEE, + 0x1FEF, + 0x1FF0, + 0x1FF2, + 0x1FF3, + 0x1FF4, + 0x1FF5, + 0x1FF6, + 0x1FF7, + 0x1FF8, + 0x1FF9, + 0x1FFA, + 0x1FFB, + 0x1FFC, + 0x1FFD, + 0x1FFE, + 0x1FFF, + 0x2000, + 0x200B, + 0x200C, + 0x200E, + 0x2010, + 0x2011, + 0x2012, + 0x2017, + 0x2018, + 0x2024, + 0x2027, + 0x2028, + 0x202F, + 0x2030, + 0x2033, + 0x2034, + 0x2035, + 0x2036, + 0x2037, + 0x2038, + 0x203C, + 0x203D, + 0x203E, + 0x203F, + 0x2047, + 0x2048, + 0x2049, + 0x204A, + 0x2057, + 0x2058, + 0x205F, + 0x2060, + 0x2065, + 0x206A, + 0x2070, + 0x2071, + 0x2072, + 0x2074, + 0x2075, + 0x2076, + 0x2077, + 0x2078, + 0x2079, + 0x207A, + 0x207B, + 0x207C, + 0x207D, + 0x207E, + 0x207F, + 0x2080, + 0x2081, + 0x2082, + 0x2083, + 0x2084, + 0x2085, + 0x2086, + 0x2087, + 0x2088, + 0x2089, + 0x208A, + 0x208B, + 0x208C, + 0x208D, + 0x208E, + 0x208F, + 0x2090, + 0x2091, + 0x2092, + 0x2093, + 0x2094, + 0x2095, + 0x2096, + 0x2097, + 0x2098, + 0x2099, + 0x209A, + 0x209B, + 0x209C, + 0x209D, + 0x20A0, + 0x20A8, + 0x20A9, + 0x20C2, + 0x20D0, + 0x20F1, + 0x2100, + 0x2101, + 0x2102, + 0x2103, + 0x2104, + 0x2105, + 0x2106, + 0x2107, + 0x2108, + 0x2109, + 0x210A, + 0x210B, + 0x210F, + 0x2110, + 0x2112, + 0x2114, + 0x2115, + 0x2116, + 0x2117, + 0x2119, + 0x211A, + 0x211B, + 0x211E, + 0x2120, + 0x2121, + 0x2122, + 0x2123, + 0x2124, + 0x2125, + 0x2126, + 0x2127, + 0x2128, + 0x2129, + 0x212A, + 0x212B, + 0x212C, + 0x212D, + 0x212E, + 0x212F, + 0x2131, + 0x2132, + 0x2133, + 0x2134, + 0x2135, + 0x2136, + 0x2137, + 0x2138, + 0x2139, + 0x213A, + 0x213B, + 0x213C, + 0x213D, + 0x213F, + 0x2140, + 0x2141, + 0x2145, + 0x2147, + 0x2148, + 0x2149, + 0x214A, + 0x2150, + 0x2151, + 0x2152, + 0x2153, + 0x2154, + 0x2155, + 0x2156, + 0x2157, + 0x2158, + 0x2159, + 0x215A, + 0x215B, + 0x215C, + 0x215D, + 0x215E, + 0x215F, + 0x2160, + 0x2161, + 0x2162, + 0x2163, + 0x2164, + 0x2165, + 0x2166, + 0x2167, + 0x2168, + 0x2169, + 0x216A, + 0x216B, + 0x216C, + 0x216D, + 0x216E, + 0x216F, + 0x2170, + 0x2171, + 0x2172, + 0x2173, + 0x2174, + 0x2175, + 0x2176, + 0x2177, + 0x2178, + 0x2179, + 0x217A, + 0x217B, + 0x217C, + 0x217D, + 0x217E, + 0x217F, + 0x2180, + 0x2183, + 0x2184, + 0x2189, + 0x218A, + 0x218C, + 0x2190, + 0x222C, + 0x222D, + 0x222E, + 0x222F, + 0x2230, + 0x2231, + 0x2329, + 0x232A, + 0x232B, + 0x242A, + 0x2440, + 0x244B, + 0x2460, + 0x2461, + 0x2462, + 0x2463, + 0x2464, + 0x2465, + 0x2466, + 0x2467, + 0x2468, + 0x2469, + 0x246A, + 0x246B, + 0x246C, + 0x246D, + 0x246E, + 0x246F, + 0x2470, + 0x2471, + 0x2472, + 0x2473, + 0x2474, + 0x2475, + 0x2476, + 0x2477, + 0x2478, + 0x2479, + 0x247A, + 0x247B, + 0x247C, + 0x247D, + 0x247E, + 0x247F, + 0x2480, + 0x2481, + 0x2482, + 0x2483, + 0x2484, + 0x2485, + 0x2486, + 0x2487, + 0x2488, + 0x249C, + 0x249D, + 0x249E, + 0x249F, + 0x24A0, + 0x24A1, + 0x24A2, + 0x24A3, + 0x24A4, + 0x24A5, + 0x24A6, + 0x24A7, + 0x24A8, + 0x24A9, + 0x24AA, + 0x24AB, + 0x24AC, + 0x24AD, + 0x24AE, + 0x24AF, + 0x24B0, + 0x24B1, + 0x24B2, + 0x24B3, + 0x24B4, + 0x24B5, + 0x24B6, + 0x24B7, + 0x24B8, + 0x24B9, + 0x24BA, + 0x24BB, + 0x24BC, + 0x24BD, + 0x24BE, + 0x24BF, + 0x24C0, + 0x24C1, + 0x24C2, + 0x24C3, + 0x24C4, + 0x24C5, + 0x24C6, + 0x24C7, + 0x24C8, + 0x24C9, + 0x24CA, + 0x24CB, + 0x24CC, + 0x24CD, + 0x24CE, + 0x24CF, + 0x24D0, + 0x24D1, + 0x24D2, + 0x24D3, + 0x24D4, + 0x24D5, + 0x24D6, + 0x24D7, + 0x24D8, + 0x24D9, + 0x24DA, + 0x24DB, + 0x24DC, + 0x24DD, + 0x24DE, + 0x24DF, + 0x24E0, + 0x24E1, + 0x24E2, + 0x24E3, + 0x24E4, + 0x24E5, + 0x24E6, + 0x24E7, + 0x24E8, + 0x24E9, + 0x24EA, + 0x24EB, + 0x2A0C, + 0x2A0D, + 0x2A74, + 0x2A75, + 0x2A76, + 0x2A77, + 0x2ADC, + 0x2ADD, + 0x2B74, + 0x2B76, + 0x2C00, + 0x2C01, + 0x2C02, + 0x2C03, + 0x2C04, + 0x2C05, + 0x2C06, + 0x2C07, + 0x2C08, + 0x2C09, + 0x2C0A, + 0x2C0B, + 0x2C0C, + 0x2C0D, + 0x2C0E, + 0x2C0F, + 0x2C10, + 0x2C11, + 0x2C12, + 0x2C13, + 0x2C14, + 0x2C15, + 0x2C16, + 0x2C17, + 0x2C18, + 0x2C19, + 0x2C1A, + 0x2C1B, + 0x2C1C, + 0x2C1D, + 0x2C1E, + 0x2C1F, + 0x2C20, + 0x2C21, + 0x2C22, + 0x2C23, + 0x2C24, + 0x2C25, + 0x2C26, + 0x2C27, + 0x2C28, + 0x2C29, + 0x2C2A, + 0x2C2B, + 0x2C2C, + 0x2C2D, + 0x2C2E, + 0x2C2F, + 0x2C30, + 0x2C60, + 0x2C61, + 0x2C62, + 0x2C63, + 0x2C64, + 0x2C65, + 0x2C67, + 0x2C68, + 0x2C69, + 0x2C6A, + 0x2C6B, + 0x2C6C, + 0x2C6D, + 0x2C6E, + 0x2C6F, + 0x2C70, + 0x2C71, + 0x2C72, + 0x2C73, + 0x2C75, + 0x2C76, + 0x2C7C, + 0x2C7D, + 0x2C7E, + 0x2C7F, + 0x2C80, + 0x2C81, + 0x2C82, + 0x2C83, + 0x2C84, + 0x2C85, + 0x2C86, + 0x2C87, + 0x2C88, + 0x2C89, + 0x2C8A, + 0x2C8B, + 0x2C8C, + 0x2C8D, + 0x2C8E, + 0x2C8F, + 0x2C90, + 0x2C91, + 0x2C92, + 0x2C93, + 0x2C94, + 0x2C95, + 0x2C96, + 0x2C97, + 0x2C98, + 0x2C99, + 0x2C9A, + 0x2C9B, + 0x2C9C, + 0x2C9D, + 0x2C9E, + 0x2C9F, + 0x2CA0, + 0x2CA1, + 0x2CA2, + 0x2CA3, + 0x2CA4, + 0x2CA5, + 0x2CA6, + 0x2CA7, + 0x2CA8, + 0x2CA9, + 0x2CAA, + 0x2CAB, + 0x2CAC, + 0x2CAD, + 0x2CAE, + 0x2CAF, + 0x2CB0, + 0x2CB1, + 0x2CB2, + 0x2CB3, + 0x2CB4, + 0x2CB5, + 0x2CB6, + 0x2CB7, + 0x2CB8, + 0x2CB9, + 0x2CBA, + 0x2CBB, + 0x2CBC, + 0x2CBD, + 0x2CBE, + 0x2CBF, + 0x2CC0, + 0x2CC1, + 0x2CC2, + 0x2CC3, + 0x2CC4, + 0x2CC5, + 0x2CC6, + 0x2CC7, + 0x2CC8, + 0x2CC9, + 0x2CCA, + 0x2CCB, + 0x2CCC, + 0x2CCD, + 0x2CCE, + 0x2CCF, + 0x2CD0, + 0x2CD1, + 0x2CD2, + 0x2CD3, + 0x2CD4, + 0x2CD5, + 0x2CD6, + 0x2CD7, + 0x2CD8, + 0x2CD9, + 0x2CDA, + 0x2CDB, + 0x2CDC, + 0x2CDD, + 0x2CDE, + 0x2CDF, + 0x2CE0, + 0x2CE1, + 0x2CE2, + 0x2CE3, + 0x2CEB, + 0x2CEC, + 0x2CED, + 0x2CEE, + 0x2CF2, + 0x2CF3, + 0x2CF4, + 0x2CF9, + 0x2D26, + 0x2D27, + 0x2D28, + 0x2D2D, + 0x2D2E, + 0x2D30, + 0x2D68, + 0x2D6F, + 0x2D70, + 0x2D71, + 0x2D7F, + 0x2D97, + 0x2DA0, + 0x2DA7, + 0x2DA8, + 0x2DAF, + 0x2DB0, + 0x2DB7, + 0x2DB8, + 0x2DBF, + 0x2DC0, + 0x2DC7, + 0x2DC8, + 0x2DCF, + 0x2DD0, + 0x2DD7, + 0x2DD8, + 0x2DDF, + 0x2DE0, + 0x2E5E, + 0x2E80, + 0x2E9A, + 0x2E9B, + 0x2E9F, + 0x2EA0, + 0x2EF3, + 0x2EF4, + 0x2F00, + 0x2F01, + 0x2F02, + 0x2F03, + 0x2F04, + 0x2F05, + 0x2F06, + 0x2F07, + 0x2F08, + 0x2F09, + 0x2F0A, + 0x2F0B, + 0x2F0C, + 0x2F0D, + 0x2F0E, + 0x2F0F, + 0x2F10, + 0x2F11, + 0x2F12, + 0x2F13, + 0x2F14, + 0x2F15, + 0x2F16, + 0x2F17, + 0x2F18, + 0x2F19, + 0x2F1A, + 0x2F1B, + 0x2F1C, + 0x2F1D, + 0x2F1E, + 0x2F1F, + 0x2F20, + 0x2F21, + 0x2F22, + 0x2F23, + 0x2F24, + 0x2F25, + 0x2F26, + 0x2F27, + 0x2F28, + 0x2F29, + 0x2F2A, + 0x2F2B, + 0x2F2C, + 0x2F2D, + 0x2F2E, + 0x2F2F, + 0x2F30, + 0x2F31, + 0x2F32, + 0x2F33, + 0x2F34, + 0x2F35, + 0x2F36, + 0x2F37, + 0x2F38, + 0x2F39, + 0x2F3A, + 0x2F3B, + 0x2F3C, + 0x2F3D, + 0x2F3E, + 0x2F3F, + 0x2F40, + 0x2F41, + 0x2F42, + 0x2F43, + 0x2F44, + 0x2F45, + 0x2F46, + 0x2F47, + 0x2F48, + 0x2F49, + 0x2F4A, + 0x2F4B, + 0x2F4C, + 0x2F4D, + 0x2F4E, + 0x2F4F, + 0x2F50, + 0x2F51, + 0x2F52, + 0x2F53, + 0x2F54, + 0x2F55, + 0x2F56, + 0x2F57, + 0x2F58, + 0x2F59, + 0x2F5A, + 0x2F5B, + 0x2F5C, + 0x2F5D, + 0x2F5E, + 0x2F5F, + 0x2F60, + 0x2F61, + 0x2F62, + 0x2F63, + 0x2F64, + 0x2F65, + 0x2F66, + 0x2F67, + 0x2F68, + 0x2F69, + 0x2F6A, + 0x2F6B, + 0x2F6C, + 0x2F6D, + 0x2F6E, + 0x2F6F, + 0x2F70, + 0x2F71, + 0x2F72, + 0x2F73, + 0x2F74, + 0x2F75, + 0x2F76, + 0x2F77, + 0x2F78, + 0x2F79, + 0x2F7A, + 0x2F7B, + 0x2F7C, + 0x2F7D, + 0x2F7E, + 0x2F7F, + 0x2F80, + 0x2F81, + 0x2F82, + 0x2F83, + 0x2F84, + 0x2F85, + 0x2F86, + 0x2F87, + 0x2F88, + 0x2F89, + 0x2F8A, + 0x2F8B, + 0x2F8C, + 0x2F8D, + 0x2F8E, + 0x2F8F, + 0x2F90, + 0x2F91, + 0x2F92, + 0x2F93, + 0x2F94, + 0x2F95, + 0x2F96, + 0x2F97, + 0x2F98, + 0x2F99, + 0x2F9A, + 0x2F9B, + 0x2F9C, + 0x2F9D, + 0x2F9E, + 0x2F9F, + 0x2FA0, + 0x2FA1, + 0x2FA2, + 0x2FA3, + 0x2FA4, + 0x2FA5, + 0x2FA6, + 0x2FA7, + 0x2FA8, + 0x2FA9, + 0x2FAA, + 0x2FAB, + 0x2FAC, + 0x2FAD, + 0x2FAE, + 0x2FAF, + 0x2FB0, + 0x2FB1, + 0x2FB2, + 0x2FB3, + 0x2FB4, + 0x2FB5, + 0x2FB6, + 0x2FB7, + 0x2FB8, + 0x2FB9, + 0x2FBA, + 0x2FBB, + 0x2FBC, + 0x2FBD, + 0x2FBE, + 0x2FBF, + 0x2FC0, + 0x2FC1, + 0x2FC2, + 0x2FC3, + 0x2FC4, + 0x2FC5, + 0x2FC6, + 0x2FC7, + 0x2FC8, + 0x2FC9, + 0x2FCA, + 0x2FCB, + 0x2FCC, + 0x2FCD, + 0x2FCE, + 0x2FCF, + 0x2FD0, + 0x2FD1, + 0x2FD2, + 0x2FD3, + 0x2FD4, + 0x2FD5, + 0x2FD6, + 0x3000, + 0x3001, + 0x3002, + 0x3003, + 0x3036, + 0x3037, + 0x3038, + 0x3039, + 0x303A, + 0x303B, + 0x3040, + 0x3041, + 0x3097, + 0x3099, + 0x309B, + 0x309C, + 0x309D, + 0x309F, + 0x30A0, + 0x30FF, + 0x3100, + 0x3105, + 0x3130, + 0x3131, + 0x3132, + 0x3133, + 0x3134, + 0x3135, + 0x3136, + 0x3137, + 0x3138, + 0x3139, + 0x313A, + 0x313B, + 0x313C, + 0x313D, + 0x313E, + 0x313F, + 0x3140, + 0x3141, + 0x3142, + 0x3143, + 0x3144, + 0x3145, + 0x3146, + 0x3147, + 0x3148, + 0x3149, + 0x314A, + 0x314B, + 0x314C, + 0x314D, + 0x314E, + 0x314F, + 0x3150, + 0x3151, + 0x3152, + 0x3153, + 0x3154, + 0x3155, + 0x3156, + 0x3157, + 0x3158, + 0x3159, + 0x315A, + 0x315B, + 0x315C, + 0x315D, + 0x315E, + 0x315F, + 0x3160, + 0x3161, + 0x3162, + 0x3163, + 0x3164, + 0x3165, + 0x3166, + 0x3167, + 0x3168, + 0x3169, + 0x316A, + 0x316B, + 0x316C, + 0x316D, + 0x316E, + 0x316F, + 0x3170, + 0x3171, + 0x3172, + 0x3173, + 0x3174, + 0x3175, + 0x3176, + 0x3177, + 0x3178, + 0x3179, + 0x317A, + 0x317B, + 0x317C, + 0x317D, + 0x317E, + 0x317F, + 0x3180, + 0x3181, + 0x3182, + 0x3183, + 0x3184, + 0x3185, + 0x3186, + 0x3187, + 0x3188, + 0x3189, + 0x318A, + 0x318B, + 0x318C, + 0x318D, + 0x318E, + 0x318F, + 0x3190, + 0x3192, + 0x3193, + 0x3194, + 0x3195, + 0x3196, + 0x3197, + 0x3198, + 0x3199, + 0x319A, + 0x319B, + 0x319C, + 0x319D, + 0x319E, + 0x319F, + 0x31A0, + 0x31E6, + 0x31F0, + 0x3200, + 0x3201, + 0x3202, + 0x3203, + 0x3204, + 0x3205, + 0x3206, + 0x3207, + 0x3208, + 0x3209, + 0x320A, + 0x320B, + 0x320C, + 0x320D, + 0x320E, + 0x320F, + 0x3210, + 0x3211, + 0x3212, + 0x3213, + 0x3214, + 0x3215, + 0x3216, + 0x3217, + 0x3218, + 0x3219, + 0x321A, + 0x321B, + 0x321C, + 0x321D, + 0x321E, + 0x321F, + 0x3220, + 0x3221, + 0x3222, + 0x3223, + 0x3224, + 0x3225, + 0x3226, + 0x3227, + 0x3228, + 0x3229, + 0x322A, + 0x322B, + 0x322C, + 0x322D, + 0x322E, + 0x322F, + 0x3230, + 0x3231, + 0x3232, + 0x3233, + 0x3234, + 0x3235, + 0x3236, + 0x3237, + 0x3238, + 0x3239, + 0x323A, + 0x323B, + 0x323C, + 0x323D, + 0x323E, + 0x323F, + 0x3240, + 0x3241, + 0x3242, + 0x3243, + 0x3244, + 0x3245, + 0x3246, + 0x3247, + 0x3248, + 0x3250, + 0x3251, + 0x3252, + 0x3253, + 0x3254, + 0x3255, + 0x3256, + 0x3257, + 0x3258, + 0x3259, + 0x325A, + 0x325B, + 0x325C, + 0x325D, + 0x325E, + 0x325F, + 0x3260, + 0x3261, + 0x3262, + 0x3263, + 0x3264, + 0x3265, + 0x3266, + 0x3267, + 0x3268, + 0x3269, + 0x326A, + 0x326B, + 0x326C, + 0x326D, + 0x326E, + 0x326F, + 0x3270, + 0x3271, + 0x3272, + 0x3273, + 0x3274, + 0x3275, + 0x3276, + 0x3277, + 0x3278, + 0x3279, + 0x327A, + 0x327B, + 0x327C, + 0x327D, + 0x327E, + 0x327F, + 0x3280, + 0x3281, + 0x3282, + 0x3283, + 0x3284, + 0x3285, + 0x3286, + 0x3287, + 0x3288, + 0x3289, + 0x328A, + 0x328B, + 0x328C, + 0x328D, + 0x328E, + 0x328F, + 0x3290, + 0x3291, + 0x3292, + 0x3293, + 0x3294, + 0x3295, + 0x3296, + 0x3297, + 0x3298, + 0x3299, + 0x329A, + 0x329B, + 0x329C, + 0x329D, + 0x329E, + 0x329F, + 0x32A0, + 0x32A1, + 0x32A2, + 0x32A3, + 0x32A4, + 0x32A5, + 0x32A6, + 0x32A7, + 0x32A8, + 0x32A9, + 0x32AA, + 0x32AB, + 0x32AC, + 0x32AD, + 0x32AE, + 0x32AF, + 0x32B0, + 0x32B1, + 0x32B2, + 0x32B3, + 0x32B4, + 0x32B5, + 0x32B6, + 0x32B7, + 0x32B8, + 0x32B9, + 0x32BA, + 0x32BB, + 0x32BC, + 0x32BD, + 0x32BE, + 0x32BF, + 0x32C0, + 0x32C1, + 0x32C2, + 0x32C3, + 0x32C4, + 0x32C5, + 0x32C6, + 0x32C7, + 0x32C8, + 0x32C9, + 0x32CA, + 0x32CB, + 0x32CC, + 0x32CD, + 0x32CE, + 0x32CF, + 0x32D0, + 0x32D1, + 0x32D2, + 0x32D3, + 0x32D4, + 0x32D5, + 0x32D6, + 0x32D7, + 0x32D8, + 0x32D9, + 0x32DA, + 0x32DB, + 0x32DC, + 0x32DD, + 0x32DE, + 0x32DF, + 0x32E0, + 0x32E1, + 0x32E2, + 0x32E3, + 0x32E4, + 0x32E5, + 0x32E6, + 0x32E7, + 0x32E8, + 0x32E9, + 0x32EA, + 0x32EB, + 0x32EC, + 0x32ED, + 0x32EE, + 0x32EF, + 0x32F0, + 0x32F1, + 0x32F2, + 0x32F3, + 0x32F4, + 0x32F5, + 0x32F6, + 0x32F7, + 0x32F8, + 0x32F9, + 0x32FA, + 0x32FB, + 0x32FC, + 0x32FD, + 0x32FE, + 0x32FF, + 0x3300, + 0x3301, + 0x3302, + 0x3303, + 0x3304, + 0x3305, + 0x3306, + 0x3307, + 0x3308, + 0x3309, + 0x330A, + 0x330B, + 0x330C, + 0x330D, + 0x330E, + 0x330F, + 0x3310, + 0x3311, + 0x3312, + 0x3313, + 0x3314, + 0x3315, + 0x3316, + 0x3317, + 0x3318, + 0x3319, + 0x331A, + 0x331B, + 0x331C, + 0x331D, + 0x331E, + 0x331F, + 0x3320, + 0x3321, + 0x3322, + 0x3323, + 0x3324, + 0x3325, + 0x3326, + 0x3327, + 0x3328, + 0x3329, + 0x332A, + 0x332B, + 0x332C, + 0x332D, + 0x332E, + 0x332F, + 0x3330, + 0x3331, + 0x3332, + 0x3333, + 0x3334, + 0x3335, + 0x3336, + 0x3337, + 0x3338, + 0x3339, + 0x333A, + 0x333B, + 0x333C, + 0x333D, + 0x333E, + 0x333F, + 0x3340, + 0x3341, + 0x3342, + 0x3343, + 0x3344, + 0x3345, + 0x3346, + 0x3347, + 0x3348, + 0x3349, + 0x334A, + 0x334B, + 0x334C, + 0x334D, + 0x334E, + 0x334F, + 0x3350, + 0x3351, + 0x3352, + 0x3353, + 0x3354, + 0x3355, + 0x3356, + 0x3357, + 0x3358, + 0x3359, + 0x335A, + 0x335B, + 0x335C, + 0x335D, + 0x335E, + 0x335F, + 0x3360, + 0x3361, + 0x3362, + 0x3363, + 0x3364, + 0x3365, + 0x3366, + 0x3367, + 0x3368, + 0x3369, + 0x336A, + 0x336B, + 0x336C, + 0x336D, + 0x336E, + 0x336F, + 0x3370, + 0x3371, + 0x3372, + 0x3373, + 0x3374, + 0x3375, + 0x3376, + 0x3377, + 0x3378, + 0x3379, + 0x337A, + 0x337B, + 0x337C, + 0x337D, + 0x337E, + 0x337F, + 0x3380, + 0x3381, + 0x3382, + 0x3383, + 0x3384, + 0x3385, + 0x3386, + 0x3387, + 0x3388, + 0x3389, + 0x338A, + 0x338B, + 0x338C, + 0x338D, + 0x338E, + 0x338F, + 0x3390, + 0x3391, + 0x3392, + 0x3393, + 0x3394, + 0x3395, + 0x3396, + 0x3397, + 0x3398, + 0x3399, + 0x339A, + 0x339B, + 0x339C, + 0x339D, + 0x339E, + 0x339F, + 0x33A0, + 0x33A1, + 0x33A2, + 0x33A3, + 0x33A4, + 0x33A5, + 0x33A6, + 0x33A7, + 0x33A8, + 0x33A9, + 0x33AA, + 0x33AB, + 0x33AC, + 0x33AD, + 0x33AE, + 0x33AF, + 0x33B0, + 0x33B1, + 0x33B2, + 0x33B3, + 0x33B4, + 0x33B5, + 0x33B6, + 0x33B7, + 0x33B8, + 0x33B9, + 0x33BA, + 0x33BB, + 0x33BC, + 0x33BD, + 0x33BE, + 0x33BF, + 0x33C0, + 0x33C1, + 0x33C2, + 0x33C3, + 0x33C4, + 0x33C5, + 0x33C6, + 0x33C7, + 0x33C8, + 0x33C9, + 0x33CA, + 0x33CB, + 0x33CC, + 0x33CD, + 0x33CE, + 0x33CF, + 0x33D0, + 0x33D1, + 0x33D2, + 0x33D3, + 0x33D4, + 0x33D5, + 0x33D6, + 0x33D7, + 0x33D8, + 0x33D9, + 0x33DA, + 0x33DB, + 0x33DC, + 0x33DD, + 0x33DE, + 0x33DF, + 0x33E0, + 0x33E1, + 0x33E2, + 0x33E3, + 0x33E4, + 0x33E5, + 0x33E6, + 0x33E7, + 0x33E8, + 0x33E9, + 0x33EA, + 0x33EB, + 0x33EC, + 0x33ED, + 0x33EE, + 0x33EF, + 0x33F0, + 0x33F1, + 0x33F2, + 0x33F3, + 0x33F4, + 0x33F5, + 0x33F6, + 0x33F7, + 0x33F8, + 0x33F9, + 0x33FA, + 0x33FB, + 0x33FC, + 0x33FD, + 0x33FE, + 0x33FF, + 0x3400, + 0xA48D, + 0xA490, + 0xA4C7, + 0xA4D0, + 0xA62C, + 0xA640, + 0xA641, + 0xA642, + 0xA643, + 0xA644, + 0xA645, + 0xA646, + 0xA647, + 0xA648, + 0xA649, + 0xA64A, + 0xA64B, + 0xA64C, + 0xA64D, + 0xA64E, + 0xA64F, + 0xA650, + 0xA651, + 0xA652, + 0xA653, + 0xA654, + 0xA655, + 0xA656, + 0xA657, + 0xA658, + 0xA659, + 0xA65A, + 0xA65B, + 0xA65C, + 0xA65D, + 0xA65E, + 0xA65F, + 0xA660, + 0xA661, + 0xA662, + 0xA663, + 0xA664, + 0xA665, + 0xA666, + 0xA667, + 0xA668, + 0xA669, + 0xA66A, + 0xA66B, + 0xA66C, + 0xA66D, + 0xA680, + 0xA681, + 0xA682, + 0xA683, + 0xA684, + 0xA685, + 0xA686, + 0xA687, + 0xA688, + 0xA689, + 0xA68A, + 0xA68B, + 0xA68C, + 0xA68D, + 0xA68E, + 0xA68F, + 0xA690, + 0xA691, + 0xA692, + 0xA693, + 0xA694, + 0xA695, + 0xA696, + 0xA697, + 0xA698, + 0xA699, + 0xA69A, + 0xA69B, + 0xA69C, + 0xA69D, + 0xA69E, + 0xA6F8, + 0xA700, + 0xA722, + 0xA723, + 0xA724, + 0xA725, + 0xA726, + 0xA727, + 0xA728, + 0xA729, + 0xA72A, + 0xA72B, + 0xA72C, + 0xA72D, + 0xA72E, + 0xA72F, + 0xA732, + 0xA733, + 0xA734, + 0xA735, + 0xA736, + 0xA737, + 0xA738, + 0xA739, + 0xA73A, + 0xA73B, + 0xA73C, + 0xA73D, + 0xA73E, + 0xA73F, + 0xA740, + 0xA741, + 0xA742, + 0xA743, + 0xA744, + 0xA745, + 0xA746, + 0xA747, + 0xA748, + 0xA749, + 0xA74A, + 0xA74B, + 0xA74C, + 0xA74D, + 0xA74E, + 0xA74F, + 0xA750, + 0xA751, + 0xA752, + 0xA753, + 0xA754, + 0xA755, + 0xA756, + 0xA757, + 0xA758, + 0xA759, + 0xA75A, + 0xA75B, + 0xA75C, + 0xA75D, + 0xA75E, + 0xA75F, + 0xA760, + 0xA761, + 0xA762, + 0xA763, + 0xA764, + 0xA765, + 0xA766, + 0xA767, + 0xA768, + 0xA769, + 0xA76A, + 0xA76B, + 0xA76C, + 0xA76D, + 0xA76E, + 0xA76F, + 0xA770, + 0xA771, + 0xA779, + 0xA77A, + 0xA77B, + 0xA77C, + 0xA77D, + 0xA77E, + 0xA77F, + 0xA780, + 0xA781, + 0xA782, + 0xA783, + 0xA784, + 0xA785, + 0xA786, + 0xA787, + 0xA78B, + 0xA78C, + 0xA78D, + 0xA78E, + 0xA790, + 0xA791, + 0xA792, + 0xA793, + 0xA796, + 0xA797, + 0xA798, + 0xA799, + 0xA79A, + 0xA79B, + 0xA79C, + 0xA79D, + 0xA79E, + 0xA79F, + 0xA7A0, + 0xA7A1, + 0xA7A2, + 0xA7A3, + 0xA7A4, + 0xA7A5, + 0xA7A6, + 0xA7A7, + 0xA7A8, + 0xA7A9, + 0xA7AA, + 0xA7AB, + 0xA7AC, + 0xA7AD, + 0xA7AE, + 0xA7AF, + 0xA7B0, + 0xA7B1, + 0xA7B2, + 0xA7B3, + 0xA7B4, + 0xA7B5, + 0xA7B6, + 0xA7B7, + 0xA7B8, + 0xA7B9, + 0xA7BA, + 0xA7BB, + 0xA7BC, + 0xA7BD, + 0xA7BE, + 0xA7BF, + 0xA7C0, + 0xA7C1, + 0xA7C2, + 0xA7C3, + 0xA7C4, + 0xA7C5, + 0xA7C6, + 0xA7C7, + 0xA7C8, + 0xA7C9, + 0xA7CA, + 0xA7CB, + 0xA7CC, + 0xA7CD, + 0xA7CE, + 0xA7CF, + 0xA7D0, + 0xA7D1, + 0xA7D2, + 0xA7D3, + 0xA7D4, + 0xA7D5, + 0xA7D6, + 0xA7D7, + 0xA7D8, + 0xA7D9, + 0xA7DA, + 0xA7DB, + 0xA7DC, + 0xA7DD, + 0xA7F1, + 0xA7F2, + 0xA7F3, + 0xA7F4, + 0xA7F5, + 0xA7F6, + 0xA7F8, + 0xA7F9, + 0xA7FA, + 0xA82D, + 0xA830, + 0xA83A, + 0xA840, + 0xA878, + 0xA880, + 0xA8C6, + 0xA8CE, + 0xA8DA, + 0xA8E0, + 0xA954, + 0xA95F, + 0xA97D, + 0xA980, + 0xA9CE, + 0xA9CF, + 0xA9DA, + 0xA9DE, + 0xA9FF, + 0xAA00, + 0xAA37, + 0xAA40, + 0xAA4E, + 0xAA50, + 0xAA5A, + 0xAA5C, + 0xAAC3, + 0xAADB, + 0xAAF7, + 0xAB01, + 0xAB07, + 0xAB09, + 0xAB0F, + 0xAB11, + 0xAB17, + 0xAB20, + 0xAB27, + 0xAB28, + 0xAB2F, + 0xAB30, + 0xAB5C, + 0xAB5D, + 0xAB5E, + 0xAB5F, + 0xAB60, + 0xAB69, + 0xAB6A, + 0xAB6C, + 0xAB70, + 0xAB71, + 0xAB72, + 0xAB73, + 0xAB74, + 0xAB75, + 0xAB76, + 0xAB77, + 0xAB78, + 0xAB79, + 0xAB7A, + 0xAB7B, + 0xAB7C, + 0xAB7D, + 0xAB7E, + 0xAB7F, + 0xAB80, + 0xAB81, + 0xAB82, + 0xAB83, + 0xAB84, + 0xAB85, + 0xAB86, + 0xAB87, + 0xAB88, + 0xAB89, + 0xAB8A, + 0xAB8B, + 0xAB8C, + 0xAB8D, + 0xAB8E, + 0xAB8F, + 0xAB90, + 0xAB91, + 0xAB92, + 0xAB93, + 0xAB94, + 0xAB95, + 0xAB96, + 0xAB97, + 0xAB98, + 0xAB99, + 0xAB9A, + 0xAB9B, + 0xAB9C, + 0xAB9D, + 0xAB9E, + 0xAB9F, + 0xABA0, + 0xABA1, + 0xABA2, + 0xABA3, + 0xABA4, + 0xABA5, + 0xABA6, + 0xABA7, + 0xABA8, + 0xABA9, + 0xABAA, + 0xABAB, + 0xABAC, + 0xABAD, + 0xABAE, + 0xABAF, + 0xABB0, + 0xABB1, + 0xABB2, + 0xABB3, + 0xABB4, + 0xABB5, + 0xABB6, + 0xABB7, + 0xABB8, + 0xABB9, + 0xABBA, + 0xABBB, + 0xABBC, + 0xABBD, + 0xABBE, + 0xABBF, + 0xABC0, + 0xABEE, + 0xABF0, + 0xABFA, + 0xAC00, + 0xD7A4, + 0xD7B0, + 0xD7C7, + 0xD7CB, + 0xD7FC, + 0xF900, + 0xF901, + 0xF902, + 0xF903, + 0xF904, + 0xF905, + 0xF906, + 0xF907, + 0xF909, + 0xF90A, + 0xF90B, + 0xF90C, + 0xF90D, + 0xF90E, + 0xF90F, + 0xF910, + 0xF911, + 0xF912, + 0xF913, + 0xF914, + 0xF915, + 0xF916, + 0xF917, + 0xF918, + 0xF919, + 0xF91A, + 0xF91B, + 0xF91C, + 0xF91D, + 0xF91E, + 0xF91F, + 0xF920, + 0xF921, + 0xF922, + 0xF923, + 0xF924, + 0xF925, + 0xF926, + 0xF927, + 0xF928, + 0xF929, + 0xF92A, + 0xF92B, + 0xF92C, + 0xF92D, + 0xF92E, + 0xF92F, + 0xF930, + 0xF931, + 0xF932, + 0xF933, + 0xF934, + 0xF935, + 0xF936, + 0xF937, + 0xF938, + 0xF939, + 0xF93A, + 0xF93B, + 0xF93C, + 0xF93D, + 0xF93E, + 0xF93F, + 0xF940, + 0xF941, + 0xF942, + 0xF943, + 0xF944, + 0xF945, + 0xF946, + 0xF947, + 0xF948, + 0xF949, + 0xF94A, + 0xF94B, + 0xF94C, + 0xF94D, + 0xF94E, + 0xF94F, + 0xF950, + 0xF951, + 0xF952, + 0xF953, + 0xF954, + 0xF955, + 0xF956, + 0xF957, + 0xF958, + 0xF959, + 0xF95A, + 0xF95B, + 0xF95C, + 0xF95D, + 0xF95E, + 0xF95F, + 0xF960, + 0xF961, + 0xF962, + 0xF963, + 0xF964, + 0xF965, + 0xF966, + 0xF967, + 0xF968, + 0xF969, + 0xF96A, + 0xF96B, + 0xF96C, + 0xF96D, + 0xF96E, + 0xF96F, + 0xF970, + 0xF971, + 0xF972, + 0xF973, + 0xF974, + 0xF975, + 0xF976, + 0xF977, + 0xF978, + 0xF979, + 0xF97A, + 0xF97B, + 0xF97C, + 0xF97D, + 0xF97E, + 0xF97F, + 0xF980, + 0xF981, + 0xF982, + 0xF983, + 0xF984, + 0xF985, + 0xF986, + 0xF987, + 0xF988, + 0xF989, + 0xF98A, + 0xF98B, + 0xF98C, + 0xF98D, + 0xF98E, + 0xF98F, + 0xF990, + 0xF991, + 0xF992, + 0xF993, + 0xF994, + 0xF995, + 0xF996, + 0xF997, + 0xF998, + 0xF999, + 0xF99A, + 0xF99B, + 0xF99C, + 0xF99D, + 0xF99E, + 0xF99F, + 0xF9A0, + 0xF9A1, + 0xF9A2, + 0xF9A3, + 0xF9A4, + 0xF9A5, + 0xF9A6, + 0xF9A7, + 0xF9A8, + 0xF9A9, + 0xF9AA, + 0xF9AB, + 0xF9AC, + 0xF9AD, + 0xF9AE, + 0xF9AF, + 0xF9B0, + 0xF9B1, + 0xF9B2, + 0xF9B3, + 0xF9B4, + 0xF9B5, + 0xF9B6, + 0xF9B7, + 0xF9B8, + 0xF9B9, + 0xF9BA, + 0xF9BB, + 0xF9BC, + 0xF9BD, + 0xF9BE, + 0xF9BF, + 0xF9C0, + 0xF9C1, + 0xF9C2, + 0xF9C3, + 0xF9C4, + 0xF9C5, + 0xF9C6, + 0xF9C7, + 0xF9C8, + 0xF9C9, + 0xF9CA, + 0xF9CB, + 0xF9CC, + 0xF9CD, + 0xF9CE, + 0xF9CF, + 0xF9D0, + 0xF9D1, + 0xF9D2, + 0xF9D3, + 0xF9D4, + 0xF9D5, + 0xF9D6, + 0xF9D7, + 0xF9D8, + 0xF9D9, + 0xF9DA, + 0xF9DB, + 0xF9DC, + 0xF9DD, + 0xF9DE, + 0xF9DF, + 0xF9E0, + 0xF9E1, + 0xF9E2, + 0xF9E3, + 0xF9E4, + 0xF9E5, + 0xF9E6, + 0xF9E7, + 0xF9E8, + 0xF9E9, + 0xF9EA, + 0xF9EB, + 0xF9EC, + 0xF9ED, + 0xF9EE, + 0xF9EF, + 0xF9F0, + 0xF9F1, + 0xF9F2, + 0xF9F3, + 0xF9F4, + 0xF9F5, + 0xF9F6, + 0xF9F7, + 0xF9F8, + 0xF9F9, + 0xF9FA, + 0xF9FB, + 0xF9FC, + 0xF9FD, + 0xF9FE, + 0xF9FF, + 0xFA00, + 0xFA01, + 0xFA02, + 0xFA03, + 0xFA04, + 0xFA05, + 0xFA06, + 0xFA07, + 0xFA08, + 0xFA09, + 0xFA0A, + 0xFA0B, + 0xFA0C, + 0xFA0D, + 0xFA0E, + 0xFA10, + 0xFA11, + 0xFA12, + 0xFA13, + 0xFA15, + 0xFA16, + 0xFA17, + 0xFA18, + 0xFA19, + 0xFA1A, + 0xFA1B, + 0xFA1C, + 0xFA1D, + 0xFA1E, + 0xFA1F, + 0xFA20, + 0xFA21, + 0xFA22, + 0xFA23, + 0xFA25, + 0xFA26, + 0xFA27, + 0xFA2A, + 0xFA2B, + 0xFA2C, + 0xFA2D, + 0xFA2E, + 0xFA2F, + 0xFA30, + 0xFA31, + 0xFA32, + 0xFA33, + 0xFA34, + 0xFA35, + 0xFA36, + 0xFA37, + 0xFA38, + 0xFA39, + 0xFA3A, + 0xFA3B, + 0xFA3C, + 0xFA3D, + 0xFA3E, + 0xFA3F, + 0xFA40, + 0xFA41, + 0xFA42, + 0xFA43, + 0xFA44, + 0xFA45, + 0xFA46, + 0xFA47, + 0xFA48, + 0xFA49, + 0xFA4A, + 0xFA4B, + 0xFA4C, + 0xFA4D, + 0xFA4E, + 0xFA4F, + 0xFA50, + 0xFA51, + 0xFA52, + 0xFA53, + 0xFA54, + 0xFA55, + 0xFA56, + 0xFA57, + 0xFA58, + 0xFA59, + 0xFA5A, + 0xFA5B, + 0xFA5C, + 0xFA5D, + 0xFA5F, + 0xFA60, + 0xFA61, + 0xFA62, + 0xFA63, + 0xFA64, + 0xFA65, + 0xFA66, + 0xFA67, + 0xFA68, + 0xFA69, + 0xFA6A, + 0xFA6B, + 0xFA6C, + 0xFA6D, + 0xFA6E, + 0xFA70, + 0xFA71, + 0xFA72, + 0xFA73, + 0xFA74, + 0xFA75, + 0xFA76, + 0xFA77, + 0xFA78, + 0xFA79, + 0xFA7A, + 0xFA7B, + 0xFA7C, + 0xFA7D, + 0xFA7E, + 0xFA7F, + 0xFA80, + 0xFA81, + 0xFA82, + 0xFA83, + 0xFA84, + 0xFA85, + 0xFA86, + 0xFA87, + 0xFA88, + 0xFA89, + 0xFA8A, + 0xFA8B, + 0xFA8C, + 0xFA8D, + 0xFA8E, + 0xFA8F, + 0xFA90, + 0xFA91, + 0xFA92, + 0xFA93, + 0xFA94, + 0xFA95, + 0xFA96, + 0xFA97, + 0xFA98, + 0xFA99, + 0xFA9A, + 0xFA9B, + 0xFA9C, + 0xFA9D, + 0xFA9E, + 0xFA9F, + 0xFAA0, + 0xFAA1, + 0xFAA2, + 0xFAA3, + 0xFAA4, + 0xFAA5, + 0xFAA6, + 0xFAA7, + 0xFAA8, + 0xFAA9, + 0xFAAA, + 0xFAAB, + 0xFAAC, + 0xFAAD, + 0xFAAE, + 0xFAAF, + 0xFAB0, + 0xFAB1, + 0xFAB2, + 0xFAB3, + 0xFAB4, + 0xFAB5, + 0xFAB6, + 0xFAB7, + 0xFAB8, + 0xFAB9, + 0xFABA, + 0xFABB, + 0xFABC, + 0xFABD, + 0xFABE, + 0xFABF, + 0xFAC0, + 0xFAC1, + 0xFAC2, + 0xFAC3, + 0xFAC4, + 0xFAC5, + 0xFAC6, + 0xFAC7, + 0xFAC8, + 0xFAC9, + 0xFACA, + 0xFACB, + 0xFACC, + 0xFACD, + 0xFACE, + 0xFACF, + 0xFAD0, + 0xFAD1, + 0xFAD2, + 0xFAD3, + 0xFAD4, + 0xFAD5, + 0xFAD6, + 0xFAD7, + 0xFAD8, + 0xFAD9, + 0xFADA, + 0xFB00, + 0xFB01, + 0xFB02, + 0xFB03, + 0xFB04, + 0xFB05, + 0xFB07, + 0xFB13, + 0xFB14, + 0xFB15, + 0xFB16, + 0xFB17, + 0xFB18, + 0xFB1D, + 0xFB1E, + 0xFB1F, + 0xFB20, + 0xFB21, + 0xFB22, + 0xFB23, + 0xFB24, + 0xFB25, + 0xFB26, + 0xFB27, + 0xFB28, + 0xFB29, + 0xFB2A, + 0xFB2B, + 0xFB2C, + 0xFB2D, + 0xFB2E, + 0xFB2F, + 0xFB30, + 0xFB31, + 0xFB32, + 0xFB33, + 0xFB34, + 0xFB35, + 0xFB36, + 0xFB37, + 0xFB38, + 0xFB39, + 0xFB3A, + 0xFB3B, + 0xFB3C, + 0xFB3D, + 0xFB3E, + 0xFB3F, + 0xFB40, + 0xFB41, + 0xFB42, + 0xFB43, + 0xFB44, + 0xFB45, + 0xFB46, + 0xFB47, + 0xFB48, + 0xFB49, + 0xFB4A, + 0xFB4B, + 0xFB4C, + 0xFB4D, + 0xFB4E, + 0xFB4F, + 0xFB50, + 0xFB52, + 0xFB56, + 0xFB5A, + 0xFB5E, + 0xFB62, + 0xFB66, + 0xFB6A, + 0xFB6E, + 0xFB72, + 0xFB76, + 0xFB7A, + 0xFB7E, + 0xFB82, + 0xFB84, + 0xFB86, + 0xFB88, + 0xFB8A, + 0xFB8C, + 0xFB8E, + 0xFB92, + 0xFB96, + 0xFB9A, + 0xFB9E, + 0xFBA0, + 0xFBA4, + 0xFBA6, + 0xFBAA, + 0xFBAE, + 0xFBB0, + 0xFBB2, + 0xFBD3, + 0xFBD7, + 0xFBD9, + 0xFBDB, + 0xFBDD, + 0xFBDE, + 0xFBE0, + 0xFBE2, + 0xFBE4, + 0xFBE8, + 0xFBEA, + 0xFBEC, + 0xFBEE, + 0xFBF0, + 0xFBF2, + 0xFBF4, + 0xFBF6, + 0xFBF9, + 0xFBFC, + 0xFC00, + 0xFC01, + 0xFC02, + 0xFC03, + 0xFC04, + 0xFC05, + 0xFC06, + 0xFC07, + 0xFC08, + 0xFC09, + 0xFC0A, + 0xFC0B, + 0xFC0C, + 0xFC0D, + 0xFC0E, + 0xFC0F, + 0xFC10, + 0xFC11, + 0xFC12, + 0xFC13, + 0xFC14, + 0xFC15, + 0xFC16, + 0xFC17, + 0xFC18, + 0xFC19, + 0xFC1A, + 0xFC1B, + 0xFC1C, + 0xFC1D, + 0xFC1E, + 0xFC1F, + 0xFC20, + 0xFC21, + 0xFC22, + 0xFC23, + 0xFC24, + 0xFC25, + 0xFC26, + 0xFC27, + 0xFC28, + 0xFC29, + 0xFC2A, + 0xFC2B, + 0xFC2C, + 0xFC2D, + 0xFC2E, + 0xFC2F, + 0xFC30, + 0xFC31, + 0xFC32, + 0xFC33, + 0xFC34, + 0xFC35, + 0xFC36, + 0xFC37, + 0xFC38, + 0xFC39, + 0xFC3A, + 0xFC3B, + 0xFC3C, + 0xFC3D, + 0xFC3E, + 0xFC3F, + 0xFC40, + 0xFC41, + 0xFC42, + 0xFC43, + 0xFC44, + 0xFC45, + 0xFC46, + 0xFC47, + 0xFC48, + 0xFC49, + 0xFC4A, + 0xFC4B, + 0xFC4C, + 0xFC4D, + 0xFC4E, + 0xFC4F, + 0xFC50, + 0xFC51, + 0xFC52, + 0xFC53, + 0xFC54, + 0xFC55, + 0xFC56, + 0xFC57, + 0xFC58, + 0xFC59, + 0xFC5A, + 0xFC5B, + 0xFC5C, + 0xFC5D, + 0xFC5E, + 0xFC5F, + 0xFC60, + 0xFC61, + 0xFC62, + 0xFC63, + 0xFC64, + 0xFC65, + 0xFC66, + 0xFC67, + 0xFC68, + 0xFC69, + 0xFC6A, + 0xFC6B, + 0xFC6C, + 0xFC6D, + 0xFC6E, + 0xFC6F, + 0xFC70, + 0xFC71, + 0xFC72, + 0xFC73, + 0xFC74, + 0xFC75, + 0xFC76, + 0xFC77, + 0xFC78, + 0xFC79, + 0xFC7A, + 0xFC7B, + 0xFC7C, + 0xFC7D, + 0xFC7E, + 0xFC7F, + 0xFC80, + 0xFC81, + 0xFC82, + 0xFC83, + 0xFC84, + 0xFC85, + 0xFC86, + 0xFC87, + 0xFC88, + 0xFC89, + 0xFC8A, + 0xFC8B, + 0xFC8C, + 0xFC8D, + 0xFC8E, + 0xFC8F, + 0xFC90, + 0xFC91, + 0xFC92, + 0xFC93, + 0xFC94, + 0xFC95, + 0xFC96, + 0xFC97, + 0xFC98, + 0xFC99, + 0xFC9A, + 0xFC9B, + 0xFC9C, + 0xFC9D, + 0xFC9E, + 0xFC9F, + 0xFCA0, + 0xFCA1, + 0xFCA2, + 0xFCA3, + 0xFCA4, + 0xFCA5, + 0xFCA6, + 0xFCA7, + 0xFCA8, + 0xFCA9, + 0xFCAA, + 0xFCAB, + 0xFCAC, + 0xFCAD, + 0xFCAE, + 0xFCAF, + 0xFCB0, + 0xFCB1, + 0xFCB2, + 0xFCB3, + 0xFCB4, + 0xFCB5, + 0xFCB6, + 0xFCB7, + 0xFCB8, + 0xFCB9, + 0xFCBA, + 0xFCBB, + 0xFCBC, + 0xFCBD, + 0xFCBE, + 0xFCBF, + 0xFCC0, + 0xFCC1, + 0xFCC2, + 0xFCC3, + 0xFCC4, + 0xFCC5, + 0xFCC6, + 0xFCC7, + 0xFCC8, + 0xFCC9, + 0xFCCA, + 0xFCCB, + 0xFCCC, + 0xFCCD, + 0xFCCE, + 0xFCCF, + 0xFCD0, + 0xFCD1, + 0xFCD2, + 0xFCD3, + 0xFCD4, + 0xFCD5, + 0xFCD6, + 0xFCD7, + 0xFCD8, + 0xFCD9, + 0xFCDA, + 0xFCDB, + 0xFCDC, + 0xFCDD, + 0xFCDE, + 0xFCDF, + 0xFCE0, + 0xFCE1, + 0xFCE2, + 0xFCE3, + 0xFCE4, + 0xFCE5, + 0xFCE6, + 0xFCE7, + 0xFCE8, + 0xFCE9, + 0xFCEA, + 0xFCEB, + 0xFCEC, + 0xFCED, + 0xFCEE, + 0xFCEF, + 0xFCF0, + 0xFCF1, + 0xFCF2, + 0xFCF3, + 0xFCF4, + 0xFCF5, + 0xFCF6, + 0xFCF7, + 0xFCF8, + 0xFCF9, + 0xFCFA, + 0xFCFB, + 0xFCFC, + 0xFCFD, + 0xFCFE, + 0xFCFF, + 0xFD00, + 0xFD01, + 0xFD02, + 0xFD03, + 0xFD04, + 0xFD05, + 0xFD06, + 0xFD07, + 0xFD08, + 0xFD09, + 0xFD0A, + 0xFD0B, + 0xFD0C, + 0xFD0D, + 0xFD0E, + 0xFD0F, + 0xFD10, + 0xFD11, + 0xFD12, + 0xFD13, + 0xFD14, + 0xFD15, + 0xFD16, + 0xFD17, + 0xFD18, + 0xFD19, + 0xFD1A, + 0xFD1B, + 0xFD1C, + 0xFD1D, + 0xFD1E, + 0xFD1F, + 0xFD20, + 0xFD21, + 0xFD22, + 0xFD23, + 0xFD24, + 0xFD25, + 0xFD26, + 0xFD27, + 0xFD28, + 0xFD29, + 0xFD2A, + 0xFD2B, + 0xFD2C, + 0xFD2D, + 0xFD2E, + 0xFD2F, + 0xFD30, + 0xFD31, + 0xFD32, + 0xFD33, + 0xFD34, + 0xFD35, + 0xFD36, + 0xFD37, + 0xFD38, + 0xFD39, + 0xFD3A, + 0xFD3B, + 0xFD3C, + 0xFD3E, + 0xFD50, + 0xFD51, + 0xFD53, + 0xFD54, + 0xFD55, + 0xFD56, + 0xFD57, + 0xFD58, + 0xFD5A, + 0xFD5B, + 0xFD5C, + 0xFD5D, + 0xFD5E, + 0xFD5F, + 0xFD61, + 0xFD62, + 0xFD64, + 0xFD66, + 0xFD67, + 0xFD69, + 0xFD6A, + 0xFD6C, + 0xFD6E, + 0xFD6F, + 0xFD71, + 0xFD73, + 0xFD74, + 0xFD75, + 0xFD76, + 0xFD78, + 0xFD79, + 0xFD7A, + 0xFD7B, + 0xFD7C, + 0xFD7E, + 0xFD7F, + 0xFD80, + 0xFD81, + 0xFD82, + 0xFD83, + 0xFD85, + 0xFD87, + 0xFD89, + 0xFD8A, + 0xFD8B, + 0xFD8C, + 0xFD8D, + 0xFD8E, + 0xFD8F, + 0xFD90, + 0xFD92, + 0xFD93, + 0xFD94, + 0xFD95, + 0xFD96, + 0xFD97, + 0xFD99, + 0xFD9A, + 0xFD9B, + 0xFD9C, + 0xFD9E, + 0xFD9F, + 0xFDA0, + 0xFDA1, + 0xFDA2, + 0xFDA3, + 0xFDA4, + 0xFDA5, + 0xFDA6, + 0xFDA7, + 0xFDA8, + 0xFDA9, + 0xFDAA, + 0xFDAB, + 0xFDAC, + 0xFDAD, + 0xFDAE, + 0xFDAF, + 0xFDB0, + 0xFDB1, + 0xFDB2, + 0xFDB3, + 0xFDB4, + 0xFDB5, + 0xFDB6, + 0xFDB7, + 0xFDB8, + 0xFDB9, + 0xFDBA, + 0xFDBB, + 0xFDBC, + 0xFDBD, + 0xFDBE, + 0xFDBF, + 0xFDC0, + 0xFDC1, + 0xFDC2, + 0xFDC3, + 0xFDC4, + 0xFDC5, + 0xFDC6, + 0xFDC7, + 0xFDC8, + 0xFDD0, + 0xFDF0, + 0xFDF1, + 0xFDF2, + 0xFDF3, + 0xFDF4, + 0xFDF5, + 0xFDF6, + 0xFDF7, + 0xFDF8, + 0xFDF9, + 0xFDFA, + 0xFDFB, + 0xFDFC, + 0xFDFD, + 0xFE00, + 0xFE10, + 0xFE11, + 0xFE12, + 0xFE13, + 0xFE14, + 0xFE15, + 0xFE16, + 0xFE17, + 0xFE18, + 0xFE19, + 0xFE20, + 0xFE30, + 0xFE31, + 0xFE32, + 0xFE33, + 0xFE35, + 0xFE36, + 0xFE37, + 0xFE38, + 0xFE39, + 0xFE3A, + 0xFE3B, + 0xFE3C, + 0xFE3D, + 0xFE3E, + 0xFE3F, + 0xFE40, + 0xFE41, + 0xFE42, + 0xFE43, + 0xFE44, + 0xFE45, + 0xFE47, + 0xFE48, + 0xFE49, + 0xFE4D, + 0xFE50, + 0xFE51, + 0xFE52, + 0xFE54, + 0xFE55, + 0xFE56, + 0xFE57, + 0xFE58, + 0xFE59, + 0xFE5A, + 0xFE5B, + 0xFE5C, + 0xFE5D, + 0xFE5E, + 0xFE5F, + 0xFE60, + 0xFE61, + 0xFE62, + 0xFE63, + 0xFE64, + 0xFE65, + 0xFE66, + 0xFE67, + 0xFE68, + 0xFE69, + 0xFE6A, + 0xFE6B, + 0xFE6C, + 0xFE70, + 0xFE71, + 0xFE72, + 0xFE73, + 0xFE74, + 0xFE75, + 0xFE76, + 0xFE77, + 0xFE78, + 0xFE79, + 0xFE7A, + 0xFE7B, + 0xFE7C, + 0xFE7D, + 0xFE7E, + 0xFE7F, + 0xFE80, + 0xFE81, + 0xFE83, + 0xFE85, + 0xFE87, + 0xFE89, + 0xFE8D, + 0xFE8F, + 0xFE93, + 0xFE95, + 0xFE99, + 0xFE9D, + 0xFEA1, + 0xFEA5, + 0xFEA9, + 0xFEAB, + 0xFEAD, + 0xFEAF, + 0xFEB1, + 0xFEB5, + 0xFEB9, + 0xFEBD, + 0xFEC1, + 0xFEC5, + 0xFEC9, + 0xFECD, + 0xFED1, + 0xFED5, + 0xFED9, + 0xFEDD, + 0xFEE1, + 0xFEE5, + 0xFEE9, + 0xFEED, + 0xFEEF, + 0xFEF1, + 0xFEF5, + 0xFEF7, + 0xFEF9, + 0xFEFB, + 0xFEFD, + 0xFEFF, + 0xFF00, + 0xFF01, + 0xFF02, + 0xFF03, + 0xFF04, + 0xFF05, + 0xFF06, + 0xFF07, + 0xFF08, + 0xFF09, + 0xFF0A, + 0xFF0B, + 0xFF0C, + 0xFF0D, + 0xFF0E, + 0xFF0F, + 0xFF10, + 0xFF11, + 0xFF12, + 0xFF13, + 0xFF14, + 0xFF15, + 0xFF16, + 0xFF17, + 0xFF18, + 0xFF19, + 0xFF1A, + 0xFF1B, + 0xFF1C, + 0xFF1D, + 0xFF1E, + 0xFF1F, + 0xFF20, + 0xFF21, + 0xFF22, + 0xFF23, + 0xFF24, + 0xFF25, + 0xFF26, + 0xFF27, + 0xFF28, + 0xFF29, + 0xFF2A, + 0xFF2B, + 0xFF2C, + 0xFF2D, + 0xFF2E, + 0xFF2F, + 0xFF30, + 0xFF31, + 0xFF32, + 0xFF33, + 0xFF34, + 0xFF35, + 0xFF36, + 0xFF37, + 0xFF38, + 0xFF39, + 0xFF3A, + 0xFF3B, + 0xFF3C, + 0xFF3D, + 0xFF3E, + 0xFF3F, + 0xFF40, + 0xFF41, + 0xFF42, + 0xFF43, + 0xFF44, + 0xFF45, + 0xFF46, + 0xFF47, + 0xFF48, + 0xFF49, + 0xFF4A, + 0xFF4B, + 0xFF4C, + 0xFF4D, + 0xFF4E, + 0xFF4F, + 0xFF50, + 0xFF51, + 0xFF52, + 0xFF53, + 0xFF54, + 0xFF55, + 0xFF56, + 0xFF57, + 0xFF58, + 0xFF59, + 0xFF5A, + 0xFF5B, + 0xFF5C, + 0xFF5D, + 0xFF5E, + 0xFF5F, + 0xFF60, + 0xFF61, + 0xFF62, + 0xFF63, + 0xFF64, + 0xFF65, + 0xFF66, + 0xFF67, + 0xFF68, + 0xFF69, + 0xFF6A, + 0xFF6B, + 0xFF6C, + 0xFF6D, + 0xFF6E, + 0xFF6F, + 0xFF70, + 0xFF71, + 0xFF72, + 0xFF73, + 0xFF74, + 0xFF75, + 0xFF76, + 0xFF77, + 0xFF78, + 0xFF79, + 0xFF7A, + 0xFF7B, + 0xFF7C, + 0xFF7D, + 0xFF7E, + 0xFF7F, + 0xFF80, + 0xFF81, + 0xFF82, + 0xFF83, + 0xFF84, + 0xFF85, + 0xFF86, + 0xFF87, + 0xFF88, + 0xFF89, + 0xFF8A, + 0xFF8B, + 0xFF8C, + 0xFF8D, + 0xFF8E, + 0xFF8F, + 0xFF90, + 0xFF91, + 0xFF92, + 0xFF93, + 0xFF94, + 0xFF95, + 0xFF96, + 0xFF97, + 0xFF98, + 0xFF99, + 0xFF9A, + 0xFF9B, + 0xFF9C, + 0xFF9D, + 0xFF9E, + 0xFF9F, + 0xFFA0, + 0xFFA1, + 0xFFA2, + 0xFFA3, + 0xFFA4, + 0xFFA5, + 0xFFA6, + 0xFFA7, + 0xFFA8, + 0xFFA9, + 0xFFAA, + 0xFFAB, + 0xFFAC, + 0xFFAD, + 0xFFAE, + 0xFFAF, + 0xFFB0, + 0xFFB1, + 0xFFB2, + 0xFFB3, + 0xFFB4, + 0xFFB5, + 0xFFB6, + 0xFFB7, + 0xFFB8, + 0xFFB9, + 0xFFBA, + 0xFFBB, + 0xFFBC, + 0xFFBD, + 0xFFBE, + 0xFFBF, + 0xFFC2, + 0xFFC3, + 0xFFC4, + 0xFFC5, + 0xFFC6, + 0xFFC7, + 0xFFC8, + 0xFFCA, + 0xFFCB, + 0xFFCC, + 0xFFCD, + 0xFFCE, + 0xFFCF, + 0xFFD0, + 0xFFD2, + 0xFFD3, + 0xFFD4, + 0xFFD5, + 0xFFD6, + 0xFFD7, + 0xFFD8, + 0xFFDA, + 0xFFDB, + 0xFFDC, + 0xFFDD, + 0xFFE0, + 0xFFE1, + 0xFFE2, + 0xFFE3, + 0xFFE4, + 0xFFE5, + 0xFFE6, + 0xFFE7, + 0xFFE8, + 0xFFE9, + 0xFFEA, + 0xFFEB, + 0xFFEC, + 0xFFED, + 0xFFEE, + 0xFFEF, + 0x10000, + 0x1000C, + 0x1000D, + 0x10027, + 0x10028, + 0x1003B, + 0x1003C, + 0x1003E, + 0x1003F, + 0x1004E, + 0x10050, + 0x1005E, + 0x10080, + 0x100FB, + 0x10100, + 0x10103, + 0x10107, + 0x10134, + 0x10137, + 0x1018F, + 0x10190, + 0x1019D, + 0x101A0, + 0x101A1, + 0x101D0, + 0x101FE, + 0x10280, + 0x1029D, + 0x102A0, + 0x102D1, + 0x102E0, + 0x102FC, + 0x10300, + 0x10324, + 0x1032D, + 0x1034B, + 0x10350, + 0x1037B, + 0x10380, + 0x1039E, + 0x1039F, + 0x103C4, + 0x103C8, + 0x103D6, + 0x10400, + 0x10401, + 0x10402, + 0x10403, + 0x10404, + 0x10405, + 0x10406, + 0x10407, + 0x10408, + 0x10409, + 0x1040A, + 0x1040B, + 0x1040C, + 0x1040D, + 0x1040E, + 0x1040F, + 0x10410, + 0x10411, + 0x10412, + 0x10413, + 0x10414, + 0x10415, + 0x10416, + 0x10417, + 0x10418, + 0x10419, + 0x1041A, + 0x1041B, + 0x1041C, + 0x1041D, + 0x1041E, + 0x1041F, + 0x10420, + 0x10421, + 0x10422, + 0x10423, + 0x10424, + 0x10425, + 0x10426, + 0x10427, + 0x10428, + 0x1049E, + 0x104A0, + 0x104AA, + 0x104B0, + 0x104B1, + 0x104B2, + 0x104B3, + 0x104B4, + 0x104B5, + 0x104B6, + 0x104B7, + 0x104B8, + 0x104B9, + 0x104BA, + 0x104BB, + 0x104BC, + 0x104BD, + 0x104BE, + 0x104BF, + 0x104C0, + 0x104C1, + 0x104C2, + 0x104C3, + 0x104C4, + 0x104C5, + 0x104C6, + 0x104C7, + 0x104C8, + 0x104C9, + 0x104CA, + 0x104CB, + 0x104CC, + 0x104CD, + 0x104CE, + 0x104CF, + 0x104D0, + 0x104D1, + 0x104D2, + 0x104D3, + 0x104D4, + 0x104D8, + 0x104FC, + 0x10500, + 0x10528, + 0x10530, + 0x10564, + 0x1056F, + 0x10570, + 0x10571, + 0x10572, + 0x10573, + 0x10574, + 0x10575, + 0x10576, + 0x10577, + 0x10578, + 0x10579, + 0x1057A, + 0x1057B, + 0x1057C, + 0x1057D, + 0x1057E, + 0x1057F, + 0x10580, + 0x10581, + 0x10582, + 0x10583, + 0x10584, + 0x10585, + 0x10586, + 0x10587, + 0x10588, + 0x10589, + 0x1058A, + 0x1058B, + 0x1058C, + 0x1058D, + 0x1058E, + 0x1058F, + 0x10590, + 0x10591, + 0x10592, + 0x10593, + 0x10594, + 0x10595, + 0x10596, + 0x10597, + 0x105A2, + 0x105A3, + 0x105B2, + 0x105B3, + 0x105BA, + 0x105BB, + 0x105BD, + 0x105C0, + 0x105F4, + 0x10600, + 0x10737, + 0x10740, + 0x10756, + 0x10760, + 0x10768, + 0x10780, + 0x10781, + 0x10782, + 0x10783, + 0x10784, + 0x10785, + 0x10786, + 0x10787, + 0x10788, + 0x10789, + 0x1078A, + 0x1078B, + 0x1078C, + 0x1078D, + 0x1078E, + 0x1078F, + 0x10790, + 0x10791, + 0x10792, + 0x10793, + 0x10794, + 0x10795, + 0x10796, + 0x10797, + 0x10798, + 0x10799, + 0x1079A, + 0x1079B, + 0x1079C, + 0x1079D, + 0x1079E, + 0x1079F, + 0x107A0, + 0x107A1, + 0x107A2, + 0x107A3, + 0x107A4, + 0x107A5, + 0x107A6, + 0x107A7, + 0x107A8, + 0x107A9, + 0x107AA, + 0x107AB, + 0x107AC, + 0x107AD, + 0x107AE, + 0x107AF, + 0x107B0, + 0x107B1, + 0x107B2, + 0x107B3, + 0x107B4, + 0x107B5, + 0x107B6, + 0x107B7, + 0x107B8, + 0x107B9, + 0x107BA, + 0x107BB, + 0x10800, + 0x10806, + 0x10808, + 0x10809, + 0x1080A, + 0x10836, + 0x10837, + 0x10839, + 0x1083C, + 0x1083D, + 0x1083F, + 0x10856, + 0x10857, + 0x1089F, + 0x108A7, + 0x108B0, + 0x108E0, + 0x108F3, + 0x108F4, + 0x108F6, + 0x108FB, + 0x1091C, + 0x1091F, + 0x1093A, + 0x1093F, + 0x1095A, + 0x10980, + 0x109B8, + 0x109BC, + 0x109D0, + 0x109D2, + 0x10A04, + 0x10A05, + 0x10A07, + 0x10A0C, + 0x10A14, + 0x10A15, + 0x10A18, + 0x10A19, + 0x10A36, + 0x10A38, + 0x10A3B, + 0x10A3F, + 0x10A49, + 0x10A50, + 0x10A59, + 0x10A60, + 0x10AA0, + 0x10AC0, + 0x10AE7, + 0x10AEB, + 0x10AF7, + 0x10B00, + 0x10B36, + 0x10B39, + 0x10B56, + 0x10B58, + 0x10B73, + 0x10B78, + 0x10B92, + 0x10B99, + 0x10B9D, + 0x10BA9, + 0x10BB0, + 0x10C00, + 0x10C49, + 0x10C80, + 0x10C81, + 0x10C82, + 0x10C83, + 0x10C84, + 0x10C85, + 0x10C86, + 0x10C87, + 0x10C88, + 0x10C89, + 0x10C8A, + 0x10C8B, + 0x10C8C, + 0x10C8D, + 0x10C8E, + 0x10C8F, + 0x10C90, + 0x10C91, + 0x10C92, + 0x10C93, + 0x10C94, + 0x10C95, + 0x10C96, + 0x10C97, + 0x10C98, + 0x10C99, + 0x10C9A, + 0x10C9B, + 0x10C9C, + 0x10C9D, + 0x10C9E, + 0x10C9F, + 0x10CA0, + 0x10CA1, + 0x10CA2, + 0x10CA3, + 0x10CA4, + 0x10CA5, + 0x10CA6, + 0x10CA7, + 0x10CA8, + 0x10CA9, + 0x10CAA, + 0x10CAB, + 0x10CAC, + 0x10CAD, + 0x10CAE, + 0x10CAF, + 0x10CB0, + 0x10CB1, + 0x10CB2, + 0x10CB3, + 0x10CC0, + 0x10CF3, + 0x10CFA, + 0x10D28, + 0x10D30, + 0x10D3A, + 0x10D40, + 0x10D50, + 0x10D51, + 0x10D52, + 0x10D53, + 0x10D54, + 0x10D55, + 0x10D56, + 0x10D57, + 0x10D58, + 0x10D59, + 0x10D5A, + 0x10D5B, + 0x10D5C, + 0x10D5D, + 0x10D5E, + 0x10D5F, + 0x10D60, + 0x10D61, + 0x10D62, + 0x10D63, + 0x10D64, + 0x10D65, + 0x10D66, + 0x10D69, + 0x10D86, + 0x10D8E, + 0x10D90, + 0x10E60, + 0x10E7F, + 0x10E80, + 0x10EAA, + 0x10EAB, + 0x10EAE, + 0x10EB0, + 0x10EB2, + 0x10EC2, + 0x10EC8, + 0x10ED0, + 0x10ED9, + 0x10EFA, + 0x10F28, + 0x10F30, + 0x10F5A, + 0x10F70, + 0x10F8A, + 0x10FB0, + 0x10FCC, + 0x10FE0, + 0x10FF7, + 0x11000, + 0x1104E, + 0x11052, + 0x11076, + 0x1107F, + 0x110BD, + 0x110BE, + 0x110C3, + 0x110D0, + 0x110E9, + 0x110F0, + 0x110FA, + 0x11100, + 0x11135, + 0x11136, + 0x11148, + 0x11150, + 0x11177, + 0x11180, + 0x111E0, + 0x111E1, + 0x111F5, + 0x11200, + 0x11212, + 0x11213, + 0x11242, + 0x11280, + 0x11287, + 0x11288, + 0x11289, + 0x1128A, + 0x1128E, + 0x1128F, + 0x1129E, + 0x1129F, + 0x112AA, + 0x112B0, + 0x112EB, + 0x112F0, + 0x112FA, + 0x11300, + 0x11304, + 0x11305, + 0x1130D, + 0x1130F, + 0x11311, + 0x11313, + 0x11329, + 0x1132A, + 0x11331, + 0x11332, + 0x11334, + 0x11335, + 0x1133A, + 0x1133B, + 0x11345, + 0x11347, + 0x11349, + 0x1134B, + 0x1134E, + 0x11350, + 0x11351, + 0x11357, + 0x11358, + 0x1135D, + 0x11364, + 0x11366, + 0x1136D, + 0x11370, + 0x11375, + 0x11380, + 0x1138A, + 0x1138B, + 0x1138C, + 0x1138E, + 0x1138F, + 0x11390, + 0x113B6, + 0x113B7, + 0x113C1, + 0x113C2, + 0x113C3, + 0x113C5, + 0x113C6, + 0x113C7, + 0x113CB, + 0x113CC, + 0x113D6, + 0x113D7, + 0x113D9, + 0x113E1, + 0x113E3, + 0x11400, + 0x1145C, + 0x1145D, + 0x11462, + 0x11480, + 0x114C8, + 0x114D0, + 0x114DA, + 0x11580, + 0x115B6, + 0x115B8, + 0x115DE, + 0x11600, + 0x11645, + 0x11650, + 0x1165A, + 0x11660, + 0x1166D, + 0x11680, + 0x116BA, + 0x116C0, + 0x116CA, + 0x116D0, + 0x116E4, + 0x11700, + 0x1171B, + 0x1171D, + 0x1172C, + 0x11730, + 0x11747, + 0x11800, + 0x1183C, + 0x118A0, + 0x118A1, + 0x118A2, + 0x118A3, + 0x118A4, + 0x118A5, + 0x118A6, + 0x118A7, + 0x118A8, + 0x118A9, + 0x118AA, + 0x118AB, + 0x118AC, + 0x118AD, + 0x118AE, + 0x118AF, + 0x118B0, + 0x118B1, + 0x118B2, + 0x118B3, + 0x118B4, + 0x118B5, + 0x118B6, + 0x118B7, + 0x118B8, + 0x118B9, + 0x118BA, + 0x118BB, + 0x118BC, + 0x118BD, + 0x118BE, + 0x118BF, + 0x118C0, + 0x118F3, + 0x118FF, + 0x11907, + 0x11909, + 0x1190A, + 0x1190C, + 0x11914, + 0x11915, + 0x11917, + 0x11918, + 0x11936, + 0x11937, + 0x11939, + 0x1193B, + 0x11947, + 0x11950, + 0x1195A, + 0x119A0, + 0x119A8, + 0x119AA, + 0x119D8, + 0x119DA, + 0x119E5, + 0x11A00, + 0x11A48, + 0x11A50, + 0x11AA3, + 0x11AB0, + 0x11AF9, + 0x11B00, + 0x11B0A, + 0x11B60, + 0x11B68, + 0x11BC0, + 0x11BE2, + 0x11BF0, + 0x11BFA, + 0x11C00, + 0x11C09, + 0x11C0A, + 0x11C37, + 0x11C38, + 0x11C46, + 0x11C50, + 0x11C6D, + 0x11C70, + 0x11C90, + 0x11C92, + 0x11CA8, + 0x11CA9, + 0x11CB7, + 0x11D00, + 0x11D07, + 0x11D08, + 0x11D0A, + 0x11D0B, + 0x11D37, + 0x11D3A, + 0x11D3B, + 0x11D3C, + 0x11D3E, + 0x11D3F, + 0x11D48, + 0x11D50, + 0x11D5A, + 0x11D60, + 0x11D66, + 0x11D67, + 0x11D69, + 0x11D6A, + 0x11D8F, + 0x11D90, + 0x11D92, + 0x11D93, + 0x11D99, + 0x11DA0, + 0x11DAA, + 0x11DB0, + 0x11DDC, + 0x11DE0, + 0x11DEA, + 0x11EE0, + 0x11EF9, + 0x11F00, + 0x11F11, + 0x11F12, + 0x11F3B, + 0x11F3E, + 0x11F5B, + 0x11FB0, + 0x11FB1, + 0x11FC0, + 0x11FF2, + 0x11FFF, + 0x1239A, + 0x12400, + 0x1246F, + 0x12470, + 0x12475, + 0x12480, + 0x12544, + 0x12F90, + 0x12FF3, + 0x13000, + 0x13430, + 0x13440, + 0x13456, + 0x13460, + 0x143FB, + 0x14400, + 0x14647, + 0x16100, + 0x1613A, + 0x16800, + 0x16A39, + 0x16A40, + 0x16A5F, + 0x16A60, + 0x16A6A, + 0x16A6E, + 0x16ABF, + 0x16AC0, + 0x16ACA, + 0x16AD0, + 0x16AEE, + 0x16AF0, + 0x16AF6, + 0x16B00, + 0x16B46, + 0x16B50, + 0x16B5A, + 0x16B5B, + 0x16B62, + 0x16B63, + 0x16B78, + 0x16B7D, + 0x16B90, + 0x16D40, + 0x16D7A, + 0x16E40, + 0x16E41, + 0x16E42, + 0x16E43, + 0x16E44, + 0x16E45, + 0x16E46, + 0x16E47, + 0x16E48, + 0x16E49, + 0x16E4A, + 0x16E4B, + 0x16E4C, + 0x16E4D, + 0x16E4E, + 0x16E4F, + 0x16E50, + 0x16E51, + 0x16E52, + 0x16E53, + 0x16E54, + 0x16E55, + 0x16E56, + 0x16E57, + 0x16E58, + 0x16E59, + 0x16E5A, + 0x16E5B, + 0x16E5C, + 0x16E5D, + 0x16E5E, + 0x16E5F, + 0x16E60, + 0x16E9B, + 0x16EA0, + 0x16EA1, + 0x16EA2, + 0x16EA3, + 0x16EA4, + 0x16EA5, + 0x16EA6, + 0x16EA7, + 0x16EA8, + 0x16EA9, + 0x16EAA, + 0x16EAB, + 0x16EAC, + 0x16EAD, + 0x16EAE, + 0x16EAF, + 0x16EB0, + 0x16EB1, + 0x16EB2, + 0x16EB3, + 0x16EB4, + 0x16EB5, + 0x16EB6, + 0x16EB7, + 0x16EB8, + 0x16EB9, + 0x16EBB, + 0x16ED4, + 0x16F00, + 0x16F4B, + 0x16F4F, + 0x16F88, + 0x16F8F, + 0x16FA0, + 0x16FE0, + 0x16FE5, + 0x16FF0, + 0x16FF7, + 0x17000, + 0x18CD6, + 0x18CFF, + 0x18D1F, + 0x18D80, + 0x18DF3, + 0x1AFF0, + 0x1AFF4, + 0x1AFF5, + 0x1AFFC, + 0x1AFFD, + 0x1AFFF, + 0x1B000, + 0x1B123, + 0x1B132, + 0x1B133, + 0x1B150, + 0x1B153, + 0x1B155, + 0x1B156, + 0x1B164, + 0x1B168, + 0x1B170, + 0x1B2FC, + 0x1BC00, + 0x1BC6B, + 0x1BC70, + 0x1BC7D, + 0x1BC80, + 0x1BC89, + 0x1BC90, + 0x1BC9A, + 0x1BC9C, + 0x1BCA0, + 0x1BCA4, + 0x1CC00, + 0x1CCD6, + 0x1CCD7, + 0x1CCD8, + 0x1CCD9, + 0x1CCDA, + 0x1CCDB, + 0x1CCDC, + 0x1CCDD, + 0x1CCDE, + 0x1CCDF, + 0x1CCE0, + 0x1CCE1, + 0x1CCE2, + 0x1CCE3, + 0x1CCE4, + 0x1CCE5, + 0x1CCE6, + 0x1CCE7, + 0x1CCE8, + 0x1CCE9, + 0x1CCEA, + 0x1CCEB, + 0x1CCEC, + 0x1CCED, + 0x1CCEE, + 0x1CCEF, + 0x1CCF0, + 0x1CCF1, + 0x1CCF2, + 0x1CCF3, + 0x1CCF4, + 0x1CCF5, + 0x1CCF6, + 0x1CCF7, + 0x1CCF8, + 0x1CCF9, + 0x1CCFA, + 0x1CCFD, + 0x1CD00, + 0x1CEB4, + 0x1CEBA, + 0x1CED1, + 0x1CEE0, + 0x1CEF1, + 0x1CF00, + 0x1CF2E, + 0x1CF30, + 0x1CF47, + 0x1CF50, + 0x1CFC4, + 0x1D000, + 0x1D0F6, + 0x1D100, + 0x1D127, + 0x1D129, + 0x1D15E, + 0x1D15F, + 0x1D160, + 0x1D161, + 0x1D162, + 0x1D163, + 0x1D164, + 0x1D165, + 0x1D173, + 0x1D17B, + 0x1D1BB, + 0x1D1BC, + 0x1D1BD, + 0x1D1BE, + 0x1D1BF, + 0x1D1C0, + 0x1D1C1, + 0x1D1EB, + 0x1D200, + 0x1D246, + 0x1D2C0, + 0x1D2D4, + 0x1D2E0, + 0x1D2F4, + 0x1D300, + 0x1D357, + 0x1D360, + 0x1D379, + 0x1D400, + 0x1D401, + 0x1D402, + 0x1D403, + 0x1D404, + 0x1D405, + 0x1D406, + 0x1D407, + 0x1D408, + 0x1D409, + 0x1D40A, + 0x1D40B, + 0x1D40C, + 0x1D40D, + 0x1D40E, + 0x1D40F, + 0x1D410, + 0x1D411, + 0x1D412, + 0x1D413, + 0x1D414, + 0x1D415, + 0x1D416, + 0x1D417, + 0x1D418, + 0x1D419, + 0x1D41A, + 0x1D41B, + 0x1D41C, + 0x1D41D, + 0x1D41E, + 0x1D41F, + 0x1D420, + 0x1D421, + 0x1D422, + 0x1D423, + 0x1D424, + 0x1D425, + 0x1D426, + 0x1D427, + 0x1D428, + 0x1D429, + 0x1D42A, + 0x1D42B, + 0x1D42C, + 0x1D42D, + 0x1D42E, + 0x1D42F, + 0x1D430, + 0x1D431, + 0x1D432, + 0x1D433, + 0x1D434, + 0x1D435, + 0x1D436, + 0x1D437, + 0x1D438, + 0x1D439, + 0x1D43A, + 0x1D43B, + 0x1D43C, + 0x1D43D, + 0x1D43E, + 0x1D43F, + 0x1D440, + 0x1D441, + 0x1D442, + 0x1D443, + 0x1D444, + 0x1D445, + 0x1D446, + 0x1D447, + 0x1D448, + 0x1D449, + 0x1D44A, + 0x1D44B, + 0x1D44C, + 0x1D44D, + 0x1D44E, + 0x1D44F, + 0x1D450, + 0x1D451, + 0x1D452, + 0x1D453, + 0x1D454, + 0x1D455, + 0x1D456, + 0x1D457, + 0x1D458, + 0x1D459, + 0x1D45A, + 0x1D45B, + 0x1D45C, + 0x1D45D, + 0x1D45E, + 0x1D45F, + 0x1D460, + 0x1D461, + 0x1D462, + 0x1D463, + 0x1D464, + 0x1D465, + 0x1D466, + 0x1D467, + 0x1D468, + 0x1D469, + 0x1D46A, + 0x1D46B, + 0x1D46C, + 0x1D46D, + 0x1D46E, + 0x1D46F, + 0x1D470, + 0x1D471, + 0x1D472, + 0x1D473, + 0x1D474, + 0x1D475, + 0x1D476, + 0x1D477, + 0x1D478, + 0x1D479, + 0x1D47A, + 0x1D47B, + 0x1D47C, + 0x1D47D, + 0x1D47E, + 0x1D47F, + 0x1D480, + 0x1D481, + 0x1D482, + 0x1D483, + 0x1D484, + 0x1D485, + 0x1D486, + 0x1D487, + 0x1D488, + 0x1D489, + 0x1D48A, + 0x1D48B, + 0x1D48C, + 0x1D48D, + 0x1D48E, + 0x1D48F, + 0x1D490, + 0x1D491, + 0x1D492, + 0x1D493, + 0x1D494, + 0x1D495, + 0x1D496, + 0x1D497, + 0x1D498, + 0x1D499, + 0x1D49A, + 0x1D49B, + 0x1D49C, + 0x1D49D, + 0x1D49E, + 0x1D49F, + 0x1D4A0, + 0x1D4A2, + 0x1D4A3, + 0x1D4A5, + 0x1D4A6, + 0x1D4A7, + 0x1D4A9, + 0x1D4AA, + 0x1D4AB, + 0x1D4AC, + 0x1D4AD, + 0x1D4AE, + 0x1D4AF, + 0x1D4B0, + 0x1D4B1, + 0x1D4B2, + 0x1D4B3, + 0x1D4B4, + 0x1D4B5, + 0x1D4B6, + 0x1D4B7, + 0x1D4B8, + 0x1D4B9, + 0x1D4BA, + 0x1D4BB, + 0x1D4BC, + 0x1D4BD, + 0x1D4BE, + 0x1D4BF, + 0x1D4C0, + 0x1D4C1, + 0x1D4C2, + 0x1D4C3, + 0x1D4C4, + 0x1D4C5, + 0x1D4C6, + 0x1D4C7, + 0x1D4C8, + 0x1D4C9, + 0x1D4CA, + 0x1D4CB, + 0x1D4CC, + 0x1D4CD, + 0x1D4CE, + 0x1D4CF, + 0x1D4D0, + 0x1D4D1, + 0x1D4D2, + 0x1D4D3, + 0x1D4D4, + 0x1D4D5, + 0x1D4D6, + 0x1D4D7, + 0x1D4D8, + 0x1D4D9, + 0x1D4DA, + 0x1D4DB, + 0x1D4DC, + 0x1D4DD, + 0x1D4DE, + 0x1D4DF, + 0x1D4E0, + 0x1D4E1, + 0x1D4E2, + 0x1D4E3, + 0x1D4E4, + 0x1D4E5, + 0x1D4E6, + 0x1D4E7, + 0x1D4E8, + 0x1D4E9, + 0x1D4EA, + 0x1D4EB, + 0x1D4EC, + 0x1D4ED, + 0x1D4EE, + 0x1D4EF, + 0x1D4F0, + 0x1D4F1, + 0x1D4F2, + 0x1D4F3, + 0x1D4F4, + 0x1D4F5, + 0x1D4F6, + 0x1D4F7, + 0x1D4F8, + 0x1D4F9, + 0x1D4FA, + 0x1D4FB, + 0x1D4FC, + 0x1D4FD, + 0x1D4FE, + 0x1D4FF, + 0x1D500, + 0x1D501, + 0x1D502, + 0x1D503, + 0x1D504, + 0x1D505, + 0x1D506, + 0x1D507, + 0x1D508, + 0x1D509, + 0x1D50A, + 0x1D50B, + 0x1D50D, + 0x1D50E, + 0x1D50F, + 0x1D510, + 0x1D511, + 0x1D512, + 0x1D513, + 0x1D514, + 0x1D515, + 0x1D516, + 0x1D517, + 0x1D518, + 0x1D519, + 0x1D51A, + 0x1D51B, + 0x1D51C, + 0x1D51D, + 0x1D51E, + 0x1D51F, + 0x1D520, + 0x1D521, + 0x1D522, + 0x1D523, + 0x1D524, + 0x1D525, + 0x1D526, + 0x1D527, + 0x1D528, + 0x1D529, + 0x1D52A, + 0x1D52B, + 0x1D52C, + 0x1D52D, + 0x1D52E, + 0x1D52F, + 0x1D530, + 0x1D531, + 0x1D532, + 0x1D533, + 0x1D534, + 0x1D535, + 0x1D536, + 0x1D537, + 0x1D538, + 0x1D539, + 0x1D53A, + 0x1D53B, + 0x1D53C, + 0x1D53D, + 0x1D53E, + 0x1D53F, + 0x1D540, + 0x1D541, + 0x1D542, + 0x1D543, + 0x1D544, + 0x1D545, + 0x1D546, + 0x1D547, + 0x1D54A, + 0x1D54B, + 0x1D54C, + 0x1D54D, + 0x1D54E, + 0x1D54F, + 0x1D550, + 0x1D551, + 0x1D552, + 0x1D553, + 0x1D554, + 0x1D555, + 0x1D556, + 0x1D557, + 0x1D558, + 0x1D559, + 0x1D55A, + 0x1D55B, + 0x1D55C, + 0x1D55D, + 0x1D55E, + 0x1D55F, + 0x1D560, + 0x1D561, + 0x1D562, + 0x1D563, + 0x1D564, + 0x1D565, + 0x1D566, + 0x1D567, + 0x1D568, + 0x1D569, + 0x1D56A, + 0x1D56B, + 0x1D56C, + 0x1D56D, + 0x1D56E, + 0x1D56F, + 0x1D570, + 0x1D571, + 0x1D572, + 0x1D573, + 0x1D574, + 0x1D575, + 0x1D576, + 0x1D577, + 0x1D578, + 0x1D579, + 0x1D57A, + 0x1D57B, + 0x1D57C, + 0x1D57D, + 0x1D57E, + 0x1D57F, + 0x1D580, + 0x1D581, + 0x1D582, + 0x1D583, + 0x1D584, + 0x1D585, + 0x1D586, + 0x1D587, + 0x1D588, + 0x1D589, + 0x1D58A, + 0x1D58B, + 0x1D58C, + 0x1D58D, + 0x1D58E, + 0x1D58F, + 0x1D590, + 0x1D591, + 0x1D592, + 0x1D593, + 0x1D594, + 0x1D595, + 0x1D596, + 0x1D597, + 0x1D598, + 0x1D599, + 0x1D59A, + 0x1D59B, + 0x1D59C, + 0x1D59D, + 0x1D59E, + 0x1D59F, + 0x1D5A0, + 0x1D5A1, + 0x1D5A2, + 0x1D5A3, + 0x1D5A4, + 0x1D5A5, + 0x1D5A6, + 0x1D5A7, + 0x1D5A8, + 0x1D5A9, + 0x1D5AA, + 0x1D5AB, + 0x1D5AC, + 0x1D5AD, + 0x1D5AE, + 0x1D5AF, + 0x1D5B0, + 0x1D5B1, + 0x1D5B2, + 0x1D5B3, + 0x1D5B4, + 0x1D5B5, + 0x1D5B6, + 0x1D5B7, + 0x1D5B8, + 0x1D5B9, + 0x1D5BA, + 0x1D5BB, + 0x1D5BC, + 0x1D5BD, + 0x1D5BE, + 0x1D5BF, + 0x1D5C0, + 0x1D5C1, + 0x1D5C2, + 0x1D5C3, + 0x1D5C4, + 0x1D5C5, + 0x1D5C6, + 0x1D5C7, + 0x1D5C8, + 0x1D5C9, + 0x1D5CA, + 0x1D5CB, + 0x1D5CC, + 0x1D5CD, + 0x1D5CE, + 0x1D5CF, + 0x1D5D0, + 0x1D5D1, + 0x1D5D2, + 0x1D5D3, + 0x1D5D4, + 0x1D5D5, + 0x1D5D6, + 0x1D5D7, + 0x1D5D8, + 0x1D5D9, + 0x1D5DA, + 0x1D5DB, + 0x1D5DC, + 0x1D5DD, + 0x1D5DE, + 0x1D5DF, + 0x1D5E0, + 0x1D5E1, + 0x1D5E2, + 0x1D5E3, + 0x1D5E4, + 0x1D5E5, + 0x1D5E6, + 0x1D5E7, + 0x1D5E8, + 0x1D5E9, + 0x1D5EA, + 0x1D5EB, + 0x1D5EC, + 0x1D5ED, + 0x1D5EE, + 0x1D5EF, + 0x1D5F0, + 0x1D5F1, + 0x1D5F2, + 0x1D5F3, + 0x1D5F4, + 0x1D5F5, + 0x1D5F6, + 0x1D5F7, + 0x1D5F8, + 0x1D5F9, + 0x1D5FA, + 0x1D5FB, + 0x1D5FC, + 0x1D5FD, + 0x1D5FE, + 0x1D5FF, + 0x1D600, + 0x1D601, + 0x1D602, + 0x1D603, + 0x1D604, + 0x1D605, + 0x1D606, + 0x1D607, + 0x1D608, + 0x1D609, + 0x1D60A, + 0x1D60B, + 0x1D60C, + 0x1D60D, + 0x1D60E, + 0x1D60F, + 0x1D610, + 0x1D611, + 0x1D612, + 0x1D613, + 0x1D614, + 0x1D615, + 0x1D616, + 0x1D617, + 0x1D618, + 0x1D619, + 0x1D61A, + 0x1D61B, + 0x1D61C, + 0x1D61D, + 0x1D61E, + 0x1D61F, + 0x1D620, + 0x1D621, + 0x1D622, + 0x1D623, + 0x1D624, + 0x1D625, + 0x1D626, + 0x1D627, + 0x1D628, + 0x1D629, + 0x1D62A, + 0x1D62B, + 0x1D62C, + 0x1D62D, + 0x1D62E, + 0x1D62F, + 0x1D630, + 0x1D631, + 0x1D632, + 0x1D633, + 0x1D634, + 0x1D635, + 0x1D636, + 0x1D637, + 0x1D638, + 0x1D639, + 0x1D63A, + 0x1D63B, + 0x1D63C, + 0x1D63D, + 0x1D63E, + 0x1D63F, + 0x1D640, + 0x1D641, + 0x1D642, + 0x1D643, + 0x1D644, + 0x1D645, + 0x1D646, + 0x1D647, + 0x1D648, + 0x1D649, + 0x1D64A, + 0x1D64B, + 0x1D64C, + 0x1D64D, + 0x1D64E, + 0x1D64F, + 0x1D650, + 0x1D651, + 0x1D652, + 0x1D653, + 0x1D654, + 0x1D655, + 0x1D656, + 0x1D657, + 0x1D658, + 0x1D659, + 0x1D65A, + 0x1D65B, + 0x1D65C, + 0x1D65D, + 0x1D65E, + 0x1D65F, + 0x1D660, + 0x1D661, + 0x1D662, + 0x1D663, + 0x1D664, + 0x1D665, + 0x1D666, + 0x1D667, + 0x1D668, + 0x1D669, + 0x1D66A, + 0x1D66B, + 0x1D66C, + 0x1D66D, + 0x1D66E, + 0x1D66F, + 0x1D670, + 0x1D671, + 0x1D672, + 0x1D673, + 0x1D674, + 0x1D675, + 0x1D676, + 0x1D677, + 0x1D678, + 0x1D679, + 0x1D67A, + 0x1D67B, + 0x1D67C, + 0x1D67D, + 0x1D67E, + 0x1D67F, + 0x1D680, + 0x1D681, + 0x1D682, + 0x1D683, + 0x1D684, + 0x1D685, + 0x1D686, + 0x1D687, + 0x1D688, + 0x1D689, + 0x1D68A, + 0x1D68B, + 0x1D68C, + 0x1D68D, + 0x1D68E, + 0x1D68F, + 0x1D690, + 0x1D691, + 0x1D692, + 0x1D693, + 0x1D694, + 0x1D695, + 0x1D696, + 0x1D697, + 0x1D698, + 0x1D699, + 0x1D69A, + 0x1D69B, + 0x1D69C, + 0x1D69D, + 0x1D69E, + 0x1D69F, + 0x1D6A0, + 0x1D6A1, + 0x1D6A2, + 0x1D6A3, + 0x1D6A4, + 0x1D6A5, + 0x1D6A6, + 0x1D6A8, + 0x1D6A9, + 0x1D6AA, + 0x1D6AB, + 0x1D6AC, + 0x1D6AD, + 0x1D6AE, + 0x1D6AF, + 0x1D6B0, + 0x1D6B1, + 0x1D6B2, + 0x1D6B3, + 0x1D6B4, + 0x1D6B5, + 0x1D6B6, + 0x1D6B7, + 0x1D6B8, + 0x1D6B9, + 0x1D6BA, + 0x1D6BB, + 0x1D6BC, + 0x1D6BD, + 0x1D6BE, + 0x1D6BF, + 0x1D6C0, + 0x1D6C1, + 0x1D6C2, + 0x1D6C3, + 0x1D6C4, + 0x1D6C5, + 0x1D6C6, + 0x1D6C7, + 0x1D6C8, + 0x1D6C9, + 0x1D6CA, + 0x1D6CB, + 0x1D6CC, + 0x1D6CD, + 0x1D6CE, + 0x1D6CF, + 0x1D6D0, + 0x1D6D1, + 0x1D6D2, + 0x1D6D3, + 0x1D6D5, + 0x1D6D6, + 0x1D6D7, + 0x1D6D8, + 0x1D6D9, + 0x1D6DA, + 0x1D6DB, + 0x1D6DC, + 0x1D6DD, + 0x1D6DE, + 0x1D6DF, + 0x1D6E0, + 0x1D6E1, + 0x1D6E2, + 0x1D6E3, + 0x1D6E4, + 0x1D6E5, + 0x1D6E6, + 0x1D6E7, + 0x1D6E8, + 0x1D6E9, + 0x1D6EA, + 0x1D6EB, + 0x1D6EC, + 0x1D6ED, + 0x1D6EE, + 0x1D6EF, + 0x1D6F0, + 0x1D6F1, + 0x1D6F2, + 0x1D6F3, + 0x1D6F4, + 0x1D6F5, + 0x1D6F6, + 0x1D6F7, + 0x1D6F8, + 0x1D6F9, + 0x1D6FA, + 0x1D6FB, + 0x1D6FC, + 0x1D6FD, + 0x1D6FE, + 0x1D6FF, + 0x1D700, + 0x1D701, + 0x1D702, + 0x1D703, + 0x1D704, + 0x1D705, + 0x1D706, + 0x1D707, + 0x1D708, + 0x1D709, + 0x1D70A, + 0x1D70B, + 0x1D70C, + 0x1D70D, + 0x1D70F, + 0x1D710, + 0x1D711, + 0x1D712, + 0x1D713, + 0x1D714, + 0x1D715, + 0x1D716, + 0x1D717, + 0x1D718, + 0x1D719, + 0x1D71A, + 0x1D71B, + 0x1D71C, + 0x1D71D, + 0x1D71E, + 0x1D71F, + 0x1D720, + 0x1D721, + 0x1D722, + 0x1D723, + 0x1D724, + 0x1D725, + 0x1D726, + 0x1D727, + 0x1D728, + 0x1D729, + 0x1D72A, + 0x1D72B, + 0x1D72C, + 0x1D72D, + 0x1D72E, + 0x1D72F, + 0x1D730, + 0x1D731, + 0x1D732, + 0x1D733, + 0x1D734, + 0x1D735, + 0x1D736, + 0x1D737, + 0x1D738, + 0x1D739, + 0x1D73A, + 0x1D73B, + 0x1D73C, + 0x1D73D, + 0x1D73E, + 0x1D73F, + 0x1D740, + 0x1D741, + 0x1D742, + 0x1D743, + 0x1D744, + 0x1D745, + 0x1D746, + 0x1D747, + 0x1D749, + 0x1D74A, + 0x1D74B, + 0x1D74C, + 0x1D74D, + 0x1D74E, + 0x1D74F, + 0x1D750, + 0x1D751, + 0x1D752, + 0x1D753, + 0x1D754, + 0x1D755, + 0x1D756, + 0x1D757, + 0x1D758, + 0x1D759, + 0x1D75A, + 0x1D75B, + 0x1D75C, + 0x1D75D, + 0x1D75E, + 0x1D75F, + 0x1D760, + 0x1D761, + 0x1D762, + 0x1D763, + 0x1D764, + 0x1D765, + 0x1D766, + 0x1D767, + 0x1D768, + 0x1D769, + 0x1D76A, + 0x1D76B, + 0x1D76C, + 0x1D76D, + 0x1D76E, + 0x1D76F, + 0x1D770, + 0x1D771, + 0x1D772, + 0x1D773, + 0x1D774, + 0x1D775, + 0x1D776, + 0x1D777, + 0x1D778, + 0x1D779, + 0x1D77A, + 0x1D77B, + 0x1D77C, + 0x1D77D, + 0x1D77E, + 0x1D77F, + 0x1D780, + 0x1D781, + 0x1D783, + 0x1D784, + 0x1D785, + 0x1D786, + 0x1D787, + 0x1D788, + 0x1D789, + 0x1D78A, + 0x1D78B, + 0x1D78C, + 0x1D78D, + 0x1D78E, + 0x1D78F, + 0x1D790, + 0x1D791, + 0x1D792, + 0x1D793, + 0x1D794, + 0x1D795, + 0x1D796, + 0x1D797, + 0x1D798, + 0x1D799, + 0x1D79A, + 0x1D79B, + 0x1D79C, + 0x1D79D, + 0x1D79E, + 0x1D79F, + 0x1D7A0, + 0x1D7A1, + 0x1D7A2, + 0x1D7A3, + 0x1D7A4, + 0x1D7A5, + 0x1D7A6, + 0x1D7A7, + 0x1D7A8, + 0x1D7A9, + 0x1D7AA, + 0x1D7AB, + 0x1D7AC, + 0x1D7AD, + 0x1D7AE, + 0x1D7AF, + 0x1D7B0, + 0x1D7B1, + 0x1D7B2, + 0x1D7B3, + 0x1D7B4, + 0x1D7B5, + 0x1D7B6, + 0x1D7B7, + 0x1D7B8, + 0x1D7B9, + 0x1D7BA, + 0x1D7BB, + 0x1D7BD, + 0x1D7BE, + 0x1D7BF, + 0x1D7C0, + 0x1D7C1, + 0x1D7C2, + 0x1D7C3, + 0x1D7C4, + 0x1D7C5, + 0x1D7C6, + 0x1D7C7, + 0x1D7C8, + 0x1D7C9, + 0x1D7CA, + 0x1D7CC, + 0x1D7CE, + 0x1D7CF, + 0x1D7D0, + 0x1D7D1, + 0x1D7D2, + 0x1D7D3, + 0x1D7D4, + 0x1D7D5, + 0x1D7D6, + 0x1D7D7, + 0x1D7D8, + 0x1D7D9, + 0x1D7DA, + 0x1D7DB, + 0x1D7DC, + 0x1D7DD, + 0x1D7DE, + 0x1D7DF, + 0x1D7E0, + 0x1D7E1, + 0x1D7E2, + 0x1D7E3, + 0x1D7E4, + 0x1D7E5, + 0x1D7E6, + 0x1D7E7, + 0x1D7E8, + 0x1D7E9, + 0x1D7EA, + 0x1D7EB, + 0x1D7EC, + 0x1D7ED, + 0x1D7EE, + 0x1D7EF, + 0x1D7F0, + 0x1D7F1, + 0x1D7F2, + 0x1D7F3, + 0x1D7F4, + 0x1D7F5, + 0x1D7F6, + 0x1D7F7, + 0x1D7F8, + 0x1D7F9, + 0x1D7FA, + 0x1D7FB, + 0x1D7FC, + 0x1D7FD, + 0x1D7FE, + 0x1D7FF, + 0x1D800, + 0x1DA8C, + 0x1DA9B, + 0x1DAA0, + 0x1DAA1, + 0x1DAB0, + 0x1DF00, + 0x1DF1F, + 0x1DF25, + 0x1DF2B, + 0x1E000, + 0x1E007, + 0x1E008, + 0x1E019, + 0x1E01B, + 0x1E022, + 0x1E023, + 0x1E025, + 0x1E026, + 0x1E02B, + 0x1E030, + 0x1E031, + 0x1E032, + 0x1E033, + 0x1E034, + 0x1E035, + 0x1E036, + 0x1E037, + 0x1E038, + 0x1E039, + 0x1E03A, + 0x1E03B, + 0x1E03C, + 0x1E03D, + 0x1E03E, + 0x1E03F, + 0x1E040, + 0x1E041, + 0x1E042, + 0x1E043, + 0x1E044, + 0x1E045, + 0x1E046, + 0x1E047, + 0x1E048, + 0x1E049, + 0x1E04A, + 0x1E04B, + 0x1E04C, + 0x1E04D, + 0x1E04E, + 0x1E04F, + 0x1E050, + 0x1E051, + 0x1E052, + 0x1E053, + 0x1E054, + 0x1E055, + 0x1E056, + 0x1E057, + 0x1E058, + 0x1E059, + 0x1E05A, + 0x1E05B, + 0x1E05C, + 0x1E05D, + 0x1E05E, + 0x1E05F, + 0x1E060, + 0x1E061, + 0x1E062, + 0x1E063, + 0x1E064, + 0x1E065, + 0x1E066, + 0x1E067, + 0x1E068, + 0x1E069, + 0x1E06A, + 0x1E06B, + 0x1E06C, + 0x1E06D, + 0x1E06E, + 0x1E08F, + 0x1E090, + 0x1E100, + 0x1E12D, + 0x1E130, + 0x1E13E, + 0x1E140, + 0x1E14A, + 0x1E14E, + 0x1E150, + 0x1E290, + 0x1E2AF, + 0x1E2C0, + 0x1E2FA, + 0x1E2FF, + 0x1E300, + 0x1E4D0, + 0x1E4FA, + 0x1E5D0, + 0x1E5FB, + 0x1E5FF, + 0x1E600, + 0x1E6C0, + 0x1E6DF, + 0x1E6E0, + 0x1E6F6, + 0x1E6FE, + 0x1E700, + 0x1E7E0, + 0x1E7E7, + 0x1E7E8, + 0x1E7EC, + 0x1E7ED, + 0x1E7EF, + 0x1E7F0, + 0x1E7FF, + 0x1E800, + 0x1E8C5, + 0x1E8C7, + 0x1E8D7, + 0x1E900, + 0x1E901, + 0x1E902, + 0x1E903, + 0x1E904, + 0x1E905, + 0x1E906, + 0x1E907, + 0x1E908, + 0x1E909, + 0x1E90A, + 0x1E90B, + 0x1E90C, + 0x1E90D, + 0x1E90E, + 0x1E90F, + 0x1E910, + 0x1E911, + 0x1E912, + 0x1E913, + 0x1E914, + 0x1E915, + 0x1E916, + 0x1E917, + 0x1E918, + 0x1E919, + 0x1E91A, + 0x1E91B, + 0x1E91C, + 0x1E91D, + 0x1E91E, + 0x1E91F, + 0x1E920, + 0x1E921, + 0x1E922, + 0x1E94C, + 0x1E950, + 0x1E95A, + 0x1E95E, + 0x1E960, + 0x1EC71, + 0x1ECB5, + 0x1ED01, + 0x1ED3E, + 0x1EE00, + 0x1EE01, + 0x1EE02, + 0x1EE03, + 0x1EE04, + 0x1EE05, + 0x1EE06, + 0x1EE07, + 0x1EE08, + 0x1EE09, + 0x1EE0A, + 0x1EE0B, + 0x1EE0C, + 0x1EE0D, + 0x1EE0E, + 0x1EE0F, + 0x1EE10, + 0x1EE11, + 0x1EE12, + 0x1EE13, + 0x1EE14, + 0x1EE15, + 0x1EE16, + 0x1EE17, + 0x1EE18, + 0x1EE19, + 0x1EE1A, + 0x1EE1B, + 0x1EE1C, + 0x1EE1D, + 0x1EE1E, + 0x1EE1F, + 0x1EE20, + 0x1EE21, + 0x1EE22, + 0x1EE23, + 0x1EE24, + 0x1EE25, + 0x1EE27, + 0x1EE28, + 0x1EE29, + 0x1EE2A, + 0x1EE2B, + 0x1EE2C, + 0x1EE2D, + 0x1EE2E, + 0x1EE2F, + 0x1EE30, + 0x1EE31, + 0x1EE32, + 0x1EE33, + 0x1EE34, + 0x1EE35, + 0x1EE36, + 0x1EE37, + 0x1EE38, + 0x1EE39, + 0x1EE3A, + 0x1EE3B, + 0x1EE3C, + 0x1EE42, + 0x1EE43, + 0x1EE47, + 0x1EE48, + 0x1EE49, + 0x1EE4A, + 0x1EE4B, + 0x1EE4C, + 0x1EE4D, + 0x1EE4E, + 0x1EE4F, + 0x1EE50, + 0x1EE51, + 0x1EE52, + 0x1EE53, + 0x1EE54, + 0x1EE55, + 0x1EE57, + 0x1EE58, + 0x1EE59, + 0x1EE5A, + 0x1EE5B, + 0x1EE5C, + 0x1EE5D, + 0x1EE5E, + 0x1EE5F, + 0x1EE60, + 0x1EE61, + 0x1EE62, + 0x1EE63, + 0x1EE64, + 0x1EE65, + 0x1EE67, + 0x1EE68, + 0x1EE69, + 0x1EE6A, + 0x1EE6B, + 0x1EE6C, + 0x1EE6D, + 0x1EE6E, + 0x1EE6F, + 0x1EE70, + 0x1EE71, + 0x1EE72, + 0x1EE73, + 0x1EE74, + 0x1EE75, + 0x1EE76, + 0x1EE77, + 0x1EE78, + 0x1EE79, + 0x1EE7A, + 0x1EE7B, + 0x1EE7C, + 0x1EE7D, + 0x1EE7E, + 0x1EE7F, + 0x1EE80, + 0x1EE81, + 0x1EE82, + 0x1EE83, + 0x1EE84, + 0x1EE85, + 0x1EE86, + 0x1EE87, + 0x1EE88, + 0x1EE89, + 0x1EE8A, + 0x1EE8B, + 0x1EE8C, + 0x1EE8D, + 0x1EE8E, + 0x1EE8F, + 0x1EE90, + 0x1EE91, + 0x1EE92, + 0x1EE93, + 0x1EE94, + 0x1EE95, + 0x1EE96, + 0x1EE97, + 0x1EE98, + 0x1EE99, + 0x1EE9A, + 0x1EE9B, + 0x1EE9C, + 0x1EEA1, + 0x1EEA2, + 0x1EEA3, + 0x1EEA4, + 0x1EEA5, + 0x1EEA6, + 0x1EEA7, + 0x1EEA8, + 0x1EEA9, + 0x1EEAA, + 0x1EEAB, + 0x1EEAC, + 0x1EEAD, + 0x1EEAE, + 0x1EEAF, + 0x1EEB0, + 0x1EEB1, + 0x1EEB2, + 0x1EEB3, + 0x1EEB4, + 0x1EEB5, + 0x1EEB6, + 0x1EEB7, + 0x1EEB8, + 0x1EEB9, + 0x1EEBA, + 0x1EEBB, + 0x1EEBC, + 0x1EEF0, + 0x1EEF2, + 0x1F000, + 0x1F02C, + 0x1F030, + 0x1F094, + 0x1F0A0, + 0x1F0AF, + 0x1F0B1, + 0x1F0C0, + 0x1F0C1, + 0x1F0D0, + 0x1F0D1, + 0x1F0F6, + 0x1F101, + 0x1F102, + 0x1F103, + 0x1F104, + 0x1F105, + 0x1F106, + 0x1F107, + 0x1F108, + 0x1F109, + 0x1F10A, + 0x1F10B, + 0x1F110, + 0x1F111, + 0x1F112, + 0x1F113, + 0x1F114, + 0x1F115, + 0x1F116, + 0x1F117, + 0x1F118, + 0x1F119, + 0x1F11A, + 0x1F11B, + 0x1F11C, + 0x1F11D, + 0x1F11E, + 0x1F11F, + 0x1F120, + 0x1F121, + 0x1F122, + 0x1F123, + 0x1F124, + 0x1F125, + 0x1F126, + 0x1F127, + 0x1F128, + 0x1F129, + 0x1F12A, + 0x1F12B, + 0x1F12C, + 0x1F12D, + 0x1F12E, + 0x1F12F, + 0x1F130, + 0x1F131, + 0x1F132, + 0x1F133, + 0x1F134, + 0x1F135, + 0x1F136, + 0x1F137, + 0x1F138, + 0x1F139, + 0x1F13A, + 0x1F13B, + 0x1F13C, + 0x1F13D, + 0x1F13E, + 0x1F13F, + 0x1F140, + 0x1F141, + 0x1F142, + 0x1F143, + 0x1F144, + 0x1F145, + 0x1F146, + 0x1F147, + 0x1F148, + 0x1F149, + 0x1F14A, + 0x1F14B, + 0x1F14C, + 0x1F14D, + 0x1F14E, + 0x1F14F, + 0x1F150, + 0x1F16A, + 0x1F16B, + 0x1F16C, + 0x1F16D, + 0x1F190, + 0x1F191, + 0x1F1AE, + 0x1F1E6, + 0x1F200, + 0x1F201, + 0x1F202, + 0x1F203, + 0x1F210, + 0x1F211, + 0x1F212, + 0x1F213, + 0x1F214, + 0x1F215, + 0x1F216, + 0x1F217, + 0x1F218, + 0x1F219, + 0x1F21A, + 0x1F21B, + 0x1F21C, + 0x1F21D, + 0x1F21E, + 0x1F21F, + 0x1F220, + 0x1F221, + 0x1F222, + 0x1F223, + 0x1F224, + 0x1F225, + 0x1F226, + 0x1F227, + 0x1F228, + 0x1F229, + 0x1F22A, + 0x1F22B, + 0x1F22C, + 0x1F22D, + 0x1F22E, + 0x1F22F, + 0x1F230, + 0x1F231, + 0x1F232, + 0x1F233, + 0x1F234, + 0x1F235, + 0x1F236, + 0x1F237, + 0x1F238, + 0x1F239, + 0x1F23A, + 0x1F23B, + 0x1F23C, + 0x1F240, + 0x1F241, + 0x1F242, + 0x1F243, + 0x1F244, + 0x1F245, + 0x1F246, + 0x1F247, + 0x1F248, + 0x1F249, + 0x1F250, + 0x1F251, + 0x1F252, + 0x1F260, + 0x1F266, + 0x1F300, + 0x1F6D9, + 0x1F6DC, + 0x1F6ED, + 0x1F6F0, + 0x1F6FD, + 0x1F700, + 0x1F7DA, + 0x1F7E0, + 0x1F7EC, + 0x1F7F0, + 0x1F7F1, + 0x1F800, + 0x1F80C, + 0x1F810, + 0x1F848, + 0x1F850, + 0x1F85A, + 0x1F860, + 0x1F888, + 0x1F890, + 0x1F8AE, + 0x1F8B0, + 0x1F8BC, + 0x1F8C0, + 0x1F8C2, + 0x1F8D0, + 0x1F8D9, + 0x1F900, + 0x1FA58, + 0x1FA60, + 0x1FA6E, + 0x1FA70, + 0x1FA7D, + 0x1FA80, + 0x1FA8B, + 0x1FA8E, + 0x1FAC7, + 0x1FAC8, + 0x1FAC9, + 0x1FACD, + 0x1FADD, + 0x1FADF, + 0x1FAEB, + 0x1FAEF, + 0x1FAF9, + 0x1FB00, + 0x1FB93, + 0x1FB94, + 0x1FBF0, + 0x1FBF1, + 0x1FBF2, + 0x1FBF3, + 0x1FBF4, + 0x1FBF5, + 0x1FBF6, + 0x1FBF7, + 0x1FBF8, + 0x1FBF9, + 0x1FBFA, + 0x1FBFB, + 0x20000, + 0x2A6E0, + 0x2A700, + 0x2B81E, + 0x2B820, + 0x2CEAE, + 0x2CEB0, + 0x2EBE1, + 0x2EBF0, + 0x2EE5E, + 0x2F800, + 0x2F801, + 0x2F802, + 0x2F803, + 0x2F804, + 0x2F805, + 0x2F806, + 0x2F807, + 0x2F808, + 0x2F809, + 0x2F80A, + 0x2F80B, + 0x2F80C, + 0x2F80D, + 0x2F80E, + 0x2F80F, + 0x2F810, + 0x2F811, + 0x2F812, + 0x2F813, + 0x2F814, + 0x2F815, + 0x2F816, + 0x2F817, + 0x2F818, + 0x2F819, + 0x2F81A, + 0x2F81B, + 0x2F81C, + 0x2F81D, + 0x2F81E, + 0x2F81F, + 0x2F820, + 0x2F821, + 0x2F822, + 0x2F823, + 0x2F824, + 0x2F825, + 0x2F826, + 0x2F827, + 0x2F828, + 0x2F829, + 0x2F82A, + 0x2F82B, + 0x2F82C, + 0x2F82D, + 0x2F82E, + 0x2F82F, + 0x2F830, + 0x2F831, + 0x2F834, + 0x2F835, + 0x2F836, + 0x2F837, + 0x2F838, + 0x2F839, + 0x2F83A, + 0x2F83B, + 0x2F83C, + 0x2F83D, + 0x2F83E, + 0x2F83F, + 0x2F840, + 0x2F841, + 0x2F842, + 0x2F843, + 0x2F844, + 0x2F845, + 0x2F847, + 0x2F848, + 0x2F849, + 0x2F84A, + 0x2F84B, + 0x2F84C, + 0x2F84D, + 0x2F84E, + 0x2F84F, + 0x2F850, + 0x2F851, + 0x2F852, + 0x2F853, + 0x2F854, + 0x2F855, + 0x2F856, + 0x2F857, + 0x2F858, + 0x2F859, + 0x2F85A, + 0x2F85B, + 0x2F85C, + 0x2F85D, + 0x2F85E, + 0x2F85F, + 0x2F860, + 0x2F861, + 0x2F862, + 0x2F863, + 0x2F864, + 0x2F865, + 0x2F866, + 0x2F867, + 0x2F868, + 0x2F869, + 0x2F86A, + 0x2F86C, + 0x2F86D, + 0x2F86E, + 0x2F86F, + 0x2F870, + 0x2F871, + 0x2F872, + 0x2F873, + 0x2F874, + 0x2F875, + 0x2F876, + 0x2F877, + 0x2F878, + 0x2F879, + 0x2F87A, + 0x2F87B, + 0x2F87C, + 0x2F87D, + 0x2F87E, + 0x2F87F, + 0x2F880, + 0x2F881, + 0x2F882, + 0x2F883, + 0x2F884, + 0x2F885, + 0x2F886, + 0x2F887, + 0x2F888, + 0x2F889, + 0x2F88A, + 0x2F88B, + 0x2F88C, + 0x2F88D, + 0x2F88E, + 0x2F88F, + 0x2F890, + 0x2F891, + 0x2F893, + 0x2F894, + 0x2F896, + 0x2F897, + 0x2F898, + 0x2F899, + 0x2F89A, + 0x2F89B, + 0x2F89C, + 0x2F89D, + 0x2F89E, + 0x2F89F, + 0x2F8A0, + 0x2F8A1, + 0x2F8A2, + 0x2F8A3, + 0x2F8A4, + 0x2F8A5, + 0x2F8A6, + 0x2F8A7, + 0x2F8A8, + 0x2F8A9, + 0x2F8AA, + 0x2F8AB, + 0x2F8AC, + 0x2F8AD, + 0x2F8AE, + 0x2F8AF, + 0x2F8B0, + 0x2F8B1, + 0x2F8B2, + 0x2F8B3, + 0x2F8B4, + 0x2F8B5, + 0x2F8B6, + 0x2F8B7, + 0x2F8B8, + 0x2F8B9, + 0x2F8BA, + 0x2F8BB, + 0x2F8BC, + 0x2F8BD, + 0x2F8BE, + 0x2F8BF, + 0x2F8C0, + 0x2F8C1, + 0x2F8C2, + 0x2F8C3, + 0x2F8C4, + 0x2F8C5, + 0x2F8C6, + 0x2F8C7, + 0x2F8C8, + 0x2F8C9, + 0x2F8CA, + 0x2F8CB, + 0x2F8CC, + 0x2F8CD, + 0x2F8CE, + 0x2F8CF, + 0x2F8D0, + 0x2F8D1, + 0x2F8D2, + 0x2F8D3, + 0x2F8D4, + 0x2F8D5, + 0x2F8D6, + 0x2F8D7, + 0x2F8D8, + 0x2F8D9, + 0x2F8DA, + 0x2F8DB, + 0x2F8DC, + 0x2F8DD, + 0x2F8DE, + 0x2F8DF, + 0x2F8E0, + 0x2F8E1, + 0x2F8E2, + 0x2F8E3, + 0x2F8E4, + 0x2F8E5, + 0x2F8E6, + 0x2F8E7, + 0x2F8E8, + 0x2F8E9, + 0x2F8EA, + 0x2F8EB, + 0x2F8EC, + 0x2F8ED, + 0x2F8EE, + 0x2F8EF, + 0x2F8F0, + 0x2F8F1, + 0x2F8F2, + 0x2F8F3, + 0x2F8F4, + 0x2F8F5, + 0x2F8F6, + 0x2F8F7, + 0x2F8F8, + 0x2F8F9, + 0x2F8FA, + 0x2F8FB, + 0x2F8FC, + 0x2F8FD, + 0x2F8FE, + 0x2F8FF, + 0x2F900, + 0x2F901, + 0x2F902, + 0x2F903, + 0x2F904, + 0x2F905, + 0x2F906, + 0x2F907, + 0x2F908, + 0x2F909, + 0x2F90A, + 0x2F90B, + 0x2F90C, + 0x2F90D, + 0x2F90E, + 0x2F90F, + 0x2F910, + 0x2F911, + 0x2F912, + 0x2F913, + 0x2F914, + 0x2F915, + 0x2F916, + 0x2F917, + 0x2F918, + 0x2F919, + 0x2F91A, + 0x2F91B, + 0x2F91C, + 0x2F91D, + 0x2F91E, + 0x2F91F, + 0x2F920, + 0x2F921, + 0x2F922, + 0x2F923, + 0x2F924, + 0x2F925, + 0x2F926, + 0x2F927, + 0x2F928, + 0x2F929, + 0x2F92A, + 0x2F92B, + 0x2F92C, + 0x2F92E, + 0x2F92F, + 0x2F930, + 0x2F931, + 0x2F932, + 0x2F933, + 0x2F934, + 0x2F935, + 0x2F936, + 0x2F937, + 0x2F938, + 0x2F939, + 0x2F93A, + 0x2F93B, + 0x2F93C, + 0x2F93D, + 0x2F93E, + 0x2F93F, + 0x2F940, + 0x2F941, + 0x2F942, + 0x2F943, + 0x2F944, + 0x2F945, + 0x2F946, + 0x2F948, + 0x2F949, + 0x2F94A, + 0x2F94B, + 0x2F94C, + 0x2F94D, + 0x2F94E, + 0x2F94F, + 0x2F950, + 0x2F951, + 0x2F952, + 0x2F953, + 0x2F954, + 0x2F955, + 0x2F956, + 0x2F957, + 0x2F958, + 0x2F959, + 0x2F95A, + 0x2F95B, + 0x2F95C, + 0x2F95D, + 0x2F95F, + 0x2F960, + 0x2F961, + 0x2F962, + 0x2F963, + 0x2F964, + 0x2F965, + 0x2F966, + 0x2F967, + 0x2F968, + 0x2F969, + 0x2F96A, + 0x2F96B, + 0x2F96C, + 0x2F96D, + 0x2F96E, + 0x2F96F, + 0x2F970, + 0x2F971, + 0x2F972, + 0x2F973, + 0x2F974, + 0x2F975, + 0x2F976, + 0x2F977, + 0x2F978, + 0x2F979, + 0x2F97A, + 0x2F97B, + 0x2F97C, + 0x2F97D, + 0x2F97E, + 0x2F97F, + 0x2F980, + 0x2F981, + 0x2F982, + 0x2F983, + 0x2F984, + 0x2F985, + 0x2F986, + 0x2F987, + 0x2F988, + 0x2F989, + 0x2F98A, + 0x2F98B, + 0x2F98C, + 0x2F98D, + 0x2F98E, + 0x2F98F, + 0x2F990, + 0x2F991, + 0x2F992, + 0x2F993, + 0x2F994, + 0x2F995, + 0x2F996, + 0x2F997, + 0x2F998, + 0x2F999, + 0x2F99A, + 0x2F99B, + 0x2F99C, + 0x2F99D, + 0x2F99E, + 0x2F99F, + 0x2F9A0, + 0x2F9A1, + 0x2F9A2, + 0x2F9A3, + 0x2F9A4, + 0x2F9A5, + 0x2F9A6, + 0x2F9A7, + 0x2F9A8, + 0x2F9A9, + 0x2F9AA, + 0x2F9AB, + 0x2F9AC, + 0x2F9AD, + 0x2F9AE, + 0x2F9AF, + 0x2F9B0, + 0x2F9B1, + 0x2F9B2, + 0x2F9B3, + 0x2F9B4, + 0x2F9B5, + 0x2F9B6, + 0x2F9B7, + 0x2F9B8, + 0x2F9B9, + 0x2F9BA, + 0x2F9BB, + 0x2F9BC, + 0x2F9BD, + 0x2F9BE, + 0x2F9BF, + 0x2F9C0, + 0x2F9C1, + 0x2F9C2, + 0x2F9C3, + 0x2F9C4, + 0x2F9C5, + 0x2F9C6, + 0x2F9C7, + 0x2F9C8, + 0x2F9C9, + 0x2F9CA, + 0x2F9CB, + 0x2F9CC, + 0x2F9CD, + 0x2F9CE, + 0x2F9CF, + 0x2F9D0, + 0x2F9D1, + 0x2F9D2, + 0x2F9D3, + 0x2F9D4, + 0x2F9D5, + 0x2F9D6, + 0x2F9D7, + 0x2F9D8, + 0x2F9D9, + 0x2F9DA, + 0x2F9DB, + 0x2F9DC, + 0x2F9DD, + 0x2F9DE, + 0x2F9DF, + 0x2F9E0, + 0x2F9E1, + 0x2F9E2, + 0x2F9E3, + 0x2F9E4, + 0x2F9E5, + 0x2F9E6, + 0x2F9E7, + 0x2F9E8, + 0x2F9E9, + 0x2F9EA, + 0x2F9EB, + 0x2F9EC, + 0x2F9ED, + 0x2F9EE, + 0x2F9EF, + 0x2F9F0, + 0x2F9F1, + 0x2F9F2, + 0x2F9F3, + 0x2F9F4, + 0x2F9F5, + 0x2F9F6, + 0x2F9F7, + 0x2F9F8, + 0x2F9F9, + 0x2F9FA, + 0x2F9FB, + 0x2F9FC, + 0x2F9FD, + 0x2F9FE, + 0x2FA00, + 0x2FA01, + 0x2FA02, + 0x2FA03, + 0x2FA04, + 0x2FA05, + 0x2FA06, + 0x2FA07, + 0x2FA08, + 0x2FA09, + 0x2FA0A, + 0x2FA0B, + 0x2FA0C, + 0x2FA0D, + 0x2FA0E, + 0x2FA0F, + 0x2FA10, + 0x2FA11, + 0x2FA12, + 0x2FA13, + 0x2FA14, + 0x2FA15, + 0x2FA16, + 0x2FA17, + 0x2FA18, + 0x2FA19, + 0x2FA1A, + 0x2FA1B, + 0x2FA1C, + 0x2FA1D, + 0x2FA1E, + 0x30000, + 0x3134B, + 0x31350, + 0x3347A, + 0xE0100, + 0xE01F0, + ), +) + +uts46_statuses: bytes = ( + b"VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + b"VMMMMMMMMMMMMMMMMMMMMMMMMMMVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + b"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXMVVVVVVVMVMVVIVMVVMMMMVVMMMVMMMV" + b"MMMMMMMMMMMMMMMMMMMMMMMVMMMMMMMDVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV" + b"MVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMMVMVMVMVMVMMV" + b"MVMVMVMMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMMVMVMVMVMM" + b"VMVMMVMMMVMMMMVMMVMMMVMMVMMVMVMVMMVMVMVMMVMMMVMVMMVMVMMMMVMVMVMV" + b"MVMVMVMVMVMVMVMVMVMVMVMVMVMMVMMMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVM" + b"VMVMVMVMVMVMVMVMVMVMVMVMVMVMMVMMVMVMMMMVMVMVMVMVMMMMMMMMMVMMMMMM" + b"VMMMMMVMMVMMMVIVMVMVMVMVXMVMMXMMMMMMMXMXMMVMMMMMMMMMMMMMMMMMXMMM" + b"MMMMMMVDVMMMMMMMMVMVMVMVMVMVMVMVMVMVMVMVMVMMMVMMVMVMMVMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMVMVMVMVMVMVMVMVMVMVMV" + b"MVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMV" + b"MVMMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVM" + b"VMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVXMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMXVMVXVXVXVXVXVXVMMMMVXVXVXVXVXVXVXVXVXVX" + b"VXVXVMMMMMMMMVXVXVXVXVXVXVXVXVXVXVXMMXMVXVXVXVXVXVXVXVMXVMXVXVXV" + b"XVXVXVXMMMVXMXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXMMXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXV" + b"XVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXV" + b"XVMVXVXVXVXVXVXVXVMVXVXVXVXVXMMVXVMVMVXVMVMVMVMVMVXVMVMMMMMVMVMV" + b"XVMVMVMVMVMVXVXVXVMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMXMXVMV" + b"IVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXMMMMMMXVXVXVXVXVXVXVXVXVX" + b"VIVXVXVXVIVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVMMM" + b"MMMMMMVXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMVXVXVMMMV" + b"MMMMMMMMMMMVMMMMMMMMMMMMMMMMMMVMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMVMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMVMVMVMVMVMVMVMVMVMVMVMVMVMV" + b"MVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMV" + b"MVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMMVMVM" + b"VMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVM" + b"VMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMMMMMMMMVXMMMMMMXVMMMMMMMMVMMMMMM" + b"MMVXMMMMMMXVXMXMXMXMVMMMMMMMMVMVMVMVMVMVMVMXMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMVMMMXVMMMMMMMMMMMMMMXVMMMMMMMMMVMXVM" + b"MMMXMMMVMVMMMMMMMMXMMMXVMMMMMMMMXMIDXVMVMVXVXMVMMVMMVMVMVMMMVMVM" + b"IXIMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMMMMMMMMMMXVMVXVXMMMMVMMMVM" + b"MMMMMVMMVMMMVMMMVMVMVMVMMMMVMMMMMMMMMMVMMMMMVMMMMVMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMVMVXVMMVMMVMMVXVXMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMVMMMVMVXVMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMVMMMVMVMVMVMMMMVMVMVMM" + b"MMMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMV" + b"MVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVXVXVXVXVXMVXVXVXVXVX" + b"VXVXVXVXVXVXVXVMVMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMVMVMVMMMVXVXVMMVMVMXV" + b"XMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMIMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXVMMMMMMMMMMMMMMVXVMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMXMMMMMMMMMMMMM" + b"MMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVXVXVXMVMVMVMVMVMVMVM" + b"VMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMMVXV" + b"MVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMV" + b"MVMVMVMVMVMVMVMVMVMMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMVMMMMMVM" + b"MMMMVMVMVMVMVMVMVMVMMMMVMVMMVMVMVMVMVMVMVMVMXMMMMMVMMVXVXVXVXVXV" + b"XVXVXVXVXVXVXVXVXVXVXVXVXVXVXVMMMMVMVXMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVXVXVXVXVX" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMVMVMVMMMMMMMMMMVMVMVMMVMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMXMMMMMMXMMMMMXMVMMMMMMMMMMMMMMMMMMMMMMMMXMMMMM" + b"XMXMMXMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMVMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVXMMMMMMMMMMMMMVIMMXMMM" + b"MMMXVXMMMMMMMMMMMMMMMMMMMVMMMMMMXMMMMMMMMMMMMMMMMMMMXMMMMXMMMVMX" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXIXMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMIMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMMMXMMMMM" + b"MXMMMMMMXMMMXMMMMMMMXMMMMMMMXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXV" + b"XVXVXVXVXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVXVXMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMXVXVXVXVMMMMMMMMMMMXMMMMMMMMMMMMMMMXMMM" + b"MMMMXMMXVXVXVXVXVXVXVXVXVMMMMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMXMMMMMMMMMXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXVXVXVXVXVXVXVXVXVXVXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMXVXVXVXVMMMMMMMMMMMMMMMMMMMMMMXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMVXMMMMMMMMMMMMMMMMMMMMMMMMMXVXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXVXVXVXVXVXVXVXVXVIXVMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVXVXVX" + b"VXVXVXVXVXVXVMMMMMMMVIVMMMMMMVXVXVXVXVXVXMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMXMMXMXMMXMMMMXMMMMMMMMMMMMXMXMMMMMMMXMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMXMMMMMMMMXMMMMM" + b"MMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMXMMMMMXMXMMMMMMMXMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMVXVXVXVXVXVXVXVXVXVXMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXVXVXVXVXVXVXVXVXVX" + b"VXVXVXVXVXVXVXVXVXVXVXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVXVXVXVX" + b"VXMMMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMXMMXMXMXMMMMMMMMMMXMMMMXMXMXMX" + b"MXMXMXMMMXMMXMXMXMXMXMXMXMMXMXMMMMXMMMMMMMXMMMMXMMMMXMXMMMMMMMMM" + b"MXMMMMMMMMMMMMMMMMMXMMMXMMMMMXMMMMMMMMMMMMMMMMMXVXVXVXVXVXVXVXMM" + b"MMMMMMMMVMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMVMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMVMMMVMVXVMMMXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMXMMMMMMMMMXMMXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVXVX" + b"VXVMMMMMMMMMMVXVXVXVXVXVXMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM" + b"MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMXVXVXIX" +) + +uts46_replacements: tuple[Optional[str], ...] = ( + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + " ", + None, + None, + None, + None, + None, + None, + None, + " ̈", + None, + "a", + None, + None, + None, + None, + " ̄", + None, + None, + "2", + "3", + " ́", + "μ", + None, + None, + " ̧", + "1", + "o", + None, + "1⁄4", + "1⁄2", + "3⁄4", + None, + "à", + "á", + "â", + "ã", + "ä", + "å", + "æ", + "ç", + "è", + "é", + "ê", + "ë", + "ì", + "í", + "î", + "ï", + "ð", + "ñ", + "ò", + "ó", + "ô", + "õ", + "ö", + None, + "ø", + "ù", + "ú", + "û", + "ü", + "ý", + "þ", + "ss", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ā", + None, + "ă", + None, + "ą", + None, + "ć", + None, + "ĉ", + None, + "ċ", + None, + "č", + None, + "ď", + None, + "đ", + None, + "ē", + None, + "ĕ", + None, + "ė", + None, + "ę", + None, + "ě", + None, + "ĝ", + None, + "ğ", + None, + "ġ", + None, + "ģ", + None, + "ĥ", + None, + "ħ", + None, + "ĩ", + None, + "ī", + None, + "ĭ", + None, + "į", + None, + "i̇", + None, + "ij", + "ĵ", + None, + "ķ", + None, + "ĺ", + None, + "ļ", + None, + "ľ", + None, + "l·", + "ł", + None, + "ń", + None, + "ņ", + None, + "ň", + None, + "ʼn", + "ŋ", + None, + "ō", + None, + "ŏ", + None, + "ő", + None, + "œ", + None, + "ŕ", + None, + "ŗ", + None, + "ř", + None, + "ś", + None, + "ŝ", + None, + "ş", + None, + "š", + None, + "ţ", + None, + "ť", + None, + "ŧ", + None, + "ũ", + None, + "ū", + None, + "ŭ", + None, + "ů", + None, + "ű", + None, + "ų", + None, + "ŵ", + None, + "ŷ", + None, + "ÿ", + "ź", + None, + "ż", + None, + "ž", + None, + "s", + None, + "ɓ", + "ƃ", + None, + "ƅ", + None, + "ɔ", + "ƈ", + None, + "ɖ", + "ɗ", + "ƌ", + None, + "ǝ", + "ə", + "ɛ", + "ƒ", + None, + "ɠ", + "ɣ", + None, + "ɩ", + "ɨ", + "ƙ", + None, + "ɯ", + "ɲ", + None, + "ɵ", + "ơ", + None, + "ƣ", + None, + "ƥ", + None, + "ʀ", + "ƨ", + None, + "ʃ", + None, + "ƭ", + None, + "ʈ", + "ư", + None, + "ʊ", + "ʋ", + "ƴ", + None, + "ƶ", + None, + "ʒ", + "ƹ", + None, + "ƽ", + None, + "dž", + "lj", + "nj", + "ǎ", + None, + "ǐ", + None, + "ǒ", + None, + "ǔ", + None, + "ǖ", + None, + "ǘ", + None, + "ǚ", + None, + "ǜ", + None, + "ǟ", + None, + "ǡ", + None, + "ǣ", + None, + "ǥ", + None, + "ǧ", + None, + "ǩ", + None, + "ǫ", + None, + "ǭ", + None, + "ǯ", + None, + "dz", + "ǵ", + None, + "ƕ", + "ƿ", + "ǹ", + None, + "ǻ", + None, + "ǽ", + None, + "ǿ", + None, + "ȁ", + None, + "ȃ", + None, + "ȅ", + None, + "ȇ", + None, + "ȉ", + None, + "ȋ", + None, + "ȍ", + None, + "ȏ", + None, + "ȑ", + None, + "ȓ", + None, + "ȕ", + None, + "ȗ", + None, + "ș", + None, + "ț", + None, + "ȝ", + None, + "ȟ", + None, + "ƞ", + None, + "ȣ", + None, + "ȥ", + None, + "ȧ", + None, + "ȩ", + None, + "ȫ", + None, + "ȭ", + None, + "ȯ", + None, + "ȱ", + None, + "ȳ", + None, + "ⱥ", + "ȼ", + None, + "ƚ", + "ⱦ", + None, + "ɂ", + None, + "ƀ", + "ʉ", + "ʌ", + "ɇ", + None, + "ɉ", + None, + "ɋ", + None, + "ɍ", + None, + "ɏ", + None, + "h", + "ɦ", + "j", + "r", + "ɹ", + "ɻ", + "ʁ", + "w", + "y", + None, + " ̆", + " ̇", + " ̊", + " ̨", + " ̃", + " ̋", + None, + "ɣ", + "l", + "s", + "x", + "ʕ", + None, + "̀", + "́", + None, + "̓", + "̈́", + "ι", + None, + None, + None, + "ͱ", + None, + "ͳ", + None, + "ʹ", + None, + "ͷ", + None, + None, + " ι", + None, + ";", + "ϳ", + None, + " ́", + " ̈́", + "ά", + "·", + "έ", + "ή", + "ί", + None, + "ό", + None, + "ύ", + "ώ", + None, + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + None, + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "ϊ", + "ϋ", + None, + "σ", + None, + "ϗ", + "β", + "θ", + "υ", + "ύ", + "ϋ", + "φ", + "π", + None, + "ϙ", + None, + "ϛ", + None, + "ϝ", + None, + "ϟ", + None, + "ϡ", + None, + "ϣ", + None, + "ϥ", + None, + "ϧ", + None, + "ϩ", + None, + "ϫ", + None, + "ϭ", + None, + "ϯ", + None, + "κ", + "ρ", + "σ", + None, + "θ", + "ε", + None, + "ϸ", + None, + "σ", + "ϻ", + None, + "ͻ", + "ͼ", + "ͽ", + "ѐ", + "ё", + "ђ", + "ѓ", + "є", + "ѕ", + "і", + "ї", + "ј", + "љ", + "њ", + "ћ", + "ќ", + "ѝ", + "ў", + "џ", + "а", + "б", + "в", + "г", + "д", + "е", + "ж", + "з", + "и", + "й", + "к", + "л", + "м", + "н", + "о", + "п", + "р", + "с", + "т", + "у", + "ф", + "х", + "ц", + "ч", + "ш", + "щ", + "ъ", + "ы", + "ь", + "э", + "ю", + "я", + None, + "ѡ", + None, + "ѣ", + None, + "ѥ", + None, + "ѧ", + None, + "ѩ", + None, + "ѫ", + None, + "ѭ", + None, + "ѯ", + None, + "ѱ", + None, + "ѳ", + None, + "ѵ", + None, + "ѷ", + None, + "ѹ", + None, + "ѻ", + None, + "ѽ", + None, + "ѿ", + None, + "ҁ", + None, + "ҋ", + None, + "ҍ", + None, + "ҏ", + None, + "ґ", + None, + "ғ", + None, + "ҕ", + None, + "җ", + None, + "ҙ", + None, + "қ", + None, + "ҝ", + None, + "ҟ", + None, + "ҡ", + None, + "ң", + None, + "ҥ", + None, + "ҧ", + None, + "ҩ", + None, + "ҫ", + None, + "ҭ", + None, + "ү", + None, + "ұ", + None, + "ҳ", + None, + "ҵ", + None, + "ҷ", + None, + "ҹ", + None, + "һ", + None, + "ҽ", + None, + "ҿ", + None, + "ӏ", + "ӂ", + None, + "ӄ", + None, + "ӆ", + None, + "ӈ", + None, + "ӊ", + None, + "ӌ", + None, + "ӎ", + None, + "ӑ", + None, + "ӓ", + None, + "ӕ", + None, + "ӗ", + None, + "ә", + None, + "ӛ", + None, + "ӝ", + None, + "ӟ", + None, + "ӡ", + None, + "ӣ", + None, + "ӥ", + None, + "ӧ", + None, + "ө", + None, + "ӫ", + None, + "ӭ", + None, + "ӯ", + None, + "ӱ", + None, + "ӳ", + None, + "ӵ", + None, + "ӷ", + None, + "ӹ", + None, + "ӻ", + None, + "ӽ", + None, + "ӿ", + None, + "ԁ", + None, + "ԃ", + None, + "ԅ", + None, + "ԇ", + None, + "ԉ", + None, + "ԋ", + None, + "ԍ", + None, + "ԏ", + None, + "ԑ", + None, + "ԓ", + None, + "ԕ", + None, + "ԗ", + None, + "ԙ", + None, + "ԛ", + None, + "ԝ", + None, + "ԟ", + None, + "ԡ", + None, + "ԣ", + None, + "ԥ", + None, + "ԧ", + None, + "ԩ", + None, + "ԫ", + None, + "ԭ", + None, + "ԯ", + None, + None, + "ա", + "բ", + "գ", + "դ", + "ե", + "զ", + "է", + "ը", + "թ", + "ժ", + "ի", + "լ", + "խ", + "ծ", + "կ", + "հ", + "ձ", + "ղ", + "ճ", + "մ", + "յ", + "ն", + "շ", + "ո", + "չ", + "պ", + "ջ", + "ռ", + "ս", + "վ", + "տ", + "ր", + "ց", + "ւ", + "փ", + "ք", + "օ", + "ֆ", + None, + None, + "եւ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "اٴ", + "وٴ", + "ۇٴ", + "يٴ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "क़", + "ख़", + "ग़", + "ज़", + "ड़", + "ढ़", + "फ़", + "य़", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ড়", + "ঢ়", + None, + "য়", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ਲ਼", + None, + None, + "ਸ਼", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ਖ਼", + "ਗ਼", + "ਜ਼", + None, + None, + "ਫ਼", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ଡ଼", + "ଢ଼", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ํา", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ໍາ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ຫນ", + "ຫມ", + None, + None, + None, + "་", + None, + "གྷ", + None, + None, + None, + "ཌྷ", + None, + "དྷ", + None, + "བྷ", + None, + "ཛྷ", + None, + "ཀྵ", + None, + None, + None, + "ཱི", + None, + "ཱུ", + "ྲྀ", + "ྲཱྀ", + "ླྀ", + "ླཱྀ", + None, + "ཱྀ", + None, + "ྒྷ", + None, + None, + None, + "ྜྷ", + None, + "ྡྷ", + None, + "ྦྷ", + None, + "ྫྷ", + None, + "ྐྵ", + None, + None, + None, + None, + None, + None, + None, + "ⴀ", + "ⴁ", + "ⴂ", + "ⴃ", + "ⴄ", + "ⴅ", + "ⴆ", + "ⴇ", + "ⴈ", + "ⴉ", + "ⴊ", + "ⴋ", + "ⴌ", + "ⴍ", + "ⴎ", + "ⴏ", + "ⴐ", + "ⴑ", + "ⴒ", + "ⴓ", + "ⴔ", + "ⴕ", + "ⴖ", + "ⴗ", + "ⴘ", + "ⴙ", + "ⴚ", + "ⴛ", + "ⴜ", + "ⴝ", + "ⴞ", + "ⴟ", + "ⴠ", + "ⴡ", + "ⴢ", + "ⴣ", + "ⴤ", + "ⴥ", + None, + "ⴧ", + None, + "ⴭ", + None, + None, + "ნ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "Ᏸ", + "Ᏹ", + "Ᏺ", + "Ᏻ", + "Ᏼ", + "Ᏽ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "в", + "д", + "о", + "с", + "т", + "ъ", + "ѣ", + "ꙋ", + "\u1c8a", + None, + None, + "ა", + "ბ", + "გ", + "დ", + "ე", + "ვ", + "ზ", + "თ", + "ი", + "კ", + "ლ", + "მ", + "ნ", + "ო", + "პ", + "ჟ", + "რ", + "ს", + "ტ", + "უ", + "ფ", + "ქ", + "ღ", + "ყ", + "შ", + "ჩ", + "ც", + "ძ", + "წ", + "ჭ", + "ხ", + "ჯ", + "ჰ", + "ჱ", + "ჲ", + "ჳ", + "ჴ", + "ჵ", + "ჶ", + "ჷ", + "ჸ", + "ჹ", + "ჺ", + None, + "ჽ", + "ჾ", + "ჿ", + None, + None, + None, + None, + None, + "a", + "æ", + "b", + None, + "d", + "e", + "ǝ", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + None, + "o", + "ȣ", + "p", + "r", + "t", + "u", + "w", + "a", + "ɐ", + "ɑ", + "ᴂ", + "b", + "d", + "e", + "ə", + "ɛ", + "ɜ", + "g", + None, + "k", + "m", + "ŋ", + "o", + "ɔ", + "ᴖ", + "ᴗ", + "p", + "t", + "u", + "ᴝ", + "ɯ", + "v", + "ᴥ", + "β", + "γ", + "δ", + "φ", + "χ", + "i", + "r", + "u", + "v", + "β", + "γ", + "ρ", + "φ", + "χ", + None, + "н", + None, + "ɒ", + "c", + "ɕ", + "ð", + "ɜ", + "f", + "ɟ", + "ɡ", + "ɥ", + "ɨ", + "ɩ", + "ɪ", + "ᵻ", + "ʝ", + "ɭ", + "ᶅ", + "ʟ", + "ɱ", + "ɰ", + "ɲ", + "ɳ", + "ɴ", + "ɵ", + "ɸ", + "ʂ", + "ʃ", + "ƫ", + "ʉ", + "ʊ", + "ᴜ", + "ʋ", + "ʌ", + "z", + "ʐ", + "ʑ", + "ʒ", + "θ", + None, + "ḁ", + None, + "ḃ", + None, + "ḅ", + None, + "ḇ", + None, + "ḉ", + None, + "ḋ", + None, + "ḍ", + None, + "ḏ", + None, + "ḑ", + None, + "ḓ", + None, + "ḕ", + None, + "ḗ", + None, + "ḙ", + None, + "ḛ", + None, + "ḝ", + None, + "ḟ", + None, + "ḡ", + None, + "ḣ", + None, + "ḥ", + None, + "ḧ", + None, + "ḩ", + None, + "ḫ", + None, + "ḭ", + None, + "ḯ", + None, + "ḱ", + None, + "ḳ", + None, + "ḵ", + None, + "ḷ", + None, + "ḹ", + None, + "ḻ", + None, + "ḽ", + None, + "ḿ", + None, + "ṁ", + None, + "ṃ", + None, + "ṅ", + None, + "ṇ", + None, + "ṉ", + None, + "ṋ", + None, + "ṍ", + None, + "ṏ", + None, + "ṑ", + None, + "ṓ", + None, + "ṕ", + None, + "ṗ", + None, + "ṙ", + None, + "ṛ", + None, + "ṝ", + None, + "ṟ", + None, + "ṡ", + None, + "ṣ", + None, + "ṥ", + None, + "ṧ", + None, + "ṩ", + None, + "ṫ", + None, + "ṭ", + None, + "ṯ", + None, + "ṱ", + None, + "ṳ", + None, + "ṵ", + None, + "ṷ", + None, + "ṹ", + None, + "ṻ", + None, + "ṽ", + None, + "ṿ", + None, + "ẁ", + None, + "ẃ", + None, + "ẅ", + None, + "ẇ", + None, + "ẉ", + None, + "ẋ", + None, + "ẍ", + None, + "ẏ", + None, + "ẑ", + None, + "ẓ", + None, + "ẕ", + None, + "aʾ", + "ṡ", + None, + "ß", + None, + "ạ", + None, + "ả", + None, + "ấ", + None, + "ầ", + None, + "ẩ", + None, + "ẫ", + None, + "ậ", + None, + "ắ", + None, + "ằ", + None, + "ẳ", + None, + "ẵ", + None, + "ặ", + None, + "ẹ", + None, + "ẻ", + None, + "ẽ", + None, + "ế", + None, + "ề", + None, + "ể", + None, + "ễ", + None, + "ệ", + None, + "ỉ", + None, + "ị", + None, + "ọ", + None, + "ỏ", + None, + "ố", + None, + "ồ", + None, + "ổ", + None, + "ỗ", + None, + "ộ", + None, + "ớ", + None, + "ờ", + None, + "ở", + None, + "ỡ", + None, + "ợ", + None, + "ụ", + None, + "ủ", + None, + "ứ", + None, + "ừ", + None, + "ử", + None, + "ữ", + None, + "ự", + None, + "ỳ", + None, + "ỵ", + None, + "ỷ", + None, + "ỹ", + None, + "ỻ", + None, + "ỽ", + None, + "ỿ", + None, + "ἀ", + "ἁ", + "ἂ", + "ἃ", + "ἄ", + "ἅ", + "ἆ", + "ἇ", + None, + None, + "ἐ", + "ἑ", + "ἒ", + "ἓ", + "ἔ", + "ἕ", + None, + None, + "ἠ", + "ἡ", + "ἢ", + "ἣ", + "ἤ", + "ἥ", + "ἦ", + "ἧ", + None, + "ἰ", + "ἱ", + "ἲ", + "ἳ", + "ἴ", + "ἵ", + "ἶ", + "ἷ", + None, + None, + "ὀ", + "ὁ", + "ὂ", + "ὃ", + "ὄ", + "ὅ", + None, + None, + None, + "ὑ", + None, + "ὓ", + None, + "ὕ", + None, + "ὗ", + None, + "ὠ", + "ὡ", + "ὢ", + "ὣ", + "ὤ", + "ὥ", + "ὦ", + "ὧ", + None, + "ά", + None, + "έ", + None, + "ή", + None, + "ί", + None, + "ό", + None, + "ύ", + None, + "ώ", + None, + "ἀι", + "ἁι", + "ἂι", + "ἃι", + "ἄι", + "ἅι", + "ἆι", + "ἇι", + "ἀι", + "ἁι", + "ἂι", + "ἃι", + "ἄι", + "ἅι", + "ἆι", + "ἇι", + "ἠι", + "ἡι", + "ἢι", + "ἣι", + "ἤι", + "ἥι", + "ἦι", + "ἧι", + "ἠι", + "ἡι", + "ἢι", + "ἣι", + "ἤι", + "ἥι", + "ἦι", + "ἧι", + "ὠι", + "ὡι", + "ὢι", + "ὣι", + "ὤι", + "ὥι", + "ὦι", + "ὧι", + "ὠι", + "ὡι", + "ὢι", + "ὣι", + "ὤι", + "ὥι", + "ὦι", + "ὧι", + None, + "ὰι", + "αι", + "άι", + None, + None, + "ᾶι", + "ᾰ", + "ᾱ", + "ὰ", + "ά", + "αι", + " ̓", + "ι", + " ̓", + " ͂", + " ̈͂", + "ὴι", + "ηι", + "ήι", + None, + None, + "ῆι", + "ὲ", + "έ", + "ὴ", + "ή", + "ηι", + " ̓̀", + " ̓́", + " ̓͂", + None, + "ΐ", + None, + None, + "ῐ", + "ῑ", + "ὶ", + "ί", + None, + " ̔̀", + " ̔́", + " ̔͂", + None, + "ΰ", + None, + "ῠ", + "ῡ", + "ὺ", + "ύ", + "ῥ", + " ̈̀", + " ̈́", + "`", + None, + "ὼι", + "ωι", + "ώι", + None, + None, + "ῶι", + "ὸ", + "ό", + "ὼ", + "ώ", + "ωι", + " ́", + " ̔", + None, + " ", + None, + "", + None, + None, + "‐", + None, + " ̳", + None, + None, + None, + None, + " ", + None, + "′′", + "′′′", + None, + "‵‵", + "‵‵‵", + None, + "!!", + None, + " ̅", + None, + "??", + "?!", + "!?", + None, + "′′′′", + None, + " ", + None, + None, + None, + "0", + "i", + None, + "4", + "5", + "6", + "7", + "8", + "9", + "+", + "−", + "=", + "(", + ")", + "n", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "+", + "−", + "=", + "(", + ")", + None, + "a", + "e", + "o", + "x", + "ə", + "h", + "k", + "l", + "m", + "n", + "p", + "s", + "t", + None, + None, + "rs", + None, + None, + None, + None, + "a/c", + "a/s", + "c", + "°c", + None, + "c/o", + "c/u", + "ɛ", + None, + "°f", + "g", + "h", + "ħ", + "i", + "l", + None, + "n", + "no", + None, + "p", + "q", + "r", + None, + "sm", + "tel", + "tm", + None, + "z", + None, + "ω", + None, + "z", + None, + "k", + "å", + "b", + "c", + None, + "e", + "f", + "ⅎ", + "m", + "o", + "א", + "ב", + "ג", + "ד", + "i", + None, + "fax", + "π", + "γ", + "π", + "∑", + None, + "d", + "e", + "i", + "j", + None, + "1⁄7", + "1⁄9", + "1⁄10", + "1⁄3", + "2⁄3", + "1⁄5", + "2⁄5", + "3⁄5", + "4⁄5", + "1⁄6", + "5⁄6", + "1⁄8", + "3⁄8", + "5⁄8", + "7⁄8", + "1⁄", + "i", + "ii", + "iii", + "iv", + "v", + "vi", + "vii", + "viii", + "ix", + "x", + "xi", + "xii", + "l", + "c", + "d", + "m", + "i", + "ii", + "iii", + "iv", + "v", + "vi", + "vii", + "viii", + "ix", + "x", + "xi", + "xii", + "l", + "c", + "d", + "m", + None, + "ↄ", + None, + "0⁄3", + None, + None, + None, + "∫∫", + "∫∫∫", + None, + "∮∮", + "∮∮∮", + None, + "〈", + "〉", + None, + None, + None, + None, + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "(1)", + "(2)", + "(3)", + "(4)", + "(5)", + "(6)", + "(7)", + "(8)", + "(9)", + "(10)", + "(11)", + "(12)", + "(13)", + "(14)", + "(15)", + "(16)", + "(17)", + "(18)", + "(19)", + "(20)", + None, + "(a)", + "(b)", + "(c)", + "(d)", + "(e)", + "(f)", + "(g)", + "(h)", + "(i)", + "(j)", + "(k)", + "(l)", + "(m)", + "(n)", + "(o)", + "(p)", + "(q)", + "(r)", + "(s)", + "(t)", + "(u)", + "(v)", + "(w)", + "(x)", + "(y)", + "(z)", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "0", + None, + "∫∫∫∫", + None, + "::=", + "==", + "===", + None, + "⫝̸", + None, + None, + None, + "ⰰ", + "ⰱ", + "ⰲ", + "ⰳ", + "ⰴ", + "ⰵ", + "ⰶ", + "ⰷ", + "ⰸ", + "ⰹ", + "ⰺ", + "ⰻ", + "ⰼ", + "ⰽ", + "ⰾ", + "ⰿ", + "ⱀ", + "ⱁ", + "ⱂ", + "ⱃ", + "ⱄ", + "ⱅ", + "ⱆ", + "ⱇ", + "ⱈ", + "ⱉ", + "ⱊ", + "ⱋ", + "ⱌ", + "ⱍ", + "ⱎ", + "ⱏ", + "ⱐ", + "ⱑ", + "ⱒ", + "ⱓ", + "ⱔ", + "ⱕ", + "ⱖ", + "ⱗ", + "ⱘ", + "ⱙ", + "ⱚ", + "ⱛ", + "ⱜ", + "ⱝ", + "ⱞ", + "ⱟ", + None, + "ⱡ", + None, + "ɫ", + "ᵽ", + "ɽ", + None, + "ⱨ", + None, + "ⱪ", + None, + "ⱬ", + None, + "ɑ", + "ɱ", + "ɐ", + "ɒ", + None, + "ⱳ", + None, + "ⱶ", + None, + "j", + "v", + "ȿ", + "ɀ", + "ⲁ", + None, + "ⲃ", + None, + "ⲅ", + None, + "ⲇ", + None, + "ⲉ", + None, + "ⲋ", + None, + "ⲍ", + None, + "ⲏ", + None, + "ⲑ", + None, + "ⲓ", + None, + "ⲕ", + None, + "ⲗ", + None, + "ⲙ", + None, + "ⲛ", + None, + "ⲝ", + None, + "ⲟ", + None, + "ⲡ", + None, + "ⲣ", + None, + "ⲥ", + None, + "ⲧ", + None, + "ⲩ", + None, + "ⲫ", + None, + "ⲭ", + None, + "ⲯ", + None, + "ⲱ", + None, + "ⲳ", + None, + "ⲵ", + None, + "ⲷ", + None, + "ⲹ", + None, + "ⲻ", + None, + "ⲽ", + None, + "ⲿ", + None, + "ⳁ", + None, + "ⳃ", + None, + "ⳅ", + None, + "ⳇ", + None, + "ⳉ", + None, + "ⳋ", + None, + "ⳍ", + None, + "ⳏ", + None, + "ⳑ", + None, + "ⳓ", + None, + "ⳕ", + None, + "ⳗ", + None, + "ⳙ", + None, + "ⳛ", + None, + "ⳝ", + None, + "ⳟ", + None, + "ⳡ", + None, + "ⳣ", + None, + "ⳬ", + None, + "ⳮ", + None, + "ⳳ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ⵡ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "母", + None, + "龟", + None, + "一", + "丨", + "丶", + "丿", + "乙", + "亅", + "二", + "亠", + "人", + "儿", + "入", + "八", + "冂", + "冖", + "冫", + "几", + "凵", + "刀", + "力", + "勹", + "匕", + "匚", + "匸", + "十", + "卜", + "卩", + "厂", + "厶", + "又", + "口", + "囗", + "土", + "士", + "夂", + "夊", + "夕", + "大", + "女", + "子", + "宀", + "寸", + "小", + "尢", + "尸", + "屮", + "山", + "巛", + "工", + "己", + "巾", + "干", + "幺", + "广", + "廴", + "廾", + "弋", + "弓", + "彐", + "彡", + "彳", + "心", + "戈", + "戶", + "手", + "支", + "攴", + "文", + "斗", + "斤", + "方", + "无", + "日", + "曰", + "月", + "木", + "欠", + "止", + "歹", + "殳", + "毋", + "比", + "毛", + "氏", + "气", + "水", + "火", + "爪", + "父", + "爻", + "爿", + "片", + "牙", + "牛", + "犬", + "玄", + "玉", + "瓜", + "瓦", + "甘", + "生", + "用", + "田", + "疋", + "疒", + "癶", + "白", + "皮", + "皿", + "目", + "矛", + "矢", + "石", + "示", + "禸", + "禾", + "穴", + "立", + "竹", + "米", + "糸", + "缶", + "网", + "羊", + "羽", + "老", + "而", + "耒", + "耳", + "聿", + "肉", + "臣", + "自", + "至", + "臼", + "舌", + "舛", + "舟", + "艮", + "色", + "艸", + "虍", + "虫", + "血", + "行", + "衣", + "襾", + "見", + "角", + "言", + "谷", + "豆", + "豕", + "豸", + "貝", + "赤", + "走", + "足", + "身", + "車", + "辛", + "辰", + "辵", + "邑", + "酉", + "釆", + "里", + "金", + "長", + "門", + "阜", + "隶", + "隹", + "雨", + "靑", + "非", + "面", + "革", + "韋", + "韭", + "音", + "頁", + "風", + "飛", + "食", + "首", + "香", + "馬", + "骨", + "高", + "髟", + "鬥", + "鬯", + "鬲", + "鬼", + "魚", + "鳥", + "鹵", + "鹿", + "麥", + "麻", + "黃", + "黍", + "黑", + "黹", + "黽", + "鼎", + "鼓", + "鼠", + "鼻", + "齊", + "齒", + "龍", + "龜", + "龠", + None, + " ", + None, + ".", + None, + "〒", + None, + "十", + "卄", + "卅", + None, + None, + None, + None, + None, + " ゙", + " ゚", + None, + "より", + None, + "コト", + None, + None, + None, + "ᄀ", + "ᄁ", + "ᆪ", + "ᄂ", + "ᆬ", + "ᆭ", + "ᄃ", + "ᄄ", + "ᄅ", + "ᆰ", + "ᆱ", + "ᆲ", + "ᆳ", + "ᆴ", + "ᆵ", + "ᄚ", + "ᄆ", + "ᄇ", + "ᄈ", + "ᄡ", + "ᄉ", + "ᄊ", + "ᄋ", + "ᄌ", + "ᄍ", + "ᄎ", + "ᄏ", + "ᄐ", + "ᄑ", + "ᄒ", + "ᅡ", + "ᅢ", + "ᅣ", + "ᅤ", + "ᅥ", + "ᅦ", + "ᅧ", + "ᅨ", + "ᅩ", + "ᅪ", + "ᅫ", + "ᅬ", + "ᅭ", + "ᅮ", + "ᅯ", + "ᅰ", + "ᅱ", + "ᅲ", + "ᅳ", + "ᅴ", + "ᅵ", + None, + "ᄔ", + "ᄕ", + "ᇇ", + "ᇈ", + "ᇌ", + "ᇎ", + "ᇓ", + "ᇗ", + "ᇙ", + "ᄜ", + "ᇝ", + "ᇟ", + "ᄝ", + "ᄞ", + "ᄠ", + "ᄢ", + "ᄣ", + "ᄧ", + "ᄩ", + "ᄫ", + "ᄬ", + "ᄭ", + "ᄮ", + "ᄯ", + "ᄲ", + "ᄶ", + "ᅀ", + "ᅇ", + "ᅌ", + "ᇱ", + "ᇲ", + "ᅗ", + "ᅘ", + "ᅙ", + "ᆄ", + "ᆅ", + "ᆈ", + "ᆑ", + "ᆒ", + "ᆔ", + "ᆞ", + "ᆡ", + None, + None, + "一", + "二", + "三", + "四", + "上", + "中", + "下", + "甲", + "乙", + "丙", + "丁", + "天", + "地", + "人", + None, + None, + None, + "(ᄀ)", + "(ᄂ)", + "(ᄃ)", + "(ᄅ)", + "(ᄆ)", + "(ᄇ)", + "(ᄉ)", + "(ᄋ)", + "(ᄌ)", + "(ᄎ)", + "(ᄏ)", + "(ᄐ)", + "(ᄑ)", + "(ᄒ)", + "(가)", + "(나)", + "(다)", + "(라)", + "(마)", + "(바)", + "(사)", + "(아)", + "(자)", + "(차)", + "(카)", + "(타)", + "(파)", + "(하)", + "(주)", + "(오전)", + "(오후)", + None, + "(一)", + "(二)", + "(三)", + "(四)", + "(五)", + "(六)", + "(七)", + "(八)", + "(九)", + "(十)", + "(月)", + "(火)", + "(水)", + "(木)", + "(金)", + "(土)", + "(日)", + "(株)", + "(有)", + "(社)", + "(名)", + "(特)", + "(財)", + "(祝)", + "(労)", + "(代)", + "(呼)", + "(学)", + "(監)", + "(企)", + "(資)", + "(協)", + "(祭)", + "(休)", + "(自)", + "(至)", + "問", + "幼", + "文", + "箏", + None, + "pte", + "21", + "22", + "23", + "24", + "25", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "33", + "34", + "35", + "ᄀ", + "ᄂ", + "ᄃ", + "ᄅ", + "ᄆ", + "ᄇ", + "ᄉ", + "ᄋ", + "ᄌ", + "ᄎ", + "ᄏ", + "ᄐ", + "ᄑ", + "ᄒ", + "가", + "나", + "다", + "라", + "마", + "바", + "사", + "아", + "자", + "차", + "카", + "타", + "파", + "하", + "참고", + "주의", + "우", + None, + "一", + "二", + "三", + "四", + "五", + "六", + "七", + "八", + "九", + "十", + "月", + "火", + "水", + "木", + "金", + "土", + "日", + "株", + "有", + "社", + "名", + "特", + "財", + "祝", + "労", + "秘", + "男", + "女", + "適", + "優", + "印", + "注", + "項", + "休", + "写", + "正", + "上", + "中", + "下", + "左", + "右", + "医", + "宗", + "学", + "監", + "企", + "資", + "協", + "夜", + "36", + "37", + "38", + "39", + "40", + "41", + "42", + "43", + "44", + "45", + "46", + "47", + "48", + "49", + "50", + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", + "hg", + "erg", + "ev", + "ltd", + "ア", + "イ", + "ウ", + "エ", + "オ", + "カ", + "キ", + "ク", + "ケ", + "コ", + "サ", + "シ", + "ス", + "セ", + "ソ", + "タ", + "チ", + "ツ", + "テ", + "ト", + "ナ", + "ニ", + "ヌ", + "ネ", + "ノ", + "ハ", + "ヒ", + "フ", + "ヘ", + "ホ", + "マ", + "ミ", + "ム", + "メ", + "モ", + "ヤ", + "ユ", + "ヨ", + "ラ", + "リ", + "ル", + "レ", + "ロ", + "ワ", + "ヰ", + "ヱ", + "ヲ", + "令和", + "アパート", + "アルファ", + "アンペア", + "アール", + "イニング", + "インチ", + "ウォン", + "エスクード", + "エーカー", + "オンス", + "オーム", + "カイリ", + "カラット", + "カロリー", + "ガロン", + "ガンマ", + "ギガ", + "ギニー", + "キュリー", + "ギルダー", + "キロ", + "キログラム", + "キロメートル", + "キロワット", + "グラム", + "グラムトン", + "クルゼイロ", + "クローネ", + "ケース", + "コルナ", + "コーポ", + "サイクル", + "サンチーム", + "シリング", + "センチ", + "セント", + "ダース", + "デシ", + "ドル", + "トン", + "ナノ", + "ノット", + "ハイツ", + "パーセント", + "パーツ", + "バーレル", + "ピアストル", + "ピクル", + "ピコ", + "ビル", + "ファラッド", + "フィート", + "ブッシェル", + "フラン", + "ヘクタール", + "ペソ", + "ペニヒ", + "ヘルツ", + "ペンス", + "ページ", + "ベータ", + "ポイント", + "ボルト", + "ホン", + "ポンド", + "ホール", + "ホーン", + "マイクロ", + "マイル", + "マッハ", + "マルク", + "マンション", + "ミクロン", + "ミリ", + "ミリバール", + "メガ", + "メガトン", + "メートル", + "ヤード", + "ヤール", + "ユアン", + "リットル", + "リラ", + "ルピー", + "ルーブル", + "レム", + "レントゲン", + "ワット", + "0点", + "1点", + "2点", + "3点", + "4点", + "5点", + "6点", + "7点", + "8点", + "9点", + "10点", + "11点", + "12点", + "13点", + "14点", + "15点", + "16点", + "17点", + "18点", + "19点", + "20点", + "21点", + "22点", + "23点", + "24点", + "hpa", + "da", + "au", + "bar", + "ov", + "pc", + "dm", + "dm2", + "dm3", + "iu", + "平成", + "昭和", + "大正", + "明治", + "株式会社", + "pa", + "na", + "μa", + "ma", + "ka", + "kb", + "mb", + "gb", + "cal", + "kcal", + "pf", + "nf", + "μf", + "μg", + "mg", + "kg", + "hz", + "khz", + "mhz", + "ghz", + "thz", + "μl", + "ml", + "dl", + "kl", + "fm", + "nm", + "μm", + "mm", + "cm", + "km", + "mm2", + "cm2", + "m2", + "km2", + "mm3", + "cm3", + "m3", + "km3", + "m∕s", + "m∕s2", + "pa", + "kpa", + "mpa", + "gpa", + "rad", + "rad∕s", + "rad∕s2", + "ps", + "ns", + "μs", + "ms", + "pv", + "nv", + "μv", + "mv", + "kv", + "mv", + "pw", + "nw", + "μw", + "mw", + "kw", + "mw", + "kω", + "mω", + None, + "bq", + "cc", + "cd", + "c∕kg", + None, + "db", + "gy", + "ha", + "hp", + "in", + "kk", + "km", + "kt", + "lm", + "ln", + "log", + "lx", + "mb", + "mil", + "mol", + "ph", + None, + "ppm", + "pr", + "sr", + "sv", + "wb", + "v∕m", + "a∕m", + "1日", + "2日", + "3日", + "4日", + "5日", + "6日", + "7日", + "8日", + "9日", + "10日", + "11日", + "12日", + "13日", + "14日", + "15日", + "16日", + "17日", + "18日", + "19日", + "20日", + "21日", + "22日", + "23日", + "24日", + "25日", + "26日", + "27日", + "28日", + "29日", + "30日", + "31日", + "gal", + None, + None, + None, + None, + None, + None, + "ꙁ", + None, + "ꙃ", + None, + "ꙅ", + None, + "ꙇ", + None, + "ꙉ", + None, + "ꙋ", + None, + "ꙍ", + None, + "ꙏ", + None, + "ꙑ", + None, + "ꙓ", + None, + "ꙕ", + None, + "ꙗ", + None, + "ꙙ", + None, + "ꙛ", + None, + "ꙝ", + None, + "ꙟ", + None, + "ꙡ", + None, + "ꙣ", + None, + "ꙥ", + None, + "ꙧ", + None, + "ꙩ", + None, + "ꙫ", + None, + "ꙭ", + None, + "ꚁ", + None, + "ꚃ", + None, + "ꚅ", + None, + "ꚇ", + None, + "ꚉ", + None, + "ꚋ", + None, + "ꚍ", + None, + "ꚏ", + None, + "ꚑ", + None, + "ꚓ", + None, + "ꚕ", + None, + "ꚗ", + None, + "ꚙ", + None, + "ꚛ", + None, + "ъ", + "ь", + None, + None, + None, + "ꜣ", + None, + "ꜥ", + None, + "ꜧ", + None, + "ꜩ", + None, + "ꜫ", + None, + "ꜭ", + None, + "ꜯ", + None, + "ꜳ", + None, + "ꜵ", + None, + "ꜷ", + None, + "ꜹ", + None, + "ꜻ", + None, + "ꜽ", + None, + "ꜿ", + None, + "ꝁ", + None, + "ꝃ", + None, + "ꝅ", + None, + "ꝇ", + None, + "ꝉ", + None, + "ꝋ", + None, + "ꝍ", + None, + "ꝏ", + None, + "ꝑ", + None, + "ꝓ", + None, + "ꝕ", + None, + "ꝗ", + None, + "ꝙ", + None, + "ꝛ", + None, + "ꝝ", + None, + "ꝟ", + None, + "ꝡ", + None, + "ꝣ", + None, + "ꝥ", + None, + "ꝧ", + None, + "ꝩ", + None, + "ꝫ", + None, + "ꝭ", + None, + "ꝯ", + None, + "ꝯ", + None, + "ꝺ", + None, + "ꝼ", + None, + "ᵹ", + "ꝿ", + None, + "ꞁ", + None, + "ꞃ", + None, + "ꞅ", + None, + "ꞇ", + None, + "ꞌ", + None, + "ɥ", + None, + "ꞑ", + None, + "ꞓ", + None, + "ꞗ", + None, + "ꞙ", + None, + "ꞛ", + None, + "ꞝ", + None, + "ꞟ", + None, + "ꞡ", + None, + "ꞣ", + None, + "ꞥ", + None, + "ꞧ", + None, + "ꞩ", + None, + "ɦ", + "ɜ", + "ɡ", + "ɬ", + "ɪ", + None, + "ʞ", + "ʇ", + "ʝ", + "ꭓ", + "ꞵ", + None, + "ꞷ", + None, + "ꞹ", + None, + "ꞻ", + None, + "ꞽ", + None, + "ꞿ", + None, + "ꟁ", + None, + "ꟃ", + None, + "ꞔ", + "ʂ", + "ᶎ", + "ꟈ", + None, + "ꟊ", + None, + "ɤ", + "\ua7cd", + None, + "\ua7cf", + None, + "ꟑ", + None, + "ꟓ", + None, + "ꟕ", + None, + "ꟗ", + None, + "ꟙ", + None, + "\ua7db", + None, + "ƛ", + None, + "s", + "c", + "f", + "q", + "ꟶ", + None, + "ħ", + "œ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ꜧ", + "ꬷ", + "ɫ", + "ꭒ", + None, + "ʍ", + None, + None, + "Ꭰ", + "Ꭱ", + "Ꭲ", + "Ꭳ", + "Ꭴ", + "Ꭵ", + "Ꭶ", + "Ꭷ", + "Ꭸ", + "Ꭹ", + "Ꭺ", + "Ꭻ", + "Ꭼ", + "Ꭽ", + "Ꭾ", + "Ꭿ", + "Ꮀ", + "Ꮁ", + "Ꮂ", + "Ꮃ", + "Ꮄ", + "Ꮅ", + "Ꮆ", + "Ꮇ", + "Ꮈ", + "Ꮉ", + "Ꮊ", + "Ꮋ", + "Ꮌ", + "Ꮍ", + "Ꮎ", + "Ꮏ", + "Ꮐ", + "Ꮑ", + "Ꮒ", + "Ꮓ", + "Ꮔ", + "Ꮕ", + "Ꮖ", + "Ꮗ", + "Ꮘ", + "Ꮙ", + "Ꮚ", + "Ꮛ", + "Ꮜ", + "Ꮝ", + "Ꮞ", + "Ꮟ", + "Ꮠ", + "Ꮡ", + "Ꮢ", + "Ꮣ", + "Ꮤ", + "Ꮥ", + "Ꮦ", + "Ꮧ", + "Ꮨ", + "Ꮩ", + "Ꮪ", + "Ꮫ", + "Ꮬ", + "Ꮭ", + "Ꮮ", + "Ꮯ", + "Ꮰ", + "Ꮱ", + "Ꮲ", + "Ꮳ", + "Ꮴ", + "Ꮵ", + "Ꮶ", + "Ꮷ", + "Ꮸ", + "Ꮹ", + "Ꮺ", + "Ꮻ", + "Ꮼ", + "Ꮽ", + "Ꮾ", + "Ꮿ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "豈", + "更", + "車", + "賈", + "滑", + "串", + "句", + "龜", + "契", + "金", + "喇", + "奈", + "懶", + "癩", + "羅", + "蘿", + "螺", + "裸", + "邏", + "樂", + "洛", + "烙", + "珞", + "落", + "酪", + "駱", + "亂", + "卵", + "欄", + "爛", + "蘭", + "鸞", + "嵐", + "濫", + "藍", + "襤", + "拉", + "臘", + "蠟", + "廊", + "朗", + "浪", + "狼", + "郎", + "來", + "冷", + "勞", + "擄", + "櫓", + "爐", + "盧", + "老", + "蘆", + "虜", + "路", + "露", + "魯", + "鷺", + "碌", + "祿", + "綠", + "菉", + "錄", + "鹿", + "論", + "壟", + "弄", + "籠", + "聾", + "牢", + "磊", + "賂", + "雷", + "壘", + "屢", + "樓", + "淚", + "漏", + "累", + "縷", + "陋", + "勒", + "肋", + "凜", + "凌", + "稜", + "綾", + "菱", + "陵", + "讀", + "拏", + "樂", + "諾", + "丹", + "寧", + "怒", + "率", + "異", + "北", + "磻", + "便", + "復", + "不", + "泌", + "數", + "索", + "參", + "塞", + "省", + "葉", + "說", + "殺", + "辰", + "沈", + "拾", + "若", + "掠", + "略", + "亮", + "兩", + "凉", + "梁", + "糧", + "良", + "諒", + "量", + "勵", + "呂", + "女", + "廬", + "旅", + "濾", + "礪", + "閭", + "驪", + "麗", + "黎", + "力", + "曆", + "歷", + "轢", + "年", + "憐", + "戀", + "撚", + "漣", + "煉", + "璉", + "秊", + "練", + "聯", + "輦", + "蓮", + "連", + "鍊", + "列", + "劣", + "咽", + "烈", + "裂", + "說", + "廉", + "念", + "捻", + "殮", + "簾", + "獵", + "令", + "囹", + "寧", + "嶺", + "怜", + "玲", + "瑩", + "羚", + "聆", + "鈴", + "零", + "靈", + "領", + "例", + "禮", + "醴", + "隸", + "惡", + "了", + "僚", + "寮", + "尿", + "料", + "樂", + "燎", + "療", + "蓼", + "遼", + "龍", + "暈", + "阮", + "劉", + "杻", + "柳", + "流", + "溜", + "琉", + "留", + "硫", + "紐", + "類", + "六", + "戮", + "陸", + "倫", + "崙", + "淪", + "輪", + "律", + "慄", + "栗", + "率", + "隆", + "利", + "吏", + "履", + "易", + "李", + "梨", + "泥", + "理", + "痢", + "罹", + "裏", + "裡", + "里", + "離", + "匿", + "溺", + "吝", + "燐", + "璘", + "藺", + "隣", + "鱗", + "麟", + "林", + "淋", + "臨", + "立", + "笠", + "粒", + "狀", + "炙", + "識", + "什", + "茶", + "刺", + "切", + "度", + "拓", + "糖", + "宅", + "洞", + "暴", + "輻", + "行", + "降", + "見", + "廓", + "兀", + "嗀", + None, + "塚", + None, + "晴", + None, + "凞", + "猪", + "益", + "礼", + "神", + "祥", + "福", + "靖", + "精", + "羽", + None, + "蘒", + None, + "諸", + None, + "逸", + "都", + None, + "飯", + "飼", + "館", + "鶴", + "郞", + "隷", + "侮", + "僧", + "免", + "勉", + "勤", + "卑", + "喝", + "嘆", + "器", + "塀", + "墨", + "層", + "屮", + "悔", + "慨", + "憎", + "懲", + "敏", + "既", + "暑", + "梅", + "海", + "渚", + "漢", + "煮", + "爫", + "琢", + "碑", + "社", + "祉", + "祈", + "祐", + "祖", + "祝", + "禍", + "禎", + "穀", + "突", + "節", + "練", + "縉", + "繁", + "署", + "者", + "臭", + "艹", + "著", + "褐", + "視", + "謁", + "謹", + "賓", + "贈", + "辶", + "逸", + "難", + "響", + "頻", + "恵", + "𤋮", + "舘", + None, + "並", + "况", + "全", + "侀", + "充", + "冀", + "勇", + "勺", + "喝", + "啕", + "喙", + "嗢", + "塚", + "墳", + "奄", + "奔", + "婢", + "嬨", + "廒", + "廙", + "彩", + "徭", + "惘", + "慎", + "愈", + "憎", + "慠", + "懲", + "戴", + "揄", + "搜", + "摒", + "敖", + "晴", + "朗", + "望", + "杖", + "歹", + "殺", + "流", + "滛", + "滋", + "漢", + "瀞", + "煮", + "瞧", + "爵", + "犯", + "猪", + "瑱", + "甆", + "画", + "瘝", + "瘟", + "益", + "盛", + "直", + "睊", + "着", + "磌", + "窱", + "節", + "类", + "絛", + "練", + "缾", + "者", + "荒", + "華", + "蝹", + "襁", + "覆", + "視", + "調", + "諸", + "請", + "謁", + "諾", + "諭", + "謹", + "變", + "贈", + "輸", + "遲", + "醙", + "鉶", + "陼", + "難", + "靖", + "韛", + "響", + "頋", + "頻", + "鬒", + "龜", + "𢡊", + "𢡄", + "𣏕", + "㮝", + "䀘", + "䀹", + "𥉉", + "𥳐", + "𧻓", + "齃", + "龎", + None, + "ff", + "fi", + "fl", + "ffi", + "ffl", + "st", + None, + "մն", + "մե", + "մի", + "վն", + "մխ", + None, + "יִ", + None, + "ײַ", + "ע", + "א", + "ד", + "ה", + "כ", + "ל", + "ם", + "ר", + "ת", + "+", + "שׁ", + "שׂ", + "שּׁ", + "שּׂ", + "אַ", + "אָ", + "אּ", + "בּ", + "גּ", + "דּ", + "הּ", + "וּ", + "זּ", + None, + "טּ", + "יּ", + "ךּ", + "כּ", + "לּ", + None, + "מּ", + None, + "נּ", + "סּ", + None, + "ףּ", + "פּ", + None, + "צּ", + "קּ", + "רּ", + "שּ", + "תּ", + "וֹ", + "בֿ", + "כֿ", + "פֿ", + "אל", + "ٱ", + "ٻ", + "پ", + "ڀ", + "ٺ", + "ٿ", + "ٹ", + "ڤ", + "ڦ", + "ڄ", + "ڃ", + "چ", + "ڇ", + "ڍ", + "ڌ", + "ڎ", + "ڈ", + "ژ", + "ڑ", + "ک", + "گ", + "ڳ", + "ڱ", + "ں", + "ڻ", + "ۀ", + "ہ", + "ھ", + "ے", + "ۓ", + None, + "ڭ", + "ۇ", + "ۆ", + "ۈ", + "ۇٴ", + "ۋ", + "ۅ", + "ۉ", + "ې", + "ى", + "ئا", + "ئە", + "ئو", + "ئۇ", + "ئۆ", + "ئۈ", + "ئې", + "ئى", + "ی", + "ئج", + "ئح", + "ئم", + "ئى", + "ئي", + "بج", + "بح", + "بخ", + "بم", + "بى", + "بي", + "تج", + "تح", + "تخ", + "تم", + "تى", + "تي", + "ثج", + "ثم", + "ثى", + "ثي", + "جح", + "جم", + "حج", + "حم", + "خج", + "خح", + "خم", + "سج", + "سح", + "سخ", + "سم", + "صح", + "صم", + "ضج", + "ضح", + "ضخ", + "ضم", + "طح", + "طم", + "ظم", + "عج", + "عم", + "غج", + "غم", + "فج", + "فح", + "فخ", + "فم", + "فى", + "في", + "قح", + "قم", + "قى", + "قي", + "كا", + "كج", + "كح", + "كخ", + "كل", + "كم", + "كى", + "كي", + "لج", + "لح", + "لخ", + "لم", + "لى", + "لي", + "مج", + "مح", + "مخ", + "مم", + "مى", + "مي", + "نج", + "نح", + "نخ", + "نم", + "نى", + "ني", + "هج", + "هم", + "هى", + "هي", + "يج", + "يح", + "يخ", + "يم", + "يى", + "يي", + "ذٰ", + "رٰ", + "ىٰ", + " ٌّ", + " ٍّ", + " َّ", + " ُّ", + " ِّ", + " ّٰ", + "ئر", + "ئز", + "ئم", + "ئن", + "ئى", + "ئي", + "بر", + "بز", + "بم", + "بن", + "بى", + "بي", + "تر", + "تز", + "تم", + "تن", + "تى", + "تي", + "ثر", + "ثز", + "ثم", + "ثن", + "ثى", + "ثي", + "فى", + "في", + "قى", + "قي", + "كا", + "كل", + "كم", + "كى", + "كي", + "لم", + "لى", + "لي", + "ما", + "مم", + "نر", + "نز", + "نم", + "نن", + "نى", + "ني", + "ىٰ", + "ير", + "يز", + "يم", + "ين", + "يى", + "يي", + "ئج", + "ئح", + "ئخ", + "ئم", + "ئه", + "بج", + "بح", + "بخ", + "بم", + "به", + "تج", + "تح", + "تخ", + "تم", + "ته", + "ثم", + "جح", + "جم", + "حج", + "حم", + "خج", + "خم", + "سج", + "سح", + "سخ", + "سم", + "صح", + "صخ", + "صم", + "ضج", + "ضح", + "ضخ", + "ضم", + "طح", + "ظم", + "عج", + "عم", + "غج", + "غم", + "فج", + "فح", + "فخ", + "فم", + "قح", + "قم", + "كج", + "كح", + "كخ", + "كل", + "كم", + "لج", + "لح", + "لخ", + "لم", + "له", + "مج", + "مح", + "مخ", + "مم", + "نج", + "نح", + "نخ", + "نم", + "نه", + "هج", + "هم", + "هٰ", + "يج", + "يح", + "يخ", + "يم", + "يه", + "ئم", + "ئه", + "بم", + "به", + "تم", + "ته", + "ثم", + "ثه", + "سم", + "سه", + "شم", + "شه", + "كل", + "كم", + "لم", + "نم", + "نه", + "يم", + "يه", + "ـَّ", + "ـُّ", + "ـِّ", + "طى", + "طي", + "عى", + "عي", + "غى", + "غي", + "سى", + "سي", + "شى", + "شي", + "حى", + "حي", + "جى", + "جي", + "خى", + "خي", + "صى", + "صي", + "ضى", + "ضي", + "شج", + "شح", + "شخ", + "شم", + "شر", + "سر", + "صر", + "ضر", + "طى", + "طي", + "عى", + "عي", + "غى", + "غي", + "سى", + "سي", + "شى", + "شي", + "حى", + "حي", + "جى", + "جي", + "خى", + "خي", + "صى", + "صي", + "ضى", + "ضي", + "شج", + "شح", + "شخ", + "شم", + "شر", + "سر", + "صر", + "ضر", + "شج", + "شح", + "شخ", + "شم", + "سه", + "شه", + "طم", + "سج", + "سح", + "سخ", + "شج", + "شح", + "شخ", + "طم", + "ظم", + "اً", + None, + "تجم", + "تحج", + "تحم", + "تخم", + "تمج", + "تمح", + "تمخ", + "جمح", + "حمي", + "حمى", + "سحج", + "سجح", + "سجى", + "سمح", + "سمج", + "سمم", + "صحح", + "صمم", + "شحم", + "شجي", + "شمخ", + "شمم", + "ضحى", + "ضخم", + "طمح", + "طمم", + "طمي", + "عجم", + "عمم", + "عمى", + "غمم", + "غمي", + "غمى", + "فخم", + "قمح", + "قمم", + "لحم", + "لحي", + "لحى", + "لجج", + "لخم", + "لمح", + "محج", + "محم", + "محي", + "مجح", + "مجم", + "مخج", + "مخم", + None, + "مجخ", + "همج", + "همم", + "نحم", + "نحى", + "نجم", + "نجى", + "نمي", + "نمى", + "يمم", + "بخي", + "تجي", + "تجى", + "تخي", + "تخى", + "تمي", + "تمى", + "جمي", + "جحى", + "جمى", + "سخى", + "صحي", + "شحي", + "ضحي", + "لجي", + "لمي", + "يحي", + "يجي", + "يمي", + "ممي", + "قمي", + "نحي", + "قمح", + "لحم", + "عمي", + "كمي", + "نجح", + "مخي", + "لجم", + "كمم", + "لجم", + "نجح", + "جحي", + "حجي", + "مجي", + "فمي", + "بحي", + "كمم", + "عجم", + "صمم", + "سخي", + "نجي", + None, + None, + "صلے", + "قلے", + "الله", + "اكبر", + "محمد", + "صلعم", + "رسول", + "عليه", + "وسلم", + "صلى", + "صلى الله عليه وسلم", + "جل جلاله", + "ریال", + None, + None, + ",", + "、", + None, + ":", + ";", + "!", + "?", + "〖", + "〗", + None, + None, + None, + "—", + "–", + "_", + "(", + ")", + "{", + "}", + "〔", + "〕", + "【", + "】", + "《", + "》", + "〈", + "〉", + "「", + "」", + "『", + "』", + None, + "[", + "]", + " ̅", + "_", + ",", + "、", + None, + ";", + ":", + "?", + "!", + "—", + "(", + ")", + "{", + "}", + "〔", + "〕", + "#", + "&", + "*", + "+", + "-", + "<", + ">", + "=", + None, + "\\", + "$", + "%", + "@", + None, + " ً", + "ـً", + " ٌ", + None, + " ٍ", + None, + " َ", + "ـَ", + " ُ", + "ـُ", + " ِ", + "ـِ", + " ّ", + "ـّ", + " ْ", + "ـْ", + "ء", + "آ", + "أ", + "ؤ", + "إ", + "ئ", + "ا", + "ب", + "ة", + "ت", + "ث", + "ج", + "ح", + "خ", + "د", + "ذ", + "ر", + "ز", + "س", + "ش", + "ص", + "ض", + "ط", + "ظ", + "ع", + "غ", + "ف", + "ق", + "ك", + "ل", + "م", + "ن", + "ه", + "و", + "ى", + "ي", + "لآ", + "لأ", + "لإ", + "لا", + None, + None, + None, + "!", + '"', + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "[", + "\\", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + "⦅", + "⦆", + ".", + "「", + "」", + "、", + "・", + "ヲ", + "ァ", + "ィ", + "ゥ", + "ェ", + "ォ", + "ャ", + "ュ", + "ョ", + "ッ", + "ー", + "ア", + "イ", + "ウ", + "エ", + "オ", + "カ", + "キ", + "ク", + "ケ", + "コ", + "サ", + "シ", + "ス", + "セ", + "ソ", + "タ", + "チ", + "ツ", + "テ", + "ト", + "ナ", + "ニ", + "ヌ", + "ネ", + "ノ", + "ハ", + "ヒ", + "フ", + "ヘ", + "ホ", + "マ", + "ミ", + "ム", + "メ", + "モ", + "ヤ", + "ユ", + "ヨ", + "ラ", + "リ", + "ル", + "レ", + "ロ", + "ワ", + "ン", + "゙", + "゚", + None, + "ᄀ", + "ᄁ", + "ᆪ", + "ᄂ", + "ᆬ", + "ᆭ", + "ᄃ", + "ᄄ", + "ᄅ", + "ᆰ", + "ᆱ", + "ᆲ", + "ᆳ", + "ᆴ", + "ᆵ", + "ᄚ", + "ᄆ", + "ᄇ", + "ᄈ", + "ᄡ", + "ᄉ", + "ᄊ", + "ᄋ", + "ᄌ", + "ᄍ", + "ᄎ", + "ᄏ", + "ᄐ", + "ᄑ", + "ᄒ", + None, + "ᅡ", + "ᅢ", + "ᅣ", + "ᅤ", + "ᅥ", + "ᅦ", + None, + "ᅧ", + "ᅨ", + "ᅩ", + "ᅪ", + "ᅫ", + "ᅬ", + None, + "ᅭ", + "ᅮ", + "ᅯ", + "ᅰ", + "ᅱ", + "ᅲ", + None, + "ᅳ", + "ᅴ", + "ᅵ", + None, + "¢", + "£", + "¬", + " ̄", + "¦", + "¥", + "₩", + None, + "│", + "←", + "↑", + "→", + "↓", + "■", + "○", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "𐐨", + "𐐩", + "𐐪", + "𐐫", + "𐐬", + "𐐭", + "𐐮", + "𐐯", + "𐐰", + "𐐱", + "𐐲", + "𐐳", + "𐐴", + "𐐵", + "𐐶", + "𐐷", + "𐐸", + "𐐹", + "𐐺", + "𐐻", + "𐐼", + "𐐽", + "𐐾", + "𐐿", + "𐑀", + "𐑁", + "𐑂", + "𐑃", + "𐑄", + "𐑅", + "𐑆", + "𐑇", + "𐑈", + "𐑉", + "𐑊", + "𐑋", + "𐑌", + "𐑍", + "𐑎", + "𐑏", + None, + None, + None, + None, + "𐓘", + "𐓙", + "𐓚", + "𐓛", + "𐓜", + "𐓝", + "𐓞", + "𐓟", + "𐓠", + "𐓡", + "𐓢", + "𐓣", + "𐓤", + "𐓥", + "𐓦", + "𐓧", + "𐓨", + "𐓩", + "𐓪", + "𐓫", + "𐓬", + "𐓭", + "𐓮", + "𐓯", + "𐓰", + "𐓱", + "𐓲", + "𐓳", + "𐓴", + "𐓵", + "𐓶", + "𐓷", + "𐓸", + "𐓹", + "𐓺", + "𐓻", + None, + None, + None, + None, + None, + None, + None, + None, + "𐖗", + "𐖘", + "𐖙", + "𐖚", + "𐖛", + "𐖜", + "𐖝", + "𐖞", + "𐖟", + "𐖠", + "𐖡", + None, + "𐖣", + "𐖤", + "𐖥", + "𐖦", + "𐖧", + "𐖨", + "𐖩", + "𐖪", + "𐖫", + "𐖬", + "𐖭", + "𐖮", + "𐖯", + "𐖰", + "𐖱", + None, + "𐖳", + "𐖴", + "𐖵", + "𐖶", + "𐖷", + "𐖸", + "𐖹", + None, + "𐖻", + "𐖼", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ː", + "ˑ", + "æ", + "ʙ", + "ɓ", + None, + "ʣ", + "ꭦ", + "ʥ", + "ʤ", + "ɖ", + "ɗ", + "ᶑ", + "ɘ", + "ɞ", + "ʩ", + "ɤ", + "ɢ", + "ɠ", + "ʛ", + "ħ", + "ʜ", + "ɧ", + "ʄ", + "ʪ", + "ʫ", + "ɬ", + "𝼄", + "ꞎ", + "ɮ", + "𝼅", + "ʎ", + "𝼆", + "ø", + "ɶ", + "ɷ", + "q", + "ɺ", + "𝼈", + "ɽ", + "ɾ", + "ʀ", + "ʨ", + "ʦ", + "ꭧ", + "ʧ", + "ʈ", + "ⱱ", + None, + "ʏ", + "ʡ", + "ʢ", + "ʘ", + "ǀ", + "ǁ", + "ǂ", + "𝼊", + "𝼞", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "𐳀", + "𐳁", + "𐳂", + "𐳃", + "𐳄", + "𐳅", + "𐳆", + "𐳇", + "𐳈", + "𐳉", + "𐳊", + "𐳋", + "𐳌", + "𐳍", + "𐳎", + "𐳏", + "𐳐", + "𐳑", + "𐳒", + "𐳓", + "𐳔", + "𐳕", + "𐳖", + "𐳗", + "𐳘", + "𐳙", + "𐳚", + "𐳛", + "𐳜", + "𐳝", + "𐳞", + "𐳟", + "𐳠", + "𐳡", + "𐳢", + "𐳣", + "𐳤", + "𐳥", + "𐳦", + "𐳧", + "𐳨", + "𐳩", + "𐳪", + "𐳫", + "𐳬", + "𐳭", + "𐳮", + "𐳯", + "𐳰", + "𐳱", + "𐳲", + None, + None, + None, + None, + None, + None, + None, + None, + "\U00010d70", + "\U00010d71", + "\U00010d72", + "\U00010d73", + "\U00010d74", + "\U00010d75", + "\U00010d76", + "\U00010d77", + "\U00010d78", + "\U00010d79", + "\U00010d7a", + "\U00010d7b", + "\U00010d7c", + "\U00010d7d", + "\U00010d7e", + "\U00010d7f", + "\U00010d80", + "\U00010d81", + "\U00010d82", + "\U00010d83", + "\U00010d84", + "\U00010d85", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "𑣀", + "𑣁", + "𑣂", + "𑣃", + "𑣄", + "𑣅", + "𑣆", + "𑣇", + "𑣈", + "𑣉", + "𑣊", + "𑣋", + "𑣌", + "𑣍", + "𑣎", + "𑣏", + "𑣐", + "𑣑", + "𑣒", + "𑣓", + "𑣔", + "𑣕", + "𑣖", + "𑣗", + "𑣘", + "𑣙", + "𑣚", + "𑣛", + "𑣜", + "𑣝", + "𑣞", + "𑣟", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "𖹠", + "𖹡", + "𖹢", + "𖹣", + "𖹤", + "𖹥", + "𖹦", + "𖹧", + "𖹨", + "𖹩", + "𖹪", + "𖹫", + "𖹬", + "𖹭", + "𖹮", + "𖹯", + "𖹰", + "𖹱", + "𖹲", + "𖹳", + "𖹴", + "𖹵", + "𖹶", + "𖹷", + "𖹸", + "𖹹", + "𖹺", + "𖹻", + "𖹼", + "𖹽", + "𖹾", + "𖹿", + None, + None, + "\U00016ebb", + "\U00016ebc", + "\U00016ebd", + "\U00016ebe", + "\U00016ebf", + "\U00016ec0", + "\U00016ec1", + "\U00016ec2", + "\U00016ec3", + "\U00016ec4", + "\U00016ec5", + "\U00016ec6", + "\U00016ec7", + "\U00016ec8", + "\U00016ec9", + "\U00016eca", + "\U00016ecb", + "\U00016ecc", + "\U00016ecd", + "\U00016ece", + "\U00016ecf", + "\U00016ed0", + "\U00016ed1", + "\U00016ed2", + "\U00016ed3", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "𝅗𝅥", + "𝅘𝅥", + "𝅘𝅥𝅮", + "𝅘𝅥𝅯", + "𝅘𝅥𝅰", + "𝅘𝅥𝅱", + "𝅘𝅥𝅲", + None, + None, + None, + "𝆹𝅥", + "𝆺𝅥", + "𝆹𝅥𝅮", + "𝆺𝅥𝅮", + "𝆹𝅥𝅯", + "𝆺𝅥𝅯", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + None, + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + None, + "c", + "d", + None, + "g", + None, + "j", + "k", + None, + "n", + "o", + "p", + "q", + None, + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + None, + "f", + None, + "h", + "i", + "j", + "k", + "l", + "m", + "n", + None, + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + None, + "d", + "e", + "f", + "g", + None, + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + None, + "s", + "t", + "u", + "v", + "w", + "x", + "y", + None, + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + None, + "d", + "e", + "f", + "g", + None, + "i", + "j", + "k", + "l", + "m", + None, + "o", + None, + "s", + "t", + "u", + "v", + "w", + "x", + "y", + None, + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "ı", + "ȷ", + None, + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "θ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∇", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∂", + "ε", + "θ", + "κ", + "φ", + "ρ", + "π", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "θ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∇", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∂", + "ε", + "θ", + "κ", + "φ", + "ρ", + "π", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "θ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∇", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∂", + "ε", + "θ", + "κ", + "φ", + "ρ", + "π", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "θ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∇", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∂", + "ε", + "θ", + "κ", + "φ", + "ρ", + "π", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "θ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∇", + "α", + "β", + "γ", + "δ", + "ε", + "ζ", + "η", + "θ", + "ι", + "κ", + "λ", + "μ", + "ν", + "ξ", + "ο", + "π", + "ρ", + "σ", + "τ", + "υ", + "φ", + "χ", + "ψ", + "ω", + "∂", + "ε", + "θ", + "κ", + "φ", + "ρ", + "π", + "ϝ", + None, + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "а", + "б", + "в", + "г", + "д", + "е", + "ж", + "з", + "и", + "к", + "л", + "м", + "о", + "п", + "р", + "с", + "т", + "у", + "ф", + "х", + "ц", + "ч", + "ш", + "ы", + "э", + "ю", + "ꚉ", + "ә", + "і", + "ј", + "ө", + "ү", + "ӏ", + "а", + "б", + "в", + "г", + "д", + "е", + "ж", + "з", + "и", + "к", + "л", + "о", + "п", + "с", + "у", + "ф", + "х", + "ц", + "ч", + "ш", + "ъ", + "ы", + "ґ", + "і", + "ѕ", + "џ", + "ҫ", + "ꙑ", + "ұ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "𞤢", + "𞤣", + "𞤤", + "𞤥", + "𞤦", + "𞤧", + "𞤨", + "𞤩", + "𞤪", + "𞤫", + "𞤬", + "𞤭", + "𞤮", + "𞤯", + "𞤰", + "𞤱", + "𞤲", + "𞤳", + "𞤴", + "𞤵", + "𞤶", + "𞤷", + "𞤸", + "𞤹", + "𞤺", + "𞤻", + "𞤼", + "𞤽", + "𞤾", + "𞤿", + "𞥀", + "𞥁", + "𞥂", + "𞥃", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "ا", + "ب", + "ج", + "د", + None, + "و", + "ز", + "ح", + "ط", + "ي", + "ك", + "ل", + "م", + "ن", + "س", + "ع", + "ف", + "ص", + "ق", + "ر", + "ش", + "ت", + "ث", + "خ", + "ذ", + "ض", + "ظ", + "غ", + "ٮ", + "ں", + "ڡ", + "ٯ", + None, + "ب", + "ج", + None, + "ه", + None, + "ح", + None, + "ي", + "ك", + "ل", + "م", + "ن", + "س", + "ع", + "ف", + "ص", + "ق", + None, + "ش", + "ت", + "ث", + "خ", + None, + "ض", + None, + "غ", + None, + "ج", + None, + "ح", + None, + "ي", + None, + "ل", + None, + "ن", + "س", + "ع", + None, + "ص", + "ق", + None, + "ش", + None, + "خ", + None, + "ض", + None, + "غ", + None, + "ں", + None, + "ٯ", + None, + "ب", + "ج", + None, + "ه", + None, + "ح", + "ط", + "ي", + "ك", + None, + "م", + "ن", + "س", + "ع", + "ف", + "ص", + "ق", + None, + "ش", + "ت", + "ث", + "خ", + None, + "ض", + "ظ", + "غ", + "ٮ", + None, + "ڡ", + None, + "ا", + "ب", + "ج", + "د", + "ه", + "و", + "ز", + "ح", + "ط", + "ي", + None, + "ل", + "م", + "ن", + "س", + "ع", + "ف", + "ص", + "ق", + "ر", + "ش", + "ت", + "ث", + "خ", + "ذ", + "ض", + "ظ", + "غ", + None, + "ب", + "ج", + "د", + None, + "و", + "ز", + "ح", + "ط", + "ي", + None, + "ل", + "م", + "ن", + "س", + "ع", + "ف", + "ص", + "ق", + "ر", + "ش", + "ت", + "ث", + "خ", + "ذ", + "ض", + "ظ", + "غ", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "0,", + "1,", + "2,", + "3,", + "4,", + "5,", + "6,", + "7,", + "8,", + "9,", + None, + "(a)", + "(b)", + "(c)", + "(d)", + "(e)", + "(f)", + "(g)", + "(h)", + "(i)", + "(j)", + "(k)", + "(l)", + "(m)", + "(n)", + "(o)", + "(p)", + "(q)", + "(r)", + "(s)", + "(t)", + "(u)", + "(v)", + "(w)", + "(x)", + "(y)", + "(z)", + "〔s〕", + "c", + "r", + "cd", + "wz", + None, + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "hv", + "mv", + "sd", + "ss", + "ppv", + "wc", + None, + "mc", + "md", + "mr", + None, + "dj", + None, + None, + None, + "ほか", + "ココ", + "サ", + None, + "手", + "字", + "双", + "デ", + "二", + "多", + "解", + "天", + "交", + "映", + "無", + "料", + "前", + "後", + "再", + "新", + "初", + "終", + "生", + "販", + "声", + "吹", + "演", + "投", + "捕", + "一", + "三", + "遊", + "左", + "中", + "右", + "指", + "走", + "打", + "禁", + "空", + "合", + "満", + "有", + "月", + "申", + "割", + "営", + "配", + None, + "〔本〕", + "〔三〕", + "〔二〕", + "〔安〕", + "〔点〕", + "〔打〕", + "〔盗〕", + "〔勝〕", + "〔敗〕", + None, + "得", + "可", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + "丽", + "丸", + "乁", + "𠄢", + "你", + "侮", + "侻", + "倂", + "偺", + "備", + "僧", + "像", + "㒞", + "𠘺", + "免", + "兔", + "兤", + "具", + "𠔜", + "㒹", + "內", + "再", + "𠕋", + "冗", + "冤", + "仌", + "冬", + "况", + "𩇟", + "凵", + "刃", + "㓟", + "刻", + "剆", + "割", + "剷", + "㔕", + "勇", + "勉", + "勤", + "勺", + "包", + "匆", + "北", + "卉", + "卑", + "博", + "即", + "卽", + "卿", + "𠨬", + "灰", + "及", + "叟", + "𠭣", + "叫", + "叱", + "吆", + "咞", + "吸", + "呈", + "周", + "咢", + "哶", + "唐", + "啓", + "啣", + "善", + "喙", + "喫", + "喳", + "嗂", + "圖", + "嘆", + "圗", + "噑", + "噴", + "切", + "壮", + "城", + "埴", + "堍", + "型", + "堲", + "報", + "墬", + "𡓤", + "売", + "壷", + "夆", + "多", + "夢", + "奢", + "𡚨", + "𡛪", + "姬", + "娛", + "娧", + "姘", + "婦", + "㛮", + "㛼", + "嬈", + "嬾", + "𡧈", + "寃", + "寘", + "寧", + "寳", + "𡬘", + "寿", + "将", + "当", + "尢", + "㞁", + "屠", + "屮", + "峀", + "岍", + "𡷤", + "嵃", + "𡷦", + "嵮", + "嵫", + "嵼", + "巡", + "巢", + "㠯", + "巽", + "帨", + "帽", + "幩", + "㡢", + "𢆃", + "㡼", + "庰", + "庳", + "庶", + "廊", + "𪎒", + "廾", + "𢌱", + "舁", + "弢", + "㣇", + "𣊸", + "𦇚", + "形", + "彫", + "㣣", + "徚", + "忍", + "志", + "忹", + "悁", + "㤺", + "㤜", + "悔", + "𢛔", + "惇", + "慈", + "慌", + "慎", + "慌", + "慺", + "憎", + "憲", + "憤", + "憯", + "懞", + "懲", + "懶", + "成", + "戛", + "扝", + "抱", + "拔", + "捐", + "𢬌", + "挽", + "拼", + "捨", + "掃", + "揤", + "𢯱", + "搢", + "揅", + "掩", + "㨮", + "摩", + "摾", + "撝", + "摷", + "㩬", + "敏", + "敬", + "𣀊", + "旣", + "書", + "晉", + "㬙", + "暑", + "㬈", + "㫤", + "冒", + "冕", + "最", + "暜", + "肭", + "䏙", + "朗", + "望", + "朡", + "杞", + "杓", + "𣏃", + "㭉", + "柺", + "枅", + "桒", + "梅", + "𣑭", + "梎", + "栟", + "椔", + "㮝", + "楂", + "榣", + "槪", + "檨", + "𣚣", + "櫛", + "㰘", + "次", + "𣢧", + "歔", + "㱎", + "歲", + "殟", + "殺", + "殻", + "𣪍", + "𡴋", + "𣫺", + "汎", + "𣲼", + "沿", + "泍", + "汧", + "洖", + "派", + "海", + "流", + "浩", + "浸", + "涅", + "𣴞", + "洴", + "港", + "湮", + "㴳", + "滋", + "滇", + "𣻑", + "淹", + "潮", + "𣽞", + "𣾎", + "濆", + "瀹", + "瀞", + "瀛", + "㶖", + "灊", + "災", + "灷", + "炭", + "𠔥", + "煅", + "𤉣", + "熜", + "𤎫", + "爨", + "爵", + "牐", + "𤘈", + "犀", + "犕", + "𤜵", + "𤠔", + "獺", + "王", + "㺬", + "玥", + "㺸", + "瑇", + "瑜", + "瑱", + "璅", + "瓊", + "㼛", + "甤", + "𤰶", + "甾", + "𤲒", + "異", + "𢆟", + "瘐", + "𤾡", + "𤾸", + "𥁄", + "㿼", + "䀈", + "直", + "𥃳", + "𥃲", + "𥄙", + "𥄳", + "眞", + "真", + "睊", + "䀹", + "瞋", + "䁆", + "䂖", + "𥐝", + "硎", + "碌", + "磌", + "䃣", + "𥘦", + "祖", + "𥚚", + "𥛅", + "福", + "秫", + "䄯", + "穀", + "穊", + "穏", + "𥥼", + "𥪧", + "竮", + "䈂", + "𥮫", + "篆", + "築", + "䈧", + "𥲀", + "糒", + "䊠", + "糨", + "糣", + "紀", + "𥾆", + "絣", + "䌁", + "緇", + "縂", + "繅", + "䌴", + "𦈨", + "𦉇", + "䍙", + "𦋙", + "罺", + "𦌾", + "羕", + "翺", + "者", + "𦓚", + "𦔣", + "聠", + "𦖨", + "聰", + "𣍟", + "䏕", + "育", + "脃", + "䐋", + "脾", + "媵", + "𦞧", + "𦞵", + "𣎓", + "𣎜", + "舁", + "舄", + "辞", + "䑫", + "芑", + "芋", + "芝", + "劳", + "花", + "芳", + "芽", + "苦", + "𦬼", + "若", + "茝", + "荣", + "莭", + "茣", + "莽", + "菧", + "著", + "荓", + "菊", + "菌", + "菜", + "𦰶", + "𦵫", + "𦳕", + "䔫", + "蓱", + "蓳", + "蔖", + "𧏊", + "蕤", + "𦼬", + "䕝", + "䕡", + "𦾱", + "𧃒", + "䕫", + "虐", + "虜", + "虧", + "虩", + "蚩", + "蚈", + "蜎", + "蛢", + "蝹", + "蜨", + "蝫", + "螆", + "䗗", + "蟡", + "蠁", + "䗹", + "衠", + "衣", + "𧙧", + "裗", + "裞", + "䘵", + "裺", + "㒻", + "𧢮", + "𧥦", + "䚾", + "䛇", + "誠", + "諭", + "變", + "豕", + "𧲨", + "貫", + "賁", + "贛", + "起", + "𧼯", + "𠠄", + "跋", + "趼", + "跰", + "𠣞", + "軔", + "輸", + "𨗒", + "𨗭", + "邔", + "郱", + "鄑", + "𨜮", + "鄛", + "鈸", + "鋗", + "鋘", + "鉼", + "鏹", + "鐕", + "𨯺", + "開", + "䦕", + "閷", + "𨵷", + "䧦", + "雃", + "嶲", + "霣", + "𩅅", + "𩈚", + "䩮", + "䩶", + "韠", + "𩐊", + "䪲", + "𩒖", + "頋", + "頩", + "𩖶", + "飢", + "䬳", + "餩", + "馧", + "駂", + "駾", + "䯎", + "𩬰", + "鬒", + "鱀", + "鳽", + "䳎", + "䳭", + "鵧", + "𪃎", + "䳸", + "𪄅", + "𪈎", + "𪊑", + "麻", + "䵖", + "黹", + "黾", + "鼅", + "鼏", + "鼖", + "鼻", + "𪘀", + None, + None, + None, + None, + None, + None, + None, +) diff --git a/server/venv/Lib/site-packages/k5sprt64.dll b/server/venv/Lib/site-packages/k5sprt64.dll new file mode 100644 index 0000000..ec4eab3 Binary files /dev/null and b/server/venv/Lib/site-packages/k5sprt64.dll differ diff --git a/server/venv/Lib/site-packages/krb5_64.dll b/server/venv/Lib/site-packages/krb5_64.dll new file mode 100644 index 0000000..8cdd0bf Binary files /dev/null and b/server/venv/Lib/site-packages/krb5_64.dll differ diff --git a/server/venv/Lib/site-packages/krbcc64.dll b/server/venv/Lib/site-packages/krbcc64.dll new file mode 100644 index 0000000..9cf617d Binary files /dev/null and b/server/venv/Lib/site-packages/krbcc64.dll differ diff --git a/server/venv/Lib/site-packages/libcrypto-3-x64.dll b/server/venv/Lib/site-packages/libcrypto-3-x64.dll new file mode 100644 index 0000000..8441fd3 Binary files /dev/null and b/server/venv/Lib/site-packages/libcrypto-3-x64.dll differ diff --git a/server/venv/Lib/site-packages/libmysql.dll b/server/venv/Lib/site-packages/libmysql.dll new file mode 100644 index 0000000..1fffc7e Binary files /dev/null and b/server/venv/Lib/site-packages/libmysql.dll differ diff --git a/server/venv/Lib/site-packages/libsasl.dll b/server/venv/Lib/site-packages/libsasl.dll new file mode 100644 index 0000000..4af1df6 Binary files /dev/null and b/server/venv/Lib/site-packages/libsasl.dll differ diff --git a/server/venv/Lib/site-packages/libssl-3-x64.dll b/server/venv/Lib/site-packages/libssl-3-x64.dll new file mode 100644 index 0000000..4134cee Binary files /dev/null and b/server/venv/Lib/site-packages/libssl-3-x64.dll differ diff --git a/server/venv/Lib/site-packages/mysql/__init__.py b/server/venv/Lib/site-packages/mysql/__init__.py new file mode 100644 index 0000000..1f2326a --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/server/venv/Lib/site-packages/mysql/ai/__init__.py b/server/venv/Lib/site-packages/mysql/ai/__init__.py new file mode 100644 index 0000000..0efd1ec --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/server/venv/Lib/site-packages/mysql/ai/genai/__init__.py b/server/venv/Lib/site-packages/mysql/ai/genai/__init__.py new file mode 100644 index 0000000..700cb7f --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/genai/__init__.py @@ -0,0 +1,43 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""GenAI package for MySQL Connector/Python. + +Performs optional dependency checks and exposes public classes: +- MyEmbeddings +- MyLLM +- MyVectorStore +""" +from mysql.ai.utils import check_dependencies as _check_dependencies + +_check_dependencies(["GENAI"]) +del _check_dependencies + +from .embedding import MyEmbeddings +from .generation import MyLLM +from .vector_store import MyVectorStore diff --git a/server/venv/Lib/site-packages/mysql/ai/genai/embedding.py b/server/venv/Lib/site-packages/mysql/ai/genai/embedding.py new file mode 100644 index 0000000..1089484 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/genai/embedding.py @@ -0,0 +1,197 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Embeddings integration utilities for MySQL Connector/Python. + +Provides MyEmbeddings class to generate embeddings via MySQL HeatWave +using ML_EMBED_TABLE and ML_EMBED_ROW. +""" + +from typing import Dict, List, Optional + +import pandas as pd + +from langchain_core.embeddings import Embeddings +from pydantic import PrivateAttr + +from mysql.ai.utils import ( + atomic_transaction, + execute_sql, + format_value_sql, + source_schema, + sql_table_from_df, + sql_table_to_df, + temporary_sql_tables, +) +from mysql.connector.abstracts import MySQLConnectionAbstract + + +class MyEmbeddings(Embeddings): + """ + Embedding generator class that uses a MySQL database to compute embeddings for input text. + + This class batches input text into temporary SQL tables, invokes MySQL's ML_EMBED_TABLE + to generate embeddings, and retrieves the results as lists of floats. + + Attributes: + _db_connection (MySQLConnectionAbstract): MySQL connection used for all database operations. + schema_name (str): Name of the database schema to use. + options_placeholder (str): SQL-ready placeholder string for ML_EMBED_TABLE options. + options_params (dict): Dictionary of concrete option values to be passed as SQL parameters. + """ + + _db_connection: MySQLConnectionAbstract = PrivateAttr() + + def __init__( + self, db_connection: MySQLConnectionAbstract, options: Optional[Dict] = None + ): + """ + Initialize MyEmbeddings with a database connection and optional embedding parameters. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-row.html + A full list of supported options can be found under "options" + + NOTE: The supported "options" are the intersection of the options provided in + https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-row.html + https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-table.html + + Args: + db_connection: Active MySQL connector database connection. + options: Optional dictionary of options for embedding operations. + + Raises: + ValueError: If the schema name is not valid + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + super().__init__() + self._db_connection = db_connection + self.schema_name = source_schema(db_connection) + options = options or {} + self.options_placeholder, self.options_params = format_value_sql(options) + + def embed_documents(self, texts: List[str]) -> List[List[float]]: + """ + Generate embeddings for a list of input texts using the MySQL ML embedding procedure. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-table.html + + Args: + texts: List of input strings to embed. + + Returns: + List of lists of floats, with each inner list containing the embedding for a text. + + Raises: + DatabaseError: + If provided options are invalid or unsupported. + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: + If one or more text entries were unable to be embedded. + + Implementation notes: + - Creates a temporary table to pass input text to the MySQL embedding service. + - Adds a primary key to ensure results preserve input order. + - Calls ML_EMBED_TABLE and fetches the resulting embeddings. + - Deletes the temporary table after use to avoid polluting the database. + - Embedding vectors are extracted from the "embeddings" column of the result table. + """ + if not texts: + return [] + + df = pd.DataFrame({"id": range(len(texts)), "text": texts}) + + with ( + atomic_transaction(self._db_connection) as cursor, + temporary_sql_tables(self._db_connection) as temporary_tables, + ): + qualified_table_name, table_name = sql_table_from_df( + cursor, self.schema_name, df + ) + temporary_tables.append((self.schema_name, table_name)) + + # ML_EMBED_TABLE expects input/output columns and options as parameters + embed_query = ( + "CALL sys.ML_EMBED_TABLE(" + f"'{qualified_table_name}.text', " + f"'{qualified_table_name}.embeddings', " + f"{self.options_placeholder}" + ")" + ) + execute_sql(cursor, embed_query, params=self.options_params) + + # Read back all columns, including "embeddings" + df_embeddings = sql_table_to_df(cursor, self.schema_name, table_name) + + if df_embeddings["embeddings"].isnull().any() or any( + e is None for e in df_embeddings["embeddings"] + ): + raise ValueError( + "Failure to generate embeddings for one or more text entry." + ) + + # Convert fetched embeddings to lists of floats + embeddings = df_embeddings["embeddings"].tolist() + embeddings = [list(e) for e in embeddings] + + return embeddings + + def embed_query(self, text: str) -> List[float]: + """ + Generate an embedding for a single text string. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-embed-row.html + + Args: + text: The input string to embed. + + Returns: + List of floats representing the embedding vector. + + Raises: + DatabaseError: + If provided options are invalid or unsupported. + If a database connection issue occurs. + If an operational error occurs during execution. + + Example: + >>> MyEmbeddings(db_conn).embed_query("Hello world") + [0.1, 0.2, ...] + """ + with atomic_transaction(self._db_connection) as cursor: + execute_sql( + cursor, + f'SELECT sys.ML_EMBED_ROW("%s", {self.options_placeholder})', + params=(text, *self.options_params), + ) + return list(cursor.fetchone()[0]) diff --git a/server/venv/Lib/site-packages/mysql/ai/genai/generation.py b/server/venv/Lib/site-packages/mysql/ai/genai/generation.py new file mode 100644 index 0000000..c837acb --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/genai/generation.py @@ -0,0 +1,162 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""GenAI LLM integration utilities for MySQL Connector/Python. + +Provides MyLLM wrapper that issues ML_GENERATE calls via SQL. +""" + +import json + +from typing import Any, List, Optional + +try: + from langchain_core.language_models.llms import LLM +except ImportError: + from langchain.llms.base import LLM +from pydantic import PrivateAttr + +from mysql.ai.utils import atomic_transaction, execute_sql, format_value_sql +from mysql.connector.abstracts import MySQLConnectionAbstract + + +class MyLLM(LLM): + """ + Custom Large Language Model (LLM) interface for MySQL HeatWave. + + This class wraps the generation functionality provided by HeatWave LLMs, + exposing an interface compatible with common LLM APIs for text generation. + It provides full support for generative queries and limited support for + agentic queries. + + Attributes: + _db_connection (MySQLConnectionAbstract): + Underlying MySQL connector database connection. + """ + + _db_connection: MySQLConnectionAbstract = PrivateAttr() + + class Config: + """ + Pydantic config for the model. + + By default, LangChain (through Pydantic BaseModel) does not allow + setting or storing undeclared attributes such as _db_connection. + Setting extra = "allow" makes it possible to store extra attributes + on the class instance, which is required for MyLLM. + """ + + extra = "allow" + + def __init__(self, db_connection: MySQLConnectionAbstract): + """ + Initialize the MyLLM instance with an active MySQL database connection. + + Args: + db_connection: A MySQL connection object used to run LLM queries. + + Notes: + The db_connection is stored as a private attribute via object.__setattr__, + which is compatible with Pydantic models. + """ + super().__init__() + + self._db_connection = db_connection + + def _call( + self, + prompt: str, + stop: Optional[List[str]] = None, + **kwargs: Any, + ) -> str: + """ + Generate a text completion from the LLM for a given input prompt. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwgenai-ml-generate.html + A full list of supported options (specified by kwargs) can be found under "options" + + Args: + prompt: The input prompt string for the language model. + stop: Optional list of stop strings to support agentic and chain-of-thought + reasoning workflows. + **kwargs: Additional keyword arguments providing generation options to + the LLM (these are serialized to JSON and passed to the HeatWave syscall). + + Returns: + The generated model output as a string. + (The actual completion does NOT include the input prompt.) + + Raises: + DatabaseError: + If provided options are invalid or unsupported. + If a database connection issue occurs. + If an operational error occurs during execution. + + Implementation Notes: + - Serializes kwargs into a SQL-compatible JSON string. + - Calls the LLM stored procedure using a database cursor context. + - Uses `sys.ML_GENERATE` on the server to produce the model output. + - Expects the server response to be a JSON object with a 'text' key. + """ + options = kwargs.copy() + if stop is not None: + options["stop_sequences"] = stop + + options_placeholder, options_params = format_value_sql(options) + with atomic_transaction(self._db_connection) as cursor: + # The prompt is passed as a parameterized argument (avoids SQL injection). + generate_query = f"""SELECT sys.ML_GENERATE("%s", {options_placeholder});""" + execute_sql(cursor, generate_query, params=(prompt, *options_params)) + # Expect a JSON-encoded result from MySQL; parse to extract the output. + llm_response = json.loads(cursor.fetchone()[0])["text"] + + return llm_response + + @property + def _identifying_params(self) -> dict: + """ + Return a dictionary of params that uniquely identify this LLM instance. + + Returns: + dict: Dictionary of identifier parameters (should include + model_name for tracing/caching). + """ + return { + "model_name": "mysql_heatwave_llm", + } + + @property + def _llm_type(self) -> str: + """ + Get the type name of this LLM implementation. + + Returns: + A string identifying the LLM provider (used for logging or metrics). + """ + return "mysql_heatwave_llm" diff --git a/server/venv/Lib/site-packages/mysql/ai/genai/vector_store.py b/server/venv/Lib/site-packages/mysql/ai/genai/vector_store.py new file mode 100644 index 0000000..bb02634 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/genai/vector_store.py @@ -0,0 +1,520 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""MySQL-backed vector store for embeddings and semantic document retrieval. + +Provides a VectorStore implementation persisting documents, metadata, and +embeddings in MySQL, plus similarity search utilities. +""" + +import json + +from typing import Any, Iterable, List, Optional, Sequence, Union + +import pandas as pd + +from langchain_core.documents import Document +from langchain_core.embeddings import Embeddings +from langchain_core.vectorstores import VectorStore +from pydantic import PrivateAttr + +from mysql.ai.genai.embedding import MyEmbeddings +from mysql.ai.utils import ( + VAR_NAME_SPACE, + atomic_transaction, + delete_sql_table, + execute_sql, + extend_sql_table, + format_value_sql, + get_random_name, + is_table_empty, + source_schema, + table_exists, +) +from mysql.connector.abstracts import MySQLConnectionAbstract + +BASIC_EMBEDDING_QUERY = "Hello world!" +EMBEDDING_SOURCE = "external_source" + +VAR_EMBEDDING = f"{VAR_NAME_SPACE}.embedding" +VAR_CONTEXT = f"{VAR_NAME_SPACE}.context" +VAR_CONTEXT_MAP = f"{VAR_NAME_SPACE}.context_map" +VAR_RETRIEVAL_INFO = f"{VAR_NAME_SPACE}.retrieval_info" +VAR_OPTIONS = f"{VAR_NAME_SPACE}.options" + +ID_SPACE = "internal_ai_id_" + + +class MyVectorStore(VectorStore): + """ + MySQL-backed vector store for handling embeddings and semantic document retrieval. + + Supports adding, deleting, and searching high-dimensional vector representations + of documents using efficient storage and HeatWave ML similarity search procedures. + + Supports use as a context manager: when used in a `with` statement, all backing + tables/data are deleted automatically when the block exits (even on exception). + + Attributes: + db_connection (MySQLConnectionAbstract): Active MySQL database connection. + embedder (Embeddings): Embeddings generator for computing vector representations. + schema_name (str): SQL schema for table storage. + table_name (Optional[str]): Name of the active table backing the store + (or None until created). + embedding_dimension (int): Size of embedding vectors stored. + next_id (int): Internal counter for unique document ID generation. + """ + + _db_connection: MySQLConnectionAbstract = PrivateAttr() + _embedder: Embeddings = PrivateAttr() + _schema_name: str = PrivateAttr() + _table_name: Optional[str] = PrivateAttr() + _embedding_dimension: int = PrivateAttr() + _next_id: int = PrivateAttr() + + def __init__( + self, + db_connection: MySQLConnectionAbstract, + embedder: Optional[Embeddings] = None, + ) -> None: + """ + Initialize a MyVectorStore with a database connection and embedding generator. + + Args: + db_connection: MySQL database connection for all vector operations. + embedder: Embeddings generator used for creating and querying embeddings. + + Raises: + ValueError: If the schema name is not valid + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + super().__init__() + self._next_id = 0 + + self._schema_name = source_schema(db_connection) + self._embedder = embedder or MyEmbeddings(db_connection) + self._db_connection = db_connection + self._table_name: Optional[str] = None + + # Embedding dimension determined using an example call. + # Assumes embeddings have fixed length. + self._embedding_dimension = len( + self._embedder.embed_query(BASIC_EMBEDDING_QUERY) + ) + + def _get_ids(self, num_ids: int) -> list[str]: + """ + Generate a batch of unique internal document IDs for vector storage. + + Args: + num_ids: Number of IDs to create. + + Returns: + List of sequentially numbered internal string IDs. + """ + ids = [ + f"internal_ai_id_{i}" for i in range(self._next_id, self._next_id + num_ids) + ] + self._next_id += num_ids + return ids + + def _make_vector_store(self) -> None: + """ + Create a backing SQL table for storing vectors if not already created. + + Returns: + None + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + The table name is randomized to avoid collisions. + Schema includes content, metadata, and embedding vector. + """ + if self._table_name is None: + with atomic_transaction(self._db_connection) as cursor: + table_name = get_random_name( + lambda table_name: not table_exists( + cursor, self._schema_name, table_name + ) + ) + + create_table_stmt = f""" + CREATE TABLE {self._schema_name}.{table_name} ( + `id` VARCHAR(128) NOT NULL, + `content` TEXT, + `metadata` JSON DEFAULT NULL, + `embed` vector(%s), + PRIMARY KEY (`id`) + ) ENGINE=InnoDB; + """ + execute_sql( + cursor, create_table_stmt, params=(self._embedding_dimension,) + ) + + self._table_name = table_name + + def delete(self, ids: Optional[Sequence[str]] = None, **_: Any) -> None: + """ + Delete documents by ID. Optionally deletes the vector table if empty after deletions. + + Args: + ids: Optional sequence of document IDs to delete. If None, no action is taken. + + Returns: + None + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + If the backing table is empty after deletions, the table is dropped and + table_name is set to None. + """ + with atomic_transaction(self._db_connection) as cursor: + if ids: + for _id in ids: + execute_sql( + cursor, + f"DELETE FROM {self._schema_name}.{self._table_name} WHERE id = %s", + params=(_id,), + ) + + if is_table_empty(cursor, self._schema_name, self._table_name): + self.delete_all() + + def delete_all(self) -> None: + """ + Delete and drop the entire vector store table. + + Returns: + None + """ + if self._table_name is not None: + with atomic_transaction(self._db_connection) as cursor: + delete_sql_table(cursor, self._schema_name, self._table_name) + self._table_name = None + + def add_texts( + self, + texts: Iterable[str], + metadatas: Optional[list[dict]] = None, + ids: Optional[List[str]] = None, + **_: dict, + ) -> List[str]: + """ + Add a batch of text strings and corresponding metadata to the vector store. + + Args: + texts: List of strings to embed and store. + metadatas: Optional list of metadata dicts (one per text). + ids: Optional custom document IDs. + + Returns: + List of document IDs corresponding to the added texts. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + If metadatas is None, an empty dict is assigned to each document. + """ + texts = list(texts) + + documents = [ + Document(page_content=text, metadata=meta) + for text, meta in zip(texts, metadatas or [{}] * len(texts)) + ] + return self.add_documents(documents, ids=ids) + + @classmethod + def from_texts( + cls, + texts: Iterable[str], + embedder: Embeddings, + metadatas: Optional[list[dict]] = None, + db_connection: MySQLConnectionAbstract = None, + ) -> VectorStore: + """ + Construct and populate a MyVectorStore instance from raw texts and metadata. + + Args: + texts: List of strings to vectorize and store. + embedder: Embeddings generator to use. + metadatas: Optional list of metadata dicts per text. + db_connection: Active MySQL connection. + + Returns: + Instance of MyVectorStore containing the added texts. + + Raises: + ValueError: If db_connection is not provided. + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + if db_connection is None: + raise ValueError( + "db_connection must be specified to create a MyVectorStore object" + ) + + texts = list(texts) + + instance = cls(db_connection=db_connection, embedder=embedder) + instance.add_texts(texts, metadatas=metadatas) + + return instance + + def add_documents( + self, documents: list[Document], ids: list[str] = None + ) -> list[str]: + """ + Embed and store Document objects as high-dimensional vectors with metadata. + + Args: + documents: List of Document objects (each with 'page_content' and 'metadata'). + ids: Optional list of explicit document IDs. Must match the length of documents. + + Returns: + List of document IDs stored. + + Raises: + ValueError: If provided IDs do not match the number of documents. + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + Automatically creates the backing table if it does not exist. + """ + if ids and len(ids) != len(documents): + msg = ( + "ids must be the same length as documents. " + f"Got {len(ids)} ids and {len(documents)} documents." + ) + raise ValueError(msg) + + if len(documents) > 0: + self._make_vector_store() + else: + return [] + + if ids is None: + ids = self._get_ids(len(documents)) + + content = [doc.page_content for doc in documents] + vectors = self._embedder.embed_documents(content) + + df = pd.DataFrame() + df["id"] = ids + df["content"] = content + df["embed"] = vectors + df["metadata"] = [doc.metadata for doc in documents] + + with atomic_transaction(self._db_connection) as cursor: + extend_sql_table( + cursor, + self._schema_name, + self._table_name, + df, + col_name_to_placeholder_string={"embed": "string_to_vector(%s)"}, + ) + + return ids + + def similarity_search( + self, + query: str, + k: int = 3, + **kwargs: Any, + ) -> list[Document]: + """ + Search for and return the most similar documents in the store to the given query. + + Args: + query: String query to embed and use for similarity search. + k: Number of top documents to return. + kwargs: options to pass to ML_SIMILARITY_SEARCH. Currently supports + distance_metric, max_distance, percentage_distance, and segment_overlap + + Returns: + List of Document objects, ordered from most to least similar. + + Raises: + DatabaseError: + If provided kwargs are invalid or unsupported. + If a database connection issue occurs. + If an operational error occurs during execution. + + Implementation Notes: + - Calls ML similarity search within MySQL using stored procedures. + - Retrieves IDs, content, and metadata for search matches. + - Parsing and retrieval for context results are handled via intermediate JSONs. + """ + if self._table_name is None: + return [] + + embedding = self._embedder.embed_query(query) + + with atomic_transaction(self._db_connection) as cursor: + # Set the embedding variable for the similarity search SP + execute_sql( + cursor, + f"SET @{VAR_EMBEDDING} = string_to_vector(%s)", + params=[str(embedding)], + ) + + distance_metric = kwargs.get("distance_metric", "COSINE") + retrieval_options = { + "max_distance": kwargs.get("max_distance", 0.6), + "percentage_distance": kwargs.get("percentage_distance", 20.0), + "segment_overlap": kwargs.get("segment_overlap", 0), + } + + retrieval_options_placeholder, retrieval_options_params = format_value_sql( + retrieval_options + ) + similarity_search_query = f""" + CALL sys.ML_SIMILARITY_SEARCH( + @{VAR_EMBEDDING}, + JSON_ARRAY( + '{self._schema_name}.{self._table_name}' + ), + JSON_OBJECT( + "segment", "content", + "segment_embedding", "embed", + "document_name", "id" + ), + {k}, + %s, + NULL, + NULL, + {retrieval_options_placeholder}, + @{VAR_CONTEXT}, + @{VAR_CONTEXT_MAP}, + @{VAR_RETRIEVAL_INFO} + ) + """ + + execute_sql( + cursor, + similarity_search_query, + params=[distance_metric, *retrieval_options_params], + ) + execute_sql(cursor, f"SELECT @{VAR_CONTEXT_MAP}") + + results = [] + + context_maps = json.loads(cursor.fetchone()[0]) + for context in context_maps: + execute_sql( + cursor, + ( + "SELECT id, content, metadata " + f"FROM {self._schema_name}.{self._table_name} " + "WHERE id = %s" + ), + params=(context["document_name"],), + ) + doc_id, content, metadata = cursor.fetchone() + + doc_args = { + "id": doc_id, + "page_content": content, + } + if metadata is not None: + doc_args["metadata"] = json.loads(metadata) + + doc = Document(**doc_args) + results.append(doc) + + return results + + def __enter__(self) -> "VectorStore": + """ + Enter the runtime context related to this vector store instance. + + Returns: + The current MyVectorStore object, allowing use within a `with` statement block. + + Usage Notes: + - Intended for use in a `with` statement to ensure automatic + cleanup of resources. + - No special initialization occurs during context entry, but enables + proper context-managed lifecycle. + + Example: + with MyVectorStore(db_connection, embedder) as vectorstore: + vectorstore.add_texts([...]) + # Vector store is active within this block. + # All storage and resources are now cleaned up. + """ + return self + + def __exit__( + self, + exc_type: Union[type, None], + exc_val: Union[BaseException, None], + exc_tb: Union[object, None], + ) -> None: + """ + Exit the runtime context for the vector store, ensuring all storage + resources are cleaned up. + + Args: + exc_type: The exception type, if any exception occurred in the context block. + exc_val: The exception value, if any exception occurred in the context block. + exc_tb: The traceback object, if any exception occurred in the context block. + + Returns: + None: Indicates that exceptions are never suppressed; they will propagate as normal. + + Implementation Notes: + - Automatically deletes all vector store data and backing tables via `delete_all()` + upon exiting the context. + - This cleanup occurs whether the block exits normally or due to an exception. + - Does not suppress exceptions; errors in the context block will continue to propagate. + - Use when the vector store lifecycle is intended to be temporary or scoped. + + Example: + with MyVectorStore(db_connection, embedder) as vectorstore: + vectorstore.add_texts([...]) + # Vector store is active within this block. + # All storage and resources are now cleaned up. + """ + self.delete_all() + # No return, so exceptions are never suppressed diff --git a/server/venv/Lib/site-packages/mysql/ai/ml/__init__.py b/server/venv/Lib/site-packages/mysql/ai/ml/__init__.py new file mode 100644 index 0000000..4b45d0d --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/ml/__init__.py @@ -0,0 +1,48 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""ML package for MySQL Connector/Python. + +Performs optional dependency checks and exposes ML utilities: +- ML_TASK, MyModel +- MyClassifier, MyRegressor, MyGenericTransformer +- MyAnomalyDetector +""" +from mysql.ai.utils import check_dependencies as _check_dependencies + +_check_dependencies(["ML"]) +del _check_dependencies + +# Sklearn models +from .classifier import MyClassifier + +# Minimal interface +from .model import ML_TASK, MyModel +from .outlier import MyAnomalyDetector +from .regressor import MyRegressor +from .transformer import MyGenericTransformer diff --git a/server/venv/Lib/site-packages/mysql/ai/ml/base.py b/server/venv/Lib/site-packages/mysql/ai/ml/base.py new file mode 100644 index 0000000..811cb55 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/ml/base.py @@ -0,0 +1,142 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Base classes for MySQL HeatWave ML estimators for Connector/Python. + +Implements a scikit-learn-compatible base estimator wrapping server-side ML. +""" +from typing import Optional, Union + +import pandas as pd + +from sklearn.base import BaseEstimator + +from mysql.connector.abstracts import MySQLConnectionAbstract +from mysql.ai.ml.model import ML_TASK, MyModel +from mysql.ai.utils import copy_dict + + +class MyBaseMLModel(BaseEstimator): + """ + Base class for MySQL HeatWave machine learning estimators. + + Implements the scikit-learn API and core model management logic, + including fit, explain, serialization, and dynamic option handling. + For use as a base class by classifiers, regressors, transformers, and outlier models. + + Args: + db_connection (MySQLConnectionAbstract): An active MySQL connector database connection. + task (str): ML task type, e.g. "classification" or "regression". + model_name (str, optional): Custom name for the deployed model. + fit_extra_options (dict, optional): Extra options for fitting. + + Attributes: + _model: Underlying database helper for fit/predict/explain. + fit_extra_options: User-provided options for fitting. + """ + + def __init__( + self, + db_connection: MySQLConnectionAbstract, + task: Union[str, ML_TASK], + model_name: Optional[str] = None, + fit_extra_options: Optional[dict] = None, + ): + """ + Initialize a MyBaseMLModel with connection, task, and option parameters. + + Args: + db_connection: Active MySQL connector database connection. + task: String label of ML task (e.g. "classification"). + model_name: Optional custom model name. + fit_extra_options: Optional extra fit options. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + self._model = MyModel(db_connection, task=task, model_name=model_name) + self.fit_extra_options = copy_dict(fit_extra_options) + + def fit( + self, + X: pd.DataFrame, # pylint: disable=invalid-name + y: Optional[pd.DataFrame] = None, + ) -> "MyBaseMLModel": + """ + Fit the underlying ML model using pandas DataFrames. + Delegates to MyMLModelPandasHelper.fit. + + Args: + X: Features DataFrame. + y: (Optional) Target labels DataFrame or Series. + + Returns: + self + + Raises: + DatabaseError: + If provided options are invalid or unsupported. + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + Additional temp SQL resources may be created and cleaned up during the operation. + """ + self._model.fit(X, y, self.fit_extra_options) + return self + + def _delete_model(self) -> bool: + """ + Deletes the model from the model catalog if present + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Returns: + Whether the model was deleted + """ + return self._model._delete_model() + + def get_model_info(self) -> Optional[dict]: + """ + Checks if the model name is available. Model info will only be present in the + catalog if the model has previously been fitted. + + Returns: + True if the model name is not part of the model catalog + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + return self._model.get_model_info() diff --git a/server/venv/Lib/site-packages/mysql/ai/ml/classifier.py b/server/venv/Lib/site-packages/mysql/ai/ml/classifier.py new file mode 100644 index 0000000..ffa624b --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/ml/classifier.py @@ -0,0 +1,194 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Classifier utilities for MySQL Connector/Python. + +Provides a scikit-learn compatible classifier backed by HeatWave ML. +""" +from typing import Optional, Union + +import numpy as np +import pandas as pd +from sklearn.base import ClassifierMixin + +from mysql.ai.ml.base import MyBaseMLModel +from mysql.ai.ml.model import ML_TASK +from mysql.ai.utils import copy_dict + +from mysql.connector.abstracts import MySQLConnectionAbstract + + +class MyClassifier(MyBaseMLModel, ClassifierMixin): + """ + MySQL HeatWave scikit-learn compatible classifier estimator. + + Provides prediction and probability output from a model deployed in MySQL, + and manages fit, explain, and prediction options as per HeatWave ML interface. + + Attributes: + predict_extra_options (dict): Dictionary of optional parameters passed through + to the MySQL backend for prediction and probability inference. + _model (MyModel): Underlying interface for database model operations. + fit_extra_options (dict): See MyBaseMLModel. + + Args: + db_connection (MySQLConnectionAbstract): Active MySQL connector DB connection. + model_name (str, optional): Custom name for the model. + fit_extra_options (dict, optional): Extra options for fitting. + explain_extra_options (dict, optional): Extra options for explanations. + predict_extra_options (dict, optional): Extra options for predict/predict_proba. + + Methods: + predict(X): Predict class labels. + predict_proba(X): Predict class probabilities. + """ + + def __init__( + self, + db_connection: MySQLConnectionAbstract, + model_name: Optional[str] = None, + fit_extra_options: Optional[dict] = None, + explain_extra_options: Optional[dict] = None, + predict_extra_options: Optional[dict] = None, + ): + """ + Initialize a MyClassifier. + + Args: + db_connection: Active MySQL connector database connection. + model_name: Optional, custom model name. + fit_extra_options: Optional fit options. + explain_extra_options: Optional explain options. + predict_extra_options: Optional predict/predict_proba options. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + MyBaseMLModel.__init__( + self, + db_connection, + ML_TASK.CLASSIFICATION, + model_name=model_name, + fit_extra_options=fit_extra_options, + ) + self.predict_extra_options = copy_dict(predict_extra_options) + self.explain_extra_options = copy_dict(explain_extra_options) + + def predict( + self, X: Union[pd.DataFrame, np.ndarray] + ) -> np.ndarray: # pylint: disable=invalid-name + """ + Predict class labels for the input features using the MySQL model. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html + A full list of supported options can be found under "ML_PREDICT_TABLE Options" + + Args: + X: Input samples as a numpy array or pandas DataFrame. + + Returns: + ndarray: Array of predicted class labels, shape (n_samples,). + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + """ + result = self._model.predict(X, options=self.predict_extra_options) + return result["Prediction"].to_numpy() + + def predict_proba( + self, X: Union[pd.DataFrame, np.ndarray] + ) -> np.ndarray: # pylint: disable=invalid-name + """ + Predict class probabilities for the input features using the MySQL model. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html + A full list of supported options can be found under "ML_PREDICT_TABLE Options" + + Args: + X: Input samples as a numpy array or pandas DataFrame. + + Returns: + ndarray: Array of shape (n_samples, n_classes) with class probabilities. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + """ + result = self._model.predict(X, options=self.predict_extra_options) + + classes = sorted(result["ml_results"].iloc[0]["probabilities"].keys()) + + return np.stack( + result["ml_results"].map( + lambda ml_result: [ + ml_result["probabilities"][class_name] for class_name in classes + ] + ) + ) + + def explain_predictions( + self, X: Union[pd.DataFrame, np.ndarray] + ) -> pd.DataFrame: # pylint: disable=invalid-name + """ + Explain model predictions using provided data. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html + A full list of supported options can be found under "ML_EXPLAIN_TABLE Options" + + Args: + X: DataFrame for which predictions should be explained. + + Returns: + DataFrame containing explanation details (feature attributions, etc.) + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + Temporary input/output tables are cleaned up after explanation. + """ + self._model.explain_predictions(X, options=self.explain_extra_options) diff --git a/server/venv/Lib/site-packages/mysql/ai/ml/model.py b/server/venv/Lib/site-packages/mysql/ai/ml/model.py new file mode 100644 index 0000000..ea02f49 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/ml/model.py @@ -0,0 +1,780 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +"""HeatWave ML model utilities for MySQL Connector/Python. + +Provides classes to manage training, prediction, scoring, and explanations +via MySQL HeatWave stored procedures. +""" +import copy +import json + +from enum import Enum +from typing import Any, Dict, Optional, Union + +import numpy as np +import pandas as pd + +from mysql.ai.utils import ( + VAR_NAME_SPACE, + atomic_transaction, + convert_to_df, + execute_sql, + format_value_sql, + get_random_name, + source_schema, + sql_response_to_df, + sql_table_from_df, + sql_table_to_df, + table_exists, + temporary_sql_tables, + validate_name, +) +from mysql.connector.abstracts import MySQLConnectionAbstract + + +class ML_TASK(Enum): # pylint: disable=invalid-name + """Enumeration of supported ML tasks for HeatWave.""" + + CLASSIFICATION = "classification" + REGRESSION = "regression" + FORECASTING = "forecasting" + ANOMALY_DETECTION = "anomaly_detection" + LOG_ANOMALY_DETECTION = "log_anomaly_detection" + RECOMMENDATION = "recommendation" + TOPIC_MODELING = "topic_modeling" + + @staticmethod + def get_task_string(task: Union[str, "ML_TASK"]) -> str: + """ + Return the string representation of a machine learning task. + + Args: + task (Union[str, ML_TASK]): The task to convert. + Accepts either a task enum member (ML_TASK) or a string. + + Returns: + str: The string value of the ML task. + """ + + if isinstance(task, str): + return task + + return task.value + + +class _MyModelCommon: + """ + Common utilities and workflow for MySQL HeatWave ML models. + + This class handles model lifecycle steps such as loading, fitting, scoring, + making predictions, and explaining models or predictions. Not intended for + direct instantiation, but as a superclass for heatwave model wrappers. + + Attributes: + db_connection: MySQL connector database connection. + task: ML task, e.g., "classification" or "regression". + model_name: Identifier of model in MySQL. + schema_name: Database schema used for operations and temp tables. + """ + + def __init__( + self, + db_connection: MySQLConnectionAbstract, + task: Union[str, ML_TASK] = ML_TASK.CLASSIFICATION, + model_name: Optional[str] = None, + ): + """ + Instantiate _MyMLModelCommon. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-train.html + A full list of supported tasks can be found under "Common ML_TRAIN Options" + + Args: + db_connection: MySQL database connection. + task: ML task type (default: "classification"). + model_name: Name to register the model within MySQL (default: None). + + Raises: + ValueError: If the schema name is not valid + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Returns: + None + """ + self.db_connection = db_connection + self.task = ML_TASK.get_task_string(task) + self.schema_name = source_schema(db_connection) + + with atomic_transaction(self.db_connection) as cursor: + execute_sql(cursor, "CALL sys.ML_CREATE_OR_UPGRADE_CATALOG();") + + if model_name is None: + model_name = get_random_name(self._is_model_name_available) + + self.model_var = f"{VAR_NAME_SPACE}.{model_name}" + self.model_var_score = f"{self.model_var}.score" + + self.model_name = model_name + validate_name(model_name) + + with atomic_transaction(self.db_connection) as cursor: + execute_sql(cursor, f"SET @{self.model_var} = %s;", params=(model_name,)) + + def _delete_model(self) -> bool: + """ + Deletes the model from the model catalog if present + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Returns: + Whether the model was deleted + """ + current_user = self._get_user() + + qualified_model_catalog = f"ML_SCHEMA_{current_user}.MODEL_CATALOG" + delete_model = ( + f"DELETE FROM {qualified_model_catalog} " + f"WHERE model_handle = @{self.model_var}" + ) + + with atomic_transaction(self.db_connection) as cursor: + execute_sql(cursor, delete_model) + return cursor.rowcount > 0 + + def _get_model_info(self, model_name: str) -> Optional[dict]: + """ + Retrieves the model info from the model_catalog + + Args: + model_var: The model alias to retrieve + + Returns: + The model info from the model_catalog (None if the model is not present in the catalog) + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + + def process_col(elem: Any) -> Any: + if isinstance(elem, str): + try: + elem = json.loads(elem) + except json.JSONDecodeError: + pass + return elem + + current_user = self._get_user() + + qualified_model_catalog = f"ML_SCHEMA_{current_user}.MODEL_CATALOG" + model_exists = ( + f"SELECT * FROM {qualified_model_catalog} WHERE model_handle = %s" + ) + + with atomic_transaction(self.db_connection) as cursor: + execute_sql(cursor, model_exists, params=(model_name,)) + model_info_df = sql_response_to_df(cursor) + + if model_info_df.empty: + result = None + else: + unprocessed_result = model_info_df.to_json(orient="records") + unprocessed_result_json = json.loads(unprocessed_result)[0] + result = { + key: process_col(elem) + for key, elem in unprocessed_result_json.items() + } + + return result + + def get_model_info(self) -> Optional[dict]: + """ + Checks if the model name is available. + Model info is present in the catalog only if the model was previously fitted. + + Returns: + True if the model name is not part of the model catalog + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + return self._get_model_info(self.model_name) + + def _is_model_name_available(self, model_name: str) -> bool: + """ + Checks if the model name is available + + Returns: + True if the model name is not part of the model catalog + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + return self._get_model_info(model_name) is None + + def _load_model(self) -> None: + """ + Loads the model specified by `self.model_name` into MySQL. + After loading, the model is ready to handle ML operations. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-model-load.html + + Raises: + DatabaseError: + If the model is not initialized, i.e., fit or import has not been called + If a database connection issue occurs. + If an operational error occurs during execution. + + Returns: + None + """ + with atomic_transaction(self.db_connection) as cursor: + load_model_query = f"CALL sys.ML_MODEL_LOAD(@{self.model_var}, NULL);" + execute_sql(cursor, load_model_query) + + def _get_user(self) -> str: + """ + Fetch the current database user (without host). + + Returns: + The username string associated with the connection. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the user name includes unsupported characters + """ + with atomic_transaction(self.db_connection) as cursor: + cursor.execute("SELECT CURRENT_USER()") + current_user = cursor.fetchone()[0].split("@")[0] + + return validate_name(current_user) + + def explain_model(self) -> dict: + """ + Get model explanations, such as detailed feature importances. + + Returns: + dict: Feature importances and model explainability data. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-model-explanations.html + + Raises: + DatabaseError: + If the model is not initialized, i.e., fit or import has not been called + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: + If the model does not exist in the model catalog. + Should only occur if model was not fitted or was deleted. + """ + self._load_model() + with atomic_transaction(self.db_connection) as cursor: + current_user = self._get_user() + + qualified_model_catalog = f"ML_SCHEMA_{current_user}.MODEL_CATALOG" + explain_query = ( + f"SELECT model_explanation FROM {qualified_model_catalog} " + f"WHERE model_handle = @{self.model_var}" + ) + + execute_sql(cursor, explain_query) + df = sql_response_to_df(cursor) + + return df.iloc[0, 0] + + def _fit( + self, + table_name: str, + target_column_name: Optional[str], + options: Optional[dict], + ) -> None: + """ + Fit an ML model using a referenced SQL table and target column. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-train.html + A full list of supported options can be found under "Common ML_TRAIN Options" + + Args: + table_name: Name of the training data table. + target_column_name: Name of the target/label column. + options: Additional fit/config options (may override defaults). + + Raises: + DatabaseError: + If provided options are invalid or unsupported. + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the table or target_column name is not valid + + Returns: + None + """ + validate_name(table_name) + if target_column_name is not None: + validate_name(target_column_name) + target_col_string = f"'{target_column_name}'" + else: + target_col_string = "NULL" + + if options is None: + options = {} + options = copy.deepcopy(options) + options["task"] = self.task + + self._delete_model() + + with atomic_transaction(self.db_connection) as cursor: + placeholders, parameters = format_value_sql(options) + execute_sql( + cursor, + ( + "CALL sys.ML_TRAIN(" + f"'{self.schema_name}.{table_name}', " + f"{target_col_string}, " + f"{placeholders}, " + f"@{self.model_var}" + ")" + ), + params=parameters, + ) + + def _predict( + self, table_name: str, output_table_name: str, options: Optional[dict] + ) -> None: + """ + Predict on a given data table and write results to an output table. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html + A full list of supported options can be found under "ML_PREDICT_TABLE Options" + + Args: + table_name: Name of the SQL table with input data. + output_table_name: Name for the SQL output table to contain predictions. + options: Optional prediction options. + + Returns: + None + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the table or output_table name is not valid + """ + validate_name(table_name) + validate_name(output_table_name) + + self._load_model() + with atomic_transaction(self.db_connection) as cursor: + placeholders, parameters = format_value_sql(options) + execute_sql( + cursor, + ( + "CALL sys.ML_PREDICT_TABLE(" + f"'{self.schema_name}.{table_name}', " + f"@{self.model_var}, " + f"'{self.schema_name}.{output_table_name}', " + f"{placeholders}" + ")" + ), + params=parameters, + ) + + def _score( + self, + table_name: str, + target_column_name: str, + metric: str, + options: Optional[dict], + ) -> float: + """ + Evaluate model performance with a scoring metric. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-score.html + A full list of supported options can be found under + "Options for Recommendation Models" and + "Options for Anomaly Detection Models" + + Args: + table_name: Table with features and ground truth. + target_column_name: Column of true target labels. + metric: String name of the metric to compute. + options: Optional dictionary of further scoring options. + + Returns: + float: Computed score from the ML system. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the table or target_column name or metric is not valid + """ + validate_name(table_name) + validate_name(target_column_name) + validate_name(metric) + + self._load_model() + with atomic_transaction(self.db_connection) as cursor: + placeholders, parameters = format_value_sql(options) + execute_sql( + cursor, + ( + "CALL sys.ML_SCORE(" + f"'{self.schema_name}.{table_name}', " + f"'{target_column_name}', " + f"@{self.model_var}, " + "%s, " + f"@{self.model_var_score}, " + f"{placeholders}" + ")" + ), + params=[metric, *parameters], + ) + execute_sql(cursor, f"SELECT @{self.model_var_score}") + df = sql_response_to_df(cursor) + + return df.iloc[0, 0] + + def _explain_predictions( + self, table_name: str, output_table_name: str, options: Optional[dict] + ) -> pd.DataFrame: + """ + Produce explanations for model predictions on provided data. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html + A full list of supported options can be found under "ML_EXPLAIN_TABLE Options" + + Args: + table_name: Name of the SQL table with input data. + output_table_name: Name for the SQL table to store explanations. + options: Optional dictionary (default: + {"prediction_explainer": "permutation_importance"}). + + Returns: + DataFrame: Prediction explanations from the output SQL table. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the table or output_table name is not valid + """ + validate_name(table_name) + validate_name(output_table_name) + + if options is None: + options = {"prediction_explainer": "permutation_importance"} + + self._load_model() + + with atomic_transaction(self.db_connection) as cursor: + placeholders, parameters = format_value_sql(options) + execute_sql( + cursor, + ( + "CALL sys.ML_EXPLAIN_TABLE(" + f"'{self.schema_name}.{table_name}', " + f"@{self.model_var}, " + f"'{self.schema_name}.{output_table_name}', " + f"{placeholders}" + ")" + ), + params=parameters, + ) + execute_sql(cursor, f"SELECT * FROM {self.schema_name}.{output_table_name}") + df = sql_response_to_df(cursor) + + return df + + +class MyModel(_MyModelCommon): + """ + Convenience class for managing the ML workflow using pandas DataFrames. + + Methods convert in-memory DataFrames into temp SQL tables before delegating to the + _MyMLModelCommon routines, and automatically clean up temp resources. + """ + + def fit( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + y: Optional[Union[pd.DataFrame, np.ndarray]], + options: Optional[dict] = None, + ) -> None: + """ + Fit a model using DataFrame inputs. + + If an 'id' column is defined in either dataframe, it will be used as the primary key. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-train.html + A full list of supported options can be found under "Common ML_TRAIN Options" + + Args: + X: Features DataFrame. + y: (Optional) Target labels DataFrame or Series. If None, only X is used. + options: Additional options to pass to training. + + Returns: + None + + Raises: + DatabaseError: + If provided options are invalid or unsupported. + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + Combines X and y as necessary. Creates a temporary table in the schema for training, + and deletes it afterward. + """ + X, y = convert_to_df(X), convert_to_df(y) + + with ( + atomic_transaction(self.db_connection) as cursor, + temporary_sql_tables(self.db_connection) as temporary_tables, + ): + if y is not None: + if isinstance(y, pd.DataFrame): + # keep column name if it exists + target_column_name = y.columns[0] + else: + target_column_name = get_random_name( + lambda name: name not in X.columns + ) + + if target_column_name in X.columns: + raise ValueError( + f"Target column y with name {target_column_name} already present " + "in feature dataframe X" + ) + + df_combined = X.copy() + df_combined[target_column_name] = y + final_df = df_combined + else: + target_column_name = None + final_df = X + + _, table_name = sql_table_from_df(cursor, self.schema_name, final_df) + temporary_tables.append((self.schema_name, table_name)) + + self._fit(table_name, target_column_name, options) + + def predict( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + options: Optional[dict] = None, + ) -> pd.DataFrame: + """ + Generate model predictions using DataFrame input. + + If an 'id' column is defined in either dataframe, it will be used as the primary key. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-predict-table.html + A full list of supported options can be found under "ML_PREDICT_TABLE Options" + + Args: + X: DataFrame containing prediction features (no labels). + options: Additional prediction settings. + + Returns: + DataFrame with prediction results as returned by HeatWave. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + Temporary SQL tables are created and deleted for input/output. + """ + X = convert_to_df(X) + + with ( + atomic_transaction(self.db_connection) as cursor, + temporary_sql_tables(self.db_connection) as temporary_tables, + ): + _, table_name = sql_table_from_df(cursor, self.schema_name, X) + temporary_tables.append((self.schema_name, table_name)) + + output_table_name = get_random_name( + lambda table_name: not table_exists( + cursor, self.schema_name, table_name + ) + ) + temporary_tables.append((self.schema_name, output_table_name)) + + self._predict(table_name, output_table_name, options) + predictions = sql_table_to_df(cursor, self.schema_name, output_table_name) + + # ml_results is text but known to always follow JSON format + predictions["ml_results"] = predictions["ml_results"].map(json.loads) + + return predictions + + def score( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + y: Union[pd.DataFrame, np.ndarray], + metric: str, + options: Optional[dict] = None, + ) -> float: + """ + Score the model using X/y data and a selected metric. + + If an 'id' column is defined in either dataframe, it will be used as the primary key. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-score.html + A full list of supported options can be found under + "Options for Recommendation Models" and + "Options for Anomaly Detection Models" + + Args: + X: DataFrame of features. + y: DataFrame or Series of labels. + metric: Metric name (e.g., "balanced_accuracy"). + options: Optional ml scoring options. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + + Returns: + float: Computed score. + """ + X, y = convert_to_df(X), convert_to_df(y) + + with ( + atomic_transaction(self.db_connection) as cursor, + temporary_sql_tables(self.db_connection) as temporary_tables, + ): + target_column_name = get_random_name(lambda name: name not in X.columns) + df_combined = X.copy() + df_combined[target_column_name] = y + final_df = df_combined + + _, table_name = sql_table_from_df(cursor, self.schema_name, final_df) + temporary_tables.append((self.schema_name, table_name)) + + score = self._score(table_name, target_column_name, metric, options) + + return score + + def explain_predictions( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + options: Dict = None, + ) -> pd.DataFrame: + """ + Explain model predictions using provided data. + + If an 'id' column is defined in either dataframe, it will be used as the primary key. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html + A full list of supported options can be found under + "ML_EXPLAIN_TABLE Options" + + Args: + X: DataFrame for which predictions should be explained. + options: Optional dictionary of explainability options. + + Returns: + DataFrame containing explanation details (feature attributions, etc.) + + Raises: + DatabaseError: + If provided options are invalid or unsupported, or if the model is not initialized, + i.e., fit or import has not been called + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + Temporary input/output tables are cleaned up after explanation. + """ + X = convert_to_df(X) + + with ( + atomic_transaction(self.db_connection) as cursor, + temporary_sql_tables(self.db_connection) as temporary_tables, + ): + + _, table_name = sql_table_from_df(cursor, self.schema_name, X) + temporary_tables.append((self.schema_name, table_name)) + + output_table_name = get_random_name( + lambda table_name: not table_exists( + cursor, self.schema_name, table_name + ) + ) + temporary_tables.append((self.schema_name, output_table_name)) + + explanations = self._explain_predictions( + table_name, output_table_name, options + ) + + return explanations diff --git a/server/venv/Lib/site-packages/mysql/ai/ml/outlier.py b/server/venv/Lib/site-packages/mysql/ai/ml/outlier.py new file mode 100644 index 0000000..aab3cb5 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/ml/outlier.py @@ -0,0 +1,221 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Outlier/anomaly detection utilities for MySQL Connector/Python. + +Provides a scikit-learn compatible wrapper using HeatWave to score anomalies. +""" +from typing import Optional, Union + +import numpy as np +import pandas as pd +from sklearn.base import OutlierMixin + +from mysql.ai.ml.base import MyBaseMLModel +from mysql.ai.ml.model import ML_TASK +from mysql.ai.utils import copy_dict + +from mysql.connector.abstracts import MySQLConnectionAbstract + +EPS = 1e-5 + + +def _get_logits(prob: Union[float, np.ndarray]) -> Union[float, np.ndarray]: + """ + Compute logit (logodds) for a probability, clipping to avoid numerical overflow. + + Args: + prob: Scalar or array of probability values in (0,1). + + Returns: + logit-transformed probabilities. + """ + result = np.clip(prob, EPS, 1 - EPS) + return np.log(result / (1 - result)) + + +class MyAnomalyDetector(MyBaseMLModel, OutlierMixin): + """ + MySQL HeatWave scikit-learn compatible anomaly/outlier detector. + + Flags samples as outliers when the probability of being an anomaly + exceeds a user-tunable threshold. + Includes helpers to obtain decision scores and anomaly probabilities + for ranking. + + Args: + db_connection (MySQLConnectionAbstract): Active MySQL DB connection. + model_name (str, optional): Custom model name in the database. + fit_extra_options (dict, optional): Extra options for fitting. + score_extra_options (dict, optional): Extra options for scoring/prediction. + + Attributes: + boundary: Decision threshold boundary in logit space. Derived from + trained model's catalog info + + Methods: + predict(X): Predict outlier/inlier labels. + score_samples(X): Compute anomaly (normal class) logit scores. + decision_function(X): Compute signed score above/below threshold for ranking. + """ + + def __init__( + self, + db_connection: MySQLConnectionAbstract, + model_name: Optional[str] = None, + fit_extra_options: Optional[dict] = None, + score_extra_options: Optional[dict] = None, + ): + """ + Initialize an anomaly detector instance with threshold and extra options. + + Args: + db_connection: Active MySQL DB connection. + model_name: Optional model name in DB. + fit_extra_options: Optional extra fit options. + score_extra_options: Optional extra scoring options. + + Raises: + ValueError: If outlier_threshold is not in (0,1). + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + MyBaseMLModel.__init__( + self, + db_connection, + ML_TASK.ANOMALY_DETECTION, + model_name=model_name, + fit_extra_options=fit_extra_options, + ) + self.score_extra_options = copy_dict(score_extra_options) + self.boundary: Optional[float] = None + + def predict( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + ) -> np.ndarray: + """ + Predict outlier/inlier binary labels for input samples. + + Args: + X: Samples to predict on. + + Returns: + ndarray: Values are -1 for outliers, +1 for inliers, as per scikit-learn convention. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + """ + return np.where(self.decision_function(X) < 0.0, -1, 1) + + def decision_function( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + ) -> np.ndarray: + """ + Compute signed distance to the outlier threshold. + + Args: + X: Samples to predict on. + + Returns: + ndarray: Score > 0 means inlier, < 0 means outlier; |value| gives margin. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: + If the provided model info does not provide threshold + """ + sample_scores = self.score_samples(X) + + if self.boundary is None: + model_info = self.get_model_info() + if model_info is None: + raise ValueError("Model does not exist in catalog.") + + threshold = model_info["model_metadata"]["training_params"].get( + "anomaly_detection_threshold", None + ) + if threshold is None: + raise ValueError( + "Trained model is outdated and does not support threshold. " + "Try retraining or using an existing, trained model with MyModel." + ) + + # scikit-learn uses large positive values as inlier + # and negative as outlier, so we need to flip our threshold + self.boundary = _get_logits(1.0 - threshold) + + return sample_scores - self.boundary + + def score_samples( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + ) -> np.ndarray: + """ + Compute normal probability logit score for each sample. + Used for ranking, thresholding. + + Args: + X: Samples to score. + + Returns: + ndarray: Logit scores based on "normal" class probability. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + """ + result = self._model.predict(X, options=self.score_extra_options) + + return _get_logits( + result["ml_results"] + .apply(lambda x: x["probabilities"]["normal"]) + .to_numpy() + ) diff --git a/server/venv/Lib/site-packages/mysql/ai/ml/regressor.py b/server/venv/Lib/site-packages/mysql/ai/ml/regressor.py new file mode 100644 index 0000000..cfc7e63 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/ml/regressor.py @@ -0,0 +1,154 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Regressor utilities for MySQL Connector/Python. + +Provides a scikit-learn compatible regressor backed by HeatWave ML. +""" +from typing import Optional, Union + +import numpy as np +import pandas as pd +from sklearn.base import RegressorMixin + +from mysql.ai.ml.base import MyBaseMLModel +from mysql.ai.ml.model import ML_TASK +from mysql.ai.utils import copy_dict + +from mysql.connector.abstracts import MySQLConnectionAbstract + + +class MyRegressor(MyBaseMLModel, RegressorMixin): + """ + MySQL HeatWave scikit-learn compatible regressor estimator. + + Provides prediction output from a regression model deployed in MySQL, + and manages fit, explain, and prediction options as per HeatWave ML interface. + + Attributes: + predict_extra_options (dict): Optional parameter dict passed to the backend for prediction. + _model (MyModel): Underlying interface for database model operations. + fit_extra_options (dict): See MyBaseMLModel. + explain_extra_options (dict): See MyBaseMLModel. + + Args: + db_connection (MySQLConnectionAbstract): Active MySQL connector DB connection. + model_name (str, optional): Custom name for the model. + fit_extra_options (dict, optional): Extra options for fitting. + explain_extra_options (dict, optional): Extra options for explanations. + predict_extra_options (dict, optional): Extra options for predictions. + + Methods: + predict(X): Predict regression target. + """ + + def __init__( + self, + db_connection: MySQLConnectionAbstract, + model_name: Optional[str] = None, + fit_extra_options: Optional[dict] = None, + explain_extra_options: Optional[dict] = None, + predict_extra_options: Optional[dict] = None, + ): + """ + Initialize a MyRegressor. + + Args: + db_connection: Active MySQL connector database connection. + model_name: Optional, custom model name. + fit_extra_options: Optional fit options. + explain_extra_options: Optional explain options. + predict_extra_options: Optional prediction options. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + MyBaseMLModel.__init__( + self, + db_connection, + ML_TASK.REGRESSION, + model_name=model_name, + fit_extra_options=fit_extra_options, + ) + + self.predict_extra_options = copy_dict(predict_extra_options) + self.explain_extra_options = copy_dict(explain_extra_options) + + def predict( + self, X: Union[pd.DataFrame, np.ndarray] + ) -> np.ndarray: # pylint: disable=invalid-name + """ + Predict a continuous target for the input features using the MySQL model. + + Args: + X: Input samples as a numpy array or pandas DataFrame. + + Returns: + ndarray: Array of predicted target values, shape (n_samples,). + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + """ + result = self._model.predict(X, options=self.predict_extra_options) + return result["Prediction"].to_numpy() + + def explain_predictions( + self, X: Union[pd.DataFrame, np.ndarray] + ) -> pd.DataFrame: # pylint: disable=invalid-name + """ + Explain model predictions using provided data. + + References: + https://dev.mysql.com/doc/heatwave/en/mys-hwaml-ml-explain-table.html + A full list of supported options can be found under "ML_EXPLAIN_TABLE Options" + + Args: + X: DataFrame for which predictions should be explained. + + Returns: + DataFrame containing explanation details (feature attributions, etc.) + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + + Notes: + Temporary input/output tables are cleaned up after explanation. + """ + self._model.explain_predictions(X, options=self.explain_extra_options) diff --git a/server/venv/Lib/site-packages/mysql/ai/ml/transformer.py b/server/venv/Lib/site-packages/mysql/ai/ml/transformer.py new file mode 100644 index 0000000..ba07d4a --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/ml/transformer.py @@ -0,0 +1,164 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +"""Generic transformer utilities for MySQL Connector/Python. + +Provides a scikit-learn compatible Transformer using HeatWave for fit/transform +and scoring operations. +""" +from typing import Optional, Union + +import numpy as np +import pandas as pd +from sklearn.base import TransformerMixin + +from mysql.ai.ml.base import MyBaseMLModel +from mysql.ai.ml.model import ML_TASK +from mysql.ai.utils import copy_dict +from mysql.connector.abstracts import MySQLConnectionAbstract + + +class MyGenericTransformer(MyBaseMLModel, TransformerMixin): + """ + MySQL HeatWave scikit-learn compatible generic transformer. + + Can be used as the transformation step in an sklearn pipeline. Implements fit, transform, + explain, and scoring capability, passing options for server-side transform logic. + + Args: + db_connection (MySQLConnectionAbstract): Active MySQL connector database connection. + task (str): ML task type for transformer (default: "classification"). + score_metric (str): Scoring metric to request from backend (default: "balanced_accuracy"). + model_name (str, optional): Custom name for the deployed model. + fit_extra_options (dict, optional): Extra fit options. + transform_extra_options (dict, optional): Extra options for transformations. + score_extra_options (dict, optional): Extra options for scoring. + + Attributes: + score_metric (str): Name of the backend metric to use for scoring + (e.g. "balanced_accuracy"). + score_extra_options (dict): Dictionary of optional scoring parameters; + passed to backend score. + transform_extra_options (dict): Dictionary of inference (/predict) + parameters for the backend. + fit_extra_options (dict): See MyBaseMLModel. + _model (MyModel): Underlying interface for database model operations. + + Methods: + fit(X, y): Fit the underlying model using the provided features/targets. + transform(X): Transform features using the backend model. + score(X, y): Score data using backend metric and options. + """ + + def __init__( + self, + db_connection: MySQLConnectionAbstract, + task: Union[str, ML_TASK] = ML_TASK.CLASSIFICATION, + score_metric: str = "balanced_accuracy", + model_name: Optional[str] = None, + fit_extra_options: Optional[dict] = None, + transform_extra_options: Optional[dict] = None, + score_extra_options: Optional[dict] = None, + ): + """ + Initialize transformer with required and optional arguments. + + Args: + db_connection: Active MySQL backend database connection. + task: ML task type for transformer. + score_metric: Requested backend scoring metric. + model_name: Optional model name for storage. + fit_extra_options: Optional extra options for fitting. + transform_extra_options: Optional extra options for transformation/inference. + score_extra_options: Optional extra scoring options. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + MyBaseMLModel.__init__( + self, + db_connection, + task, + model_name=model_name, + fit_extra_options=fit_extra_options, + ) + + self.score_metric = score_metric + self.score_extra_options = copy_dict(score_extra_options) + + self.transform_extra_options = copy_dict(transform_extra_options) + + def transform( + self, X: pd.DataFrame + ) -> pd.DataFrame: # pylint: disable=invalid-name + """ + Transform input data to model predictions using the underlying helper. + + Args: + X: DataFrame of features to predict/transform. + + Returns: + pd.DataFrame: Results of transformation as returned by backend. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + """ + return self._model.predict(X, options=self.transform_extra_options) + + def score( + self, + X: Union[pd.DataFrame, np.ndarray], # pylint: disable=invalid-name + y: Union[pd.DataFrame, np.ndarray], + ) -> float: + """ + Score the transformed data using the backend scoring interface. + + Args: + X: Transformed features. + y: Target labels or data for scoring. + + Returns: + float: Score based on backend metric. + + Raises: + DatabaseError: + If provided options are invalid or unsupported, + or if the model is not initialized, i.e., fit or import has not + been called + If a database connection issue occurs. + If an operational error occurs during execution. + """ + return self._model.score( + X, y, self.score_metric, options=self.score_extra_options + ) diff --git a/server/venv/Lib/site-packages/mysql/ai/utils/__init__.py b/server/venv/Lib/site-packages/mysql/ai/utils/__init__.py new file mode 100644 index 0000000..c6e5f62 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/utils/__init__.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Utilities for AI-related helpers in MySQL Connector/Python. + +This package exposes: +- check_dependencies(): runtime dependency guard for optional AI features +- atomic_transaction(): context manager ensuring atomic DB transactions +- utils: general-purpose helpers used by AI integrations + +Importing this package validates base dependencies required for AI utilities. +""" + +from .dependencies import check_dependencies + +check_dependencies(["BASE"]) + +from .atomic_cursor import atomic_transaction +from .utils import * diff --git a/server/venv/Lib/site-packages/mysql/ai/utils/atomic_cursor.py b/server/venv/Lib/site-packages/mysql/ai/utils/atomic_cursor.py new file mode 100644 index 0000000..be13553 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/utils/atomic_cursor.py @@ -0,0 +1,94 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Atomic transaction context manager utilities for MySQL Connector/Python. + +Provides context manager atomic_transaction() that ensures commit on success +and rollback on error without obscuring the original exception. +""" + +from contextlib import contextmanager +from typing import Iterator + +from mysql.connector.abstracts import MySQLConnectionAbstract +from mysql.connector.cursor import MySQLCursorAbstract + + +@contextmanager +def atomic_transaction( + conn: MySQLConnectionAbstract, +) -> Iterator[MySQLCursorAbstract]: + """ + Context manager that wraps a MySQL database cursor and ensures transaction + rollback in case of exception. + + NOTE: DDL statements such as CREATE TABLE cause implicit commits. These cannot + be managed by a cursor object. Changes made at or before a DDL statement will + be committed and not rolled back. Callers are responsible for any cleanup of + this type. + + This class acts as a robust, PEP 343-compliant context manager for handling + database cursor operations on a MySQL connection. It ensures that all operations + executed within the context block are part of the same transaction, and + automatically calls `connection.rollback()` if an exception occurs, helping + to maintain database integrity. On normal completion (no exception), it simply + closes the cursor after use. Exceptions are always propagated to the caller. + + Args: + conn: A MySQLConnectionAbstract instance. + """ + old_autocommit = conn.autocommit + cursor = conn.cursor() + + exception_raised = False + try: + if old_autocommit: + conn.autocommit = False + + yield cursor # provide cursor to block + + conn.commit() + except Exception: # pylint: disable=broad-exception-caught + exception_raised = True + try: + conn.rollback() + except Exception: # pylint: disable=broad-exception-caught + # Don't obscure original exception + pass + + # Raise original exception + raise + finally: + conn.autocommit = old_autocommit + + try: + cursor.close() + except Exception: # pylint: disable=broad-exception-caught + # don't obscure original exception if exists + if not exception_raised: + raise diff --git a/server/venv/Lib/site-packages/mysql/ai/utils/dependencies.py b/server/venv/Lib/site-packages/mysql/ai/utils/dependencies.py new file mode 100644 index 0000000..77a502d --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/utils/dependencies.py @@ -0,0 +1,88 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Dependency checking utilities for AI features in MySQL Connector/Python. + +Provides check_dependencies() to assert required optional packages are present +with acceptable minimum versions at runtime. +""" + +import importlib.metadata + +from typing import List + + +def check_dependencies(tasks: List[str]) -> None: + """ + Check required runtime dependencies and minimum versions; raise an error + if any are missing or version-incompatible. + + This verifies the presence and minimum version of essential Python packages. + Missing or insufficient versions cause an ImportError listing the packages + and a suggested install command. + + Args: + tasks (List[str]): Task types to check requirements for. + + Raises: + ImportError: If any required dependencies are missing or below the + minimum version. + """ + task_set = set(tasks) + task_set.add("BASE") + + # Requirements: (import_name, min_version) + task_to_requirement = { + "BASE": [("pandas", "1.5.0")], + "GENAI": [ + ("langchain", "0.1.11"), + ("langchain_core", "0.1.11"), + ("pydantic", "1.10.0"), + ], + "ML": [("scikit-learn", "1.3.0")], + } + requirements = [] + for task in task_set: + requirements.extend(task_to_requirement[task]) + requirements_set = set(requirements) + + problems = [] + for name, min_version in requirements_set: + try: + installed_version = importlib.metadata.version(name) + # Version comparison uses simple string comparison to avoid extra + # dependencies. This is valid for the dependencies defined above; + # reconsider if adding packages with version schemes that do not + # compare correctly as strings. + error = installed_version < min_version + except importlib.metadata.PackageNotFoundError: + error = True + if error: + problems.append(f"{name} v{min_version} (or later)") + if problems: + raise ImportError("Please install " + ", ".join(problems) + ".") diff --git a/server/venv/Lib/site-packages/mysql/ai/utils/utils.py b/server/venv/Lib/site-packages/mysql/ai/utils/utils.py new file mode 100644 index 0000000..c679dec --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/ai/utils/utils.py @@ -0,0 +1,573 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +"""General utilities for AI features in MySQL Connector/Python. + +Includes helpers for: +- defensive dict copying +- temporary table lifecycle management +- SQL execution and result conversions +- DataFrame to/from SQL table utilities +- schema/table/column name validation +- array-like to DataFrame conversion +""" + +import copy +import json +import random +import re +import string + +from contextlib import contextmanager +from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union + +import numpy as np +import pandas as pd + +from mysql.ai.utils.atomic_cursor import atomic_transaction + +from mysql.connector.abstracts import MySQLConnectionAbstract +from mysql.connector.cursor import MySQLCursorAbstract +from mysql.connector.types import ParamsSequenceOrDictType + +VAR_NAME_SPACE = "mysql_ai" +RANDOM_TABLE_NAME_LENGTH = 32 + +PD_TO_SQL_DTYPE_MAPPING = { + "int64": "BIGINT", + "float64": "DOUBLE", + "object": "LONGTEXT", + "bool": "BOOLEAN", + "datetime64[ns]": "DATETIME", +} + +DEFAULT_SCHEMA = "mysql_ai" + +# Misc Utilities + + +def copy_dict(options: Optional[dict]) -> dict: + """ + Make a defensive copy of a dictionary, or return an empty dict if None. + + Args: + options: param dict or None + + Returns: + dict + """ + if options is None: + return {} + + return copy.deepcopy(options) + + +@contextmanager +def temporary_sql_tables( + db_connection: MySQLConnectionAbstract, +) -> Iterator[list[tuple[str, str]]]: + """ + Context manager to track and automatically clean up temporary SQL tables. + + Args: + db_connection: Database connection object used to create and delete tables. + + Returns: + None + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + + Yields: + temporary_tables: List of (schema_name, table_name) tuples created during the + context. All tables in this list are deleted on context exit. + """ + temporary_tables: List[Tuple[str, str]] = [] + try: + yield temporary_tables + finally: + with atomic_transaction(db_connection) as cursor: + for schema_name, table_name in temporary_tables: + delete_sql_table(cursor, schema_name, table_name) + + +def execute_sql( + cursor: MySQLCursorAbstract, query: str, params: ParamsSequenceOrDictType = None +) -> None: + """ + Execute an SQL query with optional parameters using the given cursor. + + Args: + cursor: MySQLCursorAbstract object to execute the query. + query: SQL query string to execute. + params: Optional sequence or dict providing parameters for the query. + + Raises: + DatabaseError: + If the provided SQL query/params are invalid + If the query is valid but the sql raises as an exception + If a database connection issue occurs. + If an operational error occurs during execution. + + Returns: + None + """ + cursor.execute(query, params or ()) + + +def _get_name() -> str: + """ + Generate a random uppercase string of fixed length for table names. + + Returns: + Random string of length RANDOM_TABLE_NAME_LENGTH. + """ + char_set = string.ascii_uppercase + return "".join(random.choices(char_set, k=RANDOM_TABLE_NAME_LENGTH)) + + +def get_random_name(condition: Callable[[str], bool], max_calls: int = 100) -> str: + """ + Generate a random string name that satisfies a given condition. + + Args: + condition: Callable that takes a generated name and returns True if it is valid. + max_calls: Maximum number of attempts before giving up (default 100). + + Returns: + A random string that fulfills the provided condition. + + Raises: + RuntimeError: If the maximum number of attempts is reached without success. + """ + for _ in range(max_calls): + if condition(name := _get_name()): + return name + # condition never met + raise RuntimeError("Reached max tries without successfully finding a unique name") + + +# Format conversions + + +def format_value_sql(value: Any) -> Tuple[str, List[Any]]: + """ + Convert a Python value into its SQL-compatible string representation and parameters. + + Args: + value: The value to format. + + Returns: + Tuple containing: + - A string for substitution into a SQL query. + - A list of parameters to be bound into the query. + """ + if isinstance(value, (dict, list)): + if len(value) == 0: + return "%s", [None] + return "CAST(%s as JSON)", [json.dumps(value)] + return "%s", [value] + + +def sql_response_to_df(cursor: MySQLCursorAbstract) -> pd.DataFrame: + """ + Convert the results of a cursor's last executed query to a pandas DataFrame. + + Args: + cursor: MySQLCursorAbstract with a completed query. + + Returns: + DataFrame with data from the cursor. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + If a compatible SELECT query wasn't the last statement ran + """ + + def _json_processor(elem: Optional[str]) -> Optional[dict]: + return json.loads(elem) if elem is not None else None + + def _default_processor(elem: Any) -> Any: + return elem + + idx_to_processor = {} + for idx, col in enumerate(cursor.description): + if col[1] == 245: + # 245 is the MySQL type code for JSON + idx_to_processor[idx] = _json_processor + else: + idx_to_processor[idx] = _default_processor + + rows = cursor.fetchall() + + # Process results + processed_rows = [] + for row in rows: + processed_row = list(row) + + for idx, elem in enumerate(row): + processed_row[idx] = idx_to_processor[idx](elem) + + processed_rows.append(processed_row) + + return pd.DataFrame(processed_rows, columns=cursor.column_names) + + +def sql_table_to_df( + cursor: MySQLCursorAbstract, schema_name: str, table_name: str +) -> pd.DataFrame: + """ + Load the entire contents of a SQL table into a pandas DataFrame. + + Args: + cursor: MySQLCursorAbstract to execute the query. + schema_name: Name of the schema containing the table. + table_name: Name of the table to fetch. + + Returns: + DataFrame containing all rows from the specified table. + + Raises: + DatabaseError: + If the table does not exist + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the schema or table name is not valid + """ + validate_name(schema_name) + validate_name(table_name) + + execute_sql(cursor, f"SELECT * FROM {schema_name}.{table_name}") + return sql_response_to_df(cursor) + + +# Table operations + + +def table_exists( + cursor: MySQLCursorAbstract, schema_name: str, table_name: str +) -> bool: + """ + Check whether a table exists in a specific schema. + + Args: + cursor: MySQLCursorAbstract object to execute the query. + schema_name: Name of the database schema. + table_name: Name of the table. + + Returns: + True if the table exists, False otherwise. + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the schema or table name is not valid + """ + validate_name(schema_name) + validate_name(table_name) + + cursor.execute( + """ + SELECT 1 + FROM information_schema.tables + WHERE table_schema = %s AND table_name = %s + LIMIT 1 + """, + (schema_name, table_name), + ) + return cursor.fetchone() is not None + + +def delete_sql_table( + cursor: MySQLCursorAbstract, schema_name: str, table_name: str +) -> None: + """ + Drop a table from the SQL database if it exists. + + Args: + cursor: MySQLCursorAbstract to execute the drop command. + schema_name: Name of the schema. + table_name: Name of the table to delete. + + Returns: + None + + Raises: + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the schema or table name is not valid + """ + validate_name(schema_name) + validate_name(table_name) + + execute_sql(cursor, f"DROP TABLE IF EXISTS {schema_name}.{table_name}") + + +def extend_sql_table( + cursor: MySQLCursorAbstract, + schema_name: str, + table_name: str, + df: pd.DataFrame, + col_name_to_placeholder_string: Dict[str, str] = None, +) -> None: + """ + Insert all rows from a pandas DataFrame into an existing SQL table. + + Args: + cursor: MySQLCursorAbstract for execution. + schema_name: Name of the database schema. + table_name: Table to insert new rows into. + df: DataFrame containing the rows to insert. + col_name_to_placeholder_string: + Optional mapping of column names to custom SQL value/placeholder + strings. + + Returns: + None + + Raises: + DatabaseError: + If the rows could not be inserted into the table, e.g., a type or shape issue + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the schema or table name is not valid + """ + if col_name_to_placeholder_string is None: + col_name_to_placeholder_string = {} + + validate_name(schema_name) + validate_name(table_name) + for col in df.columns: + validate_name(str(col)) + + qualified_table_name = f"{schema_name}.{table_name}" + + # Iterate over all rows in the DataFrame to build insert statements row by row + for row in df.values: + placeholders, params = [], [] + for elem, col in zip(row, df.columns): + elem = elem.item() if hasattr(elem, "item") else elem + + if col in col_name_to_placeholder_string: + elem_placeholder, elem_params = col_name_to_placeholder_string[col], [ + str(elem) + ] + else: + elem_placeholder, elem_params = format_value_sql(elem) + + placeholders.append(elem_placeholder) + params.extend(elem_params) + + cols_sql = ", ".join([str(col) for col in df.columns]) + placeholders_sql = ", ".join(placeholders) + insert_sql = ( + f"INSERT INTO {qualified_table_name} " + f"({cols_sql}) VALUES ({placeholders_sql})" + ) + execute_sql(cursor, insert_sql, params=params) + + +def sql_table_from_df( + cursor: MySQLCursorAbstract, schema_name: str, df: pd.DataFrame +) -> Tuple[str, str]: + """ + Create a new SQL table with a random name, and populate it with data from a DataFrame. + + If an 'id' column is defined in the dataframe, it will be used as the primary key. + + Args: + cursor: MySQLCursorAbstract for executing SQL. + schema_name: Schema in which to create the table. + df: DataFrame containing the data to be inserted. + + Returns: + Tuple (qualified_table_name, table_name): The schema-qualified and + unqualified table names. + + Raises: + RuntimeError: If a random available table name could not be found. + ValueError: If any schema, table, or a column name is invalid. + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + table_name = get_random_name( + lambda table_name: not table_exists(cursor, schema_name, table_name) + ) + qualified_table_name = f"{schema_name}.{table_name}" + + validate_name(schema_name) + validate_name(table_name) + for col in df.columns: + validate_name(str(col)) + + columns_sql = [] + for col, dtype in df.dtypes.items(): + # Map pandas dtype to SQL type, fallback is VARCHAR + sql_type = PD_TO_SQL_DTYPE_MAPPING.get(str(dtype), "LONGTEXT") + validate_name(str(col)) + columns_sql.append(f"{col} {sql_type}") + + columns_str = ", ".join(columns_sql) + + has_id_col = any(col.lower() == "id" for col in df.columns) + if has_id_col: + columns_str += ", PRIMARY KEY (id)" + + # Create table with generated columns + create_table_sql = f"CREATE TABLE {qualified_table_name} ({columns_str})" + execute_sql(cursor, create_table_sql) + + try: + # Insert provided data into new table + extend_sql_table(cursor, schema_name, table_name, df) + except Exception: # pylint: disable=broad-exception-caught + # Delete table before we lose access to it + delete_sql_table(cursor, schema_name, table_name) + raise + return qualified_table_name, table_name + + +def validate_name(name: str) -> str: + """ + Validate that the string is a legal SQL identifier (letters, digits, underscores). + + Args: + name: Name (schema, table, or column) to validate. + + Returns: + The validated name. + + Raises: + ValueError: If the name does not meet format requirements. + """ + # Accepts only letters, digits, and underscores; change as needed + if not (isinstance(name, str) and re.match(r"^[A-Za-z0-9_]+$", name)): + raise ValueError(f"Unsupported name format {name}") + + return name + + +def source_schema(db_connection: MySQLConnectionAbstract) -> str: + """ + Retrieve the name of the currently selected schema, or set and ensure the default schema. + + Args: + db_connection: MySQL connector database connection object. + + Returns: + Name of the schema (database in use). + + Raises: + ValueError: If the schema name is not valid + DatabaseError: + If a database connection issue occurs. + If an operational error occurs during execution. + """ + schema = db_connection.database + if schema is None: + schema = DEFAULT_SCHEMA + + with atomic_transaction(db_connection) as cursor: + create_database_stmt = f"CREATE DATABASE IF NOT EXISTS {schema}" + execute_sql(cursor, create_database_stmt) + + validate_name(schema) + + return schema + + +def is_table_empty( + cursor: MySQLCursorAbstract, schema_name: str, table_name: str +) -> bool: + """ + Determine if a given SQL table is empty. + + Args: + cursor: MySQLCursorAbstract with access to the database. + schema_name: Name of the schema containing the table. + table_name: Name of the table to check. + + Returns: + True if the table has no rows, False otherwise. + + Raises: + DatabaseError: + If the table does not exist + If a database connection issue occurs. + If an operational error occurs during execution. + ValueError: If the schema or table name is not valid + """ + validate_name(schema_name) + validate_name(table_name) + + cursor.execute(f"SELECT 1 FROM {schema_name}.{table_name} LIMIT 1") + return cursor.fetchone() is None + + +def convert_to_df( + arr: Optional[Union[pd.DataFrame, pd.Series, np.ndarray]], + col_prefix: str = "feature", +) -> Optional[pd.DataFrame]: + """ + Convert input data to a pandas DataFrame if necessary. + + Args: + arr: Input data as a pandas DataFrame, NumPy ndarray, pandas Series, or None. + + Returns: + If the input is None, returns None. + Otherwise, returns a DataFrame backed by the same underlying data whenever + possible (except in cases where pandas or NumPy must copy, such as for + certain views or non-contiguous arrays). + + Notes: + - If an ndarray is passed, column names will be integer indices (0, 1, ...). + - If a DataFrame is passed, column names and indices are preserved. + - The returned DataFrame is a shallow copy and shares data with the original + input when possible; however, copies may still occur for certain input + types or memory layouts. + """ + if arr is None: + return None + + if isinstance(arr, pd.DataFrame): + return pd.DataFrame(arr) + if isinstance(arr, pd.Series): + return arr.to_frame() + + if arr.ndim == 1: + arr = arr.reshape(-1, 1) + col_names = [f"{col_prefix}_{idx}" for idx in range(arr.shape[1])] + + return pd.DataFrame(arr, columns=col_names, copy=False) diff --git a/server/venv/Lib/site-packages/mysql/connector/__init__.py b/server/venv/Lib/site-packages/mysql/connector/__init__.py new file mode 100644 index 0000000..fbb145d --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/__init__.py @@ -0,0 +1,127 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""MySQL Connector/Python - MySQL driver written in Python.""" + +try: + from .connection_cext import CMySQLConnection +except ImportError: + HAVE_CEXT = False +else: + HAVE_CEXT = True + + +from . import version +from .connection import MySQLConnection +from .constants import CharacterSet, ClientFlag, FieldFlag, FieldType, RefreshOption +from .dbapi import ( + BINARY, + DATETIME, + NUMBER, + ROWID, + STRING, + Binary, + Date, + DateFromTicks, + Time, + TimeFromTicks, + Timestamp, + TimestampFromTicks, + apilevel, + paramstyle, + threadsafety, +) +from .errors import ( # pylint: disable=redefined-builtin + DatabaseError, + DataError, + Error, + IntegrityError, + InterfaceError, + InternalError, + NotSupportedError, + OperationalError, + PoolError, + ProgrammingError, + Warning, + custom_error_exception, +) +from .pooling import connect + +Connect = connect + +__version_info__ = version.VERSION +"""This attribute indicates the Connector/Python version as an array +of version components.""" + +__version__ = version.VERSION_TEXT +"""This attribute indicates the Connector/Python version as a string.""" + +__all__ = [ + "MySQLConnection", + "Connect", + "custom_error_exception", + # Some useful constants + "FieldType", + "FieldFlag", + "ClientFlag", + "CharacterSet", + "RefreshOption", + "HAVE_CEXT", + # Error handling + "Error", + "Warning", + "InterfaceError", + "DatabaseError", + "NotSupportedError", + "DataError", + "IntegrityError", + "PoolError", + "ProgrammingError", + "OperationalError", + "InternalError", + # DBAPI PEP 249 required exports + "connect", + "apilevel", + "threadsafety", + "paramstyle", + "Date", + "Time", + "Timestamp", + "Binary", + "DateFromTicks", + "DateFromTicks", + "TimestampFromTicks", + "TimeFromTicks", + "STRING", + "BINARY", + "NUMBER", + "DATETIME", + "ROWID", + # C Extension + "CMySQLConnection", +] diff --git a/server/venv/Lib/site-packages/mysql/connector/_decorating.py b/server/venv/Lib/site-packages/mysql/connector/_decorating.py new file mode 100644 index 0000000..ae31711 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/_decorating.py @@ -0,0 +1,111 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Decorators Hub.""" + +import functools +import warnings + +from typing import TYPE_CHECKING, Any, Callable + +from .constants import RefreshOption + +if TYPE_CHECKING: + from .abstracts import MySQLConnectionAbstract + + +def cmd_refresh_verify_options() -> Callable: + """Decorator verifying which options are relevant and which aren't based on + the server version the client is connecting to.""" + + def decorator(cmd_refresh: Callable) -> Callable: + @functools.wraps(cmd_refresh) + def wrapper( + cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any + ) -> Callable: + options: int = args[0] + if (options & RefreshOption.GRANT) and cnx.server_version >= ( + 9, + 2, + 0, + ): + warnings.warn( + "As of MySQL Server 9.2.0, refreshing grant tables is not needed " + "if you use statements GRANT, REVOKE, CREATE, DROP, or ALTER. " + "You should expect this option to be unsupported in a future " + "version of MySQL Connector/Python when MySQL Server removes it.", + category=DeprecationWarning, + stacklevel=1, + ) + + return cmd_refresh(cnx, options, **kwargs) + + return wrapper + + return decorator + + +def handle_read_write_timeout() -> Callable: + """ + Decorator to close the current connection if a read or a write timeout + is raised by the method passed via the func parameter. + """ + + def decorator(cnx_method: Callable) -> Callable: + @functools.wraps(cnx_method) + def handle_cnx_method( + cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any + ) -> Any: + try: + return cnx_method(cnx, *args, **kwargs) + except Exception as err: + if isinstance(err, TimeoutError): + cnx.close() + raise err + + return handle_cnx_method + + return decorator + + +def deprecated(reason: str) -> Callable: + """Use it to decorate deprecated methods.""" + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Callable: + warnings.warn( + f"Call to deprecated function {func.__name__}. Reason: {reason}", + category=DeprecationWarning, + stacklevel=2, + ) + return func(*args, **kwargs) + + return wrapper + + return decorator diff --git a/server/venv/Lib/site-packages/mysql/connector/_scripting.py b/server/venv/Lib/site-packages/mysql/connector/_scripting.py new file mode 100644 index 0000000..7cec860 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/_scripting.py @@ -0,0 +1,406 @@ +# Copyright (c) 2024, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Classes and methods utilized to work with MySQL Scripts.""" + +import re +import unicodedata + +from collections import deque +from typing import Deque, Generator, Optional + +from .errors import InterfaceError +from .types import MySQLScriptPartition + +DEFAULT_DELIMITER = b";" +"""The default delimiter of MySQL Client and the only one +recognized by the MySQL server protocol.""" + +DELIMITER_RESERVED_SYMBOLS = { + "$": rb"\$", + "^": rb"\^", + "?": rb"\?", + "(": rb"\(", + ")": rb"\)", + "[": rb"\[", + "]": rb"\]", + "{": rb"\{", + "}": rb"\}", + ".": rb"\.", + "|": rb"\|", + "+": rb"\+", + "-": rb"\-", + "*": rb"\*", +} +"""Symbols with a special meaning in regular expression contexts.""" + +DELIMITER_PATTERN: re.Pattern = re.compile( + rb"""(delimiter\s+)(?=(?:[^"'`]*(?:"[^"]*"|'[^']*'|`[^`]*`))*[^"'`]*$)""", + flags=re.IGNORECASE | re.MULTILINE, +) +"""Regular expression pattern recognizing the delimiter command.""" + + +class MySQLScriptSplitter: + """Breaks a MySQL script into single statements. + + It strips custom delimiters and comments along the way, except for comments + representing a MySQL extension or optimizer hint. + """ + + _regex_sql_split_stmts = b"""(?=(?:[^"'`]*(?:"[^"]*"|'[^']*'|`[^`]*`))*[^"'`]*$)""" + + def __init__(self, sql_script: bytes) -> None: + """Constructor.""" + self._code = sql_script + self._single_stmts: Optional[list[bytes]] = None + self._mappable_stmts: Optional[list[bytes]] = None + self._re_sql_split_stmts: dict[bytes, re.Pattern] = {} + + def _split_statement(self, code: bytes, delimiter: bytes) -> list[bytes]: + """Split code context by delimiter.""" + snippets = [] + + if delimiter not in self._re_sql_split_stmts: + if b"\\" in delimiter: + raise InterfaceError( + "The backslash (\\) character is not a valid delimiter." + ) + delimiter_pattern = [ + DELIMITER_RESERVED_SYMBOLS.get(char, char.encode()) + for char in delimiter.decode() + ] + self._re_sql_split_stmts[delimiter] = re.compile( + b"".join(delimiter_pattern) + self._regex_sql_split_stmts + ) + + for snippet in self._re_sql_split_stmts[delimiter].split(code): + snippet_strip = snippet.strip() + if snippet_strip: + snippets.append(snippet_strip) + + return snippets + + @staticmethod + def is_white_space_char(char: int) -> bool: + """Validates whether `char` is a white-space character or not.""" + return unicodedata.category(chr(char))[0] in {"Z"} + + @staticmethod + def is_control_char(char: int) -> bool: + """Validates whether `char` is a control character or not.""" + return unicodedata.category(chr(char))[0] in {"C"} + + @staticmethod + def split_by_control_char_or_white_space(string: bytes) -> list[bytes]: + """Split `string` by any control character or whitespace.""" + return re.split(rb"[\s\x00-\x1f\x7f-\x9f]", string) + + @staticmethod + def has_delimiter(code: bytes) -> bool: + """Validates whether `code` has the delimiter command pattern or not.""" + return re.search(DELIMITER_PATTERN, code) is not None + + @staticmethod + def remove_comments(code: bytes) -> bytes: + """Remove MySQL comments which include `--`-style, `#`-style + and `C`-style comments. + + A `--`-style comment spans from `--` to the end of the line. + It requires the second dash to be + followed by at least one whitespace or control character + (such as a space, tab, newline, and so on). + + A `#`-style comment spans from `#` to the end of the line. + + A C-style comment spans from a `/*` sequence to the following `*/` + sequence, as in the C programming language. This syntax enables a + comment to extend over multiple lines because the beginning and + closing sequences need not be on the same line. + + **NOTE: Only C-style comments representing MySQL extensions or + optimizer hints are preserved**. E.g., + + ``` + /*! MySQL-specific code */ + + /*+ MySQL-specific code */ + ``` + + *For Reference Manual- MySQL Comments*, see + https://dev.mysql.com/doc/refman/en/comments.html. + """ + + def is_dash_style(b_str: bytes, b_char: int) -> bool: + return b_str == b"--" and ( + MySQLScriptSplitter.is_control_char(b_char) + or MySQLScriptSplitter.is_white_space_char(b_char) + ) + + def is_hash_style(b_str: bytes) -> bool: + return b_str == b"#" + + def is_c_style(b_str: bytes, b_char: int) -> bool: + return b_str == b"/*" and b_char not in {ord("!"), ord("+")} + + buf = bytearray(b"") + i, literal_ctx = 0, None + line_break, single_quote, double_quote = ord("\n"), ord("'"), ord('"') + while i < len(code): + if literal_ctx is None: + style = None + if is_dash_style(buf[-2:], code[i]): + style = "--" + elif is_hash_style(buf[-1:]): + style = "#" + elif is_c_style(buf[-2:], code[i]): + style = "/*" + if style is not None: + if style in ("--", "#"): + while i < len(code) and code[i] != line_break: + i += 1 + else: + while i + 1 < len(code) and code[i : i + 2] != b"*/": + i += 1 + i += 2 + + for _ in range(len(style)): + buf.pop() + + while buf and ( + MySQLScriptSplitter.is_control_char(buf[-1]) + or MySQLScriptSplitter.is_white_space_char(buf[-1]) + ): + buf.pop() + + continue + + if literal_ctx is None and code[i] in [single_quote, double_quote]: + literal_ctx = code[i] + elif literal_ctx is not None and code[i] in {literal_ctx, line_break}: + literal_ctx = None + + buf.append(code[i]) + i += 1 + + return bytes(buf) + + def split_script(self, remove_comments: bool = True) -> list[bytes]: + """Splits the given script text into a sequence of individual statements. + + The word DELIMITER and any of its lower and upper case combinations + such as delimiter, DeLiMiter, etc., are considered reserved words by + the connector. Users must quote these when included in multi statements + for other purposes different from declaring an actual statement delimiter; + e.g., as names for tables, columns, variables, in comments, etc. + + ``` + CREATE TABLE `delimiter` (begin INT, end INT); -- I am a `DELimiTer` comment + ``` + + If they are not quoted, the statement-mapping will not produce the expected + experience. + + See https://dev.mysql.com/doc/refman/8.0/en/keywords.html to know more + about quoting a reserved word. + + *Note that comments are always ignored as they are not considered to be + part of statements, with one exeception; **C-style comments representing + MySQL extensions or optimizer hints are preserved***. + """ + # If it was already computed, then skip computation and use the cache + if self._single_stmts is not None: + return self._single_stmts + + # initialize variables + self._single_stmts = [] + delimiter = DEFAULT_DELIMITER + buf: list[bytes] = [] + prev = b"" + + # remove comments + if remove_comments: + code = MySQLScriptSplitter.remove_comments(code=self._code) + else: + code = self._code + + # let's split the script by `delimiter pattern` - the pattern is also + # included in the returned list. + for curr in re.split(pattern=DELIMITER_PATTERN, string=code): + # Checking if the previous substring is a "switch of context + # (delimiter)" point. + if re.search(DELIMITER_PATTERN, prev): + # The next delimiter must be the sequence of chars until + # reaching a control char or whitespace + next_delimiter = self.split_by_control_char_or_white_space(curr)[0] + + # We shall remove the delimiter command from the code + buf.pop() + + # At this point buf includes all the code where `delimiter` applies. + self._single_stmts.extend( + self._split_statement(code=b" ".join(buf), delimiter=delimiter) + ) + + # From the current substring, let's take everything but the + # "next delimiter" portion. Also, let's update the delimiter + delimiter, buf = next_delimiter, [curr[len(next_delimiter) :]] + else: + # Let's accumulate + buf.append(curr) + + # track the previous substring + prev = curr + + # Ensure there are no loose ends + if buf: + self._single_stmts.extend( + self._split_statement(code=b" ".join(buf), delimiter=delimiter) + ) + + return self._single_stmts + + def __repr__(self) -> str: + return self._code.decode("utf-8") + + +def split_multi_statement( + sql_code: bytes, + map_results: bool = False, +) -> Generator[MySQLScriptPartition, None, None]: + """Breaks a MySQL script into sub-scripts. + + If the given script uses `DELIMITER` statements (which are not recognized + by MySQL Server), the connector will parse such statements to remove them + from the script and substitute delimiters as needed. This pre-processing + may cause a performance hit when using long scripts. Note that when enabling + `map_results`, the script is expected to use `DELIMITER` statements in order + to split the script into multiple query strings. + + Args: + sql_code: MySQL script. + map_results: If True, each sub-script is `statement-result` mappable. + + Returns: + A generator of typed dictionaries with keys `single_stmts` and `mappable_stmts`. + + If mapping disabled and no delimiters detected, it returns a 1-item generator, + the field `single_stmts` is an empty list and the `mappable_stmt` field + corresponds to the unmodified script, that may be mappable. + + If mapping disabled and delimiters detected, it returns a 1-item generator, + the field `single_stmts` is a list including all the single statements + found in the script and the `mappable_stmt` field corresponds to the processed + script (delimiters are stripped) that may be mappable. + + If maping enabled, the script is broken into mappable partitions. It returns + an N-item generator (as many items as computed partitions), the field + `single_stmts` is a list including all the single statements of the partition + and the `mappable_stmt` field corresponds to the sub-script (partition) that + is guaranteed to be mappable. + + Raises: + `InterfaceError` if an invalid delimiter string is found. + """ + if not MySQLScriptSplitter.has_delimiter(sql_code) and not map_results: + # For those users executing single statements or scripts with no delimiters, + # they can get a performance boost by bypassing the multi statement splitter. + + # Simply wrap the multi statement up (so it can be processed correctly + # downstream) and return it as it is. + yield MySQLScriptPartition(single_stmts=deque([]), mappable_stmt=sql_code) + return + + tok = MySQLScriptSplitter(sql_script=sql_code) + + # The splitter splits the sql code into many single statements + # while also getting rid of the delimiters (if any). + stmts = tok.split_script() + + # if there are not statements to execute + if not stmts: + # Simply wrap the multi statement up (so it can be processed correctly + # downstream). + yield MySQLScriptPartition(single_stmts=deque([b""]), mappable_stmt=b"") + return + + if not map_results: + # group single statements into a unique and possibly no mappable + # multi statement. + yield MySQLScriptPartition( + single_stmts=deque(stmts), mappable_stmt=b";\n".join(stmts) + ) + return + + # group single statements into one or more mappable multi statements. + i = 0 + partition_ids = (j for j, stmt in enumerate(stmts) if stmt[:5].upper() == b"CALL ") + for j in partition_ids: + if j > i: + yield ( + MySQLScriptPartition( + mappable_stmt=b";\n".join(stmts[i:j]), + single_stmts=deque(stmts[i:j]), + ) + ) + yield MySQLScriptPartition( + mappable_stmt=stmts[j], single_stmts=deque([stmts[j]]) + ) + i = j + 1 + + if i < len(stmts): + yield ( + MySQLScriptPartition( + mappable_stmt=b";\n".join(stmts[i : len(stmts)]), + single_stmts=deque(stmts[i : len(stmts)]), + ) + ) + + +def get_local_infile_filenames(script: bytes) -> Deque[str]: + """Scans the MySQL script looking for `filenames` (one for each + `LOCAL INFILE` statement found). + + Arguments: + script: a MySQL script that may include one or more `LOCAL INFILE` statements. + + Returns: + filenames: a list of filenames (one for each `LOCAL INFILE` statement found). + An empty list is returned if no matches are found. + """ + matches = re.findall( + pattern=rb"""LOCAL\s+INFILE\s+(["'])((?:\\\1|(?:(?!\1)).)*)(\1)""", + string=MySQLScriptSplitter.remove_comments(script), + flags=re.IGNORECASE, + ) + if not matches or len(matches[0]) != 3: + return deque([]) + + # If there is a match, we get ("'", "filename", "'") , that's to say, + # the 1st and 3rd entries are the quote symbols, and the 2nd the actual filename. + return deque([match[1].decode("utf-8") for match in matches]) diff --git a/server/venv/Lib/site-packages/mysql/connector/abstracts.py b/server/venv/Lib/site-packages/mysql/connector/abstracts.py new file mode 100644 index 0000000..b6f0df9 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/abstracts.py @@ -0,0 +1,3185 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="assignment,attr-defined" + +"""Module gathering all abstract base classes.""" + +from __future__ import annotations + +import os +import re +import weakref + +from abc import ABC, abstractmethod +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from inspect import signature +from time import sleep +from types import TracebackType +from typing import ( + Any, + BinaryIO, + Callable, + ClassVar, + Deque, + Dict, + Generator, + Iterator, + List, + Mapping, + NoReturn, + Optional, + Sequence, + Tuple, + Type, + Union, + cast, +) + +TLS_V1_3_SUPPORTED = False +try: + import ssl + + if hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3: + TLS_V1_3_SUPPORTED = True +except ImportError: + # If import fails, we don't have SSL support. + pass + +from .constants import ( + CONN_ATTRS_DN, + DEFAULT_CONFIGURATION, + DEPRECATED_METHOD_WARNING, + MYSQL_DEFAULT_CHARSET_ID_57, + MYSQL_DEFAULT_CHARSET_ID_80, + OPENSSL_CS_NAMES, + TLS_CIPHER_SUITES, + TLS_VERSIONS, + CharacterSet, + ClientFlag, +) +from .conversion import MySQLConverter, MySQLConverterBase +from .errors import ( + DatabaseError, + Error, + InterfaceError, + NotSupportedError, + ProgrammingError, +) +from .opentelemetry.constants import ( + CONNECTION_SPAN_NAME, + OPTION_CNX_SPAN, + OPTION_CNX_TRACER, + OTEL_ENABLED, +) + +if OTEL_ENABLED: + from .opentelemetry.instrumentation import ( + end_span, + record_exception_event, + set_connection_span_attrs, + trace, + ) + +from ._decorating import deprecated +from .optionfiles import read_option_files +from .tls_ciphers import UNACCEPTABLE_TLS_CIPHERSUITES, UNACCEPTABLE_TLS_VERSIONS +from .types import ( + BinaryProtocolType, + CextEofPacketType, + DescriptionType, + EofPacketType, + HandShakeType, + MySQLConvertibleType, + MySQLScriptPartition, + RowItemType, + RowType, + StrOrBytes, + WarningType, +) +from .utils import GenericWrapper, import_object + +DUPLICATED_IN_LIST_ERROR = ( + "The '{list}' list must not contain repeated values, the value " + "'{value}' is duplicated." +) + +TLS_VERSION_ERROR = ( + "The given tls_version: '{}' is not recognized as a valid " + "TLS protocol version (should be one of {})." +) + +TLS_VERSION_UNACCEPTABLE_ERROR = ( + "The given tls_version: '{}' are no longer allowed (should be one of {})." +) + +TLS_VER_NO_SUPPORTED = ( + "No supported TLS protocol version found in the 'tls-versions' list '{}'. " +) + +KRB_SERVICE_PRINCIPAL_ERROR = ( + 'Option "krb_service_principal" {error}, must be a string in the form ' + '"primary/instance@realm" e.g "ldap/ldapauth@MYSQL.COM" where "@realm" ' + "is optional and if it is not given will be assumed to belong to the " + "default realm, as configured in the krb5.conf file." +) + +OPENID_TOKEN_FILE_ERROR = ( + 'Option "openid_token_file" {error}, it must be a string in the form ' + '"path/to/openid/token/file".' +) + +MYSQL_PY_TYPES = ( + Decimal, + bytes, + date, + datetime, + float, + int, + str, + time, + timedelta, +) + + +class CMySQLPrepStmt(GenericWrapper): + """Structure to represent a result from `CMySQLConnection.cmd_stmt_prepare`. + It can be used consistently as a type hint. + + `_mysql_connector.MySQLPrepStmt` isn't available when the C-ext isn't built. + + In this regard, `CmdStmtPrepareResult` acts as a proxy/wrapper entity for a + `_mysql_connector.MySQLPrepStmt` instance. + """ + + +class MySQLConnectionAbstract(ABC): + """Abstract class for classes connecting to a MySQL server.""" + + def __init__(self) -> None: + """Initialize""" + # private (shouldn't be manipulated directly internally) + self.__charset_id: Optional[int] = None + """It shouldn't be manipulated directly, even internally. If you need + to manipulate the charset ID, use the property `_charset_id` (read & write) + instead. Similarly, `_charset_id` shouldn't be manipulated externally, + in this case, use property `charset_id` (read-only). + """ + + # protected (can be manipulated directly internally) + self._tracer: Any = None # opentelemetry related + self._span: Any = None # opentelemetry related + self.otel_context_propagation: bool = True # opentelemetry related + + self._client_flags: int = ClientFlag.get_default() + self._sql_mode: Optional[str] = None + self._time_zone: Optional[str] = None + self._autocommit: bool = False + self._server_version: Optional[Tuple[int, ...]] = None + self._handshake: Optional[HandShakeType] = None + self._conn_attrs: Dict[str, str] = {} + + self._user: str = "" + self._password: str = "" + self._password1: str = "" + self._password2: str = "" + self._password3: str = "" + self._database: str = "" + self._host: str = "127.0.0.1" + self._port: int = 3306 + self._unix_socket: Optional[str] = None + self._client_host: str = "" + self._client_port: int = 0 + self._ssl: Dict[str, Optional[Union[str, bool, List[str]]]] = {} + self._ssl_disabled: bool = DEFAULT_CONFIGURATION["ssl_disabled"] + self._force_ipv6: bool = False + self._oci_config_file: Optional[str] = None + self._oci_config_profile: Optional[str] = None + self._webauthn_callback: Optional[Union[str, Callable[[str], None]]] = None + self._krb_service_principal: Optional[str] = None + self._openid_token_file: Optional[str] = None + + self._use_unicode: bool = True + self._get_warnings: bool = False + self._raise_on_warnings: bool = False + self._connection_timeout: Optional[int] = DEFAULT_CONFIGURATION[ + "connect_timeout" + ] + self._read_timeout: Optional[int] = DEFAULT_CONFIGURATION["read_timeout"] + self._write_timeout: Optional[int] = DEFAULT_CONFIGURATION["write_timeout"] + self._buffered: bool = False + self._unread_result: bool = False + self._have_next_result: bool = False + self._raw: bool = False + self._in_transaction: bool = False + self._allow_local_infile: bool = DEFAULT_CONFIGURATION["allow_local_infile"] + self._allow_local_infile_in_path: Optional[str] = DEFAULT_CONFIGURATION[ + "allow_local_infile_in_path" + ] + + self._prepared_statements: Any = None + self._query_attrs: Dict[str, BinaryProtocolType] = {} + + self._ssl_active: bool = False + self._auth_plugin: Optional[str] = None + self._auth_plugin_class: Optional[str] = None + self._pool_config_version: Any = None + self.converter: Optional[MySQLConverter] = None + self._converter_class: Optional[Type[MySQLConverter]] = None + self._converter_str_fallback: bool = False + self._compress: bool = False + + self._consume_results: bool = False + self._init_command: Optional[str] = None + self._character_set: CharacterSet = CharacterSet() + + self._local_infile_filenames: Optional[Deque[str]] = None + """Stores the filenames from `LOCAL INFILE` requests + found in the executed query.""" + + self._query: Optional[bytes] = None + """The query being processed.""" + + def __enter__(self) -> MySQLConnectionAbstract: + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_value: BaseException, + traceback: TracebackType, + ) -> None: + self.close() + + def get_self(self) -> MySQLConnectionAbstract: + """Returns self for `weakref.proxy`. + + This method is used when the original object is needed when using + `weakref.proxy`. + """ + return self + + @property + def is_secure(self) -> bool: + """Returns `True` if is a secure connection.""" + return self._ssl_active or ( + self._unix_socket is not None and os.name == "posix" + ) + + @property + def have_next_result(self) -> bool: + """Returns If have next result.""" + return self._have_next_result + + @property + def query_attrs(self) -> List[Tuple[str, BinaryProtocolType]]: + """Returns query attributes list.""" + return list(self._query_attrs.items()) + + def query_attrs_append(self, value: Tuple[str, BinaryProtocolType]) -> None: + """Adds element to the query attributes list on the connector's side. + + If an element in the query attributes list already matches + the attribute name provided, the new element will NOT be added. + + Args: + value: key-value as a 2-tuple. + """ + attr_name, attr_value = value + if attr_name not in self._query_attrs: + self._query_attrs[attr_name] = attr_value + + def query_attrs_remove(self, name: str) -> BinaryProtocolType: + """Removes element by name from the query attributes list on the connector's side. + + If no match, `None` is returned, else the corresponding value is returned. + + Args: + name: key name. + """ + return self._query_attrs.pop(name, None) + + def query_attrs_clear(self) -> None: + """Clears query attributes list on the connector's side.""" + self._query_attrs = {} + + def _validate_tls_ciphersuites(self) -> None: + """Validates the tls_ciphersuites option.""" + tls_ciphersuites = [] + tls_cs = self._ssl["tls_ciphersuites"] + + if isinstance(tls_cs, str): + if not (tls_cs.startswith("[") and tls_cs.endswith("]")): + raise AttributeError( + f"tls_ciphersuites must be a list, found: '{tls_cs}'" + ) + tls_css = tls_cs[1:-1].split(",") + if not tls_css: + raise AttributeError( + "No valid cipher suite found in 'tls_ciphersuites' list" + ) + for _tls_cs in tls_css: + _tls_cs = tls_cs.strip().upper() + if _tls_cs: + tls_ciphersuites.append(_tls_cs) + + elif isinstance(tls_cs, (list, set)): + tls_ciphersuites = [tls_cs for tls_cs in tls_cs if tls_cs] + else: + raise AttributeError( + "tls_ciphersuites should be a list with one or more " + f"ciphersuites. Found: '{tls_cs}'" + ) + + tls_versions = ( + TLS_VERSIONS[:] + if self._ssl.get("tls_versions", None) is None + else self._ssl["tls_versions"][:] # type: ignore[index] + ) + + # A newer TLS version can use a cipher introduced on + # an older version. + tls_versions.sort(reverse=True) # type: ignore[union-attr] + newer_tls_ver = tls_versions[0] + # translated_names[0] are TLSv1.2 only + # translated_names[1] are TLSv1.3 only + translated_names: List[List[str]] = [[], []] + iani_cipher_suites_names = {} + ossl_cipher_suites_names: List[str] = [] + + # Old ciphers can work with new TLS versions. + # Find all the ciphers introduced on previous TLS versions. + for tls_ver in TLS_VERSIONS[: TLS_VERSIONS.index(newer_tls_ver) + 1]: + iani_cipher_suites_names.update(TLS_CIPHER_SUITES[tls_ver]) + ossl_cipher_suites_names.extend(OPENSSL_CS_NAMES[tls_ver]) + + for name in tls_ciphersuites: + if "-" in name and name in ossl_cipher_suites_names: + if name in OPENSSL_CS_NAMES["TLSv1.3"]: + translated_names[1].append(name) + else: + translated_names[0].append(name) + elif name in iani_cipher_suites_names: + translated_name = iani_cipher_suites_names[name] + if translated_name in translated_names: + raise AttributeError( + DUPLICATED_IN_LIST_ERROR.format( + list="tls_ciphersuites", value=translated_name + ) + ) + if name in TLS_CIPHER_SUITES["TLSv1.3"]: + translated_names[1].append(iani_cipher_suites_names[name]) + else: + translated_names[0].append(iani_cipher_suites_names[name]) + else: + raise AttributeError( + f"The value '{name}' in tls_ciphersuites is not a valid " + "cipher suite" + ) + if not translated_names[0] and not translated_names[1]: + raise AttributeError( + "No valid cipher suite found in the 'tls_ciphersuites' list" + ) + + # raise an error when using an unacceptable cipher + for cipher_as_ossl in translated_names[0]: + if cipher_as_ossl in UNACCEPTABLE_TLS_CIPHERSUITES["TLSv1.2"].values(): + raise NotSupportedError( + f"Cipher {cipher_as_ossl} when used with TLSv1.2 is unacceptable." + ) + for cipher_as_ossl in translated_names[1]: + if cipher_as_ossl in UNACCEPTABLE_TLS_CIPHERSUITES["TLSv1.3"].values(): + raise NotSupportedError( + f"Cipher {cipher_as_ossl} when used with TLSv1.3 is unacceptable." + ) + + self._ssl["tls_ciphersuites"] = [ + ":".join(translated_names[0]), + ":".join(translated_names[1]), + ] + + def _validate_tls_versions(self) -> None: + """Validates the tls_versions option.""" + tls_versions = [] + tls_version = self._ssl["tls_versions"] + + if isinstance(tls_version, str): + if not (tls_version.startswith("[") and tls_version.endswith("]")): + raise AttributeError( + f"tls_versions must be a list, found: '{tls_version}'" + ) + tls_vers = tls_version[1:-1].split(",") + for tls_ver in tls_vers: + tls_version = tls_ver.strip() + if tls_version == "": + continue + if tls_version in tls_versions: + raise AttributeError( + DUPLICATED_IN_LIST_ERROR.format( + list="tls_versions", value=tls_version + ) + ) + tls_versions.append(tls_version) + if tls_vers == ["TLSv1.3"] and not TLS_V1_3_SUPPORTED: + raise AttributeError( + TLS_VER_NO_SUPPORTED.format(tls_version, TLS_VERSIONS) + ) + elif isinstance(tls_version, list): + if not tls_version: + raise AttributeError( + "At least one TLS protocol version must be specified in " + "'tls_versions' list" + ) + for tls_ver in tls_version: + if tls_ver in tls_versions: + raise AttributeError( + DUPLICATED_IN_LIST_ERROR.format( + list="tls_versions", value=tls_ver + ) + ) + tls_versions.append(tls_ver) + elif isinstance(tls_version, set): + for tls_ver in tls_version: + tls_versions.append(tls_ver) + else: + raise AttributeError( + "tls_versions should be a list with one or more of versions " + f"in {', '.join(TLS_VERSIONS)}. found: '{tls_versions}'" + ) + + if not tls_versions: + raise AttributeError( + "At least one TLS protocol version must be specified " + "in 'tls_versions' list when this option is given" + ) + + use_tls_versions = [] + unacceptable_tls_versions = [] + invalid_tls_versions = [] + for tls_ver in tls_versions: + if tls_ver in TLS_VERSIONS: + use_tls_versions.append(tls_ver) + if tls_ver in UNACCEPTABLE_TLS_VERSIONS: + unacceptable_tls_versions.append(tls_ver) + else: + invalid_tls_versions.append(tls_ver) + + if use_tls_versions: + if use_tls_versions == ["TLSv1.3"] and not TLS_V1_3_SUPPORTED: + raise NotSupportedError( + TLS_VER_NO_SUPPORTED.format(tls_version, TLS_VERSIONS) + ) + self._ssl["tls_versions"] = use_tls_versions + elif unacceptable_tls_versions: + raise NotSupportedError( + TLS_VERSION_UNACCEPTABLE_ERROR.format( + unacceptable_tls_versions, TLS_VERSIONS + ) + ) + elif invalid_tls_versions: + raise AttributeError(TLS_VERSION_ERROR.format(tls_ver, TLS_VERSIONS)) + + @property + def user(self) -> str: + """The user name used for connecting to the MySQL server.""" + return self._user + + @property + def server_host(self) -> str: + """MySQL server IP address or name.""" + return self._host + + @property + def server_port(self) -> int: + "MySQL server TCP/IP port." + return self._port + + @property + def unix_socket(self) -> Optional[str]: + "The Unix socket file for connecting to the MySQL server." + return self._unix_socket + + @property + @abstractmethod + def database(self) -> str: + """The current database.""" + + @database.setter + def database(self, value: str) -> None: + """Sets the current database.""" + self.cmd_query(f"USE {value}") + + @property + def can_consume_results(self) -> bool: + """Returns whether to consume results.""" + return self._consume_results + + @can_consume_results.setter + def can_consume_results(self, value: bool) -> None: + """Sets if can consume results.""" + assert isinstance(value, bool) + self._consume_results = value + + @property + def pool_config_version(self) -> Any: + """Returns the pool configuration version.""" + return self._pool_config_version + + @pool_config_version.setter + def pool_config_version(self, value: Any) -> None: + """Sets the pool configuration version""" + self._pool_config_version = value + + def config(self, **kwargs: Any) -> None: + """Configures the MySQL Connection. + + This method allows you to configure the `MySQLConnection` + instance after it has been instantiated. + + Args: + **kwargs: For a complete list of possible arguments, see [1]. + + Raises: + AttributeError: When provided unsupported connection arguments. + InterfaceError: When the provided connection argument is invalid. + + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html + """ + # opentelemetry related + self._span = kwargs.pop(OPTION_CNX_SPAN, None) + self._tracer = kwargs.pop(OPTION_CNX_TRACER, None) + + config = kwargs.copy() + if "dsn" in config: + raise NotSupportedError("Data source name is not supported") + + # Read option files + config = read_option_files(**config) + + # Configure how we handle MySQL warnings + try: + self.get_warnings = config["get_warnings"] + del config["get_warnings"] + except KeyError: + pass # Leave what was set or default + try: + self.raise_on_warnings = config["raise_on_warnings"] + del config["raise_on_warnings"] + except KeyError: + pass # Leave what was set or default + + # Configure client flags + try: + default = ClientFlag.get_default() + self.client_flags = config["client_flags"] or default + del config["client_flags"] + except KeyError: + pass # Missing client_flags-argument is OK + + try: + if config["compress"]: + self._compress = True + self.client_flags = [ClientFlag.COMPRESS] + except KeyError: + pass # Missing compress argument is OK + + self._allow_local_infile = config.get( + "allow_local_infile", DEFAULT_CONFIGURATION["allow_local_infile"] + ) + self._allow_local_infile_in_path = config.get( + "allow_local_infile_in_path", + DEFAULT_CONFIGURATION["allow_local_infile_in_path"], + ) + infile_in_path = None + if self._allow_local_infile_in_path: + infile_in_path = os.path.abspath(self._allow_local_infile_in_path) + if ( + infile_in_path + and os.path.exists(infile_in_path) + and not os.path.isdir(infile_in_path) + or os.path.islink(infile_in_path) + ): + raise AttributeError("allow_local_infile_in_path must be a directory") + if self._allow_local_infile or self._allow_local_infile_in_path: + self.client_flags = [ClientFlag.LOCAL_FILES] + else: + self.client_flags = [-ClientFlag.LOCAL_FILES] + + try: + if not config["consume_results"]: + self._consume_results = False + else: + self._consume_results = True + except KeyError: + self._consume_results = False + + # Configure auth_plugin + try: + self._auth_plugin = config["auth_plugin"] + del config["auth_plugin"] + except KeyError: + self._auth_plugin = "" + + # Disallow the usage of some default authentication plugins + if self._auth_plugin == "authentication_webauthn_client": + raise InterfaceError( + f"'{self._auth_plugin}' cannot be used as the default authentication " + "plugin" + ) + + # Set converter class + try: + self.converter_class = config["converter_class"] + except KeyError: + pass # Using default converter class + except TypeError as err: + raise AttributeError( + "Converter class should be a subclass of " + "conversion.MySQLConverterBase" + ) from err + + # Compatible configuration with other drivers + compat_map = [ + # (,) + ("db", "database"), + ("username", "user"), + ("passwd", "password"), + ("connect_timeout", "connection_timeout"), + ("read_default_file", "option_files"), + ] + for compat, translate in compat_map: + try: + if translate not in config: + config[translate] = config[compat] + del config[compat] + except KeyError: + pass # Missing compat argument is OK + + # Configure login information + if "user" in config or "password" in config: + try: + user = config["user"] + del config["user"] + except KeyError: + user = self._user + try: + password = config["password"] + del config["password"] + except KeyError: + password = self._password + self.set_login(user, password) + + # Configure host information + if "host" in config and config["host"]: + self._host = config["host"] + + # Check network locations + try: + self._port = int(config["port"]) + del config["port"] + except KeyError: + pass # Missing port argument is OK + except ValueError as err: + raise InterfaceError("TCP/IP port number should be an integer") from err + + if "ssl_disabled" in config: + self._ssl_disabled = config.pop("ssl_disabled") + + # If an init_command is set, keep it, so we can execute it in _post_connection + if "init_command" in config: + self._init_command = config["init_command"] + del config["init_command"] + + # Other configuration + set_ssl_flag = False + for key, value in config.items(): + try: + DEFAULT_CONFIGURATION[key] + except KeyError: + raise AttributeError(f"Unsupported argument '{key}'") from None + # SSL Configuration + if key.startswith("ssl_"): + set_ssl_flag = True + self._ssl.update({key.replace("ssl_", ""): value}) + elif key.startswith("tls_"): + set_ssl_flag = True + self._ssl.update({key: value}) + else: + attribute = "_" + key + try: + setattr(self, attribute, value.strip()) + except AttributeError: + setattr(self, attribute, value) + + # Disable SSL for unix socket connections + if self._unix_socket and os.name == "posix": + self._ssl_disabled = True + + if self._ssl_disabled: + if self._auth_plugin == "mysql_clear_password": + raise InterfaceError( + "Clear password authentication is not supported over insecure channels" + ) + if self._auth_plugin == "authentication_openid_connect_client": + raise InterfaceError( + "OpenID Connect authentication is not supported over insecure channels" + ) + + if set_ssl_flag: + if "verify_cert" not in self._ssl: + self._ssl["verify_cert"] = DEFAULT_CONFIGURATION["ssl_verify_cert"] + if "verify_identity" not in self._ssl: + self._ssl["verify_identity"] = DEFAULT_CONFIGURATION[ + "ssl_verify_identity" + ] + # Make sure both ssl_key/ssl_cert are set, or neither (XOR) + if "ca" not in self._ssl or self._ssl["ca"] is None: + self._ssl["ca"] = "" + if bool("key" in self._ssl) != bool("cert" in self._ssl): + raise AttributeError( + "ssl_key and ssl_cert need to be both specified, or neither" + ) + # Make sure key/cert are set to None + if not set(("key", "cert")) <= set(self._ssl): + self._ssl["key"] = None + self._ssl["cert"] = None + elif (self._ssl["key"] is None) != (self._ssl["cert"] is None): + raise AttributeError( + "ssl_key and ssl_cert need to be both set, or neither" + ) + if self._ssl.get("tls_versions") is not None: + self._validate_tls_versions() + + if self._ssl.get("tls_ciphersuites") is not None: + self._validate_tls_ciphersuites() + + if self._conn_attrs is None: + self._conn_attrs = {} + elif not isinstance(self._conn_attrs, dict): + raise InterfaceError("conn_attrs must be of type dict") + else: + for attr_name, attr_value in self._conn_attrs.items(): + if attr_name in CONN_ATTRS_DN: + continue + # Validate name type + if not isinstance(attr_name, str): + raise InterfaceError( + "Attribute name should be a string, found: " + f"'{attr_name}' in '{self._conn_attrs}'" + ) + # Validate attribute name limit 32 characters + if len(attr_name) > 32: + raise InterfaceError( + f"Attribute name '{attr_name}' exceeds 32 characters limit size" + ) + # Validate names in connection attributes cannot start with "_" + if attr_name.startswith("_"): + raise InterfaceError( + "Key names in connection attributes cannot start with " + "'_', found: '{attr_name}'" + ) + # Validate value type + if not isinstance(attr_value, str): + raise InterfaceError( + f"Attribute '{attr_name}' value: '{attr_value}' must " + "be a string type" + ) + # Validate attribute value limit 1024 characters + if len(attr_value) > 1024: + raise InterfaceError( + f"Attribute '{attr_name}' value: '{attr_value}' " + "exceeds 1024 characters limit size" + ) + + if self._client_flags & ClientFlag.CONNECT_ARGS: + self._add_default_conn_attrs() + + if "kerberos_auth_mode" in config and config["kerberos_auth_mode"] is not None: + if not isinstance(config["kerberos_auth_mode"], str): + raise InterfaceError("'kerberos_auth_mode' must be of type str") + kerberos_auth_mode = config["kerberos_auth_mode"].lower() + if kerberos_auth_mode == "sspi": + if os.name != "nt": + raise InterfaceError( + "'kerberos_auth_mode=SSPI' is only available on Windows" + ) + self._auth_plugin_class = "MySQLSSPIKerberosAuthPlugin" + elif kerberos_auth_mode == "gssapi": + self._auth_plugin_class = "MySQLKerberosAuthPlugin" + else: + raise InterfaceError( + "Invalid 'kerberos_auth_mode' mode. Please use 'SSPI' or 'GSSAPI'" + ) + + if ( + "krb_service_principal" in config + and config["krb_service_principal"] is not None + ): + self._krb_service_principal = config["krb_service_principal"] + if not isinstance(self._krb_service_principal, str): + raise InterfaceError( + KRB_SERVICE_PRINCIPAL_ERROR.format(error="is not a string") + ) + if self._krb_service_principal == "": + raise InterfaceError( + KRB_SERVICE_PRINCIPAL_ERROR.format( + error="can not be an empty string" + ) + ) + if "/" not in self._krb_service_principal: + raise InterfaceError( + KRB_SERVICE_PRINCIPAL_ERROR.format(error="is incorrectly formatted") + ) + + if self._webauthn_callback: + self._validate_callable("webauth_callback", self._webauthn_callback, 1) + + if config.get("openid_token_file") is not None: + self._openid_token_file = config["openid_token_file"] + if not isinstance(self._openid_token_file, str): + raise InterfaceError( + OPENID_TOKEN_FILE_ERROR.format(error="is not a string") + ) + if self._openid_token_file == "": + raise InterfaceError( + OPENID_TOKEN_FILE_ERROR.format(error="cannot be an empty string") + ) + if not os.path.exists(self._openid_token_file): + raise InterfaceError( + f"The path '{self._openid_token_file}' provided via 'openid_token_file' " + "does not exist" + ) + + if config.get("read_timeout") is not None: + self._read_timeout = config["read_timeout"] + if not isinstance(self._read_timeout, int) or self._read_timeout < 0: + raise InterfaceError("Option read_timeout must be a positive integer") + if config.get("write_timeout") is not None: + self._write_timeout = config["write_timeout"] + if not isinstance(self._write_timeout, int) or self._write_timeout < 0: + raise InterfaceError("Option write_timeout must be a positive integer") + + def _add_default_conn_attrs(self) -> None: + """Adds the default connection attributes.""" + + @staticmethod + def _validate_callable( + option_name: str, callback: Union[str, Callable], num_args: int = 0 + ) -> None: + """Validates if it's a Python callable. + + Args: + option_name (str): Connection option name. + callback (str or callable): The fully qualified path to the callable or + a callable. + num_args (int): Number of positional arguments allowed. + + Raises: + ProgrammingError: If `callback` is not valid or wrong number of positional + arguments. + + .. versionadded:: 8.2.0 + """ + if isinstance(callback, str): + try: + callback = import_object(callback) + except ValueError as err: + raise ProgrammingError(f"{err}") from err + + if not callable(callback): + raise ProgrammingError(f"Expected a callable for '{option_name}'") + + # Check if the callable signature has positional arguments + num_params = len(signature(callback).parameters) + if num_params != num_args: + raise ProgrammingError( + f"'{option_name}' requires {num_args} positional argument, but the " + f"callback provided has {num_params}" + ) + + @staticmethod + def _check_server_version(server_version: StrOrBytes) -> Tuple[int, ...]: + """Checks the MySQL version. + + This method will check the MySQL version and raise an InterfaceError + when it is not supported or invalid. It will return the version + as a tuple with major, minor and patch. + + Raises InterfaceError if invalid server version. + + Returns tuple + """ + if isinstance(server_version, (bytearray, bytes)): + server_version = server_version.decode() + + regex_ver = re.compile(r"^(\d{1,2})\.(\d{1,2})\.(\d{1,3})(.*)") + match = regex_ver.match(server_version) + if not match: + raise InterfaceError("Failed parsing MySQL version") + + version = tuple(int(v) for v in match.groups()[0:3]) + if version < (4, 1): + raise InterfaceError(f"MySQL Version '{server_version}' is not supported") + + return version + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="server_version")) + def get_server_version(self) -> Optional[Tuple[int, ...]]: + """Gets the MySQL version. + + Returns: + The MySQL server version as a tuple. If not previously connected, it will + return `None`. + """ + return self._server_version + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="server_info")) + def get_server_info(self) -> Optional[str]: + """Gets the original MySQL version information. + + Returns: + The original MySQL server as text. If not previously connected, it will + return `None`. + """ + return self.server_info + + @property + def server_version(self) -> Optional[Tuple[int, ...]]: + """Gets the MySQL Server version the connector is connected to. + + Returns: + The MySQL server version as a tuple. If not previously connected, it will + return `None`. + """ + return self._server_version + + @property + def server_info(self) -> Optional[str]: + """Gets the original MySQL server version information. + + Returns: + The original MySQL server as text. If not previously connected, it will + return `None`. + """ + try: + return self._handshake["server_version_original"] # type: ignore[return-value] + except (TypeError, KeyError): + return None + + @property + @abstractmethod + def in_transaction(self) -> bool: + """Returns bool to indicate whether a transaction is active for the connection. + + The value is `True` regardless of whether you start a transaction using the + `start_transaction()` API call or by directly executing an SQL statement such + as START TRANSACTION or BEGIN. + + `in_transaction` was added in MySQL Connector/Python 1.1.0. + + Examples: + ``` + >>> cnx.start_transaction() + >>> cnx.in_transaction + True + >>> cnx.commit() + >>> cnx.in_transaction + False + ``` + """ + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="client_flags")) + def set_client_flags(self, flags: Union[int, Sequence[int]]) -> int: + """Sets the client flags. + + The flags-argument can be either an int or a list (or tuple) of + ClientFlag-values. If it is an integer, it will set client_flags + to flags as is. + + If flags is a sequence, each item in the sequence sets the flag when the + value is positive or unsets it when negative (see example below). + + Args: + flags: A list (or tuple), each flag will be set or unset when it's negative. + + Returns: + integer: Client flags. + + Raises: + ProgrammingError: When the flags argument is not a set or an integer + bigger than 0. + + Examples: + ``` + For example, to unset `LONG_FLAG` and set the `FOUND_ROWS` flags: + >>> from mysql.connector.constants import ClientFlag + >>> cnx.set_client_flags([ClientFlag.FOUND_ROWS, -ClientFlag.LONG_FLAG]) + >>> cnx.reconnect() + ``` + """ + self.client_flags = flags + return self.client_flags + + @property + def client_flags(self) -> int: + """Gets the client flags of the current session.""" + return self._client_flags + + @client_flags.setter + def client_flags(self, flags: Union[int, Sequence[int]]) -> None: + """Sets the client flags. + + The flags-argument can be either an int or a list (or tuple) of + ClientFlag-values. If it is an integer, it will set client_flags + to flags as is. + + If flags is a sequence, each item in the sequence sets the flag when the + value is positive or unsets it when negative (see example below). + + Args: + flags: A list (or tuple), each flag will be set or unset when it's negative. + + Raises: + ProgrammingError: When the flags argument is not a set or an integer + bigger than 0. + + Examples: + ``` + For example, to unset `LONG_FLAG` and set the `FOUND_ROWS` flags: + >>> from mysql.connector.constants import ClientFlag + >>> cnx.client_flags = [ClientFlag.FOUND_ROWS, -ClientFlag.LONG_FLAG] + >>> cnx.reconnect() + ``` + """ + if isinstance(flags, int) and flags > 0: + self._client_flags = flags + elif isinstance(flags, (tuple, list)): + for flag in flags: + if flag < 0: + self._client_flags &= ~abs(flag) + else: + self._client_flags |= flag + else: + raise ProgrammingError("client_flags setter expect integer (>0) or set") + + def shutdown(self) -> NoReturn: + """Shuts down connection to MySQL Server. + + This method closes the socket. It raises no exceptions. + + Unlike `disconnect()`, `shutdown()` closes the client connection without + attempting to send a `QUIT` command to the server first. Thus, it will not + block if the connection is disrupted for some reason such as network failure. + """ + raise NotImplementedError + + def isset_client_flag(self, flag: int) -> bool: + """Checks if a client flag is set. + + Returns: + `True` if the client flag was set, `False` otherwise. + """ + return (self._client_flags & flag) > 0 + + @property + def time_zone(self) -> str: + """Gets the current time zone.""" + return self.info_query("SELECT @@session.time_zone")[ + 0 + ] # type: ignore[return-value] + + @time_zone.setter + def time_zone(self, value: str) -> None: + """Sets the time zone.""" + self.cmd_query(f"SET @@session.time_zone = '{value}'") + self._time_zone = value + + @property + def sql_mode(self) -> str: + """Gets the SQL mode.""" + if self._sql_mode is None: + self._sql_mode = self.info_query("SELECT @@session.sql_mode")[0] + return self._sql_mode + + @sql_mode.setter + def sql_mode(self, value: Union[str, Sequence[int]]) -> None: + """Sets the SQL mode. + + This method sets the SQL Mode for the current connection. The value + argument can be either a string with comma separate mode names, or + a sequence of mode names. + + It is good practice to use the constants class `SQLMode`: + ``` + >>> from mysql.connector.constants import SQLMode + >>> cnx.sql_mode = [SQLMode.NO_ZERO_DATE, SQLMode.REAL_AS_FLOAT] + ``` + """ + if isinstance(value, (list, tuple)): + value = ",".join(value) + self.cmd_query(f"SET @@session.sql_mode = '{value}'") + self._sql_mode = value + + @abstractmethod + def info_query(self, query: str) -> Optional[RowType]: + """Sends a query which only returns 1 row. + + Shortcut for: + + ``` + cursor = self.cursor(buffered=True) + cursor.execute(query) + return cursor.fetchone() + ``` + + Args: + query: Statement to execute. + + Returns: + row: A tuple (RowType). + """ + + def set_login( + self, username: Optional[str] = None, password: Optional[str] = None + ) -> None: + """Sets login information for MySQL. + + Sets the username and/or password for the user connecting to the MySQL Server. + + Args: + username: Account's user name. + password: Account's password. + """ + if username is not None: + self._user = username.strip() + else: + self._user = "" + if password is not None: + self._password = password + else: + self._password = "" + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="use_unicode")) + def set_unicode(self, value: bool = True) -> None: + """Sets whether we return string fields as unicode or not. + + Args: + value: A boolean - default is `True`. + """ + self.use_unicode = value + + @property + def use_unicode(self) -> bool: + """Gets whether we return string fields as unicode or not.""" + return self._use_unicode + + @use_unicode.setter + @abstractmethod + def use_unicode(self, value: bool) -> None: + """Sets whether we return string fields as unicode or not. + + Args: + value: A boolean - default is `True`. + """ + + @property + def autocommit(self) -> bool: + """Gets whether autocommit is on or off.""" + value = self.info_query("SELECT @@session.autocommit")[0] + return value == 1 + + @autocommit.setter + def autocommit(self, value: bool) -> None: + """Toggles autocommit.""" + switch = "ON" if value else "OFF" + self.cmd_query(f"SET @@session.autocommit = {switch}") + self._autocommit = value + + @property + def get_warnings(self) -> bool: + """Gets whether this connection retrieves warnings automatically. + + This method returns whether this connection retrieves warnings + automatically. + + Returns `True`, or `False` when warnings are not retrieved. + """ + return self._get_warnings + + @get_warnings.setter + def get_warnings(self, value: bool) -> None: + """Sets whether warnings should be automatically retrieved. + + The toggle-argument must be a boolean. When True, cursors for this + connection will retrieve information about warnings (if any). + + Raises `ValueError` on error. + """ + if not isinstance(value, bool): + raise ValueError("Expected a boolean type") + self._get_warnings = value + + @property + def raise_on_warnings(self) -> bool: + """Gets whether this connection raises an error on warnings. + + This method returns whether this connection will raise errors when + MySQL reports warnings. + + Returns `True` or `False`. + """ + return self._raise_on_warnings + + @raise_on_warnings.setter + def raise_on_warnings(self, value: bool) -> None: + """Sets whether warnings raise an error. + + The toggle-argument must be a boolean. When True, cursors for this + connection will raise an error when MySQL reports warnings. + + Raising on warnings implies retrieving warnings automatically. In + other words: warnings will be set to True. If set to False, warnings + will be also set to False. + + Raises `ValueError` on error. + """ + if not isinstance(value, bool): + raise ValueError("Expected a boolean type") + self._raise_on_warnings = value + # Don't disable warning retrieval if raising explicitly disabled + if value: + self._get_warnings = value + + @property + def unread_result(self) -> bool: + """Gets whether there is an unread result. + + This method is used by cursors to check whether another cursor still + needs to retrieve its result set. + + Returns `True`, or `False` when there is no unread result. + """ + return self._unread_result + + @unread_result.setter + def unread_result(self, value: bool) -> None: + """Sets whether there is an unread result. + + This method is used by cursors to let other cursors know there is + still a result set that needs to be retrieved. + + Raises `ValueError` on errors. + """ + if not isinstance(value, bool): + raise ValueError("Expected a boolean type") + self._unread_result = value + + @property + def collation(self) -> str: + """Returns the collation for current connection. + + This property returns the collation name of the current connection. + The server is queried when the connection is active. If not connected, + the configured collation name is returned. + + Returns a string. + """ + return self._character_set.get_charset_info(self._charset_id)[2] + + @property + def charset(self) -> str: + """Returns the character set for current connection. + + This property returns the character set name of the current connection. + The server is queried when the connection is active. If not connected, + the configured character set name is returned. + + Returns a string. + """ + return self._character_set.get_info(self._charset_id)[0] + + @property + def charset_id(self) -> int: + """The charset ID utilized during the connection phase. + + If the charset ID hasn't been set, the default charset ID is returned. + """ + return self._charset_id + + @property + def _charset_id(self) -> int: + """The charset ID utilized during the connection phase. + + If the charset ID hasn't been set, the default charset ID is returned. + """ + if self.__charset_id is None: + if self._server_version is None: + # We mustn't set the private since we still don't know + # the server version. We temporarily return the default + # charset for undefined scenarios - eventually, the server + # info will be available and the private variable will be set. + return MYSQL_DEFAULT_CHARSET_ID_57 + + self.__charset_id = ( + MYSQL_DEFAULT_CHARSET_ID_57 + if self._server_version < (8, 0) + else MYSQL_DEFAULT_CHARSET_ID_80 + ) + + return self.__charset_id + + @_charset_id.setter + def _charset_id(self, value: int) -> None: + """Sets the charset ID utilized during the connection phase.""" + self.__charset_id = value + + @property + def python_charset(self) -> str: + """Returns the Python character set for current connection. + + This property returns the character set name of the current connection. + Note that, unlike property charset, this checks if the previously set + character set is supported by Python and if not, it returns the + equivalent character set that Python supports. + + Returns a string. + """ + encoding = self._character_set.get_info(self._charset_id)[0] + if encoding in ("utf8mb4", "utf8mb3", "binary"): + return "utf8" + return encoding + + def set_charset_collation( + self, charset: Optional[Union[int, str]] = None, collation: Optional[str] = None + ) -> None: + """Sets the character set and collation for the current connection. + + This method sets the character set and collation to be used for + the current connection. The charset argument can be either the + name of a character set as a string, or the numerical equivalent + as defined in constants.CharacterSet. + + When the collation is not given, the default will be looked up and + used. + + Args: + charset: Can be either the name of a character set, or the numerical + equivalent as defined in `constants.CharacterSet`. + collation: When collation is `None`, the default collation for the + character set is used. + + Examples: + The following will set the collation for the latin1 character set to + `latin1_general_ci`: + ``` + >>> cnx = mysql.connector.connect(user='scott') + >>> cnx.set_charset_collation('latin1', 'latin1_general_ci') + ``` + """ + err_msg = "{} should be either integer, string or None" + if not isinstance(charset, (int, str)) and charset is not None: + raise ValueError(err_msg.format("charset")) + if not isinstance(collation, str) and collation is not None: + raise ValueError("collation should be either string or None") + + if charset: + if isinstance(charset, int): + ( + self._charset_id, + charset_name, + collation_name, + ) = self._character_set.get_charset_info(charset) + elif isinstance(charset, str): + ( + self._charset_id, + charset_name, + collation_name, + ) = self._character_set.get_charset_info(charset, collation) + else: + raise ValueError(err_msg.format("charset")) + elif collation: + ( + self._charset_id, + charset_name, + collation_name, + ) = self._character_set.get_charset_info(collation=collation) + else: + charset = DEFAULT_CONFIGURATION["charset"] + ( + self._charset_id, + charset_name, + collation_name, + ) = self._character_set.get_charset_info(charset, collation=None) + + self._execute_query(f"SET NAMES '{charset_name}' COLLATE '{collation_name}'") + + if self.converter: + self.converter.set_charset(charset_name, character_set=self._character_set) + + @property + def read_timeout(self) -> Optional[int]: + """ + Gets the connection context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + """ + return self._read_timeout + + @read_timeout.setter + def read_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the connection context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set + in seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the read_timeout of the current session to + 5 seconds: + ``` + >>> cnx = mysql.connector.connect(user='scott') + >>> cnx.read_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option read_timeout must be a positive integer or None" + ) + self._read_timeout = timeout + + @property + def write_timeout(self) -> Optional[int]: + """ + Gets the connection context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + """ + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the connection context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set in + seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the write_timeout of the current + session to 5 seconds: + ``` + >>> cnx = mysql.connector.connect(user='scott') + >>> cnx.write_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option write_timeout must be a positive integer or None" + ) + self._write_timeout = timeout + + @property + @abstractmethod + def connection_id(self) -> Optional[int]: + """MySQL connection ID.""" + + @abstractmethod + def _do_handshake(self) -> None: + """Gathers information of the MySQL server before authentication.""" + + @abstractmethod + def _open_connection(self) -> None: + """Opens the connection to the MySQL server.""" + + def _post_connection(self) -> None: + """Executes commands after connection has been established. + + This method executes commands after the connection has been + established. Some setting like autocommit, character set, and SQL mode + are set using this method. + """ + self.set_charset_collation(charset=self._charset_id) + self.autocommit = self._autocommit + if self._time_zone: + self.time_zone = self._time_zone + if self._sql_mode: + self.sql_mode = self._sql_mode + if self._init_command: + self._execute_query(self._init_command) + + @abstractmethod + def close(self) -> None: + """Disconnects from the MySQL server. + + This method tries to send a `QUIT` command and close the socket. It raises + no exceptions. + + `MySQLConnection.close()` is a synonymous for `MySQLConnection.disconnect()` + method name and more commonly used. + + To shut down the connection without sending a `QUIT` command first, + use `shutdown()`. + """ + + disconnect: ClassVar[Callable[["MySQLConnectionAbstract"], None]] = close + + def connect(self, **kwargs: Any) -> None: + """Connects to the MySQL server. + + This method sets up the connection to the MySQL server. If no + arguments are given, it will use the already configured or default + values. + + Args: + **kwargs: For a complete list of possible arguments, see [1]. + + Examples: + ``` + >>> cnx = MySQLConnection(user='joe', database='test') + ``` + + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html + """ + # open connection using the default charset id + if kwargs: + self.config(**kwargs) + + self.disconnect() + self._open_connection() + + charset, collation = ( + kwargs.pop("charset", None), + kwargs.pop("collation", None), + ) + if charset or collation: + self._charset_id = self._character_set.get_charset_info(charset, collation)[ + 0 + ] + + if not self._client_flags & ClientFlag.CAN_HANDLE_EXPIRED_PASSWORDS: + self._post_connection() + else: + # the server does not allow to run any other statement different from + # ALTER when user's password has been expired - the server either + # disconnects the client or restricts the client to "sandbox mode" [1]. + # [1]: https://dev.mysql.com/doc/refman/5.7/en/expired-password-handling.html + try: + self.set_charset_collation(charset=self._charset_id) + except DatabaseError: + # get out of sandbox mode - with no FOR user clause, the statement sets + # the password for the current user. + self.cmd_query(f"SET PASSWORD = '{self._password1 or self._password}'") + + # Set charset and collation. + self.set_charset_collation(charset=self._charset_id) + + # go back to sandbox mode. + self.cmd_query("ALTER USER CURRENT_USER() PASSWORD EXPIRE") + + def reconnect(self, attempts: int = 1, delay: int = 0) -> None: + """Attempts to reconnect to the MySQL server. + + The argument `attempts` should be the number of times a reconnect + is tried. The `delay` argument is the number of seconds to wait between + each retry. + + You may want to set the number of attempts higher and use delay when + you expect the MySQL server to be down for maintenance or when you + expect the network to be temporary unavailable. + + Args: + attempts: Number of attempts to make when reconnecting. + delay: Use it (defined in seconds) if you want to wait between each retry. + + Raises: + InterfaceError: When reconnection fails. + """ + counter = 0 + span = None + + if self._tracer: + # pylint: disable=possibly-used-before-assignment + span = self._tracer.start_span( + name=CONNECTION_SPAN_NAME, kind=trace.SpanKind.CLIENT + ) + + try: + while counter != attempts: + counter = counter + 1 + try: + self.disconnect() + self.connect() + if self.is_connected(): + break + except (Error, IOError) as err: + if counter == attempts: + msg = ( + f"Can not reconnect to MySQL after {attempts} " + f"attempt(s): {err}" + ) + raise InterfaceError(msg) from err + if delay > 0: + sleep(delay) + except InterfaceError as interface_err: + if OTEL_ENABLED: + set_connection_span_attrs(self, span) + record_exception_event(span, interface_err) + end_span(span) + raise + + self._span = span + if OTEL_ENABLED: + set_connection_span_attrs(self, self._span) + + @abstractmethod + def is_connected(self) -> bool: + """Reports whether the connection to MySQL Server is available or not. + + Checks whether the connection to MySQL is available using the `ping()` method, + but unlike `ping()`, `is_connected()` returns `True` when the connection is + available, `False` otherwise + """ + + @abstractmethod + def ping(self, reconnect: bool = False, attempts: int = 1, delay: int = 0) -> None: + """Checks availability of the MySQL server. + + When reconnect is set to `True`, one or more attempts are made to try + to reconnect to the MySQL server using the reconnect()-method. + + `delay` is the number of seconds to wait between each retry. + + When the connection is not available, an InterfaceError is raised. + + Args: + reconnect: If True, one or more `attempts` are made to try to reconnect + to the MySQL server, and these options are forwarded to the + `reconnect()` method. + attempts: Number of attempts to make when reconnecting. + delay: Use it (defined in seconds) if you want to wait between each retry. + + Raises: + InterfaceError: When the connection is not available. Use the + `is_connected()` method if you just want to check the + connection without raising an error. + """ + + @abstractmethod + def commit(self) -> None: + """Commits current transaction. + + This method is part of PEP 249 - Python Database API Specification v2.0. + + This method sends a COMMIT statement to the MySQL server, committing the + current transaction. Since by default Connector/Python does not autocommit. + + It is important to call this method after every transaction that modifies + data for tables that use transactional storage engines. + + Examples: + ``` + >>> stmt = "INSERT INTO employees (first_name) VALUES (%s), (%s)" + >>> cursor.execute(stmt, ('Jane', 'Mary')) + >>> cnx.commit() + ``` + """ + + @abstractmethod + def cursor( + self, + buffered: Optional[bool] = None, + raw: Optional[bool] = None, + prepared: Optional[bool] = None, + cursor_class: Optional[Type["MySQLCursorAbstract"]] = None, + dictionary: Optional[bool] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> "MySQLCursorAbstract": + """Instantiates and returns a cursor. + + By default, `MySQLCursor` or `CMySQLCursor` is returned. Depending on the + options while connecting, a buffered and/or raw cursor is instantiated + instead. Also depending upon the cursor options, rows can be returned as + a dictionary or a tuple. + + Dictionary based cursors are available with buffered output + but not raw. + + It is possible to also give a custom cursor through the `cursor_class` + parameter, but it needs to be a subclass of `mysql.connector.cursor.MySQLCursor` + or `mysql.connector.cursor_cext.CMySQLCursor` according to the type of + connection that's being used. + + **NOTE: The parameters read and write timeouts in cursors are unsupported for + C-Extension.** + + Args: + buffered: If `True`, the cursor fetches all rows from the server after an + operation is executed. This is useful when queries return small + result sets. + raw: If `True`, the cursor skips the conversion from MySQL data types to + Python types when fetching rows. A raw cursor is usually used to get + better performance or when you want to do the conversion yourself. + prepared: If `True`, the cursor is used for executing prepared statements. + cursor_class: It can be used to pass a class to use for instantiating a + new cursor. It must be a subclass of `cursor.MySQLCursor` + or `cursor_cext.CMySQLCursor` according to the type of + connection that's being used. + dictionary: If `True`, the cursor returns rows as dictionaries. + read_timeout: A positive integer representing timeout in seconds for each + attempt to read any data from the server. + write_timeout: A positive integer representing timeout in seconds for each + attempt to send any data to the server. + + Returns: + cursor: A cursor object. + + Raises: + ProgrammingError: When `cursor_class` is not a subclass of + `MySQLCursorAbstract`. + ValueError: When cursor is not available. + InterfaceError: When read_timeout or write_timeout is not a positive integer. + """ + + @abstractmethod + def _execute_query(self, query: str) -> None: + """Executes a query.""" + + @abstractmethod + def rollback(self) -> None: + """Rollbacks current transaction. + + Sends a ROLLBACK statement to the MySQL server, undoing all data changes + from the current transaction. By default, Connector/Python does not + autocommit, so it is possible to cancel transactions when using + transactional storage engines such as `InnoDB`. + + Examples: + ``` + >>> stmt = "INSERT INTO employees (first_name) VALUES (%s), (%s)" + >>> cursor.execute(stmt, ('Jane', 'Mary')) + >>> cnx.rollback() + ``` + """ + + def start_transaction( + self, + consistent_snapshot: bool = False, + isolation_level: Optional[str] = None, + readonly: Optional[bool] = None, + ) -> None: + """Starts a transaction. + + This method explicitly starts a transaction sending the + START TRANSACTION statement to the MySQL server. You can optionally + set whether there should be a consistent snapshot, which + isolation level you need or which access mode i.e. READ ONLY or + READ WRITE. + + Args: + consistent_snapshot: If `True`, Connector/Python sends WITH CONSISTENT + SNAPSHOT with the statement. MySQL ignores this for + isolation levels for which that option does not apply. + isolation_level: Permitted values are 'READ UNCOMMITTED', 'READ COMMITTED', + 'REPEATABLE READ', and 'SERIALIZABLE'. If the value is + `None`, no isolation level is sent, so the default level + applies. + readonly: Can be `True` to start the transaction in READ ONLY mode or + `False` to start it in READ WRITE mode. If readonly is omitted, + the server's default access mode is used. + + Raises: + ProgrammingError: When a transaction is already in progress + and when `ValueError` when `isolation_level` + specifies an Unknown level. + + Examples: + For example, to start a transaction with isolation level `SERIALIZABLE`, + you would do the following: + ``` + >>> cnx = mysql.connector.connect(...) + >>> cnx.start_transaction(isolation_level='SERIALIZABLE') + ``` + """ + if self.in_transaction: + raise ProgrammingError("Transaction already in progress") + + if isolation_level: + level = isolation_level.strip().replace("-", " ").upper() + levels = [ + "READ UNCOMMITTED", + "READ COMMITTED", + "REPEATABLE READ", + "SERIALIZABLE", + ] + + if level not in levels: + raise ValueError(f'Unknown isolation level "{isolation_level}"') + + self._execute_query(f"SET TRANSACTION ISOLATION LEVEL {level}") + + if readonly is not None: + if self._server_version < (5, 6, 5): + raise ValueError( + f"MySQL server version {self._server_version} does not " + "support this feature" + ) + + if readonly: + access_mode = "READ ONLY" + else: + access_mode = "READ WRITE" + self._execute_query(f"SET TRANSACTION {access_mode}") + + query = "START TRANSACTION" + if consistent_snapshot: + query += " WITH CONSISTENT SNAPSHOT" + self.cmd_query(query) + + @abstractmethod + def reset_session( + self, + user_variables: Optional[Dict[str, Any]] = None, + session_variables: Optional[Dict[str, Any]] = None, + ) -> None: + """Clears the current active session. + + This method resets the session state, if the MySQL server is 5.7.3 + or later active session will be reset without re-authenticating. + For other server versions session will be reset by re-authenticating. + + It is possible to provide a sequence of variables and their values to + be set after clearing the session. This is possible for both user + defined variables and session variables. + + Args: + user_variables: User variables map. + session_variables: System variables map. + + Raises: + OperationalError: If not connected. + InternalError: If there are unread results and InterfaceError on errors. + + Examples: + ``` + >>> user_variables = {'var1': '1', 'var2': '10'} + >>> session_variables = {'wait_timeout': 100000, 'sql_mode': 'TRADITIONAL'} + >>> cnx.reset_session(user_variables, session_variables) + ``` + """ + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="converter_class")) + def set_converter_class(self, convclass: Optional[Type[MySQLConverter]]) -> None: + """ + Sets the converter class to be used. + + Args: + convclass: Should be a class overloading methods and members of + `conversion.MySQLConverter`. + """ + self.converter_class = convclass + + @property + def converter_class(self) -> Optional[Type[MySQLConverter]]: + """Gets the converter class set for the current session.""" + return self._converter_class + + @converter_class.setter + def converter_class(self, convclass: Optional[Type[MySQLConverter]]) -> None: + """ + Sets the converter class to be used. + + Args: + convclass: Should be a class overloading methods and members of + `conversion.MySQLConverter`. + """ + if convclass and issubclass(convclass, MySQLConverterBase): + charset_name = self._character_set.get_info(self._charset_id)[0] + self._converter_class = convclass + self.converter = convclass(charset_name, self.use_unicode) + self.converter.str_fallback = self._converter_str_fallback + else: + raise TypeError( + "Converter class should be a subclass of conversion.MySQLConverterBase." + ) + + @abstractmethod + def get_row( + self, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Optional[CMySQLPrepStmt] = None, + **kwargs: Any, + ) -> Tuple[Optional[RowType], Optional[Dict[str, Any]]]: + """Retrieves the next row of a query result set. + + Args: + binary: If `True`, read as binary result (only meaningful for pure Python + connections). + columns: Field types (only meaningful for pure Python connections and when + `binary=True`). + raw: If `True`, the converter class does not convert the parsed values. + prep_stmt: Prepared statement object (only meaningful for + C-ext connections). + + Returns: + tuple: The row as a tuple (RowType) containing byte objects, or `None` when + no more rows are available. (at position 0). EOF packet + information as a dictionary containing `status_flag` and + `warning_count` (at position 1). + + Raises: + InterfaceError: When all rows have been retrieved. + """ + + @abstractmethod + def get_rows( + self, + count: Optional[int] = None, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Optional[CMySQLPrepStmt] = None, + **kwargs: Any, + ) -> Tuple[List[RowType], Optional[Dict[str, Any]]]: + """Gets all rows returned by the MySQL server. + + Args: + count: Used to obtain a given number of rows. If set to `None`, all + rows are fetched. + binary: If `True`, read as binary result (only meaningful for pure Python + connections). + columns: Field types (only meaningful for pure Python connections and when + `binary=True`). + raw: If `True`, the converter class does not convert the parsed values. + prep_stmt: Prepared statement object (only meaningful for + C-ext connections). + + Returns: + tuple: A list of tuples (RowType) containing the row data as byte objects, + or an empty list when no rows are available (at position 0). + EOF packet information as a dictionary containing `status_flag` + and `warning_count` (at position 1). + + Raises: + InterfaceError: When all rows have been retrieved. + """ + + @abstractmethod + def cmd_init_db(self, database: str) -> Optional[Dict[str, Any]]: + """Changes the current database. + + This method makes specified database the default (current) database. + In subsequent queries, this database is the default for table references + that include no explicit database qualifier. + + Args: + database: Database to become the default (current) database. + + Returns: + ok_packet: Dictionary containing the OK packet information. + """ + + @abstractmethod + def cmd_query( + self, + query: str, + raw: Optional[bool] = False, + buffered: bool = False, + raw_as_string: bool = False, + **kwargs: Any, + ) -> Optional[Dict[str, Any]]: + """Sends a query to the MySQL server. + + This method sends the query to the MySQL server and returns the result. + To **send multiple statements, use the `cmd_query_iter()` method instead**. + + The returned dictionary contains information depending on what kind of query + was executed. If the query is a `SELECT` statement, the result contains + information about columns. Other statements return a dictionary containing + OK or EOF packet information. + + Errors received from the MySQL server are raised as exceptions. + + **Arguments `raw`, `buffered` and `raw_as_string` are only meaningful + for `C-ext` connections**. + + Args: + query: Statement to be executed. + raw: If `True`, the cursor skips the conversion from MySQL data types to + Python types when fetching rows. A raw cursor is usually used to get + better performance or when you want to do the conversion yourself. If + not provided, take its value from the MySQL instance. + buffered: If `True`, the cursor fetches all rows from the server after an + operation is executed. This is useful when queries return small + result sets. + raw_as_string: Is a special argument for Python v2 and returns `str` + instead of `bytearray`. + + Returns: + dictionary: `Result` or `OK packet` information + """ + + @abstractmethod + def cmd_query_iter( + self, + statements: str, + **kwargs: Any, + ) -> Generator[Mapping[str, Any], None, None]: + """Sends one or more statements to the MySQL server. + + Similar to the `cmd_query()` method, but instead returns a generator + object to iterate through results. It sends the statements to the + MySQL server and through the iterator you can get the results. + + Use `cmd_query_iter()` when sending multiple statements, and separate + the statements with semicolons. + + Args: + statements: Statements to be executed separated with semicolons. + + Returns: + generator: Generator object with `Result` or `OK packet` information. + + Examples: + The following example shows how to iterate through the results after + sending multiple statements: + ``` + >>> statement = 'SELECT 1; INSERT INTO t1 VALUES (); SELECT 2' + >>> for result in cnx.cmd_query_iter(statement): + >>> if 'columns' in result: + >>> columns = result['columns'] + >>> rows = cnx.get_rows() + >>> else: + >>> # do something useful with INSERT result + ``` + """ + + @abstractmethod + def cmd_refresh(self, options: int) -> Dict[str, Any]: + """Send the Refresh command to the MySQL server. + + This method sends the Refresh command to the MySQL server. The options + argument should be a bitwise value using constants.RefreshOption. + + Typical usage example: + ``` + RefreshOption = mysql.connector.RefreshOption + refresh = RefreshOption.LOG | RefreshOption.INFO + cnx.cmd_refresh(refresh) + ``` + + Args: + options: Bitmask value constructed using constants from + the `constants.RefreshOption` class. + + Returns: + A dictionary representing the OK packet got as response when executing + the command. + + Raises: + ValueError: If an invalid command `refresh options` is provided. + DeprecationWarning: If one of the options is deprecated for the server you + are connecting to. + """ + + @abstractmethod + def cmd_quit(self) -> Optional[bytes]: + """Closes the current connection with the server. + + This method sends the `QUIT` command to the MySQL server, closing the + current connection. Since there is no response from the MySQL server, + the packet that was sent is returned. + + Returns: + packet_sent: `None` when using a C-ext connection, + else the actual packet that was sent. + """ + + @abstractmethod + def cmd_shutdown(self, shutdown_type: Optional[int] = None) -> None: + """Shuts down the MySQL Server. + + This method sends the SHUTDOWN command to the MySQL server. + The `shutdown_type` is not used, and it's kept for backward compatibility. + """ + + @abstractmethod + def cmd_statistics(self) -> Optional[Dict[str, Any]]: + """Sends the statistics command to the MySQL Server. + + Returns: + dict: Stats packet information about the MySQL server including uptime + in seconds and the number of running threads, questions, reloads, and + open tables. + """ + + @staticmethod + def cmd_process_info() -> NoReturn: + """Get the process list of the MySQL Server. + + This method is a placeholder to notify that the PROCESS_INFO command + is not supported by raising the `NotSupportedError`. + + The command + "SHOW PROCESSLIST" should be send using the cmd_query()-method or + using the `INFORMATION_SCHEMA` database. + + Raises `NotSupportedError` exception. + """ + raise NotSupportedError( + "Not implemented. Use SHOW PROCESSLIST or INFORMATION_SCHEMA" + ) + + @abstractmethod + def cmd_process_kill(self, mysql_pid: int) -> Optional[Dict[str, Any]]: + """Kills a MySQL process. + + Asks the server to kill the thread specified by `mysql_pid`. Although + still available, it is better to use the KILL SQL statement. + + Args: + mysql_pid: Process ID to be killed. + + Returns: + ok_packet: Dictionary containing the OK packet information. + + Examples: + ``` + >>> cnx.cmd_process_kill(123) # using cmd_process_kill() + >>> cnx.cmd_query('KILL 123') # alternatively (recommended) + ``` + """ + + @abstractmethod + def cmd_debug(self) -> Optional[Dict[str, Any]]: + """Instructs the server to write debugging information to the error log. + + The connected user must have the `SUPER` privilege. + + Returns: + ok_packet: Dictionary containing the EOF (end-of-file) packet information. + """ + + @abstractmethod + def cmd_ping(self) -> Optional[Dict[str, Any]]: + """Checks whether the connection to the server is working. + + This method is not to be used directly. Use `ping()` or + `is_connected()` instead. + + Returns: + ok_packet: Dictionary containing the OK packet information. + """ + + @abstractmethod + def cmd_change_user( + self, + username: str = "", + password: str = "", + database: str = "", + charset: Optional[int] = None, + password1: str = "", + password2: str = "", + password3: str = "", + oci_config_file: str = "", + oci_config_profile: str = "", + openid_token_file: str = "", + ) -> Optional[Dict[str, Any]]: + """Changes the current logged in user. + + It also causes the specified database to become the default (current) + database. It is also possible to change the character set using the + charset argument. The character set passed during initial connection + is reused if no value of charset is passed via this method. + + Args: + username: New account's username. + password: New account's password. + database: Database to become the default (current) database. + charset: Client charset (see [1]), only the lower 8-bits. + password1: New account's password factor 1 - it's used instead + of `password` if set (higher precedence). + password2: New account's password factor 2. + password3: New account's password factor 3. + oci_config_file: OCI configuration file location (path-like string). + oci_config_profile: OCI configuration profile location (path-like string). + openid_token_file: OpenID Connect token file location (path-like string). + + Returns: + ok_packet: Dictionary containing the OK packet information. + + Examples: + ``` + >>> cnx.cmd_change_user(username='', password='', database='', charset=33) + ``` + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_basic_character_set.html#a_protocol_character_set + """ + + @abstractmethod + def cmd_stmt_prepare( + self, + statement: bytes, + **kwargs: Any, + ) -> Union[Mapping[str, Any], CMySQLPrepStmt]: + """Prepares a MySQL statement. + + Args: + statement: statement to prepare. + + Returns: + prepared_stmt: A `Prepared Statement` structure - a dictionary + is returned when using a pure Python connection, and a + `_mysql_connector.MySQLPrepStmt` object is returned when + using a C-ext connection. + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/ + page_protocol_com_stmt_prepare.html + """ + + @abstractmethod + def cmd_stmt_execute( + self, + statement_id: Union[int, CMySQLPrepStmt], + data: Sequence[BinaryProtocolType] = (), + parameters: Sequence = (), + flags: int = 0, + **kwargs: Any, + ) -> Optional[Union[Dict[str, Any], Tuple]]: + """Executes a prepared MySQL statement. + + Args: + statement_id: Statement ID found in the dictionary returned by + `MySQLConnection.cmd_stmt_prepare` when using a pure Python + connection, or a `_mysql_connector.MySQLPrepStmt` instance + as returned by `CMySQLConnection.cmd_stmt_prepare` when + using a C-ext connection. + data: Data sequence against which the prepared statement will be executed. + parameters: Currently unused! + flags: see [1]. + + Returns: + dictionary or tuple: `OK packet` or `Result` information. + + Notes: + The previous method's signature applies to pure Python, the C-ext has + the following signature: + + ``` + def cmd_stmt_execute( + self, statement_id: CMySQLPrepStmt, *args: Any + ) -> Optional[Union[Dict[str, Any], Tuple]]: + ``` + + You should expect a similar returned value type, however, the input + is different. In this case `data` must be provided as positional + arguments instead of a sequence. + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_com_stmt_execute.html + """ + + @abstractmethod + def cmd_stmt_close( + self, + statement_id: Union[int, CMySQLPrepStmt], + **kwargs: Any, + ) -> None: + """Deallocates a prepared MySQL statement. + + Args: + statement_id: Statement ID found in the dictionary returned by + `MySQLConnection.cmd_stmt_prepare` when using a pure Python + connection, or a `_mysql_connector.MySQLPrepStmt` instance + as returned by `CMySQLConnection.cmd_stmt_prepare` when + using a C-ext connection. + """ + + @abstractmethod + def cmd_stmt_send_long_data( + self, + statement_id: Union[int, CMySQLPrepStmt], + param_id: int, + data: BinaryIO, + **kwargs: Any, + ) -> int: + """Sends data for a column. + + Currently, not implemented for the C-ext. + + Args: + statement_id: Statement ID found in the dictionary returned by + `MySQLConnection.cmd_stmt_prepare` when using a pure Python + connection, or a `_mysql_connector.MySQLPrepStmt` instance + as returned by `CMySQLConnection.cmd_stmt_prepare` when + using a C-ext connection. + param_id: The parameter to supply data to [1]. + data: The actual payload to send [1]. + + Returns: + total_sent: The total number of bytes that were sent is returned. + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_com_stmt_send_long_data.html + """ + + @abstractmethod + def cmd_stmt_reset( + self, + statement_id: Union[int, CMySQLPrepStmt], + **kwargs: Any, + ) -> None: + """Resets data for prepared statement sent as long data. + + Args: + statement_id: Statement ID found in the dictionary returned by + `MySQLConnection.cmd_stmt_prepare` when using a pure Python + connection, or a `_mysql_connector.MySQLPrepStmt` instance + as returned by `CMySQLConnection.cmd_stmt_prepare` when + using a C-ext connection. + """ + + @abstractmethod + def cmd_reset_connection(self) -> bool: + """Resets the session state without re-authenticating. + + Reset command only works on MySQL server 5.7.3 or later. + + This method permits the session state to be cleared without reauthenticating. + For MySQL servers older than 5.7.3 (when `COM_RESET_CONNECTION` was introduced) + , the `reset_session()` method can be used instead - that method resets the + session state by reauthenticating, which is more expensive. + + This method was added in Connector/Python 1.2.1. + + Returns: + `True` for a successful reset otherwise `False`. + """ + + +class MySQLCursorAbstract(ABC): + """Abstract cursor class + + Abstract class defining cursor class with method and members + required by the Python Database API Specification v2.0. + """ + + def __init__( + self, + connection: Optional[MySQLConnectionAbstract] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + """Defines the MySQL cursor interface.""" + + self._connection: Optional[MySQLConnectionAbstract] = connection + if connection is not None: + if not isinstance(connection, MySQLConnectionAbstract): + raise InterfaceError(errno=2048) + self._connection = weakref.proxy(connection) + + self._description: Optional[List[DescriptionType]] = None + self._rowcount: int = -1 + self._last_insert_id: Optional[int] = None + self._warnings: Optional[List[WarningType]] = None + self._warning_count: int = 0 + self._executed: Optional[bytes] = None + self._executed_list: List[bytes] = [] + self._stored_results: List[MySQLCursorAbstract] = [] + self.arraysize: int = 1 + self._binary: bool = False + self._raw: bool = False + self._nextrow: Tuple[ + Optional[RowType], Optional[Union[EofPacketType, CextEofPacketType]] + ] = ( + None, + None, + ) + self._read_timeout: Optional[int] = read_timeout + self._write_timeout: Optional[int] = write_timeout + + # multi statement execution + self._stmt_partitions: Optional[Generator[MySQLScriptPartition, None, None]] = ( + None + ) + self._stmt_partition: Optional[MySQLScriptPartition] = None + self._stmt_map_results: bool = False + + def __enter__(self) -> MySQLCursorAbstract: + return self + + def __exit__( + self, + exc_type: Type[BaseException], + exc_value: BaseException, + traceback: TracebackType, + ) -> None: + self.close() + + @property + def read_timeout(self) -> Optional[int]: + """ + Gets the cursor context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + """ + return self._read_timeout + + @read_timeout.setter + def read_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the cursor context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set + in seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the read_timeout of the current session to + 5 seconds: + ``` + >>> cnx = mysql.connector.connect(user='scott') + >>> cur = cnx.cursor() + >>> cur.read_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option read_timeout must be a positive integer or None" + ) + self._read_timeout = timeout + + @property + def write_timeout(self) -> Optional[int]: + """ + Gets the connection context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + """ + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the connection context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set in + seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the write_timeout of the current + session to 5 seconds: + ``` + >>> cnx = mysql.connector.connect(user='scott') + >>> cur = cnx.cursor() + >>> cur.write_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option write_timeout must be a positive integer or None" + ) + self._write_timeout = timeout + + @abstractmethod + def callproc( + self, procname: str, args: Sequence = () + ) -> Optional[Union[Dict[str, RowItemType], RowType]]: + """Calls a stored procedure with the given arguments. + + The arguments will be set during this session, meaning they will be called like + ___arg where is an enumeration (+1) of the arguments. + + Args: + procname: The stored procedure name. + args: Sequence of parameters - it must contain one entry for each argument + that the procedure expects. + + Returns: + Does not return a value, but a result set will be available when the + CALL-statement executes successfully. + + `callproc()` returns a modified copy of the input sequence. `Input` + parameters are left untouched. `Output` and `input/output` parameters may + be replaced with new values. + + Result sets produced by the stored procedure are automatically fetched and + stored as `MySQLCursorBuffered` instances. + + The value returned (if any) is a `Dict` when cursor's subclass is + `MySQLCursorDict`, else a `Tuple` (RowType). + + Raises: + InterfaceError: When something is wrong + + Examples: + 1) Defining the Stored Routine in MySQL: + ``` + CREATE PROCEDURE multiply(IN pFac1 INT, IN pFac2 INT, OUT pProd INT) + BEGIN + SET pProd := pFac1 * pFac2; + END; + ``` + + 2) Executing in Python: + ``` + >>> args = (5, 6, 0) # 0 is to hold value of the OUT parameter pProd + >>> cursor.callproc('multiply', args) + ('5', '6', 30L) + ``` + + References: + [1]: https://dev.mysql.com/doc/connector-python/en/\ + connector-python-api-mysqlcursor-callproc.html + """ + + @abstractmethod + def close(self) -> None: + """Close the cursor. + + Use close() when you are done using a cursor. This method closes the cursor, + resets all results, and ensures that the cursor object has no reference to its + original connection object. + + This method is part of PEP 249 - Python Database API Specification v2.0. + """ + + @abstractmethod + def execute( + self, + operation: str, + params: Union[ + Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType] + ] = (), + map_results: bool = False, + ) -> None: + """Executes the given operation (a MySQL script) substituting any markers + with the given parameters. + + For example, getting all rows where id is 5: + ``` + cursor.execute("SELECT * FROM t1 WHERE id = %s", (5,)) + ``` + + If you want each single statement in the script to be related + to its corresponding result set, you should enable the `map_results` + switch - see workflow example below. + + If the given script uses `DELIMITER` statements (which are not recognized + by MySQL Server), the connector will parse such statements to remove them + from the script and substitute delimiters as needed. This pre-processing + may cause a performance hit when using long scripts. Note that when enabling + `map_results`, the script is expected to use `DELIMITER` statements in order + to split the script into multiple query strings. + + The following characters are currently not supported by the connector in + `DELIMITER` statements: `"`, `'`, #`, `/*` and `*/`. + + If warnings were generated, and `connection.get_warnings` is + `True`, then `self.warnings` will be a list containing these + warnings. + + Args: + operation: Operation to be executed - it can be a single or a + multi statement. + params: The parameters found in the tuple or dictionary params are bound + to the variables in the operation. Specify variables using `%s` or + `%(name)s` parameter style (that is, using format or pyformat style). + map_results: It is `False` by default. If `True`, it allows you to know what + statement caused what result set - see workflow example below. + Only relevant when working with multi statements. + + Returns: + `None`. + + Example (basic usage): + The following example runs many single statements in a + single go and loads the corresponding result sets + sequentially: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + with cnx.cursor() as cur: + cur.execute(sql_operation) + + result_set = cur.fetchall() + # do something with result set + ... + + while cur.nextset(): + result_set = cur.fetchall() + # do something with result set + ... + ``` + + In case the operation is a single statement, you may skip the + looping section as no more result sets are to be expected. + + Example (statement-result mapping): + The following example runs many single statements in a + single go and loads the corresponding result sets + sequentially. Additionally, each result set gets related + to the statement that caused it: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + with cnx.cursor() as cur: + cur.execute(sql_operation, map_results=True) + + # statement 1 is `SET @a=1, @b='2024-02-01'`, + # result set from statement 1 is `[]` - aka, an empty set. + result_set, statement = cur.fetchall(), cur.statement + # do something with result set + ... + + # 1st call to `nextset()` will laod the result set from statement 2, + # statement 2 is `SELECT @a, LENGTH('hello'), @b`, + # result set from statement 2 is `[(1, 5, '2024-02-01')]`. + # + # 2nd call to `nextset()` will laod the result set from statement 3, + # statement 3 is `SELECT @@version`, + # result set from statement 3 is `[('9.0.0-labs-mrs-8',)]`. + # + # 3rd call to `nextset()` will return `None` as there are no more sets, + # leading to the end of the consumption process of result sets. + while cur.nextset(): + result_set, statement = cur.fetchall(), cur.statement + # do something with result set + ... + ``` + + In case the mapping is disabled (`map_results=False`), all result + sets get related to the same statement, which is the one provided + when calling `execute()`. In other words, the property `statement` + will not change as result sets are consumed, which contrasts with + the case in which the mapping is enabled. Note that we offer a + new fetch-related API command which can be leveraged as a shortcut + for consuming result sets - it is equivalent to the previous + workflow. + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + with cnx.cursor() as cur: + cur.execute(sql_operation, map_results=True) + for statement, result_set in cur.fetchsets(): + # do something with result set + ``` + """ + + @abstractmethod + def executemany( + self, + operation: str, + seq_params: Sequence[ + Union[Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType]] + ], + ) -> None: + """Executes the given operation multiple times. + + The `executemany()` method will execute the operation iterating + over the list of parameters in `seq_params`. + + `INSERT` statements are optimized by batching the data, that is + using the MySQL multiple rows syntax. + + Args: + operation: Operation to be executed. + seq_params: Parameters to be used when executing the operation. + + Returns: + Results are discarded. If they are needed, consider looping over data + using the `execute()` method. + + Examples: + An optimization is applied for inserts: The data values given by the + parameter sequences are batched using multiple-row syntax. The following + example inserts three records: + + ``` + >>> data = [ + >>> ('Jane', date(2005, 2, 12)), + >>> ('Joe', date(2006, 5, 23)), + >>> ('John', date(2010, 10, 3)), + >>> ] + >>> stmt = "INSERT INTO employees (first_name, hire_date) VALUES (%s, %s)" + >>> cursor.executemany(stmt, data) + ``` + + For the preceding example, the INSERT statement sent to MySQL is: + ``` + >>> INSERT INTO employees (first_name, hire_date) + >>> VALUES ('Jane', '2005-02-12'), ('Joe', '2006-05-23'), ('John', '2010-10-03') + ``` + """ + + @abstractmethod + def fetchone(self) -> Optional[Union[RowType, Dict[str, RowItemType]]]: + """Retrieves next row of a query result set + + Returns: + If the cursor's subclass is `MySQLCursorDict`, a dictionaries is + returned, otherwise a tuple (RowType). `None` is returned when there aren't + results to be read. + + Examples: + ``` + >>> cursor.execute("SELECT * FROM employees") + >>> row = cursor.fetchone() + >>> while row is not None: + >>> print(row) + >>> row = cursor.fetchone() + ``` + """ + + @abstractmethod + def fetchmany(self, size: int = 1) -> List[Union[RowType, Dict[str, RowItemType]]]: + """Fetches the next set of rows of a query result. + + Args: + size: The number of rows returned can be specified using the size + argument, which is one by default. + + Returns: + If the cursor's subclass is `MySQLCursorDict`, a list of dictionaries is + returned, otherwise a list of tuples (RowType). When no more rows are + available, it returns an empty list. + """ + + @abstractmethod + def fetchall(self) -> List[Union[RowType, Dict[str, RowItemType]]]: + """Fetches all (or all remaining) rows of a query result set. + + Returns: + If the cursor's subclass is `MySQLCursorDict`, a list of dictionaries is + returned, otherwise a list of tuples (RowType). + + Examples: + ``` + >>> cursor.execute("SELECT * FROM employees ORDER BY emp_no") + >>> head_rows = cursor.fetchmany(size=2) + >>> remaining_rows = cursor.fetchall() + ``` + """ + + def fetchsets( + self, + ) -> Generator[ + tuple[Optional[str], list[Union[RowType, Dict[str, RowItemType]]]], None, None + ]: + """Generates the result sets stream caused by the last `execute*()`. + + Returns: + A 2-tuple; the first element is the statement that caused the + result set, and the second is the result set itself. + + This method is used as part of the multi statement + execution workflow - see example below. + + Example: + Consider the following example where multiple statements are executed in one + go: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + with cnx.cursor() as cur: + cur.execute(sql_operation, map_results=True) + + result_set, statement = cur.fetchall(), cur.statement + # do something with result set + ... + + while cur.nextset(): + result_set, statement = cur.fetchall(), cur.statement + # do something with result set + ... + ``` + + In this case, as an alternative to loading the result sets with `nextset()` + in combination with a `while` loop, you can use `fetchsets()` which is + equivalent to the previous approach: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + with cnx.cursor() as cur: + cur.execute(sql_operation) + for statement, result_set in cur.fetchsets(): + # do something with result set + ``` + """ + # Some cursor flavor such as `buffered` raise an exception when they don't have + # result sets to fetch from. + # Some others, such as `dictionary` or `raw`, return an empty result set + # under the same circumstances. + statement_cached = None + if not self._stmt_map_results: + statement_cached = self.statement + + try: + result_set = self.fetchall() + except InterfaceError: + result_set = [] + yield ( + self.statement if self._stmt_map_results else statement_cached + ), result_set + while self.nextset(): + try: + result_set = self.fetchall() + except InterfaceError: + result_set = [] + yield ( + self.statement if self._stmt_map_results else statement_cached + ), result_set + + @abstractmethod + def stored_results(self) -> Iterator[MySQLCursorAbstract]: + """Returns an iterator (of MySQLCursorAbstract subclass instances) for stored results. + + This method returns an iterator over results which are stored when + callproc() is called. The iterator will provide `MySQLCursorBuffered` + instances. + + Examples: + ``` + >>> cursor.callproc('myproc') + () + >>> for result in cursor.stored_results(): + ... print result.fetchall() + ... + [(1,)] + [(2,)] + ``` + """ + + @abstractmethod + def nextset(self) -> Optional[bool]: + """Makes the cursor skip to the next available set, discarding + any remaining rows from the current set. + + This method is used as part of the multi statement + execution workflow - see example below. + + Returns: + It returns `None` if there are no more sets. Otherwise, it returns + `True` and subsequent calls to the `fetch*()` methods will return + rows from the next result set. + + Example: + The following example runs many single statements in a + single go and loads the corresponding result sets + sequentially: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + with cnx.cursor() as cur: + cur.execute(sql_operation) + + result_set = cur.fetchall() + # do something with result set + ... + + while cur.nextset(): + result_set = cur.fetchall() + # do something with result set + ... + ``` + + In case the operation is a single statement, you may skip the + looping section as no more result sets are to be expected. + """ + + def setinputsizes(self, sizes: Any) -> NoReturn: + """Not Implemented.""" + + def setoutputsize(self, size: Any, column: Any = None) -> NoReturn: + """Not Implemented.""" + + def reset(self, free: bool = True) -> None: + """Resets the cursor to default""" + + @property + def description(self) -> Optional[List[DescriptionType]]: + """This read-only property returns a list of tuples describing the columns in a + result set. + + A tuple is described as follows:: + ``` + (column_name, + type, + None, + None, + None, + None, + null_ok, + column_flags) # Addition to PEP-249 specs + ``` + + See [1] for more details and examples. + + Returns: + A list of tuples. + + References: + [1]: https://dev.mysql.com/doc/connector-python/en/\ + connector-python-api-mysqlcursor-description.html + """ + return self._description + + @property + def rowcount(self) -> int: + """Returns the number of rows produced or affected. + + This property returns the number of rows produced by queries + such as `SELECT`, or affected rows when executing DML statements + like `INSERT` or `UPDATE`. + + Note that for non-buffered cursors it is impossible to know the + number of rows produced before having fetched them all. For those, + the number of rows will be -1 right after execution, and + incremented when fetching rows. + + Returns an integer. + """ + return self._rowcount + + @property + def lastrowid(self) -> Optional[int]: + """Returns the value generated for an AUTO_INCREMENT column. + + Returns the value generated for an AUTO_INCREMENT column by + the previous INSERT or UPDATE statement or `None` when there is + no such a value available. + + Returns a long value or `None`. + """ + return self._last_insert_id + + @property + def warnings(self) -> Optional[List[WarningType]]: + """Gets a list of tuples (WarningType) containing warnings generated + by the previously executed operation. + + Examples: + ``` + >>> cnx.get_warnings = True + >>> cursor.execute("SELECT 'a'+1") + >>> cursor.fetchall() + [(1.0,)] + >>> cursor.warnings + [(u'Warning', 1292, u"Truncated incorrect DOUBLE value: 'a'")] + ``` + """ + return self._warnings + + @property + def warning_count(self) -> int: + """Returns the number of warnings. + + This property returns the number of warnings generated by the + previously executed operation. + + Returns an integer value. + """ + return self._warning_count + + @property + def statement(self) -> Optional[str]: + """Returns the latest executed statement. + + When a multiple statement is executed, the value of `statement` + corresponds to the one that caused the current result set, provided + the statement-result mapping was enabled. Otherwise, the value of + `statement` matches the statement just as provided when calling + `execute()` and it does not change as result sets are traversed. + """ + if self._executed is None: + return None + try: + return self._executed.strip().decode("utf-8") + except (AttributeError, UnicodeDecodeError): + return cast(str, self._executed.strip()) + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="warnings")) + def fetchwarnings(self) -> Optional[List[WarningType]]: + """Returns a list of tuples (WarningType) containing warnings generated by + the previously executed operation. + + Examples: + ``` + >>> cnx.get_warnings = True + >>> cursor.execute("SELECT 'a'+1") + >>> cursor.fetchall() + [(1.0,)] + >>> cursor.fetchwarnings() + [(u'Warning', 1292, u"Truncated incorrect DOUBLE value: 'a'")] + ``` + """ + return self._warnings + + def get_attributes(self) -> Optional[List[Tuple[str, BinaryProtocolType]]]: + """Gets a list of query attributes from the connector's side. + + Returns: + List of existing query attributes. + """ + if hasattr(self, "_connection"): + return self._connection.query_attrs + return None + + def add_attribute(self, name: str, value: BinaryProtocolType) -> None: + """Adds a query attribute and its value into the connector's query attributes list. + + Query attributes must be enabled on the server - they are disabled by default. A + warning is logged when setting query attributes for a server connection + that does not support them. + + Args: + name: Key name used to identify the attribute. + value: A value converted to the MySQL Binary Protocol. + + Raises: + ProgrammingError: If the value's conversion fails. + """ + if not isinstance(name, str): + raise ProgrammingError("Parameter `name` must be a string type") + if value is not None and not isinstance(value, MYSQL_PY_TYPES): + raise ProgrammingError( + f"Object {value} cannot be converted to a MySQL type" + ) + if hasattr(self, "_connection"): + self._connection.query_attrs_append((name, value)) + + def remove_attribute(self, name: str) -> BinaryProtocolType: + """Removes a query attribute by name from the connector's query attributes list. + + If no match, `None` is returned, else the corresponding value is returned. + + Args: + name: Key name used to identify the attribute. + + Returns: + value: Attribute's value. + """ + if not isinstance(name, str): + raise ProgrammingError("Parameter `name` must be a string type") + if hasattr(self, "_connection"): + return self._connection.query_attrs_remove(name) + return None + + def clear_attributes(self) -> None: + """Clears the list of query attributes on the connector's side.""" + if hasattr(self, "_connection"): + self._connection.query_attrs_clear() diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/__init__.py b/server/venv/Lib/site-packages/mysql/connector/aio/__init__.py new file mode 100644 index 0000000..2572e6c --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/__init__.py @@ -0,0 +1,40 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""MySQL Connector/Python - MySQL driver written in Python.""" + +from .connection import MySQLConnection, MySQLConnectionAbstract +from .pooling import MySQLConnectionPool, PooledMySQLConnection, connect + +__all__ = [ + "MySQLConnection", + "connect", + "MySQLConnectionAbstract", + "MySQLConnectionPool", + "PooledMySQLConnection", +] diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/_decorating.py b/server/venv/Lib/site-packages/mysql/connector/aio/_decorating.py new file mode 100644 index 0000000..e1c9b7e --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/_decorating.py @@ -0,0 +1,112 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Decorators Hub.""" + +import functools +import warnings + +from typing import TYPE_CHECKING, Any, Callable + +from ..constants import RefreshOption +from ..errors import ReadTimeoutError, WriteTimeoutError + +if TYPE_CHECKING: + from .abstracts import MySQLConnectionAbstract + + +def cmd_refresh_verify_options() -> Callable: + """Decorator verifying which options are relevant and which aren't based on + the server version the client is connecting to.""" + + def decorator(cmd_refresh: Callable) -> Callable: + @functools.wraps(cmd_refresh) + async def wrapper( + cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any + ) -> Callable: + options: int = args[0] + if (options & RefreshOption.GRANT) and cnx.server_version >= ( + 9, + 2, + 0, + ): + warnings.warn( + "As of MySQL Server 9.2.0, refreshing grant tables is not needed " + "if you use statements GRANT, REVOKE, CREATE, DROP, or ALTER. " + "You should expect this option to be unsupported in a future " + "version of MySQL Connector/Python when MySQL Server removes it.", + category=DeprecationWarning, + stacklevel=1, + ) + + return await cmd_refresh(cnx, options, **kwargs) + + return wrapper + + return decorator + + +def deprecated(reason: str) -> Callable: + """Use it to decorate deprecated methods.""" + + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Callable: + warnings.warn( + f"Call to deprecated function {func.__name__}. Reason: {reason}", + category=DeprecationWarning, + stacklevel=2, + ) + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def handle_read_write_timeout() -> Callable: + """ + Decorator to close the current connection if a read or a write timeout + is raised by the method passed via the func parameter. + """ + + def decorator(cnx_method: Callable) -> Callable: + @functools.wraps(cnx_method) + async def handle_cnx_method( + cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any + ) -> Any: + try: + return await cnx_method(cnx, *args, **kwargs) + except Exception as err: + if isinstance(err, (ReadTimeoutError, WriteTimeoutError)): + await cnx.close() + raise err + + return handle_cnx_method + + return decorator diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/abstracts.py b/server/venv/Lib/site-packages/mysql/connector/aio/abstracts.py new file mode 100644 index 0000000..184b3cd --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/abstracts.py @@ -0,0 +1,2703 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="assignment,attr-defined" +# pylint: disable=dangerous-default-value + +"""Module gathering all abstract base classes.""" + +from __future__ import annotations + +from mysql.connector.optionfiles import read_option_files + +__all__ = ["MySQLConnectionAbstract", "MySQLCursorAbstract", "ServerInfo"] + +import asyncio +import os +import re +import weakref + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from inspect import signature +from types import TracebackType +from typing import ( + TYPE_CHECKING, + Any, + AsyncGenerator, + Awaitable, + BinaryIO, + Callable, + ClassVar, + Deque, + Dict, + Generator, + Iterator, + List, + Mapping, + NoReturn, + Optional, + Sequence, + Tuple, + Type, + Union, + cast, +) + +from .._decorating import deprecated +from ..abstracts import ( + DUPLICATED_IN_LIST_ERROR, + KRB_SERVICE_PRINCIPAL_ERROR, + MYSQL_PY_TYPES, + OPENID_TOKEN_FILE_ERROR, + TLS_V1_3_SUPPORTED, + TLS_VER_NO_SUPPORTED, + TLS_VERSION_ERROR, + TLS_VERSION_UNACCEPTABLE_ERROR, +) +from ..constants import ( + CONN_ATTRS_DN, + DEFAULT_CONFIGURATION, + DEPRECATED_METHOD_WARNING, + MYSQL_DEFAULT_CHARSET_ID_57, + MYSQL_DEFAULT_CHARSET_ID_80, + OPENSSL_CS_NAMES, + TLS_CIPHER_SUITES, + TLS_VERSIONS, + ClientFlag, +) +from ..conversion import MySQLConverter, MySQLConverterBase +from ..errors import ( + Error, + InterfaceError, + InternalError, + NotSupportedError, + ProgrammingError, +) +from ..tls_ciphers import UNACCEPTABLE_TLS_CIPHERSUITES, UNACCEPTABLE_TLS_VERSIONS +from ..types import ( + BinaryProtocolType, + DescriptionType, + EofPacketType, + HandShakeType, + MySQLScriptPartition, + OkPacketType, + ParamsSequenceType, + ResultType, + RowType, + StatsPacketType, + StrOrBytes, + WarningType, +) +from ..utils import GenericWrapper, import_object +from .authentication import MySQLAuthenticator +from .charsets import Charset, charsets +from .protocol import MySQLProtocol + +if TYPE_CHECKING: + from .network import MySQLTcpSocket, MySQLUnixSocket + + +IS_POSIX = os.name == "posix" + + +@dataclass +class ServerInfo: + """Stores the server information retrieved on the handshake. + + Also parses and validates the server version, storing it as a tuple. + """ + + protocol: int + version: str + version_tuple: Tuple[int, ...] = field(init=False) + thread_id: int + charset: int + status_flags: int + auth_plugin: str + auth_data: bytes + capabilities: int + query_attrs_is_supported: bool = False + + def __post_init__(self) -> None: + """Parse and validate server version. + + Raises: + InterfaceError: If parsing fails or MySQL version is not supported. + """ + version_re = re.compile(r"^(\d{1,2})\.(\d{1,2})\.(\d{1,3})(.*)") + match = version_re.match(self.version) + if not match: + raise InterfaceError("Failed parsing MySQL version") + + version = tuple(int(v) for v in match.groups()[0:3]) + if version < (4, 1): + raise InterfaceError(f"MySQL Version '{self.version}' is not supported") + self.version_tuple = version + + +class MySQLConnectionAbstract(ABC): + """Defines the MySQL connection interface.""" + + def __init__( + self, + *, + user: Optional[str] = None, + password: str = "", + host: str = "127.0.0.1", + port: int = 3306, + database: Optional[str] = None, + password1: str = "", + password2: str = "", + password3: str = "", + charset: str = "", + collation: str = "", + auth_plugin: Optional[str] = None, + client_flags: Optional[int] = None, + compress: bool = False, + consume_results: bool = False, + autocommit: bool = False, + time_zone: Optional[str] = None, + conn_attrs: Dict[str, str] = {}, + sql_mode: Optional[str] = None, + init_command: Optional[str] = None, + get_warnings: bool = False, + raise_on_warnings: bool = False, + buffered: bool = False, + raw: bool = False, + kerberos_auth_mode: Optional[str] = None, + krb_service_principal: Optional[str] = None, + openid_token_file: Optional[str] = None, + webauthn_callback: Optional[Union[str, Callable[[str], None]]] = None, + allow_local_infile: bool = DEFAULT_CONFIGURATION["allow_local_infile"], + allow_local_infile_in_path: Optional[str] = DEFAULT_CONFIGURATION[ + "allow_local_infile_in_path" + ], + converter_class: Optional[MySQLConverter] = None, + converter_str_fallback: bool = False, + connection_timeout: int = DEFAULT_CONFIGURATION["connect_timeout"], + read_timeout: Optional[int] = DEFAULT_CONFIGURATION["read_timeout"], + write_timeout: Optional[int] = DEFAULT_CONFIGURATION["write_timeout"], + unix_socket: Optional[str] = None, + use_unicode: Optional[bool] = True, + ssl_ca: Optional[str] = None, + ssl_cert: Optional[str] = None, + ssl_key: Optional[str] = None, + ssl_verify_cert: Optional[bool] = False, + ssl_verify_identity: Optional[bool] = False, + ssl_disabled: Optional[bool] = DEFAULT_CONFIGURATION["ssl_disabled"], + tls_versions: Optional[List[str]] = None, + tls_ciphersuites: Optional[List[str]] = None, + loop: Optional[asyncio.AbstractEventLoop] = None, + oci_config_file: Optional[str] = None, + oci_config_profile: Optional[str] = None, + ): + # private (shouldn't be manipulated directly internally) + self.__charset: Optional[Charset] = None + """It shouldn't be manipulated directly, even internally. If you need + to manipulate the charset object, use the property `_charset` (read & write) + instead. Similarly, `_charset` shouldn't be manipulated externally. + """ + + # protected (can be manipulated directly internally) + self._user: str = user + self._password: str = password + self._host: str = host + self._port: int = port + self._database: str = database + self._password1: str = password1 + self._password2: str = password2 + self._password3: str = password3 + self._unix_socket: str = unix_socket + self._connection_timeout: int = connection_timeout + self._read_timeout: Optional[int] = read_timeout + self._write_timeout: Optional[int] = write_timeout + self._connection_attrs: Dict[str, str] = conn_attrs + self._compress: bool = compress + self._consume_results: bool = consume_results + self._autocommit: bool = autocommit + self._time_zone: Optional[str] = time_zone + self._sql_mode: Optional[str] = sql_mode + self._init_command: Optional[str] = init_command + self._protocol: MySQLProtocol = MySQLProtocol() + self._socket: Optional[Union[MySQLTcpSocket, MySQLUnixSocket]] = None + self._charset_name: Optional[str] = charset + """Charset name provided by the user at connection time.""" + self._charset_collation: Optional[str] = collation + """Collation provided by the user at connection time.""" + self._ssl_active: bool = False + self._ssl_disabled: bool = ssl_disabled + self._ssl_ca: Optional[str] = ssl_ca + self._ssl_cert: Optional[str] = ssl_cert + self._ssl_key: Optional[str] = ssl_key + self._ssl_verify_cert: Optional[bool] = ssl_verify_cert + self._ssl_verify_identity: Optional[bool] = ssl_verify_identity + self._tls_versions: Optional[List[str]] = tls_versions + self._tls_ciphersuites: Optional[List[str]] = tls_ciphersuites + self._auth_plugin: Optional[str] = auth_plugin + self._auth_plugin_class: Optional[str] = None + self._pool_config_version: Any = None + self._handshake: Optional[HandShakeType] = None + self._loop: Optional[asyncio.AbstractEventLoop] = ( + loop or asyncio.get_event_loop() + ) + self._client_flags: int = client_flags or ClientFlag.get_default() + self._server_info: Optional[ServerInfo] = None + self._cursors: weakref.WeakSet = weakref.WeakSet() + self._query_attrs: Dict[str, BinaryProtocolType] = {} + self._query_attrs_supported: int = False + self._columns_desc: List[DescriptionType] = [] + self._authenticator: MySQLAuthenticator = MySQLAuthenticator() + self._converter_class: Type[MySQLConverter] = converter_class or MySQLConverter + self._converter_str_fallback: bool = converter_str_fallback + self._kerberos_auth_mode: Optional[str] = kerberos_auth_mode + self._krb_service_principal: Optional[str] = krb_service_principal + self._openid_token_file: Optional[str] = openid_token_file + self._allow_local_infile: bool = allow_local_infile + self._allow_local_infile_in_path: Optional[str] = allow_local_infile_in_path + self._get_warnings: bool = get_warnings + self.raise_on_warnings: bool = raise_on_warnings + self._buffered: bool = buffered + self._raw: bool = raw + self._use_unicode: bool = use_unicode + self._have_next_result: bool = False + self._unread_result: bool = False + self._in_transaction: bool = False + self._oci_config_file: Optional[str] = oci_config_file + """Path to the configuration file.""" + self._oci_config_profile: Optional[str] = oci_config_profile + """Profile name.""" + self._webauthn_callback: Optional[Union[str, Callable[[str], None]]] = ( + webauthn_callback + ) + + self.converter: Optional[MySQLConverter] = None + + self._local_infile_filenames: Optional[Deque[str]] = None + """Stores the filenames from `LOCAL INFILE` requests + found in the executed query.""" + + self._query: Optional[bytes] = None + """The query being processed.""" + + self._validate_connection_options() + + async def __aenter__(self) -> MySQLConnectionAbstract: + if not self.is_socket_connected(): + await self.connect() + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + await self.close() + + def _validate_connection_options(self) -> None: + """Validate connection options.""" + if self._user: + try: + self._user = self._user.strip() + except AttributeError as err: + raise AttributeError("'user' must be a string") from err + + if self._compress: + self.client_flags = [ClientFlag.COMPRESS] + + if self._allow_local_infile_in_path: + infile_in_path = os.path.abspath(self._allow_local_infile_in_path) + if ( + infile_in_path + and os.path.exists(infile_in_path) + and not os.path.isdir(infile_in_path) + or os.path.islink(infile_in_path) + ): + raise AttributeError("allow_local_infile_in_path must be a directory") + if self._allow_local_infile or self._allow_local_infile_in_path: + self.client_flags = [ClientFlag.LOCAL_FILES] + else: + self.client_flags = [-ClientFlag.LOCAL_FILES] + + # Disallow the usage of some default authentication plugins + if self._auth_plugin == "authentication_webauthn_client": + raise InterfaceError( + f"'{self._auth_plugin}' cannot be used as the default authentication " + "plugin" + ) + + # Disable SSL for unix socket connections + if self._unix_socket and os.name == "posix": + self._ssl_disabled = True + + if self._ssl_disabled: + if self._auth_plugin == "mysql_clear_password": + raise InterfaceError( + "Clear password authentication is not supported over insecure " + " channels" + ) + if self._auth_plugin == "authentication_openid_connect_client": + raise InterfaceError( + "OpenID Connect authentication is not supported over insecure channels" + ) + + if not isinstance(self._port, int): + raise InterfaceError("TCP/IP port number should be an integer") + + if any([self._ssl_ca, self._ssl_cert, self._ssl_key]): + # Make sure both ssl_key/ssl_cert are set, or neither (XOR) + if not all([self._ssl_key, self._ssl_cert]): + raise AttributeError( + "ssl_key and ssl_cert need to be both specified, or neither" + ) + + if (self._ssl_key is None) != (self._ssl_cert is None): + raise AttributeError( + "ssl_key and ssl_cert need to be both set, or neither" + ) + if self._tls_versions is not None: + self._validate_tls_versions() + + if self._tls_ciphersuites is not None: + self._validate_tls_ciphersuites() + + if not isinstance(self._connection_attrs, dict): + raise InterfaceError("conn_attrs must be of type dict") + + for attr_name, attr_value in self._connection_attrs.items(): + if attr_name in CONN_ATTRS_DN: + continue + # Validate name type + if not isinstance(attr_name, str): + raise InterfaceError( + "Attribute name should be a string, found: " + f"'{attr_name}' in '{self._connection_attrs}'" + ) + # Validate attribute name limit 32 characters + if len(attr_name) > 32: + raise InterfaceError( + f"Attribute name '{attr_name}' exceeds 32 characters limit size" + ) + # Validate names in connection attributes cannot start with "_" + if attr_name.startswith("_"): + raise InterfaceError( + "Key names in connection attributes cannot start with " + "'_', found: '{attr_name}'" + ) + # Validate value type + if not isinstance(attr_value, str): + raise InterfaceError( + f"Attribute '{attr_name}' value: '{attr_value}' must " + "be a string type" + ) + # Validate attribute value limit 1024 characters + if len(attr_value) > 1024: + raise InterfaceError( + f"Attribute '{attr_name}' value: '{attr_value}' " + "exceeds 1024 characters limit size" + ) + + if self._client_flags & ClientFlag.CONNECT_ARGS: + self._add_default_conn_attrs() + + if self._kerberos_auth_mode: + if not isinstance(self._kerberos_auth_mode, str): + raise InterfaceError("'kerberos_auth_mode' must be of type str") + kerberos_auth_mode = self._kerberos_auth_mode.lower() + if kerberos_auth_mode == "sspi": + if os.name != "nt": + raise InterfaceError( + "'kerberos_auth_mode=SSPI' is only available on Windows" + ) + self._auth_plugin_class = "MySQLSSPIKerberosAuthPlugin" + elif kerberos_auth_mode == "gssapi": + self._auth_plugin_class = "MySQLKerberosAuthPlugin" + else: + raise InterfaceError( + "Invalid 'kerberos_auth_mode' mode. Please use 'SSPI' or 'GSSAPI'" + ) + + if self._krb_service_principal: + if not isinstance(self._krb_service_principal, str): + raise InterfaceError( + KRB_SERVICE_PRINCIPAL_ERROR.format(error="is not a string") + ) + if self._krb_service_principal == "": + raise InterfaceError( + KRB_SERVICE_PRINCIPAL_ERROR.format( + error="can not be an empty string" + ) + ) + if "/" not in self._krb_service_principal: + raise InterfaceError( + KRB_SERVICE_PRINCIPAL_ERROR.format(error="is incorrectly formatted") + ) + + if self._webauthn_callback: + self._validate_callable("webauth_callback", self._webauthn_callback, 1) + + if self._openid_token_file: + if not isinstance(self._openid_token_file, str): + raise InterfaceError( + OPENID_TOKEN_FILE_ERROR.format(error="is not a string") + ) + if self._openid_token_file == "": + raise InterfaceError( + OPENID_TOKEN_FILE_ERROR.format(error="cannot be an empty string") + ) + if not os.path.exists(self._openid_token_file): + raise InterfaceError( + f"The path '{self._openid_token_file}' provided via 'openid_token_file' " + "does not exist" + ) + if self._read_timeout is not None: + if not isinstance(self._read_timeout, int) or self._read_timeout < 0: + raise InterfaceError("Option read_timeout must be a positive integer") + if self._write_timeout is not None: + if not isinstance(self._write_timeout, int) or self._write_timeout < 0: + raise InterfaceError("Option write_timeout must be a positive integer") + + def _set_connection_options(self, **kwargs: Any) -> None: + """Internal utility to set or update the connection options. + + Args: + **kwargs: For a complete list of possible arguments, see [1]. + + Raises: + AttributeError: When provided unsupported connection arguments. + + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html + """ + config = kwargs.copy() + # Compatible configuration with other drivers + compat_map = [ + # (,) + ("db", "database"), + ("username", "user"), + ("passwd", "password"), + ("connect_timeout", "connection_timeout"), + ("read_default_file", "option_files"), + ] + for compat, translate in compat_map: + try: + if translate not in config: + config[translate] = config[compat] + del config[compat] + except KeyError: + pass # Missing compat argument is OK + # Read option files + config = read_option_files(**config) + + # Set the configurations which needs to be passed via their respective + # property setter methods + self.get_warnings = config.pop("get_warnings", False) + self.raise_on_warnings = config.pop("raise_on_warnings", False) + self.client_flags = config.pop("client_flags", ClientFlag.get_default()) + self.converter_class = config.pop("converter_class", MySQLConverter) + + # Other configurations + for key, value in config.items(): + try: + DEFAULT_CONFIGURATION[key] + except KeyError: + raise AttributeError(f"Unsupported argument '{key}'") from None + attribute = "_" + key + try: + setattr(self, attribute, value.strip()) + except AttributeError: + setattr(self, attribute, value) + + # validate the set configurations + self._validate_connection_options() + + def _validate_tls_ciphersuites(self) -> None: + """Validates the tls_ciphersuites option.""" + tls_ciphersuites = [] + tls_cs = self._tls_ciphersuites + + if isinstance(tls_cs, str): + if not (tls_cs.startswith("[") and tls_cs.endswith("]")): + raise AttributeError( + f"tls_ciphersuites must be a list, found: '{tls_cs}'" + ) + tls_css = tls_cs[1:-1].split(",") + if not tls_css: + raise AttributeError( + "No valid cipher suite found in 'tls_ciphersuites' list" + ) + for _tls_cs in tls_css: + _tls_cs = tls_cs.strip().upper() + if _tls_cs: + tls_ciphersuites.append(_tls_cs) + + elif isinstance(tls_cs, (list, set)): + tls_ciphersuites = [tls_cs for tls_cs in tls_cs if tls_cs] + else: + raise AttributeError( + "tls_ciphersuites should be a list with one or more " + f"ciphersuites. Found: '{tls_cs}'" + ) + + tls_versions = ( + TLS_VERSIONS[:] if self._tls_versions is None else self._tls_versions[:] + ) + + # A newer TLS version can use a cipher introduced on + # an older version. + tls_versions.sort(reverse=True) + newer_tls_ver = tls_versions[0] + # translated_names[0] are TLSv1.2 only + # translated_names[1] are TLSv1.3 only + translated_names: List[List[str]] = [[], []] + iani_cipher_suites_names = {} + ossl_cipher_suites_names: List[str] = [] + + # Old ciphers can work with new TLS versions. + # Find all the ciphers introduced on previous TLS versions. + for tls_ver in TLS_VERSIONS[: TLS_VERSIONS.index(newer_tls_ver) + 1]: + iani_cipher_suites_names.update(TLS_CIPHER_SUITES[tls_ver]) + ossl_cipher_suites_names.extend(OPENSSL_CS_NAMES[tls_ver]) + + for name in tls_ciphersuites: + if "-" in name and name in ossl_cipher_suites_names: + if name in OPENSSL_CS_NAMES["TLSv1.3"]: + translated_names[1].append(name) + else: + translated_names[0].append(name) + elif name in iani_cipher_suites_names: + translated_name = iani_cipher_suites_names[name] + if translated_name in translated_names: + raise AttributeError( + DUPLICATED_IN_LIST_ERROR.format( + list="tls_ciphersuites", value=translated_name + ) + ) + if name in TLS_CIPHER_SUITES["TLSv1.3"]: + translated_names[1].append(iani_cipher_suites_names[name]) + else: + translated_names[0].append(iani_cipher_suites_names[name]) + else: + raise AttributeError( + f"The value '{name}' in tls_ciphersuites is not a valid " + "cipher suite" + ) + if not translated_names[0] and not translated_names[1]: + raise AttributeError( + "No valid cipher suite found in the 'tls_ciphersuites' list" + ) + + # raise an error when using an unacceptable cipher + for cipher_as_ossl in translated_names[0]: + if cipher_as_ossl in UNACCEPTABLE_TLS_CIPHERSUITES["TLSv1.2"].values(): + raise NotSupportedError( + f"Cipher {cipher_as_ossl} when used with TLSv1.2 is unacceptable." + ) + for cipher_as_ossl in translated_names[1]: + if cipher_as_ossl in UNACCEPTABLE_TLS_CIPHERSUITES["TLSv1.3"].values(): + raise NotSupportedError( + f"Cipher {cipher_as_ossl} when used with TLSv1.3 is unacceptable." + ) + + self._tls_ciphersuites = [ + ":".join(translated_names[0]), + ":".join(translated_names[1]), + ] + + def _validate_tls_versions(self) -> None: + """Validates the tls_versions option.""" + tls_versions = [] + tls_version = self._tls_versions + + if isinstance(tls_version, str): + if not (tls_version.startswith("[") and tls_version.endswith("]")): + raise AttributeError( + f"tls_versions must be a list, found: '{tls_version}'" + ) + tls_vers = tls_version[1:-1].split(",") + for tls_ver in tls_vers: + tls_version = tls_ver.strip() + if tls_version == "": + continue + if tls_version in tls_versions: + raise AttributeError( + DUPLICATED_IN_LIST_ERROR.format( + list="tls_versions", value=tls_version + ) + ) + tls_versions.append(tls_version) + if tls_vers == ["TLSv1.3"] and not TLS_V1_3_SUPPORTED: + raise AttributeError( + TLS_VER_NO_SUPPORTED.format(tls_version, TLS_VERSIONS) + ) + elif isinstance(tls_version, list): + if not tls_version: + raise AttributeError( + "At least one TLS protocol version must be specified in " + "'tls_versions' list" + ) + for tls_ver in tls_version: + if tls_ver in tls_versions: + raise AttributeError( + DUPLICATED_IN_LIST_ERROR.format( + list="tls_versions", value=tls_ver + ) + ) + tls_versions.append(tls_ver) + elif isinstance(tls_version, set): + for tls_ver in tls_version: + tls_versions.append(tls_ver) + else: + raise AttributeError( + "tls_versions should be a list with one or more of versions " + f"in {', '.join(TLS_VERSIONS)}. found: '{tls_versions}'" + ) + + if not tls_versions: + raise AttributeError( + "At least one TLS protocol version must be specified " + "in 'tls_versions' list when this option is given" + ) + + use_tls_versions = [] + unacceptable_tls_versions = [] + invalid_tls_versions = [] + for tls_ver in tls_versions: + if tls_ver in TLS_VERSIONS: + use_tls_versions.append(tls_ver) + if tls_ver in UNACCEPTABLE_TLS_VERSIONS: + unacceptable_tls_versions.append(tls_ver) + else: + invalid_tls_versions.append(tls_ver) + + if use_tls_versions: + if use_tls_versions == ["TLSv1.3"] and not TLS_V1_3_SUPPORTED: + raise NotSupportedError( + TLS_VER_NO_SUPPORTED.format(tls_version, TLS_VERSIONS) + ) + self._tls_versions = use_tls_versions + elif unacceptable_tls_versions: + raise NotSupportedError( + TLS_VERSION_UNACCEPTABLE_ERROR.format( + unacceptable_tls_versions, TLS_VERSIONS + ) + ) + elif invalid_tls_versions: + raise AttributeError(TLS_VERSION_ERROR.format(tls_ver, TLS_VERSIONS)) + + @staticmethod + def _validate_callable( + option_name: str, callback: Union[str, Callable], num_args: int = 0 + ) -> None: + """Validates if it's a Python callable. + + Args: + option_name (str): Connection option name. + callback (str or callable): The fully qualified path to the callable or + a callable. + num_args (int): Number of positional arguments allowed. + + Raises: + ProgrammingError: If `callback` is not valid or wrong number of positional + arguments. + + .. versionadded:: 8.2.0 + """ + if isinstance(callback, str): + try: + callback = import_object(callback) + except ValueError as err: + raise ProgrammingError(f"{err}") from err + + if not callable(callback): + raise ProgrammingError(f"Expected a callable for '{option_name}'") + + # Check if the callable signature has positional arguments + num_params = len(signature(callback).parameters) + if num_params != num_args: + raise ProgrammingError( + f"'{option_name}' requires {num_args} positional argument, but the " + f"callback provided has {num_params}" + ) + + @property + @abstractmethod + def connection_id(self) -> Optional[int]: + """MySQL connection ID.""" + + @property + def user(self) -> str: + """User used while connecting to MySQL.""" + return self._user + + @property + def server_host(self) -> str: + """MySQL server IP address or name.""" + return self._host + + @property + def server_port(self) -> int: + "MySQL server TCP/IP port." + return self._port + + @property + def unix_socket(self) -> Optional[str]: + "MySQL Unix socket file location." + return self._unix_socket + + @property + def database(self) -> str: + """Get the current database.""" + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await get_database()` to get the database instead" + ) + + @database.setter + def database(self, value: str) -> None: + """Set the current database.""" + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await set_database(name)` to set the database instead" + ) + + @property + def read_timeout(self) -> Optional[int]: + """ + Gets the connection context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + """ + return self._read_timeout + + @read_timeout.setter + def read_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the connection context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set + in seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the read_timeout of the current session to + 5 seconds: + ``` + >>> cnx = await mysql.connector.aio.connect(user='scott') + >>> cnx.read_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option read_timeout must be a positive integer or None" + ) + self._read_timeout = timeout + + @property + def write_timeout(self) -> Optional[int]: + """ + Gets the connection context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + """ + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the connection context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set in + seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the write_timeout of the current + session to 5 seconds: + ``` + >>> cnx = await mysql.connector.connect(user='scott') + >>> cnx.write_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option write_timeout must be a positive integer or None" + ) + self._write_timeout = timeout + + async def get_database(self) -> str: + """Get the current database.""" + result = await self.info_query("SELECT DATABASE()") + return result[0] # type: ignore[return-value] + + async def set_database(self, value: str) -> None: + """Set the current database.""" + await self.cmd_query(f"USE {value}") + + @property + def can_consume_results(self) -> bool: + """Returns whether to consume results""" + return self._consume_results + + @can_consume_results.setter + def can_consume_results(self, value: bool) -> None: + """Set if can consume results.""" + assert isinstance(value, bool) + self._consume_results = value + + @property + def pool_config_version(self) -> Any: + """Returns the pool configuration version.""" + return self._pool_config_version + + @pool_config_version.setter + def pool_config_version(self, value: Any) -> None: + """Sets the pool configuration version""" + self._pool_config_version = value + + @property + def in_transaction(self) -> bool: + """MySQL session has started a transaction.""" + return self._in_transaction + + @property + def loop(self) -> asyncio.AbstractEventLoop: + """Return the event loop.""" + return self._loop + + @property + def is_secure(self) -> bool: + """Return True if is a secure connection.""" + return self._ssl_active or (self._unix_socket is not None and IS_POSIX) + + @property + def query_attrs(self) -> List[Tuple[str, BinaryProtocolType]]: + """Returns query attributes list.""" + return list(self._query_attrs.items()) + + @property + def have_next_result(self) -> bool: + """Return if have next result.""" + return self._have_next_result + + @property + def autocommit(self) -> bool: + """Get whether autocommit is on or off.""" + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await get_autocommit()` to get the autocommit instead" + ) + + @autocommit.setter + def autocommit(self, value: bool) -> None: + """Toggle autocommit.""" + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await set_autocommit(value)` to set the autocommit instead" + ) + + async def get_autocommit(self) -> bool: + """Get whether autocommit is on or off.""" + value = await self.info_query("SELECT @@session.autocommit") + return value[0] == 1 + + async def set_autocommit(self, value: bool) -> None: + """Toggle autocommit.""" + switch = "ON" if value else "OFF" + await self.cmd_query(f"SET @@session.autocommit = {switch}") + self._autocommit = value + + @property + def time_zone(self) -> str: + """Gets the current time zone.""" + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await get_time_zone()` to get the time zone instead" + ) + + @time_zone.setter + def time_zone(self, value: str) -> None: + """Sets the time zone.""" + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await get_autocommit(value)` to get the autocommit instead" + ) + + async def get_time_zone(self) -> str: + """Gets the current time zone.""" + value = await self.info_query("SELECT @@session.time_zone") + return value[0] # type: ignore[return-value] + + async def set_time_zone(self, value: str) -> None: + """Sets the time zone.""" + await self.cmd_query(f"SET @@session.time_zone = '{value}'") + self._time_zone = value + + @property + async def sql_mode(self) -> str: + """Gets the SQL mode.""" + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await get_sql_mode()` to get the SQL mode instead" + ) + + @sql_mode.setter + async def sql_mode(self, value: Union[str, Sequence[int]]) -> None: + """Sets the SQL mode. + + This method sets the SQL Mode for the current connection. The value + argument can be either a string with comma separate mode names, or + a sequence of mode names. + + It is good practice to use the constants class `SQLMode`: + ``` + >>> from mysql.connector.constants import SQLMode + >>> cnx.sql_mode = [SQLMode.NO_ZERO_DATE, SQLMode.REAL_AS_FLOAT] + ``` + """ + raise ProgrammingError( + "The use of async properties are not supported by Python. " + "Use `await set_sql_mode(value)` to set the SQL mode instead" + ) + + async def get_sql_mode(self) -> str: + """Gets the SQL mode.""" + if self._sql_mode is None: + self._sql_mode = (await self.info_query("SELECT @@session.sql_mode"))[0] + return self._sql_mode + + async def set_sql_mode(self, value: Union[str, Sequence[int]]) -> None: + """Sets the SQL mode. + + This method sets the SQL Mode for the current connection. The value + argument can be either a string with comma separate mode names, or + a sequence of mode names. + + It is good practice to use the constants class `SQLMode`: + ``` + >>> from mysql.connector.constants import SQLMode + >>> cnx.sql_mode = [SQLMode.NO_ZERO_DATE, SQLMode.REAL_AS_FLOAT] + ``` + """ + if isinstance(value, (list, tuple)): + value = ",".join(value) + await self.cmd_query(f"SET @@session.sql_mode = '{value}'") + self._sql_mode = value + + @property + def get_warnings(self) -> bool: + """Get whether this connection retrieves warnings automatically. + + This method returns whether this connection retrieves warnings automatically. + """ + return self._get_warnings + + @get_warnings.setter + def get_warnings(self, value: bool) -> None: + """Set whether warnings should be automatically retrieved. + + The toggle-argument must be a boolean. When True, cursors for this connection + will retrieve information about warnings (if any). + + Raises: + ValueError: When the value is not a bool type. + """ + if not isinstance(value, bool): + raise ValueError("Expected a boolean type") + self._get_warnings = value + + @property + def raise_on_warnings(self) -> bool: + """Get whether this connection raises an error on warnings. + + This method returns whether this connection will raise errors when MySQL + reports warnings. + """ + return self._raise_on_warnings + + @raise_on_warnings.setter + def raise_on_warnings(self, value: bool) -> None: + """Set whether warnings raise an error. + + The toggle-argument must be a boolean. When True, cursors for this connection + will raise an error when MySQL reports warnings. + + Raising on warnings implies retrieving warnings automatically. + In other words: warnings will be set to True. If set to False, warnings will + be also set to False. + + Raises: + ValueError: When the value is not a bool type. + """ + if not isinstance(value, bool): + raise ValueError("Expected a boolean type") + self._raise_on_warnings = value + # Don't disable warning retrieval if raising explicitly disabled + if value: + self._get_warnings = value + + @property + def unread_result(self) -> bool: + """Get whether there is an unread result. + + This method is used by cursors to check whether another cursor still needs to + retrieve its result set. + """ + return self._unread_result + + @unread_result.setter + def unread_result(self, value: bool) -> None: + """Set whether there is an unread result. + + This method is used by cursors to let other cursors know there is still a + result set that needs to be retrieved. + + Raises: + ValueError: When the value is not a bool type. + """ + if not isinstance(value, bool): + raise ValueError("Expected a boolean type") + self._unread_result = value + + @property + def collation(self) -> str: + """Returns the collation for current connection. + + This property returns the collation name of the current connection. + The server is queried when the connection is active. If not connected, + the configured collation name is returned. + + Returns a string. + """ + return self._charset.collation + + @property + def charset(self) -> str: + """Return the character set for current connection. + + This property returns the character set name of the current connection. + The server is queried when the connection is active. + If not connected, the configured character set name is returned. + """ + return self._charset.name + + @property + def charset_id(self) -> int: + """The charset ID utilized during the connection phase. + + If the charset ID hasn't been set, the default charset ID is returned. + """ + return self._charset.charset_id + + @property + def _charset(self) -> Charset: + """The charset object encapsulates charset and collation information.""" + if self.__charset is None: + if self._server_info is None: + # We mustn't set `_charset` since we still don't know + # the server version. We temporarily return the default + # charset for undefined scenarios - eventually, the server + # info will be available and `_charset` (data class) will be set. + return charsets.get_by_id(MYSQL_DEFAULT_CHARSET_ID_57) + + self.__charset = charsets.get_by_id( + ( + MYSQL_DEFAULT_CHARSET_ID_57 + if self._server_info.version_tuple < (8, 0) + else MYSQL_DEFAULT_CHARSET_ID_80 + ) + ) + return self.__charset + + @_charset.setter + def _charset(self, value: Charset) -> None: + """The charset object encapsulates charset and collation information.""" + self.__charset = value + + @property + def python_charset(self) -> str: + """Return the Python character set for current connection. + + This property returns the character set name of the current connection. + Note that, unlike property charset, this checks if the previously set + character set is supported by Python and if not, it returns the equivalent + character set that Python supports. + """ + if self._charset is None or self._charset.name in ( + "utf8mb4", + "utf8mb3", + "binary", + ): + return "utf8" + return self._charset.name + + @abstractmethod + def _add_default_conn_attrs(self) -> None: + """Add the default connection attributes.""" + + @abstractmethod + async def _execute_query(self, query: str) -> ResultType: + """Execute a query. + + This method simply calls cmd_query() after checking for unread result. If there + are still unread result, an InterfaceError is raised. Otherwise whatever + cmd_query() returns is returned. + """ + + async def _post_connection(self) -> None: + """Executes commands after connection has been established. + + This method executes commands after the connection has been established. + Some setting like autocommit, character set, and SQL mode are set using this + method. + """ + await self.set_charset_collation(charset=self._charset.charset_id) + await self.set_autocommit(self._autocommit) + if self._time_zone: + await self.set_time_zone(self._time_zone) + if self._sql_mode: + await self.set_sql_mode(self._sql_mode) + if self._init_command: + await self._execute_query(self._init_command) + + async def set_charset_collation( + self, charset: Optional[Union[int, str]] = None, collation: Optional[str] = None + ) -> None: + """Set the character set and collation for the current connection. + + This method sets the character set and collation to be used for the current + connection. The charset argument can be either the name of a character set as + a string, or the numerical equivalent as defined in constants.CharacterSet. + + When the collation is not given, the default will be looked up and used. + + Args: + charset: Can be either the name of a character set, or the numerical + equivalent as defined in `constants.CharacterSet`. + collation: When collation is `None`, the default collation for the + character set is used. + + Examples: + The following will set the collation for the latin1 character set to + `latin1_general_ci`: + ``` + >>> cnx = mysql.connector.connect(user='scott') + >>> cnx.set_charset_collation('latin1', 'latin1_general_ci') + ``` + """ + err_msg = "{} should be either integer, string or None" + if not isinstance(charset, (int, str)) and charset is not None: + raise ValueError(err_msg.format("charset")) + if not isinstance(collation, str) and collation is not None: + raise ValueError("collation should be either string or None") + + if charset and collation: + charset_str: str = ( + charsets.get_by_id(charset).name + if isinstance(charset, int) + else charset + ) + self._charset = charsets.get_by_name_and_collation(charset_str, collation) + elif charset: + if isinstance(charset, int): + self._charset = charsets.get_by_id(charset) + elif isinstance(charset, str): + self._charset = charsets.get_by_name(charset) + else: + raise ValueError(err_msg.format("charset")) + elif collation: + self._charset = charsets.get_by_collation(collation) + else: + charset = DEFAULT_CONFIGURATION["charset"] + self._charset = charsets.get_by_name(charset) # type: ignore[arg-type] + + await self.cmd_query( + f"SET NAMES '{self._charset.name}' COLLATE '{self._charset.collation}'" + ) + + if self.converter: + self.converter.set_charset(self._charset.name) + + def isset_client_flag(self, flag: int) -> bool: + """Checks if a client flag is set. + + Returns: + `True` if the client flag was set, `False` otherwise. + """ + return (self._client_flags & flag) > 0 + + def set_allow_local_infile_in_path(self, path: str) -> None: + """Set the path that user can upload files. + + Args: + path (str): Path that user can upload files. + """ + + self._allow_local_infile_in_path = path + + def get_self(self) -> MySQLConnectionAbstract: + """Return self for weakref.proxy. + + This method is used when the original object is needed when using + weakref.proxy. + """ + return self + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="server_version")) + def get_server_version(self) -> Optional[Tuple[int, ...]]: + """Gets the MySQL version. + + Returns: + The MySQL server version as a tuple. If not previously connected, it will + return `None`. + """ + return self.server_version + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="server_info")) + def get_server_info(self) -> Optional[str]: + """Gets the original MySQL version information. + + Returns: + The original MySQL server as text. If not previously connected, it will + return `None`. + """ + return self.server_info + + @property + def server_version(self) -> Optional[Tuple[int, ...]]: + """Gets the MySQL version. + + Returns: + The MySQL server version as a tuple. If not previously connected, it will + return `None`. + """ + if self._server_info is not None: + return self._server_info.version_tuple + return None + + @property + def server_info(self) -> Optional[str]: + """Gets the original MySQL version information. + + Returns: + The original MySQL server as text. If not previously connected, it will + return `None`. + """ + try: + return self._handshake["server_version_original"] # type: ignore[return-value] + except (TypeError, KeyError): + return None + + @abstractmethod + def is_socket_connected(self) -> bool: + """Reports whether the socket is connected. + + Instead of ping the server like ``is_connected()``, it only checks if the + socket connection flag is set. + """ + + @abstractmethod + async def is_connected(self) -> bool: + """Reports whether the connection to MySQL Server is available. + + This method checks whether the connection to MySQL is available. + It is similar to ``ping()``, but unlike the ``ping()`` method, either `True` + or `False` is returned and no exception is raised. + """ + + @abstractmethod + async def ping( + self, reconnect: bool = False, attempts: int = 1, delay: int = 0 + ) -> bool: + """Check availability of the MySQL server. + + When reconnect is set to `True`, one or more attempts are made to try to + reconnect to the MySQL server using the ``reconnect()`` method. + + ``delay`` is the number of seconds to wait between each retry. + + When the connection is not available, an InterfaceError is raised. Use the + ``is_connected()`` method if you just want to check the connection without + raising an error. + + Raises: + InterfaceError: On errors. + """ + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="client_flags")) + def set_client_flags(self, flags: Union[int, Sequence[int]]) -> int: + """Set the client flags. + + The flags-argument can be either an int or a list (or tuple) of ClientFlag + values. If it is an integer, it will set client_flags to flags as is. + If flags is a list or tuple, each flag will be set or unset when it's negative. + + client_flags = [ClientFlag.FOUND_ROWS,-ClientFlag.LONG_FLAG] + + Raises: + ProgrammingError: When the flags argument is not a set or an integer bigger + than 0. + """ + self.client_flags = flags + return self.client_flags + + @property + def client_flags(self) -> int: + """Gets the client flags of the current session.""" + return self._client_flags + + @client_flags.setter + def client_flags(self, flags: Union[int, Sequence[int]]) -> None: + """Sets the client flags. + + The flags-argument can be either an int or a list (or tuple) of ClientFlag + values. If it is an integer, it will set client_flags to flags as is. + If flags is a list or tuple, each flag will be set or unset when it's negative. + + client_flags = [ClientFlag.FOUND_ROWS,-ClientFlag.LONG_FLAG] + + Raises: + ProgrammingError: When the flags argument is not a set or an integer bigger + than 0. + """ + if isinstance(flags, int) and flags > 0: + self._client_flags = flags + elif isinstance(flags, (tuple, list)): + for flag in flags: + if flag < 0: + self._client_flags &= ~abs(flag) + else: + self._client_flags |= flag + else: + raise ProgrammingError("client_flags setter expect integer (>0) or set") + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="converter_class")) + def set_converter_class(self, convclass: Optional[Type[MySQLConverter]]) -> None: + """Set the converter class to be used. + + This should be a class overloading methods and members of + conversion.MySQLConverter. + + Raises: + TypeError: When the class is not a subclass of `conversion.MySQLConverter`. + """ + self.converter_class = convclass + + @property + def converter_class(self) -> Type[MySQLConverter]: + """Gets the converter class set for the current session.""" + return self._converter_class + + @converter_class.setter + def converter_class(self, convclass: Optional[Type[MySQLConverter]]) -> None: + """Sets the converter class to be used. + + This should be a class overloading methods and members of + conversion.MySQLConverter. + + Raises: + TypeError: When the class is not a subclass of `conversion.MySQLConverter`. + """ + if convclass and issubclass(convclass, MySQLConverterBase): + self._converter_class = convclass + self.converter = convclass(self._charset.name, self.use_unicode) + self.converter.str_fallback = self._converter_str_fallback + else: + raise TypeError( + "Converter class should be a subclass of conversion.MySQLConverter" + ) + + @property + def use_unicode(self) -> bool: + """Gets whether we return string fields as unicode or not.""" + return self._use_unicode + + @use_unicode.setter + def use_unicode(self, value: bool) -> None: + """Sets whether we return string fields as unicode or not. + + Args: + value: A boolean - default is `True`. + """ + self._use_unicode = value + if self.converter: + self.converter.set_unicode(value) + + def query_attrs_append(self, value: Tuple[str, BinaryProtocolType]) -> None: + """Add element to the query attributes list on the connector's side. + + If an element in the query attributes list already matches + the attribute name provided, the new element will NOT be added. + + Args: + value: key-value as a 2-tuple. + """ + attr_name, attr_value = value + if attr_name not in self._query_attrs: + self._query_attrs[attr_name] = attr_value + + def query_attrs_remove(self, name: str) -> BinaryProtocolType: + """Remove element by name from the query attributes list. + + If no match, `None` is returned, else the corresponding value is returned. + + Args: + name: key name. + """ + return self._query_attrs.pop(name, None) + + def query_attrs_clear(self) -> None: + """Clears query attributes list on the connector's side.""" + self._query_attrs = {} + + async def handle_unread_result(self) -> None: + """Handle unread result. + + Consume pending results if is configured for it. + + Raises: + InternalError: When there are pending results and they were not consumed. + """ + if self._consume_results: + await self.consume_results() + elif self.unread_result: + raise InternalError("Unread result found") + + async def consume_results(self) -> None: + """Consume pending results.""" + if self.unread_result: + await self.get_rows() + + async def info_query(self, query: StrOrBytes) -> Optional[RowType]: + """Send a query which only returns 1 row.""" + async with await self.cursor(buffered=True) as cursor: + await cursor.execute(cast(str, query)) + return await cursor.fetchone() + + def add_cursor(self, cursor: MySQLCursorAbstract) -> None: + """Add cursor to the weakref set.""" + self._cursors.add(cursor) + + def remove_cursor(self, cursor: MySQLCursorAbstract) -> None: + """Remove cursor from the weakref set.""" + self._cursors.remove(cursor) + + @abstractmethod + async def connect(self) -> None: + """Connect to the MySQL server.""" + + async def reconnect(self, attempts: int = 1, delay: int = 0) -> None: + """Attempts to reconnect to the MySQL server. + + The argument `attempts` should be the number of times a reconnect is tried. + The `delay` argument is the number of seconds to wait between each retry. + + You may want to set the number of attempts higher and use delay when you expect + the MySQL server to be down for maintenance or when you expect the network to + be temporary unavailable. + + Args: + attempts: Number of attempts to make when reconnecting. + delay: Use it (defined in seconds) if you want to wait between each retry. + + Raises: + InterfaceError: When reconnection fails. + """ + counter = 0 + while counter != attempts: + counter = counter + 1 + try: + await self.disconnect() + await self.connect() + if await self.is_connected(): + break + except (Error, IOError) as err: + if counter == attempts: + msg = ( + f"Can not reconnect to MySQL after {attempts} " + f"attempt(s): {err}" + ) + raise InterfaceError(msg) from err + if delay > 0: + await asyncio.sleep(delay) + + async def shutdown(self) -> NoReturn: + """Shuts down connection to MySQL Server. + + This method closes the socket. It raises no exceptions. + + Unlike `disconnect()`, `shutdown()` closes the client connection without + attempting to send a `QUIT` command to the server first. Thus, it will not + block if the connection is disrupted for some reason such as network failure. + """ + raise NotImplementedError + + @abstractmethod + async def close(self) -> None: + """Close the connection. + + It closes any opened cursor associated to this connection, and closes the + underling socket connection. + + `MySQLConnection.close()` is a synonymous for `MySQLConnection.disconnect()` + method name and more commonly used. + + This method tries to send a `QUIT` command and close the socket. It raises + no exceptions. + """ + + disconnect: ClassVar[Callable[["MySQLConnectionAbstract"], Awaitable[None]]] = close + + @abstractmethod + async def cursor( + self, + buffered: Optional[bool] = None, + raw: Optional[bool] = None, + prepared: Optional[bool] = None, + cursor_class: Optional[Type[MySQLCursorAbstract]] = None, + dictionary: Optional[bool] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> MySQLCursorAbstract: + """Instantiate and return a cursor. + + By default, MySQLCursor is returned. Depending on the options while + connecting, a buffered and/or raw cursor is instantiated instead. + Also depending upon the cursor options, rows can be returned as a dictionary + or a tuple. + + It is possible to also give a custom cursor through the cursor_class + parameter, but it needs to be a subclass of + mysql.connector.aio.abstracts.MySQLCursorAbstract. + + Raises: + ProgrammingError: When cursor_class is not a subclass of + MySQLCursor. + ValueError: When cursor is not available. + """ + + @abstractmethod + async def get_row( + self, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + **kwargs: Any, + ) -> Tuple[Optional[RowType], Optional[EofPacketType]]: + """Get the next rows returned by the MySQL server. + + This method gets one row from the result set after sending, for example, the + query command. The result is a tuple consisting of the row and the EOF packet. + If no row was available in the result set, the row data will be None. + """ + + @abstractmethod + async def get_rows( + self, + count: Optional[int] = None, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Any = None, + **kwargs: Any, + ) -> Tuple[List[RowType], Optional[EofPacketType]]: + """Get all rows returned by the MySQL server. + + This method gets all rows returned by the MySQL server after sending, for + example, the query command. The result is a tuple consisting of a list of rows + and the EOF packet. + """ + + @abstractmethod + async def commit(self) -> None: + """Commit current transaction.""" + + @abstractmethod + async def rollback(self) -> None: + """Rollback current transaction.""" + + async def start_transaction( + self, + consistent_snapshot: bool = False, + isolation_level: Optional[str] = None, + readonly: Optional[bool] = None, + ) -> None: + """Starts a transaction. + This method explicitly starts a transaction sending the + START TRANSACTION statement to the MySQL server. You can optionally + set whether there should be a consistent snapshot, which + isolation level you need or which access mode i.e. READ ONLY or + READ WRITE. + Args: + consistent_snapshot: If `True`, Connector/Python sends WITH CONSISTENT + SNAPSHOT with the statement. MySQL ignores this for + isolation levels for which that option does not apply. + isolation_level: Permitted values are 'READ UNCOMMITTED', 'READ COMMITTED', + 'REPEATABLE READ', and 'SERIALIZABLE'. If the value is + `None`, no isolation level is sent, so the default level + applies. + readonly: Can be `True` to start the transaction in READ ONLY mode or + `False` to start it in READ WRITE mode. If readonly is omitted, + the server's default access mode is used. + Raises: + ProgrammingError: When a transaction is already in progress + and when `ValueError` when `isolation_level` + specifies an Unknown level. + Examples: + For example, to start a transaction with isolation level `SERIALIZABLE`, + you would do the following: + ``` + >>> cnx = mysql.connector.aio.connect(...) + >>> await cnx.start_transaction(isolation_level='SERIALIZABLE') + ``` + """ + if self.in_transaction: + raise ProgrammingError("Transaction already in progress") + + if isolation_level: + level = isolation_level.strip().replace("-", " ").upper() + levels = [ + "READ UNCOMMITTED", + "READ COMMITTED", + "REPEATABLE READ", + "SERIALIZABLE", + ] + + if level not in levels: + raise ValueError(f'Unknown isolation level "{isolation_level}"') + + await self._execute_query(f"SET TRANSACTION ISOLATION LEVEL {level}") + + if readonly is not None: + if self.server_version < (5, 6, 5): + raise ValueError( + f"MySQL server version {self.server_version} does not " + "support this feature" + ) + + if readonly: + access_mode = "READ ONLY" + else: + access_mode = "READ WRITE" + await self._execute_query(f"SET TRANSACTION {access_mode}") + + query = "START TRANSACTION" + if consistent_snapshot: + query += " WITH CONSISTENT SNAPSHOT" + await self.cmd_query(query) + + @abstractmethod + async def reset_session( + self, + user_variables: Optional[Dict[str, Any]] = None, + session_variables: Optional[Dict[str, Any]] = None, + ) -> None: + """Clears the current active session. + + This method resets the session state, if the MySQL server is 5.7.3 + or later active session will be reset without re-authenticating. + For other server versions session will be reset by re-authenticating. + + It is possible to provide a sequence of variables and their values to + be set after clearing the session. This is possible for both user + defined variables and session variables. + + Args: + user_variables: User variables map. + session_variables: System variables map. + + Raises: + OperationalError: If not connected. + InternalError: If there are unread results and InterfaceError on errors. + + Examples: + ``` + >>> user_variables = {'var1': '1', 'var2': '10'} + >>> session_variables = {'wait_timeout': 100000, 'sql_mode': 'TRADITIONAL'} + >>> await cnx.reset_session(user_variables, session_variables) + ``` + """ + + @abstractmethod + async def cmd_reset_connection(self) -> bool: + """Resets the session state without re-authenticating. + + Reset command only works on MySQL server 5.7.3 or later. + The result is True for a successful reset otherwise False. + """ + + @abstractmethod + async def cmd_init_db(self, database: str) -> OkPacketType: + """Change the current database. + + This method changes the current (default) database by sending the INIT_DB + command. The result is a dictionary containing the OK packet infawaitormation. + """ + + @abstractmethod + async def cmd_query( + self, + query: StrOrBytes, + raw: bool = False, + buffered: bool = False, + raw_as_string: bool = False, + **kwargs: Any, + ) -> ResultType: + """Send a query to the MySQL server. + + This method send the query to the MySQL server and returns the result. + + If there was a text result, a tuple will be returned consisting of the number + of columns and a list containing information about these columns. + + When the query doesn't return a text result, the OK or EOF packet information + as dictionary will be returned. In case the result was an error, exception + Error will be raised. + """ + + async def cmd_query_iter( + self, + statements: StrOrBytes, + **kwargs: Any, + ) -> Generator[ResultType, None, None]: + """Send one or more statements to the MySQL server. + + Similar to the cmd_query method, but instead returns a generator + object to iterate through results. It sends the statements to the + MySQL server and through the iterator you can get the results. + + statement = 'SELECT 1; INSERT INTO t1 VALUES (); SELECT 2' + for result in await cnx.cmd_query(statement, iterate=True): + if 'columns' in result: + columns = result['columns'] + rows = await cnx.get_rows() + else: + # do something useful with INSERT result + """ + + @abstractmethod + async def cmd_stmt_fetch( + self, statement_id: int, rows: int = 1, **kwargs: Any + ) -> None: + """Fetch a MySQL statement Result Set. + + This method will send the FETCH command to MySQL together with the given + statement id and the number of rows to fetch. + """ + + @abstractmethod + async def cmd_stmt_prepare( + self, + statement: bytes, + **kwargs: Any, + ) -> Mapping[str, Union[int, List[DescriptionType]]]: + """Prepare a MySQL statement. + + This method will send the PREPARE command to MySQL together with the given + statement. + """ + + @abstractmethod + async def cmd_stmt_execute( + self, + statement_id: Union[int, CMySQLPrepStmt], + data: Sequence[BinaryProtocolType] = (), + parameters: Sequence = (), + flags: int = 0, + **kwargs: Any, + ) -> Optional[Union[Dict[str, Any], Tuple]]: + """Execute a prepared MySQL statement.""" + + @abstractmethod + async def cmd_stmt_reset( + self, + statement_id: int, + **kwargs: Any, + ) -> None: + """Reset data for prepared statement sent as long data. + + The result is a dictionary with OK packet information. + """ + + @abstractmethod + async def cmd_stmt_close(self, statement_id: int, **kwargs: Any) -> None: + """Deallocate a prepared MySQL statement. + + This method deallocates the prepared statement using the statement_id. + Note that the MySQL server does not return anything. + """ + + @abstractmethod + async def cmd_refresh(self, options: int) -> OkPacketType: + """Send the Refresh command to the MySQL server. + + This method sends the Refresh command to the MySQL server. The options + argument should be a bitwise value using constants.RefreshOption. + + Typical usage example: + ``` + RefreshOption = mysql.connector.RefreshOption + refresh = RefreshOption.LOG | RefreshOption.INFO + await cnx.cmd_refresh(refresh) + ``` + + Args: + options: Bitmask value constructed using constants from + the `constants.RefreshOption` class. + + Returns: + A dictionary representing the OK packet got as response when executing + the command. + + Raises: + ValueError: If an invalid command `refresh options` is provided. + DeprecationWarning: If one of the options is deprecated for the server you + are connecting to. + """ + + async def cmd_stmt_send_long_data( + self, + statement_id: int, + param_id: int, + data: BinaryIO, + **kwargs: Any, + ) -> int: + """Send data for a column. + + This methods send data for a column (for example BLOB) for statement identified + by statement_id. The param_id indicate which parameter the data belongs too. + The data argument should be a file-like object. + + Since MySQL does not send anything back, no error is raised. When the MySQL + server is not reachable, an OperationalError is raised. + + cmd_stmt_send_long_data should be called before cmd_stmt_execute. + + The total bytes send is returned. + """ + + @abstractmethod + async def cmd_quit(self) -> bytes: + """Close the current connection with the server. + + Send the QUIT command to the MySQL server, closing the current connection. + """ + + @abstractmethod + async def cmd_shutdown(self, shutdown_type: Optional[int] = None) -> None: + """Shut down the MySQL Server. + + This method sends the SHUTDOWN command to the MySQL server. + The `shutdown_type` is not used, and it's kept for backward compatibility. + """ + + @abstractmethod + async def cmd_statistics(self) -> StatsPacketType: + """Send the statistics command to the MySQL Server. + + This method sends the STATISTICS command to the MySQL server. The result is a + dictionary with various statistical information. + """ + + @abstractmethod + async def cmd_process_kill(self, mysql_pid: int) -> OkPacketType: + """Kill a MySQL process. + + This method send the PROCESS_KILL command to the server along with the + process ID. The result is a dictionary with the OK packet information. + """ + + @abstractmethod + async def cmd_debug(self) -> EofPacketType: + """Send the DEBUG command. + + This method sends the DEBUG command to the MySQL server, which requires the + MySQL user to have SUPER privilege. The output will go to the MySQL server + error log and the result of this method is a dictionary with EOF packet + information. + """ + + @abstractmethod + async def cmd_ping(self) -> OkPacketType: + """Send the PING command. + + This method sends the PING command to the MySQL server. It is used to check + if the the connection is still valid. The result of this method is dictionary + with OK packet information. + """ + + @abstractmethod + async def cmd_change_user( + self, + username: str = "", + password: str = "", + database: str = "", + charset: Optional[int] = None, + password1: str = "", + password2: str = "", + password3: str = "", + oci_config_file: str = "", + oci_config_profile: str = "", + openid_token_file: str = "", + ) -> Optional[OkPacketType]: + """Changes the current logged in user. + + It also causes the specified database to become the default (current) + database. It is also possible to change the character set using the + charset argument. The character set passed during initial connection + is reused if no value of charset is passed via this method. + + Args: + username: New account's username. + password: New account's password. + database: Database to become the default (current) database. + charset: Client charset (see [1]), only the lower 8-bits. + password1: New account's password factor 1 - it's used instead + of `password` if set (higher precedence). + password2: New account's password factor 2. + password3: New account's password factor 3. + oci_config_file: OCI configuration file location (path-like string). + oci_config_profile: OCI configuration profile location (path-like string). + openid_token_file: OpenID Connect token file location (path-like string). + + Returns: + ok_packet: Dictionary containing the OK packet information. + + Examples: + ``` + >>> cnx.cmd_change_user(username='', password='', database='', charset=33) + ``` + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_basic_character_set.html#a_protocol_character_set + """ + + +class MySQLCursorAbstract(ABC): + """Defines the MySQL cursor interface.""" + + def __init__( + self, + connection: MySQLConnectionAbstract, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ): + self._connection: MySQLConnectionAbstract = connection + self._loop: asyncio.AbstractEventLoop = connection.loop + self._description: Optional[List[DescriptionType]] = None + self._last_insert_id: Optional[int] = None + self._warnings: Optional[List[WarningType]] = None + self._warning_count: int = 0 + self._executed: Optional[bytes] = None + self._executed_list: List[bytes] = [] + self._stored_results: List[Any] = [] + self._binary: bool = False + self._raw: bool = False + self._rowcount: int = -1 + self._nextrow: Tuple[Optional[RowType], Optional[EofPacketType]] = ( + None, + None, + ) + self.arraysize: int = 1 + + # multi statement execution + self._stmt_partitions: Optional[Generator[MySQLScriptPartition, None, None]] = ( + None + ) + self._stmt_partition: Optional[MySQLScriptPartition] = None + self._stmt_map_results: bool = False + + self._read_timeout: Optional[int] = read_timeout + self._write_timeout: Optional[int] = write_timeout + self._connection.add_cursor(self) + + async def __aenter__(self) -> MySQLCursorAbstract: + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]] = None, + exc_value: Optional[BaseException] = None, + traceback: Optional[TracebackType] = None, + ) -> None: + await self.close() + + async def __aiter__(self) -> Iterator[RowType]: + """Iterate over result set. + + Iteration over the result set which calls self.fetchone() + and returns the next row. + """ + return self # type: ignore[return-value] + + async def __anext__(self) -> RowType: + """ + Used for iterating over the result set. Calls self.fetchone() + to get the next row. + """ + try: + row = await self.fetchone() + except InterfaceError: + raise StopAsyncIteration from None + if not row: + raise StopAsyncIteration + return row + + @property + def read_timeout(self) -> Optional[int]: + """ + Gets the cursor context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + """ + return self._read_timeout + + @read_timeout.setter + def read_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the cursor context's timeout in seconds for each attempt + to read any data from the server. + + `read_timeout` is number of seconds upto which the connector should wait + for the server to reply back before raising an ReadTimeoutError. We can set + this option to None, which would signal the connector to wait indefinitely + till the read operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set + in seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the read_timeout of the current session's cursor + context to 5 seconds: + ``` + >>> cnx = await mysql.connector.connect(user='scott') + >>> cur = await cnx.cursor() + >>> cur.read_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option read_timeout must be a positive integer or None" + ) + self._read_timeout = timeout + + @property + def write_timeout(self) -> Optional[int]: + """ + Gets the cursor context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + """ + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, timeout: Optional[int]) -> None: + """ + Sets or updates the cursor context's timeout in seconds for each attempt + to send data to the server. + + `write_timeout` is number of seconds upto which the connector should spend to + write to the server before raising an WriteTimeoutError. We can set this option + to None, which would signal the connector to wait indefinitely till the write + operation is completed or stopped abruptly. + + Args: + timeout: Accepts a non-negative integer which is the timeout to be set in + seconds or None. + Raises: + InterfaceError: If a positive integer or None is not passed via the + timeout parameter. + Examples: + The following will set the write_timeout of the current session's cursor + context to 5 seconds: + ``` + >>> cnx = await mysql.connector.connect(user='scott') + >>> cur = await cnx.cursor() + >>> cur.write_timeout = 5 + ``` + """ + if timeout is not None: + if not isinstance(timeout, int) or timeout < 0: + raise InterfaceError( + "Option write_timeout must be a positive integer or None" + ) + self._write_timeout = timeout + + @property + def description(self) -> Optional[List[DescriptionType]]: + """Return description of columns in a result. + + This property returns a list of tuples describing the columns in in a result + set. A tuple is described as follows: + + (column_name, + type, + None, + None, + None, + None, + null_ok, + column_flags) # Addition to PEP-249 specs + + Returns a list of tuples. + """ + return self._description + + @property + def rowcount(self) -> int: + """Return the number of rows produced or affected. + + This property returns the number of rows produced by queries such as a + SELECT, or affected rows when executing DML statements like INSERT or UPDATE. + + Note that for non-buffered cursors it is impossible to know the number of rows + produced before having fetched them all. For those, the number of rows will + be -1 right after execution, and incremented when fetching rows. + """ + return self._rowcount + + @property + def lastrowid(self) -> Optional[int]: + """Gets the value generated for an AUTO_INCREMENT column by the previous + INSERT or UPDATE statement or None when there is no such value available. + """ + return self._last_insert_id + + @property + def warnings(self) -> Optional[List[WarningType]]: + """Gets warnings.""" + return self._warnings + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="warnings")) + def fetchwarnings(self) -> Optional[List[WarningType]]: + """Returns Warnings.""" + return self._warnings + + @property + def warning_count(self) -> int: + """Return the number of warnings. + + This property returns the number of warnings generated by the previously + executed operation. + """ + return self._warning_count + + @property + def column_names(self) -> Tuple[str, ...]: + """Returns column names. + + This property returns the columns names as a tuple. + """ + if not self.description: + return tuple() + return tuple(d[0] for d in self.description) + + @property + def statement(self) -> Optional[str]: + """Returns the latest executed statement. + + When a multiple statement is executed, the value of `statement` + corresponds to the one that caused the current result set, provided + the statement-result mapping was enabled. Otherwise, the value of + `statement` matches the statement just as provided when calling + `execute()` and it does not change as result sets are traversed. + """ + if self._executed is None: + return None + try: + return self._executed.strip().decode("utf-8") + except (AttributeError, UnicodeDecodeError): + return cast(str, self._executed.strip()) + + @property + def with_rows(self) -> bool: + """Returns whether the cursor could have rows returned. + + This property returns True when column descriptions are available and possibly + also rows, which will need to be fetched. + """ + return bool(self._description) + + @abstractmethod + def stored_results(self) -> Iterator[MySQLCursorAbstract]: + """Returns an iterator for stored results. + + This method returns an iterator over results which are stored when callproc() + is called. The iterator will provide MySQLCursorBuffered instances. + """ + + @abstractmethod + async def execute( + self, + operation: str, + params: Union[Sequence[Any], Dict[str, Any]] = (), + map_results: bool = False, + ) -> None: + """Executes the given operation (a MySQL script) substituting any markers + with the given parameters. + + For example, getting all rows where id is 5: + ``` + cursor.execute("SELECT * FROM t1 WHERE id = %s", (5,)) + ``` + + If you want each single statement in the script to be related + to its corresponding result set, you should enable the `map_results` + switch - see workflow example below. + + If the given script uses `DELIMITER` statements (which are not recognized + by MySQL Server), the connector will parse such statements to remove them + from the script and substitute delimiters as needed. This pre-processing + may cause a performance hit when using long scripts. Note that when enabling + `map_results`, the script is expected to use `DELIMITER` statements in order + to split the script into multiple query strings. + + The following characters are currently not supported by the connector in + `DELIMITER` statements: `"`, `'`, #`, `/*` and `*/`. + + If warnings were generated, and `connection.get_warnings` is + `True`, then `self.warnings` will be a list containing these + warnings. + + Args: + operation: Operation to be executed - it can be a single or a + multi statement. + params: The parameters found in the tuple or dictionary params are bound + to the variables in the operation. Specify variables using `%s` or + `%(name)s` parameter style (that is, using format or pyformat style). + map_results: It is `False` by default. If `True`, it allows you to know what + statement caused what result set - see workflow example below. + Only relevant when working with multi statements. + + Returns: + `None`. + + Example (basic usage): + The following example runs many single statements in a + single go and loads the corresponding result sets + sequentially: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + async with await cnx.cursor() as cur: + await cur.execute(sql_operation) + + result_set = await cur.fetchall() + # do something with result set + ... + + while (await cur.nextset()): + result_set = await cur.fetchall() + # do something with result set + ... + ``` + + In case the operation is a single statement, you may skip the + looping section as no more result sets are to be expected. + + Example (statement-result mapping): + The following example runs many single statements in a + single go and loads the corresponding result sets + sequentially. Additionally, each result set gets related + to the statement that caused it: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + async with await cnx.cursor() as cur: + await cur.execute(sql_operation, map_results=True) + + # statement 1 is `SET @a=1, @b='2024-02-01'`, + # result set from statement 1 is `[]` - aka, an empty set. + result_set, statement = await cur.fetchall(), cur.statement + # do something with result set + ... + + # 1st call to `nextset()` will laod the result set from statement 2, + # statement 2 is `SELECT @a, LENGTH('hello'), @b`, + # result set from statement 2 is `[(1, 5, '2024-02-01')]`. + # + # 2nd call to `nextset()` will laod the result set from statement 3, + # statement 3 is `SELECT @@version`, + # result set from statement 3 is `[('9.0.0-labs-mrs-8',)]`. + # + # 3rd call to `nextset()` will return `None` as there are no more sets, + # leading to the end of the consumption process of result sets. + while (await cur.nextset()): + result_set, statement = await cur.fetchall(), cur.statement + # do something with result set + ... + ``` + + In case the mapping is disabled (`map_results=False`), all result + sets get related to the same statement, which is the one provided + when calling `execute()`. In other words, the property `statement` + will not change as result sets are consumed, which contrasts with + the case in which the mapping is enabled. Note that we offer a + new fetch-related API command which can be leveraged as a shortcut + for consuming result sets - it is equivalent to the previous + workflow. + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + async with await cnx.cursor() as cur: + await cur.execute(sql_operation, map_results=True) + async for statement, result_set in cur.fetchsets(): + # do something with result set + ``` + """ + + @abstractmethod + async def executemulti( + self, + operation: str, + params: Union[Sequence[Any], Dict[str, Any]] = (), + map_results: bool = False, + ) -> None: + """Executes the given operation (it can be a multi statement + or a MySQL script) substituting any markers with the given parameters. + + **NOTE: `executemulti()` is deprecated and will be removed in a + future release. Use `execute()` instead.** + + If you want each single statement in the script to be related + to its corresponding result set, you should enable the `map_results` + switch - see workflow example below. This capability reduces performance. + + **Unexpected behavior might happen if your script includes the following + symbols as delimiters `"`, `'`, `#`, `/*` and `*/`. The use of these should + be avoided for now**. + + Refer to the documentation of `execute()` to see the multi statement execution + workflow. + + Args: + operation: Operation to be executed - it can be a single or a + multi statement. + params: The parameters found in the tuple or dictionary params are bound + to the variables in the operation. Specify variables using `%s` or + `%(name)s` parameter style (that is, using format or pyformat style). + map_results: It is `False` by default. If `True`, it allows you to know what + statement caused what result set - see workflow example below. + Only relevant when working with multi statements. + + Returns: + `None`. + """ + + @abstractmethod + async def executemany( + self, + operation: str, + seq_params: Sequence[ParamsSequenceType], + ) -> None: + """Prepare and execute a MySQL Prepared Statement many times. + + This method will prepare the given operation and execute with each tuple found + the list seq_params. + + If the cursor instance already had a prepared statement, it is first closed. + + executemany() simply calls execute(). + """ + + @abstractmethod + async def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Raises: + InterfaceError: If there is no result to fetch. + + Returns: + tuple or None: A row from query result set. + """ + + @abstractmethod + async def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Raises: + InterfaceError: If there is no result to fetch. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + + @abstractmethod + async def fetchmany(self, size: int = 1) -> List[Sequence[Any]]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, which + defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + + async def fetchsets( + self, + ) -> AsyncGenerator[tuple[Optional[str], list[RowType]], None]: + """Generates the result sets stream caused by the last `execute*()`. + + Returns: + A 2-tuple; the first element is the statement that caused the + result set, and the second is the result set itself. + + Example: + Consider the following example where multiple statements are executed in one + go: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + async with await cnx.cursor() as cur: + await cur.execute(sql_operation, map_results=True) + + result_set, statement = await cur.fetchall(), cur.statement + # do something with result set + ... + + while (await cur.nextset()): + result_set, statement = await cur.fetchall(), cur.statement + # do something with result set + ... + ``` + + In this case, as an alternative to loading the result sets with `nextset()` + in combination with a `while` loop, you can use `fetchsets()` which is + equivalent to the previous approach: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + async with await cnx.cursor() as cur: + await cur.execute(sql_operation, map_results=True) + async for statement, result_set in cur.fetchsets(): + # do something with result set + ``` + """ + # Some cursor flavor such as `buffered` raise an exception when they don't have + # result sets to fetch from. + # Some others, such as `dictionary` or `raw`, return an empty result set + # under the same circumstances. + statement_cached = None + if not self._stmt_map_results: + statement_cached = self.statement + + try: + result_set = await self.fetchall() + except InterfaceError: + result_set = [] + yield ( + self.statement if self._stmt_map_results else statement_cached + ), result_set + while await self.nextset(): + try: + result_set = await self.fetchall() + except InterfaceError: + result_set = [] + yield ( + self.statement if self._stmt_map_results else statement_cached + ), result_set + + @abstractmethod + async def nextset(self) -> Optional[bool]: + """Makes the cursor skip to the next available set, discarding + any remaining rows from the current set. + + This method should be used as part of the multi statement + execution workflow - see example below. + + Returns: + It returns `None` if there are no more sets. Otherwise, it returns + `True` and subsequent calls to the `fetch*()` methods will return + rows from the next result set. + + Example: + The following example runs many single statements in a + single go and loads the corresponding result sets + sequentially: + + ``` + sql_operation = ''' + SET @a=1, @b='2024-02-01'; + SELECT @a, LENGTH('hello'), @b; + SELECT @@version; + ''' + async with await cnx.cursor() as cur: + await cur.execute(sql_operation) + + result_set = await cur.fetchall() + # do something with result set + ... + + while (await cur.nextset()): + result_set = await cur.fetchall() + # do something with result set + ... + ``` + + In case the operation is a single statement, you may skip the + looping section as no more result sets are to be expected. + """ + + @abstractmethod + async def close(self) -> bool: + """Close the cursor.""" + + @deprecated(DEPRECATED_METHOD_WARNING.format(property_name="lastrowid")) + def getlastrowid(self) -> Optional[int]: + """Return the value generated for an AUTO_INCREMENT column. + + Returns the value generated for an AUTO_INCREMENT column by the previous + INSERT or UPDATE statement. + """ + return self._last_insert_id + + async def reset(self, free: bool = True) -> Any: + """Reset the cursor to default.""" + + def get_attributes(self) -> Optional[List[Tuple[str, BinaryProtocolType]]]: + """Gets a list of query attributes from the connector's side. + + Returns: + List of existing query attributes. + """ + if hasattr(self, "_connection"): + return self._connection.query_attrs + return None + + def add_attribute(self, name: str, value: BinaryProtocolType) -> None: + """Add a query attribute and its value into the connector's query attributes. + + Query attributes must be enabled on the server - they are disabled by default. A + warning is logged when setting query attributes for a server connection + that does not support them. + + Args: + name: Key name used to identify the attribute. + value: A value converted to the MySQL Binary Protocol. + + Raises: + ProgrammingError: If the value's conversion fails. + """ + if not isinstance(name, str): + raise ProgrammingError("Parameter `name` must be a string type") + if value is not None and not isinstance(value, MYSQL_PY_TYPES): + raise ProgrammingError( + f"Object {value} cannot be converted to a MySQL type" + ) + self._connection.query_attrs_append((name, value)) + + def remove_attribute(self, name: str) -> BinaryProtocolType: + """Removes a query attribute by name from the connector's query attributes. + + If no match, `None` is returned, else the corresponding value is returned. + + Args: + name: Key name used to identify the attribute. + + Returns: + value: Attribute's value. + """ + if not isinstance(name, str): + raise ProgrammingError("Parameter `name` must be a string type") + return self._connection.query_attrs_remove(name) + + def clear_attributes(self) -> None: + """Clears the list of query attributes on the connector's side.""" + self._connection.query_attrs_clear() + + +class CMySQLPrepStmt(GenericWrapper): + """Structure to represent a result from `CMySQLConnection.cmd_stmt_prepare`. + It can be used consistently as a type hint. + + `_mysql_connector.MySQLPrepStmt` isn't available when the C-ext isn't built. + + In this regard, `CmdStmtPrepareResult` acts as a proxy/wrapper entity for a + `_mysql_connector.MySQLPrepStmt` instance. + """ diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/authentication.py b/server/venv/Lib/site-packages/mysql/connector/aio/authentication.py new file mode 100644 index 0000000..321b26d --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/authentication.py @@ -0,0 +1,335 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Implementing support for MySQL Authentication Plugins.""" + +from __future__ import annotations + +__all__ = ["MySQLAuthenticator"] + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from ..errors import InterfaceError, NotSupportedError, get_exception +from ..protocol import ( + AUTH_SWITCH_STATUS, + DEFAULT_CHARSET_ID, + DEFAULT_MAX_ALLOWED_PACKET, + ERR_STATUS, + EXCHANGE_FURTHER_STATUS, + MFA_STATUS, + OK_STATUS, +) +from ..types import HandShakeType +from .logger import logger +from .plugins import MySQLAuthPlugin, get_auth_plugin +from .protocol import MySQLProtocol + +if TYPE_CHECKING: + from .network import MySQLSocket + + +class MySQLAuthenticator: + """Implements the authentication phase.""" + + def __init__(self) -> None: + """Constructor.""" + self._username: str = "" + self._passwords: Dict[int, str] = {} + self._plugin_config: Dict[str, Any] = {} + self._ssl_enabled: bool = False + self._auth_strategy: Optional[MySQLAuthPlugin] = None + self._auth_plugin_class: Optional[str] = None + + @property + def ssl_enabled(self) -> bool: + """Signals whether or not SSL is enabled.""" + return self._ssl_enabled + + @property + def plugin_config(self) -> Dict[str, Any]: + """Custom arguments that are being provided to the authentication plugin. + + The parameters defined here will override the ones defined in the + auth plugin itself. + + The plugin config is a read-only property - the plugin configuration + provided when invoking `authenticate()` is recorded and can be queried + by accessing this property. + + Returns: + dict: The latest plugin configuration provided when invoking + `authenticate()`. + """ + return self._plugin_config + + def update_plugin_config(self, config: Dict[str, Any]) -> None: + """Update the 'plugin_config' instance variable""" + self._plugin_config.update(config) + + def _switch_auth_strategy( + self, + new_strategy_name: str, + strategy_class: Optional[str] = None, + username: Optional[str] = None, + password_factor: int = 1, + ) -> None: + """Switch the authorization plugin. + + Args: + new_strategy_name: New authorization plugin name to switch to. + strategy_class: New authorization plugin class to switch to + (has higher precedence than the authorization plugin name). + username: Username to be used - if not defined, the username + provided when `authentication()` was invoked is used. + password_factor: Up to three levels of authentication (MFA) are allowed, + hence you can choose the password corresponding to the 1st, + 2nd, or 3rd factor - 1st is the default. + """ + if username is None: + username = self._username + + if strategy_class is None: + strategy_class = self._auth_plugin_class + + logger.debug("Switching to strategy %s", new_strategy_name) + self._auth_strategy = get_auth_plugin( + plugin_name=new_strategy_name, auth_plugin_class=strategy_class + )( + username, + self._passwords.get(password_factor, ""), + ssl_enabled=self.ssl_enabled, + ) + + async def _mfa_n_factor( + self, + sock: MySQLSocket, + pkt: bytes, + ) -> Optional[bytes]: + """Handle MFA (Multi-Factor Authentication) response. + + Up to three levels of authentication (MFA) are allowed. + + Args: + sock: Pointer to the socket connection. + pkt: MFA response. + + Returns: + ok_packet: If last server's response is an OK packet. + None: If last server's response isn't an OK packet and no ERROR was raised. + + Raises: + InterfaceError: If got an invalid N factor. + errors.ErrorTypes: If got an ERROR response. + """ + n_factor = 2 + while pkt[4] == MFA_STATUS: + if n_factor not in self._passwords: + raise InterfaceError( + "Failed Multi Factor Authentication (invalid N factor)" + ) + + new_strategy_name, auth_data = MySQLProtocol.parse_auth_next_factor(pkt) + self._switch_auth_strategy(new_strategy_name, password_factor=n_factor) + logger.debug("MFA %i factor %s", n_factor, self._auth_strategy.name) + + pkt = await self._auth_strategy.auth_switch_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == EXCHANGE_FURTHER_STATUS: + auth_data = MySQLProtocol.parse_auth_more_data(pkt) + pkt = await self._auth_strategy.auth_more_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == OK_STATUS: + logger.debug("MFA completed succesfully") + return pkt + + if pkt[4] == ERR_STATUS: + raise get_exception(pkt) + + n_factor += 1 + + logger.warning("MFA terminated with a no ok packet") + return None + + async def _handle_server_response( + self, + sock: MySQLSocket, + pkt: bytes, + ) -> Optional[bytes]: + """Handle server's response. + + Args: + sock: Pointer to the socket connection. + pkt: Server's response after completing the `HandShakeResponse`. + + Returns: + ok_packet: If last server's response is an OK packet. + None: If last server's response isn't an OK packet and no ERROR was raised. + + Raises: + errors.ErrorTypes: If got an ERROR response. + NotSupportedError: If got Authentication with old (insecure) passwords. + """ + if pkt[4] == AUTH_SWITCH_STATUS and len(pkt) == 5: + raise NotSupportedError( + "Authentication with old (insecure) passwords " + "is not supported. For more information, lookup " + "Password Hashing in the latest MySQL manual" + ) + + if pkt[4] == AUTH_SWITCH_STATUS: + logger.debug("Server's response is an auth switch request") + new_strategy_name, auth_data = MySQLProtocol.parse_auth_switch_request(pkt) + self._switch_auth_strategy(new_strategy_name) + pkt = await self._auth_strategy.auth_switch_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == EXCHANGE_FURTHER_STATUS: + logger.debug("Exchanging further packets") + auth_data = MySQLProtocol.parse_auth_more_data(pkt) + pkt = await self._auth_strategy.auth_more_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == OK_STATUS: + logger.debug("%s completed succesfully", self._auth_strategy.name) + return pkt + + if pkt[4] == MFA_STATUS: + logger.debug("Starting multi-factor authentication") + logger.debug("MFA 1 factor %s", self._auth_strategy.name) + return await self._mfa_n_factor(sock, pkt) + + if pkt[4] == ERR_STATUS: + raise get_exception(pkt) + + return None + + async def authenticate( + self, + sock: MySQLSocket, + handshake: HandShakeType, + username: str = "", + password1: str = "", + password2: str = "", + password3: str = "", + database: Optional[str] = None, + charset: int = DEFAULT_CHARSET_ID, + client_flags: int = 0, + ssl_enabled: bool = False, + max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET, + auth_plugin: Optional[str] = None, + auth_plugin_class: Optional[str] = None, + conn_attrs: Optional[Dict[str, str]] = None, + is_change_user_request: bool = False, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> bytes: + """Perform the authentication phase. + + During re-authentication you must set `is_change_user_request` to True. + + Args: + sock: Pointer to the socket connection. + handshake: Initial handshake. + username: Account's username. + password1: Account's password factor 1. + password2: Account's password factor 2. + password3: Account's password factor 3. + database: Initial database name for the connection. + charset: Client charset (see [1]), only the lower 8-bits. + client_flags: Integer representing client capabilities flags. + ssl_enabled: Boolean indicating whether SSL is enabled, + max_allowed_packet: Maximum packet size. + auth_plugin: Authorization plugin name. + auth_plugin_class: Authorization plugin class (has higher precedence + than the authorization plugin name). + conn_attrs: Connection attributes. + is_change_user_request: Whether is a `change user request` operation or not. + read_timeout: Timeout in seconds upto which the connector should wait for + the server to reply back before raising an ReadTimeoutError. + write_timeout: Timeout in seconds upto which the connector should spend to + send data to the server before raising an WriteTimeoutError. + + Returns: + ok_packet: OK packet. + + Raises: + InterfaceError: If OK packet is NULL. + ReadTimeoutError: If the time taken for the server to reply back exceeds + 'read_timeout' (if set). + WriteTimeoutError: If the time taken to send data packets to the server + exceeds 'write_timeout' (if set). + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_basic_character_set.html#a_protocol_character_set + """ + # update credentials, plugin config and plugin class + self._username = username + self._passwords = {1: password1, 2: password2, 3: password3} + self._ssl_enabled = ssl_enabled + self._auth_plugin_class = auth_plugin_class + + # client's handshake response + response_payload, self._auth_strategy = MySQLProtocol.make_auth( + handshake=handshake, + username=username, + password=password1, + database=database, + charset=charset, + client_flags=client_flags, + max_allowed_packet=max_allowed_packet, + auth_plugin=auth_plugin, + auth_plugin_class=auth_plugin_class, + conn_attrs=conn_attrs, + is_change_user_request=is_change_user_request, + ssl_enabled=self.ssl_enabled, + plugin_config=self.plugin_config, + ) + + # client sends transaction response + send_args = ( + (0, 0, write_timeout) + if is_change_user_request + else (None, None, write_timeout) + ) + await sock.write(response_payload, *send_args) + + # server replies back + pkt = bytes(await sock.read(read_timeout)) + + ok_pkt = await self._handle_server_response(sock, pkt) + if ok_pkt is None: + raise InterfaceError("Got a NULL ok_pkt") from None + + return ok_pkt diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/charsets.py b/server/venv/Lib/site-packages/mysql/connector/aio/charsets.py new file mode 100644 index 0000000..12e552c --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/charsets.py @@ -0,0 +1,686 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""This module contains the MySQL Server Character Sets.""" + +__all__ = ["Charset", "charsets"] + +from collections import defaultdict +from dataclasses import dataclass +from typing import DefaultDict, Dict, Optional, Sequence, Tuple + +from ..errors import ProgrammingError + + +@dataclass +class Charset: + """Dataclass representing a character set.""" + + charset_id: int + name: str + collation: str + is_default: bool + + +class Charsets: + """MySQL supported character sets and collations class. + + This class holds the list of character sets with their collations supported by + MySQL, making available methods to get character sets by name, collation, or ID. + It uses a sparse matrix or tree-like representation using a dict in a dict to hold + the character set name and collations combinations. + The list is hardcoded, so we avoid a database query when getting the name of the + used character set or collation. + + The call of ``charsets.set_mysql_major_version()`` should be done before using any + of the retrieval methods. + + Usage: + >>> from mysql.connector.aio.charsets import charsets + >>> charsets.set_mysql_major_version(8) + >>> charsets.get_by_name("utf-8") + Charset(charset_id=255, + name='utf8mb4', + collation='utf8mb4_0900_ai_ci', + is_default=True) + """ + + def __init__(self) -> None: + self._charset_id_store: Dict[int, Charset] = {} + self._collation_store: Dict[str, Charset] = {} + self._name_store: DefaultDict[str, Dict[str, Charset]] = defaultdict(dict) + self._mysql_major_version: Optional[int] = None + + def set_mysql_major_version(self, version: int) -> None: + """Set the MySQL major version. + + Sets what tuple should be used based on the MySQL major version to store the + list of character sets and collations. + + Args: + version: The MySQL major version (i.e. 8 or 5) + """ + self._mysql_major_version = version + self._charset_id_store.clear() + self._collation_store.clear() + self._name_store.clear() + + charsets_tuple: Sequence[Tuple[int, str, str, bool]] = None + if version >= 8: + charsets_tuple = MYSQL_8_CHARSETS + elif version == 5: + charsets_tuple = MYSQL_5_CHARSETS + else: + raise ProgrammingError("Invalid MySQL major version") + + for charset_id, name, collation, is_default in charsets_tuple: + charset = Charset(charset_id, name, collation, is_default) + self._charset_id_store[charset_id] = charset + self._collation_store[collation] = charset + self._name_store[name][collation] = charset + + def get_by_id(self, charset_id: int) -> Charset: + """Get character set by ID. + + Args: + charset_id: The charset ID. + + Returns: + Charset: The Charset dataclass instance. + """ + try: + return self._charset_id_store[charset_id] + except KeyError as err: + raise ProgrammingError(f"Character set ID {charset_id} unknown") from err + + def get_by_collation(self, collation: str) -> Charset: + """Get character set by collation. + + Args: + collation: The collation name. + + Returns: + Charset: The Charset dataclass instance. + """ + try: + return self._collation_store[collation] + except KeyError as err: + raise ProgrammingError(f"Collation {collation} unknown") from err + + def get_by_name(self, name: str) -> Charset: + """Get character set by name. + + Args: + name: The charset name. + + Returns: + Charset: The Charset dataclass instance. + """ + try: + if name in ("utf8", "utf-8") and self._mysql_major_version == 8: + name = "utf8mb4" + for charset in self._name_store[name].values(): + if charset.is_default: + return charset + except KeyError as err: + raise ProgrammingError(f"Character set name {name} unknown") from err + raise ProgrammingError(f"No default was found for character set '{name}'") + + def get_by_name_and_collation(self, name: str, collation: str) -> Charset: + """Get character set by name and collation. + + Args: + name: The charset name. + collation: The collation name. + + Returns: + Charset: The Charset dataclass instance. + """ + try: + return self._name_store[name][collation] + except KeyError as err: + raise ProgrammingError( + f"Character set name '{name}' with collation '{collation}' not found" + ) from err + + +MYSQL_8_CHARSETS = ( + (1, "big5", "big5_chinese_ci", True), + (2, "latin2", "latin2_czech_cs", False), + (3, "dec8", "dec8_swedish_ci", True), + (4, "cp850", "cp850_general_ci", True), + (5, "latin1", "latin1_german1_ci", False), + (6, "hp8", "hp8_english_ci", True), + (7, "koi8r", "koi8r_general_ci", True), + (8, "latin1", "latin1_swedish_ci", True), + (9, "latin2", "latin2_general_ci", True), + (10, "swe7", "swe7_swedish_ci", True), + (11, "ascii", "ascii_general_ci", True), + (12, "ujis", "ujis_japanese_ci", True), + (13, "sjis", "sjis_japanese_ci", True), + (14, "cp1251", "cp1251_bulgarian_ci", False), + (15, "latin1", "latin1_danish_ci", False), + (16, "hebrew", "hebrew_general_ci", True), + (18, "tis620", "tis620_thai_ci", True), + (19, "euckr", "euckr_korean_ci", True), + (20, "latin7", "latin7_estonian_cs", False), + (21, "latin2", "latin2_hungarian_ci", False), + (22, "koi8u", "koi8u_general_ci", True), + (23, "cp1251", "cp1251_ukrainian_ci", False), + (24, "gb2312", "gb2312_chinese_ci", True), + (25, "greek", "greek_general_ci", True), + (26, "cp1250", "cp1250_general_ci", True), + (27, "latin2", "latin2_croatian_ci", False), + (28, "gbk", "gbk_chinese_ci", True), + (29, "cp1257", "cp1257_lithuanian_ci", False), + (30, "latin5", "latin5_turkish_ci", True), + (31, "latin1", "latin1_german2_ci", False), + (32, "armscii8", "armscii8_general_ci", True), + (33, "utf8mb3", "utf8mb3_general_ci", True), + (34, "cp1250", "cp1250_czech_cs", False), + (35, "ucs2", "ucs2_general_ci", True), + (36, "cp866", "cp866_general_ci", True), + (37, "keybcs2", "keybcs2_general_ci", True), + (38, "macce", "macce_general_ci", True), + (39, "macroman", "macroman_general_ci", True), + (40, "cp852", "cp852_general_ci", True), + (41, "latin7", "latin7_general_ci", True), + (42, "latin7", "latin7_general_cs", False), + (43, "macce", "macce_bin", False), + (44, "cp1250", "cp1250_croatian_ci", False), + (45, "utf8mb4", "utf8mb4_general_ci", False), + (46, "utf8mb4", "utf8mb4_bin", False), + (47, "latin1", "latin1_bin", False), + (48, "latin1", "latin1_general_ci", False), + (49, "latin1", "latin1_general_cs", False), + (50, "cp1251", "cp1251_bin", False), + (51, "cp1251", "cp1251_general_ci", True), + (52, "cp1251", "cp1251_general_cs", False), + (53, "macroman", "macroman_bin", False), + (54, "utf16", "utf16_general_ci", True), + (55, "utf16", "utf16_bin", False), + (56, "utf16le", "utf16le_general_ci", True), + (57, "cp1256", "cp1256_general_ci", True), + (58, "cp1257", "cp1257_bin", False), + (59, "cp1257", "cp1257_general_ci", True), + (60, "utf32", "utf32_general_ci", True), + (61, "utf32", "utf32_bin", False), + (62, "utf16le", "utf16le_bin", False), + (63, "binary", "binary", True), + (64, "armscii8", "armscii8_bin", False), + (65, "ascii", "ascii_bin", False), + (66, "cp1250", "cp1250_bin", False), + (67, "cp1256", "cp1256_bin", False), + (68, "cp866", "cp866_bin", False), + (69, "dec8", "dec8_bin", False), + (70, "greek", "greek_bin", False), + (71, "hebrew", "hebrew_bin", False), + (72, "hp8", "hp8_bin", False), + (73, "keybcs2", "keybcs2_bin", False), + (74, "koi8r", "koi8r_bin", False), + (75, "koi8u", "koi8u_bin", False), + (76, "utf8mb3", "utf8mb3_tolower_ci", False), + (77, "latin2", "latin2_bin", False), + (78, "latin5", "latin5_bin", False), + (79, "latin7", "latin7_bin", False), + (80, "cp850", "cp850_bin", False), + (81, "cp852", "cp852_bin", False), + (82, "swe7", "swe7_bin", False), + (83, "utf8mb3", "utf8mb3_bin", False), + (84, "big5", "big5_bin", False), + (85, "euckr", "euckr_bin", False), + (86, "gb2312", "gb2312_bin", False), + (87, "gbk", "gbk_bin", False), + (88, "sjis", "sjis_bin", False), + (89, "tis620", "tis620_bin", False), + (90, "ucs2", "ucs2_bin", False), + (91, "ujis", "ujis_bin", False), + (92, "geostd8", "geostd8_general_ci", True), + (93, "geostd8", "geostd8_bin", False), + (94, "latin1", "latin1_spanish_ci", False), + (95, "cp932", "cp932_japanese_ci", True), + (96, "cp932", "cp932_bin", False), + (97, "eucjpms", "eucjpms_japanese_ci", True), + (98, "eucjpms", "eucjpms_bin", False), + (99, "cp1250", "cp1250_polish_ci", False), + (101, "utf16", "utf16_unicode_ci", False), + (102, "utf16", "utf16_icelandic_ci", False), + (103, "utf16", "utf16_latvian_ci", False), + (104, "utf16", "utf16_romanian_ci", False), + (105, "utf16", "utf16_slovenian_ci", False), + (106, "utf16", "utf16_polish_ci", False), + (107, "utf16", "utf16_estonian_ci", False), + (108, "utf16", "utf16_spanish_ci", False), + (109, "utf16", "utf16_swedish_ci", False), + (110, "utf16", "utf16_turkish_ci", False), + (111, "utf16", "utf16_czech_ci", False), + (112, "utf16", "utf16_danish_ci", False), + (113, "utf16", "utf16_lithuanian_ci", False), + (114, "utf16", "utf16_slovak_ci", False), + (115, "utf16", "utf16_spanish2_ci", False), + (116, "utf16", "utf16_roman_ci", False), + (117, "utf16", "utf16_persian_ci", False), + (118, "utf16", "utf16_esperanto_ci", False), + (119, "utf16", "utf16_hungarian_ci", False), + (120, "utf16", "utf16_sinhala_ci", False), + (121, "utf16", "utf16_german2_ci", False), + (122, "utf16", "utf16_croatian_ci", False), + (123, "utf16", "utf16_unicode_520_ci", False), + (124, "utf16", "utf16_vietnamese_ci", False), + (128, "ucs2", "ucs2_unicode_ci", False), + (129, "ucs2", "ucs2_icelandic_ci", False), + (130, "ucs2", "ucs2_latvian_ci", False), + (131, "ucs2", "ucs2_romanian_ci", False), + (132, "ucs2", "ucs2_slovenian_ci", False), + (133, "ucs2", "ucs2_polish_ci", False), + (134, "ucs2", "ucs2_estonian_ci", False), + (135, "ucs2", "ucs2_spanish_ci", False), + (136, "ucs2", "ucs2_swedish_ci", False), + (137, "ucs2", "ucs2_turkish_ci", False), + (138, "ucs2", "ucs2_czech_ci", False), + (139, "ucs2", "ucs2_danish_ci", False), + (140, "ucs2", "ucs2_lithuanian_ci", False), + (141, "ucs2", "ucs2_slovak_ci", False), + (142, "ucs2", "ucs2_spanish2_ci", False), + (143, "ucs2", "ucs2_roman_ci", False), + (144, "ucs2", "ucs2_persian_ci", False), + (145, "ucs2", "ucs2_esperanto_ci", False), + (146, "ucs2", "ucs2_hungarian_ci", False), + (147, "ucs2", "ucs2_sinhala_ci", False), + (148, "ucs2", "ucs2_german2_ci", False), + (149, "ucs2", "ucs2_croatian_ci", False), + (150, "ucs2", "ucs2_unicode_520_ci", False), + (151, "ucs2", "ucs2_vietnamese_ci", False), + (159, "ucs2", "ucs2_general_mysql500_ci", False), + (160, "utf32", "utf32_unicode_ci", False), + (161, "utf32", "utf32_icelandic_ci", False), + (162, "utf32", "utf32_latvian_ci", False), + (163, "utf32", "utf32_romanian_ci", False), + (164, "utf32", "utf32_slovenian_ci", False), + (165, "utf32", "utf32_polish_ci", False), + (166, "utf32", "utf32_estonian_ci", False), + (167, "utf32", "utf32_spanish_ci", False), + (168, "utf32", "utf32_swedish_ci", False), + (169, "utf32", "utf32_turkish_ci", False), + (170, "utf32", "utf32_czech_ci", False), + (171, "utf32", "utf32_danish_ci", False), + (172, "utf32", "utf32_lithuanian_ci", False), + (173, "utf32", "utf32_slovak_ci", False), + (174, "utf32", "utf32_spanish2_ci", False), + (175, "utf32", "utf32_roman_ci", False), + (176, "utf32", "utf32_persian_ci", False), + (177, "utf32", "utf32_esperanto_ci", False), + (178, "utf32", "utf32_hungarian_ci", False), + (179, "utf32", "utf32_sinhala_ci", False), + (180, "utf32", "utf32_german2_ci", False), + (181, "utf32", "utf32_croatian_ci", False), + (182, "utf32", "utf32_unicode_520_ci", False), + (183, "utf32", "utf32_vietnamese_ci", False), + (192, "utf8mb3", "utf8mb3_unicode_ci", False), + (193, "utf8mb3", "utf8mb3_icelandic_ci", False), + (194, "utf8mb3", "utf8mb3_latvian_ci", False), + (195, "utf8mb3", "utf8mb3_romanian_ci", False), + (196, "utf8mb3", "utf8mb3_slovenian_ci", False), + (197, "utf8mb3", "utf8mb3_polish_ci", False), + (198, "utf8mb3", "utf8mb3_estonian_ci", False), + (199, "utf8mb3", "utf8mb3_spanish_ci", False), + (200, "utf8mb3", "utf8mb3_swedish_ci", False), + (201, "utf8mb3", "utf8mb3_turkish_ci", False), + (202, "utf8mb3", "utf8mb3_czech_ci", False), + (203, "utf8mb3", "utf8mb3_danish_ci", False), + (204, "utf8mb3", "utf8mb3_lithuanian_ci", False), + (205, "utf8mb3", "utf8mb3_slovak_ci", False), + (206, "utf8mb3", "utf8mb3_spanish2_ci", False), + (207, "utf8mb3", "utf8mb3_roman_ci", False), + (208, "utf8mb3", "utf8mb3_persian_ci", False), + (209, "utf8mb3", "utf8mb3_esperanto_ci", False), + (210, "utf8mb3", "utf8mb3_hungarian_ci", False), + (211, "utf8mb3", "utf8mb3_sinhala_ci", False), + (212, "utf8mb3", "utf8mb3_german2_ci", False), + (213, "utf8mb3", "utf8mb3_croatian_ci", False), + (214, "utf8mb3", "utf8mb3_unicode_520_ci", False), + (215, "utf8mb3", "utf8mb3_vietnamese_ci", False), + (223, "utf8mb3", "utf8mb3_general_mysql500_ci", False), + (224, "utf8mb4", "utf8mb4_unicode_ci", False), + (225, "utf8mb4", "utf8mb4_icelandic_ci", False), + (226, "utf8mb4", "utf8mb4_latvian_ci", False), + (227, "utf8mb4", "utf8mb4_romanian_ci", False), + (228, "utf8mb4", "utf8mb4_slovenian_ci", False), + (229, "utf8mb4", "utf8mb4_polish_ci", False), + (230, "utf8mb4", "utf8mb4_estonian_ci", False), + (231, "utf8mb4", "utf8mb4_spanish_ci", False), + (232, "utf8mb4", "utf8mb4_swedish_ci", False), + (233, "utf8mb4", "utf8mb4_turkish_ci", False), + (234, "utf8mb4", "utf8mb4_czech_ci", False), + (235, "utf8mb4", "utf8mb4_danish_ci", False), + (236, "utf8mb4", "utf8mb4_lithuanian_ci", False), + (237, "utf8mb4", "utf8mb4_slovak_ci", False), + (238, "utf8mb4", "utf8mb4_spanish2_ci", False), + (239, "utf8mb4", "utf8mb4_roman_ci", False), + (240, "utf8mb4", "utf8mb4_persian_ci", False), + (241, "utf8mb4", "utf8mb4_esperanto_ci", False), + (242, "utf8mb4", "utf8mb4_hungarian_ci", False), + (243, "utf8mb4", "utf8mb4_sinhala_ci", False), + (244, "utf8mb4", "utf8mb4_german2_ci", False), + (245, "utf8mb4", "utf8mb4_croatian_ci", False), + (246, "utf8mb4", "utf8mb4_unicode_520_ci", False), + (247, "utf8mb4", "utf8mb4_vietnamese_ci", False), + (248, "gb18030", "gb18030_chinese_ci", True), + (249, "gb18030", "gb18030_bin", False), + (250, "gb18030", "gb18030_unicode_520_ci", False), + (255, "utf8mb4", "utf8mb4_0900_ai_ci", True), + (256, "utf8mb4", "utf8mb4_de_pb_0900_ai_ci", False), + (257, "utf8mb4", "utf8mb4_is_0900_ai_ci", False), + (258, "utf8mb4", "utf8mb4_lv_0900_ai_ci", False), + (259, "utf8mb4", "utf8mb4_ro_0900_ai_ci", False), + (260, "utf8mb4", "utf8mb4_sl_0900_ai_ci", False), + (261, "utf8mb4", "utf8mb4_pl_0900_ai_ci", False), + (262, "utf8mb4", "utf8mb4_et_0900_ai_ci", False), + (263, "utf8mb4", "utf8mb4_es_0900_ai_ci", False), + (264, "utf8mb4", "utf8mb4_sv_0900_ai_ci", False), + (265, "utf8mb4", "utf8mb4_tr_0900_ai_ci", False), + (266, "utf8mb4", "utf8mb4_cs_0900_ai_ci", False), + (267, "utf8mb4", "utf8mb4_da_0900_ai_ci", False), + (268, "utf8mb4", "utf8mb4_lt_0900_ai_ci", False), + (269, "utf8mb4", "utf8mb4_sk_0900_ai_ci", False), + (270, "utf8mb4", "utf8mb4_es_trad_0900_ai_ci", False), + (271, "utf8mb4", "utf8mb4_la_0900_ai_ci", False), + (273, "utf8mb4", "utf8mb4_eo_0900_ai_ci", False), + (274, "utf8mb4", "utf8mb4_hu_0900_ai_ci", False), + (275, "utf8mb4", "utf8mb4_hr_0900_ai_ci", False), + (277, "utf8mb4", "utf8mb4_vi_0900_ai_ci", False), + (278, "utf8mb4", "utf8mb4_0900_as_cs", False), + (279, "utf8mb4", "utf8mb4_de_pb_0900_as_cs", False), + (280, "utf8mb4", "utf8mb4_is_0900_as_cs", False), + (281, "utf8mb4", "utf8mb4_lv_0900_as_cs", False), + (282, "utf8mb4", "utf8mb4_ro_0900_as_cs", False), + (283, "utf8mb4", "utf8mb4_sl_0900_as_cs", False), + (284, "utf8mb4", "utf8mb4_pl_0900_as_cs", False), + (285, "utf8mb4", "utf8mb4_et_0900_as_cs", False), + (286, "utf8mb4", "utf8mb4_es_0900_as_cs", False), + (287, "utf8mb4", "utf8mb4_sv_0900_as_cs", False), + (288, "utf8mb4", "utf8mb4_tr_0900_as_cs", False), + (289, "utf8mb4", "utf8mb4_cs_0900_as_cs", False), + (290, "utf8mb4", "utf8mb4_da_0900_as_cs", False), + (291, "utf8mb4", "utf8mb4_lt_0900_as_cs", False), + (292, "utf8mb4", "utf8mb4_sk_0900_as_cs", False), + (293, "utf8mb4", "utf8mb4_es_trad_0900_as_cs", False), + (294, "utf8mb4", "utf8mb4_la_0900_as_cs", False), + (296, "utf8mb4", "utf8mb4_eo_0900_as_cs", False), + (297, "utf8mb4", "utf8mb4_hu_0900_as_cs", False), + (298, "utf8mb4", "utf8mb4_hr_0900_as_cs", False), + (300, "utf8mb4", "utf8mb4_vi_0900_as_cs", False), + (303, "utf8mb4", "utf8mb4_ja_0900_as_cs", False), + (304, "utf8mb4", "utf8mb4_ja_0900_as_cs_ks", False), + (305, "utf8mb4", "utf8mb4_0900_as_ci", False), + (306, "utf8mb4", "utf8mb4_ru_0900_ai_ci", False), + (307, "utf8mb4", "utf8mb4_ru_0900_as_cs", False), + (308, "utf8mb4", "utf8mb4_zh_0900_as_cs", False), + (309, "utf8mb4", "utf8mb4_0900_bin", False), + (310, "utf8mb4", "utf8mb4_nb_0900_ai_ci", False), + (311, "utf8mb4", "utf8mb4_nb_0900_as_cs", False), + (312, "utf8mb4", "utf8mb4_nn_0900_ai_ci", False), + (313, "utf8mb4", "utf8mb4_nn_0900_as_cs", False), + (314, "utf8mb4", "utf8mb4_sr_latn_0900_ai_ci", False), + (315, "utf8mb4", "utf8mb4_sr_latn_0900_as_cs", False), + (316, "utf8mb4", "utf8mb4_bs_0900_ai_ci", False), + (317, "utf8mb4", "utf8mb4_bs_0900_as_cs", False), + (318, "utf8mb4", "utf8mb4_bg_0900_ai_ci", False), + (319, "utf8mb4", "utf8mb4_bg_0900_as_cs", False), + (320, "utf8mb4", "utf8mb4_gl_0900_ai_ci", False), + (321, "utf8mb4", "utf8mb4_gl_0900_as_cs", False), + (322, "utf8mb4", "utf8mb4_mn_cyrl_0900_ai_ci", False), + (323, "utf8mb4", "utf8mb4_mn_cyrl_0900_as_cs", False), +) + +MYSQL_5_CHARSETS = ( + (1, "big5", "big5_chinese_ci", True), + (2, "latin2", "latin2_czech_cs", False), + (3, "dec8", "dec8_swedish_ci", True), + (4, "cp850", "cp850_general_ci", True), + (5, "latin1", "latin1_german1_ci", False), + (6, "hp8", "hp8_english_ci", True), + (7, "koi8r", "koi8r_general_ci", True), + (8, "latin1", "latin1_swedish_ci", True), + (9, "latin2", "latin2_general_ci", True), + (10, "swe7", "swe7_swedish_ci", True), + (11, "ascii", "ascii_general_ci", True), + (12, "ujis", "ujis_japanese_ci", True), + (13, "sjis", "sjis_japanese_ci", True), + (14, "cp1251", "cp1251_bulgarian_ci", False), + (15, "latin1", "latin1_danish_ci", False), + (16, "hebrew", "hebrew_general_ci", True), + (18, "tis620", "tis620_thai_ci", True), + (19, "euckr", "euckr_korean_ci", True), + (20, "latin7", "latin7_estonian_cs", False), + (21, "latin2", "latin2_hungarian_ci", False), + (22, "koi8u", "koi8u_general_ci", True), + (23, "cp1251", "cp1251_ukrainian_ci", False), + (24, "gb2312", "gb2312_chinese_ci", True), + (25, "greek", "greek_general_ci", True), + (26, "cp1250", "cp1250_general_ci", True), + (27, "latin2", "latin2_croatian_ci", False), + (28, "gbk", "gbk_chinese_ci", True), + (29, "cp1257", "cp1257_lithuanian_ci", False), + (30, "latin5", "latin5_turkish_ci", True), + (31, "latin1", "latin1_german2_ci", False), + (32, "armscii8", "armscii8_general_ci", True), + (33, "utf8", "utf8_general_ci", True), + (34, "cp1250", "cp1250_czech_cs", False), + (35, "ucs2", "ucs2_general_ci", True), + (36, "cp866", "cp866_general_ci", True), + (37, "keybcs2", "keybcs2_general_ci", True), + (38, "macce", "macce_general_ci", True), + (39, "macroman", "macroman_general_ci", True), + (40, "cp852", "cp852_general_ci", True), + (41, "latin7", "latin7_general_ci", True), + (42, "latin7", "latin7_general_cs", False), + (43, "macce", "macce_bin", False), + (44, "cp1250", "cp1250_croatian_ci", False), + (45, "utf8mb4", "utf8mb4_general_ci", True), + (46, "utf8mb4", "utf8mb4_bin", False), + (47, "latin1", "latin1_bin", False), + (48, "latin1", "latin1_general_ci", False), + (49, "latin1", "latin1_general_cs", False), + (50, "cp1251", "cp1251_bin", False), + (51, "cp1251", "cp1251_general_ci", True), + (52, "cp1251", "cp1251_general_cs", False), + (53, "macroman", "macroman_bin", False), + (54, "utf16", "utf16_general_ci", True), + (55, "utf16", "utf16_bin", False), + (56, "utf16le", "utf16le_general_ci", True), + (57, "cp1256", "cp1256_general_ci", True), + (58, "cp1257", "cp1257_bin", False), + (59, "cp1257", "cp1257_general_ci", True), + (60, "utf32", "utf32_general_ci", True), + (61, "utf32", "utf32_bin", False), + (62, "utf16le", "utf16le_bin", False), + (63, "binary", "binary", True), + (64, "armscii8", "armscii8_bin", False), + (65, "ascii", "ascii_bin", False), + (66, "cp1250", "cp1250_bin", False), + (67, "cp1256", "cp1256_bin", False), + (68, "cp866", "cp866_bin", False), + (69, "dec8", "dec8_bin", False), + (70, "greek", "greek_bin", False), + (71, "hebrew", "hebrew_bin", False), + (72, "hp8", "hp8_bin", False), + (73, "keybcs2", "keybcs2_bin", False), + (74, "koi8r", "koi8r_bin", False), + (75, "koi8u", "koi8u_bin", False), + (77, "latin2", "latin2_bin", False), + (78, "latin5", "latin5_bin", False), + (79, "latin7", "latin7_bin", False), + (80, "cp850", "cp850_bin", False), + (81, "cp852", "cp852_bin", False), + (82, "swe7", "swe7_bin", False), + (83, "utf8", "utf8_bin", False), + (84, "big5", "big5_bin", False), + (85, "euckr", "euckr_bin", False), + (86, "gb2312", "gb2312_bin", False), + (87, "gbk", "gbk_bin", False), + (88, "sjis", "sjis_bin", False), + (89, "tis620", "tis620_bin", False), + (90, "ucs2", "ucs2_bin", False), + (91, "ujis", "ujis_bin", False), + (92, "geostd8", "geostd8_general_ci", True), + (93, "geostd8", "geostd8_bin", False), + (94, "latin1", "latin1_spanish_ci", False), + (95, "cp932", "cp932_japanese_ci", True), + (96, "cp932", "cp932_bin", False), + (97, "eucjpms", "eucjpms_japanese_ci", True), + (98, "eucjpms", "eucjpms_bin", False), + (99, "cp1250", "cp1250_polish_ci", False), + (101, "utf16", "utf16_unicode_ci", False), + (102, "utf16", "utf16_icelandic_ci", False), + (103, "utf16", "utf16_latvian_ci", False), + (104, "utf16", "utf16_romanian_ci", False), + (105, "utf16", "utf16_slovenian_ci", False), + (106, "utf16", "utf16_polish_ci", False), + (107, "utf16", "utf16_estonian_ci", False), + (108, "utf16", "utf16_spanish_ci", False), + (109, "utf16", "utf16_swedish_ci", False), + (110, "utf16", "utf16_turkish_ci", False), + (111, "utf16", "utf16_czech_ci", False), + (112, "utf16", "utf16_danish_ci", False), + (113, "utf16", "utf16_lithuanian_ci", False), + (114, "utf16", "utf16_slovak_ci", False), + (115, "utf16", "utf16_spanish2_ci", False), + (116, "utf16", "utf16_roman_ci", False), + (117, "utf16", "utf16_persian_ci", False), + (118, "utf16", "utf16_esperanto_ci", False), + (119, "utf16", "utf16_hungarian_ci", False), + (120, "utf16", "utf16_sinhala_ci", False), + (121, "utf16", "utf16_german2_ci", False), + (122, "utf16", "utf16_croatian_ci", False), + (123, "utf16", "utf16_unicode_520_ci", False), + (124, "utf16", "utf16_vietnamese_ci", False), + (128, "ucs2", "ucs2_unicode_ci", False), + (129, "ucs2", "ucs2_icelandic_ci", False), + (130, "ucs2", "ucs2_latvian_ci", False), + (131, "ucs2", "ucs2_romanian_ci", False), + (132, "ucs2", "ucs2_slovenian_ci", False), + (133, "ucs2", "ucs2_polish_ci", False), + (134, "ucs2", "ucs2_estonian_ci", False), + (135, "ucs2", "ucs2_spanish_ci", False), + (136, "ucs2", "ucs2_swedish_ci", False), + (137, "ucs2", "ucs2_turkish_ci", False), + (138, "ucs2", "ucs2_czech_ci", False), + (139, "ucs2", "ucs2_danish_ci", False), + (140, "ucs2", "ucs2_lithuanian_ci", False), + (141, "ucs2", "ucs2_slovak_ci", False), + (142, "ucs2", "ucs2_spanish2_ci", False), + (143, "ucs2", "ucs2_roman_ci", False), + (144, "ucs2", "ucs2_persian_ci", False), + (145, "ucs2", "ucs2_esperanto_ci", False), + (146, "ucs2", "ucs2_hungarian_ci", False), + (147, "ucs2", "ucs2_sinhala_ci", False), + (148, "ucs2", "ucs2_german2_ci", False), + (149, "ucs2", "ucs2_croatian_ci", False), + (150, "ucs2", "ucs2_unicode_520_ci", False), + (151, "ucs2", "ucs2_vietnamese_ci", False), + (159, "ucs2", "ucs2_general_mysql500_ci", False), + (160, "utf32", "utf32_unicode_ci", False), + (161, "utf32", "utf32_icelandic_ci", False), + (162, "utf32", "utf32_latvian_ci", False), + (163, "utf32", "utf32_romanian_ci", False), + (164, "utf32", "utf32_slovenian_ci", False), + (165, "utf32", "utf32_polish_ci", False), + (166, "utf32", "utf32_estonian_ci", False), + (167, "utf32", "utf32_spanish_ci", False), + (168, "utf32", "utf32_swedish_ci", False), + (169, "utf32", "utf32_turkish_ci", False), + (170, "utf32", "utf32_czech_ci", False), + (171, "utf32", "utf32_danish_ci", False), + (172, "utf32", "utf32_lithuanian_ci", False), + (173, "utf32", "utf32_slovak_ci", False), + (174, "utf32", "utf32_spanish2_ci", False), + (175, "utf32", "utf32_roman_ci", False), + (176, "utf32", "utf32_persian_ci", False), + (177, "utf32", "utf32_esperanto_ci", False), + (178, "utf32", "utf32_hungarian_ci", False), + (179, "utf32", "utf32_sinhala_ci", False), + (180, "utf32", "utf32_german2_ci", False), + (181, "utf32", "utf32_croatian_ci", False), + (182, "utf32", "utf32_unicode_520_ci", False), + (183, "utf32", "utf32_vietnamese_ci", False), + (192, "utf8", "utf8_unicode_ci", False), + (193, "utf8", "utf8_icelandic_ci", False), + (194, "utf8", "utf8_latvian_ci", False), + (195, "utf8", "utf8_romanian_ci", False), + (196, "utf8", "utf8_slovenian_ci", False), + (197, "utf8", "utf8_polish_ci", False), + (198, "utf8", "utf8_estonian_ci", False), + (199, "utf8", "utf8_spanish_ci", False), + (200, "utf8", "utf8_swedish_ci", False), + (201, "utf8", "utf8_turkish_ci", False), + (202, "utf8", "utf8_czech_ci", False), + (203, "utf8", "utf8_danish_ci", False), + (204, "utf8", "utf8_lithuanian_ci", False), + (205, "utf8", "utf8_slovak_ci", False), + (206, "utf8", "utf8_spanish2_ci", False), + (207, "utf8", "utf8_roman_ci", False), + (208, "utf8", "utf8_persian_ci", False), + (209, "utf8", "utf8_esperanto_ci", False), + (210, "utf8", "utf8_hungarian_ci", False), + (211, "utf8", "utf8_sinhala_ci", False), + (212, "utf8", "utf8_german2_ci", False), + (213, "utf8", "utf8_croatian_ci", False), + (214, "utf8", "utf8_unicode_520_ci", False), + (215, "utf8", "utf8_vietnamese_ci", False), + (223, "utf8", "utf8_general_mysql500_ci", False), + (224, "utf8mb4", "utf8mb4_unicode_ci", False), + (225, "utf8mb4", "utf8mb4_icelandic_ci", False), + (226, "utf8mb4", "utf8mb4_latvian_ci", False), + (227, "utf8mb4", "utf8mb4_romanian_ci", False), + (228, "utf8mb4", "utf8mb4_slovenian_ci", False), + (229, "utf8mb4", "utf8mb4_polish_ci", False), + (230, "utf8mb4", "utf8mb4_estonian_ci", False), + (231, "utf8mb4", "utf8mb4_spanish_ci", False), + (232, "utf8mb4", "utf8mb4_swedish_ci", False), + (233, "utf8mb4", "utf8mb4_turkish_ci", False), + (234, "utf8mb4", "utf8mb4_czech_ci", False), + (235, "utf8mb4", "utf8mb4_danish_ci", False), + (236, "utf8mb4", "utf8mb4_lithuanian_ci", False), + (237, "utf8mb4", "utf8mb4_slovak_ci", False), + (238, "utf8mb4", "utf8mb4_spanish2_ci", False), + (239, "utf8mb4", "utf8mb4_roman_ci", False), + (240, "utf8mb4", "utf8mb4_persian_ci", False), + (241, "utf8mb4", "utf8mb4_esperanto_ci", False), + (242, "utf8mb4", "utf8mb4_hungarian_ci", False), + (243, "utf8mb4", "utf8mb4_sinhala_ci", False), + (244, "utf8mb4", "utf8mb4_german2_ci", False), + (245, "utf8mb4", "utf8mb4_croatian_ci", False), + (246, "utf8mb4", "utf8mb4_unicode_520_ci", False), + (247, "utf8mb4", "utf8mb4_vietnamese_ci", False), + (248, "gb18030", "gb18030_chinese_ci", True), + (249, "gb18030", "gb18030_bin", False), + (250, "gb18030", "gb18030_unicode_520_ci", False), +) + +charsets = Charsets() diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/connection.py b/server/venv/Lib/site-packages/mysql/connector/aio/connection.py new file mode 100644 index 0000000..eaac247 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/connection.py @@ -0,0 +1,1580 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="arg-type,operator,attr-defined,assignment" + +"""Implemention of the communication with MySQL servers in pure Python.""" + +__all__ = ["MySQLConnection"] + +import asyncio +import contextlib +import datetime +import getpass +import os +import socket +import struct +import warnings + +from decimal import Decimal +from io import IOBase +from typing import ( + Any, + AsyncGenerator, + BinaryIO, + Dict, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + Union, +) + +from .. import version +from .._scripting import get_local_infile_filenames +from ..constants import ( + ClientFlag, + FieldType, + RefreshOption, + ServerCmd, + ServerFlag, + flag_is_set, +) +from ..errors import ( + ConnectionTimeoutError, + DatabaseError, + Error, + InterfaceError, + InternalError, + NotSupportedError, + OperationalError, + ProgrammingError, + ReadTimeoutError, + WriteTimeoutError, + get_exception, +) +from ..protocol import EOF_STATUS, ERR_STATUS, LOCAL_INFILE_STATUS, OK_STATUS +from ..types import ( + BinaryProtocolType, + DescriptionType, + EofPacketType, + OkPacketType, + ResultType, + RowType, + StatsPacketType, + StrOrBytes, +) +from ..utils import ( + get_platform, + int1store, + int4store, + lc_int, + warn_ciphersuites_deprecated, + warn_tls_version_deprecated, +) +from ._decorating import cmd_refresh_verify_options, handle_read_write_timeout +from .abstracts import MySQLConnectionAbstract, MySQLCursorAbstract, ServerInfo +from .charsets import charsets +from .cursor import ( + MySQLCursor, + MySQLCursorBuffered, + MySQLCursorBufferedDict, + MySQLCursorBufferedRaw, + MySQLCursorDict, + MySQLCursorPrepared, + MySQLCursorPreparedDict, + MySQLCursorRaw, +) +from .logger import logger +from .network import MySQLTcpSocket, MySQLUnixSocket + + +class MySQLConnection(MySQLConnectionAbstract): + """Implementation of the pure Python MySQL connection.""" + + _mfa_nfactor: int = 1 + + @property + def connection_id(self) -> Optional[int]: + """MySQL connection ID.""" + if self._handshake: + return self._handshake.get("server_threadid") # type: ignore[return-value] + return None + + async def connect(self) -> None: + try: + if self._unix_socket and os.name == "posix": + self._socket = MySQLUnixSocket(unix_socket=self._unix_socket) + else: + self._socket = MySQLTcpSocket(host=self._host, port=self._port) + await asyncio.wait_for( + self._socket.open_connection(), self._connection_timeout + ) + await self._do_handshake() + await self._do_auth() + except Exception as err: + await self._socket.close_connection() + if isinstance(err, (asyncio.CancelledError, asyncio.TimeoutError)): + raise ConnectionTimeoutError( + errno=2003, + msg=f"Can't connect to MySQL server on {self._host}:{self._port} (timed out)", + ) from err + raise + + if self._client_flags & ClientFlag.COMPRESS: + # Update the network layer accordingly + self._socket.switch_to_compressed_mode() + + # Set converter class + try: + self.converter_class = self._converter_class + except TypeError as err: + raise AttributeError( + "Converter classA should be a subclass of " + "conversion.MySQLConverterBase" + ) from err + + # Post connection settings + await self._post_connection() + + # pylint: disable=protected-access + if ( + not self._ssl_disabled + and hasattr(self._socket._writer, "get_extra_info") + and callable(self._socket._writer.get_extra_info) + ): + # Raise a deprecation warning if deprecated TLS version + # or cipher is being used. + + # `get_extra_info("cipher")` returns a three-value tuple containing the name + # of the cipher being used, the version of the SSL protocol + # that defines its use, and the number of secret bits being used. + cipher, tls_version, _ = self._socket._writer.get_extra_info("cipher") + warn_tls_version_deprecated(tls_version) + warn_ciphersuites_deprecated(cipher, tls_version) + + def _add_default_conn_attrs(self) -> None: + """Add the default connection attributes.""" + platform = get_platform() + license_chunks = version.LICENSE.split(" ") + if license_chunks[0] == "GPLv2": + client_license = "GPL-2.0" + else: + client_license = "Commercial" + default_conn_attrs = { + "_pid": str(os.getpid()), + "_platform": platform["arch"], + "_source_host": socket.gethostname(), + "_client_name": "mysql-connector-python", + "_client_license": client_license, + "_client_version": ".".join([str(x) for x in version.VERSION[0:3]]), + "_os": platform["version"], + } + + self._connection_attrs.update(default_conn_attrs) + + async def _execute_query(self, query: str) -> ResultType: + """Execute a query. + + This method simply calls cmd_query() after checking for unread result. If there + are still unread result, an InterfaceError is raised. Otherwise whatever + cmd_query() returns is returned. + """ + await self.handle_unread_result() + return await self.cmd_query(query) + + async def _do_handshake(self) -> None: + """Get the handshake from the MySQL server.""" + packet = await self._socket.read() + logger.debug("Protocol::Handshake packet: %s", packet) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + + self._handshake = self._protocol.parse_handshake(packet) + + self._server_info = ServerInfo( + protocol=self._handshake["protocol"], + version=self._handshake["server_version_original"], + thread_id=self._handshake["server_threadid"], + charset=self._handshake["charset"], + status_flags=self._handshake["server_status"], + auth_plugin=self._handshake["auth_plugin"], + auth_data=self._handshake["auth_data"], + capabilities=self._handshake["capabilities"], + ) + + # Set the charsets for the correspondent server major version + charsets.set_mysql_major_version(self._server_info.version_tuple[0]) + logger.debug("Protocol::Handshake charset: %s", self._server_info.charset) + + # Set charset if provided else use the server default + if self._charset_name and self._charset_collation: + self._charset = charsets.get_by_name_and_collation( + self._charset_name, self._charset_collation + ) + elif self._charset_name: + self._charset = charsets.get_by_name(self._charset_name) + elif self._charset_collation: + self._charset = charsets.get_by_collation(self._charset_collation) + + if not self._handshake["capabilities"] & ClientFlag.SSL: + if not self.is_secure: + if self._auth_plugin == "mysql_clear_password": + raise InterfaceError( + "Clear password authentication is not supported over " + "insecure channels" + ) + if self._auth_plugin == "authentication_openid_connect_client": + raise InterfaceError( + "OpenID Connect authentication is not supported over " + "insecure channels" + ) + if self._ssl_verify_cert: + raise InterfaceError( + "SSL is required but the server doesn't support it", + errno=2026, + ) + self._client_flags &= ~ClientFlag.SSL + elif not self._ssl_disabled: + self._client_flags |= ClientFlag.SSL + + if self._handshake["capabilities"] & ClientFlag.PLUGIN_AUTH: + self.client_flags = [ClientFlag.PLUGIN_AUTH] + + if self._handshake["capabilities"] & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + self._server_info.query_attrs_is_supported = True + self.client_flags = [ClientFlag.CLIENT_QUERY_ATTRIBUTES] + + if self._handshake["capabilities"] & ClientFlag.MULTI_FACTOR_AUTHENTICATION: + self.client_flags = [ClientFlag.MULTI_FACTOR_AUTHENTICATION] + + async def _do_auth(self) -> None: + """Authenticate with the MySQL server. + + Authentication happens in two parts. We first send a response to the + handshake. The MySQL server will then send either an AuthSwitchRequest + or an error packet. + + Raises NotSupportedError when we get the old, insecure password + reply back. Raises any error coming from MySQL. + """ + if ( # pylint: disable=too-many-boolean-expressions + self._auth_plugin + and self._auth_plugin.startswith("authentication_oci") + or ( + self._auth_plugin + and self._auth_plugin.startswith("authentication_kerberos") + and os.name == "nt" + ) + ) and not self._user: + self._user = getpass.getuser() + logger.debug( + "MySQL user is empty, OS user: %s will be used for %s", + self._user, + self._auth_plugin, + ) + + password = ( + self._password1 + if self._password1 and self._password != self._password1 + else self._password + ) + + self._ssl_active = False + if not self._ssl_disabled and (self._client_flags & ClientFlag.SSL): + ssl_context = self._socket.build_ssl_context( + ssl_ca=self._ssl_ca, + ssl_cert=self._ssl_cert, + ssl_key=self._ssl_key, + ssl_verify_cert=self._ssl_verify_cert, + ssl_verify_identity=self._ssl_verify_identity, + tls_versions=self._tls_versions, + tls_cipher_suites=self._tls_ciphersuites, + ) + packet: bytes = self._protocol.make_auth_ssl( + charset=self._charset.charset_id, client_flags=self._client_flags + ) + await self._socket.write(packet) + await self._socket.switch_to_ssl(ssl_context) + self._ssl_active = True + + # Add the custom configurations required by specific auth plugins + self._authenticator.update_plugin_config( + config={ + "krb_service_principal": self._krb_service_principal, + "oci_config_file": self._oci_config_file, + "oci_config_profile": self._oci_config_profile, + "webauthn_callback": self._webauthn_callback, + "openid_token_file": self._openid_token_file, + } + ) + + ok_pkt = await self._authenticator.authenticate( + sock=self._socket, + handshake=self._handshake, + username=self._user, + password1=password, + password2=self._password2, + password3=self._password3, + database=self._database, + charset=self._charset.charset_id, + client_flags=self._client_flags, + ssl_enabled=self._ssl_active, + auth_plugin=self._auth_plugin, + auth_plugin_class=self._auth_plugin_class, + conn_attrs=self._connection_attrs, + ) + self._handle_ok(ok_pkt) + + if not (self._client_flags & ClientFlag.CONNECT_WITH_DB) and self._database: + await self.cmd_init_db(self._database) + + def _handle_ok(self, packet: bytes) -> OkPacketType: + """Handle a MySQL OK packet. + + This method handles a MySQL OK packet. When the packet is found to be an Error + packet, an error will be raised. If the packet is neither an OK or an Error + packet, InterfaceError will be raised. + """ + if packet[4] == OK_STATUS: + ok_pkt = self._protocol.parse_ok(packet) + self._handle_server_status(ok_pkt["status_flag"]) + return ok_pkt + if packet[4] == ERR_STATUS: + raise get_exception(packet) + raise InterfaceError("Expected OK packet") + + def _handle_server_status(self, flags: int) -> None: + """Handle the server flags found in MySQL packets. + + This method handles the server flags send by MySQL OK and EOF packets. + It, for example, checks whether there exists more result sets or whether there + is an ongoing transaction. + """ + self._have_next_result = flag_is_set(ServerFlag.MORE_RESULTS_EXISTS, flags) + self._in_transaction = flag_is_set(ServerFlag.STATUS_IN_TRANS, flags) + + def _handle_eof(self, packet: bytes) -> EofPacketType: + """Handle a MySQL EOF packet. + + This method handles a MySQL EOF packet. When the packet is found to be an Error + packet, an error will be raised. If the packet is neither and OK or an Error + packet, InterfaceError will be raised. + """ + if packet[4] == EOF_STATUS: + eof = self._protocol.parse_eof(packet) + self._handle_server_status(eof["status_flag"]) + return eof + if packet[4] == ERR_STATUS: + raise get_exception(packet) + raise InterfaceError("Expected EOF packet") + + @handle_read_write_timeout() + async def _handle_load_data_infile( + self, + filename: str, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> OkPacketType: + """Handle a LOAD DATA INFILE LOCAL request.""" + if self._local_infile_filenames is None: + self._local_infile_filenames = get_local_infile_filenames(self._query) + if not self._local_infile_filenames: + raise InterfaceError( + "No `LOCAL INFILE` statements found in the client's request. " + "Check your request includes valid `LOCAL INFILE` statements." + ) + elif not self._local_infile_filenames: + raise InterfaceError( + "Got more `LOCAL INFILE` responses than number of `LOCAL INFILE` " + "statements specified in the client's request. Please, report this " + "issue to the development team." + ) + + file_name = os.path.abspath(filename) + file_name_from_request = os.path.abspath(self._local_infile_filenames.popleft()) + + # Verify the file location specified by `filename` from client's request exists + if not os.path.exists(file_name_from_request): + raise InterfaceError( + f"Location specified by filename {file_name_from_request} " + "from client's request does not exist." + ) + + # Verify the file location specified by `filename` from server's response exists + if not os.path.exists(file_name): + raise InterfaceError( + f"Location specified by filename {file_name} from server's " + "response does not exist." + ) + + # Verify the `filename` specified by server's response matches the one from + # the client's request. + try: + if not os.path.samefile(file_name, file_name_from_request): + raise InterfaceError( + f"Filename {file_name} from the server's response is not the same " + f"as filename {file_name_from_request} from the " + "client's request." + ) + except OSError as err: + raise InterfaceError from err + + if os.path.islink(file_name): + raise OperationalError("Use of symbolic link is not allowed") + if not self._allow_local_infile and not self._allow_local_infile_in_path: + raise DatabaseError( + "LOAD DATA LOCAL INFILE file request rejected due to " + "restrictions on access." + ) + if not self._allow_local_infile and self._allow_local_infile_in_path: + # Validate filename is inside of allow_local_infile_in_path path. + infile_path = os.path.abspath(self._allow_local_infile_in_path) + c_path = None + try: + c_path = os.path.commonpath([infile_path, file_name]) + except ValueError as err: + err_msg = ( + "{} while loading file `{}` and path `{}` given" + " in allow_local_infile_in_path" + ) + raise InterfaceError( + err_msg.format(str(err), file_name, infile_path) + ) from err + + if c_path != infile_path: + err_msg = ( + "The file `{}` is not found in the given " + "allow_local_infile_in_path {}" + ) + raise DatabaseError(err_msg.format(file_name, infile_path)) + + try: + data_file = open(file_name, "rb") # pylint: disable=consider-using-with + return self._handle_ok( + await self._send_data(data_file, True, read_timeout, write_timeout) + ) + except IOError: + # Send a empty packet to cancel the operation + try: + await self._socket.write( + b"", write_timeout=write_timeout or self._write_timeout + ) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + raise InterfaceError(f"File '{file_name}' could not be read") from None + finally: + try: + data_file.close() + except (IOError, NameError): + pass + + @handle_read_write_timeout() + async def _handle_result( + self, + packet: bytes, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> ResultType: + """Handle a MySQL Result. + + This method handles a MySQL result, for example, after sending the query + command. OK and EOF packets will be handled and returned. + If the packet is an Error packet, an Error-exception will be raised. + + The dictionary returned of: + - columns: column information + - eof: the EOF-packet information + """ + if not packet or len(packet) < 4: + raise InterfaceError("Empty response") + if packet[4] == OK_STATUS: + return self._handle_ok(packet) + if packet[4] == LOCAL_INFILE_STATUS: + filename = packet[5:].decode() + return await self._handle_load_data_infile( + filename, read_timeout, write_timeout + ) + if packet[4] == EOF_STATUS: + return self._handle_eof(packet) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + + # We have a text result set + column_count = self._protocol.parse_column_count(packet) + if not column_count or not isinstance(column_count, int): + raise InterfaceError("Illegal result set") + + self._columns_desc = [ + None, + ] * column_count + for i in range(0, column_count): + self._columns_desc[i] = self._protocol.parse_column( + await self._socket.read(read_timeout or self._read_timeout), + self.python_charset, + ) + + eof = self._handle_eof( + await self._socket.read(read_timeout or self._read_timeout) + ) + self.unread_result = True + return {"columns": self._columns_desc, "eof": eof} + + def _handle_binary_ok(self, packet: bytes) -> Dict[str, int]: + """Handle a MySQL Binary Protocol OK packet + + This method handles a MySQL Binary Protocol OK packet. When the + packet is found to be an Error packet, an error will be raised. If + the packet is neither an OK or an Error packet, InterfaceError + will be raised. + + Returns a dict() + """ + if packet[4] == OK_STATUS: + return self._protocol.parse_binary_prepare_ok(packet) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + raise InterfaceError("Expected Binary OK packet") + + @handle_read_write_timeout() + async def _handle_binary_result( + self, packet: bytes + ) -> Union[OkPacketType, Tuple[int, List[DescriptionType], EofPacketType]]: + """Handle a MySQL Result + + This method handles a MySQL result, for example, after sending the + query command. OK and EOF packets will be handled and returned. If + the packet is an Error packet, an Error exception will be raised. + + The tuple returned by this method consist of: + - the number of columns in the result, + - a list of tuples with information about the columns, + - the EOF packet information as a dictionary. + + Returns tuple() or dict() + """ + if not packet or len(packet) < 4: + raise InterfaceError("Empty response") + if packet[4] == OK_STATUS: + return self._handle_ok(packet) + if packet[4] == EOF_STATUS: + return self._handle_eof(packet) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + + # We have a binary result set + column_count = self._protocol.parse_column_count(packet) + if not column_count or not isinstance(column_count, int): + raise InterfaceError("Illegal result set.") + + columns: List[DescriptionType] = [None] * column_count + for i in range(0, column_count): + columns[i] = self._protocol.parse_column( + await self._socket.read(self._read_timeout), self.python_charset + ) + + eof = self._handle_eof(await self._socket.read(self._read_timeout)) + return (column_count, columns, eof) + + @handle_read_write_timeout() + async def _send_cmd( + self, + command: int, + argument: Optional[bytes] = None, + packet_number: int = 0, + packet: Optional[bytes] = None, + expect_response: bool = True, + compressed_packet_number: int = 0, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> Optional[bytearray]: + """Send a command to the MySQL server. + + This method sends a command with an optional argument. + If packet is not None, it will be sent and the argument will be ignored. + + The packet_number is optional and should usually not be used. + + Some commands might not result in the MySQL server returning a response. If a + command does not return anything, you should set expect_response to False. + The _send_cmd method will then return None instead of a MySQL packet. + """ + await self.handle_unread_result() + + try: + await self._socket.write( + self._protocol.make_command(command, packet or argument), + packet_number, + compressed_packet_number, + write_timeout or self._write_timeout, + ) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + if not expect_response: + return None + return await self._socket.read(read_timeout or self._read_timeout) + + @handle_read_write_timeout() + async def _send_data( + self, + data_file: BinaryIO, + send_empty_packet: bool = False, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> bytearray: + """Send data to the MySQL server + + This method accepts a file-like object and sends its data + as is to the MySQL server. If the send_empty_packet is + True, it will send an extra empty package (for example + when using LOAD LOCAL DATA INFILE). + + Returns a MySQL packet. + """ + await self.handle_unread_result() + + if not hasattr(data_file, "read"): + raise ValueError("expecting a file-like object") + + chunk_size = 131072 # 128 KB + try: + buf = data_file.read(chunk_size - 16) + while buf: + await self._socket.write( + buf, write_timeout=write_timeout or self._write_timeout + ) + buf = data_file.read(chunk_size - 16) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + if send_empty_packet: + try: + await self._socket.write(b"", write_timeout or self._write_timeout) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + res = await self._socket.read(read_timeout or self._read_timeout) + return res + + def is_socket_connected(self) -> bool: + """Reports whether the socket is connected. + + Instead of ping the server like ``is_connected()``, it only checks if the + socket connection flag is set. + """ + return bool(self._socket and self._socket.is_connected()) + + async def is_connected(self) -> bool: + """Reports whether the connection to MySQL Server is available. + + This method checks whether the connection to MySQL is available. + It is similar to ``ping()``, but unlike the ``ping()`` method, either `True` + or `False` is returned and no exception is raised. + """ + try: + await self.cmd_ping() + except Error: + return False + return True + + async def reset_session( + self, + user_variables: Optional[Dict[str, Any]] = None, + session_variables: Optional[Dict[str, Any]] = None, + ) -> None: + """Clears the current active session + + This method resets the session state, if the MySQL server is 5.7.3 + or later active session will be reset without re-authenticating. + For other server versions session will be reset by re-authenticating. + + It is possible to provide a sequence of variables and their values to + be set after clearing the session. This is possible for both user + defined variables and session variables. + This method takes two arguments user_variables and session_variables + which are dictionaries. + + Raises OperationalError if not connected, InternalError if there are + unread results and InterfaceError on errors. + """ + if not await self.is_connected(): + raise OperationalError("MySQL Connection not available.") + + if not await self.cmd_reset_connection(): + try: + await self.cmd_change_user( + self._user, + self._password, + self._database, + self._charset.charset_id, + self._password1, + self._password2, + self._password3, + self._oci_config_file, + self._oci_config_profile, + self._openid_token_file, + ) + except ProgrammingError: + await self.reconnect() + + cur = await self.cursor() + if user_variables: + for key, value in user_variables.items(): + await cur.execute(f"SET @`{key}` = %s", (value,)) + if session_variables: + for key, value in session_variables.items(): + await cur.execute(f"SET SESSION `{key}` = %s", (value,)) + await cur.close() + + async def ping( + self, reconnect: bool = False, attempts: int = 1, delay: int = 0 + ) -> None: + """Check availability of the MySQL server. + + When reconnect is set to `True`, one or more attempts are made to try to + reconnect to the MySQL server using the ``reconnect()`` method. + + ``delay`` is the number of seconds to wait between each retry. + + When the connection is not available, an InterfaceError is raised. Use the + ``is_connected()`` method if you just want to check the connection without + raising an error. + + Raises: + InterfaceError: On errors. + """ + try: + await self.cmd_ping() + except Error as err: + if reconnect: + await self.reconnect(attempts=attempts, delay=delay) + else: + raise InterfaceError("Connection to MySQL is not available") from err + + async def shutdown(self) -> None: + """Shut down connection to MySQL Server. + + This method closes the socket. It raises no exceptions. + + Unlike `disconnect()`, `shutdown()` closes the client connection without + attempting to send a QUIT command to the server first. Thus, it will not + block if the connection is disrupted for some reason such as network failure. + """ + if not self._socket: + return + + try: + await self._socket.close_connection() + except Exception: # pylint: disable=broad-exception-caught + pass # Getting an exception would mean we are disconnected. + + async def close(self) -> None: + with contextlib.suppress(Error): + for cursor in tuple(self._cursors): + await cursor.close() + self._cursors.clear() + + if self._socket and self._socket.is_connected(): + await self.cmd_quit() + + if self._socket: + await self._socket.close_connection() + self._socket = None + + disconnect = close + + async def cursor( + self, + buffered: Optional[bool] = None, + raw: Optional[bool] = None, + prepared: Optional[bool] = None, + cursor_class: Optional[Type[MySQLCursorAbstract]] = None, + dictionary: Optional[bool] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> MySQLCursor: + """Instantiate and return a cursor. + + By default, MySQLCursor is returned. Depending on the options while + connecting, a buffered and/or raw cursor is instantiated instead. + Also depending upon the cursor options, rows can be returned as a dictionary + or a tuple. + + It is possible to also give a custom cursor through the cursor_class + parameter, but it needs to be a subclass of + mysql.connector.aio.abstracts.MySQLCursorAbstract. + + Raises: + ProgrammingError: When cursor_class is not a subclass of + MySQLCursor. + ValueError: When cursor is not available. + InterfaceError: When read or write timeout is not a positive integer. + """ + if not self._socket or not self._socket.is_connected(): + raise OperationalError("MySQL Connection not available") + + if read_timeout is not None and ( + not isinstance(read_timeout, int) or read_timeout < 0 + ): + raise InterfaceError("Option read_timeout must be a positive integer") + if write_timeout is not None and ( + not isinstance(write_timeout, int) or write_timeout < 0 + ): + raise InterfaceError("Option write_timeout must be a positive integer") + + await self.handle_unread_result() + + if cursor_class is not None: + if not issubclass(cursor_class, MySQLCursor): + raise ProgrammingError( + "Cursor class needs be to subclass of MySQLCursorAbstract" + ) + return (cursor_class)(self, read_timeout, write_timeout) + + buffered = buffered if buffered is not None else self._buffered + raw = raw if raw is not None else self._raw + + cursor_type = 0 + if buffered is True: + cursor_type |= 1 + if raw is True: + cursor_type |= 2 + if dictionary is True: + cursor_type |= 4 + if prepared is True: + cursor_type |= 16 + + types = { + 0: MySQLCursor, + 1: MySQLCursorBuffered, + 2: MySQLCursorRaw, + 3: MySQLCursorBufferedRaw, + 4: MySQLCursorDict, + 5: MySQLCursorBufferedDict, + 16: MySQLCursorPrepared, + 20: MySQLCursorPreparedDict, + } + try: + return (types[cursor_type])(self, read_timeout, write_timeout) + except KeyError: + args = ("buffered", "raw", "dictionary", "prepared") + criteria = ", ".join( + [args[i] for i in range(4) if cursor_type & (1 << i) != 0] + ) + raise ValueError( + f"Cursor not available with given criteria: {criteria}" + ) from None + + @handle_read_write_timeout() + async def get_row( + self, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + **kwargs: Any, + ) -> Tuple[Optional[RowType], Optional[EofPacketType]]: + """Get the next rows returned by the MySQL server. + + This method gets one row from the result set after sending, for example, the + query command. The result is a tuple consisting of the row and the EOF packet. + If no row was available in the result set, the row data will be None. + """ + rows, eof = await self.get_rows( + count=1, + binary=binary, + columns=columns, + raw=raw, + **kwargs, + ) + if rows: + return (rows[0], eof) + return (None, eof) + + @handle_read_write_timeout() + async def get_rows( + self, + count: Optional[int] = None, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Any = None, + **kwargs: Any, + ) -> Tuple[List[RowType], Optional[EofPacketType]]: + """Get all rows returned by the MySQL server. + + This method gets all rows returned by the MySQL server after sending, for + example, the query command. The result is a tuple consisting of a list of rows + and the EOF packet. + """ + if raw is None: + raw = self._raw + + if not self.unread_result: + raise InternalError("No result set available") + + rows = ([], None) # type: ignore[var-annotated] + try: + read_timeout = kwargs.get("read_timeout", None) + if binary: + charset = self.charset + if charset == "utf8mb4": + charset = "utf8" + rows = await self._protocol.read_binary_result( + self._socket, + columns, + count, + charset, + read_timeout or self._read_timeout, + ) + else: + rows = await self._protocol.read_text_result( + self._socket, + self._server_info.version, + count, + read_timeout or self._read_timeout, + ) + except Error as err: + self.unread_result = False + raise err + + rows, eof_p = rows + if ( + not (binary or raw) + and self._columns_desc is not None + and rows + and hasattr(self, "converter") + ): + row_to_python = self.converter.row_to_python + rows = [row_to_python(row, self._columns_desc) for row in rows] + + if eof_p is not None: + self._handle_server_status( + eof_p["status_flag"] + if "status_flag" in eof_p + else eof_p["server_status"] + ) + self.unread_result = False + + return rows, eof_p + + async def commit(self) -> None: + """Commit current transaction.""" + await self._execute_query("COMMIT") + + async def rollback(self) -> None: + """Rollback current transaction.""" + if self.unread_result: + await self.get_rows() + await self._execute_query("ROLLBACK") + + async def cmd_reset_connection(self) -> bool: + """Resets the session state without re-authenticating. + + Reset command only works on MySQL server 5.7.3 or later. + The result is True for a successful reset otherwise False. + """ + try: + self._handle_ok(await self._send_cmd(ServerCmd.RESET_CONNECTION)) + await self._post_connection() + return True + except (NotSupportedError, OperationalError): + return False + + async def cmd_init_db(self, database: str) -> OkPacketType: + """Change the current database. + + This method changes the current (default) database by sending the INIT_DB + command. The result is a dictionary containing the OK packet infawaitormation. + """ + return self._handle_ok( + await self._send_cmd(ServerCmd.INIT_DB, database.encode("utf-8")) + ) + + async def cmd_query( + self, + query: StrOrBytes, + raw: bool = False, + buffered: bool = False, + raw_as_string: bool = False, + **kwargs: Any, + ) -> ResultType: + if not isinstance(query, bytearray): + if isinstance(query, str): + query = query.encode() + query = bytearray(query) + + # Set/Reset internal state related to query execution + self._query = query + self._local_infile_filenames = None + + # Prepare query attrs + charset = self._charset.name if self._charset.name != "utf8mb4" else "utf8" + packet = bytearray() + if not self._server_info.query_attrs_is_supported and self._query_attrs: + warnings.warn( + "This version of the server does not support Query Attributes", + category=Warning, + ) + if self._client_flags & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + names = [] + types = [] + values: List[bytes] = [] + null_bitmap = [0] * ((len(self._query_attrs) + 7) // 8) + for pos, attr_tuple in enumerate(self._query_attrs): + value = attr_tuple[1] + flags = 0 + if value is None: + null_bitmap[(pos // 8)] |= 1 << (pos % 8) + types.append(int1store(FieldType.NULL) + int1store(flags)) + continue + if isinstance(value, int): + ( + packed, + field_type, + flags, + ) = self._protocol.prepare_binary_integer(value) + values.append(packed) + elif isinstance(value, str): + value = value.encode(charset) + values.append(lc_int(len(value)) + value) + field_type = FieldType.VARCHAR + elif isinstance(value, bytes): + values.append(lc_int(len(value)) + value) + field_type = FieldType.BLOB + elif isinstance(value, Decimal): + values.append( + lc_int(len(str(value).encode(charset))) + + str(value).encode(charset) + ) + field_type = FieldType.DECIMAL + elif isinstance(value, float): + values.append(struct.pack(" parameter_count Number of parameters + packet.extend(lc_int(len(self._query_attrs))) + # int parameter_set_count Number of parameter sets. + # Currently always 1 + packet.extend(lc_int(1)) + if values: + packet.extend( + b"".join([struct.pack("B", bit) for bit in null_bitmap]) + + int1store(1) + ) + for _type, name in zip(types, names): + packet.extend(_type) + packet.extend(name) + + for value in values: + packet.extend(value) + + packet.extend(query) + query = bytes(packet) + try: + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + result = await self._handle_result( + await self._send_cmd( + ServerCmd.QUERY, + query, + read_timeout=read_timeout, + write_timeout=write_timeout, + ), + read_timeout, + write_timeout, + ) + except ProgrammingError as err: + if err.errno == 3948 and "Loading local data is disabled" in err.msg: + err_msg = ( + "LOAD DATA LOCAL INFILE file request rejected due " + "to restrictions on access." + ) + raise DatabaseError(err_msg) from err + raise + return result + + async def cmd_query_iter( # type: ignore[override] + self, + statements: StrOrBytes, + **kwargs: Any, + ) -> AsyncGenerator[ResultType, None]: + """Send one or more statements to the MySQL server. + + Similar to the cmd_query method, but instead returns a generator + object to iterate through results. It sends the statements to the + MySQL server and through the iterator you can get the results. + + statement = 'SELECT 1; INSERT INTO t1 VALUES (); SELECT 2' + for result in await cnx.cmd_query(statement, iterate=True): + if 'columns' in result: + columns = result['columns'] + rows = await cnx.get_rows() + else: + # do something useful with INSERT result + """ + try: + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + packet = bytearray() + if not isinstance(statements, bytearray): + if isinstance(statements, str): + statements = statements.encode("utf8") + statements = bytearray(statements) + + if self._client_flags & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + # int parameter_count Number of parameters + packet.extend(lc_int(0)) + # int parameter_set_count Number of parameter sets. + # Currently always 1 + packet.extend(lc_int(1)) + + packet.extend(statements) + query = bytes(packet) + # Handle the first query result + yield await self._handle_result( + await self._send_cmd( + ServerCmd.QUERY, + query, + read_timeout=read_timeout, + write_timeout=write_timeout, + ), + read_timeout, + write_timeout, + ) + + # Handle next results, if any + while self._have_next_result: + await self.handle_unread_result() + yield await self._handle_result( + await self._socket.read(read_timeout or self._read_timeout), + read_timeout, + write_timeout, + ) + except (ReadTimeoutError, WriteTimeoutError) as err: + raise err + + async def cmd_stmt_fetch( + self, statement_id: int, rows: int = 1, **kwargs: Any + ) -> None: + """Fetch a MySQL statement Result Set. + + This method will send the FETCH command to MySQL together with the given + statement id and the number of rows to fetch. + """ + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + packet = self._protocol.make_stmt_fetch(statement_id, rows) + self.unread_result = False + await self._send_cmd( + ServerCmd.STMT_FETCH, + packet, + expect_response=False, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + self.unread_result = True + + @handle_read_write_timeout() + async def cmd_stmt_prepare( + self, + statement: bytes, + **kwargs: Any, + ) -> Mapping[str, Union[int, List[DescriptionType]]]: + """Prepare a MySQL statement. + + This method will send the PREPARE command to MySQL together with the given + statement. + """ + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + packet = await self._send_cmd( + ServerCmd.STMT_PREPARE, + statement, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + result = self._handle_binary_ok(packet) + + result["columns"] = [] + result["parameters"] = [] + if result["num_params"] > 0: + for _ in range(0, result["num_params"]): + result["parameters"].append( + self._protocol.parse_column( + await self._socket.read(read_timeout or self._read_timeout), + self.python_charset, + ) + ) + self._handle_eof( + await self._socket.read(read_timeout or self._read_timeout) + ) + if result["num_columns"] > 0: + for _ in range(0, result["num_columns"]): + result["columns"].append( + self._protocol.parse_column( + await self._socket.read(read_timeout or self._read_timeout), + self.python_charset, + ) + ) + self._handle_eof( + await self._socket.read(read_timeout or self._read_timeout) + ) + + return result + + async def cmd_stmt_execute( + self, + statement_id: int, # type: ignore[override] + data: Sequence[BinaryProtocolType] = (), + parameters: Sequence[Any] = (), + flags: int = 0, + **kwargs: Any, + ) -> Union[OkPacketType, Tuple[int, List[DescriptionType], EofPacketType]]: + """Execute a prepared MySQL statement.""" + parameters = list(parameters) + long_data_used = {} + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + + if data: + for param_id, _ in enumerate(parameters): + if isinstance(data[param_id], IOBase): + binary = True + try: + binary = "b" not in data[param_id].mode # type: ignore[union-attr] + except AttributeError: + pass + await self.cmd_stmt_send_long_data( + statement_id, + param_id, + data[param_id], + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + long_data_used[param_id] = (binary,) + if not self._query_attrs_supported and self._query_attrs: + warnings.warn( + "This version of the server does not support Query Attributes", + category=Warning, + ) + if self._client_flags & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + execute_packet = self._protocol.make_stmt_execute( + statement_id, + data, + tuple(parameters), + flags, + long_data_used, + self.charset, + self._query_attrs, + self._converter_str_fallback, + ) + else: + execute_packet = self._protocol.make_stmt_execute( + statement_id, + data, + tuple(parameters), + flags, + long_data_used, + self.charset, + converter_str_fallback=self._converter_str_fallback, + ) + packet = await self._send_cmd( + ServerCmd.STMT_EXECUTE, + packet=execute_packet, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + result = await self._handle_binary_result(packet) + return result + + async def cmd_stmt_reset(self, statement_id: int, **kwargs: Any) -> None: + """Reset data for prepared statement sent as long data. + + The result is a dictionary with OK packet information. + """ + self._handle_ok( + await self._send_cmd( + ServerCmd.STMT_RESET, + int4store(statement_id), + read_timeout=kwargs.get("read_timeout", None), + write_timeout=kwargs.get("write_timeout", None), + ), + ) + + async def cmd_stmt_close(self, statement_id: int, **kwargs: Any) -> None: + """Deallocate a prepared MySQL statement. + + This method deallocates the prepared statement using the statement_id. + Note that the MySQL server does not return anything. + """ + await self._send_cmd( + ServerCmd.STMT_CLOSE, + int4store(statement_id), + expect_response=False, + read_timeout=kwargs.get("read_timeout", None), + write_timeout=kwargs.get("write_timeout", None), + ) + + @cmd_refresh_verify_options() + async def cmd_refresh(self, options: int) -> OkPacketType: + if not options & ( + RefreshOption.GRANT + | RefreshOption.LOG + | RefreshOption.TABLES + | RefreshOption.HOST + | RefreshOption.STATUS + | RefreshOption.REPLICA + ): + raise ValueError("Invalid command REFRESH option") + + res = None + if options & RefreshOption.GRANT: + res = await self.cmd_query("FLUSH PRIVILEGES") + if options & RefreshOption.LOG: + res = await self.cmd_query("FLUSH LOGS") + if options & RefreshOption.TABLES: + res = await self.cmd_query("FLUSH TABLES") + if options & RefreshOption.HOST: + res = await self.cmd_query("TRUNCATE TABLE performance_schema.host_cache") + if options & RefreshOption.STATUS: + res = await self.cmd_query("FLUSH STATUS") + if options & RefreshOption.REPLICA: + res = await self.cmd_query( + "RESET SLAVE" + if self._server_info.version_tuple < (8, 0, 22) + else "RESET REPLICA" + ) + + return res # type: ignore[return-value] + + async def cmd_stmt_send_long_data( + self, + statement_id: int, + param_id: int, + data: BinaryIO, + **kwargs: Any, + ) -> int: + """Send data for a column. + + This methods send data for a column (for example BLOB) for statement identified + by statement_id. The param_id indicate which parameter the data belongs too. + The data argument should be a file-like object. + + Since MySQL does not send anything back, no error is raised. When the MySQL + server is not reachable, an OperationalError is raised. + + cmd_stmt_send_long_data should be called before cmd_stmt_execute. + + The total bytes send is returned. + """ + chunk_size = 131072 # 128 KB + total_sent = 0 + try: + buf = data.read(chunk_size) + while buf: + packet = self._protocol.prepare_stmt_send_long_data( + statement_id, param_id, buf + ) + await self._send_cmd( + ServerCmd.STMT_SEND_LONG_DATA, + packet=packet, + expect_response=False, + read_timeout=kwargs.get("read_timeout", None), + write_timeout=kwargs.get("write_timeout", None), + ) + total_sent += len(buf) + buf = data.read(chunk_size) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + return total_sent + + async def cmd_quit(self) -> bytes: + """Close the current connection with the server. + + Send the QUIT command to the MySQL server, closing the current connection. + """ + await self.handle_unread_result() + packet = self._protocol.make_command(ServerCmd.QUIT) + try: + await self._socket.write(packet, write_timeout=self._write_timeout) + except WriteTimeoutError as _: + pass + return packet + + async def cmd_shutdown(self, shutdown_type: Optional[int] = None) -> None: + """Shut down the MySQL Server. + + This method sends the SHUTDOWN command to the MySQL server. + The `shutdown_type` is not used, and it's kept for backward compatibility. + """ + await self.cmd_query("SHUTDOWN") + + async def cmd_statistics(self) -> StatsPacketType: + """Send the statistics command to the MySQL Server. + + This method sends the STATISTICS command to the MySQL server. The result is a + dictionary with various statistical information. + """ + await self.handle_unread_result() + + packet = self._protocol.make_command(ServerCmd.STATISTICS) + await self._socket.write(packet, 0, 0, self._write_timeout) + return self._protocol.parse_statistics( + await self._socket.read(self._read_timeout) + ) + + async def cmd_process_kill(self, mysql_pid: int) -> OkPacketType: + """Kill a MySQL process. + + This method send the PROCESS_KILL command to the server along with the + process ID. The result is a dictionary with the OK packet information. + """ + if not isinstance(mysql_pid, int): + raise ValueError("MySQL PID must be int") + return await self.cmd_query(f"KILL {mysql_pid}") # type: ignore[return-value] + + async def cmd_debug(self) -> EofPacketType: + """Send the DEBUG command. + + This method sends the DEBUG command to the MySQL server, which requires the + MySQL user to have SUPER privilege. The output will go to the MySQL server + error log and the result of this method is a dictionary with EOF packet + information. + """ + return self._handle_eof(await self._send_cmd(ServerCmd.DEBUG)) + + async def cmd_ping(self) -> OkPacketType: + """Send the PING command. + + This method sends the PING command to the MySQL server. It is used to check + if the the connection is still valid. The result of this method is dictionary + with OK packet information. + """ + return self._handle_ok(await self._send_cmd(ServerCmd.PING)) + + async def cmd_change_user( + self, + username: str = "", + password: str = "", + database: str = "", + charset: Optional[int] = None, + password1: str = "", + password2: str = "", + password3: str = "", + oci_config_file: str = "", + oci_config_profile: str = "", + openid_token_file: str = "", + ) -> Optional[OkPacketType]: + """Change the current logged in user. + + This method allows to change the current logged in user information. + The result is a dictionary with OK packet information. + """ + # If charset isn't defined, we use the same charset ID defined previously, + # otherwise, we run a verification and update the charset ID. + if charset is not None: + if not isinstance(charset, int): + raise ValueError("charset must be an integer") + if charset < 0: + raise ValueError("charset should be either zero or a postive integer") + self._charset = charsets.get_by_id(charset) + + self._mfa_nfactor = 1 + self._user = username + self._password = password + self._password1 = password1 + self._password2 = password2 + self._password3 = password3 + + if self._password1 and password != self._password1: + self._password = self._password1 + + await self.handle_unread_result() + + if self._compress: + raise NotSupportedError("Change user is not supported with compression") + + if oci_config_file: + self._oci_config_file = oci_config_file + if openid_token_file: + self._openid_token_file = openid_token_file + self._oci_config_profile = oci_config_profile + + # Update the custom configurations needed by specific auth plugins + self._authenticator.update_plugin_config( + config={ + "oci_config_file": self._oci_config_file, + "oci_config_profile": self._oci_config_profile, + "openid_token_file": self._openid_token_file, + } + ) + + ok_packet: bytes = await self._authenticator.authenticate( + sock=self._socket, + handshake=self._handshake, + username=self._user, + password1=self._password, + password2=self._password2, + password3=self._password3, + database=self._database, + charset=self._charset.charset_id, + client_flags=self._client_flags, + ssl_enabled=self._ssl_active, + auth_plugin=self._auth_plugin, + conn_attrs=self._connection_attrs, + is_change_user_request=True, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + + if not (self._client_flags & ClientFlag.CONNECT_WITH_DB) and database: + await self.cmd_init_db(database) + + # return ok_pkt + return self._handle_ok(ok_packet) diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/cursor.py b/server/venv/Lib/site-packages/mysql/connector/aio/cursor.py new file mode 100644 index 0000000..8eec4bf --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/cursor.py @@ -0,0 +1,1392 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="assignment,arg-type,attr-defined,index,override,call-overload" + +"""Implementation of cursor classes in pure Python.""" + +__all__ = ["MySQLCursor"] + +import re +import warnings + +from decimal import Decimal +from typing import ( + Any, + AsyncGenerator, + Dict, + Iterator, + List, + NoReturn, + Optional, + Sequence, + Tuple, + Union, +) + +from .._decorating import deprecated as deprecated_sync +from .._scripting import split_multi_statement +from ..constants import ServerFlag +from ..cursor import ( + MAX_RESULTS, + RE_PY_MAPPING_PARAM, + RE_PY_PARAM, + RE_SQL_COMMENT, + RE_SQL_FIND_PARAM, + RE_SQL_INSERT_STMT, + RE_SQL_INSERT_VALUES, + RE_SQL_ON_DUPLICATE, + RE_SQL_PYTHON_CAPTURE_PARAM_NAME, + RE_SQL_PYTHON_REPLACE_PARAM, +) +from ..errors import ( + Error, + InterfaceError, + NotSupportedError, + ProgrammingError, + get_mysql_exception, +) +from ..types import ( + DescriptionType, + EofPacketType, + ParamsDictType, + ParamsSequenceOrDictType, + ParamsSequenceType, + ResultType, + RowItemType, + RowType, + StrOrBytes, + WarningType, +) +from ._decorating import deprecated +from .abstracts import MySQLConnectionAbstract, MySQLCursorAbstract + +ERR_NO_RESULT_TO_FETCH = "No result set to fetch from" + + +class _ParamSubstitutor: + """Substitute parameters into a SQL statement.""" + + def __init__(self, params: Sequence[bytes]) -> None: + self.params: Sequence[bytes] = params + self.index: int = 0 + + def __call__(self, matchobj: re.Match) -> bytes: + index = self.index + self.index += 1 + try: + return bytes(self.params[index]) + except IndexError: + raise ProgrammingError( + "Not enough parameters for the SQL statement" + ) from None + + @property + def remaining(self) -> int: + """Return the number of parameters remaining to be substituted.""" + return len(self.params) - self.index + + +def _bytestr_format_dict(bytestr: bytes, value_dict: Dict[bytes, bytes]) -> bytes: + """ + >>> _bytestr_format_dict(b'%(a)s', {b'a': b'foobar'}) + b'foobar + >>> _bytestr_format_dict(b'%%(a)s', {b'a': b'foobar'}) + b'%%(a)s' + >>> _bytestr_format_dict(b'%%%(a)s', {b'a': b'foobar'}) + b'%%foobar' + >>> _bytestr_format_dict(b'%(x)s %(y)s', + ... {b'x': b'x=%(y)s', b'y': b'y=%(x)s'}) + b'x=%(y)s y=%(x)s' + """ + + def replace(matchobj: re.Match) -> bytes: + """Replace pattern.""" + value: Optional[bytes] = None + groups = matchobj.groupdict() + if groups["conversion_type"] == b"%": + value = b"%" + if groups["conversion_type"] == b"s": + key = groups["mapping_key"] + value = value_dict[key] + if value is None: + raise ValueError( + f"Unsupported conversion_type: {groups['conversion_type']}" + ) + return value + + stmt = RE_PY_MAPPING_PARAM.sub(replace, bytestr) + return stmt + + +class MySQLCursor(MySQLCursorAbstract): + """Default cursor for interacting with MySQL. + + This cursor will execute statements and handle the result. It will not automatically + fetch all rows. + + MySQLCursor should be inherited whenever other functionallity is required. + An example would to change the fetch* member functions to return dictionaries instead + of lists of values. + + Implements the Python Database API Specification v2.0 (PEP-249). + """ + + async def __anext__(self) -> RowType: + res = await self.fetchone() + if res is not None: + return res + raise StopAsyncIteration + + async def close(self) -> bool: + if not self._connection: + return False + self._connection.remove_cursor(self) + await self._connection.handle_unread_result() + await self._reset_result() + self._connection = None + return True + + async def _process_params_dict( + self, params: ParamsDictType + ) -> Dict[bytes, Union[bytes, Decimal]]: + """Process query parameters given as dictionary.""" + res: Dict[bytes, Any] = {} + try: + sql_mode = await self._connection.get_sql_mode() + to_mysql = self._connection.converter.to_mysql + escape = self._connection.converter.escape + quote = self._connection.converter.quote + for key, value in params.items(): + conv = value + conv = to_mysql(conv) + conv = escape(conv, sql_mode) + if not isinstance(value, Decimal): + conv = quote(conv) + res[key.encode()] = conv + except Exception as err: + raise ProgrammingError( + f"Failed processing pyformat-parameters; {err}" + ) from err + return res + + async def _process_params( + self, params: ParamsSequenceType + ) -> Tuple[Union[bytes, Decimal], ...]: + """Process query parameters.""" + result = params[:] + try: + sql_mode = await self._connection.get_sql_mode() + to_mysql = self._connection.converter.to_mysql + escape = self._connection.converter.escape + quote = self._connection.converter.quote + result = [to_mysql(value) for value in result] + result = [escape(value, sql_mode) for value in result] + result = [ + quote(value) if not isinstance(params[i], Decimal) else value + for i, value in enumerate(result) + ] + except Exception as err: + raise ProgrammingError( + f"Failed processing format-parameters; {err}" + ) from err + return tuple(result) + + async def _fetch_row(self, raw: bool = False) -> Optional[RowType]: + """Return the next row in the result set.""" + if not self._connection.unread_result: + return None + row = None + + if self._nextrow == (None, None): + (row, eof) = await self._connection.get_row( + binary=self._binary, + columns=self.description, + raw=raw, + read_timeout=self._read_timeout, + ) + else: + (row, eof) = self._nextrow + + if row: + self._nextrow = await self._connection.get_row( + binary=self._binary, + columns=self.description, + raw=raw, + read_timeout=self._read_timeout, + ) + eof = self._nextrow[1] + if eof is not None: + await self._handle_eof(eof) + if self._rowcount == -1: + self._rowcount = 1 + else: + self._rowcount += 1 + if eof: + await self._handle_eof(eof) + + return row + + async def _handle_result(self, result: ResultType) -> None: + """Handle the result after a command was send. + + The result can be either an OK-packet or a dictionary containing column/eof + information. + + Raises: + InterfaceError: When result is not a dict() or result is invalid. + """ + if not isinstance(result, dict): + raise InterfaceError("Result was not a dict()") + + if "columns" in result: + # Weak test, must be column/eof information + self._description = result["columns"] + self._connection.unread_result = True + await self._handle_resultset() + elif "affected_rows" in result: + # Weak test, must be an OK-packet + self._connection.unread_result = False + await self._handle_noresultset(result) + else: + raise InterfaceError("Invalid result") + + async def _handle_resultset(self) -> None: + """Handle the result set. + + This method handles the result set and is called after reading and storing + column information in _handle_result(). For non-buffering cursors, this method + is usually doing nothing. + """ + + async def _handle_noresultset(self, res: ResultType) -> None: + """Handle result of execute() when there is no result set. + + Raises: + ProgrammingError: When failing handling a non-resultset. + """ + try: + self._rowcount = res["affected_rows"] + self._last_insert_id = res["insert_id"] + self._warning_count = res["warning_count"] + except (KeyError, TypeError) as err: + raise ProgrammingError(f"Failed handling non-resultset; {err}") from None + + await self._handle_warnings() + + async def _handle_warnings(self) -> None: + """Handle possible warnings after all results are consumed. + + Raises: + Error: Also raises exceptions if raise_on_warnings is set. + """ + if self._connection.get_warnings and self._warning_count: + self._warnings = await self._fetch_warnings() + + if not self._warnings: + return + + err = get_mysql_exception( + self._warnings[0][1], + self._warnings[0][2], + warning=not self._connection.raise_on_warnings, + ) + + if self._connection.raise_on_warnings: + raise err + + warnings.warn(err, stacklevel=4) + + async def _handle_eof(self, eof: EofPacketType) -> None: + """Handle EOF packet.""" + self._connection.unread_result = False + self._nextrow = (None, None) + self._warning_count = eof["warning_count"] + await self._handle_warnings() + + async def _reset_result(self, preserve_last_executed_stmt: bool = False) -> None: + """Reset the cursor to default. + + Args: + preserve_last_executed_stmt: If it is False, the last executed + statement value is reset. Otherwise, + such a value is preserved. + """ + self._description = None + self._warnings = None + self._warning_count = 0 + self._stored_results = [] + self._rowcount = -1 + self._nextrow = (None, None) + + if not preserve_last_executed_stmt: + # reset inner state related to statement execution + self._executed = None + self._executed_list = [] + self._stmt_partitions = None + self._stmt_partition = None + self._stmt_map_results = False + + await self.reset() + + def _have_unread_result(self) -> bool: + """Check whether there is an unread result.""" + try: + return self._connection.unread_result + except AttributeError: + return False + + def _check_executed(self) -> None: + """Check if the statement has been executed. + + Raises: + InterfaceError: If the statement has not been executed. + """ + if self._executed is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + + async def _prepare_statement( + self, + operation: StrOrBytes, + params: Union[Sequence[Any], Dict[str, Any]] = (), + ) -> bytes: + """Prepare SQL statement for execution. + + Converts the SQL statement to bytes and replaces the parameters in the + placeholders. + + Raises: + ProgrammingError: On converting to bytes, missing parameters or invalid + parameters type. + """ + try: + stmt = ( + operation + if isinstance(operation, (bytes, bytearray)) + else operation.encode(self._connection.python_charset) + ) + except (UnicodeDecodeError, UnicodeEncodeError) as err: + raise ProgrammingError(str(err)) from err + + if params: + if isinstance(params, dict): + stmt = _bytestr_format_dict( + stmt, await self._process_params_dict(params) + ) + elif isinstance(params, (list, tuple)): + psub = _ParamSubstitutor(await self._process_params(params)) + stmt = RE_PY_PARAM.sub(psub, stmt) + if psub.remaining != 0: + raise ProgrammingError( + "Not all parameters were used in the SQL statement" + ) + else: + raise ProgrammingError( + f"Could not process parameters: {type(params).__name__}({params})," + " it must be of type list, tuple or dict" + ) + + return stmt + + async def _fetch_warnings(self) -> Optional[List[WarningType]]: + """Fetch warnings doing a SHOW WARNINGS.""" + result = [] + async with await self._connection.cursor( + raw=False, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) as cur: + await cur.execute("SHOW WARNINGS") + result = await cur.fetchall() + return result if result else None # type: ignore[return-value] + + async def _batch_insert( + self, operation: str, seq_params: Sequence[ParamsSequenceOrDictType] + ) -> Optional[bytes]: + """Implements multi row insert""" + + def remove_comments(match: re.Match) -> str: + """Remove comments from INSERT statements. + + This function is used while removing comments from INSERT + statements. If the matched string is a comment not enclosed + by quotes, it returns an empty string, else the string itself. + """ + if match.group(1): + return "" + return match.group(2) + + tmp = re.sub( + RE_SQL_ON_DUPLICATE, + "", + re.sub(RE_SQL_COMMENT, remove_comments, operation), + ) + + matches = re.search(RE_SQL_INSERT_VALUES, tmp) + if not matches: + raise InterfaceError( + "Failed rewriting statement for multi-row INSERT. Check SQL syntax" + ) + fmt = matches.group(1).encode(self._connection.python_charset) + values = [] + + try: + stmt = operation.encode(self._connection.python_charset) + for params in seq_params: + tmp = fmt + if isinstance(params, dict): + tmp = _bytestr_format_dict( + tmp, await self._process_params_dict(params) + ) + else: + psub = _ParamSubstitutor(await self._process_params(params)) + tmp = RE_PY_PARAM.sub(psub, tmp) + if psub.remaining != 0: + raise ProgrammingError( + "Not all parameters were used in the SQL statement" + ) + values.append(tmp) + if fmt in stmt: + stmt = stmt.replace(fmt, b",".join(values), 1) + self._executed = stmt + return stmt + return None + except (UnicodeDecodeError, UnicodeEncodeError) as err: + raise ProgrammingError(str(err)) from err + except Error: + raise + except Exception as err: + raise InterfaceError(f"Failed executing the operation; {err}") from None + + @deprecated_sync( + "The property counterpart 'stored_results' will be added in a future release, " + "and this method will be removed." + ) + def stored_results(self) -> Iterator[MySQLCursorAbstract]: + """Returns an iterator for stored results. + + This method returns an iterator over results which are stored when callproc() + is called. The iterator will provide MySQLCursorBuffered instances. + """ + return iter(self._stored_results) + + async def callproc( + self, + procname: str, + args: Sequence[Any] = (), + ) -> Optional[Union[Dict[str, RowItemType], RowType]]: + """Calls a stored procedure with the given arguments + + The arguments will be set during this session, meaning + they will be called like ___arg where + is an enumeration (+1) of the arguments. + + Coding Example: + 1) Defining the Stored Routine in MySQL: + CREATE PROCEDURE multiply(IN pFac1 INT, IN pFac2 INT, OUT pProd INT) + BEGIN + SET pProd := pFac1 * pFac2; + END + + 2) Executing in Python: + args = (5, 5, 0) # 0 is to hold pprod + await cursor.callproc('multiply', args) + print(await cursor.fetchone()) + + For OUT and INOUT parameters the user should provide the + type of the parameter as well. The argument should be a + tuple with first item as the value of the parameter to pass + and second argument the type of the argument. + + In the above example, one can call callproc method like: + args = (5, 5, (0, 'INT')) + await cursor.callproc('multiply', args) + + The type of the argument given in the tuple will be used by + the MySQL CAST function to convert the values in the corresponding + MySQL type (See CAST in MySQL Reference for more information) + + Does not return a value, but a result set will be + available when the CALL-statement execute successfully. + Raises exceptions when something is wrong. + """ + if not procname or not isinstance(procname, str): + raise ValueError("procname must be a string") + + if not isinstance(args, (tuple, list)): + raise ValueError("args must be a sequence") + + self._stored_results = [] + + results = [] + try: + argnames = [] + argtypes = [] + + # MySQL itself does support calling procedures with their full + # name .. It's necessary to split + # by '.' and grab the procedure name from procname. + procname_abs = procname.split(".")[-1] + if args: + argvalues = [] + for idx, arg in enumerate(args): + argname = f"@_{procname_abs}_arg{idx + 1}" + argnames.append(argname) + if isinstance(arg, tuple): + argtypes.append(f" CAST({argname} AS {arg[1]})") + argvalues.append(arg[0]) + else: + argtypes.append(argname) + argvalues.append(arg) + + placeholders = ",".join(f"{arg}=%s" for arg in argnames) + await self.execute(f"SET {placeholders}", argvalues) + + call = f"CALL {procname}({','.join(argnames)})" + + # We disable consuming results temporary to make sure we + # getting all results + can_consume_results = self._connection.can_consume_results + async for result in self._connection.cmd_query_iter( + call, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ): + self._connection.can_consume_results = False + if isinstance(self, (MySQLCursorDict, MySQLCursorBufferedDict)): + cursor_class = MySQLCursorBufferedDict + elif self._raw: + cursor_class = MySQLCursorBufferedRaw + else: + cursor_class = MySQLCursorBuffered + # pylint: disable=protected-access + + # cursor_class = MySQLCursorBuffered + cur = cursor_class(self._connection.get_self()) + cur._executed = f"(a result of {call})" + await cur._handle_result(result) + # pylint: enable=protected-access + if cur.warnings is not None: + self._warnings = cur.warnings + if "columns" in result: + results.append(cur) + + self._connection.can_consume_results = can_consume_results + if argnames: + # Create names aliases to be compatible with namedtuples + args = [ + f"{name} AS {alias}" + for name, alias in zip( + argtypes, [arg.lstrip("@_") for arg in argnames] + ) + ] + select = f"SELECT {','.join(args)}" + await self.execute(select) + self._stored_results = results + return await self.fetchone() + + self._stored_results = results + return tuple() + except Error: + raise + except Exception as err: + raise InterfaceError(f"Failed calling stored routine; {err}") from None + + async def execute( + self, + operation: str, + params: Union[Sequence[Any], Dict[str, Any]] = (), + map_results: bool = False, + ) -> None: + if not self._connection: + raise ProgrammingError("Cursor is not connected") + + if not operation: + return None + + await self._connection.handle_unread_result() + await self._reset_result() + + stmt = await self._prepare_statement(operation, params) + + self._stmt_partitions = split_multi_statement( + sql_code=stmt, map_results=map_results + ) + self._stmt_partition = next(self._stmt_partitions) + self._stmt_map_results = map_results + self._executed_list = self._stmt_partition["single_stmts"] + self._executed = ( + self._stmt_partition["single_stmts"].popleft() + if map_results + else self._stmt_partition["mappable_stmt"] + ) + + await self._handle_result( + await self._connection.cmd_query( + self._stmt_partition["mappable_stmt"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + ) + + return None + + @deprecated( + "executemulti() is deprecated and will be removed in a future release. " + + "Use execute() instead." + ) + async def executemulti( + self, + operation: str, + params: Union[Sequence[Any], Dict[str, Any]] = (), + map_results: bool = False, + ) -> None: + return await self.execute( + operation=operation, params=params, map_results=map_results + ) + + async def executemany( + self, + operation: str, + seq_params: Sequence[ParamsSequenceType], + ) -> None: + """Prepare and execute a MySQL Prepared Statement many times. + + This method will prepare the given operation and execute with each tuple found + the list seq_params. + + If the cursor instance already had a prepared statement, it is first closed. + """ + if not operation or not seq_params: + return None + await self._connection.handle_unread_result() + + try: + _ = iter(seq_params) + except TypeError as err: + raise ProgrammingError("Parameters for query must be an Iterable") from err + + # Optimize INSERTs by batching them + if re.match(RE_SQL_INSERT_STMT, operation): + if not seq_params: + self._rowcount = 0 + return None + stmt = await self._batch_insert(operation, seq_params) + if stmt is not None: + self._executed = stmt + return await self.execute(stmt) + + rowcnt = 0 + try: + for params in seq_params: + await self.execute(operation, params) + if self.with_rows and self._have_unread_result(): + await self.fetchall() + rowcnt += self._rowcount + except (ValueError, TypeError) as err: + raise ProgrammingError(f"Failed executing the operation; {err}") from None + self._rowcount = rowcnt + + async def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Raises: + InterfaceError: If there is no result to fetch. + + Returns: + tuple or None: A row from query result set. + """ + if self._executed is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + return await self._fetch_row() + + async def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + if self._executed is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + + if not self._connection.unread_result: + return [] + + rows, eof = await self._connection.get_rows(read_timeout=self._read_timeout) + if self._nextrow[0]: + rows.insert(0, self._nextrow[0]) + + await self._handle_eof(eof) + rowcount = len(rows) + if rowcount >= 0 and self._rowcount == -1: + self._rowcount = 0 + self._rowcount += rowcount + return rows + + async def fetchmany(self, size: Optional[int] = None) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, which + defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + res = [] + cnt = size or self.arraysize + while cnt > 0: + cnt -= 1 + row = await self.fetchone() + if row: + res.append(row) + + return res + + async def nextset(self) -> Optional[bool]: + if self._connection._have_next_result: + # prepare cursor to load the next result set, and ultimately, load it. + await self._connection.handle_unread_result() + await self._reset_result(preserve_last_executed_stmt=True) + await self._handle_result( + await self._connection._handle_result( + await self._connection._socket.read(read_timeout=self._read_timeout) + ) + ) + + # if mapping is enabled, run the if-block, otherwise simply return `True`. + if self._stmt_partitions is not None and self._stmt_map_results: + if not self._stmt_partition["single_stmts"]: + # It means there are still results to be consumed, but no more + # statements to relate these results to. + # In this case, we raise a no fatal error and don't clear + # `_executed` so its current value is reported when users + # access the property `statement`. + # If this case ever happens, a bug report should be filed, + # assuming it is happening on supported use cases. + warnings.warn( + "MappingWarning: Number of result sets greater than number " + "of single statements." + ) + else: + self._executed = self._stmt_partition["single_stmts"].popleft() + return True + if self._stmt_partitions is not None: + # Let's see if there are more mappable statements (partitions) + # to be executed. + # If there are no more partitions, we simply return `None`, otherwise + # we execute the correponding mappable multi statement and repeat the + # process all over again. + try: + self._stmt_partition = next(self._stmt_partitions) + except StopIteration: + pass + else: + # This block only happens when mapping is enabled because when it + # is disabled, only one partition is generated, and at this point, + # such partiton has already been processed. + self._executed = self._stmt_partition["single_stmts"].popleft() + await self._handle_result( + await self._connection.cmd_query( + self._stmt_partition["mappable_stmt"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + ) + return True + + await self._reset_result() + return None + + +class MySQLCursorBuffered(MySQLCursor): + """Cursor which fetches rows within execute().""" + + def __init__( + self, + connection: MySQLConnectionAbstract, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + super().__init__(connection, read_timeout, write_timeout) + self._rows: Optional[List[RowType]] = None + self._next_row: int = 0 + + async def _handle_resultset(self) -> None: + """Handle the result set. + + This method handles the result set and is called after reading and storing + column information in _handle_result(). For non-buffering cursors, this method + is usually doing nothing. + """ + self._rows, eof = await self._connection.get_rows( + raw=self._raw, read_timeout=self._read_timeout + ) + self._rowcount = len(self._rows) + await self._handle_eof(eof) + self._next_row = 0 + self._connection.unread_result = False + + async def _fetch_row(self, raw: bool = False) -> Optional[RowType]: + """Return the next row in the result set.""" + row = None + try: + row = self._rows[self._next_row] + except (IndexError, TypeError): + return None + self._next_row += 1 + return row + + async def reset(self, free: bool = True) -> None: + """Reset the cursor to default.""" + self._rows = None + + async def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return await self._fetch_row() + + async def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + if self._executed is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + if self._rows is None: + return [] + res = [] + res = self._rows[self._next_row :] + self._next_row = len(self._rows) + return res + + @property + def with_rows(self) -> bool: + return self._rows is not None + + +class MySQLCursorRaw(MySQLCursor): + """Skip conversion from MySQL datatypes to Python types when fetching rows.""" + + def __init__( + self, + connection: MySQLConnectionAbstract, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + super().__init__(connection, read_timeout, write_timeout) + self._raw: bool = True + + async def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return await self._fetch_row(raw=True) + + async def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + if not self._have_unread_result(): + return [] + rows, eof = await self._connection.get_rows( + raw=True, read_timeout=self._read_timeout + ) + if self._nextrow[0]: + rows.insert(0, self._nextrow[0]) + await self._handle_eof(eof) + rowcount = len(rows) + if rowcount >= 0 and self._rowcount == -1: + self._rowcount = 0 + self._rowcount += rowcount + return rows + + +class MySQLCursorBufferedRaw(MySQLCursorBuffered): + """ + Cursor which skips conversion from MySQL datatypes to Python types when + fetching rows and fetches rows within execute(). + """ + + def __init__( + self, + connection: MySQLConnectionAbstract, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + super().__init__(connection, read_timeout, write_timeout) + self._raw: bool = True + + @property + def with_rows(self) -> bool: + return self._rows is not None + + +class MySQLCursorDict(MySQLCursor): + """ + Cursor fetching rows as dictionaries. + + The fetch methods of this class will return dictionaries instead of tuples. + Each row is a dictionary that looks like: + row = { + "col1": value1, + "col2": value2 + } + """ + + def _row_to_python( + self, + rowdata: RowType, + desc: Optional[List[DescriptionType]] = None, # pylint: disable=unused-argument + ) -> Optional[Dict[str, RowItemType]]: + """Convert a MySQL text result row to Python types + + Returns a dictionary. + """ + return dict(zip(self.column_names, rowdata)) if rowdata else None + + async def fetchone(self) -> Optional[Dict[str, RowItemType]]: + """Return next row of a query result set. + + Returns: + dict or None: A dict from query result set. + """ + return self._row_to_python(await super().fetchone(), self.description) + + async def fetchall(self) -> List[Optional[Dict[str, RowItemType]]]: + """Return all rows of a query result set. + + Returns: + list: A list of dictionaries with all rows of a query + result set where column names are used as keys. + """ + return [ + self._row_to_python(row, self.description) + for row in await super().fetchall() + if row + ] + + +class MySQLCursorBufferedDict(MySQLCursorDict, MySQLCursorBuffered): + """ + Buffered Cursor fetching rows as dictionaries. + """ + + async def fetchone(self) -> Optional[Dict[str, RowItemType]]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + row = await self._fetch_row() + if row: + return self._row_to_python(row, self.description) + return None + + async def fetchall(self) -> List[Optional[Dict[str, RowItemType]]]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + if self._executed is None or self._rows is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + res = [] + for row in self._rows[self._next_row :]: + res.append(self._row_to_python(row, self.description)) + self._next_row = len(self._rows) + return res + + +class MySQLCursorPrepared(MySQLCursor): + """Cursor using MySQL Prepared Statements""" + + def __init__( + self, + connection: MySQLConnectionAbstract, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ): + super().__init__(connection, read_timeout, write_timeout) + self._rows: Optional[List[RowType]] = None + self._next_row: int = 0 + self._prepared: Optional[Dict[str, Union[int, List[DescriptionType]]]] = None + self._binary: bool = True + self._have_result: Optional[bool] = None + self._last_row_sent: bool = False + self._cursor_exists: bool = False + + async def reset(self, free: bool = True) -> None: + if self._prepared: + try: + await self._connection.cmd_stmt_close( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + except Error: + # We tried to deallocate, but it's OK when we fail. + pass + self._prepared = None + self._last_row_sent = False + self._cursor_exists = False + + async def _handle_noresultset(self, res: ResultType) -> None: + self._handle_server_status( + res.get("status_flag", res.get("server_status", 0)), + ) + await super()._handle_noresultset(res) + + def _handle_server_status(self, flags: int) -> None: + self._cursor_exists = flags & ServerFlag.STATUS_CURSOR_EXISTS != 0 + self._last_row_sent = flags & ServerFlag.STATUS_LAST_ROW_SENT != 0 + + async def _handle_eof(self, eof: EofPacketType) -> None: + self._handle_server_status( + eof.get("status_flag", eof.get("server_status", 0)), + ) + await super()._handle_eof(eof) + + async def callproc(self, procname: Any, args: Any = ()) -> NoReturn: + """Calls a stored procedue + + Not supported with MySQLCursorPrepared. + """ + raise NotSupportedError() + + @deprecated( + "executemulti() is deprecated and will be removed in a future release. " + + "Use execute() instead." + ) + async def executemulti( + self, + operation: str, + params: Union[Sequence[Any], Dict[str, Any]] = (), + map_results: bool = False, + ) -> AsyncGenerator[MySQLCursorAbstract, None]: + """Execute multiple statements. + + Executes the given operation substituting any markers with the given + parameters. + """ + raise NotSupportedError() + + async def close(self) -> None: + """Close the cursor + + This method will try to deallocate the prepared statement and close + the cursor. + """ + await self.reset() + await super().close() + + def _row_to_python(self, rowdata: Any, desc: Any = None) -> Any: + """Convert row data from MySQL to Python types + + The conversion is done while reading binary data in the protocol module. + """ + + async def _handle_result(self, result: ResultType) -> None: + """Handle result after execution""" + if isinstance(result, dict): + self._connection.unread_result = False + self._have_result = False + await self._handle_noresultset(result) + else: + self._description = result[1] + self._connection.unread_result = True + self._have_result = True + if "status_flag" in result[2]: # type: ignore[operator] + self._handle_server_status(result[2]["status_flag"]) + elif "server_status" in result[2]: # type: ignore[operator] + self._handle_server_status(result[2]["server_status"]) + + async def execute( + self, + operation: StrOrBytes, + params: Optional[ParamsSequenceOrDictType] = None, + map_results: bool = False, + ) -> None: + """Prepare and execute a MySQL Prepared Statement + + This method will prepare the given operation and execute it using + the optionally given parameters. + + If the cursor instance already had a prepared statement, it is + first closed. + + *Argument "map_results" is unused as multi statement execution + is not supported for prepared statements*. + + Raises: + ProgrammingError: When providing a multi statement operation + or setting *map_results* to True. + """ + if map_results: + raise ProgrammingError( + "Multi statement execution not supported for prepared statements." + ) + + if not self._connection: + raise ProgrammingError("Cursor is not connected") + + if not operation: + return None + + charset = self._connection.charset + if charset == "utf8mb4": + charset = "utf8" + + if not isinstance(operation, str): + try: + operation = operation.decode(charset) + except UnicodeDecodeError as err: + raise ProgrammingError(str(err)) from err + + if isinstance(params, dict): + replacement_keys = re.findall(RE_SQL_PYTHON_CAPTURE_PARAM_NAME, operation) + try: + # Replace params dict with params tuple in correct order + params = tuple(params[key] for key in replacement_keys) + except KeyError as err: + raise ProgrammingError( + "Not all placeholders were found in the parameters dict" + ) from err + # Convert %(name)s to ? before sending it to MySQL + operation = re.sub(RE_SQL_PYTHON_REPLACE_PARAM, "?", operation) + + if operation is not self._executed: + if self._prepared: + await self._connection.cmd_stmt_close( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + self._executed = operation + + try: + operation = operation.encode(charset) + except UnicodeEncodeError as err: + raise ProgrammingError(str(err)) from err + + if b"%s" in operation: + # Convert %s to ? before sending it to MySQL + operation = re.sub(RE_SQL_FIND_PARAM, b"?", operation) + + try: + self._prepared = await self._connection.cmd_stmt_prepare( + operation, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + except Error: + self._executed = None + raise + + await self._connection.cmd_stmt_reset( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + + if self._prepared["parameters"] and not params: + return + if params: + if not isinstance(params, (tuple, list)): + raise ProgrammingError( + errno=1210, + msg="Incorrect type of argument: " + f"{type(params).__name__}({params})" + ", it must be of type tuple or list the argument given to " + "the prepared statement", + ) + if len(self._prepared["parameters"]) != len(params): + raise ProgrammingError( + errno=1210, + msg="Incorrect number of arguments executing prepared statement", + ) + + if params is None: + params = () + res = await self._connection.cmd_stmt_execute( + self._prepared["statement_id"], + data=params, + parameters=self._prepared["parameters"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + await self._handle_result(res) + + async def executemany( + self, + operation: str, + seq_params: Sequence[ParamsSequenceType], + ) -> None: + """Prepare and execute a MySQL Prepared Statement many times + + This method will prepare the given operation and execute with each + tuple found the list seq_params. + + If the cursor instance already had a prepared statement, it is + first closed. + + executemany() simply calls execute(). + """ + if not operation or not seq_params: + return None + await self._connection.handle_unread_result() + + try: + _ = iter(seq_params) + except TypeError as err: + raise ProgrammingError("Parameters for query must be an Iterable") from err + + rowcnt = 0 + try: + for params in seq_params: + await self.execute(operation, params) + if self.with_rows and self._have_unread_result(): + await self.fetchall() + rowcnt += self._rowcount + except (ValueError, TypeError) as err: + raise InterfaceError(f"Failed executing the operation; {err}") from None + self._rowcount = rowcnt + + async def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + if self._cursor_exists: + await self._connection.cmd_stmt_fetch( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + return await self._fetch_row() or None + + async def fetchmany(self, size: Optional[int] = None) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + res = [] + cnt = size or self.arraysize + while cnt > 0 and self._have_unread_result(): + cnt -= 1 + row = await self._fetch_row() + if row: + res.append(row) + return res + + async def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + rows = [] + if self._nextrow[0]: + rows.append(self._nextrow[0]) + while self._have_unread_result(): + if self._cursor_exists: + await self._connection.cmd_stmt_fetch( + self._prepared["statement_id"], + MAX_RESULTS, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + tmp, eof = await self._connection.get_rows( + binary=self._binary, + columns=self.description, + read_timeout=self._read_timeout, + ) + rows.extend(tmp) + await self._handle_eof(eof) + self._rowcount = len(rows) + return rows + + +class MySQLCursorPreparedDict(MySQLCursorDict, MySQLCursorPrepared): # type: ignore[misc] + """ + This class is a blend of features from MySQLCursorDict and MySQLCursorPrepared + + Multiple inheritance in python is allowed but care must be taken + when assuming methods resolution. In the case of multiple + inheritance, a given attribute is first searched in the current + class if it's not found then it's searched in the parent classes. + The parent classes are searched in a left-right fashion and each + class is searched once. + Based on python's attribute resolution, in this case, attributes + are searched as follows: + 1. MySQLCursorPreparedDict (current class) + 2. MySQLCursorDict (left parent class) + 3. MySQLCursorPrepared (right parent class) + 4. MySQLCursor (base class) + """ + + async def fetchmany( + self, size: Optional[int] = None + ) -> List[Dict[str, RowItemType]]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set represented + as a list of dictionaries where column names are used as keys. + """ + return [ + self._row_to_python(row, self.description) + for row in await super().fetchmany(size=size) + if row + ] diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/logger.py b/server/venv/Lib/site-packages/mysql/connector/aio/logger.py new file mode 100644 index 0000000..bdb7035 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/logger.py @@ -0,0 +1,33 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Setup of the `mysql.connector.aio` logger.""" + +import logging + +logger = logging.getLogger("mysql.connector.aio") diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/network.py b/server/venv/Lib/site-packages/mysql/connector/aio/network.py new file mode 100644 index 0000000..60a4340 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/network.py @@ -0,0 +1,765 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# pylint: disable=dangerous-default-value + +"""Module implementing low-level socket communication with MySQL servers.""" + + +__all__ = ["MySQLTcpSocket", "MySQLUnixSocket"] + +import asyncio +import struct +import zlib + +try: + import ssl + + TLS_VERSIONS = { + "TLSv1": ssl.PROTOCOL_TLSv1, + "TLSv1.1": ssl.PROTOCOL_TLSv1_1, + "TLSv1.2": ssl.PROTOCOL_TLSv1_2, + "TLSv1.3": ssl.PROTOCOL_TLS, + } +except ImportError: + ssl = None + +from abc import ABC, abstractmethod +from collections import deque +from typing import Any, Deque, List, Optional, Tuple + +from ..errors import ( + InterfaceError, + NotSupportedError, + OperationalError, + ProgrammingError, + ReadTimeoutError, + WriteTimeoutError, +) +from ..network import ( + COMPRESSED_PACKET_HEADER_LENGTH, + MAX_PAYLOAD_LENGTH, + MIN_COMPRESS_LENGTH, + PACKET_HEADER_LENGTH, +) +from .utils import StreamWriter, open_connection + + +def _strioerror(err: IOError) -> str: + """Reformat the IOError error message. + + This function reformats the IOError error message. + """ + return str(err) if not err.errno else f"{err.errno} {err.strerror}" + + +class NetworkBroker(ABC): + """Broker class interface. + + The network object is a broker used as a delegate by a socket object. Whenever the + socket wants to deliver or get packets to or from the MySQL server it needs to rely + on its network broker (netbroker). + + The netbroker sends `payloads` and receives `packets`. + + A packet is a bytes sequence, it has a header and body (referred to as payload). + The first `PACKET_HEADER_LENGTH` or `COMPRESSED_PACKET_HEADER_LENGTH` + (as appropriate) bytes correspond to the `header`, the remaining ones represent the + `payload`. + + The maximum payload length allowed to be sent per packet to the server is + `MAX_PAYLOAD_LENGTH`. When `send` is called with a payload whose length is greater + than `MAX_PAYLOAD_LENGTH` the netbroker breaks it down into packets, so the caller + of `send` can provide payloads of arbitrary length. + + Finally, data received by the netbroker comes directly from the server, expect to + get a packet for each call to `recv`. The received packet contains a header and + payload, the latter respecting `MAX_PAYLOAD_LENGTH`. + """ + + @abstractmethod + async def write( + self, + writer: StreamWriter, + address: str, + payload: bytes, + packet_number: Optional[int] = None, + compressed_packet_number: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + """Send `payload` to the MySQL server. + + If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is + broken down into packets. + + Args: + sock: Object holding the socket connection. + address: Socket's location. + payload: Packet's body to send. + packet_number: Sequence id (packet ID) to attach to the header when sending + plain packets. + compressed_packet_number: Same as `packet_number` but used when sending + compressed packets. + write_timeout: Timeout in seconds before which sending a packet to the server + should finish else WriteTimeoutError is raised. + + + Raises: + :class:`OperationalError`: If something goes wrong while sending packets to + the MySQL server. + """ + + @abstractmethod + async def read( + self, + reader: asyncio.StreamReader, + address: str, + read_timeout: Optional[int] = None, + ) -> bytearray: + """Get the next available packet from the MySQL server. + + Args: + sock: Object holding the socket connection. + address: Socket's location. + read_timeout: Timeout in seconds before which reading a packet from the server + should finish. + + Returns: + packet: A packet from the MySQL server. + + Raises: + :class:`OperationalError`: If something goes wrong while receiving packets + from the MySQL server. + :class:`ReadTimeoutError`: If the time to receive a packet from the server takes + longer than `read_timeout`. + :class:`InterfaceError`: If something goes wrong while receiving packets + from the MySQL server. + """ + + +class NetworkBrokerPlain(NetworkBroker): + """Broker class for MySQL socket communication.""" + + def __init__(self) -> None: + self._pktnr: int = -1 # packet number + + @staticmethod + def get_header(pkt: bytes) -> Tuple[int, int]: + """Recover the header information from a packet.""" + if len(pkt) < PACKET_HEADER_LENGTH: + raise ValueError("Can't recover header info from an incomplete packet") + + pll, seqid = ( + struct.unpack(" None: + """Set the given packet id, if any, else increment packet id.""" + if next_id is None: + self._pktnr += 1 + else: + self._pktnr = next_id + self._pktnr %= 256 + + async def _write_pkt( + self, + writer: StreamWriter, + address: str, + pkt: bytes, + ) -> None: + """Write packet to the comm channel.""" + try: + writer.write(pkt) + await writer.drain() + except IOError as err: + raise OperationalError( + errno=2055, values=(address, _strioerror(err)) + ) from err + except AttributeError as err: + raise OperationalError(errno=2006) from err + + async def _read_chunk( + self, + reader: asyncio.StreamReader, + size: int = 0, + read_timeout: Optional[int] = None, + ) -> bytearray: + """Read `size` bytes from the comm channel.""" + try: + pkt = bytearray(b"") + while len(pkt) < size: + chunk = await asyncio.wait_for( + reader.read(size - len(pkt)), read_timeout + ) + if not chunk: + raise InterfaceError(errno=2013) + pkt += chunk + return pkt + except asyncio.TimeoutError as err: + raise ReadTimeoutError(errno=3024) from err + except asyncio.CancelledError as err: + raise err + + async def write( + self, + writer: StreamWriter, + address: str, + payload: bytes, + packet_number: Optional[int] = None, + compressed_packet_number: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + """Send payload to the MySQL server. + + If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is + broken down into packets. + """ + self._set_next_pktnr(packet_number) + # If the payload is larger than or equal to MAX_PAYLOAD_LENGTH the length is + # set to 2^24 - 1 (ff ff ff) and additional packets are sent with the rest of + # the payload until the payload of a packet is less than MAX_PAYLOAD_LENGTH. + offset = 0 + try: + for _ in range(len(payload) // MAX_PAYLOAD_LENGTH): + # payload_len, sequence_id, payload + await asyncio.wait_for( + self._write_pkt( + writer, + address, + b"\xff" * 3 + + struct.pack(" bytearray: + """Receive `one` packet from the MySQL server.""" + try: + # Read the header of the MySQL packet. + header = await self._read_chunk(reader, PACKET_HEADER_LENGTH, read_timeout) + + # Pull the payload length and sequence id. + payload_len, self._pktnr = self.get_header(header) + + # Read the payload, and return packet. + return header + await self._read_chunk(reader, payload_len, read_timeout) + except IOError as err: + raise OperationalError( + errno=2055, values=(address, _strioerror(err)) + ) from err + + +class NetworkBrokerCompressed(NetworkBrokerPlain): + """Broker class for MySQL socket communication.""" + + def __init__(self) -> None: + super().__init__() + self._compressed_pktnr = -1 + self._queue_read: Deque[bytearray] = deque() + + @staticmethod + def _prepare_packets(payload: bytes, pktnr: int) -> List[bytes]: + """Prepare a payload for sending to the MySQL server.""" + offset = 0 + pkts = [] + + # If the payload is larger than or equal to MAX_PAYLOAD_LENGTH the length is + # set to 2^24 - 1 (ff ff ff) and additional packets are sent with the rest of + # the payload until the payload of a packet is less than MAX_PAYLOAD_LENGTH. + for _ in range(len(payload) // MAX_PAYLOAD_LENGTH): + # payload length + sequence id + payload + pkts.append( + b"\xff" * 3 + + struct.pack(" Tuple[int, int, int]: # type: ignore[override] + """Recover the header information from a packet.""" + if len(pkt) < COMPRESSED_PACKET_HEADER_LENGTH: + raise ValueError("Can't recover header info from an incomplete packet") + + compressed_pll, seqid, uncompressed_pll = ( + struct.unpack(" None: + """Set the given packet id, if any, else increment packet id.""" + if next_id is None: + self._compressed_pktnr += 1 + else: + self._compressed_pktnr = next_id + self._compressed_pktnr %= 256 + + async def _write_pkt( + self, + writer: StreamWriter, + address: str, + pkt: bytes, + ) -> None: + """Compress packet and write it to the comm channel.""" + compressed_pkt = zlib.compress(pkt) + pkt = ( + struct.pack(" None: + """Send `payload` as compressed packets to the MySQL server. + + If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is + broken down into packets. + """ + # Get next packet numbers. + self._set_next_pktnr(packet_number) + self._set_next_compressed_pktnr(compressed_packet_number) + try: + payload_prep = bytearray(b"").join( + self._prepare_packets(payload, self._pktnr) + ) + if len(payload) >= MAX_PAYLOAD_LENGTH - PACKET_HEADER_LENGTH: + # Sending a MySQL payload of the size greater or equal to 2^24 - 5 via + # compression leads to at least one extra compressed packet WHY? let's say + # len(payload) is MAX_PAYLOAD_LENGTH - 3; when preparing the payload, a + # header of size PACKET_HEADER_LENGTH is pre-appended to the payload. + # This means that len(payload_prep) is + # MAX_PAYLOAD_LENGTH - 3 + PACKET_HEADER_LENGTH = MAX_PAYLOAD_LENGTH + 1 + # surpassing the maximum allowed payload size per packet. + offset = 0 + + # Send several MySQL packets. + for _ in range(len(payload_prep) // MAX_PAYLOAD_LENGTH): + await asyncio.wait_for( + self._write_pkt( + writer, + address, + payload_prep[offset : offset + MAX_PAYLOAD_LENGTH], + ), + write_timeout, + ) + self._set_next_compressed_pktnr() + offset += MAX_PAYLOAD_LENGTH + await asyncio.wait_for( + self._write_pkt(writer, address, payload_prep[offset:]), + write_timeout, + ) + else: + # Send one MySQL packet. + # For small packets it may be too costly to compress the packet. + # Usually payloads less than 50 bytes (MIN_COMPRESS_LENGTH) aren't + # compressed (see MySQL source code Documentation). + if len(payload) > MIN_COMPRESS_LENGTH: + # Perform compression. + await asyncio.wait_for( + self._write_pkt(writer, address, payload_prep), write_timeout + ) + else: + # Skip compression. + await asyncio.wait_for( + super()._write_pkt( + writer, + address, + struct.pack(" None: + """Handle reading of a compressed packet.""" + # compressed_pll stands for compressed payload length. + pkt = bytearray( + zlib.decompress( + await super()._read_chunk(reader, compressed_pll, read_timeout) + ) + ) + offset = 0 + while offset < len(pkt): + # pll stands for payload length + pll = struct.unpack( + " len(pkt) - offset: + # More bytes need to be consumed. + # Read the header of the next MySQL packet. + header = await super()._read_chunk( + reader, COMPRESSED_PACKET_HEADER_LENGTH, read_timeout + ) + + # compressed payload length, sequence id, uncompressed payload length. + ( + compressed_pll, + self._compressed_pktnr, + uncompressed_pll, + ) = self.get_header(header) + compressed_pkt = await super()._read_chunk( + reader, compressed_pll, read_timeout + ) + + # Recalling that if uncompressed payload length == 0, the packet comes + # in uncompressed, so no decompression is needed. + pkt += ( + compressed_pkt + if uncompressed_pll == 0 + else zlib.decompress(compressed_pkt) + ) + + self._queue_read.append(pkt[offset : offset + PACKET_HEADER_LENGTH + pll]) + offset += PACKET_HEADER_LENGTH + pll + + async def read( + self, + reader: asyncio.StreamReader, + address: str, + read_timeout: Optional[int] = None, + ) -> bytearray: + """Receive `one` or `several` packets from the MySQL server, enqueue them, and + return the packet at the head. + """ + + if not self._queue_read: + try: + # Read the header of the next MySQL packet. + header = await super()._read_chunk( + reader, COMPRESSED_PACKET_HEADER_LENGTH, read_timeout + ) + + # compressed payload length, sequence id, uncompressed payload length + ( + compressed_pll, + self._compressed_pktnr, + uncompressed_pll, + ) = self.get_header(header) + + if uncompressed_pll == 0: + # Packet is not compressed, so just store it. + self._queue_read.append( + await super()._read_chunk(reader, compressed_pll, read_timeout) + ) + else: + # Packet comes in compressed, further action is needed. + await self._read_compressed_pkt( + reader, compressed_pll, read_timeout + ) + except IOError as err: + raise OperationalError( + errno=2055, values=(address, _strioerror(err)) + ) from err + + if not self._queue_read: + return None + + pkt = self._queue_read.popleft() + self._pktnr = pkt[3] + + return pkt + + +class MySQLSocket(ABC): + """MySQL socket communication interface. + + Examples: + Subclasses: network.MySQLTCPSocket and network.MySQLUnixSocket. + """ + + def __init__(self) -> None: + """Network layer where transactions are made with plain (uncompressed) packets + is enabled by default. + """ + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[StreamWriter] = None + self._connection_timeout: Optional[int] = None + self._address: Optional[str] = None + self._netbroker: NetworkBroker = NetworkBrokerPlain() + self._is_connected: bool = False + + @property + def address(self) -> str: + """Socket location.""" + return self._address + + @abstractmethod + async def open_connection(self, **kwargs: Any) -> None: + """Open the socket.""" + + async def close_connection(self) -> None: + """Close the connection.""" + if self._writer: + try: + self._writer.close() + # Without transport.abort(), an error is raised when using SSL + if self._writer.transport is not None: + self._writer.transport.abort() + await self._writer.wait_closed() + except Exception as _: # pylint: disable=broad-exception-caught) + # we can ignore issues like ConnectionRefused or ConnectionAborted + # as these instances might popup if the connection was closed due to timeout issues + pass + self._is_connected = False + + def is_connected(self) -> bool: + """Check if the socket is connected. + + Return: + bool: Returns `True` if the socket is connected to MySQL server. + """ + return self._is_connected + + def set_connection_timeout(self, timeout: int) -> None: + """Set the connection timeout.""" + self._connection_timeout = timeout + + def switch_to_compressed_mode(self) -> None: + """Enable network layer where transactions are made with compressed packets.""" + self._netbroker = NetworkBrokerCompressed() + + async def switch_to_ssl(self, ssl_context: ssl.SSLContext) -> None: + """Upgrade an existing stream-based connection to TLS. + + The `start_tls()` method from `asyncio.streams.StreamWriter` is only available + in Python 3.11. This method is used as a workaround. + + The MySQL TLS negotiation happens in the middle of the TCP connection. + Therefore, passing a socket to open connection will cause it to negotiate + TLS on an existing connection. + + Args: + ssl_context: The SSL Context to be used. + + Raises: + RuntimeError: If the transport does not expose the socket instance. + """ + # Ensure that self._writer is already created + assert self._writer is not None + + socket = self._writer.transport.get_extra_info("socket") + if socket.family == 1: # socket.AF_UNIX + raise ProgrammingError("SSL is not supported when using Unix sockets") + + await self._writer.start_tls(ssl_context) + + async def write( + self, + payload: bytes, + packet_number: Optional[int] = None, + compressed_packet_number: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + """Send packets to the MySQL server.""" + await self._netbroker.write( + self._writer, + self.address, + payload, + packet_number=packet_number, + compressed_packet_number=compressed_packet_number, + write_timeout=write_timeout, + ) + + async def read(self, read_timeout: Optional[int] = None) -> bytearray: + """Read packets from the MySQL server.""" + return await self._netbroker.read(self._reader, self.address, read_timeout) + + def build_ssl_context( + self, + ssl_ca: Optional[str] = None, + ssl_cert: Optional[str] = None, + ssl_key: Optional[str] = None, + ssl_verify_cert: Optional[bool] = False, + ssl_verify_identity: Optional[bool] = False, + tls_versions: Optional[List[str]] = [], + tls_cipher_suites: Optional[List[str]] = [], + ) -> ssl.SSLContext: + """Build a SSLContext.""" + tls_version: Optional[str] = None + + if not self._reader: + raise InterfaceError(errno=2048) + + if ssl is None: + raise RuntimeError("Python installation has no SSL support") + + try: + if tls_versions: + tls_versions.sort(reverse=True) + tls_version = tls_versions[0] + ssl_protocol = TLS_VERSIONS[tls_version] + context = ssl.SSLContext(ssl_protocol) + + if tls_version == "TLSv1.3": + if "TLSv1.2" not in tls_versions: + context.options |= ssl.OP_NO_TLSv1_2 + if "TLSv1.1" not in tls_versions: + context.options |= ssl.OP_NO_TLSv1_1 + if "TLSv1" not in tls_versions: + context.options |= ssl.OP_NO_TLSv1 + else: + context = ssl.create_default_context() + + context.check_hostname = ssl_verify_identity + + if ssl_verify_cert: + context.verify_mode = ssl.CERT_REQUIRED + elif ssl_verify_identity: + context.verify_mode = ssl.CERT_OPTIONAL + else: + context.verify_mode = ssl.CERT_NONE + + context.load_default_certs() + + if ssl_ca: + try: + context.load_verify_locations(ssl_ca) + except (IOError, ssl.SSLError) as err: + raise InterfaceError(f"Invalid CA Certificate: {err}") from err + if ssl_cert: + try: + context.load_cert_chain(ssl_cert, ssl_key) + except (IOError, ssl.SSLError) as err: + raise InterfaceError(f"Invalid Certificate/Key: {err}") from err + + # TLSv1.3 ciphers cannot be disabled with `SSLContext.set_ciphers(...)`, + # see https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers. + if tls_cipher_suites and tls_version == "TLSv1.2": + context.set_ciphers(":".join(tls_cipher_suites)) + + return context + except NameError as err: + raise NotSupportedError("Python installation has no SSL support") from err + except ( + IOError, + NotImplementedError, + ssl.CertificateError, + ssl.SSLError, + ) as err: + raise InterfaceError(str(err)) from err + + +class MySQLTcpSocket(MySQLSocket): + """MySQL socket class using TCP/IP. + + Args: + host: MySQL host name. + port: MySQL port. + force_ipv6: Force IPv6 usage. + """ + + def __init__( + self, host: str = "127.0.0.1", port: int = 3306, force_ipv6: bool = False + ): + super().__init__() + self._host: str = host + self._port: int = port + self._force_ipv6: bool = force_ipv6 + self._address: str = f"{host}:{port}" + + async def open_connection(self, **kwargs: Any) -> None: + """Open TCP/IP connection.""" + self._reader, self._writer = await open_connection( + host=self._host, port=self._port, **kwargs + ) + self._is_connected = True + + +class MySQLUnixSocket(MySQLSocket): + """MySQL socket class using UNIX sockets. + + Args: + unix_socket: UNIX socket file path. + """ + + def __init__(self, unix_socket: str = "/tmp/mysql.sock"): + super().__init__() + self._address: str = unix_socket + + async def open_connection(self, **kwargs: Any) -> None: + """Open UNIX socket connection.""" + ( + self._reader, + self._writer, + ) = await asyncio.open_unix_connection( # type: ignore[assignment] + path=self._address, **kwargs + ) + self._is_connected = True diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/__init__.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/__init__.py new file mode 100644 index 0000000..015700e --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/__init__.py @@ -0,0 +1,162 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Base Authentication Plugin class.""" + +__all__ = ["MySQLAuthPlugin", "get_auth_plugin"] + +import importlib + +from abc import ABC, abstractmethod +from functools import lru_cache +from typing import TYPE_CHECKING, Any, Optional, Type + +from mysql.connector.errors import NotSupportedError, ProgrammingError +from mysql.connector.logger import logger + +if TYPE_CHECKING: + from ..network import MySQLSocket + +DEFAULT_PLUGINS_PKG = "mysql.connector.aio.plugins" + + +class MySQLAuthPlugin(ABC): + """Authorization plugin interface.""" + + def __init__( + self, + username: str, + password: str, + ssl_enabled: bool = False, + ) -> None: + """Constructor.""" + self._username: str = "" if username is None else username + self._password: str = "" if password is None else password + self._ssl_enabled: bool = ssl_enabled + + @property + def ssl_enabled(self) -> bool: + """Signals whether or not SSL is enabled.""" + return self._ssl_enabled + + @property + @abstractmethod + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + + @property + @abstractmethod + def name(self) -> str: + """Plugin official name.""" + + @abstractmethod + def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]: + """Make the client's authorization response. + + Args: + auth_data: Authorization data. + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Client's authorization response. + """ + + async def auth_more_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth more data` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Authentication method data (from a packet representing + an `auth more data` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth communication. + """ + raise NotImplementedError + + @abstractmethod + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth communication. + """ + + +@lru_cache(maxsize=10, typed=False) +def get_auth_plugin( + plugin_name: str, + auth_plugin_class: Optional[str] = None, +) -> Type[MySQLAuthPlugin]: + """Return authentication class based on plugin name + + This function returns the class for the authentication plugin plugin_name. + The returned class is a subclass of BaseAuthPlugin. + + Args: + plugin_name (str): Authentication plugin name. + auth_plugin_class (str): Authentication plugin class name. + + Raises: + NotSupportedError: When plugin_name is not supported. + + Returns: + Subclass of `MySQLAuthPlugin`. + """ + package = DEFAULT_PLUGINS_PKG + if plugin_name: + try: + logger.info("package: %s", package) + logger.info("plugin_name: %s", plugin_name) + plugin_module = importlib.import_module(f".{plugin_name}", package) + if not auth_plugin_class or not hasattr(plugin_module, auth_plugin_class): + auth_plugin_class = plugin_module.AUTHENTICATION_PLUGIN_CLASS + logger.info("AUTHENTICATION_PLUGIN_CLASS: %s", auth_plugin_class) + return getattr(plugin_module, auth_plugin_class) + except ModuleNotFoundError as err: + logger.warning("Requested Module was not found: %s", err) + except ValueError as err: + raise ProgrammingError(f"Invalid module name: {err}") from err + raise NotSupportedError(f"Authentication plugin '{plugin_name}' is not supported") diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_kerberos_client.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_kerberos_client.py new file mode 100644 index 0000000..73dc753 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_kerberos_client.py @@ -0,0 +1,577 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="str-bytes-safe,misc" + +"""Kerberos Authentication Plugin.""" + +import getpass +import os +import struct + +from abc import abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, Any, Optional, Tuple + +from mysql.connector.errors import InterfaceError, ProgrammingError +from mysql.connector.logger import logger + +from ..authentication import ERR_STATUS + +if TYPE_CHECKING: + from ..network import MySQLSocket + +try: + import gssapi +except ImportError: + gssapi = None + if os.name != "nt": + raise ProgrammingError( + "Module gssapi is required for GSSAPI authentication " + "mechanism but was not found. Unable to authenticate " + "with the server" + ) from None + +try: + import sspi + import sspicon +except ImportError: + sspi = None + sspicon = None + +from . import MySQLAuthPlugin + +AUTHENTICATION_PLUGIN_CLASS = ( + "MySQLSSPIKerberosAuthPlugin" if os.name == "nt" else "MySQLKerberosAuthPlugin" +) + + +class MySQLBaseKerberosAuthPlugin(MySQLAuthPlugin): + """Base class for the MySQL Kerberos authentication plugin.""" + + @property + def name(self) -> str: + """Plugin official name.""" + return "authentication_kerberos_client" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return False + + @abstractmethod + def auth_continue( + self, tgt_auth_challenge: Optional[bytes] + ) -> Tuple[Optional[bytes], bool]: + """Continue with the Kerberos TGT service request. + + With the TGT authentication service given response generate a TGT + service request. This method must be invoked sequentially (in a loop) + until the security context is completed and an empty response needs to + be send to acknowledge the server. + + Args: + tgt_auth_challenge: the challenge for the negotiation. + + Returns: + tuple (bytearray TGS service request, + bool True if context is completed otherwise False). + """ + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + logger.debug("# auth_data: %s", auth_data) + response = self.auth_response(auth_data, ignore_auth_data=False, **kwargs) + if response is None: + raise InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + packet = await sock.read() + logger.debug("# server response packet: %s", packet) + + if packet != ERR_STATUS: + rcode_size = 5 # Reader size for the response status code + logger.debug("# Continue with GSSAPI authentication") + logger.debug("# Response header: %s", packet[: rcode_size + 1]) + logger.debug("# Response size: %s", len(packet)) + logger.debug("# Negotiate a service request") + complete = False + tries = 0 + + while not complete and tries < 5: + logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20) + logger.debug("<< Server response: %s", packet) + logger.debug("# Response code: %s", packet[: rcode_size + 1]) + token, complete = self.auth_continue(packet[rcode_size:]) + if token: + await sock.write(token) + if complete: + break + packet = await sock.read() + + logger.debug(">> Response to server: %s", token) + tries += 1 + + if not complete: + raise InterfaceError( + f"Unable to fulfill server request after {tries} " + f"attempts. Last server response: {packet}" + ) + + logger.debug( + "Last response from server: %s length: %d", + packet, + len(packet), + ) + + # Receive OK packet from server. + packet = await sock.read() + logger.debug("<< Ok packet from server: %s", packet) + + return bytes(packet) + + +# pylint: disable=c-extension-no-member,no-member +class MySQLKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin): + """Implement the MySQL Kerberos authentication plugin.""" + + context: Optional[gssapi.SecurityContext] = None + + @staticmethod + def get_user_from_credentials() -> str: + """Get user from credentials without realm.""" + try: + creds = gssapi.Credentials(usage="initiate") + user = str(creds.name) + if user.find("@") != -1: + user, _ = user.split("@", 1) + return user + except gssapi.raw.misc.GSSError: + return getpass.getuser() + + @staticmethod + def get_store() -> dict: + """Get a credentials store dictionary. + + Returns: + dict: Credentials store dictionary with the krb5 ccache name. + + Raises: + InterfaceError: If 'KRB5CCNAME' environment variable is empty. + """ + krb5ccname = os.environ.get( + "KRB5CCNAME", + ( + f"/tmp/krb5cc_{os.getuid()}" + if os.name == "posix" + else Path("%TEMP%").joinpath("krb5cc") + ), + ) + if not krb5ccname: + raise InterfaceError( + "The 'KRB5CCNAME' environment variable is set to empty" + ) + logger.debug("Using krb5 ccache name: FILE:%s", krb5ccname) + store = {b"ccache": f"FILE:{krb5ccname}".encode("utf-8")} + return store + + def _acquire_cred_with_password(self, upn: str) -> gssapi.raw.creds.Creds: + """Acquire and store credentials through provided password. + + Args: + upn (str): User Principal Name. + + Returns: + gssapi.raw.creds.Creds: GSSAPI credentials. + """ + logger.debug("Attempt to acquire credentials through provided password") + user = gssapi.Name(upn, gssapi.NameType.user) + password = self._password.encode("utf-8") + + try: + acquire_cred_result = gssapi.raw.acquire_cred_with_password( + user, password, usage="initiate" + ) + creds = acquire_cred_result.creds + gssapi.raw.store_cred_into( + self.get_store(), + creds=creds, + mech=gssapi.MechType.kerberos, + overwrite=True, + set_default=True, + ) + except gssapi.raw.misc.GSSError as err: + raise ProgrammingError( + f"Unable to acquire credentials with the given password: {err}" + ) from err + return creds + + @staticmethod + def _parse_auth_data(packet: bytes) -> Tuple[str, str]: + """Parse authentication data. + + Get the SPN and REALM from the authentication data packet. + + Format: + SPN string length two bytes + + SPN string + + UPN realm string length two bytes + + UPN realm string + + Returns: + tuple: With 'spn' and 'realm'. + """ + spn_len = struct.unpack(" Optional[bytes]: + """Prepare the first message to the server.""" + spn = None + realm = None + + if auth_data and not kwargs.get("ignore_auth_data", True): + try: + spn, realm = self._parse_auth_data(auth_data) + except struct.error as err: + raise InterruptedError(f"Invalid authentication data: {err}") from err + + if spn is None: + return self._password.encode() + b"\x00" + + upn = f"{self._username}@{realm}" if self._username else None + + logger.debug("Service Principal: %s", spn) + logger.debug("Realm: %s", realm) + + try: + # Attempt to retrieve credentials from cache file + creds: Any = gssapi.Credentials(usage="initiate") + creds_upn = str(creds.name) + + logger.debug("Cached credentials found") + logger.debug("Cached credentials UPN: %s", creds_upn) + + # Remove the realm from user + if creds_upn.find("@") != -1: + creds_user, creds_realm = creds_upn.split("@", 1) + else: + creds_user = creds_upn + creds_realm = None + + upn = f"{self._username}@{realm}" if self._username else creds_upn + + # The user from cached credentials matches with the given user? + if self._username and self._username != creds_user: + logger.debug( + "The user from cached credentials doesn't match with the " + "given user" + ) + if self._password is not None: + creds = self._acquire_cred_with_password(upn) + if creds_realm and creds_realm != realm and self._password is not None: + creds = self._acquire_cred_with_password(upn) + except gssapi.raw.exceptions.ExpiredCredentialsError as err: + if upn and self._password is not None: + creds = self._acquire_cred_with_password(upn) + else: + raise InterfaceError(f"Credentials has expired: {err}") from err + except gssapi.raw.misc.GSSError as err: + if upn and self._password is not None: + creds = self._acquire_cred_with_password(upn) + else: + raise InterfaceError( + f"Unable to retrieve cached credentials error: {err}" + ) from err + + flags = ( + gssapi.RequirementFlag.mutual_authentication, + gssapi.RequirementFlag.extended_error, + gssapi.RequirementFlag.delegate_to_peer, + ) + name = gssapi.Name(spn, name_type=gssapi.NameType.kerberos_principal) + cname = name.canonicalize(gssapi.MechType.kerberos) + self.context = gssapi.SecurityContext( + name=cname, creds=creds, flags=sum(flags), usage="initiate" + ) + + try: + initial_client_token: Optional[bytes] = self.context.step() + except gssapi.raw.misc.GSSError as err: + raise InterfaceError(f"Unable to initiate security context: {err}") from err + + logger.debug("Initial client token: %s", initial_client_token) + return initial_client_token + + def auth_continue( + self, tgt_auth_challenge: Optional[bytes] + ) -> Tuple[Optional[bytes], bool]: + """Continue with the Kerberos TGT service request. + + With the TGT authentication service given response generate a TGT + service request. This method must be invoked sequentially (in a loop) + until the security context is completed and an empty response needs to + be send to acknowledge the server. + + Args: + tgt_auth_challenge: the challenge for the negotiation. + + Returns: + tuple (bytearray TGS service request, + bool True if context is completed otherwise False). + """ + logger.debug("tgt_auth challenge: %s", tgt_auth_challenge) + + resp: Optional[bytes] = self.context.step(tgt_auth_challenge) + + logger.debug("Context step response: %s", resp) + logger.debug("Context completed?: %s", self.context.complete) + + return resp, self.context.complete + + def auth_accept_close_handshake(self, message: bytes) -> bytes: + """Accept handshake and generate closing handshake message for server. + + This method verifies the server authenticity from the given message + and included signature and generates the closing handshake for the + server. + + When this method is invoked the security context is already established + and the client and server can send GSSAPI formated secure messages. + + To finish the authentication handshake the server sends a message + with the security layer availability and the maximum buffer size. + + Since the connector only uses the GSSAPI authentication mechanism to + authenticate the user with the server, the server will verify clients + message signature and terminate the GSSAPI authentication and send two + messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a + OK packet (that must be received after sent the returned message from + this method). + + Args: + message: a wrapped gssapi message from the server. + + Returns: + bytearray (closing handshake message to be send to the server). + """ + if not self.context.complete: + raise ProgrammingError("Security context is not completed") + logger.debug("Server message: %s", message) + logger.debug("GSSAPI flags in use: %s", self.context.actual_flags) + try: + unwraped = self.context.unwrap(message) + logger.debug("Unwraped: %s", unwraped) + except gssapi.raw.exceptions.BadMICError as err: + logger.debug("Unable to unwrap server message: %s", err) + raise InterfaceError(f"Unable to unwrap server message: {err}") from err + + logger.debug("Unwrapped server message: %s", unwraped) + # The message contents for the clients closing message: + # - security level 1 byte, must be always 1. + # - conciliated buffer size 3 bytes, without importance as no + # further GSSAPI messages will be sends. + response = bytearray(b"\x01\x00\x00\00") + # Closing handshake must not be encrypted. + logger.debug("Message response: %s", response) + wraped = self.context.wrap(response, encrypt=False) + logger.debug( + "Wrapped message response: %s, length: %d", + wraped[0], + len(wraped[0]), + ) + + return wraped.message + + +class MySQLSSPIKerberosAuthPlugin(MySQLBaseKerberosAuthPlugin): + """Implement the MySQL Kerberos authentication plugin with Windows SSPI""" + + context: Any = None + clientauth: Any = None + + @staticmethod + def _parse_auth_data(packet: bytes) -> Tuple[str, str]: + """Parse authentication data. + + Get the SPN and REALM from the authentication data packet. + + Format: + SPN string length two bytes + + SPN string + + UPN realm string length two bytes + + UPN realm string + + Returns: + tuple: With 'spn' and 'realm'. + """ + spn_len = struct.unpack(" Optional[bytes]: + """Prepare the first message to the server. + + Args: + kwargs: + ignore_auth_data (bool): if True, the provided auth data is ignored. + """ + logger.debug("auth_response for sspi") + spn = None + realm = None + + if auth_data and not kwargs.get("ignore_auth_data", True): + try: + spn, realm = self._parse_auth_data(auth_data) + except struct.error as err: + raise InterruptedError(f"Invalid authentication data: {err}") from err + + logger.debug("Service Principal: %s", spn) + logger.debug("Realm: %s", realm) + + if sspicon is None or sspi is None: + raise ProgrammingError( + 'Package "pywin32" (Python for Win32 (pywin32) extensions)' + " is not installed." + ) + + flags = (sspicon.ISC_REQ_MUTUAL_AUTH, sspicon.ISC_REQ_DELEGATE) + + if self._username and self._password: + _auth_info = (self._username, realm, self._password) + else: + _auth_info = None + + targetspn = spn + logger.debug("targetspn: %s", targetspn) + logger.debug("_auth_info is None: %s", _auth_info is None) + + # The Security Support Provider Interface (SSPI) is an interface + # that allows us to choose from a set of SSPs available in the + # system; the idea of SSPI is to keep interface consistent no + # matter what back end (a.k.a., SSP) we choose. + + # When using SSPI we should not use Kerberos directly as SSP, + # as remarked in [2], but we can use it indirectly via another + # SSP named Negotiate that acts as an application layer between + # SSPI and the other SSPs [1]. + + # Negotiate can select between Kerberos and NTLM on the fly; + # it chooses Kerberos unless it cannot be used by one of the + # systems involved in the authentication or the calling + # application did not provide sufficient information to use + # Kerberos. + + # prefix: https://docs.microsoft.com/en-us/windows/win32/secauthn + # [1] prefix/microsoft-negotiate?source=recommendations + # [2] prefix/microsoft-kerberos?source=recommendations + self.clientauth = sspi.ClientAuth( + "Negotiate", + targetspn=targetspn, + auth_info=_auth_info, + scflags=sum(flags), + datarep=sspicon.SECURITY_NETWORK_DREP, + ) + + try: + data = None + err, out_buf = self.clientauth.authorize(data) + logger.debug("Context step err: %s", err) + logger.debug("Context step out_buf: %s", out_buf) + logger.debug("Context completed?: %s", self.clientauth.authenticated) + initial_client_token = out_buf[0].Buffer + logger.debug("pkg_info: %s", self.clientauth.pkg_info) + except Exception as err: + raise InterfaceError(f"Unable to initiate security context: {err}") from err + + logger.debug("Initial client token: %s", initial_client_token) + return initial_client_token + + def auth_continue( + self, tgt_auth_challenge: Optional[bytes] + ) -> Tuple[Optional[bytes], bool]: + """Continue with the Kerberos TGT service request. + + With the TGT authentication service given response generate a TGT + service request. This method must be invoked sequentially (in a loop) + until the security context is completed and an empty response needs to + be send to acknowledge the server. + + Args: + tgt_auth_challenge: the challenge for the negotiation. + + Returns: + tuple (bytearray TGS service request, + bool True if context is completed otherwise False). + """ + logger.debug("tgt_auth challenge: %s", tgt_auth_challenge) + + err, out_buf = self.clientauth.authorize(tgt_auth_challenge) + + logger.debug("Context step err: %s", err) + logger.debug("Context step out_buf: %s", out_buf) + resp = out_buf[0].Buffer + logger.debug("Context step resp: %s", resp) + logger.debug("Context completed?: %s", self.clientauth.authenticated) + + return resp, self.clientauth.authenticated diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_ldap_sasl_client.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_ldap_sasl_client.py new file mode 100644 index 0000000..cf4d15b --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_ldap_sasl_client.py @@ -0,0 +1,595 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""LDAP SASL Authentication Plugin.""" + +import hmac + +from base64 import b64decode, b64encode +from hashlib import sha1, sha256 +from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple +from uuid import uuid4 + +from mysql.connector.authentication import ERR_STATUS +from mysql.connector.errors import InterfaceError, ProgrammingError +from mysql.connector.logger import logger +from mysql.connector.types import StrOrBytes +from mysql.connector.utils import ( + normalize_unicode_string as norm_ustr, + validate_normalized_unicode_string as valid_norm, +) + +if TYPE_CHECKING: + from ..network import MySQLSocket + +try: + import gssapi +except ImportError: + raise ProgrammingError( + "Module gssapi is required for GSSAPI authentication " + "mechanism but was not found. Unable to authenticate " + "with the server" + ) from None + +from . import MySQLAuthPlugin + +AUTHENTICATION_PLUGIN_CLASS = "MySQLLdapSaslPasswordAuthPlugin" + + +# pylint: disable=c-extension-no-member,no-member +class MySQLLdapSaslPasswordAuthPlugin(MySQLAuthPlugin): + """Class implementing the MySQL ldap sasl authentication plugin. + + The MySQL's ldap sasl authentication plugin support two authentication + methods SCRAM-SHA-1 and GSSAPI (using Kerberos). This implementation only + support SCRAM-SHA-1 and SCRAM-SHA-256. + + SCRAM-SHA-1 amd SCRAM-SHA-256 + This method requires 2 messages from client and 2 responses from + server. + + The first message from client will be generated by prepare_password(), + after receive the response from the server, it is required that this + response is passed back to auth_continue() which will return the + second message from the client. After send this second message to the + server, the second server respond needs to be passed to auth_finalize() + to finish the authentication process. + """ + + sasl_mechanisms: List[str] = ["SCRAM-SHA-1", "SCRAM-SHA-256", "GSSAPI"] + def_digest_mode: Callable = sha1 + client_nonce: Optional[str] = None + client_salt: Any = None + server_salt: Optional[str] = None + krb_service_principal: Optional[str] = None + iterations: int = 0 + server_auth_var: Optional[str] = None + target_name: Optional[gssapi.Name] = None + ctx: gssapi.SecurityContext = None + servers_first: Optional[str] = None + server_nonce: Optional[str] = None + + @staticmethod + def _xor(bytes1: bytes, bytes2: bytes) -> bytes: + return bytes([b1 ^ b2 for b1, b2 in zip(bytes1, bytes2)]) + + def _hmac(self, password: bytes, salt: bytes) -> bytes: + digest_maker = hmac.new(password, salt, self.def_digest_mode) + return digest_maker.digest() + + def _hi(self, password: str, salt: bytes, count: int) -> bytes: + """Prepares Hi + Hi(password, salt, iterations) where Hi(p,s,i) is defined as + PBKDF2 (HMAC, p, s, i, output length of H). + """ + pw = password.encode() + hi = self._hmac(pw, salt + b"\x00\x00\x00\x01") + aux = hi + for _ in range(count - 1): + aux = self._hmac(pw, aux) + hi = self._xor(hi, aux) + return hi + + @staticmethod + def _normalize(string: str) -> str: + norm_str = norm_ustr(string) + broken_rule = valid_norm(norm_str) + if broken_rule is not None: + raise InterfaceError(f"broken_rule: {broken_rule}") + return norm_str + + def _first_message(self) -> bytes: + """This method generates the first message to the server to start the + + The client-first message consists of a gs2-header, + the desired username, and a randomly generated client nonce cnonce. + + The first message from the server has the form: + b'n,a=,n=,r= + + Returns client's first message + """ + cfm_fprnat = "n,a={user_name},n={user_name},r={client_nonce}" + self.client_nonce = str(uuid4()).replace("-", "") + cfm: StrOrBytes = cfm_fprnat.format( + user_name=self._normalize(self._username), + client_nonce=self.client_nonce, + ) + + if isinstance(cfm, str): + cfm = cfm.encode("utf8") + return cfm + + def _first_message_krb(self) -> Optional[bytes]: + """Get a TGT Authentication request and initiates security context. + + This method will contact the Kerberos KDC in order of obtain a TGT. + """ + user_name = gssapi.raw.names.import_name( + self._username.encode("utf8"), name_type=gssapi.NameType.user + ) + + # Use defaults store = {'ccache': 'FILE:/tmp/krb5cc_1000'}#, + # 'keytab':'/etc/some.keytab' } + # Attempt to retrieve credential from default cache file. + try: + cred: Any = gssapi.Credentials() + logger.debug( + "# Stored credentials found, if password was given it will be ignored." + ) + try: + # validate credentials has not expired. + cred.lifetime + except gssapi.raw.exceptions.ExpiredCredentialsError as err: + logger.warning(" Credentials has expired: %s", err) + cred.acquire(user_name) + raise InterfaceError(f"Credentials has expired: {err}") from err + except gssapi.raw.misc.GSSError as err: + if not self._password: + raise InterfaceError( + f"Unable to retrieve stored credentials error: {err}" + ) from err + try: + logger.debug("# Attempt to retrieve credentials with given password") + acquire_cred_result = gssapi.raw.acquire_cred_with_password( + user_name, + self._password.encode("utf8"), + usage="initiate", + ) + cred = acquire_cred_result[0] + except gssapi.raw.misc.GSSError as err2: + raise ProgrammingError( + f"Unable to retrieve credentials with the given password: {err2}" + ) from err + + flags_l = ( + gssapi.RequirementFlag.mutual_authentication, + gssapi.RequirementFlag.extended_error, + gssapi.RequirementFlag.delegate_to_peer, + ) + + if self.krb_service_principal: + service_principal = self.krb_service_principal + else: + service_principal = "ldap/ldapauth" + logger.debug("# service principal: %s", service_principal) + servk = gssapi.Name( + service_principal, name_type=gssapi.NameType.kerberos_principal + ) + self.target_name = servk + self.ctx = gssapi.SecurityContext( + name=servk, creds=cred, flags=sum(flags_l), usage="initiate" + ) + + try: + # step() returns bytes | None, see documentation, + # so this method could return a NULL payload. + # ref: https://pythongssapi.github.io/ + # suffix: python-gssapi/latest/gssapi.html#gssapi.sec_contexts.SecurityContext + initial_client_token = self.ctx.step() + except gssapi.raw.misc.GSSError as err: + raise InterfaceError(f"Unable to initiate security context: {err}") from err + + logger.debug("# initial client token: %s", initial_client_token) + return initial_client_token + + def auth_continue_krb( + self, tgt_auth_challenge: Optional[bytes] + ) -> Tuple[Optional[bytes], bool]: + """Continue with the Kerberos TGT service request. + + With the TGT authentication service given response generate a TGT + service request. This method must be invoked sequentially (in a loop) + until the security context is completed and an empty response needs to + be send to acknowledge the server. + + Args: + tgt_auth_challenge the challenge for the negotiation. + + Returns: tuple (bytearray TGS service request, + bool True if context is completed otherwise False). + """ + logger.debug("tgt_auth challenge: %s", tgt_auth_challenge) + + resp = self.ctx.step(tgt_auth_challenge) + logger.debug("# context step response: %s", resp) + logger.debug("# context completed?: %s", self.ctx.complete) + + return resp, self.ctx.complete + + def auth_accept_close_handshake(self, message: bytes) -> bytes: + """Accept handshake and generate closing handshake message for server. + + This method verifies the server authenticity from the given message + and included signature and generates the closing handshake for the + server. + + When this method is invoked the security context is already established + and the client and server can send GSSAPI formated secure messages. + + To finish the authentication handshake the server sends a message + with the security layer availability and the maximum buffer size. + + Since the connector only uses the GSSAPI authentication mechanism to + authenticate the user with the server, the server will verify clients + message signature and terminate the GSSAPI authentication and send two + messages; an authentication acceptance b'\x01\x00\x00\x08\x01' and a + OK packet (that must be received after sent the returned message from + this method). + + Args: + message a wrapped hssapi message from the server. + + Returns: bytearray closing handshake message to be send to the server. + """ + if not self.ctx.complete: + raise ProgrammingError("Security context is not completed.") + logger.debug("# servers message: %s", message) + logger.debug("# GSSAPI flags in use: %s", self.ctx.actual_flags) + try: + unwraped = self.ctx.unwrap(message) + logger.debug("# unwraped: %s", unwraped) + except gssapi.raw.exceptions.BadMICError as err: + raise InterfaceError(f"Unable to unwrap server message: {err}") from err + + logger.debug("# unwrapped server message: %s", unwraped) + # The message contents for the clients closing message: + # - security level 1 byte, must be always 1. + # - conciliated buffer size 3 bytes, without importance as no + # further GSSAPI messages will be sends. + response = bytearray(b"\x01\x00\x00\00") + # Closing handshake must not be encrypted. + logger.debug("# message response: %s", response) + wraped = self.ctx.wrap(response, encrypt=False) + logger.debug( + "# wrapped message response: %s, length: %d", + wraped[0], + len(wraped[0]), + ) + + return wraped.message + + def auth_response( + self, + auth_data: bytes, + **kwargs: Any, + ) -> Optional[bytes]: + """This method will prepare the fist message to the server. + + Returns bytes to send to the server as the first message. + """ + # pylint: disable=attribute-defined-outside-init + self._auth_data = auth_data + + auth_mechanism = self._auth_data.decode() + logger.debug("read_method_name_from_server: %s", auth_mechanism) + if auth_mechanism not in self.sasl_mechanisms: + auth_mechanisms = '", "'.join(self.sasl_mechanisms[:-1]) + raise InterfaceError( + f'The sasl authentication method "{auth_mechanism}" requested ' + f'from the server is not supported. Only "{auth_mechanisms}" ' + f'and "{self.sasl_mechanisms[-1]}" are supported' + ) + + if b"GSSAPI" in self._auth_data: + return self._first_message_krb() + + if self._auth_data == b"SCRAM-SHA-256": + self.def_digest_mode = sha256 + + return self._first_message() + + def _second_message(self) -> bytes: + """This method generates the second message to the server + + Second message consist on the concatenation of the client and the + server nonce, and cproof. + + c=>,r=,p= + where: + : xor(, ) + + : hmac(salted_password, b"Client Key") + : hmac(, ) + : h() + : ,, + c=,r= + : n=r= + """ + if not self._auth_data: + raise InterfaceError("Missing authentication data (seed)") + + passw = self._normalize(self._password) + salted_password = self._hi(passw, b64decode(self.server_salt), self.iterations) + logger.debug("salted_password: %s", b64encode(salted_password).decode()) + + client_key = self._hmac(salted_password, b"Client Key") + logger.debug("client_key: %s", b64encode(client_key).decode()) + + stored_key = self.def_digest_mode(client_key).digest() + logger.debug("stored_key: %s", b64encode(stored_key).decode()) + + server_key = self._hmac(salted_password, b"Server Key") + logger.debug("server_key: %s", b64encode(server_key).decode()) + + client_first_no_header = ",".join( + [ + f"n={self._normalize(self._username)}", + f"r={self.client_nonce}", + ] + ) + logger.debug("client_first_no_header: %s", client_first_no_header) + + client_header = b64encode( + f"n,a={self._normalize(self._username)},".encode() + ).decode() + + auth_msg = ",".join( + [ + client_first_no_header, + self.servers_first, + f"c={client_header}", + f"r={self.server_nonce}", + ] + ) + logger.debug("auth_msg: %s", auth_msg) + + client_signature = self._hmac(stored_key, auth_msg.encode()) + logger.debug("client_signature: %s", b64encode(client_signature).decode()) + + client_proof = self._xor(client_key, client_signature) + logger.debug("client_proof: %s", b64encode(client_proof).decode()) + + self.server_auth_var = b64encode( + self._hmac(server_key, auth_msg.encode()) + ).decode() + logger.debug("server_auth_var: %s", self.server_auth_var) + + msg = ",".join( + [ + f"c={client_header}", + f"r={self.server_nonce}", + f"p={b64encode(client_proof).decode()}", + ] + ) + logger.debug("second_message: %s", msg) + return msg.encode() + + def _validate_first_reponse(self, servers_first: bytes) -> None: + """Validates first message from the server. + + Extracts the server's salt and iterations from the servers 1st response. + First message from the server is in the form: + ,i= + """ + if not servers_first or not isinstance(servers_first, (bytearray, bytes)): + raise InterfaceError(f"Unexpected server message: {repr(servers_first)}") + try: + servers_first_str = servers_first.decode() + self.servers_first = servers_first_str + r_server_nonce, s_salt, i_counter = servers_first_str.split(",") + except ValueError: + raise InterfaceError( + f"Unexpected server message: {servers_first_str}" + ) from None + if ( + not r_server_nonce.startswith("r=") + or not s_salt.startswith("s=") + or not i_counter.startswith("i=") + ): + raise InterfaceError( + f"Incomplete reponse from the server: {servers_first_str}" + ) + if self.client_nonce in r_server_nonce: + self.server_nonce = r_server_nonce[2:] + logger.debug("server_nonce: %s", self.server_nonce) + else: + raise InterfaceError( + "Unable to authenticate response: response not well formed " + f"{servers_first_str}" + ) + self.server_salt = s_salt[2:] + logger.debug( + "server_salt: %s length: %s", + self.server_salt, + len(self.server_salt), + ) + try: + i_counter = i_counter[2:] + logger.debug("iterations: %s", i_counter) + self.iterations = int(i_counter) + except Exception as err: + raise InterfaceError( + f"Unable to authenticate: iterations not found {servers_first_str}" + ) from err + + def auth_continue(self, servers_first_response: bytes) -> bytes: + """return the second message from the client. + + Returns bytes to send to the server as the second message. + """ + self._validate_first_reponse(servers_first_response) + return self._second_message() + + def _validate_second_reponse(self, servers_second: bytearray) -> bool: + """Validates second message from the server. + + The client and the server prove to each other they have the same Auth + variable. + + The second message from the server consist of the server's proof: + server_proof = HMAC(, ) + where: + : hmac(, b"Server Key") + : ,, + c=,r= + + Our server_proof must be equal to the Auth variable send on this second + response. + """ + if ( + not servers_second + or not isinstance(servers_second, bytearray) + or len(servers_second) <= 2 + or not servers_second.startswith(b"v=") + ): + raise InterfaceError("The server's proof is not well formated") + server_var = servers_second[2:].decode() + logger.debug("server auth variable: %s", server_var) + return self.server_auth_var == server_var + + def auth_finalize(self, servers_second_response: bytearray) -> bool: + """finalize the authentication process. + + Raises InterfaceError if the ervers_second_response is invalid. + + Returns True in successful authentication False otherwise. + """ + if not self._validate_second_reponse(servers_second_response): + raise InterfaceError( + "Authentication failed: Unable to proof server identity" + ) + return True + + @property + def name(self) -> str: + """Plugin official name.""" + return "authentication_ldap_sasl_client" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return False + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + logger.debug("# auth_data: %s", auth_data) + self.krb_service_principal = kwargs.get("krb_service_principal") + + response = self.auth_response(auth_data, **kwargs) + if response is None: + raise InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + packet = await sock.read() + logger.debug("# server response packet: %s", packet) + + if len(packet) >= 6 and packet[5] == 114 and packet[6] == 61: # 'r' and '=' + # Continue with sasl authentication + dec_response = packet[5:] + cresponse = self.auth_continue(dec_response) + await sock.write(cresponse) + packet = await sock.read() + if packet[5] == 118 and packet[6] == 61: # 'v' and '=' + if self.auth_finalize(packet[5:]): + # receive packed OK + packet = await sock.read() + elif auth_data == b"GSSAPI" and packet[4] != ERR_STATUS: + rcode_size = 5 # header size for the response status code. + logger.debug("# Continue with sasl GSSAPI authentication") + logger.debug("# response header: %s", packet[: rcode_size + 1]) + logger.debug("# response size: %s", len(packet)) + + logger.debug("# Negotiate a service request") + complete = False + tries = 0 # To avoid a infinite loop attempt no more than feedback messages + while not complete and tries < 5: + logger.debug("%s Attempt %s %s", "-" * 20, tries + 1, "-" * 20) + logger.debug("<< server response: %s", packet) + logger.debug("# response code: %s", packet[: rcode_size + 1]) + step, complete = self.auth_continue_krb(packet[rcode_size:]) + logger.debug(" >> response to server: %s", step) + await sock.write(step or b"") + packet = await sock.read() + tries += 1 + if not complete: + raise InterfaceError( + f"Unable to fulfill server request after {tries} " + f"attempts. Last server response: {packet}" + ) + logger.debug( + " last GSSAPI response from server: %s length: %d", + packet, + len(packet), + ) + last_step = self.auth_accept_close_handshake(packet[rcode_size:]) + logger.debug( + " >> last response to server: %s length: %d", + last_step, + len(last_step), + ) + await sock.write(last_step) + # Receive final handshake from server + packet = await sock.read() + logger.debug("<< final handshake from server: %s", packet) + + # receive OK packet from server. + packet = await sock.read() + logger.debug("<< ok packet from server: %s", packet) + + return bytes(packet) + + +# pylint: enable=c-extension-no-member,no-member diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_oci_client.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_oci_client.py new file mode 100644 index 0000000..4237eb9 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_oci_client.py @@ -0,0 +1,234 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="arg-type,union-attr,call-arg" + +"""OCI Authentication Plugin.""" + +import json +import os + +from base64 import b64encode +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Optional + +from mysql.connector import errors +from mysql.connector.logger import logger + +if TYPE_CHECKING: + from ..network import MySQLSocket + +try: + from cryptography.exceptions import UnsupportedAlgorithm + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives.asymmetric.types import PRIVATE_KEY_TYPES +except ImportError: + raise errors.ProgrammingError("Package 'cryptography' is not installed") from None + +try: + from oci import config, exceptions +except ImportError: + raise errors.ProgrammingError( + "Package 'oci' (Oracle Cloud Infrastructure Python SDK) is not installed" + ) from None + +from . import MySQLAuthPlugin + +AUTHENTICATION_PLUGIN_CLASS = "MySQLOCIAuthPlugin" +OCI_SECURITY_TOKEN_MAX_SIZE = 10 * 1024 # In bytes +OCI_SECURITY_TOKEN_TOO_LARGE = "Ephemeral security token is too large (10KB max)" +OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE = ( + "Ephemeral security token file ('security_token_file') could not be read" +) +OCI_PROFILE_MISSING_PROPERTIES = ( + "OCI configuration file does not contain a 'fingerprint' or 'key_file' entry" +) + + +class MySQLOCIAuthPlugin(MySQLAuthPlugin): + """Implement the MySQL OCI IAM authentication plugin.""" + + context: Any = None + oci_config_profile: str = "DEFAULT" + oci_config_file: str = config.DEFAULT_LOCATION + + @staticmethod + def _prepare_auth_response(signature: bytes, oci_config: Dict[str, Any]) -> str: + """Prepare client's authentication response + + Prepares client's authentication response in JSON format + Args: + signature (bytes): server's nonce to be signed by client. + oci_config (dict): OCI configuration object. + + Returns: + str: JSON string with the following format: + {"fingerprint": str, "signature": str, "token": base64.base64.base64} + + Raises: + ProgrammingError: If the ephemeral security token file can't be open or the + token is too large. + """ + signature_64 = b64encode(signature) + auth_response = { + "fingerprint": oci_config["fingerprint"], + "signature": signature_64.decode(), + } + + # The security token, if it exists, should be a JWT (JSON Web Token), consisted + # of a base64-encoded header, body, and signature, separated by '.', + # e.g. "Base64.Base64.Base64", stored in a file at the path specified by the + # security_token_file configuration property + if oci_config.get("security_token_file"): + try: + security_token_file = Path(oci_config["security_token_file"]) + # Check if token exceeds the maximum size + if security_token_file.stat().st_size > OCI_SECURITY_TOKEN_MAX_SIZE: + raise errors.ProgrammingError(OCI_SECURITY_TOKEN_TOO_LARGE) + auth_response["token"] = security_token_file.read_text(encoding="utf-8") + except (OSError, UnicodeError) as err: + raise errors.ProgrammingError( + OCI_SECURITY_TOKEN_FILE_NOT_AVAILABLE + ) from err + return json.dumps(auth_response, separators=(",", ":")) + + @staticmethod + def _get_private_key(key_path: str) -> PRIVATE_KEY_TYPES: + """Get the private_key form the given location""" + try: + with open(os.path.expanduser(key_path), "rb") as key_file: + private_key = serialization.load_pem_private_key( + key_file.read(), + password=None, + ) + except (TypeError, OSError, ValueError, UnsupportedAlgorithm) as err: + raise errors.ProgrammingError( + "An error occurred while reading the API_KEY from " + f'"{key_path}": {err}' + ) + + return private_key + + def _get_valid_oci_config(self) -> Dict[str, Any]: + """Get a valid OCI config from the given configuration file path""" + error_list = [] + req_keys = { + "fingerprint": (lambda x: len(x) > 32), + "key_file": (lambda x: os.path.exists(os.path.expanduser(x))), + } + + oci_config: Dict[str, Any] = {} + try: + # key_file is validated by oci.config if present + oci_config = config.from_file( + self.oci_config_file or config.DEFAULT_LOCATION, + self.oci_config_profile or "DEFAULT", + ) + for req_key, req_value in req_keys.items(): + try: + # Verify parameter in req_key is present and valid + if oci_config[req_key] and not req_value(oci_config[req_key]): + error_list.append(f'Parameter "{req_key}" is invalid') + except KeyError: + error_list.append(f"Does not contain parameter {req_key}") + except ( + exceptions.ConfigFileNotFound, + exceptions.InvalidConfig, + exceptions.InvalidKeyFilePath, + exceptions.InvalidPrivateKey, + exceptions.ProfileNotFound, + ) as err: + error_list.append(str(err)) + + # Raise errors if any + if error_list: + raise errors.ProgrammingError( + f"Invalid oci-config-file: {self.oci_config_file}. " + f"Errors found: {error_list}" + ) + + return oci_config + + @property + def name(self) -> str: + """Plugin official name.""" + return "authentication_oci_client" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return False + + def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]: + """Prepare authentication string for the server.""" + logger.debug("server nonce: %s, len %d", auth_data, len(auth_data)) + + oci_config = self._get_valid_oci_config() + + private_key = self._get_private_key(oci_config["key_file"]) + signature = private_key.sign(auth_data, padding.PKCS1v15(), hashes.SHA256()) + + auth_response = self._prepare_auth_response(signature, oci_config) + logger.debug("authentication response: %s", auth_response) + return auth_response.encode() + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + self.oci_config_file = kwargs.get("oci_config_file", "DEFAULT") + self.oci_config_profile = kwargs.get( + "oci_config_profile", config.DEFAULT_LOCATION + ) + logger.debug("# oci configuration file path: %s", self.oci_config_file) + + response = self.auth_response(auth_data, **kwargs) + if response is None: + raise errors.InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + packet = await sock.read() + logger.debug("# server response packet: %s", packet) + + return bytes(packet) diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_openid_connect_client.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_openid_connect_client.py new file mode 100644 index 0000000..87ee74b --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_openid_connect_client.py @@ -0,0 +1,172 @@ +# Copyright (c) 2024, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""OpenID Authentication Plugin.""" + +import re + +from pathlib import Path +from typing import Any, List + +from mysql.connector import errors, utils +from mysql.connector.aio.network import MySQLSocket +from mysql.connector.logger import logger + +from . import MySQLAuthPlugin + +AUTHENTICATION_PLUGIN_CLASS = "MySQLOpenIDConnectAuthPlugin" +OPENID_TOKEN_MAX_SIZE = 10 * 1024 # In bytes + + +class MySQLOpenIDConnectAuthPlugin(MySQLAuthPlugin): + """Class implementing the MySQL OpenID Connect Authentication Plugin.""" + + _openid_capability_flag: bytes = utils.int1store(1) + + @property + def name(self) -> str: + """Plugin official name.""" + return "authentication_openid_connect_client" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return True + + @staticmethod + def _validate_openid_token(token: str) -> bool: + """Helper method used to validate OpenID Connect token + + The Token is represented as a JSON Web Token (JWT) consists of a + base64-encoded header, body, and signature, separated by '.' e.g., + "Base64url.Base64url.Base64url". The First part of the token contains + the header, the second part contains payload and the third part contains + signature. These token parts should be Base64 URLSafe i.e., Token cannot + contain characters other than a-z, A-Z, 0-9 and special characters '-', '_'. + + Args: + token (str): Base64url-encoded OpenID connect token fetched from + the file path passed via `openid_token_file` connection + argument. + + Returns: + bool: Signal indicating whether the token is valid or not. + """ + header_payload_sig: List[str] = token.split(".") + if len(header_payload_sig) != 3: + # invalid structure + return False + urlsafe_pattern = re.compile("^[a-zA-Z0-9-_]*$") + return all( + ( + len(token_part) and urlsafe_pattern.search(token_part) is not None + for token_part in header_payload_sig + ) + ) + + def auth_response(self, auth_data: bytes, **kwargs: Any) -> bytes: + """Prepares authentication string for the server. + Args: + auth_data: Authorization data. + kwargs: Custom configuration to be passed to the auth plugin + when invoked. + + Returns: + packet: Client's authorization response. + The OpenID Connect authorization response follows the pattern :- + int<1> capability flag + string id token + + Raises: + InterfaceError: If the connection is insecure or the OpenID Token is too large, + invalid or non-existent. + ProgrammingError: If the OpenID Token file could not be read. + """ + try: + # Check if the connection is secure + if self.requires_ssl and not self._ssl_enabled: + raise errors.InterfaceError(f"{self.name} requires SSL") + + # Validate the file + token_file_path: str = kwargs.get("openid_token_file", None) + openid_token_file: Path = Path(token_file_path) + # Check if token exceeds the maximum size + if openid_token_file.stat().st_size > OPENID_TOKEN_MAX_SIZE: + raise errors.InterfaceError( + "The OpenID Connect token file size is too large (> 10KB)" + ) + openid_token: str = openid_token_file.read_text(encoding="utf-8") + openid_token = openid_token.strip() + # Validate the JWT Token + if not self._validate_openid_token(openid_token): + raise errors.InterfaceError("The OpenID Connect Token is invalid") + + # build the auth_response packet + auth_response: List[bytes] = [ + self._openid_capability_flag, + utils.lc_int(len(openid_token)), + openid_token.encode(), + ] + return b"".join(auth_response) + except (SyntaxError, TypeError, OSError, UnicodeError) as err: + raise errors.ProgrammingError( + "The OpenID Connect Token File (openid_token_file) could not be read" + ) from err + + async def auth_switch_response( + self, sock: MySQLSocket, auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + + Raises: + InterfaceError: If a NULL auth response is received from auth_response method. + """ + response = self.auth_response(auth_data, **kwargs) + + if response is None: + raise errors.InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + packet = await sock.read() + logger.debug("# server response packet: %s", packet) + + return bytes(packet) diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_webauthn_client.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_webauthn_client.py new file mode 100644 index 0000000..ac8b158 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/authentication_webauthn_client.py @@ -0,0 +1,291 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""WebAuthn Authentication Plugin.""" + + +from typing import TYPE_CHECKING, Any, Callable, Optional + +from mysql.connector import errors, utils + +from ..logger import logger +from . import MySQLAuthPlugin + +if TYPE_CHECKING: + from ..network import MySQLSocket + +try: + from fido2.cbor import dump_bytes as cbor_dump_bytes + from fido2.client import Fido2Client, UserInteraction + from fido2.hid import CtapHidDevice + from fido2.webauthn import PublicKeyCredentialRequestOptions +except ImportError as import_err: + raise errors.ProgrammingError( + "Module fido2 is required for WebAuthn authentication mechanism but was " + "not found. Unable to authenticate with the server" + ) from import_err + +try: + from fido2.pcsc import CtapPcscDevice + + CTAP_PCSC_DEVICE_AVAILABLE = True +except ModuleNotFoundError: + CTAP_PCSC_DEVICE_AVAILABLE = False + + +AUTHENTICATION_PLUGIN_CLASS = "MySQLWebAuthnAuthPlugin" + + +class ClientInteraction(UserInteraction): + """Provides user interaction to the Client.""" + + def __init__(self, callback: Optional[Callable] = None): + self.callback = callback + self.msg = ( + "Please insert FIDO device and perform gesture action for authentication " + "to complete." + ) + + def prompt_up(self) -> None: + """Prompt message for the user interaction with the FIDO device.""" + if self.callback is None: + print(self.msg) + else: + self.callback(self.msg) + + +class MySQLWebAuthnAuthPlugin(MySQLAuthPlugin): + """Class implementing the MySQL WebAuthn authentication plugin.""" + + client: Optional[Fido2Client] = None + callback: Optional[Callable] = None + options: dict = {"rpId": None, "challenge": None, "allowCredentials": []} + + @property + def name(self) -> str: + """Plugin official name.""" + return "authentication_webauthn_client" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return False + + def get_assertion_response( + self, credential_id: Optional[bytearray] = None + ) -> bytes: + """Get assertion from authenticator and return the response. + + Args: + credential_id (Optional[bytearray]): The credential ID. + + Returns: + bytearray: The response packet with the data from the assertion. + """ + if self.client is None: + raise errors.InterfaceError("No WebAuthn client found") + + if credential_id is not None: + # If credential_id is not None, it's because the FIDO device does not + # support resident keys and the credential_id was requested from the server + self.options["allowCredentials"] = [ + { + "id": credential_id, + "type": "public-key", + } + ] + + # Get assertion from authenticator + assertion = self.client.get_assertion( + PublicKeyCredentialRequestOptions.from_dict(self.options) + ) + number_of_assertions = len(assertion.get_assertions()) + client_data_json = b"" + + # Build response packet + # + # Format: + # int<1> 0x02 (2) status tag + # int number of assertions length encoded number of assertions + # string authenticator data variable length raw binary string + # string signed challenge variable length raw binary string + # ... + # ... + # string authenticator data variable length raw binary string + # string signed challenge variable length raw binary string + # string ClientDataJSON variable length raw binary string + packet = utils.lc_int(2) + packet += utils.lc_int(number_of_assertions) + + # Add authenticator data and signed challenge for each assertion + for i in range(number_of_assertions): + assertion_response = assertion.get_response(i) + + # string authenticator_data + authenticator_data = cbor_dump_bytes(assertion_response.authenticator_data) + + # string signed_challenge + signature = assertion_response.signature + + packet += utils.lc_int(len(authenticator_data)) + packet += authenticator_data + packet += utils.lc_int(len(signature)) + packet += signature + + # string client_data_json + client_data_json = assertion_response.client_data + + packet += utils.lc_int(len(client_data_json)) + packet += client_data_json + + logger.debug("WebAuthn - payload response packet: %s", packet) + return packet + + def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]: + """Find authenticator device and check if supports resident keys. + + It also creates a Fido2Client using the relying party ID from the server. + + Raises: + InterfaceError: When the FIDO device is not found. + + Returns: + bytes: 2 if the authenticator supports resident keys else 1. + """ + try: + packets, capability = utils.read_int(auth_data, 1) + challenge, rp_id = utils.read_lc_string_list(packets) + self.options["challenge"] = challenge + self.options["rpId"] = rp_id.decode() + logger.debug("WebAuthn - capability: %d", capability) + logger.debug("WebAuthn - challenge: %s", self.options["challenge"]) + logger.debug("WebAuthn - relying party id: %s", self.options["rpId"]) + except ValueError as err: + raise errors.InterfaceError( + "Unable to parse MySQL WebAuthn authentication data" + ) from err + + # Locate a device + device = next(CtapHidDevice.list_devices(), None) + if device is not None: + logger.debug("WebAuthn - Use USB HID channel") + elif CTAP_PCSC_DEVICE_AVAILABLE: + device = next(CtapPcscDevice.list_devices(), None) + + if device is None: + raise errors.InterfaceError("No FIDO device found") + + # Set up a FIDO 2 client using the origin relying party id + self.client = Fido2Client( + device, + f"https://{self.options['rpId']}", + user_interaction=ClientInteraction(self.callback), + ) + + if not self.client.info.options.get("rk"): + logger.debug("WebAuthn - Authenticator doesn't support resident keys") + return b"1" + + logger.debug("WebAuthn - Authenticator with support for resident key found") + return b"2" + + async def auth_more_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth more data` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Authentication method data (from a packet representing + an `auth more data` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + _, credential_id = utils.read_lc_string(auth_data) + + response = self.get_assertion_response(credential_id) + + logger.debug("WebAuthn - request: %s size: %s", response, len(response)) + await sock.write(response) + + pkt = bytes(await sock.read()) + logger.debug("WebAuthn - server response packet: %s", pkt) + + return pkt + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + webauth_callback = kwargs.get("webauthn_callback") or kwargs.get( + "fido_callback" + ) + self.callback = ( + utils.import_object(webauth_callback) + if isinstance(webauth_callback, str) + else webauth_callback + ) + + response = self.auth_response(auth_data) + credential_id = None + + if response == b"1": + # Authenticator doesn't support resident keys, request credential_id + logger.debug("WebAuthn - request credential_id") + await sock.write(utils.lc_int(int(response))) + + # return a packet representing an `auth more data` response + return bytes(await sock.read()) + + response = self.get_assertion_response(credential_id) + + logger.debug("WebAuthn - request: %s size: %s", response, len(response)) + await sock.write(response) + + pkt = bytes(await sock.read()) + logger.debug("WebAuthn - server response packet: %s", pkt) + + return pkt diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/caching_sha2_password.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/caching_sha2_password.py new file mode 100644 index 0000000..83385c3 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/caching_sha2_password.py @@ -0,0 +1,160 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Caching SHA2 Password Authentication Plugin.""" + +import struct + +from hashlib import sha256 +from typing import TYPE_CHECKING, Any, Optional + +from mysql.connector.errors import InterfaceError +from mysql.connector.logger import logger + +from . import MySQLAuthPlugin + +if TYPE_CHECKING: + from ..network import MySQLSocket + +AUTHENTICATION_PLUGIN_CLASS = "MySQLCachingSHA2PasswordAuthPlugin" + + +class MySQLCachingSHA2PasswordAuthPlugin(MySQLAuthPlugin): + """Class implementing the MySQL caching_sha2_password authentication plugin + + Note that encrypting using RSA is not supported since the Python + Standard Library does not provide this OpenSSL functionality. + """ + + perform_full_authentication: int = 4 + + def _scramble(self, auth_data: bytes) -> bytes: + """Return a scramble of the password using a Nonce sent by the + server. + + The scramble is of the form: + XOR(SHA2(password), SHA2(SHA2(SHA2(password)), Nonce)) + """ + if not auth_data: + raise InterfaceError("Missing authentication data (seed)") + + if not self._password: + return b"" + + hash1 = sha256(self._password.encode()).digest() + hash2 = sha256() + hash2.update(sha256(hash1).digest()) + hash2.update(auth_data) + hash2_digest = hash2.digest() + xored = [h1 ^ h2 for (h1, h2) in zip(hash1, hash2_digest)] + hash3 = struct.pack("32B", *xored) + return hash3 + + @property + def name(self) -> str: + """Plugin official name.""" + return "caching_sha2_password" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return False + + def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]: + """Make the client's authorization response. + + Args: + auth_data: Authorization data. + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Client's authorization response. + """ + if not auth_data: + return None + if len(auth_data) > 1: + return self._scramble(auth_data) + if auth_data[0] == self.perform_full_authentication: + # return password as clear text. + return self._password.encode() + b"\x00" + + return None + + async def auth_more_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth more data` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Authentication method data (from a packet representing + an `auth more data` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + response = self.auth_response(auth_data, **kwargs) + if response: + await sock.write(response) + + return bytes(await sock.read()) + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + response = self.auth_response(auth_data, **kwargs) + if response is None: + raise InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + pkt = bytes(await sock.read()) + logger.debug("# server response packet: %s", pkt) + + return pkt diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/mysql_clear_password.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/mysql_clear_password.py new file mode 100644 index 0000000..c4260c2 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/mysql_clear_password.py @@ -0,0 +1,105 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Clear Password Authentication Plugin.""" + +from typing import TYPE_CHECKING, Any, Optional + +from mysql.connector import errors +from mysql.connector.logger import logger + +from . import MySQLAuthPlugin + +if TYPE_CHECKING: + from ..network import MySQLSocket + +AUTHENTICATION_PLUGIN_CLASS = "MySQLClearPasswordAuthPlugin" + + +class MySQLClearPasswordAuthPlugin(MySQLAuthPlugin): + """Class implementing the MySQL Clear Password authentication plugin""" + + def _prepare_password(self) -> bytes: + """Prepare and return password as as clear text. + + Returns: + bytes: Prepared password. + """ + return self._password.encode() + b"\x00" + + @property + def name(self) -> str: + """Plugin official name.""" + return "mysql_clear_password" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return False + + def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]: + """Return the prepared password to send to MySQL. + + Raises: + InterfaceError: When SSL is required by not enabled. + + Returns: + str: The prepared password. + """ + if self.requires_ssl and not self._ssl_enabled: + raise errors.InterfaceError(f"{self.name} requires SSL") + return self._prepare_password() + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + response = self.auth_response(auth_data, **kwargs) + if response is None: + raise errors.InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + pkt = bytes(await sock.read()) + logger.debug("# server response packet: %s", pkt) + + return pkt diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/mysql_native_password.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/mysql_native_password.py new file mode 100644 index 0000000..a1c6aa8 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/mysql_native_password.py @@ -0,0 +1,121 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Native Password Authentication Plugin.""" + +import struct + +from hashlib import sha1 +from typing import TYPE_CHECKING, Any, Optional + +from mysql.connector.errors import InterfaceError +from mysql.connector.logger import logger + +from . import MySQLAuthPlugin + +if TYPE_CHECKING: + from ..network import MySQLSocket + +AUTHENTICATION_PLUGIN_CLASS = "MySQLNativePasswordAuthPlugin" + + +class MySQLNativePasswordAuthPlugin(MySQLAuthPlugin): + """Class implementing the MySQL Native Password authentication plugin""" + + def _prepare_password(self, auth_data: bytes) -> bytes: + """Prepares and returns password as native MySQL 4.1+ password""" + if not auth_data: + raise InterfaceError("Missing authentication data (seed)") + + if not self._password: + return b"" + + hash4 = None + try: + hash1 = sha1(self._password.encode()).digest() + hash2 = sha1(hash1).digest() + hash3 = sha1(auth_data + hash2).digest() + xored = [h1 ^ h3 for (h1, h3) in zip(hash1, hash3)] + hash4 = struct.pack("20B", *xored) + except (struct.error, TypeError) as err: + raise InterfaceError(f"Failed scrambling password; {err}") from err + + return hash4 + + @property + def name(self) -> str: + """Plugin official name.""" + return "mysql_native_password" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return False + + def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]: + """Make the client's authorization response. + + Args: + auth_data: Authorization data. + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Client's authorization response. + """ + return self._prepare_password(auth_data) + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + response = self.auth_response(auth_data, **kwargs) + if response is None: + raise InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + pkt = bytes(await sock.read()) + logger.debug("# server response packet: %s", pkt) + + return pkt diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/plugins/sha256_password.py b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/sha256_password.py new file mode 100644 index 0000000..680b98a --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/plugins/sha256_password.py @@ -0,0 +1,109 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""SHA256 Password Authentication Plugin.""" + +from typing import TYPE_CHECKING, Any, Optional + +from mysql.connector import errors +from mysql.connector.logger import logger + +from . import MySQLAuthPlugin + +if TYPE_CHECKING: + from ..network import MySQLSocket + +AUTHENTICATION_PLUGIN_CLASS = "MySQLSHA256PasswordAuthPlugin" + + +class MySQLSHA256PasswordAuthPlugin(MySQLAuthPlugin): + """Class implementing the MySQL SHA256 authentication plugin + + Note that encrypting using RSA is not supported since the Python + Standard Library does not provide this OpenSSL functionality. + """ + + def _prepare_password(self) -> bytes: + """Prepare and return password as as clear text. + + Returns: + password (bytes): Prepared password. + """ + return self._password.encode() + b"\x00" + + @property + def name(self) -> str: + """Plugin official name.""" + return "sha256_password" + + @property + def requires_ssl(self) -> bool: + """Signals whether or not SSL is required.""" + return True + + def auth_response(self, auth_data: bytes, **kwargs: Any) -> Optional[bytes]: + """Return the prepared password to send to MySQL. + + Raises: + InterfaceError: When SSL is required by not enabled. + + Returns: + str: The prepared password. + """ + if self.requires_ssl and not self.ssl_enabled: + raise errors.InterfaceError(f"{self.name} requires SSL") + return self._prepare_password() + + async def auth_switch_response( + self, sock: "MySQLSocket", auth_data: bytes, **kwargs: Any + ) -> bytes: + """Handles server's `auth switch request` response. + + Args: + sock: Pointer to the socket connection. + auth_data: Plugin provided data (extracted from a packet + representing an `auth switch request` response). + kwargs: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override the ones + defined in the auth plugin itself. + + Returns: + packet: Last server's response after back-and-forth + communication. + """ + response = self.auth_response(auth_data, **kwargs) + if response is None: + raise errors.InterfaceError("Got a NULL auth response") + + logger.debug("# request: %s size: %s", response, len(response)) + await sock.write(response) + + pkt = bytes(await sock.read()) + logger.debug("# server response packet: %s", pkt) + + return pkt diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/pooling.py b/server/venv/Lib/site-packages/mysql/connector/aio/pooling.py new file mode 100644 index 0000000..928a515 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/pooling.py @@ -0,0 +1,688 @@ +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Implementing pooling of connections to MySQL servers.""" + +from __future__ import annotations + +import asyncio +import os +import random +import re +import sys + +from types import TracebackType +from typing import TYPE_CHECKING, Any, Dict, NoReturn, Optional, Tuple, Type, Union +from uuid import UUID, uuid4 + +from mysql.connector.constants import CNX_POOL_ARGS + +try: + import dns.asyncresolver + import dns.exception +except ImportError: + HAVE_DNSPYTHON = False +else: + HAVE_DNSPYTHON = True + +from ..errors import ( + Error, + InterfaceError, + NotSupportedError, + PoolError, + ProgrammingError, +) +from ..pooling import DEFAULT_CONFIGURATION, generate_pool_name, read_option_files +from .connection import MySQLConnection + +if TYPE_CHECKING: + from .abstracts import MySQLConnectionAbstract + + +CONNECTION_POOL_LOCK = asyncio.Lock() +CNX_POOL_MAXSIZE = 32 +CNX_POOL_MAXNAMESIZE = 64 +CNX_POOL_NAMEREGEX = re.compile(r"[^a-zA-Z0-9._:\-*$#]") +MYSQL_CNX_CLASS: Union[type, Tuple[type, ...]] = (MySQLConnection,) + +_CONNECTION_POOLS: Dict[str, MySQLConnectionPool] = {} + + +async def _get_pooled_connection(**kwargs: Any) -> PooledMySQLConnection: + """Return a pooled MySQL connection.""" + # If no pool name specified, generate one + pool_name = ( + kwargs["pool_name"] if "pool_name" in kwargs else generate_pool_name(**kwargs) + ) + + # Setup the pool, ensuring only 1 thread can update at a time + if pool_name not in _CONNECTION_POOLS: + pool = MySQLConnectionPool(**kwargs) + await pool.initialize_pool() + + async with CONNECTION_POOL_LOCK: + _CONNECTION_POOLS[pool_name] = pool + elif isinstance(_CONNECTION_POOLS[pool_name], MySQLConnectionPool): + # pool_size must be the same + check_size = _CONNECTION_POOLS[pool_name].pool_size + if "pool_size" in kwargs and kwargs["pool_size"] != check_size: + raise PoolError("Size can not be changed for active pools.") + + # Return pooled connection + try: + return await _CONNECTION_POOLS[pool_name].get_connection() + except AttributeError: + raise InterfaceError( + f"Failed getting connection from pool '{pool_name}'" + ) from None + + +async def _get_failover_connection( + **kwargs: Any, +) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]: + """Return a MySQL connection and try to failover if needed. + + An InterfaceError is raise when no MySQL is available. ValueError is + raised when the failover server configuration contains an illegal + connection argument. Supported arguments are user, password, host, port, + unix_socket and database. ValueError is also raised when the failover + argument was not provided. + + Returns MySQLConnection instance. + """ + config = kwargs.copy() + try: + failover = config["failover"] + except KeyError: + raise ValueError("failover argument not provided") from None + del config["failover"] + + support_cnx_args = { + "user", + "password", + "host", + "port", + "unix_socket", + "database", + "pool_name", + "pool_size", + "priority", + } + + # First check if we can add all use the configuration + priority_count = 0 + for server in failover: + diff = set(server.keys()) - support_cnx_args + if diff: + arg = "s" if len(diff) > 1 else "" + lst = ", ".join(diff) + raise ValueError( + f"Unsupported connection argument {arg} in failover: {lst}" + ) + if hasattr(server, "priority"): + priority_count += 1 + + server["priority"] = server.get("priority", 100) + if server["priority"] < 0 or server["priority"] > 100: + raise InterfaceError( + "Priority value should be in the range of 0 to 100, " + f"got : {server['priority']}" + ) + if not isinstance(server["priority"], int): + raise InterfaceError( + "Priority value should be an integer in the range of 0 to " + f"100, got : {server['priority']}" + ) + + if 0 < priority_count < len(failover): + raise ProgrammingError( + "You must either assign no priority to any " + "of the routers or give a priority for " + "every router" + ) + + server_directory = {} + server_priority_list = [] + for server in sorted(failover, key=lambda x: x["priority"], reverse=True): + if server["priority"] not in server_directory: + server_directory[server["priority"]] = [server] + server_priority_list.append(server["priority"]) + else: + server_directory[server["priority"]].append(server) + + for priority in server_priority_list: + failover_list = server_directory[priority] + for _ in range(len(failover_list)): + last = len(failover_list) - 1 + index = random.randint(0, last) + server = failover_list.pop(index) + new_config = config.copy() + new_config.update(server) + new_config.pop("priority", None) + try: + return await connect(**new_config) + except Error: + # If we failed to connect, we try the next server + pass + + raise InterfaceError("Unable to connect to any of the target hosts") + + +async def connect( + *args: Any, **kwargs: Any +) -> Union[PooledMySQLConnection, MySQLConnectionAbstract]: + """Creates a MySQL connection object. + + In its simpliest form, `connect()` will open a connection to a + MySQL server and return a `MySQLConnectionAbstract` subclass + object such as `MySQLConnection` or `CMySQLConnection`. + + Args: + *args: N/A. + **kwargs: For a complete list of possible arguments, see [1]. If no arguments + are given, it uses the already configured or default values. + + Returns: + A `MySQLConnectionAbstract` subclass instance (such as `MySQLConnection`) + or a `PooledMySQLConnection` instance. + + Raises: + `mysql.connector.errors.NotSupportedError`: if pooled connection is not supported + for the Python version and OS that are being used. + + Examples: + A connection with the MySQL server can be established using either the + `mysql.connector.aio.connect()` method or a `MySQLConnectionAbstract` subclass: + ``` + >>> from mysql.connector.aio import MySQLConnection + >>> + >>> cnx1 = await mysql.connector.aio.connect(user='joe', database='test') + >>> cnx2 = MySQLConnection(user='joe', database='test') + >>> + ``` + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html + """ + # DNS SRV + dns_srv = kwargs.pop("dns_srv") if "dns_srv" in kwargs else False + + if not isinstance(dns_srv, bool): + raise InterfaceError("The value of 'dns-srv' must be a boolean") + + if dns_srv: + if not HAVE_DNSPYTHON: + raise InterfaceError( + "MySQL host configuration requested DNS " + "SRV. This requires the Python dnspython " + "module. Please refer to documentation" + ) + if "unix_socket" in kwargs: + raise InterfaceError( + "Using Unix domain sockets with DNS SRV lookup is not allowed" + ) + if "port" in kwargs: + raise InterfaceError( + "Specifying a port number with DNS SRV lookup is not allowed" + ) + if "failover" in kwargs: + raise InterfaceError( + "Specifying multiple hostnames with DNS SRV look up is not allowed" + ) + if "host" not in kwargs: + kwargs["host"] = DEFAULT_CONFIGURATION["host"] + + try: + srv_records = await dns.asyncresolver.query(kwargs["host"], "SRV") + except dns.exception.DNSException: + raise InterfaceError( + f"Unable to locate any hosts for '{kwargs['host']}'" + ) from None + + failover = [] + for srv in srv_records: + failover.append( + { + "host": srv.target.to_text(omit_final_dot=True), + "port": srv.port, + "priority": srv.priority, + "weight": srv.weight, + } + ) + + failover.sort(key=lambda x: (x["priority"], -x["weight"])) + kwargs["failover"] = [ + {"host": srv["host"], "port": srv["port"]} for srv in failover + ] + + # Failover + if "failover" in kwargs: + return await _get_failover_connection(**kwargs) + + # Pooled connections + try: + if any(key in kwargs for key in CNX_POOL_ARGS): + return await _get_pooled_connection(**kwargs) + except NameError: + # No pooling + pass + + # Option files + if "read_default_file" in kwargs: + kwargs["option_files"] = kwargs["read_default_file"] + kwargs.pop("read_default_file") + + if "option_files" in kwargs: + new_config = read_option_files(**kwargs) + return await connect(**new_config) + + if "use_pure" in kwargs: + del kwargs["use_pure"] # Remove 'use_pure' from kwargs + + cnx = MySQLConnection(*args, **kwargs) + await cnx.connect() + return cnx + + +def _check_support() -> None: + """Raises error if the Python version and OS combination is not + supported for Pooling.""" + py_version_info = sys.version_info + if os.name == "nt" and py_version_info.major == 3 and py_version_info.minor <= 10: + raise NotSupportedError( + "Pooled connections are not supported for Python versions " + "lower than 3.11 on Windows" + ) + + +class PooledMySQLConnection: + """Class holding a MySQL Connection in a pool + PooledMySQLConnection is used by MySQLConnectionPool to return an + instance holding a MySQL connection. It works like a MySQLConnection + except for methods like close() and config(). + The close()-method will add the connection back to the pool rather + than disconnecting from the MySQL server. + Configuring the connection have to be done through the MySQLConnectionPool + method set_config(). Using config() on pooled connection will raise a + PoolError. + Attributes: + pool_name (str): Returns the name of the connection pool to which the + connection belongs. + """ + + def __init__(self, pool: MySQLConnectionPool, cnx: MySQLConnectionAbstract) -> None: + """Constructor. + Args: + pool: A `MySQLConnectionPool` instance. + cnx: A `MySQLConnectionAbstract` subclass instance. + """ + _check_support() + + if not isinstance(pool, MySQLConnectionPool): + raise AttributeError("pool must be an instance of MySQLConnectionPool") + if not isinstance(cnx, MYSQL_CNX_CLASS): + raise AttributeError("cnx should be an instance of MySQLConnection") + self._cnx_pool: MySQLConnectionPool = pool + self._cnx: MySQLConnectionAbstract = cnx + + async def __aenter__(self) -> PooledMySQLConnection: + return self + + async def __aexit__( + self, + exc_type: Type[BaseException], + exc_value: BaseException, + traceback: TracebackType, + ) -> None: + await self.close() + + def __getattr__(self, attr: Any) -> Any: + """Calls attributes of the MySQLConnection instance""" + return getattr(self._cnx, attr) + + async def close(self) -> None: + """Do not close, but adds connection back to pool. + For a pooled connection, close() does not actually close it but returns it + to the pool and makes it available for subsequent connection requests. If the + pool configuration parameters are changed, a returned connection is closed + and reopened with the new configuration before being returned from the pool + again in response to a connection request. + """ + cnx = self._cnx + try: + if self._cnx_pool.can_reset_session and await cnx.is_connected(): + await cnx.reset_session() + finally: + await self._cnx_pool.add_connection(cnx) + self._cnx = None + + @staticmethod + def config(**kwargs: Any) -> NoReturn: + """Configuration is done through the pool. + For pooled connections, the `config()` method raises a `PoolError` + exception. Configuration for pooled connections should be done + using the pool object. + """ + raise PoolError( + "Configuration for pooled connections should be done through the " + "pool itself" + ) + + @property + def pool_name(self) -> str: + """Returns the name of the connection pool to which the connection belongs.""" + return self._cnx_pool.pool_name + + +class MySQLConnectionPool: + """Class defining a pool of MySQL connections""" + + def __init__( + self, + pool_size: int = 5, + pool_name: Optional[str] = None, + pool_reset_session: bool = True, + **kwargs: Any, + ) -> None: + """Constructor. + + Initialize a MySQL connection pool with a maximum number of + connections set to `pool_size`. + + NOTE: The coroutine `await cnxpool.initialize_pool(**dbconfig)` must be called + after initializing this class to open up the pool of required number of database + connections and making the instance of MySQLConnectionPool ready-to-use. + Contents of `dbconfig` is described in [1]. + + Args: + pool_name: The pool name. If this argument is not given, Connector/Python + automatically generates the name, composed from whichever of + the host, port, user, and database connection arguments are + given in kwargs, in that order. + pool_size: The pool size. If this argument is not given, the default is 5. + pool_reset_session: Whether to reset session variables when the connection + is returned to the pool. + + Examples: + ``` + >>> dbconfig = { + >>> "database": "test", + >>> "user": "joe", + >>> } + >>> # initialize the MySQLConnectionPool instance + >>> cnxpool = mysql.connector.aio.pooling.MySQLConnectionPool( + >>> pool_name = "mypool", + >>> pool_size = 3, + >>> ) + >>> # Open up the pool of connections + >>> await cnxpool.initialize_pool(**dbconfig) + >>> # cnxpool is ready to be used ... + ``` + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html + """ + _check_support() + + self._pool_size: Optional[int] = None + self._pool_name: Optional[str] = None + self._reset_session: bool = pool_reset_session + self._set_pool_size(pool_size) + if pool_name: + self._set_pool_name(pool_name) + self._cnx_config: Dict[str, Any] = kwargs + self._cnx_queue: asyncio.Queue[MySQLConnectionAbstract] = asyncio.Queue( + self._pool_size + ) + self._config_version: UUID = uuid4() + + async def initialize_pool(self) -> None: + """Opens the connection pool and fill with MySQL database connections. + + This coroutine must be called after initializing an instance of MySQLConnectionPool + to make the build the pool of connections and make the instance ready to be used. + + Args: + **kwargs: Connection arguments, as described in [1]. + + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html + """ + if not self._pool_name: + async with CONNECTION_POOL_LOCK: + self._set_pool_name(generate_pool_name(**self._cnx_config)) + if self._cnx_config: + await self.set_config(**self._cnx_config) + cnt = 0 + while cnt < self._pool_size: + await self.add_connection() + cnt += 1 + + @property + def pool_name(self) -> str: + """Returns the name of the connection pool.""" + return self._pool_name + + @property + def pool_size(self) -> int: + """Returns number of connections managed by the pool.""" + return self._pool_size + + @property + def can_reset_session(self) -> bool: + """Returns whether to reset session.""" + return self._reset_session + + async def set_config(self, **kwargs: Any) -> None: + """Set the connection configuration for `MySQLConnectionAbstract` subclass instances. + This method sets the configuration used for creating `MySQLConnectionAbstract` + subclass instances such as `MySQLConnection`. See [1] for valid + connection arguments. + Args: + **kwargs: Connection arguments - for a complete list of possible + arguments, see [1]. + Raises: + PoolError: When a connection argument is not valid, missing + or not supported by `MySQLConnectionAbstract`. + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-connectargs.html + """ + if not kwargs: + return + + try: + # test if we're able to connect using the provided connection options + cnx = await connect(**kwargs) + await cnx.disconnect() + async with CONNECTION_POOL_LOCK: + self._cnx_config = kwargs + self._config_version = uuid4() + except Exception as err: + raise PoolError(f"Connection configuration not valid: {err}") from err + + def _set_pool_size(self, pool_size: int) -> None: + """Set the size of the pool + This method sets the size of the pool but it will not resize the pool. + Raises an AttributeError when the pool_size is not valid. Invalid size + is 0, negative or higher than pooling.CNX_POOL_MAXSIZE. + """ + if pool_size <= 0 or pool_size > CNX_POOL_MAXSIZE: + raise AttributeError( + "Pool size should be higher than 0 and lower or equal to " + f"{CNX_POOL_MAXSIZE}" + ) + self._pool_size = pool_size + + def _set_pool_name(self, pool_name: str) -> None: + r"""Set the name of the pool. + This method checks the validity and sets the name of the pool. + Raises an AttributeError when pool_name contains illegal characters + ([^a-zA-Z0-9._\-*$#]) or is longer than pooling.CNX_POOL_MAXNAMESIZE. + """ + if CNX_POOL_NAMEREGEX.search(pool_name): + raise AttributeError(f"Pool name '{pool_name}' contains illegal characters") + if len(pool_name) > CNX_POOL_MAXNAMESIZE: + raise AttributeError(f"Pool name '{pool_name}' is too long") + self._pool_name = pool_name + + def _queue_connection(self, cnx: MySQLConnectionAbstract) -> None: + """Put connection back in the queue + This method is putting a connection back in the queue. It will not + acquire a lock as the methods using _queue_connection() will have it + set. + Raises `PoolError` on errors. + """ + if not isinstance(cnx, MYSQL_CNX_CLASS): + raise PoolError( + "Connection instance not subclass of MySQLConnectionAbstract" + ) + + try: + self._cnx_queue.put_nowait(cnx) + except asyncio.QueueFull as err: + raise PoolError("Failed adding connection; queue is full") from err + + async def add_connection( + self, cnx: Optional[MySQLConnectionAbstract] = None + ) -> None: + """Adds a connection to the pool. + This method instantiates a `MySQLConnection` using the configuration + passed when initializing the `MySQLConnectionPool` instance or using + the `set_config()` method. + If cnx is a `MySQLConnection` instance, it will be added to the + queue. + Args: + cnx: The `MySQLConnectionAbstract` subclass object to be added to + the pool. If this argument is missing (aka `None`), the pool + creates a new connection and adds it. + Raises: + PoolError: When no configuration is set, when no more + connection can be added (maximum reached) or when the connection + can not be instantiated. + """ + if not self._cnx_config: + raise PoolError("Connection configuration not available") + + if self._cnx_queue.full(): + raise PoolError("Failed adding connection; queue is full") + + if not cnx: + cnx = await connect(**self._cnx_config) + try: + if ( + self._reset_session + and self._cnx_config["compress"] + and cnx.get_server_version() < (5, 7, 3) + ): + raise NotSupportedError( + "Pool reset session is not supported with " + "compression for MySQL server version 5.7.2 " + "or earlier" + ) + except KeyError: + pass + + cnx.pool_config_version = self._config_version + else: + if not isinstance(cnx, MYSQL_CNX_CLASS): + raise PoolError( + "Connection instance not subclass of MySQLConnectionAbstract" + ) + + self._queue_connection(cnx) + + async def get_connection(self) -> PooledMySQLConnection: + """Gets a connection from the pool. + This method returns an PooledMySQLConnection instance which + has a reference to the pool that created it, and the next available + MySQL connection. + When the MySQL connection is not connect, a reconnect is attempted. + Returns: + A `PooledMySQLConnection` instance. + Raises: + PoolError: On errors. + """ + + try: + cnx = self._cnx_queue.get_nowait() + except asyncio.QueueEmpty as err: + raise PoolError("Failed getting connection; pool exhausted") from err + + if ( + not await cnx.is_connected() + or self._config_version != cnx.pool_config_version + ): + try: + cnx._set_connection_options(**self._cnx_config) + await cnx.reconnect() + except InterfaceError: + self._queue_connection(cnx) + raise + cnx.pool_config_version = self._config_version + + return PooledMySQLConnection(self, cnx) + + async def _remove_connections(self) -> int: + """Close all connections + This method closes all connections and returns the number of connections it closed. + This method should be used internally while cleaning-up the connection pool by closing each + and every active connection objects currently residing in the pool. + + Returns: + An integer defining the number of open connections closed during clean-up. + Raises: + PoolError: On errors while fetching connections from the pool or while disconnecting + an open connection. + """ + cnt = 0 + cnxq = self._cnx_queue + while cnxq.qsize(): + try: + cnx = cnxq.get_nowait() + await cnx.disconnect() + cnt += 1 + except asyncio.QueueEmpty: + return cnt + except PoolError: + raise + except Error: + # Any other error when closing means connection is closed + pass + + return cnt + + async def close_pool(self) -> int: + """Cleans up the connection pool + This public method gracefully shuts down the connection pool by disconnecting all of the + open connections currently active in the pool. + + Returns: + An integer defining the number of open connections closed while shutting down the pool. + Raises: + PoolError: On errors while fetching connections from the pool or while disconnecting + an open connection. + """ + return await self._remove_connections() diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/protocol.py b/server/venv/Lib/site-packages/mysql/connector/aio/protocol.py new file mode 100644 index 0000000..9b30004 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/protocol.py @@ -0,0 +1,325 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Implements the MySQL Client/Server protocol.""" + +__all__ = ["MySQLProtocol"] + +import struct + +from typing import Any, Dict, List, Optional, Tuple + +from ..constants import ClientFlag, ServerCmd +from ..errors import InterfaceError, ProgrammingError, get_exception +from ..logger import logger +from ..protocol import ( + DEFAULT_CHARSET_ID, + DEFAULT_MAX_ALLOWED_PACKET, + MySQLProtocol as _MySQLProtocol, +) +from ..types import BinaryProtocolType, DescriptionType, EofPacketType, HandShakeType +from ..utils import lc_int, read_lc_string_list +from .network import MySQLSocket +from .plugins import MySQLAuthPlugin, get_auth_plugin +from .plugins.caching_sha2_password import MySQLCachingSHA2PasswordAuthPlugin + + +class MySQLProtocol(_MySQLProtocol): + """Implements MySQL client/server protocol. + + Create and parses MySQL packets. + """ + + @staticmethod + def auth_plugin_first_response( # type: ignore[override] + auth_data: bytes, + username: str, + password: str, + auth_plugin: str, + auth_plugin_class: Optional[str] = None, + ssl_enabled: bool = False, + plugin_config: Optional[Dict[str, Any]] = None, + ) -> Tuple[bytes, MySQLAuthPlugin]: + """Prepare the first authentication response. + + Args: + auth_data: Authorization data from initial handshake. + username: Account's username. + password: Account's password. + client_flags: Integer representing client capabilities flags. + auth_plugin: Authorization plugin name. + auth_plugin_class: Authorization plugin class (has higher precedence + than the authorization plugin name). + ssl_enabled: Whether SSL is enabled or not. + plugin_config: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override + the ones defined in the auth plugin itself. + + Returns: + auth_response: Authorization plugin response. + auth_strategy: Authorization plugin instance created based + on the provided `auth_plugin` and `auth_plugin_class` + parameters. + + Raises: + InterfaceError: If authentication fails or when got a NULL auth response. + """ + if not password and auth_plugin == "": + # return auth response and an arbitrary auth strategy + return b"\x00", MySQLCachingSHA2PasswordAuthPlugin( + username, password, ssl_enabled=ssl_enabled + ) + + if plugin_config is None: + plugin_config = {} + + try: + auth_strategy = get_auth_plugin(auth_plugin, auth_plugin_class)( + username, password, ssl_enabled=ssl_enabled + ) + auth_response = auth_strategy.auth_response(auth_data, **plugin_config) + except (TypeError, InterfaceError) as err: + raise InterfaceError(f"Failed authentication: {err}") from err + + if auth_response is None: + raise InterfaceError( + "Got NULL auth response while authenticating with " + f"plugin {auth_strategy.name}" + ) + + auth_response = lc_int(len(auth_response)) + auth_response + + return auth_response, auth_strategy + + @staticmethod + def make_auth( # type: ignore[override] + handshake: HandShakeType, + username: str, + password: str, + database: Optional[str] = None, + charset: int = DEFAULT_CHARSET_ID, + client_flags: int = 0, + max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET, + auth_plugin: Optional[str] = None, + auth_plugin_class: Optional[str] = None, + conn_attrs: Optional[Dict[str, str]] = None, + is_change_user_request: bool = False, + ssl_enabled: bool = False, + plugin_config: Optional[Dict[str, Any]] = None, + ) -> Tuple[bytes, MySQLAuthPlugin]: + """Make a MySQL Authentication packet. + + Args: + handshake: Initial handshake. + username: Account's username. + password: Account's password. + database: Initial database name for the connection + charset: Client charset (see [2]), only the lower 8-bits. + client_flags: Integer representing client capabilities flags. + max_allowed_packet: Maximum packet size. + auth_plugin: Authorization plugin name. + auth_plugin_class: Authorization plugin class (has higher precedence + than the authorization plugin name). + conn_attrs: Connection attributes. + is_change_user_request: Whether is a `change user request` operation or not. + ssl_enabled: Whether SSL is enabled or not. + plugin_config: Custom configuration to be passed to the auth plugin + when invoked. The parameters defined here will override + the one defined in the auth plugin itself. + + Returns: + handshake_response: Handshake response as per [1]. + auth_strategy: Authorization plugin instance created based + on the provided `auth_plugin` and `auth_plugin_class`. + + Raises: + ProgrammingError: Handshake misses authentication info. + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_connection_phase_packets_protocol_handshake_response.html + + [2]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_basic_character_set.html#a_protocol_character_set + """ + b_username = username.encode() + response_payload = [] + + if is_change_user_request: + logger.debug("Got a `change user` request") + + logger.debug("Starting authorization phase") + if handshake is None: + raise ProgrammingError("Got a NULL handshake") from None + + if handshake.get("auth_data") is None: + raise ProgrammingError("Handshake misses authentication info") from None + + try: + auth_plugin = auth_plugin or handshake["auth_plugin"] # type: ignore[assignment] + except (TypeError, KeyError) as err: + raise ProgrammingError( + f"Handshake misses authentication plugin info ({err})" + ) from None + + logger.debug("The provided initial strategy is %s", auth_plugin) + + if is_change_user_request: + response_payload.append( + struct.pack( + f" Tuple[ + List[Tuple[BinaryProtocolType, ...]], + Optional[EofPacketType], + ]: + """Read MySQL binary protocol result. + + Reads all or given number of binary resultset rows from the socket. + """ + rows = [] + eof = None + values = None + i = 0 + while True: + if eof or i == count: + break + packet = await sock.read(read_timeout) + if packet[4] == 254: + eof = self.parse_eof(packet) + values = None + elif packet[4] == 0: + eof = None + values = self._parse_binary_values(columns, packet[5:], charset) + if eof is None and values is not None: + rows.append(values) + elif eof is None and values is None: + raise get_exception(packet) + i += 1 + return (rows, eof) + + # pylint: disable=invalid-overridden-method + async def read_text_result( # type: ignore[override] + self, + sock: MySQLSocket, + version: Tuple[int, ...], + count: int = 1, + read_timeout: Optional[int] = None, + ) -> Tuple[ + List[Tuple[Optional[bytes], ...]], + Optional[EofPacketType], + ]: + """Read MySQL text result. + + Reads all or given number of rows from the socket. + + Returns a tuple with 2 elements: a list with all rows and + the EOF packet. + """ + # Keep unused 'version' for API backward compatibility + _ = version + rows = [] + eof = None + rowdata = None + i = 0 + while True: + if eof or i == count: + break + packet = await sock.read(read_timeout) + if packet.startswith(b"\xff\xff\xff"): + datas = [packet[4:]] + packet = await sock.read(read_timeout) + while packet.startswith(b"\xff\xff\xff"): + datas.append(packet[4:]) + packet = await sock.read(read_timeout) + datas.append(packet[4:]) + rowdata = read_lc_string_list(b"".join(datas)) + elif packet[4] == 254 and packet[0] < 7: + eof = self.parse_eof(packet) + rowdata = None + else: + eof = None + rowdata = read_lc_string_list(bytes(packet[4:])) + if eof is None and rowdata is not None: + rows.append(rowdata) + elif eof is None and rowdata is None: + raise get_exception(packet) + i += 1 + return rows, eof diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/py.typed b/server/venv/Lib/site-packages/mysql/connector/aio/py.typed new file mode 100644 index 0000000..1f2326a --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/py.typed @@ -0,0 +1,27 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/server/venv/Lib/site-packages/mysql/connector/aio/utils.py b/server/venv/Lib/site-packages/mysql/connector/aio/utils.py new file mode 100644 index 0000000..cf3328f --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/aio/utils.py @@ -0,0 +1,155 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="attr-defined" +# pylint: disable=protected-access + +"""Utilities.""" + +__all__ = ["to_thread", "open_connection"] + +import asyncio +import contextvars +import functools + +try: + import ssl +except ImportError: + ssl = None + +from typing import TYPE_CHECKING, Any, Callable, Tuple + +if TYPE_CHECKING: + from mysql.connector.aio.abstracts import MySQLConnectionAbstract + + __all__.append("StreamWriter") + + +class StreamReaderProtocol(asyncio.StreamReaderProtocol): + """Extends asyncio.streams.StreamReaderProtocol for adding start_tls(). + + The ``start_tls()`` is based on ``asyncio.streams.StreamWriter`` introduced + in Python 3.11. It provides the same functionality for older Python versions. + """ + + def _replace_writer(self, writer: asyncio.StreamWriter) -> None: + """Replace stream writer. + + Args: + writer: Stream Writer. + """ + transport = writer.transport + self._stream_writer = writer + self._transport = transport + self._over_ssl = transport.get_extra_info("sslcontext") is not None + + +class StreamWriter(asyncio.streams.StreamWriter): + """Extends asyncio.streams.StreamWriter for adding start_tls(). + + The ``start_tls()`` is based on ``asyncio.streams.StreamWriter`` introduced + in Python 3.11. It provides the same functionality for older Python versions. + """ + + async def start_tls( + self, + ssl_context: ssl.SSLContext, + *, + server_hostname: str = None, + ssl_handshake_timeout: int = None, + ) -> None: + """Upgrade an existing stream-based connection to TLS. + + Args: + ssl_context: Configured SSL context. + server_hostname: Server host name. + ssl_handshake_timeout: SSL handshake timeout. + """ + server_side = self._protocol._client_connected_cb is not None + protocol = self._protocol + await self.drain() + new_transport = await self._loop.start_tls( + # pylint: disable=access-member-before-definition + self._transport, # type: ignore[has-type] + protocol, + ssl_context, + server_side=server_side, + server_hostname=server_hostname, + ssl_handshake_timeout=ssl_handshake_timeout, + ) + self._transport = ( # pylint: disable=attribute-defined-outside-init + new_transport + ) + protocol._replace_writer(self) + + +async def open_connection( + host: str = None, port: int = None, *, limit: int = 2**16, **kwds: Any +) -> Tuple[asyncio.StreamReader, StreamWriter]: + """A wrapper for create_connection() returning a (reader, writer) pair. + + This function is based on ``asyncio.streams.open_connection`` and adds a custom + stream reader. + + MySQL expects TLS negotiation to happen in the middle of a TCP connection, not at + the start. + This function in conjunction with ``_StreamReaderProtocol`` and ``_StreamWriter`` + allows the TLS negotiation on an existing connection. + + Args: + host: Server host name. + port: Server port. + limit: The buffer size limit used by the returned ``StreamReader`` instance. + By default the limit is set to 64 KiB. + + Returns: + tuple: Returns a pair of reader and writer objects that are instances of + ``StreamReader`` and ``StreamWriter`` classes. + """ + loop = asyncio.get_running_loop() + reader = asyncio.streams.StreamReader(limit=limit, loop=loop) + protocol = StreamReaderProtocol(reader, loop=loop) + transport, _ = await loop.create_connection(lambda: protocol, host, port, **kwds) + writer = StreamWriter(transport, protocol, reader, loop) + return reader, writer + + +async def to_thread(func: Callable, *args: Any, **kwargs: Any) -> asyncio.Future: + """Asynchronously run function ``func`` in a separate thread. + + This function is based on ``asyncio.to_thread()`` introduced in Python 3.9, which + provides the same functionality for older Python versions. + + Returns: + coroutine: A coroutine that can be awaited to get the eventual result of + ``func``. + """ + loop = asyncio.get_running_loop() + ctx = contextvars.copy_context() + func_call = functools.partial(ctx.run, func, *args, **kwargs) + return await loop.run_in_executor(None, func_call) diff --git a/server/venv/Lib/site-packages/mysql/connector/authentication.py b/server/venv/Lib/site-packages/mysql/connector/authentication.py new file mode 100644 index 0000000..6a40b70 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/authentication.py @@ -0,0 +1,385 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Implementing support for MySQL Authentication Plugins""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from .errors import InterfaceError, NotSupportedError, get_exception +from .logger import logger +from .plugins import MySQLAuthPlugin, get_auth_plugin +from .protocol import ( + AUTH_SWITCH_STATUS, + DEFAULT_CHARSET_ID, + DEFAULT_MAX_ALLOWED_PACKET, + ERR_STATUS, + EXCHANGE_FURTHER_STATUS, + MFA_STATUS, + OK_STATUS, + MySQLProtocol, +) +from .types import HandShakeType + +if TYPE_CHECKING: + from .network import MySQLSocket + + +class MySQLAuthenticator: + """Implements the authentication phase.""" + + def __init__(self) -> None: + """Constructor.""" + self._username: str = "" + self._passwords: Dict[int, str] = {} + self._plugin_config: Dict[str, Any] = {} + self._ssl_enabled: bool = False + self._auth_strategy: Optional[MySQLAuthPlugin] = None + self._auth_plugin_class: Optional[str] = None + + @property + def ssl_enabled(self) -> bool: + """Signals whether or not SSL is enabled.""" + return self._ssl_enabled + + @property + def plugin_config(self) -> Dict[str, Any]: + """Custom arguments that are being provided to the authentication plugin when called. + + The parameters defined here will override the ones defined in the + auth plugin itself. + + The plugin config is a read-only property - the plugin configuration + provided when invoking `authenticate()` is recorded and can be queried + by accessing this property. + + Returns: + dict: The latest plugin configuration provided when invoking + `authenticate()`. + """ + return self._plugin_config + + def update_plugin_config(self, config: Dict[str, Any]) -> None: + """Update the 'plugin_config' instance variable""" + self._plugin_config.update(config) + + def setup_ssl( + self, + sock: MySQLSocket, + host: str, + ssl_options: Optional[Dict[str, Any]], + charset: int = DEFAULT_CHARSET_ID, + client_flags: int = 0, + max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET, + ) -> bytes: + """Sets up an SSL communication channel. + + Args: + sock: Pointer to the socket connection. + host: Server host name. + ssl_options: SSL and TLS connection options (see + `network.MySQLSocket.build_ssl_context`). + charset: Client charset (see [1]), only the lower 8-bits. + client_flags: Integer representing client capabilities flags. + max_allowed_packet: Maximum packet size. + + Returns: + ssl_request_payload: Payload used to carry out SSL authentication. + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_basic_character_set.html#a_protocol_character_set + """ + if ssl_options is None: + ssl_options = {} + + # SSL connection request packet + ssl_request_payload = MySQLProtocol.make_auth_ssl( + charset=charset, + client_flags=client_flags, + max_allowed_packet=max_allowed_packet, + ) + sock.send(ssl_request_payload) + + logger.debug("Building SSL context") + ssl_context = sock.build_ssl_context( + ssl_ca=ssl_options.get("ca"), + ssl_cert=ssl_options.get("cert"), + ssl_key=ssl_options.get("key"), + ssl_verify_cert=ssl_options.get("verify_cert", False), + ssl_verify_identity=ssl_options.get("verify_identity", False), + tls_versions=ssl_options.get("tls_versions"), + tls_cipher_suites=ssl_options.get("tls_ciphersuites"), + ) + + logger.debug("Switching to SSL") + sock.switch_to_ssl(ssl_context, host) + + logger.debug("SSL has been enabled") + self._ssl_enabled = True + + return ssl_request_payload + + def _switch_auth_strategy( + self, + new_strategy_name: str, + strategy_class: Optional[str] = None, + username: Optional[str] = None, + password_factor: int = 1, + ) -> None: + """Switches the authorization plugin. + + Args: + new_strategy_name: New authorization plugin name to switch to. + strategy_class: New authorization plugin class to switch to + (has higher precedence than the authorization plugin name). + username: Username to be used - if not defined, the username + provided when `authentication()` was invoked is used. + password_factor: Up to three levels of authentication (MFA) are allowed, + hence you can choose the password corresponding to the 1st, + 2nd, or 3rd factor - 1st is the default. + """ + if username is None: + username = self._username + + if strategy_class is None: + strategy_class = self._auth_plugin_class + + logger.debug("Switching to strategy %s", new_strategy_name) + self._auth_strategy = get_auth_plugin( + plugin_name=new_strategy_name, auth_plugin_class=strategy_class + )( + username, + self._passwords.get(password_factor, ""), + ssl_enabled=self.ssl_enabled, + ) + + def _mfa_n_factor( + self, + sock: MySQLSocket, + pkt: bytes, + ) -> Optional[bytes]: + """Handles MFA (Multi-Factor Authentication) response. + + Up to three levels of authentication (MFA) are allowed. + + Args: + sock: Pointer to the socket connection. + pkt: MFA response. + + Returns: + ok_packet: If last server's response is an OK packet. + None: If last server's response isn't an OK packet and no ERROR was raised. + + Raises: + InterfaceError: If got an invalid N factor. + errors.ErrorTypes: If got an ERROR response. + """ + n_factor = 2 + while pkt[4] == MFA_STATUS: + if n_factor not in self._passwords: + raise InterfaceError( + "Failed Multi Factor Authentication (invalid N factor)" + ) + + new_strategy_name, auth_data = MySQLProtocol.parse_auth_next_factor(pkt) + self._switch_auth_strategy(new_strategy_name, password_factor=n_factor) + logger.debug("MFA %i factor %s", n_factor, self._auth_strategy.name) + + pkt = self._auth_strategy.auth_switch_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == EXCHANGE_FURTHER_STATUS: + auth_data = MySQLProtocol.parse_auth_more_data(pkt) + pkt = self._auth_strategy.auth_more_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == OK_STATUS: + logger.debug("MFA completed succesfully") + return pkt + + if pkt[4] == ERR_STATUS: + raise get_exception(pkt) + + n_factor += 1 + + logger.warning("MFA terminated with a no ok packet") + return None + + def _handle_server_response( + self, + sock: MySQLSocket, + pkt: bytes, + ) -> Optional[bytes]: + """Handles server's response. + + Args: + sock: Pointer to the socket connection. + pkt: Server's response after completing the `HandShakeResponse`. + + Returns: + ok_packet: If last server's response is an OK packet. + None: If last server's response isn't an OK packet and no ERROR was raised. + + Raises: + errors.ErrorTypes: If got an ERROR response. + NotSupportedError: If got Authentication with old (insecure) passwords. + """ + if pkt[4] == AUTH_SWITCH_STATUS and len(pkt) == 5: + raise NotSupportedError( + "Authentication with old (insecure) passwords " + "is not supported. For more information, lookup " + "Password Hashing in the latest MySQL manual" + ) + + if pkt[4] == AUTH_SWITCH_STATUS: + logger.debug("Server's response is an auth switch request") + new_strategy_name, auth_data = MySQLProtocol.parse_auth_switch_request(pkt) + self._switch_auth_strategy(new_strategy_name) + pkt = self._auth_strategy.auth_switch_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == EXCHANGE_FURTHER_STATUS: + logger.debug("Exchanging further packets") + auth_data = MySQLProtocol.parse_auth_more_data(pkt) + pkt = self._auth_strategy.auth_more_response( + sock, auth_data, **self._plugin_config + ) + + if pkt[4] == OK_STATUS: + logger.debug("%s completed succesfully", self._auth_strategy.name) + return pkt + + if pkt[4] == MFA_STATUS: + logger.debug("Starting multi-factor authentication") + logger.debug("MFA 1 factor %s", self._auth_strategy.name) + return self._mfa_n_factor(sock, pkt) + + if pkt[4] == ERR_STATUS: + raise get_exception(pkt) + + return None + + def authenticate( + self, + sock: MySQLSocket, + handshake: HandShakeType, + username: str = "", + password1: str = "", + password2: str = "", + password3: str = "", + database: Optional[str] = None, + charset: int = DEFAULT_CHARSET_ID, + client_flags: int = 0, + max_allowed_packet: int = DEFAULT_MAX_ALLOWED_PACKET, + auth_plugin: Optional[str] = None, + auth_plugin_class: Optional[str] = None, + conn_attrs: Optional[Dict[str, str]] = None, + is_change_user_request: bool = False, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> bytes: + """Performs the authentication phase. + + During re-authentication you must set `is_change_user_request` to True. + + Args: + sock: Pointer to the socket connection. + handshake: Initial handshake. + username: Account's username. + password1: Account's password factor 1. + password2: Account's password factor 2. + password3: Account's password factor 3. + database: Initial database name for the connection. + charset: Client charset (see [1]), only the lower 8-bits. + client_flags: Integer representing client capabilities flags. + max_allowed_packet: Maximum packet size. + auth_plugin: Authorization plugin name. + auth_plugin_class: Authorization plugin class (has higher precedence + than the authorization plugin name). + conn_attrs: Connection attributes. + is_change_user_request: Whether is a `change user request` operation or not. + read_timeout: Timeout in seconds upto which the connector should wait for + the server to reply back before raising an ReadTimeoutError. + write_timeout: Timeout in seconds upto which the connector should spend to + send data to the server before raising an WriteTimeoutError. + Returns: + ok_packet: OK packet. + + Raises: + InterfaceError: If OK packet is NULL. + ReadTimeoutError: If the time taken for the server to reply back exceeds + 'read_timeout' (if set). + WriteTimeoutError: If the time taken to send data packets to the server + exceeds 'write_timeout' (if set). + + References: + [1]: https://dev.mysql.com/doc/dev/mysql-server/latest/\ + page_protocol_basic_character_set.html#a_protocol_character_set + """ + # update credentials, plugin config and plugin class + self._username = username + self._passwords = {1: password1, 2: password2, 3: password3} + self._auth_plugin_class = auth_plugin_class + + # client's handshake response + response_payload, self._auth_strategy = MySQLProtocol.make_auth( + handshake=handshake, + username=username, + password=password1, + database=database, + charset=charset, + client_flags=client_flags, + max_allowed_packet=max_allowed_packet, + auth_plugin=auth_plugin, + auth_plugin_class=auth_plugin_class, + conn_attrs=conn_attrs, + is_change_user_request=is_change_user_request, + ssl_enabled=self.ssl_enabled, + plugin_config=self.plugin_config, + ) + + # client sends transaction response + send_args = ( + (0, 0, write_timeout) + if is_change_user_request + else (None, None, write_timeout) + ) + sock.send(response_payload, *send_args) + + # server replies back + pkt = bytes(sock.recv(read_timeout)) + + ok_pkt = self._handle_server_response(sock, pkt) + if ok_pkt is None: + raise InterfaceError("Got a NULL ok_pkt") from None + + return ok_pkt diff --git a/server/venv/Lib/site-packages/mysql/connector/charsets.py b/server/venv/Lib/site-packages/mysql/connector/charsets.py new file mode 100644 index 0000000..5220b9d --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/charsets.py @@ -0,0 +1,620 @@ +# -*- coding: utf-8 -*- # pylint: disable=missing-module-docstring + +# Copyright (c) 2013, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from typing import List, Optional, Tuple + +"""This module contains the MySQL Server Character Sets.""" # pylint: disable=pointless-string-statement + +# This file was auto-generated. +_GENERATED_ON: str = "2022-05-09" +_MYSQL_VERSION: Tuple[int, int, int] = (8, 0, 30) + +MYSQL_CHARACTER_SETS: List[Optional[Tuple[str, str, bool]]] = [ + # (character set name, collation, default) + None, + ("big5", "big5_chinese_ci", True), # 1 + ("latin2", "latin2_czech_cs", False), # 2 + ("dec8", "dec8_swedish_ci", True), # 3 + ("cp850", "cp850_general_ci", True), # 4 + ("latin1", "latin1_german1_ci", False), # 5 + ("hp8", "hp8_english_ci", True), # 6 + ("koi8r", "koi8r_general_ci", True), # 7 + ("latin1", "latin1_swedish_ci", True), # 8 + ("latin2", "latin2_general_ci", True), # 9 + ("swe7", "swe7_swedish_ci", True), # 10 + ("ascii", "ascii_general_ci", True), # 11 + ("ujis", "ujis_japanese_ci", True), # 12 + ("sjis", "sjis_japanese_ci", True), # 13 + ("cp1251", "cp1251_bulgarian_ci", False), # 14 + ("latin1", "latin1_danish_ci", False), # 15 + ("hebrew", "hebrew_general_ci", True), # 16 + None, + ("tis620", "tis620_thai_ci", True), # 18 + ("euckr", "euckr_korean_ci", True), # 19 + ("latin7", "latin7_estonian_cs", False), # 20 + ("latin2", "latin2_hungarian_ci", False), # 21 + ("koi8u", "koi8u_general_ci", True), # 22 + ("cp1251", "cp1251_ukrainian_ci", False), # 23 + ("gb2312", "gb2312_chinese_ci", True), # 24 + ("greek", "greek_general_ci", True), # 25 + ("cp1250", "cp1250_general_ci", True), # 26 + ("latin2", "latin2_croatian_ci", False), # 27 + ("gbk", "gbk_chinese_ci", True), # 28 + ("cp1257", "cp1257_lithuanian_ci", False), # 29 + ("latin5", "latin5_turkish_ci", True), # 30 + ("latin1", "latin1_german2_ci", False), # 31 + ("armscii8", "armscii8_general_ci", True), # 32 + ("utf8mb3", "utf8mb3_general_ci", True), # 33 + ("cp1250", "cp1250_czech_cs", False), # 34 + ("ucs2", "ucs2_general_ci", True), # 35 + ("cp866", "cp866_general_ci", True), # 36 + ("keybcs2", "keybcs2_general_ci", True), # 37 + ("macce", "macce_general_ci", True), # 38 + ("macroman", "macroman_general_ci", True), # 39 + ("cp852", "cp852_general_ci", True), # 40 + ("latin7", "latin7_general_ci", True), # 41 + ("latin7", "latin7_general_cs", False), # 42 + ("macce", "macce_bin", False), # 43 + ("cp1250", "cp1250_croatian_ci", False), # 44 + ("utf8mb4", "utf8mb4_general_ci", False), # 45 + ("utf8mb4", "utf8mb4_bin", False), # 46 + ("latin1", "latin1_bin", False), # 47 + ("latin1", "latin1_general_ci", False), # 48 + ("latin1", "latin1_general_cs", False), # 49 + ("cp1251", "cp1251_bin", False), # 50 + ("cp1251", "cp1251_general_ci", True), # 51 + ("cp1251", "cp1251_general_cs", False), # 52 + ("macroman", "macroman_bin", False), # 53 + ("utf16", "utf16_general_ci", True), # 54 + ("utf16", "utf16_bin", False), # 55 + ("utf16le", "utf16le_general_ci", True), # 56 + ("cp1256", "cp1256_general_ci", True), # 57 + ("cp1257", "cp1257_bin", False), # 58 + ("cp1257", "cp1257_general_ci", True), # 59 + ("utf32", "utf32_general_ci", True), # 60 + ("utf32", "utf32_bin", False), # 61 + ("utf16le", "utf16le_bin", False), # 62 + ("binary", "binary", True), # 63 + ("armscii8", "armscii8_bin", False), # 64 + ("ascii", "ascii_bin", False), # 65 + ("cp1250", "cp1250_bin", False), # 66 + ("cp1256", "cp1256_bin", False), # 67 + ("cp866", "cp866_bin", False), # 68 + ("dec8", "dec8_bin", False), # 69 + ("greek", "greek_bin", False), # 70 + ("hebrew", "hebrew_bin", False), # 71 + ("hp8", "hp8_bin", False), # 72 + ("keybcs2", "keybcs2_bin", False), # 73 + ("koi8r", "koi8r_bin", False), # 74 + ("koi8u", "koi8u_bin", False), # 75 + ("utf8mb3", "utf8mb3_tolower_ci", False), # 76 + ("latin2", "latin2_bin", False), # 77 + ("latin5", "latin5_bin", False), # 78 + ("latin7", "latin7_bin", False), # 79 + ("cp850", "cp850_bin", False), # 80 + ("cp852", "cp852_bin", False), # 81 + ("swe7", "swe7_bin", False), # 82 + ("utf8mb3", "utf8mb3_bin", False), # 83 + ("big5", "big5_bin", False), # 84 + ("euckr", "euckr_bin", False), # 85 + ("gb2312", "gb2312_bin", False), # 86 + ("gbk", "gbk_bin", False), # 87 + ("sjis", "sjis_bin", False), # 88 + ("tis620", "tis620_bin", False), # 89 + ("ucs2", "ucs2_bin", False), # 90 + ("ujis", "ujis_bin", False), # 91 + ("geostd8", "geostd8_general_ci", True), # 92 + ("geostd8", "geostd8_bin", False), # 93 + ("latin1", "latin1_spanish_ci", False), # 94 + ("cp932", "cp932_japanese_ci", True), # 95 + ("cp932", "cp932_bin", False), # 96 + ("eucjpms", "eucjpms_japanese_ci", True), # 97 + ("eucjpms", "eucjpms_bin", False), # 98 + ("cp1250", "cp1250_polish_ci", False), # 99 + None, + ("utf16", "utf16_unicode_ci", False), # 101 + ("utf16", "utf16_icelandic_ci", False), # 102 + ("utf16", "utf16_latvian_ci", False), # 103 + ("utf16", "utf16_romanian_ci", False), # 104 + ("utf16", "utf16_slovenian_ci", False), # 105 + ("utf16", "utf16_polish_ci", False), # 106 + ("utf16", "utf16_estonian_ci", False), # 107 + ("utf16", "utf16_spanish_ci", False), # 108 + ("utf16", "utf16_swedish_ci", False), # 109 + ("utf16", "utf16_turkish_ci", False), # 110 + ("utf16", "utf16_czech_ci", False), # 111 + ("utf16", "utf16_danish_ci", False), # 112 + ("utf16", "utf16_lithuanian_ci", False), # 113 + ("utf16", "utf16_slovak_ci", False), # 114 + ("utf16", "utf16_spanish2_ci", False), # 115 + ("utf16", "utf16_roman_ci", False), # 116 + ("utf16", "utf16_persian_ci", False), # 117 + ("utf16", "utf16_esperanto_ci", False), # 118 + ("utf16", "utf16_hungarian_ci", False), # 119 + ("utf16", "utf16_sinhala_ci", False), # 120 + ("utf16", "utf16_german2_ci", False), # 121 + ("utf16", "utf16_croatian_ci", False), # 122 + ("utf16", "utf16_unicode_520_ci", False), # 123 + ("utf16", "utf16_vietnamese_ci", False), # 124 + None, + None, + None, + ("ucs2", "ucs2_unicode_ci", False), # 128 + ("ucs2", "ucs2_icelandic_ci", False), # 129 + ("ucs2", "ucs2_latvian_ci", False), # 130 + ("ucs2", "ucs2_romanian_ci", False), # 131 + ("ucs2", "ucs2_slovenian_ci", False), # 132 + ("ucs2", "ucs2_polish_ci", False), # 133 + ("ucs2", "ucs2_estonian_ci", False), # 134 + ("ucs2", "ucs2_spanish_ci", False), # 135 + ("ucs2", "ucs2_swedish_ci", False), # 136 + ("ucs2", "ucs2_turkish_ci", False), # 137 + ("ucs2", "ucs2_czech_ci", False), # 138 + ("ucs2", "ucs2_danish_ci", False), # 139 + ("ucs2", "ucs2_lithuanian_ci", False), # 140 + ("ucs2", "ucs2_slovak_ci", False), # 141 + ("ucs2", "ucs2_spanish2_ci", False), # 142 + ("ucs2", "ucs2_roman_ci", False), # 143 + ("ucs2", "ucs2_persian_ci", False), # 144 + ("ucs2", "ucs2_esperanto_ci", False), # 145 + ("ucs2", "ucs2_hungarian_ci", False), # 146 + ("ucs2", "ucs2_sinhala_ci", False), # 147 + ("ucs2", "ucs2_german2_ci", False), # 148 + ("ucs2", "ucs2_croatian_ci", False), # 149 + ("ucs2", "ucs2_unicode_520_ci", False), # 150 + ("ucs2", "ucs2_vietnamese_ci", False), # 151 + None, + None, + None, + None, + None, + None, + None, + ("ucs2", "ucs2_general_mysql500_ci", False), # 159 + ("utf32", "utf32_unicode_ci", False), # 160 + ("utf32", "utf32_icelandic_ci", False), # 161 + ("utf32", "utf32_latvian_ci", False), # 162 + ("utf32", "utf32_romanian_ci", False), # 163 + ("utf32", "utf32_slovenian_ci", False), # 164 + ("utf32", "utf32_polish_ci", False), # 165 + ("utf32", "utf32_estonian_ci", False), # 166 + ("utf32", "utf32_spanish_ci", False), # 167 + ("utf32", "utf32_swedish_ci", False), # 168 + ("utf32", "utf32_turkish_ci", False), # 169 + ("utf32", "utf32_czech_ci", False), # 170 + ("utf32", "utf32_danish_ci", False), # 171 + ("utf32", "utf32_lithuanian_ci", False), # 172 + ("utf32", "utf32_slovak_ci", False), # 173 + ("utf32", "utf32_spanish2_ci", False), # 174 + ("utf32", "utf32_roman_ci", False), # 175 + ("utf32", "utf32_persian_ci", False), # 176 + ("utf32", "utf32_esperanto_ci", False), # 177 + ("utf32", "utf32_hungarian_ci", False), # 178 + ("utf32", "utf32_sinhala_ci", False), # 179 + ("utf32", "utf32_german2_ci", False), # 180 + ("utf32", "utf32_croatian_ci", False), # 181 + ("utf32", "utf32_unicode_520_ci", False), # 182 + ("utf32", "utf32_vietnamese_ci", False), # 183 + None, + None, + None, + None, + None, + None, + None, + None, + ("utf8mb3", "utf8mb3_unicode_ci", False), # 192 + ("utf8mb3", "utf8mb3_icelandic_ci", False), # 193 + ("utf8mb3", "utf8mb3_latvian_ci", False), # 194 + ("utf8mb3", "utf8mb3_romanian_ci", False), # 195 + ("utf8mb3", "utf8mb3_slovenian_ci", False), # 196 + ("utf8mb3", "utf8mb3_polish_ci", False), # 197 + ("utf8mb3", "utf8mb3_estonian_ci", False), # 198 + ("utf8mb3", "utf8mb3_spanish_ci", False), # 199 + ("utf8mb3", "utf8mb3_swedish_ci", False), # 200 + ("utf8mb3", "utf8mb3_turkish_ci", False), # 201 + ("utf8mb3", "utf8mb3_czech_ci", False), # 202 + ("utf8mb3", "utf8mb3_danish_ci", False), # 203 + ("utf8mb3", "utf8mb3_lithuanian_ci", False), # 204 + ("utf8mb3", "utf8mb3_slovak_ci", False), # 205 + ("utf8mb3", "utf8mb3_spanish2_ci", False), # 206 + ("utf8mb3", "utf8mb3_roman_ci", False), # 207 + ("utf8mb3", "utf8mb3_persian_ci", False), # 208 + ("utf8mb3", "utf8mb3_esperanto_ci", False), # 209 + ("utf8mb3", "utf8mb3_hungarian_ci", False), # 210 + ("utf8mb3", "utf8mb3_sinhala_ci", False), # 211 + ("utf8mb3", "utf8mb3_german2_ci", False), # 212 + ("utf8mb3", "utf8mb3_croatian_ci", False), # 213 + ("utf8mb3", "utf8mb3_unicode_520_ci", False), # 214 + ("utf8mb3", "utf8mb3_vietnamese_ci", False), # 215 + None, + None, + None, + None, + None, + None, + None, + ("utf8mb3", "utf8mb3_general_mysql500_ci", False), # 223 + ("utf8mb4", "utf8mb4_unicode_ci", False), # 224 + ("utf8mb4", "utf8mb4_icelandic_ci", False), # 225 + ("utf8mb4", "utf8mb4_latvian_ci", False), # 226 + ("utf8mb4", "utf8mb4_romanian_ci", False), # 227 + ("utf8mb4", "utf8mb4_slovenian_ci", False), # 228 + ("utf8mb4", "utf8mb4_polish_ci", False), # 229 + ("utf8mb4", "utf8mb4_estonian_ci", False), # 230 + ("utf8mb4", "utf8mb4_spanish_ci", False), # 231 + ("utf8mb4", "utf8mb4_swedish_ci", False), # 232 + ("utf8mb4", "utf8mb4_turkish_ci", False), # 233 + ("utf8mb4", "utf8mb4_czech_ci", False), # 234 + ("utf8mb4", "utf8mb4_danish_ci", False), # 235 + ("utf8mb4", "utf8mb4_lithuanian_ci", False), # 236 + ("utf8mb4", "utf8mb4_slovak_ci", False), # 237 + ("utf8mb4", "utf8mb4_spanish2_ci", False), # 238 + ("utf8mb4", "utf8mb4_roman_ci", False), # 239 + ("utf8mb4", "utf8mb4_persian_ci", False), # 240 + ("utf8mb4", "utf8mb4_esperanto_ci", False), # 241 + ("utf8mb4", "utf8mb4_hungarian_ci", False), # 242 + ("utf8mb4", "utf8mb4_sinhala_ci", False), # 243 + ("utf8mb4", "utf8mb4_german2_ci", False), # 244 + ("utf8mb4", "utf8mb4_croatian_ci", False), # 245 + ("utf8mb4", "utf8mb4_unicode_520_ci", False), # 246 + ("utf8mb4", "utf8mb4_vietnamese_ci", False), # 247 + ("gb18030", "gb18030_chinese_ci", True), # 248 + ("gb18030", "gb18030_bin", False), # 249 + ("gb18030", "gb18030_unicode_520_ci", False), # 250 + None, + None, + None, + None, + ("utf8mb4", "utf8mb4_0900_ai_ci", True), # 255 + ("utf8mb4", "utf8mb4_de_pb_0900_ai_ci", False), # 256 + ("utf8mb4", "utf8mb4_is_0900_ai_ci", False), # 257 + ("utf8mb4", "utf8mb4_lv_0900_ai_ci", False), # 258 + ("utf8mb4", "utf8mb4_ro_0900_ai_ci", False), # 259 + ("utf8mb4", "utf8mb4_sl_0900_ai_ci", False), # 260 + ("utf8mb4", "utf8mb4_pl_0900_ai_ci", False), # 261 + ("utf8mb4", "utf8mb4_et_0900_ai_ci", False), # 262 + ("utf8mb4", "utf8mb4_es_0900_ai_ci", False), # 263 + ("utf8mb4", "utf8mb4_sv_0900_ai_ci", False), # 264 + ("utf8mb4", "utf8mb4_tr_0900_ai_ci", False), # 265 + ("utf8mb4", "utf8mb4_cs_0900_ai_ci", False), # 266 + ("utf8mb4", "utf8mb4_da_0900_ai_ci", False), # 267 + ("utf8mb4", "utf8mb4_lt_0900_ai_ci", False), # 268 + ("utf8mb4", "utf8mb4_sk_0900_ai_ci", False), # 269 + ("utf8mb4", "utf8mb4_es_trad_0900_ai_ci", False), # 270 + ("utf8mb4", "utf8mb4_la_0900_ai_ci", False), # 271 + None, + ("utf8mb4", "utf8mb4_eo_0900_ai_ci", False), # 273 + ("utf8mb4", "utf8mb4_hu_0900_ai_ci", False), # 274 + ("utf8mb4", "utf8mb4_hr_0900_ai_ci", False), # 275 + None, + ("utf8mb4", "utf8mb4_vi_0900_ai_ci", False), # 277 + ("utf8mb4", "utf8mb4_0900_as_cs", False), # 278 + ("utf8mb4", "utf8mb4_de_pb_0900_as_cs", False), # 279 + ("utf8mb4", "utf8mb4_is_0900_as_cs", False), # 280 + ("utf8mb4", "utf8mb4_lv_0900_as_cs", False), # 281 + ("utf8mb4", "utf8mb4_ro_0900_as_cs", False), # 282 + ("utf8mb4", "utf8mb4_sl_0900_as_cs", False), # 283 + ("utf8mb4", "utf8mb4_pl_0900_as_cs", False), # 284 + ("utf8mb4", "utf8mb4_et_0900_as_cs", False), # 285 + ("utf8mb4", "utf8mb4_es_0900_as_cs", False), # 286 + ("utf8mb4", "utf8mb4_sv_0900_as_cs", False), # 287 + ("utf8mb4", "utf8mb4_tr_0900_as_cs", False), # 288 + ("utf8mb4", "utf8mb4_cs_0900_as_cs", False), # 289 + ("utf8mb4", "utf8mb4_da_0900_as_cs", False), # 290 + ("utf8mb4", "utf8mb4_lt_0900_as_cs", False), # 291 + ("utf8mb4", "utf8mb4_sk_0900_as_cs", False), # 292 + ("utf8mb4", "utf8mb4_es_trad_0900_as_cs", False), # 293 + ("utf8mb4", "utf8mb4_la_0900_as_cs", False), # 294 + None, + ("utf8mb4", "utf8mb4_eo_0900_as_cs", False), # 296 + ("utf8mb4", "utf8mb4_hu_0900_as_cs", False), # 297 + ("utf8mb4", "utf8mb4_hr_0900_as_cs", False), # 298 + None, + ("utf8mb4", "utf8mb4_vi_0900_as_cs", False), # 300 + None, + None, + ("utf8mb4", "utf8mb4_ja_0900_as_cs", False), # 303 + ("utf8mb4", "utf8mb4_ja_0900_as_cs_ks", False), # 304 + ("utf8mb4", "utf8mb4_0900_as_ci", False), # 305 + ("utf8mb4", "utf8mb4_ru_0900_ai_ci", False), # 306 + ("utf8mb4", "utf8mb4_ru_0900_as_cs", False), # 307 + ("utf8mb4", "utf8mb4_zh_0900_as_cs", False), # 308 + ("utf8mb4", "utf8mb4_0900_bin", False), # 309 + ("utf8mb4", "utf8mb4_nb_0900_ai_ci", False), # 310 + ("utf8mb4", "utf8mb4_nb_0900_as_cs", False), # 311 + ("utf8mb4", "utf8mb4_nn_0900_ai_ci", False), # 312 + ("utf8mb4", "utf8mb4_nn_0900_as_cs", False), # 313 + ("utf8mb4", "utf8mb4_sr_latn_0900_ai_ci", False), # 314 + ("utf8mb4", "utf8mb4_sr_latn_0900_as_cs", False), # 315 + ("utf8mb4", "utf8mb4_bs_0900_ai_ci", False), # 316 + ("utf8mb4", "utf8mb4_bs_0900_as_cs", False), # 317 + ("utf8mb4", "utf8mb4_bg_0900_ai_ci", False), # 318 + ("utf8mb4", "utf8mb4_bg_0900_as_cs", False), # 319 + ("utf8mb4", "utf8mb4_gl_0900_ai_ci", False), # 320 + ("utf8mb4", "utf8mb4_gl_0900_as_cs", False), # 321 + ("utf8mb4", "utf8mb4_mn_cyrl_0900_ai_ci", False), # 322 + ("utf8mb4", "utf8mb4_mn_cyrl_0900_as_cs", False), # 323 +] + +MYSQL_CHARACTER_SETS_57: List[Optional[Tuple[str, str, bool]]] = [ + # (character set name, collation, default) + None, + ("big5", "big5_chinese_ci", True), # 1 + ("latin2", "latin2_czech_cs", False), # 2 + ("dec8", "dec8_swedish_ci", True), # 3 + ("cp850", "cp850_general_ci", True), # 4 + ("latin1", "latin1_german1_ci", False), # 5 + ("hp8", "hp8_english_ci", True), # 6 + ("koi8r", "koi8r_general_ci", True), # 7 + ("latin1", "latin1_swedish_ci", True), # 8 + ("latin2", "latin2_general_ci", True), # 9 + ("swe7", "swe7_swedish_ci", True), # 10 + ("ascii", "ascii_general_ci", True), # 11 + ("ujis", "ujis_japanese_ci", True), # 12 + ("sjis", "sjis_japanese_ci", True), # 13 + ("cp1251", "cp1251_bulgarian_ci", False), # 14 + ("latin1", "latin1_danish_ci", False), # 15 + ("hebrew", "hebrew_general_ci", True), # 16 + None, + ("tis620", "tis620_thai_ci", True), # 18 + ("euckr", "euckr_korean_ci", True), # 19 + ("latin7", "latin7_estonian_cs", False), # 20 + ("latin2", "latin2_hungarian_ci", False), # 21 + ("koi8u", "koi8u_general_ci", True), # 22 + ("cp1251", "cp1251_ukrainian_ci", False), # 23 + ("gb2312", "gb2312_chinese_ci", True), # 24 + ("greek", "greek_general_ci", True), # 25 + ("cp1250", "cp1250_general_ci", True), # 26 + ("latin2", "latin2_croatian_ci", False), # 27 + ("gbk", "gbk_chinese_ci", True), # 28 + ("cp1257", "cp1257_lithuanian_ci", False), # 29 + ("latin5", "latin5_turkish_ci", True), # 30 + ("latin1", "latin1_german2_ci", False), # 31 + ("armscii8", "armscii8_general_ci", True), # 32 + ("utf8", "utf8_general_ci", True), # 33 + ("cp1250", "cp1250_czech_cs", False), # 34 + ("ucs2", "ucs2_general_ci", True), # 35 + ("cp866", "cp866_general_ci", True), # 36 + ("keybcs2", "keybcs2_general_ci", True), # 37 + ("macce", "macce_general_ci", True), # 38 + ("macroman", "macroman_general_ci", True), # 39 + ("cp852", "cp852_general_ci", True), # 40 + ("latin7", "latin7_general_ci", True), # 41 + ("latin7", "latin7_general_cs", False), # 42 + ("macce", "macce_bin", False), # 43 + ("cp1250", "cp1250_croatian_ci", False), # 44 + ("utf8mb4", "utf8mb4_general_ci", True), # 45 + ("utf8mb4", "utf8mb4_bin", False), # 46 + ("latin1", "latin1_bin", False), # 47 + ("latin1", "latin1_general_ci", False), # 48 + ("latin1", "latin1_general_cs", False), # 49 + ("cp1251", "cp1251_bin", False), # 50 + ("cp1251", "cp1251_general_ci", True), # 51 + ("cp1251", "cp1251_general_cs", False), # 52 + ("macroman", "macroman_bin", False), # 53 + ("utf16", "utf16_general_ci", True), # 54 + ("utf16", "utf16_bin", False), # 55 + ("utf16le", "utf16le_general_ci", True), # 56 + ("cp1256", "cp1256_general_ci", True), # 57 + ("cp1257", "cp1257_bin", False), # 58 + ("cp1257", "cp1257_general_ci", True), # 59 + ("utf32", "utf32_general_ci", True), # 60 + ("utf32", "utf32_bin", False), # 61 + ("utf16le", "utf16le_bin", False), # 62 + ("binary", "binary", True), # 63 + ("armscii8", "armscii8_bin", False), # 64 + ("ascii", "ascii_bin", False), # 65 + ("cp1250", "cp1250_bin", False), # 66 + ("cp1256", "cp1256_bin", False), # 67 + ("cp866", "cp866_bin", False), # 68 + ("dec8", "dec8_bin", False), # 69 + ("greek", "greek_bin", False), # 70 + ("hebrew", "hebrew_bin", False), # 71 + ("hp8", "hp8_bin", False), # 72 + ("keybcs2", "keybcs2_bin", False), # 73 + ("koi8r", "koi8r_bin", False), # 74 + ("koi8u", "koi8u_bin", False), # 75 + None, + ("latin2", "latin2_bin", False), # 77 + ("latin5", "latin5_bin", False), # 78 + ("latin7", "latin7_bin", False), # 79 + ("cp850", "cp850_bin", False), # 80 + ("cp852", "cp852_bin", False), # 81 + ("swe7", "swe7_bin", False), # 82 + ("utf8", "utf8_bin", False), # 83 + ("big5", "big5_bin", False), # 84 + ("euckr", "euckr_bin", False), # 85 + ("gb2312", "gb2312_bin", False), # 86 + ("gbk", "gbk_bin", False), # 87 + ("sjis", "sjis_bin", False), # 88 + ("tis620", "tis620_bin", False), # 89 + ("ucs2", "ucs2_bin", False), # 90 + ("ujis", "ujis_bin", False), # 91 + ("geostd8", "geostd8_general_ci", True), # 92 + ("geostd8", "geostd8_bin", False), # 93 + ("latin1", "latin1_spanish_ci", False), # 94 + ("cp932", "cp932_japanese_ci", True), # 95 + ("cp932", "cp932_bin", False), # 96 + ("eucjpms", "eucjpms_japanese_ci", True), # 97 + ("eucjpms", "eucjpms_bin", False), # 98 + ("cp1250", "cp1250_polish_ci", False), # 99 + None, + ("utf16", "utf16_unicode_ci", False), # 101 + ("utf16", "utf16_icelandic_ci", False), # 102 + ("utf16", "utf16_latvian_ci", False), # 103 + ("utf16", "utf16_romanian_ci", False), # 104 + ("utf16", "utf16_slovenian_ci", False), # 105 + ("utf16", "utf16_polish_ci", False), # 106 + ("utf16", "utf16_estonian_ci", False), # 107 + ("utf16", "utf16_spanish_ci", False), # 108 + ("utf16", "utf16_swedish_ci", False), # 109 + ("utf16", "utf16_turkish_ci", False), # 110 + ("utf16", "utf16_czech_ci", False), # 111 + ("utf16", "utf16_danish_ci", False), # 112 + ("utf16", "utf16_lithuanian_ci", False), # 113 + ("utf16", "utf16_slovak_ci", False), # 114 + ("utf16", "utf16_spanish2_ci", False), # 115 + ("utf16", "utf16_roman_ci", False), # 116 + ("utf16", "utf16_persian_ci", False), # 117 + ("utf16", "utf16_esperanto_ci", False), # 118 + ("utf16", "utf16_hungarian_ci", False), # 119 + ("utf16", "utf16_sinhala_ci", False), # 120 + ("utf16", "utf16_german2_ci", False), # 121 + ("utf16", "utf16_croatian_ci", False), # 122 + ("utf16", "utf16_unicode_520_ci", False), # 123 + ("utf16", "utf16_vietnamese_ci", False), # 124 + None, + None, + None, + ("ucs2", "ucs2_unicode_ci", False), # 128 + ("ucs2", "ucs2_icelandic_ci", False), # 129 + ("ucs2", "ucs2_latvian_ci", False), # 130 + ("ucs2", "ucs2_romanian_ci", False), # 131 + ("ucs2", "ucs2_slovenian_ci", False), # 132 + ("ucs2", "ucs2_polish_ci", False), # 133 + ("ucs2", "ucs2_estonian_ci", False), # 134 + ("ucs2", "ucs2_spanish_ci", False), # 135 + ("ucs2", "ucs2_swedish_ci", False), # 136 + ("ucs2", "ucs2_turkish_ci", False), # 137 + ("ucs2", "ucs2_czech_ci", False), # 138 + ("ucs2", "ucs2_danish_ci", False), # 139 + ("ucs2", "ucs2_lithuanian_ci", False), # 140 + ("ucs2", "ucs2_slovak_ci", False), # 141 + ("ucs2", "ucs2_spanish2_ci", False), # 142 + ("ucs2", "ucs2_roman_ci", False), # 143 + ("ucs2", "ucs2_persian_ci", False), # 144 + ("ucs2", "ucs2_esperanto_ci", False), # 145 + ("ucs2", "ucs2_hungarian_ci", False), # 146 + ("ucs2", "ucs2_sinhala_ci", False), # 147 + ("ucs2", "ucs2_german2_ci", False), # 148 + ("ucs2", "ucs2_croatian_ci", False), # 149 + ("ucs2", "ucs2_unicode_520_ci", False), # 150 + ("ucs2", "ucs2_vietnamese_ci", False), # 151 + None, + None, + None, + None, + None, + None, + None, + ("ucs2", "ucs2_general_mysql500_ci", False), # 159 + ("utf32", "utf32_unicode_ci", False), # 160 + ("utf32", "utf32_icelandic_ci", False), # 161 + ("utf32", "utf32_latvian_ci", False), # 162 + ("utf32", "utf32_romanian_ci", False), # 163 + ("utf32", "utf32_slovenian_ci", False), # 164 + ("utf32", "utf32_polish_ci", False), # 165 + ("utf32", "utf32_estonian_ci", False), # 166 + ("utf32", "utf32_spanish_ci", False), # 167 + ("utf32", "utf32_swedish_ci", False), # 168 + ("utf32", "utf32_turkish_ci", False), # 169 + ("utf32", "utf32_czech_ci", False), # 170 + ("utf32", "utf32_danish_ci", False), # 171 + ("utf32", "utf32_lithuanian_ci", False), # 172 + ("utf32", "utf32_slovak_ci", False), # 173 + ("utf32", "utf32_spanish2_ci", False), # 174 + ("utf32", "utf32_roman_ci", False), # 175 + ("utf32", "utf32_persian_ci", False), # 176 + ("utf32", "utf32_esperanto_ci", False), # 177 + ("utf32", "utf32_hungarian_ci", False), # 178 + ("utf32", "utf32_sinhala_ci", False), # 179 + ("utf32", "utf32_german2_ci", False), # 180 + ("utf32", "utf32_croatian_ci", False), # 181 + ("utf32", "utf32_unicode_520_ci", False), # 182 + ("utf32", "utf32_vietnamese_ci", False), # 183 + None, + None, + None, + None, + None, + None, + None, + None, + ("utf8", "utf8_unicode_ci", False), # 192 + ("utf8", "utf8_icelandic_ci", False), # 193 + ("utf8", "utf8_latvian_ci", False), # 194 + ("utf8", "utf8_romanian_ci", False), # 195 + ("utf8", "utf8_slovenian_ci", False), # 196 + ("utf8", "utf8_polish_ci", False), # 197 + ("utf8", "utf8_estonian_ci", False), # 198 + ("utf8", "utf8_spanish_ci", False), # 199 + ("utf8", "utf8_swedish_ci", False), # 200 + ("utf8", "utf8_turkish_ci", False), # 201 + ("utf8", "utf8_czech_ci", False), # 202 + ("utf8", "utf8_danish_ci", False), # 203 + ("utf8", "utf8_lithuanian_ci", False), # 204 + ("utf8", "utf8_slovak_ci", False), # 205 + ("utf8", "utf8_spanish2_ci", False), # 206 + ("utf8", "utf8_roman_ci", False), # 207 + ("utf8", "utf8_persian_ci", False), # 208 + ("utf8", "utf8_esperanto_ci", False), # 209 + ("utf8", "utf8_hungarian_ci", False), # 210 + ("utf8", "utf8_sinhala_ci", False), # 211 + ("utf8", "utf8_german2_ci", False), # 212 + ("utf8", "utf8_croatian_ci", False), # 213 + ("utf8", "utf8_unicode_520_ci", False), # 214 + ("utf8", "utf8_vietnamese_ci", False), # 215 + None, + None, + None, + None, + None, + None, + None, + ("utf8", "utf8_general_mysql500_ci", False), # 223 + ("utf8mb4", "utf8mb4_unicode_ci", False), # 224 + ("utf8mb4", "utf8mb4_icelandic_ci", False), # 225 + ("utf8mb4", "utf8mb4_latvian_ci", False), # 226 + ("utf8mb4", "utf8mb4_romanian_ci", False), # 227 + ("utf8mb4", "utf8mb4_slovenian_ci", False), # 228 + ("utf8mb4", "utf8mb4_polish_ci", False), # 229 + ("utf8mb4", "utf8mb4_estonian_ci", False), # 230 + ("utf8mb4", "utf8mb4_spanish_ci", False), # 231 + ("utf8mb4", "utf8mb4_swedish_ci", False), # 232 + ("utf8mb4", "utf8mb4_turkish_ci", False), # 233 + ("utf8mb4", "utf8mb4_czech_ci", False), # 234 + ("utf8mb4", "utf8mb4_danish_ci", False), # 235 + ("utf8mb4", "utf8mb4_lithuanian_ci", False), # 236 + ("utf8mb4", "utf8mb4_slovak_ci", False), # 237 + ("utf8mb4", "utf8mb4_spanish2_ci", False), # 238 + ("utf8mb4", "utf8mb4_roman_ci", False), # 239 + ("utf8mb4", "utf8mb4_persian_ci", False), # 240 + ("utf8mb4", "utf8mb4_esperanto_ci", False), # 241 + ("utf8mb4", "utf8mb4_hungarian_ci", False), # 242 + ("utf8mb4", "utf8mb4_sinhala_ci", False), # 243 + ("utf8mb4", "utf8mb4_german2_ci", False), # 244 + ("utf8mb4", "utf8mb4_croatian_ci", False), # 245 + ("utf8mb4", "utf8mb4_unicode_520_ci", False), # 246 + ("utf8mb4", "utf8mb4_vietnamese_ci", False), # 247 + ("gb18030", "gb18030_chinese_ci", True), # 248 + ("gb18030", "gb18030_bin", False), # 249 + ("gb18030", "gb18030_unicode_520_ci", False), # 250 +] diff --git a/server/venv/Lib/site-packages/mysql/connector/connection.py b/server/venv/Lib/site-packages/mysql/connector/connection.py new file mode 100644 index 0000000..662ebb6 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/connection.py @@ -0,0 +1,1784 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="arg-type,operator,attr-defined,assignment" + +"""Implementing communication with MySQL servers.""" +from __future__ import annotations + +import datetime +import getpass +import os +import socket +import struct +import sys +import warnings + +from decimal import Decimal +from io import IOBase +from typing import ( + TYPE_CHECKING, + Any, + BinaryIO, + Dict, + Generator, + List, + Mapping, + Optional, + Sequence, + Tuple, + Type, + Union, +) + +from . import version +from ._decorating import cmd_refresh_verify_options, handle_read_write_timeout +from ._scripting import get_local_infile_filenames +from .abstracts import MySQLConnectionAbstract +from .authentication import MySQLAuthenticator, get_auth_plugin +from .constants import ( + ClientFlag, + FieldType, + RefreshOption, + ServerCmd, + ServerFlag, + flag_is_set, +) +from .conversion import MySQLConverter +from .cursor import ( + MySQLCursor, + MySQLCursorBuffered, + MySQLCursorBufferedDict, + MySQLCursorBufferedRaw, + MySQLCursorDict, + MySQLCursorPrepared, + MySQLCursorPreparedDict, + MySQLCursorRaw, +) +from .errors import ( + ConnectionTimeoutError, + DatabaseError, + Error, + InterfaceError, + InternalError, + NotSupportedError, + OperationalError, + ProgrammingError, + ReadTimeoutError, + WriteTimeoutError, + get_exception, +) +from .logger import logger +from .network import MySQLSocket, MySQLTCPSocket, MySQLUnixSocket +from .opentelemetry.constants import OTEL_ENABLED +from .opentelemetry.context_propagation import with_context_propagation +from .protocol import ( + EOF_STATUS, + ERR_STATUS, + LOCAL_INFILE_STATUS, + OK_STATUS, + MySQLProtocol, +) +from .types import ( + BinaryProtocolType, + DescriptionType, + EofPacketType, + HandShakeType, + OkPacketType, + ResultType, + RowType, + StatsPacketType, + StrOrBytes, +) +from .utils import ( + get_platform, + int1store, + int4store, + lc_int, + warn_ciphersuites_deprecated, + warn_tls_version_deprecated, +) + +if TYPE_CHECKING: + from .abstracts import CMySQLPrepStmt + +if OTEL_ENABLED: + from .opentelemetry.instrumentation import end_span, record_exception_event + + +class MySQLConnection(MySQLConnectionAbstract): + """Connection to a MySQL Server""" + + def __init__(self, **kwargs: Any) -> None: + self._protocol: Optional[MySQLProtocol] = None + self._socket: Optional[MySQLSocket] = None + self._handshake: Optional[HandShakeType] = None + super().__init__() + + self._converter_class: Type[MySQLConverter] = MySQLConverter + + self._client_flags: int = ClientFlag.get_default() + self._sql_mode: Optional[str] = None + self._time_zone: Optional[str] = None + self._autocommit: bool = False + + self._user: str = "" + self._password: str = "" + self._database: str = "" + self._host: str = "127.0.0.1" + self._port: int = 3306 + self._unix_socket: Optional[str] = None + self._client_host: str = "" + self._client_port: int = 0 + self._ssl: Dict[str, Optional[Union[str, bool, List[str]]]] = {} + self._force_ipv6: bool = False + + self._use_unicode: bool = True + self._get_warnings: bool = False + self._raise_on_warnings: bool = False + self._buffered: bool = False + self._unread_result: bool = False + self._have_next_result: bool = False + self._raw: bool = False + self._in_transaction: bool = False + + self._prepared_statements: Any = None + + self._ssl_active: bool = False + self._auth_plugin: Optional[str] = None + self._krb_service_principal: Optional[str] = None + self._pool_config_version: Any = None + self._query_attrs_supported: int = False + + self._columns_desc: List[DescriptionType] = [] + self._mfa_nfactor: int = 1 + + self._authenticator: MySQLAuthenticator = MySQLAuthenticator() + + if kwargs: + try: + self.connect(**kwargs) + except Exception: + # Tidy-up underlying socket on failure + self.close() + self._socket = None + raise + + def _add_default_conn_attrs(self) -> None: + """Add the default connection attributes.""" + platform = get_platform() + license_chunks = version.LICENSE.split(" ") + if license_chunks[0] == "GPLv2": + client_license = "GPL-2.0" + else: + client_license = "Commercial" + default_conn_attrs = { + "_pid": str(os.getpid()), + "_platform": platform["arch"], + "_source_host": socket.gethostname(), + "_client_name": "mysql-connector-python", + "_client_license": client_license, + "_client_version": ".".join([str(x) for x in version.VERSION[0:3]]), + "_os": platform["version"], + } + + self._conn_attrs.update((default_conn_attrs)) + + def _do_handshake(self) -> None: + """Get the handshake from the MySQL server""" + packet = bytes(self._socket.recv()) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + + self._handshake = None + handshake = self._protocol.parse_handshake(packet) + + server_version = handshake["server_version_original"] + + self._server_version = self._check_server_version( + server_version + if isinstance(server_version, (str, bytes, bytearray)) + else "Unknown" + ) + self._character_set.set_mysql_version(self._server_version) + + if not handshake["capabilities"] & ClientFlag.SSL: + if not self.is_secure: + if self._auth_plugin == "mysql_clear_password": + raise InterfaceError( + "Clear password authentication is not supported over " + "insecure channels" + ) + if self._auth_plugin == "authentication_openid_connect_client": + raise InterfaceError( + "OpenID Connect authentication is not supported over " + "insecure channels" + ) + if self._ssl.get("verify_cert"): + raise InterfaceError( + "SSL is required but the server doesn't support it", + errno=2026, + ) + self._client_flags &= ~ClientFlag.SSL + elif not self._ssl_disabled: + self._client_flags |= ClientFlag.SSL + + if handshake["capabilities"] & ClientFlag.PLUGIN_AUTH: + self.client_flags = [ClientFlag.PLUGIN_AUTH] + + if handshake["capabilities"] & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + self._query_attrs_supported = True + self.client_flags = [ClientFlag.CLIENT_QUERY_ATTRIBUTES] + + if handshake["capabilities"] & ClientFlag.MULTI_FACTOR_AUTHENTICATION: + self.client_flags = [ClientFlag.MULTI_FACTOR_AUTHENTICATION] + + self._handshake = handshake + + def _do_auth( + self, + username: Optional[str] = None, + password: Optional[str] = None, + database: Optional[str] = None, + client_flags: int = 0, + ssl_options: Optional[Dict[str, Optional[Union[str, bool, List[str]]]]] = None, + conn_attrs: Optional[Dict[str, str]] = None, + ) -> bool: + """Authenticate with the MySQL server + + Authentication happens in two parts. We first send a response to the + handshake. The MySQL server will then send either an AuthSwitchRequest + or an error packet. + + Raises NotSupportedError when we get the old, insecure password + reply back. Raises any error coming from MySQL. + """ + if ( + self._auth_plugin.startswith("authentication_oci") + or ( + self._auth_plugin.startswith("authentication_kerberos") + and os.name == "nt" + ) + ) and not username: + username = getpass.getuser() + logger.debug( + "MySQL user is empty, OS user: %s will be used for %s", + username, + self._auth_plugin, + ) + + if self._password1 and password != self._password1: + password = self._password1 + + self._ssl_active = False + if not self._ssl_disabled and (client_flags & ClientFlag.SSL): + self._authenticator.setup_ssl( + self._socket, + self.server_host, + ssl_options, + charset=self._charset_id, + client_flags=client_flags, + ) + self._ssl_active = True + + # Add the custom configurations required by specific auth plugins + self._authenticator.update_plugin_config( + config={ + "krb_service_principal": self._krb_service_principal, + "oci_config_file": self._oci_config_file, + "oci_config_profile": self._oci_config_profile, + "webauthn_callback": self._webauthn_callback, + "openid_token_file": self._openid_token_file, + } + ) + + ok_pkt = self._authenticator.authenticate( + sock=self._socket, + handshake=self._handshake, + username=username, + password1=password, + password2=self._password2, + password3=self._password3, + database=database, + charset=self._charset_id, + client_flags=client_flags, + auth_plugin=self._auth_plugin, + auth_plugin_class=self._auth_plugin_class, + conn_attrs=conn_attrs, + ) + self._handle_ok(ok_pkt) + + if not (client_flags & ClientFlag.CONNECT_WITH_DB) and database: + self.cmd_init_db(database) + + return True + + def _get_connection(self) -> MySQLSocket: + """Get connection based on configuration + + This method will return the appropriated connection object using + the connection parameters. + + Returns subclass of MySQLBaseSocket. + """ + conn = None + if self._unix_socket and os.name == "posix": + conn = MySQLUnixSocket(unix_socket=self.unix_socket) + else: + conn = MySQLTCPSocket( + host=self.server_host, + port=self.server_port, + force_ipv6=self._force_ipv6, + ) + + conn.set_connection_timeout(self._connection_timeout) + return conn + + def _open_connection(self) -> None: + """Open the connection to the MySQL server + + This method sets up and opens the connection to the MySQL server. + + Raises on errors. + """ + # setting connection's read and write timeout to None temporarily + # till connections is established successfully. + stored_read_timeout = self.read_timeout + stored_write_timeout = self.write_timeout + self.read_timeout = self.write_timeout = None + + if self._auth_plugin == "authentication_kerberos_client" and not self._user: + cls = get_auth_plugin(self._auth_plugin, self._auth_plugin_class) + self._user = cls.get_user_from_credentials() + + self._protocol = MySQLProtocol() + self._socket = self._get_connection() + try: + self._socket.open_connection() + + # do initial handshake + self._do_handshake() + + # start authentication negotiation + self._do_auth( + self._user, + self._password, + self._database, + self._client_flags, + self._ssl, + self._conn_attrs, + ) + self.converter_class = self._converter_class + + if self._client_flags & ClientFlag.COMPRESS: + # update the network layer accordingly + self._socket.switch_to_compressed_mode() + + self._socket.set_connection_timeout(None) + except Exception as err: + # close socket + self._socket.close_connection() + if isinstance(err, (ReadTimeoutError, WriteTimeoutError)): + raise ConnectionTimeoutError( + errno=err.errno, + msg=err.msg, + ) from err + raise err + finally: + # as the connection is established, set back the read + # and write timeouts to the original value + self.read_timeout = stored_read_timeout + self.write_timeout = stored_write_timeout + + if ( + not self._ssl_disabled + and hasattr(self._socket.sock, "cipher") + and callable(self._socket.sock.cipher) + ): + # Raise a deprecation warning if deprecated TLS version + # or cipher is being used. + + # `cipher()` returns a three-value tuple containing the name + # of the cipher being used, the version of the SSL protocol + # that defines its use, and the number of secret bits being used. + cipher, tls_version, _ = self._socket.sock.cipher() + warn_tls_version_deprecated(tls_version) + warn_ciphersuites_deprecated(cipher, tls_version) + + def shutdown(self) -> None: + """Shut down connection to MySQL Server. + + This method closes the socket. It raises no exceptions. + + Unlike `disconnect()`, `shutdown()` closes the client connection without + attempting to send a QUIT command to the server first. Thus, it will not + block if the connection is disrupted for some reason such as network failure. + """ + if not self._socket: + return + + try: + self._socket.shutdown() + except (AttributeError, Error): + pass # Getting an exception would mean we are disconnected. + + def close(self) -> None: + if self._span and self._span.is_recording(): + # pylint: disable=possibly-used-before-assignment + record_exception_event(self._span, sys.exc_info()[1]) + + if not self._socket: + return + + try: + self.cmd_quit() + except (AttributeError, Error): + pass # Getting an exception would mean we are disconnected. + + try: + self._socket.close_connection() + except Exception as err: + if OTEL_ENABLED: + record_exception_event(self._span, err) + raise + finally: + if OTEL_ENABLED: + end_span(self._span) + + self._handshake = None + + disconnect = close + + @handle_read_write_timeout() + def _send_cmd( + self, + command: int, + argument: Optional[bytes] = None, + packet_number: int = 0, + packet: Optional[bytes] = None, + expect_response: bool = True, + compressed_packet_number: int = 0, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> Optional[bytearray]: + """Send a command to the MySQL server + + This method sends a command with an optional argument. + If packet is not None, it will be sent and the argument will be + ignored. + + The packet_number is optional and should usually not be used. + + Some commands might not result in the MySQL server returning + a response. If a command does not return anything, you should + set expect_response to False. The _send_cmd method will then + return None instead of a MySQL packet. + + Returns a MySQL packet or None. + """ + self.handle_unread_result() + + try: + self._socket.send( + self._protocol.make_command(command, packet or argument), + packet_number, + compressed_packet_number, + write_timeout or self._write_timeout, + ) + return ( + self._socket.recv(read_timeout or self._read_timeout) + if expect_response + else None + ) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + @handle_read_write_timeout() + def _send_data( + self, + data_file: BinaryIO, + send_empty_packet: bool = False, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> bytearray: + """Send data to the MySQL server + + This method accepts a file-like object and sends its data + as is to the MySQL server. If the send_empty_packet is + True, it will send an extra empty package (for example + when using LOAD LOCAL DATA INFILE). + + Returns a MySQL packet. + """ + self.handle_unread_result() + + if not hasattr(data_file, "read"): + raise ValueError("expecting a file-like object") + + chunk_size = 131072 # 128 KB + try: + buf = data_file.read(chunk_size - 16) + while buf: + self._socket.send( + buf, write_timeout=write_timeout or self._write_timeout + ) + buf = data_file.read(chunk_size - 16) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + if send_empty_packet: + try: + self._socket.send( + b"", write_timeout=write_timeout or self._write_timeout + ) + except WriteTimeoutError as err: + raise err + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + return self._socket.recv(read_timeout or self._read_timeout) + + def _handle_server_status(self, flags: int) -> None: + """Handle the server flags found in MySQL packets + + This method handles the server flags send by MySQL OK and EOF + packets. It, for example, checks whether there exists more result + sets or whether there is an ongoing transaction. + """ + self._have_next_result = flag_is_set(ServerFlag.MORE_RESULTS_EXISTS, flags) + self._in_transaction = flag_is_set(ServerFlag.STATUS_IN_TRANS, flags) + + @property + def in_transaction(self) -> bool: + """MySQL session has started a transaction""" + return self._in_transaction + + def _handle_ok(self, packet: bytes) -> OkPacketType: + """Handle a MySQL OK packet + + This method handles a MySQL OK packet. When the packet is found to + be an Error packet, an error will be raised. If the packet is neither + an OK or an Error packet, InterfaceError will be raised. + + Returns a dict() + """ + if packet[4] == OK_STATUS: + ok_pkt = self._protocol.parse_ok(packet) + self._handle_server_status(ok_pkt["status_flag"]) + return ok_pkt + if packet[4] == ERR_STATUS: + raise get_exception(packet) + raise InterfaceError("Expected OK packet") + + def _handle_eof(self, packet: bytes) -> EofPacketType: + """Handle a MySQL EOF packet + + This method handles a MySQL EOF packet. When the packet is found to + be an Error packet, an error will be raised. If the packet is neither + and OK or an Error packet, InterfaceError will be raised. + + Returns a dict() + """ + if packet[4] == EOF_STATUS: + eof = self._protocol.parse_eof(packet) + self._handle_server_status(eof["status_flag"]) + return eof + if packet[4] == ERR_STATUS: + raise get_exception(packet) + raise InterfaceError("Expected EOF packet") + + @handle_read_write_timeout() + def _handle_load_data_infile( + self, + filename: str, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> OkPacketType: + """Handle a LOAD DATA INFILE LOCAL request""" + if self._local_infile_filenames is None: + self._local_infile_filenames = get_local_infile_filenames(self._query) + if not self._local_infile_filenames: + raise InterfaceError( + "No `LOCAL INFILE` statements found in the client's request. " + "Check your request includes valid `LOCAL INFILE` statements." + ) + elif not self._local_infile_filenames: + raise InterfaceError( + "Got more `LOCAL INFILE` responses than number of `LOCAL INFILE` " + "statements specified in the client's request. Please, report this " + "issue to the development team." + ) + + file_name = os.path.abspath(filename) + file_name_from_request = os.path.abspath(self._local_infile_filenames.popleft()) + + # Verify the file location specified by `filename` from client's request exists + if not os.path.exists(file_name_from_request): + raise InterfaceError( + f"Location specified by filename {file_name_from_request} " + "from client's request does not exist." + ) + + # Verify the file location specified by `filename` from server's response exists + if not os.path.exists(file_name): + raise InterfaceError( + f"Location specified by filename {file_name} from server's " + "response does not exist." + ) + + # Verify the `filename` specified by server's response matches the one from + # the client's request. + try: + if not os.path.samefile(file_name, file_name_from_request): + raise InterfaceError( + f"Filename {file_name} from the server's response is not the same " + f"as filename {file_name_from_request} from the " + "client's request." + ) + except OSError as err: + raise InterfaceError from err + + if os.path.islink(file_name): + raise OperationalError("Use of symbolic link is not allowed") + if not self._allow_local_infile and not self._allow_local_infile_in_path: + raise DatabaseError( + "LOAD DATA LOCAL INFILE file request rejected due to " + "restrictions on access." + ) + if not self._allow_local_infile and self._allow_local_infile_in_path: + # validate filename is inside of allow_local_infile_in_path path. + infile_path = os.path.abspath(self._allow_local_infile_in_path) + c_path = None + try: + c_path = os.path.commonpath([infile_path, file_name]) + except ValueError as err: + err_msg = ( + "{} while loading file `{}` and path `{}` given" + " in allow_local_infile_in_path" + ) + raise InterfaceError( + err_msg.format(str(err), file_name, infile_path) + ) from err + + if c_path != infile_path: + err_msg = ( + "The file `{}` is not found in the given " + "allow_local_infile_in_path {}" + ) + raise DatabaseError(err_msg.format(file_name, infile_path)) + + try: + data_file = open(file_name, "rb") # pylint: disable=consider-using-with + return self._handle_ok( + self._send_data(data_file, True, read_timeout, write_timeout) + ) + except IOError: + # Send a empty packet to cancel the operation + try: + self._socket.send( + b"", write_timeout=write_timeout or self._write_timeout + ) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + raise InterfaceError(f"File '{file_name}' could not be read") from None + finally: + try: + data_file.close() + except (IOError, NameError): + pass + + @handle_read_write_timeout() + def _handle_result( + self, + packet: bytes, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> ResultType: + """Handle a MySQL Result + + This method handles a MySQL result, for example, after sending the + query command. OK and EOF packets will be handled and returned. If + the packet is an Error packet, an Error-exception will be + raised. + + The dictionary returned of: + - columns: column information + - eof: the EOF-packet information + + Returns a dict() + """ + if not packet or len(packet) < 4: + raise InterfaceError("Empty response") + if packet[4] == OK_STATUS: + return self._handle_ok(packet) + if packet[4] == LOCAL_INFILE_STATUS: + filename = packet[5:].decode() + return self._handle_load_data_infile(filename, read_timeout, write_timeout) + if packet[4] == EOF_STATUS: + return self._handle_eof(packet) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + + # We have a text result set + column_count = self._protocol.parse_column_count(packet) + if not column_count or not isinstance(column_count, int): + raise InterfaceError("Illegal result set") + + self._columns_desc = [ + None, + ] * column_count + for i in range(0, column_count): + self._columns_desc[i] = self._protocol.parse_column( + self._socket.recv(read_timeout or self._read_timeout), + self.python_charset, + ) + + eof = self._handle_eof(self._socket.recv(read_timeout or self._read_timeout)) + self.unread_result = True + return {"columns": self._columns_desc, "eof": eof} + + def get_row( + self, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Optional[CMySQLPrepStmt] = None, + **kwargs: Any, + ) -> Tuple[Optional[RowType], Optional[EofPacketType]]: + """Get the next rows returned by the MySQL server + + This method gets one row from the result set after sending, for + example, the query command. The result is a tuple consisting of the + row and the EOF packet. + If no row was available in the result set, the row data will be None. + + Returns a tuple. + """ + read_timeout = kwargs.get("read_timeout", None) + (rows, eof) = self.get_rows( + count=1, + binary=binary, + columns=columns, + raw=raw, + read_timeout=read_timeout, + ) + if rows: + return (rows[0], eof) + return (None, eof) + + @handle_read_write_timeout() + def get_rows( + self, + count: Optional[int] = None, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Optional[CMySQLPrepStmt] = None, + **kwargs: Any, + ) -> Tuple[List[RowType], Optional[EofPacketType]]: + """Get all rows returned by the MySQL server + + This method gets all rows returned by the MySQL server after sending, + for example, the query command. The result is a tuple consisting of + a list of rows and the EOF packet. + + Returns a tuple() + """ + if raw is None: + raw = self._raw + + if not self.unread_result: + raise InternalError("No result set available") + + rows = ([], None) # type: ignore[var-annotated] + try: + read_timeout = kwargs.get("read_timeout", None) + if binary: + charset = self.charset + if charset == "utf8mb4": + charset = "utf8" + rows = self._protocol.read_binary_result( + self._socket, + columns, + count, + charset, + read_timeout or self._read_timeout, + ) + else: + rows = self._protocol.read_text_result( + self._socket, + self._server_version, + count, + read_timeout or self._read_timeout, + ) + except Error as err: + self.unread_result = False + raise err + + rows, eof_p = rows + if ( + not (binary or raw) + and self._columns_desc is not None + and rows + and hasattr(self, "converter") + ): + row_to_python = self.converter.row_to_python + rows = [row_to_python(row, self._columns_desc) for row in rows] + + if eof_p is not None: + self._handle_server_status( + eof_p["status_flag"] + if "status_flag" in eof_p + else eof_p["server_status"] + ) + self.unread_result = False + + return rows, eof_p + + def consume_results(self) -> None: + """Consume results""" + if self.unread_result: + self.get_rows() + + def cmd_init_db(self, database: str) -> OkPacketType: + """Change the current database + + This method changes the current (default) database by sending the + INIT_DB command. The result is a dictionary containing the OK packet + information. + + Returns a dict() + """ + return self._handle_ok( + self._send_cmd(ServerCmd.INIT_DB, database.encode("utf-8")) + ) + + @with_context_propagation + @handle_read_write_timeout() + def cmd_query( + self, + query: StrOrBytes, + raw: bool = False, + buffered: bool = False, + raw_as_string: bool = False, + **kwargs: Any, + ) -> ResultType: + if not isinstance(query, bytearray): + if isinstance(query, str): + query = query.encode("utf-8") + query = bytearray(query) + + # Set/Reset internal state related to query execution + self._query = query + self._local_infile_filenames = None + + # Prepare query attrs + charset = self.charset if self.charset != "utf8mb4" else "utf8" + packet = bytearray() + if not self._query_attrs_supported and self._query_attrs: + warnings.warn( + "This version of the server does not support Query Attributes", + category=Warning, + ) + if self._client_flags & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + names = [] + types = [] + values: List[bytes] = [] + null_bitmap = [0] * ((len(self._query_attrs) + 7) // 8) + for pos, attr_tuple in enumerate(self._query_attrs.items()): + value = attr_tuple[1] + flags = 0 + if value is None: + null_bitmap[(pos // 8)] |= 1 << (pos % 8) + types.append(int1store(FieldType.NULL) + int1store(flags)) + continue + if isinstance(value, int): + ( + packed, + field_type, + flags, + ) = self._protocol.prepare_binary_integer(value) + values.append(packed) + elif isinstance(value, str): + value = value.encode(charset) + values.append(lc_int(len(value)) + value) + field_type = FieldType.STRING + elif isinstance(value, bytes): + values.append(lc_int(len(value)) + value) + field_type = FieldType.STRING + elif isinstance(value, Decimal): + values.append( + lc_int(len(str(value).encode(charset))) + + str(value).encode(charset) + ) + field_type = FieldType.DECIMAL + elif isinstance(value, float): + values.append(struct.pack(" parameter_count Number of parameters + packet.extend(lc_int(len(self._query_attrs))) + # int parameter_set_count Number of parameter sets. + # Currently always 1 + packet.extend(lc_int(1)) + if values: + packet.extend( + b"".join([struct.pack("B", bit) for bit in null_bitmap]) + + int1store(1) + ) + for _type, name in zip(types, names): + packet.extend(_type) + packet.extend(name) + + for value in values: + packet.extend(value) + + packet.extend(query) + query = bytes(packet) + try: + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + + result = self._handle_result( + self._send_cmd( + ServerCmd.QUERY, + query, + read_timeout=read_timeout, + write_timeout=write_timeout, + ), + read_timeout, + write_timeout, + ) + except ProgrammingError as err: + if err.errno == 3948 and "Loading local data is disabled" in err.msg: + err_msg = ( + "LOAD DATA LOCAL INFILE file request rejected due " + "to restrictions on access." + ) + raise DatabaseError(err_msg) from err + raise + return result + + @handle_read_write_timeout() + def cmd_query_iter( + self, + statements: StrOrBytes, + **kwargs: Any, + ) -> Generator[ResultType, None, None]: + """Send one or more statements to the MySQL server + + Similar to the cmd_query method, but instead returns a generator + object to iterate through results. It sends the statements to the + MySQL server and through the iterator you can get the results. + + statement = 'SELECT 1; INSERT INTO t1 VALUES (); SELECT 2' + for result in cnx.cmd_query(statement, iterate=True): + if 'columns' in result: + columns = result['columns'] + rows = cnx.get_rows() + else: + # do something useful with INSERT result + + Returns a generator. + """ + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + packet = bytearray() + if not isinstance(statements, bytearray): + if isinstance(statements, str): + statements = statements.encode("utf8") + statements = bytearray(statements) + + if self._client_flags & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + # int parameter_count Number of parameters + packet.extend(lc_int(0)) + # int parameter_set_count Number of parameter sets. + # Currently always 1 + packet.extend(lc_int(1)) + + packet.extend(statements) + query = bytes(packet) + # Handle the first query result + yield self._handle_result( + self._send_cmd( + ServerCmd.QUERY, + query, + read_timeout=read_timeout, + write_timeout=write_timeout, + ), + read_timeout, + write_timeout, + ) + + # Handle next results, if any + while self._have_next_result: + self.handle_unread_result() + yield self._handle_result( + self._socket.recv(read_timeout=read_timeout or self._read_timeout), + read_timeout, + write_timeout, + ) + + @cmd_refresh_verify_options() + def cmd_refresh(self, options: int) -> OkPacketType: + if not options & ( + RefreshOption.GRANT + | RefreshOption.LOG + | RefreshOption.TABLES + | RefreshOption.HOST + | RefreshOption.STATUS + | RefreshOption.REPLICA + ): + raise ValueError("Invalid command REFRESH option") + + res = None + if options & RefreshOption.GRANT: + res = self.cmd_query("FLUSH PRIVILEGES") + if options & RefreshOption.LOG: + res = self.cmd_query("FLUSH LOGS") + if options & RefreshOption.TABLES: + res = self.cmd_query("FLUSH TABLES") + if options & RefreshOption.HOST: + res = self.cmd_query("TRUNCATE TABLE performance_schema.host_cache") + if options & RefreshOption.STATUS: + res = self.cmd_query("FLUSH STATUS") + if options & RefreshOption.REPLICA: + res = self.cmd_query( + "RESET SLAVE" if self._server_version < (8, 0, 22) else "RESET REPLICA" + ) + + return res + + def cmd_quit(self) -> bytes: + """Close the current connection with the server + + This method sends the `QUIT` command to the MySQL server, closing the + current connection. Since there is no response from the MySQL server, + the packet that was sent is returned. + + Returns a str() + """ + self.handle_unread_result() + + packet = self._protocol.make_command(ServerCmd.QUIT) + try: + self._socket.send(packet, 0, 0, self._write_timeout) + except WriteTimeoutError as _: + pass + return packet + + def cmd_shutdown(self, shutdown_type: Optional[int] = None) -> None: + """Shut down the MySQL Server + + This method sends the SHUTDOWN command to the MySQL server. + The `shutdown_type` is not used, and it's kept for backward compatibility. + """ + self.cmd_query("SHUTDOWN") + + @handle_read_write_timeout() + def cmd_statistics(self) -> StatsPacketType: + """Send the statistics command to the MySQL Server + + This method sends the STATISTICS command to the MySQL server. The + result is a dictionary with various statistical information. + + Returns a dict() + """ + self.handle_unread_result() + + packet = self._protocol.make_command(ServerCmd.STATISTICS) + self._socket.send(packet, 0, 0, self._write_timeout) + return self._protocol.parse_statistics(self._socket.recv(self._read_timeout)) + + def cmd_process_kill(self, mysql_pid: int) -> OkPacketType: + """Kill a MySQL process + + This method send the PROCESS_KILL command to the server along with + the process ID. The result is a dictionary with the OK packet + information. + """ + if not isinstance(mysql_pid, int): + raise ValueError("MySQL PID must be int") + return self.cmd_query(f"KILL {mysql_pid}") + + def cmd_debug(self) -> EofPacketType: + """Send the DEBUG command + + This method sends the DEBUG command to the MySQL server, which + requires the MySQL user to have SUPER privilege. The output will go + to the MySQL server error log and the result of this method is a + dictionary with EOF packet information. + + Returns a dict() + """ + return self._handle_eof(self._send_cmd(ServerCmd.DEBUG)) + + def cmd_ping(self) -> OkPacketType: + """Send the PING command + + This method sends the PING command to the MySQL server. It is used to + check if the the connection is still valid. The result of this + method is dictionary with OK packet information. + + Returns a dict() + """ + return self._handle_ok(self._send_cmd(ServerCmd.PING)) + + @handle_read_write_timeout() + def cmd_change_user( + self, + username: str = "", + password: str = "", + database: str = "", + charset: Optional[int] = None, + password1: str = "", + password2: str = "", + password3: str = "", + oci_config_file: str = "", + oci_config_profile: str = "", + openid_token_file: str = "", + ) -> Optional[OkPacketType]: + """Change the current logged in user + + This method allows to change the current logged in user information. + The result is a dictionary with OK packet information. + + Returns a dict() + """ + # If charset isn't defined, we use the same charset ID defined previously, + # otherwise, we run a verification and update the charset ID. + if charset is not None: + if not isinstance(charset, int): + raise ValueError("charset must be an integer") + if charset < 0: + raise ValueError("charset should be either zero or a postive integer") + self._charset_id = charset + + self._mfa_nfactor = 1 + self._user = username + self._password = password + self._password1 = password1 + self._password2 = password2 + self._password3 = password3 + + if self._password1 and password != self._password1: + self._password = self._password1 + + self.handle_unread_result() + + if self._compress: + raise NotSupportedError("Change user is not supported with compression") + + if oci_config_file: + self._oci_config_file = oci_config_file + if openid_token_file: + self._openid_token_file = openid_token_file + self._oci_config_profile = oci_config_profile + + # Update the custom configurations needed by specific auth plugins + self._authenticator.update_plugin_config( + config={ + "oci_config_file": self._oci_config_file, + "oci_config_profile": self._oci_config_profile, + "openid_token_file": self._openid_token_file, + } + ) + + ok_pkt = self._authenticator.authenticate( + sock=self._socket, + handshake=self._handshake, + username=self._user, + password1=self._password, + password2=self._password2, + password3=self._password3, + database=database, + charset=self._charset_id, + client_flags=self._client_flags, + auth_plugin=self._auth_plugin, + auth_plugin_class=self._auth_plugin_class, + conn_attrs=self._conn_attrs, + is_change_user_request=True, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + + if not (self._client_flags & ClientFlag.CONNECT_WITH_DB) and database: + self.cmd_init_db(database) + + self._post_connection() + + # return ok_pkt + return self._handle_ok(ok_pkt) + + @property + def database(self) -> str: + """Get the current database""" + return self.info_query("SELECT DATABASE()")[0] # type: ignore[return-value] + + @database.setter + def database(self, value: str) -> None: + """Set the current database""" + self.cmd_init_db(value) + + def is_connected(self) -> bool: + """Reports whether the connection to MySQL Server is available + + This method checks whether the connection to MySQL is available. + It is similar to ping(), but unlike the ping()-method, either True + or False is returned and no exception is raised. + + Returns True or False. + """ + try: + self.cmd_ping() + except Error: + return False # This method does not raise + return True + + def set_allow_local_infile_in_path(self, path: str) -> None: + """Set the path that user can upload files. + + Args: + path (str): Path that user can upload files. + """ + self._allow_local_infile_in_path = path + + @MySQLConnectionAbstract.use_unicode.setter + def use_unicode(self, value: bool) -> None: + self._use_unicode = value + if self.converter: + self.converter.set_unicode(value) + + def reset_session( + self, + user_variables: Optional[Dict[str, Any]] = None, + session_variables: Optional[Dict[str, Any]] = None, + ) -> None: + """Clears the current active session + + This method resets the session state, if the MySQL server is 5.7.3 + or later active session will be reset without re-authenticating. + For other server versions session will be reset by re-authenticating. + + It is possible to provide a sequence of variables and their values to + be set after clearing the session. This is possible for both user + defined variables and session variables. + This method takes two arguments user_variables and session_variables + which are dictionaries. + + Raises OperationalError if not connected, InternalError if there are + unread results and InterfaceError on errors. + """ + if not self.is_connected(): + raise OperationalError("MySQL Connection not available.") + + if not self.cmd_reset_connection(): + try: + self.cmd_change_user( + self._user, + self._password, + self._database, + self._charset_id, + self._password1, + self._password2, + self._password3, + self._oci_config_file, + self._oci_config_profile, + self._openid_token_file, + ) + except ProgrammingError: + self.reconnect() + + cur = self.cursor() + if user_variables: + for key, value in user_variables.items(): + cur.execute(f"SET @`{key}` = %s", (value,)) + if session_variables: + for key, value in session_variables.items(): + cur.execute(f"SET SESSION `{key}` = %s", (value,)) + cur.close() + + def ping(self, reconnect: bool = False, attempts: int = 1, delay: int = 0) -> None: + """Check availability of the MySQL server + + When reconnect is set to True, one or more attempts are made to try + to reconnect to the MySQL server using the reconnect()-method. + + delay is the number of seconds to wait between each retry. + + When the connection is not available, an InterfaceError is raised. Use + the is_connected()-method if you just want to check the connection + without raising an error. + + Raises InterfaceError on errors. + """ + try: + self.cmd_ping() + except Error as err: + if reconnect: + self.reconnect(attempts=attempts, delay=delay) + else: + raise InterfaceError("Connection to MySQL is not available") from err + + @property + def connection_id(self) -> Optional[int]: + """MySQL connection ID""" + if self._handshake: + return self._handshake.get("server_threadid") # type: ignore[return-value] + return None + + def cursor( + self, + buffered: Optional[bool] = None, + raw: Optional[bool] = None, + prepared: Optional[bool] = None, + cursor_class: Optional[Type[MySQLCursor]] = None, # type: ignore[override] + dictionary: Optional[bool] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> MySQLCursor: + """Instantiates and returns a cursor + + By default, MySQLCursor is returned. Depending on the options + while connecting, a buffered and/or raw cursor is instantiated + instead. Also depending upon the cursor options, rows can be + returned as a dictionary or a tuple. + + Dictionary based cursors are available with buffered + output but not raw. + + It is possible to also give a custom cursor through the + cursor_class parameter, but it needs to be a subclass of + mysql.connector.cursor.MySQLCursor. + + Raises ProgrammingError when cursor_class is not a subclass of + MySQLCursor. Raises ValueError when cursor is not available. + Raises InterfaceError when read_timeout or write_timeout is not + a positive integer. + + Returns a cursor-object + """ + self.handle_unread_result() + + if not self.is_connected(): + raise OperationalError("MySQL Connection not available") + if read_timeout is not None and ( + not isinstance(read_timeout, int) or read_timeout < 0 + ): + raise InterfaceError("Option read_timeout must be a positive integer") + if write_timeout is not None and ( + not isinstance(write_timeout, int) or write_timeout < 0 + ): + raise InterfaceError("Option write_timeout must be a positive integer") + if cursor_class is not None: + if not issubclass(cursor_class, MySQLCursor): + raise ProgrammingError( + "Cursor class needs be to subclass of MySQLCursor" + ) + return (cursor_class)(self, read_timeout, write_timeout) + + buffered = buffered if buffered is not None else self._buffered + raw = raw if raw is not None else self._raw + + cursor_type = 0 + if buffered is True: + cursor_type |= 1 + if raw is True: + cursor_type |= 2 + if dictionary is True: + cursor_type |= 4 + if prepared is True: + cursor_type |= 16 + + types = { + 0: MySQLCursor, # 0 + 1: MySQLCursorBuffered, + 2: MySQLCursorRaw, + 3: MySQLCursorBufferedRaw, + 4: MySQLCursorDict, + 5: MySQLCursorBufferedDict, + 16: MySQLCursorPrepared, + 20: MySQLCursorPreparedDict, + } + try: + return (types[cursor_type])(self, read_timeout, write_timeout) + except KeyError: + args = ("buffered", "raw", "dictionary", "prepared") + raise ValueError( + "Cursor not available with given criteria: " + + ", ".join([args[i] for i in range(4) if cursor_type & (1 << i) != 0]) + ) from None + + def commit(self) -> None: + """Commit current transaction""" + self._execute_query("COMMIT") + + def rollback(self) -> None: + """Rollback current transaction""" + if self.unread_result: + self.get_rows() + + self._execute_query("ROLLBACK") + + def _execute_query(self, query: str) -> None: + """Execute a query + + This method simply calls cmd_query() after checking for unread + result. If there are still unread result, an InterfaceError + is raised. Otherwise whatever cmd_query() returns is returned. + + Returns a dict() + """ + self.handle_unread_result() + self.cmd_query(query) + + def info_query(self, query: str) -> Optional[RowType]: + """Send a query which only returns 1 row""" + cursor = self.cursor( + buffered=True, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + cursor.execute(query) + return cursor.fetchone() + + def _handle_binary_ok(self, packet: bytes) -> Dict[str, int]: + """Handle a MySQL Binary Protocol OK packet + + This method handles a MySQL Binary Protocol OK packet. When the + packet is found to be an Error packet, an error will be raised. If + the packet is neither an OK or an Error packet, InterfaceError + will be raised. + + Returns a dict() + """ + if packet[4] == OK_STATUS: + return self._protocol.parse_binary_prepare_ok(packet) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + raise InterfaceError("Expected Binary OK packet") + + @handle_read_write_timeout() + def _handle_binary_result( + self, packet: bytes, read_timeout: Optional[int] = None + ) -> Union[OkPacketType, Tuple[int, List[DescriptionType], EofPacketType]]: + """Handle a MySQL Result + + This method handles a MySQL result, for example, after sending the + query command. OK and EOF packets will be handled and returned. If + the packet is an Error packet, an Error exception will be raised. + + The tuple returned by this method consist of: + - the number of columns in the result, + - a list of tuples with information about the columns, + - the EOF packet information as a dictionary. + + Returns tuple() or dict() + """ + if not packet or len(packet) < 4: + raise InterfaceError("Empty response") + if packet[4] == OK_STATUS: + return self._handle_ok(packet) + if packet[4] == EOF_STATUS: + return self._handle_eof(packet) + if packet[4] == ERR_STATUS: + raise get_exception(packet) + + # We have a binary result set + column_count = self._protocol.parse_column_count(packet) + if not column_count or not isinstance(column_count, int): + raise InterfaceError("Illegal result set.") + + columns: List[DescriptionType] = [None] * column_count + for i in range(0, column_count): + columns[i] = self._protocol.parse_column( + self._socket.recv(read_timeout or self._read_timeout), + self.python_charset, + ) + + eof = self._handle_eof(self._socket.recv(read_timeout or self._read_timeout)) + return (column_count, columns, eof) + + def cmd_stmt_fetch( + self, + statement_id: int, + rows: int = 1, + **kwargs: Any, + ) -> None: + """Fetch a MySQL statement Result Set + + This method will send the FETCH command to MySQL together with the + given statement id and the number of rows to fetch. + """ + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + packet = self._protocol.make_stmt_fetch(statement_id, rows) + self.unread_result = False + self._send_cmd( + ServerCmd.STMT_FETCH, + packet, + expect_response=False, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + self.unread_result = True + + @handle_read_write_timeout() + def cmd_stmt_prepare( + self, + statement: bytes, + **kwargs: Any, + ) -> Mapping[str, Union[int, List[DescriptionType]]]: + """Prepare a MySQL statement + + This method will send the PREPARE command to MySQL together with the + given statement. + + Returns a dict() + """ + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + + packet = self._send_cmd( + ServerCmd.STMT_PREPARE, + statement, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + result = self._handle_binary_ok(packet) + + result["columns"] = [] + result["parameters"] = [] + if result["num_params"] > 0: + for _ in range(0, result["num_params"]): + result["parameters"].append( + self._protocol.parse_column( + self._socket.recv(read_timeout or self._read_timeout), + self.python_charset, + ) + ) + self._handle_eof(self._socket.recv(read_timeout or self._read_timeout)) + if result["num_columns"] > 0: + for _ in range(0, result["num_columns"]): + result["columns"].append( + self._protocol.parse_column( + self._socket.recv(read_timeout or self._read_timeout), + self.python_charset, + ) + ) + self._handle_eof(self._socket.recv(read_timeout or self._read_timeout)) + return result + + @with_context_propagation + def cmd_stmt_execute( + self, + statement_id: int, + data: Sequence[BinaryProtocolType] = (), + parameters: Sequence = (), + flags: int = 0, + **kwargs: Any, + ) -> Union[OkPacketType, Tuple[int, List[DescriptionType], EofPacketType]]: + """Execute a prepared MySQL statement""" + parameters = list(parameters) + long_data_used = {} + read_timeout = kwargs.get("read_timeout", None) + write_timeout = kwargs.get("write_timeout", None) + + if data: + for param_id, _ in enumerate(parameters): + if isinstance(data[param_id], IOBase): + binary = True + try: + binary = "b" not in data[param_id].mode # type: ignore[union-attr] + except AttributeError: + pass + self.cmd_stmt_send_long_data( + statement_id, + param_id, + data[param_id], + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + long_data_used[param_id] = (binary,) + if not self._query_attrs_supported and self._query_attrs: + warnings.warn( + "This version of the server does not support Query Attributes", + category=Warning, + ) + if self._client_flags & ClientFlag.CLIENT_QUERY_ATTRIBUTES: + execute_packet = self._protocol.make_stmt_execute( + statement_id, + data, + tuple(parameters), + flags, + long_data_used, + self.charset, + self.query_attrs, + self._converter_str_fallback, + ) + else: + execute_packet = self._protocol.make_stmt_execute( + statement_id, + data, + tuple(parameters), + flags, + long_data_used, + self.charset, + converter_str_fallback=self._converter_str_fallback, + ) + packet = self._send_cmd( + ServerCmd.STMT_EXECUTE, + packet=execute_packet, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + result = self._handle_binary_result(packet, read_timeout) + return result + + def cmd_stmt_close( + self, + statement_id: int, # type: ignore[override] + **kwargs: Any, + ) -> None: + """Deallocate a prepared MySQL statement + + This method deallocates the prepared statement using the + statement_id. Note that the MySQL server does not return + anything. + """ + self._send_cmd( + ServerCmd.STMT_CLOSE, + int4store(statement_id), + expect_response=False, + read_timeout=kwargs.get("read_timeout", None), + write_timeout=kwargs.get("write_timeout", None), + ) + + def cmd_stmt_send_long_data( + self, + statement_id: int, # type: ignore[override] + param_id: int, + data: BinaryIO, + **kwargs: Any, + ) -> int: + """Send data for a column + + This methods send data for a column (for example BLOB) for statement + identified by statement_id. The param_id indicate which parameter + the data belongs too. + The data argument should be a file-like object. + + Since MySQL does not send anything back, no error is raised. When + the MySQL server is not reachable, an OperationalError is raised. + + cmd_stmt_send_long_data should be called before cmd_stmt_execute. + + The total bytes send is returned. + + Returns int. + """ + chunk_size = 131072 # 128 KB + total_sent = 0 + try: + buf = data.read(chunk_size) + while buf: + packet = self._protocol.prepare_stmt_send_long_data( + statement_id, param_id, buf + ) + self._send_cmd( + ServerCmd.STMT_SEND_LONG_DATA, + packet=packet, + expect_response=False, + read_timeout=kwargs.get("read_timeout", None), + write_timeout=kwargs.get("write_timeout", None), + ) + total_sent += len(buf) + buf = data.read(chunk_size) + except AttributeError as err: + raise OperationalError("MySQL Connection not available") from err + + return total_sent + + def cmd_stmt_reset( + self, + statement_id: int, # type: ignore[override] + **kwargs: Any, + ) -> None: + """Reset data for prepared statement sent as long data + + The result is a dictionary with OK packet information. + + Returns a dict() + """ + self._handle_ok( + self._send_cmd( + ServerCmd.STMT_RESET, + int4store(statement_id), + read_timeout=kwargs.get("read_timeout", None), + write_timeout=kwargs.get("write_timeout", None), + ) + ) + + def cmd_reset_connection(self) -> bool: + """Resets the session state without re-authenticating + + Reset command only works on MySQL server 5.7.3 or later. + The result is True for a successful reset otherwise False. + + Returns bool + """ + try: + self._handle_ok(self._send_cmd(ServerCmd.RESET_CONNECTION)) + self._post_connection() + return True + except (NotSupportedError, OperationalError): + return False + + def handle_unread_result(self) -> None: + """Check whether there is an unread result""" + if self.can_consume_results: + self.consume_results() + elif self.unread_result: + raise InternalError("Unread result found") diff --git a/server/venv/Lib/site-packages/mysql/connector/connection_cext.py b/server/venv/Lib/site-packages/mysql/connector/connection_cext.py new file mode 100644 index 0000000..2c3e94f --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/connection_cext.py @@ -0,0 +1,1170 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="arg-type" + +"""Connection class using the C Extension.""" + +import os +import platform +import socket +import sys +import warnings + +from typing import ( + Any, + BinaryIO, + Dict, + List, + NoReturn, + Optional, + Sequence, + Tuple, + Type, + Union, +) + +from . import version +from ._decorating import cmd_refresh_verify_options +from .abstracts import CMySQLPrepStmt, MySQLConnectionAbstract +from .constants import ClientFlag, FieldFlag, FieldType, ServerFlag, ShutdownType +from .conversion import MySQLConverter +from .errors import ( + InterfaceError, + InternalError, + OperationalError, + ProgrammingError, + get_mysql_exception, +) +from .protocol import MySQLProtocol +from .types import ( + CextEofPacketType, + CextResultType, + DescriptionType, + ParamsSequenceOrDictType, + RowType, + StatsPacketType, + StrOrBytes, +) +from .utils import ( + import_object, + warn_ciphersuites_deprecated, + warn_tls_version_deprecated, +) + +HAVE_CMYSQL = False + +try: + import _mysql_connector + + from _mysql_connector import MySQLInterfaceError + + from .cursor_cext import ( + CMySQLCursor, + CMySQLCursorBuffered, + CMySQLCursorBufferedDict, + CMySQLCursorBufferedRaw, + CMySQLCursorDict, + CMySQLCursorPrepared, + CMySQLCursorPreparedDict, + CMySQLCursorRaw, + ) + + HAVE_CMYSQL = True +except ImportError as exc: + raise ImportError( + f"MySQL Connector/Python C Extension not available ({exc})" + ) from exc + +from .opentelemetry.constants import OTEL_ENABLED +from .opentelemetry.context_propagation import with_context_propagation + +if OTEL_ENABLED: + from .opentelemetry.instrumentation import end_span, record_exception_event + + +class CMySQLConnection(MySQLConnectionAbstract): + """Class initiating a MySQL Connection using Connector/C.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialization""" + if not HAVE_CMYSQL: + raise RuntimeError("MySQL Connector/Python C Extension not available") + self._cmysql: Optional[ + _mysql_connector.MySQL # pylint: disable=c-extension-no-member + ] = None + self._columns: List[DescriptionType] = [] + self._plugin_dir: str = os.path.join( + os.path.dirname(os.path.abspath(_mysql_connector.__file__)), + "mysql", + "vendor", + "plugin", + ) + if platform.system() == "Linux": + # Use the authentication plugins from system if they aren't bundled + if not os.path.exists(self._plugin_dir): + self._plugin_dir = ( + "/usr/lib64/mysql/plugin" + if os.path.exists("/usr/lib64/mysql/plugin") + else "/usr/lib/mysql/plugin" + ) + + self.converter: Optional[MySQLConverter] = None + super().__init__() + + if kwargs: + try: + self.connect(**kwargs) + except Exception: + self.close() + raise + + def _add_default_conn_attrs(self) -> None: + """Add default connection attributes""" + license_chunks = version.LICENSE.split(" ") + if license_chunks[0] == "GPLv2": + client_license = "GPL-2.0" + else: + client_license = "Commercial" + + self._conn_attrs.update( + { + "_connector_name": "mysql-connector-python", + "_connector_license": client_license, + "_connector_version": ".".join([str(x) for x in version.VERSION[0:3]]), + "_source_host": socket.gethostname(), + } + ) + + def _do_handshake(self) -> None: + """Gather information of the MySQL server before authentication""" + self._handshake = { + "protocol": self._cmysql.get_proto_info(), + "server_version_original": self._cmysql.get_server_info(), + "server_threadid": self._cmysql.thread_id(), + "charset": None, + "server_status": None, + "auth_plugin": None, + "auth_data": None, + "capabilities": self._cmysql.st_server_capabilities(), + } + + self._server_version = self._check_server_version( + self._handshake["server_version_original"] + ) + self._character_set.set_mysql_version(self._server_version) + + @property + def _server_status(self) -> int: + """Returns the server status attribute of MYSQL structure""" + return self._cmysql.st_server_status() + + def set_allow_local_infile_in_path(self, path: str) -> None: + """set local_infile_in_path + + Set allow_local_infile_in_path. + """ + + if self._cmysql: + self._cmysql.set_load_data_local_infile_option(path) + + @MySQLConnectionAbstract.use_unicode.setter # type: ignore + def use_unicode(self, value: bool) -> None: + self._use_unicode = value + if self._cmysql: + self._cmysql.use_unicode(value) + if self.converter: + self.converter.set_unicode(value) + + @property + def autocommit(self) -> bool: + """Get whether autocommit is on or off""" + value = self.info_query("SELECT @@session.autocommit")[0] + return value == 1 + + @autocommit.setter + def autocommit(self, value: bool) -> None: + """Toggle autocommit""" + try: + self._cmysql.autocommit(value) + self._autocommit = value + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + @property + def read_timeout(self) -> Optional[int]: + return self._read_timeout + + @read_timeout.setter + def read_timeout(self, timeout: int) -> None: + raise ProgrammingError( + """ + The use of read_timeout after the connection has been established is unsupported + in the C-Extension + """ + ) + + @property + def write_timeout(self) -> Optional[int]: + return self._write_timeout + + @write_timeout.setter + def write_timeout(self, timeout: int) -> None: + raise ProgrammingError( + """ + Changes in write_timeout after the connection has been established is unsupported + in the C-Extension + """ + ) + + @property + def database(self) -> str: + """Get the current database""" + return self.info_query("SELECT DATABASE()")[0] # type: ignore[return-value] + + @database.setter + def database(self, value: str) -> None: + """Set the current database""" + try: + self._cmysql.select_db(value) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + @property + def in_transaction(self) -> bool: + """MySQL session has started a transaction""" + return bool(self._server_status & ServerFlag.STATUS_IN_TRANS) + + def _open_connection(self) -> None: + charset_name = self._character_set.get_info(self._charset_id)[0] + # pylint: disable=c-extension-no-member + self._cmysql = _mysql_connector.MySQL( + buffered=self._buffered, + raw=self._raw, + charset_name=charset_name, + connection_timeout=(self._connection_timeout or 0), + use_unicode=self.use_unicode, + auth_plugin=self._auth_plugin, + plugin_dir=self._plugin_dir, + ) + # pylint: enable=c-extension-no-member + if not self.isset_client_flag(ClientFlag.CONNECT_ARGS): + self._conn_attrs = {} + + cnx_kwargs = { + "host": self._host, + "user": self._user, + "password": self._password, + "password1": self._password1, + "password2": self._password2, + "password3": self._password3, + "database": self._database, + "port": self._port, + "client_flags": self.client_flags, + "unix_socket": self._unix_socket, + "compress": self._compress, + "ssl_disabled": True, + "conn_attrs": self._conn_attrs, + "local_infile": self._allow_local_infile, + "load_data_local_dir": self._allow_local_infile_in_path, + "oci_config_file": self._oci_config_file, + "oci_config_profile": self._oci_config_profile, + "webauthn_callback": ( + import_object(self._webauthn_callback) + if isinstance(self._webauthn_callback, str) + else self._webauthn_callback + ), + "openid_token_file": self._openid_token_file, + "read_timeout": self._read_timeout if self._read_timeout else 0, + "write_timeout": self._write_timeout if self._write_timeout else 0, + } + + tls_versions = self._ssl.get("tls_versions") + if tls_versions is not None: + tls_versions.sort(reverse=True) # type: ignore[union-attr] + tls_versions = ",".join(tls_versions) + if self._ssl.get("tls_ciphersuites") is not None: + ssl_ciphersuites = ( + self._ssl.get("tls_ciphersuites")[0] or None # type: ignore[index] + ) # if it's the empty string, then use `None` instead + tls_ciphersuites = self._ssl.get("tls_ciphersuites")[ # type: ignore[index] + 1 + ] + else: + ssl_ciphersuites = None + tls_ciphersuites = None + if ( + tls_versions is not None + and "TLSv1.3" in tls_versions + and not tls_ciphersuites + ): + tls_ciphersuites = "TLS_AES_256_GCM_SHA384" + if not self._ssl_disabled: + cnx_kwargs.update( + { + "ssl_ca": self._ssl.get("ca"), + "ssl_cert": self._ssl.get("cert"), + "ssl_key": self._ssl.get("key"), + "ssl_cipher_suites": ssl_ciphersuites, + "tls_versions": tls_versions, + "tls_cipher_suites": tls_ciphersuites, + "ssl_verify_cert": self._ssl.get("verify_cert") or False, + "ssl_verify_identity": self._ssl.get("verify_identity") or False, + "ssl_disabled": self._ssl_disabled, + } + ) + + if os.name == "nt" and self._auth_plugin_class == "MySQLKerberosAuthPlugin": + cnx_kwargs["use_kerberos_gssapi"] = True + + try: + self._cmysql.connect(**cnx_kwargs) + self._cmysql.converter_str_fallback = self._converter_str_fallback + if self.converter: + self.converter.str_fallback = self._converter_str_fallback + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + self._do_handshake() + + if ( + not self._ssl_disabled + and hasattr(self._cmysql, "get_ssl_cipher") + and callable(self._cmysql.get_ssl_cipher) + ): + # Raise a deprecation warning if deprecated TLS version + # or cipher is being used. + + # `get_ssl_cipher()` returns the name of the cipher being used. + cipher = self._cmysql.get_ssl_cipher() + for tls_version in set(self._ssl.get("tls_versions", [])): + warn_tls_version_deprecated(tls_version) + warn_ciphersuites_deprecated(cipher, tls_version) + + def close(self) -> None: + if self._span and self._span.is_recording(): + # pylint: disable=possibly-used-before-assignment + record_exception_event(self._span, sys.exc_info()[1]) + + if not self._cmysql: + return + + try: + self.free_result() + self._cmysql.close() + except MySQLInterfaceError as err: + if OTEL_ENABLED: + record_exception_event(self._span, err) + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + finally: + if OTEL_ENABLED: + end_span(self._span) + + disconnect = close + + def is_closed(self) -> bool: + """Return True if the connection to MySQL Server is closed.""" + return not self._cmysql.connected() + + def is_connected(self) -> bool: + """Reports whether the connection to MySQL Server is available""" + if self._cmysql: + self.handle_unread_result() + return self._cmysql.ping() + + return False + + def ping(self, reconnect: bool = False, attempts: int = 1, delay: int = 0) -> None: + """Check availability of the MySQL server + + When reconnect is set to True, one or more attempts are made to try + to reconnect to the MySQL server using the reconnect()-method. + + delay is the number of seconds to wait between each retry. + + When the connection is not available, an InterfaceError is raised. Use + the is_connected()-method if you just want to check the connection + without raising an error. + + Raises InterfaceError on errors. + """ + self.handle_unread_result() + + try: + connected = self._cmysql.ping() + except AttributeError: + pass # Raise or reconnect later + else: + if connected: + return + + if reconnect: + self.reconnect(attempts=attempts, delay=delay) + else: + raise InterfaceError("Connection to MySQL is not available") + + def set_character_set_name(self, charset: str) -> None: + """Sets the default character set name for current connection.""" + self._cmysql.set_character_set(charset) + + def info_query(self, query: StrOrBytes) -> Optional[RowType]: + """Send a query which only returns 1 row""" + first_row = () + try: + self._cmysql.query(query) + if self._cmysql.have_result_set: + first_row = self._cmysql.fetch_row() + if self._cmysql.fetch_row(): + self._cmysql.free_result() + raise InterfaceError("Query should not return more than 1 row") + self._cmysql.free_result() + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + return first_row + + @property + def connection_id(self) -> Optional[int]: + """MySQL connection ID""" + try: + return self._cmysql.thread_id() + except MySQLInterfaceError: + pass # Just return None + + return None + + def get_rows( + self, + count: Optional[int] = None, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Optional[CMySQLPrepStmt] = None, + **kwargs: Any, + ) -> Tuple[List[RowType], Optional[CextEofPacketType]]: + """Get all or a subset of rows returned by the MySQL server""" + unread_result = prep_stmt.have_result_set if prep_stmt else self.unread_result + if not (self._cmysql and unread_result): + raise InternalError("No result set available") + + if raw is None: + raw = self._raw + + rows: List[Tuple] = [] + if count is not None and count <= 0: + raise AttributeError("count should be 1 or higher, or None") + + counter = 0 + try: + fetch_row = prep_stmt.fetch_row if prep_stmt else self._cmysql.fetch_row + if self.converter or raw: + # When using a converter class or `raw`, the C extension should not + # convert the values. This can be accomplished by setting + # the raw option to True. + self._cmysql.raw(True) + + row = fetch_row() + while row: + row = list(row) + + if not self._cmysql.raw() and not raw: + # `not _cmysql.raw()` means the c-ext conversion layer will happen. + # `not raw` means the caller wants conversion to happen. + # For a VECTOR type, the c-ext conversion layer cannot return + # an array.array type since such a type isn't part of the Python/C + # API. Therefore, the c-ext will treat VECTOR types as if they + # were BLOB types - be returned as `bytes` always. + # Hence, a VECTOR type must be cast to an array.array type using the + # built-in python conversion layer. + # pylint: disable=protected-access + for i, dsc in enumerate(self._columns): + if dsc[1] == FieldType.VECTOR: + row[i] = MySQLConverter._vector_to_python(row[i]) + + if not self._raw and self.converter: + for i, _ in enumerate(row): + if not raw: + row[i] = self.converter.to_python(self._columns[i], row[i]) + + rows.append(tuple(row)) + counter += 1 + + if count and counter == count: + break + + row = fetch_row() + if not row: + _eof: Optional[CextEofPacketType] = self.fetch_eof_columns(prep_stmt)[ + "eof" + ] # type: ignore[assignment] + if prep_stmt: + prep_stmt.free_result() + self._unread_result = False + else: + self.free_result() + else: + _eof = None + except MySQLInterfaceError as err: + if prep_stmt: + prep_stmt.free_result() + else: + self.free_result() + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + return rows, _eof + + def get_row( + self, + binary: bool = False, + columns: Optional[List[DescriptionType]] = None, + raw: Optional[bool] = None, + prep_stmt: Optional[CMySQLPrepStmt] = None, + **kwargs: Any, + ) -> Tuple[Optional[RowType], Optional[CextEofPacketType]]: + """Get the next rows returned by the MySQL server""" + try: + rows, eof = self.get_rows( + count=1, + binary=binary, + columns=columns, + raw=raw, + prep_stmt=prep_stmt, + ) + if rows: + return (rows[0], eof) + return (None, eof) + except IndexError: + # No row available + return (None, None) + + def next_result(self) -> Optional[bool]: + """Reads the next result""" + if self._cmysql: + self._cmysql.consume_result() + return self._cmysql.next_result() + return None + + def free_result(self) -> None: + """Frees the result""" + if self._cmysql: + self._cmysql.free_result() + + def commit(self) -> None: + """Commit current transaction""" + if self._cmysql: + self.handle_unread_result() + self._cmysql.commit() + + def rollback(self) -> None: + """Rollback current transaction""" + if self._cmysql: + self._cmysql.consume_result() + self._cmysql.rollback() + + def cmd_init_db(self, database: str) -> None: + """Change the current database""" + try: + self._cmysql.select_db(database) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + def fetch_eof_columns( + self, prep_stmt: Optional[CMySQLPrepStmt] = None + ) -> CextResultType: + """Fetch EOF and column information""" + have_result_set = ( + prep_stmt.have_result_set if prep_stmt else self._cmysql.have_result_set + ) + if not have_result_set: + raise InterfaceError("No result set") + + fields = prep_stmt.fetch_fields() if prep_stmt else self._cmysql.fetch_fields() + self._columns = [] + for col in fields: + self._columns.append( + ( + col[4], + int(col[8]), + None, + None, + None, + None, + ~int(col[9]) & FieldFlag.NOT_NULL, + int(col[9]), + int(col[6]), + ) + ) + + return { + "eof": { + "status_flag": self._server_status, + "warning_count": self._cmysql.st_warning_count(), + }, + "columns": self._columns, + } + + def fetch_eof_status(self) -> Optional[CextEofPacketType]: + """Fetch EOF and status information""" + if self._cmysql: + return { + "warning_count": self._cmysql.st_warning_count(), + "field_count": self._cmysql.st_field_count(), + "insert_id": self._cmysql.insert_id(), + "affected_rows": self._cmysql.affected_rows(), + "server_status": self._server_status, + } + + return None + + def cmd_stmt_prepare( + self, + statement: bytes, + **kwargs: Any, + ) -> CMySQLPrepStmt: + """Prepares the SQL statement""" + if not self._cmysql: + raise OperationalError("MySQL Connection not available") + + try: + stmt = self._cmysql.stmt_prepare(statement) + stmt.converter_str_fallback = self._converter_str_fallback + return CMySQLPrepStmt(stmt) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + @with_context_propagation + def cmd_stmt_execute( + self, + statement_id: CMySQLPrepStmt, + *args: Any, + **kwargs: Any, + ) -> Optional[Union[CextEofPacketType, CextResultType]]: + """Executes the prepared statement""" + try: + statement_id.stmt_execute(*args, query_attrs=self.query_attrs) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + self._columns = [] + if not statement_id.have_result_set: + # No result + self._unread_result = False + return self.fetch_eof_status() + + self._unread_result = True + return self.fetch_eof_columns(statement_id) + + def cmd_stmt_close( + self, + statement_id: CMySQLPrepStmt, # type: ignore[override] + **kwargs: Any, + ) -> None: + """Closes the prepared statement""" + if self._unread_result: + raise InternalError("Unread result found") + try: + statement_id.stmt_close() + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + def cmd_stmt_reset( + self, + statement_id: CMySQLPrepStmt, # type: ignore[override] + **kwargs: Any, + ) -> None: + """Resets the prepared statement""" + if self._unread_result: + raise InternalError("Unread result found") + try: + statement_id.stmt_reset() + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + @with_context_propagation + def cmd_query( + self, + query: StrOrBytes, + raw: Optional[bool] = None, + buffered: bool = False, + raw_as_string: bool = False, + **kwargs: Any, + ) -> Optional[Union[CextEofPacketType, CextResultType]]: + self.handle_unread_result() + if raw is None: + raw = self._raw + try: + if not isinstance(query, bytes): + query = query.encode("utf-8") + + # Set/Reset internal state related to query execution + self._query = query + self._local_infile_filenames = None + + self._cmysql.query( + query, + raw=raw, + buffered=buffered, + raw_as_string=raw_as_string, + query_attrs=self.query_attrs, + ) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + except AttributeError as err: + addr = ( + self._unix_socket if self._unix_socket else f"{self._host}:{self._port}" + ) + raise OperationalError( + errno=2055, values=(addr, "Connection not available.") + ) from err + + self._columns = [] + if not self._cmysql.have_result_set: + # No result + return self.fetch_eof_status() + + return self.fetch_eof_columns() + + _execute_query = cmd_query + + def cursor( + self, + buffered: Optional[bool] = None, + raw: Optional[bool] = None, + prepared: Optional[bool] = None, + cursor_class: Optional[Type[CMySQLCursor]] = None, # type: ignore[override] + dictionary: Optional[bool] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> CMySQLCursor: + """Instantiates and returns a cursor using C Extension + + By default, CMySQLCursor is returned. Depending on the options + while connecting, a buffered and/or raw cursor is instantiated + instead. Also depending upon the cursor options, rows can be + returned as a dictionary or a tuple. + + Dictionary based cursors are available with buffered + output but not raw. + + It is possible to also give a custom cursor through the + cursor_class parameter, but it needs to be a subclass of + mysql.connector.cursor_cext.CMySQLCursor. + + Raises ProgrammingError when cursor_class is not a subclass of + CMySQLCursor. Raises ValueError when cursor is not available. + + Returns instance of CMySQLCursor or subclass. + + :param buffered: Return a buffering cursor + :param raw: Return a raw cursor + :param prepared: Return a cursor which uses prepared statements + :param cursor_class: Use a custom cursor class + :param dictionary: Rows are returned as dictionary + :return: Subclass of CMySQLCursor + :rtype: CMySQLCursor or subclass + """ + self.handle_unread_result(prepared) + if not self.is_connected(): + raise OperationalError("MySQL Connection not available.") + if read_timeout or write_timeout: + warnings.warn( + """The use of read_timeout after the connection has been established is unsupported + in the C-Extension""", + category=Warning, + ) + if cursor_class is not None: + if not issubclass(cursor_class, CMySQLCursor): + raise ProgrammingError( + "Cursor class needs be to subclass of cursor_cext.CMySQLCursor" + ) + return (cursor_class)(self) + + buffered = buffered or self._buffered + raw = raw or self._raw + + cursor_type = 0 + if buffered is True: + cursor_type |= 1 + if raw is True: + cursor_type |= 2 + if dictionary is True: + cursor_type |= 4 + if prepared is True: + cursor_type |= 16 + + types = { + 0: CMySQLCursor, # 0 + 1: CMySQLCursorBuffered, + 2: CMySQLCursorRaw, + 3: CMySQLCursorBufferedRaw, + 4: CMySQLCursorDict, + 5: CMySQLCursorBufferedDict, + 16: CMySQLCursorPrepared, + 20: CMySQLCursorPreparedDict, + } + try: + return (types[cursor_type])(self) + except KeyError: + args = ("buffered", "raw", "dictionary", "prepared") + raise ValueError( + "Cursor not available with given criteria: " + + ", ".join([args[i] for i in range(4) if cursor_type & (1 << i) != 0]) + ) from None + + @property + def num_rows(self) -> int: + """Returns number of rows of current result set""" + if not self._cmysql.have_result_set: + raise InterfaceError("No result set") + + return self._cmysql.num_rows() + + @property + def warning_count(self) -> int: + """Returns number of warnings""" + if not self._cmysql: + return 0 + + return self._cmysql.warning_count() + + @property + def result_set_available(self) -> bool: + """Check if a result set is available""" + if not self._cmysql: + return False + + return self._cmysql.have_result_set + + @property # type: ignore[misc] + def unread_result(self) -> bool: + """Check if there are unread results or rows""" + return self.result_set_available + + @property + def more_results(self) -> bool: + """Check if there are more results""" + return self._cmysql.more_results() + + def prepare_for_mysql( + self, params: ParamsSequenceOrDictType + ) -> Union[Sequence[bytes], Dict[bytes, bytes]]: + """Prepare parameters for statements + + This method is use by cursors to prepared parameters found in the + list (or tuple) params. + + Returns dict. + """ + result: Union[List[bytes], Dict[bytes, bytes]] = [] + if isinstance(params, (list, tuple)): + if self.converter: + result = [ + self.converter.quote( + self.converter.escape( + self.converter.to_mysql(value), self._sql_mode + ) + ) + for value in params + ] + else: + result = self._cmysql.convert_to_mysql(*params) + elif isinstance(params, dict): + result = {} + if self.converter: + for key, value in params.items(): + result[key.encode()] = self.converter.quote( + self.converter.escape( + self.converter.to_mysql(value), self._sql_mode + ) + ) + else: + for key, value in params.items(): + result[key.encode()] = self._cmysql.convert_to_mysql(value)[0] + else: + raise ProgrammingError( + f"Could not process parameters: {type(params).__name__}({params})," + " it must be of type list, tuple or dict" + ) + + return result + + def consume_results(self) -> None: + """Consume the current result + + This method consume the result by reading (consuming) all rows. + """ + self._cmysql.consume_result() + + def cmd_change_user( + self, + username: str = "", + password: str = "", + database: str = "", + charset: Optional[int] = None, + password1: str = "", + password2: str = "", + password3: str = "", + oci_config_file: Optional[str] = None, + oci_config_profile: Optional[str] = None, + openid_token_file: Optional[str] = None, + ) -> None: + """Change the current logged in user""" + try: + self._cmysql.change_user( + username, + password, + database, + password1, + password2, + password3, + oci_config_file, + oci_config_profile, + openid_token_file, + ) + + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + # If charset isn't defined, we use the same charset ID defined previously, + # otherwise, we run a verification and update the charset ID. + if charset is not None: + if not isinstance(charset, int): + raise ValueError("charset must be an integer") + if charset < 0: + raise ValueError("charset should be either zero or a postive integer") + self._charset_id = charset + self._user = username # updating user accordingly + self._post_connection() + + def cmd_reset_connection(self) -> bool: + """Resets the session state without re-authenticating + + Reset command only works on MySQL server 5.7.3 or later. + The result is True for a successful reset otherwise False. + + Returns bool + """ + res = self._cmysql.reset_connection() + if res: + self._post_connection() + return res + + @cmd_refresh_verify_options() + def cmd_refresh(self, options: int) -> Optional[CextEofPacketType]: + try: + self.handle_unread_result() + self._cmysql.refresh(options) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + return self.fetch_eof_status() + + def cmd_quit(self) -> None: + """Close the current connection with the server""" + self.close() + + def cmd_shutdown(self, shutdown_type: Optional[int] = None) -> None: + """Shut down the MySQL Server + + This method sends the SHUTDOWN command to the MySQL server. + The `shutdown_type` is not used, and it's kept for backward compatibility. + """ + if not self._cmysql: + raise OperationalError("MySQL Connection not available") + + if shutdown_type: + if not ShutdownType.get_info(shutdown_type): + raise InterfaceError("Invalid shutdown type") + level = shutdown_type + else: + level = ShutdownType.SHUTDOWN_DEFAULT + + try: + self._cmysql.shutdown(level) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + self.close() + + def cmd_statistics(self) -> StatsPacketType: + """Return statistics from the MySQL server""" + self.handle_unread_result() + + try: + stat = self._cmysql.stat() + return MySQLProtocol().parse_statistics(stat, with_header=False) + except (MySQLInterfaceError, InterfaceError) as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + + def cmd_process_kill(self, mysql_pid: int) -> None: + """Kill a MySQL process""" + if not isinstance(mysql_pid, int): + raise ValueError("MySQL PID must be int") + self.cmd_query(f"KILL {mysql_pid}") + + def cmd_debug(self) -> NoReturn: + """Send the DEBUG command""" + raise NotImplementedError + + def cmd_ping(self) -> NoReturn: + """Send the PING command""" + raise NotImplementedError + + def cmd_query_iter(self, statements: str, **kwargs: Any) -> NoReturn: + """Send one or more statements to the MySQL server""" + raise NotImplementedError + + def cmd_stmt_send_long_data( + self, + statement_id: CMySQLPrepStmt, # type: ignore[override] + param_id: int, + data: BinaryIO, + **kwargs: Any, + ) -> NoReturn: + """Send data for a column""" + raise NotImplementedError + + def handle_unread_result(self, prepared: bool = False) -> None: + """Check whether there is an unread result""" + unread_result = self._unread_result if prepared is True else self.unread_result + if self.can_consume_results: + self.consume_results() + elif unread_result: + raise InternalError("Unread result found") + + def reset_session( + self, + user_variables: Optional[Dict[str, Any]] = None, + session_variables: Optional[Dict[str, Any]] = None, + ) -> None: + """Clears the current active session + + This method resets the session state, if the MySQL server is 5.7.3 + or later active session will be reset without re-authenticating. + For other server versions session will be reset by re-authenticating. + + It is possible to provide a sequence of variables and their values to + be set after clearing the session. This is possible for both user + defined variables and session variables. + This method takes two arguments user_variables and session_variables + which are dictionaries. + + Raises OperationalError if not connected, InternalError if there are + unread results and InterfaceError on errors. + """ + if not self.is_connected(): + raise OperationalError("MySQL Connection not available.") + + if not self.cmd_reset_connection(): + try: + self.cmd_change_user( + self._user, + self._password, + self._database, + self._charset_id, + self._password1, + self._password2, + self._password3, + self._oci_config_file, + self._oci_config_profile, + ) + except ProgrammingError: + self.reconnect() + + if user_variables or session_variables: + cur = self.cursor() + if user_variables: + for key, value in user_variables.items(): + cur.execute(f"SET @`{key}` = %s", (value,)) + if session_variables: + for key, value in session_variables.items(): + cur.execute(f"SET SESSION `{key}` = %s", (value,)) + cur.close() diff --git a/server/venv/Lib/site-packages/mysql/connector/constants.py b/server/venv/Lib/site-packages/mysql/connector/constants.py new file mode 100644 index 0000000..99d2d51 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/constants.py @@ -0,0 +1,1165 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Various MySQL constants and character sets.""" + +import warnings + +from abc import ABC, ABCMeta +from datetime import date, datetime, time, timedelta +from decimal import Decimal +from time import struct_time +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union, ValuesView + +from .charsets import MYSQL_CHARACTER_SETS, MYSQL_CHARACTER_SETS_57 +from .errors import ProgrammingError +from .tls_ciphers import APPROVED_TLS_VERSIONS, DEPRECATED_TLS_VERSIONS + +NET_BUFFER_LENGTH: int = 8192 +MAX_MYSQL_TABLE_COLUMNS: int = 4096 +PARAMETER_COUNT_AVAILABLE: int = 8 +"""Flag used to send the Query Attributes with 0 (or more) parameters.""" +MYSQL_VECTOR_TYPE_CODE = "f" +"""Expected `typecode` when decoding VECTOR values from +MySQL (blob) to Python (array.array). +""" +MYSQL_DEFAULT_CHARSET_ID_57 = 45 +MYSQL_DEFAULT_CHARSET_ID_80 = 255 + +DEFAULT_CONFIGURATION: Dict[str, Optional[Union[str, bool, int]]] = { + "database": None, + "user": "", + "password": "", + "password1": "", + "password2": "", + "password3": "", + "host": "127.0.0.1", + "port": 3306, + "unix_socket": None, + "use_unicode": True, + "charset": "utf8mb4", + "collation": None, + "converter_class": None, + "converter_str_fallback": False, + "autocommit": False, + "time_zone": None, + "sql_mode": None, + "get_warnings": False, + "raise_on_warnings": False, + "connection_timeout": None, + "read_timeout": None, + "write_timeout": None, + "client_flags": 0, + "compress": False, + "buffered": False, + "raw": False, + "ssl_ca": None, + "ssl_cert": None, + "ssl_key": None, + "ssl_verify_cert": False, + "ssl_verify_identity": False, + "ssl_cipher": None, + "tls_ciphersuites": None, + "ssl_disabled": False, + "tls_versions": None, + "passwd": None, + "db": None, + "connect_timeout": None, + "dsn": None, + "force_ipv6": False, + "auth_plugin": None, + "allow_local_infile": False, + "allow_local_infile_in_path": None, + "consume_results": False, + "conn_attrs": None, + "dns_srv": False, + "use_pure": False, + "krb_service_principal": None, + "oci_config_file": None, + "oci_config_profile": None, + "webauthn_callback": None, + "kerberos_auth_mode": None, + "init_command": None, + "openid_token_file": None, +} + +CNX_POOL_ARGS: Tuple[str, str, str] = ("pool_name", "pool_size", "pool_reset_session") + +CONN_ATTRS_DN: Tuple[str, ...] = ( + "_pid", + "_platform", + "_source_host", + "_client_name", + "_client_license", + "_client_version", + "_os", + "_connector_name", + "_connector_license", + "_connector_version", +) + +DEPRECATED_METHOD_WARNING: str = """ + The property counterpart '{property_name}' should be used instead. +""" + +TLS_VERSIONS: List[str] = APPROVED_TLS_VERSIONS + DEPRECATED_TLS_VERSIONS +"""Accepted TLS versions. A warning is raised when using a deprecated version.""" + +# TLS v1.2 cipher suites IANI to OpenSSL name translation +TLSV1_2_CIPHER_SUITES: Dict[str, str] = { + "TLS_RSA_WITH_NULL_SHA256": "NULL-SHA256", + "TLS_RSA_WITH_AES_128_CBC_SHA256": "AES128-SHA256", + "TLS_RSA_WITH_AES_256_CBC_SHA256": "AES256-SHA256", + "TLS_RSA_WITH_AES_128_GCM_SHA256": "AES128-GCM-SHA256", + "TLS_RSA_WITH_AES_256_GCM_SHA384": "AES256-GCM-SHA384", + "TLS_DH_RSA_WITH_AES_128_CBC_SHA256": "DH-RSA-AES128-SHA256", + "TLS_DH_RSA_WITH_AES_256_CBC_SHA256": "DH-RSA-AES256-SHA256", + "TLS_DH_RSA_WITH_AES_128_GCM_SHA256": "DH-RSA-AES128-GCM-SHA256", + "TLS_DH_RSA_WITH_AES_256_GCM_SHA384": "DH-RSA-AES256-GCM-SHA384", + "TLS_DH_DSS_WITH_AES_128_CBC_SHA256": "DH-DSS-AES128-SHA256", + "TLS_DH_DSS_WITH_AES_256_CBC_SHA256": "DH-DSS-AES256-SHA256", + "TLS_DH_DSS_WITH_AES_128_GCM_SHA256": "DH-DSS-AES128-GCM-SHA256", + "TLS_DH_DSS_WITH_AES_256_GCM_SHA384": "DH-DSS-AES256-GCM-SHA384", + "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256": "DHE-RSA-AES128-SHA256", + "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256": "DHE-RSA-AES256-SHA256", + "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256": "DHE-RSA-AES128-GCM-SHA256", + "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384": "DHE-RSA-AES256-GCM-SHA384", + "TLS_DHE_DSS_WITH_AES_128_CBC_SHA256": "DHE-DSS-AES128-SHA256", + "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256": "DHE-DSS-AES256-SHA256", + "TLS_DHE_DSS_WITH_AES_128_GCM_SHA256": "DHE-DSS-AES128-GCM-SHA256", + "TLS_DHE_DSS_WITH_AES_256_GCM_SHA384": "DHE-DSS-AES256-GCM-SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256": "ECDHE-RSA-AES128-SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384": "ECDHE-RSA-AES256-SHA384", + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": "ECDHE-RSA-AES128-GCM-SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384": "ECDHE-RSA-AES256-GCM-SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256": "ECDHE-ECDSA-AES128-SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384": "ECDHE-ECDSA-AES256-SHA384", + "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256": "ECDHE-ECDSA-AES128-GCM-SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384": "ECDHE-ECDSA-AES256-GCM-SHA384", + "TLS_DH_anon_WITH_AES_128_CBC_SHA256": "ADH-AES128-SHA256", + "TLS_DH_anon_WITH_AES_256_CBC_SHA256": "ADH-AES256-SHA256", + "TLS_DH_anon_WITH_AES_128_GCM_SHA256": "ADH-AES128-GCM-SHA256", + "TLS_DH_anon_WITH_AES_256_GCM_SHA384": "ADH-AES256-GCM-SHA384", + "RSA_WITH_AES_128_CCM": "AES128-CCM", + "RSA_WITH_AES_256_CCM": "AES256-CCM", + "DHE_RSA_WITH_AES_128_CCM": "DHE-RSA-AES128-CCM", + "DHE_RSA_WITH_AES_256_CCM": "DHE-RSA-AES256-CCM", + "RSA_WITH_AES_128_CCM_8": "AES128-CCM8", + "RSA_WITH_AES_256_CCM_8": "AES256-CCM8", + "DHE_RSA_WITH_AES_128_CCM_8": "DHE-RSA-AES128-CCM8", + "DHE_RSA_WITH_AES_256_CCM_8": "DHE-RSA-AES256-CCM8", + "ECDHE_ECDSA_WITH_AES_128_CCM": "ECDHE-ECDSA-AES128-CCM", + "ECDHE_ECDSA_WITH_AES_256_CCM": "ECDHE-ECDSA-AES256-CCM", + "ECDHE_ECDSA_WITH_AES_128_CCM_8": "ECDHE-ECDSA-AES128-CCM8", + "ECDHE_ECDSA_WITH_AES_256_CCM_8": "ECDHE-ECDSA-AES256-CCM8", + # ARIA cipher suites from RFC6209, extending TLS v1.2 + "TLS_RSA_WITH_ARIA_128_GCM_SHA256": "ARIA128-GCM-SHA256", + "TLS_RSA_WITH_ARIA_256_GCM_SHA384": "ARIA256-GCM-SHA384", + "TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256": "DHE-RSA-ARIA128-GCM-SHA256", + "TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384": "DHE-RSA-ARIA256-GCM-SHA384", + "TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256": "DHE-DSS-ARIA128-GCM-SHA256", + "TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384": "DHE-DSS-ARIA256-GCM-SHA384", + "TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256": "ECDHE-ECDSA-ARIA128-GCM-SHA256", + "TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384": "ECDHE-ECDSA-ARIA256-GCM-SHA384", + "TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256": "ECDHE-ARIA128-GCM-SHA256", + "TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384": "ECDHE-ARIA256-GCM-SHA384", + "TLS_PSK_WITH_ARIA_128_GCM_SHA256": "PSK-ARIA128-GCM-SHA256", + "TLS_PSK_WITH_ARIA_256_GCM_SHA384": "PSK-ARIA256-GCM-SHA384", + "TLS_DHE_PSK_WITH_ARIA_128_GCM_SHA256": "DHE-PSK-ARIA128-GCM-SHA256", + "TLS_DHE_PSK_WITH_ARIA_256_GCM_SHA384": "DHE-PSK-ARIA256-GCM-SHA384", + "TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256": "RSA-PSK-ARIA128-GCM-SHA256", + "TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384": "RSA-PSK-ARIA256-GCM-SHA384", + # Camellia HMAC-Based cipher suites from RFC6367, extending TLS v1.2 + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256": "ECDHE-ECDSA-CAMELLIA128-SHA256", + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384": "ECDHE-ECDSA-CAMELLIA256-SHA384", + "TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256": "ECDHE-RSA-CAMELLIA128-SHA256", + "TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384": "ECDHE-RSA-CAMELLIA256-SHA384", + # Pre-shared keying (PSK) cipher suites", + "PSK_WITH_NULL_SHA": "PSK-NULL-SHA", + "DHE_PSK_WITH_NULL_SHA": "DHE-PSK-NULL-SHA", + "RSA_PSK_WITH_NULL_SHA": "RSA-PSK-NULL-SHA", + "PSK_WITH_RC4_128_SHA": "PSK-RC4-SHA", + "PSK_WITH_3DES_EDE_CBC_SHA": "PSK-3DES-EDE-CBC-SHA", + "PSK_WITH_AES_128_CBC_SHA": "PSK-AES128-CBC-SHA", + "PSK_WITH_AES_256_CBC_SHA": "PSK-AES256-CBC-SHA", + "DHE_PSK_WITH_RC4_128_SHA": "DHE-PSK-RC4-SHA", + "DHE_PSK_WITH_3DES_EDE_CBC_SHA": "DHE-PSK-3DES-EDE-CBC-SHA", + "DHE_PSK_WITH_AES_128_CBC_SHA": "DHE-PSK-AES128-CBC-SHA", + "DHE_PSK_WITH_AES_256_CBC_SHA": "DHE-PSK-AES256-CBC-SHA", + "RSA_PSK_WITH_RC4_128_SHA": "RSA-PSK-RC4-SHA", + "RSA_PSK_WITH_3DES_EDE_CBC_SHA": "RSA-PSK-3DES-EDE-CBC-SHA", + "RSA_PSK_WITH_AES_128_CBC_SHA": "RSA-PSK-AES128-CBC-SHA", + "RSA_PSK_WITH_AES_256_CBC_SHA": "RSA-PSK-AES256-CBC-SHA", + "PSK_WITH_AES_128_GCM_SHA256": "PSK-AES128-GCM-SHA256", + "PSK_WITH_AES_256_GCM_SHA384": "PSK-AES256-GCM-SHA384", + "DHE_PSK_WITH_AES_128_GCM_SHA256": "DHE-PSK-AES128-GCM-SHA256", + "DHE_PSK_WITH_AES_256_GCM_SHA384": "DHE-PSK-AES256-GCM-SHA384", + "RSA_PSK_WITH_AES_128_GCM_SHA256": "RSA-PSK-AES128-GCM-SHA256", + "RSA_PSK_WITH_AES_256_GCM_SHA384": "RSA-PSK-AES256-GCM-SHA384", + "PSK_WITH_AES_128_CBC_SHA256": "PSK-AES128-CBC-SHA256", + "PSK_WITH_AES_256_CBC_SHA384": "PSK-AES256-CBC-SHA384", + "PSK_WITH_NULL_SHA256": "PSK-NULL-SHA256", + "PSK_WITH_NULL_SHA384": "PSK-NULL-SHA384", + "DHE_PSK_WITH_AES_128_CBC_SHA256": "DHE-PSK-AES128-CBC-SHA256", + "DHE_PSK_WITH_AES_256_CBC_SHA384": "DHE-PSK-AES256-CBC-SHA384", + "DHE_PSK_WITH_NULL_SHA256": "DHE-PSK-NULL-SHA256", + "DHE_PSK_WITH_NULL_SHA384": "DHE-PSK-NULL-SHA384", + "RSA_PSK_WITH_AES_128_CBC_SHA256": "RSA-PSK-AES128-CBC-SHA256", + "RSA_PSK_WITH_AES_256_CBC_SHA384": "RSA-PSK-AES256-CBC-SHA384", + "RSA_PSK_WITH_NULL_SHA256": "RSA-PSK-NULL-SHA256", + "RSA_PSK_WITH_NULL_SHA384": "RSA-PSK-NULL-SHA384", + "ECDHE_PSK_WITH_RC4_128_SHA": "ECDHE-PSK-RC4-SHA", + "ECDHE_PSK_WITH_3DES_EDE_CBC_SHA": "ECDHE-PSK-3DES-EDE-CBC-SHA", + "ECDHE_PSK_WITH_AES_128_CBC_SHA": "ECDHE-PSK-AES128-CBC-SHA", + "ECDHE_PSK_WITH_AES_256_CBC_SHA": "ECDHE-PSK-AES256-CBC-SHA", + "ECDHE_PSK_WITH_AES_128_CBC_SHA256": "ECDHE-PSK-AES128-CBC-SHA256", + "ECDHE_PSK_WITH_AES_256_CBC_SHA384": "ECDHE-PSK-AES256-CBC-SHA384", + "ECDHE_PSK_WITH_NULL_SHA": "ECDHE-PSK-NULL-SHA", + "ECDHE_PSK_WITH_NULL_SHA256": "ECDHE-PSK-NULL-SHA256", + "ECDHE_PSK_WITH_NULL_SHA384": "ECDHE-PSK-NULL-SHA384", + "PSK_WITH_CAMELLIA_128_CBC_SHA256": "PSK-CAMELLIA128-SHA256", + "PSK_WITH_CAMELLIA_256_CBC_SHA384": "PSK-CAMELLIA256-SHA384", + "DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256": "DHE-PSK-CAMELLIA128-SHA256", + "DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384": "DHE-PSK-CAMELLIA256-SHA384", + "RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256": "RSA-PSK-CAMELLIA128-SHA256", + "RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384": "RSA-PSK-CAMELLIA256-SHA384", + "ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256": "ECDHE-PSK-CAMELLIA128-SHA256", + "ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384": "ECDHE-PSK-CAMELLIA256-SHA384", + "PSK_WITH_AES_128_CCM": "PSK-AES128-CCM", + "PSK_WITH_AES_256_CCM": "PSK-AES256-CCM", + "DHE_PSK_WITH_AES_128_CCM": "DHE-PSK-AES128-CCM", + "DHE_PSK_WITH_AES_256_CCM": "DHE-PSK-AES256-CCM", + "PSK_WITH_AES_128_CCM_8": "PSK-AES128-CCM8", + "PSK_WITH_AES_256_CCM_8": "PSK-AES256-CCM8", + "DHE_PSK_WITH_AES_128_CCM_8": "DHE-PSK-AES128-CCM8", + "DHE_PSK_WITH_AES_256_CCM_8": "DHE-PSK-AES256-CCM8", + # ChaCha20-Poly1305 cipher suites, extending TLS v1.2 + "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256": "ECDHE-RSA-CHACHA20-POLY1305", + "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": "ECDHE-ECDSA-CHACHA20-POLY1305", + "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256": "DHE-RSA-CHACHA20-POLY1305", + "TLS_PSK_WITH_CHACHA20_POLY1305_SHA256": "PSK-CHACHA20-POLY1305", + "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256": "ECDHE-PSK-CHACHA20-POLY1305", + "TLS_DHE_PSK_WITH_CHACHA20_POLY1305_SHA256": "DHE-PSK-CHACHA20-POLY1305", + "TLS_RSA_PSK_WITH_CHACHA20_POLY1305_SHA256": "RSA-PSK-CHACHA20-POLY1305", +} + +# TLS v1.3 cipher suites IANI to OpenSSL name translation +TLSV1_3_CIPHER_SUITES: Dict[str, str] = { + "TLS_AES_128_GCM_SHA256": "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384": "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256": "TLS_CHACHA20_POLY1305_SHA256", + "TLS_AES_128_CCM_SHA256": "TLS_AES_128_CCM_SHA256", + "TLS_AES_128_CCM_8_SHA256": "TLS_AES_128_CCM_8_SHA256", +} + +TLS_CIPHER_SUITES: Dict[str, Dict[str, str]] = { + "TLSv1.2": TLSV1_2_CIPHER_SUITES, + "TLSv1.3": TLSV1_3_CIPHER_SUITES, +} + +OPENSSL_CS_NAMES: Dict[str, ValuesView[str]] = { + "TLSv1.2": TLSV1_2_CIPHER_SUITES.values(), + "TLSv1.3": TLSV1_3_CIPHER_SUITES.values(), +} + +NATIVE_SUPPORTED_CONVERSION_TYPES = { + int: "int", + float: "float", + str: "str", + bytes: "bytes", + bytearray: "bytearray", + bool: "bool", + type(None): "nonetype", + datetime: "datetime", + date: "date", + time: "time", + struct_time: "struct_time", + timedelta: "timedelta", + Decimal: "decimal", +} +"""Dictionary of the supported data types of the default MySQLConverter class +and the corresponding tag names used to map against their respective methods +'_{tag_name}_to_mysql(...)'. These converter methods are then used to convert +the matched data type to mysql recognizable type.""" + + +def flag_is_set(flag: int, flags: int) -> bool: + """Checks if the flag is set + + Returns boolean""" + if (flags & flag) > 0: + return True + return False + + +def _obsolete_option(name: str, new_name: str, value: int) -> int: + """Raise a deprecation warning and advise a new option name. + + Args: + name (str): The name of the option. + new_name (str): The new option name. + value (int): The value of the option. + + Returns: + int: The value of the option. + """ + warnings.warn( + f"The option '{name}' has been deprecated, use '{new_name}' instead.", + category=DeprecationWarning, + ) + return value + + +class _Constants(ABC): + """Base class for constants.""" + + prefix: str = "" + desc: Dict[str, Tuple[int, str]] = {} + + @classmethod + def get_desc(cls, name: str) -> Optional[str]: + """Get description of given constant""" + try: + return cls.desc[name][1] + except (IndexError, KeyError): + return None + + @classmethod + def get_info(cls, setid: int) -> Union[Optional[str], Tuple[str, str]]: + """Get information about given constant""" + for name, info in cls.desc.items(): + if info[0] == setid: + return name + return None + + @classmethod + def get_full_info(cls) -> Union[str, Sequence[str]]: + """get full information about given constant""" + res: Union[str, List[str]] = [] + try: + res = [f"{k} : {v[1]}" for k, v in cls.desc.items()] + except (AttributeError, IndexError) as err: + res = f"No information found in constant class. {err}" + + return res + + +class _Flags(_Constants): + """Base class for classes describing flags""" + + @classmethod + def get_bit_info(cls, value: int) -> List[str]: + """Get the name of all bits set + + Returns a list of strings.""" + res = [] + for name, info in cls.desc.items(): + if value & info[0]: + res.append(name) + return res + + +class FieldType(_Constants): + """MySQL Field Types. + + This class provides all supported MySQL field or data types. They can be useful + when dealing with raw data or defining your own converters. The field type is + stored with every cursor in the description for each column. + + The `FieldType` class shouldn't be instantiated. + + Examples: + The following example shows how to print the name of the data type for + each column in a result set. + + ``` + from __future__ import print_function + import mysql.connector + from mysql.connector import FieldType + + cnx = mysql.connector.connect(user='scott', database='test') + cursor = cnx.cursor() + + cursor.execute( + "SELECT DATE(NOW()) AS `c1`, TIME(NOW()) AS `c2`, " + "NOW() AS `c3`, 'a string' AS `c4`, 42 AS `c5`") + rows = cursor.fetchall() + + for desc in cursor.description: + colname = desc[0] + coltype = desc[1] + print("Column {} has type {}".format( + colname, FieldType.get_info(coltype))) + + cursor.close() + cnx.close() + ``` + """ + + prefix: str = "FIELD_TYPE_" + DECIMAL: int = 0x00 + TINY: int = 0x01 + SHORT: int = 0x02 + LONG: int = 0x03 + FLOAT: int = 0x04 + DOUBLE: int = 0x05 + NULL: int = 0x06 + TIMESTAMP: int = 0x07 + LONGLONG: int = 0x08 + INT24: int = 0x09 + DATE: int = 0x0A + TIME: int = 0x0B + DATETIME: int = 0x0C + YEAR: int = 0x0D + NEWDATE: int = 0x0E + VARCHAR: int = 0x0F + BIT: int = 0x10 + VECTOR: int = 0xF2 + JSON: int = 0xF5 + NEWDECIMAL: int = 0xF6 + ENUM: int = 0xF7 + SET: int = 0xF8 + TINY_BLOB: int = 0xF9 + MEDIUM_BLOB: int = 0xFA + LONG_BLOB: int = 0xFB + BLOB: int = 0xFC + VAR_STRING: int = 0xFD + STRING: int = 0xFE + GEOMETRY: int = 0xFF + + desc: Dict[str, Tuple[int, str]] = { + "DECIMAL": (DECIMAL, "DECIMAL"), + "TINY": (TINY, "TINY"), + "SHORT": (SHORT, "SHORT"), + "LONG": (LONG, "LONG"), + "FLOAT": (FLOAT, "FLOAT"), + "DOUBLE": (DOUBLE, "DOUBLE"), + "NULL": (NULL, "NULL"), + "TIMESTAMP": (TIMESTAMP, "TIMESTAMP"), + "LONGLONG": (LONGLONG, "LONGLONG"), + "INT24": (INT24, "INT24"), + "DATE": (DATE, "DATE"), + "TIME": (TIME, "TIME"), + "DATETIME": (DATETIME, "DATETIME"), + "YEAR": (YEAR, "YEAR"), + "NEWDATE": (NEWDATE, "NEWDATE"), + "VARCHAR": (VARCHAR, "VARCHAR"), + "BIT": (BIT, "BIT"), + "VECTOR": (VECTOR, "VECTOR"), + "JSON": (JSON, "JSON"), + "NEWDECIMAL": (NEWDECIMAL, "NEWDECIMAL"), + "ENUM": (ENUM, "ENUM"), + "SET": (SET, "SET"), + "TINY_BLOB": (TINY_BLOB, "TINY_BLOB"), + "MEDIUM_BLOB": (MEDIUM_BLOB, "MEDIUM_BLOB"), + "LONG_BLOB": (LONG_BLOB, "LONG_BLOB"), + "BLOB": (BLOB, "BLOB"), + "VAR_STRING": (VAR_STRING, "VAR_STRING"), + "STRING": (STRING, "STRING"), + "GEOMETRY": (GEOMETRY, "GEOMETRY"), + } + + @classmethod + def get_string_types(cls) -> List[int]: + """Get the list of all string types""" + return [ + cls.VARCHAR, + cls.ENUM, + cls.VAR_STRING, + cls.STRING, + ] + + @classmethod + def get_binary_types(cls) -> List[int]: + """Get the list of all binary types""" + return [ + cls.TINY_BLOB, + cls.MEDIUM_BLOB, + cls.LONG_BLOB, + cls.BLOB, + ] + + @classmethod + def get_number_types(cls) -> List[int]: + """Get the list of all number types""" + return [ + cls.DECIMAL, + cls.NEWDECIMAL, + cls.TINY, + cls.SHORT, + cls.LONG, + cls.FLOAT, + cls.DOUBLE, + cls.LONGLONG, + cls.INT24, + cls.BIT, + cls.YEAR, + ] + + @classmethod + def get_timestamp_types(cls) -> List[int]: + """Get the list of all timestamp types""" + return [ + cls.DATETIME, + cls.TIMESTAMP, + ] + + +class FieldFlag(_Flags): + """MySQL Field Flags + + Field flags as found in MySQL sources mysql-src/include/mysql_com.h + """ + + _prefix: str = "" + NOT_NULL: int = 1 << 0 + PRI_KEY: int = 1 << 1 + UNIQUE_KEY: int = 1 << 2 + MULTIPLE_KEY: int = 1 << 3 + BLOB: int = 1 << 4 + UNSIGNED: int = 1 << 5 + ZEROFILL: int = 1 << 6 + BINARY: int = 1 << 7 + + ENUM: int = 1 << 8 + AUTO_INCREMENT: int = 1 << 9 + TIMESTAMP: int = 1 << 10 + SET: int = 1 << 11 + + NO_DEFAULT_VALUE: int = 1 << 12 + ON_UPDATE_NOW: int = 1 << 13 + NUM: int = 1 << 14 + PART_KEY: int = 1 << 15 + GROUP: int = 1 << 14 # SAME AS NUM !!!!!!!???? + UNIQUE: int = 1 << 16 + BINCMP: int = 1 << 17 + + GET_FIXED_FIELDS: int = 1 << 18 + FIELD_IN_PART_FUNC: int = 1 << 19 + FIELD_IN_ADD_INDEX: int = 1 << 20 + FIELD_IS_RENAMED: int = 1 << 21 + + desc: Dict[str, Tuple[int, str]] = { + "NOT_NULL": (1 << 0, "Field can't be NULL"), + "PRI_KEY": (1 << 1, "Field is part of a primary key"), + "UNIQUE_KEY": (1 << 2, "Field is part of a unique key"), + "MULTIPLE_KEY": (1 << 3, "Field is part of a key"), + "BLOB": (1 << 4, "Field is a blob"), + "UNSIGNED": (1 << 5, "Field is unsigned"), + "ZEROFILL": (1 << 6, "Field is zerofill"), + "BINARY": (1 << 7, "Field is binary "), + "ENUM": (1 << 8, "field is an enum"), + "AUTO_INCREMENT": (1 << 9, "field is a autoincrement field"), + "TIMESTAMP": (1 << 10, "Field is a timestamp"), + "SET": (1 << 11, "field is a set"), + "NO_DEFAULT_VALUE": (1 << 12, "Field doesn't have default value"), + "ON_UPDATE_NOW": (1 << 13, "Field is set to NOW on UPDATE"), + "NUM": (1 << 14, "Field is num (for clients)"), + "PART_KEY": (1 << 15, "Intern; Part of some key"), + "GROUP": (1 << 14, "Intern: Group field"), # Same as NUM + "UNIQUE": (1 << 16, "Intern: Used by sql_yacc"), + "BINCMP": (1 << 17, "Intern: Used by sql_yacc"), + "GET_FIXED_FIELDS": (1 << 18, "Used to get fields in item tree"), + "FIELD_IN_PART_FUNC": (1 << 19, "Field part of partition func"), + "FIELD_IN_ADD_INDEX": (1 << 20, "Intern: Field used in ADD INDEX"), + "FIELD_IS_RENAMED": (1 << 21, "Intern: Field is being renamed"), + } + + +class ServerCmdMeta(ABCMeta): + """ClientFlag Metaclass.""" + + def __getattribute__(cls, name: str) -> Any: + deprecated_options = ( + "FIELD_LIST", + "REFRESH", + "SHUTDOWN", + "PROCESS_INFO", + "PROCESS_KILL", + ) + if name in deprecated_options: + warnings.warn( + f"The option 'ServerCmd.{name}' is deprecated and will be removed in " + "a future release.", + category=DeprecationWarning, + ) + return super().__getattribute__(name) + + +class ServerCmd(_Constants, metaclass=ServerCmdMeta): + """MySQL Server Commands""" + + _prefix: str = "COM_" + SLEEP: int = 0 + QUIT: int = 1 + INIT_DB: int = 2 + QUERY: int = 3 + FIELD_LIST: int = 4 + CREATE_DB: int = 5 + DROP_DB: int = 6 + REFRESH: int = 7 + SHUTDOWN: int = 8 + STATISTICS: int = 9 + PROCESS_INFO: int = 10 + CONNECT: int = 11 + PROCESS_KILL: int = 12 + DEBUG: int = 13 + PING: int = 14 + TIME: int = 15 + DELAYED_INSERT: int = 16 + CHANGE_USER: int = 17 + BINLOG_DUMP: int = 18 + TABLE_DUMP: int = 19 + CONNECT_OUT: int = 20 + REGISTER_REPLICA: int = 21 + STMT_PREPARE: int = 22 + STMT_EXECUTE: int = 23 + STMT_SEND_LONG_DATA: int = 24 + STMT_CLOSE: int = 25 + STMT_RESET: int = 26 + SET_OPTION: int = 27 + STMT_FETCH: int = 28 + DAEMON: int = 29 + BINLOG_DUMP_GTID: int = 30 + RESET_CONNECTION: int = 31 + + desc: Dict[str, Tuple[int, str]] = { + "SLEEP": (0, "SLEEP"), + "QUIT": (1, "QUIT"), + "INIT_DB": (2, "INIT_DB"), + "QUERY": (3, "QUERY"), + "FIELD_LIST": (4, "FIELD_LIST"), + "CREATE_DB": (5, "CREATE_DB"), + "DROP_DB": (6, "DROP_DB"), + "REFRESH": (7, "REFRESH"), + "SHUTDOWN": (8, "SHUTDOWN"), + "STATISTICS": (9, "STATISTICS"), + "PROCESS_INFO": (10, "PROCESS_INFO"), + "CONNECT": (11, "CONNECT"), + "PROCESS_KILL": (12, "PROCESS_KILL"), + "DEBUG": (13, "DEBUG"), + "PING": (14, "PING"), + "TIME": (15, "TIME"), + "DELAYED_INSERT": (16, "DELAYED_INSERT"), + "CHANGE_USER": (17, "CHANGE_USER"), + "BINLOG_DUMP": (18, "BINLOG_DUMP"), + "TABLE_DUMP": (19, "TABLE_DUMP"), + "CONNECT_OUT": (20, "CONNECT_OUT"), + "REGISTER_REPLICA": (21, "REGISTER_REPLICA"), + "STMT_PREPARE": (22, "STMT_PREPARE"), + "STMT_EXECUTE": (23, "STMT_EXECUTE"), + "STMT_SEND_LONG_DATA": (24, "STMT_SEND_LONG_DATA"), + "STMT_CLOSE": (25, "STMT_CLOSE"), + "STMT_RESET": (26, "STMT_RESET"), + "SET_OPTION": (27, "SET_OPTION"), + "STMT_FETCH": (28, "STMT_FETCH"), + "DAEMON": (29, "DAEMON"), + "BINLOG_DUMP_GTID": (30, "BINLOG_DUMP_GTID"), + "RESET_CONNECTION": (31, "RESET_CONNECTION"), + } + + +class ClientFlag(_Flags): + """MySQL Client Flags. + + Client options as found in the MySQL sources mysql-src/include/mysql_com.h. + + This class provides constants defining MySQL client flags that can be used + when the connection is established to configure the session. The `ClientFlag` + class is available when importing mysql.connector. + + The `ClientFlag` class shouldn't be instantiated. + + Examples: + ``` + >>> import mysql.connector + >>> mysql.connector.ClientFlag.FOUND_ROWS + 2 + ``` + """ + + LONG_PASSWD: int = 1 << 0 + FOUND_ROWS: int = 1 << 1 + LONG_FLAG: int = 1 << 2 + CONNECT_WITH_DB: int = 1 << 3 + NO_SCHEMA: int = 1 << 4 + COMPRESS: int = 1 << 5 + ODBC: int = 1 << 6 + LOCAL_FILES: int = 1 << 7 + IGNORE_SPACE: int = 1 << 8 + PROTOCOL_41: int = 1 << 9 + INTERACTIVE: int = 1 << 10 + SSL: int = 1 << 11 + IGNORE_SIGPIPE: int = 1 << 12 + TRANSACTIONS: int = 1 << 13 + RESERVED: int = 1 << 14 + SECURE_CONNECTION: int = 1 << 15 + MULTI_STATEMENTS: int = 1 << 16 + MULTI_RESULTS: int = 1 << 17 + PS_MULTI_RESULTS: int = 1 << 18 + PLUGIN_AUTH: int = 1 << 19 + CONNECT_ARGS: int = 1 << 20 + PLUGIN_AUTH_LENENC_CLIENT_DATA: int = 1 << 21 + CAN_HANDLE_EXPIRED_PASSWORDS: int = 1 << 22 + SESION_TRACK: int = 1 << 23 # deprecated + SESSION_TRACK: int = 1 << 23 + DEPRECATE_EOF: int = 1 << 24 + CLIENT_QUERY_ATTRIBUTES: int = 1 << 27 + SSL_VERIFY_SERVER_CERT: int = 1 << 30 + REMEMBER_OPTIONS: int = 1 << 31 + MULTI_FACTOR_AUTHENTICATION: int = 1 << 28 + + desc: Dict[str, Tuple[int, str]] = { + "LONG_PASSWD": (1 << 0, "New more secure passwords"), + "FOUND_ROWS": (1 << 1, "Found instead of affected rows"), + "LONG_FLAG": (1 << 2, "Get all column flags"), + "CONNECT_WITH_DB": (1 << 3, "One can specify db on connect"), + "NO_SCHEMA": (1 << 4, "Don't allow database.table.column"), + "COMPRESS": (1 << 5, "Can use compression protocol"), + "ODBC": (1 << 6, "ODBC client"), + "LOCAL_FILES": (1 << 7, "Can use LOAD DATA LOCAL"), + "IGNORE_SPACE": (1 << 8, "Ignore spaces before ''"), + "PROTOCOL_41": (1 << 9, "New 4.1 protocol"), + "INTERACTIVE": (1 << 10, "This is an interactive client"), + "SSL": (1 << 11, "Switch to SSL after handshake"), + "IGNORE_SIGPIPE": (1 << 12, "IGNORE sigpipes"), + "TRANSACTIONS": (1 << 13, "Client knows about transactions"), + "RESERVED": (1 << 14, "Old flag for 4.1 protocol"), + "SECURE_CONNECTION": (1 << 15, "New 4.1 authentication"), + "MULTI_STATEMENTS": (1 << 16, "Enable/disable multi-stmt support"), + "MULTI_RESULTS": (1 << 17, "Enable/disable multi-results"), + "PS_MULTI_RESULTS": (1 << 18, "Multi-results in PS-protocol"), + "PLUGIN_AUTH": (1 << 19, "Client supports plugin authentication"), + "CONNECT_ARGS": (1 << 20, "Client supports connection attributes"), + "PLUGIN_AUTH_LENENC_CLIENT_DATA": ( + 1 << 21, + "Enable authentication response packet to be larger than 255 bytes", + ), + "CAN_HANDLE_EXPIRED_PASSWORDS": ( + 1 << 22, + "Don't close the connection for a connection with expired password", + ), + "SESION_TRACK": ( # deprecated + 1 << 23, + "Capable of handling server state change information", + ), + "SESSION_TRACK": ( + 1 << 23, + "Capable of handling server state change information", + ), + "DEPRECATE_EOF": (1 << 24, "Client no longer needs EOF packet"), + "CLIENT_QUERY_ATTRIBUTES": ( + 1 << 27, + "Support optional extension for query parameters", + ), + "SSL_VERIFY_SERVER_CERT": (1 << 30, ""), + "REMEMBER_OPTIONS": (1 << 31, ""), + } + + default: List[int] = [ + LONG_PASSWD, + LONG_FLAG, + CONNECT_WITH_DB, + PROTOCOL_41, + TRANSACTIONS, + SECURE_CONNECTION, + MULTI_STATEMENTS, + MULTI_RESULTS, + CONNECT_ARGS, + PLUGIN_AUTH_LENENC_CLIENT_DATA, + ] + + @classmethod + def get_default(cls) -> int: + """Get the default client options set + + Returns a flag with all the default client options set""" + flags = 0 + for option in cls.default: + flags |= option + return flags + + +class ServerFlag(_Flags): + """MySQL Server Flags + + Server flags as found in the MySQL sources mysql-src/include/mysql_com.h + """ + + _prefix: str = "SERVER_" + STATUS_IN_TRANS: int = 1 << 0 + STATUS_AUTOCOMMIT: int = 1 << 1 + MORE_RESULTS_EXISTS: int = 1 << 3 + QUERY_NO_GOOD_INDEX_USED: int = 1 << 4 + QUERY_NO_INDEX_USED: int = 1 << 5 + STATUS_CURSOR_EXISTS: int = 1 << 6 + STATUS_LAST_ROW_SENT: int = 1 << 7 + STATUS_DB_DROPPED: int = 1 << 8 + STATUS_NO_BACKSLASH_ESCAPES: int = 1 << 9 + SERVER_STATUS_METADATA_CHANGED: int = 1 << 10 + SERVER_QUERY_WAS_SLOW: int = 1 << 11 + SERVER_PS_OUT_PARAMS: int = 1 << 12 + SERVER_STATUS_IN_TRANS_READONLY: int = 1 << 13 + SERVER_SESSION_STATE_CHANGED: int = 1 << 14 + + desc: Dict[str, Tuple[int, str]] = { + "SERVER_STATUS_IN_TRANS": (1 << 0, "Transaction has started"), + "SERVER_STATUS_AUTOCOMMIT": (1 << 1, "Server in auto_commit mode"), + "SERVER_MORE_RESULTS_EXISTS": ( + 1 << 3, + "Multi query - next query exists", + ), + "SERVER_QUERY_NO_GOOD_INDEX_USED": (1 << 4, ""), + "SERVER_QUERY_NO_INDEX_USED": (1 << 5, ""), + "SERVER_STATUS_CURSOR_EXISTS": ( + 1 << 6, + "Set when server opened a read-only non-scrollable cursor for a query.", + ), + "SERVER_STATUS_LAST_ROW_SENT": ( + 1 << 7, + "Set when a read-only cursor is exhausted", + ), + "SERVER_STATUS_DB_DROPPED": (1 << 8, "A database was dropped"), + "SERVER_STATUS_NO_BACKSLASH_ESCAPES": (1 << 9, ""), + "SERVER_STATUS_METADATA_CHANGED": ( + 1024, + "Set if after a prepared statement " + "reprepare we discovered that the " + "new statement returns a different " + "number of result set columns.", + ), + "SERVER_QUERY_WAS_SLOW": (2048, ""), + "SERVER_PS_OUT_PARAMS": ( + 4096, + "To mark ResultSet containing output parameter values.", + ), + "SERVER_STATUS_IN_TRANS_READONLY": ( + 8192, + "Set if multi-statement transaction is a read-only transaction.", + ), + "SERVER_SESSION_STATE_CHANGED": ( + 1 << 14, + "Session state has changed on the " + "server because of the execution of " + "the last statement", + ), + } + + +class RefreshOptionMeta(ABCMeta): + """RefreshOption Metaclass.""" + + @property + def SLAVE(self) -> int: # pylint: disable=bad-mcs-method-argument,invalid-name + """Return the deprecated alias of RefreshOption.REPLICA. + + Raises a warning about this attribute deprecation. + """ + return _obsolete_option( + "RefreshOption.SLAVE", + "RefreshOption.REPLICA", + RefreshOption.REPLICA, + ) + + +class RefreshOption(_Constants, metaclass=RefreshOptionMeta): + """MySQL Refresh command options. + + Options used when sending the COM_REFRESH server command. + """ + + _prefix: str = "REFRESH_" + GRANT: int = 1 << 0 + LOG: int = 1 << 1 + TABLES: int = 1 << 2 + HOST: int = 1 << 3 + STATUS: int = 1 << 4 + REPLICA: int = 1 << 6 + + desc: Dict[str, Tuple[int, str]] = { + "GRANT": (1 << 0, "Refresh grant tables"), + "LOG": (1 << 1, "Start on new log file"), + "TABLES": (1 << 2, "close all tables"), + "HOST": (1 << 3, "Flush host cache"), + "STATUS": (1 << 4, "Flush status variables"), + "REPLICA": (1 << 6, "Reset source info and restart replica thread"), + "SLAVE": (1 << 6, "Deprecated option; use REPLICA instead."), + } + + +class ShutdownType(_Constants): + """MySQL Shutdown types + + Shutdown types used by the COM_SHUTDOWN server command. + """ + + _prefix: str = "" + SHUTDOWN_DEFAULT: int = 0 + SHUTDOWN_WAIT_CONNECTIONS: int = 1 + SHUTDOWN_WAIT_TRANSACTIONS: int = 2 + SHUTDOWN_WAIT_UPDATES: int = 8 + SHUTDOWN_WAIT_ALL_BUFFERS: int = 16 + SHUTDOWN_WAIT_CRITICAL_BUFFERS: int = 17 + KILL_QUERY: int = 254 + KILL_CONNECTION: int = 255 + + desc: Dict[str, Tuple[int, str]] = { + "SHUTDOWN_DEFAULT": ( + SHUTDOWN_DEFAULT, + "defaults to SHUTDOWN_WAIT_ALL_BUFFERS", + ), + "SHUTDOWN_WAIT_CONNECTIONS": ( + SHUTDOWN_WAIT_CONNECTIONS, + "wait for existing connections to finish", + ), + "SHUTDOWN_WAIT_TRANSACTIONS": ( + SHUTDOWN_WAIT_TRANSACTIONS, + "wait for existing trans to finish", + ), + "SHUTDOWN_WAIT_UPDATES": ( + SHUTDOWN_WAIT_UPDATES, + "wait for existing updates to finish", + ), + "SHUTDOWN_WAIT_ALL_BUFFERS": ( + SHUTDOWN_WAIT_ALL_BUFFERS, + "flush InnoDB and other storage engine buffers", + ), + "SHUTDOWN_WAIT_CRITICAL_BUFFERS": ( + SHUTDOWN_WAIT_CRITICAL_BUFFERS, + "don't flush InnoDB buffers, flush other storage engines' buffers", + ), + "KILL_QUERY": (KILL_QUERY, "(no description)"), + "KILL_CONNECTION": (KILL_CONNECTION, "(no description)"), + } + + +class CharacterSet: + """MySQL supported character sets and collations + + List of character sets with their collations supported by MySQL. This + maps to the character set we get from the server within the handshake + packet. + + The list is hardcode so we avoid a database query when getting the + name of the used character set or collation. + """ + + # Multi-byte character sets which use 5c (backslash) in characters + slash_charsets: Tuple[int, ...] = (1, 13, 28, 84, 87, 88) + + def __init__(self) -> None: + # Use LTS character set as default + self._desc: List[Optional[Tuple[str, str, bool]]] = MYSQL_CHARACTER_SETS_57 + self._mysql_version: Tuple[int, ...] = (5, 7) + + def set_mysql_version(self, version: Tuple[int, ...]) -> None: + """Set the MySQL major version and change the charset mapping if is 5.7. + + Args: + version (tuple): MySQL version tuple. + """ + self._mysql_version = version[:2] + if self._mysql_version >= (8, 0): + self._desc = MYSQL_CHARACTER_SETS + + def get_info(self, setid: int) -> Tuple[str, str]: + """Retrieves character set information as tuple using an ID + + Retrieves character set and collation information based on the + given MySQL ID. + + Raises ProgrammingError when character set is not supported. + + Returns a tuple. + """ + try: + return self._desc[setid][0:2] + except IndexError: + raise ProgrammingError(f"Character set '{setid}' unsupported") from None + + def get_desc(self, name: int) -> str: + """Retrieves character set information as string using an ID + + Retrieves character set and collation information based on the + given MySQL ID. + + Returns a tuple. + """ + charset, collation = self.get_info(name) + return f"{charset}/{collation}" + + def get_default_collation(self, charset: Union[int, str]) -> Tuple[str, str, int]: + """Retrieves the default collation for given character set + + Raises ProgrammingError when character set is not supported. + + Returns list (collation, charset, index) + """ + if isinstance(charset, int): + try: + info = self._desc[charset] + return info[1], info[0], charset + except (IndexError, KeyError) as err: + raise ProgrammingError( + f"Character set ID '{charset}' unsupported" + ) from err + + for cid, info in enumerate(self._desc): + if info is None: + continue + if info[0] == charset and info[2] is True: + return info[1], info[0], cid + + raise ProgrammingError(f"Character set '{charset}' unsupported") + + def get_charset_info( + self, charset: Optional[Union[int, str]] = None, collation: Optional[str] = None + ) -> Tuple[int, str, str]: + """Get character set information using charset name and/or collation + + Retrieves character set and collation information given character + set name and/or a collation name. + If charset is an integer, it will look up the character set based + on the MySQL's ID. + For example: + get_charset_info('utf8',None) + get_charset_info(collation='utf8_general_ci') + get_charset_info(47) + + Raises ProgrammingError when character set is not supported. + + Returns a tuple with (id, characterset name, collation) + """ + info: Optional[Union[Tuple[str, str, bool], Tuple[str, str, int]]] = None + if isinstance(charset, int): + try: + info = self._desc[charset] + return (charset, info[0], info[1]) + except IndexError as err: + raise ProgrammingError(f"Character set ID {charset} unknown") from err + + if charset in ("utf8", "utf-8") and self._mysql_version >= (8, 0): + charset = "utf8mb4" + if charset is not None and collation is None: + info = self.get_default_collation(charset) + return (info[2], info[1], info[0]) + if charset is None and collation is not None: + for cid, info in enumerate(self._desc): + if info is None: + continue + if collation == info[1]: + return (cid, info[0], info[1]) + raise ProgrammingError(f"Collation '{collation}' unknown") + for cid, info in enumerate(self._desc): + if info is None: + continue + if info[0] == charset and info[1] == collation: + return (cid, info[0], info[1]) + _ = self.get_default_collation(charset) + raise ProgrammingError(f"Collation '{collation}' unknown") + + def get_supported(self) -> Tuple[str, ...]: + """Retrieves a list with names of all supproted character sets + + Returns a tuple. + """ + res = [] + for info in self._desc: + if info and info[0] not in res: + res.append(info[0]) + return tuple(res) + + +class SQLMode(_Constants): + """MySQL SQL Modes + + The numeric values of SQL Modes are not interesting, only the names + are used when setting the SQL_MODE system variable using the MySQL + SET command. + + The `SQLMode` class shouldn't be instantiated. + + See http://dev.mysql.com/doc/refman/5.6/en/server-sql-mode.html + """ + + _prefix: str = "MODE_" + REAL_AS_FLOAT: str = "REAL_AS_FLOAT" + PIPES_AS_CONCAT: str = "PIPES_AS_CONCAT" + ANSI_QUOTES: str = "ANSI_QUOTES" + IGNORE_SPACE: str = "IGNORE_SPACE" + NOT_USED: str = "NOT_USED" + ONLY_FULL_GROUP_BY: str = "ONLY_FULL_GROUP_BY" + NO_UNSIGNED_SUBTRACTION: str = "NO_UNSIGNED_SUBTRACTION" + NO_DIR_IN_CREATE: str = "NO_DIR_IN_CREATE" + POSTGRESQL: str = "POSTGRESQL" + ORACLE: str = "ORACLE" + MSSQL: str = "MSSQL" + DB2: str = "DB2" + MAXDB: str = "MAXDB" + NO_KEY_OPTIONS: str = "NO_KEY_OPTIONS" + NO_TABLE_OPTIONS: str = "NO_TABLE_OPTIONS" + NO_FIELD_OPTIONS: str = "NO_FIELD_OPTIONS" + MYSQL323: str = "MYSQL323" + MYSQL40: str = "MYSQL40" + ANSI: str = "ANSI" + NO_AUTO_VALUE_ON_ZERO: str = "NO_AUTO_VALUE_ON_ZERO" + NO_BACKSLASH_ESCAPES: str = "NO_BACKSLASH_ESCAPES" + STRICT_TRANS_TABLES: str = "STRICT_TRANS_TABLES" + STRICT_ALL_TABLES: str = "STRICT_ALL_TABLES" + NO_ZERO_IN_DATE: str = "NO_ZERO_IN_DATE" + NO_ZERO_DATE: str = "NO_ZERO_DATE" + INVALID_DATES: str = "INVALID_DATES" + ERROR_FOR_DIVISION_BY_ZERO: str = "ERROR_FOR_DIVISION_BY_ZERO" + TRADITIONAL: str = "TRADITIONAL" + NO_AUTO_CREATE_USER: str = "NO_AUTO_CREATE_USER" + HIGH_NOT_PRECEDENCE: str = "HIGH_NOT_PRECEDENCE" + NO_ENGINE_SUBSTITUTION: str = "NO_ENGINE_SUBSTITUTION" + PAD_CHAR_TO_FULL_LENGTH: str = "PAD_CHAR_TO_FULL_LENGTH" + + @classmethod + def get_desc(cls, name: str) -> Optional[str]: + raise NotImplementedError + + @classmethod + def get_info(cls, setid: int) -> Optional[str]: + raise NotImplementedError + + @classmethod + def get_full_info(cls) -> Tuple[str, ...]: + """Returns a sequence of all available SQL Modes + + This class method returns a tuple containing all SQL Mode names. The + names will be alphabetically sorted. + + Returns a tuple. + """ + res = [] + for key in vars(cls).keys(): + if not key.startswith("_") and not hasattr(getattr(cls, key), "__call__"): + res.append(key) + return tuple(sorted(res)) diff --git a/server/venv/Lib/site-packages/mysql/connector/conversion.py b/server/venv/Lib/site-packages/mysql/connector/conversion.py new file mode 100644 index 0000000..0e92a71 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/conversion.py @@ -0,0 +1,789 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Converting MySQL and Python types""" + +import array +import datetime +import math +import struct +import time + +from decimal import Decimal +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union + +from .constants import ( + MYSQL_VECTOR_TYPE_CODE, + NATIVE_SUPPORTED_CONVERSION_TYPES, + CharacterSet, + FieldFlag, + FieldType, + SQLMode, +) +from .custom_types import HexLiteral +from .types import ( + DescriptionType, + MySQLConvertibleType, + MySQLProducedType, + PythonProducedType, + StrOrBytes, +) +from .utils import NUMERIC_TYPES + +CONVERT_ERROR = "Could not convert '{value}' to python {pytype}" + + +class MySQLConverterBase: + """Base class for conversion classes + + All class dealing with converting to and from MySQL data types must + be a subclass of this class. + """ + + def __init__( + self, + charset: Optional[str] = "utf8", + use_unicode: bool = True, + str_fallback: bool = False, + ) -> None: + self._character_set: CharacterSet = CharacterSet() + self.python_types: Optional[Tuple] = None + self.mysql_types: Optional[Tuple] = None + self.charset: Optional[str] = None + self.charset_id: int = 0 + self.set_charset(charset) + self.use_unicode: bool = use_unicode + self.str_fallback: bool = str_fallback + self._cache_field_types: Dict[ + int, + Callable[[bytes, DescriptionType], PythonProducedType], + ] = {} + + def set_charset( + self, charset: Optional[str], character_set: Optional[CharacterSet] = None + ) -> None: + """Set character set""" + if charset in ("utf8mb4", "utf8mb3"): + charset = "utf8" + if charset is not None: + self.charset = charset + else: + # default to utf8 + self.charset = "utf8" + + if character_set: + self._character_set = character_set + + self.charset_id = self._character_set.get_charset_info(self.charset)[0] + + def set_unicode(self, value: bool = True) -> None: + """Set whether to use Unicode""" + self.use_unicode = value + + def to_mysql( + self, value: MySQLConvertibleType + ) -> Union[MySQLConvertibleType, HexLiteral]: + """Convert Python data type to MySQL""" + type_name = value.__class__.__name__.lower() + try: + converted: MySQLConvertibleType = getattr(self, f"_{type_name}_to_mysql")( + value + ) + return converted + except AttributeError: + return value + + def to_python( + self, vtype: DescriptionType, value: Optional[bytes] + ) -> PythonProducedType: + """Convert MySQL data type to Python""" + + if (value == b"\x00" or value is None) and vtype[1] != FieldType.BIT: + # Don't go further when we hit a NULL value + return None + + if not self._cache_field_types: + self._cache_field_types = {} + for name, info in FieldType.desc.items(): + try: + self._cache_field_types[info[0]] = getattr( + self, f"_{name.lower()}_to_python" + ) + except AttributeError: + # We ignore field types which has no method + pass + if value is None: + return None + try: + return self._cache_field_types[vtype[1]](value, vtype) + except KeyError: + return value + + @staticmethod + def escape( + value: Any, + sql_mode: Optional[Union[str, bytes]] = None, # pylint: disable=unused-argument + ) -> Any: + """Escape buffer for sending to MySQL""" + return value + + @staticmethod + def quote(buf: Any) -> StrOrBytes: + """Quote buffer for sending to MySQL""" + return str(buf) + + +class MySQLConverter(MySQLConverterBase): + """Default conversion class for MySQL Connector/Python. + + o escape method: for escaping values send to MySQL + o quoting method: for quoting values send to MySQL in statements + o conversion mapping: maps Python and MySQL data types to + function for converting them. + + Whenever one needs to convert values differently, a converter_class + argument can be given while instantiating a new connection like + cnx.connect(converter_class=CustomMySQLConverterClass). + + """ + + def __init__( + self, + charset: Optional[str] = None, + use_unicode: bool = True, + str_fallback: bool = False, + ) -> None: + super().__init__(charset, use_unicode, str_fallback) + self._cache_field_types: Dict[ + int, + Callable[[bytes, DescriptionType], PythonProducedType], + ] = {} + + @staticmethod + def escape(value: Any, sql_mode: Optional[Union[str, bytes]] = None) -> Any: + """ + Escapes special characters as they are expected to by when MySQL + receives them. + As found in MySQL source mysys/charset.c + + Returns the value if not a string, or the escaped string. + """ + if isinstance(sql_mode, bytes): + # sql_mode is returned as bytes if use_unicode is set to False during connect() + sql_mode = sql_mode.decode() + if isinstance(value, (bytes, bytearray)): + if sql_mode is not None and SQLMode.NO_BACKSLASH_ESCAPES in sql_mode: + return value.replace(b"'", b"''") + value = value.replace(b"\\", b"\\\\") + value = value.replace(b"\n", b"\\n") + value = value.replace(b"\r", b"\\r") + value = value.replace(b"\047", b"\134\047") # single quotes + value = value.replace(b"\042", b"\134\042") # double quotes + value = value.replace(b"\032", b"\134\032") # for Win32 + elif isinstance(value, str) and not isinstance(value, HexLiteral): + if sql_mode is not None and SQLMode.NO_BACKSLASH_ESCAPES in sql_mode: + return value.replace("'", "''") + value = value.replace("\\", "\\\\") + value = value.replace("\n", "\\n") + value = value.replace("\r", "\\r") + value = value.replace("\047", "\134\047") # single quotes + value = value.replace("\042", "\134\042") # double quotes + value = value.replace("\032", "\134\032") # for Win32 + return value + + @staticmethod + def quote(buf: Optional[Union[float, int, Decimal, HexLiteral, bytes]]) -> bytes: + """ + Quote the parameters for commands. General rules: + o numbers are returns as bytes using ascii codec + o None is returned as bytearray(b'NULL') + o Everything else is single quoted '' + + Returns a bytearray object. + """ + if isinstance(buf, NUMERIC_TYPES): + return str(buf).encode("ascii") + if isinstance(buf, type(None)): + return bytearray(b"NULL") + return bytearray(b"'" + buf + b"'") # type: ignore[operator] + + def to_mysql(self, value: MySQLConvertibleType) -> MySQLProducedType: + """Convert Python data type to MySQL""" + if isinstance(value, Enum): + value = value.value + # check if type of value object matches any one of the native supported conversion types + # most of the types will match the condition below + type_name: str = NATIVE_SUPPORTED_CONVERSION_TYPES.get(type(value), "") + if not type_name: + # check if the value object inherits from one of the native supported conversion types + type_name = next( + ( + name + for data_type, name in NATIVE_SUPPORTED_CONVERSION_TYPES.items() + if isinstance(value, data_type) + ), + value.__class__.__name__.lower(), + ) + try: + converted: MySQLProducedType = getattr(self, f"_{type_name}_to_mysql")( + value + ) + return converted + except AttributeError: + # Value type is not a native one, nor a subclass of a native one + if self.str_fallback: + return str(value).encode() + raise TypeError( + f"Python '{type_name}' cannot be converted to a MySQL type" + ) from None + + def to_python( + self, + vtype: DescriptionType, + value: Optional[bytes], + ) -> PythonProducedType: + """Convert MySQL data type to Python""" + # \x00 + if value == 0 and vtype[1] != FieldType.BIT: + # Don't go further when we hit a NULL value + return None + if value is None: + return None + + if not self._cache_field_types: + self._cache_field_types = {} + for name, info in FieldType.desc.items(): + try: + self._cache_field_types[info[0]] = getattr( + self, f"_{name.lower()}_to_python" + ) + except AttributeError: + # We ignore field types which has no method + pass + + try: + return self._cache_field_types[vtype[1]](value, vtype) + except KeyError: + # If one type is not defined, we just return the value as str + try: + return value.decode("utf-8") + except UnicodeDecodeError: + return value + except ValueError as err: + raise ValueError(f"{err} (field {vtype[0]})") from err + except TypeError as err: + raise TypeError(f"{err} (field {vtype[0]})") from err + + @staticmethod + def _int_to_mysql(value: int) -> int: + """Convert value to int""" + return int(value) + + @staticmethod + def _long_to_mysql(value: int) -> int: + """Convert value to int + + Note: There is no type "long" in Python 3 since integers are of unlimited size. + Since Python 2 is no longer supported, this method should be deprecated. + """ + return int(value) + + @staticmethod + def _float_to_mysql(value: float) -> Optional[float]: + """Convert value to float""" + if math.isnan(value): + return None + return float(value) + + def _str_to_mysql(self, value: str) -> Union[bytes, HexLiteral]: + """Convert value to string""" + return self._unicode_to_mysql(value) + + def _unicode_to_mysql(self, value: str) -> Union[bytes, HexLiteral]: + """Convert unicode""" + charset = self.charset + charset_id = self.charset_id + if charset == "binary": + charset = "utf8" + charset_id = self._character_set.get_charset_info(charset)[0] + encoded = value.encode(charset) + if charset_id in self._character_set.slash_charsets: + if b"\x5c" in encoded: + return HexLiteral(value, charset) + return encoded + + @staticmethod + def _bytes_to_mysql(value: bytes) -> bytes: + """Convert value to bytes""" + return value + + @staticmethod + def _bytearray_to_mysql(value: bytearray) -> bytes: + """Convert value to bytes""" + return bytes(value) + + @staticmethod + def _bool_to_mysql(value: bool) -> int: + """Convert value to boolean""" + return 1 if value else 0 + + @staticmethod + def _nonetype_to_mysql(value: None) -> None: # pylint: disable=unused-argument + """ + This would return what None would be in MySQL, but instead we + leave it None and return it right away. The actual conversion + from None to NULL happens in the quoting functionality. + + Return None. + """ + return None + + @staticmethod + def _datetime_to_mysql(value: datetime.datetime) -> bytes: + """ + Converts a datetime instance to a string suitable for MySQL. + The returned string has format: %Y-%m-%d %H:%M:%S[.%f] + + If the instance isn't a datetime.datetime type, it return None. + + Returns a bytes. + """ + if value.microsecond: + fmt = "{0:04d}-{1:02d}-{2:02d} {3:02d}:{4:02d}:{5:02d}.{6:06d}" + return fmt.format( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.microsecond, + ).encode("ascii") + + fmt = "{0:04d}-{1:02d}-{2:02d} {3:02d}:{4:02d}:{5:02d}" + return fmt.format( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + ).encode("ascii") + + @staticmethod + def _date_to_mysql(value: datetime.date) -> bytes: + """ + Converts a date instance to a string suitable for MySQL. + The returned string has format: %Y-%m-%d + + If the instance isn't a datetime.date type, it return None. + + Returns a bytes. + """ + return f"{value.year:04d}-{value.month:02d}-{value.day:02d}".encode("ascii") + + @staticmethod + def _time_to_mysql(value: datetime.time) -> bytes: + """ + Converts a time instance to a string suitable for MySQL. + The returned string has format: %H:%M:%S[.%f] + + If the instance isn't a datetime.time type, it return None. + + Returns a bytes. + """ + if value.microsecond: + return value.strftime("%H:%M:%S.%f").encode("ascii") + return value.strftime("%H:%M:%S").encode("ascii") + + @staticmethod + def _struct_time_to_mysql(value: time.struct_time) -> bytes: + """ + Converts a time.struct_time sequence to a string suitable + for MySQL. + The returned string has format: %Y-%m-%d %H:%M:%S + + Returns a bytes or None when not valid. + """ + return time.strftime("%Y-%m-%d %H:%M:%S", value).encode("ascii") + + @staticmethod + def _timedelta_to_mysql(value: datetime.timedelta) -> bytes: + """ + Converts a timedelta instance to a string suitable for MySQL. + The returned string has format: %H:%M:%S + + Returns a bytes. + """ + seconds = abs(value.days * 86400 + value.seconds) + + if value.microseconds: + fmt = "{0:02d}:{1:02d}:{2:02d}.{3:06d}" + if value.days < 0: + mcs = 1000000 - value.microseconds + seconds -= 1 + else: + mcs = value.microseconds + else: + fmt = "{0:02d}:{1:02d}:{2:02d}" + + if value.days < 0: + fmt = "-" + fmt + + (hours, remainder) = divmod(seconds, 3600) + (mins, secs) = divmod(remainder, 60) + + if value.microseconds: + result = fmt.format(hours, mins, secs, mcs) + else: + result = fmt.format(hours, mins, secs) + + return result.encode("ascii") + + @staticmethod + def _decimal_to_mysql(value: Decimal) -> Optional[bytes]: + """ + Converts a decimal.Decimal instance to a string suitable for + MySQL. + + Returns a bytes or None when not valid. + """ + if isinstance(value, Decimal): + return str(value).encode("ascii") + + return None + + def row_to_python( + self, row: Tuple[bytes, ...], fields: List[DescriptionType] + ) -> Tuple[PythonProducedType, ...]: + """Convert a MySQL text result row to Python types + + The row argument is a sequence containing text result returned + by a MySQL server. Each value of the row is converted to the + using the field type information in the fields argument. + + Returns a tuple. + """ + i = 0 + result: List[PythonProducedType] = [None] * len(fields) + + if not self._cache_field_types: + self._cache_field_types = {} + for name, info in FieldType.desc.items(): + try: + self._cache_field_types[info[0]] = getattr( + self, f"_{name.lower()}_to_python" + ) + except AttributeError: + # We ignore field types which has no method + pass + + for field in fields: + field_type = field[1] + + if (row[i] == 0 and field_type != FieldType.BIT) or row[i] is None: + # Don't convert NULL value + i += 1 + continue + + try: + result[i] = self._cache_field_types[field_type](row[i], field) + except KeyError: + # If one type is not defined, we just return the value as str + try: + result[i] = row[i].decode("utf-8") + except UnicodeDecodeError: + result[i] = row[i] + except (ValueError, TypeError) as err: + # Item "ValueError" of "Union[ValueError, TypeError]" has no attribute "message" + err.message = f"{err} (field {field[0]})" # type: ignore[union-attr] + raise + + i += 1 + + return tuple(result) + + # pylint: disable=unused-argument + @staticmethod + def _float_to_python(value: bytes, desc: Optional[DescriptionType] = None) -> float: + """ + Returns value as float type. + """ + return float(value) + + _double_to_python = _float_to_python + + @staticmethod + def _int_to_python(value: bytes, desc: Optional[DescriptionType] = None) -> int: + """ + Returns value as int type. + """ + return int(value) + + _tiny_to_python = _int_to_python + _short_to_python = _int_to_python + _int24_to_python = _int_to_python + _long_to_python = _int_to_python + _longlong_to_python = _int_to_python + + def _decimal_to_python( + self, value: bytes, desc: Optional[DescriptionType] = None + ) -> Decimal: + """ + Returns value as a decimal.Decimal. + """ + val = value.decode(self.charset) + return Decimal(val) + + _newdecimal_to_python = _decimal_to_python + + @staticmethod + def _str(value: bytes, desc: Optional[DescriptionType] = None) -> str: + """ + Returns value as str type. + """ + return str(value) + + @staticmethod + def _bit_to_python(value: bytes, dsc: Optional[DescriptionType] = None) -> int: + """Returns BIT columntype as integer""" + int_val = value + if len(int_val) < 8: + int_val = b"\x00" * (8 - len(int_val)) + int_val + return int(struct.unpack(">Q", int_val)[0]) + + @staticmethod + def _date_to_python( + value: bytes, dsc: Optional[DescriptionType] = None + ) -> Optional[datetime.date]: + """Converts TIME column MySQL to a python datetime.datetime type. + + Raises ValueError if the value can not be converted. + + Returns DATE column type as datetime.date type. + """ + if isinstance(value, datetime.date): + return value + try: + parts = value.split(b"-") + if len(parts) != 3: + raise ValueError(f"invalid datetime format: {parts} len: {len(parts)}") + try: + return datetime.date(int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None + except (IndexError, ValueError): + raise ValueError( + f"Could not convert {repr(value)} to python datetime.timedelta" + ) from None + + _NEWDATE_to_python = _date_to_python + + @staticmethod + def _time_to_python( + value: bytes, dsc: Optional[DescriptionType] = None + ) -> datetime.timedelta: + """Converts TIME column value to python datetime.time value type. + + Converts the TIME column MySQL type passed as bytes to a python + datetime.datetime type. + + Raises ValueError if the value can not be converted. + + Returns datetime.timedelta type. + """ + mcs: Optional[Union[int, bytes]] = None + try: + (hms, mcs) = value.split(b".") + mcs = int(mcs.ljust(6, b"0")) + except (TypeError, ValueError): + hms = value + mcs = 0 + try: + (hours, mins, secs) = [int(d) for d in hms.split(b":")] + if value[0] == 45 or value[0] == "-": + mins, secs, mcs = ( + -mins, + -secs, + -mcs, # pylint: disable=invalid-unary-operand-type + ) + return datetime.timedelta( + hours=hours, minutes=mins, seconds=secs, microseconds=mcs + ) + except (IndexError, TypeError, ValueError): + raise ValueError( + CONVERT_ERROR.format(value=value, pytype="datetime.timedelta") + ) from None + + @staticmethod + def _datetime_to_python( + value: bytes, dsc: Optional[DescriptionType] = None + ) -> Optional[datetime.datetime]: + """Converts DATETIME column value to python datetime.time value type. + + Converts the DATETIME column MySQL type passed as bytes to a python + datetime.datetime type. + + Returns: datetime.datetime type. + """ + if isinstance(value, datetime.datetime): + return value + datetime_val = None + mcs: Optional[Union[int, bytes]] = None + try: + (date_, time_) = value.split(b" ") + if len(time_) > 8: + (hms, mcs) = time_.split(b".") + mcs = int(mcs.ljust(6, b"0")) + else: + hms = time_ + mcs = 0 + dtval = ( + [int(i) for i in date_.split(b"-")] + + [int(i) for i in hms.split(b":")] + + [ + mcs, + ] + ) + if len(dtval) < 6: + raise ValueError(f"invalid datetime format: {dtval} len: {len(dtval)}") + # Note that by default MySQL accepts invalid timestamps + # (this is also backward compatibility). + # Traditionaly C/py returns None for this well formed but + # invalid datetime for python like '0000-00-00 HH:MM:SS'. + try: + datetime_val = datetime.datetime(*dtval) # type: ignore[arg-type] + except ValueError: + return None + except (IndexError, TypeError): + raise ValueError( + CONVERT_ERROR.format(value=value, pytype="datetime.timedelta") + ) from None + + return datetime_val + + _timestamp_to_python = _datetime_to_python + + @staticmethod + def _year_to_python(value: bytes, dsc: Optional[DescriptionType] = None) -> int: + """Returns YEAR column type as integer""" + try: + year = int(value) + except ValueError as err: + raise ValueError(f"Failed converting YEAR to int ({repr(value)})") from err + + return year + + def _set_to_python( + self, value: bytes, dsc: Optional[DescriptionType] = None + ) -> Set[str]: + """Returns SET column type as set + + Actually, MySQL protocol sees a SET as a string type field. So this + code isn't called directly, but used by STRING_to_python() method. + + Returns SET column type as a set. + """ + set_type = None + val = value.decode(self.charset) + if not val: + return set() + try: + set_type = set(val.split(",")) + except ValueError as err: + raise ValueError( + f"Could not convert set {repr(value)} to a sequence" + ) from err + return set_type + + def _string_to_python( + self, value: bytes, dsc: Optional[DescriptionType] = None + ) -> Union[StrOrBytes, Set[str]]: + """ + Note that a SET is a string too, but using the FieldFlag we can see + whether we have to split it. + + Returns string typed columns as string type. + """ + if self.charset == "binary": + return value + if dsc is not None: + if dsc[1] == FieldType.JSON and self.use_unicode: + return value.decode(self.charset) + if dsc[7] & FieldFlag.SET: + return self._set_to_python(value, dsc) + # 'binary' charset + if dsc[8] == 63: + return value + if isinstance(value, (bytes, bytearray)) and self.use_unicode: + try: + return value.decode(self.charset) + except UnicodeDecodeError: + return value + + return value + + _var_string_to_python = _string_to_python + _json_to_python = _string_to_python + + def _blob_to_python( + self, value: bytes, dsc: Optional[DescriptionType] = None + ) -> Union[StrOrBytes, Set[str]]: + """Convert BLOB data type to Python.""" + if dsc is not None: + if ( + dsc[7] & FieldFlag.BLOB + and dsc[7] & FieldFlag.BINARY + # 'binary' charset + and dsc[8] == 63 + ): + return bytes(value) + return self._string_to_python(value, dsc) + + @staticmethod + def _vector_to_python( + value: Optional[bytes], desc: Optional[DescriptionType] = None + ) -> Optional[array.array]: + """ + Converts a MySQL VECTOR value to a Python array.array type. + + Returns an array of floats if `value` isn't `None`, otherwise `None`. + """ + if value is None or isinstance(value, array.array): + return value + + if isinstance(value, (bytes, bytearray)): + return array.array(MYSQL_VECTOR_TYPE_CODE, value) + + raise TypeError(f"Got unsupported type {value.__class__.__name__}") + + _long_blob_to_python = _blob_to_python + _medium_blob_to_python = _blob_to_python + _tiny_blob_to_python = _blob_to_python + # pylint: enable=unused-argument diff --git a/server/venv/Lib/site-packages/mysql/connector/cursor.py b/server/venv/Lib/site-packages/mysql/connector/cursor.py new file mode 100644 index 0000000..c7364d7 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/cursor.py @@ -0,0 +1,1489 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="assignment,arg-type,attr-defined,index,override,call-overload" + +"""Cursor classes.""" +from __future__ import annotations + +import re +import warnings + +from decimal import Decimal +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Iterator, + List, + NoReturn, + Optional, + Sequence, + Tuple, + Union, + cast, +) + +from ._decorating import deprecated +from ._scripting import split_multi_statement +from .abstracts import MySQLCursorAbstract +from .constants import ServerFlag +from .errors import ( + Error, + InterfaceError, + NotSupportedError, + ProgrammingError, + ReadTimeoutError, + WriteTimeoutError, + get_mysql_exception, +) +from .types import ( + DescriptionType, + EofPacketType, + ParamsDictType, + ParamsSequenceOrDictType, + ParamsSequenceType, + ResultType, + RowItemType, + RowType, + StrOrBytes, + WarningType, +) + +if TYPE_CHECKING: + from .connection import MySQLConnection + + +SQL_COMMENT = r"\/\*.*?\*\/" +RE_SQL_COMMENT = re.compile( + rf"""({SQL_COMMENT})|(["'`][^"'`]*?({SQL_COMMENT})[^"'`]*?["'`])""", + re.I | re.M | re.S, +) +RE_SQL_ON_DUPLICATE = re.compile( + r"""\s*ON\s+DUPLICATE\s+KEY(?:[^"'`]*["'`][^"'`]*["'`])*[^"'`]*$""", + re.I | re.M | re.S, +) +RE_SQL_INSERT_STMT = re.compile( + rf"({SQL_COMMENT}|\s)*INSERT({SQL_COMMENT}|\s)" + r"*(?:IGNORE\s+)?INTO\s+[`'\"]?.+[`'\"]?(?:\.[`'\"]?.+[`'\"]?)" + r"{0,2}\s+VALUES\s*(\(.+\)).*", + re.I | re.M | re.S, +) +RE_SQL_INSERT_VALUES = re.compile(r".*VALUES\s*(\(.+\)).*", re.I | re.M | re.S) +RE_PY_PARAM = re.compile(b"(%s)") +RE_PY_MAPPING_PARAM = re.compile( + rb""" + % + \((?P[^)]+)\) + (?P[diouxXeEfFgGcrs%]) + """, + re.X, +) +RE_SQL_SPLIT_STMTS = re.compile( + b""";(?=(?:[^"'`]*(?:"[^"]*"|'[^']*'|`[^`]*`))*[^"'`]*$)""" +) +RE_SQL_FIND_PARAM = re.compile(b"""%s(?=(?:[^"'`]*["'`][^"'`]*["'`])*[^"'`]*$)""") +RE_SQL_PYTHON_REPLACE_PARAM = re.compile(r"%\(.*?\)s") +RE_SQL_PYTHON_CAPTURE_PARAM_NAME = re.compile(r"%\((.*?)\)s") + +ERR_NO_RESULT_TO_FETCH = "No result set to fetch from" + +MAX_RESULTS = 4294967295 + + +class _ParamSubstitutor: + """ + Substitutes parameters into SQL statement. + """ + + def __init__(self, params: Sequence[bytes]) -> None: + self.params: Sequence[bytes] = params + self.index: int = 0 + + def __call__(self, matchobj: re.Match) -> bytes: + index = self.index + self.index += 1 + try: + return bytes(self.params[index]) + except IndexError: + raise ProgrammingError( + "Not enough parameters for the SQL statement" + ) from None + + @property + def remaining(self) -> int: + """Returns number of parameters remaining to be substituted""" + return len(self.params) - self.index + + +def _bytestr_format_dict(bytestr: bytes, value_dict: Dict[bytes, bytes]) -> bytes: + """ + >>> _bytestr_format_dict(b'%(a)s', {b'a': b'foobar'}) + b'foobar + >>> _bytestr_format_dict(b'%%(a)s', {b'a': b'foobar'}) + b'%%(a)s' + >>> _bytestr_format_dict(b'%%%(a)s', {b'a': b'foobar'}) + b'%%foobar' + >>> _bytestr_format_dict(b'%(x)s %(y)s', + ... {b'x': b'x=%(y)s', b'y': b'y=%(x)s'}) + b'x=%(y)s y=%(x)s' + """ + + def replace(matchobj: re.Match) -> bytes: + """Replace pattern.""" + value: Optional[bytes] = None + groups = matchobj.groupdict() + if groups["conversion_type"] == b"%": + value = b"%" + if groups["conversion_type"] == b"s": + key = groups["mapping_key"] + value = value_dict[key] + if value is None: + raise ValueError( + f"Unsupported conversion_type: {groups['conversion_type']}" + ) + return value + + stmt = RE_PY_MAPPING_PARAM.sub(replace, bytestr) + return stmt + + +class MySQLCursor(MySQLCursorAbstract): + """Default cursor for interacting with MySQL + + This cursor will execute statements and handle the result. It will + not automatically fetch all rows. + + MySQLCursor should be inherited whenever other functionallity is + required. An example would to change the fetch* member functions + to return dictionaries instead of lists of values. + + Implements the Python Database API Specification v2.0 (PEP-249) + """ + + def __init__( + self, + connection: Optional[MySQLConnection] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + """Initialize""" + super().__init__(connection, read_timeout, write_timeout) + self._connection: MySQLConnection = cast("MySQLConnection", self._connection) + + def __iter__(self) -> Iterator[RowType]: + """ + Iteration over the result set which calls self.fetchone() + and returns the next row. + """ + return iter(self.fetchone, None) + + def _reset_result(self, preserve_last_executed_stmt: bool = False) -> None: + """Reset the cursor to default. + + Args: + preserve_last_executed_stmt: If it is False, the last executed + statement value is reset. Otherwise, + such a value is preserved. + """ + self._rowcount: int = -1 + self._nextrow = (None, None) + self._stored_results: List[MySQLCursor] = [] + self._warnings: Optional[List[WarningType]] = None + self._warning_count: int = 0 + self._description: Optional[List[DescriptionType]] = None + + if not preserve_last_executed_stmt: + # reset inner state related to statement execution + self._executed = None + self._executed_list = [] + self._stmt_partitions = None + self._stmt_partition = None + self._stmt_map_results = False + + self.reset() + + def _have_unread_result(self) -> bool: + """Check whether there is an unread result""" + try: + return self._connection.unread_result + except AttributeError: + return False + + def _check_executed(self) -> None: + """Check if the statement has been executed. + + Raises an error if the statement has not been executed. + """ + if self._executed is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + + def __next__(self) -> RowType: + """ + Used for iterating over the result set. Calles self.fetchone() + to get the next row. + """ + try: + row = self.fetchone() + except InterfaceError: + raise StopIteration from None + if not row: + raise StopIteration + return row + + def close(self) -> bool: + """Close the cursor + + Returns True when successful, otherwise False. + """ + if self._connection is None: + return False + + self._connection.handle_unread_result() + self._reset_result() + self._connection = None + + return True + + def _process_params_dict( + self, params: ParamsDictType + ) -> Dict[bytes, Union[bytes, Decimal]]: + """Process query parameters given as dictionary""" + res: Dict[bytes, Any] = {} + try: + sql_mode = self._connection.sql_mode + to_mysql = self._connection.converter.to_mysql + escape = self._connection.converter.escape + quote = self._connection.converter.quote + for key, value in params.items(): + conv = value + conv = to_mysql(conv) + conv = escape(conv, sql_mode) + if not isinstance(value, Decimal): + conv = quote(conv) + res[key.encode()] = conv + except Exception as err: + raise ProgrammingError( + f"Failed processing pyformat-parameters; {err}" + ) from err + return res + + def _process_params( + self, params: ParamsSequenceType + ) -> Tuple[Union[bytes, Decimal], ...]: + """Process query parameters.""" + res = params[:] + try: + sql_mode = self._connection.sql_mode + to_mysql = self._connection.converter.to_mysql + escape = self._connection.converter.escape + quote = self._connection.converter.quote + res = [to_mysql(value) for value in res] + res = [escape(value, sql_mode) for value in res] + res = [ + quote(value) if not isinstance(params[i], Decimal) else value + for i, value in enumerate(res) + ] + except Exception as err: + raise ProgrammingError( + f"Failed processing format-parameters; {err}" + ) from err + return tuple(res) + + def _handle_noresultset(self, res: ResultType) -> None: + """Handles result of execute() when there is no result set""" + try: + self._rowcount = res["affected_rows"] + self._last_insert_id = res["insert_id"] + self._warning_count = res["warning_count"] + except (KeyError, TypeError) as err: + raise ProgrammingError(f"Failed handling non-resultset; {err}") from None + + self._handle_warnings() + + def _handle_resultset(self) -> None: + """Handles result set + + This method handles the result set and is called after reading + and storing column information in _handle_result(). For non-buffering + cursors, this method is usually doing nothing. + """ + + def _handle_result(self, result: ResultType) -> None: + """ + Handle the result after a command was send. The result can be either + an OK-packet or a dictionary containing column/eof information. + + Raises InterfaceError when result is not a dict() or result is + invalid. + """ + if not isinstance(result, dict): + raise InterfaceError("Result was not a dict()") + + if "columns" in result: + # Weak test, must be column/eof information + self._description = result["columns"] + self._connection.unread_result = True + self._handle_resultset() + elif "affected_rows" in result: + # Weak test, must be an OK-packet + self._connection.unread_result = False + self._handle_noresultset(result) + else: + raise InterfaceError("Invalid result") + + def execute( + self, + operation: str, + params: Optional[ParamsSequenceOrDictType] = None, + map_results: bool = False, + ) -> None: + if not operation: + return None + + try: + if not self._connection: + raise ProgrammingError + except (ProgrammingError, ReferenceError) as err: + raise ProgrammingError("Cursor is not connected") from err + + self._connection.handle_unread_result() + self._reset_result() + + stmt = b"" + try: + if isinstance(operation, str): + stmt = operation.encode(self._connection.python_charset) + else: + stmt = cast(bytes, operation) + except (UnicodeDecodeError, UnicodeEncodeError) as err: + raise ProgrammingError(str(err)) from err + + if params: + if isinstance(params, dict): + stmt = _bytestr_format_dict(stmt, self._process_params_dict(params)) + elif isinstance(params, (list, tuple)): + psub = _ParamSubstitutor(self._process_params(params)) + stmt = RE_PY_PARAM.sub(psub, stmt) + if psub.remaining != 0: + raise ProgrammingError( + "Not all parameters were used in the SQL statement" + ) + else: + raise ProgrammingError( + f"Could not process parameters: {type(params).__name__}({params})," + " it must be of type list, tuple or dict" + ) + + self._stmt_partitions = split_multi_statement( + sql_code=stmt, map_results=map_results + ) + self._stmt_partition = next(self._stmt_partitions) + self._stmt_map_results = map_results + self._executed_list = self._stmt_partition["single_stmts"] + self._executed = ( + self._stmt_partition["single_stmts"].popleft() + if map_results + else self._stmt_partition["mappable_stmt"] + ) + + self._handle_result( + self._connection.cmd_query( + self._stmt_partition["mappable_stmt"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + ) + + return None + + def _batch_insert( + self, operation: str, seq_params: Sequence[ParamsSequenceOrDictType] + ) -> Optional[bytes]: + """Implements multi row insert""" + + def remove_comments(match: re.Match) -> str: + """Remove comments from INSERT statements. + + This function is used while removing comments from INSERT + statements. If the matched string is a comment not enclosed + by quotes, it returns an empty string, else the string itself. + """ + if match.group(1): + return "" + return match.group(2) + + tmp = re.sub( + RE_SQL_ON_DUPLICATE, + "", + re.sub(RE_SQL_COMMENT, remove_comments, operation), + ) + + matches = re.search(RE_SQL_INSERT_VALUES, tmp) + if not matches: + raise InterfaceError( + "Failed rewriting statement for multi-row INSERT. Check SQL syntax" + ) + fmt = matches.group(1).encode(self._connection.python_charset) + values = [] + + try: + stmt = operation.encode(self._connection.python_charset) + for params in seq_params: + tmp = fmt + if isinstance(params, dict): + tmp = _bytestr_format_dict(tmp, self._process_params_dict(params)) + else: + psub = _ParamSubstitutor(self._process_params(params)) + tmp = RE_PY_PARAM.sub(psub, tmp) + if psub.remaining != 0: + raise ProgrammingError( + "Not all parameters were used in the SQL statement" + ) + values.append(tmp) + if fmt in stmt: + stmt = stmt.replace(fmt, b",".join(values), 1) + self._executed = stmt + return stmt + return None + except (UnicodeDecodeError, UnicodeEncodeError) as err: + raise ProgrammingError(str(err)) from err + except Error: + raise + except Exception as err: + raise InterfaceError(f"Failed executing the operation; {err}") from None + + def executemany( + self, operation: str, seq_params: Sequence[ParamsSequenceOrDictType] + ) -> None: + """Execute the given operation multiple times + + The executemany() method will execute the operation iterating + over the list of parameters in seq_params. + + Example: Inserting 3 new employees and their phone number + + data = [ + ('Jane','555-001'), + ('Joe', '555-001'), + ('John', '555-003') + ] + stmt = "INSERT INTO employees (name, phone) VALUES ('%s','%s)" + cursor.executemany(stmt, data) + + INSERT statements are optimized by batching the data, that is + using the MySQL multiple rows syntax. + + Results are discarded. If they are needed, consider looping over + data using the execute() method. + """ + if not operation or not seq_params: + return None + self._connection.handle_unread_result() + + try: + _ = iter(seq_params) + except TypeError as err: + raise ProgrammingError("Parameters for query must be an Iterable") from err + + # Optimize INSERTs by batching them + if re.match(RE_SQL_INSERT_STMT, operation): + if not seq_params: + self._rowcount = 0 + return None + stmt = self._batch_insert(operation, seq_params) + if stmt is not None: + self._executed = stmt + return self.execute(stmt) + + rowcnt = 0 + try: + for params in seq_params: + self.execute(operation, params) + if self.with_rows and self._have_unread_result(): + self.fetchall() + rowcnt += self._rowcount + except (ValueError, TypeError) as err: + raise InterfaceError(f"Failed executing the operation; {err}") from None + self._rowcount = rowcnt + return None + + @deprecated( + "The property counterpart 'stored_results' will be added in a future release, " + "and this method will be removed." + ) + def stored_results(self) -> Iterator[MySQLCursor]: + """Returns an iterator for stored results + + This method returns an iterator over results which are stored when + callproc() is called. The iterator will provide MySQLCursorBuffered + instances. + + Returns a iterator. + """ + return iter(self._stored_results) + + def callproc( + self, + procname: str, + args: Sequence = (), + ) -> Optional[Union[Dict[str, RowItemType], RowType]]: + """Calls a stored procedure with the given arguments + + The arguments will be set during this session, meaning + they will be called like ___arg where + is an enumeration (+1) of the arguments. + + Coding Example: + 1) Defining the Stored Routine in MySQL: + CREATE PROCEDURE multiply(IN pFac1 INT, IN pFac2 INT, OUT pProd INT) + BEGIN + SET pProd := pFac1 * pFac2; + END + + 2) Executing in Python: + args = (5, 5, 0) # 0 is to hold pprod + cursor.callproc('multiply', args) + print(cursor.fetchone()) + + For OUT and INOUT parameters the user should provide the + type of the parameter as well. The argument should be a + tuple with first item as the value of the parameter to pass + and second argument the type of the argument. + + In the above example, one can call callproc method like: + args = (5, 5, (0, 'INT')) + cursor.callproc('multiply', args) + + The type of the argument given in the tuple will be used by + the MySQL CAST function to convert the values in the corresponding + MySQL type (See CAST in MySQL Reference for more information) + + Does not return a value, but a result set will be + available when the CALL-statement execute successfully. + Raises exceptions when something is wrong. + """ + if not procname or not isinstance(procname, str): + raise ValueError("procname must be a string") + + if not isinstance(args, (tuple, list)): + raise ValueError("args must be a sequence") + + argfmt = "@_{name}_arg{index}" + self._stored_results = [] + + results = [] + try: + argnames = [] + argtypes = [] + + # MySQL itself does support calling procedures with their full + # name .. It's necessary to split + # by '.' and grab the procedure name from procname. + procname_abs = procname.split(".")[-1] + if args: + argvalues = [] + for idx, arg in enumerate(args): + argname = argfmt.format(name=procname_abs, index=idx + 1) + argnames.append(argname) + if isinstance(arg, tuple): + argtypes.append(f" CAST({argname} AS {arg[1]})") + argvalues.append(arg[0]) + else: + argtypes.append(argname) + argvalues.append(arg) + + placeholders = ",".join(f"{arg}=%s" for arg in argnames) + self.execute(f"SET {placeholders}", argvalues) + + call = f"CALL {procname}({','.join(argnames)})" + + # We disable consuming results temporary to make sure we + # getting all results + can_consume_results = self._connection.can_consume_results + for result in self._connection.cmd_query_iter( + call, read_timeout=self._read_timeout, write_timeout=self._write_timeout + ): + self._connection.can_consume_results = False + if isinstance(self, (MySQLCursorDict, MySQLCursorBufferedDict)): + cursor_class = MySQLCursorBufferedDict + elif self._raw: + cursor_class = MySQLCursorBufferedRaw + else: + cursor_class = MySQLCursorBuffered + # pylint: disable=protected-access + cur = cursor_class(self._connection.get_self()) + cur._executed = f"(a result of {call})" + cur._handle_result(result) + # pylint: enable=protected-access + if cur.warnings is not None: + self._warnings = cur.warnings + if "columns" in result: + results.append(cur) + self._connection.can_consume_results = can_consume_results + + if argnames: + # Create names aliases to be compatible with namedtuples + args = [ + f"{name} AS {alias}" + for name, alias in zip( + argtypes, [arg.lstrip("@_") for arg in argnames] + ) + ] + select = f"SELECT {','.join(args)}" + self.execute(select) + self._stored_results = results + return self.fetchone() + + self._stored_results = results + return tuple() + + except Error: + raise + except Exception as err: + raise InterfaceError(f"Failed calling stored routine; {err}") from None + + def _fetch_warnings(self) -> Optional[List[WarningType]]: + """ + Fetch warnings doing a SHOW WARNINGS. Can be called after getting + the result. + + Returns a result set or None when there were no warnings. + """ + res = [] + try: + cur = self._connection.cursor( + raw=False, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + cur.execute("SHOW WARNINGS") + res = cur.fetchall() + cur.close() + except Exception as err: + raise InterfaceError(f"Failed getting warnings; {err}") from None + + if res: + return res # type: ignore[return-value] + + return None + + def _handle_warnings(self) -> None: + """Handle possible warnings after all results are consumed. + + Raises: + Error: Also raises exceptions if raise_on_warnings is set. + """ + if self._connection.get_warnings and self._warning_count: + self._warnings = self._fetch_warnings() + + if not self._warnings: + return + + err = get_mysql_exception( + self._warnings[0][1], + self._warnings[0][2], + warning=not self._connection.raise_on_warnings, + ) + + if self._connection.raise_on_warnings: + raise err + + warnings.warn(err, stacklevel=4) + + def _handle_eof(self, eof: EofPacketType) -> None: + """Handle EOF packet""" + self._connection.unread_result = False + self._nextrow = (None, None) + self._warning_count = eof["warning_count"] + self._handle_warnings() + + def _fetch_row(self, raw: bool = False) -> Optional[RowType]: + """Returns the next row in the result set + + Returns a tuple or None. + """ + if not self._have_unread_result(): + return None + row = None + + if self._nextrow == (None, None): + (row, eof) = self._connection.get_row( + binary=self._binary, + columns=self.description, + raw=raw, + read_timeout=self._read_timeout, + ) + else: + (row, eof) = self._nextrow + + if row: + self._nextrow = self._connection.get_row( + binary=self._binary, + columns=self.description, + raw=raw, + read_timeout=self._read_timeout, + ) + eof = self._nextrow[1] + if eof is not None: + self._handle_eof(eof) + if self._rowcount == -1: + self._rowcount = 1 + else: + self._rowcount += 1 + if eof: + self._handle_eof(eof) + + return row + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return self._fetch_row() + + def fetchmany(self, size: Optional[int] = None) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + res = [] + cnt = size or self.arraysize + while cnt > 0 and self._have_unread_result(): + cnt -= 1 + row = self.fetchone() + if row: + res.append(row) + return res + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + if not self._have_unread_result(): + return [] + + (rows, eof) = self._connection.get_rows(read_timeout=self._read_timeout) + if self._nextrow[0]: + rows.insert(0, self._nextrow[0]) + + self._handle_eof(eof) + rowcount = len(rows) + if rowcount >= 0 and self._rowcount == -1: + self._rowcount = 0 + self._rowcount += rowcount + return rows + + def nextset(self) -> Optional[bool]: + if self._connection._have_next_result: + # prepare cursor to load the next result set, and ultimately, load it. + self._connection.handle_unread_result() + self._reset_result(preserve_last_executed_stmt=True) + self._handle_result( + self._connection._handle_result( + self._connection._socket.recv(read_timeout=self._read_timeout) + ) + ) + + # if mapping is enabled, run the if-block, otherwise simply return `True`. + if self._stmt_partitions is not None and self._stmt_map_results: + if not self._stmt_partition["single_stmts"]: + # It means there are still results to be consumed, but no more + # statements to relate these results to. + # In this case, we raise a no fatal error and don't clear + # `_executed` so its current value is reported when users + # access the property `statement`. + # If this case ever happens, a bug report should be filed, + # assuming it is happening on supported use cases. + warnings.warn( + "MappingWarning: Number of result sets greater than number " + "of single statements." + ) + else: + self._executed = self._stmt_partition["single_stmts"].popleft() + return True + if self._stmt_partitions is not None: + # Let's see if there are more mappable statements (partitions) + # to be executed. + # If there are no more partitions, we simply return `None`, otherwise + # we execute the correponding mappable multi statement and repeat the + # process all over again. + try: + self._stmt_partition = next(self._stmt_partitions) + except StopIteration: + pass + else: + # This block only happens when mapping is enabled because when it + # is disabled, only one partition is generated, and at this point, + # such partiton has already been processed. + self._executed = self._stmt_partition["single_stmts"].popleft() + self._handle_result( + self._connection.cmd_query( + self._stmt_partition["mappable_stmt"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + ) + return True + + self._reset_result() + return None + + @property + def column_names(self) -> Tuple[str, ...]: + """Returns column names + + This property returns the columns names as a tuple. + + Returns a tuple. + """ + if not self.description: + return tuple() + return tuple(d[0] for d in self.description) + + @property + def with_rows(self) -> bool: + """Returns whether the cursor could have rows returned + + This property returns True when column descriptions are available + and possibly also rows, which will need to be fetched. + + Returns True or False. + """ + if not self.description: + return False + return True + + def __str__(self) -> str: + fmt = "{class_name}: {stmt}" + if self._executed: + try: + executed = self._executed.decode("utf-8") + except AttributeError: + executed = self._executed + if len(executed) > 40: + executed = executed[:40] + ".." + else: + executed = "(Nothing executed yet)" + return fmt.format(class_name=self.__class__.__name__, stmt=executed) + + +class MySQLCursorBuffered(MySQLCursor): + """Cursor which fetches rows within execute()""" + + def __init__( + self, + connection: Optional[MySQLConnection] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + super().__init__(connection, read_timeout, write_timeout) + self._rows: Optional[List[RowType]] = None + self._next_row: int = 0 + + def _handle_resultset(self) -> None: + (self._rows, eof) = self._connection.get_rows(read_timeout=self._read_timeout) + self._rowcount = len(self._rows) + self._handle_eof(eof) + self._next_row = 0 + try: + self._connection.unread_result = False + except AttributeError: + pass + + def reset(self, free: bool = True) -> None: + self._rows = None + + def _fetch_row(self, raw: bool = False) -> Optional[RowType]: + row = None + try: + row = self._rows[self._next_row] + except (IndexError, TypeError): + return None + self._next_row += 1 + return row + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return self._fetch_row() + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + if self._executed is None or self._rows is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + res = [] + res = self._rows[self._next_row :] + self._next_row = len(self._rows) + return res + + def fetchmany(self, size: Optional[int] = None) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + res = [] + cnt = size or self.arraysize + while cnt > 0: + cnt -= 1 + row = self.fetchone() + if row: + res.append(row) + + return res + + @property + def with_rows(self) -> bool: + return self._rows is not None + + +class MySQLCursorRaw(MySQLCursor): + """ + Skips conversion from MySQL datatypes to Python types when fetching rows. + """ + + def __init__( + self, + connection: Optional[MySQLConnection] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + super().__init__(connection, read_timeout, write_timeout) + self._raw: bool = True + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return self._fetch_row(raw=self._raw) + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + if not self._have_unread_result(): + return [] + (rows, eof) = self._connection.get_rows( + raw=self._raw, read_timeout=self._read_timeout + ) + if self._nextrow[0]: + rows.insert(0, self._nextrow[0]) + self._handle_eof(eof) + rowcount = len(rows) + if rowcount >= 0 and self._rowcount == -1: + self._rowcount = 0 + self._rowcount += rowcount + return rows + + +class MySQLCursorBufferedRaw(MySQLCursorBuffered): + """ + Cursor which skips conversion from MySQL datatypes to Python types when + fetching rows and fetches rows within execute(). + """ + + def __init__( + self, + connection: Optional[MySQLConnection] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + super().__init__(connection, read_timeout, write_timeout) + self._raw: bool = True + + def _handle_resultset(self) -> None: + (self._rows, eof) = self._connection.get_rows( + raw=self._raw, read_timeout=self._read_timeout + ) + self._rowcount = len(self._rows) + self._handle_eof(eof) + self._next_row = 0 + try: + self._connection.unread_result = False + except AttributeError: + pass + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return self._fetch_row() + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + if self._rows is None: + return [] + return list(self._rows[self._next_row :]) + + @property + def with_rows(self) -> bool: + return self._rows is not None + + +class MySQLCursorPrepared(MySQLCursor): + """Cursor using MySQL Prepared Statements""" + + def __init__( + self, + connection: Optional[MySQLConnection] = None, + read_timeout: Optional[int] = None, + write_timeout: Optional[int] = None, + ): + super().__init__(connection, read_timeout, write_timeout) + self._rows: Optional[List[RowType]] = None + self._next_row: int = 0 + self._prepared: Optional[Dict[str, Union[int, List[DescriptionType]]]] = None + self._binary: bool = True + self._have_result: Optional[bool] = None + self._last_row_sent: bool = False + self._cursor_exists: bool = False + + def reset(self, free: bool = True) -> None: + if self._prepared: + try: + self._connection.cmd_stmt_close( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + except Error: + # We tried to deallocate, but it's OK when we fail. + pass + self._prepared = None + self._executed = None + self._last_row_sent = False + self._cursor_exists = False + + def _handle_noresultset(self, res: ResultType) -> None: + self._handle_server_status(res.get("status_flag", res.get("server_status", 0))) + super()._handle_noresultset(res) + + def _handle_server_status(self, flags: int) -> None: + """Check for SERVER_STATUS_CURSOR_EXISTS and + SERVER_STATUS_LAST_ROW_SENT flags set by the server. + """ + self._cursor_exists = flags & ServerFlag.STATUS_CURSOR_EXISTS != 0 + self._last_row_sent = flags & ServerFlag.STATUS_LAST_ROW_SENT != 0 + + def _handle_eof(self, eof: EofPacketType) -> None: + self._handle_server_status(eof.get("status_flag", eof.get("server_status", 0))) + super()._handle_eof(eof) + + def callproc(self, procname: Any, args: Any = ()) -> NoReturn: + """Calls a stored procedue + + Not supported with MySQLCursorPrepared. + """ + raise NotSupportedError() + + def close(self) -> None: + """Close the cursor + + This method will try to deallocate the prepared statement and close + the cursor. + """ + self.reset() + super().close() + + def _row_to_python(self, rowdata: Any, desc: Any = None) -> Any: + """Convert row data from MySQL to Python types + + The conversion is done while reading binary data in the + protocol module. + """ + + def _handle_result(self, result: ResultType) -> None: + """Handle result after execution""" + if isinstance(result, dict): + self._connection.unread_result = False + self._have_result = False + self._handle_noresultset(result) + else: + self._description = result[1] + self._connection.unread_result = True + self._have_result = True + + if "status_flag" in result[2]: # type: ignore[operator] + self._handle_server_status(result[2]["status_flag"]) + elif "server_status" in result[2]: # type: ignore[operator] + self._handle_server_status(result[2]["server_status"]) + + def execute( + self, + operation: StrOrBytes, + params: Optional[ParamsSequenceOrDictType] = None, + map_results: bool = False, + ) -> None: + """Prepare and execute a MySQL Prepared Statement + + This method will prepare the given operation and execute it using + the optionally given parameters. + + If the cursor instance already had a prepared statement, it is + first closed. + + *Argument "map_results" is unused as multi statement execution + is not supported for prepared statements*. + + Raises: + ProgrammingError: When providing a multi statement operation + or setting *map_results* to True. + """ + if map_results: + raise ProgrammingError( + "Multi statement execution not supported for prepared statements." + ) + + charset = self._connection.charset + if charset == "utf8mb4": + charset = "utf8" + + if not isinstance(operation, str): + try: + operation = operation.decode(charset) + except UnicodeDecodeError as err: + raise ProgrammingError(str(err)) from err + + if isinstance(params, dict): + replacement_keys = re.findall(RE_SQL_PYTHON_CAPTURE_PARAM_NAME, operation) + try: + # Replace params dict with params tuple in correct order. + params = tuple(params[key] for key in replacement_keys) + except KeyError as err: + raise ProgrammingError( + "Not all placeholders were found in the parameters dict" + ) from err + # Convert %(name)s to ? before sending it to MySQL + operation = re.sub(RE_SQL_PYTHON_REPLACE_PARAM, "?", operation) + + if operation is not self._executed: + if self._prepared: + self._connection.cmd_stmt_close( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + self._executed = operation + + try: + operation = operation.encode(charset) + except UnicodeEncodeError as err: + raise ProgrammingError(str(err)) from err + + if b"%s" in operation: + # Convert %s to ? before sending it to MySQL + operation = re.sub(RE_SQL_FIND_PARAM, b"?", operation) + + try: + self._prepared = self._connection.cmd_stmt_prepare( + operation, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + except Error: + self._executed = None + raise + + self._connection.cmd_stmt_reset( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + + if self._prepared["parameters"] and not params: + return + if params: + if not isinstance(params, (tuple, list)): + raise ProgrammingError( + errno=1210, + msg=f"Incorrect type of argument: {type(params).__name__}({params})" + ", it must be of type tuple or list the argument given to " + "the prepared statement", + ) + if len(self._prepared["parameters"]) != len(params): + raise ProgrammingError( + errno=1210, + msg="Incorrect number of arguments executing prepared statement", + ) + + if params is None: + params = () + try: + res = self._connection.cmd_stmt_execute( + self._prepared["statement_id"], + data=params, + parameters=self._prepared["parameters"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + self._handle_result(res) + except (ReadTimeoutError, WriteTimeoutError) as err: + self.reset() + raise err + + def executemany( + self, + operation: str, + seq_params: Sequence[ParamsSequenceType], + ) -> None: + """Prepare and execute a MySQL Prepared Statement many times + + This method will prepare the given operation and execute with each + tuple found the list seq_params. + + If the cursor instance already had a prepared statement, it is + first closed. + + executemany() simply calls execute(). + """ + rowcnt = 0 + try: + for params in seq_params: + self.execute(operation, params) + if self.with_rows and self._have_unread_result(): + self.fetchall() + rowcnt += self._rowcount + except (ValueError, TypeError) as err: + raise InterfaceError(f"Failed executing the operation; {err}") from None + self._rowcount = rowcnt + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + if self._cursor_exists: + self._connection.cmd_stmt_fetch( + self._prepared["statement_id"], + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + return self._fetch_row() or None + + def fetchmany(self, size: Optional[int] = None) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + res = [] + cnt = size or self.arraysize + while cnt > 0 and self._have_unread_result(): + cnt -= 1 + row = self._fetch_row() + if row: + res.append(row) + return res + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + rows = [] + if self._nextrow[0]: + rows.append(self._nextrow[0]) + while self._have_unread_result(): + if self._cursor_exists: + self._connection.cmd_stmt_fetch( + self._prepared["statement_id"], + MAX_RESULTS, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) + (tmp, eof) = self._connection.get_rows( + binary=self._binary, + columns=self.description, + read_timeout=self._read_timeout, + ) + rows.extend(tmp) + self._handle_eof(eof) + self._rowcount = len(rows) + return rows + + +class MySQLCursorDict(MySQLCursor): + """ + Cursor fetching rows as dictionaries. + + The fetch methods of this class will return dictionaries instead of tuples. + Each row is a dictionary that looks like: + row = { + "col1": value1, + "col2": value2 + } + """ + + def _row_to_python( + self, + rowdata: RowType, + desc: Optional[List[DescriptionType]] = None, # pylint: disable=unused-argument + ) -> Optional[Dict[str, RowItemType]]: + """Convert a MySQL text result row to Python types + + Returns a dictionary. + """ + return dict(zip(self.column_names, rowdata)) if rowdata else None + + def fetchone(self) -> Optional[Dict[str, RowItemType]]: + """Return next row of a query result set. + + Returns: + dict or None: A dict from query result set. + """ + return self._row_to_python(super().fetchone(), self.description) + + def fetchall(self) -> List[Optional[Dict[str, RowItemType]]]: + """Return all rows of a query result set. + + Returns: + list: A list of dictionaries with all rows of a query + result set where column names are used as keys. + """ + return [ + self._row_to_python(row, self.description) + for row in super().fetchall() + if row + ] + + +class MySQLCursorBufferedDict(MySQLCursorDict, MySQLCursorBuffered): + """ + Buffered Cursor fetching rows as dictionaries. + """ + + def fetchone(self) -> Optional[Dict[str, RowItemType]]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + row = self._fetch_row() + if row: + return self._row_to_python(row, self.description) + return None + + def fetchall(self) -> List[Optional[Dict[str, RowItemType]]]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + if self._executed is None or self._rows is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + res = [] + for row in self._rows[self._next_row :]: + res.append(self._row_to_python(row, self.description)) + self._next_row = len(self._rows) + return res + + +class MySQLCursorPreparedDict(MySQLCursorDict, MySQLCursorPrepared): # type: ignore[misc] + """ + This class is a blend of features from MySQLCursorDict and MySQLCursorPrepared + + Multiple inheritance in python is allowed but care must be taken + when assuming methods resolution. In the case of multiple + inheritance, a given attribute is first searched in the current + class if it's not found then it's searched in the parent classes. + The parent classes are searched in a left-right fashion and each + class is searched once. + Based on python's attribute resolution, in this case, attributes + are searched as follows: + 1. MySQLCursorPreparedDict (current class) + 2. MySQLCursorDict (left parent class) + 3. MySQLCursorPrepared (right parent class) + 4. MySQLCursor (base class) + """ + + def fetchmany(self, size: Optional[int] = None) -> List[Dict[str, RowItemType]]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set represented + as a list of dictionaries where column names are used as keys. + """ + return [ + self._row_to_python(row, self.description) + for row in super().fetchmany(size=size) + if row + ] diff --git a/server/venv/Lib/site-packages/mysql/connector/cursor_cext.py b/server/venv/Lib/site-packages/mysql/connector/cursor_cext.py new file mode 100644 index 0000000..f54dd62 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/cursor_cext.py @@ -0,0 +1,1275 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="assignment,override,union-attr" + +"""Cursor classes using the C Extension.""" +from __future__ import annotations + +import re +import warnings + +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + Iterator, + List, + NoReturn, + Optional, + Sequence, + Tuple, + Union, + cast, +) + +# pylint: disable=import-error,no-name-in-module +from _mysql_connector import MySQLInterfaceError + +from ._decorating import deprecated +from .types import ( + CextEofPacketType, + CextResultType, + DescriptionType, + ParamsSequenceOrDictType, + ParamsSequenceType, + RowItemType, + RowType, + StrOrBytesAny, + WarningType, +) + +# pylint: enable=import-error,no-name-in-module +# isort: split + +from ._scripting import split_multi_statement +from .abstracts import CMySQLPrepStmt, MySQLCursorAbstract +from .cursor import ( + RE_PY_PARAM, + RE_SQL_COMMENT, + RE_SQL_FIND_PARAM, + RE_SQL_INSERT_STMT, + RE_SQL_INSERT_VALUES, + RE_SQL_ON_DUPLICATE, + RE_SQL_PYTHON_CAPTURE_PARAM_NAME, + RE_SQL_PYTHON_REPLACE_PARAM, + _bytestr_format_dict, +) +from .errors import ( + Error, + InterfaceError, + NotSupportedError, + ProgrammingError, + get_mysql_exception, +) + +if TYPE_CHECKING: + from .connection_cext import CMySQLConnection + +ERR_NO_RESULT_TO_FETCH = "No result set to fetch from" + + +class _ParamSubstitutor: + """ + Substitutes parameters into SQL statement. + """ + + def __init__(self, params: Sequence[bytes]) -> None: + self.params: Sequence[bytes] = params + self.index: int = 0 + + def __call__(self, matchobj: object) -> bytes: + index = self.index + self.index += 1 + try: + return self.params[index] + except IndexError: + raise ProgrammingError( + "Not enough parameters for the SQL statement" + ) from None + + @property + def remaining(self) -> int: + """Returns number of parameters remaining to be substituted""" + return len(self.params) - self.index + + +class CMySQLCursor(MySQLCursorAbstract): + """Default cursor for interacting with MySQL using C Extension""" + + def __init__( + self, + connection: CMySQLConnection, + ) -> None: + """Initialize""" + super().__init__(connection) + + self._affected_rows: int = -1 + self._raw_as_string: bool = False + self._buffered: bool = False + self._connection: CMySQLConnection = cast("CMySQLConnection", self._connection) + + def reset(self, free: bool = True) -> None: + self._rowcount = -1 + self._nextrow = None + self._affected_rows = -1 + self._last_insert_id: int = 0 + self._warning_count: int = 0 + self._warnings: Optional[List[WarningType]] = None + self._warnings = None + self._warning_count = 0 + self._description: Optional[List[DescriptionType]] = None + if free and self._connection: + self._connection.free_result() + + super().reset() + + def _reset_result( + self, free: bool = True, preserve_last_executed_stmt: bool = False + ) -> None: + """Resets the cursor to default. + + This method is similar to `reset()`. Unlike `reset()`, this hidden method + allows to customize the reset. + + Args: + free: If `True`, the result will be freed. + preserve_last_executed_stmt: If `False`, the last executed + statement value is reset. Otherwise, + such a value is preserved. + """ + if not preserve_last_executed_stmt: + # reset inner state related to statement execution + self._executed = None + self._executed_list = [] + self._stmt_partitions = None + self._stmt_partition = None + self._stmt_map_results = False + + self.reset(free=free) + + @property + def read_timeout(self) -> Optional[float]: + raise ProgrammingError( + """ + The use of read_timeout after the connection has been established is unsupported + in the C-Extension + """ + ) + + @read_timeout.setter + def read_timeout(self, timeout: int) -> None: + raise ProgrammingError( + """ + Changes in read_timeout after the connection has been established is unsupported + in the C-Extension + """ + ) + + @property + def write_timeout(self) -> Optional[float]: + raise ProgrammingError( + """ + The use of write_timeout after the connection has been established is unsupported + in the C-Extension + """ + ) + + @write_timeout.setter + def write_timeout(self, timeout: int) -> None: + raise ProgrammingError( + """ + Changes in write_timeout after the connection has been established is unsupported + in the C-Extension + """ + ) + + def _check_executed(self) -> None: + """Check if the statement has been executed. + + Raises an error if the statement has not been executed. + """ + if self._executed is None: + raise InterfaceError(ERR_NO_RESULT_TO_FETCH) + + def _fetch_warnings(self) -> Optional[List[WarningType]]: + """Fetch warnings + + Fetch warnings doing a SHOW WARNINGS. Can be called after getting + the result. + + Returns a result set or None when there were no warnings. + + Raises Error (or subclass) on errors. + + Returns list of tuples or None. + """ + warns = [] + try: + # force freeing result + self._connection.consume_results() + _ = self._connection.cmd_query("SHOW WARNINGS") + warns = self._connection.get_rows(raw=self._raw)[0] + self._connection.consume_results() + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + except Exception as err: + raise InterfaceError(f"Failed getting warnings; {err}") from None + + if warns: + return warns # type: ignore[return-value] + + return None + + def _handle_warnings(self) -> None: + """Handle possible warnings after all results are consumed. + + Raises: + Error: Also raises exceptions if raise_on_warnings is set. + """ + if self._connection.get_warnings and self._warning_count: + self._warnings = self._fetch_warnings() + + if not self._warnings: + return + + err = get_mysql_exception( + *self._warnings[0][1:3], warning=not self._connection.raise_on_warnings + ) + if self._connection.raise_on_warnings: + raise err + + warnings.warn(str(err), stacklevel=4) + + def _handle_result(self, result: Union[CextEofPacketType, CextResultType]) -> None: + """Handles the result after statement execution""" + if "columns" in result: + self._description = result["columns"] + self._rowcount = 0 + self._handle_resultset() + else: + self._last_insert_id = result["insert_id"] + self._warning_count = result["warning_count"] + self._affected_rows = result["affected_rows"] + self._rowcount = -1 + self._handle_warnings() + + def _handle_resultset(self) -> None: + """Handle a result set""" + + def _handle_eof(self) -> None: + """Handle end of reading the result + + Raises an Error on errors. + """ + self._warning_count = self._connection.warning_count + self._handle_warnings() + if not self._connection.more_results: + self._connection.free_result() + + def execute( + self, + operation: str, + params: ParamsSequenceOrDictType = (), + map_results: bool = False, + ) -> None: + if not operation: + return None + + try: + if not self._connection or self._connection.is_closed(): + raise ProgrammingError + except (ProgrammingError, ReferenceError) as err: + raise ProgrammingError("Cursor is not connected", 2055) from err + + self._connection.handle_unread_result() + self.reset() + + stmt = b"" + try: + if isinstance(operation, str): + stmt = operation.encode(self._connection.python_charset) + else: + stmt = cast(bytes, operation) + except (UnicodeDecodeError, UnicodeEncodeError) as err: + raise ProgrammingError(str(err)) from err + + if params: + prepared = self._connection.prepare_for_mysql(params) + if isinstance(prepared, dict): + stmt = _bytestr_format_dict(stmt, prepared) + elif isinstance(prepared, (list, tuple)): + psub = _ParamSubstitutor(prepared) + stmt = RE_PY_PARAM.sub(psub, stmt) + if psub.remaining != 0: + raise ProgrammingError( + "Not all parameters were used in the SQL statement" + ) + + self._stmt_partitions = split_multi_statement( + sql_code=stmt, map_results=map_results + ) + self._stmt_partition = next(self._stmt_partitions) + self._stmt_map_results = map_results + self._executed_list = self._stmt_partition["single_stmts"] + self._executed = ( + self._stmt_partition["single_stmts"].popleft() + if map_results + else self._stmt_partition["mappable_stmt"] + ) + + try: + self._handle_result( + self._connection.cmd_query( + self._stmt_partition["mappable_stmt"], + raw=self._raw, + buffered=self._buffered, + raw_as_string=self._raw_as_string, + ) + ) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + return None + + def _batch_insert( + self, + operation: str, + seq_params: Sequence[ParamsSequenceOrDictType], + ) -> Optional[bytes]: + """Implements multi row insert""" + + def remove_comments(match: re.Match) -> str: + """Remove comments from INSERT statements. + + This function is used while removing comments from INSERT + statements. If the matched string is a comment not enclosed + by quotes, it returns an empty string, else the string itself. + """ + if match.group(1): + return "" + return match.group(2) + + tmp = re.sub( + RE_SQL_ON_DUPLICATE, + "", + re.sub(RE_SQL_COMMENT, remove_comments, operation), + ) + + matches = re.search(RE_SQL_INSERT_VALUES, tmp) + if not matches: + raise InterfaceError( + "Failed rewriting statement for multi-row INSERT. Check SQL syntax" + ) + fmt = matches.group(1).encode(self._connection.python_charset) + values = [] + + try: + stmt = operation.encode(self._connection.python_charset) + for params in seq_params: + tmp = fmt + prepared = self._connection.prepare_for_mysql(params) + if isinstance(prepared, dict): + tmp = _bytestr_format_dict(cast(bytes, tmp), prepared) + elif isinstance(prepared, (list, tuple)): + psub = _ParamSubstitutor(prepared) + tmp = RE_PY_PARAM.sub(psub, tmp) # type: ignore[call-overload] + if psub.remaining != 0: + raise ProgrammingError( + "Not all parameters were used in the SQL statement" + ) + values.append(tmp) + + if fmt in stmt: + stmt = stmt.replace(fmt, b",".join(values), 1) # type: ignore[arg-type] + self._executed = stmt + return stmt + return None + except (UnicodeDecodeError, UnicodeEncodeError) as err: + raise ProgrammingError(str(err)) from err + except Exception as err: + raise InterfaceError(f"Failed executing the operation; {err}") from None + + def executemany( + self, + operation: str, + seq_params: Sequence[ParamsSequenceOrDictType], + ) -> None: + """Execute the given operation multiple times + + The executemany() method will execute the operation iterating + over the list of parameters in seq_params. + + Example: Inserting 3 new employees and their phone number + + data = [ + ('Jane','555-001'), + ('Joe', '555-001'), + ('John', '555-003') + ] + stmt = "INSERT INTO employees (name, phone) VALUES ('%s','%s)" + cursor.executemany(stmt, data) + + INSERT statements are optimized by batching the data, that is + using the MySQL multiple rows syntax. + + Results are discarded! If they are needed, consider looping over + data using the execute() method. + """ + if not operation or not seq_params: + return None + + try: + if not self._connection: + raise ProgrammingError + except (ProgrammingError, ReferenceError) as err: + raise ProgrammingError("Cursor is not connected") from err + self._connection.handle_unread_result() + + if not isinstance(seq_params, (list, tuple)): + raise ProgrammingError("Parameters for query must be list or tuple.") + + # Optimize INSERTs by batching them + if re.match(RE_SQL_INSERT_STMT, operation): + if not seq_params: + self._rowcount = 0 + return None + stmt = self._batch_insert(operation, seq_params) + if stmt is not None: + self._executed = stmt + return self.execute(cast(str, stmt)) + + rowcnt = 0 + try: + # When processing read ops (e.g., SELECT), rowcnt is updated + # based on self._rowcount. For write ops (e.g., INSERT) is + # updated based on self._affected_rows. + # The variable self._description is None for write ops, that's + # why we use it as indicator for updating rowcnt. + for params in seq_params: + self.execute(operation, params) + if self.with_rows and self._connection.unread_result: + self.fetchall() + rowcnt += self._rowcount if self.description else self._affected_rows + except (ValueError, TypeError) as err: + raise InterfaceError(f"Failed executing the operation; {err}") from None + + self._rowcount = rowcnt + return None + + @property + def description(self) -> Optional[List[DescriptionType]]: + """Returns description of columns in a result""" + return self._description + + @property + def rowcount(self) -> int: + """Returns the number of rows produced or affected""" + if self._rowcount == -1: + return self._affected_rows + return self._rowcount + + def close(self) -> bool: + """Close the cursor + + The result will be freed. + """ + if not self._connection: + return False + + self._connection.handle_unread_result() + self._warnings = None + self._connection = None + return True + + def callproc( + self, + procname: str, + args: Sequence = (), + ) -> Optional[Union[Dict[str, RowItemType], RowType]]: + """Calls a stored procedure with the given arguments""" + if not procname or not isinstance(procname, str): + raise ValueError("procname must be a string") + + if not isinstance(args, (tuple, list)): + raise ValueError("args must be a sequence") + + argfmt = "@_{name}_arg{index}" + self._stored_results = [] + + try: + argnames = [] + argtypes = [] + + # MySQL itself does support calling procedures with their full + # name .. It's necessary to split + # by '.' and grab the procedure name from procname. + procname_abs = procname.split(".")[-1] + if args: + argvalues = [] + for idx, arg in enumerate(args): + argname = argfmt.format(name=procname_abs, index=idx + 1) + argnames.append(argname) + if isinstance(arg, tuple): + argtypes.append(f" CAST({argname} AS {arg[1]})") + argvalues.append(arg[0]) + else: + argtypes.append(argname) + argvalues.append(arg) + + placeholders = ",".join(f"{arg}=%s" for arg in argnames) + self.execute(f"SET {placeholders}", argvalues) + + call = f"CALL {procname}({','.join(argnames)})" + + result = self._connection.cmd_query( + call, raw=self._raw, raw_as_string=self._raw_as_string + ) + + results = [] + while self._connection.result_set_available: + result = self._connection.fetch_eof_columns() + if isinstance(self, (CMySQLCursorDict, CMySQLCursorBufferedDict)): + cursor_class = CMySQLCursorBufferedDict + elif self._raw: + cursor_class = CMySQLCursorBufferedRaw + else: + cursor_class = CMySQLCursorBuffered + # pylint: disable=protected-access + cur = cursor_class(self._connection.get_self()) # type: ignore[arg-type] + cur._executed = f"(a result of {call})" + cur._handle_result(result) + # pylint: enable=protected-access + results.append(cur) + self._connection.next_result() + self._stored_results = results + self._handle_eof() + + if argnames: + self.reset() + # Create names aliases to be compatible with namedtuples + args = [ + f"{name} AS {alias}" + for name, alias in zip( + argtypes, [arg.lstrip("@_") for arg in argnames] + ) + ] + select = f"SELECT {','.join(args)}" + self.execute(select) + + return self.fetchone() + return tuple() + + except Error: + raise + except Exception as err: + raise InterfaceError(f"Failed calling stored routine; {err}") from None + + def nextset(self) -> Optional[bool]: + if self._connection.next_result(): + # prepare cursor to load the next result set, and ultimately, load it. + self._reset_result(free=False, preserve_last_executed_stmt=True) + if not self._connection.result_set_available: + self._handle_result(self._connection.fetch_eof_status()) + else: + self._handle_result(self._connection.fetch_eof_columns()) + + # if mapping is enabled, run the if-block, otherwise simply return `True`. + if self._stmt_partitions is not None and self._stmt_map_results: + if not self._stmt_partition["single_stmts"]: + # It means there are still results to be consumed, but no more + # statements to relate these results to. + # In this case, we raise a no fatal error and don't clear + # `_executed` so its current value is reported when users + # access the property `statement`. + # If this case ever happens, a bug report should be filed, + # assuming it is happening on supported use cases. + warnings.warn( + "MappingWarning: Number of result sets greater than number " + "of single statements." + ) + else: + self._executed = self._stmt_partition["single_stmts"].popleft() + return True + if self._stmt_partitions is not None: + # Let's see if there are more mappable statements (partitions) + # to be executed. + # If there are no more partitions, we simply return `None`, otherwise + # we execute the correponding mappable multi statement and repeat the + # process all over again. + try: + self._stmt_partition = next(self._stmt_partitions) + except StopIteration: + pass + else: + # This block only happens when mapping is enabled because when it + # is disabled, only one partition is generated, and at this point, + # such partiton has already been processed. + self._executed = self._stmt_partition["single_stmts"].popleft() + try: + self._handle_result( + self._connection.cmd_query( + self._stmt_partition["mappable_stmt"], + raw=self._raw, + buffered=self._buffered, + raw_as_string=self._raw_as_string, + ) + ) + except MySQLInterfaceError as err: + if hasattr(err, "errno"): + raise get_mysql_exception( + err.errno, msg=err.msg, sqlstate=err.sqlstate + ) from err + raise InterfaceError(str(err)) from err + return True + + self._reset_result(free=True) + return None + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + if not self._connection.unread_result: + return [] + + rows = self._connection.get_rows(raw=self._raw) + if self._nextrow and self._nextrow[0]: + rows[0].insert(0, self._nextrow[0]) + + if not rows[0]: + self._handle_eof() + return [] + + self._rowcount += len(rows[0]) + self._handle_eof() + return rows[0] + + def fetchmany(self, size: int = 1) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + if self._nextrow and self._nextrow[0]: + rows = [self._nextrow[0]] + size -= 1 + else: + rows = [] + + if size and self._connection.unread_result: + rows.extend(self._connection.get_rows(size, raw=self._raw)[0]) + + if self._connection.unread_result: + self._nextrow = self._connection.get_row() + if ( + self._nextrow + and not self._nextrow[0] + and not self._connection.more_results + ): + self._connection.free_result() + else: + self._nextrow = (None, None) + + if not rows: + self._handle_eof() + return [] + + self._rowcount += len(rows) + return rows + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + row = self._nextrow + if not row and self._connection.unread_result: + row = self._connection.get_row() + + if row and row[0]: + self._nextrow = self._connection.get_row() + if not self._nextrow[0] and not self._connection.more_results: + self._connection.free_result() + else: + self._handle_eof() + return None + self._rowcount += 1 + return row[0] + + def __iter__(self) -> Iterator[RowType]: + """Iteration over the result set + + Iteration over the result set which calls self.fetchone() + and returns the next row. + """ + return iter(self.fetchone, None) + + @deprecated( + "The property counterpart 'stored_results' will be added in a future release, " + "and this method will be removed." + ) + def stored_results(self) -> Generator[CMySQLCursor, None, None]: + """Returns an iterator for stored results + + This method returns an iterator over results which are stored when + callproc() is called. The iterator will provide MySQLCursorBuffered + instances. + + Returns a iterator. + """ + # pylint: disable=use-yield-from + for result in self._stored_results: + yield result # type: ignore[misc] + self._stored_results = [] + + def __next__(self) -> RowType: + """Iteration over the result set + Used for iterating over the result set. Calls self.fetchone() + to get the next row. + + Raises StopIteration when no more rows are available. + """ + try: + row = self.fetchone() + except InterfaceError: + raise StopIteration from None + if not row: + raise StopIteration from None + return row + + @property + def column_names(self) -> Tuple[str, ...]: + """Returns column names + + This property returns the columns names as a tuple. + + Returns a tuple. + """ + if not self.description: + return () + return tuple(d[0] for d in self.description) + + @property + def with_rows(self) -> bool: + """Returns whether the cursor could have rows returned + + This property returns True when column descriptions are available + and possibly also rows, which will need to be fetched. + + Returns True or False. + """ + if self.description: + return True + return False + + def __str__(self) -> str: + fmt = "{class_name}: {stmt}" + if self._executed: + try: + executed = self._executed.decode("utf-8") + except AttributeError: + executed = self._executed + if len(executed) > 40: + executed = executed[:40] + ".." + else: + executed = "(Nothing executed yet)" + + return fmt.format(class_name=self.__class__.__name__, stmt=executed) + + +class CMySQLCursorBuffered(CMySQLCursor): + """Cursor using C Extension buffering results""" + + def __init__( + self, + connection: CMySQLConnection, + ): + """Initialize""" + super().__init__(connection) + + self._rows: Optional[List[RowType]] = None + self._next_row: int = 0 + + def _handle_resultset(self) -> None: + """Handle a result set""" + self._rows = self._connection.get_rows(raw=self._raw)[0] + self._next_row = 0 + self._rowcount: int = len(self._rows) + self._handle_eof() + + def reset(self, free: bool = True) -> None: + self._rows = None + self._next_row = 0 + super().reset(free=free) + + def _fetch_row(self) -> Optional[RowType]: + """Returns the next row in the result set + + Returns a tuple or None. + """ + row = None + try: + row = self._rows[self._next_row] + except IndexError: + return None + self._next_row += 1 + return row + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + if self._rows is None: + return [] + res = self._rows[self._next_row :] + self._next_row = len(self._rows) + return res + + def fetchmany(self, size: int = 1) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + res = [] + cnt = size or self.arraysize + while cnt > 0: + cnt -= 1 + row = self._fetch_row() + if row: + res.append(row) + else: + break + return res + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return self._fetch_row() + + @property + def with_rows(self) -> bool: + """Returns whether the cursor could have rows returned + + This property returns True when rows are available, + which will need to be fetched. + + Returns True or False. + """ + return self._rows is not None + + +class CMySQLCursorRaw(CMySQLCursor): + """Cursor using C Extension return raw results""" + + def __init__( + self, + connection: CMySQLConnection, + ) -> None: + super().__init__(connection) + self._raw = True + + +class CMySQLCursorBufferedRaw(CMySQLCursorBuffered): + """Cursor using C Extension buffering raw results""" + + def __init__( + self, + connection: CMySQLConnection, + ): + super().__init__(connection) + self._raw = True + + +class CMySQLCursorDict(CMySQLCursor): + """Cursor using C Extension returning rows as dictionaries""" + + def fetchone(self) -> Optional[Dict[str, RowItemType]]: + """Return next row of a query result set. + + Returns: + dict or None: A dict from query result set. + """ + row = super().fetchone() + return dict(zip(self.column_names, row)) if row else None + + def fetchmany(self, size: int = 1) -> List[Dict[str, RowItemType]]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set represented + as a list of dictionaries where column names are used as keys. + """ + res = super().fetchmany(size=size) + return [dict(zip(self.column_names, row)) for row in res] + + def fetchall(self) -> List[Dict[str, RowItemType]]: + """Return all rows of a query result set. + + Returns: + list: A list of dictionaries with all rows of a query + result set where column names are used as keys. + """ + res = super().fetchall() + return [dict(zip(self.column_names, row)) for row in res] + + +class CMySQLCursorBufferedDict(CMySQLCursorBuffered): + """Cursor using C Extension buffering and returning rows as dictionaries""" + + def _fetch_row(self) -> Optional[Dict[str, RowItemType]]: + row = super()._fetch_row() + if row: + return dict(zip(self.column_names, row)) + return None + + def fetchall(self) -> List[Dict[str, RowItemType]]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + res = super().fetchall() + return [dict(zip(self.column_names, row)) for row in res] + + +class CMySQLCursorPrepared(CMySQLCursor): + """Cursor using MySQL Prepared Statements""" + + def __init__( + self, + connection: CMySQLConnection, + ): + super().__init__(connection) + self._rows: Optional[List[RowType]] = None + self._rowcount: int = 0 + self._next_row: int = 0 + self._binary: bool = True + self._stmt: Optional[CMySQLPrepStmt] = None + + def _handle_eof(self) -> None: + """Handle EOF packet""" + self._nextrow = (None, None) + self._handle_warnings() + + def _fetch_row(self, raw: bool = False) -> Optional[RowType]: + """Returns the next row in the result set + + Returns a tuple or None. + """ + if not self._stmt or not self._stmt.have_result_set: + return None + row = None + + if self._nextrow == (None, None): + (row, eof) = self._connection.get_row( + binary=self._binary, + columns=self.description, + raw=raw, + prep_stmt=self._stmt, + ) + else: + (row, eof) = self._nextrow + + if row: + self._nextrow = self._connection.get_row( + binary=self._binary, + columns=self.description, + raw=raw, + prep_stmt=self._stmt, + ) + eof = self._nextrow[1] + if eof is not None: + self._warning_count = eof["warning_count"] + self._handle_eof() + if self._rowcount == -1: + self._rowcount = 1 + else: + self._rowcount += 1 + if eof: + self._warning_count = eof["warning_count"] + self._handle_eof() + + return row + + def callproc(self, procname: Any, args: Any = None) -> NoReturn: + """Calls a stored procedue + + Not supported with CMySQLCursorPrepared. + """ + raise NotSupportedError() + + def close(self) -> None: + """Close the cursor + + This method will try to deallocate the prepared statement and close + the cursor. + """ + if self._stmt: + self.reset() + self._connection.cmd_stmt_close(self._stmt) + self._stmt = None + super().close() + + def reset(self, free: bool = True) -> None: + """Resets the prepared statement. + + Args: + free: If `True`, the result will be freed. + preserve_last_executed_stmt: If `False`, the last executed + statement value is reset. Otherwise, + such a value is preserved. + """ + if self._stmt: + self._connection.cmd_stmt_reset(self._stmt) + super().reset(free=free) + + def execute( + self, + operation: StrOrBytesAny, + params: Optional[ParamsSequenceOrDictType] = None, + map_results: bool = False, + ) -> None: + """Prepare and execute a MySQL Prepared Statement + + This method will prepare the given operation and execute it using + the given parameters. + + If the cursor instance already had a prepared statement, it is + first closed. + + Note: argument "multi" is unused. + """ + if map_results: + raise ProgrammingError( + "Multi statement execution not supported for prepared statements." + ) + + if not operation: + return + + try: + if not self._connection or self._connection.is_closed(): + raise ProgrammingError + except (ProgrammingError, ReferenceError) as err: + raise ProgrammingError("Cursor is not connected", 2055) from err + + self._connection.handle_unread_result(prepared=True) + + charset = self._connection.charset + if charset == "utf8mb4": + charset = "utf8" + + if not isinstance(operation, str): + try: + operation = operation.decode(charset) + except UnicodeDecodeError as err: + raise ProgrammingError(str(err)) from err + + if isinstance(params, dict): + replacement_keys = re.findall(RE_SQL_PYTHON_CAPTURE_PARAM_NAME, operation) + try: + # Replace params dict with params tuple in correct order. + params = tuple(params[key] for key in replacement_keys) + except KeyError as err: + raise ProgrammingError( + "Not all placeholders were found in the parameters dict" + ) from err + # Convert %(name)s to ? before sending it to MySQL + operation = re.sub(RE_SQL_PYTHON_REPLACE_PARAM, "?", operation) + + if operation is not self._executed: + if self._stmt: + self._connection.cmd_stmt_close(self._stmt) + self._executed = operation + + try: + operation = operation.encode(charset) + except UnicodeEncodeError as err: + raise ProgrammingError(str(err)) from err + + if b"%s" in operation: + # Convert %s to ? before sending it to MySQL + operation = re.sub(RE_SQL_FIND_PARAM, b"?", operation) + + try: + self._stmt = self._connection.cmd_stmt_prepare(operation) + except Error: + self._executed = None + self._stmt = None + raise + + self._connection.cmd_stmt_reset(self._stmt) + + if self._stmt.param_count > 0 and not params: + return + if params: + if not isinstance(params, (tuple, list)): + raise ProgrammingError( + errno=1210, + msg=f"Incorrect type of argument: {type(params).__name__}({params})" + ", it must be of type tuple or list the argument given to " + "the prepared statement", + ) + if self._stmt.param_count != len(params): + raise ProgrammingError( + errno=1210, + msg="Incorrect number of arguments executing prepared statement", + ) + + if params is None: + params = () + res = self._connection.cmd_stmt_execute(self._stmt, *params) + if res: + self._handle_result(res) + + def executemany( + self, operation: str, seq_params: Sequence[ParamsSequenceType] + ) -> None: + """Prepare and execute a MySQL Prepared Statement many times + + This method will prepare the given operation and execute with each + tuple found the list seq_params. + + If the cursor instance already had a prepared statement, it is + first closed. + """ + rowcnt = 0 + try: + for params in seq_params: + self.execute(operation, params) + if self.with_rows: + self.fetchall() + rowcnt += self._rowcount + except (ValueError, TypeError) as err: + raise InterfaceError(f"Failed executing the operation; {err}") from err + self._rowcount = rowcnt + + def fetchone(self) -> Optional[RowType]: + """Return next row of a query result set. + + Returns: + tuple or None: A row from query result set. + """ + self._check_executed() + return self._fetch_row() or None + + def fetchmany(self, size: Optional[int] = None) -> List[RowType]: + """Return the next set of rows of a query result set. + + When no more rows are available, it returns an empty list. + The number of rows returned can be specified using the size argument, + which defaults to one. + + Returns: + list: The next set of rows of a query result set. + """ + self._check_executed() + res = [] + cnt = size or self.arraysize + while cnt > 0 and self._stmt.have_result_set: + cnt -= 1 + row = self._fetch_row() + if row: + res.append(row) + return res + + def fetchall(self) -> List[RowType]: + """Return all rows of a query result set. + + Returns: + list: A list of tuples with all rows of a query result set. + """ + self._check_executed() + if not self._stmt.have_result_set: + return [] + + rows = self._connection.get_rows(prep_stmt=self._stmt) + if self._nextrow and self._nextrow[0]: + rows[0].insert(0, self._nextrow[0]) + + if not rows[0]: + self._handle_eof() + return [] + + self._rowcount += len(rows[0]) + self._handle_eof() + return rows[0] + + +class CMySQLCursorPreparedDict(CMySQLCursorDict, CMySQLCursorPrepared): # type: ignore[misc] + """This class is a blend of features from CMySQLCursorDict and CMySQLCursorPrepared + + Multiple inheritance in python is allowed but care must be taken + when assuming methods resolution. In the case of multiple + inheritance, a given attribute is first searched in the current + class if it's not found then it's searched in the parent classes. + The parent classes are searched in a left-right fashion and each + class is searched once. + Based on python's attribute resolution, in this case, attributes + are searched as follows: + 1. CMySQLCursorPreparedDict (current class) + 2. CMySQLCursorDict (left parent class) + 3. CMySQLCursorPrepared (right parent class) + 4. CMySQLCursor (base class) + """ diff --git a/server/venv/Lib/site-packages/mysql/connector/custom_types.py b/server/venv/Lib/site-packages/mysql/connector/custom_types.py new file mode 100644 index 0000000..c61ae49 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/custom_types.py @@ -0,0 +1,49 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Custom Python types used by MySQL Connector/Python""" +from __future__ import annotations + +from typing import Type + + +class HexLiteral(str): + """Class holding MySQL hex literals""" + + charset: str = "" + original: str = "" + + def __new__(cls: Type[HexLiteral], str_: str, charset: str = "utf8") -> HexLiteral: + hexed = [f"{i:02x}" for i in str_.encode(charset)] + obj = str.__new__(cls, "".join(hexed)) + obj.charset = charset + obj.original = str_ + return obj + + def __str__(self) -> str: + return "0x" + self diff --git a/server/venv/Lib/site-packages/mysql/connector/dbapi.py b/server/venv/Lib/site-packages/mysql/connector/dbapi.py new file mode 100644 index 0000000..c780ec1 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/dbapi.py @@ -0,0 +1,92 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +""" +This module implements some constructors and singletons as required by the +DB API v2.0 (PEP-249). +""" + +# Python Db API v2 +# pylint: disable=invalid-name +apilevel: str = "2.0" +"""This attribute is a string that indicates the supported DB API level.""" + +threadsafety: int = 1 +"""This attribute is an integer that indicates the supported level of thread safety +provided by Connector/Python.""" + +paramstyle: str = "pyformat" +"""This attribute is a string that indicates the Connector/Python default +parameter style.""" + +import datetime +import time + +from typing import Tuple + +from . import constants + + +class _DBAPITypeObject: + def __init__(self, *values: int) -> None: + self.values: Tuple[int, ...] = values + + def __eq__(self, other: object) -> bool: + return other in self.values + + def __ne__(self, other: object) -> bool: + return other not in self.values + + +Date = datetime.date +Time = datetime.time +Timestamp = datetime.datetime + + +def DateFromTicks(ticks: int) -> datetime.date: + """Construct an object holding a date value from the given ticks value.""" + return Date(*time.localtime(ticks)[:3]) + + +def TimeFromTicks(ticks: int) -> datetime.time: + """Construct an object holding a time value from the given ticks value.""" + return Time(*time.localtime(ticks)[3:6]) + + +def TimestampFromTicks(ticks: int) -> datetime.datetime: + """Construct an object holding a time stamp from the given ticks value.""" + return Timestamp(*time.localtime(ticks)[:6]) + + +Binary = bytes + +STRING = _DBAPITypeObject(*constants.FieldType.get_string_types()) +BINARY = _DBAPITypeObject(*constants.FieldType.get_binary_types()) +NUMBER = _DBAPITypeObject(*constants.FieldType.get_number_types()) +DATETIME = _DBAPITypeObject(*constants.FieldType.get_timestamp_types()) +ROWID = _DBAPITypeObject() diff --git a/server/venv/Lib/site-packages/mysql/connector/django/__init__.py b/server/venv/Lib/site-packages/mysql/connector/django/__init__.py new file mode 100644 index 0000000..1f2326a --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/server/venv/Lib/site-packages/mysql/connector/django/base.py b/server/venv/Lib/site-packages/mysql/connector/django/base.py new file mode 100644 index 0000000..8168303 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/base.py @@ -0,0 +1,654 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="override" + +"""Django database Backend using MySQL Connector/Python. + +This Django database backend is heavily based on the MySQL backend from Django. + +Changes include: +* Support for microseconds (MySQL 5.6.3 and later) +* Using INFORMATION_SCHEMA where possible +* Using new defaults for, for example SQL_AUTO_IS_NULL + +Requires and comes with MySQL Connector/Python v8.0.22 and later: + http://dev.mysql.com/downloads/connector/python/ +""" + +import warnings + +from datetime import datetime, time +from typing import ( + TYPE_CHECKING, + Any, + Dict, + Generator, + Iterator, + List, + Optional, + Sequence, + Set, + Tuple, + Union, +) + +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.db import IntegrityError +from django.db.backends.base.base import BaseDatabaseWrapper +from django.utils import dateparse, timezone +from django.utils.functional import cached_property + +from mysql.connector.types import MySQLConvertibleType + +try: + import mysql.connector + + from mysql.connector.conversion import MySQLConverter + from mysql.connector.custom_types import HexLiteral + from mysql.connector.pooling import PooledMySQLConnection + from mysql.connector.types import ParamsSequenceOrDictType, RowType, StrOrBytes +except ImportError as err: + raise ImproperlyConfigured(f"Error loading mysql.connector module: {err}") from err + +try: + from _mysql_connector import datetime_to_mysql +except ImportError: + HAVE_CEXT = False +else: + HAVE_CEXT = True + +from .client import DatabaseClient +from .creation import DatabaseCreation +from .features import DatabaseFeatures +from .introspection import DatabaseIntrospection +from .operations import DatabaseOperations +from .schema import DatabaseSchemaEditor +from .validation import DatabaseValidation + +Error = mysql.connector.Error +DatabaseError = mysql.connector.DatabaseError +NotSupportedError = mysql.connector.NotSupportedError +OperationalError = mysql.connector.OperationalError +ProgrammingError = mysql.connector.ProgrammingError + +if TYPE_CHECKING: + from mysql.connector.abstracts import MySQLConnectionAbstract, MySQLCursorAbstract + + +def adapt_datetime_with_timezone_support(value: datetime) -> StrOrBytes: + """Equivalent to DateTimeField.get_db_prep_value. Used only by raw SQL.""" + if settings.USE_TZ: + if timezone.is_naive(value): + warnings.warn( + f"MySQL received a naive datetime ({value})" + " while time zone support is active.", + RuntimeWarning, + ) + default_timezone = timezone.get_default_timezone() + value = timezone.make_aware(value, default_timezone) + # pylint: disable=no-member + value = value.astimezone(timezone.utc).replace( # type: ignore[attr-defined] + tzinfo=None + ) + if HAVE_CEXT: + mysql_datetime: bytes = datetime_to_mysql(value) + return mysql_datetime + return value.strftime("%Y-%m-%d %H:%M:%S.%f") + + +class CursorWrapper: + """Wrapper around MySQL Connector/Python's cursor class. + + The cursor class is defined by the options passed to MySQL + Connector/Python. If buffered option is True in those options, + MySQLCursorBuffered will be used. + """ + + codes_for_integrityerror = ( + 1048, # Column cannot be null + 1690, # BIGINT UNSIGNED value is out of range + 3819, # CHECK constraint is violated + 4025, # CHECK constraint failed + ) + + def __init__(self, cursor: "MySQLCursorAbstract") -> None: + self.cursor: "MySQLCursorAbstract" = cursor + + @staticmethod + def _adapt_execute_args_dict( + args: Dict[str, MySQLConvertibleType], + ) -> Dict[str, MySQLConvertibleType]: + if not args: + return args + new_args = dict(args) + for key, value in args.items(): + if isinstance(value, datetime): + new_args[key] = adapt_datetime_with_timezone_support(value) + + return new_args + + @staticmethod + def _adapt_execute_args( + args: Optional[Sequence[MySQLConvertibleType]], + ) -> Optional[Sequence[MySQLConvertibleType]]: + if not args: + return args + new_args = list(args) + for i, arg in enumerate(args): + if isinstance(arg, datetime): + new_args[i] = adapt_datetime_with_timezone_support(arg) + + return tuple(new_args) + + def execute( + self, + query: str, + args: Optional[ + Union[Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType]] + ] = None, + ) -> Optional[Generator["MySQLCursorAbstract", None, None]]: + """Executes the given operation + + This wrapper method around the execute()-method of the cursor is + mainly needed to re-raise using different exceptions. + """ + new_args: Optional[ParamsSequenceOrDictType] = None + if isinstance(args, dict): + new_args = self._adapt_execute_args_dict(args) + else: + new_args = self._adapt_execute_args(args) + try: + return self.cursor.execute(query, new_args) + except mysql.connector.OperationalError as exc: + if exc.args[0] in self.codes_for_integrityerror: + raise IntegrityError(*tuple(exc.args)) from None + raise + + def executemany( + self, + query: str, + args: Sequence[ + Union[Sequence[MySQLConvertibleType], Dict[str, MySQLConvertibleType]] + ], + ) -> Optional[Generator["MySQLCursorAbstract", None, None]]: + """Executes the given operation + + This wrapper method around the executemany()-method of the cursor is + mainly needed to re-raise using different exceptions. + """ + try: + return self.cursor.executemany(query, args) + except mysql.connector.OperationalError as exc: + if exc.args[0] in self.codes_for_integrityerror: + raise IntegrityError(*tuple(exc.args)) from None + raise + + def __getattr__(self, attr: Any) -> Any: + """Return an attribute of wrapped cursor""" + return getattr(self.cursor, attr) + + def __iter__(self) -> Iterator[RowType]: + """Return an iterator over wrapped cursor""" + return iter(self.cursor) + + +class DatabaseWrapper(BaseDatabaseWrapper): # pylint: disable=abstract-method + """Represent a database connection.""" + + vendor = "mysql" + # This dictionary maps Field objects to their associated MySQL column + # types, as strings. Column-type strings can contain format strings; they'll + # be interpolated against the values of Field.__dict__ before being output. + # If a column type is set to None, it won't be included in the output. + data_types = { + "AutoField": "integer AUTO_INCREMENT", + "BigAutoField": "bigint AUTO_INCREMENT", + "BinaryField": "longblob", + "BooleanField": "bool", + "CharField": "varchar(%(max_length)s)", + "DateField": "date", + "DateTimeField": "datetime(6)", + "DecimalField": "numeric(%(max_digits)s, %(decimal_places)s)", + "DurationField": "bigint", + "FileField": "varchar(%(max_length)s)", + "FilePathField": "varchar(%(max_length)s)", + "FloatField": "double precision", + "IntegerField": "integer", + "BigIntegerField": "bigint", + "IPAddressField": "char(15)", + "GenericIPAddressField": "char(39)", + "JSONField": "json", + "NullBooleanField": "bool", + "OneToOneField": "integer", + "PositiveBigIntegerField": "bigint UNSIGNED", + "PositiveIntegerField": "integer UNSIGNED", + "PositiveSmallIntegerField": "smallint UNSIGNED", + "SlugField": "varchar(%(max_length)s)", + "SmallAutoField": "smallint AUTO_INCREMENT", + "SmallIntegerField": "smallint", + "TextField": "longtext", + "TimeField": "time(6)", + "UUIDField": "char(32)", + } + + # For these data types: + # - MySQL < 8.0.13 doesn't accept default values and + # implicitly treat them as nullable + # - all versions of MySQL doesn't support full width database + # indexes + _limited_data_types = ( + "tinyblob", + "blob", + "mediumblob", + "longblob", + "tinytext", + "text", + "mediumtext", + "longtext", + "json", + ) + + operators = { + "exact": "= %s", + "iexact": "LIKE %s", + "contains": "LIKE CAST(%s AS BINARY)", + "icontains": "LIKE %s", + "gt": "> %s", + "gte": ">= %s", + "lt": "< %s", + "lte": "<= %s", + "startswith": "LIKE CAST(%s AS BINARY)", + "endswith": "LIKE CAST(%s AS BINARY)", + "istartswith": "LIKE %s", + "iendswith": "LIKE %s", + } + + # The patterns below are used to generate SQL pattern lookup clauses when + # the right-hand side of the lookup isn't a raw string (it might be an expression + # or the result of a bilateral transformation). + # In those cases, special characters for LIKE operators (e.g. \, *, _) should be + # escaped on database side. + # + # Note: we use str.format() here for readability as '%' is used as a wildcard for + # the LIKE operator. + pattern_esc = r"REPLACE(REPLACE(REPLACE({}, '\\', '\\\\'), '%%', '\%%'), '_', '\_')" + pattern_ops = { + "contains": "LIKE CAST(CONCAT('%%', {}, '%%') AS BINARY)", + "icontains": "LIKE CONCAT('%%', {}, '%%')", + "startswith": "LIKE CAST(CONCAT({}, '%%') AS BINARY)", + "istartswith": "LIKE CONCAT({}, '%%')", + "endswith": "LIKE CAST(CONCAT('%%', {}) AS BINARY)", + "iendswith": "LIKE CONCAT('%%', {})", + } + + isolation_level: Optional[str] = None + isolation_levels = { + "read uncommitted", + "read committed", + "repeatable read", + "serializable", + } + + Database = mysql.connector + SchemaEditorClass = DatabaseSchemaEditor + # Classes instantiated in __init__(). + client_class = DatabaseClient + creation_class = DatabaseCreation + features_class = DatabaseFeatures + introspection_class = DatabaseIntrospection + ops_class = DatabaseOperations + validation_class = DatabaseValidation + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + options = self.settings_dict.get("OPTIONS") + if options: + self._use_pure = options.get("use_pure", not HAVE_CEXT) + converter_class = options.get( + "converter_class", + DjangoMySQLConverter, + ) + if not issubclass(converter_class, DjangoMySQLConverter): + raise ProgrammingError( + "Converter class should be a subclass of " + "mysql.connector.django.base.DjangoMySQLConverter" + ) + self.converter = converter_class() + else: + self.converter = DjangoMySQLConverter() + self._use_pure = not HAVE_CEXT + + def __getattr__(self, attr: str) -> bool: + if attr.startswith("mysql_is"): + return False + raise AttributeError + + def get_connection_params(self) -> Dict[str, Any]: + kwargs: dict = { + "consume_results": True, + } + + settings_dict = self.settings_dict + + if settings_dict["USER"]: + kwargs["user"] = settings_dict["USER"] + if settings_dict["NAME"]: + kwargs["database"] = settings_dict["NAME"] + if settings_dict["PASSWORD"]: + kwargs["passwd"] = settings_dict["PASSWORD"] + if settings_dict["HOST"].startswith("/"): + kwargs["unix_socket"] = settings_dict["HOST"] + elif settings_dict["HOST"]: + kwargs["host"] = settings_dict["HOST"] + if settings_dict["PORT"]: + kwargs["port"] = int(settings_dict["PORT"]) + if settings_dict.get("OPTIONS", {}).get("init_command"): + kwargs["init_command"] = settings_dict["OPTIONS"]["init_command"] + + # Raise exceptions for database warnings if DEBUG is on + kwargs["raise_on_warnings"] = settings.DEBUG + + kwargs["client_flags"] = [ + # Need potentially affected rows on UPDATE + mysql.connector.constants.ClientFlag.FOUND_ROWS, + ] + + try: + options = settings_dict["OPTIONS"].copy() + isolation_level = options.pop("isolation_level", None) + if isolation_level: + isolation_level = isolation_level.lower() + if isolation_level not in self.isolation_levels: + valid_levels = ", ".join( + f"'{level}'" for level in sorted(self.isolation_levels) + ) + raise ImproperlyConfigured( + f"Invalid transaction isolation level '{isolation_level}' " + f"specified.\nUse one of {valid_levels}, or None." + ) + self.isolation_level = isolation_level + kwargs.update(options) + except KeyError: + # OPTIONS missing is OK + pass + return kwargs + + def get_new_connection( + self, conn_params: Dict[str, Any] + ) -> Union[PooledMySQLConnection, "MySQLConnectionAbstract"]: + if "converter_class" not in conn_params: + conn_params["converter_class"] = DjangoMySQLConverter + cnx = mysql.connector.connect(**conn_params) + + return cnx + + def init_connection_state(self) -> None: + assignments = [] + if self.features.is_sql_auto_is_null_enabled: # type: ignore[attr-defined] + # SQL_AUTO_IS_NULL controls whether an AUTO_INCREMENT column on + # a recently inserted row will return when the field is tested + # for NULL. Disabling this brings this aspect of MySQL in line + # with SQL standards. + assignments.append("SET SQL_AUTO_IS_NULL = 0") + + if self.isolation_level: + assignments.append( + "SET SESSION TRANSACTION ISOLATION LEVEL " + f"{self.isolation_level.upper()}" + ) + + if assignments: + with self.cursor() as cursor: + cursor.execute("; ".join(assignments)) + + if "AUTOCOMMIT" in self.settings_dict: + try: + self.set_autocommit(self.settings_dict["AUTOCOMMIT"]) + except AttributeError: + self._set_autocommit(self.settings_dict["AUTOCOMMIT"]) + + def create_cursor(self, name: Any = None) -> CursorWrapper: + cursor = self.connection.cursor() + return CursorWrapper(cursor) + + def _rollback(self) -> None: + try: + BaseDatabaseWrapper._rollback(self) # type: ignore[attr-defined] + except NotSupportedError: + pass + + def _set_autocommit(self, autocommit: bool) -> None: + with self.wrap_database_errors: + self.connection.autocommit = autocommit + + def disable_constraint_checking(self) -> bool: + """ + Disable foreign key checks, primarily for use in adding rows with + forward references. Always return True to indicate constraint checks + need to be re-enabled. + """ + with self.cursor() as cursor: + cursor.execute("SET foreign_key_checks=0") + return True + + def enable_constraint_checking(self) -> None: + """ + Re-enable foreign key checks after they have been disabled. + """ + # Override needs_rollback in case constraint_checks_disabled is + # nested inside transaction.atomic. + self.needs_rollback, needs_rollback = False, self.needs_rollback + try: + with self.cursor() as cursor: + cursor.execute("SET foreign_key_checks=1") + finally: + self.needs_rollback = needs_rollback + + def check_constraints(self, table_names: Optional[List[str]] = None) -> None: + """ + Check each table name in `table_names` for rows with invalid foreign + key references. This method is intended to be used in conjunction with + `disable_constraint_checking()` and `enable_constraint_checking()`, to + determine if rows with invalid references were entered while constraint + checks were off. + """ + with self.cursor() as cursor: + if table_names is None: + table_names = self.introspection.table_names(cursor) + for table_name in table_names: + primary_key_column_name = self.introspection.get_primary_key_column( + cursor, table_name + ) + if not primary_key_column_name: + continue + key_columns = self.introspection.get_key_columns( # type: ignore[attr-defined] + cursor, table_name + ) + for ( + column_name, + referenced_table_name, + referenced_column_name, + ) in key_columns: + cursor.execute( + f""" + SELECT REFERRING.`{primary_key_column_name}`, + REFERRING.`{column_name}` + FROM `{table_name}` as REFERRING + LEFT JOIN `{referenced_table_name}` as REFERRED + ON ( + REFERRING.`{column_name}` = + REFERRED.`{referenced_column_name}` + ) + WHERE REFERRING.`{column_name}` IS NOT NULL + AND REFERRED.`{referenced_column_name}` IS NULL + """ + ) + for bad_row in cursor.fetchall(): + raise IntegrityError( + f"The row in table '{table_name}' with primary " + f"key '{bad_row[0]}' has an invalid foreign key: " + f"{table_name}.{column_name} contains a value " + f"'{bad_row[1]}' that does not have a " + f"corresponding value in " + f"{referenced_table_name}." + f"{referenced_column_name}." + ) + + def is_usable(self) -> bool: + try: + self.connection.ping() + except Error: + return False + return True + + @cached_property # type: ignore[misc] + @staticmethod + def display_name() -> str: + """Display name.""" + return "MySQL" + + @cached_property + def data_type_check_constraints(self) -> Dict[str, str]: + """Mapping of Field objects to their SQL for CHECK constraints.""" + if self.features.supports_column_check_constraints: + check_constraints = { + "PositiveBigIntegerField": "`%(column)s` >= 0", + "PositiveIntegerField": "`%(column)s` >= 0", + "PositiveSmallIntegerField": "`%(column)s` >= 0", + } + return check_constraints + return {} + + @cached_property + def mysql_server_data(self) -> Dict[str, Any]: + """Return MySQL server data.""" + with self.temporary_connection() as cursor: + # Select some server variables and test if the time zone + # definitions are installed. CONVERT_TZ returns NULL if 'UTC' + # timezone isn't loaded into the mysql.time_zone table. + cursor.execute( + """ + SELECT VERSION(), + @@sql_mode, + @@default_storage_engine, + @@sql_auto_is_null, + @@lower_case_table_names, + CONVERT_TZ('2001-01-01 01:00:00', 'UTC', 'UTC') IS NOT NULL + """ + ) + row = cursor.fetchone() + return { + "version": row[0], + "sql_mode": row[1], + "default_storage_engine": row[2], + "sql_auto_is_null": bool(row[3]), + "lower_case_table_names": bool(row[4]), + "has_zoneinfo_database": bool(row[5]), + } + + @cached_property + def mysql_server_info(self) -> Any: + """Return MySQL version.""" + if self.connection: + return self.connection.server_info + with self.temporary_connection() as cursor: + cursor.execute("SELECT VERSION()") + return cursor.fetchone()[0] + + @cached_property + def mysql_version(self) -> Tuple[int, ...]: + """Return MySQL version.""" + if self.connection: + return self.connection.server_version + config = self.get_connection_params() + with mysql.connector.connect(**config) as conn: + server_version: Tuple[int, ...] = conn.server_version + return server_version + + @cached_property + def sql_mode(self) -> Set[str]: + """Return SQL mode.""" + if self.connection: + return set(self.connection.sql_mode.split(",")) + with self.cursor() as cursor: + cursor.execute("SELECT @@sql_mode") + sql_mode = cursor.fetchone() + return set(sql_mode[0].split(",") if sql_mode else ()) + + @property + def use_pure(self) -> bool: + """Return True if pure Python version is being used.""" + ans: bool = self._use_pure + return ans + + +class DjangoMySQLConverter(MySQLConverter): + """Custom converter for Django.""" + + # pylint: disable=unused-argument + + @staticmethod + def _time_to_python(value: bytes, dsc: Any = None) -> Optional[time]: + """Return MySQL TIME data type as datetime.time() + + Returns datetime.time() + """ + return dateparse.parse_time(value.decode("utf-8")) + + @staticmethod + def _datetime_to_python(value: bytes, dsc: Any = None) -> Optional[datetime]: + """Connector/Python always returns naive datetime.datetime + + Connector/Python always returns naive timestamps since MySQL has + no time zone support. + + - A naive datetime is a datetime that doesn't know its own timezone. + + Django needs a non-naive datetime, but in this method we don't need + to make a datetime value time zone aware since Django itself at some + point will make it aware (at least in versions 3.2.16 and 4.1.2) when + USE_TZ=True. This may change in a future release, we need to keep an + eye on this behaviour. + + Returns datetime.datetime() + """ + return MySQLConverter._datetime_to_python(value) if value else None + + # pylint: enable=unused-argument + + def _safestring_to_mysql(self, value: str) -> Union[bytes, HexLiteral]: + return self._str_to_mysql(value) + + def _safetext_to_mysql(self, value: str) -> Union[bytes, HexLiteral]: + return self._str_to_mysql(value) + + def _safebytes_to_mysql(self, value: bytes) -> bytes: + return self._bytes_to_mysql(value) diff --git a/server/venv/Lib/site-packages/mysql/connector/django/client.py b/server/venv/Lib/site-packages/mysql/connector/django/client.py new file mode 100644 index 0000000..8080bbb --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/client.py @@ -0,0 +1,106 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Database Client.""" + +import os +import subprocess + +from typing import Any, Dict, Iterable, List, Optional, Tuple + +from django.db.backends.base.client import BaseDatabaseClient + + +class DatabaseClient(BaseDatabaseClient): + """Encapsulate backend-specific methods for opening a client shell.""" + + executable_name = "mysql" + + @classmethod + def settings_to_cmd_args_env( + cls, settings_dict: Dict[str, Any], parameters: Optional[Iterable[str]] = None + ) -> Tuple[List[str], Optional[Dict[str, Any]]]: + args = [cls.executable_name] + + db = settings_dict["OPTIONS"].get("database", settings_dict["NAME"]) + user = settings_dict["OPTIONS"].get("user", settings_dict["USER"]) + passwd = settings_dict["OPTIONS"].get("password", settings_dict["PASSWORD"]) + host = settings_dict["OPTIONS"].get("host", settings_dict["HOST"]) + port = settings_dict["OPTIONS"].get("port", settings_dict["PORT"]) + ssl_ca = settings_dict["OPTIONS"].get("ssl_ca") + ssl_cert = settings_dict["OPTIONS"].get("ssl_cert") + ssl_key = settings_dict["OPTIONS"].get("ssl_key") + defaults_file = settings_dict["OPTIONS"].get("read_default_file") + charset = settings_dict["OPTIONS"].get("charset") + + # --defaults-file should always be the first option + if defaults_file: + args.append(f"--defaults-file={defaults_file}") + + # Load any custom init_commands. We always force SQL_MODE to TRADITIONAL + init_command = settings_dict["OPTIONS"].get("init_command", "") + args.append(f"--init-command=SET @@session.SQL_MODE=TRADITIONAL;{init_command}") + + if user: + args.append(f"--user={user}") + if passwd: + args.append(f"--password={passwd}") + + if host: + if "/" in host: + args.append(f"--socket={host}") + else: + args.append(f"--host={host}") + + if port: + args.append(f"--port={port}") + + if db: + args.append(f"--database={db}") + + if ssl_ca: + args.append(f"--ssl-ca={ssl_ca}") + if ssl_cert: + args.append(f"--ssl-cert={ssl_cert}") + if ssl_key: + args.append(f"--ssl-key={ssl_key}") + + if charset: + args.append(f"--default-character-set={charset}") + + if parameters: + args.extend(parameters) + + return args, None + + def runshell(self, parameters: Optional[Iterable[str]] = None) -> None: + args, env = self.settings_to_cmd_args_env( + self.connection.settings_dict, parameters + ) + env = {**os.environ, **env} if env else None + subprocess.run(args, env=env, check=True) diff --git a/server/venv/Lib/site-packages/mysql/connector/django/compiler.py b/server/venv/Lib/site-packages/mysql/connector/django/compiler.py new file mode 100644 index 0000000..083758a --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/compiler.py @@ -0,0 +1,45 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""SQL Compiler classes.""" + +from django.db.backends.mysql.compiler import ( + SQLAggregateCompiler, + SQLCompiler, + SQLDeleteCompiler, + SQLInsertCompiler, + SQLUpdateCompiler, +) + +__all__ = [ + "SQLAggregateCompiler", + "SQLCompiler", + "SQLDeleteCompiler", + "SQLInsertCompiler", + "SQLUpdateCompiler", +] diff --git a/server/venv/Lib/site-packages/mysql/connector/django/creation.py b/server/venv/Lib/site-packages/mysql/connector/django/creation.py new file mode 100644 index 0000000..2a880e7 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/creation.py @@ -0,0 +1,33 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Backend specific database creation.""" + +from django.db.backends.mysql.creation import DatabaseCreation + +__all__ = ["DatabaseCreation"] diff --git a/server/venv/Lib/site-packages/mysql/connector/django/features.py b/server/venv/Lib/site-packages/mysql/connector/django/features.py new file mode 100644 index 0000000..5c1dc8d --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/features.py @@ -0,0 +1,50 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Database Features.""" + +from typing import Any, List + +from django.db.backends.mysql.features import DatabaseFeatures as MySQLDatabaseFeatures +from django.utils.functional import cached_property + + +class DatabaseFeatures(MySQLDatabaseFeatures): + """Database Features Specification class.""" + + empty_fetchmany_value: List[Any] = [] + + @cached_property + def can_introspect_check_constraints(self) -> bool: # type: ignore[override] + """Check if backend support introspection CHECK of constraints.""" + return self.connection.mysql_version >= (8, 0, 16) + + @cached_property + def supports_microsecond_precision(self) -> bool: + """Check if backend support microsecond precision.""" + return self.connection.mysql_version >= (5, 6, 3) diff --git a/server/venv/Lib/site-packages/mysql/connector/django/introspection.py b/server/venv/Lib/site-packages/mysql/connector/django/introspection.py new file mode 100644 index 0000000..8c7d773 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/introspection.py @@ -0,0 +1,461 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="override,attr-defined,call-arg" + +"""Database Introspection.""" + +from collections import namedtuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple + +import sqlparse + +from django import VERSION as DJANGO_VERSION +from django.db.backends.base.introspection import ( + BaseDatabaseIntrospection, + FieldInfo as BaseFieldInfo, + TableInfo, +) +from django.db.models import Index +from django.utils.datastructures import OrderedSet + +from mysql.connector.constants import FieldType + +# from .base import CursorWrapper produces a circular import error, +# avoiding importing CursorWrapper explicitly, using a documented +# trick; write the imports inside if TYPE_CHECKING: so that they +# are not executed at runtime. +# Ref: https://buildmedia.readthedocs.org/media/pdf/mypy/stable/mypy.pdf [page 42] +if TYPE_CHECKING: + # CursorWraper is used exclusively for type hinting + from mysql.connector.django.base import CursorWrapper + +# Based on my investigation, named tuples to +# comply with mypy need to define a static list or tuple +# for field_names (second argument). In this case, the field +# names are created dynamically for FieldInfo which triggers +# a mypy error. The solution is not straightforward since +# FieldInfo attributes are Django version dependent. Code +# refactory is needed to fix this issue. +FieldInfo = namedtuple( # type: ignore[misc] + "FieldInfo", + BaseFieldInfo._fields + ("extra", "is_unsigned", "has_json_constraint"), +) +if DJANGO_VERSION < (3, 2, 0): + InfoLine = namedtuple( + "InfoLine", + "col_name data_type max_len num_prec num_scale extra column_default " + "is_unsigned", + ) +else: + InfoLine = namedtuple( # type: ignore[no-redef] + "InfoLine", + "col_name data_type max_len num_prec num_scale extra column_default " + "collation is_unsigned", + ) + + +class DatabaseIntrospection(BaseDatabaseIntrospection): + """Encapsulate backend-specific introspection utilities.""" + + data_types_reverse = { + FieldType.BLOB: "TextField", + FieldType.DECIMAL: "DecimalField", + FieldType.NEWDECIMAL: "DecimalField", + FieldType.DATE: "DateField", + FieldType.DATETIME: "DateTimeField", + FieldType.DOUBLE: "FloatField", + FieldType.FLOAT: "FloatField", + FieldType.INT24: "IntegerField", + FieldType.LONG: "IntegerField", + FieldType.LONGLONG: "BigIntegerField", + FieldType.SHORT: "SmallIntegerField", + FieldType.STRING: "CharField", + FieldType.TIME: "TimeField", + FieldType.TIMESTAMP: "DateTimeField", + FieldType.TINY: "IntegerField", + FieldType.TINY_BLOB: "TextField", + FieldType.MEDIUM_BLOB: "TextField", + FieldType.LONG_BLOB: "TextField", + FieldType.VAR_STRING: "CharField", + } + + def get_field_type(self, data_type: str, description: FieldInfo) -> str: + field_type = super().get_field_type(data_type, description) # type: ignore[arg-type] + if "auto_increment" in description.extra: + if field_type == "IntegerField": + return "AutoField" + if field_type == "BigIntegerField": + return "BigAutoField" + if field_type == "SmallIntegerField": + return "SmallAutoField" + if description.is_unsigned: + if field_type == "BigIntegerField": + return "PositiveBigIntegerField" + if field_type == "IntegerField": + return "PositiveIntegerField" + if field_type == "SmallIntegerField": + return "PositiveSmallIntegerField" + # JSON data type is an alias for LONGTEXT in MariaDB, use check + # constraints clauses to introspect JSONField. + if description.has_json_constraint: + return "JSONField" + return field_type + + def get_table_list(self, cursor: "CursorWrapper") -> List[TableInfo]: + """Return a list of table and view names in the current database.""" + cursor.execute("SHOW FULL TABLES") + return [ + TableInfo(row[0], {"BASE TABLE": "t", "VIEW": "v"}.get(row[1])) + for row in cursor.fetchall() + ] + + def get_table_description( + self, cursor: "CursorWrapper", table_name: str + ) -> List[FieldInfo]: + """ + Return a description of the table with the DB-API cursor.description + interface." + """ + json_constraints: Dict[Any, Any] = {} + # A default collation for the given table. + cursor.execute( + """ + SELECT table_collation + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = %s + """, + [table_name], + ) + row = cursor.fetchone() + default_column_collation = row[0] if row else "" + # information_schema database gives more accurate results for some figures: + # - varchar length returned by cursor.description is an internal length, + # not visible length (#5725) + # - precision and scale (for decimal fields) (#5014) + # - auto_increment is not available in cursor.description + if DJANGO_VERSION < (3, 2, 0): + cursor.execute( + """ + SELECT + column_name, data_type, character_maximum_length, + numeric_precision, numeric_scale, extra, column_default, + CASE + WHEN column_type LIKE '%% unsigned' THEN 1 + ELSE 0 + END AS is_unsigned + FROM information_schema.columns + WHERE table_name = %s AND table_schema = DATABASE() + """, + [table_name], + ) + else: + cursor.execute( + """ + SELECT + column_name, data_type, character_maximum_length, + numeric_precision, numeric_scale, extra, column_default, + CASE + WHEN collation_name = %s THEN NULL + ELSE collation_name + END AS collation_name, + CASE + WHEN column_type LIKE '%% unsigned' THEN 1 + ELSE 0 + END AS is_unsigned + FROM information_schema.columns + WHERE table_name = %s AND table_schema = DATABASE() + """, + [default_column_collation, table_name], + ) + field_info = {line[0]: InfoLine(*line) for line in cursor.fetchall()} + + cursor.execute( + f"SELECT * FROM {self.connection.ops.quote_name(table_name)} LIMIT 1" + ) + + def to_int(i: Any) -> Optional[int]: + return int(i) if i is not None else i + + fields = [] + for line in cursor.description: + info = field_info[line[0]] + if DJANGO_VERSION < (3, 2, 0): + fields.append( + FieldInfo( + *line[:3], + to_int(info.max_len) or line[3], + to_int(info.num_prec) or line[4], + to_int(info.num_scale) or line[5], + line[6], + info.column_default, + info.extra, + info.is_unsigned, + line[0] in json_constraints, + ) + ) + else: + fields.append( + FieldInfo( + *line[:3], + to_int(info.max_len) or line[3], + to_int(info.num_prec) or line[4], + to_int(info.num_scale) or line[5], + line[6], + info.column_default, + info.collation, + info.extra, + info.is_unsigned, + line[0] in json_constraints, + ) + ) + return fields + + def get_indexes( + self, cursor: "CursorWrapper", table_name: str + ) -> Dict[int, Dict[str, bool]]: + """Return indexes from table.""" + cursor.execute(f"SHOW INDEX FROM {self.connection.ops.quote_name(table_name)}") + # Do a two-pass search for indexes: on first pass check which indexes + # are multicolumn, on second pass check which single-column indexes + # are present. + rows = list(cursor.fetchall()) + multicol_indexes = set() + for row in rows: + if row[3] > 1: + multicol_indexes.add(row[2]) + indexes: Dict[int, Dict[str, bool]] = {} + for row in rows: + if row[2] in multicol_indexes: + continue + if row[4] not in indexes: + indexes[row[4]] = {"primary_key": False, "unique": False} + # It's possible to have the unique and PK constraints in + # separate indexes. + if row[2] == "PRIMARY": + indexes[row[4]]["primary_key"] = True + if not row[1]: + indexes[row[4]]["unique"] = True + return indexes + + def get_primary_key_column( + self, cursor: "CursorWrapper", table_name: str + ) -> Optional[int]: + """ + Returns the name of the primary key column for the given table + """ + for column in self.get_indexes(cursor, table_name).items(): + if column[1]["primary_key"]: + return column[0] + return None + + def get_sequences( + self, cursor: "CursorWrapper", table_name: str, table_fields: Any = () + ) -> List[Dict[str, str]]: + for field_info in self.get_table_description(cursor, table_name): + if "auto_increment" in field_info.extra: + # MySQL allows only one auto-increment column per table. + return [{"table": table_name, "column": field_info.name}] + return [] + + def get_relations( + self, cursor: "CursorWrapper", table_name: str + ) -> Dict[str, Tuple[str, str]]: + """ + Return a dictionary of {field_name: (field_name_other_table, other_table)} + representing all relationships to the given table. + """ + constraints = self.get_key_columns(cursor, table_name) + relations = {} + for my_fieldname, other_table, other_field in constraints: + relations[my_fieldname] = (other_field, other_table) + return relations + + def get_key_columns( + self, cursor: "CursorWrapper", table_name: str + ) -> List[Tuple[str, str, str]]: + """ + Return a list of (column_name, referenced_table_name, referenced_column_name) + for all key columns in the given table. + """ + key_columns: List[Any] = [] + cursor.execute( + """ + SELECT column_name, referenced_table_name, referenced_column_name + FROM information_schema.key_column_usage + WHERE table_name = %s + AND table_schema = DATABASE() + AND referenced_table_name IS NOT NULL + AND referenced_column_name IS NOT NULL""", + [table_name], + ) + key_columns.extend(cursor.fetchall()) + return key_columns + + def get_storage_engine(self, cursor: "CursorWrapper", table_name: str) -> str: + """ + Retrieve the storage engine for a given table. Return the default + storage engine if the table doesn't exist. + """ + cursor.execute( + "SELECT engine FROM information_schema.tables WHERE table_name = %s", + [table_name], + ) + result = cursor.fetchone() + # pylint: disable=protected-access + if not result: + return self.connection.features._mysql_storage_engine + # pylint: enable=protected-access + return result[0] + + def _parse_constraint_columns( + self, check_clause: Any, columns: Set[str] + ) -> OrderedSet: + check_columns: OrderedSet = OrderedSet() + statement = sqlparse.parse(check_clause)[0] + tokens = (token for token in statement.flatten() if not token.is_whitespace) + for token in tokens: + if ( + token.ttype == sqlparse.tokens.Name + and self.connection.ops.quote_name(token.value) == token.value + and token.value[1:-1] in columns + ): + check_columns.add(token.value[1:-1]) + return check_columns + + def get_constraints( + self, cursor: "CursorWrapper", table_name: str + ) -> Dict[str, Any]: + """ + Retrieve any constraints or keys (unique, pk, fk, check, index) across + one or more columns. + """ + constraints: Dict[str, Any] = {} + # Get the actual constraint names and columns + name_query = """ + SELECT kc.`constraint_name`, kc.`column_name`, + kc.`referenced_table_name`, kc.`referenced_column_name` + FROM information_schema.key_column_usage AS kc + WHERE + kc.table_schema = DATABASE() AND + kc.table_name = %s + ORDER BY kc.`ordinal_position` + """ + cursor.execute(name_query, [table_name]) + for constraint, column, ref_table, ref_column in cursor.fetchall(): + if constraint not in constraints: + constraints[constraint] = { + "columns": OrderedSet(), + "primary_key": False, + "unique": False, + "index": False, + "check": False, + "foreign_key": (ref_table, ref_column) if ref_column else None, + } + if self.connection.features.supports_index_column_ordering: + constraints[constraint]["orders"] = [] + constraints[constraint]["columns"].add(column) + # Now get the constraint types + type_query = """ + SELECT c.constraint_name, c.constraint_type + FROM information_schema.table_constraints AS c + WHERE + c.table_schema = DATABASE() AND + c.table_name = %s + """ + cursor.execute(type_query, [table_name]) + for constraint, kind in cursor.fetchall(): + if kind.lower() == "primary key": + constraints[constraint]["primary_key"] = True + constraints[constraint]["unique"] = True + elif kind.lower() == "unique": + constraints[constraint]["unique"] = True + # Add check constraints. + if self.connection.features.can_introspect_check_constraints: + unnamed_constraints_index = 0 + columns = { + info.name for info in self.get_table_description(cursor, table_name) + } + type_query = """ + SELECT cc.constraint_name, cc.check_clause + FROM + information_schema.check_constraints AS cc, + information_schema.table_constraints AS tc + WHERE + cc.constraint_schema = DATABASE() AND + tc.table_schema = cc.constraint_schema AND + cc.constraint_name = tc.constraint_name AND + tc.constraint_type = 'CHECK' AND + tc.table_name = %s + """ + cursor.execute(type_query, [table_name]) + for constraint, check_clause in cursor.fetchall(): + constraint_columns = self._parse_constraint_columns( + check_clause, columns + ) + # Ensure uniqueness of unnamed constraints. Unnamed unique + # and check columns constraints have the same name as + # a column. + if set(constraint_columns) == {constraint}: + unnamed_constraints_index += 1 + constraint = f"__unnamed_constraint_{unnamed_constraints_index}__" + constraints[constraint] = { + "columns": constraint_columns, + "primary_key": False, + "unique": False, + "index": False, + "check": True, + "foreign_key": None, + } + # Now add in the indexes + cursor.execute(f"SHOW INDEX FROM {self.connection.ops.quote_name(table_name)}") + for _, _, index, _, column, order, type_ in [ + x[:6] + (x[10],) for x in cursor.fetchall() + ]: + if index not in constraints: + constraints[index] = { + "columns": OrderedSet(), + "primary_key": False, + "unique": False, + "check": False, + "foreign_key": None, + } + if self.connection.features.supports_index_column_ordering: + constraints[index]["orders"] = [] + constraints[index]["index"] = True + constraints[index]["type"] = ( + Index.suffix if type_ == "BTREE" else type_.lower() + ) + constraints[index]["columns"].add(column) + if self.connection.features.supports_index_column_ordering: + constraints[index]["orders"].append("DESC" if order == "D" else "ASC") + # Convert the sorted sets to lists + for constraint in constraints.values(): + constraint["columns"] = list(constraint["columns"]) + return constraints diff --git a/server/venv/Lib/site-packages/mysql/connector/django/operations.py b/server/venv/Lib/site-packages/mysql/connector/django/operations.py new file mode 100644 index 0000000..8491181 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/operations.py @@ -0,0 +1,104 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="override,attr-defined" + +"""Database Operations.""" + +from datetime import datetime, time, timezone +from typing import Optional + +from django.conf import settings +from django.db.backends.mysql.operations import ( + DatabaseOperations as MySQLDatabaseOperations, +) +from django.utils import timezone as django_timezone + +try: + from _mysql_connector import datetime_to_mysql, time_to_mysql +except ImportError: + HAVE_CEXT = False +else: + HAVE_CEXT = True + + +class DatabaseOperations(MySQLDatabaseOperations): + """Database Operations class.""" + + compiler_module = "mysql.connector.django.compiler" + + def regex_lookup(self, lookup_type: str) -> str: + """Return the string to use in a query when performing regular + expression lookup.""" + if self.connection.mysql_version < (8, 0, 0): + if lookup_type == "regex": + return "%s REGEXP BINARY %s" + return "%s REGEXP %s" + + match_option = "c" if lookup_type == "regex" else "i" + return f"REGEXP_LIKE(%s, %s, '{match_option}')" + + def adapt_datetimefield_value(self, value: Optional[datetime]) -> Optional[bytes]: + """Transform a datetime value to an object compatible with what is + expected by the backend driver for datetime columns.""" + return self.value_to_db_datetime(value) + + def value_to_db_datetime(self, value: Optional[datetime]) -> Optional[bytes]: + """Convert value to MySQL DATETIME.""" + ans: Optional[bytes] = None + if value is None: + return ans + # MySQL doesn't support tz-aware times + if django_timezone.is_aware(value): + if settings.USE_TZ: + value = value.astimezone(timezone.utc).replace(tzinfo=None) + else: + raise ValueError("MySQL backend does not support timezone-aware times") + if not self.connection.features.supports_microsecond_precision: + value = value.replace(microsecond=0) + if not self.connection.use_pure: + return datetime_to_mysql(value) + return self.connection.converter.to_mysql(value) + + def adapt_timefield_value(self, value: Optional[time]) -> Optional[bytes]: + """Transform a time value to an object compatible with what is expected + by the backend driver for time columns.""" + return self.value_to_db_time(value) + + def value_to_db_time(self, value: Optional[time]) -> Optional[bytes]: + """Convert value to MySQL TIME.""" + if value is None: + return None + + # MySQL doesn't support tz-aware times + if django_timezone.is_aware(value): + raise ValueError("MySQL backend does not support timezone-aware times") + + if not self.connection.use_pure: + return time_to_mysql(value) + return self.connection.converter.to_mysql(value) diff --git a/server/venv/Lib/site-packages/mysql/connector/django/schema.py b/server/venv/Lib/site-packages/mysql/connector/django/schema.py new file mode 100644 index 0000000..59808df --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/schema.py @@ -0,0 +1,59 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="override" + +"""Database schema editor.""" +from typing import Any + +from django.db.backends.mysql.schema import ( + DatabaseSchemaEditor as MySQLDatabaseSchemaEditor, +) + + +class DatabaseSchemaEditor(MySQLDatabaseSchemaEditor): + """This class is responsible for emitting schema-changing statements to the + databases. + """ + + def quote_value(self, value: Any) -> Any: + """Quote value.""" + self.connection.ensure_connection() + if isinstance(value, str): + value = value.replace("%", "%%") + quoted = self.connection.connection.converter.escape(value) + if isinstance(value, str) and isinstance(quoted, bytes): + quoted = quoted.decode() + return quoted + + def prepare_default(self, value: Any) -> Any: + """Implement the required abstract method. + + MySQL has requires_literal_defaults=False, therefore return the value. + """ + return value diff --git a/server/venv/Lib/site-packages/mysql/connector/django/validation.py b/server/venv/Lib/site-packages/mysql/connector/django/validation.py new file mode 100644 index 0000000..98e62d3 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/django/validation.py @@ -0,0 +1,33 @@ +# Copyright (c) 2020, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Backend specific database validation.""" + +from django.db.backends.mysql.validation import DatabaseValidation + +__all__ = ["DatabaseValidation"] diff --git a/server/venv/Lib/site-packages/mysql/connector/errorcode.py b/server/venv/Lib/site-packages/mysql/connector/errorcode.py new file mode 100644 index 0000000..1660ef4 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/errorcode.py @@ -0,0 +1,1877 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2013, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""This module contains the MySQL Server and Client error codes.""" + +# This file was auto-generated. +_GENERATED_ON = "2021-08-11" +_MYSQL_VERSION = (8, 0, 27) + +# Start MySQL Errors +OBSOLETE_ER_HASHCHK = 1000 +OBSOLETE_ER_NISAMCHK = 1001 +ER_NO = 1002 +ER_YES = 1003 +ER_CANT_CREATE_FILE = 1004 +ER_CANT_CREATE_TABLE = 1005 +ER_CANT_CREATE_DB = 1006 +ER_DB_CREATE_EXISTS = 1007 +ER_DB_DROP_EXISTS = 1008 +OBSOLETE_ER_DB_DROP_DELETE = 1009 +ER_DB_DROP_RMDIR = 1010 +OBSOLETE_ER_CANT_DELETE_FILE = 1011 +ER_CANT_FIND_SYSTEM_REC = 1012 +ER_CANT_GET_STAT = 1013 +OBSOLETE_ER_CANT_GET_WD = 1014 +ER_CANT_LOCK = 1015 +ER_CANT_OPEN_FILE = 1016 +ER_FILE_NOT_FOUND = 1017 +ER_CANT_READ_DIR = 1018 +OBSOLETE_ER_CANT_SET_WD = 1019 +ER_CHECKREAD = 1020 +OBSOLETE_ER_DISK_FULL = 1021 +ER_DUP_KEY = 1022 +OBSOLETE_ER_ERROR_ON_CLOSE = 1023 +ER_ERROR_ON_READ = 1024 +ER_ERROR_ON_RENAME = 1025 +ER_ERROR_ON_WRITE = 1026 +ER_FILE_USED = 1027 +OBSOLETE_ER_FILSORT_ABORT = 1028 +OBSOLETE_ER_FORM_NOT_FOUND = 1029 +ER_GET_ERRNO = 1030 +ER_ILLEGAL_HA = 1031 +ER_KEY_NOT_FOUND = 1032 +ER_NOT_FORM_FILE = 1033 +ER_NOT_KEYFILE = 1034 +ER_OLD_KEYFILE = 1035 +ER_OPEN_AS_READONLY = 1036 +ER_OUTOFMEMORY = 1037 +ER_OUT_OF_SORTMEMORY = 1038 +OBSOLETE_ER_UNEXPECTED_EOF = 1039 +ER_CON_COUNT_ERROR = 1040 +ER_OUT_OF_RESOURCES = 1041 +ER_BAD_HOST_ERROR = 1042 +ER_HANDSHAKE_ERROR = 1043 +ER_DBACCESS_DENIED_ERROR = 1044 +ER_ACCESS_DENIED_ERROR = 1045 +ER_NO_DB_ERROR = 1046 +ER_UNKNOWN_COM_ERROR = 1047 +ER_BAD_NULL_ERROR = 1048 +ER_BAD_DB_ERROR = 1049 +ER_TABLE_EXISTS_ERROR = 1050 +ER_BAD_TABLE_ERROR = 1051 +ER_NON_UNIQ_ERROR = 1052 +ER_SERVER_SHUTDOWN = 1053 +ER_BAD_FIELD_ERROR = 1054 +ER_WRONG_FIELD_WITH_GROUP = 1055 +ER_WRONG_GROUP_FIELD = 1056 +ER_WRONG_SUM_SELECT = 1057 +ER_WRONG_VALUE_COUNT = 1058 +ER_TOO_LONG_IDENT = 1059 +ER_DUP_FIELDNAME = 1060 +ER_DUP_KEYNAME = 1061 +ER_DUP_ENTRY = 1062 +ER_WRONG_FIELD_SPEC = 1063 +ER_PARSE_ERROR = 1064 +ER_EMPTY_QUERY = 1065 +ER_NONUNIQ_TABLE = 1066 +ER_INVALID_DEFAULT = 1067 +ER_MULTIPLE_PRI_KEY = 1068 +ER_TOO_MANY_KEYS = 1069 +ER_TOO_MANY_KEY_PARTS = 1070 +ER_TOO_LONG_KEY = 1071 +ER_KEY_COLUMN_DOES_NOT_EXITS = 1072 +ER_BLOB_USED_AS_KEY = 1073 +ER_TOO_BIG_FIELDLENGTH = 1074 +ER_WRONG_AUTO_KEY = 1075 +ER_READY = 1076 +OBSOLETE_ER_NORMAL_SHUTDOWN = 1077 +OBSOLETE_ER_GOT_SIGNAL = 1078 +ER_SHUTDOWN_COMPLETE = 1079 +ER_FORCING_CLOSE = 1080 +ER_IPSOCK_ERROR = 1081 +ER_NO_SUCH_INDEX = 1082 +ER_WRONG_FIELD_TERMINATORS = 1083 +ER_BLOBS_AND_NO_TERMINATED = 1084 +ER_TEXTFILE_NOT_READABLE = 1085 +ER_FILE_EXISTS_ERROR = 1086 +ER_LOAD_INFO = 1087 +ER_ALTER_INFO = 1088 +ER_WRONG_SUB_KEY = 1089 +ER_CANT_REMOVE_ALL_FIELDS = 1090 +ER_CANT_DROP_FIELD_OR_KEY = 1091 +ER_INSERT_INFO = 1092 +ER_UPDATE_TABLE_USED = 1093 +ER_NO_SUCH_THREAD = 1094 +ER_KILL_DENIED_ERROR = 1095 +ER_NO_TABLES_USED = 1096 +ER_TOO_BIG_SET = 1097 +ER_NO_UNIQUE_LOGFILE = 1098 +ER_TABLE_NOT_LOCKED_FOR_WRITE = 1099 +ER_TABLE_NOT_LOCKED = 1100 +ER_BLOB_CANT_HAVE_DEFAULT = 1101 +ER_WRONG_DB_NAME = 1102 +ER_WRONG_TABLE_NAME = 1103 +ER_TOO_BIG_SELECT = 1104 +ER_UNKNOWN_ERROR = 1105 +ER_UNKNOWN_PROCEDURE = 1106 +ER_WRONG_PARAMCOUNT_TO_PROCEDURE = 1107 +ER_WRONG_PARAMETERS_TO_PROCEDURE = 1108 +ER_UNKNOWN_TABLE = 1109 +ER_FIELD_SPECIFIED_TWICE = 1110 +ER_INVALID_GROUP_FUNC_USE = 1111 +ER_UNSUPPORTED_EXTENSION = 1112 +ER_TABLE_MUST_HAVE_COLUMNS = 1113 +ER_RECORD_FILE_FULL = 1114 +ER_UNKNOWN_CHARACTER_SET = 1115 +ER_TOO_MANY_TABLES = 1116 +ER_TOO_MANY_FIELDS = 1117 +ER_TOO_BIG_ROWSIZE = 1118 +ER_STACK_OVERRUN = 1119 +ER_WRONG_OUTER_JOIN_UNUSED = 1120 +ER_NULL_COLUMN_IN_INDEX = 1121 +ER_CANT_FIND_UDF = 1122 +ER_CANT_INITIALIZE_UDF = 1123 +ER_UDF_NO_PATHS = 1124 +ER_UDF_EXISTS = 1125 +ER_CANT_OPEN_LIBRARY = 1126 +ER_CANT_FIND_DL_ENTRY = 1127 +ER_FUNCTION_NOT_DEFINED = 1128 +ER_HOST_IS_BLOCKED = 1129 +ER_HOST_NOT_PRIVILEGED = 1130 +ER_PASSWORD_ANONYMOUS_USER = 1131 +ER_PASSWORD_NOT_ALLOWED = 1132 +ER_PASSWORD_NO_MATCH = 1133 +ER_UPDATE_INFO = 1134 +ER_CANT_CREATE_THREAD = 1135 +ER_WRONG_VALUE_COUNT_ON_ROW = 1136 +ER_CANT_REOPEN_TABLE = 1137 +ER_INVALID_USE_OF_NULL = 1138 +ER_REGEXP_ERROR = 1139 +ER_MIX_OF_GROUP_FUNC_AND_FIELDS = 1140 +ER_NONEXISTING_GRANT = 1141 +ER_TABLEACCESS_DENIED_ERROR = 1142 +ER_COLUMNACCESS_DENIED_ERROR = 1143 +ER_ILLEGAL_GRANT_FOR_TABLE = 1144 +ER_GRANT_WRONG_HOST_OR_USER = 1145 +ER_NO_SUCH_TABLE = 1146 +ER_NONEXISTING_TABLE_GRANT = 1147 +ER_NOT_ALLOWED_COMMAND = 1148 +ER_SYNTAX_ERROR = 1149 +OBSOLETE_ER_UNUSED1 = 1150 +OBSOLETE_ER_UNUSED2 = 1151 +ER_ABORTING_CONNECTION = 1152 +ER_NET_PACKET_TOO_LARGE = 1153 +ER_NET_READ_ERROR_FROM_PIPE = 1154 +ER_NET_FCNTL_ERROR = 1155 +ER_NET_PACKETS_OUT_OF_ORDER = 1156 +ER_NET_UNCOMPRESS_ERROR = 1157 +ER_NET_READ_ERROR = 1158 +ER_NET_READ_INTERRUPTED = 1159 +ER_NET_ERROR_ON_WRITE = 1160 +ER_NET_WRITE_INTERRUPTED = 1161 +ER_TOO_LONG_STRING = 1162 +ER_TABLE_CANT_HANDLE_BLOB = 1163 +ER_TABLE_CANT_HANDLE_AUTO_INCREMENT = 1164 +OBSOLETE_ER_UNUSED3 = 1165 +ER_WRONG_COLUMN_NAME = 1166 +ER_WRONG_KEY_COLUMN = 1167 +ER_WRONG_MRG_TABLE = 1168 +ER_DUP_UNIQUE = 1169 +ER_BLOB_KEY_WITHOUT_LENGTH = 1170 +ER_PRIMARY_CANT_HAVE_NULL = 1171 +ER_TOO_MANY_ROWS = 1172 +ER_REQUIRES_PRIMARY_KEY = 1173 +OBSOLETE_ER_NO_RAID_COMPILED = 1174 +ER_UPDATE_WITHOUT_KEY_IN_SAFE_MODE = 1175 +ER_KEY_DOES_NOT_EXITS = 1176 +ER_CHECK_NO_SUCH_TABLE = 1177 +ER_CHECK_NOT_IMPLEMENTED = 1178 +ER_CANT_DO_THIS_DURING_AN_TRANSACTION = 1179 +ER_ERROR_DURING_COMMIT = 1180 +ER_ERROR_DURING_ROLLBACK = 1181 +ER_ERROR_DURING_FLUSH_LOGS = 1182 +OBSOLETE_ER_ERROR_DURING_CHECKPOINT = 1183 +ER_NEW_ABORTING_CONNECTION = 1184 +OBSOLETE_ER_DUMP_NOT_IMPLEMENTED = 1185 +OBSOLETE_ER_FLUSH_MASTER_BINLOG_CLOSED = 1186 +OBSOLETE_ER_INDEX_REBUILD = 1187 +ER_MASTER = 1188 +ER_MASTER_NET_READ = 1189 +ER_MASTER_NET_WRITE = 1190 +ER_FT_MATCHING_KEY_NOT_FOUND = 1191 +ER_LOCK_OR_ACTIVE_TRANSACTION = 1192 +ER_UNKNOWN_SYSTEM_VARIABLE = 1193 +ER_CRASHED_ON_USAGE = 1194 +ER_CRASHED_ON_REPAIR = 1195 +ER_WARNING_NOT_COMPLETE_ROLLBACK = 1196 +ER_TRANS_CACHE_FULL = 1197 +OBSOLETE_ER_SLAVE_MUST_STOP = 1198 +ER_SLAVE_NOT_RUNNING = 1199 +ER_BAD_SLAVE = 1200 +ER_MASTER_INFO = 1201 +ER_SLAVE_THREAD = 1202 +ER_TOO_MANY_USER_CONNECTIONS = 1203 +ER_SET_CONSTANTS_ONLY = 1204 +ER_LOCK_WAIT_TIMEOUT = 1205 +ER_LOCK_TABLE_FULL = 1206 +ER_READ_ONLY_TRANSACTION = 1207 +OBSOLETE_ER_DROP_DB_WITH_READ_LOCK = 1208 +OBSOLETE_ER_CREATE_DB_WITH_READ_LOCK = 1209 +ER_WRONG_ARGUMENTS = 1210 +ER_NO_PERMISSION_TO_CREATE_USER = 1211 +OBSOLETE_ER_UNION_TABLES_IN_DIFFERENT_DIR = 1212 +ER_LOCK_DEADLOCK = 1213 +ER_TABLE_CANT_HANDLE_FT = 1214 +ER_CANNOT_ADD_FOREIGN = 1215 +ER_NO_REFERENCED_ROW = 1216 +ER_ROW_IS_REFERENCED = 1217 +ER_CONNECT_TO_MASTER = 1218 +OBSOLETE_ER_QUERY_ON_MASTER = 1219 +ER_ERROR_WHEN_EXECUTING_COMMAND = 1220 +ER_WRONG_USAGE = 1221 +ER_WRONG_NUMBER_OF_COLUMNS_IN_SELECT = 1222 +ER_CANT_UPDATE_WITH_READLOCK = 1223 +ER_MIXING_NOT_ALLOWED = 1224 +ER_DUP_ARGUMENT = 1225 +ER_USER_LIMIT_REACHED = 1226 +ER_SPECIFIC_ACCESS_DENIED_ERROR = 1227 +ER_LOCAL_VARIABLE = 1228 +ER_GLOBAL_VARIABLE = 1229 +ER_NO_DEFAULT = 1230 +ER_WRONG_VALUE_FOR_VAR = 1231 +ER_WRONG_TYPE_FOR_VAR = 1232 +ER_VAR_CANT_BE_READ = 1233 +ER_CANT_USE_OPTION_HERE = 1234 +ER_NOT_SUPPORTED_YET = 1235 +ER_MASTER_FATAL_ERROR_READING_BINLOG = 1236 +ER_SLAVE_IGNORED_TABLE = 1237 +ER_INCORRECT_GLOBAL_LOCAL_VAR = 1238 +ER_WRONG_FK_DEF = 1239 +ER_KEY_REF_DO_NOT_MATCH_TABLE_REF = 1240 +ER_OPERAND_COLUMNS = 1241 +ER_SUBQUERY_NO_1_ROW = 1242 +ER_UNKNOWN_STMT_HANDLER = 1243 +ER_CORRUPT_HELP_DB = 1244 +OBSOLETE_ER_CYCLIC_REFERENCE = 1245 +ER_AUTO_CONVERT = 1246 +ER_ILLEGAL_REFERENCE = 1247 +ER_DERIVED_MUST_HAVE_ALIAS = 1248 +ER_SELECT_REDUCED = 1249 +ER_TABLENAME_NOT_ALLOWED_HERE = 1250 +ER_NOT_SUPPORTED_AUTH_MODE = 1251 +ER_SPATIAL_CANT_HAVE_NULL = 1252 +ER_COLLATION_CHARSET_MISMATCH = 1253 +OBSOLETE_ER_SLAVE_WAS_RUNNING = 1254 +OBSOLETE_ER_SLAVE_WAS_NOT_RUNNING = 1255 +ER_TOO_BIG_FOR_UNCOMPRESS = 1256 +ER_ZLIB_Z_MEM_ERROR = 1257 +ER_ZLIB_Z_BUF_ERROR = 1258 +ER_ZLIB_Z_DATA_ERROR = 1259 +ER_CUT_VALUE_GROUP_CONCAT = 1260 +ER_WARN_TOO_FEW_RECORDS = 1261 +ER_WARN_TOO_MANY_RECORDS = 1262 +ER_WARN_NULL_TO_NOTNULL = 1263 +ER_WARN_DATA_OUT_OF_RANGE = 1264 +WARN_DATA_TRUNCATED = 1265 +ER_WARN_USING_OTHER_HANDLER = 1266 +ER_CANT_AGGREGATE_2COLLATIONS = 1267 +OBSOLETE_ER_DROP_USER = 1268 +ER_REVOKE_GRANTS = 1269 +ER_CANT_AGGREGATE_3COLLATIONS = 1270 +ER_CANT_AGGREGATE_NCOLLATIONS = 1271 +ER_VARIABLE_IS_NOT_STRUCT = 1272 +ER_UNKNOWN_COLLATION = 1273 +ER_SLAVE_IGNORED_SSL_PARAMS = 1274 +OBSOLETE_ER_SERVER_IS_IN_SECURE_AUTH_MODE = 1275 +ER_WARN_FIELD_RESOLVED = 1276 +ER_BAD_SLAVE_UNTIL_COND = 1277 +ER_MISSING_SKIP_SLAVE = 1278 +ER_UNTIL_COND_IGNORED = 1279 +ER_WRONG_NAME_FOR_INDEX = 1280 +ER_WRONG_NAME_FOR_CATALOG = 1281 +OBSOLETE_ER_WARN_QC_RESIZE = 1282 +ER_BAD_FT_COLUMN = 1283 +ER_UNKNOWN_KEY_CACHE = 1284 +ER_WARN_HOSTNAME_WONT_WORK = 1285 +ER_UNKNOWN_STORAGE_ENGINE = 1286 +ER_WARN_DEPRECATED_SYNTAX = 1287 +ER_NON_UPDATABLE_TABLE = 1288 +ER_FEATURE_DISABLED = 1289 +ER_OPTION_PREVENTS_STATEMENT = 1290 +ER_DUPLICATED_VALUE_IN_TYPE = 1291 +ER_TRUNCATED_WRONG_VALUE = 1292 +OBSOLETE_ER_TOO_MUCH_AUTO_TIMESTAMP_COLS = 1293 +ER_INVALID_ON_UPDATE = 1294 +ER_UNSUPPORTED_PS = 1295 +ER_GET_ERRMSG = 1296 +ER_GET_TEMPORARY_ERRMSG = 1297 +ER_UNKNOWN_TIME_ZONE = 1298 +ER_WARN_INVALID_TIMESTAMP = 1299 +ER_INVALID_CHARACTER_STRING = 1300 +ER_WARN_ALLOWED_PACKET_OVERFLOWED = 1301 +ER_CONFLICTING_DECLARATIONS = 1302 +ER_SP_NO_RECURSIVE_CREATE = 1303 +ER_SP_ALREADY_EXISTS = 1304 +ER_SP_DOES_NOT_EXIST = 1305 +ER_SP_DROP_FAILED = 1306 +ER_SP_STORE_FAILED = 1307 +ER_SP_LILABEL_MISMATCH = 1308 +ER_SP_LABEL_REDEFINE = 1309 +ER_SP_LABEL_MISMATCH = 1310 +ER_SP_UNINIT_VAR = 1311 +ER_SP_BADSELECT = 1312 +ER_SP_BADRETURN = 1313 +ER_SP_BADSTATEMENT = 1314 +ER_UPDATE_LOG_DEPRECATED_IGNORED = 1315 +ER_UPDATE_LOG_DEPRECATED_TRANSLATED = 1316 +ER_QUERY_INTERRUPTED = 1317 +ER_SP_WRONG_NO_OF_ARGS = 1318 +ER_SP_COND_MISMATCH = 1319 +ER_SP_NORETURN = 1320 +ER_SP_NORETURNEND = 1321 +ER_SP_BAD_CURSOR_QUERY = 1322 +ER_SP_BAD_CURSOR_SELECT = 1323 +ER_SP_CURSOR_MISMATCH = 1324 +ER_SP_CURSOR_ALREADY_OPEN = 1325 +ER_SP_CURSOR_NOT_OPEN = 1326 +ER_SP_UNDECLARED_VAR = 1327 +ER_SP_WRONG_NO_OF_FETCH_ARGS = 1328 +ER_SP_FETCH_NO_DATA = 1329 +ER_SP_DUP_PARAM = 1330 +ER_SP_DUP_VAR = 1331 +ER_SP_DUP_COND = 1332 +ER_SP_DUP_CURS = 1333 +ER_SP_CANT_ALTER = 1334 +ER_SP_SUBSELECT_NYI = 1335 +ER_STMT_NOT_ALLOWED_IN_SF_OR_TRG = 1336 +ER_SP_VARCOND_AFTER_CURSHNDLR = 1337 +ER_SP_CURSOR_AFTER_HANDLER = 1338 +ER_SP_CASE_NOT_FOUND = 1339 +ER_FPARSER_TOO_BIG_FILE = 1340 +ER_FPARSER_BAD_HEADER = 1341 +ER_FPARSER_EOF_IN_COMMENT = 1342 +ER_FPARSER_ERROR_IN_PARAMETER = 1343 +ER_FPARSER_EOF_IN_UNKNOWN_PARAMETER = 1344 +ER_VIEW_NO_EXPLAIN = 1345 +OBSOLETE_ER_FRM_UNKNOWN_TYPE = 1346 +ER_WRONG_OBJECT = 1347 +ER_NONUPDATEABLE_COLUMN = 1348 +OBSOLETE_ER_VIEW_SELECT_DERIVED_UNUSED = 1349 +ER_VIEW_SELECT_CLAUSE = 1350 +ER_VIEW_SELECT_VARIABLE = 1351 +ER_VIEW_SELECT_TMPTABLE = 1352 +ER_VIEW_WRONG_LIST = 1353 +ER_WARN_VIEW_MERGE = 1354 +ER_WARN_VIEW_WITHOUT_KEY = 1355 +ER_VIEW_INVALID = 1356 +ER_SP_NO_DROP_SP = 1357 +OBSOLETE_ER_SP_GOTO_IN_HNDLR = 1358 +ER_TRG_ALREADY_EXISTS = 1359 +ER_TRG_DOES_NOT_EXIST = 1360 +ER_TRG_ON_VIEW_OR_TEMP_TABLE = 1361 +ER_TRG_CANT_CHANGE_ROW = 1362 +ER_TRG_NO_SUCH_ROW_IN_TRG = 1363 +ER_NO_DEFAULT_FOR_FIELD = 1364 +ER_DIVISION_BY_ZERO = 1365 +ER_TRUNCATED_WRONG_VALUE_FOR_FIELD = 1366 +ER_ILLEGAL_VALUE_FOR_TYPE = 1367 +ER_VIEW_NONUPD_CHECK = 1368 +ER_VIEW_CHECK_FAILED = 1369 +ER_PROCACCESS_DENIED_ERROR = 1370 +ER_RELAY_LOG_FAIL = 1371 +OBSOLETE_ER_PASSWD_LENGTH = 1372 +ER_UNKNOWN_TARGET_BINLOG = 1373 +ER_IO_ERR_LOG_INDEX_READ = 1374 +ER_BINLOG_PURGE_PROHIBITED = 1375 +ER_FSEEK_FAIL = 1376 +ER_BINLOG_PURGE_FATAL_ERR = 1377 +ER_LOG_IN_USE = 1378 +ER_LOG_PURGE_UNKNOWN_ERR = 1379 +ER_RELAY_LOG_INIT = 1380 +ER_NO_BINARY_LOGGING = 1381 +ER_RESERVED_SYNTAX = 1382 +OBSOLETE_ER_WSAS_FAILED = 1383 +OBSOLETE_ER_DIFF_GROUPS_PROC = 1384 +OBSOLETE_ER_NO_GROUP_FOR_PROC = 1385 +OBSOLETE_ER_ORDER_WITH_PROC = 1386 +OBSOLETE_ER_LOGGING_PROHIBIT_CHANGING_OF = 1387 +OBSOLETE_ER_NO_FILE_MAPPING = 1388 +OBSOLETE_ER_WRONG_MAGIC = 1389 +ER_PS_MANY_PARAM = 1390 +ER_KEY_PART_0 = 1391 +ER_VIEW_CHECKSUM = 1392 +ER_VIEW_MULTIUPDATE = 1393 +ER_VIEW_NO_INSERT_FIELD_LIST = 1394 +ER_VIEW_DELETE_MERGE_VIEW = 1395 +ER_CANNOT_USER = 1396 +ER_XAER_NOTA = 1397 +ER_XAER_INVAL = 1398 +ER_XAER_RMFAIL = 1399 +ER_XAER_OUTSIDE = 1400 +ER_XAER_RMERR = 1401 +ER_XA_RBROLLBACK = 1402 +ER_NONEXISTING_PROC_GRANT = 1403 +ER_PROC_AUTO_GRANT_FAIL = 1404 +ER_PROC_AUTO_REVOKE_FAIL = 1405 +ER_DATA_TOO_LONG = 1406 +ER_SP_BAD_SQLSTATE = 1407 +ER_STARTUP = 1408 +ER_LOAD_FROM_FIXED_SIZE_ROWS_TO_VAR = 1409 +ER_CANT_CREATE_USER_WITH_GRANT = 1410 +ER_WRONG_VALUE_FOR_TYPE = 1411 +ER_TABLE_DEF_CHANGED = 1412 +ER_SP_DUP_HANDLER = 1413 +ER_SP_NOT_VAR_ARG = 1414 +ER_SP_NO_RETSET = 1415 +ER_CANT_CREATE_GEOMETRY_OBJECT = 1416 +OBSOLETE_ER_FAILED_ROUTINE_BREAK_BINLOG = 1417 +ER_BINLOG_UNSAFE_ROUTINE = 1418 +ER_BINLOG_CREATE_ROUTINE_NEED_SUPER = 1419 +OBSOLETE_ER_EXEC_STMT_WITH_OPEN_CURSOR = 1420 +ER_STMT_HAS_NO_OPEN_CURSOR = 1421 +ER_COMMIT_NOT_ALLOWED_IN_SF_OR_TRG = 1422 +ER_NO_DEFAULT_FOR_VIEW_FIELD = 1423 +ER_SP_NO_RECURSION = 1424 +ER_TOO_BIG_SCALE = 1425 +ER_TOO_BIG_PRECISION = 1426 +ER_M_BIGGER_THAN_D = 1427 +ER_WRONG_LOCK_OF_SYSTEM_TABLE = 1428 +ER_CONNECT_TO_FOREIGN_DATA_SOURCE = 1429 +ER_QUERY_ON_FOREIGN_DATA_SOURCE = 1430 +ER_FOREIGN_DATA_SOURCE_DOESNT_EXIST = 1431 +ER_FOREIGN_DATA_STRING_INVALID_CANT_CREATE = 1432 +ER_FOREIGN_DATA_STRING_INVALID = 1433 +OBSOLETE_ER_CANT_CREATE_FEDERATED_TABLE = 1434 +ER_TRG_IN_WRONG_SCHEMA = 1435 +ER_STACK_OVERRUN_NEED_MORE = 1436 +ER_TOO_LONG_BODY = 1437 +ER_WARN_CANT_DROP_DEFAULT_KEYCACHE = 1438 +ER_TOO_BIG_DISPLAYWIDTH = 1439 +ER_XAER_DUPID = 1440 +ER_DATETIME_FUNCTION_OVERFLOW = 1441 +ER_CANT_UPDATE_USED_TABLE_IN_SF_OR_TRG = 1442 +ER_VIEW_PREVENT_UPDATE = 1443 +ER_PS_NO_RECURSION = 1444 +ER_SP_CANT_SET_AUTOCOMMIT = 1445 +OBSOLETE_ER_MALFORMED_DEFINER = 1446 +ER_VIEW_FRM_NO_USER = 1447 +ER_VIEW_OTHER_USER = 1448 +ER_NO_SUCH_USER = 1449 +ER_FORBID_SCHEMA_CHANGE = 1450 +ER_ROW_IS_REFERENCED_2 = 1451 +ER_NO_REFERENCED_ROW_2 = 1452 +ER_SP_BAD_VAR_SHADOW = 1453 +ER_TRG_NO_DEFINER = 1454 +ER_OLD_FILE_FORMAT = 1455 +ER_SP_RECURSION_LIMIT = 1456 +OBSOLETE_ER_SP_PROC_TABLE_CORRUPT = 1457 +ER_SP_WRONG_NAME = 1458 +ER_TABLE_NEEDS_UPGRADE = 1459 +ER_SP_NO_AGGREGATE = 1460 +ER_MAX_PREPARED_STMT_COUNT_REACHED = 1461 +ER_VIEW_RECURSIVE = 1462 +ER_NON_GROUPING_FIELD_USED = 1463 +ER_TABLE_CANT_HANDLE_SPKEYS = 1464 +ER_NO_TRIGGERS_ON_SYSTEM_SCHEMA = 1465 +ER_REMOVED_SPACES = 1466 +ER_AUTOINC_READ_FAILED = 1467 +ER_USERNAME = 1468 +ER_HOSTNAME = 1469 +ER_WRONG_STRING_LENGTH = 1470 +ER_NON_INSERTABLE_TABLE = 1471 +ER_ADMIN_WRONG_MRG_TABLE = 1472 +ER_TOO_HIGH_LEVEL_OF_NESTING_FOR_SELECT = 1473 +ER_NAME_BECOMES_EMPTY = 1474 +ER_AMBIGUOUS_FIELD_TERM = 1475 +ER_FOREIGN_SERVER_EXISTS = 1476 +ER_FOREIGN_SERVER_DOESNT_EXIST = 1477 +ER_ILLEGAL_HA_CREATE_OPTION = 1478 +ER_PARTITION_REQUIRES_VALUES_ERROR = 1479 +ER_PARTITION_WRONG_VALUES_ERROR = 1480 +ER_PARTITION_MAXVALUE_ERROR = 1481 +OBSOLETE_ER_PARTITION_SUBPARTITION_ERROR = 1482 +OBSOLETE_ER_PARTITION_SUBPART_MIX_ERROR = 1483 +ER_PARTITION_WRONG_NO_PART_ERROR = 1484 +ER_PARTITION_WRONG_NO_SUBPART_ERROR = 1485 +ER_WRONG_EXPR_IN_PARTITION_FUNC_ERROR = 1486 +OBSOLETE_ER_NO_CONST_EXPR_IN_RANGE_OR_LIST_ERROR = 1487 +ER_FIELD_NOT_FOUND_PART_ERROR = 1488 +OBSOLETE_ER_LIST_OF_FIELDS_ONLY_IN_HASH_ERROR = 1489 +ER_INCONSISTENT_PARTITION_INFO_ERROR = 1490 +ER_PARTITION_FUNC_NOT_ALLOWED_ERROR = 1491 +ER_PARTITIONS_MUST_BE_DEFINED_ERROR = 1492 +ER_RANGE_NOT_INCREASING_ERROR = 1493 +ER_INCONSISTENT_TYPE_OF_FUNCTIONS_ERROR = 1494 +ER_MULTIPLE_DEF_CONST_IN_LIST_PART_ERROR = 1495 +ER_PARTITION_ENTRY_ERROR = 1496 +ER_MIX_HANDLER_ERROR = 1497 +ER_PARTITION_NOT_DEFINED_ERROR = 1498 +ER_TOO_MANY_PARTITIONS_ERROR = 1499 +ER_SUBPARTITION_ERROR = 1500 +ER_CANT_CREATE_HANDLER_FILE = 1501 +ER_BLOB_FIELD_IN_PART_FUNC_ERROR = 1502 +ER_UNIQUE_KEY_NEED_ALL_FIELDS_IN_PF = 1503 +ER_NO_PARTS_ERROR = 1504 +ER_PARTITION_MGMT_ON_NONPARTITIONED = 1505 +ER_FOREIGN_KEY_ON_PARTITIONED = 1506 +ER_DROP_PARTITION_NON_EXISTENT = 1507 +ER_DROP_LAST_PARTITION = 1508 +ER_COALESCE_ONLY_ON_HASH_PARTITION = 1509 +ER_REORG_HASH_ONLY_ON_SAME_NO = 1510 +ER_REORG_NO_PARAM_ERROR = 1511 +ER_ONLY_ON_RANGE_LIST_PARTITION = 1512 +ER_ADD_PARTITION_SUBPART_ERROR = 1513 +ER_ADD_PARTITION_NO_NEW_PARTITION = 1514 +ER_COALESCE_PARTITION_NO_PARTITION = 1515 +ER_REORG_PARTITION_NOT_EXIST = 1516 +ER_SAME_NAME_PARTITION = 1517 +ER_NO_BINLOG_ERROR = 1518 +ER_CONSECUTIVE_REORG_PARTITIONS = 1519 +ER_REORG_OUTSIDE_RANGE = 1520 +ER_PARTITION_FUNCTION_FAILURE = 1521 +OBSOLETE_ER_PART_STATE_ERROR = 1522 +ER_LIMITED_PART_RANGE = 1523 +ER_PLUGIN_IS_NOT_LOADED = 1524 +ER_WRONG_VALUE = 1525 +ER_NO_PARTITION_FOR_GIVEN_VALUE = 1526 +ER_FILEGROUP_OPTION_ONLY_ONCE = 1527 +ER_CREATE_FILEGROUP_FAILED = 1528 +ER_DROP_FILEGROUP_FAILED = 1529 +ER_TABLESPACE_AUTO_EXTEND_ERROR = 1530 +ER_WRONG_SIZE_NUMBER = 1531 +ER_SIZE_OVERFLOW_ERROR = 1532 +ER_ALTER_FILEGROUP_FAILED = 1533 +ER_BINLOG_ROW_LOGGING_FAILED = 1534 +OBSOLETE_ER_BINLOG_ROW_WRONG_TABLE_DEF = 1535 +OBSOLETE_ER_BINLOG_ROW_RBR_TO_SBR = 1536 +ER_EVENT_ALREADY_EXISTS = 1537 +OBSOLETE_ER_EVENT_STORE_FAILED = 1538 +ER_EVENT_DOES_NOT_EXIST = 1539 +OBSOLETE_ER_EVENT_CANT_ALTER = 1540 +OBSOLETE_ER_EVENT_DROP_FAILED = 1541 +ER_EVENT_INTERVAL_NOT_POSITIVE_OR_TOO_BIG = 1542 +ER_EVENT_ENDS_BEFORE_STARTS = 1543 +ER_EVENT_EXEC_TIME_IN_THE_PAST = 1544 +OBSOLETE_ER_EVENT_OPEN_TABLE_FAILED = 1545 +OBSOLETE_ER_EVENT_NEITHER_M_EXPR_NOR_M_AT = 1546 +OBSOLETE_ER_COL_COUNT_DOESNT_MATCH_CORRUPTED = 1547 +OBSOLETE_ER_CANNOT_LOAD_FROM_TABLE = 1548 +OBSOLETE_ER_EVENT_CANNOT_DELETE = 1549 +OBSOLETE_ER_EVENT_COMPILE_ERROR = 1550 +ER_EVENT_SAME_NAME = 1551 +OBSOLETE_ER_EVENT_DATA_TOO_LONG = 1552 +ER_DROP_INDEX_FK = 1553 +ER_WARN_DEPRECATED_SYNTAX_WITH_VER = 1554 +OBSOLETE_ER_CANT_WRITE_LOCK_LOG_TABLE = 1555 +ER_CANT_LOCK_LOG_TABLE = 1556 +ER_FOREIGN_DUPLICATE_KEY_OLD_UNUSED = 1557 +ER_COL_COUNT_DOESNT_MATCH_PLEASE_UPDATE = 1558 +OBSOLETE_ER_TEMP_TABLE_PREVENTS_SWITCH_OUT_OF_RBR = 1559 +ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_FORMAT = 1560 +OBSOLETE_ER_NDB_CANT_SWITCH_BINLOG_FORMAT = 1561 +ER_PARTITION_NO_TEMPORARY = 1562 +ER_PARTITION_CONST_DOMAIN_ERROR = 1563 +ER_PARTITION_FUNCTION_IS_NOT_ALLOWED = 1564 +OBSOLETE_ER_DDL_LOG_ERROR_UNUSED = 1565 +ER_NULL_IN_VALUES_LESS_THAN = 1566 +ER_WRONG_PARTITION_NAME = 1567 +ER_CANT_CHANGE_TX_CHARACTERISTICS = 1568 +ER_DUP_ENTRY_AUTOINCREMENT_CASE = 1569 +OBSOLETE_ER_EVENT_MODIFY_QUEUE_ERROR = 1570 +ER_EVENT_SET_VAR_ERROR = 1571 +ER_PARTITION_MERGE_ERROR = 1572 +OBSOLETE_ER_CANT_ACTIVATE_LOG = 1573 +OBSOLETE_ER_RBR_NOT_AVAILABLE = 1574 +ER_BASE64_DECODE_ERROR = 1575 +ER_EVENT_RECURSION_FORBIDDEN = 1576 +OBSOLETE_ER_EVENTS_DB_ERROR = 1577 +ER_ONLY_INTEGERS_ALLOWED = 1578 +ER_UNSUPORTED_LOG_ENGINE = 1579 +ER_BAD_LOG_STATEMENT = 1580 +ER_CANT_RENAME_LOG_TABLE = 1581 +ER_WRONG_PARAMCOUNT_TO_NATIVE_FCT = 1582 +ER_WRONG_PARAMETERS_TO_NATIVE_FCT = 1583 +ER_WRONG_PARAMETERS_TO_STORED_FCT = 1584 +ER_NATIVE_FCT_NAME_COLLISION = 1585 +ER_DUP_ENTRY_WITH_KEY_NAME = 1586 +ER_BINLOG_PURGE_EMFILE = 1587 +ER_EVENT_CANNOT_CREATE_IN_THE_PAST = 1588 +ER_EVENT_CANNOT_ALTER_IN_THE_PAST = 1589 +OBSOLETE_ER_SLAVE_INCIDENT = 1590 +ER_NO_PARTITION_FOR_GIVEN_VALUE_SILENT = 1591 +ER_BINLOG_UNSAFE_STATEMENT = 1592 +ER_BINLOG_FATAL_ERROR = 1593 +OBSOLETE_ER_SLAVE_RELAY_LOG_READ_FAILURE = 1594 +OBSOLETE_ER_SLAVE_RELAY_LOG_WRITE_FAILURE = 1595 +OBSOLETE_ER_SLAVE_CREATE_EVENT_FAILURE = 1596 +OBSOLETE_ER_SLAVE_MASTER_COM_FAILURE = 1597 +ER_BINLOG_LOGGING_IMPOSSIBLE = 1598 +ER_VIEW_NO_CREATION_CTX = 1599 +ER_VIEW_INVALID_CREATION_CTX = 1600 +OBSOLETE_ER_SR_INVALID_CREATION_CTX = 1601 +ER_TRG_CORRUPTED_FILE = 1602 +ER_TRG_NO_CREATION_CTX = 1603 +ER_TRG_INVALID_CREATION_CTX = 1604 +ER_EVENT_INVALID_CREATION_CTX = 1605 +ER_TRG_CANT_OPEN_TABLE = 1606 +OBSOLETE_ER_CANT_CREATE_SROUTINE = 1607 +OBSOLETE_ER_NEVER_USED = 1608 +ER_NO_FORMAT_DESCRIPTION_EVENT_BEFORE_BINLOG_STATEMENT = 1609 +ER_SLAVE_CORRUPT_EVENT = 1610 +OBSOLETE_ER_LOAD_DATA_INVALID_COLUMN_UNUSED = 1611 +ER_LOG_PURGE_NO_FILE = 1612 +ER_XA_RBTIMEOUT = 1613 +ER_XA_RBDEADLOCK = 1614 +ER_NEED_REPREPARE = 1615 +OBSOLETE_ER_DELAYED_NOT_SUPPORTED = 1616 +WARN_NO_MASTER_INFO = 1617 +WARN_OPTION_IGNORED = 1618 +ER_PLUGIN_DELETE_BUILTIN = 1619 +WARN_PLUGIN_BUSY = 1620 +ER_VARIABLE_IS_READONLY = 1621 +ER_WARN_ENGINE_TRANSACTION_ROLLBACK = 1622 +OBSOLETE_ER_SLAVE_HEARTBEAT_FAILURE = 1623 +ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE = 1624 +ER_NDB_REPLICATION_SCHEMA_ERROR = 1625 +ER_CONFLICT_FN_PARSE_ERROR = 1626 +ER_EXCEPTIONS_WRITE_ERROR = 1627 +ER_TOO_LONG_TABLE_COMMENT = 1628 +ER_TOO_LONG_FIELD_COMMENT = 1629 +ER_FUNC_INEXISTENT_NAME_COLLISION = 1630 +ER_DATABASE_NAME = 1631 +ER_TABLE_NAME = 1632 +ER_PARTITION_NAME = 1633 +ER_SUBPARTITION_NAME = 1634 +ER_TEMPORARY_NAME = 1635 +ER_RENAMED_NAME = 1636 +ER_TOO_MANY_CONCURRENT_TRXS = 1637 +WARN_NON_ASCII_SEPARATOR_NOT_IMPLEMENTED = 1638 +ER_DEBUG_SYNC_TIMEOUT = 1639 +ER_DEBUG_SYNC_HIT_LIMIT = 1640 +ER_DUP_SIGNAL_SET = 1641 +ER_SIGNAL_WARN = 1642 +ER_SIGNAL_NOT_FOUND = 1643 +ER_SIGNAL_EXCEPTION = 1644 +ER_RESIGNAL_WITHOUT_ACTIVE_HANDLER = 1645 +ER_SIGNAL_BAD_CONDITION_TYPE = 1646 +WARN_COND_ITEM_TRUNCATED = 1647 +ER_COND_ITEM_TOO_LONG = 1648 +ER_UNKNOWN_LOCALE = 1649 +ER_SLAVE_IGNORE_SERVER_IDS = 1650 +OBSOLETE_ER_QUERY_CACHE_DISABLED = 1651 +ER_SAME_NAME_PARTITION_FIELD = 1652 +ER_PARTITION_COLUMN_LIST_ERROR = 1653 +ER_WRONG_TYPE_COLUMN_VALUE_ERROR = 1654 +ER_TOO_MANY_PARTITION_FUNC_FIELDS_ERROR = 1655 +ER_MAXVALUE_IN_VALUES_IN = 1656 +ER_TOO_MANY_VALUES_ERROR = 1657 +ER_ROW_SINGLE_PARTITION_FIELD_ERROR = 1658 +ER_FIELD_TYPE_NOT_ALLOWED_AS_PARTITION_FIELD = 1659 +ER_PARTITION_FIELDS_TOO_LONG = 1660 +ER_BINLOG_ROW_ENGINE_AND_STMT_ENGINE = 1661 +ER_BINLOG_ROW_MODE_AND_STMT_ENGINE = 1662 +ER_BINLOG_UNSAFE_AND_STMT_ENGINE = 1663 +ER_BINLOG_ROW_INJECTION_AND_STMT_ENGINE = 1664 +ER_BINLOG_STMT_MODE_AND_ROW_ENGINE = 1665 +ER_BINLOG_ROW_INJECTION_AND_STMT_MODE = 1666 +ER_BINLOG_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE = 1667 +ER_BINLOG_UNSAFE_LIMIT = 1668 +OBSOLETE_ER_UNUSED4 = 1669 +ER_BINLOG_UNSAFE_SYSTEM_TABLE = 1670 +ER_BINLOG_UNSAFE_AUTOINC_COLUMNS = 1671 +ER_BINLOG_UNSAFE_UDF = 1672 +ER_BINLOG_UNSAFE_SYSTEM_VARIABLE = 1673 +ER_BINLOG_UNSAFE_SYSTEM_FUNCTION = 1674 +ER_BINLOG_UNSAFE_NONTRANS_AFTER_TRANS = 1675 +ER_MESSAGE_AND_STATEMENT = 1676 +OBSOLETE_ER_SLAVE_CONVERSION_FAILED = 1677 +ER_SLAVE_CANT_CREATE_CONVERSION = 1678 +ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_FORMAT = 1679 +ER_PATH_LENGTH = 1680 +ER_WARN_DEPRECATED_SYNTAX_NO_REPLACEMENT = 1681 +ER_WRONG_NATIVE_TABLE_STRUCTURE = 1682 +ER_WRONG_PERFSCHEMA_USAGE = 1683 +ER_WARN_I_S_SKIPPED_TABLE = 1684 +ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_BINLOG_DIRECT = 1685 +ER_STORED_FUNCTION_PREVENTS_SWITCH_BINLOG_DIRECT = 1686 +ER_SPATIAL_MUST_HAVE_GEOM_COL = 1687 +ER_TOO_LONG_INDEX_COMMENT = 1688 +ER_LOCK_ABORTED = 1689 +ER_DATA_OUT_OF_RANGE = 1690 +OBSOLETE_ER_WRONG_SPVAR_TYPE_IN_LIMIT = 1691 +ER_BINLOG_UNSAFE_MULTIPLE_ENGINES_AND_SELF_LOGGING_ENGINE = 1692 +ER_BINLOG_UNSAFE_MIXED_STATEMENT = 1693 +ER_INSIDE_TRANSACTION_PREVENTS_SWITCH_SQL_LOG_BIN = 1694 +ER_STORED_FUNCTION_PREVENTS_SWITCH_SQL_LOG_BIN = 1695 +ER_FAILED_READ_FROM_PAR_FILE = 1696 +ER_VALUES_IS_NOT_INT_TYPE_ERROR = 1697 +ER_ACCESS_DENIED_NO_PASSWORD_ERROR = 1698 +ER_SET_PASSWORD_AUTH_PLUGIN = 1699 +OBSOLETE_ER_GRANT_PLUGIN_USER_EXISTS = 1700 +ER_TRUNCATE_ILLEGAL_FK = 1701 +ER_PLUGIN_IS_PERMANENT = 1702 +ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MIN = 1703 +ER_SLAVE_HEARTBEAT_VALUE_OUT_OF_RANGE_MAX = 1704 +ER_STMT_CACHE_FULL = 1705 +ER_MULTI_UPDATE_KEY_CONFLICT = 1706 +ER_TABLE_NEEDS_REBUILD = 1707 +WARN_OPTION_BELOW_LIMIT = 1708 +ER_INDEX_COLUMN_TOO_LONG = 1709 +ER_ERROR_IN_TRIGGER_BODY = 1710 +ER_ERROR_IN_UNKNOWN_TRIGGER_BODY = 1711 +ER_INDEX_CORRUPT = 1712 +ER_UNDO_RECORD_TOO_BIG = 1713 +ER_BINLOG_UNSAFE_INSERT_IGNORE_SELECT = 1714 +ER_BINLOG_UNSAFE_INSERT_SELECT_UPDATE = 1715 +ER_BINLOG_UNSAFE_REPLACE_SELECT = 1716 +ER_BINLOG_UNSAFE_CREATE_IGNORE_SELECT = 1717 +ER_BINLOG_UNSAFE_CREATE_REPLACE_SELECT = 1718 +ER_BINLOG_UNSAFE_UPDATE_IGNORE = 1719 +ER_PLUGIN_NO_UNINSTALL = 1720 +ER_PLUGIN_NO_INSTALL = 1721 +ER_BINLOG_UNSAFE_WRITE_AUTOINC_SELECT = 1722 +ER_BINLOG_UNSAFE_CREATE_SELECT_AUTOINC = 1723 +ER_BINLOG_UNSAFE_INSERT_TWO_KEYS = 1724 +ER_TABLE_IN_FK_CHECK = 1725 +ER_UNSUPPORTED_ENGINE = 1726 +ER_BINLOG_UNSAFE_AUTOINC_NOT_FIRST = 1727 +ER_CANNOT_LOAD_FROM_TABLE_V2 = 1728 +ER_MASTER_DELAY_VALUE_OUT_OF_RANGE = 1729 +ER_ONLY_FD_AND_RBR_EVENTS_ALLOWED_IN_BINLOG_STATEMENT = 1730 +ER_PARTITION_EXCHANGE_DIFFERENT_OPTION = 1731 +ER_PARTITION_EXCHANGE_PART_TABLE = 1732 +ER_PARTITION_EXCHANGE_TEMP_TABLE = 1733 +ER_PARTITION_INSTEAD_OF_SUBPARTITION = 1734 +ER_UNKNOWN_PARTITION = 1735 +ER_TABLES_DIFFERENT_METADATA = 1736 +ER_ROW_DOES_NOT_MATCH_PARTITION = 1737 +ER_BINLOG_CACHE_SIZE_GREATER_THAN_MAX = 1738 +ER_WARN_INDEX_NOT_APPLICABLE = 1739 +ER_PARTITION_EXCHANGE_FOREIGN_KEY = 1740 +OBSOLETE_ER_NO_SUCH_KEY_VALUE = 1741 +ER_RPL_INFO_DATA_TOO_LONG = 1742 +OBSOLETE_ER_NETWORK_READ_EVENT_CHECKSUM_FAILURE = 1743 +OBSOLETE_ER_BINLOG_READ_EVENT_CHECKSUM_FAILURE = 1744 +ER_BINLOG_STMT_CACHE_SIZE_GREATER_THAN_MAX = 1745 +ER_CANT_UPDATE_TABLE_IN_CREATE_TABLE_SELECT = 1746 +ER_PARTITION_CLAUSE_ON_NONPARTITIONED = 1747 +ER_ROW_DOES_NOT_MATCH_GIVEN_PARTITION_SET = 1748 +OBSOLETE_ER_NO_SUCH_PARTITION__UNUSED = 1749 +ER_CHANGE_RPL_INFO_REPOSITORY_FAILURE = 1750 +ER_WARNING_NOT_COMPLETE_ROLLBACK_WITH_CREATED_TEMP_TABLE = 1751 +ER_WARNING_NOT_COMPLETE_ROLLBACK_WITH_DROPPED_TEMP_TABLE = 1752 +ER_MTS_FEATURE_IS_NOT_SUPPORTED = 1753 +ER_MTS_UPDATED_DBS_GREATER_MAX = 1754 +ER_MTS_CANT_PARALLEL = 1755 +ER_MTS_INCONSISTENT_DATA = 1756 +ER_FULLTEXT_NOT_SUPPORTED_WITH_PARTITIONING = 1757 +ER_DA_INVALID_CONDITION_NUMBER = 1758 +ER_INSECURE_PLAIN_TEXT = 1759 +ER_INSECURE_CHANGE_MASTER = 1760 +ER_FOREIGN_DUPLICATE_KEY_WITH_CHILD_INFO = 1761 +ER_FOREIGN_DUPLICATE_KEY_WITHOUT_CHILD_INFO = 1762 +ER_SQLTHREAD_WITH_SECURE_SLAVE = 1763 +ER_TABLE_HAS_NO_FT = 1764 +ER_VARIABLE_NOT_SETTABLE_IN_SF_OR_TRIGGER = 1765 +ER_VARIABLE_NOT_SETTABLE_IN_TRANSACTION = 1766 +OBSOLETE_ER_GTID_NEXT_IS_NOT_IN_GTID_NEXT_LIST = 1767 +OBSOLETE_ER_CANT_CHANGE_GTID_NEXT_IN_TRANSACTION = 1768 +ER_SET_STATEMENT_CANNOT_INVOKE_FUNCTION = 1769 +ER_GTID_NEXT_CANT_BE_AUTOMATIC_IF_GTID_NEXT_LIST_IS_NON_NULL = 1770 +OBSOLETE_ER_SKIPPING_LOGGED_TRANSACTION = 1771 +ER_MALFORMED_GTID_SET_SPECIFICATION = 1772 +ER_MALFORMED_GTID_SET_ENCODING = 1773 +ER_MALFORMED_GTID_SPECIFICATION = 1774 +ER_GNO_EXHAUSTED = 1775 +ER_BAD_SLAVE_AUTO_POSITION = 1776 +ER_AUTO_POSITION_REQUIRES_GTID_MODE_NOT_OFF = 1777 +ER_CANT_DO_IMPLICIT_COMMIT_IN_TRX_WHEN_GTID_NEXT_IS_SET = 1778 +ER_GTID_MODE_ON_REQUIRES_ENFORCE_GTID_CONSISTENCY_ON = 1779 +OBSOLETE_ER_GTID_MODE_REQUIRES_BINLOG = 1780 +ER_CANT_SET_GTID_NEXT_TO_GTID_WHEN_GTID_MODE_IS_OFF = 1781 +ER_CANT_SET_GTID_NEXT_TO_ANONYMOUS_WHEN_GTID_MODE_IS_ON = 1782 +ER_CANT_SET_GTID_NEXT_LIST_TO_NON_NULL_WHEN_GTID_MODE_IS_OFF = 1783 +OBSOLETE_ER_FOUND_GTID_EVENT_WHEN_GTID_MODE_IS_OFF__UNUSED = 1784 +ER_GTID_UNSAFE_NON_TRANSACTIONAL_TABLE = 1785 +ER_GTID_UNSAFE_CREATE_SELECT = 1786 +OBSOLETE_ER_GTID_UNSAFE_CREATE_DROP_TEMP_TABLE_IN_TRANSACTION = 1787 +ER_GTID_MODE_CAN_ONLY_CHANGE_ONE_STEP_AT_A_TIME = 1788 +ER_MASTER_HAS_PURGED_REQUIRED_GTIDS = 1789 +ER_CANT_SET_GTID_NEXT_WHEN_OWNING_GTID = 1790 +ER_UNKNOWN_EXPLAIN_FORMAT = 1791 +ER_CANT_EXECUTE_IN_READ_ONLY_TRANSACTION = 1792 +ER_TOO_LONG_TABLE_PARTITION_COMMENT = 1793 +ER_SLAVE_CONFIGURATION = 1794 +ER_INNODB_FT_LIMIT = 1795 +ER_INNODB_NO_FT_TEMP_TABLE = 1796 +ER_INNODB_FT_WRONG_DOCID_COLUMN = 1797 +ER_INNODB_FT_WRONG_DOCID_INDEX = 1798 +ER_INNODB_ONLINE_LOG_TOO_BIG = 1799 +ER_UNKNOWN_ALTER_ALGORITHM = 1800 +ER_UNKNOWN_ALTER_LOCK = 1801 +ER_MTS_CHANGE_MASTER_CANT_RUN_WITH_GAPS = 1802 +ER_MTS_RECOVERY_FAILURE = 1803 +ER_MTS_RESET_WORKERS = 1804 +ER_COL_COUNT_DOESNT_MATCH_CORRUPTED_V2 = 1805 +ER_SLAVE_SILENT_RETRY_TRANSACTION = 1806 +ER_DISCARD_FK_CHECKS_RUNNING = 1807 +ER_TABLE_SCHEMA_MISMATCH = 1808 +ER_TABLE_IN_SYSTEM_TABLESPACE = 1809 +ER_IO_READ_ERROR = 1810 +ER_IO_WRITE_ERROR = 1811 +ER_TABLESPACE_MISSING = 1812 +ER_TABLESPACE_EXISTS = 1813 +ER_TABLESPACE_DISCARDED = 1814 +ER_INTERNAL_ERROR = 1815 +ER_INNODB_IMPORT_ERROR = 1816 +ER_INNODB_INDEX_CORRUPT = 1817 +ER_INVALID_YEAR_COLUMN_LENGTH = 1818 +ER_NOT_VALID_PASSWORD = 1819 +ER_MUST_CHANGE_PASSWORD = 1820 +ER_FK_NO_INDEX_CHILD = 1821 +ER_FK_NO_INDEX_PARENT = 1822 +ER_FK_FAIL_ADD_SYSTEM = 1823 +ER_FK_CANNOT_OPEN_PARENT = 1824 +ER_FK_INCORRECT_OPTION = 1825 +ER_FK_DUP_NAME = 1826 +ER_PASSWORD_FORMAT = 1827 +ER_FK_COLUMN_CANNOT_DROP = 1828 +ER_FK_COLUMN_CANNOT_DROP_CHILD = 1829 +ER_FK_COLUMN_NOT_NULL = 1830 +ER_DUP_INDEX = 1831 +ER_FK_COLUMN_CANNOT_CHANGE = 1832 +ER_FK_COLUMN_CANNOT_CHANGE_CHILD = 1833 +OBSOLETE_ER_UNUSED5 = 1834 +ER_MALFORMED_PACKET = 1835 +ER_READ_ONLY_MODE = 1836 +ER_GTID_NEXT_TYPE_UNDEFINED_GTID = 1837 +ER_VARIABLE_NOT_SETTABLE_IN_SP = 1838 +OBSOLETE_ER_CANT_SET_GTID_PURGED_WHEN_GTID_MODE_IS_OFF = 1839 +ER_CANT_SET_GTID_PURGED_WHEN_GTID_EXECUTED_IS_NOT_EMPTY = 1840 +ER_CANT_SET_GTID_PURGED_WHEN_OWNED_GTIDS_IS_NOT_EMPTY = 1841 +ER_GTID_PURGED_WAS_CHANGED = 1842 +ER_GTID_EXECUTED_WAS_CHANGED = 1843 +ER_BINLOG_STMT_MODE_AND_NO_REPL_TABLES = 1844 +ER_ALTER_OPERATION_NOT_SUPPORTED = 1845 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON = 1846 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_COPY = 1847 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_PARTITION = 1848 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_FK_RENAME = 1849 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_COLUMN_TYPE = 1850 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_FK_CHECK = 1851 +OBSOLETE_ER_UNUSED6 = 1852 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_NOPK = 1853 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_AUTOINC = 1854 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_HIDDEN_FTS = 1855 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_CHANGE_FTS = 1856 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_FTS = 1857 +OBSOLETE_ER_SQL_REPLICA_SKIP_COUNTER_NOT_SETTABLE_IN_GTID_MODE = 1858 +ER_DUP_UNKNOWN_IN_INDEX = 1859 +ER_IDENT_CAUSES_TOO_LONG_PATH = 1860 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_NOT_NULL = 1861 +ER_MUST_CHANGE_PASSWORD_LOGIN = 1862 +ER_ROW_IN_WRONG_PARTITION = 1863 +ER_MTS_EVENT_BIGGER_PENDING_JOBS_SIZE_MAX = 1864 +OBSOLETE_ER_INNODB_NO_FT_USES_PARSER = 1865 +ER_BINLOG_LOGICAL_CORRUPTION = 1866 +ER_WARN_PURGE_LOG_IN_USE = 1867 +ER_WARN_PURGE_LOG_IS_ACTIVE = 1868 +ER_AUTO_INCREMENT_CONFLICT = 1869 +WARN_ON_BLOCKHOLE_IN_RBR = 1870 +ER_SLAVE_MI_INIT_REPOSITORY = 1871 +ER_SLAVE_RLI_INIT_REPOSITORY = 1872 +ER_ACCESS_DENIED_CHANGE_USER_ERROR = 1873 +ER_INNODB_READ_ONLY = 1874 +ER_STOP_SLAVE_SQL_THREAD_TIMEOUT = 1875 +ER_STOP_SLAVE_IO_THREAD_TIMEOUT = 1876 +ER_TABLE_CORRUPT = 1877 +ER_TEMP_FILE_WRITE_FAILURE = 1878 +ER_INNODB_FT_AUX_NOT_HEX_ID = 1879 +ER_OLD_TEMPORALS_UPGRADED = 1880 +ER_INNODB_FORCED_RECOVERY = 1881 +ER_AES_INVALID_IV = 1882 +ER_PLUGIN_CANNOT_BE_UNINSTALLED = 1883 +ER_GTID_UNSAFE_BINLOG_SPLITTABLE_STATEMENT_AND_ASSIGNED_GTID = 1884 +ER_SLAVE_HAS_MORE_GTIDS_THAN_MASTER = 1885 +ER_MISSING_KEY = 1886 +WARN_NAMED_PIPE_ACCESS_EVERYONE = 1887 +ER_FILE_CORRUPT = 3000 +ER_ERROR_ON_MASTER = 3001 +OBSOLETE_ER_INCONSISTENT_ERROR = 3002 +ER_STORAGE_ENGINE_NOT_LOADED = 3003 +ER_GET_STACKED_DA_WITHOUT_ACTIVE_HANDLER = 3004 +ER_WARN_LEGACY_SYNTAX_CONVERTED = 3005 +ER_BINLOG_UNSAFE_FULLTEXT_PLUGIN = 3006 +ER_CANNOT_DISCARD_TEMPORARY_TABLE = 3007 +ER_FK_DEPTH_EXCEEDED = 3008 +ER_COL_COUNT_DOESNT_MATCH_PLEASE_UPDATE_V2 = 3009 +ER_WARN_TRIGGER_DOESNT_HAVE_CREATED = 3010 +ER_REFERENCED_TRG_DOES_NOT_EXIST = 3011 +ER_EXPLAIN_NOT_SUPPORTED = 3012 +ER_INVALID_FIELD_SIZE = 3013 +ER_MISSING_HA_CREATE_OPTION = 3014 +ER_ENGINE_OUT_OF_MEMORY = 3015 +ER_PASSWORD_EXPIRE_ANONYMOUS_USER = 3016 +ER_SLAVE_SQL_THREAD_MUST_STOP = 3017 +ER_NO_FT_MATERIALIZED_SUBQUERY = 3018 +ER_INNODB_UNDO_LOG_FULL = 3019 +ER_INVALID_ARGUMENT_FOR_LOGARITHM = 3020 +ER_SLAVE_CHANNEL_IO_THREAD_MUST_STOP = 3021 +ER_WARN_OPEN_TEMP_TABLES_MUST_BE_ZERO = 3022 +ER_WARN_ONLY_MASTER_LOG_FILE_NO_POS = 3023 +ER_QUERY_TIMEOUT = 3024 +ER_NON_RO_SELECT_DISABLE_TIMER = 3025 +ER_DUP_LIST_ENTRY = 3026 +OBSOLETE_ER_SQL_MODE_NO_EFFECT = 3027 +ER_AGGREGATE_ORDER_FOR_UNION = 3028 +ER_AGGREGATE_ORDER_NON_AGG_QUERY = 3029 +ER_SLAVE_WORKER_STOPPED_PREVIOUS_THD_ERROR = 3030 +ER_DONT_SUPPORT_REPLICA_PRESERVE_COMMIT_ORDER = 3031 +ER_SERVER_OFFLINE_MODE = 3032 +ER_GIS_DIFFERENT_SRIDS = 3033 +ER_GIS_UNSUPPORTED_ARGUMENT = 3034 +ER_GIS_UNKNOWN_ERROR = 3035 +ER_GIS_UNKNOWN_EXCEPTION = 3036 +ER_GIS_INVALID_DATA = 3037 +ER_BOOST_GEOMETRY_EMPTY_INPUT_EXCEPTION = 3038 +ER_BOOST_GEOMETRY_CENTROID_EXCEPTION = 3039 +ER_BOOST_GEOMETRY_OVERLAY_INVALID_INPUT_EXCEPTION = 3040 +ER_BOOST_GEOMETRY_TURN_INFO_EXCEPTION = 3041 +ER_BOOST_GEOMETRY_SELF_INTERSECTION_POINT_EXCEPTION = 3042 +ER_BOOST_GEOMETRY_UNKNOWN_EXCEPTION = 3043 +ER_STD_BAD_ALLOC_ERROR = 3044 +ER_STD_DOMAIN_ERROR = 3045 +ER_STD_LENGTH_ERROR = 3046 +ER_STD_INVALID_ARGUMENT = 3047 +ER_STD_OUT_OF_RANGE_ERROR = 3048 +ER_STD_OVERFLOW_ERROR = 3049 +ER_STD_RANGE_ERROR = 3050 +ER_STD_UNDERFLOW_ERROR = 3051 +ER_STD_LOGIC_ERROR = 3052 +ER_STD_RUNTIME_ERROR = 3053 +ER_STD_UNKNOWN_EXCEPTION = 3054 +ER_GIS_DATA_WRONG_ENDIANESS = 3055 +ER_CHANGE_MASTER_PASSWORD_LENGTH = 3056 +ER_USER_LOCK_WRONG_NAME = 3057 +ER_USER_LOCK_DEADLOCK = 3058 +ER_REPLACE_INACCESSIBLE_ROWS = 3059 +ER_ALTER_OPERATION_NOT_SUPPORTED_REASON_GIS = 3060 +ER_ILLEGAL_USER_VAR = 3061 +ER_GTID_MODE_OFF = 3062 +OBSOLETE_ER_UNSUPPORTED_BY_REPLICATION_THREAD = 3063 +ER_INCORRECT_TYPE = 3064 +ER_FIELD_IN_ORDER_NOT_SELECT = 3065 +ER_AGGREGATE_IN_ORDER_NOT_SELECT = 3066 +ER_INVALID_RPL_WILD_TABLE_FILTER_PATTERN = 3067 +ER_NET_OK_PACKET_TOO_LARGE = 3068 +ER_INVALID_JSON_DATA = 3069 +ER_INVALID_GEOJSON_MISSING_MEMBER = 3070 +ER_INVALID_GEOJSON_WRONG_TYPE = 3071 +ER_INVALID_GEOJSON_UNSPECIFIED = 3072 +ER_DIMENSION_UNSUPPORTED = 3073 +ER_SLAVE_CHANNEL_DOES_NOT_EXIST = 3074 +OBSOLETE_ER_SLAVE_MULTIPLE_CHANNELS_HOST_PORT = 3075 +ER_SLAVE_CHANNEL_NAME_INVALID_OR_TOO_LONG = 3076 +ER_SLAVE_NEW_CHANNEL_WRONG_REPOSITORY = 3077 +OBSOLETE_ER_SLAVE_CHANNEL_DELETE = 3078 +ER_SLAVE_MULTIPLE_CHANNELS_CMD = 3079 +ER_SLAVE_MAX_CHANNELS_EXCEEDED = 3080 +ER_SLAVE_CHANNEL_MUST_STOP = 3081 +ER_SLAVE_CHANNEL_NOT_RUNNING = 3082 +ER_SLAVE_CHANNEL_WAS_RUNNING = 3083 +ER_SLAVE_CHANNEL_WAS_NOT_RUNNING = 3084 +ER_SLAVE_CHANNEL_SQL_THREAD_MUST_STOP = 3085 +ER_SLAVE_CHANNEL_SQL_SKIP_COUNTER = 3086 +ER_WRONG_FIELD_WITH_GROUP_V2 = 3087 +ER_MIX_OF_GROUP_FUNC_AND_FIELDS_V2 = 3088 +ER_WARN_DEPRECATED_SYSVAR_UPDATE = 3089 +ER_WARN_DEPRECATED_SQLMODE = 3090 +ER_CANNOT_LOG_PARTIAL_DROP_DATABASE_WITH_GTID = 3091 +ER_GROUP_REPLICATION_CONFIGURATION = 3092 +ER_GROUP_REPLICATION_RUNNING = 3093 +ER_GROUP_REPLICATION_APPLIER_INIT_ERROR = 3094 +ER_GROUP_REPLICATION_STOP_APPLIER_THREAD_TIMEOUT = 3095 +ER_GROUP_REPLICATION_COMMUNICATION_LAYER_SESSION_ERROR = 3096 +ER_GROUP_REPLICATION_COMMUNICATION_LAYER_JOIN_ERROR = 3097 +ER_BEFORE_DML_VALIDATION_ERROR = 3098 +ER_PREVENTS_VARIABLE_WITHOUT_RBR = 3099 +ER_RUN_HOOK_ERROR = 3100 +ER_TRANSACTION_ROLLBACK_DURING_COMMIT = 3101 +ER_GENERATED_COLUMN_FUNCTION_IS_NOT_ALLOWED = 3102 +ER_UNSUPPORTED_ALTER_INPLACE_ON_VIRTUAL_COLUMN = 3103 +ER_WRONG_FK_OPTION_FOR_GENERATED_COLUMN = 3104 +ER_NON_DEFAULT_VALUE_FOR_GENERATED_COLUMN = 3105 +ER_UNSUPPORTED_ACTION_ON_GENERATED_COLUMN = 3106 +ER_GENERATED_COLUMN_NON_PRIOR = 3107 +ER_DEPENDENT_BY_GENERATED_COLUMN = 3108 +ER_GENERATED_COLUMN_REF_AUTO_INC = 3109 +ER_FEATURE_NOT_AVAILABLE = 3110 +ER_CANT_SET_GTID_MODE = 3111 +ER_CANT_USE_AUTO_POSITION_WITH_GTID_MODE_OFF = 3112 +OBSOLETE_ER_CANT_REPLICATE_ANONYMOUS_WITH_AUTO_POSITION = 3113 +OBSOLETE_ER_CANT_REPLICATE_ANONYMOUS_WITH_GTID_MODE_ON = 3114 +OBSOLETE_ER_CANT_REPLICATE_GTID_WITH_GTID_MODE_OFF = 3115 +ER_CANT_ENFORCE_GTID_CONSISTENCY_WITH_ONGOING_GTID_VIOLATING_TX = 3116 +ER_ENFORCE_GTID_CONSISTENCY_WARN_WITH_ONGOING_GTID_VIOLATING_TX = 3117 +ER_ACCOUNT_HAS_BEEN_LOCKED = 3118 +ER_WRONG_TABLESPACE_NAME = 3119 +ER_TABLESPACE_IS_NOT_EMPTY = 3120 +ER_WRONG_FILE_NAME = 3121 +ER_BOOST_GEOMETRY_INCONSISTENT_TURNS_EXCEPTION = 3122 +ER_WARN_OPTIMIZER_HINT_SYNTAX_ERROR = 3123 +ER_WARN_BAD_MAX_EXECUTION_TIME = 3124 +ER_WARN_UNSUPPORTED_MAX_EXECUTION_TIME = 3125 +ER_WARN_CONFLICTING_HINT = 3126 +ER_WARN_UNKNOWN_QB_NAME = 3127 +ER_UNRESOLVED_HINT_NAME = 3128 +ER_WARN_ON_MODIFYING_GTID_EXECUTED_TABLE = 3129 +ER_PLUGGABLE_PROTOCOL_COMMAND_NOT_SUPPORTED = 3130 +ER_LOCKING_SERVICE_WRONG_NAME = 3131 +ER_LOCKING_SERVICE_DEADLOCK = 3132 +ER_LOCKING_SERVICE_TIMEOUT = 3133 +ER_GIS_MAX_POINTS_IN_GEOMETRY_OVERFLOWED = 3134 +ER_SQL_MODE_MERGED = 3135 +ER_VTOKEN_PLUGIN_TOKEN_MISMATCH = 3136 +ER_VTOKEN_PLUGIN_TOKEN_NOT_FOUND = 3137 +ER_CANT_SET_VARIABLE_WHEN_OWNING_GTID = 3138 +ER_SLAVE_CHANNEL_OPERATION_NOT_ALLOWED = 3139 +ER_INVALID_JSON_TEXT = 3140 +ER_INVALID_JSON_TEXT_IN_PARAM = 3141 +ER_INVALID_JSON_BINARY_DATA = 3142 +ER_INVALID_JSON_PATH = 3143 +ER_INVALID_JSON_CHARSET = 3144 +ER_INVALID_JSON_CHARSET_IN_FUNCTION = 3145 +ER_INVALID_TYPE_FOR_JSON = 3146 +ER_INVALID_CAST_TO_JSON = 3147 +ER_INVALID_JSON_PATH_CHARSET = 3148 +ER_INVALID_JSON_PATH_WILDCARD = 3149 +ER_JSON_VALUE_TOO_BIG = 3150 +ER_JSON_KEY_TOO_BIG = 3151 +ER_JSON_USED_AS_KEY = 3152 +ER_JSON_VACUOUS_PATH = 3153 +ER_JSON_BAD_ONE_OR_ALL_ARG = 3154 +ER_NUMERIC_JSON_VALUE_OUT_OF_RANGE = 3155 +ER_INVALID_JSON_VALUE_FOR_CAST = 3156 +ER_JSON_DOCUMENT_TOO_DEEP = 3157 +ER_JSON_DOCUMENT_NULL_KEY = 3158 +ER_SECURE_TRANSPORT_REQUIRED = 3159 +ER_NO_SECURE_TRANSPORTS_CONFIGURED = 3160 +ER_DISABLED_STORAGE_ENGINE = 3161 +ER_USER_DOES_NOT_EXIST = 3162 +ER_USER_ALREADY_EXISTS = 3163 +ER_AUDIT_API_ABORT = 3164 +ER_INVALID_JSON_PATH_ARRAY_CELL = 3165 +ER_BUFPOOL_RESIZE_INPROGRESS = 3166 +ER_FEATURE_DISABLED_SEE_DOC = 3167 +ER_SERVER_ISNT_AVAILABLE = 3168 +ER_SESSION_WAS_KILLED = 3169 +ER_CAPACITY_EXCEEDED = 3170 +ER_CAPACITY_EXCEEDED_IN_RANGE_OPTIMIZER = 3171 +OBSOLETE_ER_TABLE_NEEDS_UPG_PART = 3172 +ER_CANT_WAIT_FOR_EXECUTED_GTID_SET_WHILE_OWNING_A_GTID = 3173 +ER_CANNOT_ADD_FOREIGN_BASE_COL_VIRTUAL = 3174 +ER_CANNOT_CREATE_VIRTUAL_INDEX_CONSTRAINT = 3175 +ER_ERROR_ON_MODIFYING_GTID_EXECUTED_TABLE = 3176 +ER_LOCK_REFUSED_BY_ENGINE = 3177 +ER_UNSUPPORTED_ALTER_ONLINE_ON_VIRTUAL_COLUMN = 3178 +ER_MASTER_KEY_ROTATION_NOT_SUPPORTED_BY_SE = 3179 +OBSOLETE_ER_MASTER_KEY_ROTATION_ERROR_BY_SE = 3180 +ER_MASTER_KEY_ROTATION_BINLOG_FAILED = 3181 +ER_MASTER_KEY_ROTATION_SE_UNAVAILABLE = 3182 +ER_TABLESPACE_CANNOT_ENCRYPT = 3183 +ER_INVALID_ENCRYPTION_OPTION = 3184 +ER_CANNOT_FIND_KEY_IN_KEYRING = 3185 +ER_CAPACITY_EXCEEDED_IN_PARSER = 3186 +ER_UNSUPPORTED_ALTER_ENCRYPTION_INPLACE = 3187 +ER_KEYRING_UDF_KEYRING_SERVICE_ERROR = 3188 +ER_USER_COLUMN_OLD_LENGTH = 3189 +ER_CANT_RESET_MASTER = 3190 +ER_GROUP_REPLICATION_MAX_GROUP_SIZE = 3191 +ER_CANNOT_ADD_FOREIGN_BASE_COL_STORED = 3192 +ER_TABLE_REFERENCED = 3193 +OBSOLETE_ER_PARTITION_ENGINE_DEPRECATED_FOR_TABLE = 3194 +OBSOLETE_ER_WARN_USING_GEOMFROMWKB_TO_SET_SRID_ZERO = 3195 +OBSOLETE_ER_WARN_USING_GEOMFROMWKB_TO_SET_SRID = 3196 +ER_XA_RETRY = 3197 +ER_KEYRING_AWS_UDF_AWS_KMS_ERROR = 3198 +ER_BINLOG_UNSAFE_XA = 3199 +ER_UDF_ERROR = 3200 +ER_KEYRING_MIGRATION_FAILURE = 3201 +ER_KEYRING_ACCESS_DENIED_ERROR = 3202 +ER_KEYRING_MIGRATION_STATUS = 3203 +OBSOLETE_ER_PLUGIN_FAILED_TO_OPEN_TABLES = 3204 +OBSOLETE_ER_PLUGIN_FAILED_TO_OPEN_TABLE = 3205 +OBSOLETE_ER_AUDIT_LOG_NO_KEYRING_PLUGIN_INSTALLED = 3206 +OBSOLETE_ER_AUDIT_LOG_ENCRYPTION_PASSWORD_HAS_NOT_BEEN_SET = 3207 +OBSOLETE_ER_AUDIT_LOG_COULD_NOT_CREATE_AES_KEY = 3208 +OBSOLETE_ER_AUDIT_LOG_ENCRYPTION_PASSWORD_CANNOT_BE_FETCHED = 3209 +OBSOLETE_ER_AUDIT_LOG_JSON_FILTERING_NOT_ENABLED = 3210 +OBSOLETE_ER_AUDIT_LOG_UDF_INSUFFICIENT_PRIVILEGE = 3211 +OBSOLETE_ER_AUDIT_LOG_SUPER_PRIVILEGE_REQUIRED = 3212 +OBSOLETE_ER_COULD_NOT_REINITIALIZE_AUDIT_LOG_FILTERS = 3213 +OBSOLETE_ER_AUDIT_LOG_UDF_INVALID_ARGUMENT_TYPE = 3214 +OBSOLETE_ER_AUDIT_LOG_UDF_INVALID_ARGUMENT_COUNT = 3215 +OBSOLETE_ER_AUDIT_LOG_HAS_NOT_BEEN_INSTALLED = 3216 +OBSOLETE_ER_AUDIT_LOG_UDF_READ_INVALID_MAX_ARRAY_LENGTH_ARG_TYPE = 3217 +ER_AUDIT_LOG_UDF_READ_INVALID_MAX_ARRAY_LENGTH_ARG_VALUE = 3218 +OBSOLETE_ER_AUDIT_LOG_JSON_FILTER_PARSING_ERROR = 3219 +OBSOLETE_ER_AUDIT_LOG_JSON_FILTER_NAME_CANNOT_BE_EMPTY = 3220 +OBSOLETE_ER_AUDIT_LOG_JSON_USER_NAME_CANNOT_BE_EMPTY = 3221 +OBSOLETE_ER_AUDIT_LOG_JSON_FILTER_DOES_NOT_EXISTS = 3222 +OBSOLETE_ER_AUDIT_LOG_USER_FIRST_CHARACTER_MUST_BE_ALPHANUMERIC = 3223 +OBSOLETE_ER_AUDIT_LOG_USER_NAME_INVALID_CHARACTER = 3224 +OBSOLETE_ER_AUDIT_LOG_HOST_NAME_INVALID_CHARACTER = 3225 +OBSOLETE_ER_XA_REPLICATION_FILTERS = 3226 +OBSOLETE_ER_CANT_OPEN_ERROR_LOG = 3227 +OBSOLETE_ER_GROUPING_ON_TIMESTAMP_IN_DST = 3228 +OBSOLETE_ER_CANT_START_SERVER_NAMED_PIPE = 3229 +ER_WRITE_SET_EXCEEDS_LIMIT = 3230 +ER_UNSUPPORT_COMPRESSED_TEMPORARY_TABLE = 3500 +ER_ACL_OPERATION_FAILED = 3501 +ER_UNSUPPORTED_INDEX_ALGORITHM = 3502 +ER_NO_SUCH_DB = 3503 +ER_TOO_BIG_ENUM = 3504 +ER_TOO_LONG_SET_ENUM_VALUE = 3505 +ER_INVALID_DD_OBJECT = 3506 +ER_UPDATING_DD_TABLE = 3507 +ER_INVALID_DD_OBJECT_ID = 3508 +ER_INVALID_DD_OBJECT_NAME = 3509 +ER_TABLESPACE_MISSING_WITH_NAME = 3510 +ER_TOO_LONG_ROUTINE_COMMENT = 3511 +ER_SP_LOAD_FAILED = 3512 +ER_INVALID_BITWISE_OPERANDS_SIZE = 3513 +ER_INVALID_BITWISE_AGGREGATE_OPERANDS_SIZE = 3514 +ER_WARN_UNSUPPORTED_HINT = 3515 +ER_UNEXPECTED_GEOMETRY_TYPE = 3516 +ER_SRS_PARSE_ERROR = 3517 +ER_SRS_PROJ_PARAMETER_MISSING = 3518 +ER_WARN_SRS_NOT_FOUND = 3519 +ER_SRS_NOT_CARTESIAN = 3520 +ER_SRS_NOT_CARTESIAN_UNDEFINED = 3521 +ER_PK_INDEX_CANT_BE_INVISIBLE = 3522 +ER_UNKNOWN_AUTHID = 3523 +ER_FAILED_ROLE_GRANT = 3524 +ER_OPEN_ROLE_TABLES = 3525 +ER_FAILED_DEFAULT_ROLES = 3526 +ER_COMPONENTS_NO_SCHEME = 3527 +ER_COMPONENTS_NO_SCHEME_SERVICE = 3528 +ER_COMPONENTS_CANT_LOAD = 3529 +ER_ROLE_NOT_GRANTED = 3530 +ER_FAILED_REVOKE_ROLE = 3531 +ER_RENAME_ROLE = 3532 +ER_COMPONENTS_CANT_ACQUIRE_SERVICE_IMPLEMENTATION = 3533 +ER_COMPONENTS_CANT_SATISFY_DEPENDENCY = 3534 +ER_COMPONENTS_LOAD_CANT_REGISTER_SERVICE_IMPLEMENTATION = 3535 +ER_COMPONENTS_LOAD_CANT_INITIALIZE = 3536 +ER_COMPONENTS_UNLOAD_NOT_LOADED = 3537 +ER_COMPONENTS_UNLOAD_CANT_DEINITIALIZE = 3538 +ER_COMPONENTS_CANT_RELEASE_SERVICE = 3539 +ER_COMPONENTS_UNLOAD_CANT_UNREGISTER_SERVICE = 3540 +ER_COMPONENTS_CANT_UNLOAD = 3541 +ER_WARN_UNLOAD_THE_NOT_PERSISTED = 3542 +ER_COMPONENT_TABLE_INCORRECT = 3543 +ER_COMPONENT_MANIPULATE_ROW_FAILED = 3544 +ER_COMPONENTS_UNLOAD_DUPLICATE_IN_GROUP = 3545 +ER_CANT_SET_GTID_PURGED_DUE_SETS_CONSTRAINTS = 3546 +ER_CANNOT_LOCK_USER_MANAGEMENT_CACHES = 3547 +ER_SRS_NOT_FOUND = 3548 +ER_VARIABLE_NOT_PERSISTED = 3549 +ER_IS_QUERY_INVALID_CLAUSE = 3550 +ER_UNABLE_TO_STORE_STATISTICS = 3551 +ER_NO_SYSTEM_SCHEMA_ACCESS = 3552 +ER_NO_SYSTEM_TABLESPACE_ACCESS = 3553 +ER_NO_SYSTEM_TABLE_ACCESS = 3554 +ER_NO_SYSTEM_TABLE_ACCESS_FOR_DICTIONARY_TABLE = 3555 +ER_NO_SYSTEM_TABLE_ACCESS_FOR_SYSTEM_TABLE = 3556 +ER_NO_SYSTEM_TABLE_ACCESS_FOR_TABLE = 3557 +ER_INVALID_OPTION_KEY = 3558 +ER_INVALID_OPTION_VALUE = 3559 +ER_INVALID_OPTION_KEY_VALUE_PAIR = 3560 +ER_INVALID_OPTION_START_CHARACTER = 3561 +ER_INVALID_OPTION_END_CHARACTER = 3562 +ER_INVALID_OPTION_CHARACTERS = 3563 +ER_DUPLICATE_OPTION_KEY = 3564 +ER_WARN_SRS_NOT_FOUND_AXIS_ORDER = 3565 +ER_NO_ACCESS_TO_NATIVE_FCT = 3566 +ER_RESET_MASTER_TO_VALUE_OUT_OF_RANGE = 3567 +ER_UNRESOLVED_TABLE_LOCK = 3568 +ER_DUPLICATE_TABLE_LOCK = 3569 +ER_BINLOG_UNSAFE_SKIP_LOCKED = 3570 +ER_BINLOG_UNSAFE_NOWAIT = 3571 +ER_LOCK_NOWAIT = 3572 +ER_CTE_RECURSIVE_REQUIRES_UNION = 3573 +ER_CTE_RECURSIVE_REQUIRES_NONRECURSIVE_FIRST = 3574 +ER_CTE_RECURSIVE_FORBIDS_AGGREGATION = 3575 +ER_CTE_RECURSIVE_FORBIDDEN_JOIN_ORDER = 3576 +ER_CTE_RECURSIVE_REQUIRES_SINGLE_REFERENCE = 3577 +ER_SWITCH_TMP_ENGINE = 3578 +ER_WINDOW_NO_SUCH_WINDOW = 3579 +ER_WINDOW_CIRCULARITY_IN_WINDOW_GRAPH = 3580 +ER_WINDOW_NO_CHILD_PARTITIONING = 3581 +ER_WINDOW_NO_INHERIT_FRAME = 3582 +ER_WINDOW_NO_REDEFINE_ORDER_BY = 3583 +ER_WINDOW_FRAME_START_ILLEGAL = 3584 +ER_WINDOW_FRAME_END_ILLEGAL = 3585 +ER_WINDOW_FRAME_ILLEGAL = 3586 +ER_WINDOW_RANGE_FRAME_ORDER_TYPE = 3587 +ER_WINDOW_RANGE_FRAME_TEMPORAL_TYPE = 3588 +ER_WINDOW_RANGE_FRAME_NUMERIC_TYPE = 3589 +ER_WINDOW_RANGE_BOUND_NOT_CONSTANT = 3590 +ER_WINDOW_DUPLICATE_NAME = 3591 +ER_WINDOW_ILLEGAL_ORDER_BY = 3592 +ER_WINDOW_INVALID_WINDOW_FUNC_USE = 3593 +ER_WINDOW_INVALID_WINDOW_FUNC_ALIAS_USE = 3594 +ER_WINDOW_NESTED_WINDOW_FUNC_USE_IN_WINDOW_SPEC = 3595 +ER_WINDOW_ROWS_INTERVAL_USE = 3596 +ER_WINDOW_NO_GROUP_ORDER_UNUSED = 3597 +ER_WINDOW_EXPLAIN_JSON = 3598 +ER_WINDOW_FUNCTION_IGNORES_FRAME = 3599 +ER_WL9236_NOW_UNUSED = 3600 +ER_INVALID_NO_OF_ARGS = 3601 +ER_FIELD_IN_GROUPING_NOT_GROUP_BY = 3602 +ER_TOO_LONG_TABLESPACE_COMMENT = 3603 +ER_ENGINE_CANT_DROP_TABLE = 3604 +ER_ENGINE_CANT_DROP_MISSING_TABLE = 3605 +ER_TABLESPACE_DUP_FILENAME = 3606 +ER_DB_DROP_RMDIR2 = 3607 +ER_IMP_NO_FILES_MATCHED = 3608 +ER_IMP_SCHEMA_DOES_NOT_EXIST = 3609 +ER_IMP_TABLE_ALREADY_EXISTS = 3610 +ER_IMP_INCOMPATIBLE_MYSQLD_VERSION = 3611 +ER_IMP_INCOMPATIBLE_DD_VERSION = 3612 +ER_IMP_INCOMPATIBLE_SDI_VERSION = 3613 +ER_WARN_INVALID_HINT = 3614 +ER_VAR_DOES_NOT_EXIST = 3615 +ER_LONGITUDE_OUT_OF_RANGE = 3616 +ER_LATITUDE_OUT_OF_RANGE = 3617 +ER_NOT_IMPLEMENTED_FOR_GEOGRAPHIC_SRS = 3618 +ER_ILLEGAL_PRIVILEGE_LEVEL = 3619 +ER_NO_SYSTEM_VIEW_ACCESS = 3620 +ER_COMPONENT_FILTER_FLABBERGASTED = 3621 +ER_PART_EXPR_TOO_LONG = 3622 +ER_UDF_DROP_DYNAMICALLY_REGISTERED = 3623 +ER_UNABLE_TO_STORE_COLUMN_STATISTICS = 3624 +ER_UNABLE_TO_UPDATE_COLUMN_STATISTICS = 3625 +ER_UNABLE_TO_DROP_COLUMN_STATISTICS = 3626 +ER_UNABLE_TO_BUILD_HISTOGRAM = 3627 +ER_MANDATORY_ROLE = 3628 +ER_MISSING_TABLESPACE_FILE = 3629 +ER_PERSIST_ONLY_ACCESS_DENIED_ERROR = 3630 +ER_CMD_NEED_SUPER = 3631 +ER_PATH_IN_DATADIR = 3632 +ER_CLONE_DDL_IN_PROGRESS = 3633 +ER_CLONE_TOO_MANY_CONCURRENT_CLONES = 3634 +ER_APPLIER_LOG_EVENT_VALIDATION_ERROR = 3635 +ER_CTE_MAX_RECURSION_DEPTH = 3636 +ER_NOT_HINT_UPDATABLE_VARIABLE = 3637 +ER_CREDENTIALS_CONTRADICT_TO_HISTORY = 3638 +ER_WARNING_PASSWORD_HISTORY_CLAUSES_VOID = 3639 +ER_CLIENT_DOES_NOT_SUPPORT = 3640 +ER_I_S_SKIPPED_TABLESPACE = 3641 +ER_TABLESPACE_ENGINE_MISMATCH = 3642 +ER_WRONG_SRID_FOR_COLUMN = 3643 +ER_CANNOT_ALTER_SRID_DUE_TO_INDEX = 3644 +ER_WARN_BINLOG_PARTIAL_UPDATES_DISABLED = 3645 +ER_WARN_BINLOG_V1_ROW_EVENTS_DISABLED = 3646 +ER_WARN_BINLOG_PARTIAL_UPDATES_SUGGESTS_PARTIAL_IMAGES = 3647 +ER_COULD_NOT_APPLY_JSON_DIFF = 3648 +ER_CORRUPTED_JSON_DIFF = 3649 +ER_RESOURCE_GROUP_EXISTS = 3650 +ER_RESOURCE_GROUP_NOT_EXISTS = 3651 +ER_INVALID_VCPU_ID = 3652 +ER_INVALID_VCPU_RANGE = 3653 +ER_INVALID_THREAD_PRIORITY = 3654 +ER_DISALLOWED_OPERATION = 3655 +ER_RESOURCE_GROUP_BUSY = 3656 +ER_RESOURCE_GROUP_DISABLED = 3657 +ER_FEATURE_UNSUPPORTED = 3658 +ER_ATTRIBUTE_IGNORED = 3659 +ER_INVALID_THREAD_ID = 3660 +ER_RESOURCE_GROUP_BIND_FAILED = 3661 +ER_INVALID_USE_OF_FORCE_OPTION = 3662 +ER_GROUP_REPLICATION_COMMAND_FAILURE = 3663 +ER_SDI_OPERATION_FAILED = 3664 +ER_MISSING_JSON_TABLE_VALUE = 3665 +ER_WRONG_JSON_TABLE_VALUE = 3666 +ER_TF_MUST_HAVE_ALIAS = 3667 +ER_TF_FORBIDDEN_JOIN_TYPE = 3668 +ER_JT_VALUE_OUT_OF_RANGE = 3669 +ER_JT_MAX_NESTED_PATH = 3670 +ER_PASSWORD_EXPIRATION_NOT_SUPPORTED_BY_AUTH_METHOD = 3671 +ER_INVALID_GEOJSON_CRS_NOT_TOP_LEVEL = 3672 +ER_BAD_NULL_ERROR_NOT_IGNORED = 3673 +WARN_USELESS_SPATIAL_INDEX = 3674 +ER_DISK_FULL_NOWAIT = 3675 +ER_PARSE_ERROR_IN_DIGEST_FN = 3676 +ER_UNDISCLOSED_PARSE_ERROR_IN_DIGEST_FN = 3677 +ER_SCHEMA_DIR_EXISTS = 3678 +ER_SCHEMA_DIR_MISSING = 3679 +ER_SCHEMA_DIR_CREATE_FAILED = 3680 +ER_SCHEMA_DIR_UNKNOWN = 3681 +ER_ONLY_IMPLEMENTED_FOR_SRID_0_AND_4326 = 3682 +ER_BINLOG_EXPIRE_LOG_DAYS_AND_SECS_USED_TOGETHER = 3683 +ER_REGEXP_BUFFER_OVERFLOW = 3684 +ER_REGEXP_ILLEGAL_ARGUMENT = 3685 +ER_REGEXP_INDEX_OUTOFBOUNDS_ERROR = 3686 +ER_REGEXP_INTERNAL_ERROR = 3687 +ER_REGEXP_RULE_SYNTAX = 3688 +ER_REGEXP_BAD_ESCAPE_SEQUENCE = 3689 +ER_REGEXP_UNIMPLEMENTED = 3690 +ER_REGEXP_MISMATCHED_PAREN = 3691 +ER_REGEXP_BAD_INTERVAL = 3692 +ER_REGEXP_MAX_LT_MIN = 3693 +ER_REGEXP_INVALID_BACK_REF = 3694 +ER_REGEXP_LOOK_BEHIND_LIMIT = 3695 +ER_REGEXP_MISSING_CLOSE_BRACKET = 3696 +ER_REGEXP_INVALID_RANGE = 3697 +ER_REGEXP_STACK_OVERFLOW = 3698 +ER_REGEXP_TIME_OUT = 3699 +ER_REGEXP_PATTERN_TOO_BIG = 3700 +ER_CANT_SET_ERROR_LOG_SERVICE = 3701 +ER_EMPTY_PIPELINE_FOR_ERROR_LOG_SERVICE = 3702 +ER_COMPONENT_FILTER_DIAGNOSTICS = 3703 +ER_NOT_IMPLEMENTED_FOR_CARTESIAN_SRS = 3704 +ER_NOT_IMPLEMENTED_FOR_PROJECTED_SRS = 3705 +ER_NONPOSITIVE_RADIUS = 3706 +ER_RESTART_SERVER_FAILED = 3707 +ER_SRS_MISSING_MANDATORY_ATTRIBUTE = 3708 +ER_SRS_MULTIPLE_ATTRIBUTE_DEFINITIONS = 3709 +ER_SRS_NAME_CANT_BE_EMPTY_OR_WHITESPACE = 3710 +ER_SRS_ORGANIZATION_CANT_BE_EMPTY_OR_WHITESPACE = 3711 +ER_SRS_ID_ALREADY_EXISTS = 3712 +ER_WARN_SRS_ID_ALREADY_EXISTS = 3713 +ER_CANT_MODIFY_SRID_0 = 3714 +ER_WARN_RESERVED_SRID_RANGE = 3715 +ER_CANT_MODIFY_SRS_USED_BY_COLUMN = 3716 +ER_SRS_INVALID_CHARACTER_IN_ATTRIBUTE = 3717 +ER_SRS_ATTRIBUTE_STRING_TOO_LONG = 3718 +ER_DEPRECATED_UTF8_ALIAS = 3719 +ER_DEPRECATED_NATIONAL = 3720 +ER_INVALID_DEFAULT_UTF8MB4_COLLATION = 3721 +ER_UNABLE_TO_COLLECT_LOG_STATUS = 3722 +ER_RESERVED_TABLESPACE_NAME = 3723 +ER_UNABLE_TO_SET_OPTION = 3724 +ER_SLAVE_POSSIBLY_DIVERGED_AFTER_DDL = 3725 +ER_SRS_NOT_GEOGRAPHIC = 3726 +ER_POLYGON_TOO_LARGE = 3727 +ER_SPATIAL_UNIQUE_INDEX = 3728 +ER_INDEX_TYPE_NOT_SUPPORTED_FOR_SPATIAL_INDEX = 3729 +ER_FK_CANNOT_DROP_PARENT = 3730 +ER_GEOMETRY_PARAM_LONGITUDE_OUT_OF_RANGE = 3731 +ER_GEOMETRY_PARAM_LATITUDE_OUT_OF_RANGE = 3732 +ER_FK_CANNOT_USE_VIRTUAL_COLUMN = 3733 +ER_FK_NO_COLUMN_PARENT = 3734 +ER_CANT_SET_ERROR_SUPPRESSION_LIST = 3735 +ER_SRS_GEOGCS_INVALID_AXES = 3736 +ER_SRS_INVALID_SEMI_MAJOR_AXIS = 3737 +ER_SRS_INVALID_INVERSE_FLATTENING = 3738 +ER_SRS_INVALID_ANGULAR_UNIT = 3739 +ER_SRS_INVALID_PRIME_MERIDIAN = 3740 +ER_TRANSFORM_SOURCE_SRS_NOT_SUPPORTED = 3741 +ER_TRANSFORM_TARGET_SRS_NOT_SUPPORTED = 3742 +ER_TRANSFORM_SOURCE_SRS_MISSING_TOWGS84 = 3743 +ER_TRANSFORM_TARGET_SRS_MISSING_TOWGS84 = 3744 +ER_TEMP_TABLE_PREVENTS_SWITCH_SESSION_BINLOG_FORMAT = 3745 +ER_TEMP_TABLE_PREVENTS_SWITCH_GLOBAL_BINLOG_FORMAT = 3746 +ER_RUNNING_APPLIER_PREVENTS_SWITCH_GLOBAL_BINLOG_FORMAT = 3747 +ER_CLIENT_GTID_UNSAFE_CREATE_DROP_TEMP_TABLE_IN_TRX_IN_SBR = 3748 +OBSOLETE_ER_XA_CANT_CREATE_MDL_BACKUP = 3749 +ER_TABLE_WITHOUT_PK = 3750 +ER_WARN_DATA_TRUNCATED_FUNCTIONAL_INDEX = 3751 +ER_WARN_DATA_OUT_OF_RANGE_FUNCTIONAL_INDEX = 3752 +ER_FUNCTIONAL_INDEX_ON_JSON_OR_GEOMETRY_FUNCTION = 3753 +ER_FUNCTIONAL_INDEX_REF_AUTO_INCREMENT = 3754 +ER_CANNOT_DROP_COLUMN_FUNCTIONAL_INDEX = 3755 +ER_FUNCTIONAL_INDEX_PRIMARY_KEY = 3756 +ER_FUNCTIONAL_INDEX_ON_LOB = 3757 +ER_FUNCTIONAL_INDEX_FUNCTION_IS_NOT_ALLOWED = 3758 +ER_FULLTEXT_FUNCTIONAL_INDEX = 3759 +ER_SPATIAL_FUNCTIONAL_INDEX = 3760 +ER_WRONG_KEY_COLUMN_FUNCTIONAL_INDEX = 3761 +ER_FUNCTIONAL_INDEX_ON_FIELD = 3762 +ER_GENERATED_COLUMN_NAMED_FUNCTION_IS_NOT_ALLOWED = 3763 +ER_GENERATED_COLUMN_ROW_VALUE = 3764 +ER_GENERATED_COLUMN_VARIABLES = 3765 +ER_DEPENDENT_BY_DEFAULT_GENERATED_VALUE = 3766 +ER_DEFAULT_VAL_GENERATED_NON_PRIOR = 3767 +ER_DEFAULT_VAL_GENERATED_REF_AUTO_INC = 3768 +ER_DEFAULT_VAL_GENERATED_FUNCTION_IS_NOT_ALLOWED = 3769 +ER_DEFAULT_VAL_GENERATED_NAMED_FUNCTION_IS_NOT_ALLOWED = 3770 +ER_DEFAULT_VAL_GENERATED_ROW_VALUE = 3771 +ER_DEFAULT_VAL_GENERATED_VARIABLES = 3772 +ER_DEFAULT_AS_VAL_GENERATED = 3773 +ER_UNSUPPORTED_ACTION_ON_DEFAULT_VAL_GENERATED = 3774 +ER_GTID_UNSAFE_ALTER_ADD_COL_WITH_DEFAULT_EXPRESSION = 3775 +ER_FK_CANNOT_CHANGE_ENGINE = 3776 +ER_WARN_DEPRECATED_USER_SET_EXPR = 3777 +ER_WARN_DEPRECATED_UTF8MB3_COLLATION = 3778 +ER_WARN_DEPRECATED_NESTED_COMMENT_SYNTAX = 3779 +ER_FK_INCOMPATIBLE_COLUMNS = 3780 +ER_GR_HOLD_WAIT_TIMEOUT = 3781 +ER_GR_HOLD_KILLED = 3782 +ER_GR_HOLD_MEMBER_STATUS_ERROR = 3783 +ER_RPL_ENCRYPTION_FAILED_TO_FETCH_KEY = 3784 +ER_RPL_ENCRYPTION_KEY_NOT_FOUND = 3785 +ER_RPL_ENCRYPTION_KEYRING_INVALID_KEY = 3786 +ER_RPL_ENCRYPTION_HEADER_ERROR = 3787 +ER_RPL_ENCRYPTION_FAILED_TO_ROTATE_LOGS = 3788 +ER_RPL_ENCRYPTION_KEY_EXISTS_UNEXPECTED = 3789 +ER_RPL_ENCRYPTION_FAILED_TO_GENERATE_KEY = 3790 +ER_RPL_ENCRYPTION_FAILED_TO_STORE_KEY = 3791 +ER_RPL_ENCRYPTION_FAILED_TO_REMOVE_KEY = 3792 +ER_RPL_ENCRYPTION_UNABLE_TO_CHANGE_OPTION = 3793 +ER_RPL_ENCRYPTION_MASTER_KEY_RECOVERY_FAILED = 3794 +ER_SLOW_LOG_MODE_IGNORED_WHEN_NOT_LOGGING_TO_FILE = 3795 +ER_GRP_TRX_CONSISTENCY_NOT_ALLOWED = 3796 +ER_GRP_TRX_CONSISTENCY_BEFORE = 3797 +ER_GRP_TRX_CONSISTENCY_AFTER_ON_TRX_BEGIN = 3798 +ER_GRP_TRX_CONSISTENCY_BEGIN_NOT_ALLOWED = 3799 +ER_FUNCTIONAL_INDEX_ROW_VALUE_IS_NOT_ALLOWED = 3800 +ER_RPL_ENCRYPTION_FAILED_TO_ENCRYPT = 3801 +ER_PAGE_TRACKING_NOT_STARTED = 3802 +ER_PAGE_TRACKING_RANGE_NOT_TRACKED = 3803 +ER_PAGE_TRACKING_CANNOT_PURGE = 3804 +ER_RPL_ENCRYPTION_CANNOT_ROTATE_BINLOG_MASTER_KEY = 3805 +ER_BINLOG_MASTER_KEY_RECOVERY_OUT_OF_COMBINATION = 3806 +ER_BINLOG_MASTER_KEY_ROTATION_FAIL_TO_OPERATE_KEY = 3807 +ER_BINLOG_MASTER_KEY_ROTATION_FAIL_TO_ROTATE_LOGS = 3808 +ER_BINLOG_MASTER_KEY_ROTATION_FAIL_TO_REENCRYPT_LOG = 3809 +ER_BINLOG_MASTER_KEY_ROTATION_FAIL_TO_CLEANUP_UNUSED_KEYS = 3810 +ER_BINLOG_MASTER_KEY_ROTATION_FAIL_TO_CLEANUP_AUX_KEY = 3811 +ER_NON_BOOLEAN_EXPR_FOR_CHECK_CONSTRAINT = 3812 +ER_COLUMN_CHECK_CONSTRAINT_REFERENCES_OTHER_COLUMN = 3813 +ER_CHECK_CONSTRAINT_NAMED_FUNCTION_IS_NOT_ALLOWED = 3814 +ER_CHECK_CONSTRAINT_FUNCTION_IS_NOT_ALLOWED = 3815 +ER_CHECK_CONSTRAINT_VARIABLES = 3816 +ER_CHECK_CONSTRAINT_ROW_VALUE = 3817 +ER_CHECK_CONSTRAINT_REFERS_AUTO_INCREMENT_COLUMN = 3818 +ER_CHECK_CONSTRAINT_VIOLATED = 3819 +ER_CHECK_CONSTRAINT_REFERS_UNKNOWN_COLUMN = 3820 +ER_CHECK_CONSTRAINT_NOT_FOUND = 3821 +ER_CHECK_CONSTRAINT_DUP_NAME = 3822 +ER_CHECK_CONSTRAINT_CLAUSE_USING_FK_REFER_ACTION_COLUMN = 3823 +WARN_UNENCRYPTED_TABLE_IN_ENCRYPTED_DB = 3824 +ER_INVALID_ENCRYPTION_REQUEST = 3825 +ER_CANNOT_SET_TABLE_ENCRYPTION = 3826 +ER_CANNOT_SET_DATABASE_ENCRYPTION = 3827 +ER_CANNOT_SET_TABLESPACE_ENCRYPTION = 3828 +ER_TABLESPACE_CANNOT_BE_ENCRYPTED = 3829 +ER_TABLESPACE_CANNOT_BE_DECRYPTED = 3830 +ER_TABLESPACE_TYPE_UNKNOWN = 3831 +ER_TARGET_TABLESPACE_UNENCRYPTED = 3832 +ER_CANNOT_USE_ENCRYPTION_CLAUSE = 3833 +ER_INVALID_MULTIPLE_CLAUSES = 3834 +ER_UNSUPPORTED_USE_OF_GRANT_AS = 3835 +ER_UKNOWN_AUTH_ID_OR_ACCESS_DENIED_FOR_GRANT_AS = 3836 +ER_DEPENDENT_BY_FUNCTIONAL_INDEX = 3837 +ER_PLUGIN_NOT_EARLY = 3838 +ER_INNODB_REDO_LOG_ARCHIVE_START_SUBDIR_PATH = 3839 +ER_INNODB_REDO_LOG_ARCHIVE_START_TIMEOUT = 3840 +ER_INNODB_REDO_LOG_ARCHIVE_DIRS_INVALID = 3841 +ER_INNODB_REDO_LOG_ARCHIVE_LABEL_NOT_FOUND = 3842 +ER_INNODB_REDO_LOG_ARCHIVE_DIR_EMPTY = 3843 +ER_INNODB_REDO_LOG_ARCHIVE_NO_SUCH_DIR = 3844 +ER_INNODB_REDO_LOG_ARCHIVE_DIR_CLASH = 3845 +ER_INNODB_REDO_LOG_ARCHIVE_DIR_PERMISSIONS = 3846 +ER_INNODB_REDO_LOG_ARCHIVE_FILE_CREATE = 3847 +ER_INNODB_REDO_LOG_ARCHIVE_ACTIVE = 3848 +ER_INNODB_REDO_LOG_ARCHIVE_INACTIVE = 3849 +ER_INNODB_REDO_LOG_ARCHIVE_FAILED = 3850 +ER_INNODB_REDO_LOG_ARCHIVE_SESSION = 3851 +ER_STD_REGEX_ERROR = 3852 +ER_INVALID_JSON_TYPE = 3853 +ER_CANNOT_CONVERT_STRING = 3854 +ER_DEPENDENT_BY_PARTITION_FUNC = 3855 +ER_WARN_DEPRECATED_FLOAT_AUTO_INCREMENT = 3856 +ER_RPL_CANT_STOP_SLAVE_WHILE_LOCKED_BACKUP = 3857 +ER_WARN_DEPRECATED_FLOAT_DIGITS = 3858 +ER_WARN_DEPRECATED_FLOAT_UNSIGNED = 3859 +ER_WARN_DEPRECATED_INTEGER_DISPLAY_WIDTH = 3860 +ER_WARN_DEPRECATED_ZEROFILL = 3861 +ER_CLONE_DONOR = 3862 +ER_CLONE_PROTOCOL = 3863 +ER_CLONE_DONOR_VERSION = 3864 +ER_CLONE_OS = 3865 +ER_CLONE_PLATFORM = 3866 +ER_CLONE_CHARSET = 3867 +ER_CLONE_CONFIG = 3868 +ER_CLONE_SYS_CONFIG = 3869 +ER_CLONE_PLUGIN_MATCH = 3870 +ER_CLONE_LOOPBACK = 3871 +ER_CLONE_ENCRYPTION = 3872 +ER_CLONE_DISK_SPACE = 3873 +ER_CLONE_IN_PROGRESS = 3874 +ER_CLONE_DISALLOWED = 3875 +ER_CANNOT_GRANT_ROLES_TO_ANONYMOUS_USER = 3876 +ER_SECONDARY_ENGINE_PLUGIN = 3877 +ER_SECOND_PASSWORD_CANNOT_BE_EMPTY = 3878 +ER_DB_ACCESS_DENIED = 3879 +ER_DA_AUTH_ID_WITH_SYSTEM_USER_PRIV_IN_MANDATORY_ROLES = 3880 +ER_DA_RPL_GTID_TABLE_CANNOT_OPEN = 3881 +ER_GEOMETRY_IN_UNKNOWN_LENGTH_UNIT = 3882 +ER_DA_PLUGIN_INSTALL_ERROR = 3883 +ER_NO_SESSION_TEMP = 3884 +ER_DA_UNKNOWN_ERROR_NUMBER = 3885 +ER_COLUMN_CHANGE_SIZE = 3886 +ER_REGEXP_INVALID_CAPTURE_GROUP_NAME = 3887 +ER_DA_SSL_LIBRARY_ERROR = 3888 +ER_SECONDARY_ENGINE = 3889 +ER_SECONDARY_ENGINE_DDL = 3890 +ER_INCORRECT_CURRENT_PASSWORD = 3891 +ER_MISSING_CURRENT_PASSWORD = 3892 +ER_CURRENT_PASSWORD_NOT_REQUIRED = 3893 +ER_PASSWORD_CANNOT_BE_RETAINED_ON_PLUGIN_CHANGE = 3894 +ER_CURRENT_PASSWORD_CANNOT_BE_RETAINED = 3895 +ER_PARTIAL_REVOKES_EXIST = 3896 +ER_CANNOT_GRANT_SYSTEM_PRIV_TO_MANDATORY_ROLE = 3897 +ER_XA_REPLICATION_FILTERS = 3898 +ER_UNSUPPORTED_SQL_MODE = 3899 +ER_REGEXP_INVALID_FLAG = 3900 +ER_PARTIAL_REVOKE_AND_DB_GRANT_BOTH_EXISTS = 3901 +ER_UNIT_NOT_FOUND = 3902 +ER_INVALID_JSON_VALUE_FOR_FUNC_INDEX = 3903 +ER_JSON_VALUE_OUT_OF_RANGE_FOR_FUNC_INDEX = 3904 +ER_EXCEEDED_MV_KEYS_NUM = 3905 +ER_EXCEEDED_MV_KEYS_SPACE = 3906 +ER_FUNCTIONAL_INDEX_DATA_IS_TOO_LONG = 3907 +ER_WRONG_MVI_VALUE = 3908 +ER_WARN_FUNC_INDEX_NOT_APPLICABLE = 3909 +ER_GRP_RPL_UDF_ERROR = 3910 +ER_UPDATE_GTID_PURGED_WITH_GR = 3911 +ER_GROUPING_ON_TIMESTAMP_IN_DST = 3912 +ER_TABLE_NAME_CAUSES_TOO_LONG_PATH = 3913 +ER_AUDIT_LOG_INSUFFICIENT_PRIVILEGE = 3914 +OBSOLETE_ER_AUDIT_LOG_PASSWORD_HAS_BEEN_COPIED = 3915 +ER_DA_GRP_RPL_STARTED_AUTO_REJOIN = 3916 +ER_SYSVAR_CHANGE_DURING_QUERY = 3917 +ER_GLOBSTAT_CHANGE_DURING_QUERY = 3918 +ER_GRP_RPL_MESSAGE_SERVICE_INIT_FAILURE = 3919 +ER_CHANGE_MASTER_WRONG_COMPRESSION_ALGORITHM_CLIENT = 3920 +ER_CHANGE_MASTER_WRONG_COMPRESSION_LEVEL_CLIENT = 3921 +ER_WRONG_COMPRESSION_ALGORITHM_CLIENT = 3922 +ER_WRONG_COMPRESSION_LEVEL_CLIENT = 3923 +ER_CHANGE_MASTER_WRONG_COMPRESSION_ALGORITHM_LIST_CLIENT = 3924 +ER_CLIENT_PRIVILEGE_CHECKS_USER_CANNOT_BE_ANONYMOUS = 3925 +ER_CLIENT_PRIVILEGE_CHECKS_USER_DOES_NOT_EXIST = 3926 +ER_CLIENT_PRIVILEGE_CHECKS_USER_CORRUPT = 3927 +ER_CLIENT_PRIVILEGE_CHECKS_USER_NEEDS_RPL_APPLIER_PRIV = 3928 +ER_WARN_DA_PRIVILEGE_NOT_REGISTERED = 3929 +ER_CLIENT_KEYRING_UDF_KEY_INVALID = 3930 +ER_CLIENT_KEYRING_UDF_KEY_TYPE_INVALID = 3931 +ER_CLIENT_KEYRING_UDF_KEY_TOO_LONG = 3932 +ER_CLIENT_KEYRING_UDF_KEY_TYPE_TOO_LONG = 3933 +ER_JSON_SCHEMA_VALIDATION_ERROR_WITH_DETAILED_REPORT = 3934 +ER_DA_UDF_INVALID_CHARSET_SPECIFIED = 3935 +ER_DA_UDF_INVALID_CHARSET = 3936 +ER_DA_UDF_INVALID_COLLATION = 3937 +ER_DA_UDF_INVALID_EXTENSION_ARGUMENT_TYPE = 3938 +ER_MULTIPLE_CONSTRAINTS_WITH_SAME_NAME = 3939 +ER_CONSTRAINT_NOT_FOUND = 3940 +ER_ALTER_CONSTRAINT_ENFORCEMENT_NOT_SUPPORTED = 3941 +ER_TABLE_VALUE_CONSTRUCTOR_MUST_HAVE_COLUMNS = 3942 +ER_TABLE_VALUE_CONSTRUCTOR_CANNOT_HAVE_DEFAULT = 3943 +ER_CLIENT_QUERY_FAILURE_INVALID_NON_ROW_FORMAT = 3944 +ER_REQUIRE_ROW_FORMAT_INVALID_VALUE = 3945 +ER_FAILED_TO_DETERMINE_IF_ROLE_IS_MANDATORY = 3946 +ER_FAILED_TO_FETCH_MANDATORY_ROLE_LIST = 3947 +ER_CLIENT_LOCAL_FILES_DISABLED = 3948 +ER_IMP_INCOMPATIBLE_CFG_VERSION = 3949 +ER_DA_OOM = 3950 +ER_DA_UDF_INVALID_ARGUMENT_TO_SET_CHARSET = 3951 +ER_DA_UDF_INVALID_RETURN_TYPE_TO_SET_CHARSET = 3952 +ER_MULTIPLE_INTO_CLAUSES = 3953 +ER_MISPLACED_INTO = 3954 +ER_USER_ACCESS_DENIED_FOR_USER_ACCOUNT_BLOCKED_BY_PASSWORD_LOCK = 3955 +ER_WARN_DEPRECATED_YEAR_UNSIGNED = 3956 +ER_CLONE_NETWORK_PACKET = 3957 +ER_SDI_OPERATION_FAILED_MISSING_RECORD = 3958 +ER_DEPENDENT_BY_CHECK_CONSTRAINT = 3959 +ER_GRP_OPERATION_NOT_ALLOWED_GR_MUST_STOP = 3960 +ER_WARN_DEPRECATED_JSON_TABLE_ON_ERROR_ON_EMPTY = 3961 +ER_WARN_DEPRECATED_INNER_INTO = 3962 +ER_WARN_DEPRECATED_VALUES_FUNCTION_ALWAYS_NULL = 3963 +ER_WARN_DEPRECATED_SQL_CALC_FOUND_ROWS = 3964 +ER_WARN_DEPRECATED_FOUND_ROWS = 3965 +ER_MISSING_JSON_VALUE = 3966 +ER_MULTIPLE_JSON_VALUES = 3967 +ER_HOSTNAME_TOO_LONG = 3968 +ER_WARN_CLIENT_DEPRECATED_PARTITION_PREFIX_KEY = 3969 +ER_GROUP_REPLICATION_USER_EMPTY_MSG = 3970 +ER_GROUP_REPLICATION_USER_MANDATORY_MSG = 3971 +ER_GROUP_REPLICATION_PASSWORD_LENGTH = 3972 +ER_SUBQUERY_TRANSFORM_REJECTED = 3973 +ER_DA_GRP_RPL_RECOVERY_ENDPOINT_FORMAT = 3974 +ER_DA_GRP_RPL_RECOVERY_ENDPOINT_INVALID = 3975 +ER_WRONG_VALUE_FOR_VAR_PLUS_ACTIONABLE_PART = 3976 +ER_STATEMENT_NOT_ALLOWED_AFTER_START_TRANSACTION = 3977 +ER_FOREIGN_KEY_WITH_ATOMIC_CREATE_SELECT = 3978 +ER_NOT_ALLOWED_WITH_START_TRANSACTION = 3979 +ER_INVALID_JSON_ATTRIBUTE = 3980 +ER_ENGINE_ATTRIBUTE_NOT_SUPPORTED = 3981 +ER_INVALID_USER_ATTRIBUTE_JSON = 3982 +ER_INNODB_REDO_DISABLED = 3983 +ER_INNODB_REDO_ARCHIVING_ENABLED = 3984 +ER_MDL_OUT_OF_RESOURCES = 3985 +ER_IMPLICIT_COMPARISON_FOR_JSON = 3986 +ER_FUNCTION_DOES_NOT_SUPPORT_CHARACTER_SET = 3987 +ER_IMPOSSIBLE_STRING_CONVERSION = 3988 +ER_SCHEMA_READ_ONLY = 3989 +ER_RPL_ASYNC_RECONNECT_GTID_MODE_OFF = 3990 +ER_RPL_ASYNC_RECONNECT_AUTO_POSITION_OFF = 3991 +ER_DISABLE_GTID_MODE_REQUIRES_ASYNC_RECONNECT_OFF = 3992 +ER_DISABLE_AUTO_POSITION_REQUIRES_ASYNC_RECONNECT_OFF = 3993 +ER_INVALID_PARAMETER_USE = 3994 +ER_CHARACTER_SET_MISMATCH = 3995 +ER_WARN_VAR_VALUE_CHANGE_NOT_SUPPORTED = 3996 +ER_INVALID_TIME_ZONE_INTERVAL = 3997 +ER_INVALID_CAST = 3998 +ER_HYPERGRAPH_NOT_SUPPORTED_YET = 3999 +ER_WARN_HYPERGRAPH_EXPERIMENTAL = 4000 +ER_DA_NO_ERROR_LOG_PARSER_CONFIGURED = 4001 +ER_DA_ERROR_LOG_TABLE_DISABLED = 4002 +ER_DA_ERROR_LOG_MULTIPLE_FILTERS = 4003 +ER_DA_CANT_OPEN_ERROR_LOG = 4004 +ER_USER_REFERENCED_AS_DEFINER = 4005 +ER_CANNOT_USER_REFERENCED_AS_DEFINER = 4006 +ER_REGEX_NUMBER_TOO_BIG = 4007 +ER_SPVAR_NONINTEGER_TYPE = 4008 +WARN_UNSUPPORTED_ACL_TABLES_READ = 4009 +ER_BINLOG_UNSAFE_ACL_TABLE_READ_IN_DML_DDL = 4010 +ER_STOP_REPLICA_MONITOR_IO_THREAD_TIMEOUT = 4011 +ER_STARTING_REPLICA_MONITOR_IO_THREAD = 4012 +ER_CANT_USE_ANONYMOUS_TO_GTID_WITH_GTID_MODE_NOT_ON = 4013 +ER_CANT_COMBINE_ANONYMOUS_TO_GTID_AND_AUTOPOSITION = 4014 +ER_ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_REQUIRES_GTID_MODE_ON = 4015 +ER_SQL_REPLICA_SKIP_COUNTER_USED_WITH_GTID_MODE_ON = 4016 +ER_USING_ASSIGN_GTIDS_TO_ANONYMOUS_TRANSACTIONS_AS_LOCAL_OR_UUID = 4017 +ER_CANT_SET_ANONYMOUS_TO_GTID_AND_WAIT_UNTIL_SQL_THD_AFTER_GTIDS = 4018 +ER_CANT_SET_SQL_AFTER_OR_BEFORE_GTIDS_WITH_ANONYMOUS_TO_GTID = 4019 +ER_ANONYMOUS_TO_GTID_UUID_SAME_AS_GROUP_NAME = 4020 +ER_CANT_USE_SAME_UUID_AS_GROUP_NAME = 4021 +ER_GRP_RPL_RECOVERY_CHANNEL_STILL_RUNNING = 4022 +ER_INNODB_INVALID_AUTOEXTEND_SIZE_VALUE = 4023 +ER_INNODB_INCOMPATIBLE_WITH_TABLESPACE = 4024 +ER_INNODB_AUTOEXTEND_SIZE_OUT_OF_RANGE = 4025 +ER_CANNOT_USE_AUTOEXTEND_SIZE_CLAUSE = 4026 +ER_ROLE_GRANTED_TO_ITSELF = 4027 +ER_TABLE_MUST_HAVE_A_VISIBLE_COLUMN = 4028 +ER_INNODB_COMPRESSION_FAILURE = 4029 +ER_WARN_ASYNC_CONN_FAILOVER_NETWORK_NAMESPACE = 4030 +ER_CLIENT_INTERACTION_TIMEOUT = 4031 +ER_INVALID_CAST_TO_GEOMETRY = 4032 +ER_INVALID_CAST_POLYGON_RING_DIRECTION = 4033 +ER_GIS_DIFFERENT_SRIDS_AGGREGATION = 4034 +ER_RELOAD_KEYRING_FAILURE = 4035 +ER_SDI_GET_KEYS_INVALID_TABLESPACE = 4036 +ER_CHANGE_RPL_SRC_WRONG_COMPRESSION_ALGORITHM_SIZE = 4037 +ER_WARN_DEPRECATED_TLS_VERSION_FOR_CHANNEL_CLI = 4038 +ER_CANT_USE_SAME_UUID_AS_VIEW_CHANGE_UUID = 4039 +ER_ANONYMOUS_TO_GTID_UUID_SAME_AS_VIEW_CHANGE_UUID = 4040 +ER_GRP_RPL_VIEW_CHANGE_UUID_FAIL_GET_VARIABLE = 4041 +ER_WARN_ADUIT_LOG_MAX_SIZE_AND_PRUNE_SECONDS = 4042 +ER_WARN_ADUIT_LOG_MAX_SIZE_CLOSE_TO_ROTATE_ON_SIZE = 4043 +ER_KERBEROS_CREATE_USER = 4044 +ER_INSTALL_PLUGIN_CONFLICT_CLIENT = 4045 +ER_DA_ERROR_LOG_COMPONENT_FLUSH_FAILED = 4046 +ER_WARN_SQL_AFTER_MTS_GAPS_GAP_NOT_CALCULATED = 4047 +ER_INVALID_ASSIGNMENT_TARGET = 4048 +ER_OPERATION_NOT_ALLOWED_ON_GR_SECONDARY = 4049 +ER_GRP_RPL_FAILOVER_CHANNEL_STATUS_PROPAGATION = 4050 +ER_WARN_AUDIT_LOG_FORMAT_UNIX_TIMESTAMP_ONLY_WHEN_JSON = 4051 +ER_INVALID_MFA_PLUGIN_SPECIFIED = 4052 +ER_IDENTIFIED_BY_UNSUPPORTED = 4053 +ER_INVALID_PLUGIN_FOR_REGISTRATION = 4054 +ER_PLUGIN_REQUIRES_REGISTRATION = 4055 +ER_MFA_METHOD_EXISTS = 4056 +ER_MFA_METHOD_NOT_EXISTS = 4057 +ER_AUTHENTICATION_POLICY_MISMATCH = 4058 +ER_PLUGIN_REGISTRATION_DONE = 4059 +ER_INVALID_USER_FOR_REGISTRATION = 4060 +ER_USER_REGISTRATION_FAILED = 4061 +ER_MFA_METHODS_INVALID_ORDER = 4062 +ER_MFA_METHODS_IDENTICAL = 4063 +ER_INVALID_MFA_OPERATIONS_FOR_PASSWORDLESS_USER = 4064 +ER_CHANGE_REPLICATION_SOURCE_NO_OPTIONS_FOR_GTID_ONLY = 4065 +ER_CHANGE_REP_SOURCE_CANT_DISABLE_REQ_ROW_FORMAT_WITH_GTID_ONLY = 4066 +ER_CHANGE_REP_SOURCE_CANT_DISABLE_AUTO_POSITION_WITH_GTID_ONLY = 4067 +ER_CHANGE_REP_SOURCE_CANT_DISABLE_GTID_ONLY_WITHOUT_POSITIONS = 4068 +ER_CHANGE_REP_SOURCE_CANT_DISABLE_AUTO_POS_WITHOUT_POSITIONS = 4069 +ER_CHANGE_REP_SOURCE_GR_CHANNEL_WITH_GTID_MODE_NOT_ON = 4070 +ER_CANT_USE_GTID_ONLY_WITH_GTID_MODE_NOT_ON = 4071 +ER_WARN_C_DISABLE_GTID_ONLY_WITH_SOURCE_AUTO_POS_INVALID_POS = 4072 +ER_DA_SSL_FIPS_MODE_ERROR = 4073 +CR_UNKNOWN_ERROR = 2000 +CR_SOCKET_CREATE_ERROR = 2001 +CR_CONNECTION_ERROR = 2002 +CR_CONN_HOST_ERROR = 2003 +CR_IPSOCK_ERROR = 2004 +CR_UNKNOWN_HOST = 2005 +CR_SERVER_GONE_ERROR = 2006 +CR_VERSION_ERROR = 2007 +CR_OUT_OF_MEMORY = 2008 +CR_WRONG_HOST_INFO = 2009 +CR_LOCALHOST_CONNECTION = 2010 +CR_TCP_CONNECTION = 2011 +CR_SERVER_HANDSHAKE_ERR = 2012 +CR_SERVER_LOST = 2013 +CR_COMMANDS_OUT_OF_SYNC = 2014 +CR_NAMEDPIPE_CONNECTION = 2015 +CR_NAMEDPIPEWAIT_ERROR = 2016 +CR_NAMEDPIPEOPEN_ERROR = 2017 +CR_NAMEDPIPESETSTATE_ERROR = 2018 +CR_CANT_READ_CHARSET = 2019 +CR_NET_PACKET_TOO_LARGE = 2020 +CR_EMBEDDED_CONNECTION = 2021 +CR_PROBE_SLAVE_STATUS = 2022 +CR_PROBE_SLAVE_HOSTS = 2023 +CR_PROBE_SLAVE_CONNECT = 2024 +CR_PROBE_MASTER_CONNECT = 2025 +CR_SSL_CONNECTION_ERROR = 2026 +CR_MALFORMED_PACKET = 2027 +CR_WRONG_LICENSE = 2028 +CR_NULL_POINTER = 2029 +CR_NO_PREPARE_STMT = 2030 +CR_PARAMS_NOT_BOUND = 2031 +CR_DATA_TRUNCATED = 2032 +CR_NO_PARAMETERS_EXISTS = 2033 +CR_INVALID_PARAMETER_NO = 2034 +CR_INVALID_BUFFER_USE = 2035 +CR_UNSUPPORTED_PARAM_TYPE = 2036 +CR_SHARED_MEMORY_CONNECTION = 2037 +CR_SHARED_MEMORY_CONNECT_REQUEST_ERROR = 2038 +CR_SHARED_MEMORY_CONNECT_ANSWER_ERROR = 2039 +CR_SHARED_MEMORY_CONNECT_FILE_MAP_ERROR = 2040 +CR_SHARED_MEMORY_CONNECT_MAP_ERROR = 2041 +CR_SHARED_MEMORY_FILE_MAP_ERROR = 2042 +CR_SHARED_MEMORY_MAP_ERROR = 2043 +CR_SHARED_MEMORY_EVENT_ERROR = 2044 +CR_SHARED_MEMORY_CONNECT_ABANDONED_ERROR = 2045 +CR_SHARED_MEMORY_CONNECT_SET_ERROR = 2046 +CR_CONN_UNKNOW_PROTOCOL = 2047 +CR_INVALID_CONN_HANDLE = 2048 +CR_UNUSED_1 = 2049 +CR_FETCH_CANCELED = 2050 +CR_NO_DATA = 2051 +CR_NO_STMT_METADATA = 2052 +CR_NO_RESULT_SET = 2053 +CR_NOT_IMPLEMENTED = 2054 +CR_SERVER_LOST_EXTENDED = 2055 +CR_STMT_CLOSED = 2056 +CR_NEW_STMT_METADATA = 2057 +CR_ALREADY_CONNECTED = 2058 +CR_AUTH_PLUGIN_CANNOT_LOAD = 2059 +CR_DUPLICATE_CONNECTION_ATTR = 2060 +CR_AUTH_PLUGIN_ERR = 2061 +CR_INSECURE_API_ERR = 2062 +CR_FILE_NAME_TOO_LONG = 2063 +CR_SSL_FIPS_MODE_ERR = 2064 +CR_DEPRECATED_COMPRESSION_NOT_SUPPORTED = 2065 +CR_COMPRESSION_WRONGLY_CONFIGURED = 2066 +CR_KERBEROS_USER_NOT_FOUND = 2067 +CR_LOAD_DATA_LOCAL_INFILE_REJECTED = 2068 +CR_LOAD_DATA_LOCAL_INFILE_REALPATH_FAIL = 2069 +CR_DNS_SRV_LOOKUP_FAILED = 2070 +CR_MANDATORY_TRACKER_NOT_FOUND = 2071 +CR_INVALID_FACTOR_NO = 2072 +# End MySQL Errors + +# Start X Plugin Errors +ER_X_BAD_MESSAGE = 5000 +ER_X_CAPABILITIES_PREPARE_FAILED = 5001 +ER_X_CAPABILITY_NOT_FOUND = 5002 +ER_X_INVALID_PROTOCOL_DATA = 5003 +ER_X_BAD_CONNECTION_SESSION_ATTRIBUTE_VALUE_LENGTH = 5004 +ER_X_BAD_CONNECTION_SESSION_ATTRIBUTE_KEY_LENGTH = 5005 +ER_X_BAD_CONNECTION_SESSION_ATTRIBUTE_EMPTY_KEY = 5006 +ER_X_BAD_CONNECTION_SESSION_ATTRIBUTE_LENGTH = 5007 +ER_X_BAD_CONNECTION_SESSION_ATTRIBUTE_TYPE = 5008 +ER_X_CAPABILITY_SET_NOT_ALLOWED = 5009 +ER_X_SERVICE_ERROR = 5010 +ER_X_SESSION = 5011 +ER_X_INVALID_ARGUMENT = 5012 +ER_X_MISSING_ARGUMENT = 5013 +ER_X_BAD_INSERT_DATA = 5014 +ER_X_CMD_NUM_ARGUMENTS = 5015 +ER_X_CMD_ARGUMENT_TYPE = 5016 +ER_X_CMD_ARGUMENT_VALUE = 5017 +ER_X_BAD_UPSERT_DATA = 5018 +ER_X_DUPLICATED_CAPABILITIES = 5019 +ER_X_CMD_ARGUMENT_OBJECT_EMPTY = 5020 +ER_X_CMD_INVALID_ARGUMENT = 5021 +ER_X_BAD_UPDATE_DATA = 5050 +ER_X_BAD_TYPE_OF_UPDATE = 5051 +ER_X_BAD_COLUMN_TO_UPDATE = 5052 +ER_X_BAD_MEMBER_TO_UPDATE = 5053 +ER_X_BAD_STATEMENT_ID = 5110 +ER_X_BAD_CURSOR_ID = 5111 +ER_X_BAD_SCHEMA = 5112 +ER_X_BAD_TABLE = 5113 +ER_X_BAD_PROJECTION = 5114 +ER_X_DOC_ID_MISSING = 5115 +ER_X_DUPLICATE_ENTRY = 5116 +ER_X_DOC_REQUIRED_FIELD_MISSING = 5117 +ER_X_PROJ_BAD_KEY_NAME = 5120 +ER_X_BAD_DOC_PATH = 5121 +ER_X_CURSOR_EXISTS = 5122 +ER_X_CURSOR_REACHED_EOF = 5123 +ER_X_PREPARED_STATMENT_CAN_HAVE_ONE_CURSOR = 5131 +ER_X_PREPARED_EXECUTE_ARGUMENT_NOT_SUPPORTED = 5133 +ER_X_PREPARED_EXECUTE_ARGUMENT_CONSISTENCY = 5134 +ER_X_EXPR_BAD_OPERATOR = 5150 +ER_X_EXPR_BAD_NUM_ARGS = 5151 +ER_X_EXPR_MISSING_ARG = 5152 +ER_X_EXPR_BAD_TYPE_VALUE = 5153 +ER_X_EXPR_BAD_VALUE = 5154 +ER_X_INVALID_COLLECTION = 5156 +ER_X_INVALID_ADMIN_COMMAND = 5157 +ER_X_EXPECT_NOT_OPEN = 5158 +ER_X_EXPECT_NO_ERROR_FAILED = 5159 +ER_X_EXPECT_BAD_CONDITION = 5160 +ER_X_EXPECT_BAD_CONDITION_VALUE = 5161 +ER_X_INVALID_NAMESPACE = 5162 +ER_X_BAD_NOTICE = 5163 +ER_X_CANNOT_DISABLE_NOTICE = 5164 +ER_X_BAD_CONFIGURATION = 5165 +ER_X_MYSQLX_ACCOUNT_MISSING_PERMISSIONS = 5167 +ER_X_EXPECT_FIELD_EXISTS_FAILED = 5168 +ER_X_BAD_LOCKING = 5169 +ER_X_FRAME_COMPRESSION_DISABLED = 5170 +ER_X_DECOMPRESSION_FAILED = 5171 +ER_X_BAD_COMPRESSED_FRAME = 5174 +ER_X_CAPABILITY_COMPRESSION_INVALID_ALGORITHM = 5175 +ER_X_CAPABILITY_COMPRESSION_INVALID_SERVER_STYLE = 5176 +ER_X_CAPABILITY_COMPRESSION_INVALID_CLIENT_STYLE = 5177 +ER_X_CAPABILITY_COMPRESSION_INVALID_OPTION = 5178 +ER_X_CAPABILITY_COMPRESSION_MISSING_REQUIRED_FIELDS = 5179 +ER_X_DOCUMENT_DOESNT_MATCH_EXPECTED_SCHEMA = 5180 +ER_X_COLLECTION_OPTION_DOESNT_EXISTS = 5181 +ER_X_INVALID_VALIDATION_SCHEMA = 5182 +# End X Plugin Errors diff --git a/server/venv/Lib/site-packages/mysql/connector/errors.py b/server/venv/Lib/site-packages/mysql/connector/errors.py new file mode 100644 index 0000000..bbfbddd --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/errors.py @@ -0,0 +1,393 @@ +# Copyright (c) 2009, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Python exceptions.""" +from typing import Dict, Mapping, Optional, Tuple, Type, Union + +from .locales import get_client_error +from .types import StrOrBytes +from .utils import read_bytes, read_int + + +class Error(Exception): + """Exception that is base class for all other error exceptions. + + See [1] for more details. + + References: + [1]: https://dev.mysql.com/doc/connector-python/en/connector-python-api-errors-error.html + """ + + def __init__( + self, + msg: Optional[str] = None, + errno: Optional[int] = None, + values: Optional[Tuple[Union[int, str], ...]] = None, + sqlstate: Optional[str] = None, + ) -> None: + super().__init__() + self.msg = msg + self._full_msg = self.msg + self.errno = errno or -1 + self.sqlstate = sqlstate + + if not self.msg and (2000 <= self.errno < 3000): + self.msg = get_client_error(self.errno) + if values is not None: + try: + self.msg = self.msg % values + except TypeError as err: + self.msg = f"{self.msg} (Warning: {err})" + elif not self.msg: + self._full_msg = self.msg = "Unknown error" + + if self.msg and self.errno != -1: + fields = {"errno": self.errno, "msg": self.msg} + if self.sqlstate: + fmt = "{errno} ({state}): {msg}" + fields["state"] = self.sqlstate + else: + fmt = "{errno}: {msg}" + self._full_msg = fmt.format(**fields) + + self.args = (self.errno, self._full_msg, self.sqlstate) + + def __str__(self) -> str: + return self._full_msg + + +class Warning(Exception): # pylint: disable=redefined-builtin + """Exception for important warnings""" + + +class InterfaceError(Error): + """Exception for errors related to the interface""" + + +class DatabaseError(Error): + """Exception for errors related to the database""" + + +class InternalError(DatabaseError): + """Exception for errors internal database errors""" + + +class OperationalError(DatabaseError): + """Exception for errors related to the database's operation""" + + +class ProgrammingError(DatabaseError): + """Exception for errors programming errors""" + + +class IntegrityError(DatabaseError): + """Exception for errors regarding relational integrity""" + + +class DataError(DatabaseError): + """Exception for errors reporting problems with processed data""" + + +class NotSupportedError(DatabaseError): + """Exception for errors when an unsupported database feature was used""" + + +class PoolError(Error): + """Exception for errors relating to connection pooling""" + + +class ConnectionTimeoutError(Error): + """ + Exception for errors related to the socket connection timing out while connecting with + the server + """ + + +class ReadTimeoutError(Error): + """Exception for errors relating to socket timing out while receiving data from the server""" + + DEFAULT_READ_TIMEOUT_ERROR_MSG: str = """ + The Read Operation timed out. As a consequence the current connection has been closed to avoid + any unstable behaviour, consider using the reconnect() option and continue with the current + session's workflow. + """ + + def __init__( + self, + msg: Optional[str] = None, + errno: Optional[int] = None, + values: Optional[Tuple[str, int]] = None, + sqlstate: Optional[str] = None, + ) -> None: + super().__init__(self.DEFAULT_READ_TIMEOUT_ERROR_MSG, errno, values, sqlstate) + + +class WriteTimeoutError(Error): + """Exception for errors relating to socket timing out while sending data to the server""" + + DEFAULT_WRITE_TIMEOUT_ERROR_MSG: str = """ + The Write Operation timed out. As a consequence the current connection has been closed to avoid + any unstable behaviour, consider using the reconnect() option to continue with the current + session's workflow. + """ + + def __init__( + self, + msg: Optional[str] = None, + errno: Optional[int] = None, + values: Optional[Tuple[str, int]] = None, + sqlstate: Optional[str] = None, + ) -> None: + super().__init__(self.DEFAULT_WRITE_TIMEOUT_ERROR_MSG, errno, values, sqlstate) + + +ErrorClassTypes = Union[ + Type[Error], + Type[InterfaceError], + Type[DatabaseError], + Type[InternalError], + Type[OperationalError], + Type[ProgrammingError], + Type[IntegrityError], + Type[DataError], + Type[NotSupportedError], + Type[PoolError], + Type[ConnectionTimeoutError], + Type[ReadTimeoutError], + Type[WriteTimeoutError], +] +ErrorTypes = Union[ + Error, + InterfaceError, + DatabaseError, + InternalError, + OperationalError, + ProgrammingError, + IntegrityError, + DataError, + NotSupportedError, + PoolError, + Warning, + ConnectionTimeoutError, + ReadTimeoutError, + WriteTimeoutError, +] +# _CUSTOM_ERROR_EXCEPTIONS holds custom exceptions and is used by the +# function custom_error_exception. _ERROR_EXCEPTIONS (at bottom of module) +# is similar, but hardcoded exceptions. +_CUSTOM_ERROR_EXCEPTIONS: Dict[int, ErrorClassTypes] = {} + + +def custom_error_exception( + error: Optional[Union[int, Dict[int, Optional[ErrorClassTypes]]]] = None, + exception: Optional[ErrorClassTypes] = None, +) -> Mapping[int, Optional[ErrorClassTypes]]: + """Defines custom exceptions for MySQL server errors. + + This function defines custom exceptions for MySQL server errors and + returns the current set customizations. + + To reset the customizations, simply supply an empty dictionary. + + Args: + error: Can be a MySQL Server error number or a dictionary in which case the + key is the server error number and the value is the exception to be raised. + exception: If `error` is a MySQL Server error number then you have to pass + also the exception class, otherwise you don't. + + Returns: + dictionary: Current set customizations. + + Examples: + ``` + import mysql.connector + from mysql.connector import errorcode + + # Server error 1028 should raise a DatabaseError + mysql.connector.custom_error_exception( + 1028, mysql.connector.DatabaseError) + + # Or using a dictionary: + mysql.connector.custom_error_exception({ + 1028: mysql.connector.DatabaseError, + 1029: mysql.connector.OperationalError, + }) + + # Reset + mysql.connector.custom_error_exception({}) + ``` + """ + global _CUSTOM_ERROR_EXCEPTIONS # pylint: disable=global-statement + + if isinstance(error, dict) and not error: + _CUSTOM_ERROR_EXCEPTIONS = {} + return _CUSTOM_ERROR_EXCEPTIONS + + if not error and not exception: + return _CUSTOM_ERROR_EXCEPTIONS + + if not isinstance(error, (int, dict)): + raise ValueError("The error argument should be either an integer or dictionary") + + if isinstance(error, int): + error = {error: exception} + + for errno, _exception in error.items(): + if not isinstance(errno, int): + raise ValueError("Error number should be an integer") + try: + if _exception is None or not issubclass(_exception, Exception): + raise TypeError + except TypeError as err: + raise ValueError("Exception should be subclass of Exception") from err + _CUSTOM_ERROR_EXCEPTIONS[errno] = _exception + + return _CUSTOM_ERROR_EXCEPTIONS + + +def get_mysql_exception( + errno: int, + msg: Optional[str] = None, + sqlstate: Optional[str] = None, + warning: Optional[bool] = False, +) -> ErrorTypes: + """Get the exception matching the MySQL error + + This function will return an exception based on the SQLState. The given + message will be passed on in the returned exception. + + The exception returned can be customized using the + mysql.connector.custom_error_exception() function. + + Returns an Exception + """ + try: + return _CUSTOM_ERROR_EXCEPTIONS[errno](msg=msg, errno=errno, sqlstate=sqlstate) + except KeyError: + # Error was not mapped to particular exception + pass + + try: + return _ERROR_EXCEPTIONS[errno](msg=msg, errno=errno, sqlstate=sqlstate) + except KeyError: + # Error was not mapped to particular exception + pass + + if not sqlstate: + if warning: + return Warning(errno, msg) + return DatabaseError(msg=msg, errno=errno) + + try: + return _SQLSTATE_CLASS_EXCEPTION[sqlstate[0:2]]( + msg=msg, errno=errno, sqlstate=sqlstate + ) + except KeyError: + # Return default InterfaceError + return DatabaseError(msg=msg, errno=errno, sqlstate=sqlstate) + + +def get_exception(packet: bytes) -> ErrorTypes: + """Returns an exception object based on the MySQL error + + Returns an exception object based on the MySQL error in the given + packet. + + Returns an Error-Object. + """ + errno = errmsg = None + + try: + if packet[4] != 255: + raise ValueError("Packet is not an error packet") + except IndexError as err: + return InterfaceError(f"Failed getting Error information ({err})") + + sqlstate: Optional[StrOrBytes] = None + try: + packet = packet[5:] + packet, errno = read_int(packet, 2) + if packet[0] != 35: + # Error without SQLState + if isinstance(packet, (bytes, bytearray)): + errmsg = packet.decode("utf8") + else: + errmsg = packet + else: + packet, sqlstate = read_bytes(packet[1:], 5) + sqlstate = sqlstate.decode("utf8") + errmsg = packet.decode("utf8") + except (IndexError, UnicodeError) as err: + return InterfaceError(f"Failed getting Error information ({err})") + return get_mysql_exception(errno, errmsg, sqlstate) # type: ignore[arg-type] + + +_SQLSTATE_CLASS_EXCEPTION: Dict[str, ErrorClassTypes] = { + "02": DataError, # no data + "07": DatabaseError, # dynamic SQL error + "08": OperationalError, # connection exception + "0A": NotSupportedError, # feature not supported + "21": DataError, # cardinality violation + "22": DataError, # data exception + "23": IntegrityError, # integrity constraint violation + "24": ProgrammingError, # invalid cursor state + "25": ProgrammingError, # invalid transaction state + "26": ProgrammingError, # invalid SQL statement name + "27": ProgrammingError, # triggered data change violation + "28": ProgrammingError, # invalid authorization specification + "2A": ProgrammingError, # direct SQL syntax error or access rule violation + "2B": DatabaseError, # dependent privilege descriptors still exist + "2C": ProgrammingError, # invalid character set name + "2D": DatabaseError, # invalid transaction termination + "2E": DatabaseError, # invalid connection name + "33": DatabaseError, # invalid SQL descriptor name + "34": ProgrammingError, # invalid cursor name + "35": ProgrammingError, # invalid condition number + "37": ProgrammingError, # dynamic SQL syntax error or access rule violation + "3C": ProgrammingError, # ambiguous cursor name + "3D": ProgrammingError, # invalid catalog name + "3F": ProgrammingError, # invalid schema name + "40": InternalError, # transaction rollback + "42": ProgrammingError, # syntax error or access rule violation + "44": InternalError, # with check option violation + "HZ": OperationalError, # remote database access + "XA": IntegrityError, + "0K": OperationalError, + "HY": DatabaseError, # default when no SQLState provided by MySQL server +} + +_ERROR_EXCEPTIONS: Dict[int, ErrorClassTypes] = { + 1243: ProgrammingError, + 1210: ProgrammingError, + 2002: InterfaceError, + 2013: OperationalError, + 2049: NotSupportedError, + 2055: OperationalError, + 2061: InterfaceError, + 2026: InterfaceError, +} diff --git a/server/venv/Lib/site-packages/mysql/connector/locales/__init__.py b/server/venv/Lib/site-packages/mysql/connector/locales/__init__.py new file mode 100644 index 0000000..b20b41b --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/locales/__init__.py @@ -0,0 +1,80 @@ +# Copyright (c) 2012, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Translations.""" + +from typing import List, Optional, Union + +__all__: List[str] = ["get_client_error"] + +from .. import errorcode + + +def get_client_error(error: Union[int, str], language: str = "eng") -> Optional[str]: + """Lookup client error + + This function will lookup the client error message based on the given + error and return the error message. If the error was not found, + None will be returned. + + Error can be either an integer or a string. For example: + error: 2000 + error: CR_UNKNOWN_ERROR + + The language attribute can be used to retrieve a localized message, when + available. + + Returns a string or None. + """ + try: + tmp = __import__( + f"mysql.connector.locales.{language}", + globals(), + locals(), + ["client_error"], + ) + except ImportError: + raise ImportError( + f"No localization support for language '{language}'" + ) from None + client_error = tmp.client_error + + if isinstance(error, int): + errno = error + for key, value in errorcode.__dict__.items(): + if value == errno: + error = key + break + + if isinstance(error, (str)): + try: + return getattr(client_error, error) + except AttributeError: + return None + + raise ValueError("error argument needs to be either an integer or string") diff --git a/server/venv/Lib/site-packages/mysql/connector/locales/eng/__init__.py b/server/venv/Lib/site-packages/mysql/connector/locales/eng/__init__.py new file mode 100644 index 0000000..bb506b4 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/locales/eng/__init__.py @@ -0,0 +1,30 @@ +# Copyright (c) 2012, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""English Content +""" diff --git a/server/venv/Lib/site-packages/mysql/connector/locales/eng/client_error.py b/server/venv/Lib/site-packages/mysql/connector/locales/eng/client_error.py new file mode 100644 index 0000000..44a68ab --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/locales/eng/client_error.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2013, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""MySQL Error Messages.""" + +# This file was auto-generated. +_GENERATED_ON = "2021-08-11" +_MYSQL_VERSION = (8, 0, 27) + +# pylint: disable=line-too-long +# Start MySQL Error messages +CR_UNKNOWN_ERROR = "Unknown MySQL error" +CR_SOCKET_CREATE_ERROR = "Can't create UNIX socket (%s)" +CR_CONNECTION_ERROR = ( + "Can't connect to local MySQL server through socket '%-.100s' (%s)" +) +CR_CONN_HOST_ERROR = "Can't connect to MySQL server on '%-.100s:%u' (%s)" +CR_IPSOCK_ERROR = "Can't create TCP/IP socket (%s)" +CR_UNKNOWN_HOST = "Unknown MySQL server host '%-.100s' (%s)" +CR_SERVER_GONE_ERROR = "MySQL server has gone away" +CR_VERSION_ERROR = "Protocol mismatch; server version = %s, client version = %s" +CR_OUT_OF_MEMORY = "MySQL client ran out of memory" +CR_WRONG_HOST_INFO = "Wrong host info" +CR_LOCALHOST_CONNECTION = "Localhost via UNIX socket" +CR_TCP_CONNECTION = "%-.100s via TCP/IP" +CR_SERVER_HANDSHAKE_ERR = "Error in server handshake" +CR_SERVER_LOST = "Lost connection to MySQL server during query" +CR_COMMANDS_OUT_OF_SYNC = "Commands out of sync; you can't run this command now" +CR_NAMEDPIPE_CONNECTION = "Named pipe: %-.32s" +CR_NAMEDPIPEWAIT_ERROR = "Can't wait for named pipe to host: %-.64s pipe: %-.32s (%s)" +CR_NAMEDPIPEOPEN_ERROR = "Can't open named pipe to host: %-.64s pipe: %-.32s (%s)" +CR_NAMEDPIPESETSTATE_ERROR = ( + "Can't set state of named pipe to host: %-.64s pipe: %-.32s (%s)" +) +CR_CANT_READ_CHARSET = "Can't initialize character set %-.32s (path: %-.100s)" +CR_NET_PACKET_TOO_LARGE = "Got packet bigger than 'max_allowed_packet' bytes" +CR_EMBEDDED_CONNECTION = "Embedded server" +CR_PROBE_SLAVE_STATUS = "Error on SHOW SLAVE STATUS:" +CR_PROBE_SLAVE_HOSTS = "Error on SHOW SLAVE HOSTS:" +CR_PROBE_SLAVE_CONNECT = "Error connecting to slave:" +CR_PROBE_MASTER_CONNECT = "Error connecting to master:" +CR_SSL_CONNECTION_ERROR = "SSL connection error: %-.100s" +CR_MALFORMED_PACKET = "Malformed packet" +CR_WRONG_LICENSE = "This client library is licensed only for use with MySQL servers having '%s' license" +CR_NULL_POINTER = "Invalid use of null pointer" +CR_NO_PREPARE_STMT = "Statement not prepared" +CR_PARAMS_NOT_BOUND = "No data supplied for parameters in prepared statement" +CR_DATA_TRUNCATED = "Data truncated" +CR_NO_PARAMETERS_EXISTS = "No parameters exist in the statement" +CR_INVALID_PARAMETER_NO = "Invalid parameter number" +CR_INVALID_BUFFER_USE = ( + "Can't send long data for non-string/non-binary data types (parameter: %s)" +) +CR_UNSUPPORTED_PARAM_TYPE = "Using unsupported buffer type: %s (parameter: %s)" +CR_SHARED_MEMORY_CONNECTION = "Shared memory: %-.100s" +CR_SHARED_MEMORY_CONNECT_REQUEST_ERROR = ( + "Can't open shared memory; client could not create request event (%s)" +) +CR_SHARED_MEMORY_CONNECT_ANSWER_ERROR = ( + "Can't open shared memory; no answer event received from server (%s)" +) +CR_SHARED_MEMORY_CONNECT_FILE_MAP_ERROR = ( + "Can't open shared memory; server could not allocate file mapping (%s)" +) +CR_SHARED_MEMORY_CONNECT_MAP_ERROR = ( + "Can't open shared memory; server could not get pointer to file mapping (%s)" +) +CR_SHARED_MEMORY_FILE_MAP_ERROR = ( + "Can't open shared memory; client could not allocate file mapping (%s)" +) +CR_SHARED_MEMORY_MAP_ERROR = ( + "Can't open shared memory; client could not get pointer to file mapping (%s)" +) +CR_SHARED_MEMORY_EVENT_ERROR = ( + "Can't open shared memory; client could not create %s event (%s)" +) +CR_SHARED_MEMORY_CONNECT_ABANDONED_ERROR = ( + "Can't open shared memory; no answer from server (%s)" +) +CR_SHARED_MEMORY_CONNECT_SET_ERROR = ( + "Can't open shared memory; cannot send request event to server (%s)" +) +CR_CONN_UNKNOW_PROTOCOL = "Wrong or unknown protocol" +CR_INVALID_CONN_HANDLE = "Invalid connection handle" +CR_UNUSED_1 = "Connection using old (pre-4.1.1) authentication protocol refused (client option 'secure_auth' enabled)" +CR_FETCH_CANCELED = "Row retrieval was canceled by mysql_stmt_close() call" +CR_NO_DATA = "Attempt to read column without prior row fetch" +CR_NO_STMT_METADATA = "Prepared statement contains no metadata" +CR_NO_RESULT_SET = ( + "Attempt to read a row while there is no result set associated with the statement" +) +CR_NOT_IMPLEMENTED = "This feature is not implemented yet" +CR_SERVER_LOST_EXTENDED = "Lost connection to MySQL server at '%s', system error: %s" +CR_STMT_CLOSED = "Statement closed indirectly because of a preceding %s() call" +CR_NEW_STMT_METADATA = "The number of columns in the result set differs from the number of bound buffers. You must reset the statement, rebind the result set columns, and execute the statement again" +CR_ALREADY_CONNECTED = ( + "This handle is already connected. Use a separate handle for each connection." +) +CR_AUTH_PLUGIN_CANNOT_LOAD = "Authentication plugin '%s' cannot be loaded: %s" +CR_DUPLICATE_CONNECTION_ATTR = "There is an attribute with the same name already" +CR_AUTH_PLUGIN_ERR = "Authentication plugin '%s' reported error: %s" +CR_INSECURE_API_ERR = "Insecure API function call: '%s' Use instead: '%s'" +CR_FILE_NAME_TOO_LONG = "File name is too long" +CR_SSL_FIPS_MODE_ERR = "Set FIPS mode ON/STRICT failed" +CR_DEPRECATED_COMPRESSION_NOT_SUPPORTED = ( + "Compression protocol not supported with asynchronous protocol" +) +CR_COMPRESSION_WRONGLY_CONFIGURED = ( + "Connection failed due to wrongly configured compression algorithm" +) +CR_KERBEROS_USER_NOT_FOUND = ( + "SSO user not found, Please perform SSO authentication using kerberos." +) +CR_LOAD_DATA_LOCAL_INFILE_REJECTED = ( + "LOAD DATA LOCAL INFILE file request rejected due to restrictions on access." +) +CR_LOAD_DATA_LOCAL_INFILE_REALPATH_FAIL = ( + "Determining the real path for '%s' failed with error (%s): %s" +) +CR_DNS_SRV_LOOKUP_FAILED = "DNS SRV lookup failed with error : %s" +CR_MANDATORY_TRACKER_NOT_FOUND = ( + "Client does not recognise tracker type %s marked as mandatory by server." +) +CR_INVALID_FACTOR_NO = "Invalid first argument for MYSQL_OPT_USER_PASSWORD option. Valid value should be between 1 and 3 inclusive." +# End MySQL Error messages diff --git a/server/venv/Lib/site-packages/mysql/connector/logger.py b/server/venv/Lib/site-packages/mysql/connector/logger.py new file mode 100644 index 0000000..b67de47 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/logger.py @@ -0,0 +1,33 @@ +# Copyright (c) 2022, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Setup of the `mysql.connector` logger.""" + +import logging + +logger = logging.getLogger("mysql.connector") diff --git a/server/venv/Lib/site-packages/mysql/connector/network.py b/server/venv/Lib/site-packages/mysql/connector/network.py new file mode 100644 index 0000000..98615c1 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/network.py @@ -0,0 +1,811 @@ +# Copyright (c) 2012, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Module implementing low-level socket communication with MySQL servers.""" + +# pylint: disable=overlapping-except + +import socket +import struct +import warnings +import zlib + +from abc import ABC, abstractmethod +from collections import deque +from typing import Any, Deque, List, Optional, Tuple, Union + +try: + import ssl + + TLS_VERSIONS = { + "TLSv1": ssl.PROTOCOL_TLSv1, + "TLSv1.1": ssl.PROTOCOL_TLSv1_1, + "TLSv1.2": ssl.PROTOCOL_TLSv1_2, + "TLSv1.3": ssl.PROTOCOL_TLS, + } + TLS_V1_3_SUPPORTED = hasattr(ssl, "HAS_TLSv1_3") and ssl.HAS_TLSv1_3 +except ImportError: + # If import fails, we don't have SSL support. + TLS_V1_3_SUPPORTED = False + ssl = None + +from .errors import ( + ConnectionTimeoutError, + InterfaceError, + NotSupportedError, + OperationalError, + ProgrammingError, + ReadTimeoutError, + WriteTimeoutError, +) + +MIN_COMPRESS_LENGTH = 50 +MAX_PAYLOAD_LENGTH = 2**24 - 1 +PACKET_HEADER_LENGTH = 4 +COMPRESSED_PACKET_HEADER_LENGTH = 7 + + +def _strioerror(err: IOError) -> str: + """Reformat the IOError error message. + + This function reformats the IOError error message. + """ + return str(err) if not err.errno else f"Errno {err.errno}: {err.strerror}" + + +class NetworkBroker(ABC): + """Broker class interface. + + The network object is a broker used as a delegate by a socket object. Whenever the + socket wants to deliver or get packets to or from the MySQL server it needs to rely + on its network broker (netbroker). + + The netbroker sends `payloads` and receives `packets`. + + A packet is a bytes sequence, it has a header and body (referred to as payload). + The first `PACKET_HEADER_LENGTH` or `COMPRESSED_PACKET_HEADER_LENGTH` + (as appropriate) bytes correspond to the `header`, the remaining ones represent the + `payload`. + + The maximum payload length allowed to be sent per packet to the server is + `MAX_PAYLOAD_LENGTH`. When `send` is called with a payload whose length is greater + than `MAX_PAYLOAD_LENGTH` the netbroker breaks it down into packets, so the caller + of `send` can provide payloads of arbitrary length. + + Finally, data received by the netbroker comes directly from the server, expect to + get a packet for each call to `recv`. The received packet contains a header and + payload, the latter respecting `MAX_PAYLOAD_LENGTH`. + """ + + @abstractmethod + def send( + self, + sock: socket.socket, + address: str, + payload: bytes, + packet_number: Optional[int] = None, + compressed_packet_number: Optional[int] = None, + ) -> None: + """Send `payload` to the MySQL server. + + If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is + broken down into packets. + + Args: + sock: Object holding the socket connection. + address: Socket's location. + payload: Packet's body to send. + packet_number: Sequence id (packet ID) to attach to the header when sending + plain packets. + compressed_packet_number: Same as `packet_number` but used when sending + compressed packets. + + Raises: + :class:`OperationalError`: If something goes wrong while sending packets to + the MySQL server. + """ + + @abstractmethod + def recv(self, sock: socket.socket, address: str) -> bytearray: + """Get the next available packet from the MySQL server. + + Args: + sock: Object holding the socket connection. + address: Socket's location. + + Returns: + packet: A packet from the MySQL server. + + Raises: + :class:`OperationalError`: If something goes wrong while receiving packets + from the MySQL server. + :class:`InterfaceError`: If something goes wrong while receiving packets + from the MySQL server. + """ + + +class NetworkBrokerPlain(NetworkBroker): + """Broker class for MySQL socket communication.""" + + def __init__(self) -> None: + self._pktnr: int = -1 # packet number + + def _set_next_pktnr(self) -> None: + """Increment packet id.""" + self._pktnr = (self._pktnr + 1) % 256 + + def _send_pkt(self, sock: socket.socket, address: str, pkt: bytes) -> None: + """Write packet to the comm channel.""" + try: + sock.sendall(pkt) + except (socket.timeout, TimeoutError) as err: + raise WriteTimeoutError(errno=3024) from err + except IOError as err: + raise OperationalError( + errno=2055, values=(address, _strioerror(err)) + ) from err + except AttributeError as err: + raise OperationalError(errno=2006) from err + + def _recv_chunk(self, sock: socket.socket, size: int = 0) -> bytearray: + """Read `size` bytes from the comm channel.""" + pkt = bytearray(size) + pkt_view = memoryview(pkt) + while size: + read = sock.recv_into(pkt_view, size) + if read == 0 and size > 0: + raise InterfaceError(errno=2013) + pkt_view = pkt_view[read:] + size -= read + return pkt + + def send( + self, + sock: socket.socket, + address: str, + payload: bytes, + packet_number: Optional[int] = None, + compressed_packet_number: Optional[int] = None, + ) -> None: + """Send payload to the MySQL server. + + If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is + broken down into packets. + """ + if packet_number is None: + self._set_next_pktnr() + else: + self._pktnr = packet_number + + # If the payload is larger than or equal to MAX_PAYLOAD_LENGTH + # the length is set to 2^24 - 1 (ff ff ff) and additional + # packets are sent with the rest of the payload until the + # payload of a packet is less than MAX_PAYLOAD_LENGTH. + if len(payload) >= MAX_PAYLOAD_LENGTH: + offset = 0 + for _ in range(len(payload) // MAX_PAYLOAD_LENGTH): + # payload_len, sequence_id, payload + self._send_pkt( + sock, + address, + b"\xff\xff\xff" + + struct.pack(" bytearray: + """Receive `one` packet from the MySQL server.""" + try: + # Read the header of the MySQL packet + header = self._recv_chunk(sock, size=PACKET_HEADER_LENGTH) + + # Pull the payload length and sequence id + payload_len, self._pktnr = ( + struct.unpack(" None: + super().__init__() + self._compressed_pktnr = -1 + self._queue_read: Deque[bytearray] = deque() + + @staticmethod + def _prepare_packets(payload: bytes, pktnr: int) -> List[bytes]: + """Prepare a payload for sending to the MySQL server.""" + pkts = [] + + # If the payload is larger than or equal to MAX_PAYLOAD_LENGTH + # the length is set to 2^24 - 1 (ff ff ff) and additional + # packets are sent with the rest of the payload until the + # payload of a packet is less than MAX_PAYLOAD_LENGTH. + if len(payload) >= MAX_PAYLOAD_LENGTH: + offset = 0 + for _ in range(len(payload) // MAX_PAYLOAD_LENGTH): + # payload length + sequence id + payload + pkts.append( + b"\xff\xff\xff" + + struct.pack(" None: + """Increment packet id.""" + self._compressed_pktnr = (self._compressed_pktnr + 1) % 256 + + def _send_pkt(self, sock: socket.socket, address: str, pkt: bytes) -> None: + """Compress packet and write it to the comm channel.""" + compressed_pkt = zlib.compress(pkt) + pkt = ( + struct.pack(" None: + """Send `payload` as compressed packets to the MySQL server. + + If provided a payload whose length is greater than `MAX_PAYLOAD_LENGTH`, it is + broken down into packets. + """ + # get next packet numbers + if packet_number is None: + self._set_next_pktnr() + else: + self._pktnr = packet_number + if compressed_packet_number is None: + self._set_next_compressed_pktnr() + else: + self._compressed_pktnr = compressed_packet_number + + payload_prep = bytearray(b"").join(self._prepare_packets(payload, self._pktnr)) + if len(payload) >= MAX_PAYLOAD_LENGTH - PACKET_HEADER_LENGTH: + # sending a MySQL payload of the size greater or equal to 2^24 - 5 + # via compression leads to at least one extra compressed packet + # WHY? let's say len(payload) is MAX_PAYLOAD_LENGTH - 3; when preparing + # the payload, a header of size PACKET_HEADER_LENGTH is pre-appended + # to the payload. This means that len(payload_prep) is + # MAX_PAYLOAD_LENGTH - 3 + PACKET_HEADER_LENGTH = MAX_PAYLOAD_LENGTH + 1 + # surpassing the maximum allowed payload size per packet. + offset = 0 + + # send several MySQL packets + for _ in range(len(payload_prep) // MAX_PAYLOAD_LENGTH): + self._send_pkt( + sock, address, payload_prep[offset : offset + MAX_PAYLOAD_LENGTH] + ) + self._set_next_compressed_pktnr() + offset += MAX_PAYLOAD_LENGTH + self._send_pkt(sock, address, payload_prep[offset:]) + else: + # send one MySQL packet + # For small packets it may be too costly to compress the packet. + # Usually payloads less than 50 bytes (MIN_COMPRESS_LENGTH) + # aren't compressed (see MySQL source code Documentation). + if len(payload) > MIN_COMPRESS_LENGTH: + # perform compression + self._send_pkt(sock, address, payload_prep) + else: + # skip compression + super()._send_pkt( + sock, + address, + struct.pack(" None: + """Handle reading of a compressed packet.""" + # compressed_pll stands for compressed payload length. + # Recalling that if uncompressed payload length == 0, the packet + # comes in uncompressed, so no decompression is needed. + compressed_pkt = super()._recv_chunk(sock, size=compressed_pll) + pkt = ( + compressed_pkt + if uncompressed_pll == 0 + else bytearray(zlib.decompress(compressed_pkt)) + ) + + offset = 0 + while offset < len(pkt): + # pll stands for payload length + pll = struct.unpack( + " len(pkt) - offset: + # More bytes need to be consumed + # Read the header of the next MySQL packet + header = super()._recv_chunk(sock, size=COMPRESSED_PACKET_HEADER_LENGTH) + + # compressed payload length, sequence id, uncompressed payload length + ( + compressed_pll, + self._compressed_pktnr, + uncompressed_pll, + ) = ( + struct.unpack(" bytearray: + """Receive `one` or `several` packets from the MySQL server, enqueue them, and + return the packet at the head. + """ + if not self._queue_read: + try: + # Read the header of the next MySQL packet + header = super()._recv_chunk(sock, size=COMPRESSED_PACKET_HEADER_LENGTH) + + # compressed payload length, sequence id, uncompressed payload length + ( + compressed_pll, + self._compressed_pktnr, + uncompressed_pll, + ) = ( + struct.unpack(" None: + """Network layer where transactions are made with plain (uncompressed) packets + is enabled by default. + """ + # holds the socket connection + self.sock: Optional[socket.socket] = None + self._connection_timeout: Optional[int] = None + self.server_host: Optional[str] = None + self._netbroker: NetworkBroker = NetworkBrokerPlain() + + def switch_to_compressed_mode(self) -> None: + """Enable network layer where transactions are made with compressed packets.""" + self._netbroker = NetworkBrokerCompressed() + + def shutdown(self) -> None: + """Shut down the socket before closing it.""" + try: + self.sock.shutdown(socket.SHUT_RDWR) + self.sock.close() + except (AttributeError, OSError): + pass + + def close_connection(self) -> None: + """Close the socket.""" + try: + self.sock.close() + except (AttributeError, OSError): + pass + + def __del__(self) -> None: + self.shutdown() + + def set_connection_timeout(self, timeout: Optional[int]) -> None: + """Set the connection timeout.""" + self._connection_timeout = timeout + if self.sock: + self.sock.settimeout(timeout) + + def switch_to_ssl(self, ssl_context: Any, host: str) -> None: + """Upgrade an existing connection to TLS. + + Args: + ssl_context (ssl.SSLContext): The SSL Context to be used. + host (str): Server host name. + + Returns: + None. + + Raises: + ProgrammingError: If the transport does not expose the socket instance. + NotSupportedError: If Python installation has no SSL support. + """ + # Ensure that self.sock is already created + assert self.sock is not None + + if self.sock.family == 1: # socket.AF_UNIX + raise ProgrammingError("SSL is not supported when using Unix sockets") + + if ssl is None: + raise NotSupportedError("Python installation has no SSL support") + + try: + self.sock = ssl_context.wrap_socket(self.sock, server_hostname=host) + except NameError as err: + raise NotSupportedError("Python installation has no SSL support") from err + except (ssl.SSLError, IOError) as err: + raise InterfaceError( + errno=2055, values=(self.address, _strioerror(err)) + ) from err + except ssl.CertificateError as err: + raise InterfaceError(str(err)) from err + except NotImplementedError as err: + raise InterfaceError(str(err)) from err + + def build_ssl_context( + self, + ssl_ca: Optional[str] = None, + ssl_cert: Optional[str] = None, + ssl_key: Optional[str] = None, + ssl_verify_cert: Optional[bool] = False, + ssl_verify_identity: Optional[bool] = False, + tls_versions: Optional[List[str]] = None, + tls_cipher_suites: Optional[List[str]] = None, + ) -> Any: + """Build a SSLContext. + + Args: + ssl_ca: Certificate authority, opptional. + ssl_cert: SSL certificate, optional. + ssl_key: Private key, optional. + ssl_verify_cert: Verify the SSL certificate if `True`. + ssl_verify_identity: Verify host identity if `True`. + tls_versions: TLS protocol versions, optional. + tls_cipher_suites: Set of steps that helps to establish a secure connection. + + Returns: + ssl_context (ssl.SSLContext): An SSL Context ready be used. + + Raises: + NotSupportedError: Python installation has no SSL support. + InterfaceError: Socket undefined or invalid ssl data. + """ + tls_version: Optional[str] = None + + if not self.sock: + raise InterfaceError(errno=2048) + + if ssl is None: + raise NotSupportedError("Python installation has no SSL support") + + if tls_versions is None: + tls_versions = [] + + if tls_cipher_suites is None: + tls_cipher_suites = [] + + try: + if tls_versions: + tls_versions.sort(reverse=True) + tls_version = tls_versions[0] + ssl_protocol = TLS_VERSIONS[tls_version] + context = ssl.SSLContext(ssl_protocol) + + if tls_version == "TLSv1.3": + if "TLSv1.2" not in tls_versions: + context.options |= ssl.OP_NO_TLSv1_2 + if "TLSv1.1" not in tls_versions: + context.options |= ssl.OP_NO_TLSv1_1 + if "TLSv1" not in tls_versions: + context.options |= ssl.OP_NO_TLSv1 + else: + # `check_hostname` is True by default + context = ssl.create_default_context() + + context.check_hostname = ssl_verify_identity + + if ssl_verify_cert: + context.verify_mode = ssl.CERT_REQUIRED + elif ssl_verify_identity: + context.verify_mode = ssl.CERT_OPTIONAL + else: + context.verify_mode = ssl.CERT_NONE + + context.load_default_certs() + + if ssl_ca: + try: + context.load_verify_locations(ssl_ca) + except (IOError, ssl.SSLError) as err: + raise InterfaceError(f"Invalid CA Certificate: {err}") from err + if ssl_cert: + try: + context.load_cert_chain(ssl_cert, ssl_key) + except (IOError, ssl.SSLError) as err: + raise InterfaceError(f"Invalid Certificate/Key: {err}") from err + + # TLSv1.3 ciphers cannot be disabled with `SSLContext.set_ciphers(...)`, + # see https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers. + if tls_cipher_suites and tls_version == "TLSv1.2": + context.set_ciphers(":".join(tls_cipher_suites)) + + return context + except NameError as err: + raise NotSupportedError("Python installation has no SSL support") from err + except ( + IOError, + NotImplementedError, + ssl.CertificateError, + ssl.SSLError, + ) as err: + raise InterfaceError(str(err)) from err + + def send( + self, + payload: bytes, + packet_number: Optional[int] = None, + compressed_packet_number: Optional[int] = None, + write_timeout: Optional[int] = None, + ) -> None: + """Send `payload` to the MySQL server. + + NOTE: if `payload` is an instance of `bytearray`, then `payload` might be + changed by this method - `bytearray` is similar to passing a variable by + reference. + + If you're sure you won't read `payload` after invoking `send()`, + then you can use `bytearray.` Otherwise, you must use `bytes`. + """ + try: + if ( + not self._connection_timeout and self.sock is not None + ): # can't update the timeout during connection phase + self.sock.settimeout(float(write_timeout) if write_timeout else None) + except OSError as _: + # Ignore the OSError as the socket might not be setup properly + pass + self._netbroker.send( + self.sock, + self.address, + payload, + packet_number=packet_number, + compressed_packet_number=compressed_packet_number, + ) + + def recv(self, read_timeout: Optional[int] = None) -> bytearray: + """Get packet from the MySQL server comm channel.""" + try: + if ( + not self._connection_timeout and self.sock is not None + ): # can't update the timeout during connection phase + self.sock.settimeout(float(read_timeout) if read_timeout else None) + except OSError as _: + # Ignore the OSError as the socket might not be setup properly + pass + return self._netbroker.recv(self.sock, self.address) + + @abstractmethod + def open_connection(self) -> None: + """Open the socket.""" + + @property + @abstractmethod + def address(self) -> str: + """Get the location of the socket.""" + + +class MySQLUnixSocket(MySQLSocket): + """MySQL socket class using UNIX sockets. + + Opens a connection through the UNIX socket of the MySQL Server. + """ + + def __init__(self, unix_socket: str = "/tmp/mysql.sock") -> None: + super().__init__() + self.unix_socket: str = unix_socket + self._address: str = unix_socket + + @property + def address(self) -> str: + return self._address + + def open_connection(self) -> None: + try: + self.sock = socket.socket( + # pylint: disable=no-member + socket.AF_UNIX, + socket.SOCK_STREAM, + ) + self.sock.settimeout(self._connection_timeout) + self.sock.connect(self.unix_socket) + except (socket.timeout, TimeoutError) as err: + raise ConnectionTimeoutError( + errno=2002, + values=( + self.address, + _strioerror(err), + ), + ) from err + except IOError as err: + raise InterfaceError( + errno=2002, values=(self.address, _strioerror(err)) + ) from err + except Exception as err: + raise InterfaceError(str(err)) from err + + def switch_to_ssl( + self, *args: Any, **kwargs: Any # pylint: disable=unused-argument + ) -> None: + """Switch the socket to use SSL.""" + warnings.warn( + "SSL is disabled when using unix socket connections", + Warning, + ) + + +class MySQLTCPSocket(MySQLSocket): + """MySQL socket class using TCP/IP. + + Opens a TCP/IP connection to the MySQL Server. + """ + + def __init__( + self, + host: str = "127.0.0.1", + port: int = 3306, + force_ipv6: bool = False, + ) -> None: + super().__init__() + self.server_host: str = host + self.server_port: int = port + self.force_ipv6: bool = force_ipv6 + self._family: int = 0 + self._address: str = f"{host}:{port}" + + @property + def address(self) -> str: + return self._address + + def open_connection(self) -> None: + """Open the TCP/IP connection to the MySQL server.""" + # pylint: disable=no-member + # Get address information + addrinfo: Tuple[ + socket.AddressFamily, + socket.SocketKind, + int, + str, + Union[tuple[str, int], tuple[str, int, int, int], tuple[int, bytes]], + ] = (None, None, None, None, None) + try: + addrinfos = socket.getaddrinfo( + self.server_host, + self.server_port, + 0, + socket.SOCK_STREAM, + socket.SOL_TCP, + ) + # If multiple results we favor IPv4, unless IPv6 was forced. + for info in addrinfos: + if self.force_ipv6 and info[0] == socket.AF_INET6: + addrinfo = info + break + if info[0] == socket.AF_INET: + addrinfo = info + break + if self.force_ipv6 and addrinfo[0] is None: + raise InterfaceError(f"No IPv6 address found for {self.server_host}") + if addrinfo[0] is None: + addrinfo = addrinfos[0] + except IOError as err: + raise InterfaceError( + errno=2003, + values=(self.server_host, self.server_port, _strioerror(err)), + ) from err + + (self._family, socktype, proto, _, sockaddr) = addrinfo + + # Instantiate the socket and connect + try: + self.sock = socket.socket(self._family, socktype, proto) + self.sock.settimeout(self._connection_timeout) + self.sock.connect(sockaddr) + except (socket.timeout, TimeoutError) as err: + raise ConnectionTimeoutError( + errno=2003, + values=( + self.server_host, + self.server_port, + _strioerror(err), + ), + ) from err + except IOError as err: + raise InterfaceError( + errno=2003, + values=(self.server_host, self.server_port, _strioerror(err)), + ) from err + except Exception as err: + raise InterfaceError(str(err)) from err diff --git a/server/venv/Lib/site-packages/mysql/connector/opentelemetry/__init__.py b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/__init__.py new file mode 100644 index 0000000..a1dc4ae --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA diff --git a/server/venv/Lib/site-packages/mysql/connector/opentelemetry/constants.py b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/constants.py new file mode 100644 index 0000000..9c5e04e --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/constants.py @@ -0,0 +1,77 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Constants used by the opentelemetry instrumentation implementation.""" +# mypy: disable-error-code="no-redef,assignment" + +# pylint: disable=unused-import +OTEL_ENABLED = True +try: + # try to load otel from the system + from opentelemetry import trace # check api + from opentelemetry.sdk.trace import TracerProvider # check sdk + from opentelemetry.semconv.trace import SpanAttributes # check semconv +except ImportError: + OTEL_ENABLED = False + + +OPTION_CNX_SPAN = "_span" +""" +Connection option name used to inject the connection span. +This connection option name must not be used, is reserved. +""" + +OPTION_CNX_TRACER = "_tracer" +""" +Connection option name used to inject the opentelemetry tracer. +This connection option name must not be used, is reserved. +""" + +CONNECTION_SPAN_NAME = "connection" +""" +Connection span name to be used by the instrumentor. +""" + +FIRST_SUPPORTED_VERSION = "8.1.0" +""" +First mysql-connector-python version to support opentelemetry instrumentation. +""" + +TRACEPARENT_HEADER_NAME = "traceparent" + +DB_SYSTEM = "mysql" +DEFAULT_THREAD_NAME = "main" +DEFAULT_THREAD_ID = 0 + +# Reference: https://github.com/open-telemetry/opentelemetry-specification/blob/main/ +# specification/trace/semantic_conventions/span-general.md +NET_SOCK_FAMILY = "net.sock.family" +NET_SOCK_PEER_ADDR = "net.sock.peer.addr" +NET_SOCK_PEER_PORT = "net.sock.peer.port" +NET_SOCK_HOST_ADDR = "net.sock.host.addr" +NET_SOCK_HOST_PORT = "net.sock.host.port" diff --git a/server/venv/Lib/site-packages/mysql/connector/opentelemetry/context_propagation.py b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/context_propagation.py new file mode 100644 index 0000000..907df7e --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/context_propagation.py @@ -0,0 +1,112 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""Trace context propagation utilities.""" +# mypy: disable-error-code="no-redef" +# pylint: disable=invalid-name + +from typing import TYPE_CHECKING, Any, Callable + +from .constants import OTEL_ENABLED, TRACEPARENT_HEADER_NAME + +if OTEL_ENABLED: + # pylint: disable=import-error + # load otel from the system + from opentelemetry import trace + from opentelemetry.trace.span import format_span_id, format_trace_id + + +if TYPE_CHECKING: + from ..abstracts import MySQLConnectionAbstract + + +def build_traceparent_header(span: Any) -> str: + """Build a traceparent header according to the provided span. + + The context information from the provided span is used to build the traceparent + header that will be propagated to the MySQL server. For particulars regarding + the header creation, refer to [1]. + + This method assumes version 0 of the W3C specification. + + Args: + span (opentelemetry.trace.span.Span): current span in trace. + + Returns: + traceparent_header (str): HTTP header field that identifies requests in a + tracing system. + + References: + [1]: https://www.w3.org/TR/trace-context/#traceparent-header + """ + # pylint: disable=possibly-used-before-assignment + ctx = span.get_span_context() + + version = "00" # version 0 of the W3C specification + trace_id = format_trace_id(ctx.trace_id) + span_id = format_span_id(ctx.span_id) + trace_flags = "00" # sampled flag is off + + return "-".join([version, trace_id, span_id, trace_flags]) + + +def with_context_propagation(method: Callable) -> Callable: + """Perform trace context propagation. + + The trace context is propagated via query attributes. The `traceparent` header + from W3C specification [1] is used, in this sense, the attribute name is + `traceparent` (is RESERVED, avoid using it), and its value is built as per + instructed in [1]. + + If opentelemetry API/SDK is unavailable or there is no recording span, + trace context propagation is skipped. + + References: + [1]: https://www.w3.org/TR/trace-context/#traceparent-header + """ + + def wrapper(cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any) -> Any: + """Context propagation decorator.""" + # pylint: disable=possibly-used-before-assignment + if not OTEL_ENABLED or not cnx.otel_context_propagation: + return method(cnx, *args, **kwargs) + + current_span = trace.get_current_span() + tp_header = None + if current_span.is_recording(): + tp_header = build_traceparent_header(current_span) + cnx.query_attrs_append(value=(TRACEPARENT_HEADER_NAME, tp_header)) + + try: + result = method(cnx, *args, **kwargs) + finally: + if tp_header is not None: + cnx.query_attrs_remove(name=TRACEPARENT_HEADER_NAME) + return result + + return wrapper diff --git a/server/venv/Lib/site-packages/mysql/connector/opentelemetry/instrumentation.py b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/instrumentation.py new file mode 100644 index 0000000..8498574 --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/opentelemetry/instrumentation.py @@ -0,0 +1,687 @@ +# Copyright (c) 2023, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +"""MySQL instrumentation supporting mysql-connector.""" +# mypy: disable-error-code="no-redef" +# pylint: disable=protected-access,global-statement,invalid-name,unused-argument + +from __future__ import annotations + +import functools +import re + +from abc import ABC, abstractmethod +from contextlib import nullcontext +from typing import TYPE_CHECKING, Any, Callable, Collection, Dict, Optional, Union + +# pylint: disable=cyclic-import +if TYPE_CHECKING: + # `TYPE_CHECKING` is always False at run time, hence circular import + # will not happen at run time (no error happens whatsoever). + # Since pylint is a static checker it happens that `TYPE_CHECKING` + # is True when analyzing the code which makes pylint believe there + # is a circular import issue when there isn't. + + from ..abstracts import MySQLConnectionAbstract, MySQLCursorAbstract + from ..pooling import PooledMySQLConnection + +from ... import connector +from ..constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION +from ..logger import logger +from ..version import VERSION_TEXT + +try: + # pylint: disable=unused-import + # try to load otel from the system + from opentelemetry import trace # check api + from opentelemetry.sdk.trace import TracerProvider # check sdk + from opentelemetry.semconv.trace import SpanAttributes # check semconv +except ImportError as missing_dependencies_err: + raise connector.errors.ProgrammingError( + "OpenTelemetry installation not found. You must install the API and SDK." + ) from missing_dependencies_err + + +from .constants import ( + CONNECTION_SPAN_NAME, + DB_SYSTEM, + DEFAULT_THREAD_ID, + DEFAULT_THREAD_NAME, + FIRST_SUPPORTED_VERSION, + NET_SOCK_FAMILY, + NET_SOCK_HOST_ADDR, + NET_SOCK_HOST_PORT, + NET_SOCK_PEER_ADDR, + NET_SOCK_PEER_PORT, + OPTION_CNX_SPAN, + OPTION_CNX_TRACER, +) + +leading_comment_remover: re.Pattern = re.compile(r"^/\*.*?\*/") + + +def record_exception_event(span: trace.Span, exc: Optional[Exception]) -> None: + """Records an exeception event.""" + if not span or not span.is_recording() or not exc: + return + + span.set_status(trace.Status(trace.StatusCode.ERROR)) + span.record_exception(exc) + + +def end_span(span: trace.Span) -> None: + """Ends span.""" + if not span or not span.is_recording(): + return + + span.end() + + +def get_operation_name(operation: str) -> str: + """Parse query to extract operation name.""" + if operation and isinstance(operation, str): + # Strip leading comments so we get the operation name. + return leading_comment_remover.sub("", operation).split()[0] + return "" + + +def set_connection_span_attrs( + cnx: Optional["MySQLConnectionAbstract"], + cnx_span: trace.Span, + cnx_kwargs: Optional[Dict[str, Any]] = None, +) -> None: + """Defines connection span attributes. If `cnx` is None then we use `cnx_kwargs` + to get basic net information. Basic net attributes are defined such as: + + * DB_SYSTEM + * NET_TRANSPORT + * NET_SOCK_FAMILY + + Socket-level attributes [*] are also defined [**]. + + [*]: Socket-level attributes identify peer and host that are directly connected to + each other. Since instrumentations may have limited knowledge on network + information, instrumentations SHOULD populate such attributes to the best of + their knowledge when populate them at all. + + [**]: `CMySQLConnection` connections have no access to socket-level + details so socket-level attributes aren't included. `MySQLConnection` + connections, on the other hand, do include socket-level attributes. + + References: + [1]: https://github.com/open-telemetry/opentelemetry-specification/blob/main/\ + specification/trace/semantic_conventions/span-general.md + """ + # pylint: disable=broad-exception-caught + if not cnx_span or not cnx_span.is_recording(): + return + + if cnx_kwargs is None: + cnx_kwargs = {} + + is_tcp = not cnx._unix_socket if cnx else "unix_socket" not in cnx_kwargs + + attrs: Dict[str, Any] = { + SpanAttributes.DB_SYSTEM: DB_SYSTEM, + SpanAttributes.NET_TRANSPORT: "ip_tcp" if is_tcp else "inproc", + NET_SOCK_FAMILY: "inet" if is_tcp else "unix", + } + + # Only socket and tcp connections are supported. + if is_tcp: + attrs[SpanAttributes.NET_PEER_NAME] = ( + cnx._host if cnx else cnx_kwargs.get("host", DEFAULT_CONFIGURATION["host"]) + ) + attrs[SpanAttributes.NET_PEER_PORT] = ( + cnx._port if cnx else cnx_kwargs.get("port", DEFAULT_CONFIGURATION["port"]) + ) + + if hasattr(cnx, "_socket") and cnx._socket: + try: + ( + attrs[NET_SOCK_PEER_ADDR], + sock_peer_port, + ) = cnx._socket.sock.getpeername() + + ( + attrs[NET_SOCK_HOST_ADDR], + attrs[NET_SOCK_HOST_PORT], + ) = cnx._socket.sock.getsockname() + except Exception as sock_err: + logger.warning("Connection socket is down %s", sock_err) + else: + if attrs[SpanAttributes.NET_PEER_PORT] != sock_peer_port: + # NET_SOCK_PEER_PORT is recommended if different than net.peer.port + # and if net.sock.peer.addr is set. + attrs[NET_SOCK_PEER_PORT] = sock_peer_port + else: + # For Unix domain socket, net.sock.peer.addr attribute represents + # destination name and net.peer.name SHOULD NOT be set. + attrs[NET_SOCK_PEER_ADDR] = ( + cnx._unix_socket if cnx else cnx_kwargs.get("unix_socket") + ) + + if hasattr(cnx, "_socket") and cnx._socket: + try: + attrs[NET_SOCK_HOST_ADDR] = cnx._socket.sock.getsockname() + except Exception as sock_err: + logger.warning("Connection socket is down %s", sock_err) + + cnx_span.set_attributes(attrs) + + +def with_cnx_span_attached(method: Callable) -> Callable: + """Attach the connection span while executing the connection method.""" + + def wrapper(cnx: "MySQLConnectionAbstract", *args: Any, **kwargs: Any) -> Any: + """Connection span attacher decorator.""" + with trace.use_span( + cnx._span, end_on_exit=False + ) if cnx._span and cnx._span.is_recording() else nullcontext(): + return method(cnx, *args, **kwargs) + + return wrapper + + +def with_cnx_query_span(method: Callable) -> Callable: + """Create a query span while executing the connection method.""" + + def wrapper(cnx: TracedMySQLConnection, *args: Any, **kwargs: Any) -> Any: + """Query span creator decorator.""" + logger.info("Creating query span for connection.%s", method.__name__) + + query_span_attributes: Dict = { + SpanAttributes.DB_SYSTEM: DB_SYSTEM, + SpanAttributes.DB_USER: cnx._user, + SpanAttributes.THREAD_ID: DEFAULT_THREAD_ID, + SpanAttributes.THREAD_NAME: DEFAULT_THREAD_NAME, + "connection_type": cnx.get_wrapped_class(), + } + + with cnx._tracer.start_as_current_span( + name=method.__name__.upper(), + kind=trace.SpanKind.CLIENT, + links=[trace.Link(cnx._span.get_span_context())], + attributes=query_span_attributes, + ) if cnx._span and cnx._span.is_recording() else nullcontext(): + return method(cnx, *args, **kwargs) + + return wrapper + + +def with_cursor_query_span(method: Callable) -> Callable: + """Create a query span while executing the cursor method.""" + + def wrapper(cur: TracedMySQLCursor, *args: Any, **kwargs: Any) -> Any: + """Query span creator decorator.""" + logger.info("Creating query span for cursor.%s", method.__name__) + + connection: "MySQLConnectionAbstract" = ( + getattr(cur._wrapped, "_connection") + if hasattr(cur._wrapped, "_connection") + else getattr(cur._wrapped, "_cnx") + ) + + query_span_attributes: Dict = { + SpanAttributes.DB_SYSTEM: DB_SYSTEM, + SpanAttributes.DB_USER: connection._user, + SpanAttributes.THREAD_ID: DEFAULT_THREAD_ID, + SpanAttributes.THREAD_NAME: DEFAULT_THREAD_NAME, + "cursor_type": cur.get_wrapped_class(), + } + + with cur._tracer.start_as_current_span( + name=get_operation_name(args[0]) or "SQL statement", + kind=trace.SpanKind.CLIENT, + links=[cur._connection_span_link], + attributes=query_span_attributes, + ): + return method(cur, *args, **kwargs) + + return wrapper + + +class BaseMySQLTracer(ABC): + """Base class that provides basic object wrapper functionality.""" + + @abstractmethod + def __init__(self) -> None: + """Must be implemented by subclasses.""" + + def __getattr__(self, attr: str) -> Any: + """Gets an attribute. + + Attributes defined in the wrapper object have higher precedence + than those wrapped object equivalent. Attributes not found in + the wrapper are then searched in the wrapped object. + """ + if attr in self.__dict__: + # this object has it + return getattr(self, attr) + # proxy to the wrapped object + return getattr(self._wrapped, attr) + + def __setattr__(self, name: str, value: Any) -> None: + if "_wrapped" not in self.__dict__: + self.__dict__["_wrapped"] = value + return + + if name in self.__dict__ or name == "autocommit": + # this object has it + super().__setattr__(name, value) + return + # proxy to the wrapped object + self._wrapped.__setattr__(name, value) + + def __enter__(self) -> Any: + """Magic method.""" + self._wrapped.__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Magic method.""" + self._wrapped.__exit__(*args, **kwargs) + + def get_wrapped_class(self) -> str: + """Gets the wrapped class name.""" + return self._wrapped.__class__.__name__ + + +class TracedMySQLCursor(BaseMySQLTracer): + """Wrapper class for a `MySQLCursor` or `CMySQLCursor` object.""" + + def __init__( + self, + wrapped: "MySQLCursorAbstract", + tracer: trace.Tracer, + connection_span: trace.Span, + ): + """Constructor.""" + self._wrapped: "MySQLCursorAbstract" = wrapped + self._tracer: trace.Tracer = tracer + self._connection_span_link: trace.Link = trace.Link( + connection_span.get_span_context() + ) + + @with_cursor_query_span + def execute(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.execute(*args, **kwargs) + + @with_cursor_query_span + def executemany(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.executemany(*args, **kwargs) + + @with_cursor_query_span + def callproc(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.callproc(*args, **kwargs) + + +class TracedMySQLConnection(BaseMySQLTracer): + """Wrapper class for a `MySQLConnection` or `CMySQLConnection` object.""" + + def __init__(self, wrapped: "MySQLConnectionAbstract") -> None: + """Constructor.""" + self._wrapped: "MySQLConnectionAbstract" = wrapped + + # call `sql_mode` so its value is cached internally and querying it does not + # interfere when recording query span events later. + _ = self._wrapped.sql_mode + + def cursor(self, *args: Any, **kwargs: Any) -> TracedMySQLCursor: + """Wraps the object method.""" + return TracedMySQLCursor( + wrapped=self._wrapped.cursor(*args, **kwargs), + tracer=self._tracer, + connection_span=self._span, + ) + + @with_cnx_query_span + def cmd_change_user(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_change_user(*args, **kwargs) + + @with_cnx_query_span + def commit(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.commit(*args, **kwargs) + + @with_cnx_query_span + def rollback(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.rollback(*args, **kwargs) + + @with_cnx_query_span + def cmd_query(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_query(*args, **kwargs) + + @with_cnx_query_span + def cmd_init_db(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_init_db(*args, **kwargs) + + @with_cnx_query_span + def cmd_refresh(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_refresh(*args, **kwargs) + + @with_cnx_query_span + def cmd_quit(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_quit(*args, **kwargs) + + @with_cnx_query_span + def cmd_shutdown(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_shutdown(*args, **kwargs) + + @with_cnx_query_span + def cmd_statistics(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_statistics(*args, **kwargs) + + @with_cnx_query_span + def cmd_process_kill(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_process_kill(*args, **kwargs) + + @with_cnx_query_span + def cmd_debug(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_debug(*args, **kwargs) + + @with_cnx_query_span + def cmd_ping(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_ping(*args, **kwargs) + + @property + @with_cnx_query_span + def database(self) -> str: + """Instrument method.""" + return self._wrapped.database + + @with_cnx_query_span + def is_connected(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.is_connected(*args, **kwargs) + + @with_cnx_query_span + def reset_session(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.reset_session(*args, **kwargs) + + @with_cnx_query_span + def ping(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.ping(*args, **kwargs) + + @with_cnx_query_span + def info_query(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.info_query(*args, **kwargs) + + @with_cnx_query_span + def cmd_stmt_prepare(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_stmt_prepare(*args, **kwargs) + + @with_cnx_query_span + def cmd_stmt_execute(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_stmt_execute(*args, **kwargs) + + @with_cnx_query_span + def cmd_stmt_close(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_stmt_close(*args, **kwargs) + + @with_cnx_query_span + def cmd_stmt_send_long_data(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_stmt_send_long_data(*args, **kwargs) + + @with_cnx_query_span + def cmd_stmt_reset(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_stmt_reset(*args, **kwargs) + + @with_cnx_query_span + def cmd_reset_connection(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.cmd_reset_connection(*args, **kwargs) + + @property + @with_cnx_query_span + def time_zone(self) -> str: + """Instrument method.""" + return self._wrapped.time_zone + + @property + @with_cnx_query_span + def sql_mode(self) -> str: + """Instrument method.""" + return self._wrapped.sql_mode + + @property + @with_cnx_query_span + def autocommit(self) -> bool: + """Instrument method.""" + return self._wrapped.autocommit + + @autocommit.setter + @with_cnx_query_span + def autocommit(self, value: bool) -> None: + """Instrument method.""" + self._wrapped.autocommit = value + + @with_cnx_query_span + def set_charset_collation(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.set_charset_collation(*args, **kwargs) + + @with_cnx_query_span + def start_transaction(self, *args: Any, **kwargs: Any) -> Any: + """Instrument method.""" + return self._wrapped.start_transaction(*args, **kwargs) + + +def _instrument_connect( + connect: Callable[..., Union["MySQLConnectionAbstract", "PooledMySQLConnection"]], + tracer_provider: Optional[trace.TracerProvider] = None, +) -> Callable[..., Union["MySQLConnectionAbstract", "PooledMySQLConnection"]]: + """Retrurn the instrumented version of `connect`.""" + + # let's preserve `connect` identity. + @functools.wraps(connect) + def wrapper( + *args: Any, **kwargs: Any + ) -> Union["MySQLConnectionAbstract", "PooledMySQLConnection"]: + """Wraps the connection object returned by the method `connect`. + + Instrumentation for PooledConnections is not supported. + """ + if any(key in kwargs for key in CNX_POOL_ARGS): + logger.warning("Instrumentation for pooled connections not supported") + return connect(*args, **kwargs) + + tracer = trace.get_tracer( + instrumenting_module_name="MySQL Connector/Python", + instrumenting_library_version=VERSION_TEXT, + tracer_provider=tracer_provider, + ) + + # The connection span is passed in as an argument so the connection object can + # keep a pointer to it. + kwargs[OPTION_CNX_SPAN] = tracer.start_span( + name=CONNECTION_SPAN_NAME, kind=trace.SpanKind.CLIENT + ) + kwargs[OPTION_CNX_TRACER] = tracer + + # attach connection span + # pylint: disable=not-context-manager + with trace.use_span(kwargs[OPTION_CNX_SPAN], end_on_exit=False) as cnx_span: + # Add basic net information. + set_connection_span_attrs(None, cnx_span, kwargs) + + # Connection may fail at this point, in case it does, basic net info is already + # included so the user can check the net configuration she/he provided. + cnx = connect(*args, **kwargs) + + # connection went ok, let's refine the net information. + set_connection_span_attrs(cnx, cnx_span, kwargs) # type: ignore[arg-type] + + return TracedMySQLConnection( + wrapped=cnx, # type: ignore[return-value, arg-type] + ) + + return wrapper + + +class MySQLInstrumentor: + """MySQL instrumentation supporting mysql-connector-python.""" + + _instance: Optional[MySQLInstrumentor] = None + + def __new__(cls, *args: Any, **kwargs: Any) -> MySQLInstrumentor: + """Singlenton. + + Restricts the instantiation to a singular instance. + """ + if cls._instance is None: + # create instance + cls._instance = object.__new__(cls, *args, **kwargs) + # keep a pointer to the uninstrumented connect method + setattr(cls._instance, "_original_connect", connector.connect) + return cls._instance + + def instrumentation_dependencies(self) -> Collection[str]: + """Return a list of python packages with versions + that the will be instrumented (e.g., versions >= 8.1.0).""" + return [f"mysql-connector-python >= {FIRST_SUPPORTED_VERSION}"] + + def instrument(self, **kwargs: Any) -> None: + """Instrument the library. + + Args: + trace_module: reference to the 'trace' module from opentelemetry. + tracer_provider (optional): TracerProvider instance. + + NOTE: Instrumentation for pooled connections not supported. + """ + if connector.connect != getattr(self, "_original_connect"): + logger.warning("MySQL Connector/Python module already instrumented.") + return + connector.connect = _instrument_connect( + connect=getattr(self, "_original_connect"), + tracer_provider=kwargs.get("tracer_provider"), + ) + + def instrument_connection( + self, + connection: "MySQLConnectionAbstract", + tracer_provider: Optional[trace.TracerProvider] = None, + ) -> "MySQLConnectionAbstract": + """Enable instrumentation in a MySQL connection. + + Args: + connection: uninstrumented connection instance. + trace_module: reference to the 'trace' module from opentelemetry. + tracer_provider (optional): TracerProvider instance. + + Returns: + connection: instrumented connection instace. + + NOTE: Instrumentation for pooled connections not supported. + """ + if isinstance(connection, TracedMySQLConnection): + logger.warning("Connection already instrumented.") + return connection + + if not hasattr(connection, "_span") or not hasattr(connection, "_tracer"): + logger.warning( + "Instrumentation for class %s not supported.", + connection.__class__.__name__, + ) + return connection + + tracer = trace.get_tracer( + instrumenting_module_name="MySQL Connector/Python", + instrumenting_library_version=VERSION_TEXT, + tracer_provider=tracer_provider, + ) + connection._span = tracer.start_span( + name=CONNECTION_SPAN_NAME, kind=trace.SpanKind.CLIENT + ) + connection._tracer = tracer + + set_connection_span_attrs(connection, connection._span) + + return TracedMySQLConnection(wrapped=connection) # type: ignore[return-value] + + def uninstrument(self, **kwargs: Any) -> None: + """Uninstrument the library.""" + # pylint: disable=unused-argument + if connector.connect == getattr(self, "_original_connect"): + logger.warning("MySQL Connector/Python module already uninstrumented.") + return + connector.connect = getattr(self, "_original_connect") + + def uninstrument_connection( + self, connection: "MySQLConnectionAbstract" + ) -> "MySQLConnectionAbstract": + """Disable instrumentation in a MySQL connection. + + Args: + connection: instrumented connection instance. + + Returns: + connection: uninstrumented connection instace. + + NOTE: Instrumentation for pooled connections not supported. + """ + if not hasattr(connection, "_span"): + logger.warning( + "Uninstrumentation for class %s not supported.", + connection.__class__.__name__, + ) + return connection + + if not isinstance(connection, TracedMySQLConnection): + logger.warning("Connection already uninstrumented.") + return connection + + # stop connection span recording + if connection._span and connection._span.is_recording(): + connection._span.end() + connection._span = None + + return connection._wrapped diff --git a/server/venv/Lib/site-packages/mysql/connector/optionfiles.py b/server/venv/Lib/site-packages/mysql/connector/optionfiles.py new file mode 100644 index 0000000..849686f --- /dev/null +++ b/server/venv/Lib/site-packages/mysql/connector/optionfiles.py @@ -0,0 +1,367 @@ +# Copyright (c) 2014, 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, as +# published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, +# as designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an +# additional permission to link the program and your derivative works +# with the separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# Without limiting anything contained in the foregoing, this file, +# which is part of MySQL Connector/Python, is also subject to the +# Universal FOSS Exception, version 1.0, a copy of which can be found at +# http://oss.oracle.com/licenses/universal-foss-exception. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +# mypy: disable-error-code="attr-defined" + +"""Implements parser to parse MySQL option files.""" + +import ast +import codecs +import io +import os +import re + +from configparser import ConfigParser as SafeConfigParser, MissingSectionHeaderError +from typing import Any, Dict, List, Optional, Tuple, Union + +from .constants import CNX_POOL_ARGS, DEFAULT_CONFIGURATION + +DEFAULT_EXTENSIONS: Dict[str, Tuple[str, ...]] = { + "nt": ("ini", "cnf"), + "posix": ("cnf",), +} + + +def read_option_files(**config: Union[str, List[str]]) -> Dict[str, Any]: + """ + Read option files for connection parameters. + + Checks if connection arguments contain option file arguments, and then + reads option files accordingly. + """ + if "option_files" in config: + try: + if isinstance(config["option_groups"], str): + config["option_groups"] = [config["option_groups"]] + groups = config["option_groups"] + del config["option_groups"] + except KeyError: + groups = ["client", "connector_python"] + + if isinstance(config["option_files"], str): + config["option_files"] = [config["option_files"]] + option_parser = MySQLOptionsParser( + list(config["option_files"]), keep_dashes=False + ) + del config["option_files"] + + config_from_file: Dict[str, Any] = ( + option_parser.get_groups_as_dict_with_priority(*groups) + ) + config_options: Dict[str, Tuple[str, int, str]] = {} + for group in groups: + try: + for option, value in config_from_file[group].items(): + value += (group,) + try: + if option == "socket": + option = "unix_socket" + option_parser.set(group, "unix_socket", value[0]) + + if option not in CNX_POOL_ARGS and option != "failover": + _ = DEFAULT_CONFIGURATION[option] + + if ( + option not in config_options + or config_options[option][1] <= value[1] + ): + config_options[option] = value + except KeyError: + if group == "connector_python": + raise AttributeError( + f"Unsupported argument '{option}'" + ) from None + except KeyError: + continue + + for option, values in config_options.items(): + value, _, section = values + if ( + option not in config + and option_parser.has_section(section) + and option_parser.has_option(section, option) + ): + if option in ("password", "passwd"): # keep the value as string + config[option] = str(value) + else: + try: + config[option] = ast.literal_eval(value) + except (ValueError, TypeError, SyntaxError): + config[option] = value + if "socket" in config: + config["unix_socket"] = config.pop("socket") + return config + + +class MySQLOptionsParser(SafeConfigParser): + """This class implements methods to parse MySQL option files""" + + def __init__( + self, files: Optional[Union[List[str], str]] = None, keep_dashes: bool = True + ) -> None: + """Initialize + + If defaults is True, default option files are read first + + Raises ValueError if defaults is set to True but defaults files + cannot be found. + """ + + # Regular expression to allow options with no value(For Python v2.6) + self.optcre: re.Pattern = re.compile( + r"(?P