From cc41788abf0d494ef027af993271200a01d9fefa Mon Sep 17 00:00:00 2001 From: Ada Lundhe Date: Wed, 19 Jun 2024 13:32:12 -0500 Subject: [PATCH] AL: refactor to Hyperscale --- .devcontainer/Dockerfile | 10 + .devcontainer/devcontainer.json | 48 + .devcontainer/docker-compose.yml | 22 + .devcontainer/run.sh | 15 + .github/workflows/main.yml | 41 + .gitignore | 178 +- .version | 1 + CODE_OF_CONDUCT.md | 137 + MANIFEST.in | 1 + README.md | 302 + examples/arg_hash.py | 38 + examples/engine_test.py | 50 + examples/optimizer_test.py | 97 + hyperscale/__init__.py | 5 + hyperscale/cli/__init__.py | 28 + hyperscale/cli/base.py | 69 + hyperscale/cli/cloud.py | 82 + hyperscale/cli/cloud/__init__.py | 0 hyperscale/cli/cloud/check.py | 0 hyperscale/cli/cloud/login.py | 0 hyperscale/cli/cloud/run.py | 0 hyperscale/cli/cloud/submit.py | 0 hyperscale/cli/cloud/sync.py | 0 hyperscale/cli/cloud/update.py | 0 hyperscale/cli/cloud/watch.py | 0 hyperscale/cli/exceptions/__init__.py | 0 hyperscale/cli/exceptions/graph/__init__.py | 0 .../cli/exceptions/graph/create/__init__.py | 1 + .../graph/create/invalid_stage_type.py | 12 + .../cli/exceptions/graph/sync/__init__.py | 1 + .../exceptions/graph/sync/not_set_error.py | 6 + hyperscale/cli/exceptions/plugin/__init__.py | 0 .../cli/exceptions/plugin/create/__init__.py | 1 + .../plugin/create/invaid_plugin_type.py | 12 + hyperscale/cli/graph.py | 145 + hyperscale/cli/graph/__init__.py | 3 + hyperscale/cli/graph/check.py | 104 + hyperscale/cli/graph/create.py | 96 + hyperscale/cli/graph/run.py | 271 + hyperscale/cli/ping.py | 177 + hyperscale/cli/plugin.py | 21 + hyperscale/cli/plugin/__init__.py | 1 + hyperscale/cli/plugin/create.py | 43 + hyperscale/cli/project.py | 231 + hyperscale/cli/project/__init__.py | 4 + hyperscale/cli/project/about.py | 71 + hyperscale/cli/project/create.py | 127 + hyperscale/cli/project/get.py | 129 + hyperscale/cli/project/sync.py | 135 + hyperscale/core/__init__.py | 0 hyperscale/core/engines/__init__.py | 1 + hyperscale/core/engines/client/__init__.py | 3 + hyperscale/core/engines/client/client.py | 257 + .../engines/client/client_types/__init__.py | 9 + .../client/client_types/base_client.py | 134 + .../engines/client/client_types/graphql.py | 85 + .../client/client_types/graphql_http2.py | 84 + .../core/engines/client/client_types/grpc.py | 72 + .../core/engines/client/client_types/http.py | 188 + .../core/engines/client/client_types/http2.py | 182 + .../core/engines/client/client_types/http3.py | 190 + .../engines/client/client_types/playwright.py | 1205 ++++ .../core/engines/client/client_types/udp.py | 79 + .../engines/client/client_types/websocket.py | 77 + hyperscale/core/engines/client/config.py | 90 + .../core/engines/client/plugins_store.py | 45 + hyperscale/core/engines/client/store.py | 70 + hyperscale/core/engines/client/time_parser.py | 25 + .../core/engines/client/tracing_config.py | 131 + hyperscale/core/engines/types/__init__.py | 10 + .../core/engines/types/common/__init__.py | 3 + .../engines/types/common/action_registry.py | 60 + .../core/engines/types/common/base_action.py | 43 + .../core/engines/types/common/base_engine.py | 116 + .../core/engines/types/common/base_result.py | 47 + .../types/common/concurrency/__init__.py | 3 + .../common/concurrency/balancing_semaphore.py | 112 + .../common/concurrency/noop_semaphore.py | 32 + .../types/common/concurrency/semaphore.py | 95 + .../core/engines/types/common/constants.py | 7 + .../core/engines/types/common/decoder.py | 260 + .../core/engines/types/common/encoder.py | 603 ++ .../types/common/fast_hpack/__init__.py | 16 + .../engines/types/common/fast_hpack/hpack.py | 5058 ++++++++++++++++ .../types/common/fast_hpack/huffman.py | 329 ++ .../engines/types/common/fast_hpack/table.py | 223 + hyperscale/core/engines/types/common/hooks.py | 74 + .../engines/types/common/hpack/__init__.py | 0 .../engines/types/common/hpack/constants.py | 289 + .../engines/types/common/hpack/exceptions.py | 49 + .../types/common/hpack/huffman_encoder.py | 66 + .../types/common/hpack/huffman_table.py | 4739 +++++++++++++++ .../engines/types/common/hpack/structs.py | 39 + .../core/engines/types/common/hpack/table.py | 224 + .../core/engines/types/common/metadata.py | 28 + .../types/common/protocols/__init__.py | 3 + .../types/common/protocols/ping/__init__.py | 1 + .../types/common/protocols/ping/ping.py | 252 + .../types/common/protocols/ping/ping_type.py | 29 + .../types/common/protocols/shared/__init__.py | 1 + .../common/protocols/shared/constants.py | 2 + .../types/common/protocols/shared/protocol.py | 75 + .../types/common/protocols/shared/reader.py | 372 ++ .../types/common/protocols/shared/writer.py | 113 + .../types/common/protocols/tcp/__init__.py | 1 + .../types/common/protocols/tcp/connection.py | 125 + .../types/common/protocols/tcp/protocol.py | 137 + .../common/protocols/tcp/tls_protocol.py | 16 + .../types/common/protocols/udp/__init__.py | 1 + .../types/common/protocols/udp/connection.py | 135 + .../types/common/protocols/udp/protocol.py | 116 + .../common/protocols/udp/quic_protocol.py | 1180 ++++ .../core/engines/types/common/results_set.py | 113 + hyperscale/core/engines/types/common/ssl.py | 87 + .../core/engines/types/common/timeouts.py | 17 + hyperscale/core/engines/types/common/types.py | 61 + hyperscale/core/engines/types/common/url.py | 172 + .../core/engines/types/custom/__init__.py | 1 + .../core/engines/types/custom/client.py | 190 + .../core/engines/types/custom/connection.py | 31 + hyperscale/core/engines/types/custom/pool.py | 28 + .../core/engines/types/graphql/__init__.py | 3 + .../core/engines/types/graphql/action.py | 68 + .../core/engines/types/graphql/client.py | 327 ++ .../core/engines/types/graphql/result.py | 14 + .../engines/types/graphql_http2/__init__.py | 3 + .../engines/types/graphql_http2/action.py | 66 + .../engines/types/graphql_http2/client.py | 183 + .../engines/types/graphql_http2/result.py | 13 + .../core/engines/types/grpc/__init__.py | 3 + hyperscale/core/engines/types/grpc/action.py | 56 + hyperscale/core/engines/types/grpc/client.py | 182 + .../engines/types/grpc/protobuf_registry.py | 23 + hyperscale/core/engines/types/grpc/result.py | 32 + .../core/engines/types/http/__init__.py | 3 + hyperscale/core/engines/types/http/action.py | 162 + hyperscale/core/engines/types/http/client.py | 449 ++ .../core/engines/types/http/connection.py | 102 + hyperscale/core/engines/types/http/pool.py | 27 + hyperscale/core/engines/types/http/result.py | 155 + .../core/engines/types/http2/__init__.py | 3 + hyperscale/core/engines/types/http2/action.py | 165 + hyperscale/core/engines/types/http2/client.py | 310 + .../engines/types/http2/config/__init__.py | 1 + .../config/boolean_configuration_option.py | 16 + .../core/engines/types/http2/config/config.py | 121 + .../core/engines/types/http2/connection.py | 155 + .../engines/types/http2/errors/__init__.py | 2 + .../engines/types/http2/errors/exceptions.py | 47 + .../core/engines/types/http2/errors/types.py | 49 + .../engines/types/http2/events/__init__.py | 16 + .../engines/types/http2/events/base_event.py | 2 + .../events/connection_terminated_event.py | 44 + .../types/http2/events/data_received_event.py | 49 + .../http2/events/deferred_headers_event.py | 59 + .../types/http2/events/headers_sent_event.py | 13 + .../informational_respose_received_event.py | 50 + .../events/remote_settings_changed_event.py | 66 + .../http2/events/request_received_event.py | 49 + .../types/http2/events/request_sent_event.py | 13 + .../http2/events/response_received_event.py | 48 + .../types/http2/events/response_sent_event.py | 12 + .../events/settings_acknowledged_event.py | 22 + .../types/http2/events/stream_ended_event.py | 17 + .../types/http2/events/stream_reset.py | 29 + .../http2/events/trailers_received_event.py | 45 + .../types/http2/events/trailers_sent_event.py | 15 + .../http2/events/window_updated_event.py | 25 + .../engines/types/http2/frames/__init__.py | 1 + .../types/http2/frames/frame_buffer.py | 248 + .../types/http2/frames/types/__init__.py | 0 .../http2/frames/types/attributes/__init__.py | 22 + .../frames/types/attributes/frame_flags.py | 61 + .../frames/types/attributes/frame_length.py | 6 + .../types/attributes/stream_associations.py | 4 + .../frames/types/attributes/struct_types.py | 10 + .../types/http2/frames/types/base_frame.py | 601 ++ .../engines/types/http2/frames/types/utils.py | 18 + hyperscale/core/engines/types/http2/pipe.py | 457 ++ hyperscale/core/engines/types/http2/pool.py | 60 + hyperscale/core/engines/types/http2/result.py | 174 + hyperscale/core/engines/types/http2/stream.py | 116 + .../engines/types/http2/streams/__init__.py | 0 .../types/http2/streams/changed_setting.py | 30 + .../types/http2/streams/stream_closed_by.py | 7 + .../types/http2/streams/stream_settings.py | 248 + .../http2/streams/stream_settings_codes.py | 24 + .../types/http2/streams/stream_state.py | 10 + .../types/http2/streams/stream_state_map.py | 22 + .../engines/types/http2/windows/__init__.py | 1 + .../types/http2/windows/window_manager.py | 111 + .../core/engines/types/http3/__init__.py | 3 + hyperscale/core/engines/types/http3/action.py | 64 + hyperscale/core/engines/types/http3/client.py | 476 ++ .../core/engines/types/http3/connection.py | 74 + hyperscale/core/engines/types/http3/pool.py | 27 + hyperscale/core/engines/types/http3/result.py | 65 + .../core/engines/types/playwright/__init__.py | 11 + .../core/engines/types/playwright/client.py | 193 + .../core/engines/types/playwright/command.py | 155 + .../types/playwright/command_librarian.py | 16 + .../types/playwright/command_library.py | 363 ++ .../types/playwright/context_config.py | 29 + .../engines/types/playwright/context_group.py | 161 + .../core/engines/types/playwright/hooks.py | 61 + .../core/engines/types/playwright/pool.py | 34 + .../core/engines/types/playwright/result.py | 63 + hyperscale/core/engines/types/registry.py | 65 + .../core/engines/types/task/__init__.py | 3 + hyperscale/core/engines/types/task/result.py | 39 + hyperscale/core/engines/types/task/runner.py | 182 + hyperscale/core/engines/types/task/task.py | 50 + .../core/engines/types/tracing/__init__.py | 0 .../core/engines/types/tracing/trace.py | 343 ++ .../engines/types/tracing/trace_session.py | 45 + .../engines/types/tracing/tracing_types.py | 69 + .../core/engines/types/tracing/url_filters.py | 7 + hyperscale/core/engines/types/udp/__init__.py | 5 + hyperscale/core/engines/types/udp/action.py | 108 + hyperscale/core/engines/types/udp/client.py | 221 + .../core/engines/types/udp/connection.py | 101 + hyperscale/core/engines/types/udp/pool.py | 28 + hyperscale/core/engines/types/udp/result.py | 77 + .../core/engines/types/websocket/__init__.py | 3 + .../core/engines/types/websocket/action.py | 111 + .../core/engines/types/websocket/client.py | 240 + .../engines/types/websocket/connection.py | 11 + .../core/engines/types/websocket/constants.py | 2 + .../core/engines/types/websocket/pool.py | 27 + .../core/engines/types/websocket/result.py | 11 + .../core/engines/types/websocket/utils.py | 50 + hyperscale/core/experiments/__init__.py | 2 + hyperscale/core/experiments/distribution.py | 239 + .../core/experiments/distribution_types.py | 133 + .../experiments/distributions/__init__.py | 0 .../distributions/types/__init__.py | 101 + .../experiments/distributions/types/alpha.py | 24 + .../experiments/distributions/types/anglit.py | 23 + .../distributions/types/arcsine.py | 23 + .../experiments/distributions/types/argus.py | 24 + .../experiments/distributions/types/base.py | 131 + .../experiments/distributions/types/beta.py | 26 + .../distributions/types/beta_prime.py | 26 + .../distributions/types/bradford.py | 24 + .../experiments/distributions/types/burr.py | 26 + .../distributions/types/burr_12.py | 26 + .../experiments/distributions/types/cauchy.py | 23 + .../experiments/distributions/types/chi.py | 24 + .../distributions/types/chi_squared.py | 24 + .../experiments/distributions/types/cosine.py | 23 + .../distributions/types/crystal_ball.py | 26 + .../experiments/distributions/types/dgamma.py | 24 + .../distributions/types/dweibull.py | 24 + .../experiments/distributions/types/erlang.py | 24 + .../distributions/types/exponential.py | 23 + .../distributions/types/exponential_normal.py | 22 + .../distributions/types/exponential_power.py | 22 + .../distributions/types/f_distribution.py | 26 + .../distributions/types/fatigue_life.py | 25 + .../experiments/distributions/types/fisk.py | 24 + .../distributions/types/folded_cauchy.py | 24 + .../distributions/types/folded_normal.py | 24 + .../experiments/distributions/types/gamma.py | 24 + .../types/gauss_hypergeometric.py | 31 + .../types/generalized_exponential.py | 29 + .../types/generalized_extreme.py | 24 + .../distributions/types/generalized_gamma.py | 27 + .../types/generalized_half_logistic.py | 24 + .../types/generalized_hyperbolic.py | 29 + .../types/generalized_inverse_gauss.py | 27 + .../types/generalized_logistic.py | 24 + .../distributions/types/generalized_normal.py | 24 + .../distributions/types/generalized_pareto.py | 24 + .../experiments/distributions/types/gibrat.py | 22 + .../distributions/types/gompertz.py | 24 + .../distributions/types/gumbel_l.py | 22 + .../distributions/types/gumbel_r.py | 22 + .../distributions/types/half_cauchy.py | 22 + .../types/half_generalized_normal.py | 24 + .../distributions/types/half_logistic.py | 22 + .../distributions/types/half_normal.py | 22 + .../distributions/types/hyperbolic_secant.py | 22 + .../distributions/types/inverse_gamma.py | 24 + .../distributions/types/inverse_weibull.py | 25 + .../distributions/types/johnson_sb.py | 26 + .../distributions/types/johnson_su.py | 26 + .../distributions/types/kappa_3.py | 24 + .../distributions/types/kappa_4.py | 24 + .../experiments/distributions/types/ks_one.py | 24 + .../experiments/distributions/types/ks_two.py | 24 + .../types/ks_two_bi_generalized.py | 22 + .../distributions/types/laplace.py | 22 + .../distributions/types/laplace_asymmetric.py | 24 + .../experiments/distributions/types/levy.py | 22 + .../experiments/distributions/types/levy_l.py | 22 + .../distributions/types/levy_stable.py | 26 + .../distributions/types/log_gamma.py | 24 + .../distributions/types/log_laplace.py | 24 + .../distributions/types/log_uniform.py | 26 + .../distributions/types/logistic.py | 22 + .../experiments/distributions/types/lomax.py | 24 + .../distributions/types/maxwell.py | 22 + .../experiments/distributions/types/mielke.py | 26 + .../experiments/distributions/types/moyal.py | 22 + .../distributions/types/nakagami.py | 24 + .../types/non_central_f_distribution.py | 29 + .../types/noncentral_chi_squared.py | 26 + .../types/noncentral_t_distribution.py | 26 + .../experiments/distributions/types/normal.py | 22 + .../types/normal_inverse_gauss.py | 26 + .../experiments/distributions/types/pareto.py | 24 + .../distributions/types/pearson_3.py | 24 + .../distributions/types/power_log_normal.py | 26 + .../distributions/types/power_normal.py | 24 + .../distributions/types/powerlaw.py | 25 + .../distributions/types/r_distribution.py | 25 + .../distributions/types/rayleigh.py | 22 + .../types/reciprocal_inverse_gauss.py | 24 + .../experiments/distributions/types/rice.py | 24 + .../distributions/types/semi_circular.py | 22 + .../distributions/types/skewed_cauchy.py | 24 + .../distributions/types/skewed_normal.py | 24 + .../distributions/types/student_range.py | 26 + .../distributions/types/t_distribution.py | 24 + .../distributions/types/trapezoid.py | 26 + .../distributions/types/triangular.py | 26 + .../types/truncated_exponential.py | 24 + .../distributions/types/truncated_normal.py | 24 + .../distributions/types/truncated_pareto.py | 26 + .../types/truncated_weibull_minimum.py | 28 + .../distributions/types/tukey_lambda.py | 24 + .../distributions/types/uniform.py | 22 + .../distributions/types/vonmises.py | 24 + .../distributions/types/vonmises_line.py | 24 + .../experiments/distributions/types/wald.py | 22 + .../distributions/types/weibull_maximum.py | 24 + .../distributions/types/weibull_minimum.py | 24 + .../distributions/types/wrapped_cauchy.py | 24 + hyperscale/core/experiments/experiment.py | 95 + .../core/experiments/mutations/__init__.py | 7 + .../experiments/mutations/types/__init__.py | 5 + .../mutations/types/base/__init__.py | 0 .../mutations/types/base/mutation.py | 47 + .../mutations/types/base/mutation_type.py | 9 + .../mutations/types/base/validator.py | 23 + .../mutations/types/deform_header/__init__.py | 1 + .../mutations/types/deform_header/mutation.py | 109 + .../types/deform_header/validator.py | 13 + .../mutations/types/inject_header/__init__.py | 1 + .../mutations/types/inject_header/mutation.py | 79 + .../types/inject_header/validator.py | 14 + .../types/inject_junk_data/__init__.py | 1 + .../types/inject_junk_data/mutation.py | 93 + .../types/inject_junk_data/validator.py | 10 + .../mutations/types/inject_ping/__init__.py | 1 + .../mutations/types/inject_ping/mutation.py | 83 + .../mutations/types/inject_ping/validator.py | 16 + .../types/smuggle_request/__init__.py | 1 + .../types/smuggle_request/mutation.py | 122 + .../types/smuggle_request/validator.py | 31 + hyperscale/core/experiments/variant.py | 35 + hyperscale/core/graphs/__init__.py | 0 hyperscale/core/graphs/graph.py | 438 ++ hyperscale/core/graphs/stages/__init__.py | 9 + hyperscale/core/graphs/stages/act/__init__.py | 1 + hyperscale/core/graphs/stages/act/act.py | 88 + .../core/graphs/stages/analyze/__init__.py | 1 + .../core/graphs/stages/analyze/analyze.py | 760 +++ .../stages/analyze/parallel/__init__.py | 1 + .../analyze/parallel/process_results_batch.py | 130 + .../core/graphs/stages/base/__init__.py | 1 + .../base/exceptions/hook_validation_error.py | 12 + .../missing_reserved_method_error.py | 12 + .../base/exceptions/process_killed_error.py | 6 + .../base/exceptions/reserved_method_error.py | 12 + .../core/graphs/stages/base/import_tools.py | 128 + .../graphs/stages/base/parallel/__init__.py | 0 .../stages/base/parallel/batch_executor.py | 332 ++ .../stages/base/parallel/partition_method.py | 6 + .../stages/base/parallel/stage_priority.py | 68 + .../base/parallel/synchronization/__init__.py | 1 + .../synchronization/batched_semaphore.py | 83 + hyperscale/core/graphs/stages/base/stage.py | 108 + .../core/graphs/stages/complete/__init__.py | 1 + .../core/graphs/stages/complete/complete.py | 21 + .../core/graphs/stages/error/__init__.py | 1 + hyperscale/core/graphs/stages/error/error.py | 57 + .../core/graphs/stages/execute/__init__.py | 1 + .../core/graphs/stages/execute/execute.py | 597 ++ .../stages/execute/parallel/__init__.py | 1 + .../execute/parallel/execute_actions.py | 447 ++ .../core/graphs/stages/idle/__init__.py | 1 + hyperscale/core/graphs/stages/idle/idle.py | 24 + .../core/graphs/stages/optimize/__init__.py | 1 + .../stages/optimize/optimization/__init__.py | 2 + .../optimization/algorithms/__init__.py | 33 + .../optimization/algorithms/types/__init__.py | 4 + .../algorithms/types/base_algorithm.py | 147 + .../types/differential_evolution_optimizer.py | 29 + .../types/dual_annealing_optimizer.py | 32 + .../types/least_squares_optimizer.py | 30 + .../algorithms/types/point_optimizer.py | 179 + .../algorithms/types/shg_optimizer.py | 33 + .../distribution_fit_optimizer.py | 206 + .../stages/optimize/optimization/optimizer.py | 227 + .../optimization/parameters/__init__.py | 1 + .../optimization/parameters/parameter.py | 36 + .../parameters/parameter_range.py | 21 + .../core/graphs/stages/optimize/optimize.py | 712 +++ .../stages/optimize/parallel/__init__.py | 1 + .../optimize/parallel/optimize_stage.py | 441 ++ .../core/graphs/stages/setup/__init__.py | 1 + .../stages/setup/exceptions/__init__.py | 2 + .../setup/exceptions/hook_setup_error.py | 13 + .../exceptions/hook_setup_timeout_error.py | 12 + hyperscale/core/graphs/stages/setup/setup.py | 671 +++ .../core/graphs/stages/submit/__init__.py | 1 + .../core/graphs/stages/submit/submit.py | 387 ++ .../core/graphs/stages/types/__init__.py | 2 + .../core/graphs/stages/types/stage_states.py | 25 + .../core/graphs/stages/types/stage_types.py | 14 + hyperscale/core/graphs/status.py | 11 + .../core/graphs/transitions/__init__.py | 2 + .../core/graphs/transitions/act/__init__.py | 0 .../core/graphs/transitions/act/act_edge.py | 369 ++ .../graphs/transitions/analyze/__init__.py | 0 .../transitions/analyze/analyze_edge.py | 262 + .../graphs/transitions/common/__init__.py | 0 .../graphs/transitions/common/base_edge.py | 64 + .../transitions/common/complete_edge.py | 40 + .../graphs/transitions/common/error_edge.py | 38 + .../common/transtition_metadata.py | 9 + .../graphs/transitions/exceptions/__init__.py | 0 .../transitions/exceptions/exceptions.py | 58 + .../graphs/transitions/execute/__init__.py | 0 .../transitions/execute/execute_edge.py | 341 ++ .../core/graphs/transitions/idle/__init__.py | 0 .../core/graphs/transitions/idle/idle_edge.py | 49 + .../graphs/transitions/local_transitions.py | 106 + .../graphs/transitions/optimize/__init__.py | 0 .../transitions/optimize/optimize_edge.py | 323 ++ .../core/graphs/transitions/setup/__init__.py | 0 .../graphs/transitions/setup/setup_edge.py | 314 + .../graphs/transitions/submit/__init__.py | 0 .../graphs/transitions/submit/submit_edge.py | 232 + .../core/graphs/transitions/transition.py | 169 + .../transitions/transition_assembler.py | 427 ++ .../graphs/transitions/transition_group.py | 156 + hyperscale/core/hooks/__init__.py | 14 + hyperscale/core/hooks/types/__init__.py | 13 + .../core/hooks/types/action/__init__.py | 0 .../core/hooks/types/action/decorator.py | 35 + hyperscale/core/hooks/types/action/event.py | 33 + hyperscale/core/hooks/types/action/hook.py | 101 + .../core/hooks/types/action/validator.py | 25 + hyperscale/core/hooks/types/base/__init__.py | 0 hyperscale/core/hooks/types/base/event.py | 178 + .../core/hooks/types/base/event_dispatch.py | 269 + .../core/hooks/types/base/event_graph.py | 185 + .../core/hooks/types/base/event_types.py | 15 + hyperscale/core/hooks/types/base/get_event.py | 46 + hyperscale/core/hooks/types/base/hook.py | 77 + .../core/hooks/types/base/hook_metadata.py | 25 + hyperscale/core/hooks/types/base/hook_type.py | 16 + hyperscale/core/hooks/types/base/registrar.py | 91 + .../core/hooks/types/base/simple_context.py | 127 + .../core/hooks/types/channel/__init__.py | 0 .../core/hooks/types/channel/decorator.py | 32 + hyperscale/core/hooks/types/channel/event.py | 33 + hyperscale/core/hooks/types/channel/hook.py | 63 + .../core/hooks/types/channel/validator.py | 20 + hyperscale/core/hooks/types/check/__init__.py | 0 .../core/hooks/types/check/decorator.py | 32 + hyperscale/core/hooks/types/check/event.py | 33 + hyperscale/core/hooks/types/check/hook.py | 68 + .../core/hooks/types/check/validator.py | 22 + .../core/hooks/types/condition/__init__.py | 0 .../core/hooks/types/condition/decorator.py | 29 + .../core/hooks/types/condition/event.py | 33 + hyperscale/core/hooks/types/condition/hook.py | 62 + .../core/hooks/types/condition/validator.py | 21 + .../core/hooks/types/context/__init__.py | 0 .../core/hooks/types/context/decorator.py | 31 + hyperscale/core/hooks/types/context/event.py | 34 + hyperscale/core/hooks/types/context/hook.py | 97 + .../core/hooks/types/context/validator.py | 13 + .../core/hooks/types/depends/decorator.py | 18 + .../core/hooks/types/depends/validator.py | 22 + .../core/hooks/types/event/decorator.py | 29 + hyperscale/core/hooks/types/event/event.py | 34 + hyperscale/core/hooks/types/event/hook.py | 66 + .../core/hooks/types/event/validator.py | 13 + .../core/hooks/types/internal/decorator.py | 29 + hyperscale/core/hooks/types/internal/hook.py | 23 + .../hooks/types/internal/internal_hook.py | 23 + hyperscale/core/hooks/types/load/__init__.py | 0 hyperscale/core/hooks/types/load/decorator.py | 70 + hyperscale/core/hooks/types/load/event.py | 33 + hyperscale/core/hooks/types/load/hook.py | 177 + hyperscale/core/hooks/types/load/validator.py | 25 + .../core/hooks/types/metric/decorator.py | 33 + hyperscale/core/hooks/types/metric/event.py | 34 + hyperscale/core/hooks/types/metric/hook.py | 186 + .../core/hooks/types/metric/validator.py | 28 + hyperscale/core/hooks/types/save/__init__.py | 0 hyperscale/core/hooks/types/save/decorator.py | 32 + hyperscale/core/hooks/types/save/event.py | 33 + hyperscale/core/hooks/types/save/hook.py | 148 + hyperscale/core/hooks/types/save/validator.py | 15 + hyperscale/core/hooks/types/task/decorator.py | 37 + hyperscale/core/hooks/types/task/event.py | 33 + hyperscale/core/hooks/types/task/hook.py | 70 + hyperscale/core/hooks/types/task/validator.py | 26 + .../core/hooks/types/transform/decorator.py | 30 + .../core/hooks/types/transform/event.py | 33 + hyperscale/core/hooks/types/transform/hook.py | 117 + .../core/hooks/types/transform/validator.py | 20 + hyperscale/core/personas/__init__.py | 1 + hyperscale/core/personas/batching/__init__.py | 1 + hyperscale/core/personas/batching/batch.py | 57 + .../core/personas/batching/param_type.py | 5 + hyperscale/core/personas/persona_registry.py | 32 + .../core/personas/streaming/__init__.py | 2 + hyperscale/core/personas/streaming/stream.py | 98 + .../personas/streaming/stream_analytics.py | 42 + hyperscale/core/personas/types/__init__.py | 1 + .../approximate_distribution/__init__.py | 1 + .../approximate_distribution_persona.py | 124 + .../types/batched_persona/__init__.py | 1 + .../types/batched_persona/batched_persona.py | 51 + .../constant_arrival_rate_persona/__init__.py | 1 + .../constant_arrival_rate_persona.py | 170 + .../constant_spawn_rate_persona/__init__.py | 1 + .../constant_spawn_rate_persona.py | 85 + .../types/cyclic_nowait_persona/__init__.py | 1 + .../cyclic_nowait_persona.py | 28 + .../types/default_persona/__init__.py | 1 + .../types/default_persona/default_persona.py | 389 ++ .../types/ramped_interval_persona/__init__.py | 1 + .../ramped_interval_persona.py | 58 + .../personas/types/ramped_persona/__init__.py | 1 + .../types/ramped_persona/ramped_persona.py | 65 + .../types/sequenced_persona/__init__.py | 1 + .../sequenced_persona/sequenced_persona.py | 30 + hyperscale/core/personas/types/types.py | 33 + .../weighted_selection_persona/__init__.py | 1 + .../weighted_selection_persona.py | 87 + hyperscale/core_rewrite/__init__.py | 3 + hyperscale/core_rewrite/engines/__init__.py | 0 .../core_rewrite/engines/client/__init__.py | 3 + .../core_rewrite/engines/client/client.py | 52 + .../core_rewrite/engines/client/config.py | 60 + .../engines/client/graphql/__init__.py | 6 + .../mercury_sync_graphql_connection.py | 575 ++ .../engines/client/graphql/models/__init__.py | 0 .../client/graphql/models/graphql/__init__.py | 5 + .../graphql/models/graphql/graphql_request.py | 113 + .../models/graphql/graphql_response.py | 7 + .../graphql/optimized_graphql_request.py | 37 + .../engines/client/graphql_http2/__init__.py | 8 + .../mercury_sync_graphql_http2_connection.py | 533 ++ .../client/graphql_http2/models/__init__.py | 0 .../models/graphql_http2/__init__.py | 5 + .../graphql_http2/graphql_http2_request.py | 97 + .../graphql_http2/graphql_http2_response.py | 8 + .../graphql_http2/optimized_http2_request.py | 30 + .../engines/client/grpc/__init__.py | 6 + .../grpc/mercury_sync_grpc_connection.py | 429 ++ .../engines/client/grpc/models/__init__.py | 0 .../client/grpc/models/grpc/__init__.py | 3 + .../client/grpc/models/grpc/grpc_request.py | 62 + .../client/grpc/models/grpc/grpc_response.py | 47 + .../models/grpc/optimized_grpc_request.py | 17 + .../engines/client/http/__init__.py | 6 + .../http/mercury_sync_http_connection.py | 870 +++ .../engines/client/http/models/__init__.py | 0 .../client/http/models/http/__init__.py | 3 + .../client/http/models/http/http_request.py | 94 + .../client/http/models/http/http_response.py | 99 + .../models/http/optimized_http_request.py | 38 + .../engines/client/http/protocols/__init__.py | 1 + .../client/http/protocols/connection.py | 108 + .../client/http/protocols/tcp/__init__.py | 1 + .../client/http/protocols/tcp/connection.py | 68 + .../client/http/protocols/tcp/protocol.py | 139 + .../client/http/protocols/tcp/tls_protocol.py | 16 + .../engines/client/http2/__init__.py | 6 + .../engines/client/http2/config/__init__.py | 1 + .../config/boolean_configuration_option.py | 16 + .../engines/client/http2/config/config.py | 121 + .../engines/client/http2/errors/__init__.py | 2 + .../engines/client/http2/errors/exceptions.py | 47 + .../engines/client/http2/errors/types.py | 49 + .../engines/client/http2/events/__init__.py | 15 + .../engines/client/http2/events/base_event.py | 2 + .../events/connection_terminated_event.py | 44 + .../http2/events/data_received_event.py | 49 + .../client/http2/events/headers_sent_event.py | 13 + .../informational_respose_received_event.py | 50 + .../events/remote_settings_changed_event.py | 66 + .../http2/events/request_received_event.py | 49 + .../client/http2/events/request_sent_event.py | 13 + .../http2/events/response_received_event.py | 48 + .../http2/events/response_sent_event.py | 12 + .../events/settings_acknowledged_event.py | 22 + .../client/http2/events/stream_ended_event.py | 17 + .../client/http2/events/stream_reset.py | 29 + .../http2/events/trailers_received_event.py | 45 + .../http2/events/trailers_sent_event.py | 15 + .../http2/events/window_updated_event.py | 25 + .../client/http2/fast_hpack/__init__.py | 16 + .../engines/client/http2/fast_hpack/hpack.py | 5053 ++++++++++++++++ .../client/http2/fast_hpack/huffman.py | 329 ++ .../engines/client/http2/fast_hpack/table.py | 223 + .../engines/client/http2/frames/__init__.py | 2 + .../engines/client/http2/frames/exceptions.py | 71 + .../client/http2/frames/frame_buffer.py | 247 + .../client/http2/frames/types/__init__.py | 0 .../http2/frames/types/attributes/__init__.py | 22 + .../frames/types/attributes/frame_flags.py | 61 + .../frames/types/attributes/frame_length.py | 6 + .../types/attributes/stream_associations.py | 4 + .../frames/types/attributes/struct_types.py | 10 + .../client/http2/frames/types/base_frame.py | 350 ++ .../client/http2/frames/types/utils.py | 18 + .../http2/mercury_sync_http2_connection.py | 895 +++ .../engines/client/http2/models/__init__.py | 0 .../client/http2/models/http2/__init__.py | 3 + .../http2/models/http2/http2_request.py | 80 + .../http2/models/http2/http2_response.py | 97 + .../models/http2/optimized_http2_request.py | 38 + .../core_rewrite/engines/client/http2/pipe.py | 459 ++ .../client/http2/protocols/__init__.py | 1 + .../client/http2/protocols/connection.py | 94 + .../client/http2/protocols/tcp/__init__.py | 1 + .../client/http2/protocols/tcp/connection.py | 91 + .../client/http2/protocols/tcp/protocol.py | 139 + .../http2/protocols/tcp/tls_protocol.py | 16 + .../engines/client/http2/settings/__init__.py | 4 + .../client/http2/settings/changed_setting.py | 30 + .../client/http2/settings/stream_closed_by.py | 7 + .../client/http2/settings/stream_settings.py | 248 + .../http2/settings/stream_settings_codes.py | 24 + .../client/http2/settings/stream_state.py | 10 + .../client/http2/settings/stream_state_map.py | 22 + .../engines/client/http2/streams/__init__.py | 1 + .../engines/client/http2/streams/stream.py | 110 + .../engines/client/http2/windows/__init__.py | 1 + .../client/http2/windows/window_manager.py | 111 + .../engines/client/http3/__init__.py | 6 + .../http3/mercury_sync_http3_connection.py | 755 +++ .../engines/client/http3/models/__init__.py | 0 .../client/http3/models/http3/__init__.py | 3 + .../http3/models/http3/http3_request.py | 80 + .../http3/models/http3/http3_response.py | 99 + .../models/http3/optimized_http3_request.py | 38 + .../client/http3/protocols/__init__.py | 1 + .../http3/protocols/http3_connection.py | 60 + .../client/http3/protocols/quic/__init__.py | 0 .../http3/protocols/quic/asyncio/__init__.py | 3 + .../http3/protocols/quic/asyncio/client.py | 93 + .../http3/protocols/quic/asyncio/protocol.py | 258 + .../http3/protocols/quic/asyncio/server.py | 215 + .../client/http3/protocols/quic/buffer.py | 30 + .../http3/protocols/quic/h0/__init__.py | 0 .../http3/protocols/quic/h0/connection.py | 78 + .../http3/protocols/quic/h3/__init__.py | 0 .../http3/protocols/quic/h3/connection.py | 1235 ++++ .../client/http3/protocols/quic/h3/events.py | 100 + .../http3/protocols/quic/h3/exceptions.py | 17 + .../http3/protocols/quic/quic/__init__.py | 0 .../protocols/quic/quic/configuration.py | 164 + .../quic/quic/congestion/__init__.py | 0 .../protocols/quic/quic/congestion/base.py | 128 + .../protocols/quic/quic/congestion/cubic.py | 212 + .../protocols/quic/quic/congestion/reno.py | 77 + .../http3/protocols/quic/quic/connection.py | 3411 +++++++++++ .../http3/protocols/quic/quic/crypto.py | 231 + .../http3/protocols/quic/quic/events.py | 126 + .../http3/protocols/quic/quic/logger.py | 333 ++ .../http3/protocols/quic/quic/packet.py | 515 ++ .../protocols/quic/quic/packet_builder.py | 359 ++ .../http3/protocols/quic/quic/rangeset.py | 98 + .../http3/protocols/quic/quic/recovery.py | 389 ++ .../client/http3/protocols/quic/quic/retry.py | 53 + .../http3/protocols/quic/quic/stream.py | 361 ++ .../client/http3/protocols/quic/tls.py | 2174 +++++++ .../client/http3/protocols/quic_protocol.py | 1137 ++++ .../client/http3/protocols/udp_connection.py | 92 + .../engines/client/playwright/__init__.py | 3 + .../client/playwright/browser_file_chooser.py | 76 + .../client/playwright/browser_frame.py | 1417 +++++ .../client/playwright/browser_js_handle.py | 153 + .../client/playwright/browser_keyboard.py | 263 + .../client/playwright/browser_locator.py | 2851 +++++++++ .../client/playwright/browser_mouse.py | 359 ++ .../engines/client/playwright/browser_page.py | 5141 +++++++++++++++++ .../client/playwright/browser_session.py | 134 + .../client/playwright/browser_touchscreen.py | 84 + .../mercury_sync_playwright_connection.py | 138 + .../client/playwright/models/__init__.py | 0 .../playwright/models/browser/__init__.py | 1 + .../models/browser/browser_metadata.py | 24 + .../playwright/models/commands/__init__.py | 0 .../models/commands/file_chooser/__init__.py | 1 + .../file_chooser/set_files_command.py | 16 + .../models/commands/frame/__init__.py | 1 + .../commands/frame/frame_element_command.py | 9 + .../models/commands/js_handle/__init__.py | 1 + .../commands/js_handle/evaluate_command.py | 14 + .../models/commands/keyboard/__init__.py | 3 + .../models/commands/keyboard/key_command.py | 11 + .../models/commands/keyboard/press_command.py | 14 + .../models/commands/keyboard/type_command.py | 14 + .../models/commands/locator/__init__.py | 30 + .../commands/locator/all_texts_command.py | 10 + .../commands/locator/and_matching_command.py | 14 + .../models/commands/locator/blur_command.py | 9 + .../commands/locator/bounding_box_command.py | 10 + .../models/commands/locator/check_command.py | 21 + .../models/commands/locator/clear_command.py | 10 + .../models/commands/locator/click_command.py | 25 + .../models/commands/locator/count_command.py | 9 + .../locator/dispatch_event_command.py | 14 + .../models/commands/locator/dom_command.py | 9 + .../commands/locator/drag_to_command.py | 20 + .../models/commands/locator/fill_command.py | 16 + .../models/commands/locator/filter_command.py | 17 + .../models/commands/locator/focus_command.py | 10 + .../commands/locator/get_attribute_command.py | 12 + .../commands/locator/highlight_command.py | 10 + .../models/commands/locator/hover_command.py | 22 + .../models/commands/locator/nth_command.py | 5 + .../commands/locator/or_matching_command.py | 14 + .../models/commands/locator/press_command.py | 16 + .../locator/press_sequentially_command.py | 16 + .../locator/scroll_into_view_if_needed.py | 9 + .../commands/locator/select_option_command.py | 26 + .../commands/locator/select_text_command.py | 13 + .../commands/locator/set_checked_command.py | 18 + .../commands/locator/set_input_files.py | 18 + .../models/commands/locator/tap_command.py | 28 + .../commands/locator/wait_for_command.py | 8 + .../models/commands/mouse/__init__.py | 4 + .../models/commands/mouse/button_command.py | 9 + .../models/commands/mouse/click_command.py | 16 + .../models/commands/mouse/move_command.py | 15 + .../models/commands/mouse/wheel_command.py | 13 + .../models/commands/page/__init__.py | 86 + .../commands/page/add_init_script_command.py | 19 + .../page/add_locator_handler_command.py | 27 + .../commands/page/add_script_tag_command.py | 21 + .../commands/page/add_style_tag_command.py | 20 + .../commands/page/bring_to_front_command.py | 5 + .../models/commands/page/check_command.py | 24 + .../models/commands/page/click_command.py | 28 + .../models/commands/page/close_command.py | 15 + .../models/commands/page/content_command.py | 14 + .../commands/page/dispatch_event_command.py | 17 + .../models/commands/page/dom_command.py | 15 + .../commands/page/double_click_command.py | 28 + .../commands/page/drag_and_drop_command.py | 22 + .../commands/page/emulate_media_command.py | 19 + .../models/commands/page/evaluate_command.py | 14 + .../page/evaluate_on_selector_command.py | 17 + .../page/expect_console_message_command.py | 21 + .../commands/page/expect_download_command.py | 21 + .../commands/page/expect_event_command.py | 41 + .../page/expect_file_chooser_command.py | 21 + .../page/expect_navigation_command.py | 16 + .../commands/page/expect_popup_command.py | 21 + .../commands/page/expect_request_command.py | 19 + .../page/expect_request_finished_command.py | 21 + .../commands/page/expect_response_command.py | 23 + .../commands/page/expect_websocket_command.py | 15 + .../commands/page/expect_worker_command.py | 15 + .../commands/page/expose_binding_command.py | 17 + .../commands/page/expose_function_command.py | 15 + .../models/commands/page/fill_command.py | 18 + .../models/commands/page/focus_command.py | 15 + .../models/commands/page/frame_command.py | 12 + .../commands/page/frame_locator_command.py | 6 + .../commands/page/get_attribute_command.py | 16 + .../commands/page/get_by_role_command.py | 98 + .../commands/page/get_by_test_id_command.py | 13 + .../commands/page/get_by_text_command.py | 15 + .../models/commands/page/get_url_command.py | 7 + .../models/commands/page/go_command.py | 19 + .../models/commands/page/goto_command.py | 17 + .../models/commands/page/hover_command.py | 25 + .../models/commands/page/is_closed_command.py | 9 + .../models/commands/page/locator_command.py | 21 + .../models/commands/page/on_command.py | 103 + .../models/commands/page/opener_command.py | 9 + .../models/commands/page/pause_command.py | 9 + .../models/commands/page/pdf_command.py | 31 + .../models/commands/page/press_command.py | 18 + .../models/commands/page/reload_command.py | 13 + .../page/remove_locator_handler_command.py | 14 + .../models/commands/page/route_command.py | 17 + .../commands/page/route_from_har_command.py | 33 + .../commands/page/screenshot_command.py | 30 + .../commands/page/select_option_command.py | 28 + .../commands/page/set_checked_command.py | 21 + .../commands/page/set_content_command.py | 21 + .../page/set_extra_http_headers_command.py | 8 + .../commands/page/set_input_files_command.py | 20 + .../commands/page/set_timeout_command.py | 9 + .../page/set_viewport_size_command.py | 11 + .../models/commands/page/tap_command.py | 31 + .../models/commands/page/title_command.py | 9 + .../models/commands/page/type_command.py | 19 + .../page/wait_for_function_command.py | 15 + .../page/wait_for_load_state_command.py | 18 + .../page/wait_for_selector_command.py | 17 + .../commands/page/wait_for_timeout_command.py | 9 + .../commands/page/wait_for_url_command.py | 27 + .../models/commands/touchscreen/__init__.py | 1 + .../commands/touchscreen/tap_command.py | 11 + .../playwright/models/results/__init__.py | 1 + .../models/results/playwright_result.py | 32 + .../engines/client/plugins_store.py | 45 + .../engines/client/shared/__init__.py | 0 .../engines/client/shared/models/__init__.py | 13 + .../engines/client/shared/models/cookies.py | 82 + .../client/shared/models/ip_address_info.py | 50 + .../engines/client/shared/models/metadata.py | 8 + .../client/shared/models/request_type.py | 15 + .../client/shared/models/socket_protocol.py | 9 + .../client/shared/models/socket_type.py | 9 + .../engines/client/shared/models/types.py | 75 + .../engines/client/shared/models/url.py | 211 + .../client/shared/models/url_metadata.py | 10 + .../client/shared/protocols/__init__.py | 12 + .../client/shared/protocols/constants.py | 9 + .../shared/protocols/flow_control_mixin.py | 71 + .../client/shared/protocols/protocol_map.py | 35 + .../engines/client/shared/protocols/reader.py | 484 ++ .../engines/client/shared/protocols/writer.py | 112 + .../client/shared/request_types_map.py | 64 + .../engines/client/shared/timeouts.py | 33 + .../core_rewrite/engines/client/store.py | 69 + .../engines/client/time_parser.py | 25 + .../engines/client/tracing_config.py | 131 + .../engines/client/udp/__init__.py | 8 + .../client/udp/mercury_sync_udp_connection.py | 380 ++ .../engines/client/udp/models/__init__.py | 0 .../engines/client/udp/models/udp/__init__.py | 3 + .../udp/models/udp/optimized_udp_request.py | 15 + .../client/udp/models/udp/udp_request.py | 41 + .../client/udp/models/udp/udp_response.py | 45 + .../engines/client/udp/protocols/__init__.py | 1 + .../client/udp/protocols/connection.py | 69 + .../client/udp/protocols/dtls/__init__.py | 65 + .../udp/protocols/dtls/demux/__init__.py | 63 + .../client/udp/protocols/dtls/demux/osnet.py | 121 + .../client/udp/protocols/dtls/demux/router.py | 189 + .../engines/client/udp/protocols/dtls/err.py | 141 + .../client/udp/protocols/dtls/openssl.py | 1232 ++++ .../client/udp/protocols/dtls/patch.py | 425 ++ .../dtls/prebuilt/win32-x86/libcrypto-1_1.dll | Bin 0 -> 2508288 bytes .../dtls/prebuilt/win32-x86/libssl-1_1.dll | Bin 0 -> 531456 bytes .../dtls/prebuilt/win32-x86/manifest.pycfg | 33 + .../win32-x86_64/libcrypto-1_1-x64.dll | Bin 0 -> 3403776 bytes .../prebuilt/win32-x86_64/libssl-1_1-x64.dll | Bin 0 -> 681472 bytes .../dtls/prebuilt/win32-x86_64/manifest.pycfg | 33 + .../udp/protocols/dtls/sslconnection.py | 1014 ++++ .../udp/protocols/dtls/test/__init__.py | 22 + .../udp/protocols/dtls/test/certs/badcert.pem | 36 + .../udp/protocols/dtls/test/certs/badkey.pem | 40 + .../udp/protocols/dtls/test/certs/ca-cert.pem | 13 + .../protocols/dtls/test/certs/ca-cert_ec.pem | 11 + .../udp/protocols/dtls/test/certs/keycert.pem | 30 + .../protocols/dtls/test/certs/keycert_ec.pem | 19 + .../protocols/dtls/test/certs/nullcert.pem | 0 .../protocols/dtls/test/certs/server-cert.pem | 14 + .../dtls/test/certs/server-cert_ec.pem | 11 + .../protocols/dtls/test/certs/wrongcert.pem | 32 + .../protocols/dtls/test/certs/yahoo-cert.pem | 29 + .../udp/protocols/dtls/test/echo_seq.py | 128 + .../client/udp/protocols/dtls/test/makecerts | 36 + .../udp/protocols/dtls/test/makecerts_ec.bat | 24 + .../udp/protocols/dtls/test/openssl_ca.cnf | 12 + .../protocols/dtls/test/openssl_server.cnf | 12 + .../client/udp/protocols/dtls/test/rl.py | 48 + .../udp/protocols/dtls/test/simple_client.py | 18 + .../udp/protocols/dtls/test/test_perf.py | 439 ++ .../client/udp/protocols/dtls/test/unit.py | 1424 +++++ .../udp/protocols/dtls/test/unit_wrapper.py | 654 +++ .../client/udp/protocols/dtls/tlock.py | 57 + .../engines/client/udp/protocols/dtls/util.py | 73 + .../client/udp/protocols/dtls/wrapper.py | 455 ++ .../engines/client/udp/protocols/dtls/x509.py | 142 + .../client/udp/protocols/udp/__init__.py | 1 + .../client/udp/protocols/udp/connection.py | 61 + .../client/udp/protocols/udp/protocol.py | 118 + .../engines/client/websocket/__init__.py | 8 + .../engines/client/websocket/connection.py | 6 + .../mercury_sync_websocket_connection.py | 453 ++ .../client/websocket/models/__init__.py | 0 .../websocket/models/websocket/__init__.py | 11 + .../websocket/models/websocket/constants.py | 2 + .../websocket/optimized_websocket_request.py | 38 + .../websocket/models/websocket/utils.py | 51 + .../models/websocket/websocket_request.py | 130 + .../models/websocket/websocket_response.py | 9 + hyperscale/core_rewrite/graph.py | 156 + hyperscale/core_rewrite/hooks/__init__.py | 4 + hyperscale/core_rewrite/hooks/call_arg.py | 22 + .../core_rewrite/hooks/call_resolver.py | 1235 ++++ hyperscale/core_rewrite/hooks/hook.py | 208 + .../core_rewrite/hooks/optimized/__init__.py | 0 .../hooks/optimized/models/__init__.py | 0 .../hooks/optimized/models/base/__init__.py | 2 + .../optimized/models/base/frozen_dict.py | 245 + .../optimized/models/base/optimized_arg.py | 29 + .../optimized/models/headers/__init__.py | 1 + .../hooks/optimized/models/headers/headers.py | 51 + .../models/headers/headers_validator.py | 9 + .../optimized/models/mutation/__init__.py | 0 .../hooks/optimized/models/query/__init__.py | 1 + .../hooks/optimized/models/query/query.py | 23 + .../optimized/models/query/query_validator.py | 5 + .../hooks/optimized/models/url/__init__.py | 1 + .../hooks/optimized/models/url/url.py | 26 + .../optimized/models/url/url_validator.py | 9 + hyperscale/core_rewrite/hooks/resolved_arg.py | 34 + .../core_rewrite/hooks/resolved_arg_type.py | 14 + .../core_rewrite/hooks/resolved_auth.py | 7 + .../core_rewrite/hooks/resolved_cookies.py | 12 + .../core_rewrite/hooks/resolved_data.py | 8 + .../core_rewrite/hooks/resolved_headers.py | 7 + .../core_rewrite/hooks/resolved_method.py | 4 + .../core_rewrite/hooks/resolved_params.py | 5 + .../core_rewrite/hooks/resolved_redirects.py | 5 + hyperscale/core_rewrite/hooks/resolved_url.py | 14 + hyperscale/core_rewrite/hooks/step.py | 12 + hyperscale/core_rewrite/hooks/step_type.py | 25 + hyperscale/core_rewrite/parser/__init__.py | 1 + .../parser/dynamic_placeholder.py | 8 + .../parser/dynamic_template_string.py | 49 + hyperscale/core_rewrite/parser/parser.py | 352 ++ .../core_rewrite/parser/placeholder_call.py | 20 + hyperscale/core_rewrite/snowflake/__init__.py | 1 + .../core_rewrite/snowflake/constants.py | 3 + .../core_rewrite/snowflake/snowflake.py | 55 + .../snowflake/snowflake_generator.py | 49 + hyperscale/core_rewrite/workflow.py | 78 + hyperscale/data/__init__.py | 0 hyperscale/data/connectors/__init__.py | 0 .../data/connectors/aws_lambda/__init__.py | 0 .../aws_lambda/aws_lambda_connector.py | 155 + .../aws_lambda/aws_lambda_connector_config.py | 11 + .../data/connectors/bigtable/__init__.py | 0 .../connectors/bigtable/bigtable_connector.py | 167 + .../bigtable/bigtable_connector_config.py | 11 + .../data/connectors/cassandra/__init__.py | 0 .../cassandra/cassandra_connector.py | 467 ++ .../cassandra/cassandra_connector_config.py | 22 + .../cassandra/cassandra_load_validator.py | 16 + .../cassandra/schema_set/__init__.py | 1 + .../schema_set/action_schemas/__init__.py | 10 + .../cassandra_graphql_action_schema.py | 113 + .../cassandra_graphql_http2_action_schema.py | 113 + .../cassandra_grpc_action_schema.py | 139 + .../cassandra_http2_action_schema.py | 147 + .../cassandra_http3_action_schema.py | 147 + .../cassandra_http_action_schema.py | 147 + .../cassandra_playwright_action_schema.py | 265 + .../action_schemas/cassandra_task_schema.py | 104 + .../cassandra_udp_action_schema.py | 147 + .../cassandra_websocket_action_schema.py | 97 + .../schema_set/cassandra_schema_set.py | 144 + .../schema_set/result_schemas/__init__.py | 10 + .../cassandra_graphql_http2_result_schema.py | 60 + .../cassandra_graphql_result_schema.py | 60 + .../cassandra_grpc_result_schema.py | 58 + .../cassandra_http2_result_schema.py | 58 + .../cassandra_http3_result_schema.py | 58 + .../cassandra_http_result_schema.py | 58 + .../cassandra_playwright_result_schema.py | 85 + .../cassandra_task_result_schema.py | 45 + .../cassandra_udp_result_schema.py | 58 + .../cassandra_websocket_result_schema.py | 58 + hyperscale/data/connectors/common/__init__.py | 0 .../data/connectors/common/connector_type.py | 22 + .../common/execute_stage_summary_validator.py | 19 + hyperscale/data/connectors/connector.py | 248 + .../data/connectors/cosmosdb/__init__.py | 0 .../connectors/cosmosdb/cosmos_connector.py | 130 + .../cosmosdb/cosmos_connector_config.py | 13 + hyperscale/data/connectors/csv/__init__.py | 0 .../data/connectors/csv/csv_connector.py | 190 + .../connectors/csv/csv_connector_config.py | 12 + .../data/connectors/csv/csv_load_validator.py | 6 + hyperscale/data/connectors/empty/__init__.py | 0 .../google_cloud_storage/__init__.py | 0 .../google_cloud_storage_connector.py | 170 + .../google_cloud_storage_connector_config.py | 10 + hyperscale/data/connectors/har/__init__.py | 0 .../data/connectors/har/har_connector.py | 213 + .../connectors/har/har_connector_config.py | 8 + hyperscale/data/connectors/json/__init__.py | 0 .../data/connectors/json/json_connector.py | 182 + .../connectors/json/json_connector_config.py | 9 + hyperscale/data/connectors/kafka/__init__.py | 0 .../data/connectors/kafka/kafka_connector.py | 159 + .../kafka/kafka_connector_config.py | 17 + .../data/connectors/mongodb/__init__.py | 0 .../connectors/mongodb/mongodb_connector.py | 124 + .../mongodb/mongodb_connector_config.py | 14 + hyperscale/data/connectors/mysql/__init__.py | 0 .../data/connectors/mysql/mysql_connector.py | 160 + .../mysql/mysql_connector_config.py | 15 + .../data/connectors/postgres/__init__.py | 0 .../connectors/postgres/postgres_connector.py | 161 + .../postgres/postgres_connector_config.py | 15 + hyperscale/data/connectors/redis/__init__.py | 0 .../data/connectors/redis/redis_connector.py | 170 + .../redis/redis_connector_config.py | 16 + hyperscale/data/connectors/s3/__init__.py | 0 hyperscale/data/connectors/s3/s3_connector.py | 177 + .../data/connectors/s3/s3_connector_config.py | 11 + .../data/connectors/snowflake/__init__.py | 0 .../snowflake/snowflake_connector.py | 224 + .../snowflake/snowflake_connector_config.py | 22 + hyperscale/data/connectors/sqlite/__init__.py | 0 .../connectors/sqlite/sqlite_connector.py | 149 + .../sqlite/sqlite_connector_config.py | 14 + hyperscale/data/connectors/xml/__init__.py | 0 .../data/connectors/xml/xml_connector.py | 189 + .../connectors/xml/xml_connector_config.py | 9 + hyperscale/data/parsers/__init__.py | 0 hyperscale/data/parsers/parser.py | 211 + .../data/parsers/parser_types/__init__.py | 18 + .../parsers/parser_types/common/__init__.py | 0 .../parser_types/common/base_parser.py | 35 + .../parsers/parser_types/common/parsing.py | 42 + .../parser_types/common/result_validator.py | 24 + .../parsers/parser_types/graphql/__init__.py | 0 .../graphql/graphql_action_parser.py | 99 + .../graphql/graphql_action_validator.py | 31 + .../graphql/graphql_result_parser.py | 107 + .../parser_types/graphql_http2/__init__.py | 0 .../graphql_http2_action_parser.py | 103 + .../graphql_http2_action_validator.py | 31 + .../graphql_http2_result_parser.py | 110 + .../parsers/parser_types/grpc/__init__.py | 0 .../parser_types/grpc/grpc_action_parser.py | 109 + .../grpc/grpc_action_validator.py | 30 + .../grpc/grpc_options_validator.py | 6 + .../parser_types/grpc/grpc_result_parser.py | 108 + .../parsers/parser_types/http/__init__.py | 0 .../parser_types/http/http_action_parser.py | 99 + .../http/http_action_validator.py | 30 + .../parser_types/http/http_result_parser.py | 106 + .../parsers/parser_types/http2/__init__.py | 0 .../parser_types/http2/http2_action_parser.py | 92 + .../http2/http2_action_validator.py | 28 + .../parser_types/http2/http2_result_parser.py | 106 + .../parsers/parser_types/http3/__init__.py | 0 .../parser_types/http3/http3_action_parser.py | 92 + .../http3/http3_action_validator.py | 30 + .../parser_types/http3/http3_result_parser.py | 106 + .../parser_types/playwright/__init__.py | 0 .../playwright/playwright_action_parser.py | 166 + .../playwright/playwright_action_validator.py | 64 + .../playwright/playwright_result_parser.py | 155 + .../data/parsers/parser_types/udp/__init__.py | 0 .../parser_types/udp/udp_action_parser.py | 86 + .../parser_types/udp/udp_action_validator.py | 29 + .../parser_types/udp/udp_result_parser.py | 94 + .../parser_types/websocket/__init__.py | 0 .../websocket/websocket_action_parser.py | 95 + .../websocket/websocket_action_validator.py | 30 + .../websocket/websocket_result_parser.py | 106 + hyperscale/data/serializers/__init__.py | 1 + hyperscale/data/serializers/serializer.py | 229 + .../serializers/serializer_types/__init__.py | 10 + .../serializer_types/common/__init__.py | 0 .../common/base_serializer.py | 44 + .../common/metadata_serializer.py | 19 + .../serializer_types/graphql/__init__.py | 0 .../graphql/graphql_serializer.py | 144 + .../graphql_http2/__init__.py | 0 .../graphql_http2/graphql_http2_serializer.py | 143 + .../serializer_types/grpc/__init__.py | 0 .../serializer_types/grpc/grpc_serializer.py | 138 + .../serializer_types/http/__init__.py | 0 .../serializer_types/http/http_serializer.py | 147 + .../serializer_types/http2/__init__.py | 0 .../http2/http2_serializer.py | 138 + .../serializer_types/http3/__init__.py | 0 .../http3/http3_serializer.py | 149 + .../serializer_types/playwright/__init__.py | 0 .../playwright/playwright_serializer.py | 228 + .../serializer_types/task/__init__.py | 0 .../serializer_types/task/task_serializer.py | 95 + .../serializer_types/udp/__init__.py | 0 .../serializer_types/udp/udp_serializer.py | 133 + .../serializer_types/websocket/__init__.py | 0 .../websocket/websocket_serializer.py | 146 + hyperscale/distributed/__init__.py | 0 hyperscale/distributed/connection/__init__.py | 0 .../connection/addresses/__init__.py | 1 + .../connection/addresses/subnet_range.py | 27 + .../distributed/connection/base/__init__.py | 0 .../connection/base/connection_type.py | 7 + .../distributed/connection/tcp/__init__.py | 2 + .../tcp/mercury_sync_http_connection.py | 483 ++ .../tcp/mercury_sync_tcp_connection.py | 909 +++ .../connection/tcp/protocols/__init__.py | 2 + .../mercury_sync_tcp_client_protocol.py | 30 + .../mercury_sync_tcp_server_protocol.py | 33 + .../distributed/connection/udp/__init__.py | 2 + .../udp/mercury_sync_udp_connection.py | 573 ++ .../mercury_sync_udp_multicast_connection.py | 120 + .../connection/udp/protocols/__init__.py | 1 + .../protocols/mercury_sync_udp_protocol.py | 32 + hyperscale/distributed/discovery/__init__.py | 0 .../distributed/discovery/dns/__init__.py | 1 + .../discovery/dns/core/__init__.py | 0 .../discovery/dns/core/cache/__init__.py | 1 + .../discovery/dns/core/cache/cache_node.py | 82 + .../discovery/dns/core/cache/cache_value.py | 43 + .../discovery/dns/core/config/__init__.py | 8 + .../discovery/dns/core/config/nt.py | 68 + .../discovery/dns/core/config/posix.py | 20 + .../discovery/dns/core/config/root.py | 93 + .../discovery/dns/core/exceptions/__init__.py | 2 + .../dns/core/exceptions/dns_error.py | 14 + .../exceptions/invalid_service_url_error.py | 6 + .../dns/core/exceptions/utils/__init__.py | 1 + .../dns/core/exceptions/utils/get_bits.py | 9 + .../dns/core/nameservers/__init__.py | 1 + .../dns/core/nameservers/exceptions.py | 2 + .../dns/core/nameservers/nameserver.py | 56 + .../discovery/dns/core/random/__init__.py | 1 + .../dns/core/random/random_id_generator.py | 87 + .../discovery/dns/core/record/__init__.py | 6 + .../discovery/dns/core/record/query_type.py | 17 + .../discovery/dns/core/record/record.py | 227 + .../core/record/record_data_types/__init__.py | 17 + .../record/record_data_types/a_record_data.py | 36 + .../record_data_types/aaaa_record_data.py | 36 + .../record_data_types/cname_record_data.py | 29 + .../record_data_types/domain_record_data.py | 39 + .../record_data_types/mx_record_data.py | 69 + .../record_data_types/naptr_record_data.py | 92 + .../record_data_types/ns_record_data.py | 29 + .../record_data_types/ptr_record_data.py | 30 + .../record/record_data_types/record_data.py | 43 + .../record/record_data_types/record_types.py | 66 + .../record_data_types/soa_record_data.py | 107 + .../record_data_types/srv_record_data.py | 93 + .../record_data_types/txt_record_data.py | 42 + .../unsupported_record_data.py | 33 + .../record_data_types/utils/__init__.py | 4 + .../utils/load_domain_name.py | 43 + .../record_data_types/utils/load_string.py | 9 + .../utils/pack_domain_name.py | 36 + .../record_data_types/utils/pack_string.py | 10 + .../discovery/dns/core/url/__init__.py | 5 + .../discovery/dns/core/url/exceptions.py | 6 + .../discovery/dns/core/url/host.py | 47 + .../distributed/discovery/dns/core/url/url.py | 170 + .../distributed/discovery/dns/registrar.py | 451 ++ .../discovery/dns/request/__init__.py | 1 + .../discovery/dns/request/dns_client.py | 219 + .../discovery/dns/resolver/__init__.py | 1 + .../discovery/dns/resolver/base_resolver.py | 238 + .../discovery/dns/resolver/cache_resolver.py | 201 + .../discovery/dns/resolver/memoizer.py | 63 + .../discovery/dns/resolver/proxy_resolver.py | 190 + .../dns/resolver/recursive_resolver.py | 370 ++ .../discovery/dns/resolver/resolver.py | 126 + .../discovery/dns/resolver/types.py | 0 .../distributed/discovery/volume/__init__.py | 0 .../discovery/volume/backup_volume.py | 18 + hyperscale/distributed/encryption/__init__.py | 1 + hyperscale/distributed/encryption/aes_gcm.py | 22 + hyperscale/distributed/env/__init__.py | 5 + hyperscale/distributed/env/env.py | 91 + hyperscale/distributed/env/load_env.py | 50 + hyperscale/distributed/env/memory_parser.py | 89 + hyperscale/distributed/env/monitor_env.py | 57 + hyperscale/distributed/env/registrar_env.py | 34 + hyperscale/distributed/env/replication_env.py | 35 + hyperscale/distributed/env/time_parser.py | 31 + hyperscale/distributed/hooks/__init__.py | 5 + hyperscale/distributed/hooks/client_hook.py | 39 + hyperscale/distributed/hooks/endpoint_hook.py | 103 + .../distributed/hooks/middleware_hook.py | 19 + hyperscale/distributed/hooks/server_hook.py | 20 + hyperscale/distributed/hooks/stream_hook.py | 49 + hyperscale/distributed/middleware/__init__.py | 18 + .../distributed/middleware/base/__init__.py | 4 + .../middleware/base/base_wrapper.py | 14 + .../middleware/base/bidirectional_wrapper.py | 131 + .../middleware/base/call_wrapper.py | 110 + .../distributed/middleware/base/middleware.py | 150 + .../distributed/middleware/base/types.py | 105 + .../middleware/base/unidirectional_wrapper.py | 163 + .../middleware/circuit_breaker/__init__.py | 1 + .../circuit_breaker/circuit_breaker.py | 230 + .../circuit_breaker/circuit_breaker_state.py | 7 + .../middleware/compressor/__init__.py | 4 + .../bidirectional_gzip_compressor.py | 175 + .../bidirectional_zstandard_compressor.py | 174 + .../middleware/compressor/gzip_compressor.py | 129 + .../compressor/zstandard_compressor.py | 127 + .../distributed/middleware/cors/__init__.py | 1 + .../distributed/middleware/cors/cors.py | 166 + .../middleware/cors/cors_headers.py | 120 + .../distributed/middleware/crsf/__init__.py | 1 + .../distributed/middleware/crsf/crsf.py | 214 + .../middleware/decompressor/__init__.py | 4 + .../bidirectional_gzip_decompressor.py | 195 + .../bidirectional_zstandard_decompressor.py | 198 + .../decompressor/gzip_decompressor.py | 146 + .../decompressor/zstandard_decompressor.py | 146 + hyperscale/distributed/models/__init__.py | 0 .../distributed/models/base/__init__.py | 0 hyperscale/distributed/models/base/error.py | 7 + hyperscale/distributed/models/base/message.py | 13 + hyperscale/distributed/models/dns/__init__.py | 7 + .../distributed/models/dns/dns_entry.py | 268 + .../distributed/models/dns/dns_message.py | 274 + .../models/dns/dns_message_group.py | 9 + hyperscale/distributed/models/dns/service.py | 28 + .../distributed/models/http/__init__.py | 8 + .../distributed/models/http/http_message.py | 59 + .../distributed/models/http/http_request.py | 158 + hyperscale/distributed/models/http/limit.py | 113 + hyperscale/distributed/models/http/request.py | 147 + .../distributed/models/http/response.py | 45 + .../distributed/models/raft/__init__.py | 4 + .../distributed/models/raft/election_state.py | 8 + .../distributed/models/raft/healthcheck.py | 26 + .../distributed/models/raft/logs/__init__.py | 2 + .../distributed/models/raft/logs/entry.py | 51 + .../models/raft/logs/node_state.py | 7 + .../distributed/models/raft/raft_message.py | 22 + .../distributed/models/raft/vote_result.py | 6 + hyperscale/distributed/monitoring/__init__.py | 1 + .../distributed/monitoring/monitor_service.py | 1998 +++++++ .../distributed/rate_limiting/__init__.py | 1 + .../distributed/rate_limiting/limiter.py | 173 + .../rate_limiting/limiters/__init__.py | 6 + .../limiters/adaptive_limiter.py | 111 + .../rate_limiting/limiters/base_limiter.py | 96 + .../rate_limiting/limiters/cpu_adaptive.py | 196 + .../limiters/leaky_bucket_limiter.py | 52 + .../limiters/resource_adaptive_limiter.py | 181 + .../limiters/sliding_window_limiter.py | 58 + .../limiters/token_bucket_limiter.py | 123 + .../distributed/replication/__init__.py | 1 + .../distributed/replication/constants.py | 1 + .../replication/errors/__init__.py | 1 + .../replication/errors/invalid_term_error.py | 11 + .../distributed/replication/log_queue.py | 236 + .../replication/replication_controller.py | 1187 ++++ hyperscale/distributed/service/__init__.py | 2 + hyperscale/distributed/service/controller.py | 656 +++ .../distributed/service/plugin_group.py | 30 + .../distributed/service/plugin_wrapper.py | 0 hyperscale/distributed/service/service.py | 318 + .../distributed/service/socket/__init__.py | 4 + .../distributed/service/socket/socket.py | 52 + hyperscale/distributed/snowflake/__init__.py | 1 + hyperscale/distributed/snowflake/constants.py | 3 + hyperscale/distributed/snowflake/snowflake.py | 55 + .../snowflake/snowflake_generator.py | 54 + hyperscale/distributed/types/__init__.py | 3 + hyperscale/distributed/types/call.py | 7 + hyperscale/distributed/types/response.py | 7 + hyperscale/distributed/types/stream.py | 10 + hyperscale/logging/__init__.py | 2 + hyperscale/logging/config/__init__.py | 1 + hyperscale/logging/config/logging_config.py | 80 + hyperscale/logging/hyperscale_logger.py | 150 + hyperscale/logging/logger_types/__init__.py | 8 + .../logger_types/async_filesystem_logger.py | 113 + .../logging/logger_types/async_logger.py | 57 + .../logging/logger_types/async_spinner.py | 541 ++ .../logging/logger_types/handers/__init__.py | 0 .../handers/async_file_handler.py | 477 ++ hyperscale/logging/logger_types/logger.py | 63 + .../logging/logger_types/logger_types.py | 12 + .../logging/logger_types/logger_types_map.py | 53 + .../logger_types/sync_filesystem_logger.py | 104 + .../logging/logger_types/sync_logger.py | 36 + hyperscale/logging/logging_manager.py | 74 + hyperscale/logging/spinner/__init__.py | 1 + hyperscale/logging/spinner/progress_text.py | 101 + hyperscale/logging/spinner/timer.py | 42 + hyperscale/logging/table/__init__.py | 0 .../logging/table/execution_summary_table.py | 281 + .../table/experiments_summary_table.py | 189 + hyperscale/logging/table/summary_table.py | 121 + .../logging/table/system_summary_table.py | 308 + hyperscale/logging/table/table_types.py | 13 + hyperscale/monitoring/__init__.py | 2 + hyperscale/monitoring/base/__init__.py | 0 hyperscale/monitoring/base/exceptions.py | 6 + hyperscale/monitoring/base/monitor.py | 254 + hyperscale/monitoring/cpu/__init__.py | 1 + hyperscale/monitoring/cpu/monitor.py | 31 + hyperscale/monitoring/memory/__init__.py | 1 + hyperscale/monitoring/memory/monitor.py | 33 + hyperscale/plugins/__init__.py | 0 hyperscale/plugins/types/__init__.py | 0 hyperscale/plugins/types/common/__init__.py | 2 + hyperscale/plugins/types/common/event.py | 34 + hyperscale/plugins/types/common/plugin.py | 4 + .../plugins/types/common/plugin_hook.py | 19 + hyperscale/plugins/types/common/registrar.py | 45 + hyperscale/plugins/types/common/types.py | 23 + hyperscale/plugins/types/engine/__init__.py | 8 + hyperscale/plugins/types/engine/action.py | 75 + .../plugins/types/engine/engine_plugin.py | 122 + .../plugins/types/engine/hooks/__init__.py | 0 .../types/engine/hooks/types/__init__.py | 3 + .../plugins/types/engine/hooks/types/close.py | 20 + .../types/engine/hooks/types/connect.py | 20 + .../types/engine/hooks/types/execute.py | 20 + hyperscale/plugins/types/engine/result.py | 33 + .../plugins/types/extension/__init__.py | 5 + .../types/extension/extension_plugin.py | 80 + .../plugins/types/extension/hooks/__init__.py | 0 .../types/extension/hooks/types/__init__.py | 2 + .../extension/hooks/types/execute/__init__.py | 0 .../hooks/types/execute/decorator.py | 20 + .../extension/hooks/types/prepare/__init__.py | 0 .../hooks/types/prepare/decorator.py | 20 + hyperscale/plugins/types/extension/types.py | 5 + .../plugins/types/optimizer/__init__.py | 6 + .../plugins/types/optimizer/hooks/__init__.py | 0 .../types/optimizer/hooks/types/__init__.py | 3 + .../types/optimizer/hooks/types/get.py | 20 + .../types/optimizer/hooks/types/optimize.py | 20 + .../types/optimizer/hooks/types/update.py | 20 + .../types/optimizer/optimizer_plugin.py | 53 + hyperscale/plugins/types/persona/__init__.py | 6 + .../plugins/types/persona/hooks/__init__.py | 0 .../types/persona/hooks/types/__init__.py | 3 + .../types/persona/hooks/types/generate.py | 20 + .../types/persona/hooks/types/setup.py | 20 + .../types/persona/hooks/types/shutdown.py | 20 + .../plugins/types/persona/persona_plugin.py | 92 + hyperscale/plugins/types/plugin_types.py | 9 + hyperscale/plugins/types/reporter/__init__.py | 12 + .../plugins/types/reporter/hooks/__init__.py | 0 .../types/reporter/hooks/types/__init__.py | 7 + .../reporter/hooks/types/process_custom.py | 20 + .../reporter/hooks/types/process_errors.py | 20 + .../reporter/hooks/types/process_events.py | 20 + .../reporter/hooks/types/process_metrics.py | 20 + .../reporter/hooks/types/process_shared.py | 20 + .../reporter/hooks/types/reporter_close.py | 20 + .../reporter/hooks/types/reporter_connect.py | 20 + .../plugins/types/reporter/reporter_config.py | 51 + .../types/reporter/reporter_metrics.py | 6 + .../plugins/types/reporter/reporter_plugin.py | 67 + hyperscale/projects/__init__.py | 0 hyperscale/projects/generation/__init__.py | 2 + .../projects/generation/generator/__init__.py | 1 + .../generation/generator/generator.py | 121 + .../generation/graph_types/__init__.py | 1 + .../generation/graph_types/graph_generator.py | 166 + .../generation/graph_types/stages/__init__.py | 3 + .../graph_types/stages/execute/__init__.py | 9 + .../generated_graphql_execute_stage.py | 17 + .../execute/generated_graphql_http2_stage.py | 17 + .../execute/generated_http2_execute_stage.py | 11 + .../execute/generated_http3_execute_stage.py | 11 + .../execute/generated_http_execute_stage.py | 11 + .../generated_playwright_execute_stage.py | 15 + .../execute/generated_task_execute_stage.py | 19 + .../execute/generated_udp_execute_stage.py | 15 + .../generated_websocket_execute_stage.py | 15 + .../stages/generated_analyze_stage.py | 5 + .../stages/generated_checkpoint_stage.py | 21 + .../stages/generated_optimize_stage.py | 9 + .../stages/generated_setup_stage.py | 6 + .../stages/generated_teardown_stage.py | 9 + .../stages/generated_validate_stage.py | 15 + .../graph_types/stages/submit/__init__.py | 30 + .../generated_aws_lambda_results_stage.py | 16 + .../generated_aws_timestream_results_stage.py | 15 + .../generated_bigquery_results_stage.py | 14 + .../generated_bigtable_results_stage.py | 13 + .../generated_cassandra_results_stage.py | 16 + .../generated_cloudwatch_results_stage.py | 21 + .../generated_cosmosdb_results_stage.py | 16 + .../submit/generated_csv_results_stage.py | 9 + .../submit/generated_datadog_results_stage.py | 11 + .../generated_dogstatsd_results_stage.py | 9 + ...ated_google_cloud_storage_results_stage.py | 13 + .../generated_graphite_results_stage.py | 9 + .../generated_honeycomb_results_stage.py | 11 + .../generated_influxdb_results_stage.py | 14 + .../submit/generated_json_results_stage.py | 9 + .../submit/generated_kafka_results_stage.py | 12 + .../submit/generated_mongodb_results_stage.py | 15 + .../submit/generated_mysql_results_stage.py | 15 + .../submit/generated_netdata_results_stage.py | 9 + .../generated_newrelic_results_stage.py | 12 + .../generated_postgres_results_stage.py | 15 + .../generated_prometheus_results_stage.py | 15 + .../submit/generated_redis_results_stage.py | 16 + .../submit/generated_s3_results_stage.py | 15 + .../generated_snowflake_results_stage.py | 19 + .../submit/generated_sqlite_results_stage.py | 12 + .../submit/generated_statsd_results_stage.py | 9 + .../generated_telegraf_results_stage.py | 9 + ...generated_telegraf_statsd_results_stage.py | 9 + .../generated_timescaledb_results_stage.py | 15 + .../generation/plugin_types/__init__.py | 1 + .../plugin_types/plugin_generator.py | 32 + .../plugin_types/plugins/__init__.py | 15 + .../plugins/generated_engine_plugin.py | 63 + .../plugins/generated_optimizer_plugin.py | 23 + .../plugins/generated_persona_plugin.py | 36 + .../plugins/generated_reporter_plugin.py | 54 + hyperscale/projects/management/__init__.py | 1 + .../projects/management/graphs/__init__.py | 1 + .../management/graphs/actions/__init__.py | 5 + .../management/graphs/actions/action.py | 139 + .../management/graphs/actions/config.py | 32 + .../graphs/actions/create_gitignore.py | 27 + .../management/graphs/actions/fetch.py | 23 + .../management/graphs/actions/initialize.py | 65 + .../management/graphs/actions/synchronize.py | 54 + .../management/graphs/exceptions/__init__.py | 1 + .../graphs/exceptions/invalid_action_error.py | 12 + .../management/graphs/graph_manager.py | 119 + hyperscale/reporting/__init__.py | 40 + hyperscale/reporting/experiment/__init__.py | 0 .../experiment/experiment_metrics_set.py | 311 + .../experiment_metrics_set_types.py | 176 + .../experiment/experiments_collection.py | 37 + hyperscale/reporting/metric/__init__.py | 4 + hyperscale/reporting/metric/custom_metric.py | 25 + hyperscale/reporting/metric/metric_types.py | 17 + hyperscale/reporting/metric/metrics_group.py | 124 + hyperscale/reporting/metric/metrics_set.py | 101 + .../reporting/metric/metrics_set_types.py | 172 + .../reporting/metric/stage_metrics_summary.py | 367 ++ .../reporting/metric/stage_streams_set.py | 161 + .../reporting/processed_result/__init__.py | 2 + .../processed_results_group.py | 146 + .../reporting/processed_result/results.py | 28 + .../processed_result/types/__init__.py | 10 + .../types/base_processed_result.py | 86 + .../types/graphql_http2_processed_result.py | 45 + .../types/graphql_processed_result.py | 45 + .../types/grpc_processed_result.py | 19 + .../types/http2_processed_result.py | 103 + .../types/http3_processed_result.py | 16 + .../types/http_processed_result.py | 99 + .../types/playwright_processed_result.py | 79 + .../types/task_processed_result.py | 50 + .../types/udp_processed_result.py | 91 + .../types/websocket_processed_result.py | 19 + hyperscale/reporting/reporter.py | 299 + hyperscale/reporting/stats/__init__.py | 5 + hyperscale/reporting/stats/mean.py | 28 + hyperscale/reporting/stats/median.py | 33 + .../stats/median_absolute_deviation.py | 40 + .../reporting/stats/standard_deviation.py | 20 + hyperscale/reporting/stats/variance.py | 26 + hyperscale/reporting/system/__init__.py | 1 + .../reporting/system/system_metrics_group.py | 90 + .../reporting/system/system_metrics_set.py | 236 + .../system/system_metrics_set_types.py | 212 + hyperscale/reporting/tags/__init__.py | 1 + hyperscale/reporting/tags/tag.py | 5 + hyperscale/reporting/types/__init__.py | 156 + .../reporting/types/aws_lambda/__init__.py | 2 + .../reporting/types/aws_lambda/aws_lambda.py | 334 ++ .../types/aws_lambda/aws_lambda_config.py | 17 + .../types/aws_timestream/__init__.py | 2 + .../types/aws_timestream/aws_timestream.py | 696 +++ .../aws_timestream/aws_timestream_config.py | 22 + .../aws_timestream_error_record.py | 59 + .../aws_timestream/aws_timestream_record.py | 87 + .../reporting/types/bigquery/__init__.py | 2 + .../reporting/types/bigquery/bigquery.py | 911 +++ .../types/bigquery/bigquery_config.py | 19 + .../reporting/types/bigtable/__init__.py | 2 + .../reporting/types/bigtable/bigtable.py | 907 +++ .../types/bigtable/bigtable_config.py | 15 + .../reporting/types/cassandra/__init__.py | 2 + .../reporting/types/cassandra/cassandra.py | 831 +++ .../types/cassandra/cassandra_config.py | 26 + .../reporting/types/cloudwatch/__init__.py | 2 + .../reporting/types/cloudwatch/cloudwatch.py | 456 ++ .../types/cloudwatch/cloudwatch_config.py | 27 + hyperscale/reporting/types/common/__init__.py | 1 + hyperscale/reporting/types/common/types.py | 35 + .../reporting/types/cosmosdb/__init__.py | 2 + .../reporting/types/cosmosdb/cosmosdb.py | 403 ++ .../types/cosmosdb/cosmosdb_config.py | 23 + hyperscale/reporting/types/csv/__init__.py | 2 + hyperscale/reporting/types/csv/csv.py | 811 +++ hyperscale/reporting/types/csv/csv_config.py | 30 + .../reporting/types/datadog/__init__.py | 2 + hyperscale/reporting/types/datadog/datadog.py | 608 ++ .../reporting/types/datadog/datadog_config.py | 15 + .../reporting/types/dogstatsd/__init__.py | 2 + .../reporting/types/dogstatsd/dogstatsd.py | 115 + .../types/dogstatsd/dogstatsd_config.py | 12 + hyperscale/reporting/types/empty/__init__.py | 1 + hyperscale/reporting/types/empty/empty.py | 7 + .../types/google_cloud_storage/__init__.py | 2 + .../google_cloud_storage.py | 666 +++ .../google_cloud_storage_config.py | 14 + .../reporting/types/graphite/__init__.py | 2 + .../reporting/types/graphite/graphite.py | 299 + .../types/graphite/graphite_config.py | 12 + .../reporting/types/honeycomb/__init__.py | 2 + .../reporting/types/honeycomb/honeycomb.py | 381 ++ .../types/honeycomb/honeycomb_config.py | 9 + .../reporting/types/influxdb/__init__.py | 2 + .../reporting/types/influxdb/influxdb.py | 435 ++ .../types/influxdb/influxdb_config.py | 17 + hyperscale/reporting/types/json/__init__.py | 2 + hyperscale/reporting/types/json/json.py | 399 ++ .../reporting/types/json/json_config.py | 30 + hyperscale/reporting/types/kafka/__init__.py | 2 + hyperscale/reporting/types/kafka/kafka.py | 427 ++ .../reporting/types/kafka/kafka_config.py | 25 + .../reporting/types/mongodb/__init__.py | 2 + hyperscale/reporting/types/mongodb/mongodb.py | 236 + .../reporting/types/mongodb/mongodb_config.py | 18 + hyperscale/reporting/types/mysql/__init__.py | 2 + hyperscale/reporting/types/mysql/mysql.py | 673 +++ .../reporting/types/mysql/mysql_config.py | 19 + .../reporting/types/netdata/__init__.py | 2 + hyperscale/reporting/types/netdata/netdata.py | 17 + .../reporting/types/netdata/netdata_config.py | 9 + .../reporting/types/newrelic/__init__.py | 2 + .../reporting/types/newrelic/newrelic.py | 345 ++ .../types/newrelic/newrelic_config.py | 14 + .../reporting/types/postgres/__init__.py | 2 + .../reporting/types/postgres/postgres.py | 744 +++ .../types/postgres/postgres_config.py | 19 + .../reporting/types/prometheus/__init__.py | 2 + .../reporting/types/prometheus/prometheus.py | 722 +++ .../types/prometheus/prometheus_config.py | 18 + .../types/prometheus/prometheus_metric.py | 165 + hyperscale/reporting/types/redis/__init__.py | 2 + hyperscale/reporting/types/redis/redis.py | 421 ++ .../reporting/types/redis/redis_config.py | 20 + hyperscale/reporting/types/s3/__init__.py | 2 + hyperscale/reporting/types/s3/s3.py | 620 ++ hyperscale/reporting/types/s3/s3_config.py | 16 + .../reporting/types/snowflake/__init__.py | 2 + .../reporting/types/snowflake/snowflake.py | 722 +++ .../types/snowflake/snowflake_config.py | 26 + hyperscale/reporting/types/sqlite/__init__.py | 2 + hyperscale/reporting/types/sqlite/sqlite.py | 661 +++ .../reporting/types/sqlite/sqlite_config.py | 18 + hyperscale/reporting/types/statsd/__init__.py | 2 + hyperscale/reporting/types/statsd/statsd.py | 363 ++ .../reporting/types/statsd/statsd_config.py | 12 + .../reporting/types/telegraf/__init__.py | 2 + .../reporting/types/telegraf/telegraf.py | 287 + .../types/telegraf/telegraf_config.py | 9 + .../types/telegraf_statsd/__init__.py | 2 + .../types/telegraf_statsd/telegraf_statsd.py | 87 + .../telegraf_statsd/teleraf_statsd_config.py | 12 + .../reporting/types/timescaledb/__init__.py | 2 + .../types/timescaledb/timescaledb.py | 674 +++ .../types/timescaledb/timescaledb_config.py | 19 + hyperscale/reporting/types/xml/__init__.py | 2 + hyperscale/reporting/types/xml/xml.py | 689 +++ hyperscale/reporting/types/xml/xml_config.py | 30 + hyperscale/tools/__init__.py | 2 + hyperscale/tools/data_structures/__init__.py | 1 + .../tools/data_structures/async_list.py | 249 + hyperscale/tools/filesystem/__init__.py | 1 + hyperscale/tools/filesystem/base.py | 36 + hyperscale/tools/filesystem/binary.py | 110 + hyperscale/tools/filesystem/filesystem.py | 129 + hyperscale/tools/filesystem/text.py | 69 + hyperscale/tools/filesystem/utils.py | 85 + hyperscale/tools/helpers/__init__.py | 3 + hyperscale/tools/helpers/awaitable.py | 19 + hyperscale/tools/helpers/cancel.py | 14 + hyperscale/tools/helpers/wait.py | 53 + hyperscale/tools/helpers/wrap.py | 7 + hyperscale/versioning/__init__.py | 0 hyperscale/versioning/flags/__init__.py | 0 .../versioning/flags/exceptions/__init__.py | 0 .../flags/exceptions/latest_not_enabled.py | 6 + .../flags/exceptions/unsafe_not_enabled.py | 6 + hyperscale/versioning/flags/types/__init__.py | 0 .../versioning/flags/types/base/__init__.py | 0 .../versioning/flags/types/base/active.py | 7 + .../versioning/flags/types/base/feature.py | 19 + .../versioning/flags/types/base/flag_type.py | 6 + .../versioning/flags/types/base/registry.py | 66 + .../versioning/flags/types/unsafe/__init__.py | 0 .../versioning/flags/types/unsafe/feature.py | 23 + .../versioning/flags/types/unsafe/flag.py | 12 + .../flags/types/unstable/__init__.py | 0 .../flags/types/unstable/feature.py | 23 + .../versioning/flags/types/unstable/flag.py | 17 + requirements.in | 30 + setup.py | 181 + 1612 files changed, 154320 insertions(+), 161 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 .devcontainer/run.sh create mode 100644 .github/workflows/main.yml create mode 100644 .version create mode 100644 CODE_OF_CONDUCT.md create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 examples/arg_hash.py create mode 100644 examples/engine_test.py create mode 100644 examples/optimizer_test.py create mode 100644 hyperscale/__init__.py create mode 100644 hyperscale/cli/__init__.py create mode 100644 hyperscale/cli/base.py create mode 100644 hyperscale/cli/cloud.py create mode 100644 hyperscale/cli/cloud/__init__.py create mode 100644 hyperscale/cli/cloud/check.py create mode 100644 hyperscale/cli/cloud/login.py create mode 100644 hyperscale/cli/cloud/run.py create mode 100644 hyperscale/cli/cloud/submit.py create mode 100644 hyperscale/cli/cloud/sync.py create mode 100644 hyperscale/cli/cloud/update.py create mode 100644 hyperscale/cli/cloud/watch.py create mode 100644 hyperscale/cli/exceptions/__init__.py create mode 100644 hyperscale/cli/exceptions/graph/__init__.py create mode 100644 hyperscale/cli/exceptions/graph/create/__init__.py create mode 100644 hyperscale/cli/exceptions/graph/create/invalid_stage_type.py create mode 100644 hyperscale/cli/exceptions/graph/sync/__init__.py create mode 100644 hyperscale/cli/exceptions/graph/sync/not_set_error.py create mode 100644 hyperscale/cli/exceptions/plugin/__init__.py create mode 100644 hyperscale/cli/exceptions/plugin/create/__init__.py create mode 100644 hyperscale/cli/exceptions/plugin/create/invaid_plugin_type.py create mode 100644 hyperscale/cli/graph.py create mode 100644 hyperscale/cli/graph/__init__.py create mode 100644 hyperscale/cli/graph/check.py create mode 100644 hyperscale/cli/graph/create.py create mode 100644 hyperscale/cli/graph/run.py create mode 100644 hyperscale/cli/ping.py create mode 100644 hyperscale/cli/plugin.py create mode 100644 hyperscale/cli/plugin/__init__.py create mode 100644 hyperscale/cli/plugin/create.py create mode 100644 hyperscale/cli/project.py create mode 100644 hyperscale/cli/project/__init__.py create mode 100644 hyperscale/cli/project/about.py create mode 100644 hyperscale/cli/project/create.py create mode 100644 hyperscale/cli/project/get.py create mode 100644 hyperscale/cli/project/sync.py create mode 100644 hyperscale/core/__init__.py create mode 100644 hyperscale/core/engines/__init__.py create mode 100644 hyperscale/core/engines/client/__init__.py create mode 100644 hyperscale/core/engines/client/client.py create mode 100644 hyperscale/core/engines/client/client_types/__init__.py create mode 100644 hyperscale/core/engines/client/client_types/base_client.py create mode 100644 hyperscale/core/engines/client/client_types/graphql.py create mode 100644 hyperscale/core/engines/client/client_types/graphql_http2.py create mode 100644 hyperscale/core/engines/client/client_types/grpc.py create mode 100644 hyperscale/core/engines/client/client_types/http.py create mode 100644 hyperscale/core/engines/client/client_types/http2.py create mode 100644 hyperscale/core/engines/client/client_types/http3.py create mode 100644 hyperscale/core/engines/client/client_types/playwright.py create mode 100644 hyperscale/core/engines/client/client_types/udp.py create mode 100644 hyperscale/core/engines/client/client_types/websocket.py create mode 100644 hyperscale/core/engines/client/config.py create mode 100644 hyperscale/core/engines/client/plugins_store.py create mode 100644 hyperscale/core/engines/client/store.py create mode 100644 hyperscale/core/engines/client/time_parser.py create mode 100644 hyperscale/core/engines/client/tracing_config.py create mode 100644 hyperscale/core/engines/types/__init__.py create mode 100644 hyperscale/core/engines/types/common/__init__.py create mode 100644 hyperscale/core/engines/types/common/action_registry.py create mode 100644 hyperscale/core/engines/types/common/base_action.py create mode 100644 hyperscale/core/engines/types/common/base_engine.py create mode 100644 hyperscale/core/engines/types/common/base_result.py create mode 100644 hyperscale/core/engines/types/common/concurrency/__init__.py create mode 100644 hyperscale/core/engines/types/common/concurrency/balancing_semaphore.py create mode 100644 hyperscale/core/engines/types/common/concurrency/noop_semaphore.py create mode 100644 hyperscale/core/engines/types/common/concurrency/semaphore.py create mode 100644 hyperscale/core/engines/types/common/constants.py create mode 100644 hyperscale/core/engines/types/common/decoder.py create mode 100644 hyperscale/core/engines/types/common/encoder.py create mode 100644 hyperscale/core/engines/types/common/fast_hpack/__init__.py create mode 100644 hyperscale/core/engines/types/common/fast_hpack/hpack.py create mode 100644 hyperscale/core/engines/types/common/fast_hpack/huffman.py create mode 100644 hyperscale/core/engines/types/common/fast_hpack/table.py create mode 100644 hyperscale/core/engines/types/common/hooks.py create mode 100644 hyperscale/core/engines/types/common/hpack/__init__.py create mode 100644 hyperscale/core/engines/types/common/hpack/constants.py create mode 100644 hyperscale/core/engines/types/common/hpack/exceptions.py create mode 100644 hyperscale/core/engines/types/common/hpack/huffman_encoder.py create mode 100644 hyperscale/core/engines/types/common/hpack/huffman_table.py create mode 100644 hyperscale/core/engines/types/common/hpack/structs.py create mode 100644 hyperscale/core/engines/types/common/hpack/table.py create mode 100644 hyperscale/core/engines/types/common/metadata.py create mode 100644 hyperscale/core/engines/types/common/protocols/__init__.py create mode 100644 hyperscale/core/engines/types/common/protocols/ping/__init__.py create mode 100644 hyperscale/core/engines/types/common/protocols/ping/ping.py create mode 100644 hyperscale/core/engines/types/common/protocols/ping/ping_type.py create mode 100644 hyperscale/core/engines/types/common/protocols/shared/__init__.py create mode 100644 hyperscale/core/engines/types/common/protocols/shared/constants.py create mode 100644 hyperscale/core/engines/types/common/protocols/shared/protocol.py create mode 100644 hyperscale/core/engines/types/common/protocols/shared/reader.py create mode 100644 hyperscale/core/engines/types/common/protocols/shared/writer.py create mode 100644 hyperscale/core/engines/types/common/protocols/tcp/__init__.py create mode 100644 hyperscale/core/engines/types/common/protocols/tcp/connection.py create mode 100644 hyperscale/core/engines/types/common/protocols/tcp/protocol.py create mode 100644 hyperscale/core/engines/types/common/protocols/tcp/tls_protocol.py create mode 100644 hyperscale/core/engines/types/common/protocols/udp/__init__.py create mode 100644 hyperscale/core/engines/types/common/protocols/udp/connection.py create mode 100644 hyperscale/core/engines/types/common/protocols/udp/protocol.py create mode 100644 hyperscale/core/engines/types/common/protocols/udp/quic_protocol.py create mode 100644 hyperscale/core/engines/types/common/results_set.py create mode 100644 hyperscale/core/engines/types/common/ssl.py create mode 100644 hyperscale/core/engines/types/common/timeouts.py create mode 100644 hyperscale/core/engines/types/common/types.py create mode 100644 hyperscale/core/engines/types/common/url.py create mode 100644 hyperscale/core/engines/types/custom/__init__.py create mode 100644 hyperscale/core/engines/types/custom/client.py create mode 100644 hyperscale/core/engines/types/custom/connection.py create mode 100644 hyperscale/core/engines/types/custom/pool.py create mode 100644 hyperscale/core/engines/types/graphql/__init__.py create mode 100644 hyperscale/core/engines/types/graphql/action.py create mode 100644 hyperscale/core/engines/types/graphql/client.py create mode 100644 hyperscale/core/engines/types/graphql/result.py create mode 100644 hyperscale/core/engines/types/graphql_http2/__init__.py create mode 100644 hyperscale/core/engines/types/graphql_http2/action.py create mode 100644 hyperscale/core/engines/types/graphql_http2/client.py create mode 100644 hyperscale/core/engines/types/graphql_http2/result.py create mode 100644 hyperscale/core/engines/types/grpc/__init__.py create mode 100644 hyperscale/core/engines/types/grpc/action.py create mode 100644 hyperscale/core/engines/types/grpc/client.py create mode 100644 hyperscale/core/engines/types/grpc/protobuf_registry.py create mode 100644 hyperscale/core/engines/types/grpc/result.py create mode 100644 hyperscale/core/engines/types/http/__init__.py create mode 100644 hyperscale/core/engines/types/http/action.py create mode 100644 hyperscale/core/engines/types/http/client.py create mode 100644 hyperscale/core/engines/types/http/connection.py create mode 100644 hyperscale/core/engines/types/http/pool.py create mode 100644 hyperscale/core/engines/types/http/result.py create mode 100644 hyperscale/core/engines/types/http2/__init__.py create mode 100644 hyperscale/core/engines/types/http2/action.py create mode 100644 hyperscale/core/engines/types/http2/client.py create mode 100644 hyperscale/core/engines/types/http2/config/__init__.py create mode 100644 hyperscale/core/engines/types/http2/config/boolean_configuration_option.py create mode 100644 hyperscale/core/engines/types/http2/config/config.py create mode 100644 hyperscale/core/engines/types/http2/connection.py create mode 100644 hyperscale/core/engines/types/http2/errors/__init__.py create mode 100644 hyperscale/core/engines/types/http2/errors/exceptions.py create mode 100644 hyperscale/core/engines/types/http2/errors/types.py create mode 100644 hyperscale/core/engines/types/http2/events/__init__.py create mode 100644 hyperscale/core/engines/types/http2/events/base_event.py create mode 100644 hyperscale/core/engines/types/http2/events/connection_terminated_event.py create mode 100644 hyperscale/core/engines/types/http2/events/data_received_event.py create mode 100644 hyperscale/core/engines/types/http2/events/deferred_headers_event.py create mode 100644 hyperscale/core/engines/types/http2/events/headers_sent_event.py create mode 100644 hyperscale/core/engines/types/http2/events/informational_respose_received_event.py create mode 100644 hyperscale/core/engines/types/http2/events/remote_settings_changed_event.py create mode 100644 hyperscale/core/engines/types/http2/events/request_received_event.py create mode 100644 hyperscale/core/engines/types/http2/events/request_sent_event.py create mode 100644 hyperscale/core/engines/types/http2/events/response_received_event.py create mode 100644 hyperscale/core/engines/types/http2/events/response_sent_event.py create mode 100644 hyperscale/core/engines/types/http2/events/settings_acknowledged_event.py create mode 100644 hyperscale/core/engines/types/http2/events/stream_ended_event.py create mode 100644 hyperscale/core/engines/types/http2/events/stream_reset.py create mode 100644 hyperscale/core/engines/types/http2/events/trailers_received_event.py create mode 100644 hyperscale/core/engines/types/http2/events/trailers_sent_event.py create mode 100644 hyperscale/core/engines/types/http2/events/window_updated_event.py create mode 100644 hyperscale/core/engines/types/http2/frames/__init__.py create mode 100644 hyperscale/core/engines/types/http2/frames/frame_buffer.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/__init__.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/attributes/__init__.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/attributes/frame_flags.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/attributes/frame_length.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/attributes/stream_associations.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/attributes/struct_types.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/base_frame.py create mode 100644 hyperscale/core/engines/types/http2/frames/types/utils.py create mode 100644 hyperscale/core/engines/types/http2/pipe.py create mode 100644 hyperscale/core/engines/types/http2/pool.py create mode 100644 hyperscale/core/engines/types/http2/result.py create mode 100644 hyperscale/core/engines/types/http2/stream.py create mode 100644 hyperscale/core/engines/types/http2/streams/__init__.py create mode 100644 hyperscale/core/engines/types/http2/streams/changed_setting.py create mode 100644 hyperscale/core/engines/types/http2/streams/stream_closed_by.py create mode 100644 hyperscale/core/engines/types/http2/streams/stream_settings.py create mode 100644 hyperscale/core/engines/types/http2/streams/stream_settings_codes.py create mode 100644 hyperscale/core/engines/types/http2/streams/stream_state.py create mode 100644 hyperscale/core/engines/types/http2/streams/stream_state_map.py create mode 100644 hyperscale/core/engines/types/http2/windows/__init__.py create mode 100644 hyperscale/core/engines/types/http2/windows/window_manager.py create mode 100644 hyperscale/core/engines/types/http3/__init__.py create mode 100644 hyperscale/core/engines/types/http3/action.py create mode 100644 hyperscale/core/engines/types/http3/client.py create mode 100644 hyperscale/core/engines/types/http3/connection.py create mode 100644 hyperscale/core/engines/types/http3/pool.py create mode 100644 hyperscale/core/engines/types/http3/result.py create mode 100644 hyperscale/core/engines/types/playwright/__init__.py create mode 100644 hyperscale/core/engines/types/playwright/client.py create mode 100644 hyperscale/core/engines/types/playwright/command.py create mode 100644 hyperscale/core/engines/types/playwright/command_librarian.py create mode 100644 hyperscale/core/engines/types/playwright/command_library.py create mode 100644 hyperscale/core/engines/types/playwright/context_config.py create mode 100644 hyperscale/core/engines/types/playwright/context_group.py create mode 100644 hyperscale/core/engines/types/playwright/hooks.py create mode 100644 hyperscale/core/engines/types/playwright/pool.py create mode 100644 hyperscale/core/engines/types/playwright/result.py create mode 100644 hyperscale/core/engines/types/registry.py create mode 100644 hyperscale/core/engines/types/task/__init__.py create mode 100644 hyperscale/core/engines/types/task/result.py create mode 100644 hyperscale/core/engines/types/task/runner.py create mode 100644 hyperscale/core/engines/types/task/task.py create mode 100644 hyperscale/core/engines/types/tracing/__init__.py create mode 100644 hyperscale/core/engines/types/tracing/trace.py create mode 100644 hyperscale/core/engines/types/tracing/trace_session.py create mode 100644 hyperscale/core/engines/types/tracing/tracing_types.py create mode 100644 hyperscale/core/engines/types/tracing/url_filters.py create mode 100644 hyperscale/core/engines/types/udp/__init__.py create mode 100644 hyperscale/core/engines/types/udp/action.py create mode 100644 hyperscale/core/engines/types/udp/client.py create mode 100644 hyperscale/core/engines/types/udp/connection.py create mode 100644 hyperscale/core/engines/types/udp/pool.py create mode 100644 hyperscale/core/engines/types/udp/result.py create mode 100644 hyperscale/core/engines/types/websocket/__init__.py create mode 100644 hyperscale/core/engines/types/websocket/action.py create mode 100644 hyperscale/core/engines/types/websocket/client.py create mode 100644 hyperscale/core/engines/types/websocket/connection.py create mode 100644 hyperscale/core/engines/types/websocket/constants.py create mode 100644 hyperscale/core/engines/types/websocket/pool.py create mode 100644 hyperscale/core/engines/types/websocket/result.py create mode 100644 hyperscale/core/engines/types/websocket/utils.py create mode 100644 hyperscale/core/experiments/__init__.py create mode 100644 hyperscale/core/experiments/distribution.py create mode 100644 hyperscale/core/experiments/distribution_types.py create mode 100644 hyperscale/core/experiments/distributions/__init__.py create mode 100644 hyperscale/core/experiments/distributions/types/__init__.py create mode 100644 hyperscale/core/experiments/distributions/types/alpha.py create mode 100644 hyperscale/core/experiments/distributions/types/anglit.py create mode 100644 hyperscale/core/experiments/distributions/types/arcsine.py create mode 100644 hyperscale/core/experiments/distributions/types/argus.py create mode 100644 hyperscale/core/experiments/distributions/types/base.py create mode 100644 hyperscale/core/experiments/distributions/types/beta.py create mode 100644 hyperscale/core/experiments/distributions/types/beta_prime.py create mode 100644 hyperscale/core/experiments/distributions/types/bradford.py create mode 100644 hyperscale/core/experiments/distributions/types/burr.py create mode 100644 hyperscale/core/experiments/distributions/types/burr_12.py create mode 100644 hyperscale/core/experiments/distributions/types/cauchy.py create mode 100644 hyperscale/core/experiments/distributions/types/chi.py create mode 100644 hyperscale/core/experiments/distributions/types/chi_squared.py create mode 100644 hyperscale/core/experiments/distributions/types/cosine.py create mode 100644 hyperscale/core/experiments/distributions/types/crystal_ball.py create mode 100644 hyperscale/core/experiments/distributions/types/dgamma.py create mode 100644 hyperscale/core/experiments/distributions/types/dweibull.py create mode 100644 hyperscale/core/experiments/distributions/types/erlang.py create mode 100644 hyperscale/core/experiments/distributions/types/exponential.py create mode 100644 hyperscale/core/experiments/distributions/types/exponential_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/exponential_power.py create mode 100644 hyperscale/core/experiments/distributions/types/f_distribution.py create mode 100644 hyperscale/core/experiments/distributions/types/fatigue_life.py create mode 100644 hyperscale/core/experiments/distributions/types/fisk.py create mode 100644 hyperscale/core/experiments/distributions/types/folded_cauchy.py create mode 100644 hyperscale/core/experiments/distributions/types/folded_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/gamma.py create mode 100644 hyperscale/core/experiments/distributions/types/gauss_hypergeometric.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_exponential.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_extreme.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_gamma.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_half_logistic.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_hyperbolic.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_inverse_gauss.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_logistic.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/generalized_pareto.py create mode 100644 hyperscale/core/experiments/distributions/types/gibrat.py create mode 100644 hyperscale/core/experiments/distributions/types/gompertz.py create mode 100644 hyperscale/core/experiments/distributions/types/gumbel_l.py create mode 100644 hyperscale/core/experiments/distributions/types/gumbel_r.py create mode 100644 hyperscale/core/experiments/distributions/types/half_cauchy.py create mode 100644 hyperscale/core/experiments/distributions/types/half_generalized_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/half_logistic.py create mode 100644 hyperscale/core/experiments/distributions/types/half_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/hyperbolic_secant.py create mode 100644 hyperscale/core/experiments/distributions/types/inverse_gamma.py create mode 100644 hyperscale/core/experiments/distributions/types/inverse_weibull.py create mode 100644 hyperscale/core/experiments/distributions/types/johnson_sb.py create mode 100644 hyperscale/core/experiments/distributions/types/johnson_su.py create mode 100644 hyperscale/core/experiments/distributions/types/kappa_3.py create mode 100644 hyperscale/core/experiments/distributions/types/kappa_4.py create mode 100644 hyperscale/core/experiments/distributions/types/ks_one.py create mode 100644 hyperscale/core/experiments/distributions/types/ks_two.py create mode 100644 hyperscale/core/experiments/distributions/types/ks_two_bi_generalized.py create mode 100644 hyperscale/core/experiments/distributions/types/laplace.py create mode 100644 hyperscale/core/experiments/distributions/types/laplace_asymmetric.py create mode 100644 hyperscale/core/experiments/distributions/types/levy.py create mode 100644 hyperscale/core/experiments/distributions/types/levy_l.py create mode 100644 hyperscale/core/experiments/distributions/types/levy_stable.py create mode 100644 hyperscale/core/experiments/distributions/types/log_gamma.py create mode 100644 hyperscale/core/experiments/distributions/types/log_laplace.py create mode 100644 hyperscale/core/experiments/distributions/types/log_uniform.py create mode 100644 hyperscale/core/experiments/distributions/types/logistic.py create mode 100644 hyperscale/core/experiments/distributions/types/lomax.py create mode 100644 hyperscale/core/experiments/distributions/types/maxwell.py create mode 100644 hyperscale/core/experiments/distributions/types/mielke.py create mode 100644 hyperscale/core/experiments/distributions/types/moyal.py create mode 100644 hyperscale/core/experiments/distributions/types/nakagami.py create mode 100644 hyperscale/core/experiments/distributions/types/non_central_f_distribution.py create mode 100644 hyperscale/core/experiments/distributions/types/noncentral_chi_squared.py create mode 100644 hyperscale/core/experiments/distributions/types/noncentral_t_distribution.py create mode 100644 hyperscale/core/experiments/distributions/types/normal.py create mode 100644 hyperscale/core/experiments/distributions/types/normal_inverse_gauss.py create mode 100644 hyperscale/core/experiments/distributions/types/pareto.py create mode 100644 hyperscale/core/experiments/distributions/types/pearson_3.py create mode 100644 hyperscale/core/experiments/distributions/types/power_log_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/power_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/powerlaw.py create mode 100644 hyperscale/core/experiments/distributions/types/r_distribution.py create mode 100644 hyperscale/core/experiments/distributions/types/rayleigh.py create mode 100644 hyperscale/core/experiments/distributions/types/reciprocal_inverse_gauss.py create mode 100644 hyperscale/core/experiments/distributions/types/rice.py create mode 100644 hyperscale/core/experiments/distributions/types/semi_circular.py create mode 100644 hyperscale/core/experiments/distributions/types/skewed_cauchy.py create mode 100644 hyperscale/core/experiments/distributions/types/skewed_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/student_range.py create mode 100644 hyperscale/core/experiments/distributions/types/t_distribution.py create mode 100644 hyperscale/core/experiments/distributions/types/trapezoid.py create mode 100644 hyperscale/core/experiments/distributions/types/triangular.py create mode 100644 hyperscale/core/experiments/distributions/types/truncated_exponential.py create mode 100644 hyperscale/core/experiments/distributions/types/truncated_normal.py create mode 100644 hyperscale/core/experiments/distributions/types/truncated_pareto.py create mode 100644 hyperscale/core/experiments/distributions/types/truncated_weibull_minimum.py create mode 100644 hyperscale/core/experiments/distributions/types/tukey_lambda.py create mode 100644 hyperscale/core/experiments/distributions/types/uniform.py create mode 100644 hyperscale/core/experiments/distributions/types/vonmises.py create mode 100644 hyperscale/core/experiments/distributions/types/vonmises_line.py create mode 100644 hyperscale/core/experiments/distributions/types/wald.py create mode 100644 hyperscale/core/experiments/distributions/types/weibull_maximum.py create mode 100644 hyperscale/core/experiments/distributions/types/weibull_minimum.py create mode 100644 hyperscale/core/experiments/distributions/types/wrapped_cauchy.py create mode 100644 hyperscale/core/experiments/experiment.py create mode 100644 hyperscale/core/experiments/mutations/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/base/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/base/mutation.py create mode 100644 hyperscale/core/experiments/mutations/types/base/mutation_type.py create mode 100644 hyperscale/core/experiments/mutations/types/base/validator.py create mode 100644 hyperscale/core/experiments/mutations/types/deform_header/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/deform_header/mutation.py create mode 100644 hyperscale/core/experiments/mutations/types/deform_header/validator.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_header/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_header/mutation.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_header/validator.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_junk_data/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_junk_data/mutation.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_junk_data/validator.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_ping/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_ping/mutation.py create mode 100644 hyperscale/core/experiments/mutations/types/inject_ping/validator.py create mode 100644 hyperscale/core/experiments/mutations/types/smuggle_request/__init__.py create mode 100644 hyperscale/core/experiments/mutations/types/smuggle_request/mutation.py create mode 100644 hyperscale/core/experiments/mutations/types/smuggle_request/validator.py create mode 100644 hyperscale/core/experiments/variant.py create mode 100644 hyperscale/core/graphs/__init__.py create mode 100644 hyperscale/core/graphs/graph.py create mode 100644 hyperscale/core/graphs/stages/__init__.py create mode 100644 hyperscale/core/graphs/stages/act/__init__.py create mode 100644 hyperscale/core/graphs/stages/act/act.py create mode 100644 hyperscale/core/graphs/stages/analyze/__init__.py create mode 100644 hyperscale/core/graphs/stages/analyze/analyze.py create mode 100644 hyperscale/core/graphs/stages/analyze/parallel/__init__.py create mode 100644 hyperscale/core/graphs/stages/analyze/parallel/process_results_batch.py create mode 100644 hyperscale/core/graphs/stages/base/__init__.py create mode 100644 hyperscale/core/graphs/stages/base/exceptions/hook_validation_error.py create mode 100644 hyperscale/core/graphs/stages/base/exceptions/missing_reserved_method_error.py create mode 100644 hyperscale/core/graphs/stages/base/exceptions/process_killed_error.py create mode 100644 hyperscale/core/graphs/stages/base/exceptions/reserved_method_error.py create mode 100644 hyperscale/core/graphs/stages/base/import_tools.py create mode 100644 hyperscale/core/graphs/stages/base/parallel/__init__.py create mode 100644 hyperscale/core/graphs/stages/base/parallel/batch_executor.py create mode 100644 hyperscale/core/graphs/stages/base/parallel/partition_method.py create mode 100644 hyperscale/core/graphs/stages/base/parallel/stage_priority.py create mode 100644 hyperscale/core/graphs/stages/base/parallel/synchronization/__init__.py create mode 100644 hyperscale/core/graphs/stages/base/parallel/synchronization/batched_semaphore.py create mode 100644 hyperscale/core/graphs/stages/base/stage.py create mode 100644 hyperscale/core/graphs/stages/complete/__init__.py create mode 100644 hyperscale/core/graphs/stages/complete/complete.py create mode 100644 hyperscale/core/graphs/stages/error/__init__.py create mode 100644 hyperscale/core/graphs/stages/error/error.py create mode 100644 hyperscale/core/graphs/stages/execute/__init__.py create mode 100644 hyperscale/core/graphs/stages/execute/execute.py create mode 100644 hyperscale/core/graphs/stages/execute/parallel/__init__.py create mode 100644 hyperscale/core/graphs/stages/execute/parallel/execute_actions.py create mode 100644 hyperscale/core/graphs/stages/idle/__init__.py create mode 100644 hyperscale/core/graphs/stages/idle/idle.py create mode 100644 hyperscale/core/graphs/stages/optimize/__init__.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/__init__.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/__init__.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/__init__.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/base_algorithm.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/differential_evolution_optimizer.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/dual_annealing_optimizer.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/least_squares_optimizer.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/point_optimizer.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/shg_optimizer.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/distribution_fit_optimizer.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/optimizer.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/parameters/__init__.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter_range.py create mode 100644 hyperscale/core/graphs/stages/optimize/optimize.py create mode 100644 hyperscale/core/graphs/stages/optimize/parallel/__init__.py create mode 100644 hyperscale/core/graphs/stages/optimize/parallel/optimize_stage.py create mode 100644 hyperscale/core/graphs/stages/setup/__init__.py create mode 100644 hyperscale/core/graphs/stages/setup/exceptions/__init__.py create mode 100644 hyperscale/core/graphs/stages/setup/exceptions/hook_setup_error.py create mode 100644 hyperscale/core/graphs/stages/setup/exceptions/hook_setup_timeout_error.py create mode 100644 hyperscale/core/graphs/stages/setup/setup.py create mode 100644 hyperscale/core/graphs/stages/submit/__init__.py create mode 100644 hyperscale/core/graphs/stages/submit/submit.py create mode 100644 hyperscale/core/graphs/stages/types/__init__.py create mode 100644 hyperscale/core/graphs/stages/types/stage_states.py create mode 100644 hyperscale/core/graphs/stages/types/stage_types.py create mode 100644 hyperscale/core/graphs/status.py create mode 100644 hyperscale/core/graphs/transitions/__init__.py create mode 100644 hyperscale/core/graphs/transitions/act/__init__.py create mode 100644 hyperscale/core/graphs/transitions/act/act_edge.py create mode 100644 hyperscale/core/graphs/transitions/analyze/__init__.py create mode 100644 hyperscale/core/graphs/transitions/analyze/analyze_edge.py create mode 100644 hyperscale/core/graphs/transitions/common/__init__.py create mode 100644 hyperscale/core/graphs/transitions/common/base_edge.py create mode 100644 hyperscale/core/graphs/transitions/common/complete_edge.py create mode 100644 hyperscale/core/graphs/transitions/common/error_edge.py create mode 100644 hyperscale/core/graphs/transitions/common/transtition_metadata.py create mode 100644 hyperscale/core/graphs/transitions/exceptions/__init__.py create mode 100644 hyperscale/core/graphs/transitions/exceptions/exceptions.py create mode 100644 hyperscale/core/graphs/transitions/execute/__init__.py create mode 100644 hyperscale/core/graphs/transitions/execute/execute_edge.py create mode 100644 hyperscale/core/graphs/transitions/idle/__init__.py create mode 100644 hyperscale/core/graphs/transitions/idle/idle_edge.py create mode 100644 hyperscale/core/graphs/transitions/local_transitions.py create mode 100644 hyperscale/core/graphs/transitions/optimize/__init__.py create mode 100644 hyperscale/core/graphs/transitions/optimize/optimize_edge.py create mode 100644 hyperscale/core/graphs/transitions/setup/__init__.py create mode 100644 hyperscale/core/graphs/transitions/setup/setup_edge.py create mode 100644 hyperscale/core/graphs/transitions/submit/__init__.py create mode 100644 hyperscale/core/graphs/transitions/submit/submit_edge.py create mode 100644 hyperscale/core/graphs/transitions/transition.py create mode 100644 hyperscale/core/graphs/transitions/transition_assembler.py create mode 100644 hyperscale/core/graphs/transitions/transition_group.py create mode 100644 hyperscale/core/hooks/__init__.py create mode 100644 hyperscale/core/hooks/types/__init__.py create mode 100644 hyperscale/core/hooks/types/action/__init__.py create mode 100644 hyperscale/core/hooks/types/action/decorator.py create mode 100644 hyperscale/core/hooks/types/action/event.py create mode 100644 hyperscale/core/hooks/types/action/hook.py create mode 100644 hyperscale/core/hooks/types/action/validator.py create mode 100644 hyperscale/core/hooks/types/base/__init__.py create mode 100644 hyperscale/core/hooks/types/base/event.py create mode 100644 hyperscale/core/hooks/types/base/event_dispatch.py create mode 100644 hyperscale/core/hooks/types/base/event_graph.py create mode 100644 hyperscale/core/hooks/types/base/event_types.py create mode 100644 hyperscale/core/hooks/types/base/get_event.py create mode 100644 hyperscale/core/hooks/types/base/hook.py create mode 100644 hyperscale/core/hooks/types/base/hook_metadata.py create mode 100644 hyperscale/core/hooks/types/base/hook_type.py create mode 100644 hyperscale/core/hooks/types/base/registrar.py create mode 100644 hyperscale/core/hooks/types/base/simple_context.py create mode 100644 hyperscale/core/hooks/types/channel/__init__.py create mode 100644 hyperscale/core/hooks/types/channel/decorator.py create mode 100644 hyperscale/core/hooks/types/channel/event.py create mode 100644 hyperscale/core/hooks/types/channel/hook.py create mode 100644 hyperscale/core/hooks/types/channel/validator.py create mode 100644 hyperscale/core/hooks/types/check/__init__.py create mode 100644 hyperscale/core/hooks/types/check/decorator.py create mode 100644 hyperscale/core/hooks/types/check/event.py create mode 100644 hyperscale/core/hooks/types/check/hook.py create mode 100644 hyperscale/core/hooks/types/check/validator.py create mode 100644 hyperscale/core/hooks/types/condition/__init__.py create mode 100644 hyperscale/core/hooks/types/condition/decorator.py create mode 100644 hyperscale/core/hooks/types/condition/event.py create mode 100644 hyperscale/core/hooks/types/condition/hook.py create mode 100644 hyperscale/core/hooks/types/condition/validator.py create mode 100644 hyperscale/core/hooks/types/context/__init__.py create mode 100644 hyperscale/core/hooks/types/context/decorator.py create mode 100644 hyperscale/core/hooks/types/context/event.py create mode 100644 hyperscale/core/hooks/types/context/hook.py create mode 100644 hyperscale/core/hooks/types/context/validator.py create mode 100644 hyperscale/core/hooks/types/depends/decorator.py create mode 100644 hyperscale/core/hooks/types/depends/validator.py create mode 100644 hyperscale/core/hooks/types/event/decorator.py create mode 100644 hyperscale/core/hooks/types/event/event.py create mode 100644 hyperscale/core/hooks/types/event/hook.py create mode 100644 hyperscale/core/hooks/types/event/validator.py create mode 100644 hyperscale/core/hooks/types/internal/decorator.py create mode 100644 hyperscale/core/hooks/types/internal/hook.py create mode 100644 hyperscale/core/hooks/types/internal/internal_hook.py create mode 100644 hyperscale/core/hooks/types/load/__init__.py create mode 100644 hyperscale/core/hooks/types/load/decorator.py create mode 100644 hyperscale/core/hooks/types/load/event.py create mode 100644 hyperscale/core/hooks/types/load/hook.py create mode 100644 hyperscale/core/hooks/types/load/validator.py create mode 100644 hyperscale/core/hooks/types/metric/decorator.py create mode 100644 hyperscale/core/hooks/types/metric/event.py create mode 100644 hyperscale/core/hooks/types/metric/hook.py create mode 100644 hyperscale/core/hooks/types/metric/validator.py create mode 100644 hyperscale/core/hooks/types/save/__init__.py create mode 100644 hyperscale/core/hooks/types/save/decorator.py create mode 100644 hyperscale/core/hooks/types/save/event.py create mode 100644 hyperscale/core/hooks/types/save/hook.py create mode 100644 hyperscale/core/hooks/types/save/validator.py create mode 100644 hyperscale/core/hooks/types/task/decorator.py create mode 100644 hyperscale/core/hooks/types/task/event.py create mode 100644 hyperscale/core/hooks/types/task/hook.py create mode 100644 hyperscale/core/hooks/types/task/validator.py create mode 100644 hyperscale/core/hooks/types/transform/decorator.py create mode 100644 hyperscale/core/hooks/types/transform/event.py create mode 100644 hyperscale/core/hooks/types/transform/hook.py create mode 100644 hyperscale/core/hooks/types/transform/validator.py create mode 100644 hyperscale/core/personas/__init__.py create mode 100644 hyperscale/core/personas/batching/__init__.py create mode 100644 hyperscale/core/personas/batching/batch.py create mode 100644 hyperscale/core/personas/batching/param_type.py create mode 100644 hyperscale/core/personas/persona_registry.py create mode 100644 hyperscale/core/personas/streaming/__init__.py create mode 100644 hyperscale/core/personas/streaming/stream.py create mode 100644 hyperscale/core/personas/streaming/stream_analytics.py create mode 100644 hyperscale/core/personas/types/__init__.py create mode 100644 hyperscale/core/personas/types/approximate_distribution/__init__.py create mode 100644 hyperscale/core/personas/types/approximate_distribution/approximate_distribution_persona.py create mode 100644 hyperscale/core/personas/types/batched_persona/__init__.py create mode 100644 hyperscale/core/personas/types/batched_persona/batched_persona.py create mode 100644 hyperscale/core/personas/types/constant_arrival_rate_persona/__init__.py create mode 100644 hyperscale/core/personas/types/constant_arrival_rate_persona/constant_arrival_rate_persona.py create mode 100644 hyperscale/core/personas/types/constant_spawn_rate_persona/__init__.py create mode 100644 hyperscale/core/personas/types/constant_spawn_rate_persona/constant_spawn_rate_persona.py create mode 100644 hyperscale/core/personas/types/cyclic_nowait_persona/__init__.py create mode 100644 hyperscale/core/personas/types/cyclic_nowait_persona/cyclic_nowait_persona.py create mode 100644 hyperscale/core/personas/types/default_persona/__init__.py create mode 100644 hyperscale/core/personas/types/default_persona/default_persona.py create mode 100644 hyperscale/core/personas/types/ramped_interval_persona/__init__.py create mode 100644 hyperscale/core/personas/types/ramped_interval_persona/ramped_interval_persona.py create mode 100644 hyperscale/core/personas/types/ramped_persona/__init__.py create mode 100644 hyperscale/core/personas/types/ramped_persona/ramped_persona.py create mode 100644 hyperscale/core/personas/types/sequenced_persona/__init__.py create mode 100644 hyperscale/core/personas/types/sequenced_persona/sequenced_persona.py create mode 100644 hyperscale/core/personas/types/types.py create mode 100644 hyperscale/core/personas/types/weighted_selection_persona/__init__.py create mode 100644 hyperscale/core/personas/types/weighted_selection_persona/weighted_selection_persona.py create mode 100644 hyperscale/core_rewrite/__init__.py create mode 100644 hyperscale/core_rewrite/engines/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/client.py create mode 100644 hyperscale/core_rewrite/engines/client/config.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql/mercury_sync_graphql_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql/models/graphql/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_request.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_response.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql/models/graphql/optimized_graphql_request.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql_http2/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql_http2/mercury_sync_graphql_http2_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql_http2/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_request.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_response.py create mode 100644 hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/optimized_http2_request.py create mode 100644 hyperscale/core_rewrite/engines/client/grpc/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/grpc/mercury_sync_grpc_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/grpc/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/grpc/models/grpc/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_request.py create mode 100644 hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_response.py create mode 100644 hyperscale/core_rewrite/engines/client/grpc/models/grpc/optimized_grpc_request.py create mode 100644 hyperscale/core_rewrite/engines/client/http/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http/mercury_sync_http_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http/models/http/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http/models/http/http_request.py create mode 100644 hyperscale/core_rewrite/engines/client/http/models/http/http_response.py create mode 100644 hyperscale/core_rewrite/engines/client/http/models/http/optimized_http_request.py create mode 100644 hyperscale/core_rewrite/engines/client/http/protocols/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http/protocols/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http/protocols/tcp/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http/protocols/tcp/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http/protocols/tcp/protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/http/protocols/tcp/tls_protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/config/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/config/boolean_configuration_option.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/config/config.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/errors/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/errors/exceptions.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/errors/types.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/base_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/connection_terminated_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/data_received_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/headers_sent_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/informational_respose_received_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/remote_settings_changed_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/request_received_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/request_sent_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/response_received_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/response_sent_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/settings_acknowledged_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/stream_ended_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/stream_reset.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/trailers_received_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/trailers_sent_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/events/window_updated_event.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/fast_hpack/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/fast_hpack/hpack.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/fast_hpack/huffman.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/fast_hpack/table.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/exceptions.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/frame_buffer.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_flags.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_length.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/stream_associations.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/struct_types.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/base_frame.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/frames/types/utils.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/mercury_sync_http2_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/models/http2/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/models/http2/http2_request.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/models/http2/http2_response.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/models/http2/optimized_http2_request.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/pipe.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/protocols/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/protocols/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/protocols/tcp/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/protocols/tcp/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/protocols/tcp/protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/protocols/tcp/tls_protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/settings/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/settings/changed_setting.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/settings/stream_closed_by.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/settings/stream_settings.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/settings/stream_settings_codes.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/settings/stream_state.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/settings/stream_state_map.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/streams/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/streams/stream.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/windows/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http2/windows/window_manager.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/mercury_sync_http3_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/models/http3/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/models/http3/http3_request.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/models/http3/http3_response.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/models/http3/optimized_http3_request.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/http3_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/client.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/server.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/buffer.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/h0/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/h0/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/events.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/exceptions.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/configuration.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/base.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/cubic.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/reno.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/crypto.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/events.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/logger.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet_builder.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/rangeset.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/recovery.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/retry.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/stream.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic/tls.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/quic_protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/http3/protocols/udp_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_file_chooser.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_frame.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_js_handle.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_keyboard.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_locator.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_mouse.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_page.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_session.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/browser_touchscreen.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/mercury_sync_playwright_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/browser/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/browser/browser_metadata.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/set_files_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/frame_element_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/evaluate_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/key_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/press_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/type_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/all_texts_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/and_matching_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/blur_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/bounding_box_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/check_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/clear_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/click_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/count_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dispatch_event_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dom_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/drag_to_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/fill_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/filter_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/focus_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/get_attribute_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/highlight_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/hover_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/nth_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/or_matching_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_sequentially_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/scroll_into_view_if_needed.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_option_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_text_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_checked_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_input_files.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/tap_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/wait_for_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/button_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/click_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/move_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/wheel_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_init_script_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_locator_handler_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_script_tag_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_style_tag_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/bring_to_front_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/check_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/click_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/close_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/content_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dispatch_event_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dom_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/double_click_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/drag_and_drop_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/emulate_media_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_on_selector_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_console_message_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_download_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_event_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_file_chooser_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_navigation_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_popup_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_finished_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_response_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_websocket_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_worker_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_binding_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_function_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/fill_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/focus_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_locator_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_attribute_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_role_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_test_id_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_text_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_url_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/go_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/goto_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/hover_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/is_closed_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/locator_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/on_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/opener_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pause_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pdf_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/press_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/reload_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/remove_locator_handler_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_from_har_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/screenshot_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/select_option_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_checked_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_content_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_extra_http_headers_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_input_files_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_timeout_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_viewport_size_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/tap_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/title_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/type_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_function_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_load_state_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_selector_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_timeout_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_url_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/tap_command.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/results/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/playwright/models/results/playwright_result.py create mode 100644 hyperscale/core_rewrite/engines/client/plugins_store.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/cookies.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/ip_address_info.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/metadata.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/request_type.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/socket_protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/socket_type.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/types.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/url.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/models/url_metadata.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/protocols/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/protocols/constants.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/protocols/flow_control_mixin.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/protocols/protocol_map.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/protocols/reader.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/protocols/writer.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/request_types_map.py create mode 100644 hyperscale/core_rewrite/engines/client/shared/timeouts.py create mode 100644 hyperscale/core_rewrite/engines/client/store.py create mode 100644 hyperscale/core_rewrite/engines/client/time_parser.py create mode 100644 hyperscale/core_rewrite/engines/client/tracing_config.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/mercury_sync_udp_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/models/udp/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/models/udp/optimized_udp_request.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/models/udp/udp_request.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/models/udp/udp_response.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/osnet.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/router.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/err.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/openssl.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/patch.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/libcrypto-1_1.dll create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/libssl-1_1.dll create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/manifest.pycfg create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/libcrypto-1_1-x64.dll create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/libssl-1_1-x64.dll create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/manifest.pycfg create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/sslconnection.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badcert.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badkey.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert_ec.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert_ec.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/nullcert.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert_ec.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/wrongcert.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/yahoo-cert.pem create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/echo_seq.py create mode 100755 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts_ec.bat create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_ca.cnf create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_server.cnf create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/rl.py create mode 100755 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/simple_client.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/test_perf.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit_wrapper.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/tlock.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/util.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/wrapper.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/dtls/x509.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/udp/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/udp/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/udp/protocols/udp/protocol.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/connection.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/mercury_sync_websocket_connection.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/models/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/models/websocket/__init__.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/models/websocket/constants.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/models/websocket/optimized_websocket_request.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/models/websocket/utils.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_request.py create mode 100644 hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_response.py create mode 100644 hyperscale/core_rewrite/graph.py create mode 100644 hyperscale/core_rewrite/hooks/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/call_arg.py create mode 100644 hyperscale/core_rewrite/hooks/call_resolver.py create mode 100644 hyperscale/core_rewrite/hooks/hook.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/base/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/base/frozen_dict.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/base/optimized_arg.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/headers/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/headers/headers.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/headers/headers_validator.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/mutation/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/query/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/query/query.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/query/query_validator.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/url/__init__.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/url/url.py create mode 100644 hyperscale/core_rewrite/hooks/optimized/models/url/url_validator.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_arg.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_arg_type.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_auth.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_cookies.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_data.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_headers.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_method.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_params.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_redirects.py create mode 100644 hyperscale/core_rewrite/hooks/resolved_url.py create mode 100644 hyperscale/core_rewrite/hooks/step.py create mode 100644 hyperscale/core_rewrite/hooks/step_type.py create mode 100644 hyperscale/core_rewrite/parser/__init__.py create mode 100644 hyperscale/core_rewrite/parser/dynamic_placeholder.py create mode 100644 hyperscale/core_rewrite/parser/dynamic_template_string.py create mode 100644 hyperscale/core_rewrite/parser/parser.py create mode 100644 hyperscale/core_rewrite/parser/placeholder_call.py create mode 100644 hyperscale/core_rewrite/snowflake/__init__.py create mode 100644 hyperscale/core_rewrite/snowflake/constants.py create mode 100644 hyperscale/core_rewrite/snowflake/snowflake.py create mode 100644 hyperscale/core_rewrite/snowflake/snowflake_generator.py create mode 100644 hyperscale/core_rewrite/workflow.py create mode 100644 hyperscale/data/__init__.py create mode 100644 hyperscale/data/connectors/__init__.py create mode 100644 hyperscale/data/connectors/aws_lambda/__init__.py create mode 100644 hyperscale/data/connectors/aws_lambda/aws_lambda_connector.py create mode 100644 hyperscale/data/connectors/aws_lambda/aws_lambda_connector_config.py create mode 100644 hyperscale/data/connectors/bigtable/__init__.py create mode 100644 hyperscale/data/connectors/bigtable/bigtable_connector.py create mode 100644 hyperscale/data/connectors/bigtable/bigtable_connector_config.py create mode 100644 hyperscale/data/connectors/cassandra/__init__.py create mode 100644 hyperscale/data/connectors/cassandra/cassandra_connector.py create mode 100644 hyperscale/data/connectors/cassandra/cassandra_connector_config.py create mode 100644 hyperscale/data/connectors/cassandra/cassandra_load_validator.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/__init__.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/__init__.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_http2_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_grpc_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http2_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http3_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_playwright_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_task_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_udp_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_websocket_action_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/cassandra_schema_set.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/__init__.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_http2_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_grpc_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http2_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http3_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_playwright_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_task_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_udp_result_schema.py create mode 100644 hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_websocket_result_schema.py create mode 100644 hyperscale/data/connectors/common/__init__.py create mode 100644 hyperscale/data/connectors/common/connector_type.py create mode 100644 hyperscale/data/connectors/common/execute_stage_summary_validator.py create mode 100644 hyperscale/data/connectors/connector.py create mode 100644 hyperscale/data/connectors/cosmosdb/__init__.py create mode 100644 hyperscale/data/connectors/cosmosdb/cosmos_connector.py create mode 100644 hyperscale/data/connectors/cosmosdb/cosmos_connector_config.py create mode 100644 hyperscale/data/connectors/csv/__init__.py create mode 100644 hyperscale/data/connectors/csv/csv_connector.py create mode 100644 hyperscale/data/connectors/csv/csv_connector_config.py create mode 100644 hyperscale/data/connectors/csv/csv_load_validator.py create mode 100644 hyperscale/data/connectors/empty/__init__.py create mode 100644 hyperscale/data/connectors/google_cloud_storage/__init__.py create mode 100644 hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector.py create mode 100644 hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector_config.py create mode 100644 hyperscale/data/connectors/har/__init__.py create mode 100644 hyperscale/data/connectors/har/har_connector.py create mode 100644 hyperscale/data/connectors/har/har_connector_config.py create mode 100644 hyperscale/data/connectors/json/__init__.py create mode 100644 hyperscale/data/connectors/json/json_connector.py create mode 100644 hyperscale/data/connectors/json/json_connector_config.py create mode 100644 hyperscale/data/connectors/kafka/__init__.py create mode 100644 hyperscale/data/connectors/kafka/kafka_connector.py create mode 100644 hyperscale/data/connectors/kafka/kafka_connector_config.py create mode 100644 hyperscale/data/connectors/mongodb/__init__.py create mode 100644 hyperscale/data/connectors/mongodb/mongodb_connector.py create mode 100644 hyperscale/data/connectors/mongodb/mongodb_connector_config.py create mode 100644 hyperscale/data/connectors/mysql/__init__.py create mode 100644 hyperscale/data/connectors/mysql/mysql_connector.py create mode 100644 hyperscale/data/connectors/mysql/mysql_connector_config.py create mode 100644 hyperscale/data/connectors/postgres/__init__.py create mode 100644 hyperscale/data/connectors/postgres/postgres_connector.py create mode 100644 hyperscale/data/connectors/postgres/postgres_connector_config.py create mode 100644 hyperscale/data/connectors/redis/__init__.py create mode 100644 hyperscale/data/connectors/redis/redis_connector.py create mode 100644 hyperscale/data/connectors/redis/redis_connector_config.py create mode 100644 hyperscale/data/connectors/s3/__init__.py create mode 100644 hyperscale/data/connectors/s3/s3_connector.py create mode 100644 hyperscale/data/connectors/s3/s3_connector_config.py create mode 100644 hyperscale/data/connectors/snowflake/__init__.py create mode 100644 hyperscale/data/connectors/snowflake/snowflake_connector.py create mode 100644 hyperscale/data/connectors/snowflake/snowflake_connector_config.py create mode 100644 hyperscale/data/connectors/sqlite/__init__.py create mode 100644 hyperscale/data/connectors/sqlite/sqlite_connector.py create mode 100644 hyperscale/data/connectors/sqlite/sqlite_connector_config.py create mode 100644 hyperscale/data/connectors/xml/__init__.py create mode 100644 hyperscale/data/connectors/xml/xml_connector.py create mode 100644 hyperscale/data/connectors/xml/xml_connector_config.py create mode 100644 hyperscale/data/parsers/__init__.py create mode 100644 hyperscale/data/parsers/parser.py create mode 100644 hyperscale/data/parsers/parser_types/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/common/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/common/base_parser.py create mode 100644 hyperscale/data/parsers/parser_types/common/parsing.py create mode 100644 hyperscale/data/parsers/parser_types/common/result_validator.py create mode 100644 hyperscale/data/parsers/parser_types/graphql/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/graphql/graphql_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/graphql/graphql_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/graphql/graphql_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/graphql_http2/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/grpc/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/grpc/grpc_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/grpc/grpc_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/grpc/grpc_options_validator.py create mode 100644 hyperscale/data/parsers/parser_types/grpc/grpc_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/http/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/http/http_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/http/http_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/http/http_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/http2/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/http2/http2_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/http2/http2_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/http2/http2_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/http3/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/http3/http3_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/http3/http3_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/http3/http3_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/playwright/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/playwright/playwright_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/playwright/playwright_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/playwright/playwright_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/udp/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/udp/udp_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/udp/udp_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/udp/udp_result_parser.py create mode 100644 hyperscale/data/parsers/parser_types/websocket/__init__.py create mode 100644 hyperscale/data/parsers/parser_types/websocket/websocket_action_parser.py create mode 100644 hyperscale/data/parsers/parser_types/websocket/websocket_action_validator.py create mode 100644 hyperscale/data/parsers/parser_types/websocket/websocket_result_parser.py create mode 100644 hyperscale/data/serializers/__init__.py create mode 100644 hyperscale/data/serializers/serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/common/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/common/base_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/common/metadata_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/graphql/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/graphql/graphql_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/graphql_http2/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/graphql_http2/graphql_http2_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/grpc/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/grpc/grpc_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/http/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/http/http_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/http2/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/http2/http2_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/http3/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/http3/http3_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/playwright/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/playwright/playwright_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/task/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/task/task_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/udp/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/udp/udp_serializer.py create mode 100644 hyperscale/data/serializers/serializer_types/websocket/__init__.py create mode 100644 hyperscale/data/serializers/serializer_types/websocket/websocket_serializer.py create mode 100644 hyperscale/distributed/__init__.py create mode 100644 hyperscale/distributed/connection/__init__.py create mode 100644 hyperscale/distributed/connection/addresses/__init__.py create mode 100644 hyperscale/distributed/connection/addresses/subnet_range.py create mode 100644 hyperscale/distributed/connection/base/__init__.py create mode 100644 hyperscale/distributed/connection/base/connection_type.py create mode 100644 hyperscale/distributed/connection/tcp/__init__.py create mode 100644 hyperscale/distributed/connection/tcp/mercury_sync_http_connection.py create mode 100644 hyperscale/distributed/connection/tcp/mercury_sync_tcp_connection.py create mode 100644 hyperscale/distributed/connection/tcp/protocols/__init__.py create mode 100644 hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_client_protocol.py create mode 100644 hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_server_protocol.py create mode 100644 hyperscale/distributed/connection/udp/__init__.py create mode 100644 hyperscale/distributed/connection/udp/mercury_sync_udp_connection.py create mode 100644 hyperscale/distributed/connection/udp/mercury_sync_udp_multicast_connection.py create mode 100644 hyperscale/distributed/connection/udp/protocols/__init__.py create mode 100644 hyperscale/distributed/connection/udp/protocols/mercury_sync_udp_protocol.py create mode 100644 hyperscale/distributed/discovery/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/cache/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/cache/cache_node.py create mode 100644 hyperscale/distributed/discovery/dns/core/cache/cache_value.py create mode 100644 hyperscale/distributed/discovery/dns/core/config/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/config/nt.py create mode 100644 hyperscale/distributed/discovery/dns/core/config/posix.py create mode 100644 hyperscale/distributed/discovery/dns/core/config/root.py create mode 100644 hyperscale/distributed/discovery/dns/core/exceptions/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/exceptions/dns_error.py create mode 100644 hyperscale/distributed/discovery/dns/core/exceptions/invalid_service_url_error.py create mode 100644 hyperscale/distributed/discovery/dns/core/exceptions/utils/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/exceptions/utils/get_bits.py create mode 100644 hyperscale/distributed/discovery/dns/core/nameservers/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/nameservers/exceptions.py create mode 100644 hyperscale/distributed/discovery/dns/core/nameservers/nameserver.py create mode 100644 hyperscale/distributed/discovery/dns/core/random/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/random/random_id_generator.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/query_type.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/a_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/aaaa_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/cname_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/domain_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/mx_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/naptr_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/ns_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/ptr_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/record_types.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/soa_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/srv_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/txt_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/unsupported_record_data.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_domain_name.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_string.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_domain_name.py create mode 100644 hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_string.py create mode 100644 hyperscale/distributed/discovery/dns/core/url/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/core/url/exceptions.py create mode 100644 hyperscale/distributed/discovery/dns/core/url/host.py create mode 100644 hyperscale/distributed/discovery/dns/core/url/url.py create mode 100644 hyperscale/distributed/discovery/dns/registrar.py create mode 100644 hyperscale/distributed/discovery/dns/request/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/request/dns_client.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/__init__.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/base_resolver.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/cache_resolver.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/memoizer.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/proxy_resolver.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/recursive_resolver.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/resolver.py create mode 100644 hyperscale/distributed/discovery/dns/resolver/types.py create mode 100644 hyperscale/distributed/discovery/volume/__init__.py create mode 100644 hyperscale/distributed/discovery/volume/backup_volume.py create mode 100644 hyperscale/distributed/encryption/__init__.py create mode 100644 hyperscale/distributed/encryption/aes_gcm.py create mode 100644 hyperscale/distributed/env/__init__.py create mode 100644 hyperscale/distributed/env/env.py create mode 100644 hyperscale/distributed/env/load_env.py create mode 100644 hyperscale/distributed/env/memory_parser.py create mode 100644 hyperscale/distributed/env/monitor_env.py create mode 100644 hyperscale/distributed/env/registrar_env.py create mode 100644 hyperscale/distributed/env/replication_env.py create mode 100644 hyperscale/distributed/env/time_parser.py create mode 100644 hyperscale/distributed/hooks/__init__.py create mode 100644 hyperscale/distributed/hooks/client_hook.py create mode 100644 hyperscale/distributed/hooks/endpoint_hook.py create mode 100644 hyperscale/distributed/hooks/middleware_hook.py create mode 100644 hyperscale/distributed/hooks/server_hook.py create mode 100644 hyperscale/distributed/hooks/stream_hook.py create mode 100644 hyperscale/distributed/middleware/__init__.py create mode 100644 hyperscale/distributed/middleware/base/__init__.py create mode 100644 hyperscale/distributed/middleware/base/base_wrapper.py create mode 100644 hyperscale/distributed/middleware/base/bidirectional_wrapper.py create mode 100644 hyperscale/distributed/middleware/base/call_wrapper.py create mode 100644 hyperscale/distributed/middleware/base/middleware.py create mode 100644 hyperscale/distributed/middleware/base/types.py create mode 100644 hyperscale/distributed/middleware/base/unidirectional_wrapper.py create mode 100644 hyperscale/distributed/middleware/circuit_breaker/__init__.py create mode 100644 hyperscale/distributed/middleware/circuit_breaker/circuit_breaker.py create mode 100644 hyperscale/distributed/middleware/circuit_breaker/circuit_breaker_state.py create mode 100644 hyperscale/distributed/middleware/compressor/__init__.py create mode 100644 hyperscale/distributed/middleware/compressor/bidirectional_gzip_compressor.py create mode 100644 hyperscale/distributed/middleware/compressor/bidirectional_zstandard_compressor.py create mode 100644 hyperscale/distributed/middleware/compressor/gzip_compressor.py create mode 100644 hyperscale/distributed/middleware/compressor/zstandard_compressor.py create mode 100644 hyperscale/distributed/middleware/cors/__init__.py create mode 100644 hyperscale/distributed/middleware/cors/cors.py create mode 100644 hyperscale/distributed/middleware/cors/cors_headers.py create mode 100644 hyperscale/distributed/middleware/crsf/__init__.py create mode 100644 hyperscale/distributed/middleware/crsf/crsf.py create mode 100644 hyperscale/distributed/middleware/decompressor/__init__.py create mode 100644 hyperscale/distributed/middleware/decompressor/bidirectional_gzip_decompressor.py create mode 100644 hyperscale/distributed/middleware/decompressor/bidirectional_zstandard_decompressor.py create mode 100644 hyperscale/distributed/middleware/decompressor/gzip_decompressor.py create mode 100644 hyperscale/distributed/middleware/decompressor/zstandard_decompressor.py create mode 100644 hyperscale/distributed/models/__init__.py create mode 100644 hyperscale/distributed/models/base/__init__.py create mode 100644 hyperscale/distributed/models/base/error.py create mode 100644 hyperscale/distributed/models/base/message.py create mode 100644 hyperscale/distributed/models/dns/__init__.py create mode 100644 hyperscale/distributed/models/dns/dns_entry.py create mode 100644 hyperscale/distributed/models/dns/dns_message.py create mode 100644 hyperscale/distributed/models/dns/dns_message_group.py create mode 100644 hyperscale/distributed/models/dns/service.py create mode 100644 hyperscale/distributed/models/http/__init__.py create mode 100644 hyperscale/distributed/models/http/http_message.py create mode 100644 hyperscale/distributed/models/http/http_request.py create mode 100644 hyperscale/distributed/models/http/limit.py create mode 100644 hyperscale/distributed/models/http/request.py create mode 100644 hyperscale/distributed/models/http/response.py create mode 100644 hyperscale/distributed/models/raft/__init__.py create mode 100644 hyperscale/distributed/models/raft/election_state.py create mode 100644 hyperscale/distributed/models/raft/healthcheck.py create mode 100644 hyperscale/distributed/models/raft/logs/__init__.py create mode 100644 hyperscale/distributed/models/raft/logs/entry.py create mode 100644 hyperscale/distributed/models/raft/logs/node_state.py create mode 100644 hyperscale/distributed/models/raft/raft_message.py create mode 100644 hyperscale/distributed/models/raft/vote_result.py create mode 100644 hyperscale/distributed/monitoring/__init__.py create mode 100644 hyperscale/distributed/monitoring/monitor_service.py create mode 100644 hyperscale/distributed/rate_limiting/__init__.py create mode 100644 hyperscale/distributed/rate_limiting/limiter.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/__init__.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/adaptive_limiter.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/base_limiter.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/cpu_adaptive.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/leaky_bucket_limiter.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/resource_adaptive_limiter.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/sliding_window_limiter.py create mode 100644 hyperscale/distributed/rate_limiting/limiters/token_bucket_limiter.py create mode 100644 hyperscale/distributed/replication/__init__.py create mode 100644 hyperscale/distributed/replication/constants.py create mode 100644 hyperscale/distributed/replication/errors/__init__.py create mode 100644 hyperscale/distributed/replication/errors/invalid_term_error.py create mode 100644 hyperscale/distributed/replication/log_queue.py create mode 100644 hyperscale/distributed/replication/replication_controller.py create mode 100644 hyperscale/distributed/service/__init__.py create mode 100644 hyperscale/distributed/service/controller.py create mode 100644 hyperscale/distributed/service/plugin_group.py create mode 100644 hyperscale/distributed/service/plugin_wrapper.py create mode 100644 hyperscale/distributed/service/service.py create mode 100644 hyperscale/distributed/service/socket/__init__.py create mode 100644 hyperscale/distributed/service/socket/socket.py create mode 100644 hyperscale/distributed/snowflake/__init__.py create mode 100644 hyperscale/distributed/snowflake/constants.py create mode 100644 hyperscale/distributed/snowflake/snowflake.py create mode 100644 hyperscale/distributed/snowflake/snowflake_generator.py create mode 100644 hyperscale/distributed/types/__init__.py create mode 100644 hyperscale/distributed/types/call.py create mode 100644 hyperscale/distributed/types/response.py create mode 100644 hyperscale/distributed/types/stream.py create mode 100644 hyperscale/logging/__init__.py create mode 100644 hyperscale/logging/config/__init__.py create mode 100644 hyperscale/logging/config/logging_config.py create mode 100644 hyperscale/logging/hyperscale_logger.py create mode 100644 hyperscale/logging/logger_types/__init__.py create mode 100644 hyperscale/logging/logger_types/async_filesystem_logger.py create mode 100644 hyperscale/logging/logger_types/async_logger.py create mode 100644 hyperscale/logging/logger_types/async_spinner.py create mode 100644 hyperscale/logging/logger_types/handers/__init__.py create mode 100644 hyperscale/logging/logger_types/handers/async_file_handler.py create mode 100644 hyperscale/logging/logger_types/logger.py create mode 100644 hyperscale/logging/logger_types/logger_types.py create mode 100644 hyperscale/logging/logger_types/logger_types_map.py create mode 100644 hyperscale/logging/logger_types/sync_filesystem_logger.py create mode 100644 hyperscale/logging/logger_types/sync_logger.py create mode 100644 hyperscale/logging/logging_manager.py create mode 100644 hyperscale/logging/spinner/__init__.py create mode 100644 hyperscale/logging/spinner/progress_text.py create mode 100644 hyperscale/logging/spinner/timer.py create mode 100644 hyperscale/logging/table/__init__.py create mode 100644 hyperscale/logging/table/execution_summary_table.py create mode 100644 hyperscale/logging/table/experiments_summary_table.py create mode 100644 hyperscale/logging/table/summary_table.py create mode 100644 hyperscale/logging/table/system_summary_table.py create mode 100644 hyperscale/logging/table/table_types.py create mode 100644 hyperscale/monitoring/__init__.py create mode 100644 hyperscale/monitoring/base/__init__.py create mode 100644 hyperscale/monitoring/base/exceptions.py create mode 100644 hyperscale/monitoring/base/monitor.py create mode 100644 hyperscale/monitoring/cpu/__init__.py create mode 100644 hyperscale/monitoring/cpu/monitor.py create mode 100644 hyperscale/monitoring/memory/__init__.py create mode 100644 hyperscale/monitoring/memory/monitor.py create mode 100644 hyperscale/plugins/__init__.py create mode 100644 hyperscale/plugins/types/__init__.py create mode 100644 hyperscale/plugins/types/common/__init__.py create mode 100644 hyperscale/plugins/types/common/event.py create mode 100644 hyperscale/plugins/types/common/plugin.py create mode 100644 hyperscale/plugins/types/common/plugin_hook.py create mode 100644 hyperscale/plugins/types/common/registrar.py create mode 100644 hyperscale/plugins/types/common/types.py create mode 100644 hyperscale/plugins/types/engine/__init__.py create mode 100644 hyperscale/plugins/types/engine/action.py create mode 100644 hyperscale/plugins/types/engine/engine_plugin.py create mode 100644 hyperscale/plugins/types/engine/hooks/__init__.py create mode 100644 hyperscale/plugins/types/engine/hooks/types/__init__.py create mode 100644 hyperscale/plugins/types/engine/hooks/types/close.py create mode 100644 hyperscale/plugins/types/engine/hooks/types/connect.py create mode 100644 hyperscale/plugins/types/engine/hooks/types/execute.py create mode 100644 hyperscale/plugins/types/engine/result.py create mode 100644 hyperscale/plugins/types/extension/__init__.py create mode 100644 hyperscale/plugins/types/extension/extension_plugin.py create mode 100644 hyperscale/plugins/types/extension/hooks/__init__.py create mode 100644 hyperscale/plugins/types/extension/hooks/types/__init__.py create mode 100644 hyperscale/plugins/types/extension/hooks/types/execute/__init__.py create mode 100644 hyperscale/plugins/types/extension/hooks/types/execute/decorator.py create mode 100644 hyperscale/plugins/types/extension/hooks/types/prepare/__init__.py create mode 100644 hyperscale/plugins/types/extension/hooks/types/prepare/decorator.py create mode 100644 hyperscale/plugins/types/extension/types.py create mode 100644 hyperscale/plugins/types/optimizer/__init__.py create mode 100644 hyperscale/plugins/types/optimizer/hooks/__init__.py create mode 100644 hyperscale/plugins/types/optimizer/hooks/types/__init__.py create mode 100644 hyperscale/plugins/types/optimizer/hooks/types/get.py create mode 100644 hyperscale/plugins/types/optimizer/hooks/types/optimize.py create mode 100644 hyperscale/plugins/types/optimizer/hooks/types/update.py create mode 100644 hyperscale/plugins/types/optimizer/optimizer_plugin.py create mode 100644 hyperscale/plugins/types/persona/__init__.py create mode 100644 hyperscale/plugins/types/persona/hooks/__init__.py create mode 100644 hyperscale/plugins/types/persona/hooks/types/__init__.py create mode 100644 hyperscale/plugins/types/persona/hooks/types/generate.py create mode 100644 hyperscale/plugins/types/persona/hooks/types/setup.py create mode 100644 hyperscale/plugins/types/persona/hooks/types/shutdown.py create mode 100644 hyperscale/plugins/types/persona/persona_plugin.py create mode 100644 hyperscale/plugins/types/plugin_types.py create mode 100644 hyperscale/plugins/types/reporter/__init__.py create mode 100644 hyperscale/plugins/types/reporter/hooks/__init__.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/__init__.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/process_custom.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/process_errors.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/process_events.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/process_metrics.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/process_shared.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/reporter_close.py create mode 100644 hyperscale/plugins/types/reporter/hooks/types/reporter_connect.py create mode 100644 hyperscale/plugins/types/reporter/reporter_config.py create mode 100644 hyperscale/plugins/types/reporter/reporter_metrics.py create mode 100644 hyperscale/plugins/types/reporter/reporter_plugin.py create mode 100644 hyperscale/projects/__init__.py create mode 100644 hyperscale/projects/generation/__init__.py create mode 100644 hyperscale/projects/generation/generator/__init__.py create mode 100644 hyperscale/projects/generation/generator/generator.py create mode 100644 hyperscale/projects/generation/graph_types/__init__.py create mode 100644 hyperscale/projects/generation/graph_types/graph_generator.py create mode 100644 hyperscale/projects/generation/graph_types/stages/__init__.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/__init__.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_http2_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_http2_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_http3_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_http_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_playwright_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_task_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_udp_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/execute/generated_websocket_execute_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/generated_analyze_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/generated_checkpoint_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/generated_optimize_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/generated_setup_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/generated_teardown_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/generated_validate_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/__init__.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_aws_lambda_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_aws_timestream_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_bigquery_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_bigtable_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_cassandra_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_cloudwatch_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_cosmosdb_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_csv_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_datadog_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_dogstatsd_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_google_cloud_storage_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_graphite_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_honeycomb_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_influxdb_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_json_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_kafka_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_mongodb_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_mysql_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_netdata_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_newrelic_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_postgres_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_prometheus_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_redis_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_s3_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_snowflake_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_sqlite_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_statsd_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_statsd_results_stage.py create mode 100644 hyperscale/projects/generation/graph_types/stages/submit/generated_timescaledb_results_stage.py create mode 100644 hyperscale/projects/generation/plugin_types/__init__.py create mode 100644 hyperscale/projects/generation/plugin_types/plugin_generator.py create mode 100644 hyperscale/projects/generation/plugin_types/plugins/__init__.py create mode 100644 hyperscale/projects/generation/plugin_types/plugins/generated_engine_plugin.py create mode 100644 hyperscale/projects/generation/plugin_types/plugins/generated_optimizer_plugin.py create mode 100644 hyperscale/projects/generation/plugin_types/plugins/generated_persona_plugin.py create mode 100644 hyperscale/projects/generation/plugin_types/plugins/generated_reporter_plugin.py create mode 100644 hyperscale/projects/management/__init__.py create mode 100644 hyperscale/projects/management/graphs/__init__.py create mode 100644 hyperscale/projects/management/graphs/actions/__init__.py create mode 100644 hyperscale/projects/management/graphs/actions/action.py create mode 100644 hyperscale/projects/management/graphs/actions/config.py create mode 100644 hyperscale/projects/management/graphs/actions/create_gitignore.py create mode 100644 hyperscale/projects/management/graphs/actions/fetch.py create mode 100644 hyperscale/projects/management/graphs/actions/initialize.py create mode 100644 hyperscale/projects/management/graphs/actions/synchronize.py create mode 100644 hyperscale/projects/management/graphs/exceptions/__init__.py create mode 100644 hyperscale/projects/management/graphs/exceptions/invalid_action_error.py create mode 100644 hyperscale/projects/management/graphs/graph_manager.py create mode 100644 hyperscale/reporting/__init__.py create mode 100644 hyperscale/reporting/experiment/__init__.py create mode 100644 hyperscale/reporting/experiment/experiment_metrics_set.py create mode 100644 hyperscale/reporting/experiment/experiment_metrics_set_types.py create mode 100644 hyperscale/reporting/experiment/experiments_collection.py create mode 100644 hyperscale/reporting/metric/__init__.py create mode 100644 hyperscale/reporting/metric/custom_metric.py create mode 100644 hyperscale/reporting/metric/metric_types.py create mode 100644 hyperscale/reporting/metric/metrics_group.py create mode 100644 hyperscale/reporting/metric/metrics_set.py create mode 100644 hyperscale/reporting/metric/metrics_set_types.py create mode 100644 hyperscale/reporting/metric/stage_metrics_summary.py create mode 100644 hyperscale/reporting/metric/stage_streams_set.py create mode 100644 hyperscale/reporting/processed_result/__init__.py create mode 100644 hyperscale/reporting/processed_result/processed_results_group.py create mode 100644 hyperscale/reporting/processed_result/results.py create mode 100644 hyperscale/reporting/processed_result/types/__init__.py create mode 100644 hyperscale/reporting/processed_result/types/base_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/graphql_http2_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/graphql_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/grpc_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/http2_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/http3_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/http_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/playwright_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/task_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/udp_processed_result.py create mode 100644 hyperscale/reporting/processed_result/types/websocket_processed_result.py create mode 100644 hyperscale/reporting/reporter.py create mode 100644 hyperscale/reporting/stats/__init__.py create mode 100644 hyperscale/reporting/stats/mean.py create mode 100644 hyperscale/reporting/stats/median.py create mode 100644 hyperscale/reporting/stats/median_absolute_deviation.py create mode 100644 hyperscale/reporting/stats/standard_deviation.py create mode 100644 hyperscale/reporting/stats/variance.py create mode 100644 hyperscale/reporting/system/__init__.py create mode 100644 hyperscale/reporting/system/system_metrics_group.py create mode 100644 hyperscale/reporting/system/system_metrics_set.py create mode 100644 hyperscale/reporting/system/system_metrics_set_types.py create mode 100644 hyperscale/reporting/tags/__init__.py create mode 100644 hyperscale/reporting/tags/tag.py create mode 100644 hyperscale/reporting/types/__init__.py create mode 100644 hyperscale/reporting/types/aws_lambda/__init__.py create mode 100644 hyperscale/reporting/types/aws_lambda/aws_lambda.py create mode 100644 hyperscale/reporting/types/aws_lambda/aws_lambda_config.py create mode 100644 hyperscale/reporting/types/aws_timestream/__init__.py create mode 100644 hyperscale/reporting/types/aws_timestream/aws_timestream.py create mode 100644 hyperscale/reporting/types/aws_timestream/aws_timestream_config.py create mode 100644 hyperscale/reporting/types/aws_timestream/aws_timestream_error_record.py create mode 100644 hyperscale/reporting/types/aws_timestream/aws_timestream_record.py create mode 100644 hyperscale/reporting/types/bigquery/__init__.py create mode 100644 hyperscale/reporting/types/bigquery/bigquery.py create mode 100644 hyperscale/reporting/types/bigquery/bigquery_config.py create mode 100644 hyperscale/reporting/types/bigtable/__init__.py create mode 100644 hyperscale/reporting/types/bigtable/bigtable.py create mode 100644 hyperscale/reporting/types/bigtable/bigtable_config.py create mode 100644 hyperscale/reporting/types/cassandra/__init__.py create mode 100644 hyperscale/reporting/types/cassandra/cassandra.py create mode 100644 hyperscale/reporting/types/cassandra/cassandra_config.py create mode 100644 hyperscale/reporting/types/cloudwatch/__init__.py create mode 100644 hyperscale/reporting/types/cloudwatch/cloudwatch.py create mode 100644 hyperscale/reporting/types/cloudwatch/cloudwatch_config.py create mode 100644 hyperscale/reporting/types/common/__init__.py create mode 100644 hyperscale/reporting/types/common/types.py create mode 100644 hyperscale/reporting/types/cosmosdb/__init__.py create mode 100644 hyperscale/reporting/types/cosmosdb/cosmosdb.py create mode 100644 hyperscale/reporting/types/cosmosdb/cosmosdb_config.py create mode 100644 hyperscale/reporting/types/csv/__init__.py create mode 100644 hyperscale/reporting/types/csv/csv.py create mode 100644 hyperscale/reporting/types/csv/csv_config.py create mode 100644 hyperscale/reporting/types/datadog/__init__.py create mode 100644 hyperscale/reporting/types/datadog/datadog.py create mode 100644 hyperscale/reporting/types/datadog/datadog_config.py create mode 100644 hyperscale/reporting/types/dogstatsd/__init__.py create mode 100644 hyperscale/reporting/types/dogstatsd/dogstatsd.py create mode 100644 hyperscale/reporting/types/dogstatsd/dogstatsd_config.py create mode 100644 hyperscale/reporting/types/empty/__init__.py create mode 100644 hyperscale/reporting/types/empty/empty.py create mode 100644 hyperscale/reporting/types/google_cloud_storage/__init__.py create mode 100644 hyperscale/reporting/types/google_cloud_storage/google_cloud_storage.py create mode 100644 hyperscale/reporting/types/google_cloud_storage/google_cloud_storage_config.py create mode 100644 hyperscale/reporting/types/graphite/__init__.py create mode 100644 hyperscale/reporting/types/graphite/graphite.py create mode 100644 hyperscale/reporting/types/graphite/graphite_config.py create mode 100644 hyperscale/reporting/types/honeycomb/__init__.py create mode 100644 hyperscale/reporting/types/honeycomb/honeycomb.py create mode 100644 hyperscale/reporting/types/honeycomb/honeycomb_config.py create mode 100644 hyperscale/reporting/types/influxdb/__init__.py create mode 100644 hyperscale/reporting/types/influxdb/influxdb.py create mode 100644 hyperscale/reporting/types/influxdb/influxdb_config.py create mode 100644 hyperscale/reporting/types/json/__init__.py create mode 100644 hyperscale/reporting/types/json/json.py create mode 100644 hyperscale/reporting/types/json/json_config.py create mode 100644 hyperscale/reporting/types/kafka/__init__.py create mode 100644 hyperscale/reporting/types/kafka/kafka.py create mode 100644 hyperscale/reporting/types/kafka/kafka_config.py create mode 100644 hyperscale/reporting/types/mongodb/__init__.py create mode 100644 hyperscale/reporting/types/mongodb/mongodb.py create mode 100644 hyperscale/reporting/types/mongodb/mongodb_config.py create mode 100644 hyperscale/reporting/types/mysql/__init__.py create mode 100644 hyperscale/reporting/types/mysql/mysql.py create mode 100644 hyperscale/reporting/types/mysql/mysql_config.py create mode 100644 hyperscale/reporting/types/netdata/__init__.py create mode 100644 hyperscale/reporting/types/netdata/netdata.py create mode 100644 hyperscale/reporting/types/netdata/netdata_config.py create mode 100644 hyperscale/reporting/types/newrelic/__init__.py create mode 100644 hyperscale/reporting/types/newrelic/newrelic.py create mode 100644 hyperscale/reporting/types/newrelic/newrelic_config.py create mode 100644 hyperscale/reporting/types/postgres/__init__.py create mode 100644 hyperscale/reporting/types/postgres/postgres.py create mode 100644 hyperscale/reporting/types/postgres/postgres_config.py create mode 100644 hyperscale/reporting/types/prometheus/__init__.py create mode 100644 hyperscale/reporting/types/prometheus/prometheus.py create mode 100644 hyperscale/reporting/types/prometheus/prometheus_config.py create mode 100644 hyperscale/reporting/types/prometheus/prometheus_metric.py create mode 100644 hyperscale/reporting/types/redis/__init__.py create mode 100644 hyperscale/reporting/types/redis/redis.py create mode 100644 hyperscale/reporting/types/redis/redis_config.py create mode 100644 hyperscale/reporting/types/s3/__init__.py create mode 100644 hyperscale/reporting/types/s3/s3.py create mode 100644 hyperscale/reporting/types/s3/s3_config.py create mode 100644 hyperscale/reporting/types/snowflake/__init__.py create mode 100644 hyperscale/reporting/types/snowflake/snowflake.py create mode 100644 hyperscale/reporting/types/snowflake/snowflake_config.py create mode 100644 hyperscale/reporting/types/sqlite/__init__.py create mode 100644 hyperscale/reporting/types/sqlite/sqlite.py create mode 100644 hyperscale/reporting/types/sqlite/sqlite_config.py create mode 100644 hyperscale/reporting/types/statsd/__init__.py create mode 100644 hyperscale/reporting/types/statsd/statsd.py create mode 100644 hyperscale/reporting/types/statsd/statsd_config.py create mode 100644 hyperscale/reporting/types/telegraf/__init__.py create mode 100644 hyperscale/reporting/types/telegraf/telegraf.py create mode 100644 hyperscale/reporting/types/telegraf/telegraf_config.py create mode 100644 hyperscale/reporting/types/telegraf_statsd/__init__.py create mode 100644 hyperscale/reporting/types/telegraf_statsd/telegraf_statsd.py create mode 100644 hyperscale/reporting/types/telegraf_statsd/teleraf_statsd_config.py create mode 100644 hyperscale/reporting/types/timescaledb/__init__.py create mode 100644 hyperscale/reporting/types/timescaledb/timescaledb.py create mode 100644 hyperscale/reporting/types/timescaledb/timescaledb_config.py create mode 100644 hyperscale/reporting/types/xml/__init__.py create mode 100644 hyperscale/reporting/types/xml/xml.py create mode 100644 hyperscale/reporting/types/xml/xml_config.py create mode 100644 hyperscale/tools/__init__.py create mode 100644 hyperscale/tools/data_structures/__init__.py create mode 100644 hyperscale/tools/data_structures/async_list.py create mode 100644 hyperscale/tools/filesystem/__init__.py create mode 100644 hyperscale/tools/filesystem/base.py create mode 100644 hyperscale/tools/filesystem/binary.py create mode 100644 hyperscale/tools/filesystem/filesystem.py create mode 100644 hyperscale/tools/filesystem/text.py create mode 100644 hyperscale/tools/filesystem/utils.py create mode 100644 hyperscale/tools/helpers/__init__.py create mode 100644 hyperscale/tools/helpers/awaitable.py create mode 100644 hyperscale/tools/helpers/cancel.py create mode 100644 hyperscale/tools/helpers/wait.py create mode 100644 hyperscale/tools/helpers/wrap.py create mode 100644 hyperscale/versioning/__init__.py create mode 100644 hyperscale/versioning/flags/__init__.py create mode 100644 hyperscale/versioning/flags/exceptions/__init__.py create mode 100644 hyperscale/versioning/flags/exceptions/latest_not_enabled.py create mode 100644 hyperscale/versioning/flags/exceptions/unsafe_not_enabled.py create mode 100644 hyperscale/versioning/flags/types/__init__.py create mode 100644 hyperscale/versioning/flags/types/base/__init__.py create mode 100644 hyperscale/versioning/flags/types/base/active.py create mode 100644 hyperscale/versioning/flags/types/base/feature.py create mode 100644 hyperscale/versioning/flags/types/base/flag_type.py create mode 100644 hyperscale/versioning/flags/types/base/registry.py create mode 100644 hyperscale/versioning/flags/types/unsafe/__init__.py create mode 100644 hyperscale/versioning/flags/types/unsafe/feature.py create mode 100644 hyperscale/versioning/flags/types/unsafe/flag.py create mode 100644 hyperscale/versioning/flags/types/unstable/__init__.py create mode 100644 hyperscale/versioning/flags/types/unstable/feature.py create mode 100644 hyperscale/versioning/flags/types/unstable/flag.py create mode 100644 requirements.in create mode 100644 setup.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..f076b26 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,10 @@ +FROM corpheus91/devcontainers:python-3.12-bookworm + + +RUN apt-get update && \ + apt-get install -y libssl-dev && \ + pip install uv && \ + wget http://nz2.archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2.22_amd64.deb && \ + dpkg -i libssl1.1_1.1.1f-1ubuntu2.22_amd64.deb + + diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..3acdf8e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,48 @@ +{ + "name": "Python 3.12", + "dockerComposeFile": "docker-compose.yml", + // We can specify environmental variables via remoteEnv + // to make them only available for debugging/terminal sessions + // etc. + "service": "hyperscale", + "customizations": { + // Configure properties specific to VS Code. + "vscode": { + // Set *default* container specific settings.json values on container create. + // These are all common to https://github.com/datavant/datavant-devcontainers/blob/main/.devcontainer/devcontainer.json + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.enable": true, + "git.path": "/usr/local/bin/git", + "python.languageServer": "Pylance", + "pylint.path": [ + "/usr/local/bin/python", + "-m", + "pylint" + ], + "editor.formatOnSave": true, + + "editor.codeActionsOnSave": { + "source.fixAll": "always", + "source.organizeImports": "always" + }, + "editor.defaultFormatter": "charliermarsh.ruff", + "terminal.integrated.shellIntegration.decorationsEnabled": "both", + "editor.tabCompletion": "on", + "terminal.integrated.defaultProfile.linux": "bash" + }, + + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.vscode-pylance", + "ms-azuretools.vscode-docker", + "skellock.just" + ] + } + }, + "postCreateCommand": "bash .devcontainer/run.sh", + "workspaceFolder": "/workspace" +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..3a454a9 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.10' + +services: + hyperscale: + build: + context: .. + dockerfile: ./.devcontainer/dockerfile + ssh: + - default=${SSH_AUTH_SOCK} + environment: + - LD_LIBRARY_PATH=/usr/local/lib + volumes: + - ..:/workspace + - ../.git:/workspace/.git + - ../.devcontainer:/workspace/.devcontainer + - ~/.aws:/home/datavant/.aws + - ~/.ssh:/home/datavant/.ssh + - ~/.git:/home/datavant/.git + ports: + - 5003:5003 + stdin_open: true + tty: true \ No newline at end of file diff --git a/.devcontainer/run.sh b/.devcontainer/run.sh new file mode 100644 index 0000000..ebf48c6 --- /dev/null +++ b/.devcontainer/run.sh @@ -0,0 +1,15 @@ + +if [[ -d .venv ]]; then + uv venv +fi + +pip uninstall playwright + +source .venv/bin/activate + + +uv pip install -r requirements.in +uv pip install -e . + +playwright install +playwright install-de \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ccd13b5 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,41 @@ +# This is a basic workflow to help you get started with Actions + +name: Publish to PyPi + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: [ main ] + pull_request: + branches: [ main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - uses: actions/checkout@master + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: '3.10' + - name: Install pypa/setuptools + run: >- + python -m + pip install wheel + - name: Build a binary wheel + run: >- + python setup.py sdist bdist_wheel + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 82f9275..f70965b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,162 +1,18 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env +.idea +*.egg-info +__pycache__ +*.pyc +build +dist +.vscode +*-secret.yaml +*-secret.yml +secret.yaml +secret.yml +chromedriver +.eggs +.vscode +kube_config +dev.yaml .venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +*.png \ No newline at end of file diff --git a/.version b/.version new file mode 100644 index 0000000..67d032b --- /dev/null +++ b/.version @@ -0,0 +1 @@ +0.7.19 \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9bf1795 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,137 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + + +| Name | Email | Twitter | +|------- |-------- |---------- | +| Sean Corbett | sean.corbett@umontana.edu | [@sc_codeum](https://twitter.com/sc_codeUM/) | \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4b12732 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include config/cli/*.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffdb438 --- /dev/null +++ b/README.md @@ -0,0 +1,302 @@ + +# Hyperscale - Testing at scale +[![PyPI version](https://img.shields.io/pypi/v/hyperscale?color=gre)](https://pypi.org/project/hyperscale/) +[![License](https://img.shields.io/github/license/hyper-light/hyperscale)](https://github.com/hyper-light/hyperscale/blob/main/LICENSE) +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](https://github.com/hyper-light/hyperscale/blob/main/CODE_OF_CONDUCT.md) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/hyperscale)](https://pypi.org/project/hyperscale/) + + +| Package | Hyperscale | +| ----------- | ----------- | +| Version | 0.7.19 | +| Web | https://hyperscale.dev | +| Download | https://pypi.org/project/hyperscale/ | +| Source | https://github.com/hyper-light/hyperscale | +| Keywords | performance, testing, async, distributed, graph, DAG, workflow | + +Hyperscale is a Python performance and scalable unit/integration testing framework that makes creating and running complex test workflows easy. + +These workflows are written as directed acrylic graphs in Python, where each graph is specified as a collection of Python classes referred to as stages. Each Stage may then specify async Python methods which are then wrapped in Python decorators (referred to as hooks), which that Stage will then execute. The hook wrapping a method tells Hyperscale both what the action does and when to execute it. In combination, stages and hooks allow you to craft test workflows that can mimic real-world user behavior, optimize framework performance, or interact with a variety of Hyperscale's powerful integrations. + +
+ +___________ + +## Why Hyperscale? + +Understanding how your application performs under load can provide valuable insights - allowing you to spot issues with latency, memory usage, and stability. However, performance test tools providing these insights are often difficult to use and lack the ability to simulate complex user interaction at scale. Hyperscale was built to solve these problems by allowing developers and test engineers to author performance tests as sophisticated and scalable workflows. Hyperscale adheres to the following tenants: + +
+ +### __Speed and efficiency by default__ + +Regardless of whether running on your personal laptop or distributed across a cluster, Hyperscale is *fast*, capable of generating millions of requests or interactions per minute and without consuming excessive memory. Hyperscale pushes the limits of Python to achieve this, embracing the latest in Python async and multiprocessing language features to achieve optimal execution performance. + +
+ +### __Run with ease anywhere__ + +Authoring, managing, and running test workflows is easy. Hyperscale includes integrations with Git to facilitate easy management of collections of graphs via Projects, the ability to generate flexible starter test templates, and an API that is both fast and intuitive to understand. Distributed use almost exactly mirrors local operation, reducing the learning curve for more complex deployments. + +
+ +### __Flexibility and and painless extensibility__ +Hyperscale ships with support for HTTP, HTTP2, Websockets, and UDP out of the box. GraphQL, GRPC, and Playwright are available simply by installing the (optional) dependency packages. Hyperscale offers JSON and CSV results output by default, with 28 additional results reporting options readily available by likewise installing the required dependencies. + +Likewise, Hyperscale offers a comprehensive plugin system. You can easily write a plugin to test your Postgresql database or integrate a third party service, with CLI-generated templates to guide you and full type hints support throughout. Unlike other frameworks, no additional compilation or build steps are required - just write your plugin, import it, and include it in the appropriate Stage in your test graph. + +
+ +___________ + +## Requirements and Getting Started + +Hyperscale has been tested on Python versions 3.8.6+, though we recommend using Python 3.10+. You should likewise have the latest LTS version of OpenSSL, build-essential, and other common Unix dependencies installed (if running on a Unix-based OS). + +*Warning*: Hyperscale has currently only been tested on the latest LTS versions of Ubuntu and Debian. Other official OS support is coming Mar. 2023. + +
+ +### __Installing__ + +To install Hyperscale run: +``` +pip install hyperscale +``` +Verify the installation was was successful by running the command: +``` +hyperscale --help +``` + +which should output + +![Output of the hyperscale --help command](https://github.com/hyper-light/hyperscale/blob/main/images/hedra_help_output.png?raw=true "Verifyin Install") + +
+ +### __Creating your first graph__ + +Get started by running Hyperscale's: +``` +hyperscale graph create +``` +command in an empty directory to generate a basic test from a template. For example, run: +``` +hyperscale graph create example.py +``` +which will output the following: + +![Output of the hyperscale graph create example.py command](https://github.com/hyper-light/hyperscale/blob/main/images/hedra_graph_create.png?raw=true "Creating a Graph") + +and generate the the test below in the specified `example.py` file: +```python +from hyperscale import ( + Setup, + Execute, + action, + Analyze, + JSONConfig, + Submit, + depends, +) + +class SetupStage(Setup): + batch_size=1000 + total_time='1m' + + +@depends(SetupStage) +class ExecuteHTTPStage(Execute): + + @action() + async def http_get(self): + return await self.client.http.get('https://') + + +@depends(ExecuteHTTPStage) +class AnalyzeStage(Analyze): + pass + + +@depends(AnalyzeStage) +class SubmitJSONResultsStage(Submit): + config=JSONConfig( + events_filepath='./events.json', + metrics_filepath='./metrics.json' + ) + +``` + +We'll explain this graph below, but for now - replace the string `'https://'` with `'https://httpbin.org/get'`. + +
+Before running our test, if on a Unix system, we may need to set the maximum number of open files above its current limit. This can be done +by running: + +``` +ulimit -n 256000 +``` + +note that you can provide any number here, as long as it is greater than the `batch_size` specified in the `SetupStage` Stage. With that, we're ready run our first test by executing: +``` +hyperscale graph run example.py +``` + +Hyperscale will load the test graph file, parse/validate/setup the stages specified, then begin executing your test: + +![Output of the hyperscale graph run example.py command](https://github.com/hyper-light/hyperscale/blob/main/images/hedra_graph_run_example.png?raw=true "Running a Graph") + +The test will take a minute or two to run, but once complete you should see: + +![Output of hyperscale from a completed graph run](https://github.com/hyper-light/hyperscale/blob/main/images/hedra_graph_complete.png?raw=true "A Complete Graph Run") + +You have officially created and run your first test graph! + +
+ + +___________ + +## Development + +Local development requires at-minimum Python 3.8.6, though 3.10.0+ is recommended. To setup your environment run: + +``` +python3 -m venv ~/.hyperscale && \ +source ~/.hyperscale/bin/activate && \ +git clone https://github.com/hyper-light/hyperscale.git && \ +cd hyperscale && \ +pip install --no-cache -r requirements.in && \ +python setup.py develop +``` + +To develop or work with any of the additional provided engines, references the dependency tables below. + +
+ +___________ + +## Engines, Personas, Algorithms, and Reporters + +Much of Hyperscale's extensibility comes in the form of both extensive integrations/options and plugin capabilities for four main framework features: +
+ +### __Engines__ +Engines are the underlying protocol or library integrations required for Hyperscale to performance test your application (for example HTTP, UDP, Playwright). Hyperscale currently supports the following Engines, with additional install requirements shown if necessary: + +| Engine | Additional Install Option | Dependencies | +| ----------- | ----------- |------------ | +| HTTP | N/A | N/A | +| HTTP2 | N/A | N/A | +| HTTP3 (unstable) | pip install hyperscale[http3] | aioquic | +| UDP | N/A | N/A | +| Websocket | N/A | N/A | +| GRPC | pip install hyperscale[grpc] | protobuf | +| GraphQL | pip install hyperscale[graphql] | graphql-core | +| GraphQL-HTTP2 | pip install hyperscale[graphql] | graphql-core | +| Playwright | pip install hyperscale[playwright] && playwright install | playwright | + + +
+ +### __Personas__ + +Personas are responsible for scheduling when `@action()` or `@task()` hooks execute over the specified Execute stage's test duration. No additional install dependencies are required for Personas, and the following personas are currently supported out-of-box: + +| Persona | Setup Config Name | Description | +| ---------- | ---------------- | ----------------- | +| Approximate Distribution (unstable) | approximate-distribution | Hyperscale automatically adjusts the batch size after each batch spawns according to the concurrency at the current distribution step. This Persona is only available to and is selected by default if a Variant of an Experiment is assigned a distribution. | +| Batched | batched | Executes each action or task hook in batches of the specified size, with an optional wait between each batch spawning | +| Constant Arrival Rate | constant-arrival | Hyperscale automatically adjusts the batch size after each batch spawns based upon the number of hooks that have completed, attempting to achieve `batch_size` completions per batch | +| Constant Spawn Rate | constant-spawn | Like `Batched`, but cycles through actions before waiting `batch_interval` time. | +| Default | N/A | Cycles through all action/task hooks in the Execute stage, resulting in a (mostly) even distribution of execution | +| No-Wait | no-wait | Cycles through all action/task hooks in the Execute stage with no memory usage or other waits. __WARNING__: This persona may cause OOM. | +| Ramped | ramped | Starts at a batch size of `batch_gradient` * `batch_size`. Batch size increases by the gradient each batch with an optional wait between each batch spawning | +| Ramped Interval | ramped-interval | Executes `batch_size` hooks before waiting `batch_gradient` * `batch_interval` time. Interval increases by the gradient each batch | +| Sorted | sorted | Executes each action/task hook in batches of the specified size and in the order provided to each hook's (optional) `order` parameter | +| Weighted | weighted | Executes action/task hooks in batches of the specified size, with each batch being generated from a sampled distribution based upon that action's weight | + +
+ +### __Algorithms__ + +Algorithms are used by Hyperscale `Optimize` stages to calculate maximal test config options like `batch_size`, `batch_gradient`, and/or `batch_interval`. All out-of-box supported algorithms use `scikit-learn` and include: + +| Algorithm | Setup Config Name | Description | +| ---------- | ---------------- | ----------------- | +| SHG | shg | Uses `scikit-learn`'s Simple Global Homology (SHGO) global optimization algorithm | +| Dual Annealing | dual-annealing | Uses `scikit-learn`'s Dual Annealing global optimization algorithm | +| Differential Evolution | diff-evolution | Uses `scikit-learn`'s Differential Evolution global optimization algorithm | +| Point Optimizer (unstable) | point-optimizer | Uses a custom least-squares algorithm. Can only be used by assigning a distribution to a Variant stage for an Experiment. | + +
+ +### __Reporters__ + +Reporters are the integrations Hyperscale uses for submitting aggregated and unaggregated results (for example, to a MySQL database via the MySQL reporter). Hyperscale currently supports the following Reporters, with additional install requirements shown if necessary: + +| Engine | Additional Install Option | Dependencies | +| ----------- | ----------- |------------ | +| AWS Lambda | pip install hyperscale[aws] | boto3 | +| AWS Timestream | pip install hyperscale[aws] | boto3 | +| Big Query | pip install hyperscale[google] | google-cloud-bigquery | +| Big Table | pip install hyperscale[google] | google-cloud-bigtable | +| Cassandra | pip install hyperscale[cassandra] | cassandra-driver | +| Cloudwatch | pip install hyperscale[aws] | boto3 | +| CosmosDB | pip install hyperscale[azure] | azure-cosmos | +| CSV | N/A | N/A | +| Datadog | pip install hyperscale[datadog] | datadog | +| DogStatsD | pip install hyperscale[statsd] | aio_statsd | +| Google Cloud Storage | pip install hyperscale[google] | google-cloud-storage | +| Graphite | pip install hyperscale[statsd] | aio_statsd | +| Honeycomb | pip install hyperscale[honeycomb] | libhoney | +| InfluxDB | pip install hyperscale[influxdb] | influxdb_client | +| JSON | N/A | N/A | +| Kafka | pip install hyperscale[kafka] | aiokafka | +| MongoDB | pip install hyperscale[mongodb] | motor | +| MySQL | pip install hyperscale[sql] | aiomysql, sqlalchemy | +| NetData | pip install hyperscale[statsd] | aio_statsd | +| New Relic | pip install hyperscale[newrelic] | newrelic | +| Postgresql | pip install hyperscale[sql] | aiopg, psycopg2-binary, sqlalchemy | +| Prometheus | pip install hyperscale[prometheus] | prometheus-client, prometheus-client-api | +| Redis | pip install hyperscale[redis] | redis, aioredis | +| S3 | pip install hyperscale[aws] | boto3 | +| Snowflake | pip install hyperscale[snowflake] | snowflake-connector-python, sqlalchemy | +| SQLite3 | pip install hyperscale[sql] | sqlalchemy | +| StatsD | pip install hyperscale[statsd] | aio_statsd | +| Telegraf | pip install hyperscale[statsd] | aio_statsd | +| TelegrafStatsD | pip install hyperscale[statsd] | aio_statsd | +| TimescaleDB | pip install hyperscale[sql] | aiopg, psycopg2-binary, sqlalchemy | +| XML | pip install hyperscale[xml] | dicttoxml | + +
+ +___________ + +## Resources + +Hyperscale's official and full documentation is currently being written and will be linked here soon! + +___________ + +## License + +This software is licensed under the MIT License. See the LICENSE file in the top distribution directory for the full license text. + +___________ + +## Contributing + +Hyperscale will be open to general contributions starting Fall, 2023 (once the distributed rewrite and general testing is complete). Until then, feel +free to use Hyperscale on your local machine and report any bugs or issues you find! + +___________ + +## Code of Conduct + +Hyperscale has adopted and follows the [Contributor Covenant code of conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/code_of_conduct.md). +If you observe behavior that violates those rules please report to: + +| Name | Email | Twitter | +|------- |-------- |---------- | +| Sean Corbett | sean.corbett@umontana.edu | [@sc_codeum](https://twitter.com/sc_codeUM/) | \ No newline at end of file diff --git a/examples/arg_hash.py b/examples/arg_hash.py new file mode 100644 index 0000000..c8b87d0 --- /dev/null +++ b/examples/arg_hash.py @@ -0,0 +1,38 @@ +import threading +import uuid +from typing import Optional + +from hyperscale.core_rewrite.snowflake.snowflake_generator import SnowflakeGenerator + + +class OptimizedArg: + def __init__(self) -> None: + self._snowflake = SnowflakeGenerator( + (uuid.uuid1().int + threading.get_native_id()) >> 64 + ) + + self.arg_id = self._snowflake.generate() + self.call_id: Optional[int] = None + + self.optimized: bool = False + + def __hash__(self): + return self.arg_id + + def __eq__(self, value: object) -> bool: + return ( + isinstance( + value, + OptimizedArg, + ) + and value.arg_id == self.arg_id + ) + + +arg = OptimizedArg() + +test = {} + +test[arg] = arg + +print(test) diff --git a/examples/engine_test.py b/examples/engine_test.py new file mode 100644 index 0000000..1da5c4c --- /dev/null +++ b/examples/engine_test.py @@ -0,0 +1,50 @@ +import asyncio + +from hyperscale.core_rewrite.engines.client.playwright import ( + MercurySyncPlaywrightConnection, +) + +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) + + +async def run(): + eng = MercurySyncPlaywrightConnection(pool_size=10) + + await eng.start() + + page = await eng.open_page() + + await page.goto("https://www.google.com") + + search_locator = page.locator('[aria-label="Search"]') + await search_locator.result.click() + await search_locator.result.fill("Angie Jones") + + await search_locator.result.press("Enter") + + homepage_locator = page.get_by_text("Angie Jones - Angie Jones") + + await homepage_locator.result.click() + + logo_locator = page.locator('[class="eltd-normal-logo"]') + + await logo_locator.result.wait_for() + + await page.wait_for_load_state(state="networkidle") + + contact_locator = page.get_by_role("link", name=" Contact") + + await contact_locator.result.click() + + name_field = page.get_by_text("Your Name (required)") + await name_field.result.first.wait_for() + + await page.wait_for_load_state(state="networkidle") + + await page.screenshot("/workspace/examples/test.png") + + await eng.close() + + +loop.run_until_complete(run()) diff --git a/examples/optimizer_test.py b/examples/optimizer_test.py new file mode 100644 index 0000000..297502b --- /dev/null +++ b/examples/optimizer_test.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import asyncio +import os +from typing import Generic, Literal, TypeVar + +from hyperscale.core.engines.types.http2 import HTTP2Result +from hyperscale.core_rewrite import Graph, Workflow, step +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class Result: + pass + + +T = TypeVar("T") +K = TypeVar("K") + +State = Generic[T, K] + + +class Test(Workflow): + vus = 1000 + threads = 4 + duration = "1m" + udp_port = int(os.getenv("UDP_PORT", "9090")) + + def different(self) -> Literal["Hello there!"]: + return "Hello there!" + + @step() + async def one(self) -> HTTP2Result: + return await self.client.http.get("https://httpbin.org/get") + + @step("one") + async def two(self) -> HTTP2Result: + boop = self.different() + return await self.client.http.get( + f"https://httpbin.org/get?beep={boop}", headers={"test": (boop,)} + ) + + @step("one") + async def three(self) -> HTTP2Result: + return await self.client.http.get("https://httpbin.org/get") + + @step("two", "three") + async def four(self) -> HTTP2Result: + return await self.client.http2.post( + "https://httpbin.org/post", + headers={"test": "this"}, + cookies=[ + ("beep", "boop"), + ("bop", "bap"), + ], + params={"sort": True}, + auth=("user", "pass"), + data={"test": "this"}, + redirects=4, + ) + + @step("two", "three") + async def five(self) -> HTTP2Result: + return await self.client.udp.send(f"127.0.0.1:{self.udp_port}", "Test this!") + + @step("two", "three") + async def six( + self, + url: URL = "https://httpbin.org/get", + headers: dict[str, str] = {"test": "this"}, + ) -> HTTP2Result: + return await self.client.graphql.query( + url, + """ + query getContinents { + continents { + code + name + } + } + """, + headers=headers, + ) + + +async def run(): + w = Test() + + # d = dill.dumps(w.hooks) + + g = Graph([w]) + + await g.setup() + + # await g.run() + + +asyncio.run(run()) diff --git a/hyperscale/__init__.py b/hyperscale/__init__.py new file mode 100644 index 0000000..3f2ff2d --- /dev/null +++ b/hyperscale/__init__.py @@ -0,0 +1,5 @@ + + + + + diff --git a/hyperscale/cli/__init__.py b/hyperscale/cli/__init__.py new file mode 100644 index 0000000..9e00095 --- /dev/null +++ b/hyperscale/cli/__init__.py @@ -0,0 +1,28 @@ +import atexit +import warnings +from multiprocessing import active_children, current_process + +import click +import uvloop + +from .base import CLI + +uvloop.install() +warnings.simplefilter("ignore") + + +@click.group(cls=CLI) +def run(): + def stop_processes_at_exit(): + child_processes = active_children() + for child in child_processes: + child.close() + + process = current_process() + if process: + process.close() + + else: + print("\n") + + atexit.register(stop_processes_at_exit) diff --git a/hyperscale/cli/base.py b/hyperscale/cli/base.py new file mode 100644 index 0000000..9ac8189 --- /dev/null +++ b/hyperscale/cli/base.py @@ -0,0 +1,69 @@ +import os +from importlib.metadata import version +from typing import Any, Callable, List, Optional, Union + +import click +from art import text2art + +from hyperscale.logging import HyperscaleLogger + + +class CLI(click.MultiCommand): + + command_files = { + 'ping': 'ping.py', + 'graph': 'graph.py', + 'project': 'project.py', + 'cloud': 'cloud.py', + 'plugin': 'plugin.py' + } + logger = HyperscaleLogger() + + def __init__( + self, + name: Optional[str] = None, + invoke_without_command: bool = False, + no_args_is_help: Optional[bool] = None, + subcommand_metavar: Optional[str] = None, + chain: bool = False, + result_callback: Optional[Callable[..., Any]] = None, + **attrs: Any + ) -> None: + super().__init__( + name, + invoke_without_command, + no_args_is_help, + subcommand_metavar, + chain, + result_callback, + **attrs + ) + + self.logger.initialize() + + header_text = text2art('Hyperscale', font='alligator').strip('\n') + hyperscale_version = version('hyperscale') + + self.logger.console.sync.info(f'\n{header_text} {hyperscale_version}\n\n') + + def list_commands(self, ctx: click.Context) -> List[str]: + rv = [] + for filename in self.command_files.values(): + rv.append(filename[:-3]) + rv.sort() + return rv + + def get_command(self, ctx: click.Context, name: str) -> Union[click.Command, None]: + + ns = {} + + command_file = os.path.join( + os.path.dirname(__file__), + self.command_files.get(name, 'ping.py') + ) + + with open(command_file) as f: + code = compile(f.read(), command_file, 'exec') + eval(code, ns, ns) + + return ns.get(name) \ No newline at end of file diff --git a/hyperscale/cli/cloud.py b/hyperscale/cli/cloud.py new file mode 100644 index 0000000..326e212 --- /dev/null +++ b/hyperscale/cli/cloud.py @@ -0,0 +1,82 @@ +import click + + +@click.group(help="Commands to run graphs on and manage distributed instances of Hyperscale.") +def cloud(): + pass + + +@cloud.group(help='Login to a Hyperscale Cloud cluster.') +@click.option( + '--username', + help='Hyperscale Cloud cluster username.' +) +@click.option( + '--token', + help='Hyperscale Cloud cluster auth token.' +) +@click.option( + '--cluster', + help='Hyperscale Cluster address.' +) +def login(username: str, auth_token: str, cluster: str): + pass + + +@cloud.group(help='Run the specified graph on the Hyperscale Cloud cluster.') +@click.argument('graph_name') +@click.option( + '--cluster', + help='Hyperscale Cluster address.' +) +def run(graph_name: str, cluster: str): + pass + + +@cloud.group(help='Trigger a project sync on the Hyperscale Cloud cluster.') +@click.option( + '--cluster', + help='Hyperscale Cluster address.' +) +def sync(cluster: str): + pass + + +@cloud.group(help='Submit a test config to the Hyperscale Cloud cluster.') +@click.argument('config_path') +@click.option( + '--cluster', + help='Hyperscale Cluster address.' +) +def submit(config_path: str, cluster: str): + pass + + +@cloud.group(help='Validate the specified graph on the Hyperscale Cloud cluster.') +@click.argument('graph_name') +@click.option( + '--cluster', + help='Hyperscale Cluster address.' +) +def check(graph_name: str, cluster: str): + pass + + +@cloud.group(help='Follow test progress as it runs on the specified Hyperscale Cloud cluster.') +@click.argument('test_id') +@click.option( + '--cluster', + help='Hyperscale Cluster address.' +) +def watch(test_id: str, cluster: str): + pass + + +@cloud.group(help='Update graph config for the specified graph on the specified Hyperscale Cloud cluster.') +@click.argument('graph_name') +@click.option( + '--cluster', + help='Hyperscale Cluster address.' +) +def update(graph_name: str, cluster: str): + pass \ No newline at end of file diff --git a/hyperscale/cli/cloud/__init__.py b/hyperscale/cli/cloud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/cloud/check.py b/hyperscale/cli/cloud/check.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/cloud/login.py b/hyperscale/cli/cloud/login.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/cloud/run.py b/hyperscale/cli/cloud/run.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/cloud/submit.py b/hyperscale/cli/cloud/submit.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/cloud/sync.py b/hyperscale/cli/cloud/sync.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/cloud/update.py b/hyperscale/cli/cloud/update.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/cloud/watch.py b/hyperscale/cli/cloud/watch.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/exceptions/__init__.py b/hyperscale/cli/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/exceptions/graph/__init__.py b/hyperscale/cli/exceptions/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/exceptions/graph/create/__init__.py b/hyperscale/cli/exceptions/graph/create/__init__.py new file mode 100644 index 0000000..d94a4a8 --- /dev/null +++ b/hyperscale/cli/exceptions/graph/create/__init__.py @@ -0,0 +1 @@ +from .invalid_stage_type import InvalidStageType \ No newline at end of file diff --git a/hyperscale/cli/exceptions/graph/create/invalid_stage_type.py b/hyperscale/cli/exceptions/graph/create/invalid_stage_type.py new file mode 100644 index 0000000..ddaa2c7 --- /dev/null +++ b/hyperscale/cli/exceptions/graph/create/invalid_stage_type.py @@ -0,0 +1,12 @@ +from typing import List + + +class InvalidStageType(Exception): + + def __init__(self, stage_type: str, valid_types: List[str]) -> None: + + valid_stage_types = '\n-'.join(valid_types) + + super().__init__( + f'\n\nError - invalid stage type - {stage_type} - specified.\n\nValid types are: \n-{valid_stage_types}\n' + ) \ No newline at end of file diff --git a/hyperscale/cli/exceptions/graph/sync/__init__.py b/hyperscale/cli/exceptions/graph/sync/__init__.py new file mode 100644 index 0000000..8880da7 --- /dev/null +++ b/hyperscale/cli/exceptions/graph/sync/__init__.py @@ -0,0 +1 @@ +from .not_set_error import NotSetError \ No newline at end of file diff --git a/hyperscale/cli/exceptions/graph/sync/not_set_error.py b/hyperscale/cli/exceptions/graph/sync/not_set_error.py new file mode 100644 index 0000000..dbcb08a --- /dev/null +++ b/hyperscale/cli/exceptions/graph/sync/not_set_error.py @@ -0,0 +1,6 @@ +class NotSetError(Exception): + + def __init__(self, path: str, missing_item: str, flag: str) -> None: + super().__init__( + f'\n\nError - {missing_item} found for graph repostiory at {path}.\nSet the initial value for {missing_item} by providing the {flag} option once or check your path.\n' + ) \ No newline at end of file diff --git a/hyperscale/cli/exceptions/plugin/__init__.py b/hyperscale/cli/exceptions/plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/cli/exceptions/plugin/create/__init__.py b/hyperscale/cli/exceptions/plugin/create/__init__.py new file mode 100644 index 0000000..fad33c9 --- /dev/null +++ b/hyperscale/cli/exceptions/plugin/create/__init__.py @@ -0,0 +1 @@ +from .invaid_plugin_type import InvalidPluginType \ No newline at end of file diff --git a/hyperscale/cli/exceptions/plugin/create/invaid_plugin_type.py b/hyperscale/cli/exceptions/plugin/create/invaid_plugin_type.py new file mode 100644 index 0000000..a13f404 --- /dev/null +++ b/hyperscale/cli/exceptions/plugin/create/invaid_plugin_type.py @@ -0,0 +1,12 @@ +from typing import List + + +class InvalidPluginType(Exception): + + def __init__(self, plugin_type: str, valid_types: List[str]) -> None: + + valid_plugin_types = '\n-'.join(valid_types) + + super().__init__( + f'\n\nError - invalid plugin type - {plugin_type} - specified.\n\nValid types are: \n-{valid_plugin_types}\n' + ) \ No newline at end of file diff --git a/hyperscale/cli/graph.py b/hyperscale/cli/graph.py new file mode 100644 index 0000000..0911ee6 --- /dev/null +++ b/hyperscale/cli/graph.py @@ -0,0 +1,145 @@ +import os + +import click +import psutil + +from hyperscale.cli.graph import check_graph, create_graph, run_graph + + +@click.group(help='Commands to run, lint, generate, and manage graphs.') +def graph(): + pass + + +@graph.command(help="Run a specified test file.") +@click.argument('path') +@click.option( + '--cpus', + default=psutil.cpu_count(), + help='Number of CPUs to use. Default is the number of physical processesors available to the system.' +) +@click.option( + '--skip', + default='', + help='Comma-delimited list of Stage names to skip.' +) +@click.option( + '--retries', + default=0, + help='Global retries for graph.' +) +@click.option( + '--show-summaries', + default='', + help='Comma-delimited list of results tables to show upon completion.' +) +@click.option( + '--hide-summaries', + default='', + help='Comma-delimited list of results tables to omit upon completion.' +) +@click.option( + '--log-level', + default='info', + help='Set log level.' +) +@click.option( + '--log-directory', + default=f'{os.getcwd()}/logs', + help='Output directory for logfiles. If the directory does not exist it will be created.' +) +@click.option( + '--bypass-connection-validation', + is_flag=True, + show_default=True, + default=False, + help="Skip Hyperscale's action connection validation." +) +@click.option( + '--connection-validation-retries', + default=3, + help="Set the number of retries for connection validation." +) +@click.option( + '--enable-latest', + is_flag=True, + show_default=True, + default=False, + help='Enable features marked as unstable.' +) +def run( + path: str, + cpus: int, + skip: str, + retries: int, + show_summaries: str, + hide_summaries: str, + log_level: str, + log_directory: str, + bypass_connection_validation: bool, + connection_validation_retries: int, + enable_latest: bool, +): + run_graph( + path, + cpus, + skip, + retries, + show_summaries, + hide_summaries, + log_level, + log_directory, + bypass_connection_validation, + connection_validation_retries, + enable_latest + ) + + +@graph.command(help="Validate the specified test file.") +@click.argument('path') +@click.option( + '--log-level', + default='info', + help='Set log level.' +) +def check(path: str, log_level: str): + check_graph(path, log_level) + + +@graph.command( + help='Creates basic scaffolding for a test graph at the specified path.' +) +@click.argument('path') +@click.option( + '--stages', + help='Optional comma delimited list of stages to generate for the graph.' +) +@click.option( + '--engine', + default='http', + help='Engine to use in generated graph.' +) +@click.option( + '--reporter', + default='json', + help='Reporter to use in generated graph.' +) +@click.option( + '--log-level', + default='info', + help='Set log level.' +) +def create( + path: str, + stages: str, + engine: str, + reporter: str, + log_level: str, +): + create_graph( + path, + stages, + engine, + reporter, + log_level + ) \ No newline at end of file diff --git a/hyperscale/cli/graph/__init__.py b/hyperscale/cli/graph/__init__.py new file mode 100644 index 0000000..cb9489c --- /dev/null +++ b/hyperscale/cli/graph/__init__.py @@ -0,0 +1,3 @@ +from .check import check_graph +from .run import run_graph +from .create import create_graph \ No newline at end of file diff --git a/hyperscale/cli/graph/check.py b/hyperscale/cli/graph/check.py new file mode 100644 index 0000000..8bc15b4 --- /dev/null +++ b/hyperscale/cli/graph/check.py @@ -0,0 +1,104 @@ +import asyncio +import importlib +import inspect +import json +import ntpath +import os +import sys +from pathlib import Path + +import uvloop + +from hyperscale.core.graphs.graph import Graph +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager + +uvloop.install() + + +def check_graph(path: str, log_level: str): + + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + logging_manager.update_log_level(log_level) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + graph_name = path + if os.path.isfile(graph_name): + graph_name = Path(graph_name).stem + + logger['console'].sync.info(f'Validating graph - {graph_name} - at - {path}.\n') + + hyperscale_config_filepath = os.path.join( + os.getcwd(), + '.hyperscale.json' + ) + + hyperscale_config = {} + if os.path.exists(hyperscale_config_filepath): + with open(hyperscale_config_filepath, 'r') as hyperscale_config_file: + hyperscale_config = json.load(hyperscale_config_file) + + hyperscale_graphs = hyperscale_config.get('graphs', {}) + hyperscale_core_config = hyperscale_config.get('core', { + 'bypass_connection_validation': False, + 'connection_validation_retries': 3 + }) + + if path in hyperscale_graphs: + path = hyperscale_graphs.get(path) + + package_dir = Path(path).resolve().parent + package_dir_path = str(package_dir) + package_dir_module = package_dir_path.split('/')[-1] + + package = ntpath.basename(path) + package_slug = package.split('.')[0] + spec = importlib.util.spec_from_file_location(f'{package_dir_module}.{package_slug}', path) + + if path not in sys.path: + sys.path.append(str(package_dir.parent)) + + module = importlib.util.module_from_spec(spec) + sys.modules[module.__name__] = module + + spec.loader.exec_module(module) + + logger['console'].sync.info('Loaded graph.') + + direct_decendants = list({cls.__name__: cls for cls in Stage.__subclasses__()}.values()) + + discovered = {} + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and issubclass(obj, Stage) and obj not in direct_decendants: + discovered[name] = obj + + stages_count = len(discovered) + + logger['console'].sync.info(f'Validating - {stages_count} - stages.') + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + graph = Graph( + graph_name, + list(discovered.values()), + config={ + **hyperscale_core_config, + 'graph_module': module.__name__ + }, + cpus=1 + ) + + graph.assemble() + + logger['console'].sync.info('\nValidation complete!\n') + + os._exit(0) \ No newline at end of file diff --git a/hyperscale/cli/graph/create.py b/hyperscale/cli/graph/create.py new file mode 100644 index 0000000..b2191fa --- /dev/null +++ b/hyperscale/cli/graph/create.py @@ -0,0 +1,96 @@ +import inspect +import json +import os +from pathlib import Path +from typing import Optional + +from hyperscale.cli.exceptions.graph.create import InvalidStageType +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.projects.generation import GraphGenerator + + +def create_graph( + path: str, + stages: Optional[str], + engine: str, + reporter: str, + log_level: str +): + + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + logging_manager.update_log_level(log_level) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + logger['console'].sync.info(f'Creating new graph at - {path}.') + + if stages is None: + stages_list = [ + 'setup', + 'execute', + 'analyze', + 'submit' + ] + + else: + stages_list = stages.split(',') + + generated_stages_count = len(stages_list) + generated_stages = ''.join([ + f'\n-{stage}' for stage in stages_list + ]) + + logger['console'].sync.info(f'Generating - {generated_stages_count} stages:{generated_stages}') + + + generator = GraphGenerator() + + for stage in stages_list: + if stage not in generator.valid_types: + raise InvalidStageType(stage, [ + generator_type_name for generator_type_name, generator_type in generator.generator_types.items() if inspect.isclass( + generator_type + ) and issubclass(generator_type, Stage) + ]) + + + with open(path, 'w') as generated_test: + generated_test.write( + generator.generate_graph( + stages_list, + engine=engine, + reporter=reporter + ) + ) + + graph_name = path + if os.path.isfile(graph_name): + graph_name = Path(graph_name).stem + + hyperscale_config_filepath = os.path.join( + os.getcwd(), + '.hyperscale.json' + ) + + hyperscale_config = {} + if os.path.exists(hyperscale_config_filepath): + with open(hyperscale_config_filepath, 'r') as hyperscale_config_file: + hyperscale_config = json.load(hyperscale_config_file) + + hyperscale_graphs = hyperscale_config.get('graphs', {}) + + if hyperscale_graphs.get(graph_name) is None: + hyperscale_graphs[graph_name] = str(Path(path).absolute().resolve()) + with open(hyperscale_config_filepath, 'w') as hyperscale_config_file: + hyperscale_config['graphs'] = hyperscale_graphs + json.dump(hyperscale_config, hyperscale_config_file, indent=4) + + logger['console'].sync.info('\nGraph generated!\n') \ No newline at end of file diff --git a/hyperscale/cli/graph/run.py b/hyperscale/cli/graph/run.py new file mode 100644 index 0000000..0a3321d --- /dev/null +++ b/hyperscale/cli/graph/run.py @@ -0,0 +1,271 @@ +import asyncio +import importlib +import inspect +import json +import ntpath +import os +import signal +import sys +from multiprocessing import active_children, current_process +from pathlib import Path +from typing import Dict + +import uvloop + +from hyperscale.core.graphs.graph import Graph +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.status import GraphStatus +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.logging.table.summary_table import SummaryTable +from hyperscale.logging.table.table_types import GraphResults +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + +uvloop.install() + + +def run_graph( + path: str, + cpus: int, + skip: str, + retries: int, + show_summaries: str, + hide_summaries: str, + log_level: str, + logfiles_directory: str, + bypass_connection_validation: bool, + connection_validation_retries: int, + enable_latest: bool, +): + + if enable_latest: + active_flags[FlagTypes.UNSTABLE_FEATURE] = True + + if logfiles_directory is None: + logfiles_directory = os.getcwd() + + logging_manager.disable( + LoggerTypes.DISTRIBUTED, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + + logging_manager.update_log_level(log_level) + logging_manager.logfiles_directory = logfiles_directory + + if os.path.exists(logfiles_directory) is False: + os.mkdir(logfiles_directory) + + elif os.path.isdir(logfiles_directory) is False: + os.remove(logfiles_directory) + os.mkdir(logfiles_directory) + + logger = HyperscaleLogger() + logger.initialize() + + graph_name = path + if os.path.isfile(graph_name): + graph_name = Path(graph_name).stem + + logger['console'].sync.info(f'Loading graph - {graph_name.capitalize()}\n') + + hyperscale_config_filepath = os.path.join( + os.getcwd(), + '.hyperscale.json' + ) + + hyperscale_config = {} + if os.path.exists(hyperscale_config_filepath): + with open(hyperscale_config_filepath, 'r') as hyperscale_config_file: + hyperscale_config = json.load(hyperscale_config_file) + + hyperscale_graphs = hyperscale_config.get('graphs', {}) + hyperscale_core_config = hyperscale_config.get('core', { + 'connection_validation_retries': 3 + }) + + hyperscale_core_config['bypass_connection_validation'] = bypass_connection_validation + + if connection_validation_retries: + hyperscale_core_config['connection_validation_retries'] = connection_validation_retries + + if path in hyperscale_graphs: + path = hyperscale_graphs.get(path) + + package_dir = Path(path).resolve().parent + package_dir_path = str(package_dir) + package_dir_module = package_dir_path.split('/')[-1] + + package = ntpath.basename(path) + package_slug = package.split('.')[0] + spec = importlib.util.spec_from_file_location(f'{package_dir_module}.{package_slug}', path) + + if path not in sys.path: + sys.path.append(str(package_dir.parent)) + + module = importlib.util.module_from_spec(spec) + + sys.modules[module.__name__] = module + + spec.loader.exec_module(module) + + direct_decendants = list({cls.__name__: cls for cls in Stage.__subclasses__()}.values()) + + discovered = {} + for name, stage_candidate in inspect.getmembers(module): + if inspect.isclass( + stage_candidate + ) and issubclass( + stage_candidate, + Stage + ) and stage_candidate not in direct_decendants: + discovered[name] = stage_candidate + + if hyperscale_graphs.get(graph_name) is None: + hyperscale_graphs[graph_name] = module.__file__ + + graph_skipped_stages = skip.split(',') + hyperscale_config['logging'] = { + 'logfiles_directory': logfiles_directory, + 'log_level': log_level + } + + with open(hyperscale_config_filepath, 'w') as hyperscale_config_file: + hyperscale_config['graphs'] = hyperscale_graphs + json.dump(hyperscale_config, hyperscale_config_file, indent=4) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + logger.filesystem.sync.create_logfile('hyperscale.core.log') + logger.filesystem.create_filelogger('hyperscale.core.log') + + graph = Graph( + graph_name, + list(discovered.values()), + config={ + **hyperscale_core_config, + 'graph_path': path, + 'graph_module': module.__name__, + 'graph_skipped_stages': graph_skipped_stages + }, + cpus=cpus + ) + + def handle_loop_stop(signame): + try: + graph.cleanup() + child_processes = active_children() + for child in child_processes: + child.kill() + + process = current_process() + if process: + try: + process.kill() + + except Exception: + pass + + except BrokenPipeError: + pass + + except RuntimeError: + pass + + graph.assemble() + for signame in ('SIGINT', 'SIGTERM'): + loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop(signame) + ) + + + graph_execution_results: GraphResults = None + + try: + + if retries > 0: + for _ in range(retries): + + graph_execution_results = loop.run_until_complete(graph.run()) + + if graph.status == GraphStatus.COMPLETE: + break + + else: + graph.cleanup() + + graph = Graph( + graph_name, + list(discovered.values()), + config={ + **hyperscale_core_config, + 'graph_path': path, + 'graph_module': module.__name__, + 'graph_skipped_stages': graph_skipped_stages + }, + cpus=cpus + ) + + graph.assemble() + + else: + graph_execution_results = loop.run_until_complete(graph.run()) + + except BrokenPipeError: + graph.status = GraphStatus.CANCELLED + + except RuntimeError: + graph.status = GraphStatus.CANCELLED + + exit_code = 0 + + if graph.status == GraphStatus.FAILED: + logger.filesystem.sync['hyperscale.core'].info(f'{graph.metadata_string} - Failed - {graph.logger.spinner.display.total_timer.elapsed_message}\n') + logger.console.sync.info(f'\nGraph - {graph_name.capitalize()} - failed. {graph.logger.spinner.display.total_timer.elapsed_message}\n') + exit_code = 1 + + elif graph.status == GraphStatus.CANCELLED: + logger.console.sync.critical('\n\nAborted.\n') + exit_code = 1 + + elif graph.status == GraphStatus.COMPLETE: + + if graph_execution_results: + enabled_summaries = show_summaries.split(',') + disabled_summaries = hide_summaries.split(',') + + summaries_visibility_config: Dict[str, bool] = {} + + for enabled_summary in enabled_summaries: + summaries_visibility_config[enabled_summary] = True + + for disabled_summary in disabled_summaries: + summaries_visibility_config[disabled_summary] = False + + summary_table = SummaryTable( + graph_execution_results, + summaries_visibility_config=summaries_visibility_config + ) + + summary_table.generate_tables() + summary_table.show_tables() + + logger.filesystem.sync['hyperscale.core'].info(f'{graph.metadata_string} - Completed - {graph.logger.spinner.display.total_timer.elapsed_message}\n') + logger.console.sync.info(f'\nGraph - {graph_name.capitalize()} - completed! {graph.logger.spinner.display.total_timer.elapsed_message}\n') + + if graph.status == GraphStatus.FAILED or graph.status == GraphStatus.COMPLETE: + child_processes = active_children() + for child in child_processes: + child.kill() + + process = current_process() + if process: + try: + process.kill() + + except Exception: + pass + + graph.cleanup() + os._exit(exit_code) diff --git a/hyperscale/cli/ping.py b/hyperscale/cli/ping.py new file mode 100644 index 0000000..ab56913 --- /dev/null +++ b/hyperscale/cli/ping.py @@ -0,0 +1,177 @@ +import asyncio +import os +import traceback +from typing import TypeVar + +import click +import uvloop + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql import GraphQLAction, MercuryGraphQLClient +from hyperscale.core.engines.types.graphql_http2 import ( + GraphQLHTTP2Action, + MercuryGraphQLHTTP2Client, +) +from hyperscale.core.engines.types.grpc import GRPCAction, MercuryGRPCClient +from hyperscale.core.engines.types.http import HTTPAction, MercuryHTTPClient +from hyperscale.core.engines.types.http2 import HTTP2Action, MercuryHTTP2Client +from hyperscale.core.engines.types.http3 import HTTP3Action, MercuryHTTP3Client +from hyperscale.core.engines.types.playwright import MercuryPlaywrightClient +from hyperscale.core.engines.types.registry import registered_engines +from hyperscale.core.engines.types.udp import MercuryUDPClient, UDPAction +from hyperscale.core.engines.types.websocket import ( + MercuryWebsocketClient, + WebsocketAction, +) +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + +uvloop.install() + + +T = TypeVar( + 'T', + MercuryGRPCClient, + MercuryGraphQLHTTP2Client, + MercuryGraphQLClient, + MercuryHTTP3Client, + MercuryHTTP2Client, + MercuryHTTPClient, + MercuryPlaywrightClient, + MercuryUDPClient, + MercuryWebsocketClient +) + +@click.command(help="Ping the specified uri to ensure it can be reached.") +@click.argument('uri') +@click.option('--engine', default='http', type=str) +@click.option('--timeout', default=60, type=int) +@click.option('--log-level', default='info', type=str) +@click.option( + '--enable-latest', + is_flag=True, + show_default=True, + default=False, + help='Enable features marked as unstable.' +) +def ping( + uri: str, + engine: str, + timeout: int, + log_level: str, + enable_latest: bool, +): + + if enable_latest: + active_flags[FlagTypes.UNSTABLE_FEATURE] = True + + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + logging_manager.update_log_level(log_level) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + engine_types_map = { + 'http': RequestTypes.HTTP, + 'http2': RequestTypes.HTTP2, + 'http3': RequestTypes.HTTP3, + 'grpc': RequestTypes.GRPC, + 'graphql': RequestTypes.GRAPHQL, + 'graphql-http2': RequestTypes.GRAPHQL_HTTP2, + 'playwright': RequestTypes.PLAYWRIGHT, + 'websocket': RequestTypes.WEBSOCKET + } + + logger['console'].sync.info(f'Pinging target - {uri} - using engine - {engine}.') + logger['console'].sync.debug(f'Pinging with timeout of - {timeout} - seconds.') + + engine_type = engine_types_map.get(engine, RequestTypes.HTTP) + + asyncio.run(ping_target(uri, engine_type, timeout, logger)) + + +async def ping_target(uri: str, engine_type: RequestTypes, timeout: int, logger: HyperscaleLogger): + + action_name = f'ping_{uri}' + + timeouts = Timeouts( + connect_timeout=timeout, + total_timeout=timeout + ) + + selected_engine: T = registered_engines.get(engine_type, MercuryHTTPClient)( + concurrency=1, + timeouts=timeouts, + reset_connections=False + ) + + action_types = { + RequestTypes.HTTP: HTTPAction( + action_name, + uri + ), + RequestTypes.HTTP2: HTTP2Action( + action_name, + uri + ), + RequestTypes.HTTP3: HTTP3Action( + action_name, + uri + ), + RequestTypes.GRAPHQL: GraphQLAction( + action_name, + uri + ), + RequestTypes.GRAPHQL_HTTP2: GraphQLHTTP2Action( + action_name, + uri + ), + RequestTypes.GRPC: GRPCAction( + action_name, + uri + ), + RequestTypes.PLAYWRIGHT: HTTPAction( + action_name, + uri + ), + RequestTypes.UDP: UDPAction( + action_name, + uri + ), + RequestTypes.WEBSOCKET: WebsocketAction( + action_name, + uri + ) + } + + action = action_types.get( + engine_type, + HTTPAction( + action_name, + uri + ) + ) + + if engine_type == RequestTypes.PLAYWRIGHT: + selected_engine = MercuryHTTPClient(timeouts=timeouts) + + try: + + await logger['console'].aio.debug(f'Preparing to connect to - {uri}.') + + action.setup() + await selected_engine.prepare(action) + + await logger['console'].aio.info(f'Successfully connected to - {uri}!\n') + + except Exception: + ping_error = traceback.format_exc().split('\n')[-2] + await logger['console'].aio.error(f'Error - could not ping - {uri}.\nEncountered - {str(ping_error)} - error.\n') diff --git a/hyperscale/cli/plugin.py b/hyperscale/cli/plugin.py new file mode 100644 index 0000000..e59e145 --- /dev/null +++ b/hyperscale/cli/plugin.py @@ -0,0 +1,21 @@ +import click + +from hyperscale.cli.plugin import create_plugin + + +@click.group(help='Commands for creating and managing Hyperscale plugins.') +def plugin(): + pass + + + +@plugin.command() +@click.argument('plugin_type') +@click.argument('path') +@click.option( + '--log-level', + default='info', + help='Set log level.' +) +def create(plugin_type: str, path: str, log_level: str): + create_plugin(plugin_type, path, log_level) \ No newline at end of file diff --git a/hyperscale/cli/plugin/__init__.py b/hyperscale/cli/plugin/__init__.py new file mode 100644 index 0000000..0cd5c62 --- /dev/null +++ b/hyperscale/cli/plugin/__init__.py @@ -0,0 +1 @@ +from .create import create_plugin \ No newline at end of file diff --git a/hyperscale/cli/plugin/create.py b/hyperscale/cli/plugin/create.py new file mode 100644 index 0000000..48b10fc --- /dev/null +++ b/hyperscale/cli/plugin/create.py @@ -0,0 +1,43 @@ +import os + +from hyperscale.cli.exceptions.plugin.create import InvalidPluginType +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.projects.generation import PluginGenerator + + +def create_plugin(plugin_type: str, path: str, log_level: str): + + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + + logging_manager.update_log_level(log_level) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + logger['console'].sync.info(f'Creating new - {plugin_type} - plugin at - {path}.') + + generator = PluginGenerator() + generated_plugin_data = None + + if plugin_type in generator.generator_types: + generated_plugin_data = generator.generate_plugin(plugin_type) + + else: + raise InvalidPluginType( + plugin_type, + list(generator.generator_types.keys()) + ) + + logger['console'].sync.info('Saving template.') + + with open(path, 'w') as generated_plugin: + generated_plugin.write(f'{generated_plugin_data}\n') + + logger['console'].sync.info('\nPlugin generated!\n') + \ No newline at end of file diff --git a/hyperscale/cli/project.py b/hyperscale/cli/project.py new file mode 100644 index 0000000..4fe001d --- /dev/null +++ b/hyperscale/cli/project.py @@ -0,0 +1,231 @@ +import os + +import click + +from hyperscale.cli.project import ( + about_project, + create_project, + get_project, + sync_project, +) + + +@click.group(help='Commands for managing collections of Hyperscale graphs.') +def project(): + pass + + +@project.command( + help='Creates a project at the specified path.' +) +@click.argument('url') +@click.option( + '--project-name', + default='tests', + help='Name of project to create.' +) +@click.option( + '--path', + default=os.getcwd(), + help='Path to graph repository.' +) +@click.option( + '--username', + help='Git repository username.' +) +@click.option( + '--password', + help='Git repository password' +) +@click.option( + '--bypass-connection-validation', + is_flag=True, + show_default=True, + default=False, + help="Skip Hyperscale's action connection validation." +) +@click.option( + '--connection-validation-retries', + default=3, + help="Set the number of retries for connection validation." +) +@click.option( + '--log-level', + default='info', + help='Set log level.' +) +def create( + url: str, + project_name: str, + path: str, + username: str, + password: str, + bypass_connection_validation: bool, + connection_validation_retries: int, + log_level: str +): + create_project( + url, + project_name, + path, + username, + password, + bypass_connection_validation, + connection_validation_retries, + log_level + ) + + + +@project.command( + help="Synchronize changes to the project at the specified path." +) +@click.option( + '--url', + help='Git repository url.' +) +@click.option( + '--path', + default=os.getcwd(), + help='Path to graph repository.' +) +@click.option( + '--branch', + help='Git repository branch.' +) +@click.option( + '--remote', + help='Git repository remote.' +) +@click.option( + '--sync-message', + help='Message for git commit.' +) +@click.option( + '--username', + help='Git repository username.' +) +@click.option( + '--password', + help='Git repository password' +) +@click.option( + '--ignore', + help='Comma delimited list of files to add to the project .gitignore.' +) +@click.option( + '--log-level', + default='info', + help='Set log level.' +) +@click.option( + '--local', + is_flag=True, + show_default=True, + default=False, + help='Synchronize only local project state.' +) +def sync( + url: str, + path: str, + branch: str, + remote: str, + sync_message: str, + username: str, + password: str, + ignore: str, + log_level: str, + local: bool +): + sync_project( + url, + path, + branch, + remote, + sync_message, + username, + password, + ignore, + log_level, + local + ) + + +@project.command( + help='Clone down remote project to the specified path' +) +@click.argument('url') +@click.option( + '--path', + default=os.getcwd(), + help='Path to graph repository.' +) +@click.option( + '--branch', + help='Git repository branch.' +) +@click.option( + '--remote', + help='Git repository remote.' +) +@click.option( + '--username', + help='Git repository username.' +) +@click.option( + '--password', + help='Git repository password' +) +@click.option( + '--bypass-connection-validation', + is_flag=True, + show_default=True, + default=False, + help="Skip Hyperscale's action connection validation." +) +@click.option( + '--connection-validation-retries', + default=3, + help="Set the number of retries for connection validation." +) +@click.option( + '--log-level', + default='info', + help='Set log level.' +) +def get( + url: str, + path: str, + branch: str, + remote: str, + username: str, + password: str, + bypass_connection_validation: bool, + connection_validation_retries: int, + log_level: str +): + get_project( + url, + path, + branch, + remote, + username, + password, + bypass_connection_validation, + connection_validation_retries, + log_level + ) + + +@project.command( + help="Describe the project at the specified path" +) +@click.option( + '--path', + default=os.getcwd(), + help='Path to graph repository.' +) +def about( + path: str +): + about_project(path) \ No newline at end of file diff --git a/hyperscale/cli/project/__init__.py b/hyperscale/cli/project/__init__.py new file mode 100644 index 0000000..ad95608 --- /dev/null +++ b/hyperscale/cli/project/__init__.py @@ -0,0 +1,4 @@ +from .sync import sync_project +from .create import create_project +from .get import get_project +from .about import about_project \ No newline at end of file diff --git a/hyperscale/cli/project/about.py b/hyperscale/cli/project/about.py new file mode 100644 index 0000000..4a74397 --- /dev/null +++ b/hyperscale/cli/project/about.py @@ -0,0 +1,71 @@ +import json +import os +from typing import Any, Dict + +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager + + +def about_project( + path: str +): + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + hyperscale_config_filepath = os.path.join( + path, + '.hyperscale.json' + ) + + hyperscale_config = {} + if os.path.exists(hyperscale_config_filepath): + with open(hyperscale_config_filepath, 'r') as config_file: + hyperscale_config: Dict[str, Any] = json.load(config_file) + + else: + logger.console.sync.error(f'No project found at path - {path}\n') + os._exit(0) + + project_name = hyperscale_config.get('name') + logger.console.sync.info(f'Project - {project_name}') + + logger.console.sync.info('\nGraphs:') + hyperscale_graphs = hyperscale_config.get('graphs', {}) + + if len(hyperscale_graphs) > 0: + for graph_name, graph_path in hyperscale_graphs.items(): + logger.console.sync.info(f' - {graph_name} at {graph_path}') + + else: + logger.console.sync.info(' - No graphs registered') + + logger.console.sync.info('\nPlugins:') + hyperscale_plugins = hyperscale_config.get('plugins', {}) + + if len(hyperscale_plugins) > 0: + for plugin_name, plugin_path in hyperscale_plugins.items(): + logger.console.sync.info(f' - {plugin_name} at {plugin_path}') + + else: + logger.console.sync.info(' - No plugins registered') + + + logger.console.sync.info('\nCore Options:') + hyperscale_options: Dict[str, Any] = hyperscale_config.get('core', {}) + + if len(hyperscale_options) > 0: + for opttion_name, option_value in hyperscale_options.items(): + logger.console.sync.info(f' - {opttion_name} set as {option_value}') + + else: + logger.console.sync.info(' - No options set') + + + logger.console.sync.info('') \ No newline at end of file diff --git a/hyperscale/cli/project/create.py b/hyperscale/cli/project/create.py new file mode 100644 index 0000000..b7a0076 --- /dev/null +++ b/hyperscale/cli/project/create.py @@ -0,0 +1,127 @@ +import json +import os +from urllib.parse import urlparse + +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.projects.management import GraphManager +from hyperscale.projects.management.graphs.actions import RepoConfig + + +def create_project( + url: str, + project_name: str, + path: str, + username: str, + password: str, + bypass_connection_validation: bool, + connection_validation_retries: int, + log_level: str, +): + + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + + logging_manager.update_log_level(log_level) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + hyperscale_config_filepath = os.path.join( + path, + '.hyperscale.json' + ) + + logger['console'].sync.info(f'Checking if project exists at - {path}...') + + project_uninitialized = os.path.exists(hyperscale_config_filepath) is False + if project_uninitialized: + + logger['console'].sync.info('No project found! Creating project directories and files.') + + project_directory = os.path.join(path, project_name) + if os.path.exists(project_directory) is False: + os.mkdir(project_directory) + + package_file = open(f'{project_directory}/__init__.py', 'w') + package_file.close() + + tests_directory = os.path.join(project_directory, 'tests') + os.mkdir(tests_directory) + + package_file = open(f'{tests_directory}/__init__.py', 'w') + package_file.close() + + plugins_directory = os.path.join(project_directory, 'plugins') + os.mkdir(plugins_directory) + + package_file = open(f'{plugins_directory}/__init__.py', 'w') + package_file.close() + + log_directory = f'{os.getcwd()}/logs' + + if os.path.exists(log_directory) is False: + os.mkdir(log_directory) + + elif os.path.isdir(log_directory) is False: + os.remove(log_directory) + os.mkdir(log_directory) + + parsed_url = urlparse(url) + repo_url = f'{parsed_url.scheme}://{username}:{password}@{parsed_url.hostname}{parsed_url.path}' + + logger['console'].sync.info('Initializing project manager.') + + repo_config = RepoConfig( + path, + repo_url, + branch='main', + remote='origin', + username=username, + password=password + ) + + manager = GraphManager(repo_config, log_level=log_level) + discovered = manager.discover_graph_files() + + new_graphs_count = len(discovered['graphs']) + new_plugins_count = len(discovered['plugins']) + + logger['console'].sync.info(f'Found - {new_graphs_count} - new graphs and - {new_plugins_count} - plugins.') + + logger['console'].sync.info(f'Linking project to remote at - {url}.') + + workflow_actions = [ + 'initialize', + 'create-gitignore' + ] + + manager.execute_workflow(workflow_actions) + + hyperscale_config = { + "name": project_name, + "core": { + "bypass_connection_validation": bypass_connection_validation, + "connection_validation_retries": connection_validation_retries + }, + 'project': { + 'project_url': url, + 'project_username': username, + 'project_password': password + }, + **discovered + } + + logger['console'].sync.info('Saving project state to .hyperscale.json config.') + + with open(hyperscale_config_filepath, 'w') as hyperscale_config_file: + json.dump(hyperscale_config, hyperscale_config_file, indent=4) + + logger['console'].sync.info('Project created!') + + else: + logger['console'].sync.info(f'Found existing project at - {path}. Exiting...\n') diff --git a/hyperscale/cli/project/get.py b/hyperscale/cli/project/get.py new file mode 100644 index 0000000..51db66d --- /dev/null +++ b/hyperscale/cli/project/get.py @@ -0,0 +1,129 @@ +import json +import os +from urllib.parse import urlparse + +from hyperscale.cli.exceptions.graph.sync import NotSetError +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.projects.management import GraphManager +from hyperscale.projects.management.graphs.actions import RepoConfig + + +def get_project( + url: str, + path: str, + branch: str, + remote: str, + username: str, + password: str, + bypass_connection_validation: bool, + connection_validation_retries: int, + log_level: str +): + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + + logging_manager.update_log_level(log_level) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + logger['console'].sync.info(f'Fetching project at - {url} - and saving at - {path}...') + + hyperscale_config_filepath = os.path.join( + path, + '.hyperscale.json' + ) + + hyperscale_config = {} + if os.path.exists(hyperscale_config_filepath): + with open(hyperscale_config_filepath, 'r') as hyperscale_config_file: + hyperscale_config = json.load(hyperscale_config_file) + + hyperscale_project_config = hyperscale_config.get('project', {}) + + + if os.path.exists(path) is False: + os.mkdir(path) + + if url is None: + url = hyperscale_project_config.get('project_url') + + if username is None: + username = hyperscale_project_config.get('project_username', "") + + if password is None: + password = hyperscale_project_config.get('project_password', "") + + if branch is None: + branch = hyperscale_project_config.get('project_branch', 'main') + + if remote is None: + remote = hyperscale_project_config.get('project_remote', 'origin') + + if url is None: + raise NotSetError( + path, + 'url', + '--url' + ) + + parsed_url = urlparse(url) + repo_url = f'{parsed_url.scheme}://{username}:{password}@{parsed_url.hostname}{parsed_url.path}' + + logger['console'].sync.info('Initializing project manager.') + + repo_config = RepoConfig( + path, + repo_url, + branch=branch, + remote=remote, + username=username, + password=password + ) + + manager = GraphManager(repo_config, log_level=log_level) + workflow_actions = [ + 'fetch' + ] + + manager.execute_workflow(workflow_actions) + + hyperscale_project_config.update({ + 'project_url': url, + 'project_username': username, + 'project_password': password, + 'project_branch': branch, + 'project_remote': remote + }) + + discovered = manager.discover_graph_files() + hyperscale_config.update(discovered) + + new_graphs_count = len({ + graph_name for graph_name in discovered['graphs'] if graph_name not in hyperscale_config['graphs'] + }) + + new_plugins_count = len(({ + plugin_name for plugin_name in discovered['plugins'] if plugin_name not in hyperscale_config['plugins'] + })) + + logger['console'].sync.info(f'Found - {new_graphs_count} - new graphs and - {new_plugins_count} - plugins.') + + + logger['console'].sync.info('Saving project state to .hyperscale.json config.') + + hyperscale_config['project'] = hyperscale_project_config + hyperscale_config['core'] = { + "bypass_connection_validation": bypass_connection_validation, + "connection_validation_retries": connection_validation_retries + } + + with open(hyperscale_config_filepath, 'w') as hyperscale_config_file: + hyperscale_config = json.dump(hyperscale_config, hyperscale_config_file, indent=4) + + logger['console'].sync.info('Project fetch complete!\n') \ No newline at end of file diff --git a/hyperscale/cli/project/sync.py b/hyperscale/cli/project/sync.py new file mode 100644 index 0000000..51ebe92 --- /dev/null +++ b/hyperscale/cli/project/sync.py @@ -0,0 +1,135 @@ +import json +import os +from urllib.parse import urlparse + +from hyperscale.cli.exceptions.graph.sync import NotSetError +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.projects.management import GraphManager +from hyperscale.projects.management.graphs.actions import RepoConfig + + +def sync_project( + url: str, + path: str, + branch: str, + remote: str, + sync_message: str, + username: str, + password: str, + ignore: str, + log_level: str, + local: bool +): + + logging_manager.disable( + LoggerTypes.HYPERSCALE, + LoggerTypes.DISTRIBUTED, + LoggerTypes.FILESYSTEM, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + + logging_manager.update_log_level(log_level) + + logger = HyperscaleLogger() + logger.initialize() + logging_manager.logfiles_directory = os.getcwd() + + logger['console'].sync.info(f'Running project sync at - {path}...') + + hyperscale_config_filepath = os.path.join( + path, + '.hyperscale.json' + ) + + hyperscale_config = {} + if os.path.exists(hyperscale_config_filepath): + with open(hyperscale_config_filepath, 'r') as hyperscale_config_file: + hyperscale_config = json.load(hyperscale_config_file) + + hyperscale_project_config = hyperscale_config.get('project', {}) + + + if url is None: + url = hyperscale_project_config.get('project_url') + + if username is None: + username = hyperscale_project_config.get('project_username', "") + + if password is None: + password = hyperscale_project_config.get('project_password', "") + + if branch is None: + branch = hyperscale_project_config.get('project_branch', 'main') + + if remote is None: + remote = hyperscale_project_config.get('project_remote', 'origin') + + if url is None: + raise NotSetError( + path, + 'url', + '--url' + ) + + parsed_url = urlparse(url) + repo_url = f'{parsed_url.scheme}://{username}:{password}@{parsed_url.hostname}{parsed_url.path}' + + logger['console'].sync.info('Initializing project manager.') + + repo_config = RepoConfig( + path, + repo_url, + branch=branch, + remote=remote, + sync_message=sync_message, + username=username, + password=password, + ignore_options=ignore + ) + + manager = GraphManager(repo_config, log_level=log_level) + discovered = manager.discover_graph_files() + + new_graphs_count = len({ + graph_name for graph_name in discovered['graphs'] if graph_name not in hyperscale_config['graphs'] + }) + + new_plugins_count = len(({ + plugin_name for plugin_name in discovered['plugins'] if plugin_name not in hyperscale_config['plugins'] + })) + + logger['console'].sync.info(f'Found - {new_graphs_count} - new graphs and - {new_plugins_count} - plugins.') + + if local is False: + + logger['console'].sync.info(f'Synchronizing project state with remote at - {url}...') + + workflow_actions = [ + 'initialize', + 'synchronize' + ] + + + manager.execute_workflow(workflow_actions) + + else: + logger['console'].sync.info('Skipping remote sync.') + + hyperscale_project_config.update({ + 'project_url': url, + 'project_username': username, + 'project_password': password, + 'project_branch': branch, + 'project_remote': remote + }) + + hyperscale_config.update(discovered) + + logger['console'].sync.info('Saving project state to .hyperscale.json config.') + + hyperscale_config['project'] = hyperscale_project_config + with open(hyperscale_config_filepath, 'w') as hyperscale_config_file: + hyperscale_config = json.dump(hyperscale_config, hyperscale_config_file, indent=4) + + logger['console'].sync.info('Sync complete!\n') + diff --git a/hyperscale/core/__init__.py b/hyperscale/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/engines/__init__.py b/hyperscale/core/engines/__init__.py new file mode 100644 index 0000000..3de7bca --- /dev/null +++ b/hyperscale/core/engines/__init__.py @@ -0,0 +1 @@ +from .types.registry import registered_engines \ No newline at end of file diff --git a/hyperscale/core/engines/client/__init__.py b/hyperscale/core/engines/client/__init__.py new file mode 100644 index 0000000..96e44e2 --- /dev/null +++ b/hyperscale/core/engines/client/__init__.py @@ -0,0 +1,3 @@ +from .client import Client +from .tracing_config import TracingConfig +from .time_parser import TimeParser \ No newline at end of file diff --git a/hyperscale/core/engines/client/client.py b/hyperscale/core/engines/client/client.py new file mode 100644 index 0000000..656fd09 --- /dev/null +++ b/hyperscale/core/engines/client/client.py @@ -0,0 +1,257 @@ +import os +import threading +import uuid +from asyncio import Future +from typing import Dict, Generic, Iterable, Optional, Union + +from typing_extensions import TypeVarTuple, Unpack + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation + +from .client_types import ( + GraphQLClient, + GraphQLHTTP2Client, + GRPCClient, + HTTP2Client, + HTTP3Client, + HTTPClient, + PlaywrightClient, + UDPClient, + WebsocketClient, +) +from .config import Config +from .plugins_store import PluginsStore +from .store import ActionsStore + +T = TypeVarTuple('T') + +config_registry = [] + + +class Client(Generic[Unpack[T]]): + + def __init__( + self, + graph_name: str, + graph_id: str, + stage_name: str, + stage_id: str, + config: Optional[Config]=None + ) -> None: + + self.client_id = str(uuid.uuid4()) + self.graph_name = graph_name + self.graph_id = graph_id + self.stage_name = stage_name + self.stage_id = stage_id + + self.next_name = None + self.intercept = False + + self._config: Config = config + self._http = HTTPClient + self._http2 = HTTP2Client + self._http3 = HTTP3Client + self._grpc = GRPCClient + self._graphql = GraphQLClient + self._graphqlh2 = GraphQLHTTP2Client + self._websocket = WebsocketClient + self._playwright = PlaywrightClient + self._udp = UDPClient + + self.clients = {} + self._plugin = PluginsStore[Unpack[T]](self.metadata_string) + + self.actions = ActionsStore(self.metadata_string) + self.mutations: Dict[str, Mutation] = {} + + def __getitem__(self, key: str): + return self.clients.get(key) + + def __setitem__(self, key, value): + self.clients[key] = value + + def get_waiters(self) -> Iterable[Future]: + for session in self.clients.values(): + if session.waiter: + yield session.waiter + + def set_mutations(self): + if self._config.mutations: + for mutation in self._config.mutations: + for target in mutation.targets: + self.mutations[target] = mutation + + @property + def thread_id(self) -> int: + return threading.current_thread().ident + + @property + def process_id(self) -> int: + return os.getpid() + + @property + def metadata_string(self): + return f'Graph - {self.graph_name}:{self.graph_id} - thread:{self.thread_id} - process:{self.process_id} - Stage: {self.stage_name}:{self.stage_id} - ' + + @property + def plugin(self) -> Dict[str, Union[Unpack[T]]]: + self._plugin._config = self._config + self._plugin.actions = self.actions + self._plugin.metadata_string = self.metadata_string + self._plugin.actions.waiter = self.actions.waiter + self._plugin.actions.current_stage = self.actions.current_stage + self._plugin.intercept = self.intercept + self._plugin.next_name = self.next_name + + self._plugin.mutations.update(self.mutations) + self.mutations.update(self._plugin.mutations) + + return self._plugin + + @property + def http(self): + if self._http.initialized is False: + self._http = self._http(self._config) + self._http.metadata_string = self.metadata_string + self._http.actions = self.actions + self.clients[RequestTypes.HTTP] = self._http + + self._http.mutations.update(self.mutations) + self.mutations.update(self._http.mutations) + + self._http.next_name = self.next_name + self._http.intercept = self.intercept + return self._http + + @property + def http2(self): + if self._http2.initialized is False: + self._http2 = self._http2(self._config) + self._http2.metadata_string = self.metadata_string + self._http2.actions = self.actions + self.clients[RequestTypes.HTTP2] = self._http2 + + self._http2.mutations.update(self.mutations) + self.mutations.update(self._http2.mutations) + + self._http2.next_name = self.next_name + self._http2.intercept = self.intercept + return self._http2 + + @property + def http3(self): + if self._http3.initialized is False: + self._http3 = self._http3(self._config) + self._http3.metadata_string = self.metadata_string + self._http3.actions = self.actions + self.clients[RequestTypes.HTTP3] = self._http3 + + self._http3.mutations.update(self.mutations) + self.mutations.update(self._http3.mutations) + + self._http3.next_name = self.next_name + self._http3.intercept = self.intercept + return self._http3 + + @property + def grpc(self): + if self._grpc.initialized is False: + self._grpc = self._grpc(self._config) + self._grpc.metadata_string = self.metadata_string + self._grpc.actions = self.actions + self.clients[RequestTypes.GRPC] = self._grpc + + self._grpc.mutations.update(self.mutations) + self.mutations.update(self._grpc.mutations) + + self._grpc.next_name = self.next_name + self._grpc.intercept = self.intercept + return self._grpc + + @property + def graphql(self): + if self._graphql.initialized is False: + self._graphql = self._graphql(self._config) + self._graphql.metadata_string = self.metadata_string + self._graphql.actions = self.actions + self.clients[RequestTypes.GRAPHQL] = self._graphql + + self._graphql.mutations.update(self.mutations) + self.mutations.update(self._graphql.mutations) + + self._graphql.next_name = self.next_name + self._graphql.intercept = self.intercept + return self._graphql + + @property + def graphqlh2(self): + + if self._config is None: + self._config = config_registry.pop() + + if self._graphqlh2.initialized is False: + self._graphqlh2 = self._graphqlh2(self._config) + self._graphqlh2.metadata_string = self.metadata_string + self._graphqlh2.actions = self.actions + self.clients[RequestTypes.GRAPHQL_HTTP2] = self._graphqlh2 + + self._graphqlh2.mutations.update(self.mutations) + self.mutations.update(self._graphqlh2.mutations) + + self._graphqlh2.next_name = self.next_name + self._graphqlh2.intercept = self.intercept + return self._graphql + + @property + def websocket(self): + if self._websocket.initialized is False: + self._websocket = self._websocket(self._config) + self._websocket.metadata_string = self.metadata_string + self._websocket.actions = self.actions + self.clients[RequestTypes.WEBSOCKET] = self._websocket + + self._websocket.mutations.update(self.mutations) + self.mutations.update(self._websocket.mutations) + + self._websocket.next_name = self.next_name + self._websocket.intercept = self.intercept + return self._websocket + + @property + def playwright(self): + if self._playwright.initialized is False: + self._playwright = self._playwright(self._config) + self._playwright.metadata_string = self.metadata_string + self._playwright.actions = self.actions + self.clients[RequestTypes.PLAYWRIGHT] = self._playwright + + self._playwright.mutations.update(self.mutations) + self.mutations.update(self._playwright.mutations) + + self._playwright.next_name = self.next_name + self._playwright.intercept = self.intercept + return self._playwright + + @property + def udp(self): + if self._udp.initialized is False: + self._udp = self._udp(self._config) + self._udp.metadata_string = self.metadata_string + self._udp.actions = self.actions + self.clients[RequestTypes.UDP] = self._udp + + self._udp.mutations.update(self.mutations) + self.mutations.update(self._udp.mutations) + + self._udp.next_name = self.next_name + self._udp.intercept = self.intercept + return self._udp + + + + + + + diff --git a/hyperscale/core/engines/client/client_types/__init__.py b/hyperscale/core/engines/client/client_types/__init__.py new file mode 100644 index 0000000..ea759f0 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/__init__.py @@ -0,0 +1,9 @@ +from .http import HTTPClient +from .http2 import HTTP2Client +from .http3 import HTTP3Client +from .grpc import GRPCClient +from .graphql import GraphQLClient +from .graphql_http2 import GraphQLHTTP2Client +from .websocket import WebsocketClient +from .playwright import PlaywrightClient +from .udp import UDPClient \ No newline at end of file diff --git a/hyperscale/core/engines/client/client_types/base_client.py b/hyperscale/core/engines/client/client_types/base_client.py new file mode 100644 index 0000000..cada4f6 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/base_client.py @@ -0,0 +1,134 @@ +import asyncio +import uuid +from typing import Dict, Generic, TypeVar + +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types import ( + MercuryGraphQLClient, + MercuryGraphQLHTTP2Client, + MercuryGRPCClient, + MercuryHTTP2Client, + MercuryHTTPClient, + MercuryPlaywrightClient, + MercuryTaskRunner, + MercuryUDPClient, + MercuryWebsocketClient, +) +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql import GraphQLAction, GraphQLResult +from hyperscale.core.engines.types.graphql_http2 import ( + GraphQLHTTP2Action, + GraphQLHTTP2Result, +) +from hyperscale.core.engines.types.grpc import GRPCAction, GRPCResult +from hyperscale.core.engines.types.http import HTTPAction, HTTPResult +from hyperscale.core.engines.types.http2 import HTTP2Action, HTTP2Result +from hyperscale.core.engines.types.playwright import PlaywrightCommand, PlaywrightResult +from hyperscale.core.engines.types.task import Task, TaskResult +from hyperscale.core.engines.types.udp import UDPAction, UDPResult +from hyperscale.core.engines.types.websocket import WebsocketAction, WebsocketResult +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation +from hyperscale.core.hooks.types.event.event import Event +from hyperscale.core.hooks.types.event.hook import EventHook +from hyperscale.logging import HyperscaleLogger + +S = TypeVar( + 'S', + MercuryGraphQLClient, + MercuryGraphQLHTTP2Client, + MercuryGRPCClient, + MercuryHTTPClient, + MercuryHTTP2Client, + MercuryPlaywrightClient, + MercuryTaskRunner, + MercuryUDPClient, + MercuryWebsocketClient +) +A = TypeVar( + 'A', + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + PlaywrightCommand, + Task, + UDPAction, + WebsocketAction +) +R = TypeVar( + 'R', + GraphQLResult, + GraphQLHTTP2Result, + GRPCResult, + HTTPResult, + HTTP2Result, + PlaywrightResult, + TaskResult, + UDPResult, + WebsocketResult +) + + +class BaseClient(Generic[S, A, R]): + initialized=False + setup=False + + def __init__(self) -> None: + self.initialized = True + self.metadata_string: str = None + self.client_id = str(uuid.uuid4()) + self.session: S = None + self.request_type :RequestTypes = None + self.client_type: str = None + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.mutations: Dict[str, Mutation] = {} + + async def _execute_action(self, action: A) -> R: + + if self.mutations.get(action.name): + mutation = self.mutations[action.name] + + if action.hooks.before is None: + action.hooks.before = [] + + mutation_event = Event( + None, + EventHook( + mutation.name, + mutation.name, + mutation.mutate, + action.name + ) + ) + + mutation_event.source.stage_instance = mutation.stage + action.hooks.before.append([mutation_event]) + + await self.logger.filesystem.aio['hyperscale.core'].debug( + f'{self.metadata_string} - {self.client_type} Client {self.client_id} - Preparing Action - {action.name}:{action.action_id}' + ) + await self.session.prepare(action) + + await self.logger.filesystem.aio['hyperscale.core'].debug( + f'{self.metadata_string} - {self.client_type} Client {self.client_id} - Prepared Action - {action.name}:{action.action_id}' + ) + + if self.intercept: + await self.logger.filesystem.aio['hyperscale.core'].debug( + f'{self.metadata_string} - {self.client_type} Client {self.client_id} - Initiating suspense for Action - {action.name}:{action.action_id} - and storing' + ) + self.actions.store(self.next_name, action, self.session) + + loop = asyncio.get_event_loop() + self.waiter = loop.create_future() + await self.waiter + + return await self.session.execute_prepared_request(action) \ No newline at end of file diff --git a/hyperscale/core/engines/client/client_types/graphql.py b/hyperscale/core/engines/client/client_types/graphql.py new file mode 100644 index 0000000..4d13b27 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/graphql.py @@ -0,0 +1,85 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql import ( + GraphQLAction, + GraphQLResult, + MercuryGraphQLClient, +) +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class GraphQLClient(BaseClient[MercuryGraphQLClient, GraphQLAction, GraphQLResult]): + + def __init__(self, config: Config) -> None: + super().__init__() + + if config is None: + config = Config() + + tracing_session: Union[TraceSession, None] = None + if config.tracing: + trace_config_dict = config.tracing.to_dict() + tracing_session = TraceSession(**trace_config_dict) + + self.session = MercuryGraphQLClient( + concurrency=config.batch_size, + timeouts=Timeouts( + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections, + tracing_session=tracing_session + ) + self.request_type = RequestTypes.GRAPHQL + self.client_type = self.request_type.capitalize() + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + self.waiter = None + + self.logger = HyperscaleLogger() + self.logger.initialize() + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def query( + self, + url: str, + query: str, + operation_name: str = None, + variables: Dict[str, Any] = None, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int = 3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = GraphQLAction( + self.next_name, + url, + method='POST', + headers=headers, + data={ + "query": query, + "operation_name": operation_name, + "variables": variables + }, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) diff --git a/hyperscale/core/engines/client/client_types/graphql_http2.py b/hyperscale/core/engines/client/client_types/graphql_http2.py new file mode 100644 index 0000000..b7ef603 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/graphql_http2.py @@ -0,0 +1,84 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql_http2 import ( + GraphQLHTTP2Action, + GraphQLHTTP2Result, + MercuryGraphQLHTTP2Client, +) +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class GraphQLHTTP2Client(BaseClient[MercuryGraphQLHTTP2Client, GraphQLHTTP2Action, GraphQLHTTP2Result]): + + def __init__(self, config: Config) -> None: + super().__init__() + + if config is None: + config = Config() + + tracing_session: Union[TraceSession, None] = None + if config.tracing: + trace_config_dict = config.tracing.to_dict() + tracing_session = TraceSession(**trace_config_dict) + + self.session = MercuryGraphQLHTTP2Client( + concurrency=config.batch_size, + timeouts=Timeouts( + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections, + tracing_session=tracing_session + ) + self.request_type = RequestTypes.GRAPHQL_HTTP2 + self.client_type = self.request_type.capitalize() + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + self.waiter = None + + self.logger = HyperscaleLogger() + self.logger.initialize() + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def query( + self, + url: str, + query: str, + operation_name: str = None, + variables: Dict[str, Any] = None, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = GraphQLHTTP2Action( + self.next_name, + url, + method='POST', + headers=headers, + data={ + "query": query, + "operation_name": operation_name, + "variables": variables + }, + user=user, + tags=tags + ) + + return await self._execute_action(request) + diff --git a/hyperscale/core/engines/client/client_types/grpc.py b/hyperscale/core/engines/client/client_types/grpc.py new file mode 100644 index 0000000..5dd5682 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/grpc.py @@ -0,0 +1,72 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.grpc import GRPCAction, GRPCResult, MercuryGRPCClient +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class GRPCClient(BaseClient[MercuryGRPCClient, GRPCAction, GRPCResult]): + + def __init__(self, config: Config) -> None: + super().__init__() + + if config is None: + config = Config() + + tracing_session: Union[TraceSession, None] = None + if config.tracing: + trace_config_dict = config.tracing.to_dict() + tracing_session = TraceSession(**trace_config_dict) + + self.session = MercuryGRPCClient( + concurrency=config.batch_size, + timeouts=Timeouts( + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections, + tracing_session=tracing_session + ) + self.request_type = RequestTypes.GRPC + self.client_type = self.request_type.capitalize() + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + + self.logger = HyperscaleLogger() + self.logger.initialize() + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def request( + self, + url: str, + headers: Dict[str, str] = {}, + protobuf: Any = None, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = GRPCAction( + self.next_name, + url, + method='POST', + headers=headers, + data=protobuf, + user=user, + tags=tags + ) + + return await self._execute_action(request) \ No newline at end of file diff --git a/hyperscale/core/engines/client/client_types/http.py b/hyperscale/core/engines/client/client_types/http.py new file mode 100644 index 0000000..e04f629 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/http.py @@ -0,0 +1,188 @@ +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http import HTTPAction, HTTPResult, MercuryHTTPClient +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class HTTPClient(BaseClient[MercuryHTTPClient, HTTPAction, HTTPResult]): + + def __init__(self, config: Config) -> None: + super().__init__() + + if config is None: + config = Config() + + tracing_session: Union[TraceSession, None] = None + if config.tracing: + trace_config_dict = config.tracing.to_dict() + tracing_session = TraceSession(**trace_config_dict) + + self.session = MercuryHTTPClient( + concurrency=config.batch_size, + timeouts=Timeouts( + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections, + tracing_session=tracing_session + ) + self.request_type = RequestTypes.HTTP + self.client_type = self.request_type.capitalize() + + self.next_name = None + self.intercept = False + self.waiter = None + self.actions: ActionsStore = None + self.registered = {} + + self.logger = HyperscaleLogger() + self.logger.initialize() + + + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def get( + self, + url: str, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTPAction( + self.next_name, + url, + method='GET', + headers=headers, + data=None, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) + + async def post( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTPAction( + self.next_name, + url, + method='POST', + headers=headers, + data=data, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) + + async def put( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTPAction( + self.next_name, + url, + method='PUT', + headers=headers, + data=data, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) + + async def patch( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTPAction( + self.next_name, + url, + method='PATCH', + headers=headers, + data=data, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) + + async def delete( + self, + url: str, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTPAction( + self.next_name, + url, + method='DELETE', + headers=headers, + data=None, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) diff --git a/hyperscale/core/engines/client/client_types/http2.py b/hyperscale/core/engines/client/client_types/http2.py new file mode 100644 index 0000000..fcdf47e --- /dev/null +++ b/hyperscale/core/engines/client/client_types/http2.py @@ -0,0 +1,182 @@ +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2 import ( + HTTP2Action, + HTTP2Result, + MercuryHTTP2Client, +) +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class HTTP2Client(BaseClient[MercuryHTTP2Client, HTTP2Action, HTTP2Result]): + + def __init__(self, config: Config) -> None: + super().__init__() + + if config is None: + config = Config() + + tracing_session: Union[TraceSession, None] = None + if config.tracing: + trace_config_dict = config.tracing.to_dict() + tracing_session = TraceSession(**trace_config_dict) + + self.session = MercuryHTTP2Client( + concurrency=config.batch_size, + timeouts=Timeouts( + connect_timeout=config.connect_timeout, + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections, + tracing_session=tracing_session + ) + self.request_type = RequestTypes.HTTP2 + self.client_type = self.request_type.capitalize() + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + + self.logger = HyperscaleLogger() + self.logger.initialize() + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def get( + self, + url: str, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP2Action( + self.next_name, + url, + method='GET', + headers=headers, + data=None, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + async def post( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP2Action( + self.next_name, + url, + method='POST', + headers=headers, + data=data, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + + async def put( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP2Action( + self.next_name, + url, + method='PUT', + headers=headers, + data=data, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + + async def patch( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP2Action( + self.next_name, + url, + method='PATCH', + headers=headers, + data=data, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + + async def delete( + self, + url: str, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP2Action( + self.next_name, + url, + method='DELETE', + headers=headers, + data=None, + user=user, + tags=tags + ) + + return await self._execute_action(request) diff --git a/hyperscale/core/engines/client/client_types/http3.py b/hyperscale/core/engines/client/client_types/http3.py new file mode 100644 index 0000000..79cb026 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/http3.py @@ -0,0 +1,190 @@ +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http3 import ( + HTTP3Action, + HTTP3Result, + MercuryHTTP3Client, +) +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class HTTP3Client(BaseClient[MercuryHTTP3Client, HTTP3Action, HTTP3Result]): + + def __init__(self, config: Config) -> None: + super().__init__() + + if config is None: + config = Config() + + tracing_session: Union[TraceSession, None] = None + if config.tracing: + trace_config_dict = config.tracing.to_dict() + tracing_session = TraceSession(**trace_config_dict) + + self.session = MercuryHTTP3Client( + concurrency=config.batch_size, + timeouts=Timeouts( + connect_timeout=config.connect_timeout, + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections, + tracing_session=tracing_session + ) + self.request_type = RequestTypes.HTTP2 + self.client_type = self.request_type.capitalize() + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + + self.logger = HyperscaleLogger() + self.logger.initialize() + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def get( + self, + url: str, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP3Action( + self.next_name, + url, + method='GET', + headers=headers, + data=None, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) + + async def post( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP3Action( + self.next_name, + url, + method='POST', + headers=headers, + data=data, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) + + + async def put( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP3Action( + self.next_name, + url, + method='PUT', + headers=headers, + data=data, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + + async def patch( + self, + url: str, + headers: Dict[str, str] = {}, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP3Action( + self.next_name, + url, + method='PATCH', + headers=headers, + data=data, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) + + + async def delete( + self, + url: str, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [], + redirects: int=3, + trace: Trace=None + ): + if trace and self.session.tracing_session is None: + self.session.tracing_session = TraceSession( + **trace.to_dict() + ) + + request = HTTP3Action( + self.next_name, + url, + method='DELETE', + headers=headers, + data=None, + user=user, + tags=tags, + redirects=redirects + ) + + return await self._execute_action(request) diff --git a/hyperscale/core/engines/client/client_types/playwright.py b/hyperscale/core/engines/client/client_types/playwright.py new file mode 100644 index 0000000..7a77682 --- /dev/null +++ b/hyperscale/core/engines/client/client_types/playwright.py @@ -0,0 +1,1205 @@ +from typing import Any, Callable, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.playwright import ( + URL, + Input, + MercuryPlaywrightClient, + Options, + Page, + PlaywrightCommand, + PlaywrightResult, +) +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class PlaywrightClient(BaseClient[MercuryPlaywrightClient, PlaywrightCommand, PlaywrightResult]): + + def __init__(self, config: Config) -> None: + super().__init__() + + self.session = MercuryPlaywrightClient( + concurrency=config.batch_size, + group_size=config.group_size, + timeouts=Timeouts( + total_timeout=config.request_timeout + ) + ) + + self.request_type = RequestTypes.PLAYWRIGHT + self.client_type = self.request_type.capitalize() + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + + self.logger = HyperscaleLogger() + self.logger.initialize() + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def goto( + self, + url: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'goto', + url=URL(location=url), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def fill( + self, + selector: str, + text: str, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'fill', + page=Page(selector=selector), + input=Input(text=text), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def check( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'check', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def click( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'click', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def double_click( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'double_click', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def submit_event( + self, + selector: str, + event: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'double_click', + page=Page(selector=selector), + options=Options( + event=event, + **extra + ), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def drag_and_drop( + self, + x_coordinate: int, + y_coordinate: int, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'drag_and_drop', + page=Page( + x_coordinate=x_coordinate, + y_coordinate=y_coordinate + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def switch_active_tab( + self, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'switch_active_tab', + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def evaluate_selector( + self, + selector: str, + expression: str, + args: List[Union[int, str, float, bool, None]]=[], + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'evaluate_selector', + page=Page(selector=selector), + input=Input( + expression=expression, + args=args + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def evaluate_all_selectors( + self, + selector: str, + expression: str, + args: List[Union[int, str, float, bool, None]]=[], + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'evaluate_all_selectors', + page=Page(selector=selector), + input=Input( + expression=expression, + args=args + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def evaluate_expression( + self, + expression: str, + args: List[Union[int, str, float, bool, None]]=[], + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'evaluate_expression', + input=Input( + expression=expression, + args=args + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def evaluate_handle( + self, + expression: str, + args: List[Union[int, str, float, bool, None]]=[], + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'evaluate_handle', + input=Input( + expression=expression, + args=args + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def exepect_console_message( + self, + expression: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'exepect_console_message', + input=Input(expression=expression), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def expect_download( + self, + expression: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'expect_download', + input=Input(expression=expression), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def expect_event( + self, + event: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'expect_event', + options=Options( + event=event, + **extra + ), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def expect_location( + self, + url: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'expect_location', + url=URL(location=url), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def expect_popup( + self, + expression: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'expect_popup', + input=Input(expression=expression), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def expect_request( + self, + url: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'expect_request', + url=URL(location=url), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def expect_request_finished( + self, + url: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'expect_request_finished', + url=URL(location=url), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def expect_response( + self, + url: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'expect_response', + url=URL(location=url), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def focus( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'focus', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def hover( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'hover', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_inner_html( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_inner_html', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_text( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_text', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_input_value( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_input_value', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def press_key( + self, + selector: str, + key: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'press_key', + page=Page(selector=selector), + input=Input(key=key), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def verify_is_enabled( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'verify_is_enabled', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def verify_is_hidden( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'verify_is_hidden', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def verify_is_visible( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'verify_is_visible', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def verify_is_checked( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'verify_is_checked', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_content( + self, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_content', + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_element( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_element', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_all_elements( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_all_elements', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def reload_page( + self, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'reload_page', + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def take_screenshot( + self, + screenshot_filepath: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'take_screenshot', + input=Input(path=screenshot_filepath), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def select_option( + self, + selector: str, + option: Union[str, int, bool, float, None], + by_label: bool=False, + by_value: bool=False, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'select_option', + page=Page(selector=selector), + input=Input( + option=option, + by_label=by_label, + by_value=by_value + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def set_checked( + self, + selector: str, + checked: bool, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'set_checked', + page=Page(selector=selector), + options=Options( + is_checked=checked, + **extra + ), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def set_default_timeout( + self, + timeout: float, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'set_default_timeout', + options=Options( + timeout=timeout, + **extra + ), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def set_navigation_timeout( + self, + timeout: float, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'set_navigation_timeout', + options=Options( + timeout=timeout, + **extra + ), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def set_http_headers( + self, + headers: Dict[str, str], + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'set_http_headers', + url=URL(headers=headers), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def tap( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'tap', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_text_content( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_text_content', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_page_title( + self, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_page_title', + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def input_text( + self, + selector: str, + text: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'input_text', + page=Page(selector=selector), + input=Input(text=text), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def uncheck( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'uncheck', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def wait_for_event( + self, + event: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'wait_for_event', + options=Options( + event=event, + **extra + ), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def wait_for_function( + self, + expression: str, + args: List[Union[int, str, float, bool, None]]=[], + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'wait_for_function', + input=Input( + expression=expression, + args=args + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def wait_for_page_load_state( + self, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'wait_for_page_load_state', + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def wait_for_selector( + self, + selector: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'wait_for_selector', + page=Page(selector=selector), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def wait_for_timeout( + self, + timeout: float, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'wait_for_timeout', + options=Options( + timeout=timeout, + **extra + ), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def wait_for_url( + self, + url: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'wait_for_url', + url=URL(location=url), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def switch_frame( + self, + url: str=None, + frame: int=None, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'switch_frame', + url=URL(location=url), + page=Page(frame=frame), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_frames( + self, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_frames', + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def get_attribute( + self, + selector: str, + attribute: str, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'get_attribute', + page=Page( + selector=selector, + attribute=attribute + ), + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def go_back_page( + self, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'go_back_page', + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) + + async def go_forward_page( + self, + extra: Dict[str, Any]={}, + user: str=None, + tags: List[Dict[str, str]]=[], + checks: List[Callable]=[] + ): + command = PlaywrightCommand( + self.next_name, + 'go_forward_page', + options=Options(**extra), + user=user, + tags=tags, + checks=checks + ) + + return await self._execute_action(command) diff --git a/hyperscale/core/engines/client/client_types/udp.py b/hyperscale/core/engines/client/client_types/udp.py new file mode 100644 index 0000000..25e107d --- /dev/null +++ b/hyperscale/core/engines/client/client_types/udp.py @@ -0,0 +1,79 @@ +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.udp import MercuryUDPClient, UDPAction, UDPResult +from hyperscale.logging import HyperscaleLogger + +from .base_client import BaseClient + + +class UDPClient(BaseClient[MercuryUDPClient, UDPAction, UDPResult]): + + def __init__(self, config: Config) -> None: + super().__init__() + + self.session = MercuryUDPClient( + concurrency=config.batch_size, + timeouts=Timeouts( + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections + ) + self.request_type = RequestTypes.UDP + self.client_type = self.request_type.capitalize() + + self.next_name = None + self.intercept = False + self.waiter = None + self.actions: ActionsStore = None + self.registered = {} + + self.logger = HyperscaleLogger() + self.logger.initialize() + + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def receive( + self, + url: str, + user: str = None, + tags: List[Dict[str, str]] = [] + ): + + request = UDPAction( + self.next_name, + url, + wait_for_response=True, + data=None, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + async def send( + self, + url: str, + wait_for_resonse: bool = False, + data: Union[dict, str, bytes, Iterator] = None, + user: str = None, + tags: List[Dict[str, str]] = [] + ): + + request = UDPAction( + self.next_name, + url, + wait_for_response=wait_for_resonse, + data=data, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + \ No newline at end of file diff --git a/hyperscale/core/engines/client/client_types/websocket.py b/hyperscale/core/engines/client/client_types/websocket.py new file mode 100644 index 0000000..adccfcc --- /dev/null +++ b/hyperscale/core/engines/client/client_types/websocket.py @@ -0,0 +1,77 @@ +from typing import Any, Dict, List + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.websocket import ( + MercuryWebsocketClient, + WebsocketAction, + WebsocketResult, +) + +from .base_client import BaseClient + + +class WebsocketClient(BaseClient[MercuryWebsocketClient, WebsocketAction, WebsocketResult]): + + def __init__(self, config: Config) -> None: + super().__init__() + + self.session = MercuryWebsocketClient( + concurrency=config.batch_size, + timeouts=Timeouts( + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections + ) + self.request_type = RequestTypes.WEBSOCKET + self.client_type = self.request_type.capitalize() + + self.actions: ActionsStore = None + self.next_name = None + self.intercept = False + + def __getitem__(self, key: str): + return self.session.registered.get(key) + + async def listen( + self, + url: str, + headers: Dict[str, str] = {}, + user: str = None, + tags: List[Dict[str, str]] = [] + ): + + request = WebsocketAction( + self.next_name, + url, + method='GET', + headers=headers, + data=None, + user=user, + tags=tags + ) + + return await self._execute_action(request) + + async def send( + self, + url: str, + headers: Dict[str, str] = {}, + data: Any = None, + user: str = None, + tags: List[Dict[str, str]] = [] + ): + + request = WebsocketAction( + self.next_name, + url, + method='POST', + headers=headers, + data=data, + user=user, + tags=tags + ) + + return await self._execute_action(request) \ No newline at end of file diff --git a/hyperscale/core/engines/client/config.py b/hyperscale/core/engines/client/config.py new file mode 100644 index 0000000..ea5ed11 --- /dev/null +++ b/hyperscale/core/engines/client/config.py @@ -0,0 +1,90 @@ +from typing import Dict, List, Union + +import psutil + +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation + +from .time_parser import TimeParser +from .tracing_config import TracingConfig + + +class Config: + + def __init__(self, **kwargs) -> None: + + for config_option_name, config_option_value in dict(kwargs).items(): + if config_option_value is None: + del kwargs[config_option_name] + + self.total_time_string = kwargs.get('total_time', '1m') + parsed_time = TimeParser(self.total_time_string) + + self.log_level = kwargs.get('log_level', 'info') + self.persona_type = kwargs.get('persona_type', 'default') + self.total_time = parsed_time.time + self.batch_size = kwargs.get('batch_size', 1000) + self.batch_interval = kwargs.get('batch_interval') + self.action_interval = kwargs.get('action_interval', 0) + self.optimize_iterations = kwargs.get('optimize_iterations', 0) + self.optimizer_type = kwargs.get('optimizer_type', 'shg') + self.batch_gradient = kwargs.get('batch_gradient', 0.1) + self.cpus = kwargs.get('cpus', psutil.cpu_count(logical=False)) + self.no_run_visuals = kwargs.get('no_run_visuals', False) + self.connect_timeout = kwargs.get('connect_timeout', 15) + self.request_timeout = kwargs.get('request_timeout', 60) + self.reset_connections = kwargs.get('reset_connections') + self.graceful_stop = kwargs.get('graceful_stop', 1) + self.optimized = False + + if self.request_timeout > self.total_time: + self.request_timeout = self.total_time + + self.browser_type = kwargs.get('browser_type', 'chromium') + self.device_type = kwargs.get('device_type') + self.locale = kwargs.get('locale') + self.geolocation = kwargs.get('geolocation') + self.permissions: List[str] = kwargs.get('permissions', []) + self.color_scheme = kwargs.get('color_scheme') + self.group_size = kwargs.get('group_size') + self.playwright_options = kwargs.get('playwright_options', {}) + self.experiment: Dict[str, Union[str, int, List[float]]] = kwargs.get('experiment', {}) + self.tracing: Union[TracingConfig, None] = kwargs.get('tracing') + self.mutations: Union[List[Mutation], None] = kwargs.get('mutations', []) + self.actions_filepaths: Union[Dict[str, str], None] = kwargs.get('actions_filepaths') + + def copy(self): + + trace = None + if self.tracing: + trace = self.tracing.copy() + + return Config(**{ + 'total_time': self.total_time_string, + 'log_level': self.log_level, + 'persona_type': self.persona_type, + 'batch_size': self.batch_size, + 'batch_interval': self.batch_interval, + 'action_interval': self.action_interval, + 'optimize_iterations': self.optimize_iterations, + 'optimizer_type': self.optimizer_type, + 'batch_gradient': self.batch_gradient, + 'cpus': self.cpus, + 'no_run_visuals': self.no_run_visuals, + 'connect_timeout': self.connect_timeout, + 'request_timeout': self.request_timeout, + 'reset_connections': self.reset_connections, + 'graceful_stop': self.graceful_stop, + 'optimized': self.optimized, + 'browser_type': self.browser_type, + 'device_type': self.device_type, + 'locale': self.locale, + 'geolocation': self.geolocation, + 'permissions': self.permissions, + 'color_scheme': self.color_scheme, + 'group_size': self.group_size, + 'playwright_options': self.playwright_options, + 'experiment': self.experiment, + 'trace': trace, + 'mutations': self.mutations, + 'actions_filepaths': self.actions_filepaths + }) diff --git a/hyperscale/core/engines/client/plugins_store.py b/hyperscale/core/engines/client/plugins_store.py new file mode 100644 index 0000000..2efeb02 --- /dev/null +++ b/hyperscale/core/engines/client/plugins_store.py @@ -0,0 +1,45 @@ +from typing import Dict, Generic, Union + +from typing_extensions import TypeVarTuple, Unpack + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation + +from .store import ActionsStore + +T = TypeVarTuple('T') + + +class PluginsStore(Generic[Unpack[T]]): + + def __init__(self, metadata_string: str): + self._plugins: Dict[str, Union[Unpack[T]]] = {} + self._config: Config = None + self.actions = ActionsStore(metadata_string) + self.next_name: str = None + self.intercept: bool = False + self.metadata_string: str = metadata_string + self.clients = {} + self.mutations: Dict[str, Mutation] = {} + + def __getitem__(self, plugin_name: str) -> Union[Unpack[T]]: + + custom_plugin: Union[Unpack[T]] = self._plugins.get(plugin_name) + + if custom_plugin.initialized is False: + custom_plugin.name = plugin_name + custom_plugin.actions = self.actions + custom_plugin.initialized = True + custom_plugin.mutations.update(self.mutations) + + custom_plugin.metadata_string = self.metadata_string + custom_plugin.next_name = self.next_name + custom_plugin.intercept = self.intercept + + self._plugins[plugin_name] = custom_plugin + self.clients[plugin_name] = custom_plugin + + return custom_plugin + + def __setitem__(self, plugin_name: str, plugin: Union[Unpack[T]]) -> None: + self._plugins[plugin_name] = plugin \ No newline at end of file diff --git a/hyperscale/core/engines/client/store.py b/hyperscale/core/engines/client/store.py new file mode 100644 index 0000000..4df2919 --- /dev/null +++ b/hyperscale/core/engines/client/store.py @@ -0,0 +1,70 @@ +import asyncio +from collections import defaultdict +from typing import Any, Tuple, Union + +from hyperscale.core.engines.types import ( + MercuryGraphQLClient, + MercuryGraphQLHTTP2Client, + MercuryGRPCClient, + MercuryHTTP2Client, + MercuryHTTPClient, + MercuryPlaywrightClient, + MercuryUDPClient, + MercuryWebsocketClient, +) +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.logging import HyperscaleLogger + + +class ActionsStore: + + def __init__(self, metadata_string: str) -> None: + self.metadata_string = metadata_string + self.actions = defaultdict(dict) + self.sessions = defaultdict(dict) + self._loop = None + self.current_stage: str = None + self.waiter = None + self.setup_call = None + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + def set_waiter(self, stage: str): + + if self._loop is None: + self._loop = asyncio.get_event_loop() + + self.waiter = self._loop.create_future() + self.current_stage = stage + + async def wait_for_ready(self, setup_call): + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Action Store waiting for Action or Task to notify store it is ready') + self.setup_call = setup_call + await self.waiter + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Action Store was notified and is exiting suspension') + + def store(self, request: str, action: Any, session: Any): + + self.actions[self.current_stage][request] = action + self.sessions[self.current_stage][request] = session + + try: + self.waiter.set_result(None) + except asyncio.exceptions.CancelledError: + pass + + except asyncio.exceptions.InvalidStateError: + pass + + def get(self, stage: str, action_name: str) -> Tuple[BaseAction, Union[MercuryGraphQLClient, MercuryGraphQLHTTP2Client, MercuryGRPCClient, MercuryHTTP2Client, MercuryHTTPClient, MercuryPlaywrightClient, MercuryWebsocketClient, MercuryUDPClient]]: + action = self.actions.get( + stage + ).get(action_name) + + session = self.sessions.get( + stage + ).get(action_name) + + return action, session diff --git a/hyperscale/core/engines/client/time_parser.py b/hyperscale/core/engines/client/time_parser.py new file mode 100644 index 0000000..27a79c1 --- /dev/null +++ b/hyperscale/core/engines/client/time_parser.py @@ -0,0 +1,25 @@ +import re +from datetime import timedelta + +class TimeParser: + + def __init__(self, time_amount: str) -> None: + self.UNITS = {'s':'seconds', 'm':'minutes', 'h':'hours', 'd':'days', 'w':'weeks'} + self.time = int( + timedelta( + **{ + self.UNITS.get( + m.group( + 'unit' + ).lower(), + 'seconds' + ): float(m.group('val') + ) + for m in re.finditer( + r'(?P\d+(\.\d+)?)(?P[smhdw]?)', + time_amount, + flags=re.I + ) + } + ).total_seconds() + ) diff --git a/hyperscale/core/engines/client/tracing_config.py b/hyperscale/core/engines/client/tracing_config.py new file mode 100644 index 0000000..e3c326c --- /dev/null +++ b/hyperscale/core/engines/client/tracing_config.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Dict, Optional, Union + +from hyperscale.core.engines.types.tracing.tracing_types import ( + RequestHook, + ResponseHook, + TraceSignal, + UrlFilter, +) +from hyperscale.core.engines.types.tracing.url_filters import ( + default_params_strip_filter, +) + +OpenTelemetryTracingConfig = Union[ + UrlFilter, + RequestHook, + ResponseHook, + TraceSignal +] + + +class TracingConfig: + + def __init__( + self, + url_filter: Optional[UrlFilter]=None, + request_hook: Optional[RequestHook]=None, + response_hook: Optional[ResponseHook]=None, + on_request_headers_sent: Optional[TraceSignal]=None, + on_request_data_sent: Optional[TraceSignal]=None, + on_request_chunk_sent: Optional[TraceSignal]=None, + on_response_headers_received: Optional[TraceSignal]=None, + on_response_data_received: Optional[TraceSignal]=None, + on_response_chunk_received: Optional[TraceSignal]=None, + on_request_redirect: Optional[TraceSignal]=None, + on_connection_queued_start: Optional[TraceSignal]=None, + on_connection_queued_end: Optional[TraceSignal]=None, + on_connection_create_start: Optional[TraceSignal]=None, + on_connection_create_end: Optional[TraceSignal]=None, + on_connection_reuse_connection: Optional[TraceSignal]=None, + on_dns_resolve_host_start: Optional[TraceSignal]=None, + on_dns_resolve_host_end: Optional[TraceSignal]=None, + on_dns_cache_hit: Optional[TraceSignal]=None, + on_dns_cache_miss: Optional[TraceSignal]=None, + on_task_start: Optional[TraceSignal]=None, + on_task_end: Optional[TraceSignal]=None, + on_task_error: Optional[TraceSignal]=None + ) -> None: + + if url_filter is None: + url_filter = default_params_strip_filter + + self.url_filter = url_filter + self.request_hook = request_hook + self.response_hook = response_hook + + self.on_request_headers_sent: TraceSignal = on_request_headers_sent + self.on_request_data_sent: TraceSignal = on_request_data_sent + self.on_request_chunk_sent: TraceSignal = on_request_chunk_sent + self.on_request_redirect: TraceSignal = on_request_redirect + self.on_response_headers_received = on_response_headers_received + self.on_response_data_received = on_response_data_received + self.on_response_chunk_received: TraceSignal = on_response_chunk_received + + self.on_connection_queued_start: TraceSignal = on_connection_queued_start + self.on_connection_queued_end: TraceSignal = on_connection_queued_end + self.on_connection_create_start: TraceSignal = on_connection_create_start + self.on_connection_create_end: TraceSignal = on_connection_create_end + self.on_connection_reuse_connection: TraceSignal = on_connection_reuse_connection + + self.on_dns_resolve_host_start: TraceSignal = on_dns_resolve_host_start + self.on_dns_resolve_host_end: TraceSignal = on_dns_resolve_host_end + self.on_dns_cache_hit: TraceSignal = on_dns_cache_hit + self.on_dns_cache_miss: TraceSignal = on_dns_cache_miss + + self.on_task_start = on_task_start + self.on_task_end = on_task_end + self.on_task_error = on_task_error + + def copy(self) -> TracingConfig: + return TracingConfig( + url_filter=self.url_filter, + request_hook=self.request_hook, + response_hook=self.response_hook, + on_request_headers_sent=self.on_request_chunk_sent, + on_request_data_sent=self.on_request_data_sent, + on_request_chunk_sent=self.on_request_chunk_sent, + on_request_redirect=self.on_request_redirect, + on_response_headers_received=self.on_response_headers_received, + on_response_data_received=self.on_response_data_received, + on_response_chunk_received=self.on_response_chunk_received, + on_connection_queued_start=self.on_connection_queued_start, + on_connection_queued_end=self.on_connection_queued_end, + on_connection_create_start=self.on_connection_create_start, + on_connection_create_end=self.on_connection_create_end, + on_connection_reuse_connection=self.on_connection_reuse_connection, + on_dns_resolve_host_start=self.on_dns_resolve_host_start, + on_dns_resolve_host_end=self.on_dns_resolve_host_end, + on_dns_cache_hit=self.on_dns_cache_hit, + on_dns_cache_miss=self.on_dns_cache_miss, + on_task_start=self.on_task_start, + on_task_end=self.on_task_end, + on_task_error=self.on_task_error, + ) + + def to_dict(self) -> Dict[str, OpenTelemetryTracingConfig]: + return { + 'url_filter': self.url_filter, + 'request_hook': self.request_hook, + 'response_hook': self.response_hook, + 'on_request_headers_sent': self.on_request_chunk_sent, + 'on_request_data_sent': self.on_request_data_sent, + 'on_request_chunk_sent': self.on_request_chunk_sent, + 'on_request_redirect': self.on_request_redirect, + 'on_response_headers_received': self.on_response_headers_received, + 'on_response_data_received': self.on_response_data_received, + 'on_response_chunk_received': self.on_response_chunk_received, + 'on_connection_queued_start': self.on_connection_queued_start, + 'on_connection_queued_end': self.on_connection_queued_end, + 'on_connection_create_start': self.on_connection_create_start, + 'on_connection_create_end': self.on_connection_create_end, + 'on_connection_reuse_connection': self.on_connection_reuse_connection, + 'on_dns_resolve_host_start': self.on_dns_resolve_host_start, + 'on_dns_resolve_host_end': self.on_dns_resolve_host_end, + 'on_dns_cache_hit': self.on_dns_cache_hit, + 'on_dns_cache_miss': self.on_dns_cache_miss, + 'on_task_start': self.on_task_start, + 'on_task_end': self.on_task_end, + 'on_task_error': self.on_task_error, + } diff --git a/hyperscale/core/engines/types/__init__.py b/hyperscale/core/engines/types/__init__.py new file mode 100644 index 0000000..6b0d7d6 --- /dev/null +++ b/hyperscale/core/engines/types/__init__.py @@ -0,0 +1,10 @@ +from .graphql import MercuryGraphQLClient +from .grpc import MercuryGRPCClient +from .graphql_http2 import MercuryGraphQLHTTP2Client +from .http import MercuryHTTPClient +from .http2 import MercuryHTTP2Client +from .http3 import MercuryHTTP3Client +from .playwright import MercuryPlaywrightClient +from .websocket import MercuryWebsocketClient +from .udp import MercuryUDPClient +from .task import MercuryTaskRunner \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/__init__.py b/hyperscale/core/engines/types/common/__init__.py new file mode 100644 index 0000000..ad0759b --- /dev/null +++ b/hyperscale/core/engines/types/common/__init__.py @@ -0,0 +1,3 @@ +from .metadata import Metadata +from .url import URL +from .timeouts import Timeouts \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/action_registry.py b/hyperscale/core/engines/types/common/action_registry.py new file mode 100644 index 0000000..8cd2aa3 --- /dev/null +++ b/hyperscale/core/engines/types/common/action_registry.py @@ -0,0 +1,60 @@ +from typing import Dict, Iterable, List, Union + +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.engines.types.playwright.command import PlaywrightCommand +from hyperscale.core.engines.types.task.task import Task +from hyperscale.core.engines.types.udp.action import UDPAction +from hyperscale.core.engines.types.websocket.action import WebsocketAction + +Action = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action, + PlaywrightCommand, + Task, + UDPAction, + WebsocketAction +] + + +class ActionRegistry: + + def __init__(self) -> None: + self._actions: Dict[str, Action] = {} + + def __iter__(self) -> Iterable[Action]: + for action in self._actions.values(): + yield action + + def __setitem__(self, name: str, value: Action): + self._actions[name] = value + + def actions(self) -> List[Action]: + return list(self._actions.values()) + + def names(self): + for action_name in self._actions.keys(): + yield action_name + + def get_action(self, action_name: str) -> Union[Action, None]: + return self._actions.get(action_name) + + def get_stage_actions(self, stage_name: str) -> List[Action]: + return [ + action for action in self._actions.values() if action.stage == stage_name + ] + + +def make_action_registry(): + return ActionRegistry() + + +actions_registry = make_action_registry() \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/base_action.py b/hyperscale/core/engines/types/common/base_action.py new file mode 100644 index 0000000..1da85a9 --- /dev/null +++ b/hyperscale/core/engines/types/common/base_action.py @@ -0,0 +1,43 @@ +import uuid +from typing import ( + Any, + Dict, + List, + TypeVar, + Generic +) +from .metadata import Metadata +from .hooks import Hooks +from .types import ProtocolMap + + +A = TypeVar('A') + + +class BaseAction(Generic[A]): + + __slots__ = ( + 'action_id' + 'protocols', + 'is_setup', + 'metadata', + 'hooks', + 'event', + 'action_args', + 'mutations' + ) + + def __init__( + self, + name: str=None, + user: str=None, + tags: List[Dict[str, str]] = [] + ) -> None: + self.name = name + self.action_id = str(uuid.uuid4()) + self.protocols = ProtocolMap() + self.is_setup = False + self.metadata = Metadata(user, tags) + self.hooks: Hooks[BaseAction] = Hooks() + self.event = None + self.action_args: Dict[str, Any] = {} \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/base_engine.py b/hyperscale/core/engines/types/common/base_engine.py new file mode 100644 index 0000000..44146bc --- /dev/null +++ b/hyperscale/core/engines/types/common/base_engine.py @@ -0,0 +1,116 @@ +import asyncio +from typing import ( + TypeVar, + List, + Dict, + Any, + Generic, + Coroutine +) + + +A = TypeVar('A') +R = TypeVar('R') + + +class BaseEngine(Generic[A, R]): + + __slots__ = ( + 'waiter' + ) + + def __init__(self) -> None: + super().__init__() + + self.waiter: asyncio.Future = None + + def config_to_dict(self): + raise NotImplementedError('Cannot call config_to_dict() on base Engine class.') + + async def wait_for_active_threshold(self): + if self.waiter is None: + self.waiter = asyncio.get_event_loop().create_future() + await self.waiter + + async def execute_before(self, action: A) -> Coroutine[Any, Any, A]: + action.action_args = { + 'action': action + } + + for before_batch in action.hooks.before: + results: List[Dict[str, Any]] = await asyncio.gather(*[ + before.call(**{ + name: value for name, value in action.action_args.items() if name in before.params + }) for before in before_batch + ]) + + for before_event, result in zip(before_batch, results): + for data in result.values(): + if isinstance(result, dict): + action.action_args.update(data) + + else: + action.action_args.update({ + before_event.shortname: data + }) + + return action + + async def execute_after(self, action: A, response: R) -> Coroutine[Any, Any, R]: + action.action_args['action'] = action + action.action_args['result'] = response + + if action.hooks.notify: + action.action_args.update({ + name: action_or_task.action for name, action_or_task in action.hooks.listeners.items() + }) + + for after_batch in action.hooks.after: + results: List[Dict[str, Any]] = await asyncio.gather(*[ + after.call(**{ + name: value for name, value in action.action_args.items() if name in after.params + }) for after in after_batch + ]) + + for after_event, result in zip(after_batch, results): + for data in result.values(): + if isinstance(result, dict): + action.action_args.update(data) + + else: + action.action_args.update({ + after_event.shortname: data + }) + + return response + + async def execute_checks(self, action: A, response: R) -> Coroutine[Any, Any, R]: + action.action_args['action'] = action + action.action_args['result'] = response + + if response.error: + return response + + if action.hooks.notify: + action.action_args.update({ + name: action_or_task.action for name, action_or_task in action.hooks.listeners.items() + }) + + for check_batch in action.hooks.checks: + results: List[Dict[str, Any]] = await asyncio.gather(*[ + check.call(**{ + name: value for name, value in action.action_args.items() if name in check.params + }) for check in check_batch + ]) + + for check_event, result in zip(check_batch, results): + for data in result.values(): + if isinstance(result, dict): + action.action_args.update(data) + + else: + action.action_args.update({ + check_event.shortname: data + }) + + return action.action_args.get('result') \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/base_result.py b/hyperscale/core/engines/types/common/base_result.py new file mode 100644 index 0000000..b51aba0 --- /dev/null +++ b/hyperscale/core/engines/types/common/base_result.py @@ -0,0 +1,47 @@ +from typing import Coroutine, Dict, List, Union +from .types import RequestTypes + + +class BaseResult: + + __slots__ = ( + 'action_id', + 'name', + 'checks', + 'error', + 'source', + 'user', + 'tags', + 'type', + 'time', + 'wait_start', + 'start', + 'connect_end', + 'write_end', + 'complete' + ) + + def __init__( + self, + action_id: str, + name: str, + source: str, + user: str, + tags: List[Dict[str, str]], + type: Union[RequestTypes, str], + error: Exception + ) -> None: + self.action_id = action_id + self.name = name + self.error = error + self.source = source + self.user = user + self.tags = tags + self.type = type + + self.time = 0 + self.wait_start = 0 + self.start = 0 + self.connect_end = 0 + self.write_end = 0 + self.complete = 0 diff --git a/hyperscale/core/engines/types/common/concurrency/__init__.py b/hyperscale/core/engines/types/common/concurrency/__init__.py new file mode 100644 index 0000000..224baa3 --- /dev/null +++ b/hyperscale/core/engines/types/common/concurrency/__init__.py @@ -0,0 +1,3 @@ +from .balancing_semaphore import BalancingSemaphore +from .noop_semaphore import NoOpSemaphore +from .semaphore import Semaphore \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/concurrency/balancing_semaphore.py b/hyperscale/core/engines/types/common/concurrency/balancing_semaphore.py new file mode 100644 index 0000000..a30f811 --- /dev/null +++ b/hyperscale/core/engines/types/common/concurrency/balancing_semaphore.py @@ -0,0 +1,112 @@ +"""Synchronization primitives.""" + +__all__ = ('Lock', 'Event', 'Condition', 'Semaphore', + 'BoundedSemaphore', 'Barrier') + +import collections +from asyncio import exceptions +from asyncio import mixins + +class _ContextManagerMixin: + async def __aenter__(self): + await self.acquire() + # We have no use for the "as ..." clause in the with + # statement for locks. + return None + + async def __aexit__(self, exc_type, exc, tb): + self.release() + +class BalancingSemaphore(_ContextManagerMixin, mixins._LoopBoundMixin): + + __slots__ = ( + '_value', + '_slots', + '_waiters', + '_busy', + '_wakeup_scheduled' + ) + + """A Semaphore implementation. + A semaphore manages an internal counter which is decremented by each + acquire() call and incremented by each release() call. The counter + can never go below zero; when acquire() finds that it is zero, it blocks, + waiting until some other thread calls release(). + Semaphores also support the context management protocol. + The optional argument gives the initial value for the internal + counter; it defaults to 1. If the value given is less than 0, + ValueError is raised. + """ + + def __init__(self, value=1): + if value < 0: + raise ValueError("Semaphore initial value must be >= 0") + self._value = value + self._slots = [idx for idx in range(value)] + self._waiters = tuple([collections.deque() for _ in self._slots]) + self._busy = bytearray([0 for _ in self._slots]) + self._wakeup_scheduled = False + + def __repr__(self): + res = super().__repr__() + extra = 'locked' if self.locked() else f'unlocked, value:{self._value}' + if self._waiters: + extra = f'{extra}, waiters:{len(self._waiters)}' + return f'<{res[1:-1]} [{extra}]>' + + def _wake_up_next(self, min_idx: int): + while self._waiters[min_idx]: + waiter = self._waiters[min_idx].popleft() + if not waiter.done(): + waiter.set_result(None) + self._wakeup_scheduled = True + return + + def locked(self): + """Returns True if semaphore can not be acquired immediately.""" + return self._value == 0 + + async def acquire(self): + """Acquire a semaphore. + If the internal counter is larger than zero on entry, + decrement it by one and return True immediately. If it is + zero on entry, block, waiting until some other coroutine has + called release() to make it larger than 0, and then return + True. + """ + # _wakeup_scheduled is set if *another* task is scheduled to wakeup + # but its acquire() is not resumed yet + + # Find the line with the smalles number of futures waiting. Assign + # to that queue + min_queue = min(self._busy) + min_idx = self._busy.index(min_queue) + + # while the line of the minimum is not empty. + while self._wakeup_scheduled or self._busy[min_idx] > 0: + fut = self._get_loop().create_future() + self._waiters[min_idx].append(fut) + + try: + await fut + + self._wakeup_scheduled = False + except exceptions.CancelledError: + self._wake_up_next(min_idx) + raise + + # A line now has waiters. + self._busy[min_idx] += 1 + + return True + + def release(self): + """Release a semaphore, incrementing the internal counter by one. + When it was zero on entry and another coroutine is waiting for it to + become larger than zero again, wake up that coroutine. + """ + max_queue = max(self._busy) + max_idx = self._busy.index(max_queue) + + self._busy[max_idx] -= 1 + self._wake_up_next(max_idx) \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/concurrency/noop_semaphore.py b/hyperscale/core/engines/types/common/concurrency/noop_semaphore.py new file mode 100644 index 0000000..7eea55e --- /dev/null +++ b/hyperscale/core/engines/types/common/concurrency/noop_semaphore.py @@ -0,0 +1,32 @@ +"""Synchronization primitives.""" + +__all__ = ('Lock', 'Event', 'Condition', 'Semaphore', + 'BoundedSemaphore', 'Barrier') + +import collections +from asyncio import exceptions +from asyncio import mixins + +class _ContextManagerMixin: + async def __aenter__(self): + # We have no use for the "as ..." clause in the with + # statement for locks. + return None + + async def __aexit__(self, exc_type, exc, tb): + pass + +class NoOpSemaphore(_ContextManagerMixin, mixins._LoopBoundMixin): + """A Semaphore implementation. + A semaphore manages an internal counter which is decremented by each + acquire() call and incremented by each release() call. The counter + can never go below zero; when acquire() finds that it is zero, it blocks, + waiting until some other thread calls release(). + Semaphores also support the context management protocol. + The optional argument gives the initial value for the internal + counter; it defaults to 1. If the value given is less than 0, + ValueError is raised. + """ + + def __init__(self): + pass \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/concurrency/semaphore.py b/hyperscale/core/engines/types/common/concurrency/semaphore.py new file mode 100644 index 0000000..59e4bc5 --- /dev/null +++ b/hyperscale/core/engines/types/common/concurrency/semaphore.py @@ -0,0 +1,95 @@ +import collections +from asyncio import exceptions +from asyncio import mixins + + +class _ContextManagerMixin: + async def __aenter__(self): + await self.acquire() + # We have no use for the "as ..." clause in the with + # statement for locks. + return None + + async def __aexit__(self, exc_type, exc, tb): + self.release() + + +class Semaphore(_ContextManagerMixin, mixins._LoopBoundMixin): + """A Semaphore implementation. + + A semaphore manages an internal counter which is decremented by each + acquire() call and incremented by each release() call. The counter + can never go below zero; when acquire() finds that it is zero, it blocks, + waiting until some other thread calls release(). + + Semaphores also support the context management protocol. + + The optional argument gives the initial value for the internal + counter; it defaults to 1. If the value given is less than 0, + ValueError is raised. + """ + + __slots__ = ( + '_value', + '_waiters', + '_wakeup_scheduled' + ) + + def __init__(self, value=1, *, loop=None): + super().__init__() + if value < 0: + raise ValueError("Semaphore initial value must be >= 0") + self._value = value + self._waiters = collections.deque() + self._wakeup_scheduled = False + + def __repr__(self): + res = super().__repr__() + extra = 'locked' if self.locked() else f'unlocked, value:{self._value}' + if self._waiters: + extra = f'{extra}, waiters:{len(self._waiters)}' + return f'<{res[1:-1]} [{extra}]>' + + def _wake_up_next(self): + while self._waiters: + waiter = self._waiters.popleft() + if not waiter.done(): + waiter.set_result(None) + self._wakeup_scheduled = True + return + + def locked(self): + """Returns True if semaphore can not be acquired immediately.""" + return self._value == 0 + + async def acquire(self): + """Acquire a semaphore. + + If the internal counter is larger than zero on entry, + decrement it by one and return True immediately. If it is + zero on entry, block, waiting until some other coroutine has + called release() to make it larger than 0, and then return + True. + """ + # _wakeup_scheduled is set if *another* task is scheduled to wakeup + # but its acquire() is not resumed yet + while self._wakeup_scheduled or self._value <= 0: + fut = self._get_loop().create_future() + self._waiters.append(fut) + try: + await fut + # reset _wakeup_scheduled *after* waiting for a future + self._wakeup_scheduled = False + except exceptions.CancelledError: + self._wake_up_next() + raise + self._value -= 1 + return True + + def release(self): + """Release a semaphore, incrementing the internal counter by one. + When it was zero on entry and another coroutine is waiting for it to + become larger than zero again, wake up that coroutine. + """ + self._value += 1 + self._wake_up_next() \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/constants.py b/hyperscale/core/engines/types/common/constants.py new file mode 100644 index 0000000..29341a4 --- /dev/null +++ b/hyperscale/core/engines/types/common/constants.py @@ -0,0 +1,7 @@ +NEW_LINE = '\r\n' +WEBSOCKETS_VERSION = 13 +HEADER_LENGTH_INDEX = 6 + +READ_NUM_BYTES = 64 * 1024 +MAX_WINDOW_SIZE = (2**24) - 1 +NEXT_WINDOW_SIZE = 2**24 \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/decoder.py b/hyperscale/core/engines/types/common/decoder.py new file mode 100644 index 0000000..aa2b6dd --- /dev/null +++ b/hyperscale/core/engines/types/common/decoder.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +""" +hpack/hpack +~~~~~~~~~~~ + +Implements the HPACK header compression algorithm as detailed by the IETF. +""" +from functools import lru_cache +from .hpack.table import HeaderTable, table_entry_size +from .hpack.exceptions import ( + HPACKDecodingError, OversizedHeaderListError, InvalidTableSizeError +) + +from .hpack.huffman_table import decode_huffman + +INDEX_NONE = b'\x00' +INDEX_NEVER = b'\x10' +INDEX_INCREMENTAL = b'\x40' + +# Precompute 2^i for 1-8 for use in prefix calcs. +# Zero index is not used but there to save a subtraction +# as prefix numbers are not zero indexed. +_PREFIX_BIT_MAX_NUMBERS = [(2 ** i) - 1 for i in range(9)] + +basestring = (str, bytes) + + +# We default the maximum header list we're willing to accept to 64kB. That's a +# lot of headers, but if applications want to raise it they can do. +DEFAULT_MAX_HEADER_LIST_SIZE = 2 ** 16 + +@lru_cache(maxsize=4096) +def decode_integer(data, prefix_bits): + """ + This decodes an integer according to the wacky integer encoding rules + defined in the HPACK spec. Returns a tuple of the decoded integer and the + number of bytes that were consumed from ``data`` in order to get that + integer. + """ + + max_number = _PREFIX_BIT_MAX_NUMBERS[prefix_bits] + index = 1 + shift = 0 + mask = (0xFF >> (8 - prefix_bits)) + + try: + number = data[0] & mask + if number == max_number: + while True: + next_byte = data[index] + index += 1 + + if next_byte >= 128: + number += (next_byte - 128) << shift + else: + number += next_byte << shift + break + shift += 7 + + except IndexError: + raise HPACKDecodingError( + "Unable to decode HPACK integer representation from %r" % data + ) + + return (number, index) + + +class Decoder: + """ + An HPACK decoder object. + + .. versionchanged:: 2.3.0 + Added ``max_header_list_size`` argument. + + :param max_header_list_size: The maximum decompressed size we will allow + for any single header block. This is a protection against DoS attacks + that attempt to force the application to expand a relatively small + amount of data into a really large header list, allowing enormous + amounts of memory to be allocated. + + If this amount of data is exceeded, a `OversizedHeaderListError + ` exception will be raised. At this + point the connection should be shut down, as the HPACK state will no + longer be usable. + + Defaults to 64kB. + :type max_header_list_size: ``int`` + """ + + __slots__ = ( + 'header_table', + 'max_header_list_size', + 'max_allowed_table_size' + ) + + def __init__(self, max_header_list_size=DEFAULT_MAX_HEADER_LIST_SIZE): + self.header_table: HeaderTable = None + + #: The maximum decompressed size we will allow for any single header + #: block. This is a protection against DoS attacks that attempt to + #: force the application to expand a relatively small amount of data + #: into a really large header list, allowing enormous amounts of memory + #: to be allocated. + #: + #: If this amount of data is exceeded, a `OversizedHeaderListError + #: ` exception will be raised. At this + #: point the connection should be shut down, as the HPACK state will no + #: longer be usable. + #: + #: Defaults to 64kB. + #: + #: .. versionadded:: 2.3.0 + self.max_header_list_size = max_header_list_size + + #: Maximum allowed header table size. + #: + #: A HTTP/2 implementation should set this to the most recent value of + #: SETTINGS_HEADER_TABLE_SIZE that it sent *and has received an ACK + #: for*. Once this setting is set, the actual header table size will be + #: checked at the end of each decoding run and whenever it is changed, + #: to confirm that it fits in this size. + self.max_allowed_table_size = 0 + + @property + def header_table_size(self): + """ + Controls the size of the HPACK header table. + """ + return self.header_table.maxsize + + @header_table_size.setter + def header_table_size(self, value): + self.header_table.maxsize = value + + def decode(self, data, raw=False): + """ + Takes an HPACK-encoded header block and decodes it into a header set. + + :param data: A bytestring representing a complete HPACK-encoded header + block. + :param raw: (optional) Whether to return the headers as tuples of raw + byte strings or to decode them as UTF-8 before returning + them. The default value is False, which returns tuples of + Unicode strings + :returns: A list of two-tuples of ``(name, value)`` representing the + HPACK-encoded headers, in the order they were decoded. + :raises HPACKDecodingError: If an error is encountered while decoding + the header block. + """ + + data_mem = memoryview(data) + headers = [] + data_len = len(data) + current_index = 0 + + while current_index < data_len: + # Work out what kind of header we're decoding. + # If the high bit is 1, it's an indexed field. + current = data[current_index] + indexed = True if current & 0x80 else False + + # Otherwise, if the second-highest bit is 1 it's a field that does + # alter the header table. + literal_index = True if current & 0x40 else False + + # Otherwise, if the third-highest bit is 1 it's an encoding context + # update. + encoding_update = True if current & 0x20 else False + + if indexed: + index, consumed = decode_integer(data_mem[current_index:], 7) + header = self.header_table.get_by_index(index) + + elif literal_index: + # It's a literal header that does affect the header table. + header, consumed = self._decode_literal( + data_mem[current_index:], + True + ) + elif encoding_update: + + new_size, consumed = decode_integer( + data_mem[current_index:], + 5 + ) + + self.header_table_size = new_size + + header = None + else: + # It's a literal header that does not affect the header table. + header, consumed = self._decode_literal( + data_mem[current_index:], + False + ) + + if header: + headers.append(header) + + current_index += consumed + + return headers + + def _decode_literal(self, data, should_index): + """ + Decodes a header represented with a literal. + """ + total_consumed = 0 + + # When should_index is true, if the low six bits of the first byte are + # nonzero, the header name is indexed. + # When should_index is false, if the low four bits of the first byte + # are nonzero the header name is indexed. + if should_index: + indexed_name = data[0] & 0x3F + name_len = 6 + + else: + high_byte = data[0] + indexed_name = high_byte & 0x0F + name_len = 4 + + if indexed_name: + # Indexed header name. + index, consumed = decode_integer(data, name_len) + name = self.header_table.get_by_index(index)[0] + + total_consumed = consumed + length = 0 + else: + # Literal header name. The first byte was consumed, so we need to + # move forward. + data = data[1:] + + length, consumed = decode_integer(data, 7) + name = data[consumed:consumed + length] + + if data[0] & 0x80: + name = decode_huffman(name) + total_consumed = consumed + length + 1 # Since we moved forward 1. + + data = data[consumed + length:] + + # The header value is definitely length-based. + length, consumed = decode_integer(data, 7) + value = data[consumed:consumed + length] + + if data[0] & 0x80: + value = decode_huffman(value) + + # Updated the total consumed length. + total_consumed += length + consumed + + header = (name, value) + + # If we've been asked to index this, add it to the header table. + if should_index: + self.header_table.add(name, value) + + return (header, total_consumed) diff --git a/hyperscale/core/engines/types/common/encoder.py b/hyperscale/core/engines/types/common/encoder.py new file mode 100644 index 0000000..9bf166f --- /dev/null +++ b/hyperscale/core/engines/types/common/encoder.py @@ -0,0 +1,603 @@ +# -*- coding: utf-8 -*- +""" +hpack/hpack +~~~~~~~~~~~ + +Implements the HPACK header compression algorithm as detailed by the IETF. +""" + +from .hpack.table import HeaderTable, table_entry_size +from .hpack.exceptions import ( + HPACKDecodingError, OversizedHeaderListError, InvalidTableSizeError +) +from .hpack.huffman_encoder import HuffmanEncoder +from .hpack.constants import ( + REQUEST_CODES, REQUEST_CODES_LENGTH +) +from .hpack.huffman_table import decode_huffman +from .hpack.structs import HeaderTuple, NeverIndexedHeaderTuple + + +INDEX_NONE = b'\x00' +INDEX_NEVER = b'\x10' +INDEX_INCREMENTAL = b'\x40' + +# Precompute 2^i for 1-8 for use in prefix calcs. +# Zero index is not used but there to save a subtraction +# as prefix numbers are not zero indexed. +_PREFIX_BIT_MAX_NUMBERS = [(2 ** i) - 1 for i in range(9)] + +basestring = (str, bytes) + + +# We default the maximum header list we're willing to accept to 64kB. That's a +# lot of headers, but if applications want to raise it they can do. +DEFAULT_MAX_HEADER_LIST_SIZE = 2 ** 16 + + +def _unicode_if_needed(header, raw): + """ + Provides a header as a unicode string if raw is False, otherwise returns + it as a bytestring. + """ + name = bytes(header[0]) + value = bytes(header[1]) + if not raw: + name = name.decode('utf-8') + value = value.decode('utf-8') + return header.__class__(name, value) + + +def encode_integer(integer, prefix_bits): + """ + This encodes an integer according to the wacky integer encoding rules + defined in the HPACK spec. + """ + + if integer < 0: + raise ValueError( + "Can only encode positive integers, got %s" % integer + ) + + if prefix_bits < 1 or prefix_bits > 8: + raise ValueError( + "Prefix bits must be between 1 and 8, got %s" % prefix_bits + ) + + max_number = _PREFIX_BIT_MAX_NUMBERS[prefix_bits] + + if integer < max_number: + return bytearray([integer]) # Seriously? + else: + elements = [max_number] + integer -= max_number + + while integer >= 128: + elements.append((integer & 127) + 128) + integer >>= 7 + + elements.append(integer) + + return bytearray(elements) + + +def decode_integer(data, prefix_bits): + """ + This decodes an integer according to the wacky integer encoding rules + defined in the HPACK spec. Returns a tuple of the decoded integer and the + number of bytes that were consumed from ``data`` in order to get that + integer. + """ + if prefix_bits < 1 or prefix_bits > 8: + raise ValueError( + "Prefix bits must be between 1 and 8, got %s" % prefix_bits + ) + + max_number = _PREFIX_BIT_MAX_NUMBERS[prefix_bits] + index = 1 + shift = 0 + mask = (0xFF >> (8 - prefix_bits)) + + try: + number = data[0] & mask + if number == max_number: + while True: + next_byte = data[index] + index += 1 + + if next_byte >= 128: + number += (next_byte - 128) << shift + else: + number += next_byte << shift + break + shift += 7 + + except IndexError: + raise HPACKDecodingError( + "Unable to decode HPACK integer representation from %r" % data + ) + + return number, index + + + +def _to_bytes(string): + """ + Convert string to bytes. + """ + if not isinstance(string, basestring): # pragma: no cover + string = str(string) + + return string if isinstance(string, bytes) else string.encode('utf-8') + + +class Encoder: + """ + An HPACK encoder object. This object takes HTTP headers and emits encoded + HTTP/2 header blocks. + """ + + __slots__ = ( + 'header_table', + 'huffman_coder', + 'table_size_changes' + ) + + def __init__(self): + self.header_table = HeaderTable() + self.huffman_coder = HuffmanEncoder( + REQUEST_CODES, REQUEST_CODES_LENGTH + ) + self.table_size_changes = [] + + @property + def header_table_size(self): + """ + Controls the size of the HPACK header table. + """ + return self.header_table.maxsize + + @header_table_size.setter + def header_table_size(self, value): + self.header_table.maxsize = value + if self.header_table.resized: + self.table_size_changes.append(value) + + def encode(self, headers, huffman=True): + """ + Takes a set of headers and encodes them into a HPACK-encoded header + block. + + :param headers: The headers to encode. Must be either an iterable of + tuples, an iterable of :class:`HeaderTuple + `, or a ``dict``. + + If an iterable of tuples, the tuples may be either + two-tuples or three-tuples. If they are two-tuples, the + tuples must be of the format ``(name, value)``. If they + are three-tuples, they must be of the format + ``(name, value, sensitive)``, where ``sensitive`` is a + boolean value indicating whether the header should be + added to header tables anywhere. If not present, + ``sensitive`` defaults to ``False``. + + If an iterable of :class:`HeaderTuple + `, the tuples must always be + two-tuples. Instead of using ``sensitive`` as a third + tuple entry, use :class:`NeverIndexedHeaderTuple + ` to request that + the field never be indexed. + + .. warning:: HTTP/2 requires that all special headers + (headers whose names begin with ``:`` characters) + appear at the *start* of the header block. While + this method will ensure that happens for ``dict`` + subclasses, callers using any other iterable of + tuples **must** ensure they place their special + headers at the start of the iterable. + + For efficiency reasons users should prefer to use + iterables of two-tuples: fixing the ordering of + dictionary headers is an expensive operation that + should be avoided if possible. + + :param huffman: (optional) Whether to Huffman-encode any header sent as + a literal value. Except for use when debugging, it is + recommended that this be left enabled. + + :returns: A bytestring containing the HPACK-encoded header block. + """ + # Transforming the headers into a header block is a procedure that can + # be modeled as a chain or pipe. First, the headers are encoded. This + # encoding can be done a number of ways. If the header name-value pair + # are already in the header table we can represent them using the + # indexed representation: the same is true if they are in the static + # table. Otherwise, a literal representation will be used. + header_block = [] + + # Turn the headers into a list of tuples if possible. This is the + # natural way to interact with them in HPACK. Because dictionaries are + # un-ordered, we need to make sure we grab the "special" headers first. + # if isinstance(headers, dict): + # headers = _dict_to_iterable(headers) + + # Before we begin, if the header table size has been changed we need + # to signal all changes since last emission appropriately. + if self.header_table.resized: + header_block.append(self._encode_table_size_change()) + self.header_table.resized = False + + # Add each header to the header block + for header in headers: + + header_block.append( + self.add( + ( + _to_bytes(header[0]), + _to_bytes(header[1]) + ), + False, + huffman + ) + ) + + header_block = b''.join(header_block) + + return header_block + + def add(self, to_add, sensitive, huffman=False): + """ + This function takes a header key-value tuple and serializes it. + """ + + name, value = to_add + + # Set our indexing mode + indexbit = INDEX_INCREMENTAL if not sensitive else INDEX_NEVER + + # Search for a matching header in the header table. + match = self.header_table.search(name, value) + + if match is None: + # Not in the header table. Encode using the literal syntax, + # and add it to the header table. + encoded = self._encode_literal(name, value, indexbit, huffman) + if not sensitive: + self.header_table.add(name, value) + return encoded + + # The header is in the table, break out the values. If we matched + # perfectly, we can use the indexed representation: otherwise we + # can use the indexed literal. + index, name, perfect = match + + if perfect: + # Indexed representation. + encoded = self._encode_indexed(index) + else: + # Indexed literal. We are going to add header to the + # header table unconditionally. It is a future todo to + # filter out headers which are known to be ineffective for + # indexing since they just take space in the table and + # pushed out other valuable headers. + encoded = self._encode_indexed_literal( + index, value, indexbit, huffman + ) + if not sensitive: + self.header_table.add(name, value) + + return encoded + + def _encode_indexed(self, index): + """ + Encodes a header using the indexed representation. + """ + field = encode_integer(index, 7) + field[0] |= 0x80 # we set the top bit + return bytes(field) + + def _encode_literal(self, name, value, indexbit, huffman=False): + """ + Encodes a header with a literal name and literal value. If ``indexing`` + is True, the header will be added to the header table: otherwise it + will not. + """ + if huffman: + name = self.huffman_coder.encode(name) + value = self.huffman_coder.encode(value) + + name_len = encode_integer(len(name), 7) + value_len = encode_integer(len(value), 7) + + if huffman: + name_len[0] |= 0x80 + value_len[0] |= 0x80 + + return b''.join( + [indexbit, bytes(name_len), name, bytes(value_len), value] + ) + + def _encode_indexed_literal(self, index, value, indexbit, huffman=False): + """ + Encodes a header with an indexed name and a literal value and performs + incremental indexing. + """ + if indexbit != INDEX_INCREMENTAL: + prefix = encode_integer(index, 4) + else: + prefix = encode_integer(index, 6) + + prefix[0] |= ord(indexbit) + + if huffman: + value = self.huffman_coder.encode(value) + + value_len = encode_integer(len(value), 7) + + if huffman: + value_len[0] |= 0x80 + + return b''.join([bytes(prefix), bytes(value_len), value]) + + def _encode_table_size_change(self): + """ + Produces the encoded form of all header table size change context + updates. + """ + block = b'' + for size_bytes in self.table_size_changes: + size_bytes = encode_integer(size_bytes, 5) + size_bytes[0] |= 0x20 + block += bytes(size_bytes) + self.table_size_changes = [] + return block + + +class Decoder: + """ + An HPACK decoder object. + + .. versionchanged:: 2.3.0 + Added ``max_header_list_size`` argument. + + :param max_header_list_size: The maximum decompressed size we will allow + for any single header block. This is a protection against DoS attacks + that attempt to force the application to expand a relatively small + amount of data into a really large header list, allowing enormous + amounts of memory to be allocated. + + If this amount of data is exceeded, a `OversizedHeaderListError + ` exception will be raised. At this + point the connection should be shut down, as the HPACK state will no + longer be usable. + + Defaults to 64kB. + :type max_header_list_size: ``int`` + """ + def __init__(self, max_header_list_size=DEFAULT_MAX_HEADER_LIST_SIZE): + self.header_table = HeaderTable() + + #: The maximum decompressed size we will allow for any single header + #: block. This is a protection against DoS attacks that attempt to + #: force the application to expand a relatively small amount of data + #: into a really large header list, allowing enormous amounts of memory + #: to be allocated. + #: + #: If this amount of data is exceeded, a `OversizedHeaderListError + #: ` exception will be raised. At this + #: point the connection should be shut down, as the HPACK state will no + #: longer be usable. + #: + #: Defaults to 64kB. + #: + #: .. versionadded:: 2.3.0 + self.max_header_list_size = max_header_list_size + + #: Maximum allowed header table size. + #: + #: A HTTP/2 implementation should set this to the most recent value of + #: SETTINGS_HEADER_TABLE_SIZE that it sent *and has received an ACK + #: for*. Once this setting is set, the actual header table size will be + #: checked at the end of each decoding run and whenever it is changed, + #: to confirm that it fits in this size. + self.max_allowed_table_size = self.header_table.maxsize + + @property + def header_table_size(self): + """ + Controls the size of the HPACK header table. + """ + return self.header_table.maxsize + + @header_table_size.setter + def header_table_size(self, value): + self.header_table.maxsize = value + + def decode(self, data, raw=False): + """ + Takes an HPACK-encoded header block and decodes it into a header set. + + :param data: A bytestring representing a complete HPACK-encoded header + block. + :param raw: (optional) Whether to return the headers as tuples of raw + byte strings or to decode them as UTF-8 before returning + them. The default value is False, which returns tuples of + Unicode strings + :returns: A list of two-tuples of ``(name, value)`` representing the + HPACK-encoded headers, in the order they were decoded. + :raises HPACKDecodingError: If an error is encountered while decoding + the header block. + """ + + data_mem = bytearray(data) + headers = [] + data_len = len(data) + inflated_size = 0 + current_index = 0 + + while current_index < data_len: + # Work out what kind of header we're decoding. + # If the high bit is 1, it's an indexed field. + current = data[current_index] + indexed = True if current & 0x80 else False + + # Otherwise, if the second-highest bit is 1 it's a field that does + # alter the header table. + literal_index = True if current & 0x40 else False + + # Otherwise, if the third-highest bit is 1 it's an encoding context + # update. + encoding_update = True if current & 0x20 else False + + if indexed: + header, consumed = self._decode_indexed( + data_mem[current_index:] + ) + elif literal_index: + # It's a literal header that does affect the header table. + header, consumed = self._decode_literal_index( + data_mem[current_index:] + ) + elif encoding_update: + # It's an update to the encoding context. These are forbidden + # in a header block after any actual header. + if headers: + raise HPACKDecodingError( + "Table size update not at the start of the block" + ) + consumed = self._update_encoding_context( + data_mem[current_index:] + ) + header = None + else: + # It's a literal header that does not affect the header table. + header, consumed = self._decode_literal_no_index( + data_mem[current_index:] + ) + + if header: + headers.append(header) + inflated_size += table_entry_size(*header) + + if inflated_size > self.max_header_list_size: + raise OversizedHeaderListError( + "A header list larger than %d has been received" % + self.max_header_list_size + ) + + current_index += consumed + + # Confirm that the table size is lower than the maximum. We do this + # here to ensure that we catch when the max has been *shrunk* and the + # remote peer hasn't actually done that. + self._assert_valid_table_size() + + try: + return [_unicode_if_needed(h, raw) for h in headers] + except UnicodeDecodeError: + raise HPACKDecodingError("Unable to decode headers as UTF-8.") + + def _assert_valid_table_size(self): + """ + Check that the table size set by the encoder is lower than the maximum + we expect to have. + """ + if self.header_table_size > self.max_allowed_table_size: + raise InvalidTableSizeError( + "Encoder did not shrink table size to within the max" + ) + + def _update_encoding_context(self, data): + """ + Handles a byte that updates the encoding context. + """ + # We've been asked to resize the header table. + new_size, consumed = decode_integer(data, 5) + if new_size > self.max_allowed_table_size: + raise InvalidTableSizeError( + "Encoder exceeded max allowable table size" + ) + self.header_table_size = new_size + return consumed + + def _decode_indexed(self, data): + """ + Decodes a header represented using the indexed representation. + """ + index, consumed = decode_integer(data, 7) + header = HeaderTuple(*self.header_table.get_by_index(index)) + return header, consumed + + def _decode_literal_no_index(self, data): + return self._decode_literal(data, False) + + def _decode_literal_index(self, data): + return self._decode_literal(data, True) + + def _decode_literal(self, data, should_index): + """ + Decodes a header represented with a literal. + """ + total_consumed = 0 + + # When should_index is true, if the low six bits of the first byte are + # nonzero, the header name is indexed. + # When should_index is false, if the low four bits of the first byte + # are nonzero the header name is indexed. + if should_index: + indexed_name = data[0] & 0x3F + name_len = 6 + not_indexable = False + else: + high_byte = data[0] + indexed_name = high_byte & 0x0F + name_len = 4 + not_indexable = high_byte & 0x10 + + if indexed_name: + # Indexed header name. + index, consumed = decode_integer(data, name_len) + name = self.header_table.get_by_index(index)[0] + + total_consumed = consumed + length = 0 + else: + # Literal header name. The first byte was consumed, so we need to + # move forward. + data = data[1:] + + length, consumed = decode_integer(data, 7) + name = data[consumed:consumed + length] + if len(name) != length: + raise HPACKDecodingError("Truncated header block") + + if data[0] & 0x80: + name = decode_huffman(name) + total_consumed = consumed + length + 1 # Since we moved forward 1. + + data = data[consumed + length:] + + # The header value is definitely length-based. + length, consumed = decode_integer(data, 7) + value = data[consumed:consumed + length] + if len(value) != length: + raise HPACKDecodingError("Truncated header block") + + if data[0] & 0x80: + value = decode_huffman(value) + + # Updated the total consumed length. + total_consumed += length + consumed + + # If we have been told never to index the header field, encode that in + # the tuple we use. + if not_indexable: + header = NeverIndexedHeaderTuple(name, value) + else: + header = HeaderTuple(name, value) + + # If we've been asked to index this, add it to the header table. + if should_index: + self.header_table.add(name, value) + + return header, total_consumed diff --git a/hyperscale/core/engines/types/common/fast_hpack/__init__.py b/hyperscale/core/engines/types/common/fast_hpack/__init__.py new file mode 100644 index 0000000..8bfbb8f --- /dev/null +++ b/hyperscale/core/engines/types/common/fast_hpack/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +hpack +~~~~~ + +HTTP/2 header encoding for Python. +""" +from .hpack import Encoder, Decoder, HeaderTable + +__all__ = [ + 'Encoder', + 'Decoder', + 'HeaderTable' +] + +__version__ = '4.1.0+dev' diff --git a/hyperscale/core/engines/types/common/fast_hpack/hpack.py b/hyperscale/core/engines/types/common/fast_hpack/hpack.py new file mode 100644 index 0000000..73583f5 --- /dev/null +++ b/hyperscale/core/engines/types/common/fast_hpack/hpack.py @@ -0,0 +1,5058 @@ +# -*- coding: utf-8 -*- +""" +hpack/hpack +~~~~~~~~~~~ + +Implements the HPACK header compression algorithm as detailed by the IETF. +""" +from .table import HeaderTable +from .huffman import HuffmanEncoder + + +# Precompute 2^i for 1-8 for use in prefix calcs. +# Zero index is not used but there to save a subtraction +# as prefix numbers are not zero indexed. +_PREFIX_BIT_MAX_NUMBERS = [(2 ** i) - 1 for i in range(9)] +_HUFFMAN_TABLE = [ + # Node 0 (Root Node, never emits symbols.) + (4, 0, 0), + (5, 0, 0), + (7, 0, 0), + (8, 0, 0), + (11, 0, 0), + (12, 0, 0), + (16, 0, 0), + (19, 0, 0), + (25, 0, 0), + (28, 0, 0), + (32, 0, 0), + (35, 0, 0), + (42, 0, 0), + (49, 0, 0), + (57, 0, 0), + (64, 1, 0), + + # Node 1 + (0, 1 | (1 << 1), 48), + (0, 1 | (1 << 1), 49), + (0, 1 | (1 << 1), 50), + (0, 1 | (1 << 1), 97), + (0, 1 | (1 << 1), 99), + (0, 1 | (1 << 1), 101), + (0, 1 | (1 << 1), 105), + (0, 1 | (1 << 1), 111), + (0, 1 | (1 << 1), 115), + (0, 1 | (1 << 1), 116), + (13, 0, 0), + (14, 0, 0), + (17, 0, 0), + (18, 0, 0), + (20, 0, 0), + (21, 0, 0), + + # Node 2 + (1, (1 << 1), 48), + (22, 1 | (1 << 1), 48), + (1, (1 << 1), 49), + (22, 1 | (1 << 1), 49), + (1, (1 << 1), 50), + (22, 1 | (1 << 1), 50), + (1, (1 << 1), 97), + (22, 1 | (1 << 1), 97), + (1, (1 << 1), 99), + (22, 1 | (1 << 1), 99), + (1, (1 << 1), 101), + (22, 1 | (1 << 1), 101), + (1, (1 << 1), 105), + (22, 1 | (1 << 1), 105), + (1, (1 << 1), 111), + (22, 1 | (1 << 1), 111), + + # Node 3 + (2, (1 << 1), 48), + (9, (1 << 1), 48), + (23, (1 << 1), 48), + (40, 1 | (1 << 1), 48), + (2, (1 << 1), 49), + (9, (1 << 1), 49), + (23, (1 << 1), 49), + (40, 1 | (1 << 1), 49), + (2, (1 << 1), 50), + (9, (1 << 1), 50), + (23, (1 << 1), 50), + (40, 1 | (1 << 1), 50), + (2, (1 << 1), 97), + (9, (1 << 1), 97), + (23, (1 << 1), 97), + (40, 1 | (1 << 1), 97), + + # Node 4 + (3, (1 << 1), 48), + (6, (1 << 1), 48), + (10, (1 << 1), 48), + (15, (1 << 1), 48), + (24, (1 << 1), 48), + (31, (1 << 1), 48), + (41, (1 << 1), 48), + (56, 1 | (1 << 1), 48), + (3, (1 << 1), 49), + (6, (1 << 1), 49), + (10, (1 << 1), 49), + (15, (1 << 1), 49), + (24, (1 << 1), 49), + (31, (1 << 1), 49), + (41, (1 << 1), 49), + (56, 1 | (1 << 1), 49), + + # Node 5 + (3, (1 << 1), 50), + (6, (1 << 1), 50), + (10, (1 << 1), 50), + (15, (1 << 1), 50), + (24, (1 << 1), 50), + (31, (1 << 1), 50), + (41, (1 << 1), 50), + (56, 1 | (1 << 1), 50), + (3, (1 << 1), 97), + (6, (1 << 1), 97), + (10, (1 << 1), 97), + (15, (1 << 1), 97), + (24, (1 << 1), 97), + (31, (1 << 1), 97), + (41, (1 << 1), 97), + (56, 1 | (1 << 1), 97), + + # Node 6 + (2, (1 << 1), 99), + (9, (1 << 1), 99), + (23, (1 << 1), 99), + (40, 1 | (1 << 1), 99), + (2, (1 << 1), 101), + (9, (1 << 1), 101), + (23, (1 << 1), 101), + (40, 1 | (1 << 1), 101), + (2, (1 << 1), 105), + (9, (1 << 1), 105), + (23, (1 << 1), 105), + (40, 1 | (1 << 1), 105), + (2, (1 << 1), 111), + (9, (1 << 1), 111), + (23, (1 << 1), 111), + (40, 1 | (1 << 1), 111), + + # Node 7 + (3, (1 << 1), 99), + (6, (1 << 1), 99), + (10, (1 << 1), 99), + (15, (1 << 1), 99), + (24, (1 << 1), 99), + (31, (1 << 1), 99), + (41, (1 << 1), 99), + (56, 1 | (1 << 1), 99), + (3, (1 << 1), 101), + (6, (1 << 1), 101), + (10, (1 << 1), 101), + (15, (1 << 1), 101), + (24, (1 << 1), 101), + (31, (1 << 1), 101), + (41, (1 << 1), 101), + (56, 1 | (1 << 1), 101), + + # Node 8 + (3, (1 << 1), 105), + (6, (1 << 1), 105), + (10, (1 << 1), 105), + (15, (1 << 1), 105), + (24, (1 << 1), 105), + (31, (1 << 1), 105), + (41, (1 << 1), 105), + (56, 1 | (1 << 1), 105), + (3, (1 << 1), 111), + (6, (1 << 1), 111), + (10, (1 << 1), 111), + (15, (1 << 1), 111), + (24, (1 << 1), 111), + (31, (1 << 1), 111), + (41, (1 << 1), 111), + (56, 1 | (1 << 1), 111), + + # Node 9 + (1, (1 << 1), 115), + (22, 1 | (1 << 1), 115), + (1, (1 << 1), 116), + (22, 1 | (1 << 1), 116), + (0, 1 | (1 << 1), 32), + (0, 1 | (1 << 1), 37), + (0, 1 | (1 << 1), 45), + (0, 1 | (1 << 1), 46), + (0, 1 | (1 << 1), 47), + (0, 1 | (1 << 1), 51), + (0, 1 | (1 << 1), 52), + (0, 1 | (1 << 1), 53), + (0, 1 | (1 << 1), 54), + (0, 1 | (1 << 1), 55), + (0, 1 | (1 << 1), 56), + (0, 1 | (1 << 1), 57), + + # Node 10 + (2, (1 << 1), 115), + (9, (1 << 1), 115), + (23, (1 << 1), 115), + (40, 1 | (1 << 1), 115), + (2, (1 << 1), 116), + (9, (1 << 1), 116), + (23, (1 << 1), 116), + (40, 1 | (1 << 1), 116), + (1, (1 << 1), 32), + (22, 1 | (1 << 1), 32), + (1, (1 << 1), 37), + (22, 1 | (1 << 1), 37), + (1, (1 << 1), 45), + (22, 1 | (1 << 1), 45), + (1, (1 << 1), 46), + (22, 1 | (1 << 1), 46), + + # Node 11 + (3, (1 << 1), 115), + (6, (1 << 1), 115), + (10, (1 << 1), 115), + (15, (1 << 1), 115), + (24, (1 << 1), 115), + (31, (1 << 1), 115), + (41, (1 << 1), 115), + (56, 1 | (1 << 1), 115), + (3, (1 << 1), 116), + (6, (1 << 1), 116), + (10, (1 << 1), 116), + (15, (1 << 1), 116), + (24, (1 << 1), 116), + (31, (1 << 1), 116), + (41, (1 << 1), 116), + (56, 1 | (1 << 1), 116), + + # Node 12 + (2, (1 << 1), 32), + (9, (1 << 1), 32), + (23, (1 << 1), 32), + (40, 1 | (1 << 1), 32), + (2, (1 << 1), 37), + (9, (1 << 1), 37), + (23, (1 << 1), 37), + (40, 1 | (1 << 1), 37), + (2, (1 << 1), 45), + (9, (1 << 1), 45), + (23, (1 << 1), 45), + (40, 1 | (1 << 1), 45), + (2, (1 << 1), 46), + (9, (1 << 1), 46), + (23, (1 << 1), 46), + (40, 1 | (1 << 1), 46), + + # Node 13 + (3, (1 << 1), 32), + (6, (1 << 1), 32), + (10, (1 << 1), 32), + (15, (1 << 1), 32), + (24, (1 << 1), 32), + (31, (1 << 1), 32), + (41, (1 << 1), 32), + (56, 1 | (1 << 1), 32), + (3, (1 << 1), 37), + (6, (1 << 1), 37), + (10, (1 << 1), 37), + (15, (1 << 1), 37), + (24, (1 << 1), 37), + (31, (1 << 1), 37), + (41, (1 << 1), 37), + (56, 1 | (1 << 1), 37), + + # Node 14 + (3, (1 << 1), 45), + (6, (1 << 1), 45), + (10, (1 << 1), 45), + (15, (1 << 1), 45), + (24, (1 << 1), 45), + (31, (1 << 1), 45), + (41, (1 << 1), 45), + (56, 1 | (1 << 1), 45), + (3, (1 << 1), 46), + (6, (1 << 1), 46), + (10, (1 << 1), 46), + (15, (1 << 1), 46), + (24, (1 << 1), 46), + (31, (1 << 1), 46), + (41, (1 << 1), 46), + (56, 1 | (1 << 1), 46), + + # Node 15 + (1, (1 << 1), 47), + (22, 1 | (1 << 1), 47), + (1, (1 << 1), 51), + (22, 1 | (1 << 1), 51), + (1, (1 << 1), 52), + (22, 1 | (1 << 1), 52), + (1, (1 << 1), 53), + (22, 1 | (1 << 1), 53), + (1, (1 << 1), 54), + (22, 1 | (1 << 1), 54), + (1, (1 << 1), 55), + (22, 1 | (1 << 1), 55), + (1, (1 << 1), 56), + (22, 1 | (1 << 1), 56), + (1, (1 << 1), 57), + (22, 1 | (1 << 1), 57), + + # Node 16 + (2, (1 << 1), 47), + (9, (1 << 1), 47), + (23, (1 << 1), 47), + (40, 1 | (1 << 1), 47), + (2, (1 << 1), 51), + (9, (1 << 1), 51), + (23, (1 << 1), 51), + (40, 1 | (1 << 1), 51), + (2, (1 << 1), 52), + (9, (1 << 1), 52), + (23, (1 << 1), 52), + (40, 1 | (1 << 1), 52), + (2, (1 << 1), 53), + (9, (1 << 1), 53), + (23, (1 << 1), 53), + (40, 1 | (1 << 1), 53), + + # Node 17 + (3, (1 << 1), 47), + (6, (1 << 1), 47), + (10, (1 << 1), 47), + (15, (1 << 1), 47), + (24, (1 << 1), 47), + (31, (1 << 1), 47), + (41, (1 << 1), 47), + (56, 1 | (1 << 1), 47), + (3, (1 << 1), 51), + (6, (1 << 1), 51), + (10, (1 << 1), 51), + (15, (1 << 1), 51), + (24, (1 << 1), 51), + (31, (1 << 1), 51), + (41, (1 << 1), 51), + (56, 1 | (1 << 1), 51), + + # Node 18 + (3, (1 << 1), 52), + (6, (1 << 1), 52), + (10, (1 << 1), 52), + (15, (1 << 1), 52), + (24, (1 << 1), 52), + (31, (1 << 1), 52), + (41, (1 << 1), 52), + (56, 1 | (1 << 1), 52), + (3, (1 << 1), 53), + (6, (1 << 1), 53), + (10, (1 << 1), 53), + (15, (1 << 1), 53), + (24, (1 << 1), 53), + (31, (1 << 1), 53), + (41, (1 << 1), 53), + (56, 1 | (1 << 1), 53), + + # Node 19 + (2, (1 << 1), 54), + (9, (1 << 1), 54), + (23, (1 << 1), 54), + (40, 1 | (1 << 1), 54), + (2, (1 << 1), 55), + (9, (1 << 1), 55), + (23, (1 << 1), 55), + (40, 1 | (1 << 1), 55), + (2, (1 << 1), 56), + (9, (1 << 1), 56), + (23, (1 << 1), 56), + (40, 1 | (1 << 1), 56), + (2, (1 << 1), 57), + (9, (1 << 1), 57), + (23, (1 << 1), 57), + (40, 1 | (1 << 1), 57), + + # Node 20 + (3, (1 << 1), 54), + (6, (1 << 1), 54), + (10, (1 << 1), 54), + (15, (1 << 1), 54), + (24, (1 << 1), 54), + (31, (1 << 1), 54), + (41, (1 << 1), 54), + (56, 1 | (1 << 1), 54), + (3, (1 << 1), 55), + (6, (1 << 1), 55), + (10, (1 << 1), 55), + (15, (1 << 1), 55), + (24, (1 << 1), 55), + (31, (1 << 1), 55), + (41, (1 << 1), 55), + (56, 1 | (1 << 1), 55), + + # Node 21 + (3, (1 << 1), 56), + (6, (1 << 1), 56), + (10, (1 << 1), 56), + (15, (1 << 1), 56), + (24, (1 << 1), 56), + (31, (1 << 1), 56), + (41, (1 << 1), 56), + (56, 1 | (1 << 1), 56), + (3, (1 << 1), 57), + (6, (1 << 1), 57), + (10, (1 << 1), 57), + (15, (1 << 1), 57), + (24, (1 << 1), 57), + (31, (1 << 1), 57), + (41, (1 << 1), 57), + (56, 1 | (1 << 1), 57), + + # Node 22 + (26, 0, 0), + (27, 0, 0), + (29, 0, 0), + (30, 0, 0), + (33, 0, 0), + (34, 0, 0), + (36, 0, 0), + (37, 0, 0), + (43, 0, 0), + (46, 0, 0), + (50, 0, 0), + (53, 0, 0), + (58, 0, 0), + (61, 0, 0), + (65, 0, 0), + (68, 1, 0), + + # Node 23 + (0, 1 | (1 << 1), 61), + (0, 1 | (1 << 1), 65), + (0, 1 | (1 << 1), 95), + (0, 1 | (1 << 1), 98), + (0, 1 | (1 << 1), 100), + (0, 1 | (1 << 1), 102), + (0, 1 | (1 << 1), 103), + (0, 1 | (1 << 1), 104), + (0, 1 | (1 << 1), 108), + (0, 1 | (1 << 1), 109), + (0, 1 | (1 << 1), 110), + (0, 1 | (1 << 1), 112), + (0, 1 | (1 << 1), 114), + (0, 1 | (1 << 1), 117), + (38, 0, 0), + (39, 0, 0), + + # Node 24 + (1, (1 << 1), 61), + (22, 1 | (1 << 1), 61), + (1, (1 << 1), 65), + (22, 1 | (1 << 1), 65), + (1, (1 << 1), 95), + (22, 1 | (1 << 1), 95), + (1, (1 << 1), 98), + (22, 1 | (1 << 1), 98), + (1, (1 << 1), 100), + (22, 1 | (1 << 1), 100), + (1, (1 << 1), 102), + (22, 1 | (1 << 1), 102), + (1, (1 << 1), 103), + (22, 1 | (1 << 1), 103), + (1, (1 << 1), 104), + (22, 1 | (1 << 1), 104), + + # Node 25 + (2, (1 << 1), 61), + (9, (1 << 1), 61), + (23, (1 << 1), 61), + (40, 1 | (1 << 1), 61), + (2, (1 << 1), 65), + (9, (1 << 1), 65), + (23, (1 << 1), 65), + (40, 1 | (1 << 1), 65), + (2, (1 << 1), 95), + (9, (1 << 1), 95), + (23, (1 << 1), 95), + (40, 1 | (1 << 1), 95), + (2, (1 << 1), 98), + (9, (1 << 1), 98), + (23, (1 << 1), 98), + (40, 1 | (1 << 1), 98), + + # Node 26 + (3, (1 << 1), 61), + (6, (1 << 1), 61), + (10, (1 << 1), 61), + (15, (1 << 1), 61), + (24, (1 << 1), 61), + (31, (1 << 1), 61), + (41, (1 << 1), 61), + (56, 1 | (1 << 1), 61), + (3, (1 << 1), 65), + (6, (1 << 1), 65), + (10, (1 << 1), 65), + (15, (1 << 1), 65), + (24, (1 << 1), 65), + (31, (1 << 1), 65), + (41, (1 << 1), 65), + (56, 1 | (1 << 1), 65), + + # Node 27 + (3, (1 << 1), 95), + (6, (1 << 1), 95), + (10, (1 << 1), 95), + (15, (1 << 1), 95), + (24, (1 << 1), 95), + (31, (1 << 1), 95), + (41, (1 << 1), 95), + (56, 1 | (1 << 1), 95), + (3, (1 << 1), 98), + (6, (1 << 1), 98), + (10, (1 << 1), 98), + (15, (1 << 1), 98), + (24, (1 << 1), 98), + (31, (1 << 1), 98), + (41, (1 << 1), 98), + (56, 1 | (1 << 1), 98), + + # Node 28 + (2, (1 << 1), 100), + (9, (1 << 1), 100), + (23, (1 << 1), 100), + (40, 1 | (1 << 1), 100), + (2, (1 << 1), 102), + (9, (1 << 1), 102), + (23, (1 << 1), 102), + (40, 1 | (1 << 1), 102), + (2, (1 << 1), 103), + (9, (1 << 1), 103), + (23, (1 << 1), 103), + (40, 1 | (1 << 1), 103), + (2, (1 << 1), 104), + (9, (1 << 1), 104), + (23, (1 << 1), 104), + (40, 1 | (1 << 1), 104), + + # Node 29 + (3, (1 << 1), 100), + (6, (1 << 1), 100), + (10, (1 << 1), 100), + (15, (1 << 1), 100), + (24, (1 << 1), 100), + (31, (1 << 1), 100), + (41, (1 << 1), 100), + (56, 1 | (1 << 1), 100), + (3, (1 << 1), 102), + (6, (1 << 1), 102), + (10, (1 << 1), 102), + (15, (1 << 1), 102), + (24, (1 << 1), 102), + (31, (1 << 1), 102), + (41, (1 << 1), 102), + (56, 1 | (1 << 1), 102), + + # Node 30 + (3, (1 << 1), 103), + (6, (1 << 1), 103), + (10, (1 << 1), 103), + (15, (1 << 1), 103), + (24, (1 << 1), 103), + (31, (1 << 1), 103), + (41, (1 << 1), 103), + (56, 1 | (1 << 1), 103), + (3, (1 << 1), 104), + (6, (1 << 1), 104), + (10, (1 << 1), 104), + (15, (1 << 1), 104), + (24, (1 << 1), 104), + (31, (1 << 1), 104), + (41, (1 << 1), 104), + (56, 1 | (1 << 1), 104), + + # Node 31 + (1, (1 << 1), 108), + (22, 1 | (1 << 1), 108), + (1, (1 << 1), 109), + (22, 1 | (1 << 1), 109), + (1, (1 << 1), 110), + (22, 1 | (1 << 1), 110), + (1, (1 << 1), 112), + (22, 1 | (1 << 1), 112), + (1, (1 << 1), 114), + (22, 1 | (1 << 1), 114), + (1, (1 << 1), 117), + (22, 1 | (1 << 1), 117), + (0, 1 | (1 << 1), 58), + (0, 1 | (1 << 1), 66), + (0, 1 | (1 << 1), 67), + (0, 1 | (1 << 1), 68), + + # Node 32 + (2, (1 << 1), 108), + (9, (1 << 1), 108), + (23, (1 << 1), 108), + (40, 1 | (1 << 1), 108), + (2, (1 << 1), 109), + (9, (1 << 1), 109), + (23, (1 << 1), 109), + (40, 1 | (1 << 1), 109), + (2, (1 << 1), 110), + (9, (1 << 1), 110), + (23, (1 << 1), 110), + (40, 1 | (1 << 1), 110), + (2, (1 << 1), 112), + (9, (1 << 1), 112), + (23, (1 << 1), 112), + (40, 1 | (1 << 1), 112), + + # Node 33 + (3, (1 << 1), 108), + (6, (1 << 1), 108), + (10, (1 << 1), 108), + (15, (1 << 1), 108), + (24, (1 << 1), 108), + (31, (1 << 1), 108), + (41, (1 << 1), 108), + (56, 1 | (1 << 1), 108), + (3, (1 << 1), 109), + (6, (1 << 1), 109), + (10, (1 << 1), 109), + (15, (1 << 1), 109), + (24, (1 << 1), 109), + (31, (1 << 1), 109), + (41, (1 << 1), 109), + (56, 1 | (1 << 1), 109), + + # Node 34 + (3, (1 << 1), 110), + (6, (1 << 1), 110), + (10, (1 << 1), 110), + (15, (1 << 1), 110), + (24, (1 << 1), 110), + (31, (1 << 1), 110), + (41, (1 << 1), 110), + (56, 1 | (1 << 1), 110), + (3, (1 << 1), 112), + (6, (1 << 1), 112), + (10, (1 << 1), 112), + (15, (1 << 1), 112), + (24, (1 << 1), 112), + (31, (1 << 1), 112), + (41, (1 << 1), 112), + (56, 1 | (1 << 1), 112), + + # Node 35 + (2, (1 << 1), 114), + (9, (1 << 1), 114), + (23, (1 << 1), 114), + (40, 1 | (1 << 1), 114), + (2, (1 << 1), 117), + (9, (1 << 1), 117), + (23, (1 << 1), 117), + (40, 1 | (1 << 1), 117), + (1, (1 << 1), 58), + (22, 1 | (1 << 1), 58), + (1, (1 << 1), 66), + (22, 1 | (1 << 1), 66), + (1, (1 << 1), 67), + (22, 1 | (1 << 1), 67), + (1, (1 << 1), 68), + (22, 1 | (1 << 1), 68), + + # Node 36 + (3, (1 << 1), 114), + (6, (1 << 1), 114), + (10, (1 << 1), 114), + (15, (1 << 1), 114), + (24, (1 << 1), 114), + (31, (1 << 1), 114), + (41, (1 << 1), 114), + (56, 1 | (1 << 1), 114), + (3, (1 << 1), 117), + (6, (1 << 1), 117), + (10, (1 << 1), 117), + (15, (1 << 1), 117), + (24, (1 << 1), 117), + (31, (1 << 1), 117), + (41, (1 << 1), 117), + (56, 1 | (1 << 1), 117), + + # Node 37 + (2, (1 << 1), 58), + (9, (1 << 1), 58), + (23, (1 << 1), 58), + (40, 1 | (1 << 1), 58), + (2, (1 << 1), 66), + (9, (1 << 1), 66), + (23, (1 << 1), 66), + (40, 1 | (1 << 1), 66), + (2, (1 << 1), 67), + (9, (1 << 1), 67), + (23, (1 << 1), 67), + (40, 1 | (1 << 1), 67), + (2, (1 << 1), 68), + (9, (1 << 1), 68), + (23, (1 << 1), 68), + (40, 1 | (1 << 1), 68), + + # Node 38 + (3, (1 << 1), 58), + (6, (1 << 1), 58), + (10, (1 << 1), 58), + (15, (1 << 1), 58), + (24, (1 << 1), 58), + (31, (1 << 1), 58), + (41, (1 << 1), 58), + (56, 1 | (1 << 1), 58), + (3, (1 << 1), 66), + (6, (1 << 1), 66), + (10, (1 << 1), 66), + (15, (1 << 1), 66), + (24, (1 << 1), 66), + (31, (1 << 1), 66), + (41, (1 << 1), 66), + (56, 1 | (1 << 1), 66), + + # Node 39 + (3, (1 << 1), 67), + (6, (1 << 1), 67), + (10, (1 << 1), 67), + (15, (1 << 1), 67), + (24, (1 << 1), 67), + (31, (1 << 1), 67), + (41, (1 << 1), 67), + (56, 1 | (1 << 1), 67), + (3, (1 << 1), 68), + (6, (1 << 1), 68), + (10, (1 << 1), 68), + (15, (1 << 1), 68), + (24, (1 << 1), 68), + (31, (1 << 1), 68), + (41, (1 << 1), 68), + (56, 1 | (1 << 1), 68), + + # Node 40 + (44, 0, 0), + (45, 0, 0), + (47, 0, 0), + (48, 0, 0), + (51, 0, 0), + (52, 0, 0), + (54, 0, 0), + (55, 0, 0), + (59, 0, 0), + (60, 0, 0), + (62, 0, 0), + (63, 0, 0), + (66, 0, 0), + (67, 0, 0), + (69, 0, 0), + (72, 1, 0), + + # Node 41 + (0, 1 | (1 << 1), 69), + (0, 1 | (1 << 1), 70), + (0, 1 | (1 << 1), 71), + (0, 1 | (1 << 1), 72), + (0, 1 | (1 << 1), 73), + (0, 1 | (1 << 1), 74), + (0, 1 | (1 << 1), 75), + (0, 1 | (1 << 1), 76), + (0, 1 | (1 << 1), 77), + (0, 1 | (1 << 1), 78), + (0, 1 | (1 << 1), 79), + (0, 1 | (1 << 1), 80), + (0, 1 | (1 << 1), 81), + (0, 1 | (1 << 1), 82), + (0, 1 | (1 << 1), 83), + (0, 1 | (1 << 1), 84), + + # Node 42 + (1, (1 << 1), 69), + (22, 1 | (1 << 1), 69), + (1, (1 << 1), 70), + (22, 1 | (1 << 1), 70), + (1, (1 << 1), 71), + (22, 1 | (1 << 1), 71), + (1, (1 << 1), 72), + (22, 1 | (1 << 1), 72), + (1, (1 << 1), 73), + (22, 1 | (1 << 1), 73), + (1, (1 << 1), 74), + (22, 1 | (1 << 1), 74), + (1, (1 << 1), 75), + (22, 1 | (1 << 1), 75), + (1, (1 << 1), 76), + (22, 1 | (1 << 1), 76), + + # Node 43 + (2, (1 << 1), 69), + (9, (1 << 1), 69), + (23, (1 << 1), 69), + (40, 1 | (1 << 1), 69), + (2, (1 << 1), 70), + (9, (1 << 1), 70), + (23, (1 << 1), 70), + (40, 1 | (1 << 1), 70), + (2, (1 << 1), 71), + (9, (1 << 1), 71), + (23, (1 << 1), 71), + (40, 1 | (1 << 1), 71), + (2, (1 << 1), 72), + (9, (1 << 1), 72), + (23, (1 << 1), 72), + (40, 1 | (1 << 1), 72), + + # Node 44 + (3, (1 << 1), 69), + (6, (1 << 1), 69), + (10, (1 << 1), 69), + (15, (1 << 1), 69), + (24, (1 << 1), 69), + (31, (1 << 1), 69), + (41, (1 << 1), 69), + (56, 1 | (1 << 1), 69), + (3, (1 << 1), 70), + (6, (1 << 1), 70), + (10, (1 << 1), 70), + (15, (1 << 1), 70), + (24, (1 << 1), 70), + (31, (1 << 1), 70), + (41, (1 << 1), 70), + (56, 1 | (1 << 1), 70), + + # Node 45 + (3, (1 << 1), 71), + (6, (1 << 1), 71), + (10, (1 << 1), 71), + (15, (1 << 1), 71), + (24, (1 << 1), 71), + (31, (1 << 1), 71), + (41, (1 << 1), 71), + (56, 1 | (1 << 1), 71), + (3, (1 << 1), 72), + (6, (1 << 1), 72), + (10, (1 << 1), 72), + (15, (1 << 1), 72), + (24, (1 << 1), 72), + (31, (1 << 1), 72), + (41, (1 << 1), 72), + (56, 1 | (1 << 1), 72), + + # Node 46 + (2, (1 << 1), 73), + (9, (1 << 1), 73), + (23, (1 << 1), 73), + (40, 1 | (1 << 1), 73), + (2, (1 << 1), 74), + (9, (1 << 1), 74), + (23, (1 << 1), 74), + (40, 1 | (1 << 1), 74), + (2, (1 << 1), 75), + (9, (1 << 1), 75), + (23, (1 << 1), 75), + (40, 1 | (1 << 1), 75), + (2, (1 << 1), 76), + (9, (1 << 1), 76), + (23, (1 << 1), 76), + (40, 1 | (1 << 1), 76), + + # Node 47 + (3, (1 << 1), 73), + (6, (1 << 1), 73), + (10, (1 << 1), 73), + (15, (1 << 1), 73), + (24, (1 << 1), 73), + (31, (1 << 1), 73), + (41, (1 << 1), 73), + (56, 1 | (1 << 1), 73), + (3, (1 << 1), 74), + (6, (1 << 1), 74), + (10, (1 << 1), 74), + (15, (1 << 1), 74), + (24, (1 << 1), 74), + (31, (1 << 1), 74), + (41, (1 << 1), 74), + (56, 1 | (1 << 1), 74), + + # Node 48 + (3, (1 << 1), 75), + (6, (1 << 1), 75), + (10, (1 << 1), 75), + (15, (1 << 1), 75), + (24, (1 << 1), 75), + (31, (1 << 1), 75), + (41, (1 << 1), 75), + (56, 1 | (1 << 1), 75), + (3, (1 << 1), 76), + (6, (1 << 1), 76), + (10, (1 << 1), 76), + (15, (1 << 1), 76), + (24, (1 << 1), 76), + (31, (1 << 1), 76), + (41, (1 << 1), 76), + (56, 1 | (1 << 1), 76), + + # Node 49 + (1, (1 << 1), 77), + (22, 1 | (1 << 1), 77), + (1, (1 << 1), 78), + (22, 1 | (1 << 1), 78), + (1, (1 << 1), 79), + (22, 1 | (1 << 1), 79), + (1, (1 << 1), 80), + (22, 1 | (1 << 1), 80), + (1, (1 << 1), 81), + (22, 1 | (1 << 1), 81), + (1, (1 << 1), 82), + (22, 1 | (1 << 1), 82), + (1, (1 << 1), 83), + (22, 1 | (1 << 1), 83), + (1, (1 << 1), 84), + (22, 1 | (1 << 1), 84), + + # Node 50 + (2, (1 << 1), 77), + (9, (1 << 1), 77), + (23, (1 << 1), 77), + (40, 1 | (1 << 1), 77), + (2, (1 << 1), 78), + (9, (1 << 1), 78), + (23, (1 << 1), 78), + (40, 1 | (1 << 1), 78), + (2, (1 << 1), 79), + (9, (1 << 1), 79), + (23, (1 << 1), 79), + (40, 1 | (1 << 1), 79), + (2, (1 << 1), 80), + (9, (1 << 1), 80), + (23, (1 << 1), 80), + (40, 1 | (1 << 1), 80), + + # Node 51 + (3, (1 << 1), 77), + (6, (1 << 1), 77), + (10, (1 << 1), 77), + (15, (1 << 1), 77), + (24, (1 << 1), 77), + (31, (1 << 1), 77), + (41, (1 << 1), 77), + (56, 1 | (1 << 1), 77), + (3, (1 << 1), 78), + (6, (1 << 1), 78), + (10, (1 << 1), 78), + (15, (1 << 1), 78), + (24, (1 << 1), 78), + (31, (1 << 1), 78), + (41, (1 << 1), 78), + (56, 1 | (1 << 1), 78), + + # Node 52 + (3, (1 << 1), 79), + (6, (1 << 1), 79), + (10, (1 << 1), 79), + (15, (1 << 1), 79), + (24, (1 << 1), 79), + (31, (1 << 1), 79), + (41, (1 << 1), 79), + (56, 1 | (1 << 1), 79), + (3, (1 << 1), 80), + (6, (1 << 1), 80), + (10, (1 << 1), 80), + (15, (1 << 1), 80), + (24, (1 << 1), 80), + (31, (1 << 1), 80), + (41, (1 << 1), 80), + (56, 1 | (1 << 1), 80), + + # Node 53 + (2, (1 << 1), 81), + (9, (1 << 1), 81), + (23, (1 << 1), 81), + (40, 1 | (1 << 1), 81), + (2, (1 << 1), 82), + (9, (1 << 1), 82), + (23, (1 << 1), 82), + (40, 1 | (1 << 1), 82), + (2, (1 << 1), 83), + (9, (1 << 1), 83), + (23, (1 << 1), 83), + (40, 1 | (1 << 1), 83), + (2, (1 << 1), 84), + (9, (1 << 1), 84), + (23, (1 << 1), 84), + (40, 1 | (1 << 1), 84), + + # Node 54 + (3, (1 << 1), 81), + (6, (1 << 1), 81), + (10, (1 << 1), 81), + (15, (1 << 1), 81), + (24, (1 << 1), 81), + (31, (1 << 1), 81), + (41, (1 << 1), 81), + (56, 1 | (1 << 1), 81), + (3, (1 << 1), 82), + (6, (1 << 1), 82), + (10, (1 << 1), 82), + (15, (1 << 1), 82), + (24, (1 << 1), 82), + (31, (1 << 1), 82), + (41, (1 << 1), 82), + (56, 1 | (1 << 1), 82), + + # Node 55 + (3, (1 << 1), 83), + (6, (1 << 1), 83), + (10, (1 << 1), 83), + (15, (1 << 1), 83), + (24, (1 << 1), 83), + (31, (1 << 1), 83), + (41, (1 << 1), 83), + (56, 1 | (1 << 1), 83), + (3, (1 << 1), 84), + (6, (1 << 1), 84), + (10, (1 << 1), 84), + (15, (1 << 1), 84), + (24, (1 << 1), 84), + (31, (1 << 1), 84), + (41, (1 << 1), 84), + (56, 1 | (1 << 1), 84), + + # Node 56 + (0, 1 | (1 << 1), 85), + (0, 1 | (1 << 1), 86), + (0, 1 | (1 << 1), 87), + (0, 1 | (1 << 1), 89), + (0, 1 | (1 << 1), 106), + (0, 1 | (1 << 1), 107), + (0, 1 | (1 << 1), 113), + (0, 1 | (1 << 1), 118), + (0, 1 | (1 << 1), 119), + (0, 1 | (1 << 1), 120), + (0, 1 | (1 << 1), 121), + (0, 1 | (1 << 1), 122), + (70, 0, 0), + (71, 0, 0), + (73, 0, 0), + (74, 1, 0), + + # Node 57 + (1, (1 << 1), 85), + (22, 1 | (1 << 1), 85), + (1, (1 << 1), 86), + (22, 1 | (1 << 1), 86), + (1, (1 << 1), 87), + (22, 1 | (1 << 1), 87), + (1, (1 << 1), 89), + (22, 1 | (1 << 1), 89), + (1, (1 << 1), 106), + (22, 1 | (1 << 1), 106), + (1, (1 << 1), 107), + (22, 1 | (1 << 1), 107), + (1, (1 << 1), 113), + (22, 1 | (1 << 1), 113), + (1, (1 << 1), 118), + (22, 1 | (1 << 1), 118), + + # Node 58 + (2, (1 << 1), 85), + (9, (1 << 1), 85), + (23, (1 << 1), 85), + (40, 1 | (1 << 1), 85), + (2, (1 << 1), 86), + (9, (1 << 1), 86), + (23, (1 << 1), 86), + (40, 1 | (1 << 1), 86), + (2, (1 << 1), 87), + (9, (1 << 1), 87), + (23, (1 << 1), 87), + (40, 1 | (1 << 1), 87), + (2, (1 << 1), 89), + (9, (1 << 1), 89), + (23, (1 << 1), 89), + (40, 1 | (1 << 1), 89), + + # Node 59 + (3, (1 << 1), 85), + (6, (1 << 1), 85), + (10, (1 << 1), 85), + (15, (1 << 1), 85), + (24, (1 << 1), 85), + (31, (1 << 1), 85), + (41, (1 << 1), 85), + (56, 1 | (1 << 1), 85), + (3, (1 << 1), 86), + (6, (1 << 1), 86), + (10, (1 << 1), 86), + (15, (1 << 1), 86), + (24, (1 << 1), 86), + (31, (1 << 1), 86), + (41, (1 << 1), 86), + (56, 1 | (1 << 1), 86), + + # Node 60 + (3, (1 << 1), 87), + (6, (1 << 1), 87), + (10, (1 << 1), 87), + (15, (1 << 1), 87), + (24, (1 << 1), 87), + (31, (1 << 1), 87), + (41, (1 << 1), 87), + (56, 1 | (1 << 1), 87), + (3, (1 << 1), 89), + (6, (1 << 1), 89), + (10, (1 << 1), 89), + (15, (1 << 1), 89), + (24, (1 << 1), 89), + (31, (1 << 1), 89), + (41, (1 << 1), 89), + (56, 1 | (1 << 1), 89), + + # Node 61 + (2, (1 << 1), 106), + (9, (1 << 1), 106), + (23, (1 << 1), 106), + (40, 1 | (1 << 1), 106), + (2, (1 << 1), 107), + (9, (1 << 1), 107), + (23, (1 << 1), 107), + (40, 1 | (1 << 1), 107), + (2, (1 << 1), 113), + (9, (1 << 1), 113), + (23, (1 << 1), 113), + (40, 1 | (1 << 1), 113), + (2, (1 << 1), 118), + (9, (1 << 1), 118), + (23, (1 << 1), 118), + (40, 1 | (1 << 1), 118), + + # Node 62 + (3, (1 << 1), 106), + (6, (1 << 1), 106), + (10, (1 << 1), 106), + (15, (1 << 1), 106), + (24, (1 << 1), 106), + (31, (1 << 1), 106), + (41, (1 << 1), 106), + (56, 1 | (1 << 1), 106), + (3, (1 << 1), 107), + (6, (1 << 1), 107), + (10, (1 << 1), 107), + (15, (1 << 1), 107), + (24, (1 << 1), 107), + (31, (1 << 1), 107), + (41, (1 << 1), 107), + (56, 1 | (1 << 1), 107), + + # Node 63 + (3, (1 << 1), 113), + (6, (1 << 1), 113), + (10, (1 << 1), 113), + (15, (1 << 1), 113), + (24, (1 << 1), 113), + (31, (1 << 1), 113), + (41, (1 << 1), 113), + (56, 1 | (1 << 1), 113), + (3, (1 << 1), 118), + (6, (1 << 1), 118), + (10, (1 << 1), 118), + (15, (1 << 1), 118), + (24, (1 << 1), 118), + (31, (1 << 1), 118), + (41, (1 << 1), 118), + (56, 1 | (1 << 1), 118), + + # Node 64 + (1, (1 << 1), 119), + (22, 1 | (1 << 1), 119), + (1, (1 << 1), 120), + (22, 1 | (1 << 1), 120), + (1, (1 << 1), 121), + (22, 1 | (1 << 1), 121), + (1, (1 << 1), 122), + (22, 1 | (1 << 1), 122), + (0, 1 | (1 << 1), 38), + (0, 1 | (1 << 1), 42), + (0, 1 | (1 << 1), 44), + (0, 1 | (1 << 1), 59), + (0, 1 | (1 << 1), 88), + (0, 1 | (1 << 1), 90), + (75, 0, 0), + (78, 0, 0), + + # Node 65 + (2, (1 << 1), 119), + (9, (1 << 1), 119), + (23, (1 << 1), 119), + (40, 1 | (1 << 1), 119), + (2, (1 << 1), 120), + (9, (1 << 1), 120), + (23, (1 << 1), 120), + (40, 1 | (1 << 1), 120), + (2, (1 << 1), 121), + (9, (1 << 1), 121), + (23, (1 << 1), 121), + (40, 1 | (1 << 1), 121), + (2, (1 << 1), 122), + (9, (1 << 1), 122), + (23, (1 << 1), 122), + (40, 1 | (1 << 1), 122), + + # Node 66 + (3, (1 << 1), 119), + (6, (1 << 1), 119), + (10, (1 << 1), 119), + (15, (1 << 1), 119), + (24, (1 << 1), 119), + (31, (1 << 1), 119), + (41, (1 << 1), 119), + (56, 1 | (1 << 1), 119), + (3, (1 << 1), 120), + (6, (1 << 1), 120), + (10, (1 << 1), 120), + (15, (1 << 1), 120), + (24, (1 << 1), 120), + (31, (1 << 1), 120), + (41, (1 << 1), 120), + (56, 1 | (1 << 1), 120), + + # Node 67 + (3, (1 << 1), 121), + (6, (1 << 1), 121), + (10, (1 << 1), 121), + (15, (1 << 1), 121), + (24, (1 << 1), 121), + (31, (1 << 1), 121), + (41, (1 << 1), 121), + (56, 1 | (1 << 1), 121), + (3, (1 << 1), 122), + (6, (1 << 1), 122), + (10, (1 << 1), 122), + (15, (1 << 1), 122), + (24, (1 << 1), 122), + (31, (1 << 1), 122), + (41, (1 << 1), 122), + (56, 1 | (1 << 1), 122), + + # Node 68 + (1, (1 << 1), 38), + (22, 1 | (1 << 1), 38), + (1, (1 << 1), 42), + (22, 1 | (1 << 1), 42), + (1, (1 << 1), 44), + (22, 1 | (1 << 1), 44), + (1, (1 << 1), 59), + (22, 1 | (1 << 1), 59), + (1, (1 << 1), 88), + (22, 1 | (1 << 1), 88), + (1, (1 << 1), 90), + (22, 1 | (1 << 1), 90), + (76, 0, 0), + (77, 0, 0), + (79, 0, 0), + (81, 0, 0), + + # Node 69 + (2, (1 << 1), 38), + (9, (1 << 1), 38), + (23, (1 << 1), 38), + (40, 1 | (1 << 1), 38), + (2, (1 << 1), 42), + (9, (1 << 1), 42), + (23, (1 << 1), 42), + (40, 1 | (1 << 1), 42), + (2, (1 << 1), 44), + (9, (1 << 1), 44), + (23, (1 << 1), 44), + (40, 1 | (1 << 1), 44), + (2, (1 << 1), 59), + (9, (1 << 1), 59), + (23, (1 << 1), 59), + (40, 1 | (1 << 1), 59), + + # Node 70 + (3, (1 << 1), 38), + (6, (1 << 1), 38), + (10, (1 << 1), 38), + (15, (1 << 1), 38), + (24, (1 << 1), 38), + (31, (1 << 1), 38), + (41, (1 << 1), 38), + (56, 1 | (1 << 1), 38), + (3, (1 << 1), 42), + (6, (1 << 1), 42), + (10, (1 << 1), 42), + (15, (1 << 1), 42), + (24, (1 << 1), 42), + (31, (1 << 1), 42), + (41, (1 << 1), 42), + (56, 1 | (1 << 1), 42), + + # Node 71 + (3, (1 << 1), 44), + (6, (1 << 1), 44), + (10, (1 << 1), 44), + (15, (1 << 1), 44), + (24, (1 << 1), 44), + (31, (1 << 1), 44), + (41, (1 << 1), 44), + (56, 1 | (1 << 1), 44), + (3, (1 << 1), 59), + (6, (1 << 1), 59), + (10, (1 << 1), 59), + (15, (1 << 1), 59), + (24, (1 << 1), 59), + (31, (1 << 1), 59), + (41, (1 << 1), 59), + (56, 1 | (1 << 1), 59), + + # Node 72 + (2, (1 << 1), 88), + (9, (1 << 1), 88), + (23, (1 << 1), 88), + (40, 1 | (1 << 1), 88), + (2, (1 << 1), 90), + (9, (1 << 1), 90), + (23, (1 << 1), 90), + (40, 1 | (1 << 1), 90), + (0, 1 | (1 << 1), 33), + (0, 1 | (1 << 1), 34), + (0, 1 | (1 << 1), 40), + (0, 1 | (1 << 1), 41), + (0, 1 | (1 << 1), 63), + (80, 0, 0), + (82, 0, 0), + (84, 0, 0), + + # Node 73 + (3, (1 << 1), 88), + (6, (1 << 1), 88), + (10, (1 << 1), 88), + (15, (1 << 1), 88), + (24, (1 << 1), 88), + (31, (1 << 1), 88), + (41, (1 << 1), 88), + (56, 1 | (1 << 1), 88), + (3, (1 << 1), 90), + (6, (1 << 1), 90), + (10, (1 << 1), 90), + (15, (1 << 1), 90), + (24, (1 << 1), 90), + (31, (1 << 1), 90), + (41, (1 << 1), 90), + (56, 1 | (1 << 1), 90), + + # Node 74 + (1, (1 << 1), 33), + (22, 1 | (1 << 1), 33), + (1, (1 << 1), 34), + (22, 1 | (1 << 1), 34), + (1, (1 << 1), 40), + (22, 1 | (1 << 1), 40), + (1, (1 << 1), 41), + (22, 1 | (1 << 1), 41), + (1, (1 << 1), 63), + (22, 1 | (1 << 1), 63), + (0, 1 | (1 << 1), 39), + (0, 1 | (1 << 1), 43), + (0, 1 | (1 << 1), 124), + (83, 0, 0), + (85, 0, 0), + (88, 0, 0), + + # Node 75 + (2, (1 << 1), 33), + (9, (1 << 1), 33), + (23, (1 << 1), 33), + (40, 1 | (1 << 1), 33), + (2, (1 << 1), 34), + (9, (1 << 1), 34), + (23, (1 << 1), 34), + (40, 1 | (1 << 1), 34), + (2, (1 << 1), 40), + (9, (1 << 1), 40), + (23, (1 << 1), 40), + (40, 1 | (1 << 1), 40), + (2, (1 << 1), 41), + (9, (1 << 1), 41), + (23, (1 << 1), 41), + (40, 1 | (1 << 1), 41), + + # Node 76 + (3, (1 << 1), 33), + (6, (1 << 1), 33), + (10, (1 << 1), 33), + (15, (1 << 1), 33), + (24, (1 << 1), 33), + (31, (1 << 1), 33), + (41, (1 << 1), 33), + (56, 1 | (1 << 1), 33), + (3, (1 << 1), 34), + (6, (1 << 1), 34), + (10, (1 << 1), 34), + (15, (1 << 1), 34), + (24, (1 << 1), 34), + (31, (1 << 1), 34), + (41, (1 << 1), 34), + (56, 1 | (1 << 1), 34), + + # Node 77 + (3, (1 << 1), 40), + (6, (1 << 1), 40), + (10, (1 << 1), 40), + (15, (1 << 1), 40), + (24, (1 << 1), 40), + (31, (1 << 1), 40), + (41, (1 << 1), 40), + (56, 1 | (1 << 1), 40), + (3, (1 << 1), 41), + (6, (1 << 1), 41), + (10, (1 << 1), 41), + (15, (1 << 1), 41), + (24, (1 << 1), 41), + (31, (1 << 1), 41), + (41, (1 << 1), 41), + (56, 1 | (1 << 1), 41), + + # Node 78 + (2, (1 << 1), 63), + (9, (1 << 1), 63), + (23, (1 << 1), 63), + (40, 1 | (1 << 1), 63), + (1, (1 << 1), 39), + (22, 1 | (1 << 1), 39), + (1, (1 << 1), 43), + (22, 1 | (1 << 1), 43), + (1, (1 << 1), 124), + (22, 1 | (1 << 1), 124), + (0, 1 | (1 << 1), 35), + (0, 1 | (1 << 1), 62), + (86, 0, 0), + (87, 0, 0), + (89, 0, 0), + (90, 0, 0), + + # Node 79 + (3, (1 << 1), 63), + (6, (1 << 1), 63), + (10, (1 << 1), 63), + (15, (1 << 1), 63), + (24, (1 << 1), 63), + (31, (1 << 1), 63), + (41, (1 << 1), 63), + (56, 1 | (1 << 1), 63), + (2, (1 << 1), 39), + (9, (1 << 1), 39), + (23, (1 << 1), 39), + (40, 1 | (1 << 1), 39), + (2, (1 << 1), 43), + (9, (1 << 1), 43), + (23, (1 << 1), 43), + (40, 1 | (1 << 1), 43), + + # Node 80 + (3, (1 << 1), 39), + (6, (1 << 1), 39), + (10, (1 << 1), 39), + (15, (1 << 1), 39), + (24, (1 << 1), 39), + (31, (1 << 1), 39), + (41, (1 << 1), 39), + (56, 1 | (1 << 1), 39), + (3, (1 << 1), 43), + (6, (1 << 1), 43), + (10, (1 << 1), 43), + (15, (1 << 1), 43), + (24, (1 << 1), 43), + (31, (1 << 1), 43), + (41, (1 << 1), 43), + (56, 1 | (1 << 1), 43), + + # Node 81 + (2, (1 << 1), 124), + (9, (1 << 1), 124), + (23, (1 << 1), 124), + (40, 1 | (1 << 1), 124), + (1, (1 << 1), 35), + (22, 1 | (1 << 1), 35), + (1, (1 << 1), 62), + (22, 1 | (1 << 1), 62), + (0, 1 | (1 << 1), 0), + (0, 1 | (1 << 1), 36), + (0, 1 | (1 << 1), 64), + (0, 1 | (1 << 1), 91), + (0, 1 | (1 << 1), 93), + (0, 1 | (1 << 1), 126), + (91, 0, 0), + (92, 0, 0), + + # Node 82 + (3, (1 << 1), 124), + (6, (1 << 1), 124), + (10, (1 << 1), 124), + (15, (1 << 1), 124), + (24, (1 << 1), 124), + (31, (1 << 1), 124), + (41, (1 << 1), 124), + (56, 1 | (1 << 1), 124), + (2, (1 << 1), 35), + (9, (1 << 1), 35), + (23, (1 << 1), 35), + (40, 1 | (1 << 1), 35), + (2, (1 << 1), 62), + (9, (1 << 1), 62), + (23, (1 << 1), 62), + (40, 1 | (1 << 1), 62), + + # Node 83 + (3, (1 << 1), 35), + (6, (1 << 1), 35), + (10, (1 << 1), 35), + (15, (1 << 1), 35), + (24, (1 << 1), 35), + (31, (1 << 1), 35), + (41, (1 << 1), 35), + (56, 1 | (1 << 1), 35), + (3, (1 << 1), 62), + (6, (1 << 1), 62), + (10, (1 << 1), 62), + (15, (1 << 1), 62), + (24, (1 << 1), 62), + (31, (1 << 1), 62), + (41, (1 << 1), 62), + (56, 1 | (1 << 1), 62), + + # Node 84 + (1, (1 << 1), 0), + (22, 1 | (1 << 1), 0), + (1, (1 << 1), 36), + (22, 1 | (1 << 1), 36), + (1, (1 << 1), 64), + (22, 1 | (1 << 1), 64), + (1, (1 << 1), 91), + (22, 1 | (1 << 1), 91), + (1, (1 << 1), 93), + (22, 1 | (1 << 1), 93), + (1, (1 << 1), 126), + (22, 1 | (1 << 1), 126), + (0, 1 | (1 << 1), 94), + (0, 1 | (1 << 1), 125), + (93, 0, 0), + (94, 0, 0), + + # Node 85 + (2, (1 << 1), 0), + (9, (1 << 1), 0), + (23, (1 << 1), 0), + (40, 1 | (1 << 1), 0), + (2, (1 << 1), 36), + (9, (1 << 1), 36), + (23, (1 << 1), 36), + (40, 1 | (1 << 1), 36), + (2, (1 << 1), 64), + (9, (1 << 1), 64), + (23, (1 << 1), 64), + (40, 1 | (1 << 1), 64), + (2, (1 << 1), 91), + (9, (1 << 1), 91), + (23, (1 << 1), 91), + (40, 1 | (1 << 1), 91), + + # Node 86 + (3, (1 << 1), 0), + (6, (1 << 1), 0), + (10, (1 << 1), 0), + (15, (1 << 1), 0), + (24, (1 << 1), 0), + (31, (1 << 1), 0), + (41, (1 << 1), 0), + (56, 1 | (1 << 1), 0), + (3, (1 << 1), 36), + (6, (1 << 1), 36), + (10, (1 << 1), 36), + (15, (1 << 1), 36), + (24, (1 << 1), 36), + (31, (1 << 1), 36), + (41, (1 << 1), 36), + (56, 1 | (1 << 1), 36), + + # Node 87 + (3, (1 << 1), 64), + (6, (1 << 1), 64), + (10, (1 << 1), 64), + (15, (1 << 1), 64), + (24, (1 << 1), 64), + (31, (1 << 1), 64), + (41, (1 << 1), 64), + (56, 1 | (1 << 1), 64), + (3, (1 << 1), 91), + (6, (1 << 1), 91), + (10, (1 << 1), 91), + (15, (1 << 1), 91), + (24, (1 << 1), 91), + (31, (1 << 1), 91), + (41, (1 << 1), 91), + (56, 1 | (1 << 1), 91), + + # Node 88 + (2, (1 << 1), 93), + (9, (1 << 1), 93), + (23, (1 << 1), 93), + (40, 1 | (1 << 1), 93), + (2, (1 << 1), 126), + (9, (1 << 1), 126), + (23, (1 << 1), 126), + (40, 1 | (1 << 1), 126), + (1, (1 << 1), 94), + (22, 1 | (1 << 1), 94), + (1, (1 << 1), 125), + (22, 1 | (1 << 1), 125), + (0, 1 | (1 << 1), 60), + (0, 1 | (1 << 1), 96), + (0, 1 | (1 << 1), 123), + (95, 0, 0), + + # Node 89 + (3, (1 << 1), 93), + (6, (1 << 1), 93), + (10, (1 << 1), 93), + (15, (1 << 1), 93), + (24, (1 << 1), 93), + (31, (1 << 1), 93), + (41, (1 << 1), 93), + (56, 1 | (1 << 1), 93), + (3, (1 << 1), 126), + (6, (1 << 1), 126), + (10, (1 << 1), 126), + (15, (1 << 1), 126), + (24, (1 << 1), 126), + (31, (1 << 1), 126), + (41, (1 << 1), 126), + (56, 1 | (1 << 1), 126), + + # Node 90 + (2, (1 << 1), 94), + (9, (1 << 1), 94), + (23, (1 << 1), 94), + (40, 1 | (1 << 1), 94), + (2, (1 << 1), 125), + (9, (1 << 1), 125), + (23, (1 << 1), 125), + (40, 1 | (1 << 1), 125), + (1, (1 << 1), 60), + (22, 1 | (1 << 1), 60), + (1, (1 << 1), 96), + (22, 1 | (1 << 1), 96), + (1, (1 << 1), 123), + (22, 1 | (1 << 1), 123), + (96, 0, 0), + (110, 0, 0), + + # Node 91 + (3, (1 << 1), 94), + (6, (1 << 1), 94), + (10, (1 << 1), 94), + (15, (1 << 1), 94), + (24, (1 << 1), 94), + (31, (1 << 1), 94), + (41, (1 << 1), 94), + (56, 1 | (1 << 1), 94), + (3, (1 << 1), 125), + (6, (1 << 1), 125), + (10, (1 << 1), 125), + (15, (1 << 1), 125), + (24, (1 << 1), 125), + (31, (1 << 1), 125), + (41, (1 << 1), 125), + (56, 1 | (1 << 1), 125), + + # Node 92 + (2, (1 << 1), 60), + (9, (1 << 1), 60), + (23, (1 << 1), 60), + (40, 1 | (1 << 1), 60), + (2, (1 << 1), 96), + (9, (1 << 1), 96), + (23, (1 << 1), 96), + (40, 1 | (1 << 1), 96), + (2, (1 << 1), 123), + (9, (1 << 1), 123), + (23, (1 << 1), 123), + (40, 1 | (1 << 1), 123), + (97, 0, 0), + (101, 0, 0), + (111, 0, 0), + (133, 0, 0), + + # Node 93 + (3, (1 << 1), 60), + (6, (1 << 1), 60), + (10, (1 << 1), 60), + (15, (1 << 1), 60), + (24, (1 << 1), 60), + (31, (1 << 1), 60), + (41, (1 << 1), 60), + (56, 1 | (1 << 1), 60), + (3, (1 << 1), 96), + (6, (1 << 1), 96), + (10, (1 << 1), 96), + (15, (1 << 1), 96), + (24, (1 << 1), 96), + (31, (1 << 1), 96), + (41, (1 << 1), 96), + (56, 1 | (1 << 1), 96), + + # Node 94 + (3, (1 << 1), 123), + (6, (1 << 1), 123), + (10, (1 << 1), 123), + (15, (1 << 1), 123), + (24, (1 << 1), 123), + (31, (1 << 1), 123), + (41, (1 << 1), 123), + (56, 1 | (1 << 1), 123), + (98, 0, 0), + (99, 0, 0), + (102, 0, 0), + (105, 0, 0), + (112, 0, 0), + (119, 0, 0), + (134, 0, 0), + (153, 0, 0), + + # Node 95 + (0, 1 | (1 << 1), 92), + (0, 1 | (1 << 1), 195), + (0, 1 | (1 << 1), 208), + (100, 0, 0), + (103, 0, 0), + (104, 0, 0), + (106, 0, 0), + (107, 0, 0), + (113, 0, 0), + (116, 0, 0), + (120, 0, 0), + (126, 0, 0), + (135, 0, 0), + (142, 0, 0), + (154, 0, 0), + (169, 0, 0), + + # Node 96 + (1, (1 << 1), 92), + (22, 1 | (1 << 1), 92), + (1, (1 << 1), 195), + (22, 1 | (1 << 1), 195), + (1, (1 << 1), 208), + (22, 1 | (1 << 1), 208), + (0, 1 | (1 << 1), 128), + (0, 1 | (1 << 1), 130), + (0, 1 | (1 << 1), 131), + (0, 1 | (1 << 1), 162), + (0, 1 | (1 << 1), 184), + (0, 1 | (1 << 1), 194), + (0, 1 | (1 << 1), 224), + (0, 1 | (1 << 1), 226), + (108, 0, 0), + (109, 0, 0), + + # Node 97 + (2, (1 << 1), 92), + (9, (1 << 1), 92), + (23, (1 << 1), 92), + (40, 1 | (1 << 1), 92), + (2, (1 << 1), 195), + (9, (1 << 1), 195), + (23, (1 << 1), 195), + (40, 1 | (1 << 1), 195), + (2, (1 << 1), 208), + (9, (1 << 1), 208), + (23, (1 << 1), 208), + (40, 1 | (1 << 1), 208), + (1, (1 << 1), 128), + (22, 1 | (1 << 1), 128), + (1, (1 << 1), 130), + (22, 1 | (1 << 1), 130), + + # Node 98 + (3, (1 << 1), 92), + (6, (1 << 1), 92), + (10, (1 << 1), 92), + (15, (1 << 1), 92), + (24, (1 << 1), 92), + (31, (1 << 1), 92), + (41, (1 << 1), 92), + (56, 1 | (1 << 1), 92), + (3, (1 << 1), 195), + (6, (1 << 1), 195), + (10, (1 << 1), 195), + (15, (1 << 1), 195), + (24, (1 << 1), 195), + (31, (1 << 1), 195), + (41, (1 << 1), 195), + (56, 1 | (1 << 1), 195), + + # Node 99 + (3, (1 << 1), 208), + (6, (1 << 1), 208), + (10, (1 << 1), 208), + (15, (1 << 1), 208), + (24, (1 << 1), 208), + (31, (1 << 1), 208), + (41, (1 << 1), 208), + (56, 1 | (1 << 1), 208), + (2, (1 << 1), 128), + (9, (1 << 1), 128), + (23, (1 << 1), 128), + (40, 1 | (1 << 1), 128), + (2, (1 << 1), 130), + (9, (1 << 1), 130), + (23, (1 << 1), 130), + (40, 1 | (1 << 1), 130), + + # Node 100 + (3, (1 << 1), 128), + (6, (1 << 1), 128), + (10, (1 << 1), 128), + (15, (1 << 1), 128), + (24, (1 << 1), 128), + (31, (1 << 1), 128), + (41, (1 << 1), 128), + (56, 1 | (1 << 1), 128), + (3, (1 << 1), 130), + (6, (1 << 1), 130), + (10, (1 << 1), 130), + (15, (1 << 1), 130), + (24, (1 << 1), 130), + (31, (1 << 1), 130), + (41, (1 << 1), 130), + (56, 1 | (1 << 1), 130), + + # Node 101 + (1, (1 << 1), 131), + (22, 1 | (1 << 1), 131), + (1, (1 << 1), 162), + (22, 1 | (1 << 1), 162), + (1, (1 << 1), 184), + (22, 1 | (1 << 1), 184), + (1, (1 << 1), 194), + (22, 1 | (1 << 1), 194), + (1, (1 << 1), 224), + (22, 1 | (1 << 1), 224), + (1, (1 << 1), 226), + (22, 1 | (1 << 1), 226), + (0, 1 | (1 << 1), 153), + (0, 1 | (1 << 1), 161), + (0, 1 | (1 << 1), 167), + (0, 1 | (1 << 1), 172), + + # Node 102 + (2, (1 << 1), 131), + (9, (1 << 1), 131), + (23, (1 << 1), 131), + (40, 1 | (1 << 1), 131), + (2, (1 << 1), 162), + (9, (1 << 1), 162), + (23, (1 << 1), 162), + (40, 1 | (1 << 1), 162), + (2, (1 << 1), 184), + (9, (1 << 1), 184), + (23, (1 << 1), 184), + (40, 1 | (1 << 1), 184), + (2, (1 << 1), 194), + (9, (1 << 1), 194), + (23, (1 << 1), 194), + (40, 1 | (1 << 1), 194), + + # Node 103 + (3, (1 << 1), 131), + (6, (1 << 1), 131), + (10, (1 << 1), 131), + (15, (1 << 1), 131), + (24, (1 << 1), 131), + (31, (1 << 1), 131), + (41, (1 << 1), 131), + (56, 1 | (1 << 1), 131), + (3, (1 << 1), 162), + (6, (1 << 1), 162), + (10, (1 << 1), 162), + (15, (1 << 1), 162), + (24, (1 << 1), 162), + (31, (1 << 1), 162), + (41, (1 << 1), 162), + (56, 1 | (1 << 1), 162), + + # Node 104 + (3, (1 << 1), 184), + (6, (1 << 1), 184), + (10, (1 << 1), 184), + (15, (1 << 1), 184), + (24, (1 << 1), 184), + (31, (1 << 1), 184), + (41, (1 << 1), 184), + (56, 1 | (1 << 1), 184), + (3, (1 << 1), 194), + (6, (1 << 1), 194), + (10, (1 << 1), 194), + (15, (1 << 1), 194), + (24, (1 << 1), 194), + (31, (1 << 1), 194), + (41, (1 << 1), 194), + (56, 1 | (1 << 1), 194), + + # Node 105 + (2, (1 << 1), 224), + (9, (1 << 1), 224), + (23, (1 << 1), 224), + (40, 1 | (1 << 1), 224), + (2, (1 << 1), 226), + (9, (1 << 1), 226), + (23, (1 << 1), 226), + (40, 1 | (1 << 1), 226), + (1, (1 << 1), 153), + (22, 1 | (1 << 1), 153), + (1, (1 << 1), 161), + (22, 1 | (1 << 1), 161), + (1, (1 << 1), 167), + (22, 1 | (1 << 1), 167), + (1, (1 << 1), 172), + (22, 1 | (1 << 1), 172), + + # Node 106 + (3, (1 << 1), 224), + (6, (1 << 1), 224), + (10, (1 << 1), 224), + (15, (1 << 1), 224), + (24, (1 << 1), 224), + (31, (1 << 1), 224), + (41, (1 << 1), 224), + (56, 1 | (1 << 1), 224), + (3, (1 << 1), 226), + (6, (1 << 1), 226), + (10, (1 << 1), 226), + (15, (1 << 1), 226), + (24, (1 << 1), 226), + (31, (1 << 1), 226), + (41, (1 << 1), 226), + (56, 1 | (1 << 1), 226), + + # Node 107 + (2, (1 << 1), 153), + (9, (1 << 1), 153), + (23, (1 << 1), 153), + (40, 1 | (1 << 1), 153), + (2, (1 << 1), 161), + (9, (1 << 1), 161), + (23, (1 << 1), 161), + (40, 1 | (1 << 1), 161), + (2, (1 << 1), 167), + (9, (1 << 1), 167), + (23, (1 << 1), 167), + (40, 1 | (1 << 1), 167), + (2, (1 << 1), 172), + (9, (1 << 1), 172), + (23, (1 << 1), 172), + (40, 1 | (1 << 1), 172), + + # Node 108 + (3, (1 << 1), 153), + (6, (1 << 1), 153), + (10, (1 << 1), 153), + (15, (1 << 1), 153), + (24, (1 << 1), 153), + (31, (1 << 1), 153), + (41, (1 << 1), 153), + (56, 1 | (1 << 1), 153), + (3, (1 << 1), 161), + (6, (1 << 1), 161), + (10, (1 << 1), 161), + (15, (1 << 1), 161), + (24, (1 << 1), 161), + (31, (1 << 1), 161), + (41, (1 << 1), 161), + (56, 1 | (1 << 1), 161), + + # Node 109 + (3, (1 << 1), 167), + (6, (1 << 1), 167), + (10, (1 << 1), 167), + (15, (1 << 1), 167), + (24, (1 << 1), 167), + (31, (1 << 1), 167), + (41, (1 << 1), 167), + (56, 1 | (1 << 1), 167), + (3, (1 << 1), 172), + (6, (1 << 1), 172), + (10, (1 << 1), 172), + (15, (1 << 1), 172), + (24, (1 << 1), 172), + (31, (1 << 1), 172), + (41, (1 << 1), 172), + (56, 1 | (1 << 1), 172), + + # Node 110 + (114, 0, 0), + (115, 0, 0), + (117, 0, 0), + (118, 0, 0), + (121, 0, 0), + (123, 0, 0), + (127, 0, 0), + (130, 0, 0), + (136, 0, 0), + (139, 0, 0), + (143, 0, 0), + (146, 0, 0), + (155, 0, 0), + (162, 0, 0), + (170, 0, 0), + (180, 0, 0), + + # Node 111 + (0, 1 | (1 << 1), 176), + (0, 1 | (1 << 1), 177), + (0, 1 | (1 << 1), 179), + (0, 1 | (1 << 1), 209), + (0, 1 | (1 << 1), 216), + (0, 1 | (1 << 1), 217), + (0, 1 | (1 << 1), 227), + (0, 1 | (1 << 1), 229), + (0, 1 | (1 << 1), 230), + (122, 0, 0), + (124, 0, 0), + (125, 0, 0), + (128, 0, 0), + (129, 0, 0), + (131, 0, 0), + (132, 0, 0), + + # Node 112 + (1, (1 << 1), 176), + (22, 1 | (1 << 1), 176), + (1, (1 << 1), 177), + (22, 1 | (1 << 1), 177), + (1, (1 << 1), 179), + (22, 1 | (1 << 1), 179), + (1, (1 << 1), 209), + (22, 1 | (1 << 1), 209), + (1, (1 << 1), 216), + (22, 1 | (1 << 1), 216), + (1, (1 << 1), 217), + (22, 1 | (1 << 1), 217), + (1, (1 << 1), 227), + (22, 1 | (1 << 1), 227), + (1, (1 << 1), 229), + (22, 1 | (1 << 1), 229), + + # Node 113 + (2, (1 << 1), 176), + (9, (1 << 1), 176), + (23, (1 << 1), 176), + (40, 1 | (1 << 1), 176), + (2, (1 << 1), 177), + (9, (1 << 1), 177), + (23, (1 << 1), 177), + (40, 1 | (1 << 1), 177), + (2, (1 << 1), 179), + (9, (1 << 1), 179), + (23, (1 << 1), 179), + (40, 1 | (1 << 1), 179), + (2, (1 << 1), 209), + (9, (1 << 1), 209), + (23, (1 << 1), 209), + (40, 1 | (1 << 1), 209), + + # Node 114 + (3, (1 << 1), 176), + (6, (1 << 1), 176), + (10, (1 << 1), 176), + (15, (1 << 1), 176), + (24, (1 << 1), 176), + (31, (1 << 1), 176), + (41, (1 << 1), 176), + (56, 1 | (1 << 1), 176), + (3, (1 << 1), 177), + (6, (1 << 1), 177), + (10, (1 << 1), 177), + (15, (1 << 1), 177), + (24, (1 << 1), 177), + (31, (1 << 1), 177), + (41, (1 << 1), 177), + (56, 1 | (1 << 1), 177), + + # Node 115 + (3, (1 << 1), 179), + (6, (1 << 1), 179), + (10, (1 << 1), 179), + (15, (1 << 1), 179), + (24, (1 << 1), 179), + (31, (1 << 1), 179), + (41, (1 << 1), 179), + (56, 1 | (1 << 1), 179), + (3, (1 << 1), 209), + (6, (1 << 1), 209), + (10, (1 << 1), 209), + (15, (1 << 1), 209), + (24, (1 << 1), 209), + (31, (1 << 1), 209), + (41, (1 << 1), 209), + (56, 1 | (1 << 1), 209), + + # Node 116 + (2, (1 << 1), 216), + (9, (1 << 1), 216), + (23, (1 << 1), 216), + (40, 1 | (1 << 1), 216), + (2, (1 << 1), 217), + (9, (1 << 1), 217), + (23, (1 << 1), 217), + (40, 1 | (1 << 1), 217), + (2, (1 << 1), 227), + (9, (1 << 1), 227), + (23, (1 << 1), 227), + (40, 1 | (1 << 1), 227), + (2, (1 << 1), 229), + (9, (1 << 1), 229), + (23, (1 << 1), 229), + (40, 1 | (1 << 1), 229), + + # Node 117 + (3, (1 << 1), 216), + (6, (1 << 1), 216), + (10, (1 << 1), 216), + (15, (1 << 1), 216), + (24, (1 << 1), 216), + (31, (1 << 1), 216), + (41, (1 << 1), 216), + (56, 1 | (1 << 1), 216), + (3, (1 << 1), 217), + (6, (1 << 1), 217), + (10, (1 << 1), 217), + (15, (1 << 1), 217), + (24, (1 << 1), 217), + (31, (1 << 1), 217), + (41, (1 << 1), 217), + (56, 1 | (1 << 1), 217), + + # Node 118 + (3, (1 << 1), 227), + (6, (1 << 1), 227), + (10, (1 << 1), 227), + (15, (1 << 1), 227), + (24, (1 << 1), 227), + (31, (1 << 1), 227), + (41, (1 << 1), 227), + (56, 1 | (1 << 1), 227), + (3, (1 << 1), 229), + (6, (1 << 1), 229), + (10, (1 << 1), 229), + (15, (1 << 1), 229), + (24, (1 << 1), 229), + (31, (1 << 1), 229), + (41, (1 << 1), 229), + (56, 1 | (1 << 1), 229), + + # Node 119 + (1, (1 << 1), 230), + (22, 1 | (1 << 1), 230), + (0, 1 | (1 << 1), 129), + (0, 1 | (1 << 1), 132), + (0, 1 | (1 << 1), 133), + (0, 1 | (1 << 1), 134), + (0, 1 | (1 << 1), 136), + (0, 1 | (1 << 1), 146), + (0, 1 | (1 << 1), 154), + (0, 1 | (1 << 1), 156), + (0, 1 | (1 << 1), 160), + (0, 1 | (1 << 1), 163), + (0, 1 | (1 << 1), 164), + (0, 1 | (1 << 1), 169), + (0, 1 | (1 << 1), 170), + (0, 1 | (1 << 1), 173), + + # Node 120 + (2, (1 << 1), 230), + (9, (1 << 1), 230), + (23, (1 << 1), 230), + (40, 1 | (1 << 1), 230), + (1, (1 << 1), 129), + (22, 1 | (1 << 1), 129), + (1, (1 << 1), 132), + (22, 1 | (1 << 1), 132), + (1, (1 << 1), 133), + (22, 1 | (1 << 1), 133), + (1, (1 << 1), 134), + (22, 1 | (1 << 1), 134), + (1, (1 << 1), 136), + (22, 1 | (1 << 1), 136), + (1, (1 << 1), 146), + (22, 1 | (1 << 1), 146), + + # Node 121 + (3, (1 << 1), 230), + (6, (1 << 1), 230), + (10, (1 << 1), 230), + (15, (1 << 1), 230), + (24, (1 << 1), 230), + (31, (1 << 1), 230), + (41, (1 << 1), 230), + (56, 1 | (1 << 1), 230), + (2, (1 << 1), 129), + (9, (1 << 1), 129), + (23, (1 << 1), 129), + (40, 1 | (1 << 1), 129), + (2, (1 << 1), 132), + (9, (1 << 1), 132), + (23, (1 << 1), 132), + (40, 1 | (1 << 1), 132), + + # Node 122 + (3, (1 << 1), 129), + (6, (1 << 1), 129), + (10, (1 << 1), 129), + (15, (1 << 1), 129), + (24, (1 << 1), 129), + (31, (1 << 1), 129), + (41, (1 << 1), 129), + (56, 1 | (1 << 1), 129), + (3, (1 << 1), 132), + (6, (1 << 1), 132), + (10, (1 << 1), 132), + (15, (1 << 1), 132), + (24, (1 << 1), 132), + (31, (1 << 1), 132), + (41, (1 << 1), 132), + (56, 1 | (1 << 1), 132), + + # Node 123 + (2, (1 << 1), 133), + (9, (1 << 1), 133), + (23, (1 << 1), 133), + (40, 1 | (1 << 1), 133), + (2, (1 << 1), 134), + (9, (1 << 1), 134), + (23, (1 << 1), 134), + (40, 1 | (1 << 1), 134), + (2, (1 << 1), 136), + (9, (1 << 1), 136), + (23, (1 << 1), 136), + (40, 1 | (1 << 1), 136), + (2, (1 << 1), 146), + (9, (1 << 1), 146), + (23, (1 << 1), 146), + (40, 1 | (1 << 1), 146), + + # Node 124 + (3, (1 << 1), 133), + (6, (1 << 1), 133), + (10, (1 << 1), 133), + (15, (1 << 1), 133), + (24, (1 << 1), 133), + (31, (1 << 1), 133), + (41, (1 << 1), 133), + (56, 1 | (1 << 1), 133), + (3, (1 << 1), 134), + (6, (1 << 1), 134), + (10, (1 << 1), 134), + (15, (1 << 1), 134), + (24, (1 << 1), 134), + (31, (1 << 1), 134), + (41, (1 << 1), 134), + (56, 1 | (1 << 1), 134), + + # Node 125 + (3, (1 << 1), 136), + (6, (1 << 1), 136), + (10, (1 << 1), 136), + (15, (1 << 1), 136), + (24, (1 << 1), 136), + (31, (1 << 1), 136), + (41, (1 << 1), 136), + (56, 1 | (1 << 1), 136), + (3, (1 << 1), 146), + (6, (1 << 1), 146), + (10, (1 << 1), 146), + (15, (1 << 1), 146), + (24, (1 << 1), 146), + (31, (1 << 1), 146), + (41, (1 << 1), 146), + (56, 1 | (1 << 1), 146), + + # Node 126 + (1, (1 << 1), 154), + (22, 1 | (1 << 1), 154), + (1, (1 << 1), 156), + (22, 1 | (1 << 1), 156), + (1, (1 << 1), 160), + (22, 1 | (1 << 1), 160), + (1, (1 << 1), 163), + (22, 1 | (1 << 1), 163), + (1, (1 << 1), 164), + (22, 1 | (1 << 1), 164), + (1, (1 << 1), 169), + (22, 1 | (1 << 1), 169), + (1, (1 << 1), 170), + (22, 1 | (1 << 1), 170), + (1, (1 << 1), 173), + (22, 1 | (1 << 1), 173), + + # Node 127 + (2, (1 << 1), 154), + (9, (1 << 1), 154), + (23, (1 << 1), 154), + (40, 1 | (1 << 1), 154), + (2, (1 << 1), 156), + (9, (1 << 1), 156), + (23, (1 << 1), 156), + (40, 1 | (1 << 1), 156), + (2, (1 << 1), 160), + (9, (1 << 1), 160), + (23, (1 << 1), 160), + (40, 1 | (1 << 1), 160), + (2, (1 << 1), 163), + (9, (1 << 1), 163), + (23, (1 << 1), 163), + (40, 1 | (1 << 1), 163), + + # Node 128 + (3, (1 << 1), 154), + (6, (1 << 1), 154), + (10, (1 << 1), 154), + (15, (1 << 1), 154), + (24, (1 << 1), 154), + (31, (1 << 1), 154), + (41, (1 << 1), 154), + (56, 1 | (1 << 1), 154), + (3, (1 << 1), 156), + (6, (1 << 1), 156), + (10, (1 << 1), 156), + (15, (1 << 1), 156), + (24, (1 << 1), 156), + (31, (1 << 1), 156), + (41, (1 << 1), 156), + (56, 1 | (1 << 1), 156), + + # Node 129 + (3, (1 << 1), 160), + (6, (1 << 1), 160), + (10, (1 << 1), 160), + (15, (1 << 1), 160), + (24, (1 << 1), 160), + (31, (1 << 1), 160), + (41, (1 << 1), 160), + (56, 1 | (1 << 1), 160), + (3, (1 << 1), 163), + (6, (1 << 1), 163), + (10, (1 << 1), 163), + (15, (1 << 1), 163), + (24, (1 << 1), 163), + (31, (1 << 1), 163), + (41, (1 << 1), 163), + (56, 1 | (1 << 1), 163), + + # Node 130 + (2, (1 << 1), 164), + (9, (1 << 1), 164), + (23, (1 << 1), 164), + (40, 1 | (1 << 1), 164), + (2, (1 << 1), 169), + (9, (1 << 1), 169), + (23, (1 << 1), 169), + (40, 1 | (1 << 1), 169), + (2, (1 << 1), 170), + (9, (1 << 1), 170), + (23, (1 << 1), 170), + (40, 1 | (1 << 1), 170), + (2, (1 << 1), 173), + (9, (1 << 1), 173), + (23, (1 << 1), 173), + (40, 1 | (1 << 1), 173), + + # Node 131 + (3, (1 << 1), 164), + (6, (1 << 1), 164), + (10, (1 << 1), 164), + (15, (1 << 1), 164), + (24, (1 << 1), 164), + (31, (1 << 1), 164), + (41, (1 << 1), 164), + (56, 1 | (1 << 1), 164), + (3, (1 << 1), 169), + (6, (1 << 1), 169), + (10, (1 << 1), 169), + (15, (1 << 1), 169), + (24, (1 << 1), 169), + (31, (1 << 1), 169), + (41, (1 << 1), 169), + (56, 1 | (1 << 1), 169), + + # Node 132 + (3, (1 << 1), 170), + (6, (1 << 1), 170), + (10, (1 << 1), 170), + (15, (1 << 1), 170), + (24, (1 << 1), 170), + (31, (1 << 1), 170), + (41, (1 << 1), 170), + (56, 1 | (1 << 1), 170), + (3, (1 << 1), 173), + (6, (1 << 1), 173), + (10, (1 << 1), 173), + (15, (1 << 1), 173), + (24, (1 << 1), 173), + (31, (1 << 1), 173), + (41, (1 << 1), 173), + (56, 1 | (1 << 1), 173), + + # Node 133 + (137, 0, 0), + (138, 0, 0), + (140, 0, 0), + (141, 0, 0), + (144, 0, 0), + (145, 0, 0), + (147, 0, 0), + (150, 0, 0), + (156, 0, 0), + (159, 0, 0), + (163, 0, 0), + (166, 0, 0), + (171, 0, 0), + (174, 0, 0), + (181, 0, 0), + (190, 0, 0), + + # Node 134 + (0, 1 | (1 << 1), 178), + (0, 1 | (1 << 1), 181), + (0, 1 | (1 << 1), 185), + (0, 1 | (1 << 1), 186), + (0, 1 | (1 << 1), 187), + (0, 1 | (1 << 1), 189), + (0, 1 | (1 << 1), 190), + (0, 1 | (1 << 1), 196), + (0, 1 | (1 << 1), 198), + (0, 1 | (1 << 1), 228), + (0, 1 | (1 << 1), 232), + (0, 1 | (1 << 1), 233), + (148, 0, 0), + (149, 0, 0), + (151, 0, 0), + (152, 0, 0), + + # Node 135 + (1, (1 << 1), 178), + (22, 1 | (1 << 1), 178), + (1, (1 << 1), 181), + (22, 1 | (1 << 1), 181), + (1, (1 << 1), 185), + (22, 1 | (1 << 1), 185), + (1, (1 << 1), 186), + (22, 1 | (1 << 1), 186), + (1, (1 << 1), 187), + (22, 1 | (1 << 1), 187), + (1, (1 << 1), 189), + (22, 1 | (1 << 1), 189), + (1, (1 << 1), 190), + (22, 1 | (1 << 1), 190), + (1, (1 << 1), 196), + (22, 1 | (1 << 1), 196), + + # Node 136 + (2, (1 << 1), 178), + (9, (1 << 1), 178), + (23, (1 << 1), 178), + (40, 1 | (1 << 1), 178), + (2, (1 << 1), 181), + (9, (1 << 1), 181), + (23, (1 << 1), 181), + (40, 1 | (1 << 1), 181), + (2, (1 << 1), 185), + (9, (1 << 1), 185), + (23, (1 << 1), 185), + (40, 1 | (1 << 1), 185), + (2, (1 << 1), 186), + (9, (1 << 1), 186), + (23, (1 << 1), 186), + (40, 1 | (1 << 1), 186), + + # Node 137 + (3, (1 << 1), 178), + (6, (1 << 1), 178), + (10, (1 << 1), 178), + (15, (1 << 1), 178), + (24, (1 << 1), 178), + (31, (1 << 1), 178), + (41, (1 << 1), 178), + (56, 1 | (1 << 1), 178), + (3, (1 << 1), 181), + (6, (1 << 1), 181), + (10, (1 << 1), 181), + (15, (1 << 1), 181), + (24, (1 << 1), 181), + (31, (1 << 1), 181), + (41, (1 << 1), 181), + (56, 1 | (1 << 1), 181), + + # Node 138 + (3, (1 << 1), 185), + (6, (1 << 1), 185), + (10, (1 << 1), 185), + (15, (1 << 1), 185), + (24, (1 << 1), 185), + (31, (1 << 1), 185), + (41, (1 << 1), 185), + (56, 1 | (1 << 1), 185), + (3, (1 << 1), 186), + (6, (1 << 1), 186), + (10, (1 << 1), 186), + (15, (1 << 1), 186), + (24, (1 << 1), 186), + (31, (1 << 1), 186), + (41, (1 << 1), 186), + (56, 1 | (1 << 1), 186), + + # Node 139 + (2, (1 << 1), 187), + (9, (1 << 1), 187), + (23, (1 << 1), 187), + (40, 1 | (1 << 1), 187), + (2, (1 << 1), 189), + (9, (1 << 1), 189), + (23, (1 << 1), 189), + (40, 1 | (1 << 1), 189), + (2, (1 << 1), 190), + (9, (1 << 1), 190), + (23, (1 << 1), 190), + (40, 1 | (1 << 1), 190), + (2, (1 << 1), 196), + (9, (1 << 1), 196), + (23, (1 << 1), 196), + (40, 1 | (1 << 1), 196), + + # Node 140 + (3, (1 << 1), 187), + (6, (1 << 1), 187), + (10, (1 << 1), 187), + (15, (1 << 1), 187), + (24, (1 << 1), 187), + (31, (1 << 1), 187), + (41, (1 << 1), 187), + (56, 1 | (1 << 1), 187), + (3, (1 << 1), 189), + (6, (1 << 1), 189), + (10, (1 << 1), 189), + (15, (1 << 1), 189), + (24, (1 << 1), 189), + (31, (1 << 1), 189), + (41, (1 << 1), 189), + (56, 1 | (1 << 1), 189), + + # Node 141 + (3, (1 << 1), 190), + (6, (1 << 1), 190), + (10, (1 << 1), 190), + (15, (1 << 1), 190), + (24, (1 << 1), 190), + (31, (1 << 1), 190), + (41, (1 << 1), 190), + (56, 1 | (1 << 1), 190), + (3, (1 << 1), 196), + (6, (1 << 1), 196), + (10, (1 << 1), 196), + (15, (1 << 1), 196), + (24, (1 << 1), 196), + (31, (1 << 1), 196), + (41, (1 << 1), 196), + (56, 1 | (1 << 1), 196), + + # Node 142 + (1, (1 << 1), 198), + (22, 1 | (1 << 1), 198), + (1, (1 << 1), 228), + (22, 1 | (1 << 1), 228), + (1, (1 << 1), 232), + (22, 1 | (1 << 1), 232), + (1, (1 << 1), 233), + (22, 1 | (1 << 1), 233), + (0, 1 | (1 << 1), 1), + (0, 1 | (1 << 1), 135), + (0, 1 | (1 << 1), 137), + (0, 1 | (1 << 1), 138), + (0, 1 | (1 << 1), 139), + (0, 1 | (1 << 1), 140), + (0, 1 | (1 << 1), 141), + (0, 1 | (1 << 1), 143), + + # Node 143 + (2, (1 << 1), 198), + (9, (1 << 1), 198), + (23, (1 << 1), 198), + (40, 1 | (1 << 1), 198), + (2, (1 << 1), 228), + (9, (1 << 1), 228), + (23, (1 << 1), 228), + (40, 1 | (1 << 1), 228), + (2, (1 << 1), 232), + (9, (1 << 1), 232), + (23, (1 << 1), 232), + (40, 1 | (1 << 1), 232), + (2, (1 << 1), 233), + (9, (1 << 1), 233), + (23, (1 << 1), 233), + (40, 1 | (1 << 1), 233), + + # Node 144 + (3, (1 << 1), 198), + (6, (1 << 1), 198), + (10, (1 << 1), 198), + (15, (1 << 1), 198), + (24, (1 << 1), 198), + (31, (1 << 1), 198), + (41, (1 << 1), 198), + (56, 1 | (1 << 1), 198), + (3, (1 << 1), 228), + (6, (1 << 1), 228), + (10, (1 << 1), 228), + (15, (1 << 1), 228), + (24, (1 << 1), 228), + (31, (1 << 1), 228), + (41, (1 << 1), 228), + (56, 1 | (1 << 1), 228), + + # Node 145 + (3, (1 << 1), 232), + (6, (1 << 1), 232), + (10, (1 << 1), 232), + (15, (1 << 1), 232), + (24, (1 << 1), 232), + (31, (1 << 1), 232), + (41, (1 << 1), 232), + (56, 1 | (1 << 1), 232), + (3, (1 << 1), 233), + (6, (1 << 1), 233), + (10, (1 << 1), 233), + (15, (1 << 1), 233), + (24, (1 << 1), 233), + (31, (1 << 1), 233), + (41, (1 << 1), 233), + (56, 1 | (1 << 1), 233), + + # Node 146 + (1, (1 << 1), 1), + (22, 1 | (1 << 1), 1), + (1, (1 << 1), 135), + (22, 1 | (1 << 1), 135), + (1, (1 << 1), 137), + (22, 1 | (1 << 1), 137), + (1, (1 << 1), 138), + (22, 1 | (1 << 1), 138), + (1, (1 << 1), 139), + (22, 1 | (1 << 1), 139), + (1, (1 << 1), 140), + (22, 1 | (1 << 1), 140), + (1, (1 << 1), 141), + (22, 1 | (1 << 1), 141), + (1, (1 << 1), 143), + (22, 1 | (1 << 1), 143), + + # Node 147 + (2, (1 << 1), 1), + (9, (1 << 1), 1), + (23, (1 << 1), 1), + (40, 1 | (1 << 1), 1), + (2, (1 << 1), 135), + (9, (1 << 1), 135), + (23, (1 << 1), 135), + (40, 1 | (1 << 1), 135), + (2, (1 << 1), 137), + (9, (1 << 1), 137), + (23, (1 << 1), 137), + (40, 1 | (1 << 1), 137), + (2, (1 << 1), 138), + (9, (1 << 1), 138), + (23, (1 << 1), 138), + (40, 1 | (1 << 1), 138), + + # Node 148 + (3, (1 << 1), 1), + (6, (1 << 1), 1), + (10, (1 << 1), 1), + (15, (1 << 1), 1), + (24, (1 << 1), 1), + (31, (1 << 1), 1), + (41, (1 << 1), 1), + (56, 1 | (1 << 1), 1), + (3, (1 << 1), 135), + (6, (1 << 1), 135), + (10, (1 << 1), 135), + (15, (1 << 1), 135), + (24, (1 << 1), 135), + (31, (1 << 1), 135), + (41, (1 << 1), 135), + (56, 1 | (1 << 1), 135), + + # Node 149 + (3, (1 << 1), 137), + (6, (1 << 1), 137), + (10, (1 << 1), 137), + (15, (1 << 1), 137), + (24, (1 << 1), 137), + (31, (1 << 1), 137), + (41, (1 << 1), 137), + (56, 1 | (1 << 1), 137), + (3, (1 << 1), 138), + (6, (1 << 1), 138), + (10, (1 << 1), 138), + (15, (1 << 1), 138), + (24, (1 << 1), 138), + (31, (1 << 1), 138), + (41, (1 << 1), 138), + (56, 1 | (1 << 1), 138), + + # Node 150 + (2, (1 << 1), 139), + (9, (1 << 1), 139), + (23, (1 << 1), 139), + (40, 1 | (1 << 1), 139), + (2, (1 << 1), 140), + (9, (1 << 1), 140), + (23, (1 << 1), 140), + (40, 1 | (1 << 1), 140), + (2, (1 << 1), 141), + (9, (1 << 1), 141), + (23, (1 << 1), 141), + (40, 1 | (1 << 1), 141), + (2, (1 << 1), 143), + (9, (1 << 1), 143), + (23, (1 << 1), 143), + (40, 1 | (1 << 1), 143), + + # Node 151 + (3, (1 << 1), 139), + (6, (1 << 1), 139), + (10, (1 << 1), 139), + (15, (1 << 1), 139), + (24, (1 << 1), 139), + (31, (1 << 1), 139), + (41, (1 << 1), 139), + (56, 1 | (1 << 1), 139), + (3, (1 << 1), 140), + (6, (1 << 1), 140), + (10, (1 << 1), 140), + (15, (1 << 1), 140), + (24, (1 << 1), 140), + (31, (1 << 1), 140), + (41, (1 << 1), 140), + (56, 1 | (1 << 1), 140), + + # Node 152 + (3, (1 << 1), 141), + (6, (1 << 1), 141), + (10, (1 << 1), 141), + (15, (1 << 1), 141), + (24, (1 << 1), 141), + (31, (1 << 1), 141), + (41, (1 << 1), 141), + (56, 1 | (1 << 1), 141), + (3, (1 << 1), 143), + (6, (1 << 1), 143), + (10, (1 << 1), 143), + (15, (1 << 1), 143), + (24, (1 << 1), 143), + (31, (1 << 1), 143), + (41, (1 << 1), 143), + (56, 1 | (1 << 1), 143), + + # Node 153 + (157, 0, 0), + (158, 0, 0), + (160, 0, 0), + (161, 0, 0), + (164, 0, 0), + (165, 0, 0), + (167, 0, 0), + (168, 0, 0), + (172, 0, 0), + (173, 0, 0), + (175, 0, 0), + (177, 0, 0), + (182, 0, 0), + (185, 0, 0), + (191, 0, 0), + (207, 0, 0), + + # Node 154 + (0, 1 | (1 << 1), 147), + (0, 1 | (1 << 1), 149), + (0, 1 | (1 << 1), 150), + (0, 1 | (1 << 1), 151), + (0, 1 | (1 << 1), 152), + (0, 1 | (1 << 1), 155), + (0, 1 | (1 << 1), 157), + (0, 1 | (1 << 1), 158), + (0, 1 | (1 << 1), 165), + (0, 1 | (1 << 1), 166), + (0, 1 | (1 << 1), 168), + (0, 1 | (1 << 1), 174), + (0, 1 | (1 << 1), 175), + (0, 1 | (1 << 1), 180), + (0, 1 | (1 << 1), 182), + (0, 1 | (1 << 1), 183), + + # Node 155 + (1, (1 << 1), 147), + (22, 1 | (1 << 1), 147), + (1, (1 << 1), 149), + (22, 1 | (1 << 1), 149), + (1, (1 << 1), 150), + (22, 1 | (1 << 1), 150), + (1, (1 << 1), 151), + (22, 1 | (1 << 1), 151), + (1, (1 << 1), 152), + (22, 1 | (1 << 1), 152), + (1, (1 << 1), 155), + (22, 1 | (1 << 1), 155), + (1, (1 << 1), 157), + (22, 1 | (1 << 1), 157), + (1, (1 << 1), 158), + (22, 1 | (1 << 1), 158), + + # Node 156 + (2, (1 << 1), 147), + (9, (1 << 1), 147), + (23, (1 << 1), 147), + (40, 1 | (1 << 1), 147), + (2, (1 << 1), 149), + (9, (1 << 1), 149), + (23, (1 << 1), 149), + (40, 1 | (1 << 1), 149), + (2, (1 << 1), 150), + (9, (1 << 1), 150), + (23, (1 << 1), 150), + (40, 1 | (1 << 1), 150), + (2, (1 << 1), 151), + (9, (1 << 1), 151), + (23, (1 << 1), 151), + (40, 1 | (1 << 1), 151), + + # Node 157 + (3, (1 << 1), 147), + (6, (1 << 1), 147), + (10, (1 << 1), 147), + (15, (1 << 1), 147), + (24, (1 << 1), 147), + (31, (1 << 1), 147), + (41, (1 << 1), 147), + (56, 1 | (1 << 1), 147), + (3, (1 << 1), 149), + (6, (1 << 1), 149), + (10, (1 << 1), 149), + (15, (1 << 1), 149), + (24, (1 << 1), 149), + (31, (1 << 1), 149), + (41, (1 << 1), 149), + (56, 1 | (1 << 1), 149), + + # Node 158 + (3, (1 << 1), 150), + (6, (1 << 1), 150), + (10, (1 << 1), 150), + (15, (1 << 1), 150), + (24, (1 << 1), 150), + (31, (1 << 1), 150), + (41, (1 << 1), 150), + (56, 1 | (1 << 1), 150), + (3, (1 << 1), 151), + (6, (1 << 1), 151), + (10, (1 << 1), 151), + (15, (1 << 1), 151), + (24, (1 << 1), 151), + (31, (1 << 1), 151), + (41, (1 << 1), 151), + (56, 1 | (1 << 1), 151), + + # Node 159 + (2, (1 << 1), 152), + (9, (1 << 1), 152), + (23, (1 << 1), 152), + (40, 1 | (1 << 1), 152), + (2, (1 << 1), 155), + (9, (1 << 1), 155), + (23, (1 << 1), 155), + (40, 1 | (1 << 1), 155), + (2, (1 << 1), 157), + (9, (1 << 1), 157), + (23, (1 << 1), 157), + (40, 1 | (1 << 1), 157), + (2, (1 << 1), 158), + (9, (1 << 1), 158), + (23, (1 << 1), 158), + (40, 1 | (1 << 1), 158), + + # Node 160 + (3, (1 << 1), 152), + (6, (1 << 1), 152), + (10, (1 << 1), 152), + (15, (1 << 1), 152), + (24, (1 << 1), 152), + (31, (1 << 1), 152), + (41, (1 << 1), 152), + (56, 1 | (1 << 1), 152), + (3, (1 << 1), 155), + (6, (1 << 1), 155), + (10, (1 << 1), 155), + (15, (1 << 1), 155), + (24, (1 << 1), 155), + (31, (1 << 1), 155), + (41, (1 << 1), 155), + (56, 1 | (1 << 1), 155), + + # Node 161 + (3, (1 << 1), 157), + (6, (1 << 1), 157), + (10, (1 << 1), 157), + (15, (1 << 1), 157), + (24, (1 << 1), 157), + (31, (1 << 1), 157), + (41, (1 << 1), 157), + (56, 1 | (1 << 1), 157), + (3, (1 << 1), 158), + (6, (1 << 1), 158), + (10, (1 << 1), 158), + (15, (1 << 1), 158), + (24, (1 << 1), 158), + (31, (1 << 1), 158), + (41, (1 << 1), 158), + (56, 1 | (1 << 1), 158), + + # Node 162 + (1, (1 << 1), 165), + (22, 1 | (1 << 1), 165), + (1, (1 << 1), 166), + (22, 1 | (1 << 1), 166), + (1, (1 << 1), 168), + (22, 1 | (1 << 1), 168), + (1, (1 << 1), 174), + (22, 1 | (1 << 1), 174), + (1, (1 << 1), 175), + (22, 1 | (1 << 1), 175), + (1, (1 << 1), 180), + (22, 1 | (1 << 1), 180), + (1, (1 << 1), 182), + (22, 1 | (1 << 1), 182), + (1, (1 << 1), 183), + (22, 1 | (1 << 1), 183), + + # Node 163 + (2, (1 << 1), 165), + (9, (1 << 1), 165), + (23, (1 << 1), 165), + (40, 1 | (1 << 1), 165), + (2, (1 << 1), 166), + (9, (1 << 1), 166), + (23, (1 << 1), 166), + (40, 1 | (1 << 1), 166), + (2, (1 << 1), 168), + (9, (1 << 1), 168), + (23, (1 << 1), 168), + (40, 1 | (1 << 1), 168), + (2, (1 << 1), 174), + (9, (1 << 1), 174), + (23, (1 << 1), 174), + (40, 1 | (1 << 1), 174), + + # Node 164 + (3, (1 << 1), 165), + (6, (1 << 1), 165), + (10, (1 << 1), 165), + (15, (1 << 1), 165), + (24, (1 << 1), 165), + (31, (1 << 1), 165), + (41, (1 << 1), 165), + (56, 1 | (1 << 1), 165), + (3, (1 << 1), 166), + (6, (1 << 1), 166), + (10, (1 << 1), 166), + (15, (1 << 1), 166), + (24, (1 << 1), 166), + (31, (1 << 1), 166), + (41, (1 << 1), 166), + (56, 1 | (1 << 1), 166), + + # Node 165 + (3, (1 << 1), 168), + (6, (1 << 1), 168), + (10, (1 << 1), 168), + (15, (1 << 1), 168), + (24, (1 << 1), 168), + (31, (1 << 1), 168), + (41, (1 << 1), 168), + (56, 1 | (1 << 1), 168), + (3, (1 << 1), 174), + (6, (1 << 1), 174), + (10, (1 << 1), 174), + (15, (1 << 1), 174), + (24, (1 << 1), 174), + (31, (1 << 1), 174), + (41, (1 << 1), 174), + (56, 1 | (1 << 1), 174), + + # Node 166 + (2, (1 << 1), 175), + (9, (1 << 1), 175), + (23, (1 << 1), 175), + (40, 1 | (1 << 1), 175), + (2, (1 << 1), 180), + (9, (1 << 1), 180), + (23, (1 << 1), 180), + (40, 1 | (1 << 1), 180), + (2, (1 << 1), 182), + (9, (1 << 1), 182), + (23, (1 << 1), 182), + (40, 1 | (1 << 1), 182), + (2, (1 << 1), 183), + (9, (1 << 1), 183), + (23, (1 << 1), 183), + (40, 1 | (1 << 1), 183), + + # Node 167 + (3, (1 << 1), 175), + (6, (1 << 1), 175), + (10, (1 << 1), 175), + (15, (1 << 1), 175), + (24, (1 << 1), 175), + (31, (1 << 1), 175), + (41, (1 << 1), 175), + (56, 1 | (1 << 1), 175), + (3, (1 << 1), 180), + (6, (1 << 1), 180), + (10, (1 << 1), 180), + (15, (1 << 1), 180), + (24, (1 << 1), 180), + (31, (1 << 1), 180), + (41, (1 << 1), 180), + (56, 1 | (1 << 1), 180), + + # Node 168 + (3, (1 << 1), 182), + (6, (1 << 1), 182), + (10, (1 << 1), 182), + (15, (1 << 1), 182), + (24, (1 << 1), 182), + (31, (1 << 1), 182), + (41, (1 << 1), 182), + (56, 1 | (1 << 1), 182), + (3, (1 << 1), 183), + (6, (1 << 1), 183), + (10, (1 << 1), 183), + (15, (1 << 1), 183), + (24, (1 << 1), 183), + (31, (1 << 1), 183), + (41, (1 << 1), 183), + (56, 1 | (1 << 1), 183), + + # Node 169 + (0, 1 | (1 << 1), 188), + (0, 1 | (1 << 1), 191), + (0, 1 | (1 << 1), 197), + (0, 1 | (1 << 1), 231), + (0, 1 | (1 << 1), 239), + (176, 0, 0), + (178, 0, 0), + (179, 0, 0), + (183, 0, 0), + (184, 0, 0), + (186, 0, 0), + (187, 0, 0), + (192, 0, 0), + (199, 0, 0), + (208, 0, 0), + (223, 0, 0), + + # Node 170 + (1, (1 << 1), 188), + (22, 1 | (1 << 1), 188), + (1, (1 << 1), 191), + (22, 1 | (1 << 1), 191), + (1, (1 << 1), 197), + (22, 1 | (1 << 1), 197), + (1, (1 << 1), 231), + (22, 1 | (1 << 1), 231), + (1, (1 << 1), 239), + (22, 1 | (1 << 1), 239), + (0, 1 | (1 << 1), 9), + (0, 1 | (1 << 1), 142), + (0, 1 | (1 << 1), 144), + (0, 1 | (1 << 1), 145), + (0, 1 | (1 << 1), 148), + (0, 1 | (1 << 1), 159), + + # Node 171 + (2, (1 << 1), 188), + (9, (1 << 1), 188), + (23, (1 << 1), 188), + (40, 1 | (1 << 1), 188), + (2, (1 << 1), 191), + (9, (1 << 1), 191), + (23, (1 << 1), 191), + (40, 1 | (1 << 1), 191), + (2, (1 << 1), 197), + (9, (1 << 1), 197), + (23, (1 << 1), 197), + (40, 1 | (1 << 1), 197), + (2, (1 << 1), 231), + (9, (1 << 1), 231), + (23, (1 << 1), 231), + (40, 1 | (1 << 1), 231), + + # Node 172 + (3, (1 << 1), 188), + (6, (1 << 1), 188), + (10, (1 << 1), 188), + (15, (1 << 1), 188), + (24, (1 << 1), 188), + (31, (1 << 1), 188), + (41, (1 << 1), 188), + (56, 1 | (1 << 1), 188), + (3, (1 << 1), 191), + (6, (1 << 1), 191), + (10, (1 << 1), 191), + (15, (1 << 1), 191), + (24, (1 << 1), 191), + (31, (1 << 1), 191), + (41, (1 << 1), 191), + (56, 1 | (1 << 1), 191), + + # Node 173 + (3, (1 << 1), 197), + (6, (1 << 1), 197), + (10, (1 << 1), 197), + (15, (1 << 1), 197), + (24, (1 << 1), 197), + (31, (1 << 1), 197), + (41, (1 << 1), 197), + (56, 1 | (1 << 1), 197), + (3, (1 << 1), 231), + (6, (1 << 1), 231), + (10, (1 << 1), 231), + (15, (1 << 1), 231), + (24, (1 << 1), 231), + (31, (1 << 1), 231), + (41, (1 << 1), 231), + (56, 1 | (1 << 1), 231), + + # Node 174 + (2, (1 << 1), 239), + (9, (1 << 1), 239), + (23, (1 << 1), 239), + (40, 1 | (1 << 1), 239), + (1, (1 << 1), 9), + (22, 1 | (1 << 1), 9), + (1, (1 << 1), 142), + (22, 1 | (1 << 1), 142), + (1, (1 << 1), 144), + (22, 1 | (1 << 1), 144), + (1, (1 << 1), 145), + (22, 1 | (1 << 1), 145), + (1, (1 << 1), 148), + (22, 1 | (1 << 1), 148), + (1, (1 << 1), 159), + (22, 1 | (1 << 1), 159), + + # Node 175 + (3, (1 << 1), 239), + (6, (1 << 1), 239), + (10, (1 << 1), 239), + (15, (1 << 1), 239), + (24, (1 << 1), 239), + (31, (1 << 1), 239), + (41, (1 << 1), 239), + (56, 1 | (1 << 1), 239), + (2, (1 << 1), 9), + (9, (1 << 1), 9), + (23, (1 << 1), 9), + (40, 1 | (1 << 1), 9), + (2, (1 << 1), 142), + (9, (1 << 1), 142), + (23, (1 << 1), 142), + (40, 1 | (1 << 1), 142), + + # Node 176 + (3, (1 << 1), 9), + (6, (1 << 1), 9), + (10, (1 << 1), 9), + (15, (1 << 1), 9), + (24, (1 << 1), 9), + (31, (1 << 1), 9), + (41, (1 << 1), 9), + (56, 1 | (1 << 1), 9), + (3, (1 << 1), 142), + (6, (1 << 1), 142), + (10, (1 << 1), 142), + (15, (1 << 1), 142), + (24, (1 << 1), 142), + (31, (1 << 1), 142), + (41, (1 << 1), 142), + (56, 1 | (1 << 1), 142), + + # Node 177 + (2, (1 << 1), 144), + (9, (1 << 1), 144), + (23, (1 << 1), 144), + (40, 1 | (1 << 1), 144), + (2, (1 << 1), 145), + (9, (1 << 1), 145), + (23, (1 << 1), 145), + (40, 1 | (1 << 1), 145), + (2, (1 << 1), 148), + (9, (1 << 1), 148), + (23, (1 << 1), 148), + (40, 1 | (1 << 1), 148), + (2, (1 << 1), 159), + (9, (1 << 1), 159), + (23, (1 << 1), 159), + (40, 1 | (1 << 1), 159), + + # Node 178 + (3, (1 << 1), 144), + (6, (1 << 1), 144), + (10, (1 << 1), 144), + (15, (1 << 1), 144), + (24, (1 << 1), 144), + (31, (1 << 1), 144), + (41, (1 << 1), 144), + (56, 1 | (1 << 1), 144), + (3, (1 << 1), 145), + (6, (1 << 1), 145), + (10, (1 << 1), 145), + (15, (1 << 1), 145), + (24, (1 << 1), 145), + (31, (1 << 1), 145), + (41, (1 << 1), 145), + (56, 1 | (1 << 1), 145), + + # Node 179 + (3, (1 << 1), 148), + (6, (1 << 1), 148), + (10, (1 << 1), 148), + (15, (1 << 1), 148), + (24, (1 << 1), 148), + (31, (1 << 1), 148), + (41, (1 << 1), 148), + (56, 1 | (1 << 1), 148), + (3, (1 << 1), 159), + (6, (1 << 1), 159), + (10, (1 << 1), 159), + (15, (1 << 1), 159), + (24, (1 << 1), 159), + (31, (1 << 1), 159), + (41, (1 << 1), 159), + (56, 1 | (1 << 1), 159), + + # Node 180 + (0, 1 | (1 << 1), 171), + (0, 1 | (1 << 1), 206), + (0, 1 | (1 << 1), 215), + (0, 1 | (1 << 1), 225), + (0, 1 | (1 << 1), 236), + (0, 1 | (1 << 1), 237), + (188, 0, 0), + (189, 0, 0), + (193, 0, 0), + (196, 0, 0), + (200, 0, 0), + (203, 0, 0), + (209, 0, 0), + (216, 0, 0), + (224, 0, 0), + (238, 0, 0), + + # Node 181 + (1, (1 << 1), 171), + (22, 1 | (1 << 1), 171), + (1, (1 << 1), 206), + (22, 1 | (1 << 1), 206), + (1, (1 << 1), 215), + (22, 1 | (1 << 1), 215), + (1, (1 << 1), 225), + (22, 1 | (1 << 1), 225), + (1, (1 << 1), 236), + (22, 1 | (1 << 1), 236), + (1, (1 << 1), 237), + (22, 1 | (1 << 1), 237), + (0, 1 | (1 << 1), 199), + (0, 1 | (1 << 1), 207), + (0, 1 | (1 << 1), 234), + (0, 1 | (1 << 1), 235), + + # Node 182 + (2, (1 << 1), 171), + (9, (1 << 1), 171), + (23, (1 << 1), 171), + (40, 1 | (1 << 1), 171), + (2, (1 << 1), 206), + (9, (1 << 1), 206), + (23, (1 << 1), 206), + (40, 1 | (1 << 1), 206), + (2, (1 << 1), 215), + (9, (1 << 1), 215), + (23, (1 << 1), 215), + (40, 1 | (1 << 1), 215), + (2, (1 << 1), 225), + (9, (1 << 1), 225), + (23, (1 << 1), 225), + (40, 1 | (1 << 1), 225), + + # Node 183 + (3, (1 << 1), 171), + (6, (1 << 1), 171), + (10, (1 << 1), 171), + (15, (1 << 1), 171), + (24, (1 << 1), 171), + (31, (1 << 1), 171), + (41, (1 << 1), 171), + (56, 1 | (1 << 1), 171), + (3, (1 << 1), 206), + (6, (1 << 1), 206), + (10, (1 << 1), 206), + (15, (1 << 1), 206), + (24, (1 << 1), 206), + (31, (1 << 1), 206), + (41, (1 << 1), 206), + (56, 1 | (1 << 1), 206), + + # Node 184 + (3, (1 << 1), 215), + (6, (1 << 1), 215), + (10, (1 << 1), 215), + (15, (1 << 1), 215), + (24, (1 << 1), 215), + (31, (1 << 1), 215), + (41, (1 << 1), 215), + (56, 1 | (1 << 1), 215), + (3, (1 << 1), 225), + (6, (1 << 1), 225), + (10, (1 << 1), 225), + (15, (1 << 1), 225), + (24, (1 << 1), 225), + (31, (1 << 1), 225), + (41, (1 << 1), 225), + (56, 1 | (1 << 1), 225), + + # Node 185 + (2, (1 << 1), 236), + (9, (1 << 1), 236), + (23, (1 << 1), 236), + (40, 1 | (1 << 1), 236), + (2, (1 << 1), 237), + (9, (1 << 1), 237), + (23, (1 << 1), 237), + (40, 1 | (1 << 1), 237), + (1, (1 << 1), 199), + (22, 1 | (1 << 1), 199), + (1, (1 << 1), 207), + (22, 1 | (1 << 1), 207), + (1, (1 << 1), 234), + (22, 1 | (1 << 1), 234), + (1, (1 << 1), 235), + (22, 1 | (1 << 1), 235), + + # Node 186 + (3, (1 << 1), 236), + (6, (1 << 1), 236), + (10, (1 << 1), 236), + (15, (1 << 1), 236), + (24, (1 << 1), 236), + (31, (1 << 1), 236), + (41, (1 << 1), 236), + (56, 1 | (1 << 1), 236), + (3, (1 << 1), 237), + (6, (1 << 1), 237), + (10, (1 << 1), 237), + (15, (1 << 1), 237), + (24, (1 << 1), 237), + (31, (1 << 1), 237), + (41, (1 << 1), 237), + (56, 1 | (1 << 1), 237), + + # Node 187 + (2, (1 << 1), 199), + (9, (1 << 1), 199), + (23, (1 << 1), 199), + (40, 1 | (1 << 1), 199), + (2, (1 << 1), 207), + (9, (1 << 1), 207), + (23, (1 << 1), 207), + (40, 1 | (1 << 1), 207), + (2, (1 << 1), 234), + (9, (1 << 1), 234), + (23, (1 << 1), 234), + (40, 1 | (1 << 1), 234), + (2, (1 << 1), 235), + (9, (1 << 1), 235), + (23, (1 << 1), 235), + (40, 1 | (1 << 1), 235), + + # Node 188 + (3, (1 << 1), 199), + (6, (1 << 1), 199), + (10, (1 << 1), 199), + (15, (1 << 1), 199), + (24, (1 << 1), 199), + (31, (1 << 1), 199), + (41, (1 << 1), 199), + (56, 1 | (1 << 1), 199), + (3, (1 << 1), 207), + (6, (1 << 1), 207), + (10, (1 << 1), 207), + (15, (1 << 1), 207), + (24, (1 << 1), 207), + (31, (1 << 1), 207), + (41, (1 << 1), 207), + (56, 1 | (1 << 1), 207), + + # Node 189 + (3, (1 << 1), 234), + (6, (1 << 1), 234), + (10, (1 << 1), 234), + (15, (1 << 1), 234), + (24, (1 << 1), 234), + (31, (1 << 1), 234), + (41, (1 << 1), 234), + (56, 1 | (1 << 1), 234), + (3, (1 << 1), 235), + (6, (1 << 1), 235), + (10, (1 << 1), 235), + (15, (1 << 1), 235), + (24, (1 << 1), 235), + (31, (1 << 1), 235), + (41, (1 << 1), 235), + (56, 1 | (1 << 1), 235), + + # Node 190 + (194, 0, 0), + (195, 0, 0), + (197, 0, 0), + (198, 0, 0), + (201, 0, 0), + (202, 0, 0), + (204, 0, 0), + (205, 0, 0), + (210, 0, 0), + (213, 0, 0), + (217, 0, 0), + (220, 0, 0), + (225, 0, 0), + (231, 0, 0), + (239, 0, 0), + (246, 0, 0), + + # Node 191 + (0, 1 | (1 << 1), 192), + (0, 1 | (1 << 1), 193), + (0, 1 | (1 << 1), 200), + (0, 1 | (1 << 1), 201), + (0, 1 | (1 << 1), 202), + (0, 1 | (1 << 1), 205), + (0, 1 | (1 << 1), 210), + (0, 1 | (1 << 1), 213), + (0, 1 | (1 << 1), 218), + (0, 1 | (1 << 1), 219), + (0, 1 | (1 << 1), 238), + (0, 1 | (1 << 1), 240), + (0, 1 | (1 << 1), 242), + (0, 1 | (1 << 1), 243), + (0, 1 | (1 << 1), 255), + (206, 0, 0), + + # Node 192 + (1, (1 << 1), 192), + (22, 1 | (1 << 1), 192), + (1, (1 << 1), 193), + (22, 1 | (1 << 1), 193), + (1, (1 << 1), 200), + (22, 1 | (1 << 1), 200), + (1, (1 << 1), 201), + (22, 1 | (1 << 1), 201), + (1, (1 << 1), 202), + (22, 1 | (1 << 1), 202), + (1, (1 << 1), 205), + (22, 1 | (1 << 1), 205), + (1, (1 << 1), 210), + (22, 1 | (1 << 1), 210), + (1, (1 << 1), 213), + (22, 1 | (1 << 1), 213), + + # Node 193 + (2, (1 << 1), 192), + (9, (1 << 1), 192), + (23, (1 << 1), 192), + (40, 1 | (1 << 1), 192), + (2, (1 << 1), 193), + (9, (1 << 1), 193), + (23, (1 << 1), 193), + (40, 1 | (1 << 1), 193), + (2, (1 << 1), 200), + (9, (1 << 1), 200), + (23, (1 << 1), 200), + (40, 1 | (1 << 1), 200), + (2, (1 << 1), 201), + (9, (1 << 1), 201), + (23, (1 << 1), 201), + (40, 1 | (1 << 1), 201), + + # Node 194 + (3, (1 << 1), 192), + (6, (1 << 1), 192), + (10, (1 << 1), 192), + (15, (1 << 1), 192), + (24, (1 << 1), 192), + (31, (1 << 1), 192), + (41, (1 << 1), 192), + (56, 1 | (1 << 1), 192), + (3, (1 << 1), 193), + (6, (1 << 1), 193), + (10, (1 << 1), 193), + (15, (1 << 1), 193), + (24, (1 << 1), 193), + (31, (1 << 1), 193), + (41, (1 << 1), 193), + (56, 1 | (1 << 1), 193), + + # Node 195 + (3, (1 << 1), 200), + (6, (1 << 1), 200), + (10, (1 << 1), 200), + (15, (1 << 1), 200), + (24, (1 << 1), 200), + (31, (1 << 1), 200), + (41, (1 << 1), 200), + (56, 1 | (1 << 1), 200), + (3, (1 << 1), 201), + (6, (1 << 1), 201), + (10, (1 << 1), 201), + (15, (1 << 1), 201), + (24, (1 << 1), 201), + (31, (1 << 1), 201), + (41, (1 << 1), 201), + (56, 1 | (1 << 1), 201), + + # Node 196 + (2, (1 << 1), 202), + (9, (1 << 1), 202), + (23, (1 << 1), 202), + (40, 1 | (1 << 1), 202), + (2, (1 << 1), 205), + (9, (1 << 1), 205), + (23, (1 << 1), 205), + (40, 1 | (1 << 1), 205), + (2, (1 << 1), 210), + (9, (1 << 1), 210), + (23, (1 << 1), 210), + (40, 1 | (1 << 1), 210), + (2, (1 << 1), 213), + (9, (1 << 1), 213), + (23, (1 << 1), 213), + (40, 1 | (1 << 1), 213), + + # Node 197 + (3, (1 << 1), 202), + (6, (1 << 1), 202), + (10, (1 << 1), 202), + (15, (1 << 1), 202), + (24, (1 << 1), 202), + (31, (1 << 1), 202), + (41, (1 << 1), 202), + (56, 1 | (1 << 1), 202), + (3, (1 << 1), 205), + (6, (1 << 1), 205), + (10, (1 << 1), 205), + (15, (1 << 1), 205), + (24, (1 << 1), 205), + (31, (1 << 1), 205), + (41, (1 << 1), 205), + (56, 1 | (1 << 1), 205), + + # Node 198 + (3, (1 << 1), 210), + (6, (1 << 1), 210), + (10, (1 << 1), 210), + (15, (1 << 1), 210), + (24, (1 << 1), 210), + (31, (1 << 1), 210), + (41, (1 << 1), 210), + (56, 1 | (1 << 1), 210), + (3, (1 << 1), 213), + (6, (1 << 1), 213), + (10, (1 << 1), 213), + (15, (1 << 1), 213), + (24, (1 << 1), 213), + (31, (1 << 1), 213), + (41, (1 << 1), 213), + (56, 1 | (1 << 1), 213), + + # Node 199 + (1, (1 << 1), 218), + (22, 1 | (1 << 1), 218), + (1, (1 << 1), 219), + (22, 1 | (1 << 1), 219), + (1, (1 << 1), 238), + (22, 1 | (1 << 1), 238), + (1, (1 << 1), 240), + (22, 1 | (1 << 1), 240), + (1, (1 << 1), 242), + (22, 1 | (1 << 1), 242), + (1, (1 << 1), 243), + (22, 1 | (1 << 1), 243), + (1, (1 << 1), 255), + (22, 1 | (1 << 1), 255), + (0, 1 | (1 << 1), 203), + (0, 1 | (1 << 1), 204), + + # Node 200 + (2, (1 << 1), 218), + (9, (1 << 1), 218), + (23, (1 << 1), 218), + (40, 1 | (1 << 1), 218), + (2, (1 << 1), 219), + (9, (1 << 1), 219), + (23, (1 << 1), 219), + (40, 1 | (1 << 1), 219), + (2, (1 << 1), 238), + (9, (1 << 1), 238), + (23, (1 << 1), 238), + (40, 1 | (1 << 1), 238), + (2, (1 << 1), 240), + (9, (1 << 1), 240), + (23, (1 << 1), 240), + (40, 1 | (1 << 1), 240), + + # Node 201 + (3, (1 << 1), 218), + (6, (1 << 1), 218), + (10, (1 << 1), 218), + (15, (1 << 1), 218), + (24, (1 << 1), 218), + (31, (1 << 1), 218), + (41, (1 << 1), 218), + (56, 1 | (1 << 1), 218), + (3, (1 << 1), 219), + (6, (1 << 1), 219), + (10, (1 << 1), 219), + (15, (1 << 1), 219), + (24, (1 << 1), 219), + (31, (1 << 1), 219), + (41, (1 << 1), 219), + (56, 1 | (1 << 1), 219), + + # Node 202 + (3, (1 << 1), 238), + (6, (1 << 1), 238), + (10, (1 << 1), 238), + (15, (1 << 1), 238), + (24, (1 << 1), 238), + (31, (1 << 1), 238), + (41, (1 << 1), 238), + (56, 1 | (1 << 1), 238), + (3, (1 << 1), 240), + (6, (1 << 1), 240), + (10, (1 << 1), 240), + (15, (1 << 1), 240), + (24, (1 << 1), 240), + (31, (1 << 1), 240), + (41, (1 << 1), 240), + (56, 1 | (1 << 1), 240), + + # Node 203 + (2, (1 << 1), 242), + (9, (1 << 1), 242), + (23, (1 << 1), 242), + (40, 1 | (1 << 1), 242), + (2, (1 << 1), 243), + (9, (1 << 1), 243), + (23, (1 << 1), 243), + (40, 1 | (1 << 1), 243), + (2, (1 << 1), 255), + (9, (1 << 1), 255), + (23, (1 << 1), 255), + (40, 1 | (1 << 1), 255), + (1, (1 << 1), 203), + (22, 1 | (1 << 1), 203), + (1, (1 << 1), 204), + (22, 1 | (1 << 1), 204), + + # Node 204 + (3, (1 << 1), 242), + (6, (1 << 1), 242), + (10, (1 << 1), 242), + (15, (1 << 1), 242), + (24, (1 << 1), 242), + (31, (1 << 1), 242), + (41, (1 << 1), 242), + (56, 1 | (1 << 1), 242), + (3, (1 << 1), 243), + (6, (1 << 1), 243), + (10, (1 << 1), 243), + (15, (1 << 1), 243), + (24, (1 << 1), 243), + (31, (1 << 1), 243), + (41, (1 << 1), 243), + (56, 1 | (1 << 1), 243), + + # Node 205 + (3, (1 << 1), 255), + (6, (1 << 1), 255), + (10, (1 << 1), 255), + (15, (1 << 1), 255), + (24, (1 << 1), 255), + (31, (1 << 1), 255), + (41, (1 << 1), 255), + (56, 1 | (1 << 1), 255), + (2, (1 << 1), 203), + (9, (1 << 1), 203), + (23, (1 << 1), 203), + (40, 1 | (1 << 1), 203), + (2, (1 << 1), 204), + (9, (1 << 1), 204), + (23, (1 << 1), 204), + (40, 1 | (1 << 1), 204), + + # Node 206 + (3, (1 << 1), 203), + (6, (1 << 1), 203), + (10, (1 << 1), 203), + (15, (1 << 1), 203), + (24, (1 << 1), 203), + (31, (1 << 1), 203), + (41, (1 << 1), 203), + (56, 1 | (1 << 1), 203), + (3, (1 << 1), 204), + (6, (1 << 1), 204), + (10, (1 << 1), 204), + (15, (1 << 1), 204), + (24, (1 << 1), 204), + (31, (1 << 1), 204), + (41, (1 << 1), 204), + (56, 1 | (1 << 1), 204), + + # Node 207 + (211, 0, 0), + (212, 0, 0), + (214, 0, 0), + (215, 0, 0), + (218, 0, 0), + (219, 0, 0), + (221, 0, 0), + (222, 0, 0), + (226, 0, 0), + (228, 0, 0), + (232, 0, 0), + (235, 0, 0), + (240, 0, 0), + (243, 0, 0), + (247, 0, 0), + (250, 0, 0), + + # Node 208 + (0, 1 | (1 << 1), 211), + (0, 1 | (1 << 1), 212), + (0, 1 | (1 << 1), 214), + (0, 1 | (1 << 1), 221), + (0, 1 | (1 << 1), 222), + (0, 1 | (1 << 1), 223), + (0, 1 | (1 << 1), 241), + (0, 1 | (1 << 1), 244), + (0, 1 | (1 << 1), 245), + (0, 1 | (1 << 1), 246), + (0, 1 | (1 << 1), 247), + (0, 1 | (1 << 1), 248), + (0, 1 | (1 << 1), 250), + (0, 1 | (1 << 1), 251), + (0, 1 | (1 << 1), 252), + (0, 1 | (1 << 1), 253), + + # Node 209 + (1, (1 << 1), 211), + (22, 1 | (1 << 1), 211), + (1, (1 << 1), 212), + (22, 1 | (1 << 1), 212), + (1, (1 << 1), 214), + (22, 1 | (1 << 1), 214), + (1, (1 << 1), 221), + (22, 1 | (1 << 1), 221), + (1, (1 << 1), 222), + (22, 1 | (1 << 1), 222), + (1, (1 << 1), 223), + (22, 1 | (1 << 1), 223), + (1, (1 << 1), 241), + (22, 1 | (1 << 1), 241), + (1, (1 << 1), 244), + (22, 1 | (1 << 1), 244), + + # Node 210 + (2, (1 << 1), 211), + (9, (1 << 1), 211), + (23, (1 << 1), 211), + (40, 1 | (1 << 1), 211), + (2, (1 << 1), 212), + (9, (1 << 1), 212), + (23, (1 << 1), 212), + (40, 1 | (1 << 1), 212), + (2, (1 << 1), 214), + (9, (1 << 1), 214), + (23, (1 << 1), 214), + (40, 1 | (1 << 1), 214), + (2, (1 << 1), 221), + (9, (1 << 1), 221), + (23, (1 << 1), 221), + (40, 1 | (1 << 1), 221), + + # Node 211 + (3, (1 << 1), 211), + (6, (1 << 1), 211), + (10, (1 << 1), 211), + (15, (1 << 1), 211), + (24, (1 << 1), 211), + (31, (1 << 1), 211), + (41, (1 << 1), 211), + (56, 1 | (1 << 1), 211), + (3, (1 << 1), 212), + (6, (1 << 1), 212), + (10, (1 << 1), 212), + (15, (1 << 1), 212), + (24, (1 << 1), 212), + (31, (1 << 1), 212), + (41, (1 << 1), 212), + (56, 1 | (1 << 1), 212), + + # Node 212 + (3, (1 << 1), 214), + (6, (1 << 1), 214), + (10, (1 << 1), 214), + (15, (1 << 1), 214), + (24, (1 << 1), 214), + (31, (1 << 1), 214), + (41, (1 << 1), 214), + (56, 1 | (1 << 1), 214), + (3, (1 << 1), 221), + (6, (1 << 1), 221), + (10, (1 << 1), 221), + (15, (1 << 1), 221), + (24, (1 << 1), 221), + (31, (1 << 1), 221), + (41, (1 << 1), 221), + (56, 1 | (1 << 1), 221), + + # Node 213 + (2, (1 << 1), 222), + (9, (1 << 1), 222), + (23, (1 << 1), 222), + (40, 1 | (1 << 1), 222), + (2, (1 << 1), 223), + (9, (1 << 1), 223), + (23, (1 << 1), 223), + (40, 1 | (1 << 1), 223), + (2, (1 << 1), 241), + (9, (1 << 1), 241), + (23, (1 << 1), 241), + (40, 1 | (1 << 1), 241), + (2, (1 << 1), 244), + (9, (1 << 1), 244), + (23, (1 << 1), 244), + (40, 1 | (1 << 1), 244), + + # Node 214 + (3, (1 << 1), 222), + (6, (1 << 1), 222), + (10, (1 << 1), 222), + (15, (1 << 1), 222), + (24, (1 << 1), 222), + (31, (1 << 1), 222), + (41, (1 << 1), 222), + (56, 1 | (1 << 1), 222), + (3, (1 << 1), 223), + (6, (1 << 1), 223), + (10, (1 << 1), 223), + (15, (1 << 1), 223), + (24, (1 << 1), 223), + (31, (1 << 1), 223), + (41, (1 << 1), 223), + (56, 1 | (1 << 1), 223), + + # Node 215 + (3, (1 << 1), 241), + (6, (1 << 1), 241), + (10, (1 << 1), 241), + (15, (1 << 1), 241), + (24, (1 << 1), 241), + (31, (1 << 1), 241), + (41, (1 << 1), 241), + (56, 1 | (1 << 1), 241), + (3, (1 << 1), 244), + (6, (1 << 1), 244), + (10, (1 << 1), 244), + (15, (1 << 1), 244), + (24, (1 << 1), 244), + (31, (1 << 1), 244), + (41, (1 << 1), 244), + (56, 1 | (1 << 1), 244), + + # Node 216 + (1, (1 << 1), 245), + (22, 1 | (1 << 1), 245), + (1, (1 << 1), 246), + (22, 1 | (1 << 1), 246), + (1, (1 << 1), 247), + (22, 1 | (1 << 1), 247), + (1, (1 << 1), 248), + (22, 1 | (1 << 1), 248), + (1, (1 << 1), 250), + (22, 1 | (1 << 1), 250), + (1, (1 << 1), 251), + (22, 1 | (1 << 1), 251), + (1, (1 << 1), 252), + (22, 1 | (1 << 1), 252), + (1, (1 << 1), 253), + (22, 1 | (1 << 1), 253), + + # Node 217 + (2, (1 << 1), 245), + (9, (1 << 1), 245), + (23, (1 << 1), 245), + (40, 1 | (1 << 1), 245), + (2, (1 << 1), 246), + (9, (1 << 1), 246), + (23, (1 << 1), 246), + (40, 1 | (1 << 1), 246), + (2, (1 << 1), 247), + (9, (1 << 1), 247), + (23, (1 << 1), 247), + (40, 1 | (1 << 1), 247), + (2, (1 << 1), 248), + (9, (1 << 1), 248), + (23, (1 << 1), 248), + (40, 1 | (1 << 1), 248), + + # Node 218 + (3, (1 << 1), 245), + (6, (1 << 1), 245), + (10, (1 << 1), 245), + (15, (1 << 1), 245), + (24, (1 << 1), 245), + (31, (1 << 1), 245), + (41, (1 << 1), 245), + (56, 1 | (1 << 1), 245), + (3, (1 << 1), 246), + (6, (1 << 1), 246), + (10, (1 << 1), 246), + (15, (1 << 1), 246), + (24, (1 << 1), 246), + (31, (1 << 1), 246), + (41, (1 << 1), 246), + (56, 1 | (1 << 1), 246), + + # Node 219 + (3, (1 << 1), 247), + (6, (1 << 1), 247), + (10, (1 << 1), 247), + (15, (1 << 1), 247), + (24, (1 << 1), 247), + (31, (1 << 1), 247), + (41, (1 << 1), 247), + (56, 1 | (1 << 1), 247), + (3, (1 << 1), 248), + (6, (1 << 1), 248), + (10, (1 << 1), 248), + (15, (1 << 1), 248), + (24, (1 << 1), 248), + (31, (1 << 1), 248), + (41, (1 << 1), 248), + (56, 1 | (1 << 1), 248), + + # Node 220 + (2, (1 << 1), 250), + (9, (1 << 1), 250), + (23, (1 << 1), 250), + (40, 1 | (1 << 1), 250), + (2, (1 << 1), 251), + (9, (1 << 1), 251), + (23, (1 << 1), 251), + (40, 1 | (1 << 1), 251), + (2, (1 << 1), 252), + (9, (1 << 1), 252), + (23, (1 << 1), 252), + (40, 1 | (1 << 1), 252), + (2, (1 << 1), 253), + (9, (1 << 1), 253), + (23, (1 << 1), 253), + (40, 1 | (1 << 1), 253), + + # Node 221 + (3, (1 << 1), 250), + (6, (1 << 1), 250), + (10, (1 << 1), 250), + (15, (1 << 1), 250), + (24, (1 << 1), 250), + (31, (1 << 1), 250), + (41, (1 << 1), 250), + (56, 1 | (1 << 1), 250), + (3, (1 << 1), 251), + (6, (1 << 1), 251), + (10, (1 << 1), 251), + (15, (1 << 1), 251), + (24, (1 << 1), 251), + (31, (1 << 1), 251), + (41, (1 << 1), 251), + (56, 1 | (1 << 1), 251), + + # Node 222 + (3, (1 << 1), 252), + (6, (1 << 1), 252), + (10, (1 << 1), 252), + (15, (1 << 1), 252), + (24, (1 << 1), 252), + (31, (1 << 1), 252), + (41, (1 << 1), 252), + (56, 1 | (1 << 1), 252), + (3, (1 << 1), 253), + (6, (1 << 1), 253), + (10, (1 << 1), 253), + (15, (1 << 1), 253), + (24, (1 << 1), 253), + (31, (1 << 1), 253), + (41, (1 << 1), 253), + (56, 1 | (1 << 1), 253), + + # Node 223 + (0, 1 | (1 << 1), 254), + (227, 0, 0), + (229, 0, 0), + (230, 0, 0), + (233, 0, 0), + (234, 0, 0), + (236, 0, 0), + (237, 0, 0), + (241, 0, 0), + (242, 0, 0), + (244, 0, 0), + (245, 0, 0), + (248, 0, 0), + (249, 0, 0), + (251, 0, 0), + (252, 0, 0), + + # Node 224 + (1, (1 << 1), 254), + (22, 1 | (1 << 1), 254), + (0, 1 | (1 << 1), 2), + (0, 1 | (1 << 1), 3), + (0, 1 | (1 << 1), 4), + (0, 1 | (1 << 1), 5), + (0, 1 | (1 << 1), 6), + (0, 1 | (1 << 1), 7), + (0, 1 | (1 << 1), 8), + (0, 1 | (1 << 1), 11), + (0, 1 | (1 << 1), 12), + (0, 1 | (1 << 1), 14), + (0, 1 | (1 << 1), 15), + (0, 1 | (1 << 1), 16), + (0, 1 | (1 << 1), 17), + (0, 1 | (1 << 1), 18), + + # Node 225 + (2, (1 << 1), 254), + (9, (1 << 1), 254), + (23, (1 << 1), 254), + (40, 1 | (1 << 1), 254), + (1, (1 << 1), 2), + (22, 1 | (1 << 1), 2), + (1, (1 << 1), 3), + (22, 1 | (1 << 1), 3), + (1, (1 << 1), 4), + (22, 1 | (1 << 1), 4), + (1, (1 << 1), 5), + (22, 1 | (1 << 1), 5), + (1, (1 << 1), 6), + (22, 1 | (1 << 1), 6), + (1, (1 << 1), 7), + (22, 1 | (1 << 1), 7), + + # Node 226 + (3, (1 << 1), 254), + (6, (1 << 1), 254), + (10, (1 << 1), 254), + (15, (1 << 1), 254), + (24, (1 << 1), 254), + (31, (1 << 1), 254), + (41, (1 << 1), 254), + (56, 1 | (1 << 1), 254), + (2, (1 << 1), 2), + (9, (1 << 1), 2), + (23, (1 << 1), 2), + (40, 1 | (1 << 1), 2), + (2, (1 << 1), 3), + (9, (1 << 1), 3), + (23, (1 << 1), 3), + (40, 1 | (1 << 1), 3), + + # Node 227 + (3, (1 << 1), 2), + (6, (1 << 1), 2), + (10, (1 << 1), 2), + (15, (1 << 1), 2), + (24, (1 << 1), 2), + (31, (1 << 1), 2), + (41, (1 << 1), 2), + (56, 1 | (1 << 1), 2), + (3, (1 << 1), 3), + (6, (1 << 1), 3), + (10, (1 << 1), 3), + (15, (1 << 1), 3), + (24, (1 << 1), 3), + (31, (1 << 1), 3), + (41, (1 << 1), 3), + (56, 1 | (1 << 1), 3), + + # Node 228 + (2, (1 << 1), 4), + (9, (1 << 1), 4), + (23, (1 << 1), 4), + (40, 1 | (1 << 1), 4), + (2, (1 << 1), 5), + (9, (1 << 1), 5), + (23, (1 << 1), 5), + (40, 1 | (1 << 1), 5), + (2, (1 << 1), 6), + (9, (1 << 1), 6), + (23, (1 << 1), 6), + (40, 1 | (1 << 1), 6), + (2, (1 << 1), 7), + (9, (1 << 1), 7), + (23, (1 << 1), 7), + (40, 1 | (1 << 1), 7), + + # Node 229 + (3, (1 << 1), 4), + (6, (1 << 1), 4), + (10, (1 << 1), 4), + (15, (1 << 1), 4), + (24, (1 << 1), 4), + (31, (1 << 1), 4), + (41, (1 << 1), 4), + (56, 1 | (1 << 1), 4), + (3, (1 << 1), 5), + (6, (1 << 1), 5), + (10, (1 << 1), 5), + (15, (1 << 1), 5), + (24, (1 << 1), 5), + (31, (1 << 1), 5), + (41, (1 << 1), 5), + (56, 1 | (1 << 1), 5), + + # Node 230 + (3, (1 << 1), 6), + (6, (1 << 1), 6), + (10, (1 << 1), 6), + (15, (1 << 1), 6), + (24, (1 << 1), 6), + (31, (1 << 1), 6), + (41, (1 << 1), 6), + (56, 1 | (1 << 1), 6), + (3, (1 << 1), 7), + (6, (1 << 1), 7), + (10, (1 << 1), 7), + (15, (1 << 1), 7), + (24, (1 << 1), 7), + (31, (1 << 1), 7), + (41, (1 << 1), 7), + (56, 1 | (1 << 1), 7), + + # Node 231 + (1, (1 << 1), 8), + (22, 1 | (1 << 1), 8), + (1, (1 << 1), 11), + (22, 1 | (1 << 1), 11), + (1, (1 << 1), 12), + (22, 1 | (1 << 1), 12), + (1, (1 << 1), 14), + (22, 1 | (1 << 1), 14), + (1, (1 << 1), 15), + (22, 1 | (1 << 1), 15), + (1, (1 << 1), 16), + (22, 1 | (1 << 1), 16), + (1, (1 << 1), 17), + (22, 1 | (1 << 1), 17), + (1, (1 << 1), 18), + (22, 1 | (1 << 1), 18), + + # Node 232 + (2, (1 << 1), 8), + (9, (1 << 1), 8), + (23, (1 << 1), 8), + (40, 1 | (1 << 1), 8), + (2, (1 << 1), 11), + (9, (1 << 1), 11), + (23, (1 << 1), 11), + (40, 1 | (1 << 1), 11), + (2, (1 << 1), 12), + (9, (1 << 1), 12), + (23, (1 << 1), 12), + (40, 1 | (1 << 1), 12), + (2, (1 << 1), 14), + (9, (1 << 1), 14), + (23, (1 << 1), 14), + (40, 1 | (1 << 1), 14), + + # Node 233 + (3, (1 << 1), 8), + (6, (1 << 1), 8), + (10, (1 << 1), 8), + (15, (1 << 1), 8), + (24, (1 << 1), 8), + (31, (1 << 1), 8), + (41, (1 << 1), 8), + (56, 1 | (1 << 1), 8), + (3, (1 << 1), 11), + (6, (1 << 1), 11), + (10, (1 << 1), 11), + (15, (1 << 1), 11), + (24, (1 << 1), 11), + (31, (1 << 1), 11), + (41, (1 << 1), 11), + (56, 1 | (1 << 1), 11), + + # Node 234 + (3, (1 << 1), 12), + (6, (1 << 1), 12), + (10, (1 << 1), 12), + (15, (1 << 1), 12), + (24, (1 << 1), 12), + (31, (1 << 1), 12), + (41, (1 << 1), 12), + (56, 1 | (1 << 1), 12), + (3, (1 << 1), 14), + (6, (1 << 1), 14), + (10, (1 << 1), 14), + (15, (1 << 1), 14), + (24, (1 << 1), 14), + (31, (1 << 1), 14), + (41, (1 << 1), 14), + (56, 1 | (1 << 1), 14), + + # Node 235 + (2, (1 << 1), 15), + (9, (1 << 1), 15), + (23, (1 << 1), 15), + (40, 1 | (1 << 1), 15), + (2, (1 << 1), 16), + (9, (1 << 1), 16), + (23, (1 << 1), 16), + (40, 1 | (1 << 1), 16), + (2, (1 << 1), 17), + (9, (1 << 1), 17), + (23, (1 << 1), 17), + (40, 1 | (1 << 1), 17), + (2, (1 << 1), 18), + (9, (1 << 1), 18), + (23, (1 << 1), 18), + (40, 1 | (1 << 1), 18), + + # Node 236 + (3, (1 << 1), 15), + (6, (1 << 1), 15), + (10, (1 << 1), 15), + (15, (1 << 1), 15), + (24, (1 << 1), 15), + (31, (1 << 1), 15), + (41, (1 << 1), 15), + (56, 1 | (1 << 1), 15), + (3, (1 << 1), 16), + (6, (1 << 1), 16), + (10, (1 << 1), 16), + (15, (1 << 1), 16), + (24, (1 << 1), 16), + (31, (1 << 1), 16), + (41, (1 << 1), 16), + (56, 1 | (1 << 1), 16), + + # Node 237 + (3, (1 << 1), 17), + (6, (1 << 1), 17), + (10, (1 << 1), 17), + (15, (1 << 1), 17), + (24, (1 << 1), 17), + (31, (1 << 1), 17), + (41, (1 << 1), 17), + (56, 1 | (1 << 1), 17), + (3, (1 << 1), 18), + (6, (1 << 1), 18), + (10, (1 << 1), 18), + (15, (1 << 1), 18), + (24, (1 << 1), 18), + (31, (1 << 1), 18), + (41, (1 << 1), 18), + (56, 1 | (1 << 1), 18), + + # Node 238 + (0, 1 | (1 << 1), 19), + (0, 1 | (1 << 1), 20), + (0, 1 | (1 << 1), 21), + (0, 1 | (1 << 1), 23), + (0, 1 | (1 << 1), 24), + (0, 1 | (1 << 1), 25), + (0, 1 | (1 << 1), 26), + (0, 1 | (1 << 1), 27), + (0, 1 | (1 << 1), 28), + (0, 1 | (1 << 1), 29), + (0, 1 | (1 << 1), 30), + (0, 1 | (1 << 1), 31), + (0, 1 | (1 << 1), 127), + (0, 1 | (1 << 1), 220), + (0, 1 | (1 << 1), 249), + (253, 0, 0), + + # Node 239 + (1, (1 << 1), 19), + (22, 1 | (1 << 1), 19), + (1, (1 << 1), 20), + (22, 1 | (1 << 1), 20), + (1, (1 << 1), 21), + (22, 1 | (1 << 1), 21), + (1, (1 << 1), 23), + (22, 1 | (1 << 1), 23), + (1, (1 << 1), 24), + (22, 1 | (1 << 1), 24), + (1, (1 << 1), 25), + (22, 1 | (1 << 1), 25), + (1, (1 << 1), 26), + (22, 1 | (1 << 1), 26), + (1, (1 << 1), 27), + (22, 1 | (1 << 1), 27), + + # Node 240 + (2, (1 << 1), 19), + (9, (1 << 1), 19), + (23, (1 << 1), 19), + (40, 1 | (1 << 1), 19), + (2, (1 << 1), 20), + (9, (1 << 1), 20), + (23, (1 << 1), 20), + (40, 1 | (1 << 1), 20), + (2, (1 << 1), 21), + (9, (1 << 1), 21), + (23, (1 << 1), 21), + (40, 1 | (1 << 1), 21), + (2, (1 << 1), 23), + (9, (1 << 1), 23), + (23, (1 << 1), 23), + (40, 1 | (1 << 1), 23), + + # Node 241 + (3, (1 << 1), 19), + (6, (1 << 1), 19), + (10, (1 << 1), 19), + (15, (1 << 1), 19), + (24, (1 << 1), 19), + (31, (1 << 1), 19), + (41, (1 << 1), 19), + (56, 1 | (1 << 1), 19), + (3, (1 << 1), 20), + (6, (1 << 1), 20), + (10, (1 << 1), 20), + (15, (1 << 1), 20), + (24, (1 << 1), 20), + (31, (1 << 1), 20), + (41, (1 << 1), 20), + (56, 1 | (1 << 1), 20), + + # Node 242 + (3, (1 << 1), 21), + (6, (1 << 1), 21), + (10, (1 << 1), 21), + (15, (1 << 1), 21), + (24, (1 << 1), 21), + (31, (1 << 1), 21), + (41, (1 << 1), 21), + (56, 1 | (1 << 1), 21), + (3, (1 << 1), 23), + (6, (1 << 1), 23), + (10, (1 << 1), 23), + (15, (1 << 1), 23), + (24, (1 << 1), 23), + (31, (1 << 1), 23), + (41, (1 << 1), 23), + (56, 1 | (1 << 1), 23), + + # Node 243 + (2, (1 << 1), 24), + (9, (1 << 1), 24), + (23, (1 << 1), 24), + (40, 1 | (1 << 1), 24), + (2, (1 << 1), 25), + (9, (1 << 1), 25), + (23, (1 << 1), 25), + (40, 1 | (1 << 1), 25), + (2, (1 << 1), 26), + (9, (1 << 1), 26), + (23, (1 << 1), 26), + (40, 1 | (1 << 1), 26), + (2, (1 << 1), 27), + (9, (1 << 1), 27), + (23, (1 << 1), 27), + (40, 1 | (1 << 1), 27), + + # Node 244 + (3, (1 << 1), 24), + (6, (1 << 1), 24), + (10, (1 << 1), 24), + (15, (1 << 1), 24), + (24, (1 << 1), 24), + (31, (1 << 1), 24), + (41, (1 << 1), 24), + (56, 1 | (1 << 1), 24), + (3, (1 << 1), 25), + (6, (1 << 1), 25), + (10, (1 << 1), 25), + (15, (1 << 1), 25), + (24, (1 << 1), 25), + (31, (1 << 1), 25), + (41, (1 << 1), 25), + (56, 1 | (1 << 1), 25), + + # Node 245 + (3, (1 << 1), 26), + (6, (1 << 1), 26), + (10, (1 << 1), 26), + (15, (1 << 1), 26), + (24, (1 << 1), 26), + (31, (1 << 1), 26), + (41, (1 << 1), 26), + (56, 1 | (1 << 1), 26), + (3, (1 << 1), 27), + (6, (1 << 1), 27), + (10, (1 << 1), 27), + (15, (1 << 1), 27), + (24, (1 << 1), 27), + (31, (1 << 1), 27), + (41, (1 << 1), 27), + (56, 1 | (1 << 1), 27), + + # Node 246 + (1, (1 << 1), 28), + (22, 1 | (1 << 1), 28), + (1, (1 << 1), 29), + (22, 1 | (1 << 1), 29), + (1, (1 << 1), 30), + (22, 1 | (1 << 1), 30), + (1, (1 << 1), 31), + (22, 1 | (1 << 1), 31), + (1, (1 << 1), 127), + (22, 1 | (1 << 1), 127), + (1, (1 << 1), 220), + (22, 1 | (1 << 1), 220), + (1, (1 << 1), 249), + (22, 1 | (1 << 1), 249), + (254, 0, 0), + (255, 0, 0), + + # Node 247 + (2, (1 << 1), 28), + (9, (1 << 1), 28), + (23, (1 << 1), 28), + (40, 1 | (1 << 1), 28), + (2, (1 << 1), 29), + (9, (1 << 1), 29), + (23, (1 << 1), 29), + (40, 1 | (1 << 1), 29), + (2, (1 << 1), 30), + (9, (1 << 1), 30), + (23, (1 << 1), 30), + (40, 1 | (1 << 1), 30), + (2, (1 << 1), 31), + (9, (1 << 1), 31), + (23, (1 << 1), 31), + (40, 1 | (1 << 1), 31), + + # Node 248 + (3, (1 << 1), 28), + (6, (1 << 1), 28), + (10, (1 << 1), 28), + (15, (1 << 1), 28), + (24, (1 << 1), 28), + (31, (1 << 1), 28), + (41, (1 << 1), 28), + (56, 1 | (1 << 1), 28), + (3, (1 << 1), 29), + (6, (1 << 1), 29), + (10, (1 << 1), 29), + (15, (1 << 1), 29), + (24, (1 << 1), 29), + (31, (1 << 1), 29), + (41, (1 << 1), 29), + (56, 1 | (1 << 1), 29), + + # Node 249 + (3, (1 << 1), 30), + (6, (1 << 1), 30), + (10, (1 << 1), 30), + (15, (1 << 1), 30), + (24, (1 << 1), 30), + (31, (1 << 1), 30), + (41, (1 << 1), 30), + (56, 1 | (1 << 1), 30), + (3, (1 << 1), 31), + (6, (1 << 1), 31), + (10, (1 << 1), 31), + (15, (1 << 1), 31), + (24, (1 << 1), 31), + (31, (1 << 1), 31), + (41, (1 << 1), 31), + (56, 1 | (1 << 1), 31), + + # Node 250 + (2, (1 << 1), 127), + (9, (1 << 1), 127), + (23, (1 << 1), 127), + (40, 1 | (1 << 1), 127), + (2, (1 << 1), 220), + (9, (1 << 1), 220), + (23, (1 << 1), 220), + (40, 1 | (1 << 1), 220), + (2, (1 << 1), 249), + (9, (1 << 1), 249), + (23, (1 << 1), 249), + (40, 1 | (1 << 1), 249), + (0, 1 | (1 << 1), 10), + (0, 1 | (1 << 1), 13), + (0, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + + # Node 251 + (3, (1 << 1), 127), + (6, (1 << 1), 127), + (10, (1 << 1), 127), + (15, (1 << 1), 127), + (24, (1 << 1), 127), + (31, (1 << 1), 127), + (41, (1 << 1), 127), + (56, 1 | (1 << 1), 127), + (3, (1 << 1), 220), + (6, (1 << 1), 220), + (10, (1 << 1), 220), + (15, (1 << 1), 220), + (24, (1 << 1), 220), + (31, (1 << 1), 220), + (41, (1 << 1), 220), + (56, 1 | (1 << 1), 220), + + # Node 252 + (3, (1 << 1), 249), + (6, (1 << 1), 249), + (10, (1 << 1), 249), + (15, (1 << 1), 249), + (24, (1 << 1), 249), + (31, (1 << 1), 249), + (41, (1 << 1), 249), + (56, 1 | (1 << 1), 249), + (1, (1 << 1), 10), + (22, 1 | (1 << 1), 10), + (1, (1 << 1), 13), + (22, 1 | (1 << 1), 13), + (1, (1 << 1), 22), + (22, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + + # Node 253 + (2, (1 << 1), 10), + (9, (1 << 1), 10), + (23, (1 << 1), 10), + (40, 1 | (1 << 1), 10), + (2, (1 << 1), 13), + (9, (1 << 1), 13), + (23, (1 << 1), 13), + (40, 1 | (1 << 1), 13), + (2, (1 << 1), 22), + (9, (1 << 1), 22), + (23, (1 << 1), 22), + (40, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + + # Node 254 + (3, (1 << 1), 10), + (6, (1 << 1), 10), + (10, (1 << 1), 10), + (15, (1 << 1), 10), + (24, (1 << 1), 10), + (31, (1 << 1), 10), + (41, (1 << 1), 10), + (56, 1 | (1 << 1), 10), + (3, (1 << 1), 13), + (6, (1 << 1), 13), + (10, (1 << 1), 13), + (15, (1 << 1), 13), + (24, (1 << 1), 13), + (31, (1 << 1), 13), + (41, (1 << 1), 13), + (56, 1 | (1 << 1), 13), + + # Node 255 + (3, (1 << 1), 22), + (6, (1 << 1), 22), + (10, (1 << 1), 22), + (15, (1 << 1), 22), + (24, (1 << 1), 22), + (31, (1 << 1), 22), + (41, (1 << 1), 22), + (56, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), +] + + +class Encoder: + """ + An HPACK encoder object. This object takes HTTP headers and emits encoded + HTTP/2 header blocks. + """ + __slots__ = ( + 'header_table', + 'huffman_coder', + 'table_size_changes' + ) + + def __init__( + self, + header_table: HeaderTable + ): + self.header_table = header_table + self.huffman_coder = HuffmanEncoder() + self.table_size_changes = [] + + @property + def header_table_size(self): + """ + Controls the size of the HPACK header table. + """ + return self.header_table.maxsize + + @header_table_size.setter + def header_table_size(self, value): + self.header_table.maxsize = value + if self.header_table.resized: + self.table_size_changes.append(value) + + def encode(self, headers, huffman=True): + header_block = bytearray() + + # Signal table size changes if the header table has been resized + if self.header_table.resized: + block = bytearray() + for size_bytes in self.table_size_changes: + size_bytes = self._encode_integer(size_bytes, 5) + size_bytes[0] |= 0x20 + block += size_bytes + self.table_size_changes = [] + header_block.extend(block) + self.header_table.resized = False + + for header in headers: + + if len(header) > 2: + name, value, sensitive = header + else: + name, value = header + sensitive = False + + indexbit = b'\x40' if not sensitive else b'\x10' + match = self.header_table.search(name, value) + + if match is None: + if huffman: + name = self.huffman_coder.encode(name) + value = self.huffman_coder.encode(value) + + name_len = self._encode_integer(len(name), 7) + value_len = self._encode_integer(len(value), 7) + + if huffman: + name_len[0] |= 0x80 + value_len[0] |= 0x80 + + encoded = indexbit + bytes(name_len) + name + bytes(value_len) + value + + if not sensitive: + self.header_table.add(name, value) + + else: + index, _, perfect = match + + if perfect: + field = self._encode_integer(index, 7) + field[0] |= 0x80 + encoded = bytes(field) + else: + prefix_bits = 4 if indexbit != b'\x40' else 6 + prefix = self._encode_integer(index, prefix_bits) + prefix[0] |= ord(indexbit) + + if huffman: + value = self.huffman_coder.encode(value) + + value_len = self._encode_integer(len(value), 7) + + if huffman: + value_len[0] |= 0x80 + + encoded = prefix + bytes(value_len) + value + if not sensitive: + self.header_table.add(name, value) + + header_block += encoded + + return bytes(header_block) + + def _encode_integer(self, integer, prefix_bits): + """ + This encodes an integer according to the wacky integer encoding rules + defined in the HPACK spec. + """ + + max_number = _PREFIX_BIT_MAX_NUMBERS[prefix_bits] + + if integer < max_number: + return bytearray([integer & 127]) # Clear the highest bit for small integers + + elements = bytearray() + integer -= max_number + + while integer >= 128: + elements.append((integer & 127) + 128) + integer >>= 7 + + elements.append(integer) + + return elements + + def _encode_indexed(self, index): + """ + Encodes a header using the indexed representation. + """ + field = self._encode_integer(index, 7) + field[0] |= 0x80 # we set the top bit + return bytes(field) + +class Decoder: + __slots__ = ( + 'header_table', + 'max_header_list_size', + 'max_allowed_table_size' + ) + """ + An HPACK decoder object. + + .. versionchanged:: 2.3.0 + Added ``max_header_list_size`` argument. + + :param max_header_list_size: The maximum decompressed size we will allow + for any single header block. This is a protection against DoS attacks + that attempt to force the application to expand a relatively small + amount of data into a really large header list, allowing enormous + amounts of memory to be allocated. + + If this amount of data is exceeded, a `OversizedHeaderListError + ` exception will be raised. At this + point the connection should be shut down, as the HPACK state will no + longer be usable. + + Defaults to 64kB. + :type max_header_list_size: ``int`` + """ + def __init__( + self, + header_table: HeaderTable, + max_header_list_size=2**16 + ): + #: The maximum decompressed size we will allow for any single header + #: block. This is a protection against DoS attacks that attempt to + #: force the application to expand a relatively small amount of data + #: into a really large header list, allowing enormous amounts of memory + #: to be allocated. + #: + #: If this amount of data is exceeded, a `OversizedHeaderListError + #: ` exception will be raised. At this + #: point the connection should be shut down, as the HPACK state will no + #: longer be usable. + #: + #: Defaults to 64kB. + #: + #: .. versionadded:: 2.3.0 + self.header_table = header_table + self.max_header_list_size = max_header_list_size + + #: Maximum allowed header table size. + #: + #: A HTTP/2 implementation should set this to the most recent value of + #: SETTINGS_HEADER_TABLE_SIZE that it sent *and has received an ACK + #: for*. Once this setting is set, the actual header table size will be + #: checked at the end of each decoding run and whenever it is changed, + #: to confirm that it fits in this size. + self.max_allowed_table_size = self.header_table.maxsize + + + @property + def header_table_size(self): + """ + Controls the size of the HPACK header table. + """ + return self.header_table.maxsize + + @header_table_size.setter + def header_table_size(self, value): + self.header_table.maxsize = value + + def decode(self, data, raw=False): + """ + Takes an HPACK-encoded header block and decodes it into a header set. + + :param data: A bytestring representing a complete HPACK-encoded header + block. + :param raw: (optional) Whether to return the headers as tuples of raw + byte strings or to decode them as UTF-8 before returning + them. The default value is False, which returns tuples of + Unicode strings + :returns: A list of two-tuples of ``(name, value)`` representing the + HPACK-encoded headers, in the order they were decoded. + :raises HPACKDecodingError: If an error is encountered while decoding + the header block. + """ + + data_mem = memoryview(data) + headers = [] + data_len = len(data) + inflated_size = 0 + current_index = 0 + + while current_index < data_len: + # Work out what kind of header we're decoding. + # If the high bit is 1, it's an indexed field. + current = data[current_index] + indexed = True if current & 0x80 else False + + # Otherwise, if the second-highest bit is 1 it's a field that does + # alter the header table. + literal_index = True if current & 0x40 else False + + # Otherwise, if the third-highest bit is 1 it's an encoding context + # update. + encoding_update = True if current & 0x20 else False + + if indexed: + index, consumed = self._decode_integer( + data_mem[current_index:], + 7 + ) + header = self.header_table.get_by_index(index) + + elif literal_index: + # It's a literal header that does affect the header table. + header, consumed = self._decode_literal( + data_mem[current_index:], + True + ) + elif encoding_update: + + new_size, consumed = self._decode_integer( + data_mem[current_index:], + 5 + ) + if new_size > self.max_allowed_table_size: + raise Exception( + "Encoder exceeded max allowable table size" + ) + + self.header_table_size = new_size + header = None + else: + # It's a literal header that does not affect the header table. + header, consumed = self._decode_literal( + data_mem[current_index:], + False + ) + + if header: + headers.append(header) + name, value = header + inflated_size += ( + 32 + len(name) + len(value) + ) + + current_index += consumed + + return [ + ( + str(header[0], 'utf-8'), + str(header[1], 'utf-8') + + ) if raw else header for header in headers + ] + + def _decode_integer( + self, + data, + prefix_bits + ): + """ + This decodes an integer according to the wacky integer encoding rules + defined in the HPACK spec. Returns a tuple of the decoded integer and the + number of bytes that were consumed from ``data`` in order to get that + integer. + """ + if prefix_bits < 1 or prefix_bits > 8: + raise ValueError( + "Prefix bits must be between 1 and 8, got %s" % prefix_bits + ) + + max_number = _PREFIX_BIT_MAX_NUMBERS[prefix_bits] + index = 1 + shift = 0 + mask = (0xFF >> (8 - prefix_bits)) + + number = data[0] & mask + if number == max_number: + while True: + next_byte = data[index] + index += 1 + + if next_byte >= 128: + number += (next_byte - 128) << shift + else: + number += next_byte << shift + break + shift += 7 + + return number, index + + def _decode_literal(self, data, should_index): + """ + Decodes a header represented with a literal. + """ + total_consumed = 0 + + # When should_index is true, if the low six bits of the first byte are + # nonzero, the header name is indexed. + # When should_index is false, if the low four bits of the first byte + # are nonzero the header name is indexed. + if should_index: + indexed_name = data[0] & 0x3F + name_len = 6 + not_indexable = False + else: + high_byte = data[0] + indexed_name = high_byte & 0x0F + name_len = 4 + not_indexable = high_byte & 0x10 + + if indexed_name: + # Indexed header name. + index, consumed = self._decode_integer(data, name_len) + name = self.header_table.get_by_index(index)[0] + + total_consumed = consumed + length = 0 + else: + # Literal header name. The first byte was consumed, so we need to + # move forward. + data = data[1:] + + length, consumed = self._decode_integer(data, 7) + name = data[consumed:consumed + length] + + if data[0] & 0x80: + if not name: + name = b'' + + state = 0 + flags = 0 + decoded_bytes = bytearray() + # This loop is unrolled somewhat. Because we use a nibble, not a byte, we + # need to handle each nibble twice. We unroll that: it makes the loop body + # a bit longer, but that's ok. + for input_byte in name: + index = (state * 16) + (input_byte >> 4) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + index = (state * 16) + (input_byte & 0x0F) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + name = bytes(decoded_bytes) + + total_consumed = consumed + length + 1 # Since we moved forward 1. + + data = data[consumed + length:] + + # The header value is definitely length-based. + length, consumed = self._decode_integer(data, 7) + value = data[consumed:consumed + length] + + if data[0] & 0x80: + if not value: + value = b'' + + state = 0 + flags = 0 + decoded_bytes = bytearray() + + # This loop is unrolled somewhat. Because we use a nibble, not a byte, we + # need to handle each nibble twice. We unroll that: it makes the loop body + # a bit longer, but that's ok. + for input_byte in value: + index = (state * 16) + (input_byte >> 4) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + index = (state * 16) + (input_byte & 0x0F) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + value = bytes(decoded_bytes) + + # Updated the total consumed length. + total_consumed += length + consumed + + # If we have been told never to index the header field, encode that in + # the tuple we use. + if not_indexable: + header = (name, value) + else: + header = (name, value) + + # If we've been asked to index this, add it to the header table. + if should_index: + self.header_table.add(name, value) + + return header, total_consumed \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/fast_hpack/huffman.py b/hyperscale/core/engines/types/common/fast_hpack/huffman.py new file mode 100644 index 0000000..59a3e77 --- /dev/null +++ b/hyperscale/core/engines/types/common/fast_hpack/huffman.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +""" +hpack/huffman_decoder +~~~~~~~~~~~~~~~~~~~~~ + +An implementation of a bitwise prefix tree specially built for decoding +Huffman-coded content where we already know the Huffman table. +""" +huffman_code_list = [ + 0x1ff8, + 0x7fffd8, + 0xfffffe2, + 0xfffffe3, + 0xfffffe4, + 0xfffffe5, + 0xfffffe6, + 0xfffffe7, + 0xfffffe8, + 0xffffea, + 0x3ffffffc, + 0xfffffe9, + 0xfffffea, + 0x3ffffffd, + 0xfffffeb, + 0xfffffec, + 0xfffffed, + 0xfffffee, + 0xfffffef, + 0xffffff0, + 0xffffff1, + 0xffffff2, + 0x3ffffffe, + 0xffffff3, + 0xffffff4, + 0xffffff5, + 0xffffff6, + 0xffffff7, + 0xffffff8, + 0xffffff9, + 0xffffffa, + 0xffffffb, + 0x14, + 0x3f8, + 0x3f9, + 0xffa, + 0x1ff9, + 0x15, + 0xf8, + 0x7fa, + 0x3fa, + 0x3fb, + 0xf9, + 0x7fb, + 0xfa, + 0x16, + 0x17, + 0x18, + 0x0, + 0x1, + 0x2, + 0x19, + 0x1a, + 0x1b, + 0x1c, + 0x1d, + 0x1e, + 0x1f, + 0x5c, + 0xfb, + 0x7ffc, + 0x20, + 0xffb, + 0x3fc, + 0x1ffa, + 0x21, + 0x5d, + 0x5e, + 0x5f, + 0x60, + 0x61, + 0x62, + 0x63, + 0x64, + 0x65, + 0x66, + 0x67, + 0x68, + 0x69, + 0x6a, + 0x6b, + 0x6c, + 0x6d, + 0x6e, + 0x6f, + 0x70, + 0x71, + 0x72, + 0xfc, + 0x73, + 0xfd, + 0x1ffb, + 0x7fff0, + 0x1ffc, + 0x3ffc, + 0x22, + 0x7ffd, + 0x3, + 0x23, + 0x4, + 0x24, + 0x5, + 0x25, + 0x26, + 0x27, + 0x6, + 0x74, + 0x75, + 0x28, + 0x29, + 0x2a, + 0x7, + 0x2b, + 0x76, + 0x2c, + 0x8, + 0x9, + 0x2d, + 0x77, + 0x78, + 0x79, + 0x7a, + 0x7b, + 0x7ffe, + 0x7fc, + 0x3ffd, + 0x1ffd, + 0xffffffc, + 0xfffe6, + 0x3fffd2, + 0xfffe7, + 0xfffe8, + 0x3fffd3, + 0x3fffd4, + 0x3fffd5, + 0x7fffd9, + 0x3fffd6, + 0x7fffda, + 0x7fffdb, + 0x7fffdc, + 0x7fffdd, + 0x7fffde, + 0xffffeb, + 0x7fffdf, + 0xffffec, + 0xffffed, + 0x3fffd7, + 0x7fffe0, + 0xffffee, + 0x7fffe1, + 0x7fffe2, + 0x7fffe3, + 0x7fffe4, + 0x1fffdc, + 0x3fffd8, + 0x7fffe5, + 0x3fffd9, + 0x7fffe6, + 0x7fffe7, + 0xffffef, + 0x3fffda, + 0x1fffdd, + 0xfffe9, + 0x3fffdb, + 0x3fffdc, + 0x7fffe8, + 0x7fffe9, + 0x1fffde, + 0x7fffea, + 0x3fffdd, + 0x3fffde, + 0xfffff0, + 0x1fffdf, + 0x3fffdf, + 0x7fffeb, + 0x7fffec, + 0x1fffe0, + 0x1fffe1, + 0x3fffe0, + 0x1fffe2, + 0x7fffed, + 0x3fffe1, + 0x7fffee, + 0x7fffef, + 0xfffea, + 0x3fffe2, + 0x3fffe3, + 0x3fffe4, + 0x7ffff0, + 0x3fffe5, + 0x3fffe6, + 0x7ffff1, + 0x3ffffe0, + 0x3ffffe1, + 0xfffeb, + 0x7fff1, + 0x3fffe7, + 0x7ffff2, + 0x3fffe8, + 0x1ffffec, + 0x3ffffe2, + 0x3ffffe3, + 0x3ffffe4, + 0x7ffffde, + 0x7ffffdf, + 0x3ffffe5, + 0xfffff1, + 0x1ffffed, + 0x7fff2, + 0x1fffe3, + 0x3ffffe6, + 0x7ffffe0, + 0x7ffffe1, + 0x3ffffe7, + 0x7ffffe2, + 0xfffff2, + 0x1fffe4, + 0x1fffe5, + 0x3ffffe8, + 0x3ffffe9, + 0xffffffd, + 0x7ffffe3, + 0x7ffffe4, + 0x7ffffe5, + 0xfffec, + 0xfffff3, + 0xfffed, + 0x1fffe6, + 0x3fffe9, + 0x1fffe7, + 0x1fffe8, + 0x7ffff3, + 0x3fffea, + 0x3fffeb, + 0x1ffffee, + 0x1ffffef, + 0xfffff4, + 0xfffff5, + 0x3ffffea, + 0x7ffff4, + 0x3ffffeb, + 0x7ffffe6, + 0x3ffffec, + 0x3ffffed, + 0x7ffffe7, + 0x7ffffe8, + 0x7ffffe9, + 0x7ffffea, + 0x7ffffeb, + 0xffffffe, + 0x7ffffec, + 0x7ffffed, + 0x7ffffee, + 0x7ffffef, + 0x7fffff0, + 0x3ffffee, + 0x3fffffff, +] +huffman_code_lengths = [ + 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28, + 28, 28, 28, 28, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28, + 6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6, + 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10, + 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6, + 15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5, + 6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28, + 20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, 23, 23, 24, 23, + 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24, + 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23, + 21, 21, 22, 21, 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23, + 26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, 26, 24, 25, + 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27, + 20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23, + 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26, + 30, +] + + +class HuffmanEncoder: + huffman_codes = list(zip( + huffman_code_list, + huffman_code_lengths + )) + + """ + Encodes a string according to the Huffman encoding table defined in the + HPACK specification. + """ + + def encode(self, bytes_to_encode: bytes): + """ + Given a string of bytes, encodes them according to the HPACK Huffman + specification. + """ + # If handed the empty string, just immediately return. + if not bytes_to_encode: + return b'' + + final_num = 0 + final_int_len = 0 + + # Turn each byte into its huffman code. These codes aren't necessarily + # octet aligned, so keep track of how far through an octet we are. To + # handle this cleanly, just use a single giant integer. + for byte in bytes_to_encode: + huffman_bit, bin_int_len = self.huffman_codes[byte] + bin_int = huffman_bit & ((1 << bin_int_len) - 1) + final_num = (final_num << bin_int_len) | bin_int + final_int_len += bin_int_len + + # Pad out to an octet with ones. + bits_to_be_padded = (8 - (final_int_len % 8)) % 8 + final_num = (final_num << bits_to_be_padded) | ((1 << bits_to_be_padded) - 1) + + # Calculate the number of bytes required and directly convert the integer to bytes + total_bytes = (final_int_len + bits_to_be_padded) // 8 + encoded_bytes = final_num.to_bytes(total_bytes, byteorder='big') + + return encoded_bytes \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/fast_hpack/table.py b/hyperscale/core/engines/types/common/fast_hpack/table.py new file mode 100644 index 0000000..8193d8e --- /dev/null +++ b/hyperscale/core/engines/types/common/fast_hpack/table.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +from collections import deque +from typing import Dict, Tuple, Deque + + +class HeaderTable: + """ + Implements the combined static and dynamic header table + + The name and value arguments for all the functions + should ONLY be byte strings (b'') however this is not + strictly enforced in the interface. + + See RFC7541 Section 2.3 + """ + #: Default maximum size of the dynamic table. See + #: RFC7540 Section 6.5.2. + DEFAULT_SIZE = 4096 + + #: Constant list of static headers. See RFC7541 Section + #: 2.3.1 and Appendix A + STATIC_TABLE = ( + (b':authority' , b'' ), # noqa + (b':method' , b'GET' ), # noqa + (b':method' , b'POST' ), # noqa + (b':path' , b'/' ), # noqa + (b':path' , b'/index.html' ), # noqa + (b':scheme' , b'http' ), # noqa + (b':scheme' , b'https' ), # noqa + (b':status' , b'200' ), # noqa + (b':status' , b'204' ), # noqa + (b':status' , b'206' ), # noqa + (b':status' , b'304' ), # noqa + (b':status' , b'400' ), # noqa + (b':status' , b'404' ), # noqa + (b':status' , b'500' ), # noqa + (b'accept-charset' , b'' ), # noqa + (b'accept-encoding' , b'gzip, deflate'), # noqa + (b'accept-language' , b'' ), # noqa + (b'accept-ranges' , b'' ), # noqa + (b'accept' , b'' ), # noqa + (b'access-control-allow-origin' , b'' ), # noqa + (b'age' , b'' ), # noqa + (b'allow' , b'' ), # noqa + (b'authorization' , b'' ), # noqa + (b'cache-control' , b'' ), # noqa + (b'content-disposition' , b'' ), # noqa + (b'content-encoding' , b'' ), # noqa + (b'content-language' , b'' ), # noqa + (b'content-length' , b'' ), # noqa + (b'content-location' , b'' ), # noqa + (b'content-range' , b'' ), # noqa + (b'content-type' , b'' ), # noqa + (b'cookie' , b'' ), # noqa + (b'date' , b'' ), # noqa + (b'etag' , b'' ), # noqa + (b'expect' , b'' ), # noqa + (b'expires' , b'' ), # noqa + (b'from' , b'' ), # noqa + (b'host' , b'' ), # noqa + (b'if-match' , b'' ), # noqa + (b'if-modified-since' , b'' ), # noqa + (b'if-none-match' , b'' ), # noqa + (b'if-range' , b'' ), # noqa + (b'if-unmodified-since' , b'' ), # noqa + (b'last-modified' , b'' ), # noqa + (b'link' , b'' ), # noqa + (b'location' , b'' ), # noqa + (b'max-forwards' , b'' ), # noqa + (b'proxy-authenticate' , b'' ), # noqa + (b'proxy-authorization' , b'' ), # noqa + (b'range' , b'' ), # noqa + (b'referer' , b'' ), # noqa + (b'refresh' , b'' ), # noqa + (b'retry-after' , b'' ), # noqa + (b'server' , b'' ), # noqa + (b'set-cookie' , b'' ), # noqa + (b'strict-transport-security' , b'' ), # noqa + (b'transfer-encoding' , b'' ), # noqa + (b'user-agent' , b'' ), # noqa + (b'vary' , b'' ), # noqa + (b'via' , b'' ), # noqa + (b'www-authenticate' , b'' ), # noqa + ) # noqa + + STATIC_TABLE_LENGTH = len(STATIC_TABLE) + STATIC_TABLE_MAPPING: Dict[bytes, Tuple[int, Dict[bytes, int]]]={} + + def __init__(self): + self._maxsize = HeaderTable.DEFAULT_SIZE + self._current_size = 0 + self.resized = False + self.dynamic_entries: Deque[Tuple[bytes, bytes]] = deque() + + def get_by_index(self, index): + """ + Returns the entry specified by index + + Note that the table is 1-based ie an index of 0 is + invalid. This is due to the fact that a zero value + index signals that a completely unindexed header + follows. + + The entry will either be from the static table or + the dynamic table depending on the value of index. + """ + original_index = index + index -= 1 + + if index < 0: + raise Exception("Invalid table index %d" % original_index) + + if index < HeaderTable.STATIC_TABLE_LENGTH: + return HeaderTable.STATIC_TABLE[index] + + index -= HeaderTable.STATIC_TABLE_LENGTH + if index < len(self.dynamic_entries): + return self.dynamic_entries[index] + + def __repr__(self): + return "HeaderTable(%d, %s, %r)" % ( + self._maxsize, + self.resized, + self.dynamic_entries + ) + + def add(self, name, value): + """ + Adds a new entry to the table + + We reduce the table size if the entry will make the + table size greater than maxsize. + """ + # We just clear the table if the entry is too big + size = 32 + len(name) + len(value) + if size > self._maxsize: + self.dynamic_entries.clear() + self._current_size = 0 + else: + # Add new entry + self.dynamic_entries.appendleft((name, value)) + self._current_size += size + + # Remove entries until the size is within the limit + while self._current_size > self._maxsize: + name, value = self.dynamic_entries.pop() + self._current_size -= 32 + len(name) + len(value) + + def search(self, name, value): + """ + Searches the table for the entry specified by name + and value + + Returns one of the following: + - ``None``, no match at all + - ``(index, name, None)`` for partial matches on name only. + - ``(index, name, value)`` for perfect matches. + """ + + if ( + header_name_search_result := HeaderTable.STATIC_TABLE_MAPPING.get(name) + ): + if ( + index := header_name_search_result[1].get(value) + ): + return index, name, value + else: + partial = (header_name_search_result[0], name, None) + + else: + partial = None + + offset = HeaderTable.STATIC_TABLE_LENGTH + 1 + + for (i, (n, v)) in enumerate(self.dynamic_entries): + + if n == name: + if v == value: + return i + offset, n, v + elif partial is None: + partial = (i + offset, n, None) + + return partial + + @property + def maxsize(self): + return self._maxsize + + @maxsize.setter + def maxsize(self, newmax): + newmax = int(newmax) + oldmax = self._maxsize + self._maxsize = newmax + self.resized = (newmax != oldmax) + if newmax <= 0: + self.dynamic_entries.clear() + self._current_size = 0 + + elif oldmax > newmax: + cursize = self._current_size + while cursize > self._maxsize: + name, value = self.dynamic_entries.pop() + cursize -= ( + 32 + len(name) + len(value) + ) + self._current_size = cursize + +def _build_static_table_mapping(): + """ + Build static table mapping from header name to tuple with next structure: + (, ). + + static_table_mapping used for hash searching. + """ + static_table_mapping = {} + for index, (name, value) in enumerate(HeaderTable.STATIC_TABLE, 1): + header_name_search_result = static_table_mapping.setdefault(name, (index, {})) + header_name_search_result[1][value] = index + return static_table_mapping + + +HeaderTable.STATIC_TABLE_MAPPING = _build_static_table_mapping() diff --git a/hyperscale/core/engines/types/common/hooks.py b/hyperscale/core/engines/types/common/hooks.py new file mode 100644 index 0000000..a90fe82 --- /dev/null +++ b/hyperscale/core/engines/types/common/hooks.py @@ -0,0 +1,74 @@ +import asyncio +from typing import Generic, TypeVar +from typing import Coroutine, List, Dict + + +A = TypeVar('A') + + +class Hooks(Generic[A]): + + __slots__ = ( + 'before', + 'after', + 'checks', + 'notify', + 'listen', + 'channel_events', + 'listeners', + 'channels' + ) + + def __init__( + self, + before: List[List[Coroutine]] = None, + after: List[List[Coroutine]] = None, + checks: List[Coroutine] = None + ) -> None: + self.before: List[List[Coroutine]] = before + self.after: List[List[Coroutine]] = after + self.checks: List[List[Coroutine]] = checks + self.notify = False + self.listen = False + self.channel_events: List[asyncio.Event] = [] + self.listeners: Dict[str, A] = {} + self.channels: List[Coroutine] = [] + + @property + def channel_hook_names(self): + if self.channels: + return [ + channel.name for channel in self.channels + ] + + return [] + + def to_names(self): + + names = {} + + if self.before: + names['before'] = self.before_hook_name + + if self.after: + names['after'] = self.after_hook_name + + check_names = [] + for check in self.checks: + check_names.append(check.name) + + names['checks'] = check_names + names['channels'] = self.channel_hook_names + + names['listeners'] = [ + listener_name for listener_name in self.listeners + ] + + return names + + def action_to_serializable(self): + return { + 'notify': self.notify, + 'listen': self.listen, + 'names': self.to_names() + } diff --git a/hyperscale/core/engines/types/common/hpack/__init__.py b/hyperscale/core/engines/types/common/hpack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/engines/types/common/hpack/constants.py b/hyperscale/core/engines/types/common/hpack/constants.py new file mode 100644 index 0000000..4caf012 --- /dev/null +++ b/hyperscale/core/engines/types/common/hpack/constants.py @@ -0,0 +1,289 @@ +# -*- coding: utf-8 -*- +""" +hpack/huffman_constants +~~~~~~~~~~~~~~~~~~~~~~~ + +Defines the constant Huffman table. This takes up an upsetting amount of space, +but c'est la vie. +""" +# flake8: noqa + +REQUEST_CODES = [ + 0x1ff8, + 0x7fffd8, + 0xfffffe2, + 0xfffffe3, + 0xfffffe4, + 0xfffffe5, + 0xfffffe6, + 0xfffffe7, + 0xfffffe8, + 0xffffea, + 0x3ffffffc, + 0xfffffe9, + 0xfffffea, + 0x3ffffffd, + 0xfffffeb, + 0xfffffec, + 0xfffffed, + 0xfffffee, + 0xfffffef, + 0xffffff0, + 0xffffff1, + 0xffffff2, + 0x3ffffffe, + 0xffffff3, + 0xffffff4, + 0xffffff5, + 0xffffff6, + 0xffffff7, + 0xffffff8, + 0xffffff9, + 0xffffffa, + 0xffffffb, + 0x14, + 0x3f8, + 0x3f9, + 0xffa, + 0x1ff9, + 0x15, + 0xf8, + 0x7fa, + 0x3fa, + 0x3fb, + 0xf9, + 0x7fb, + 0xfa, + 0x16, + 0x17, + 0x18, + 0x0, + 0x1, + 0x2, + 0x19, + 0x1a, + 0x1b, + 0x1c, + 0x1d, + 0x1e, + 0x1f, + 0x5c, + 0xfb, + 0x7ffc, + 0x20, + 0xffb, + 0x3fc, + 0x1ffa, + 0x21, + 0x5d, + 0x5e, + 0x5f, + 0x60, + 0x61, + 0x62, + 0x63, + 0x64, + 0x65, + 0x66, + 0x67, + 0x68, + 0x69, + 0x6a, + 0x6b, + 0x6c, + 0x6d, + 0x6e, + 0x6f, + 0x70, + 0x71, + 0x72, + 0xfc, + 0x73, + 0xfd, + 0x1ffb, + 0x7fff0, + 0x1ffc, + 0x3ffc, + 0x22, + 0x7ffd, + 0x3, + 0x23, + 0x4, + 0x24, + 0x5, + 0x25, + 0x26, + 0x27, + 0x6, + 0x74, + 0x75, + 0x28, + 0x29, + 0x2a, + 0x7, + 0x2b, + 0x76, + 0x2c, + 0x8, + 0x9, + 0x2d, + 0x77, + 0x78, + 0x79, + 0x7a, + 0x7b, + 0x7ffe, + 0x7fc, + 0x3ffd, + 0x1ffd, + 0xffffffc, + 0xfffe6, + 0x3fffd2, + 0xfffe7, + 0xfffe8, + 0x3fffd3, + 0x3fffd4, + 0x3fffd5, + 0x7fffd9, + 0x3fffd6, + 0x7fffda, + 0x7fffdb, + 0x7fffdc, + 0x7fffdd, + 0x7fffde, + 0xffffeb, + 0x7fffdf, + 0xffffec, + 0xffffed, + 0x3fffd7, + 0x7fffe0, + 0xffffee, + 0x7fffe1, + 0x7fffe2, + 0x7fffe3, + 0x7fffe4, + 0x1fffdc, + 0x3fffd8, + 0x7fffe5, + 0x3fffd9, + 0x7fffe6, + 0x7fffe7, + 0xffffef, + 0x3fffda, + 0x1fffdd, + 0xfffe9, + 0x3fffdb, + 0x3fffdc, + 0x7fffe8, + 0x7fffe9, + 0x1fffde, + 0x7fffea, + 0x3fffdd, + 0x3fffde, + 0xfffff0, + 0x1fffdf, + 0x3fffdf, + 0x7fffeb, + 0x7fffec, + 0x1fffe0, + 0x1fffe1, + 0x3fffe0, + 0x1fffe2, + 0x7fffed, + 0x3fffe1, + 0x7fffee, + 0x7fffef, + 0xfffea, + 0x3fffe2, + 0x3fffe3, + 0x3fffe4, + 0x7ffff0, + 0x3fffe5, + 0x3fffe6, + 0x7ffff1, + 0x3ffffe0, + 0x3ffffe1, + 0xfffeb, + 0x7fff1, + 0x3fffe7, + 0x7ffff2, + 0x3fffe8, + 0x1ffffec, + 0x3ffffe2, + 0x3ffffe3, + 0x3ffffe4, + 0x7ffffde, + 0x7ffffdf, + 0x3ffffe5, + 0xfffff1, + 0x1ffffed, + 0x7fff2, + 0x1fffe3, + 0x3ffffe6, + 0x7ffffe0, + 0x7ffffe1, + 0x3ffffe7, + 0x7ffffe2, + 0xfffff2, + 0x1fffe4, + 0x1fffe5, + 0x3ffffe8, + 0x3ffffe9, + 0xffffffd, + 0x7ffffe3, + 0x7ffffe4, + 0x7ffffe5, + 0xfffec, + 0xfffff3, + 0xfffed, + 0x1fffe6, + 0x3fffe9, + 0x1fffe7, + 0x1fffe8, + 0x7ffff3, + 0x3fffea, + 0x3fffeb, + 0x1ffffee, + 0x1ffffef, + 0xfffff4, + 0xfffff5, + 0x3ffffea, + 0x7ffff4, + 0x3ffffeb, + 0x7ffffe6, + 0x3ffffec, + 0x3ffffed, + 0x7ffffe7, + 0x7ffffe8, + 0x7ffffe9, + 0x7ffffea, + 0x7ffffeb, + 0xffffffe, + 0x7ffffec, + 0x7ffffed, + 0x7ffffee, + 0x7ffffef, + 0x7fffff0, + 0x3ffffee, + 0x3fffffff, +] + +REQUEST_CODES_LENGTH = [ + 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28, + 28, 28, 28, 28, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28, + 6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6, + 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10, + 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6, + 15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5, + 6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28, + 20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, 23, 23, 24, 23, + 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24, + 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23, + 21, 21, 22, 21, 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23, + 26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, 26, 24, 25, + 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27, + 20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23, + 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26, + 30, +] diff --git a/hyperscale/core/engines/types/common/hpack/exceptions.py b/hyperscale/core/engines/types/common/hpack/exceptions.py new file mode 100644 index 0000000..571ba98 --- /dev/null +++ b/hyperscale/core/engines/types/common/hpack/exceptions.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +""" +hyper/http20/exceptions +~~~~~~~~~~~~~~~~~~~~~~~ + +This defines exceptions used in the HTTP/2 portion of hyper. +""" + + +class HPACKError(Exception): + """ + The base class for all ``hpack`` exceptions. + """ + pass + + +class HPACKDecodingError(HPACKError): + """ + An error has been encountered while performing HPACK decoding. + """ + pass + + +class InvalidTableIndex(HPACKDecodingError): + """ + An invalid table index was received. + """ + pass + + +class OversizedHeaderListError(HPACKDecodingError): + """ + A header list that was larger than we allow has been received. This may be + a DoS attack. + + .. versionadded:: 2.3.0 + """ + pass + + +class InvalidTableSizeError(HPACKDecodingError): + """ + An attempt was made to change the decoder table size to a value larger than + allowed, or the list was shrunk and the remote peer didn't shrink their + table size. + + .. versionadded:: 3.0.0 + """ + pass diff --git a/hyperscale/core/engines/types/common/hpack/huffman_encoder.py b/hyperscale/core/engines/types/common/hpack/huffman_encoder.py new file mode 100644 index 0000000..595d69b --- /dev/null +++ b/hyperscale/core/engines/types/common/hpack/huffman_encoder.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +""" +hpack/huffman_decoder +~~~~~~~~~~~~~~~~~~~~~ + +An implementation of a bitwise prefix tree specially built for decoding +Huffman-coded content where we already know the Huffman table. +""" + + +class HuffmanEncoder: + """ + Encodes a string according to the Huffman encoding table defined in the + HPACK specification. + """ + def __init__(self, huffman_code_list, huffman_code_list_lengths): + self.huffman_code_list = huffman_code_list + self.huffman_code_list_lengths = huffman_code_list_lengths + + def encode(self, bytes_to_encode): + """ + Given a string of bytes, encodes them according to the HPACK Huffman + specification. + """ + # If handed the empty string, just immediately return. + if not bytes_to_encode: + return b'' + + final_num = 0 + final_int_len = 0 + + # Turn each byte into its huffman code. These codes aren't necessarily + # octet aligned, so keep track of how far through an octet we are. To + # handle this cleanly, just use a single giant integer. + for byte in bytes_to_encode: + bin_int_len = self.huffman_code_list_lengths[byte] + bin_int = self.huffman_code_list[byte] & ( + 2 ** (bin_int_len + 1) - 1 + ) + final_num <<= bin_int_len + final_num |= bin_int + final_int_len += bin_int_len + + # Pad out to an octet with ones. + bits_to_be_padded = (8 - (final_int_len % 8)) % 8 + final_num <<= bits_to_be_padded + final_num |= (1 << bits_to_be_padded) - 1 + + # Convert the number to hex and strip off the leading '0x' and the + # trailing 'L', if present. + final_num = hex(final_num)[2:].rstrip('L') + + # If this is odd, prepend a zero. + final_num = '0' + final_num if len(final_num) % 2 != 0 else final_num + + # This number should have twice as many digits as bytes. If not, we're + # missing some leading zeroes. Work out how many bytes we want and how + # many digits we have, then add the missing zero digits to the front. + total_bytes = (final_int_len + bits_to_be_padded) // 8 + expected_digits = total_bytes * 2 + + if len(final_num) != expected_digits: + missing_digits = expected_digits - len(final_num) + final_num = ('0' * missing_digits) + final_num + + return bytes.fromhex(final_num) diff --git a/hyperscale/core/engines/types/common/hpack/huffman_table.py b/hyperscale/core/engines/types/common/hpack/huffman_table.py new file mode 100644 index 0000000..c199ef5 --- /dev/null +++ b/hyperscale/core/engines/types/common/hpack/huffman_table.py @@ -0,0 +1,4739 @@ +# -*- coding: utf-8 -*- +""" +hpack/huffman_table +~~~~~~~~~~~~~~~~~~~ + +This implementation of a Huffman decoding table for HTTP/2 is essentially a +Python port of the work originally done for nghttp2's Huffman decoding. For +this reason, while this file is made available under the MIT license as is the +rest of this module, this file is undoubtedly a derivative work of the nghttp2 +file ``nghttp2_hd_huffman_data.c``, obtained from +https://github.com/tatsuhiro-t/nghttp2/ at commit +d2b55ad1a245e1d1964579fa3fac36ebf3939e72. That work is made available under +the Apache 2.0 license under the following terms: + + Copyright (c) 2013 Tatsuhiro Tsujikawa + + 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. + +The essence of this approach is that it builds a finite state machine out of +4-bit nibbles of Huffman coded data. The input function passes 4 bits worth of +data to the state machine each time, which uses those 4 bits of data along with +the current accumulated state data to process the data given. + +For the sake of efficiency, the in-memory representation of the states, +transitions, and result values of the state machine are represented as a long +list containing three-tuples. This list is enormously long, and viewing it as +an in-memory representation is not very clear, but it is laid out here in a way +that is intended to be *somewhat* more clear. + +Essentially, the list is structured as 256 collections of 16 entries (one for +each nibble) of three-tuples. Each collection is called a "node", and the +zeroth collection is called the "root node". The state machine tracks one +value: the "state" byte. + +For each nibble passed to the state machine, it first multiplies the "state" +byte by 16 and adds the numerical value of the nibble. This number is the index +into the large flat list. + +The three-tuple that is found by looking up that index consists of three +values: + +- a new state value, used for subsequent decoding +- a collection of flags, used to determine whether data is emitted or whether + the state machine is complete. +- the byte value to emit, assuming that emitting a byte is required. + +The flags are consulted, if necessary a byte is emitted, and then the next +nibble is used. This continues until the state machine believes it has +completely Huffman-decoded the data. + +This approach has relatively little indirection, and therefore performs +relatively well, particularly on implementations like PyPy where the cost of +loops at the Python-level is not too expensive. The total number of loop +iterations is 4x the number of bytes passed to the decoder. +""" +from .exceptions import HPACKDecodingError + + +# This defines the state machine "class" at the top of the file. The reason we +# do this is to keep the terrifing monster state table at the *bottom* of the +# file so you don't have to actually *look* at the damn thing. +def decode_huffman(huffman_string): + """ + Given a bytestring of Huffman-encoded data for HPACK, returns a bytestring + of the decompressed data. + """ + if not huffman_string: + return b'' + + state = 0 + flags = 0 + decoded_bytes = bytearray() + + # Perversely, bytearrays are a lot more convenient across Python 2 and + # Python 3 because they behave *the same way* on both platforms. Given that + # we really do want numerical bytes when we iterate here, let's use a + # bytearray. + huffman_string = bytearray(huffman_string) + + # This loop is unrolled somewhat. Because we use a nibble, not a byte, we + # need to handle each nibble twice. We unroll that: it makes the loop body + # a bit longer, but that's ok. + for input_byte in huffman_string: + index = (state * 16) + (input_byte >> 4) + state, flags, output_byte = HUFFMAN_TABLE[index] + + if flags & HUFFMAN_FAIL: + raise HPACKDecodingError("Invalid Huffman String") + + if flags & HUFFMAN_EMIT_SYMBOL: + decoded_bytes.append(output_byte) + + index = (state * 16) + (input_byte & 0x0F) + state, flags, output_byte = HUFFMAN_TABLE[index] + + if flags & HUFFMAN_FAIL: + raise HPACKDecodingError("Invalid Huffman String") + + if flags & HUFFMAN_EMIT_SYMBOL: + decoded_bytes.append(output_byte) + + if not (flags & HUFFMAN_COMPLETE): + raise HPACKDecodingError("Incomplete Huffman string") + + return bytes(decoded_bytes) + + +# Some decoder flags to control state transitions. +HUFFMAN_COMPLETE = 1 +HUFFMAN_EMIT_SYMBOL = (1 << 1) +HUFFMAN_FAIL = (1 << 2) + +# This is the monster table. Avert your eyes, children. +HUFFMAN_TABLE = [ + # Node 0 (Root Node, never emits symbols.) + (4, 0, 0), + (5, 0, 0), + (7, 0, 0), + (8, 0, 0), + (11, 0, 0), + (12, 0, 0), + (16, 0, 0), + (19, 0, 0), + (25, 0, 0), + (28, 0, 0), + (32, 0, 0), + (35, 0, 0), + (42, 0, 0), + (49, 0, 0), + (57, 0, 0), + (64, HUFFMAN_COMPLETE, 0), + + # Node 1 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 48), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 49), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 50), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 97), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 99), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 101), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 105), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 111), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 115), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 116), + (13, 0, 0), + (14, 0, 0), + (17, 0, 0), + (18, 0, 0), + (20, 0, 0), + (21, 0, 0), + + # Node 2 + (1, HUFFMAN_EMIT_SYMBOL, 48), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 48), + (1, HUFFMAN_EMIT_SYMBOL, 49), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 49), + (1, HUFFMAN_EMIT_SYMBOL, 50), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 50), + (1, HUFFMAN_EMIT_SYMBOL, 97), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 97), + (1, HUFFMAN_EMIT_SYMBOL, 99), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 99), + (1, HUFFMAN_EMIT_SYMBOL, 101), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 101), + (1, HUFFMAN_EMIT_SYMBOL, 105), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 105), + (1, HUFFMAN_EMIT_SYMBOL, 111), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 111), + + # Node 3 + (2, HUFFMAN_EMIT_SYMBOL, 48), + (9, HUFFMAN_EMIT_SYMBOL, 48), + (23, HUFFMAN_EMIT_SYMBOL, 48), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 48), + (2, HUFFMAN_EMIT_SYMBOL, 49), + (9, HUFFMAN_EMIT_SYMBOL, 49), + (23, HUFFMAN_EMIT_SYMBOL, 49), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 49), + (2, HUFFMAN_EMIT_SYMBOL, 50), + (9, HUFFMAN_EMIT_SYMBOL, 50), + (23, HUFFMAN_EMIT_SYMBOL, 50), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 50), + (2, HUFFMAN_EMIT_SYMBOL, 97), + (9, HUFFMAN_EMIT_SYMBOL, 97), + (23, HUFFMAN_EMIT_SYMBOL, 97), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 97), + + # Node 4 + (3, HUFFMAN_EMIT_SYMBOL, 48), + (6, HUFFMAN_EMIT_SYMBOL, 48), + (10, HUFFMAN_EMIT_SYMBOL, 48), + (15, HUFFMAN_EMIT_SYMBOL, 48), + (24, HUFFMAN_EMIT_SYMBOL, 48), + (31, HUFFMAN_EMIT_SYMBOL, 48), + (41, HUFFMAN_EMIT_SYMBOL, 48), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 48), + (3, HUFFMAN_EMIT_SYMBOL, 49), + (6, HUFFMAN_EMIT_SYMBOL, 49), + (10, HUFFMAN_EMIT_SYMBOL, 49), + (15, HUFFMAN_EMIT_SYMBOL, 49), + (24, HUFFMAN_EMIT_SYMBOL, 49), + (31, HUFFMAN_EMIT_SYMBOL, 49), + (41, HUFFMAN_EMIT_SYMBOL, 49), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 49), + + # Node 5 + (3, HUFFMAN_EMIT_SYMBOL, 50), + (6, HUFFMAN_EMIT_SYMBOL, 50), + (10, HUFFMAN_EMIT_SYMBOL, 50), + (15, HUFFMAN_EMIT_SYMBOL, 50), + (24, HUFFMAN_EMIT_SYMBOL, 50), + (31, HUFFMAN_EMIT_SYMBOL, 50), + (41, HUFFMAN_EMIT_SYMBOL, 50), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 50), + (3, HUFFMAN_EMIT_SYMBOL, 97), + (6, HUFFMAN_EMIT_SYMBOL, 97), + (10, HUFFMAN_EMIT_SYMBOL, 97), + (15, HUFFMAN_EMIT_SYMBOL, 97), + (24, HUFFMAN_EMIT_SYMBOL, 97), + (31, HUFFMAN_EMIT_SYMBOL, 97), + (41, HUFFMAN_EMIT_SYMBOL, 97), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 97), + + # Node 6 + (2, HUFFMAN_EMIT_SYMBOL, 99), + (9, HUFFMAN_EMIT_SYMBOL, 99), + (23, HUFFMAN_EMIT_SYMBOL, 99), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 99), + (2, HUFFMAN_EMIT_SYMBOL, 101), + (9, HUFFMAN_EMIT_SYMBOL, 101), + (23, HUFFMAN_EMIT_SYMBOL, 101), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 101), + (2, HUFFMAN_EMIT_SYMBOL, 105), + (9, HUFFMAN_EMIT_SYMBOL, 105), + (23, HUFFMAN_EMIT_SYMBOL, 105), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 105), + (2, HUFFMAN_EMIT_SYMBOL, 111), + (9, HUFFMAN_EMIT_SYMBOL, 111), + (23, HUFFMAN_EMIT_SYMBOL, 111), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 111), + + # Node 7 + (3, HUFFMAN_EMIT_SYMBOL, 99), + (6, HUFFMAN_EMIT_SYMBOL, 99), + (10, HUFFMAN_EMIT_SYMBOL, 99), + (15, HUFFMAN_EMIT_SYMBOL, 99), + (24, HUFFMAN_EMIT_SYMBOL, 99), + (31, HUFFMAN_EMIT_SYMBOL, 99), + (41, HUFFMAN_EMIT_SYMBOL, 99), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 99), + (3, HUFFMAN_EMIT_SYMBOL, 101), + (6, HUFFMAN_EMIT_SYMBOL, 101), + (10, HUFFMAN_EMIT_SYMBOL, 101), + (15, HUFFMAN_EMIT_SYMBOL, 101), + (24, HUFFMAN_EMIT_SYMBOL, 101), + (31, HUFFMAN_EMIT_SYMBOL, 101), + (41, HUFFMAN_EMIT_SYMBOL, 101), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 101), + + # Node 8 + (3, HUFFMAN_EMIT_SYMBOL, 105), + (6, HUFFMAN_EMIT_SYMBOL, 105), + (10, HUFFMAN_EMIT_SYMBOL, 105), + (15, HUFFMAN_EMIT_SYMBOL, 105), + (24, HUFFMAN_EMIT_SYMBOL, 105), + (31, HUFFMAN_EMIT_SYMBOL, 105), + (41, HUFFMAN_EMIT_SYMBOL, 105), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 105), + (3, HUFFMAN_EMIT_SYMBOL, 111), + (6, HUFFMAN_EMIT_SYMBOL, 111), + (10, HUFFMAN_EMIT_SYMBOL, 111), + (15, HUFFMAN_EMIT_SYMBOL, 111), + (24, HUFFMAN_EMIT_SYMBOL, 111), + (31, HUFFMAN_EMIT_SYMBOL, 111), + (41, HUFFMAN_EMIT_SYMBOL, 111), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 111), + + # Node 9 + (1, HUFFMAN_EMIT_SYMBOL, 115), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 115), + (1, HUFFMAN_EMIT_SYMBOL, 116), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 116), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 32), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 37), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 45), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 46), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 47), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 51), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 52), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 53), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 54), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 55), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 56), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 57), + + # Node 10 + (2, HUFFMAN_EMIT_SYMBOL, 115), + (9, HUFFMAN_EMIT_SYMBOL, 115), + (23, HUFFMAN_EMIT_SYMBOL, 115), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 115), + (2, HUFFMAN_EMIT_SYMBOL, 116), + (9, HUFFMAN_EMIT_SYMBOL, 116), + (23, HUFFMAN_EMIT_SYMBOL, 116), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 116), + (1, HUFFMAN_EMIT_SYMBOL, 32), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 32), + (1, HUFFMAN_EMIT_SYMBOL, 37), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 37), + (1, HUFFMAN_EMIT_SYMBOL, 45), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 45), + (1, HUFFMAN_EMIT_SYMBOL, 46), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 46), + + # Node 11 + (3, HUFFMAN_EMIT_SYMBOL, 115), + (6, HUFFMAN_EMIT_SYMBOL, 115), + (10, HUFFMAN_EMIT_SYMBOL, 115), + (15, HUFFMAN_EMIT_SYMBOL, 115), + (24, HUFFMAN_EMIT_SYMBOL, 115), + (31, HUFFMAN_EMIT_SYMBOL, 115), + (41, HUFFMAN_EMIT_SYMBOL, 115), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 115), + (3, HUFFMAN_EMIT_SYMBOL, 116), + (6, HUFFMAN_EMIT_SYMBOL, 116), + (10, HUFFMAN_EMIT_SYMBOL, 116), + (15, HUFFMAN_EMIT_SYMBOL, 116), + (24, HUFFMAN_EMIT_SYMBOL, 116), + (31, HUFFMAN_EMIT_SYMBOL, 116), + (41, HUFFMAN_EMIT_SYMBOL, 116), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 116), + + # Node 12 + (2, HUFFMAN_EMIT_SYMBOL, 32), + (9, HUFFMAN_EMIT_SYMBOL, 32), + (23, HUFFMAN_EMIT_SYMBOL, 32), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 32), + (2, HUFFMAN_EMIT_SYMBOL, 37), + (9, HUFFMAN_EMIT_SYMBOL, 37), + (23, HUFFMAN_EMIT_SYMBOL, 37), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 37), + (2, HUFFMAN_EMIT_SYMBOL, 45), + (9, HUFFMAN_EMIT_SYMBOL, 45), + (23, HUFFMAN_EMIT_SYMBOL, 45), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 45), + (2, HUFFMAN_EMIT_SYMBOL, 46), + (9, HUFFMAN_EMIT_SYMBOL, 46), + (23, HUFFMAN_EMIT_SYMBOL, 46), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 46), + + # Node 13 + (3, HUFFMAN_EMIT_SYMBOL, 32), + (6, HUFFMAN_EMIT_SYMBOL, 32), + (10, HUFFMAN_EMIT_SYMBOL, 32), + (15, HUFFMAN_EMIT_SYMBOL, 32), + (24, HUFFMAN_EMIT_SYMBOL, 32), + (31, HUFFMAN_EMIT_SYMBOL, 32), + (41, HUFFMAN_EMIT_SYMBOL, 32), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 32), + (3, HUFFMAN_EMIT_SYMBOL, 37), + (6, HUFFMAN_EMIT_SYMBOL, 37), + (10, HUFFMAN_EMIT_SYMBOL, 37), + (15, HUFFMAN_EMIT_SYMBOL, 37), + (24, HUFFMAN_EMIT_SYMBOL, 37), + (31, HUFFMAN_EMIT_SYMBOL, 37), + (41, HUFFMAN_EMIT_SYMBOL, 37), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 37), + + # Node 14 + (3, HUFFMAN_EMIT_SYMBOL, 45), + (6, HUFFMAN_EMIT_SYMBOL, 45), + (10, HUFFMAN_EMIT_SYMBOL, 45), + (15, HUFFMAN_EMIT_SYMBOL, 45), + (24, HUFFMAN_EMIT_SYMBOL, 45), + (31, HUFFMAN_EMIT_SYMBOL, 45), + (41, HUFFMAN_EMIT_SYMBOL, 45), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 45), + (3, HUFFMAN_EMIT_SYMBOL, 46), + (6, HUFFMAN_EMIT_SYMBOL, 46), + (10, HUFFMAN_EMIT_SYMBOL, 46), + (15, HUFFMAN_EMIT_SYMBOL, 46), + (24, HUFFMAN_EMIT_SYMBOL, 46), + (31, HUFFMAN_EMIT_SYMBOL, 46), + (41, HUFFMAN_EMIT_SYMBOL, 46), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 46), + + # Node 15 + (1, HUFFMAN_EMIT_SYMBOL, 47), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 47), + (1, HUFFMAN_EMIT_SYMBOL, 51), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 51), + (1, HUFFMAN_EMIT_SYMBOL, 52), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 52), + (1, HUFFMAN_EMIT_SYMBOL, 53), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 53), + (1, HUFFMAN_EMIT_SYMBOL, 54), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 54), + (1, HUFFMAN_EMIT_SYMBOL, 55), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 55), + (1, HUFFMAN_EMIT_SYMBOL, 56), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 56), + (1, HUFFMAN_EMIT_SYMBOL, 57), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 57), + + # Node 16 + (2, HUFFMAN_EMIT_SYMBOL, 47), + (9, HUFFMAN_EMIT_SYMBOL, 47), + (23, HUFFMAN_EMIT_SYMBOL, 47), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 47), + (2, HUFFMAN_EMIT_SYMBOL, 51), + (9, HUFFMAN_EMIT_SYMBOL, 51), + (23, HUFFMAN_EMIT_SYMBOL, 51), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 51), + (2, HUFFMAN_EMIT_SYMBOL, 52), + (9, HUFFMAN_EMIT_SYMBOL, 52), + (23, HUFFMAN_EMIT_SYMBOL, 52), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 52), + (2, HUFFMAN_EMIT_SYMBOL, 53), + (9, HUFFMAN_EMIT_SYMBOL, 53), + (23, HUFFMAN_EMIT_SYMBOL, 53), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 53), + + # Node 17 + (3, HUFFMAN_EMIT_SYMBOL, 47), + (6, HUFFMAN_EMIT_SYMBOL, 47), + (10, HUFFMAN_EMIT_SYMBOL, 47), + (15, HUFFMAN_EMIT_SYMBOL, 47), + (24, HUFFMAN_EMIT_SYMBOL, 47), + (31, HUFFMAN_EMIT_SYMBOL, 47), + (41, HUFFMAN_EMIT_SYMBOL, 47), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 47), + (3, HUFFMAN_EMIT_SYMBOL, 51), + (6, HUFFMAN_EMIT_SYMBOL, 51), + (10, HUFFMAN_EMIT_SYMBOL, 51), + (15, HUFFMAN_EMIT_SYMBOL, 51), + (24, HUFFMAN_EMIT_SYMBOL, 51), + (31, HUFFMAN_EMIT_SYMBOL, 51), + (41, HUFFMAN_EMIT_SYMBOL, 51), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 51), + + # Node 18 + (3, HUFFMAN_EMIT_SYMBOL, 52), + (6, HUFFMAN_EMIT_SYMBOL, 52), + (10, HUFFMAN_EMIT_SYMBOL, 52), + (15, HUFFMAN_EMIT_SYMBOL, 52), + (24, HUFFMAN_EMIT_SYMBOL, 52), + (31, HUFFMAN_EMIT_SYMBOL, 52), + (41, HUFFMAN_EMIT_SYMBOL, 52), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 52), + (3, HUFFMAN_EMIT_SYMBOL, 53), + (6, HUFFMAN_EMIT_SYMBOL, 53), + (10, HUFFMAN_EMIT_SYMBOL, 53), + (15, HUFFMAN_EMIT_SYMBOL, 53), + (24, HUFFMAN_EMIT_SYMBOL, 53), + (31, HUFFMAN_EMIT_SYMBOL, 53), + (41, HUFFMAN_EMIT_SYMBOL, 53), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 53), + + # Node 19 + (2, HUFFMAN_EMIT_SYMBOL, 54), + (9, HUFFMAN_EMIT_SYMBOL, 54), + (23, HUFFMAN_EMIT_SYMBOL, 54), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 54), + (2, HUFFMAN_EMIT_SYMBOL, 55), + (9, HUFFMAN_EMIT_SYMBOL, 55), + (23, HUFFMAN_EMIT_SYMBOL, 55), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 55), + (2, HUFFMAN_EMIT_SYMBOL, 56), + (9, HUFFMAN_EMIT_SYMBOL, 56), + (23, HUFFMAN_EMIT_SYMBOL, 56), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 56), + (2, HUFFMAN_EMIT_SYMBOL, 57), + (9, HUFFMAN_EMIT_SYMBOL, 57), + (23, HUFFMAN_EMIT_SYMBOL, 57), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 57), + + # Node 20 + (3, HUFFMAN_EMIT_SYMBOL, 54), + (6, HUFFMAN_EMIT_SYMBOL, 54), + (10, HUFFMAN_EMIT_SYMBOL, 54), + (15, HUFFMAN_EMIT_SYMBOL, 54), + (24, HUFFMAN_EMIT_SYMBOL, 54), + (31, HUFFMAN_EMIT_SYMBOL, 54), + (41, HUFFMAN_EMIT_SYMBOL, 54), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 54), + (3, HUFFMAN_EMIT_SYMBOL, 55), + (6, HUFFMAN_EMIT_SYMBOL, 55), + (10, HUFFMAN_EMIT_SYMBOL, 55), + (15, HUFFMAN_EMIT_SYMBOL, 55), + (24, HUFFMAN_EMIT_SYMBOL, 55), + (31, HUFFMAN_EMIT_SYMBOL, 55), + (41, HUFFMAN_EMIT_SYMBOL, 55), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 55), + + # Node 21 + (3, HUFFMAN_EMIT_SYMBOL, 56), + (6, HUFFMAN_EMIT_SYMBOL, 56), + (10, HUFFMAN_EMIT_SYMBOL, 56), + (15, HUFFMAN_EMIT_SYMBOL, 56), + (24, HUFFMAN_EMIT_SYMBOL, 56), + (31, HUFFMAN_EMIT_SYMBOL, 56), + (41, HUFFMAN_EMIT_SYMBOL, 56), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 56), + (3, HUFFMAN_EMIT_SYMBOL, 57), + (6, HUFFMAN_EMIT_SYMBOL, 57), + (10, HUFFMAN_EMIT_SYMBOL, 57), + (15, HUFFMAN_EMIT_SYMBOL, 57), + (24, HUFFMAN_EMIT_SYMBOL, 57), + (31, HUFFMAN_EMIT_SYMBOL, 57), + (41, HUFFMAN_EMIT_SYMBOL, 57), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 57), + + # Node 22 + (26, 0, 0), + (27, 0, 0), + (29, 0, 0), + (30, 0, 0), + (33, 0, 0), + (34, 0, 0), + (36, 0, 0), + (37, 0, 0), + (43, 0, 0), + (46, 0, 0), + (50, 0, 0), + (53, 0, 0), + (58, 0, 0), + (61, 0, 0), + (65, 0, 0), + (68, HUFFMAN_COMPLETE, 0), + + # Node 23 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 61), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 65), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 95), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 98), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 100), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 102), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 103), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 104), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 108), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 109), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 110), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 112), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 114), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 117), + (38, 0, 0), + (39, 0, 0), + + # Node 24 + (1, HUFFMAN_EMIT_SYMBOL, 61), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 61), + (1, HUFFMAN_EMIT_SYMBOL, 65), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 65), + (1, HUFFMAN_EMIT_SYMBOL, 95), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 95), + (1, HUFFMAN_EMIT_SYMBOL, 98), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 98), + (1, HUFFMAN_EMIT_SYMBOL, 100), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 100), + (1, HUFFMAN_EMIT_SYMBOL, 102), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 102), + (1, HUFFMAN_EMIT_SYMBOL, 103), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 103), + (1, HUFFMAN_EMIT_SYMBOL, 104), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 104), + + # Node 25 + (2, HUFFMAN_EMIT_SYMBOL, 61), + (9, HUFFMAN_EMIT_SYMBOL, 61), + (23, HUFFMAN_EMIT_SYMBOL, 61), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 61), + (2, HUFFMAN_EMIT_SYMBOL, 65), + (9, HUFFMAN_EMIT_SYMBOL, 65), + (23, HUFFMAN_EMIT_SYMBOL, 65), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 65), + (2, HUFFMAN_EMIT_SYMBOL, 95), + (9, HUFFMAN_EMIT_SYMBOL, 95), + (23, HUFFMAN_EMIT_SYMBOL, 95), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 95), + (2, HUFFMAN_EMIT_SYMBOL, 98), + (9, HUFFMAN_EMIT_SYMBOL, 98), + (23, HUFFMAN_EMIT_SYMBOL, 98), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 98), + + # Node 26 + (3, HUFFMAN_EMIT_SYMBOL, 61), + (6, HUFFMAN_EMIT_SYMBOL, 61), + (10, HUFFMAN_EMIT_SYMBOL, 61), + (15, HUFFMAN_EMIT_SYMBOL, 61), + (24, HUFFMAN_EMIT_SYMBOL, 61), + (31, HUFFMAN_EMIT_SYMBOL, 61), + (41, HUFFMAN_EMIT_SYMBOL, 61), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 61), + (3, HUFFMAN_EMIT_SYMBOL, 65), + (6, HUFFMAN_EMIT_SYMBOL, 65), + (10, HUFFMAN_EMIT_SYMBOL, 65), + (15, HUFFMAN_EMIT_SYMBOL, 65), + (24, HUFFMAN_EMIT_SYMBOL, 65), + (31, HUFFMAN_EMIT_SYMBOL, 65), + (41, HUFFMAN_EMIT_SYMBOL, 65), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 65), + + # Node 27 + (3, HUFFMAN_EMIT_SYMBOL, 95), + (6, HUFFMAN_EMIT_SYMBOL, 95), + (10, HUFFMAN_EMIT_SYMBOL, 95), + (15, HUFFMAN_EMIT_SYMBOL, 95), + (24, HUFFMAN_EMIT_SYMBOL, 95), + (31, HUFFMAN_EMIT_SYMBOL, 95), + (41, HUFFMAN_EMIT_SYMBOL, 95), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 95), + (3, HUFFMAN_EMIT_SYMBOL, 98), + (6, HUFFMAN_EMIT_SYMBOL, 98), + (10, HUFFMAN_EMIT_SYMBOL, 98), + (15, HUFFMAN_EMIT_SYMBOL, 98), + (24, HUFFMAN_EMIT_SYMBOL, 98), + (31, HUFFMAN_EMIT_SYMBOL, 98), + (41, HUFFMAN_EMIT_SYMBOL, 98), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 98), + + # Node 28 + (2, HUFFMAN_EMIT_SYMBOL, 100), + (9, HUFFMAN_EMIT_SYMBOL, 100), + (23, HUFFMAN_EMIT_SYMBOL, 100), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 100), + (2, HUFFMAN_EMIT_SYMBOL, 102), + (9, HUFFMAN_EMIT_SYMBOL, 102), + (23, HUFFMAN_EMIT_SYMBOL, 102), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 102), + (2, HUFFMAN_EMIT_SYMBOL, 103), + (9, HUFFMAN_EMIT_SYMBOL, 103), + (23, HUFFMAN_EMIT_SYMBOL, 103), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 103), + (2, HUFFMAN_EMIT_SYMBOL, 104), + (9, HUFFMAN_EMIT_SYMBOL, 104), + (23, HUFFMAN_EMIT_SYMBOL, 104), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 104), + + # Node 29 + (3, HUFFMAN_EMIT_SYMBOL, 100), + (6, HUFFMAN_EMIT_SYMBOL, 100), + (10, HUFFMAN_EMIT_SYMBOL, 100), + (15, HUFFMAN_EMIT_SYMBOL, 100), + (24, HUFFMAN_EMIT_SYMBOL, 100), + (31, HUFFMAN_EMIT_SYMBOL, 100), + (41, HUFFMAN_EMIT_SYMBOL, 100), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 100), + (3, HUFFMAN_EMIT_SYMBOL, 102), + (6, HUFFMAN_EMIT_SYMBOL, 102), + (10, HUFFMAN_EMIT_SYMBOL, 102), + (15, HUFFMAN_EMIT_SYMBOL, 102), + (24, HUFFMAN_EMIT_SYMBOL, 102), + (31, HUFFMAN_EMIT_SYMBOL, 102), + (41, HUFFMAN_EMIT_SYMBOL, 102), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 102), + + # Node 30 + (3, HUFFMAN_EMIT_SYMBOL, 103), + (6, HUFFMAN_EMIT_SYMBOL, 103), + (10, HUFFMAN_EMIT_SYMBOL, 103), + (15, HUFFMAN_EMIT_SYMBOL, 103), + (24, HUFFMAN_EMIT_SYMBOL, 103), + (31, HUFFMAN_EMIT_SYMBOL, 103), + (41, HUFFMAN_EMIT_SYMBOL, 103), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 103), + (3, HUFFMAN_EMIT_SYMBOL, 104), + (6, HUFFMAN_EMIT_SYMBOL, 104), + (10, HUFFMAN_EMIT_SYMBOL, 104), + (15, HUFFMAN_EMIT_SYMBOL, 104), + (24, HUFFMAN_EMIT_SYMBOL, 104), + (31, HUFFMAN_EMIT_SYMBOL, 104), + (41, HUFFMAN_EMIT_SYMBOL, 104), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 104), + + # Node 31 + (1, HUFFMAN_EMIT_SYMBOL, 108), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 108), + (1, HUFFMAN_EMIT_SYMBOL, 109), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 109), + (1, HUFFMAN_EMIT_SYMBOL, 110), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 110), + (1, HUFFMAN_EMIT_SYMBOL, 112), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 112), + (1, HUFFMAN_EMIT_SYMBOL, 114), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 114), + (1, HUFFMAN_EMIT_SYMBOL, 117), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 117), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 58), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 66), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 67), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 68), + + # Node 32 + (2, HUFFMAN_EMIT_SYMBOL, 108), + (9, HUFFMAN_EMIT_SYMBOL, 108), + (23, HUFFMAN_EMIT_SYMBOL, 108), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 108), + (2, HUFFMAN_EMIT_SYMBOL, 109), + (9, HUFFMAN_EMIT_SYMBOL, 109), + (23, HUFFMAN_EMIT_SYMBOL, 109), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 109), + (2, HUFFMAN_EMIT_SYMBOL, 110), + (9, HUFFMAN_EMIT_SYMBOL, 110), + (23, HUFFMAN_EMIT_SYMBOL, 110), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 110), + (2, HUFFMAN_EMIT_SYMBOL, 112), + (9, HUFFMAN_EMIT_SYMBOL, 112), + (23, HUFFMAN_EMIT_SYMBOL, 112), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 112), + + # Node 33 + (3, HUFFMAN_EMIT_SYMBOL, 108), + (6, HUFFMAN_EMIT_SYMBOL, 108), + (10, HUFFMAN_EMIT_SYMBOL, 108), + (15, HUFFMAN_EMIT_SYMBOL, 108), + (24, HUFFMAN_EMIT_SYMBOL, 108), + (31, HUFFMAN_EMIT_SYMBOL, 108), + (41, HUFFMAN_EMIT_SYMBOL, 108), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 108), + (3, HUFFMAN_EMIT_SYMBOL, 109), + (6, HUFFMAN_EMIT_SYMBOL, 109), + (10, HUFFMAN_EMIT_SYMBOL, 109), + (15, HUFFMAN_EMIT_SYMBOL, 109), + (24, HUFFMAN_EMIT_SYMBOL, 109), + (31, HUFFMAN_EMIT_SYMBOL, 109), + (41, HUFFMAN_EMIT_SYMBOL, 109), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 109), + + # Node 34 + (3, HUFFMAN_EMIT_SYMBOL, 110), + (6, HUFFMAN_EMIT_SYMBOL, 110), + (10, HUFFMAN_EMIT_SYMBOL, 110), + (15, HUFFMAN_EMIT_SYMBOL, 110), + (24, HUFFMAN_EMIT_SYMBOL, 110), + (31, HUFFMAN_EMIT_SYMBOL, 110), + (41, HUFFMAN_EMIT_SYMBOL, 110), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 110), + (3, HUFFMAN_EMIT_SYMBOL, 112), + (6, HUFFMAN_EMIT_SYMBOL, 112), + (10, HUFFMAN_EMIT_SYMBOL, 112), + (15, HUFFMAN_EMIT_SYMBOL, 112), + (24, HUFFMAN_EMIT_SYMBOL, 112), + (31, HUFFMAN_EMIT_SYMBOL, 112), + (41, HUFFMAN_EMIT_SYMBOL, 112), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 112), + + # Node 35 + (2, HUFFMAN_EMIT_SYMBOL, 114), + (9, HUFFMAN_EMIT_SYMBOL, 114), + (23, HUFFMAN_EMIT_SYMBOL, 114), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 114), + (2, HUFFMAN_EMIT_SYMBOL, 117), + (9, HUFFMAN_EMIT_SYMBOL, 117), + (23, HUFFMAN_EMIT_SYMBOL, 117), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 117), + (1, HUFFMAN_EMIT_SYMBOL, 58), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 58), + (1, HUFFMAN_EMIT_SYMBOL, 66), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 66), + (1, HUFFMAN_EMIT_SYMBOL, 67), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 67), + (1, HUFFMAN_EMIT_SYMBOL, 68), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 68), + + # Node 36 + (3, HUFFMAN_EMIT_SYMBOL, 114), + (6, HUFFMAN_EMIT_SYMBOL, 114), + (10, HUFFMAN_EMIT_SYMBOL, 114), + (15, HUFFMAN_EMIT_SYMBOL, 114), + (24, HUFFMAN_EMIT_SYMBOL, 114), + (31, HUFFMAN_EMIT_SYMBOL, 114), + (41, HUFFMAN_EMIT_SYMBOL, 114), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 114), + (3, HUFFMAN_EMIT_SYMBOL, 117), + (6, HUFFMAN_EMIT_SYMBOL, 117), + (10, HUFFMAN_EMIT_SYMBOL, 117), + (15, HUFFMAN_EMIT_SYMBOL, 117), + (24, HUFFMAN_EMIT_SYMBOL, 117), + (31, HUFFMAN_EMIT_SYMBOL, 117), + (41, HUFFMAN_EMIT_SYMBOL, 117), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 117), + + # Node 37 + (2, HUFFMAN_EMIT_SYMBOL, 58), + (9, HUFFMAN_EMIT_SYMBOL, 58), + (23, HUFFMAN_EMIT_SYMBOL, 58), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 58), + (2, HUFFMAN_EMIT_SYMBOL, 66), + (9, HUFFMAN_EMIT_SYMBOL, 66), + (23, HUFFMAN_EMIT_SYMBOL, 66), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 66), + (2, HUFFMAN_EMIT_SYMBOL, 67), + (9, HUFFMAN_EMIT_SYMBOL, 67), + (23, HUFFMAN_EMIT_SYMBOL, 67), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 67), + (2, HUFFMAN_EMIT_SYMBOL, 68), + (9, HUFFMAN_EMIT_SYMBOL, 68), + (23, HUFFMAN_EMIT_SYMBOL, 68), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 68), + + # Node 38 + (3, HUFFMAN_EMIT_SYMBOL, 58), + (6, HUFFMAN_EMIT_SYMBOL, 58), + (10, HUFFMAN_EMIT_SYMBOL, 58), + (15, HUFFMAN_EMIT_SYMBOL, 58), + (24, HUFFMAN_EMIT_SYMBOL, 58), + (31, HUFFMAN_EMIT_SYMBOL, 58), + (41, HUFFMAN_EMIT_SYMBOL, 58), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 58), + (3, HUFFMAN_EMIT_SYMBOL, 66), + (6, HUFFMAN_EMIT_SYMBOL, 66), + (10, HUFFMAN_EMIT_SYMBOL, 66), + (15, HUFFMAN_EMIT_SYMBOL, 66), + (24, HUFFMAN_EMIT_SYMBOL, 66), + (31, HUFFMAN_EMIT_SYMBOL, 66), + (41, HUFFMAN_EMIT_SYMBOL, 66), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 66), + + # Node 39 + (3, HUFFMAN_EMIT_SYMBOL, 67), + (6, HUFFMAN_EMIT_SYMBOL, 67), + (10, HUFFMAN_EMIT_SYMBOL, 67), + (15, HUFFMAN_EMIT_SYMBOL, 67), + (24, HUFFMAN_EMIT_SYMBOL, 67), + (31, HUFFMAN_EMIT_SYMBOL, 67), + (41, HUFFMAN_EMIT_SYMBOL, 67), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 67), + (3, HUFFMAN_EMIT_SYMBOL, 68), + (6, HUFFMAN_EMIT_SYMBOL, 68), + (10, HUFFMAN_EMIT_SYMBOL, 68), + (15, HUFFMAN_EMIT_SYMBOL, 68), + (24, HUFFMAN_EMIT_SYMBOL, 68), + (31, HUFFMAN_EMIT_SYMBOL, 68), + (41, HUFFMAN_EMIT_SYMBOL, 68), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 68), + + # Node 40 + (44, 0, 0), + (45, 0, 0), + (47, 0, 0), + (48, 0, 0), + (51, 0, 0), + (52, 0, 0), + (54, 0, 0), + (55, 0, 0), + (59, 0, 0), + (60, 0, 0), + (62, 0, 0), + (63, 0, 0), + (66, 0, 0), + (67, 0, 0), + (69, 0, 0), + (72, HUFFMAN_COMPLETE, 0), + + # Node 41 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 69), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 70), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 71), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 72), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 73), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 74), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 75), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 76), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 77), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 78), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 79), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 80), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 81), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 82), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 83), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 84), + + # Node 42 + (1, HUFFMAN_EMIT_SYMBOL, 69), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 69), + (1, HUFFMAN_EMIT_SYMBOL, 70), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 70), + (1, HUFFMAN_EMIT_SYMBOL, 71), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 71), + (1, HUFFMAN_EMIT_SYMBOL, 72), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 72), + (1, HUFFMAN_EMIT_SYMBOL, 73), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 73), + (1, HUFFMAN_EMIT_SYMBOL, 74), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 74), + (1, HUFFMAN_EMIT_SYMBOL, 75), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 75), + (1, HUFFMAN_EMIT_SYMBOL, 76), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 76), + + # Node 43 + (2, HUFFMAN_EMIT_SYMBOL, 69), + (9, HUFFMAN_EMIT_SYMBOL, 69), + (23, HUFFMAN_EMIT_SYMBOL, 69), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 69), + (2, HUFFMAN_EMIT_SYMBOL, 70), + (9, HUFFMAN_EMIT_SYMBOL, 70), + (23, HUFFMAN_EMIT_SYMBOL, 70), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 70), + (2, HUFFMAN_EMIT_SYMBOL, 71), + (9, HUFFMAN_EMIT_SYMBOL, 71), + (23, HUFFMAN_EMIT_SYMBOL, 71), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 71), + (2, HUFFMAN_EMIT_SYMBOL, 72), + (9, HUFFMAN_EMIT_SYMBOL, 72), + (23, HUFFMAN_EMIT_SYMBOL, 72), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 72), + + # Node 44 + (3, HUFFMAN_EMIT_SYMBOL, 69), + (6, HUFFMAN_EMIT_SYMBOL, 69), + (10, HUFFMAN_EMIT_SYMBOL, 69), + (15, HUFFMAN_EMIT_SYMBOL, 69), + (24, HUFFMAN_EMIT_SYMBOL, 69), + (31, HUFFMAN_EMIT_SYMBOL, 69), + (41, HUFFMAN_EMIT_SYMBOL, 69), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 69), + (3, HUFFMAN_EMIT_SYMBOL, 70), + (6, HUFFMAN_EMIT_SYMBOL, 70), + (10, HUFFMAN_EMIT_SYMBOL, 70), + (15, HUFFMAN_EMIT_SYMBOL, 70), + (24, HUFFMAN_EMIT_SYMBOL, 70), + (31, HUFFMAN_EMIT_SYMBOL, 70), + (41, HUFFMAN_EMIT_SYMBOL, 70), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 70), + + # Node 45 + (3, HUFFMAN_EMIT_SYMBOL, 71), + (6, HUFFMAN_EMIT_SYMBOL, 71), + (10, HUFFMAN_EMIT_SYMBOL, 71), + (15, HUFFMAN_EMIT_SYMBOL, 71), + (24, HUFFMAN_EMIT_SYMBOL, 71), + (31, HUFFMAN_EMIT_SYMBOL, 71), + (41, HUFFMAN_EMIT_SYMBOL, 71), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 71), + (3, HUFFMAN_EMIT_SYMBOL, 72), + (6, HUFFMAN_EMIT_SYMBOL, 72), + (10, HUFFMAN_EMIT_SYMBOL, 72), + (15, HUFFMAN_EMIT_SYMBOL, 72), + (24, HUFFMAN_EMIT_SYMBOL, 72), + (31, HUFFMAN_EMIT_SYMBOL, 72), + (41, HUFFMAN_EMIT_SYMBOL, 72), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 72), + + # Node 46 + (2, HUFFMAN_EMIT_SYMBOL, 73), + (9, HUFFMAN_EMIT_SYMBOL, 73), + (23, HUFFMAN_EMIT_SYMBOL, 73), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 73), + (2, HUFFMAN_EMIT_SYMBOL, 74), + (9, HUFFMAN_EMIT_SYMBOL, 74), + (23, HUFFMAN_EMIT_SYMBOL, 74), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 74), + (2, HUFFMAN_EMIT_SYMBOL, 75), + (9, HUFFMAN_EMIT_SYMBOL, 75), + (23, HUFFMAN_EMIT_SYMBOL, 75), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 75), + (2, HUFFMAN_EMIT_SYMBOL, 76), + (9, HUFFMAN_EMIT_SYMBOL, 76), + (23, HUFFMAN_EMIT_SYMBOL, 76), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 76), + + # Node 47 + (3, HUFFMAN_EMIT_SYMBOL, 73), + (6, HUFFMAN_EMIT_SYMBOL, 73), + (10, HUFFMAN_EMIT_SYMBOL, 73), + (15, HUFFMAN_EMIT_SYMBOL, 73), + (24, HUFFMAN_EMIT_SYMBOL, 73), + (31, HUFFMAN_EMIT_SYMBOL, 73), + (41, HUFFMAN_EMIT_SYMBOL, 73), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 73), + (3, HUFFMAN_EMIT_SYMBOL, 74), + (6, HUFFMAN_EMIT_SYMBOL, 74), + (10, HUFFMAN_EMIT_SYMBOL, 74), + (15, HUFFMAN_EMIT_SYMBOL, 74), + (24, HUFFMAN_EMIT_SYMBOL, 74), + (31, HUFFMAN_EMIT_SYMBOL, 74), + (41, HUFFMAN_EMIT_SYMBOL, 74), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 74), + + # Node 48 + (3, HUFFMAN_EMIT_SYMBOL, 75), + (6, HUFFMAN_EMIT_SYMBOL, 75), + (10, HUFFMAN_EMIT_SYMBOL, 75), + (15, HUFFMAN_EMIT_SYMBOL, 75), + (24, HUFFMAN_EMIT_SYMBOL, 75), + (31, HUFFMAN_EMIT_SYMBOL, 75), + (41, HUFFMAN_EMIT_SYMBOL, 75), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 75), + (3, HUFFMAN_EMIT_SYMBOL, 76), + (6, HUFFMAN_EMIT_SYMBOL, 76), + (10, HUFFMAN_EMIT_SYMBOL, 76), + (15, HUFFMAN_EMIT_SYMBOL, 76), + (24, HUFFMAN_EMIT_SYMBOL, 76), + (31, HUFFMAN_EMIT_SYMBOL, 76), + (41, HUFFMAN_EMIT_SYMBOL, 76), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 76), + + # Node 49 + (1, HUFFMAN_EMIT_SYMBOL, 77), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 77), + (1, HUFFMAN_EMIT_SYMBOL, 78), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 78), + (1, HUFFMAN_EMIT_SYMBOL, 79), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 79), + (1, HUFFMAN_EMIT_SYMBOL, 80), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 80), + (1, HUFFMAN_EMIT_SYMBOL, 81), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 81), + (1, HUFFMAN_EMIT_SYMBOL, 82), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 82), + (1, HUFFMAN_EMIT_SYMBOL, 83), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 83), + (1, HUFFMAN_EMIT_SYMBOL, 84), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 84), + + # Node 50 + (2, HUFFMAN_EMIT_SYMBOL, 77), + (9, HUFFMAN_EMIT_SYMBOL, 77), + (23, HUFFMAN_EMIT_SYMBOL, 77), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 77), + (2, HUFFMAN_EMIT_SYMBOL, 78), + (9, HUFFMAN_EMIT_SYMBOL, 78), + (23, HUFFMAN_EMIT_SYMBOL, 78), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 78), + (2, HUFFMAN_EMIT_SYMBOL, 79), + (9, HUFFMAN_EMIT_SYMBOL, 79), + (23, HUFFMAN_EMIT_SYMBOL, 79), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 79), + (2, HUFFMAN_EMIT_SYMBOL, 80), + (9, HUFFMAN_EMIT_SYMBOL, 80), + (23, HUFFMAN_EMIT_SYMBOL, 80), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 80), + + # Node 51 + (3, HUFFMAN_EMIT_SYMBOL, 77), + (6, HUFFMAN_EMIT_SYMBOL, 77), + (10, HUFFMAN_EMIT_SYMBOL, 77), + (15, HUFFMAN_EMIT_SYMBOL, 77), + (24, HUFFMAN_EMIT_SYMBOL, 77), + (31, HUFFMAN_EMIT_SYMBOL, 77), + (41, HUFFMAN_EMIT_SYMBOL, 77), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 77), + (3, HUFFMAN_EMIT_SYMBOL, 78), + (6, HUFFMAN_EMIT_SYMBOL, 78), + (10, HUFFMAN_EMIT_SYMBOL, 78), + (15, HUFFMAN_EMIT_SYMBOL, 78), + (24, HUFFMAN_EMIT_SYMBOL, 78), + (31, HUFFMAN_EMIT_SYMBOL, 78), + (41, HUFFMAN_EMIT_SYMBOL, 78), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 78), + + # Node 52 + (3, HUFFMAN_EMIT_SYMBOL, 79), + (6, HUFFMAN_EMIT_SYMBOL, 79), + (10, HUFFMAN_EMIT_SYMBOL, 79), + (15, HUFFMAN_EMIT_SYMBOL, 79), + (24, HUFFMAN_EMIT_SYMBOL, 79), + (31, HUFFMAN_EMIT_SYMBOL, 79), + (41, HUFFMAN_EMIT_SYMBOL, 79), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 79), + (3, HUFFMAN_EMIT_SYMBOL, 80), + (6, HUFFMAN_EMIT_SYMBOL, 80), + (10, HUFFMAN_EMIT_SYMBOL, 80), + (15, HUFFMAN_EMIT_SYMBOL, 80), + (24, HUFFMAN_EMIT_SYMBOL, 80), + (31, HUFFMAN_EMIT_SYMBOL, 80), + (41, HUFFMAN_EMIT_SYMBOL, 80), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 80), + + # Node 53 + (2, HUFFMAN_EMIT_SYMBOL, 81), + (9, HUFFMAN_EMIT_SYMBOL, 81), + (23, HUFFMAN_EMIT_SYMBOL, 81), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 81), + (2, HUFFMAN_EMIT_SYMBOL, 82), + (9, HUFFMAN_EMIT_SYMBOL, 82), + (23, HUFFMAN_EMIT_SYMBOL, 82), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 82), + (2, HUFFMAN_EMIT_SYMBOL, 83), + (9, HUFFMAN_EMIT_SYMBOL, 83), + (23, HUFFMAN_EMIT_SYMBOL, 83), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 83), + (2, HUFFMAN_EMIT_SYMBOL, 84), + (9, HUFFMAN_EMIT_SYMBOL, 84), + (23, HUFFMAN_EMIT_SYMBOL, 84), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 84), + + # Node 54 + (3, HUFFMAN_EMIT_SYMBOL, 81), + (6, HUFFMAN_EMIT_SYMBOL, 81), + (10, HUFFMAN_EMIT_SYMBOL, 81), + (15, HUFFMAN_EMIT_SYMBOL, 81), + (24, HUFFMAN_EMIT_SYMBOL, 81), + (31, HUFFMAN_EMIT_SYMBOL, 81), + (41, HUFFMAN_EMIT_SYMBOL, 81), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 81), + (3, HUFFMAN_EMIT_SYMBOL, 82), + (6, HUFFMAN_EMIT_SYMBOL, 82), + (10, HUFFMAN_EMIT_SYMBOL, 82), + (15, HUFFMAN_EMIT_SYMBOL, 82), + (24, HUFFMAN_EMIT_SYMBOL, 82), + (31, HUFFMAN_EMIT_SYMBOL, 82), + (41, HUFFMAN_EMIT_SYMBOL, 82), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 82), + + # Node 55 + (3, HUFFMAN_EMIT_SYMBOL, 83), + (6, HUFFMAN_EMIT_SYMBOL, 83), + (10, HUFFMAN_EMIT_SYMBOL, 83), + (15, HUFFMAN_EMIT_SYMBOL, 83), + (24, HUFFMAN_EMIT_SYMBOL, 83), + (31, HUFFMAN_EMIT_SYMBOL, 83), + (41, HUFFMAN_EMIT_SYMBOL, 83), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 83), + (3, HUFFMAN_EMIT_SYMBOL, 84), + (6, HUFFMAN_EMIT_SYMBOL, 84), + (10, HUFFMAN_EMIT_SYMBOL, 84), + (15, HUFFMAN_EMIT_SYMBOL, 84), + (24, HUFFMAN_EMIT_SYMBOL, 84), + (31, HUFFMAN_EMIT_SYMBOL, 84), + (41, HUFFMAN_EMIT_SYMBOL, 84), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 84), + + # Node 56 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 85), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 86), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 87), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 89), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 106), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 107), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 113), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 118), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 119), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 120), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 121), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 122), + (70, 0, 0), + (71, 0, 0), + (73, 0, 0), + (74, HUFFMAN_COMPLETE, 0), + + # Node 57 + (1, HUFFMAN_EMIT_SYMBOL, 85), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 85), + (1, HUFFMAN_EMIT_SYMBOL, 86), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 86), + (1, HUFFMAN_EMIT_SYMBOL, 87), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 87), + (1, HUFFMAN_EMIT_SYMBOL, 89), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 89), + (1, HUFFMAN_EMIT_SYMBOL, 106), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 106), + (1, HUFFMAN_EMIT_SYMBOL, 107), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 107), + (1, HUFFMAN_EMIT_SYMBOL, 113), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 113), + (1, HUFFMAN_EMIT_SYMBOL, 118), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 118), + + # Node 58 + (2, HUFFMAN_EMIT_SYMBOL, 85), + (9, HUFFMAN_EMIT_SYMBOL, 85), + (23, HUFFMAN_EMIT_SYMBOL, 85), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 85), + (2, HUFFMAN_EMIT_SYMBOL, 86), + (9, HUFFMAN_EMIT_SYMBOL, 86), + (23, HUFFMAN_EMIT_SYMBOL, 86), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 86), + (2, HUFFMAN_EMIT_SYMBOL, 87), + (9, HUFFMAN_EMIT_SYMBOL, 87), + (23, HUFFMAN_EMIT_SYMBOL, 87), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 87), + (2, HUFFMAN_EMIT_SYMBOL, 89), + (9, HUFFMAN_EMIT_SYMBOL, 89), + (23, HUFFMAN_EMIT_SYMBOL, 89), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 89), + + # Node 59 + (3, HUFFMAN_EMIT_SYMBOL, 85), + (6, HUFFMAN_EMIT_SYMBOL, 85), + (10, HUFFMAN_EMIT_SYMBOL, 85), + (15, HUFFMAN_EMIT_SYMBOL, 85), + (24, HUFFMAN_EMIT_SYMBOL, 85), + (31, HUFFMAN_EMIT_SYMBOL, 85), + (41, HUFFMAN_EMIT_SYMBOL, 85), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 85), + (3, HUFFMAN_EMIT_SYMBOL, 86), + (6, HUFFMAN_EMIT_SYMBOL, 86), + (10, HUFFMAN_EMIT_SYMBOL, 86), + (15, HUFFMAN_EMIT_SYMBOL, 86), + (24, HUFFMAN_EMIT_SYMBOL, 86), + (31, HUFFMAN_EMIT_SYMBOL, 86), + (41, HUFFMAN_EMIT_SYMBOL, 86), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 86), + + # Node 60 + (3, HUFFMAN_EMIT_SYMBOL, 87), + (6, HUFFMAN_EMIT_SYMBOL, 87), + (10, HUFFMAN_EMIT_SYMBOL, 87), + (15, HUFFMAN_EMIT_SYMBOL, 87), + (24, HUFFMAN_EMIT_SYMBOL, 87), + (31, HUFFMAN_EMIT_SYMBOL, 87), + (41, HUFFMAN_EMIT_SYMBOL, 87), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 87), + (3, HUFFMAN_EMIT_SYMBOL, 89), + (6, HUFFMAN_EMIT_SYMBOL, 89), + (10, HUFFMAN_EMIT_SYMBOL, 89), + (15, HUFFMAN_EMIT_SYMBOL, 89), + (24, HUFFMAN_EMIT_SYMBOL, 89), + (31, HUFFMAN_EMIT_SYMBOL, 89), + (41, HUFFMAN_EMIT_SYMBOL, 89), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 89), + + # Node 61 + (2, HUFFMAN_EMIT_SYMBOL, 106), + (9, HUFFMAN_EMIT_SYMBOL, 106), + (23, HUFFMAN_EMIT_SYMBOL, 106), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 106), + (2, HUFFMAN_EMIT_SYMBOL, 107), + (9, HUFFMAN_EMIT_SYMBOL, 107), + (23, HUFFMAN_EMIT_SYMBOL, 107), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 107), + (2, HUFFMAN_EMIT_SYMBOL, 113), + (9, HUFFMAN_EMIT_SYMBOL, 113), + (23, HUFFMAN_EMIT_SYMBOL, 113), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 113), + (2, HUFFMAN_EMIT_SYMBOL, 118), + (9, HUFFMAN_EMIT_SYMBOL, 118), + (23, HUFFMAN_EMIT_SYMBOL, 118), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 118), + + # Node 62 + (3, HUFFMAN_EMIT_SYMBOL, 106), + (6, HUFFMAN_EMIT_SYMBOL, 106), + (10, HUFFMAN_EMIT_SYMBOL, 106), + (15, HUFFMAN_EMIT_SYMBOL, 106), + (24, HUFFMAN_EMIT_SYMBOL, 106), + (31, HUFFMAN_EMIT_SYMBOL, 106), + (41, HUFFMAN_EMIT_SYMBOL, 106), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 106), + (3, HUFFMAN_EMIT_SYMBOL, 107), + (6, HUFFMAN_EMIT_SYMBOL, 107), + (10, HUFFMAN_EMIT_SYMBOL, 107), + (15, HUFFMAN_EMIT_SYMBOL, 107), + (24, HUFFMAN_EMIT_SYMBOL, 107), + (31, HUFFMAN_EMIT_SYMBOL, 107), + (41, HUFFMAN_EMIT_SYMBOL, 107), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 107), + + # Node 63 + (3, HUFFMAN_EMIT_SYMBOL, 113), + (6, HUFFMAN_EMIT_SYMBOL, 113), + (10, HUFFMAN_EMIT_SYMBOL, 113), + (15, HUFFMAN_EMIT_SYMBOL, 113), + (24, HUFFMAN_EMIT_SYMBOL, 113), + (31, HUFFMAN_EMIT_SYMBOL, 113), + (41, HUFFMAN_EMIT_SYMBOL, 113), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 113), + (3, HUFFMAN_EMIT_SYMBOL, 118), + (6, HUFFMAN_EMIT_SYMBOL, 118), + (10, HUFFMAN_EMIT_SYMBOL, 118), + (15, HUFFMAN_EMIT_SYMBOL, 118), + (24, HUFFMAN_EMIT_SYMBOL, 118), + (31, HUFFMAN_EMIT_SYMBOL, 118), + (41, HUFFMAN_EMIT_SYMBOL, 118), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 118), + + # Node 64 + (1, HUFFMAN_EMIT_SYMBOL, 119), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 119), + (1, HUFFMAN_EMIT_SYMBOL, 120), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 120), + (1, HUFFMAN_EMIT_SYMBOL, 121), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 121), + (1, HUFFMAN_EMIT_SYMBOL, 122), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 122), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 38), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 42), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 44), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 59), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 88), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 90), + (75, 0, 0), + (78, 0, 0), + + # Node 65 + (2, HUFFMAN_EMIT_SYMBOL, 119), + (9, HUFFMAN_EMIT_SYMBOL, 119), + (23, HUFFMAN_EMIT_SYMBOL, 119), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 119), + (2, HUFFMAN_EMIT_SYMBOL, 120), + (9, HUFFMAN_EMIT_SYMBOL, 120), + (23, HUFFMAN_EMIT_SYMBOL, 120), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 120), + (2, HUFFMAN_EMIT_SYMBOL, 121), + (9, HUFFMAN_EMIT_SYMBOL, 121), + (23, HUFFMAN_EMIT_SYMBOL, 121), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 121), + (2, HUFFMAN_EMIT_SYMBOL, 122), + (9, HUFFMAN_EMIT_SYMBOL, 122), + (23, HUFFMAN_EMIT_SYMBOL, 122), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 122), + + # Node 66 + (3, HUFFMAN_EMIT_SYMBOL, 119), + (6, HUFFMAN_EMIT_SYMBOL, 119), + (10, HUFFMAN_EMIT_SYMBOL, 119), + (15, HUFFMAN_EMIT_SYMBOL, 119), + (24, HUFFMAN_EMIT_SYMBOL, 119), + (31, HUFFMAN_EMIT_SYMBOL, 119), + (41, HUFFMAN_EMIT_SYMBOL, 119), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 119), + (3, HUFFMAN_EMIT_SYMBOL, 120), + (6, HUFFMAN_EMIT_SYMBOL, 120), + (10, HUFFMAN_EMIT_SYMBOL, 120), + (15, HUFFMAN_EMIT_SYMBOL, 120), + (24, HUFFMAN_EMIT_SYMBOL, 120), + (31, HUFFMAN_EMIT_SYMBOL, 120), + (41, HUFFMAN_EMIT_SYMBOL, 120), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 120), + + # Node 67 + (3, HUFFMAN_EMIT_SYMBOL, 121), + (6, HUFFMAN_EMIT_SYMBOL, 121), + (10, HUFFMAN_EMIT_SYMBOL, 121), + (15, HUFFMAN_EMIT_SYMBOL, 121), + (24, HUFFMAN_EMIT_SYMBOL, 121), + (31, HUFFMAN_EMIT_SYMBOL, 121), + (41, HUFFMAN_EMIT_SYMBOL, 121), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 121), + (3, HUFFMAN_EMIT_SYMBOL, 122), + (6, HUFFMAN_EMIT_SYMBOL, 122), + (10, HUFFMAN_EMIT_SYMBOL, 122), + (15, HUFFMAN_EMIT_SYMBOL, 122), + (24, HUFFMAN_EMIT_SYMBOL, 122), + (31, HUFFMAN_EMIT_SYMBOL, 122), + (41, HUFFMAN_EMIT_SYMBOL, 122), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 122), + + # Node 68 + (1, HUFFMAN_EMIT_SYMBOL, 38), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 38), + (1, HUFFMAN_EMIT_SYMBOL, 42), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 42), + (1, HUFFMAN_EMIT_SYMBOL, 44), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 44), + (1, HUFFMAN_EMIT_SYMBOL, 59), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 59), + (1, HUFFMAN_EMIT_SYMBOL, 88), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 88), + (1, HUFFMAN_EMIT_SYMBOL, 90), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 90), + (76, 0, 0), + (77, 0, 0), + (79, 0, 0), + (81, 0, 0), + + # Node 69 + (2, HUFFMAN_EMIT_SYMBOL, 38), + (9, HUFFMAN_EMIT_SYMBOL, 38), + (23, HUFFMAN_EMIT_SYMBOL, 38), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 38), + (2, HUFFMAN_EMIT_SYMBOL, 42), + (9, HUFFMAN_EMIT_SYMBOL, 42), + (23, HUFFMAN_EMIT_SYMBOL, 42), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 42), + (2, HUFFMAN_EMIT_SYMBOL, 44), + (9, HUFFMAN_EMIT_SYMBOL, 44), + (23, HUFFMAN_EMIT_SYMBOL, 44), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 44), + (2, HUFFMAN_EMIT_SYMBOL, 59), + (9, HUFFMAN_EMIT_SYMBOL, 59), + (23, HUFFMAN_EMIT_SYMBOL, 59), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 59), + + # Node 70 + (3, HUFFMAN_EMIT_SYMBOL, 38), + (6, HUFFMAN_EMIT_SYMBOL, 38), + (10, HUFFMAN_EMIT_SYMBOL, 38), + (15, HUFFMAN_EMIT_SYMBOL, 38), + (24, HUFFMAN_EMIT_SYMBOL, 38), + (31, HUFFMAN_EMIT_SYMBOL, 38), + (41, HUFFMAN_EMIT_SYMBOL, 38), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 38), + (3, HUFFMAN_EMIT_SYMBOL, 42), + (6, HUFFMAN_EMIT_SYMBOL, 42), + (10, HUFFMAN_EMIT_SYMBOL, 42), + (15, HUFFMAN_EMIT_SYMBOL, 42), + (24, HUFFMAN_EMIT_SYMBOL, 42), + (31, HUFFMAN_EMIT_SYMBOL, 42), + (41, HUFFMAN_EMIT_SYMBOL, 42), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 42), + + # Node 71 + (3, HUFFMAN_EMIT_SYMBOL, 44), + (6, HUFFMAN_EMIT_SYMBOL, 44), + (10, HUFFMAN_EMIT_SYMBOL, 44), + (15, HUFFMAN_EMIT_SYMBOL, 44), + (24, HUFFMAN_EMIT_SYMBOL, 44), + (31, HUFFMAN_EMIT_SYMBOL, 44), + (41, HUFFMAN_EMIT_SYMBOL, 44), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 44), + (3, HUFFMAN_EMIT_SYMBOL, 59), + (6, HUFFMAN_EMIT_SYMBOL, 59), + (10, HUFFMAN_EMIT_SYMBOL, 59), + (15, HUFFMAN_EMIT_SYMBOL, 59), + (24, HUFFMAN_EMIT_SYMBOL, 59), + (31, HUFFMAN_EMIT_SYMBOL, 59), + (41, HUFFMAN_EMIT_SYMBOL, 59), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 59), + + # Node 72 + (2, HUFFMAN_EMIT_SYMBOL, 88), + (9, HUFFMAN_EMIT_SYMBOL, 88), + (23, HUFFMAN_EMIT_SYMBOL, 88), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 88), + (2, HUFFMAN_EMIT_SYMBOL, 90), + (9, HUFFMAN_EMIT_SYMBOL, 90), + (23, HUFFMAN_EMIT_SYMBOL, 90), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 90), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 33), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 34), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 40), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 41), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 63), + (80, 0, 0), + (82, 0, 0), + (84, 0, 0), + + # Node 73 + (3, HUFFMAN_EMIT_SYMBOL, 88), + (6, HUFFMAN_EMIT_SYMBOL, 88), + (10, HUFFMAN_EMIT_SYMBOL, 88), + (15, HUFFMAN_EMIT_SYMBOL, 88), + (24, HUFFMAN_EMIT_SYMBOL, 88), + (31, HUFFMAN_EMIT_SYMBOL, 88), + (41, HUFFMAN_EMIT_SYMBOL, 88), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 88), + (3, HUFFMAN_EMIT_SYMBOL, 90), + (6, HUFFMAN_EMIT_SYMBOL, 90), + (10, HUFFMAN_EMIT_SYMBOL, 90), + (15, HUFFMAN_EMIT_SYMBOL, 90), + (24, HUFFMAN_EMIT_SYMBOL, 90), + (31, HUFFMAN_EMIT_SYMBOL, 90), + (41, HUFFMAN_EMIT_SYMBOL, 90), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 90), + + # Node 74 + (1, HUFFMAN_EMIT_SYMBOL, 33), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 33), + (1, HUFFMAN_EMIT_SYMBOL, 34), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 34), + (1, HUFFMAN_EMIT_SYMBOL, 40), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 40), + (1, HUFFMAN_EMIT_SYMBOL, 41), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 41), + (1, HUFFMAN_EMIT_SYMBOL, 63), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 63), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 39), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 43), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 124), + (83, 0, 0), + (85, 0, 0), + (88, 0, 0), + + # Node 75 + (2, HUFFMAN_EMIT_SYMBOL, 33), + (9, HUFFMAN_EMIT_SYMBOL, 33), + (23, HUFFMAN_EMIT_SYMBOL, 33), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 33), + (2, HUFFMAN_EMIT_SYMBOL, 34), + (9, HUFFMAN_EMIT_SYMBOL, 34), + (23, HUFFMAN_EMIT_SYMBOL, 34), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 34), + (2, HUFFMAN_EMIT_SYMBOL, 40), + (9, HUFFMAN_EMIT_SYMBOL, 40), + (23, HUFFMAN_EMIT_SYMBOL, 40), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 40), + (2, HUFFMAN_EMIT_SYMBOL, 41), + (9, HUFFMAN_EMIT_SYMBOL, 41), + (23, HUFFMAN_EMIT_SYMBOL, 41), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 41), + + # Node 76 + (3, HUFFMAN_EMIT_SYMBOL, 33), + (6, HUFFMAN_EMIT_SYMBOL, 33), + (10, HUFFMAN_EMIT_SYMBOL, 33), + (15, HUFFMAN_EMIT_SYMBOL, 33), + (24, HUFFMAN_EMIT_SYMBOL, 33), + (31, HUFFMAN_EMIT_SYMBOL, 33), + (41, HUFFMAN_EMIT_SYMBOL, 33), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 33), + (3, HUFFMAN_EMIT_SYMBOL, 34), + (6, HUFFMAN_EMIT_SYMBOL, 34), + (10, HUFFMAN_EMIT_SYMBOL, 34), + (15, HUFFMAN_EMIT_SYMBOL, 34), + (24, HUFFMAN_EMIT_SYMBOL, 34), + (31, HUFFMAN_EMIT_SYMBOL, 34), + (41, HUFFMAN_EMIT_SYMBOL, 34), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 34), + + # Node 77 + (3, HUFFMAN_EMIT_SYMBOL, 40), + (6, HUFFMAN_EMIT_SYMBOL, 40), + (10, HUFFMAN_EMIT_SYMBOL, 40), + (15, HUFFMAN_EMIT_SYMBOL, 40), + (24, HUFFMAN_EMIT_SYMBOL, 40), + (31, HUFFMAN_EMIT_SYMBOL, 40), + (41, HUFFMAN_EMIT_SYMBOL, 40), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 40), + (3, HUFFMAN_EMIT_SYMBOL, 41), + (6, HUFFMAN_EMIT_SYMBOL, 41), + (10, HUFFMAN_EMIT_SYMBOL, 41), + (15, HUFFMAN_EMIT_SYMBOL, 41), + (24, HUFFMAN_EMIT_SYMBOL, 41), + (31, HUFFMAN_EMIT_SYMBOL, 41), + (41, HUFFMAN_EMIT_SYMBOL, 41), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 41), + + # Node 78 + (2, HUFFMAN_EMIT_SYMBOL, 63), + (9, HUFFMAN_EMIT_SYMBOL, 63), + (23, HUFFMAN_EMIT_SYMBOL, 63), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 63), + (1, HUFFMAN_EMIT_SYMBOL, 39), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 39), + (1, HUFFMAN_EMIT_SYMBOL, 43), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 43), + (1, HUFFMAN_EMIT_SYMBOL, 124), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 124), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 35), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 62), + (86, 0, 0), + (87, 0, 0), + (89, 0, 0), + (90, 0, 0), + + # Node 79 + (3, HUFFMAN_EMIT_SYMBOL, 63), + (6, HUFFMAN_EMIT_SYMBOL, 63), + (10, HUFFMAN_EMIT_SYMBOL, 63), + (15, HUFFMAN_EMIT_SYMBOL, 63), + (24, HUFFMAN_EMIT_SYMBOL, 63), + (31, HUFFMAN_EMIT_SYMBOL, 63), + (41, HUFFMAN_EMIT_SYMBOL, 63), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 63), + (2, HUFFMAN_EMIT_SYMBOL, 39), + (9, HUFFMAN_EMIT_SYMBOL, 39), + (23, HUFFMAN_EMIT_SYMBOL, 39), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 39), + (2, HUFFMAN_EMIT_SYMBOL, 43), + (9, HUFFMAN_EMIT_SYMBOL, 43), + (23, HUFFMAN_EMIT_SYMBOL, 43), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 43), + + # Node 80 + (3, HUFFMAN_EMIT_SYMBOL, 39), + (6, HUFFMAN_EMIT_SYMBOL, 39), + (10, HUFFMAN_EMIT_SYMBOL, 39), + (15, HUFFMAN_EMIT_SYMBOL, 39), + (24, HUFFMAN_EMIT_SYMBOL, 39), + (31, HUFFMAN_EMIT_SYMBOL, 39), + (41, HUFFMAN_EMIT_SYMBOL, 39), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 39), + (3, HUFFMAN_EMIT_SYMBOL, 43), + (6, HUFFMAN_EMIT_SYMBOL, 43), + (10, HUFFMAN_EMIT_SYMBOL, 43), + (15, HUFFMAN_EMIT_SYMBOL, 43), + (24, HUFFMAN_EMIT_SYMBOL, 43), + (31, HUFFMAN_EMIT_SYMBOL, 43), + (41, HUFFMAN_EMIT_SYMBOL, 43), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 43), + + # Node 81 + (2, HUFFMAN_EMIT_SYMBOL, 124), + (9, HUFFMAN_EMIT_SYMBOL, 124), + (23, HUFFMAN_EMIT_SYMBOL, 124), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 124), + (1, HUFFMAN_EMIT_SYMBOL, 35), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 35), + (1, HUFFMAN_EMIT_SYMBOL, 62), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 62), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 0), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 36), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 64), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 91), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 93), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 126), + (91, 0, 0), + (92, 0, 0), + + # Node 82 + (3, HUFFMAN_EMIT_SYMBOL, 124), + (6, HUFFMAN_EMIT_SYMBOL, 124), + (10, HUFFMAN_EMIT_SYMBOL, 124), + (15, HUFFMAN_EMIT_SYMBOL, 124), + (24, HUFFMAN_EMIT_SYMBOL, 124), + (31, HUFFMAN_EMIT_SYMBOL, 124), + (41, HUFFMAN_EMIT_SYMBOL, 124), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 124), + (2, HUFFMAN_EMIT_SYMBOL, 35), + (9, HUFFMAN_EMIT_SYMBOL, 35), + (23, HUFFMAN_EMIT_SYMBOL, 35), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 35), + (2, HUFFMAN_EMIT_SYMBOL, 62), + (9, HUFFMAN_EMIT_SYMBOL, 62), + (23, HUFFMAN_EMIT_SYMBOL, 62), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 62), + + # Node 83 + (3, HUFFMAN_EMIT_SYMBOL, 35), + (6, HUFFMAN_EMIT_SYMBOL, 35), + (10, HUFFMAN_EMIT_SYMBOL, 35), + (15, HUFFMAN_EMIT_SYMBOL, 35), + (24, HUFFMAN_EMIT_SYMBOL, 35), + (31, HUFFMAN_EMIT_SYMBOL, 35), + (41, HUFFMAN_EMIT_SYMBOL, 35), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 35), + (3, HUFFMAN_EMIT_SYMBOL, 62), + (6, HUFFMAN_EMIT_SYMBOL, 62), + (10, HUFFMAN_EMIT_SYMBOL, 62), + (15, HUFFMAN_EMIT_SYMBOL, 62), + (24, HUFFMAN_EMIT_SYMBOL, 62), + (31, HUFFMAN_EMIT_SYMBOL, 62), + (41, HUFFMAN_EMIT_SYMBOL, 62), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 62), + + # Node 84 + (1, HUFFMAN_EMIT_SYMBOL, 0), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 0), + (1, HUFFMAN_EMIT_SYMBOL, 36), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 36), + (1, HUFFMAN_EMIT_SYMBOL, 64), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 64), + (1, HUFFMAN_EMIT_SYMBOL, 91), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 91), + (1, HUFFMAN_EMIT_SYMBOL, 93), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 93), + (1, HUFFMAN_EMIT_SYMBOL, 126), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 126), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 94), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 125), + (93, 0, 0), + (94, 0, 0), + + # Node 85 + (2, HUFFMAN_EMIT_SYMBOL, 0), + (9, HUFFMAN_EMIT_SYMBOL, 0), + (23, HUFFMAN_EMIT_SYMBOL, 0), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 0), + (2, HUFFMAN_EMIT_SYMBOL, 36), + (9, HUFFMAN_EMIT_SYMBOL, 36), + (23, HUFFMAN_EMIT_SYMBOL, 36), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 36), + (2, HUFFMAN_EMIT_SYMBOL, 64), + (9, HUFFMAN_EMIT_SYMBOL, 64), + (23, HUFFMAN_EMIT_SYMBOL, 64), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 64), + (2, HUFFMAN_EMIT_SYMBOL, 91), + (9, HUFFMAN_EMIT_SYMBOL, 91), + (23, HUFFMAN_EMIT_SYMBOL, 91), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 91), + + # Node 86 + (3, HUFFMAN_EMIT_SYMBOL, 0), + (6, HUFFMAN_EMIT_SYMBOL, 0), + (10, HUFFMAN_EMIT_SYMBOL, 0), + (15, HUFFMAN_EMIT_SYMBOL, 0), + (24, HUFFMAN_EMIT_SYMBOL, 0), + (31, HUFFMAN_EMIT_SYMBOL, 0), + (41, HUFFMAN_EMIT_SYMBOL, 0), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 0), + (3, HUFFMAN_EMIT_SYMBOL, 36), + (6, HUFFMAN_EMIT_SYMBOL, 36), + (10, HUFFMAN_EMIT_SYMBOL, 36), + (15, HUFFMAN_EMIT_SYMBOL, 36), + (24, HUFFMAN_EMIT_SYMBOL, 36), + (31, HUFFMAN_EMIT_SYMBOL, 36), + (41, HUFFMAN_EMIT_SYMBOL, 36), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 36), + + # Node 87 + (3, HUFFMAN_EMIT_SYMBOL, 64), + (6, HUFFMAN_EMIT_SYMBOL, 64), + (10, HUFFMAN_EMIT_SYMBOL, 64), + (15, HUFFMAN_EMIT_SYMBOL, 64), + (24, HUFFMAN_EMIT_SYMBOL, 64), + (31, HUFFMAN_EMIT_SYMBOL, 64), + (41, HUFFMAN_EMIT_SYMBOL, 64), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 64), + (3, HUFFMAN_EMIT_SYMBOL, 91), + (6, HUFFMAN_EMIT_SYMBOL, 91), + (10, HUFFMAN_EMIT_SYMBOL, 91), + (15, HUFFMAN_EMIT_SYMBOL, 91), + (24, HUFFMAN_EMIT_SYMBOL, 91), + (31, HUFFMAN_EMIT_SYMBOL, 91), + (41, HUFFMAN_EMIT_SYMBOL, 91), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 91), + + # Node 88 + (2, HUFFMAN_EMIT_SYMBOL, 93), + (9, HUFFMAN_EMIT_SYMBOL, 93), + (23, HUFFMAN_EMIT_SYMBOL, 93), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 93), + (2, HUFFMAN_EMIT_SYMBOL, 126), + (9, HUFFMAN_EMIT_SYMBOL, 126), + (23, HUFFMAN_EMIT_SYMBOL, 126), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 126), + (1, HUFFMAN_EMIT_SYMBOL, 94), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 94), + (1, HUFFMAN_EMIT_SYMBOL, 125), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 125), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 60), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 96), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 123), + (95, 0, 0), + + # Node 89 + (3, HUFFMAN_EMIT_SYMBOL, 93), + (6, HUFFMAN_EMIT_SYMBOL, 93), + (10, HUFFMAN_EMIT_SYMBOL, 93), + (15, HUFFMAN_EMIT_SYMBOL, 93), + (24, HUFFMAN_EMIT_SYMBOL, 93), + (31, HUFFMAN_EMIT_SYMBOL, 93), + (41, HUFFMAN_EMIT_SYMBOL, 93), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 93), + (3, HUFFMAN_EMIT_SYMBOL, 126), + (6, HUFFMAN_EMIT_SYMBOL, 126), + (10, HUFFMAN_EMIT_SYMBOL, 126), + (15, HUFFMAN_EMIT_SYMBOL, 126), + (24, HUFFMAN_EMIT_SYMBOL, 126), + (31, HUFFMAN_EMIT_SYMBOL, 126), + (41, HUFFMAN_EMIT_SYMBOL, 126), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 126), + + # Node 90 + (2, HUFFMAN_EMIT_SYMBOL, 94), + (9, HUFFMAN_EMIT_SYMBOL, 94), + (23, HUFFMAN_EMIT_SYMBOL, 94), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 94), + (2, HUFFMAN_EMIT_SYMBOL, 125), + (9, HUFFMAN_EMIT_SYMBOL, 125), + (23, HUFFMAN_EMIT_SYMBOL, 125), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 125), + (1, HUFFMAN_EMIT_SYMBOL, 60), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 60), + (1, HUFFMAN_EMIT_SYMBOL, 96), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 96), + (1, HUFFMAN_EMIT_SYMBOL, 123), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 123), + (96, 0, 0), + (110, 0, 0), + + # Node 91 + (3, HUFFMAN_EMIT_SYMBOL, 94), + (6, HUFFMAN_EMIT_SYMBOL, 94), + (10, HUFFMAN_EMIT_SYMBOL, 94), + (15, HUFFMAN_EMIT_SYMBOL, 94), + (24, HUFFMAN_EMIT_SYMBOL, 94), + (31, HUFFMAN_EMIT_SYMBOL, 94), + (41, HUFFMAN_EMIT_SYMBOL, 94), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 94), + (3, HUFFMAN_EMIT_SYMBOL, 125), + (6, HUFFMAN_EMIT_SYMBOL, 125), + (10, HUFFMAN_EMIT_SYMBOL, 125), + (15, HUFFMAN_EMIT_SYMBOL, 125), + (24, HUFFMAN_EMIT_SYMBOL, 125), + (31, HUFFMAN_EMIT_SYMBOL, 125), + (41, HUFFMAN_EMIT_SYMBOL, 125), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 125), + + # Node 92 + (2, HUFFMAN_EMIT_SYMBOL, 60), + (9, HUFFMAN_EMIT_SYMBOL, 60), + (23, HUFFMAN_EMIT_SYMBOL, 60), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 60), + (2, HUFFMAN_EMIT_SYMBOL, 96), + (9, HUFFMAN_EMIT_SYMBOL, 96), + (23, HUFFMAN_EMIT_SYMBOL, 96), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 96), + (2, HUFFMAN_EMIT_SYMBOL, 123), + (9, HUFFMAN_EMIT_SYMBOL, 123), + (23, HUFFMAN_EMIT_SYMBOL, 123), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 123), + (97, 0, 0), + (101, 0, 0), + (111, 0, 0), + (133, 0, 0), + + # Node 93 + (3, HUFFMAN_EMIT_SYMBOL, 60), + (6, HUFFMAN_EMIT_SYMBOL, 60), + (10, HUFFMAN_EMIT_SYMBOL, 60), + (15, HUFFMAN_EMIT_SYMBOL, 60), + (24, HUFFMAN_EMIT_SYMBOL, 60), + (31, HUFFMAN_EMIT_SYMBOL, 60), + (41, HUFFMAN_EMIT_SYMBOL, 60), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 60), + (3, HUFFMAN_EMIT_SYMBOL, 96), + (6, HUFFMAN_EMIT_SYMBOL, 96), + (10, HUFFMAN_EMIT_SYMBOL, 96), + (15, HUFFMAN_EMIT_SYMBOL, 96), + (24, HUFFMAN_EMIT_SYMBOL, 96), + (31, HUFFMAN_EMIT_SYMBOL, 96), + (41, HUFFMAN_EMIT_SYMBOL, 96), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 96), + + # Node 94 + (3, HUFFMAN_EMIT_SYMBOL, 123), + (6, HUFFMAN_EMIT_SYMBOL, 123), + (10, HUFFMAN_EMIT_SYMBOL, 123), + (15, HUFFMAN_EMIT_SYMBOL, 123), + (24, HUFFMAN_EMIT_SYMBOL, 123), + (31, HUFFMAN_EMIT_SYMBOL, 123), + (41, HUFFMAN_EMIT_SYMBOL, 123), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 123), + (98, 0, 0), + (99, 0, 0), + (102, 0, 0), + (105, 0, 0), + (112, 0, 0), + (119, 0, 0), + (134, 0, 0), + (153, 0, 0), + + # Node 95 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 92), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 195), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 208), + (100, 0, 0), + (103, 0, 0), + (104, 0, 0), + (106, 0, 0), + (107, 0, 0), + (113, 0, 0), + (116, 0, 0), + (120, 0, 0), + (126, 0, 0), + (135, 0, 0), + (142, 0, 0), + (154, 0, 0), + (169, 0, 0), + + # Node 96 + (1, HUFFMAN_EMIT_SYMBOL, 92), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 92), + (1, HUFFMAN_EMIT_SYMBOL, 195), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 195), + (1, HUFFMAN_EMIT_SYMBOL, 208), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 208), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 128), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 130), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 131), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 162), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 184), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 194), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 224), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 226), + (108, 0, 0), + (109, 0, 0), + + # Node 97 + (2, HUFFMAN_EMIT_SYMBOL, 92), + (9, HUFFMAN_EMIT_SYMBOL, 92), + (23, HUFFMAN_EMIT_SYMBOL, 92), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 92), + (2, HUFFMAN_EMIT_SYMBOL, 195), + (9, HUFFMAN_EMIT_SYMBOL, 195), + (23, HUFFMAN_EMIT_SYMBOL, 195), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 195), + (2, HUFFMAN_EMIT_SYMBOL, 208), + (9, HUFFMAN_EMIT_SYMBOL, 208), + (23, HUFFMAN_EMIT_SYMBOL, 208), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 208), + (1, HUFFMAN_EMIT_SYMBOL, 128), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 128), + (1, HUFFMAN_EMIT_SYMBOL, 130), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 130), + + # Node 98 + (3, HUFFMAN_EMIT_SYMBOL, 92), + (6, HUFFMAN_EMIT_SYMBOL, 92), + (10, HUFFMAN_EMIT_SYMBOL, 92), + (15, HUFFMAN_EMIT_SYMBOL, 92), + (24, HUFFMAN_EMIT_SYMBOL, 92), + (31, HUFFMAN_EMIT_SYMBOL, 92), + (41, HUFFMAN_EMIT_SYMBOL, 92), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 92), + (3, HUFFMAN_EMIT_SYMBOL, 195), + (6, HUFFMAN_EMIT_SYMBOL, 195), + (10, HUFFMAN_EMIT_SYMBOL, 195), + (15, HUFFMAN_EMIT_SYMBOL, 195), + (24, HUFFMAN_EMIT_SYMBOL, 195), + (31, HUFFMAN_EMIT_SYMBOL, 195), + (41, HUFFMAN_EMIT_SYMBOL, 195), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 195), + + # Node 99 + (3, HUFFMAN_EMIT_SYMBOL, 208), + (6, HUFFMAN_EMIT_SYMBOL, 208), + (10, HUFFMAN_EMIT_SYMBOL, 208), + (15, HUFFMAN_EMIT_SYMBOL, 208), + (24, HUFFMAN_EMIT_SYMBOL, 208), + (31, HUFFMAN_EMIT_SYMBOL, 208), + (41, HUFFMAN_EMIT_SYMBOL, 208), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 208), + (2, HUFFMAN_EMIT_SYMBOL, 128), + (9, HUFFMAN_EMIT_SYMBOL, 128), + (23, HUFFMAN_EMIT_SYMBOL, 128), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 128), + (2, HUFFMAN_EMIT_SYMBOL, 130), + (9, HUFFMAN_EMIT_SYMBOL, 130), + (23, HUFFMAN_EMIT_SYMBOL, 130), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 130), + + # Node 100 + (3, HUFFMAN_EMIT_SYMBOL, 128), + (6, HUFFMAN_EMIT_SYMBOL, 128), + (10, HUFFMAN_EMIT_SYMBOL, 128), + (15, HUFFMAN_EMIT_SYMBOL, 128), + (24, HUFFMAN_EMIT_SYMBOL, 128), + (31, HUFFMAN_EMIT_SYMBOL, 128), + (41, HUFFMAN_EMIT_SYMBOL, 128), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 128), + (3, HUFFMAN_EMIT_SYMBOL, 130), + (6, HUFFMAN_EMIT_SYMBOL, 130), + (10, HUFFMAN_EMIT_SYMBOL, 130), + (15, HUFFMAN_EMIT_SYMBOL, 130), + (24, HUFFMAN_EMIT_SYMBOL, 130), + (31, HUFFMAN_EMIT_SYMBOL, 130), + (41, HUFFMAN_EMIT_SYMBOL, 130), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 130), + + # Node 101 + (1, HUFFMAN_EMIT_SYMBOL, 131), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 131), + (1, HUFFMAN_EMIT_SYMBOL, 162), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 162), + (1, HUFFMAN_EMIT_SYMBOL, 184), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 184), + (1, HUFFMAN_EMIT_SYMBOL, 194), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 194), + (1, HUFFMAN_EMIT_SYMBOL, 224), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 224), + (1, HUFFMAN_EMIT_SYMBOL, 226), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 226), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 153), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 161), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 167), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 172), + + # Node 102 + (2, HUFFMAN_EMIT_SYMBOL, 131), + (9, HUFFMAN_EMIT_SYMBOL, 131), + (23, HUFFMAN_EMIT_SYMBOL, 131), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 131), + (2, HUFFMAN_EMIT_SYMBOL, 162), + (9, HUFFMAN_EMIT_SYMBOL, 162), + (23, HUFFMAN_EMIT_SYMBOL, 162), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 162), + (2, HUFFMAN_EMIT_SYMBOL, 184), + (9, HUFFMAN_EMIT_SYMBOL, 184), + (23, HUFFMAN_EMIT_SYMBOL, 184), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 184), + (2, HUFFMAN_EMIT_SYMBOL, 194), + (9, HUFFMAN_EMIT_SYMBOL, 194), + (23, HUFFMAN_EMIT_SYMBOL, 194), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 194), + + # Node 103 + (3, HUFFMAN_EMIT_SYMBOL, 131), + (6, HUFFMAN_EMIT_SYMBOL, 131), + (10, HUFFMAN_EMIT_SYMBOL, 131), + (15, HUFFMAN_EMIT_SYMBOL, 131), + (24, HUFFMAN_EMIT_SYMBOL, 131), + (31, HUFFMAN_EMIT_SYMBOL, 131), + (41, HUFFMAN_EMIT_SYMBOL, 131), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 131), + (3, HUFFMAN_EMIT_SYMBOL, 162), + (6, HUFFMAN_EMIT_SYMBOL, 162), + (10, HUFFMAN_EMIT_SYMBOL, 162), + (15, HUFFMAN_EMIT_SYMBOL, 162), + (24, HUFFMAN_EMIT_SYMBOL, 162), + (31, HUFFMAN_EMIT_SYMBOL, 162), + (41, HUFFMAN_EMIT_SYMBOL, 162), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 162), + + # Node 104 + (3, HUFFMAN_EMIT_SYMBOL, 184), + (6, HUFFMAN_EMIT_SYMBOL, 184), + (10, HUFFMAN_EMIT_SYMBOL, 184), + (15, HUFFMAN_EMIT_SYMBOL, 184), + (24, HUFFMAN_EMIT_SYMBOL, 184), + (31, HUFFMAN_EMIT_SYMBOL, 184), + (41, HUFFMAN_EMIT_SYMBOL, 184), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 184), + (3, HUFFMAN_EMIT_SYMBOL, 194), + (6, HUFFMAN_EMIT_SYMBOL, 194), + (10, HUFFMAN_EMIT_SYMBOL, 194), + (15, HUFFMAN_EMIT_SYMBOL, 194), + (24, HUFFMAN_EMIT_SYMBOL, 194), + (31, HUFFMAN_EMIT_SYMBOL, 194), + (41, HUFFMAN_EMIT_SYMBOL, 194), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 194), + + # Node 105 + (2, HUFFMAN_EMIT_SYMBOL, 224), + (9, HUFFMAN_EMIT_SYMBOL, 224), + (23, HUFFMAN_EMIT_SYMBOL, 224), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 224), + (2, HUFFMAN_EMIT_SYMBOL, 226), + (9, HUFFMAN_EMIT_SYMBOL, 226), + (23, HUFFMAN_EMIT_SYMBOL, 226), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 226), + (1, HUFFMAN_EMIT_SYMBOL, 153), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 153), + (1, HUFFMAN_EMIT_SYMBOL, 161), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 161), + (1, HUFFMAN_EMIT_SYMBOL, 167), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 167), + (1, HUFFMAN_EMIT_SYMBOL, 172), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 172), + + # Node 106 + (3, HUFFMAN_EMIT_SYMBOL, 224), + (6, HUFFMAN_EMIT_SYMBOL, 224), + (10, HUFFMAN_EMIT_SYMBOL, 224), + (15, HUFFMAN_EMIT_SYMBOL, 224), + (24, HUFFMAN_EMIT_SYMBOL, 224), + (31, HUFFMAN_EMIT_SYMBOL, 224), + (41, HUFFMAN_EMIT_SYMBOL, 224), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 224), + (3, HUFFMAN_EMIT_SYMBOL, 226), + (6, HUFFMAN_EMIT_SYMBOL, 226), + (10, HUFFMAN_EMIT_SYMBOL, 226), + (15, HUFFMAN_EMIT_SYMBOL, 226), + (24, HUFFMAN_EMIT_SYMBOL, 226), + (31, HUFFMAN_EMIT_SYMBOL, 226), + (41, HUFFMAN_EMIT_SYMBOL, 226), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 226), + + # Node 107 + (2, HUFFMAN_EMIT_SYMBOL, 153), + (9, HUFFMAN_EMIT_SYMBOL, 153), + (23, HUFFMAN_EMIT_SYMBOL, 153), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 153), + (2, HUFFMAN_EMIT_SYMBOL, 161), + (9, HUFFMAN_EMIT_SYMBOL, 161), + (23, HUFFMAN_EMIT_SYMBOL, 161), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 161), + (2, HUFFMAN_EMIT_SYMBOL, 167), + (9, HUFFMAN_EMIT_SYMBOL, 167), + (23, HUFFMAN_EMIT_SYMBOL, 167), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 167), + (2, HUFFMAN_EMIT_SYMBOL, 172), + (9, HUFFMAN_EMIT_SYMBOL, 172), + (23, HUFFMAN_EMIT_SYMBOL, 172), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 172), + + # Node 108 + (3, HUFFMAN_EMIT_SYMBOL, 153), + (6, HUFFMAN_EMIT_SYMBOL, 153), + (10, HUFFMAN_EMIT_SYMBOL, 153), + (15, HUFFMAN_EMIT_SYMBOL, 153), + (24, HUFFMAN_EMIT_SYMBOL, 153), + (31, HUFFMAN_EMIT_SYMBOL, 153), + (41, HUFFMAN_EMIT_SYMBOL, 153), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 153), + (3, HUFFMAN_EMIT_SYMBOL, 161), + (6, HUFFMAN_EMIT_SYMBOL, 161), + (10, HUFFMAN_EMIT_SYMBOL, 161), + (15, HUFFMAN_EMIT_SYMBOL, 161), + (24, HUFFMAN_EMIT_SYMBOL, 161), + (31, HUFFMAN_EMIT_SYMBOL, 161), + (41, HUFFMAN_EMIT_SYMBOL, 161), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 161), + + # Node 109 + (3, HUFFMAN_EMIT_SYMBOL, 167), + (6, HUFFMAN_EMIT_SYMBOL, 167), + (10, HUFFMAN_EMIT_SYMBOL, 167), + (15, HUFFMAN_EMIT_SYMBOL, 167), + (24, HUFFMAN_EMIT_SYMBOL, 167), + (31, HUFFMAN_EMIT_SYMBOL, 167), + (41, HUFFMAN_EMIT_SYMBOL, 167), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 167), + (3, HUFFMAN_EMIT_SYMBOL, 172), + (6, HUFFMAN_EMIT_SYMBOL, 172), + (10, HUFFMAN_EMIT_SYMBOL, 172), + (15, HUFFMAN_EMIT_SYMBOL, 172), + (24, HUFFMAN_EMIT_SYMBOL, 172), + (31, HUFFMAN_EMIT_SYMBOL, 172), + (41, HUFFMAN_EMIT_SYMBOL, 172), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 172), + + # Node 110 + (114, 0, 0), + (115, 0, 0), + (117, 0, 0), + (118, 0, 0), + (121, 0, 0), + (123, 0, 0), + (127, 0, 0), + (130, 0, 0), + (136, 0, 0), + (139, 0, 0), + (143, 0, 0), + (146, 0, 0), + (155, 0, 0), + (162, 0, 0), + (170, 0, 0), + (180, 0, 0), + + # Node 111 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 176), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 177), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 179), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 209), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 216), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 217), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 227), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 229), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 230), + (122, 0, 0), + (124, 0, 0), + (125, 0, 0), + (128, 0, 0), + (129, 0, 0), + (131, 0, 0), + (132, 0, 0), + + # Node 112 + (1, HUFFMAN_EMIT_SYMBOL, 176), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 176), + (1, HUFFMAN_EMIT_SYMBOL, 177), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 177), + (1, HUFFMAN_EMIT_SYMBOL, 179), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 179), + (1, HUFFMAN_EMIT_SYMBOL, 209), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 209), + (1, HUFFMAN_EMIT_SYMBOL, 216), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 216), + (1, HUFFMAN_EMIT_SYMBOL, 217), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 217), + (1, HUFFMAN_EMIT_SYMBOL, 227), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 227), + (1, HUFFMAN_EMIT_SYMBOL, 229), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 229), + + # Node 113 + (2, HUFFMAN_EMIT_SYMBOL, 176), + (9, HUFFMAN_EMIT_SYMBOL, 176), + (23, HUFFMAN_EMIT_SYMBOL, 176), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 176), + (2, HUFFMAN_EMIT_SYMBOL, 177), + (9, HUFFMAN_EMIT_SYMBOL, 177), + (23, HUFFMAN_EMIT_SYMBOL, 177), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 177), + (2, HUFFMAN_EMIT_SYMBOL, 179), + (9, HUFFMAN_EMIT_SYMBOL, 179), + (23, HUFFMAN_EMIT_SYMBOL, 179), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 179), + (2, HUFFMAN_EMIT_SYMBOL, 209), + (9, HUFFMAN_EMIT_SYMBOL, 209), + (23, HUFFMAN_EMIT_SYMBOL, 209), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 209), + + # Node 114 + (3, HUFFMAN_EMIT_SYMBOL, 176), + (6, HUFFMAN_EMIT_SYMBOL, 176), + (10, HUFFMAN_EMIT_SYMBOL, 176), + (15, HUFFMAN_EMIT_SYMBOL, 176), + (24, HUFFMAN_EMIT_SYMBOL, 176), + (31, HUFFMAN_EMIT_SYMBOL, 176), + (41, HUFFMAN_EMIT_SYMBOL, 176), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 176), + (3, HUFFMAN_EMIT_SYMBOL, 177), + (6, HUFFMAN_EMIT_SYMBOL, 177), + (10, HUFFMAN_EMIT_SYMBOL, 177), + (15, HUFFMAN_EMIT_SYMBOL, 177), + (24, HUFFMAN_EMIT_SYMBOL, 177), + (31, HUFFMAN_EMIT_SYMBOL, 177), + (41, HUFFMAN_EMIT_SYMBOL, 177), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 177), + + # Node 115 + (3, HUFFMAN_EMIT_SYMBOL, 179), + (6, HUFFMAN_EMIT_SYMBOL, 179), + (10, HUFFMAN_EMIT_SYMBOL, 179), + (15, HUFFMAN_EMIT_SYMBOL, 179), + (24, HUFFMAN_EMIT_SYMBOL, 179), + (31, HUFFMAN_EMIT_SYMBOL, 179), + (41, HUFFMAN_EMIT_SYMBOL, 179), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 179), + (3, HUFFMAN_EMIT_SYMBOL, 209), + (6, HUFFMAN_EMIT_SYMBOL, 209), + (10, HUFFMAN_EMIT_SYMBOL, 209), + (15, HUFFMAN_EMIT_SYMBOL, 209), + (24, HUFFMAN_EMIT_SYMBOL, 209), + (31, HUFFMAN_EMIT_SYMBOL, 209), + (41, HUFFMAN_EMIT_SYMBOL, 209), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 209), + + # Node 116 + (2, HUFFMAN_EMIT_SYMBOL, 216), + (9, HUFFMAN_EMIT_SYMBOL, 216), + (23, HUFFMAN_EMIT_SYMBOL, 216), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 216), + (2, HUFFMAN_EMIT_SYMBOL, 217), + (9, HUFFMAN_EMIT_SYMBOL, 217), + (23, HUFFMAN_EMIT_SYMBOL, 217), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 217), + (2, HUFFMAN_EMIT_SYMBOL, 227), + (9, HUFFMAN_EMIT_SYMBOL, 227), + (23, HUFFMAN_EMIT_SYMBOL, 227), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 227), + (2, HUFFMAN_EMIT_SYMBOL, 229), + (9, HUFFMAN_EMIT_SYMBOL, 229), + (23, HUFFMAN_EMIT_SYMBOL, 229), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 229), + + # Node 117 + (3, HUFFMAN_EMIT_SYMBOL, 216), + (6, HUFFMAN_EMIT_SYMBOL, 216), + (10, HUFFMAN_EMIT_SYMBOL, 216), + (15, HUFFMAN_EMIT_SYMBOL, 216), + (24, HUFFMAN_EMIT_SYMBOL, 216), + (31, HUFFMAN_EMIT_SYMBOL, 216), + (41, HUFFMAN_EMIT_SYMBOL, 216), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 216), + (3, HUFFMAN_EMIT_SYMBOL, 217), + (6, HUFFMAN_EMIT_SYMBOL, 217), + (10, HUFFMAN_EMIT_SYMBOL, 217), + (15, HUFFMAN_EMIT_SYMBOL, 217), + (24, HUFFMAN_EMIT_SYMBOL, 217), + (31, HUFFMAN_EMIT_SYMBOL, 217), + (41, HUFFMAN_EMIT_SYMBOL, 217), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 217), + + # Node 118 + (3, HUFFMAN_EMIT_SYMBOL, 227), + (6, HUFFMAN_EMIT_SYMBOL, 227), + (10, HUFFMAN_EMIT_SYMBOL, 227), + (15, HUFFMAN_EMIT_SYMBOL, 227), + (24, HUFFMAN_EMIT_SYMBOL, 227), + (31, HUFFMAN_EMIT_SYMBOL, 227), + (41, HUFFMAN_EMIT_SYMBOL, 227), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 227), + (3, HUFFMAN_EMIT_SYMBOL, 229), + (6, HUFFMAN_EMIT_SYMBOL, 229), + (10, HUFFMAN_EMIT_SYMBOL, 229), + (15, HUFFMAN_EMIT_SYMBOL, 229), + (24, HUFFMAN_EMIT_SYMBOL, 229), + (31, HUFFMAN_EMIT_SYMBOL, 229), + (41, HUFFMAN_EMIT_SYMBOL, 229), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 229), + + # Node 119 + (1, HUFFMAN_EMIT_SYMBOL, 230), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 230), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 129), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 132), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 133), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 134), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 136), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 146), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 154), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 156), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 160), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 163), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 164), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 169), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 170), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 173), + + # Node 120 + (2, HUFFMAN_EMIT_SYMBOL, 230), + (9, HUFFMAN_EMIT_SYMBOL, 230), + (23, HUFFMAN_EMIT_SYMBOL, 230), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 230), + (1, HUFFMAN_EMIT_SYMBOL, 129), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 129), + (1, HUFFMAN_EMIT_SYMBOL, 132), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 132), + (1, HUFFMAN_EMIT_SYMBOL, 133), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 133), + (1, HUFFMAN_EMIT_SYMBOL, 134), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 134), + (1, HUFFMAN_EMIT_SYMBOL, 136), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 136), + (1, HUFFMAN_EMIT_SYMBOL, 146), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 146), + + # Node 121 + (3, HUFFMAN_EMIT_SYMBOL, 230), + (6, HUFFMAN_EMIT_SYMBOL, 230), + (10, HUFFMAN_EMIT_SYMBOL, 230), + (15, HUFFMAN_EMIT_SYMBOL, 230), + (24, HUFFMAN_EMIT_SYMBOL, 230), + (31, HUFFMAN_EMIT_SYMBOL, 230), + (41, HUFFMAN_EMIT_SYMBOL, 230), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 230), + (2, HUFFMAN_EMIT_SYMBOL, 129), + (9, HUFFMAN_EMIT_SYMBOL, 129), + (23, HUFFMAN_EMIT_SYMBOL, 129), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 129), + (2, HUFFMAN_EMIT_SYMBOL, 132), + (9, HUFFMAN_EMIT_SYMBOL, 132), + (23, HUFFMAN_EMIT_SYMBOL, 132), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 132), + + # Node 122 + (3, HUFFMAN_EMIT_SYMBOL, 129), + (6, HUFFMAN_EMIT_SYMBOL, 129), + (10, HUFFMAN_EMIT_SYMBOL, 129), + (15, HUFFMAN_EMIT_SYMBOL, 129), + (24, HUFFMAN_EMIT_SYMBOL, 129), + (31, HUFFMAN_EMIT_SYMBOL, 129), + (41, HUFFMAN_EMIT_SYMBOL, 129), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 129), + (3, HUFFMAN_EMIT_SYMBOL, 132), + (6, HUFFMAN_EMIT_SYMBOL, 132), + (10, HUFFMAN_EMIT_SYMBOL, 132), + (15, HUFFMAN_EMIT_SYMBOL, 132), + (24, HUFFMAN_EMIT_SYMBOL, 132), + (31, HUFFMAN_EMIT_SYMBOL, 132), + (41, HUFFMAN_EMIT_SYMBOL, 132), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 132), + + # Node 123 + (2, HUFFMAN_EMIT_SYMBOL, 133), + (9, HUFFMAN_EMIT_SYMBOL, 133), + (23, HUFFMAN_EMIT_SYMBOL, 133), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 133), + (2, HUFFMAN_EMIT_SYMBOL, 134), + (9, HUFFMAN_EMIT_SYMBOL, 134), + (23, HUFFMAN_EMIT_SYMBOL, 134), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 134), + (2, HUFFMAN_EMIT_SYMBOL, 136), + (9, HUFFMAN_EMIT_SYMBOL, 136), + (23, HUFFMAN_EMIT_SYMBOL, 136), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 136), + (2, HUFFMAN_EMIT_SYMBOL, 146), + (9, HUFFMAN_EMIT_SYMBOL, 146), + (23, HUFFMAN_EMIT_SYMBOL, 146), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 146), + + # Node 124 + (3, HUFFMAN_EMIT_SYMBOL, 133), + (6, HUFFMAN_EMIT_SYMBOL, 133), + (10, HUFFMAN_EMIT_SYMBOL, 133), + (15, HUFFMAN_EMIT_SYMBOL, 133), + (24, HUFFMAN_EMIT_SYMBOL, 133), + (31, HUFFMAN_EMIT_SYMBOL, 133), + (41, HUFFMAN_EMIT_SYMBOL, 133), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 133), + (3, HUFFMAN_EMIT_SYMBOL, 134), + (6, HUFFMAN_EMIT_SYMBOL, 134), + (10, HUFFMAN_EMIT_SYMBOL, 134), + (15, HUFFMAN_EMIT_SYMBOL, 134), + (24, HUFFMAN_EMIT_SYMBOL, 134), + (31, HUFFMAN_EMIT_SYMBOL, 134), + (41, HUFFMAN_EMIT_SYMBOL, 134), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 134), + + # Node 125 + (3, HUFFMAN_EMIT_SYMBOL, 136), + (6, HUFFMAN_EMIT_SYMBOL, 136), + (10, HUFFMAN_EMIT_SYMBOL, 136), + (15, HUFFMAN_EMIT_SYMBOL, 136), + (24, HUFFMAN_EMIT_SYMBOL, 136), + (31, HUFFMAN_EMIT_SYMBOL, 136), + (41, HUFFMAN_EMIT_SYMBOL, 136), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 136), + (3, HUFFMAN_EMIT_SYMBOL, 146), + (6, HUFFMAN_EMIT_SYMBOL, 146), + (10, HUFFMAN_EMIT_SYMBOL, 146), + (15, HUFFMAN_EMIT_SYMBOL, 146), + (24, HUFFMAN_EMIT_SYMBOL, 146), + (31, HUFFMAN_EMIT_SYMBOL, 146), + (41, HUFFMAN_EMIT_SYMBOL, 146), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 146), + + # Node 126 + (1, HUFFMAN_EMIT_SYMBOL, 154), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 154), + (1, HUFFMAN_EMIT_SYMBOL, 156), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 156), + (1, HUFFMAN_EMIT_SYMBOL, 160), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 160), + (1, HUFFMAN_EMIT_SYMBOL, 163), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 163), + (1, HUFFMAN_EMIT_SYMBOL, 164), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 164), + (1, HUFFMAN_EMIT_SYMBOL, 169), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 169), + (1, HUFFMAN_EMIT_SYMBOL, 170), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 170), + (1, HUFFMAN_EMIT_SYMBOL, 173), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 173), + + # Node 127 + (2, HUFFMAN_EMIT_SYMBOL, 154), + (9, HUFFMAN_EMIT_SYMBOL, 154), + (23, HUFFMAN_EMIT_SYMBOL, 154), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 154), + (2, HUFFMAN_EMIT_SYMBOL, 156), + (9, HUFFMAN_EMIT_SYMBOL, 156), + (23, HUFFMAN_EMIT_SYMBOL, 156), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 156), + (2, HUFFMAN_EMIT_SYMBOL, 160), + (9, HUFFMAN_EMIT_SYMBOL, 160), + (23, HUFFMAN_EMIT_SYMBOL, 160), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 160), + (2, HUFFMAN_EMIT_SYMBOL, 163), + (9, HUFFMAN_EMIT_SYMBOL, 163), + (23, HUFFMAN_EMIT_SYMBOL, 163), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 163), + + # Node 128 + (3, HUFFMAN_EMIT_SYMBOL, 154), + (6, HUFFMAN_EMIT_SYMBOL, 154), + (10, HUFFMAN_EMIT_SYMBOL, 154), + (15, HUFFMAN_EMIT_SYMBOL, 154), + (24, HUFFMAN_EMIT_SYMBOL, 154), + (31, HUFFMAN_EMIT_SYMBOL, 154), + (41, HUFFMAN_EMIT_SYMBOL, 154), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 154), + (3, HUFFMAN_EMIT_SYMBOL, 156), + (6, HUFFMAN_EMIT_SYMBOL, 156), + (10, HUFFMAN_EMIT_SYMBOL, 156), + (15, HUFFMAN_EMIT_SYMBOL, 156), + (24, HUFFMAN_EMIT_SYMBOL, 156), + (31, HUFFMAN_EMIT_SYMBOL, 156), + (41, HUFFMAN_EMIT_SYMBOL, 156), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 156), + + # Node 129 + (3, HUFFMAN_EMIT_SYMBOL, 160), + (6, HUFFMAN_EMIT_SYMBOL, 160), + (10, HUFFMAN_EMIT_SYMBOL, 160), + (15, HUFFMAN_EMIT_SYMBOL, 160), + (24, HUFFMAN_EMIT_SYMBOL, 160), + (31, HUFFMAN_EMIT_SYMBOL, 160), + (41, HUFFMAN_EMIT_SYMBOL, 160), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 160), + (3, HUFFMAN_EMIT_SYMBOL, 163), + (6, HUFFMAN_EMIT_SYMBOL, 163), + (10, HUFFMAN_EMIT_SYMBOL, 163), + (15, HUFFMAN_EMIT_SYMBOL, 163), + (24, HUFFMAN_EMIT_SYMBOL, 163), + (31, HUFFMAN_EMIT_SYMBOL, 163), + (41, HUFFMAN_EMIT_SYMBOL, 163), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 163), + + # Node 130 + (2, HUFFMAN_EMIT_SYMBOL, 164), + (9, HUFFMAN_EMIT_SYMBOL, 164), + (23, HUFFMAN_EMIT_SYMBOL, 164), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 164), + (2, HUFFMAN_EMIT_SYMBOL, 169), + (9, HUFFMAN_EMIT_SYMBOL, 169), + (23, HUFFMAN_EMIT_SYMBOL, 169), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 169), + (2, HUFFMAN_EMIT_SYMBOL, 170), + (9, HUFFMAN_EMIT_SYMBOL, 170), + (23, HUFFMAN_EMIT_SYMBOL, 170), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 170), + (2, HUFFMAN_EMIT_SYMBOL, 173), + (9, HUFFMAN_EMIT_SYMBOL, 173), + (23, HUFFMAN_EMIT_SYMBOL, 173), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 173), + + # Node 131 + (3, HUFFMAN_EMIT_SYMBOL, 164), + (6, HUFFMAN_EMIT_SYMBOL, 164), + (10, HUFFMAN_EMIT_SYMBOL, 164), + (15, HUFFMAN_EMIT_SYMBOL, 164), + (24, HUFFMAN_EMIT_SYMBOL, 164), + (31, HUFFMAN_EMIT_SYMBOL, 164), + (41, HUFFMAN_EMIT_SYMBOL, 164), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 164), + (3, HUFFMAN_EMIT_SYMBOL, 169), + (6, HUFFMAN_EMIT_SYMBOL, 169), + (10, HUFFMAN_EMIT_SYMBOL, 169), + (15, HUFFMAN_EMIT_SYMBOL, 169), + (24, HUFFMAN_EMIT_SYMBOL, 169), + (31, HUFFMAN_EMIT_SYMBOL, 169), + (41, HUFFMAN_EMIT_SYMBOL, 169), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 169), + + # Node 132 + (3, HUFFMAN_EMIT_SYMBOL, 170), + (6, HUFFMAN_EMIT_SYMBOL, 170), + (10, HUFFMAN_EMIT_SYMBOL, 170), + (15, HUFFMAN_EMIT_SYMBOL, 170), + (24, HUFFMAN_EMIT_SYMBOL, 170), + (31, HUFFMAN_EMIT_SYMBOL, 170), + (41, HUFFMAN_EMIT_SYMBOL, 170), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 170), + (3, HUFFMAN_EMIT_SYMBOL, 173), + (6, HUFFMAN_EMIT_SYMBOL, 173), + (10, HUFFMAN_EMIT_SYMBOL, 173), + (15, HUFFMAN_EMIT_SYMBOL, 173), + (24, HUFFMAN_EMIT_SYMBOL, 173), + (31, HUFFMAN_EMIT_SYMBOL, 173), + (41, HUFFMAN_EMIT_SYMBOL, 173), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 173), + + # Node 133 + (137, 0, 0), + (138, 0, 0), + (140, 0, 0), + (141, 0, 0), + (144, 0, 0), + (145, 0, 0), + (147, 0, 0), + (150, 0, 0), + (156, 0, 0), + (159, 0, 0), + (163, 0, 0), + (166, 0, 0), + (171, 0, 0), + (174, 0, 0), + (181, 0, 0), + (190, 0, 0), + + # Node 134 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 178), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 181), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 185), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 186), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 187), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 189), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 190), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 196), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 198), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 228), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 232), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 233), + (148, 0, 0), + (149, 0, 0), + (151, 0, 0), + (152, 0, 0), + + # Node 135 + (1, HUFFMAN_EMIT_SYMBOL, 178), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 178), + (1, HUFFMAN_EMIT_SYMBOL, 181), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 181), + (1, HUFFMAN_EMIT_SYMBOL, 185), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 185), + (1, HUFFMAN_EMIT_SYMBOL, 186), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 186), + (1, HUFFMAN_EMIT_SYMBOL, 187), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 187), + (1, HUFFMAN_EMIT_SYMBOL, 189), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 189), + (1, HUFFMAN_EMIT_SYMBOL, 190), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 190), + (1, HUFFMAN_EMIT_SYMBOL, 196), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 196), + + # Node 136 + (2, HUFFMAN_EMIT_SYMBOL, 178), + (9, HUFFMAN_EMIT_SYMBOL, 178), + (23, HUFFMAN_EMIT_SYMBOL, 178), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 178), + (2, HUFFMAN_EMIT_SYMBOL, 181), + (9, HUFFMAN_EMIT_SYMBOL, 181), + (23, HUFFMAN_EMIT_SYMBOL, 181), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 181), + (2, HUFFMAN_EMIT_SYMBOL, 185), + (9, HUFFMAN_EMIT_SYMBOL, 185), + (23, HUFFMAN_EMIT_SYMBOL, 185), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 185), + (2, HUFFMAN_EMIT_SYMBOL, 186), + (9, HUFFMAN_EMIT_SYMBOL, 186), + (23, HUFFMAN_EMIT_SYMBOL, 186), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 186), + + # Node 137 + (3, HUFFMAN_EMIT_SYMBOL, 178), + (6, HUFFMAN_EMIT_SYMBOL, 178), + (10, HUFFMAN_EMIT_SYMBOL, 178), + (15, HUFFMAN_EMIT_SYMBOL, 178), + (24, HUFFMAN_EMIT_SYMBOL, 178), + (31, HUFFMAN_EMIT_SYMBOL, 178), + (41, HUFFMAN_EMIT_SYMBOL, 178), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 178), + (3, HUFFMAN_EMIT_SYMBOL, 181), + (6, HUFFMAN_EMIT_SYMBOL, 181), + (10, HUFFMAN_EMIT_SYMBOL, 181), + (15, HUFFMAN_EMIT_SYMBOL, 181), + (24, HUFFMAN_EMIT_SYMBOL, 181), + (31, HUFFMAN_EMIT_SYMBOL, 181), + (41, HUFFMAN_EMIT_SYMBOL, 181), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 181), + + # Node 138 + (3, HUFFMAN_EMIT_SYMBOL, 185), + (6, HUFFMAN_EMIT_SYMBOL, 185), + (10, HUFFMAN_EMIT_SYMBOL, 185), + (15, HUFFMAN_EMIT_SYMBOL, 185), + (24, HUFFMAN_EMIT_SYMBOL, 185), + (31, HUFFMAN_EMIT_SYMBOL, 185), + (41, HUFFMAN_EMIT_SYMBOL, 185), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 185), + (3, HUFFMAN_EMIT_SYMBOL, 186), + (6, HUFFMAN_EMIT_SYMBOL, 186), + (10, HUFFMAN_EMIT_SYMBOL, 186), + (15, HUFFMAN_EMIT_SYMBOL, 186), + (24, HUFFMAN_EMIT_SYMBOL, 186), + (31, HUFFMAN_EMIT_SYMBOL, 186), + (41, HUFFMAN_EMIT_SYMBOL, 186), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 186), + + # Node 139 + (2, HUFFMAN_EMIT_SYMBOL, 187), + (9, HUFFMAN_EMIT_SYMBOL, 187), + (23, HUFFMAN_EMIT_SYMBOL, 187), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 187), + (2, HUFFMAN_EMIT_SYMBOL, 189), + (9, HUFFMAN_EMIT_SYMBOL, 189), + (23, HUFFMAN_EMIT_SYMBOL, 189), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 189), + (2, HUFFMAN_EMIT_SYMBOL, 190), + (9, HUFFMAN_EMIT_SYMBOL, 190), + (23, HUFFMAN_EMIT_SYMBOL, 190), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 190), + (2, HUFFMAN_EMIT_SYMBOL, 196), + (9, HUFFMAN_EMIT_SYMBOL, 196), + (23, HUFFMAN_EMIT_SYMBOL, 196), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 196), + + # Node 140 + (3, HUFFMAN_EMIT_SYMBOL, 187), + (6, HUFFMAN_EMIT_SYMBOL, 187), + (10, HUFFMAN_EMIT_SYMBOL, 187), + (15, HUFFMAN_EMIT_SYMBOL, 187), + (24, HUFFMAN_EMIT_SYMBOL, 187), + (31, HUFFMAN_EMIT_SYMBOL, 187), + (41, HUFFMAN_EMIT_SYMBOL, 187), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 187), + (3, HUFFMAN_EMIT_SYMBOL, 189), + (6, HUFFMAN_EMIT_SYMBOL, 189), + (10, HUFFMAN_EMIT_SYMBOL, 189), + (15, HUFFMAN_EMIT_SYMBOL, 189), + (24, HUFFMAN_EMIT_SYMBOL, 189), + (31, HUFFMAN_EMIT_SYMBOL, 189), + (41, HUFFMAN_EMIT_SYMBOL, 189), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 189), + + # Node 141 + (3, HUFFMAN_EMIT_SYMBOL, 190), + (6, HUFFMAN_EMIT_SYMBOL, 190), + (10, HUFFMAN_EMIT_SYMBOL, 190), + (15, HUFFMAN_EMIT_SYMBOL, 190), + (24, HUFFMAN_EMIT_SYMBOL, 190), + (31, HUFFMAN_EMIT_SYMBOL, 190), + (41, HUFFMAN_EMIT_SYMBOL, 190), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 190), + (3, HUFFMAN_EMIT_SYMBOL, 196), + (6, HUFFMAN_EMIT_SYMBOL, 196), + (10, HUFFMAN_EMIT_SYMBOL, 196), + (15, HUFFMAN_EMIT_SYMBOL, 196), + (24, HUFFMAN_EMIT_SYMBOL, 196), + (31, HUFFMAN_EMIT_SYMBOL, 196), + (41, HUFFMAN_EMIT_SYMBOL, 196), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 196), + + # Node 142 + (1, HUFFMAN_EMIT_SYMBOL, 198), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 198), + (1, HUFFMAN_EMIT_SYMBOL, 228), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 228), + (1, HUFFMAN_EMIT_SYMBOL, 232), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 232), + (1, HUFFMAN_EMIT_SYMBOL, 233), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 233), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 1), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 135), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 137), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 138), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 139), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 140), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 141), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 143), + + # Node 143 + (2, HUFFMAN_EMIT_SYMBOL, 198), + (9, HUFFMAN_EMIT_SYMBOL, 198), + (23, HUFFMAN_EMIT_SYMBOL, 198), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 198), + (2, HUFFMAN_EMIT_SYMBOL, 228), + (9, HUFFMAN_EMIT_SYMBOL, 228), + (23, HUFFMAN_EMIT_SYMBOL, 228), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 228), + (2, HUFFMAN_EMIT_SYMBOL, 232), + (9, HUFFMAN_EMIT_SYMBOL, 232), + (23, HUFFMAN_EMIT_SYMBOL, 232), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 232), + (2, HUFFMAN_EMIT_SYMBOL, 233), + (9, HUFFMAN_EMIT_SYMBOL, 233), + (23, HUFFMAN_EMIT_SYMBOL, 233), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 233), + + # Node 144 + (3, HUFFMAN_EMIT_SYMBOL, 198), + (6, HUFFMAN_EMIT_SYMBOL, 198), + (10, HUFFMAN_EMIT_SYMBOL, 198), + (15, HUFFMAN_EMIT_SYMBOL, 198), + (24, HUFFMAN_EMIT_SYMBOL, 198), + (31, HUFFMAN_EMIT_SYMBOL, 198), + (41, HUFFMAN_EMIT_SYMBOL, 198), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 198), + (3, HUFFMAN_EMIT_SYMBOL, 228), + (6, HUFFMAN_EMIT_SYMBOL, 228), + (10, HUFFMAN_EMIT_SYMBOL, 228), + (15, HUFFMAN_EMIT_SYMBOL, 228), + (24, HUFFMAN_EMIT_SYMBOL, 228), + (31, HUFFMAN_EMIT_SYMBOL, 228), + (41, HUFFMAN_EMIT_SYMBOL, 228), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 228), + + # Node 145 + (3, HUFFMAN_EMIT_SYMBOL, 232), + (6, HUFFMAN_EMIT_SYMBOL, 232), + (10, HUFFMAN_EMIT_SYMBOL, 232), + (15, HUFFMAN_EMIT_SYMBOL, 232), + (24, HUFFMAN_EMIT_SYMBOL, 232), + (31, HUFFMAN_EMIT_SYMBOL, 232), + (41, HUFFMAN_EMIT_SYMBOL, 232), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 232), + (3, HUFFMAN_EMIT_SYMBOL, 233), + (6, HUFFMAN_EMIT_SYMBOL, 233), + (10, HUFFMAN_EMIT_SYMBOL, 233), + (15, HUFFMAN_EMIT_SYMBOL, 233), + (24, HUFFMAN_EMIT_SYMBOL, 233), + (31, HUFFMAN_EMIT_SYMBOL, 233), + (41, HUFFMAN_EMIT_SYMBOL, 233), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 233), + + # Node 146 + (1, HUFFMAN_EMIT_SYMBOL, 1), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 1), + (1, HUFFMAN_EMIT_SYMBOL, 135), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 135), + (1, HUFFMAN_EMIT_SYMBOL, 137), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 137), + (1, HUFFMAN_EMIT_SYMBOL, 138), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 138), + (1, HUFFMAN_EMIT_SYMBOL, 139), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 139), + (1, HUFFMAN_EMIT_SYMBOL, 140), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 140), + (1, HUFFMAN_EMIT_SYMBOL, 141), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 141), + (1, HUFFMAN_EMIT_SYMBOL, 143), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 143), + + # Node 147 + (2, HUFFMAN_EMIT_SYMBOL, 1), + (9, HUFFMAN_EMIT_SYMBOL, 1), + (23, HUFFMAN_EMIT_SYMBOL, 1), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 1), + (2, HUFFMAN_EMIT_SYMBOL, 135), + (9, HUFFMAN_EMIT_SYMBOL, 135), + (23, HUFFMAN_EMIT_SYMBOL, 135), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 135), + (2, HUFFMAN_EMIT_SYMBOL, 137), + (9, HUFFMAN_EMIT_SYMBOL, 137), + (23, HUFFMAN_EMIT_SYMBOL, 137), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 137), + (2, HUFFMAN_EMIT_SYMBOL, 138), + (9, HUFFMAN_EMIT_SYMBOL, 138), + (23, HUFFMAN_EMIT_SYMBOL, 138), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 138), + + # Node 148 + (3, HUFFMAN_EMIT_SYMBOL, 1), + (6, HUFFMAN_EMIT_SYMBOL, 1), + (10, HUFFMAN_EMIT_SYMBOL, 1), + (15, HUFFMAN_EMIT_SYMBOL, 1), + (24, HUFFMAN_EMIT_SYMBOL, 1), + (31, HUFFMAN_EMIT_SYMBOL, 1), + (41, HUFFMAN_EMIT_SYMBOL, 1), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 1), + (3, HUFFMAN_EMIT_SYMBOL, 135), + (6, HUFFMAN_EMIT_SYMBOL, 135), + (10, HUFFMAN_EMIT_SYMBOL, 135), + (15, HUFFMAN_EMIT_SYMBOL, 135), + (24, HUFFMAN_EMIT_SYMBOL, 135), + (31, HUFFMAN_EMIT_SYMBOL, 135), + (41, HUFFMAN_EMIT_SYMBOL, 135), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 135), + + # Node 149 + (3, HUFFMAN_EMIT_SYMBOL, 137), + (6, HUFFMAN_EMIT_SYMBOL, 137), + (10, HUFFMAN_EMIT_SYMBOL, 137), + (15, HUFFMAN_EMIT_SYMBOL, 137), + (24, HUFFMAN_EMIT_SYMBOL, 137), + (31, HUFFMAN_EMIT_SYMBOL, 137), + (41, HUFFMAN_EMIT_SYMBOL, 137), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 137), + (3, HUFFMAN_EMIT_SYMBOL, 138), + (6, HUFFMAN_EMIT_SYMBOL, 138), + (10, HUFFMAN_EMIT_SYMBOL, 138), + (15, HUFFMAN_EMIT_SYMBOL, 138), + (24, HUFFMAN_EMIT_SYMBOL, 138), + (31, HUFFMAN_EMIT_SYMBOL, 138), + (41, HUFFMAN_EMIT_SYMBOL, 138), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 138), + + # Node 150 + (2, HUFFMAN_EMIT_SYMBOL, 139), + (9, HUFFMAN_EMIT_SYMBOL, 139), + (23, HUFFMAN_EMIT_SYMBOL, 139), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 139), + (2, HUFFMAN_EMIT_SYMBOL, 140), + (9, HUFFMAN_EMIT_SYMBOL, 140), + (23, HUFFMAN_EMIT_SYMBOL, 140), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 140), + (2, HUFFMAN_EMIT_SYMBOL, 141), + (9, HUFFMAN_EMIT_SYMBOL, 141), + (23, HUFFMAN_EMIT_SYMBOL, 141), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 141), + (2, HUFFMAN_EMIT_SYMBOL, 143), + (9, HUFFMAN_EMIT_SYMBOL, 143), + (23, HUFFMAN_EMIT_SYMBOL, 143), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 143), + + # Node 151 + (3, HUFFMAN_EMIT_SYMBOL, 139), + (6, HUFFMAN_EMIT_SYMBOL, 139), + (10, HUFFMAN_EMIT_SYMBOL, 139), + (15, HUFFMAN_EMIT_SYMBOL, 139), + (24, HUFFMAN_EMIT_SYMBOL, 139), + (31, HUFFMAN_EMIT_SYMBOL, 139), + (41, HUFFMAN_EMIT_SYMBOL, 139), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 139), + (3, HUFFMAN_EMIT_SYMBOL, 140), + (6, HUFFMAN_EMIT_SYMBOL, 140), + (10, HUFFMAN_EMIT_SYMBOL, 140), + (15, HUFFMAN_EMIT_SYMBOL, 140), + (24, HUFFMAN_EMIT_SYMBOL, 140), + (31, HUFFMAN_EMIT_SYMBOL, 140), + (41, HUFFMAN_EMIT_SYMBOL, 140), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 140), + + # Node 152 + (3, HUFFMAN_EMIT_SYMBOL, 141), + (6, HUFFMAN_EMIT_SYMBOL, 141), + (10, HUFFMAN_EMIT_SYMBOL, 141), + (15, HUFFMAN_EMIT_SYMBOL, 141), + (24, HUFFMAN_EMIT_SYMBOL, 141), + (31, HUFFMAN_EMIT_SYMBOL, 141), + (41, HUFFMAN_EMIT_SYMBOL, 141), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 141), + (3, HUFFMAN_EMIT_SYMBOL, 143), + (6, HUFFMAN_EMIT_SYMBOL, 143), + (10, HUFFMAN_EMIT_SYMBOL, 143), + (15, HUFFMAN_EMIT_SYMBOL, 143), + (24, HUFFMAN_EMIT_SYMBOL, 143), + (31, HUFFMAN_EMIT_SYMBOL, 143), + (41, HUFFMAN_EMIT_SYMBOL, 143), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 143), + + # Node 153 + (157, 0, 0), + (158, 0, 0), + (160, 0, 0), + (161, 0, 0), + (164, 0, 0), + (165, 0, 0), + (167, 0, 0), + (168, 0, 0), + (172, 0, 0), + (173, 0, 0), + (175, 0, 0), + (177, 0, 0), + (182, 0, 0), + (185, 0, 0), + (191, 0, 0), + (207, 0, 0), + + # Node 154 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 147), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 149), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 150), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 151), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 152), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 155), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 157), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 158), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 165), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 166), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 168), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 174), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 175), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 180), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 182), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 183), + + # Node 155 + (1, HUFFMAN_EMIT_SYMBOL, 147), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 147), + (1, HUFFMAN_EMIT_SYMBOL, 149), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 149), + (1, HUFFMAN_EMIT_SYMBOL, 150), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 150), + (1, HUFFMAN_EMIT_SYMBOL, 151), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 151), + (1, HUFFMAN_EMIT_SYMBOL, 152), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 152), + (1, HUFFMAN_EMIT_SYMBOL, 155), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 155), + (1, HUFFMAN_EMIT_SYMBOL, 157), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 157), + (1, HUFFMAN_EMIT_SYMBOL, 158), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 158), + + # Node 156 + (2, HUFFMAN_EMIT_SYMBOL, 147), + (9, HUFFMAN_EMIT_SYMBOL, 147), + (23, HUFFMAN_EMIT_SYMBOL, 147), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 147), + (2, HUFFMAN_EMIT_SYMBOL, 149), + (9, HUFFMAN_EMIT_SYMBOL, 149), + (23, HUFFMAN_EMIT_SYMBOL, 149), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 149), + (2, HUFFMAN_EMIT_SYMBOL, 150), + (9, HUFFMAN_EMIT_SYMBOL, 150), + (23, HUFFMAN_EMIT_SYMBOL, 150), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 150), + (2, HUFFMAN_EMIT_SYMBOL, 151), + (9, HUFFMAN_EMIT_SYMBOL, 151), + (23, HUFFMAN_EMIT_SYMBOL, 151), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 151), + + # Node 157 + (3, HUFFMAN_EMIT_SYMBOL, 147), + (6, HUFFMAN_EMIT_SYMBOL, 147), + (10, HUFFMAN_EMIT_SYMBOL, 147), + (15, HUFFMAN_EMIT_SYMBOL, 147), + (24, HUFFMAN_EMIT_SYMBOL, 147), + (31, HUFFMAN_EMIT_SYMBOL, 147), + (41, HUFFMAN_EMIT_SYMBOL, 147), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 147), + (3, HUFFMAN_EMIT_SYMBOL, 149), + (6, HUFFMAN_EMIT_SYMBOL, 149), + (10, HUFFMAN_EMIT_SYMBOL, 149), + (15, HUFFMAN_EMIT_SYMBOL, 149), + (24, HUFFMAN_EMIT_SYMBOL, 149), + (31, HUFFMAN_EMIT_SYMBOL, 149), + (41, HUFFMAN_EMIT_SYMBOL, 149), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 149), + + # Node 158 + (3, HUFFMAN_EMIT_SYMBOL, 150), + (6, HUFFMAN_EMIT_SYMBOL, 150), + (10, HUFFMAN_EMIT_SYMBOL, 150), + (15, HUFFMAN_EMIT_SYMBOL, 150), + (24, HUFFMAN_EMIT_SYMBOL, 150), + (31, HUFFMAN_EMIT_SYMBOL, 150), + (41, HUFFMAN_EMIT_SYMBOL, 150), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 150), + (3, HUFFMAN_EMIT_SYMBOL, 151), + (6, HUFFMAN_EMIT_SYMBOL, 151), + (10, HUFFMAN_EMIT_SYMBOL, 151), + (15, HUFFMAN_EMIT_SYMBOL, 151), + (24, HUFFMAN_EMIT_SYMBOL, 151), + (31, HUFFMAN_EMIT_SYMBOL, 151), + (41, HUFFMAN_EMIT_SYMBOL, 151), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 151), + + # Node 159 + (2, HUFFMAN_EMIT_SYMBOL, 152), + (9, HUFFMAN_EMIT_SYMBOL, 152), + (23, HUFFMAN_EMIT_SYMBOL, 152), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 152), + (2, HUFFMAN_EMIT_SYMBOL, 155), + (9, HUFFMAN_EMIT_SYMBOL, 155), + (23, HUFFMAN_EMIT_SYMBOL, 155), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 155), + (2, HUFFMAN_EMIT_SYMBOL, 157), + (9, HUFFMAN_EMIT_SYMBOL, 157), + (23, HUFFMAN_EMIT_SYMBOL, 157), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 157), + (2, HUFFMAN_EMIT_SYMBOL, 158), + (9, HUFFMAN_EMIT_SYMBOL, 158), + (23, HUFFMAN_EMIT_SYMBOL, 158), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 158), + + # Node 160 + (3, HUFFMAN_EMIT_SYMBOL, 152), + (6, HUFFMAN_EMIT_SYMBOL, 152), + (10, HUFFMAN_EMIT_SYMBOL, 152), + (15, HUFFMAN_EMIT_SYMBOL, 152), + (24, HUFFMAN_EMIT_SYMBOL, 152), + (31, HUFFMAN_EMIT_SYMBOL, 152), + (41, HUFFMAN_EMIT_SYMBOL, 152), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 152), + (3, HUFFMAN_EMIT_SYMBOL, 155), + (6, HUFFMAN_EMIT_SYMBOL, 155), + (10, HUFFMAN_EMIT_SYMBOL, 155), + (15, HUFFMAN_EMIT_SYMBOL, 155), + (24, HUFFMAN_EMIT_SYMBOL, 155), + (31, HUFFMAN_EMIT_SYMBOL, 155), + (41, HUFFMAN_EMIT_SYMBOL, 155), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 155), + + # Node 161 + (3, HUFFMAN_EMIT_SYMBOL, 157), + (6, HUFFMAN_EMIT_SYMBOL, 157), + (10, HUFFMAN_EMIT_SYMBOL, 157), + (15, HUFFMAN_EMIT_SYMBOL, 157), + (24, HUFFMAN_EMIT_SYMBOL, 157), + (31, HUFFMAN_EMIT_SYMBOL, 157), + (41, HUFFMAN_EMIT_SYMBOL, 157), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 157), + (3, HUFFMAN_EMIT_SYMBOL, 158), + (6, HUFFMAN_EMIT_SYMBOL, 158), + (10, HUFFMAN_EMIT_SYMBOL, 158), + (15, HUFFMAN_EMIT_SYMBOL, 158), + (24, HUFFMAN_EMIT_SYMBOL, 158), + (31, HUFFMAN_EMIT_SYMBOL, 158), + (41, HUFFMAN_EMIT_SYMBOL, 158), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 158), + + # Node 162 + (1, HUFFMAN_EMIT_SYMBOL, 165), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 165), + (1, HUFFMAN_EMIT_SYMBOL, 166), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 166), + (1, HUFFMAN_EMIT_SYMBOL, 168), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 168), + (1, HUFFMAN_EMIT_SYMBOL, 174), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 174), + (1, HUFFMAN_EMIT_SYMBOL, 175), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 175), + (1, HUFFMAN_EMIT_SYMBOL, 180), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 180), + (1, HUFFMAN_EMIT_SYMBOL, 182), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 182), + (1, HUFFMAN_EMIT_SYMBOL, 183), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 183), + + # Node 163 + (2, HUFFMAN_EMIT_SYMBOL, 165), + (9, HUFFMAN_EMIT_SYMBOL, 165), + (23, HUFFMAN_EMIT_SYMBOL, 165), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 165), + (2, HUFFMAN_EMIT_SYMBOL, 166), + (9, HUFFMAN_EMIT_SYMBOL, 166), + (23, HUFFMAN_EMIT_SYMBOL, 166), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 166), + (2, HUFFMAN_EMIT_SYMBOL, 168), + (9, HUFFMAN_EMIT_SYMBOL, 168), + (23, HUFFMAN_EMIT_SYMBOL, 168), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 168), + (2, HUFFMAN_EMIT_SYMBOL, 174), + (9, HUFFMAN_EMIT_SYMBOL, 174), + (23, HUFFMAN_EMIT_SYMBOL, 174), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 174), + + # Node 164 + (3, HUFFMAN_EMIT_SYMBOL, 165), + (6, HUFFMAN_EMIT_SYMBOL, 165), + (10, HUFFMAN_EMIT_SYMBOL, 165), + (15, HUFFMAN_EMIT_SYMBOL, 165), + (24, HUFFMAN_EMIT_SYMBOL, 165), + (31, HUFFMAN_EMIT_SYMBOL, 165), + (41, HUFFMAN_EMIT_SYMBOL, 165), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 165), + (3, HUFFMAN_EMIT_SYMBOL, 166), + (6, HUFFMAN_EMIT_SYMBOL, 166), + (10, HUFFMAN_EMIT_SYMBOL, 166), + (15, HUFFMAN_EMIT_SYMBOL, 166), + (24, HUFFMAN_EMIT_SYMBOL, 166), + (31, HUFFMAN_EMIT_SYMBOL, 166), + (41, HUFFMAN_EMIT_SYMBOL, 166), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 166), + + # Node 165 + (3, HUFFMAN_EMIT_SYMBOL, 168), + (6, HUFFMAN_EMIT_SYMBOL, 168), + (10, HUFFMAN_EMIT_SYMBOL, 168), + (15, HUFFMAN_EMIT_SYMBOL, 168), + (24, HUFFMAN_EMIT_SYMBOL, 168), + (31, HUFFMAN_EMIT_SYMBOL, 168), + (41, HUFFMAN_EMIT_SYMBOL, 168), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 168), + (3, HUFFMAN_EMIT_SYMBOL, 174), + (6, HUFFMAN_EMIT_SYMBOL, 174), + (10, HUFFMAN_EMIT_SYMBOL, 174), + (15, HUFFMAN_EMIT_SYMBOL, 174), + (24, HUFFMAN_EMIT_SYMBOL, 174), + (31, HUFFMAN_EMIT_SYMBOL, 174), + (41, HUFFMAN_EMIT_SYMBOL, 174), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 174), + + # Node 166 + (2, HUFFMAN_EMIT_SYMBOL, 175), + (9, HUFFMAN_EMIT_SYMBOL, 175), + (23, HUFFMAN_EMIT_SYMBOL, 175), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 175), + (2, HUFFMAN_EMIT_SYMBOL, 180), + (9, HUFFMAN_EMIT_SYMBOL, 180), + (23, HUFFMAN_EMIT_SYMBOL, 180), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 180), + (2, HUFFMAN_EMIT_SYMBOL, 182), + (9, HUFFMAN_EMIT_SYMBOL, 182), + (23, HUFFMAN_EMIT_SYMBOL, 182), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 182), + (2, HUFFMAN_EMIT_SYMBOL, 183), + (9, HUFFMAN_EMIT_SYMBOL, 183), + (23, HUFFMAN_EMIT_SYMBOL, 183), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 183), + + # Node 167 + (3, HUFFMAN_EMIT_SYMBOL, 175), + (6, HUFFMAN_EMIT_SYMBOL, 175), + (10, HUFFMAN_EMIT_SYMBOL, 175), + (15, HUFFMAN_EMIT_SYMBOL, 175), + (24, HUFFMAN_EMIT_SYMBOL, 175), + (31, HUFFMAN_EMIT_SYMBOL, 175), + (41, HUFFMAN_EMIT_SYMBOL, 175), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 175), + (3, HUFFMAN_EMIT_SYMBOL, 180), + (6, HUFFMAN_EMIT_SYMBOL, 180), + (10, HUFFMAN_EMIT_SYMBOL, 180), + (15, HUFFMAN_EMIT_SYMBOL, 180), + (24, HUFFMAN_EMIT_SYMBOL, 180), + (31, HUFFMAN_EMIT_SYMBOL, 180), + (41, HUFFMAN_EMIT_SYMBOL, 180), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 180), + + # Node 168 + (3, HUFFMAN_EMIT_SYMBOL, 182), + (6, HUFFMAN_EMIT_SYMBOL, 182), + (10, HUFFMAN_EMIT_SYMBOL, 182), + (15, HUFFMAN_EMIT_SYMBOL, 182), + (24, HUFFMAN_EMIT_SYMBOL, 182), + (31, HUFFMAN_EMIT_SYMBOL, 182), + (41, HUFFMAN_EMIT_SYMBOL, 182), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 182), + (3, HUFFMAN_EMIT_SYMBOL, 183), + (6, HUFFMAN_EMIT_SYMBOL, 183), + (10, HUFFMAN_EMIT_SYMBOL, 183), + (15, HUFFMAN_EMIT_SYMBOL, 183), + (24, HUFFMAN_EMIT_SYMBOL, 183), + (31, HUFFMAN_EMIT_SYMBOL, 183), + (41, HUFFMAN_EMIT_SYMBOL, 183), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 183), + + # Node 169 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 188), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 191), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 197), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 231), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 239), + (176, 0, 0), + (178, 0, 0), + (179, 0, 0), + (183, 0, 0), + (184, 0, 0), + (186, 0, 0), + (187, 0, 0), + (192, 0, 0), + (199, 0, 0), + (208, 0, 0), + (223, 0, 0), + + # Node 170 + (1, HUFFMAN_EMIT_SYMBOL, 188), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 188), + (1, HUFFMAN_EMIT_SYMBOL, 191), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 191), + (1, HUFFMAN_EMIT_SYMBOL, 197), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 197), + (1, HUFFMAN_EMIT_SYMBOL, 231), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 231), + (1, HUFFMAN_EMIT_SYMBOL, 239), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 239), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 9), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 142), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 144), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 145), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 148), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 159), + + # Node 171 + (2, HUFFMAN_EMIT_SYMBOL, 188), + (9, HUFFMAN_EMIT_SYMBOL, 188), + (23, HUFFMAN_EMIT_SYMBOL, 188), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 188), + (2, HUFFMAN_EMIT_SYMBOL, 191), + (9, HUFFMAN_EMIT_SYMBOL, 191), + (23, HUFFMAN_EMIT_SYMBOL, 191), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 191), + (2, HUFFMAN_EMIT_SYMBOL, 197), + (9, HUFFMAN_EMIT_SYMBOL, 197), + (23, HUFFMAN_EMIT_SYMBOL, 197), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 197), + (2, HUFFMAN_EMIT_SYMBOL, 231), + (9, HUFFMAN_EMIT_SYMBOL, 231), + (23, HUFFMAN_EMIT_SYMBOL, 231), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 231), + + # Node 172 + (3, HUFFMAN_EMIT_SYMBOL, 188), + (6, HUFFMAN_EMIT_SYMBOL, 188), + (10, HUFFMAN_EMIT_SYMBOL, 188), + (15, HUFFMAN_EMIT_SYMBOL, 188), + (24, HUFFMAN_EMIT_SYMBOL, 188), + (31, HUFFMAN_EMIT_SYMBOL, 188), + (41, HUFFMAN_EMIT_SYMBOL, 188), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 188), + (3, HUFFMAN_EMIT_SYMBOL, 191), + (6, HUFFMAN_EMIT_SYMBOL, 191), + (10, HUFFMAN_EMIT_SYMBOL, 191), + (15, HUFFMAN_EMIT_SYMBOL, 191), + (24, HUFFMAN_EMIT_SYMBOL, 191), + (31, HUFFMAN_EMIT_SYMBOL, 191), + (41, HUFFMAN_EMIT_SYMBOL, 191), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 191), + + # Node 173 + (3, HUFFMAN_EMIT_SYMBOL, 197), + (6, HUFFMAN_EMIT_SYMBOL, 197), + (10, HUFFMAN_EMIT_SYMBOL, 197), + (15, HUFFMAN_EMIT_SYMBOL, 197), + (24, HUFFMAN_EMIT_SYMBOL, 197), + (31, HUFFMAN_EMIT_SYMBOL, 197), + (41, HUFFMAN_EMIT_SYMBOL, 197), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 197), + (3, HUFFMAN_EMIT_SYMBOL, 231), + (6, HUFFMAN_EMIT_SYMBOL, 231), + (10, HUFFMAN_EMIT_SYMBOL, 231), + (15, HUFFMAN_EMIT_SYMBOL, 231), + (24, HUFFMAN_EMIT_SYMBOL, 231), + (31, HUFFMAN_EMIT_SYMBOL, 231), + (41, HUFFMAN_EMIT_SYMBOL, 231), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 231), + + # Node 174 + (2, HUFFMAN_EMIT_SYMBOL, 239), + (9, HUFFMAN_EMIT_SYMBOL, 239), + (23, HUFFMAN_EMIT_SYMBOL, 239), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 239), + (1, HUFFMAN_EMIT_SYMBOL, 9), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 9), + (1, HUFFMAN_EMIT_SYMBOL, 142), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 142), + (1, HUFFMAN_EMIT_SYMBOL, 144), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 144), + (1, HUFFMAN_EMIT_SYMBOL, 145), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 145), + (1, HUFFMAN_EMIT_SYMBOL, 148), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 148), + (1, HUFFMAN_EMIT_SYMBOL, 159), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 159), + + # Node 175 + (3, HUFFMAN_EMIT_SYMBOL, 239), + (6, HUFFMAN_EMIT_SYMBOL, 239), + (10, HUFFMAN_EMIT_SYMBOL, 239), + (15, HUFFMAN_EMIT_SYMBOL, 239), + (24, HUFFMAN_EMIT_SYMBOL, 239), + (31, HUFFMAN_EMIT_SYMBOL, 239), + (41, HUFFMAN_EMIT_SYMBOL, 239), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 239), + (2, HUFFMAN_EMIT_SYMBOL, 9), + (9, HUFFMAN_EMIT_SYMBOL, 9), + (23, HUFFMAN_EMIT_SYMBOL, 9), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 9), + (2, HUFFMAN_EMIT_SYMBOL, 142), + (9, HUFFMAN_EMIT_SYMBOL, 142), + (23, HUFFMAN_EMIT_SYMBOL, 142), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 142), + + # Node 176 + (3, HUFFMAN_EMIT_SYMBOL, 9), + (6, HUFFMAN_EMIT_SYMBOL, 9), + (10, HUFFMAN_EMIT_SYMBOL, 9), + (15, HUFFMAN_EMIT_SYMBOL, 9), + (24, HUFFMAN_EMIT_SYMBOL, 9), + (31, HUFFMAN_EMIT_SYMBOL, 9), + (41, HUFFMAN_EMIT_SYMBOL, 9), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 9), + (3, HUFFMAN_EMIT_SYMBOL, 142), + (6, HUFFMAN_EMIT_SYMBOL, 142), + (10, HUFFMAN_EMIT_SYMBOL, 142), + (15, HUFFMAN_EMIT_SYMBOL, 142), + (24, HUFFMAN_EMIT_SYMBOL, 142), + (31, HUFFMAN_EMIT_SYMBOL, 142), + (41, HUFFMAN_EMIT_SYMBOL, 142), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 142), + + # Node 177 + (2, HUFFMAN_EMIT_SYMBOL, 144), + (9, HUFFMAN_EMIT_SYMBOL, 144), + (23, HUFFMAN_EMIT_SYMBOL, 144), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 144), + (2, HUFFMAN_EMIT_SYMBOL, 145), + (9, HUFFMAN_EMIT_SYMBOL, 145), + (23, HUFFMAN_EMIT_SYMBOL, 145), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 145), + (2, HUFFMAN_EMIT_SYMBOL, 148), + (9, HUFFMAN_EMIT_SYMBOL, 148), + (23, HUFFMAN_EMIT_SYMBOL, 148), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 148), + (2, HUFFMAN_EMIT_SYMBOL, 159), + (9, HUFFMAN_EMIT_SYMBOL, 159), + (23, HUFFMAN_EMIT_SYMBOL, 159), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 159), + + # Node 178 + (3, HUFFMAN_EMIT_SYMBOL, 144), + (6, HUFFMAN_EMIT_SYMBOL, 144), + (10, HUFFMAN_EMIT_SYMBOL, 144), + (15, HUFFMAN_EMIT_SYMBOL, 144), + (24, HUFFMAN_EMIT_SYMBOL, 144), + (31, HUFFMAN_EMIT_SYMBOL, 144), + (41, HUFFMAN_EMIT_SYMBOL, 144), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 144), + (3, HUFFMAN_EMIT_SYMBOL, 145), + (6, HUFFMAN_EMIT_SYMBOL, 145), + (10, HUFFMAN_EMIT_SYMBOL, 145), + (15, HUFFMAN_EMIT_SYMBOL, 145), + (24, HUFFMAN_EMIT_SYMBOL, 145), + (31, HUFFMAN_EMIT_SYMBOL, 145), + (41, HUFFMAN_EMIT_SYMBOL, 145), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 145), + + # Node 179 + (3, HUFFMAN_EMIT_SYMBOL, 148), + (6, HUFFMAN_EMIT_SYMBOL, 148), + (10, HUFFMAN_EMIT_SYMBOL, 148), + (15, HUFFMAN_EMIT_SYMBOL, 148), + (24, HUFFMAN_EMIT_SYMBOL, 148), + (31, HUFFMAN_EMIT_SYMBOL, 148), + (41, HUFFMAN_EMIT_SYMBOL, 148), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 148), + (3, HUFFMAN_EMIT_SYMBOL, 159), + (6, HUFFMAN_EMIT_SYMBOL, 159), + (10, HUFFMAN_EMIT_SYMBOL, 159), + (15, HUFFMAN_EMIT_SYMBOL, 159), + (24, HUFFMAN_EMIT_SYMBOL, 159), + (31, HUFFMAN_EMIT_SYMBOL, 159), + (41, HUFFMAN_EMIT_SYMBOL, 159), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 159), + + # Node 180 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 171), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 206), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 215), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 225), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 236), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 237), + (188, 0, 0), + (189, 0, 0), + (193, 0, 0), + (196, 0, 0), + (200, 0, 0), + (203, 0, 0), + (209, 0, 0), + (216, 0, 0), + (224, 0, 0), + (238, 0, 0), + + # Node 181 + (1, HUFFMAN_EMIT_SYMBOL, 171), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 171), + (1, HUFFMAN_EMIT_SYMBOL, 206), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 206), + (1, HUFFMAN_EMIT_SYMBOL, 215), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 215), + (1, HUFFMAN_EMIT_SYMBOL, 225), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 225), + (1, HUFFMAN_EMIT_SYMBOL, 236), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 236), + (1, HUFFMAN_EMIT_SYMBOL, 237), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 237), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 199), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 207), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 234), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 235), + + # Node 182 + (2, HUFFMAN_EMIT_SYMBOL, 171), + (9, HUFFMAN_EMIT_SYMBOL, 171), + (23, HUFFMAN_EMIT_SYMBOL, 171), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 171), + (2, HUFFMAN_EMIT_SYMBOL, 206), + (9, HUFFMAN_EMIT_SYMBOL, 206), + (23, HUFFMAN_EMIT_SYMBOL, 206), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 206), + (2, HUFFMAN_EMIT_SYMBOL, 215), + (9, HUFFMAN_EMIT_SYMBOL, 215), + (23, HUFFMAN_EMIT_SYMBOL, 215), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 215), + (2, HUFFMAN_EMIT_SYMBOL, 225), + (9, HUFFMAN_EMIT_SYMBOL, 225), + (23, HUFFMAN_EMIT_SYMBOL, 225), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 225), + + # Node 183 + (3, HUFFMAN_EMIT_SYMBOL, 171), + (6, HUFFMAN_EMIT_SYMBOL, 171), + (10, HUFFMAN_EMIT_SYMBOL, 171), + (15, HUFFMAN_EMIT_SYMBOL, 171), + (24, HUFFMAN_EMIT_SYMBOL, 171), + (31, HUFFMAN_EMIT_SYMBOL, 171), + (41, HUFFMAN_EMIT_SYMBOL, 171), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 171), + (3, HUFFMAN_EMIT_SYMBOL, 206), + (6, HUFFMAN_EMIT_SYMBOL, 206), + (10, HUFFMAN_EMIT_SYMBOL, 206), + (15, HUFFMAN_EMIT_SYMBOL, 206), + (24, HUFFMAN_EMIT_SYMBOL, 206), + (31, HUFFMAN_EMIT_SYMBOL, 206), + (41, HUFFMAN_EMIT_SYMBOL, 206), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 206), + + # Node 184 + (3, HUFFMAN_EMIT_SYMBOL, 215), + (6, HUFFMAN_EMIT_SYMBOL, 215), + (10, HUFFMAN_EMIT_SYMBOL, 215), + (15, HUFFMAN_EMIT_SYMBOL, 215), + (24, HUFFMAN_EMIT_SYMBOL, 215), + (31, HUFFMAN_EMIT_SYMBOL, 215), + (41, HUFFMAN_EMIT_SYMBOL, 215), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 215), + (3, HUFFMAN_EMIT_SYMBOL, 225), + (6, HUFFMAN_EMIT_SYMBOL, 225), + (10, HUFFMAN_EMIT_SYMBOL, 225), + (15, HUFFMAN_EMIT_SYMBOL, 225), + (24, HUFFMAN_EMIT_SYMBOL, 225), + (31, HUFFMAN_EMIT_SYMBOL, 225), + (41, HUFFMAN_EMIT_SYMBOL, 225), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 225), + + # Node 185 + (2, HUFFMAN_EMIT_SYMBOL, 236), + (9, HUFFMAN_EMIT_SYMBOL, 236), + (23, HUFFMAN_EMIT_SYMBOL, 236), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 236), + (2, HUFFMAN_EMIT_SYMBOL, 237), + (9, HUFFMAN_EMIT_SYMBOL, 237), + (23, HUFFMAN_EMIT_SYMBOL, 237), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 237), + (1, HUFFMAN_EMIT_SYMBOL, 199), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 199), + (1, HUFFMAN_EMIT_SYMBOL, 207), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 207), + (1, HUFFMAN_EMIT_SYMBOL, 234), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 234), + (1, HUFFMAN_EMIT_SYMBOL, 235), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 235), + + # Node 186 + (3, HUFFMAN_EMIT_SYMBOL, 236), + (6, HUFFMAN_EMIT_SYMBOL, 236), + (10, HUFFMAN_EMIT_SYMBOL, 236), + (15, HUFFMAN_EMIT_SYMBOL, 236), + (24, HUFFMAN_EMIT_SYMBOL, 236), + (31, HUFFMAN_EMIT_SYMBOL, 236), + (41, HUFFMAN_EMIT_SYMBOL, 236), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 236), + (3, HUFFMAN_EMIT_SYMBOL, 237), + (6, HUFFMAN_EMIT_SYMBOL, 237), + (10, HUFFMAN_EMIT_SYMBOL, 237), + (15, HUFFMAN_EMIT_SYMBOL, 237), + (24, HUFFMAN_EMIT_SYMBOL, 237), + (31, HUFFMAN_EMIT_SYMBOL, 237), + (41, HUFFMAN_EMIT_SYMBOL, 237), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 237), + + # Node 187 + (2, HUFFMAN_EMIT_SYMBOL, 199), + (9, HUFFMAN_EMIT_SYMBOL, 199), + (23, HUFFMAN_EMIT_SYMBOL, 199), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 199), + (2, HUFFMAN_EMIT_SYMBOL, 207), + (9, HUFFMAN_EMIT_SYMBOL, 207), + (23, HUFFMAN_EMIT_SYMBOL, 207), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 207), + (2, HUFFMAN_EMIT_SYMBOL, 234), + (9, HUFFMAN_EMIT_SYMBOL, 234), + (23, HUFFMAN_EMIT_SYMBOL, 234), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 234), + (2, HUFFMAN_EMIT_SYMBOL, 235), + (9, HUFFMAN_EMIT_SYMBOL, 235), + (23, HUFFMAN_EMIT_SYMBOL, 235), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 235), + + # Node 188 + (3, HUFFMAN_EMIT_SYMBOL, 199), + (6, HUFFMAN_EMIT_SYMBOL, 199), + (10, HUFFMAN_EMIT_SYMBOL, 199), + (15, HUFFMAN_EMIT_SYMBOL, 199), + (24, HUFFMAN_EMIT_SYMBOL, 199), + (31, HUFFMAN_EMIT_SYMBOL, 199), + (41, HUFFMAN_EMIT_SYMBOL, 199), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 199), + (3, HUFFMAN_EMIT_SYMBOL, 207), + (6, HUFFMAN_EMIT_SYMBOL, 207), + (10, HUFFMAN_EMIT_SYMBOL, 207), + (15, HUFFMAN_EMIT_SYMBOL, 207), + (24, HUFFMAN_EMIT_SYMBOL, 207), + (31, HUFFMAN_EMIT_SYMBOL, 207), + (41, HUFFMAN_EMIT_SYMBOL, 207), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 207), + + # Node 189 + (3, HUFFMAN_EMIT_SYMBOL, 234), + (6, HUFFMAN_EMIT_SYMBOL, 234), + (10, HUFFMAN_EMIT_SYMBOL, 234), + (15, HUFFMAN_EMIT_SYMBOL, 234), + (24, HUFFMAN_EMIT_SYMBOL, 234), + (31, HUFFMAN_EMIT_SYMBOL, 234), + (41, HUFFMAN_EMIT_SYMBOL, 234), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 234), + (3, HUFFMAN_EMIT_SYMBOL, 235), + (6, HUFFMAN_EMIT_SYMBOL, 235), + (10, HUFFMAN_EMIT_SYMBOL, 235), + (15, HUFFMAN_EMIT_SYMBOL, 235), + (24, HUFFMAN_EMIT_SYMBOL, 235), + (31, HUFFMAN_EMIT_SYMBOL, 235), + (41, HUFFMAN_EMIT_SYMBOL, 235), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 235), + + # Node 190 + (194, 0, 0), + (195, 0, 0), + (197, 0, 0), + (198, 0, 0), + (201, 0, 0), + (202, 0, 0), + (204, 0, 0), + (205, 0, 0), + (210, 0, 0), + (213, 0, 0), + (217, 0, 0), + (220, 0, 0), + (225, 0, 0), + (231, 0, 0), + (239, 0, 0), + (246, 0, 0), + + # Node 191 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 192), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 193), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 200), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 201), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 202), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 205), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 210), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 213), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 218), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 219), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 238), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 240), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 242), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 243), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 255), + (206, 0, 0), + + # Node 192 + (1, HUFFMAN_EMIT_SYMBOL, 192), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 192), + (1, HUFFMAN_EMIT_SYMBOL, 193), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 193), + (1, HUFFMAN_EMIT_SYMBOL, 200), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 200), + (1, HUFFMAN_EMIT_SYMBOL, 201), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 201), + (1, HUFFMAN_EMIT_SYMBOL, 202), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 202), + (1, HUFFMAN_EMIT_SYMBOL, 205), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 205), + (1, HUFFMAN_EMIT_SYMBOL, 210), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 210), + (1, HUFFMAN_EMIT_SYMBOL, 213), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 213), + + # Node 193 + (2, HUFFMAN_EMIT_SYMBOL, 192), + (9, HUFFMAN_EMIT_SYMBOL, 192), + (23, HUFFMAN_EMIT_SYMBOL, 192), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 192), + (2, HUFFMAN_EMIT_SYMBOL, 193), + (9, HUFFMAN_EMIT_SYMBOL, 193), + (23, HUFFMAN_EMIT_SYMBOL, 193), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 193), + (2, HUFFMAN_EMIT_SYMBOL, 200), + (9, HUFFMAN_EMIT_SYMBOL, 200), + (23, HUFFMAN_EMIT_SYMBOL, 200), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 200), + (2, HUFFMAN_EMIT_SYMBOL, 201), + (9, HUFFMAN_EMIT_SYMBOL, 201), + (23, HUFFMAN_EMIT_SYMBOL, 201), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 201), + + # Node 194 + (3, HUFFMAN_EMIT_SYMBOL, 192), + (6, HUFFMAN_EMIT_SYMBOL, 192), + (10, HUFFMAN_EMIT_SYMBOL, 192), + (15, HUFFMAN_EMIT_SYMBOL, 192), + (24, HUFFMAN_EMIT_SYMBOL, 192), + (31, HUFFMAN_EMIT_SYMBOL, 192), + (41, HUFFMAN_EMIT_SYMBOL, 192), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 192), + (3, HUFFMAN_EMIT_SYMBOL, 193), + (6, HUFFMAN_EMIT_SYMBOL, 193), + (10, HUFFMAN_EMIT_SYMBOL, 193), + (15, HUFFMAN_EMIT_SYMBOL, 193), + (24, HUFFMAN_EMIT_SYMBOL, 193), + (31, HUFFMAN_EMIT_SYMBOL, 193), + (41, HUFFMAN_EMIT_SYMBOL, 193), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 193), + + # Node 195 + (3, HUFFMAN_EMIT_SYMBOL, 200), + (6, HUFFMAN_EMIT_SYMBOL, 200), + (10, HUFFMAN_EMIT_SYMBOL, 200), + (15, HUFFMAN_EMIT_SYMBOL, 200), + (24, HUFFMAN_EMIT_SYMBOL, 200), + (31, HUFFMAN_EMIT_SYMBOL, 200), + (41, HUFFMAN_EMIT_SYMBOL, 200), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 200), + (3, HUFFMAN_EMIT_SYMBOL, 201), + (6, HUFFMAN_EMIT_SYMBOL, 201), + (10, HUFFMAN_EMIT_SYMBOL, 201), + (15, HUFFMAN_EMIT_SYMBOL, 201), + (24, HUFFMAN_EMIT_SYMBOL, 201), + (31, HUFFMAN_EMIT_SYMBOL, 201), + (41, HUFFMAN_EMIT_SYMBOL, 201), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 201), + + # Node 196 + (2, HUFFMAN_EMIT_SYMBOL, 202), + (9, HUFFMAN_EMIT_SYMBOL, 202), + (23, HUFFMAN_EMIT_SYMBOL, 202), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 202), + (2, HUFFMAN_EMIT_SYMBOL, 205), + (9, HUFFMAN_EMIT_SYMBOL, 205), + (23, HUFFMAN_EMIT_SYMBOL, 205), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 205), + (2, HUFFMAN_EMIT_SYMBOL, 210), + (9, HUFFMAN_EMIT_SYMBOL, 210), + (23, HUFFMAN_EMIT_SYMBOL, 210), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 210), + (2, HUFFMAN_EMIT_SYMBOL, 213), + (9, HUFFMAN_EMIT_SYMBOL, 213), + (23, HUFFMAN_EMIT_SYMBOL, 213), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 213), + + # Node 197 + (3, HUFFMAN_EMIT_SYMBOL, 202), + (6, HUFFMAN_EMIT_SYMBOL, 202), + (10, HUFFMAN_EMIT_SYMBOL, 202), + (15, HUFFMAN_EMIT_SYMBOL, 202), + (24, HUFFMAN_EMIT_SYMBOL, 202), + (31, HUFFMAN_EMIT_SYMBOL, 202), + (41, HUFFMAN_EMIT_SYMBOL, 202), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 202), + (3, HUFFMAN_EMIT_SYMBOL, 205), + (6, HUFFMAN_EMIT_SYMBOL, 205), + (10, HUFFMAN_EMIT_SYMBOL, 205), + (15, HUFFMAN_EMIT_SYMBOL, 205), + (24, HUFFMAN_EMIT_SYMBOL, 205), + (31, HUFFMAN_EMIT_SYMBOL, 205), + (41, HUFFMAN_EMIT_SYMBOL, 205), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 205), + + # Node 198 + (3, HUFFMAN_EMIT_SYMBOL, 210), + (6, HUFFMAN_EMIT_SYMBOL, 210), + (10, HUFFMAN_EMIT_SYMBOL, 210), + (15, HUFFMAN_EMIT_SYMBOL, 210), + (24, HUFFMAN_EMIT_SYMBOL, 210), + (31, HUFFMAN_EMIT_SYMBOL, 210), + (41, HUFFMAN_EMIT_SYMBOL, 210), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 210), + (3, HUFFMAN_EMIT_SYMBOL, 213), + (6, HUFFMAN_EMIT_SYMBOL, 213), + (10, HUFFMAN_EMIT_SYMBOL, 213), + (15, HUFFMAN_EMIT_SYMBOL, 213), + (24, HUFFMAN_EMIT_SYMBOL, 213), + (31, HUFFMAN_EMIT_SYMBOL, 213), + (41, HUFFMAN_EMIT_SYMBOL, 213), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 213), + + # Node 199 + (1, HUFFMAN_EMIT_SYMBOL, 218), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 218), + (1, HUFFMAN_EMIT_SYMBOL, 219), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 219), + (1, HUFFMAN_EMIT_SYMBOL, 238), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 238), + (1, HUFFMAN_EMIT_SYMBOL, 240), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 240), + (1, HUFFMAN_EMIT_SYMBOL, 242), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 242), + (1, HUFFMAN_EMIT_SYMBOL, 243), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 243), + (1, HUFFMAN_EMIT_SYMBOL, 255), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 255), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 203), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 204), + + # Node 200 + (2, HUFFMAN_EMIT_SYMBOL, 218), + (9, HUFFMAN_EMIT_SYMBOL, 218), + (23, HUFFMAN_EMIT_SYMBOL, 218), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 218), + (2, HUFFMAN_EMIT_SYMBOL, 219), + (9, HUFFMAN_EMIT_SYMBOL, 219), + (23, HUFFMAN_EMIT_SYMBOL, 219), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 219), + (2, HUFFMAN_EMIT_SYMBOL, 238), + (9, HUFFMAN_EMIT_SYMBOL, 238), + (23, HUFFMAN_EMIT_SYMBOL, 238), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 238), + (2, HUFFMAN_EMIT_SYMBOL, 240), + (9, HUFFMAN_EMIT_SYMBOL, 240), + (23, HUFFMAN_EMIT_SYMBOL, 240), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 240), + + # Node 201 + (3, HUFFMAN_EMIT_SYMBOL, 218), + (6, HUFFMAN_EMIT_SYMBOL, 218), + (10, HUFFMAN_EMIT_SYMBOL, 218), + (15, HUFFMAN_EMIT_SYMBOL, 218), + (24, HUFFMAN_EMIT_SYMBOL, 218), + (31, HUFFMAN_EMIT_SYMBOL, 218), + (41, HUFFMAN_EMIT_SYMBOL, 218), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 218), + (3, HUFFMAN_EMIT_SYMBOL, 219), + (6, HUFFMAN_EMIT_SYMBOL, 219), + (10, HUFFMAN_EMIT_SYMBOL, 219), + (15, HUFFMAN_EMIT_SYMBOL, 219), + (24, HUFFMAN_EMIT_SYMBOL, 219), + (31, HUFFMAN_EMIT_SYMBOL, 219), + (41, HUFFMAN_EMIT_SYMBOL, 219), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 219), + + # Node 202 + (3, HUFFMAN_EMIT_SYMBOL, 238), + (6, HUFFMAN_EMIT_SYMBOL, 238), + (10, HUFFMAN_EMIT_SYMBOL, 238), + (15, HUFFMAN_EMIT_SYMBOL, 238), + (24, HUFFMAN_EMIT_SYMBOL, 238), + (31, HUFFMAN_EMIT_SYMBOL, 238), + (41, HUFFMAN_EMIT_SYMBOL, 238), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 238), + (3, HUFFMAN_EMIT_SYMBOL, 240), + (6, HUFFMAN_EMIT_SYMBOL, 240), + (10, HUFFMAN_EMIT_SYMBOL, 240), + (15, HUFFMAN_EMIT_SYMBOL, 240), + (24, HUFFMAN_EMIT_SYMBOL, 240), + (31, HUFFMAN_EMIT_SYMBOL, 240), + (41, HUFFMAN_EMIT_SYMBOL, 240), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 240), + + # Node 203 + (2, HUFFMAN_EMIT_SYMBOL, 242), + (9, HUFFMAN_EMIT_SYMBOL, 242), + (23, HUFFMAN_EMIT_SYMBOL, 242), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 242), + (2, HUFFMAN_EMIT_SYMBOL, 243), + (9, HUFFMAN_EMIT_SYMBOL, 243), + (23, HUFFMAN_EMIT_SYMBOL, 243), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 243), + (2, HUFFMAN_EMIT_SYMBOL, 255), + (9, HUFFMAN_EMIT_SYMBOL, 255), + (23, HUFFMAN_EMIT_SYMBOL, 255), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 255), + (1, HUFFMAN_EMIT_SYMBOL, 203), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 203), + (1, HUFFMAN_EMIT_SYMBOL, 204), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 204), + + # Node 204 + (3, HUFFMAN_EMIT_SYMBOL, 242), + (6, HUFFMAN_EMIT_SYMBOL, 242), + (10, HUFFMAN_EMIT_SYMBOL, 242), + (15, HUFFMAN_EMIT_SYMBOL, 242), + (24, HUFFMAN_EMIT_SYMBOL, 242), + (31, HUFFMAN_EMIT_SYMBOL, 242), + (41, HUFFMAN_EMIT_SYMBOL, 242), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 242), + (3, HUFFMAN_EMIT_SYMBOL, 243), + (6, HUFFMAN_EMIT_SYMBOL, 243), + (10, HUFFMAN_EMIT_SYMBOL, 243), + (15, HUFFMAN_EMIT_SYMBOL, 243), + (24, HUFFMAN_EMIT_SYMBOL, 243), + (31, HUFFMAN_EMIT_SYMBOL, 243), + (41, HUFFMAN_EMIT_SYMBOL, 243), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 243), + + # Node 205 + (3, HUFFMAN_EMIT_SYMBOL, 255), + (6, HUFFMAN_EMIT_SYMBOL, 255), + (10, HUFFMAN_EMIT_SYMBOL, 255), + (15, HUFFMAN_EMIT_SYMBOL, 255), + (24, HUFFMAN_EMIT_SYMBOL, 255), + (31, HUFFMAN_EMIT_SYMBOL, 255), + (41, HUFFMAN_EMIT_SYMBOL, 255), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 255), + (2, HUFFMAN_EMIT_SYMBOL, 203), + (9, HUFFMAN_EMIT_SYMBOL, 203), + (23, HUFFMAN_EMIT_SYMBOL, 203), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 203), + (2, HUFFMAN_EMIT_SYMBOL, 204), + (9, HUFFMAN_EMIT_SYMBOL, 204), + (23, HUFFMAN_EMIT_SYMBOL, 204), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 204), + + # Node 206 + (3, HUFFMAN_EMIT_SYMBOL, 203), + (6, HUFFMAN_EMIT_SYMBOL, 203), + (10, HUFFMAN_EMIT_SYMBOL, 203), + (15, HUFFMAN_EMIT_SYMBOL, 203), + (24, HUFFMAN_EMIT_SYMBOL, 203), + (31, HUFFMAN_EMIT_SYMBOL, 203), + (41, HUFFMAN_EMIT_SYMBOL, 203), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 203), + (3, HUFFMAN_EMIT_SYMBOL, 204), + (6, HUFFMAN_EMIT_SYMBOL, 204), + (10, HUFFMAN_EMIT_SYMBOL, 204), + (15, HUFFMAN_EMIT_SYMBOL, 204), + (24, HUFFMAN_EMIT_SYMBOL, 204), + (31, HUFFMAN_EMIT_SYMBOL, 204), + (41, HUFFMAN_EMIT_SYMBOL, 204), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 204), + + # Node 207 + (211, 0, 0), + (212, 0, 0), + (214, 0, 0), + (215, 0, 0), + (218, 0, 0), + (219, 0, 0), + (221, 0, 0), + (222, 0, 0), + (226, 0, 0), + (228, 0, 0), + (232, 0, 0), + (235, 0, 0), + (240, 0, 0), + (243, 0, 0), + (247, 0, 0), + (250, 0, 0), + + # Node 208 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 211), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 212), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 214), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 221), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 222), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 223), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 241), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 244), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 245), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 246), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 247), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 248), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 250), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 251), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 252), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 253), + + # Node 209 + (1, HUFFMAN_EMIT_SYMBOL, 211), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 211), + (1, HUFFMAN_EMIT_SYMBOL, 212), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 212), + (1, HUFFMAN_EMIT_SYMBOL, 214), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 214), + (1, HUFFMAN_EMIT_SYMBOL, 221), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 221), + (1, HUFFMAN_EMIT_SYMBOL, 222), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 222), + (1, HUFFMAN_EMIT_SYMBOL, 223), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 223), + (1, HUFFMAN_EMIT_SYMBOL, 241), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 241), + (1, HUFFMAN_EMIT_SYMBOL, 244), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 244), + + # Node 210 + (2, HUFFMAN_EMIT_SYMBOL, 211), + (9, HUFFMAN_EMIT_SYMBOL, 211), + (23, HUFFMAN_EMIT_SYMBOL, 211), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 211), + (2, HUFFMAN_EMIT_SYMBOL, 212), + (9, HUFFMAN_EMIT_SYMBOL, 212), + (23, HUFFMAN_EMIT_SYMBOL, 212), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 212), + (2, HUFFMAN_EMIT_SYMBOL, 214), + (9, HUFFMAN_EMIT_SYMBOL, 214), + (23, HUFFMAN_EMIT_SYMBOL, 214), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 214), + (2, HUFFMAN_EMIT_SYMBOL, 221), + (9, HUFFMAN_EMIT_SYMBOL, 221), + (23, HUFFMAN_EMIT_SYMBOL, 221), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 221), + + # Node 211 + (3, HUFFMAN_EMIT_SYMBOL, 211), + (6, HUFFMAN_EMIT_SYMBOL, 211), + (10, HUFFMAN_EMIT_SYMBOL, 211), + (15, HUFFMAN_EMIT_SYMBOL, 211), + (24, HUFFMAN_EMIT_SYMBOL, 211), + (31, HUFFMAN_EMIT_SYMBOL, 211), + (41, HUFFMAN_EMIT_SYMBOL, 211), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 211), + (3, HUFFMAN_EMIT_SYMBOL, 212), + (6, HUFFMAN_EMIT_SYMBOL, 212), + (10, HUFFMAN_EMIT_SYMBOL, 212), + (15, HUFFMAN_EMIT_SYMBOL, 212), + (24, HUFFMAN_EMIT_SYMBOL, 212), + (31, HUFFMAN_EMIT_SYMBOL, 212), + (41, HUFFMAN_EMIT_SYMBOL, 212), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 212), + + # Node 212 + (3, HUFFMAN_EMIT_SYMBOL, 214), + (6, HUFFMAN_EMIT_SYMBOL, 214), + (10, HUFFMAN_EMIT_SYMBOL, 214), + (15, HUFFMAN_EMIT_SYMBOL, 214), + (24, HUFFMAN_EMIT_SYMBOL, 214), + (31, HUFFMAN_EMIT_SYMBOL, 214), + (41, HUFFMAN_EMIT_SYMBOL, 214), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 214), + (3, HUFFMAN_EMIT_SYMBOL, 221), + (6, HUFFMAN_EMIT_SYMBOL, 221), + (10, HUFFMAN_EMIT_SYMBOL, 221), + (15, HUFFMAN_EMIT_SYMBOL, 221), + (24, HUFFMAN_EMIT_SYMBOL, 221), + (31, HUFFMAN_EMIT_SYMBOL, 221), + (41, HUFFMAN_EMIT_SYMBOL, 221), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 221), + + # Node 213 + (2, HUFFMAN_EMIT_SYMBOL, 222), + (9, HUFFMAN_EMIT_SYMBOL, 222), + (23, HUFFMAN_EMIT_SYMBOL, 222), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 222), + (2, HUFFMAN_EMIT_SYMBOL, 223), + (9, HUFFMAN_EMIT_SYMBOL, 223), + (23, HUFFMAN_EMIT_SYMBOL, 223), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 223), + (2, HUFFMAN_EMIT_SYMBOL, 241), + (9, HUFFMAN_EMIT_SYMBOL, 241), + (23, HUFFMAN_EMIT_SYMBOL, 241), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 241), + (2, HUFFMAN_EMIT_SYMBOL, 244), + (9, HUFFMAN_EMIT_SYMBOL, 244), + (23, HUFFMAN_EMIT_SYMBOL, 244), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 244), + + # Node 214 + (3, HUFFMAN_EMIT_SYMBOL, 222), + (6, HUFFMAN_EMIT_SYMBOL, 222), + (10, HUFFMAN_EMIT_SYMBOL, 222), + (15, HUFFMAN_EMIT_SYMBOL, 222), + (24, HUFFMAN_EMIT_SYMBOL, 222), + (31, HUFFMAN_EMIT_SYMBOL, 222), + (41, HUFFMAN_EMIT_SYMBOL, 222), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 222), + (3, HUFFMAN_EMIT_SYMBOL, 223), + (6, HUFFMAN_EMIT_SYMBOL, 223), + (10, HUFFMAN_EMIT_SYMBOL, 223), + (15, HUFFMAN_EMIT_SYMBOL, 223), + (24, HUFFMAN_EMIT_SYMBOL, 223), + (31, HUFFMAN_EMIT_SYMBOL, 223), + (41, HUFFMAN_EMIT_SYMBOL, 223), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 223), + + # Node 215 + (3, HUFFMAN_EMIT_SYMBOL, 241), + (6, HUFFMAN_EMIT_SYMBOL, 241), + (10, HUFFMAN_EMIT_SYMBOL, 241), + (15, HUFFMAN_EMIT_SYMBOL, 241), + (24, HUFFMAN_EMIT_SYMBOL, 241), + (31, HUFFMAN_EMIT_SYMBOL, 241), + (41, HUFFMAN_EMIT_SYMBOL, 241), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 241), + (3, HUFFMAN_EMIT_SYMBOL, 244), + (6, HUFFMAN_EMIT_SYMBOL, 244), + (10, HUFFMAN_EMIT_SYMBOL, 244), + (15, HUFFMAN_EMIT_SYMBOL, 244), + (24, HUFFMAN_EMIT_SYMBOL, 244), + (31, HUFFMAN_EMIT_SYMBOL, 244), + (41, HUFFMAN_EMIT_SYMBOL, 244), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 244), + + # Node 216 + (1, HUFFMAN_EMIT_SYMBOL, 245), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 245), + (1, HUFFMAN_EMIT_SYMBOL, 246), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 246), + (1, HUFFMAN_EMIT_SYMBOL, 247), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 247), + (1, HUFFMAN_EMIT_SYMBOL, 248), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 248), + (1, HUFFMAN_EMIT_SYMBOL, 250), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 250), + (1, HUFFMAN_EMIT_SYMBOL, 251), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 251), + (1, HUFFMAN_EMIT_SYMBOL, 252), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 252), + (1, HUFFMAN_EMIT_SYMBOL, 253), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 253), + + # Node 217 + (2, HUFFMAN_EMIT_SYMBOL, 245), + (9, HUFFMAN_EMIT_SYMBOL, 245), + (23, HUFFMAN_EMIT_SYMBOL, 245), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 245), + (2, HUFFMAN_EMIT_SYMBOL, 246), + (9, HUFFMAN_EMIT_SYMBOL, 246), + (23, HUFFMAN_EMIT_SYMBOL, 246), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 246), + (2, HUFFMAN_EMIT_SYMBOL, 247), + (9, HUFFMAN_EMIT_SYMBOL, 247), + (23, HUFFMAN_EMIT_SYMBOL, 247), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 247), + (2, HUFFMAN_EMIT_SYMBOL, 248), + (9, HUFFMAN_EMIT_SYMBOL, 248), + (23, HUFFMAN_EMIT_SYMBOL, 248), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 248), + + # Node 218 + (3, HUFFMAN_EMIT_SYMBOL, 245), + (6, HUFFMAN_EMIT_SYMBOL, 245), + (10, HUFFMAN_EMIT_SYMBOL, 245), + (15, HUFFMAN_EMIT_SYMBOL, 245), + (24, HUFFMAN_EMIT_SYMBOL, 245), + (31, HUFFMAN_EMIT_SYMBOL, 245), + (41, HUFFMAN_EMIT_SYMBOL, 245), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 245), + (3, HUFFMAN_EMIT_SYMBOL, 246), + (6, HUFFMAN_EMIT_SYMBOL, 246), + (10, HUFFMAN_EMIT_SYMBOL, 246), + (15, HUFFMAN_EMIT_SYMBOL, 246), + (24, HUFFMAN_EMIT_SYMBOL, 246), + (31, HUFFMAN_EMIT_SYMBOL, 246), + (41, HUFFMAN_EMIT_SYMBOL, 246), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 246), + + # Node 219 + (3, HUFFMAN_EMIT_SYMBOL, 247), + (6, HUFFMAN_EMIT_SYMBOL, 247), + (10, HUFFMAN_EMIT_SYMBOL, 247), + (15, HUFFMAN_EMIT_SYMBOL, 247), + (24, HUFFMAN_EMIT_SYMBOL, 247), + (31, HUFFMAN_EMIT_SYMBOL, 247), + (41, HUFFMAN_EMIT_SYMBOL, 247), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 247), + (3, HUFFMAN_EMIT_SYMBOL, 248), + (6, HUFFMAN_EMIT_SYMBOL, 248), + (10, HUFFMAN_EMIT_SYMBOL, 248), + (15, HUFFMAN_EMIT_SYMBOL, 248), + (24, HUFFMAN_EMIT_SYMBOL, 248), + (31, HUFFMAN_EMIT_SYMBOL, 248), + (41, HUFFMAN_EMIT_SYMBOL, 248), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 248), + + # Node 220 + (2, HUFFMAN_EMIT_SYMBOL, 250), + (9, HUFFMAN_EMIT_SYMBOL, 250), + (23, HUFFMAN_EMIT_SYMBOL, 250), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 250), + (2, HUFFMAN_EMIT_SYMBOL, 251), + (9, HUFFMAN_EMIT_SYMBOL, 251), + (23, HUFFMAN_EMIT_SYMBOL, 251), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 251), + (2, HUFFMAN_EMIT_SYMBOL, 252), + (9, HUFFMAN_EMIT_SYMBOL, 252), + (23, HUFFMAN_EMIT_SYMBOL, 252), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 252), + (2, HUFFMAN_EMIT_SYMBOL, 253), + (9, HUFFMAN_EMIT_SYMBOL, 253), + (23, HUFFMAN_EMIT_SYMBOL, 253), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 253), + + # Node 221 + (3, HUFFMAN_EMIT_SYMBOL, 250), + (6, HUFFMAN_EMIT_SYMBOL, 250), + (10, HUFFMAN_EMIT_SYMBOL, 250), + (15, HUFFMAN_EMIT_SYMBOL, 250), + (24, HUFFMAN_EMIT_SYMBOL, 250), + (31, HUFFMAN_EMIT_SYMBOL, 250), + (41, HUFFMAN_EMIT_SYMBOL, 250), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 250), + (3, HUFFMAN_EMIT_SYMBOL, 251), + (6, HUFFMAN_EMIT_SYMBOL, 251), + (10, HUFFMAN_EMIT_SYMBOL, 251), + (15, HUFFMAN_EMIT_SYMBOL, 251), + (24, HUFFMAN_EMIT_SYMBOL, 251), + (31, HUFFMAN_EMIT_SYMBOL, 251), + (41, HUFFMAN_EMIT_SYMBOL, 251), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 251), + + # Node 222 + (3, HUFFMAN_EMIT_SYMBOL, 252), + (6, HUFFMAN_EMIT_SYMBOL, 252), + (10, HUFFMAN_EMIT_SYMBOL, 252), + (15, HUFFMAN_EMIT_SYMBOL, 252), + (24, HUFFMAN_EMIT_SYMBOL, 252), + (31, HUFFMAN_EMIT_SYMBOL, 252), + (41, HUFFMAN_EMIT_SYMBOL, 252), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 252), + (3, HUFFMAN_EMIT_SYMBOL, 253), + (6, HUFFMAN_EMIT_SYMBOL, 253), + (10, HUFFMAN_EMIT_SYMBOL, 253), + (15, HUFFMAN_EMIT_SYMBOL, 253), + (24, HUFFMAN_EMIT_SYMBOL, 253), + (31, HUFFMAN_EMIT_SYMBOL, 253), + (41, HUFFMAN_EMIT_SYMBOL, 253), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 253), + + # Node 223 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 254), + (227, 0, 0), + (229, 0, 0), + (230, 0, 0), + (233, 0, 0), + (234, 0, 0), + (236, 0, 0), + (237, 0, 0), + (241, 0, 0), + (242, 0, 0), + (244, 0, 0), + (245, 0, 0), + (248, 0, 0), + (249, 0, 0), + (251, 0, 0), + (252, 0, 0), + + # Node 224 + (1, HUFFMAN_EMIT_SYMBOL, 254), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 254), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 2), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 3), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 4), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 5), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 6), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 7), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 8), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 11), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 12), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 14), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 15), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 16), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 17), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 18), + + # Node 225 + (2, HUFFMAN_EMIT_SYMBOL, 254), + (9, HUFFMAN_EMIT_SYMBOL, 254), + (23, HUFFMAN_EMIT_SYMBOL, 254), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 254), + (1, HUFFMAN_EMIT_SYMBOL, 2), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 2), + (1, HUFFMAN_EMIT_SYMBOL, 3), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 3), + (1, HUFFMAN_EMIT_SYMBOL, 4), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 4), + (1, HUFFMAN_EMIT_SYMBOL, 5), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 5), + (1, HUFFMAN_EMIT_SYMBOL, 6), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 6), + (1, HUFFMAN_EMIT_SYMBOL, 7), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 7), + + # Node 226 + (3, HUFFMAN_EMIT_SYMBOL, 254), + (6, HUFFMAN_EMIT_SYMBOL, 254), + (10, HUFFMAN_EMIT_SYMBOL, 254), + (15, HUFFMAN_EMIT_SYMBOL, 254), + (24, HUFFMAN_EMIT_SYMBOL, 254), + (31, HUFFMAN_EMIT_SYMBOL, 254), + (41, HUFFMAN_EMIT_SYMBOL, 254), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 254), + (2, HUFFMAN_EMIT_SYMBOL, 2), + (9, HUFFMAN_EMIT_SYMBOL, 2), + (23, HUFFMAN_EMIT_SYMBOL, 2), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 2), + (2, HUFFMAN_EMIT_SYMBOL, 3), + (9, HUFFMAN_EMIT_SYMBOL, 3), + (23, HUFFMAN_EMIT_SYMBOL, 3), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 3), + + # Node 227 + (3, HUFFMAN_EMIT_SYMBOL, 2), + (6, HUFFMAN_EMIT_SYMBOL, 2), + (10, HUFFMAN_EMIT_SYMBOL, 2), + (15, HUFFMAN_EMIT_SYMBOL, 2), + (24, HUFFMAN_EMIT_SYMBOL, 2), + (31, HUFFMAN_EMIT_SYMBOL, 2), + (41, HUFFMAN_EMIT_SYMBOL, 2), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 2), + (3, HUFFMAN_EMIT_SYMBOL, 3), + (6, HUFFMAN_EMIT_SYMBOL, 3), + (10, HUFFMAN_EMIT_SYMBOL, 3), + (15, HUFFMAN_EMIT_SYMBOL, 3), + (24, HUFFMAN_EMIT_SYMBOL, 3), + (31, HUFFMAN_EMIT_SYMBOL, 3), + (41, HUFFMAN_EMIT_SYMBOL, 3), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 3), + + # Node 228 + (2, HUFFMAN_EMIT_SYMBOL, 4), + (9, HUFFMAN_EMIT_SYMBOL, 4), + (23, HUFFMAN_EMIT_SYMBOL, 4), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 4), + (2, HUFFMAN_EMIT_SYMBOL, 5), + (9, HUFFMAN_EMIT_SYMBOL, 5), + (23, HUFFMAN_EMIT_SYMBOL, 5), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 5), + (2, HUFFMAN_EMIT_SYMBOL, 6), + (9, HUFFMAN_EMIT_SYMBOL, 6), + (23, HUFFMAN_EMIT_SYMBOL, 6), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 6), + (2, HUFFMAN_EMIT_SYMBOL, 7), + (9, HUFFMAN_EMIT_SYMBOL, 7), + (23, HUFFMAN_EMIT_SYMBOL, 7), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 7), + + # Node 229 + (3, HUFFMAN_EMIT_SYMBOL, 4), + (6, HUFFMAN_EMIT_SYMBOL, 4), + (10, HUFFMAN_EMIT_SYMBOL, 4), + (15, HUFFMAN_EMIT_SYMBOL, 4), + (24, HUFFMAN_EMIT_SYMBOL, 4), + (31, HUFFMAN_EMIT_SYMBOL, 4), + (41, HUFFMAN_EMIT_SYMBOL, 4), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 4), + (3, HUFFMAN_EMIT_SYMBOL, 5), + (6, HUFFMAN_EMIT_SYMBOL, 5), + (10, HUFFMAN_EMIT_SYMBOL, 5), + (15, HUFFMAN_EMIT_SYMBOL, 5), + (24, HUFFMAN_EMIT_SYMBOL, 5), + (31, HUFFMAN_EMIT_SYMBOL, 5), + (41, HUFFMAN_EMIT_SYMBOL, 5), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 5), + + # Node 230 + (3, HUFFMAN_EMIT_SYMBOL, 6), + (6, HUFFMAN_EMIT_SYMBOL, 6), + (10, HUFFMAN_EMIT_SYMBOL, 6), + (15, HUFFMAN_EMIT_SYMBOL, 6), + (24, HUFFMAN_EMIT_SYMBOL, 6), + (31, HUFFMAN_EMIT_SYMBOL, 6), + (41, HUFFMAN_EMIT_SYMBOL, 6), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 6), + (3, HUFFMAN_EMIT_SYMBOL, 7), + (6, HUFFMAN_EMIT_SYMBOL, 7), + (10, HUFFMAN_EMIT_SYMBOL, 7), + (15, HUFFMAN_EMIT_SYMBOL, 7), + (24, HUFFMAN_EMIT_SYMBOL, 7), + (31, HUFFMAN_EMIT_SYMBOL, 7), + (41, HUFFMAN_EMIT_SYMBOL, 7), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 7), + + # Node 231 + (1, HUFFMAN_EMIT_SYMBOL, 8), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 8), + (1, HUFFMAN_EMIT_SYMBOL, 11), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 11), + (1, HUFFMAN_EMIT_SYMBOL, 12), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 12), + (1, HUFFMAN_EMIT_SYMBOL, 14), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 14), + (1, HUFFMAN_EMIT_SYMBOL, 15), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 15), + (1, HUFFMAN_EMIT_SYMBOL, 16), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 16), + (1, HUFFMAN_EMIT_SYMBOL, 17), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 17), + (1, HUFFMAN_EMIT_SYMBOL, 18), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 18), + + # Node 232 + (2, HUFFMAN_EMIT_SYMBOL, 8), + (9, HUFFMAN_EMIT_SYMBOL, 8), + (23, HUFFMAN_EMIT_SYMBOL, 8), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 8), + (2, HUFFMAN_EMIT_SYMBOL, 11), + (9, HUFFMAN_EMIT_SYMBOL, 11), + (23, HUFFMAN_EMIT_SYMBOL, 11), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 11), + (2, HUFFMAN_EMIT_SYMBOL, 12), + (9, HUFFMAN_EMIT_SYMBOL, 12), + (23, HUFFMAN_EMIT_SYMBOL, 12), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 12), + (2, HUFFMAN_EMIT_SYMBOL, 14), + (9, HUFFMAN_EMIT_SYMBOL, 14), + (23, HUFFMAN_EMIT_SYMBOL, 14), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 14), + + # Node 233 + (3, HUFFMAN_EMIT_SYMBOL, 8), + (6, HUFFMAN_EMIT_SYMBOL, 8), + (10, HUFFMAN_EMIT_SYMBOL, 8), + (15, HUFFMAN_EMIT_SYMBOL, 8), + (24, HUFFMAN_EMIT_SYMBOL, 8), + (31, HUFFMAN_EMIT_SYMBOL, 8), + (41, HUFFMAN_EMIT_SYMBOL, 8), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 8), + (3, HUFFMAN_EMIT_SYMBOL, 11), + (6, HUFFMAN_EMIT_SYMBOL, 11), + (10, HUFFMAN_EMIT_SYMBOL, 11), + (15, HUFFMAN_EMIT_SYMBOL, 11), + (24, HUFFMAN_EMIT_SYMBOL, 11), + (31, HUFFMAN_EMIT_SYMBOL, 11), + (41, HUFFMAN_EMIT_SYMBOL, 11), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 11), + + # Node 234 + (3, HUFFMAN_EMIT_SYMBOL, 12), + (6, HUFFMAN_EMIT_SYMBOL, 12), + (10, HUFFMAN_EMIT_SYMBOL, 12), + (15, HUFFMAN_EMIT_SYMBOL, 12), + (24, HUFFMAN_EMIT_SYMBOL, 12), + (31, HUFFMAN_EMIT_SYMBOL, 12), + (41, HUFFMAN_EMIT_SYMBOL, 12), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 12), + (3, HUFFMAN_EMIT_SYMBOL, 14), + (6, HUFFMAN_EMIT_SYMBOL, 14), + (10, HUFFMAN_EMIT_SYMBOL, 14), + (15, HUFFMAN_EMIT_SYMBOL, 14), + (24, HUFFMAN_EMIT_SYMBOL, 14), + (31, HUFFMAN_EMIT_SYMBOL, 14), + (41, HUFFMAN_EMIT_SYMBOL, 14), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 14), + + # Node 235 + (2, HUFFMAN_EMIT_SYMBOL, 15), + (9, HUFFMAN_EMIT_SYMBOL, 15), + (23, HUFFMAN_EMIT_SYMBOL, 15), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 15), + (2, HUFFMAN_EMIT_SYMBOL, 16), + (9, HUFFMAN_EMIT_SYMBOL, 16), + (23, HUFFMAN_EMIT_SYMBOL, 16), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 16), + (2, HUFFMAN_EMIT_SYMBOL, 17), + (9, HUFFMAN_EMIT_SYMBOL, 17), + (23, HUFFMAN_EMIT_SYMBOL, 17), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 17), + (2, HUFFMAN_EMIT_SYMBOL, 18), + (9, HUFFMAN_EMIT_SYMBOL, 18), + (23, HUFFMAN_EMIT_SYMBOL, 18), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 18), + + # Node 236 + (3, HUFFMAN_EMIT_SYMBOL, 15), + (6, HUFFMAN_EMIT_SYMBOL, 15), + (10, HUFFMAN_EMIT_SYMBOL, 15), + (15, HUFFMAN_EMIT_SYMBOL, 15), + (24, HUFFMAN_EMIT_SYMBOL, 15), + (31, HUFFMAN_EMIT_SYMBOL, 15), + (41, HUFFMAN_EMIT_SYMBOL, 15), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 15), + (3, HUFFMAN_EMIT_SYMBOL, 16), + (6, HUFFMAN_EMIT_SYMBOL, 16), + (10, HUFFMAN_EMIT_SYMBOL, 16), + (15, HUFFMAN_EMIT_SYMBOL, 16), + (24, HUFFMAN_EMIT_SYMBOL, 16), + (31, HUFFMAN_EMIT_SYMBOL, 16), + (41, HUFFMAN_EMIT_SYMBOL, 16), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 16), + + # Node 237 + (3, HUFFMAN_EMIT_SYMBOL, 17), + (6, HUFFMAN_EMIT_SYMBOL, 17), + (10, HUFFMAN_EMIT_SYMBOL, 17), + (15, HUFFMAN_EMIT_SYMBOL, 17), + (24, HUFFMAN_EMIT_SYMBOL, 17), + (31, HUFFMAN_EMIT_SYMBOL, 17), + (41, HUFFMAN_EMIT_SYMBOL, 17), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 17), + (3, HUFFMAN_EMIT_SYMBOL, 18), + (6, HUFFMAN_EMIT_SYMBOL, 18), + (10, HUFFMAN_EMIT_SYMBOL, 18), + (15, HUFFMAN_EMIT_SYMBOL, 18), + (24, HUFFMAN_EMIT_SYMBOL, 18), + (31, HUFFMAN_EMIT_SYMBOL, 18), + (41, HUFFMAN_EMIT_SYMBOL, 18), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 18), + + # Node 238 + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 19), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 20), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 21), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 23), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 24), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 25), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 26), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 27), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 28), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 29), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 30), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 31), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 127), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 220), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 249), + (253, 0, 0), + + # Node 239 + (1, HUFFMAN_EMIT_SYMBOL, 19), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 19), + (1, HUFFMAN_EMIT_SYMBOL, 20), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 20), + (1, HUFFMAN_EMIT_SYMBOL, 21), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 21), + (1, HUFFMAN_EMIT_SYMBOL, 23), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 23), + (1, HUFFMAN_EMIT_SYMBOL, 24), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 24), + (1, HUFFMAN_EMIT_SYMBOL, 25), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 25), + (1, HUFFMAN_EMIT_SYMBOL, 26), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 26), + (1, HUFFMAN_EMIT_SYMBOL, 27), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 27), + + # Node 240 + (2, HUFFMAN_EMIT_SYMBOL, 19), + (9, HUFFMAN_EMIT_SYMBOL, 19), + (23, HUFFMAN_EMIT_SYMBOL, 19), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 19), + (2, HUFFMAN_EMIT_SYMBOL, 20), + (9, HUFFMAN_EMIT_SYMBOL, 20), + (23, HUFFMAN_EMIT_SYMBOL, 20), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 20), + (2, HUFFMAN_EMIT_SYMBOL, 21), + (9, HUFFMAN_EMIT_SYMBOL, 21), + (23, HUFFMAN_EMIT_SYMBOL, 21), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 21), + (2, HUFFMAN_EMIT_SYMBOL, 23), + (9, HUFFMAN_EMIT_SYMBOL, 23), + (23, HUFFMAN_EMIT_SYMBOL, 23), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 23), + + # Node 241 + (3, HUFFMAN_EMIT_SYMBOL, 19), + (6, HUFFMAN_EMIT_SYMBOL, 19), + (10, HUFFMAN_EMIT_SYMBOL, 19), + (15, HUFFMAN_EMIT_SYMBOL, 19), + (24, HUFFMAN_EMIT_SYMBOL, 19), + (31, HUFFMAN_EMIT_SYMBOL, 19), + (41, HUFFMAN_EMIT_SYMBOL, 19), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 19), + (3, HUFFMAN_EMIT_SYMBOL, 20), + (6, HUFFMAN_EMIT_SYMBOL, 20), + (10, HUFFMAN_EMIT_SYMBOL, 20), + (15, HUFFMAN_EMIT_SYMBOL, 20), + (24, HUFFMAN_EMIT_SYMBOL, 20), + (31, HUFFMAN_EMIT_SYMBOL, 20), + (41, HUFFMAN_EMIT_SYMBOL, 20), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 20), + + # Node 242 + (3, HUFFMAN_EMIT_SYMBOL, 21), + (6, HUFFMAN_EMIT_SYMBOL, 21), + (10, HUFFMAN_EMIT_SYMBOL, 21), + (15, HUFFMAN_EMIT_SYMBOL, 21), + (24, HUFFMAN_EMIT_SYMBOL, 21), + (31, HUFFMAN_EMIT_SYMBOL, 21), + (41, HUFFMAN_EMIT_SYMBOL, 21), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 21), + (3, HUFFMAN_EMIT_SYMBOL, 23), + (6, HUFFMAN_EMIT_SYMBOL, 23), + (10, HUFFMAN_EMIT_SYMBOL, 23), + (15, HUFFMAN_EMIT_SYMBOL, 23), + (24, HUFFMAN_EMIT_SYMBOL, 23), + (31, HUFFMAN_EMIT_SYMBOL, 23), + (41, HUFFMAN_EMIT_SYMBOL, 23), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 23), + + # Node 243 + (2, HUFFMAN_EMIT_SYMBOL, 24), + (9, HUFFMAN_EMIT_SYMBOL, 24), + (23, HUFFMAN_EMIT_SYMBOL, 24), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 24), + (2, HUFFMAN_EMIT_SYMBOL, 25), + (9, HUFFMAN_EMIT_SYMBOL, 25), + (23, HUFFMAN_EMIT_SYMBOL, 25), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 25), + (2, HUFFMAN_EMIT_SYMBOL, 26), + (9, HUFFMAN_EMIT_SYMBOL, 26), + (23, HUFFMAN_EMIT_SYMBOL, 26), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 26), + (2, HUFFMAN_EMIT_SYMBOL, 27), + (9, HUFFMAN_EMIT_SYMBOL, 27), + (23, HUFFMAN_EMIT_SYMBOL, 27), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 27), + + # Node 244 + (3, HUFFMAN_EMIT_SYMBOL, 24), + (6, HUFFMAN_EMIT_SYMBOL, 24), + (10, HUFFMAN_EMIT_SYMBOL, 24), + (15, HUFFMAN_EMIT_SYMBOL, 24), + (24, HUFFMAN_EMIT_SYMBOL, 24), + (31, HUFFMAN_EMIT_SYMBOL, 24), + (41, HUFFMAN_EMIT_SYMBOL, 24), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 24), + (3, HUFFMAN_EMIT_SYMBOL, 25), + (6, HUFFMAN_EMIT_SYMBOL, 25), + (10, HUFFMAN_EMIT_SYMBOL, 25), + (15, HUFFMAN_EMIT_SYMBOL, 25), + (24, HUFFMAN_EMIT_SYMBOL, 25), + (31, HUFFMAN_EMIT_SYMBOL, 25), + (41, HUFFMAN_EMIT_SYMBOL, 25), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 25), + + # Node 245 + (3, HUFFMAN_EMIT_SYMBOL, 26), + (6, HUFFMAN_EMIT_SYMBOL, 26), + (10, HUFFMAN_EMIT_SYMBOL, 26), + (15, HUFFMAN_EMIT_SYMBOL, 26), + (24, HUFFMAN_EMIT_SYMBOL, 26), + (31, HUFFMAN_EMIT_SYMBOL, 26), + (41, HUFFMAN_EMIT_SYMBOL, 26), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 26), + (3, HUFFMAN_EMIT_SYMBOL, 27), + (6, HUFFMAN_EMIT_SYMBOL, 27), + (10, HUFFMAN_EMIT_SYMBOL, 27), + (15, HUFFMAN_EMIT_SYMBOL, 27), + (24, HUFFMAN_EMIT_SYMBOL, 27), + (31, HUFFMAN_EMIT_SYMBOL, 27), + (41, HUFFMAN_EMIT_SYMBOL, 27), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 27), + + # Node 246 + (1, HUFFMAN_EMIT_SYMBOL, 28), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 28), + (1, HUFFMAN_EMIT_SYMBOL, 29), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 29), + (1, HUFFMAN_EMIT_SYMBOL, 30), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 30), + (1, HUFFMAN_EMIT_SYMBOL, 31), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 31), + (1, HUFFMAN_EMIT_SYMBOL, 127), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 127), + (1, HUFFMAN_EMIT_SYMBOL, 220), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 220), + (1, HUFFMAN_EMIT_SYMBOL, 249), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 249), + (254, 0, 0), + (255, 0, 0), + + # Node 247 + (2, HUFFMAN_EMIT_SYMBOL, 28), + (9, HUFFMAN_EMIT_SYMBOL, 28), + (23, HUFFMAN_EMIT_SYMBOL, 28), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 28), + (2, HUFFMAN_EMIT_SYMBOL, 29), + (9, HUFFMAN_EMIT_SYMBOL, 29), + (23, HUFFMAN_EMIT_SYMBOL, 29), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 29), + (2, HUFFMAN_EMIT_SYMBOL, 30), + (9, HUFFMAN_EMIT_SYMBOL, 30), + (23, HUFFMAN_EMIT_SYMBOL, 30), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 30), + (2, HUFFMAN_EMIT_SYMBOL, 31), + (9, HUFFMAN_EMIT_SYMBOL, 31), + (23, HUFFMAN_EMIT_SYMBOL, 31), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 31), + + # Node 248 + (3, HUFFMAN_EMIT_SYMBOL, 28), + (6, HUFFMAN_EMIT_SYMBOL, 28), + (10, HUFFMAN_EMIT_SYMBOL, 28), + (15, HUFFMAN_EMIT_SYMBOL, 28), + (24, HUFFMAN_EMIT_SYMBOL, 28), + (31, HUFFMAN_EMIT_SYMBOL, 28), + (41, HUFFMAN_EMIT_SYMBOL, 28), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 28), + (3, HUFFMAN_EMIT_SYMBOL, 29), + (6, HUFFMAN_EMIT_SYMBOL, 29), + (10, HUFFMAN_EMIT_SYMBOL, 29), + (15, HUFFMAN_EMIT_SYMBOL, 29), + (24, HUFFMAN_EMIT_SYMBOL, 29), + (31, HUFFMAN_EMIT_SYMBOL, 29), + (41, HUFFMAN_EMIT_SYMBOL, 29), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 29), + + # Node 249 + (3, HUFFMAN_EMIT_SYMBOL, 30), + (6, HUFFMAN_EMIT_SYMBOL, 30), + (10, HUFFMAN_EMIT_SYMBOL, 30), + (15, HUFFMAN_EMIT_SYMBOL, 30), + (24, HUFFMAN_EMIT_SYMBOL, 30), + (31, HUFFMAN_EMIT_SYMBOL, 30), + (41, HUFFMAN_EMIT_SYMBOL, 30), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 30), + (3, HUFFMAN_EMIT_SYMBOL, 31), + (6, HUFFMAN_EMIT_SYMBOL, 31), + (10, HUFFMAN_EMIT_SYMBOL, 31), + (15, HUFFMAN_EMIT_SYMBOL, 31), + (24, HUFFMAN_EMIT_SYMBOL, 31), + (31, HUFFMAN_EMIT_SYMBOL, 31), + (41, HUFFMAN_EMIT_SYMBOL, 31), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 31), + + # Node 250 + (2, HUFFMAN_EMIT_SYMBOL, 127), + (9, HUFFMAN_EMIT_SYMBOL, 127), + (23, HUFFMAN_EMIT_SYMBOL, 127), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 127), + (2, HUFFMAN_EMIT_SYMBOL, 220), + (9, HUFFMAN_EMIT_SYMBOL, 220), + (23, HUFFMAN_EMIT_SYMBOL, 220), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 220), + (2, HUFFMAN_EMIT_SYMBOL, 249), + (9, HUFFMAN_EMIT_SYMBOL, 249), + (23, HUFFMAN_EMIT_SYMBOL, 249), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 249), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 10), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 13), + (0, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 22), + (0, HUFFMAN_FAIL, 0), + + # Node 251 + (3, HUFFMAN_EMIT_SYMBOL, 127), + (6, HUFFMAN_EMIT_SYMBOL, 127), + (10, HUFFMAN_EMIT_SYMBOL, 127), + (15, HUFFMAN_EMIT_SYMBOL, 127), + (24, HUFFMAN_EMIT_SYMBOL, 127), + (31, HUFFMAN_EMIT_SYMBOL, 127), + (41, HUFFMAN_EMIT_SYMBOL, 127), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 127), + (3, HUFFMAN_EMIT_SYMBOL, 220), + (6, HUFFMAN_EMIT_SYMBOL, 220), + (10, HUFFMAN_EMIT_SYMBOL, 220), + (15, HUFFMAN_EMIT_SYMBOL, 220), + (24, HUFFMAN_EMIT_SYMBOL, 220), + (31, HUFFMAN_EMIT_SYMBOL, 220), + (41, HUFFMAN_EMIT_SYMBOL, 220), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 220), + + # Node 252 + (3, HUFFMAN_EMIT_SYMBOL, 249), + (6, HUFFMAN_EMIT_SYMBOL, 249), + (10, HUFFMAN_EMIT_SYMBOL, 249), + (15, HUFFMAN_EMIT_SYMBOL, 249), + (24, HUFFMAN_EMIT_SYMBOL, 249), + (31, HUFFMAN_EMIT_SYMBOL, 249), + (41, HUFFMAN_EMIT_SYMBOL, 249), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 249), + (1, HUFFMAN_EMIT_SYMBOL, 10), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 10), + (1, HUFFMAN_EMIT_SYMBOL, 13), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 13), + (1, HUFFMAN_EMIT_SYMBOL, 22), + (22, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 22), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + + # Node 253 + (2, HUFFMAN_EMIT_SYMBOL, 10), + (9, HUFFMAN_EMIT_SYMBOL, 10), + (23, HUFFMAN_EMIT_SYMBOL, 10), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 10), + (2, HUFFMAN_EMIT_SYMBOL, 13), + (9, HUFFMAN_EMIT_SYMBOL, 13), + (23, HUFFMAN_EMIT_SYMBOL, 13), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 13), + (2, HUFFMAN_EMIT_SYMBOL, 22), + (9, HUFFMAN_EMIT_SYMBOL, 22), + (23, HUFFMAN_EMIT_SYMBOL, 22), + (40, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 22), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + + # Node 254 + (3, HUFFMAN_EMIT_SYMBOL, 10), + (6, HUFFMAN_EMIT_SYMBOL, 10), + (10, HUFFMAN_EMIT_SYMBOL, 10), + (15, HUFFMAN_EMIT_SYMBOL, 10), + (24, HUFFMAN_EMIT_SYMBOL, 10), + (31, HUFFMAN_EMIT_SYMBOL, 10), + (41, HUFFMAN_EMIT_SYMBOL, 10), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 10), + (3, HUFFMAN_EMIT_SYMBOL, 13), + (6, HUFFMAN_EMIT_SYMBOL, 13), + (10, HUFFMAN_EMIT_SYMBOL, 13), + (15, HUFFMAN_EMIT_SYMBOL, 13), + (24, HUFFMAN_EMIT_SYMBOL, 13), + (31, HUFFMAN_EMIT_SYMBOL, 13), + (41, HUFFMAN_EMIT_SYMBOL, 13), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 13), + + # Node 255 + (3, HUFFMAN_EMIT_SYMBOL, 22), + (6, HUFFMAN_EMIT_SYMBOL, 22), + (10, HUFFMAN_EMIT_SYMBOL, 22), + (15, HUFFMAN_EMIT_SYMBOL, 22), + (24, HUFFMAN_EMIT_SYMBOL, 22), + (31, HUFFMAN_EMIT_SYMBOL, 22), + (41, HUFFMAN_EMIT_SYMBOL, 22), + (56, HUFFMAN_COMPLETE | HUFFMAN_EMIT_SYMBOL, 22), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), + (0, HUFFMAN_FAIL, 0), +] diff --git a/hyperscale/core/engines/types/common/hpack/structs.py b/hyperscale/core/engines/types/common/hpack/structs.py new file mode 100644 index 0000000..fcab929 --- /dev/null +++ b/hyperscale/core/engines/types/common/hpack/structs.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" +hpack/struct +~~~~~~~~~~~~ + +Contains structures for representing header fields with associated metadata. +""" + + +class HeaderTuple(tuple): + """ + A data structure that stores a single header field. + + HTTP headers can be thought of as tuples of ``(field name, field value)``. + A single header block is a sequence of such tuples. + + In HTTP/2, however, certain bits of additional information are required for + compressing these headers: in particular, whether the header field can be + safely added to the HPACK compression context. + + This class stores a header that can be added to the compression context. In + all other ways it behaves exactly like a tuple. + """ + __slots__ = () + + indexable = True + + def __new__(cls, *args): + return tuple.__new__(cls, args) + + +class NeverIndexedHeaderTuple(HeaderTuple): + """ + A data structure that stores a single header field that cannot be added to + a HTTP/2 header compression context. + """ + __slots__ = () + + indexable = False diff --git a/hyperscale/core/engines/types/common/hpack/table.py b/hyperscale/core/engines/types/common/hpack/table.py new file mode 100644 index 0000000..0f0c64d --- /dev/null +++ b/hyperscale/core/engines/types/common/hpack/table.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +from typing import Dict +from collections import deque +from functools import lru_cache +from .exceptions import InvalidTableIndex + + +@lru_cache(maxsize=4096) +def table_entry_size(name, value): + """ + Calculates the size of a single entry + + This size is mostly irrelevant to us and defined + specifically to accommodate memory management for + lower level implementations. The 32 extra bytes are + considered the "maximum" overhead that would be + required to represent each entry in the table. + + See RFC7541 Section 4.1 + """ + return 32 + len(name) + len(value) + + +STATIC_TABLE = ( + (b':authority' , b'' ), # noqa + (b':method' , b'GET' ), # noqa + (b':method' , b'POST' ), # noqa + (b':path' , b'/' ), # noqa + (b':path' , b'/index.html' ), # noqa + (b':scheme' , b'http' ), # noqa + (b':scheme' , b'https' ), # noqa + (b':status' , b'200' ), # noqa + (b':status' , b'204' ), # noqa + (b':status' , b'206' ), # noqa + (b':status' , b'304' ), # noqa + (b':status' , b'400' ), # noqa + (b':status' , b'404' ), # noqa + (b':status' , b'500' ), # noqa + (b'accept-charset' , b'' ), # noqa + (b'accept-encoding' , b'gzip, deflate'), # noqa + (b'accept-language' , b'' ), # noqa + (b'accept-ranges' , b'' ), # noqa + (b'accept' , b'' ), # noqa + (b'access-control-allow-origin' , b'' ), # noqa + (b'age' , b'' ), # noqa + (b'allow' , b'' ), # noqa + (b'authorization' , b'' ), # noqa + (b'cache-control' , b'' ), # noqa + (b'content-disposition' , b'' ), # noqa + (b'content-encoding' , b'' ), # noqa + (b'content-language' , b'' ), # noqa + (b'content-length' , b'' ), # noqa + (b'content-location' , b'' ), # noqa + (b'content-range' , b'' ), # noqa + (b'content-type' , b'' ), # noqa + (b'cookie' , b'' ), # noqa + (b'date' , b'' ), # noqa + (b'etag' , b'' ), # noqa + (b'expect' , b'' ), # noqa + (b'expires' , b'' ), # noqa + (b'from' , b'' ), # noqa + (b'host' , b'' ), # noqa + (b'if-match' , b'' ), # noqa + (b'if-modified-since' , b'' ), # noqa + (b'if-none-match' , b'' ), # noqa + (b'if-range' , b'' ), # noqa + (b'if-unmodified-since' , b'' ), # noqa + (b'last-modified' , b'' ), # noqa + (b'link' , b'' ), # noqa + (b'location' , b'' ), # noqa + (b'max-forwards' , b'' ), # noqa + (b'proxy-authenticate' , b'' ), # noqa + (b'proxy-authorization' , b'' ), # noqa + (b'range' , b'' ), # noqa + (b'referer' , b'' ), # noqa + (b'refresh' , b'' ), # noqa + (b'retry-after' , b'' ), # noqa + (b'server' , b'' ), # noqa + (b'set-cookie' , b'' ), # noqa + (b'strict-transport-security' , b'' ), # noqa + (b'transfer-encoding' , b'' ), # noqa + (b'user-agent' , b'' ), # noqa + (b'vary' , b'' ), # noqa + (b'via' , b'' ), # noqa + (b'www-authenticate' , b'' ), # noqa + ) # noqa + +def _build_static_table_mapping() -> Dict[bytes, bytes]: + """ + Build static table mapping from header name to tuple with next structure: + (, ). + + static_table_mapping used for hash searching. + """ + static_table_mapping = {} + for index, (name, value) in enumerate(STATIC_TABLE, 1): + header_name_search_result = static_table_mapping.setdefault(name, (index, {})) + header_name_search_result[1][value] = index + return static_table_mapping + + +STATIC_TABLE_LENGTH = len(STATIC_TABLE) +STATIC_TABLE_MAPPING = _build_static_table_mapping() + +class HeaderTable: + """ + Implements the combined static and dynamic header table + + The name and value arguments for all the functions + should ONLY be byte strings (b'') however this is not + strictly enforced in the interface. + + See RFC7541 Section 2.3 + """ + #: Default maximum size of the dynamic table. See + #: RFC7540 Section 6.5.2. + DEFAULT_SIZE = 4096 + + #: Constant list of static headers. See RFC7541 Section + #: 2.3.1 and Appendix A + + + + def __init__(self): + self._maxsize = HeaderTable.DEFAULT_SIZE + self._current_size = 0 + self.resized = False + self.dynamic_entries = deque() + + @lru_cache(maxsize=4096) + def get_by_index(self, index): + """ + Returns the entry specified by index + + Note that the table is 1-based ie an index of 0 is + invalid. This is due to the fact that a zero value + index signals that a completely unindexed header + follows. + + The entry will either be from the static table or + the dynamic table depending on the value of index. + """ + index -= 1 + if index < STATIC_TABLE_LENGTH: + return STATIC_TABLE[index] + + index -= STATIC_TABLE_LENGTH + if index < len(self.dynamic_entries): + return self.dynamic_entries[index] + + def add(self, name, value): + """ + Adds a new entry to the table + + We reduce the table size if the entry will make the + table size greater than maxsize. + """ + # We just clear the table if the entry is too big + size = table_entry_size(name, value) + if size > self._maxsize: + self.dynamic_entries.clear() + self._current_size = 0 + else: + # Add new entry + self.dynamic_entries.appendleft((name, value)) + self._current_size += size + self._shrink() + + def search(self, name, value): + """ + Searches the table for the entry specified by name + and value + + Returns one of the following: + - ``None``, no match at all + - ``(index, name, None)`` for partial matches on name only. + - ``(index, name, value)`` for perfect matches. + """ + partial = None + + header_name_search_result = STATIC_TABLE_MAPPING.get(name) + if header_name_search_result: + index = header_name_search_result[1].get(value) + if index is not None: + return index, name, value + else: + partial = (header_name_search_result[0], name, None) + + offset = STATIC_TABLE_LENGTH + 1 + for (i, (n, v)) in enumerate(self.dynamic_entries): + if n == name: + if v == value: + return i + offset, n, v + elif partial is None: + partial = (i + offset, n, None) + return partial + + @property + def maxsize(self): + return self._maxsize + + @maxsize.setter + def maxsize(self, newmax): + newmax = int(newmax) + oldmax = self._maxsize + self._maxsize = newmax + self.resized = (newmax != oldmax) + if newmax <= 0: + self.dynamic_entries.clear() + self._current_size = 0 + elif oldmax > newmax: + self._shrink() + + def _shrink(self): + """ + Shrinks the dynamic table to be at or below maxsize + """ + cursize = self._current_size + while cursize > self._maxsize: + name, value = self.dynamic_entries.pop() + cursize -= table_entry_size(name, value) + self._current_size = cursize + diff --git a/hyperscale/core/engines/types/common/metadata.py b/hyperscale/core/engines/types/common/metadata.py new file mode 100644 index 0000000..3c9a3e8 --- /dev/null +++ b/hyperscale/core/engines/types/common/metadata.py @@ -0,0 +1,28 @@ +from typing import Dict, List, Optional + + +class Metadata: + + __slots__ = ( + 'user', + 'tags' + ) + + def __init__(self, user: Optional[str] = None, tags: List[Dict[str, str]] = []) -> None: + self.user = user + self.tags = tags + + def __aiter__(self): + for tag in self.tags: + yield tag + + def update_tags(self, user: str=None, tags: List[Dict[str, str]]=[]): + if user: + self.user = user + + self.tags.extend(tags) + + def tags_to_string_list(self): + return [ + f'{tag.get("name")}:{tag.get("value")}' for tag in self.tags + ] \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/__init__.py b/hyperscale/core/engines/types/common/protocols/__init__.py new file mode 100644 index 0000000..81327c8 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/__init__.py @@ -0,0 +1,3 @@ +from .tcp import TCPConnection +from .udp import UDPConnection +from .ping import PingConnection \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/ping/__init__.py b/hyperscale/core/engines/types/common/protocols/ping/__init__.py new file mode 100644 index 0000000..95c86f1 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/ping/__init__.py @@ -0,0 +1 @@ +from .ping import PingConnection \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/ping/ping.py b/hyperscale/core/engines/types/common/protocols/ping/ping.py new file mode 100644 index 0000000..66b7a2a --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/ping/ping.py @@ -0,0 +1,252 @@ +import asyncio +import sys +import socket +import struct +import time +import functools +import uuid +from typing import Tuple, Any +from .ping_type import PingType + +if sys.platform.startswith("win"): + if sys.version_info[0] > 3 or (sys.version_info[0] == 3 and sys.version_info[1] >= 8): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +# ICMP types, see rfc792 for v4, rfc4443 for v6 +ICMP_ECHO_REQUEST = 8 +ICMP6_ECHO_REQUEST = 128 +ICMP_ECHO_REPLY = 0 +ICMP6_ECHO_REPLY = 129 + + +class PingConnection: + + def __init__(self) -> None: + self.proto_icmp = socket.getprotobyname("icmp") + self.proto_icmp6 = socket.getprotobyname("ipv6-icmp") + + + def checksum(self, buffer: bytes) -> bytes: + """ + I'm not too confident that this is right but testing seems + to suggest that it gives the same answers as in_cksum in ping.c + :param buffer: + :return: + """ + sum = 0 + count_to = (len(buffer) / 2) * 2 + count = 0 + + while count < count_to: + this_val = buffer[count + 1] * 256 + buffer[count] + sum += this_val + sum &= 0xffffffff # Necessary? + count += 2 + + if count_to < len(buffer): + sum += buffer[len(buffer) - 1] + sum &= 0xffffffff # Necessary? + + sum = (sum >> 16) + (sum & 0xffff) + sum += sum >> 16 + answer = ~sum + answer &= 0xffff + + # Swap bytes. Bugger me if I know why. + answer = answer >> 8 | (answer << 8 & 0xff00) + + return answer + + async def receive( + self, + active_socket: socket.socket, + id_: bytes, + timeout: float + ) -> None: + """ + receive the ping from the socket. + :param my_socket: + :param id_: + :param timeout: + :return: + """ + loop = asyncio.get_event_loop() + + try: + start = time.monotonic() + elapsed = 0 + + while elapsed < timeout: + rec_packet = await loop.sock_recv(active_socket, 1024) + time_received = time.perf_counter() + + if active_socket.family == socket.AddressFamily.AF_INET: + offset = 20 + else: + offset = 0 + + icmp_header = rec_packet[offset:offset + 8] + + type, code, checksum, packet_id, sequence = struct.unpack( + "bbHHh", icmp_header + ) + + if type != ICMP_ECHO_REPLY and type != ICMP6_ECHO_REPLY: + continue + + if packet_id == id_: + data = rec_packet[offset + 8:offset + 8 + struct.calcsize("d")] + time_sent = struct.unpack("d", data)[0] + + return time_received - time_sent + + elapsed = time.monotonic() - start + + except asyncio.TimeoutError: + asyncio.get_event_loop().remove_writer(active_socket) + asyncio.get_event_loop().remove_reader(active_socket) + active_socket.close() + + raise TimeoutError("Ping timeout") + + + def sendto_ready( + self, + packet: bytes, + socket: socket.socket, + future: asyncio.Future, + dest: Tuple[str, ...] + ): + try: + socket.sendto(packet, dest) + except (BlockingIOError, InterruptedError): + return # The callback will be retried + except Exception as exc: + asyncio.get_event_loop().remove_writer(socket) + future.set_exception(exc) + else: + asyncio.get_event_loop().remove_writer(socket) + future.set_result(None) + + + async def send( + self, + active_socket: socket.socket, + dest_addr: Tuple[str, ...], + id_: bytes, + family: int + ): + """ + Send one ping to the given >dest_addr<. + :param my_socket: + :param dest_addr: + :param id_: + :param timeout: + :return: + """ + icmp_type = ICMP_ECHO_REQUEST if family == socket.AddressFamily.AF_INET\ + else ICMP6_ECHO_REQUEST + + # Header is type (8), code (8), checksum (16), id (16), sequence (16) + ping_checksum = 0 + + # Make a dummy header with a 0 checksum. + header = struct.pack( + "BbHHh", + icmp_type, + 0, + ping_checksum, + id_, + 1 + ) + + bytes_in_double = struct.calcsize("d") + data = (192 - bytes_in_double) * "Q" + data = struct.pack( + "d", + time.perf_counter() + ) + data.encode("ascii") + + # Calculate the checksum on the data and the dummy header. + ping_checksum = self.checksum(header + data) + + # Now that we have the right checksum, we put that in. It's just easier + # to make up a new header than to stuff it into the dummy. + header = struct.pack( + "BbHHh", + icmp_type, + 0, + socket.htons(ping_checksum), + id_, + 1 + ) + packet = header + data + + future = asyncio.get_event_loop().create_future() + + callback = functools.partial( + self.sendto_ready, + packet=packet, + socket=active_socket, + dest=dest_addr, + future=future + ) + + asyncio.get_event_loop().add_writer( + active_socket, + callback + ) + + await future + + + async def ping( + self, + socket_config: Tuple[Any, ...]=None, + ping_type: PingType=PingType.ICMP, + timeout: int=10 + ): + """ + Returns either the delay (in seconds) or raises an exception. + :param dest_addr: + :param timeout: + :param family: + """ + + family, type_, proto, _, address = socket_config + + if ping_type == PingType.ICMP: + + if family == socket.AddressFamily.AF_INET: + proto = self.proto_icmp + else: + proto = self.proto_icmp6 + + try: + my_socket = socket.socket(family=family, type=type_, proto=proto) + my_socket.setblocking(False) + + except OSError as e: + msg = e.strerror + + if e.errno == 1: + # Operation not permitted + msg += ( + " - Note that ICMP messages can only be sent from processes" + " running as root." + ) + + raise OSError(msg) + + raise + + my_id = uuid.uuid4().int & 0xFFFF + + if ping_type == PingType.ICMP: + await self.send(my_socket, address, my_id, family) + response = await self.receive(my_socket, my_id, timeout) + + my_socket.close() + + return response + diff --git a/hyperscale/core/engines/types/common/protocols/ping/ping_type.py b/hyperscale/core/engines/types/common/protocols/ping/ping_type.py new file mode 100644 index 0000000..49cba24 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/ping/ping_type.py @@ -0,0 +1,29 @@ +from enum import Enum + + +class PingType(Enum): + ICMP='ICMP' + TCP='TCP' + UDP='UDP' + + +class PingTypesMap: + + def __init__(self) -> None: + self._ping_types = { + 'icmp': PingType.ICMP, + 'tcp': PingType.TCP, + 'udp': PingType.UDP + } + + self._ping_type_name = { + PingType.ICMP: 'icmp', + PingType.TCP: 'tcp', + PingType.UDP: 'udp' + } + + def get(self, ping_type_name: str) -> PingType: + return self._ping_types.get(ping_type_name) + + def get_name(self, ping_type: PingType) -> str: + return self._ping_type_name.get(ping_type) \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/shared/__init__.py b/hyperscale/core/engines/types/common/protocols/shared/__init__.py new file mode 100644 index 0000000..a8620ca --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/shared/__init__.py @@ -0,0 +1 @@ +from .protocol import FlowControlMixin \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/shared/constants.py b/hyperscale/core/engines/types/common/protocols/shared/constants.py new file mode 100644 index 0000000..81ef67e --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/shared/constants.py @@ -0,0 +1,2 @@ +_DEFAULT_LIMIT= 65536 +_HTTP2_LIMIT = _DEFAULT_LIMIT * 1024 \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/shared/protocol.py b/hyperscale/core/engines/types/common/protocols/shared/protocol.py new file mode 100644 index 0000000..cc4638d --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/shared/protocol.py @@ -0,0 +1,75 @@ +from asyncio import ( + Future, + Protocol, + get_event_loop +) + + +class FlowControlMixin(Protocol): + """Reusable flow control logic for StreamWriter.drain(). + This implements the protocol methods pause_writing(), + resume_writing() and connection_lost(). If the subclass overrides + these it must call the super methods. + StreamWriter.drain() must wait for _drain_helper() coroutine. + """ + __slots__ = ( + '_loop', + '_paused', + '_drain_waiter', + '_connection_lost' + ) + + def __init__(self, loop=None): + + if loop: + self._loop = loop + else: + self._loop = get_event_loop() + + self._paused = False + self._drain_waiter: Future = None + self._connection_lost = False + + def pause_writing(self): + assert not self._paused + self._paused = True + + def resume_writing(self): + assert self._paused + self._paused = False + + waiter = self._drain_waiter + if waiter is not None: + self._drain_waiter = None + if not waiter.done(): + waiter.set_result(None) + + def connection_lost(self, exc): + self._connection_lost = True + # Wake up the writer if currently paused. + if not self._paused: + return + waiter = self._drain_waiter + if waiter is None: + return + self._drain_waiter = None + if waiter.done(): + return + if exc is None: + waiter.set_result(None) + else: + waiter.set_exception(exc) + + async def _drain_helper(self): + if self._connection_lost: + raise ConnectionResetError('Connection lost') + if not self._paused: + return + waiter = self._drain_waiter + assert waiter is None or waiter.cancelled() + waiter = self._loop.create_future() + self._drain_waiter = waiter + await waiter + + def _get_close_waiter(self, stream): + raise NotImplementedError \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/shared/reader.py b/hyperscale/core/engines/types/common/protocols/shared/reader.py new file mode 100644 index 0000000..10eaed4 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/shared/reader.py @@ -0,0 +1,372 @@ +from asyncio import AbstractEventLoop, Future, Transport, get_event_loop +from asyncio.exceptions import LimitOverrunError + +from .constants import _DEFAULT_LIMIT + + +class Reader: + + _source_traceback = None + __slots__ = ( + '_limit', + '_loop', + '_buffer', + '_eof', + '_waiter', + '_exception', + '_transport', + '_paused', + '__weakref__' + ) + + def __init__(self, limit=_DEFAULT_LIMIT, loop=None): + # The line length limit is a security feature; + # it also doubles as half the buffer limit. + + if limit <= 0: + raise ValueError('Limit cannot be <= 0') + + self._limit = limit + if loop is None: + self._loop = get_event_loop() + else: + self._loop: AbstractEventLoop = loop + + self._buffer = bytearray() + self._eof = False # Whether we're done. + self._waiter: Future = None # A future used by _wait_for_data() + self._exception = None + self._transport: Transport = None + self._paused = False + + def exception(self): + return self._exception + + def set_exception(self, exc): + self._exception = exc + + waiter = self._waiter + if waiter is not None: + self._waiter = None + if not waiter.cancelled(): + waiter.set_exception(exc) + + def set_transport(self, transport): + # assert self._transport is None, 'Transport already set' + self._transport = transport + + def _maybe_resume_transport(self): + if self._paused and len(self._buffer) <= self._limit: + self._paused = False + self._transport.resume_reading() + + def feed_eof(self): + self._eof = True + + waiter = self._waiter + if waiter is not None: + self._waiter = None + if not waiter.cancelled(): + waiter.set_result(None) + + def at_eof(self): + """Return True if the buffer is empty and 'feed_eof' was called.""" + return self._eof and not self._buffer + + def feed_data(self, data): + assert not self._eof, 'feed_data after feed_eof' + + if not data: + return + + self._buffer.extend(data) + + waiter = self._waiter + if waiter is not None: + self._waiter = None + if not waiter.cancelled(): + waiter.set_result(None) + + if self._transport and self._paused == False and len(self._buffer) > 2 * self._limit: + try: + self._transport.pause_reading() + except NotImplementedError: + # The transport can't be paused. + # We'll just have to buffer all data. + # Forget the transport so we don't keep trying. + self._transport = None + else: + self._paused = True + + async def _wait_for_data(self, func_name): + """Wait until feed_data() or feed_eof() is called. + If stream was paused, automatically resume it. + """ + # StreamReader uses a future to link the protocol feed_data() method + # to a read coroutine. Running two read coroutines at the same time + # would have an unexpected behaviour. It would not possible to know + # which coroutine would get the next data. + if self._waiter: + raise RuntimeError( + f'{func_name}() called while another coroutine is ' + f'already waiting for incoming data' + ) + + assert not self._eof, '_wait_for_data after EOF' + + # Waiting for data while paused will make deadlock, so prevent it. + # This is essential for readexactly(n) for case when n > self._limit. + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + async def read(self, n=-1): + + if not self._buffer and not self._eof: + await self._wait_for_data('read') + + data = bytes(self._buffer[:n]) + del self._buffer[:n] + + self._maybe_resume_transport() + return data + + async def readline_fast(self, sep=b'\n'): + seplen = len(sep) + if self._exception is not None: + raise self._exception + + if not self._buffer: + + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + isep = self._buffer.find(sep) + if isep < 0: + chunk = bytes(self._buffer) + self._buffer.clear() + return chunk + + + chunk = self._buffer[:isep + seplen] + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + return bytes(chunk) + + async def readuntil(self, separator=b'\n'): + """Read data from the stream until ``separator`` is found. + On success, the data and separator will be removed from the + internal buffer (consumed). Returned data will include the + separator at the end. + Configured stream limit is used to check result. Limit sets the + maximal length of data that can be returned, not counting the + separator. + If an EOF occurs and the complete separator is still not found, + an IncompleteReadError exception will be raised, and the internal + buffer will be reset. The IncompleteReadError.partial attribute + may contain the separator partially. + If the data cannot be read because of over limit, a + LimitOverrunError exception will be raised, and the data + will be left in the internal buffer, so it can be read again. + """ + seplen = len(separator) + + if self._exception is not None: + raise self._exception + + # Consume whole buffer except last bytes, which length is + # one less than seplen. Let's check corner cases with + # separator='SEPARATOR': + # * we have received almost complete separator (without last + # byte). i.e buffer='some textSEPARATO'. In this case we + # can safely consume len(separator) - 1 bytes. + # * last byte of buffer is first byte of separator, i.e. + # buffer='abcdefghijklmnopqrS'. We may safely consume + # everything except that last byte, but this require to + # analyze bytes of buffer that match partial separator. + # This is slow and/or require FSM. For this case our + # implementation is not optimal, since require rescanning + # of data that is known to not belong to separator. In + # real world, separator will not be so long to notice + # performance problems. Even when reading MIME-encoded + # messages :) + + # `offset` is the number of bytes from the beginning of the buffer + # where there is no occurrence of `separator`. + offset = 0 + + # Loop until we find `separator` in the buffer, exceed the buffer size, + # or an EOF has happened. + while True: + buflen = len(self._buffer) + + # Check if we now have enough data in the buffer for `separator` to + # fit. + if buflen - offset >= seplen: + isep = self._buffer.find(separator, offset) + + if isep != -1: + # `separator` is in the buffer. `isep` will be used later + # to retrieve the data. + break + + # see upper comment for explanation. + offset = buflen + 1 - seplen + if offset > self._limit: + raise LimitOverrunError( + 'Separator is not found, and chunk exceed the limit', + offset) + + # Complete message (with full separator) may be present in buffer + # even when EOF flag is set. This may happen when the last chunk + # adds data which makes separator be found. That's why we check for + # EOF *ater* inspecting the buffer. + if self._eof: + chunk = bytes(self._buffer) + self._buffer.clear() + raise Exception('Connection closed.') + + # _wait_for_data() will resume reading if stream was paused. + if not self._buffer: + await self._wait_for_data('readuntil') + + if isep > self._limit: + raise LimitOverrunError( + 'Separator is found, but chunk is longer than limit', isep) + + chunk = self._buffer[:isep + seplen] + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + return bytes(chunk) + + async def read_headers(self, separator=b'\n'): + """Read data from the stream until ``separator`` is found. + On success, the data and separator will be removed from the + internal buffer (consumed). Returned data will include the + separator at the end. + Configured stream limit is used to check result. Limit sets the + maximal length of data that can be returned, not counting the + separator. + If an EOF occurs and the complete separator is still not found, + an IncompleteReadError exception will be raised, and the internal + buffer will be reset. The IncompleteReadError.partial attribute + may contain the separator partially. + If the data cannot be read because of over limit, a + LimitOverrunError exception will be raised, and the data + will be left in the internal buffer, so it can be read again. + """ + headers = {} + seplen = len(separator) + + while True: + + if self._exception is not None: + raise self._exception + + if not self._buffer: + + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + isep = self._buffer.find(separator) + if isep < 0: + chunk = bytes(self._buffer) + self._buffer.clear() + + else: + chunk = bytes(self._buffer[:isep + seplen]) + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + + if b':' not in chunk: + break + + decoded = chunk.strip().split(b':',1) + + key, value = decoded + headers[bytes(key).lower()] = value.strip() + + return headers + + async def iter_headers(self, separator=b'\n'): + """Read data from the stream until ``separator`` is found. + On success, the data and separator will be removed from the + internal buffer (consumed). Returned data will include the + separator at the end. + Configured stream limit is used to check result. Limit sets the + maximal length of data that can be returned, not counting the + separator. + If an EOF occurs and the complete separator is still not found, + an IncompleteReadError exception will be raised, and the internal + buffer will be reset. The IncompleteReadError.partial attribute + may contain the separator partially. + If the data cannot be read because of over limit, a + LimitOverrunError exception will be raised, and the data + will be left in the internal buffer, so it can be read again. + """ + seplen = len(separator) + + while True: + + if self._exception is not None: + raise self._exception + + if not self._buffer: + + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + isep = self._buffer.find(separator) + if isep < 0: + chunk = bytes(self._buffer) + self._buffer.clear() + + else: + chunk = bytes(self._buffer[:isep + seplen]) + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + + if b':' not in chunk: + break + + decoded = chunk.strip().split(b':',1) + + key, value = decoded + yield key.lower(), value, chunk + + async def __aiter__(self): + line = await self.readuntil() + yield line + + async def __anext__(self): + val = await self.readuntil() + if val == b'': + raise StopAsyncIteration + return val diff --git a/hyperscale/core/engines/types/common/protocols/shared/writer.py b/hyperscale/core/engines/types/common/protocols/shared/writer.py new file mode 100644 index 0000000..76c86dc --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/shared/writer.py @@ -0,0 +1,113 @@ +import asyncio +import signal +from asyncio import ( + Transport, + DatagramTransport, + Protocol, + sleep +) + + +class Writer: + __slots__ = ( + '_transport', + '_protocol', + '_reader', + '_loop', + '_complete_fut' + ) + """Wraps a Transport. + This exposes write(), writelines(), [can_]write_eof(), + get_extra_info() and close(). It adds drain() which returns an + optional Future on which you can wait for flow control. It also + adds a transport property which references the Transport + directly. + """ + + def __init__(self, transport, protocol, reader, loop): + self._transport: Transport | DatagramTransport = transport + self._protocol: Protocol = protocol + # drain() expects that the reader has an exception() method + self._reader = reader + self._loop: asyncio.AbstractEventLoop = loop + self._complete_fut = self._loop.create_future() + self._complete_fut.set_result(None) + + def __repr__(self): + info = [self.__class__.__name__, f'transport={self._transport!r}'] + if self._reader is not None: + info.append(f'reader={self._reader!r}') + return '<{}>'.format(' '.join(info)) + + @property + def transport(self): + return self._transport + + def write(self, data): + self._transport.write(data) + + def send(self, data): + self._transport.sendto(data) + + def writelines(self, data): + self._transport.writelines(data) + + def write_eof(self): + return self._transport.write_eof() + + def can_write_eof(self): + return self._transport.can_write_eof() + + def close(self): + return self._transport.close() + + def is_closing(self): + return self._transport.is_closing() + + async def wait_closed(self): + await self._protocol._get_close_waiter(self) + + def get_extra_info(self, name, default=None): + return self._transport.get_extra_info(name, default) + + async def drain(self): + """Flush the write buffer. + The intended use is to write + w.write(data) + await w.drain() + """ + if self._reader is not None: + exc = self._reader.exception() + if exc is not None: + raise exc + if self._transport.is_closing(): + # Wait for protocol.connection_lost() call + # Raise connection closing error if any, + # ConnectionResetError otherwise + # Yield to the event loop so connection_lost() may be + # called. Without this, _drain_helper() would return + # immediately, and code that calls + # write(...); await drain() + # in a loop would never call connection_lost(), so it + # would not see an error when the socket is closed. + await sleep(0) + await self._protocol._drain_helper() + + async def start_tls(self, sslcontext, *, server_hostname=None, ssl_handshake_timeout=None): + + server_side = self._protocol._client_connected_cb is not None + protocol = self._protocol + + await self.drain() + + new_transport = await self._loop.start_tls( # type: ignore + self._transport, + protocol, + sslcontext, + server_side=server_side, + server_hostname=server_hostname, + ssl_handshake_timeout=ssl_handshake_timeout + ) + + self._transport = new_transport + protocol._replace_writer(self) \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/tcp/__init__.py b/hyperscale/core/engines/types/common/protocols/tcp/__init__.py new file mode 100644 index 0000000..85966f2 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/tcp/__init__.py @@ -0,0 +1 @@ +from .connection import TCPConnection diff --git a/hyperscale/core/engines/types/common/protocols/tcp/connection.py b/hyperscale/core/engines/types/common/protocols/tcp/connection.py new file mode 100644 index 0000000..b71584e --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/tcp/connection.py @@ -0,0 +1,125 @@ +import asyncio +import socket +from asyncio.constants import SSL_HANDSHAKE_TIMEOUT +from asyncio.sslproto import SSLProtocol +from ssl import SSLContext +from typing import Optional + +from hyperscale.core.engines.types.common.protocols.shared.constants import ( + _DEFAULT_LIMIT, + _HTTP2_LIMIT, +) +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer +from hyperscale.core.engines.types.common.types import RequestTypes + +from .protocol import TCPProtocol +from .tls_protocol import TLSProtocol + + +class TCPConnection: + + def __init__(self, factory_type: RequestTypes = RequestTypes.HTTP) -> None: + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + self.transport = None + self.factory_type = factory_type + self._connection = None + self.socket: socket.socket = None + self._writer = None + + async def create(self, hostname=None, socket_config=None, *, limit=_DEFAULT_LIMIT, ssl=None): + self.loop = asyncio.get_event_loop() + + family, type_, proto, _, address = socket_config + + socket_family = socket.AF_INET6 if family == 2 else socket.AF_INET + + self.socket = socket.socket(family=family, type=type_, proto=proto) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + await self.loop.run_in_executor(None, self.socket.connect, address) + + self.socket.setblocking(False) + + reader = Reader(limit=limit, loop=self.loop) + reader_protocol = TCPProtocol(reader, loop=self.loop) + + if ssl is None: + hostname = None + + self.transport, _ = await self.loop.create_connection( + lambda: reader_protocol, + sock=self.socket, + family=socket_family, + server_hostname=hostname, + ssl=ssl + ) + + self._writer = Writer(self.transport, reader_protocol, reader, self.loop) + + return reader, self._writer + + async def create_http2(self, hostname=None, socket_config=None, ssl: Optional[SSLContext] = None, ssl_timeout: int = SSL_HANDSHAKE_TIMEOUT): + # this does the same as loop.open_connection(), but TLS upgrade is done + # manually after connection be established. + self.loop = asyncio.get_event_loop() + + family, type_, proto, _, address = socket_config + + socket_family = socket.AF_INET6 if family == 2 else socket.AF_INET + + self.socket = socket.socket(family=family, type=type_, proto=proto) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + await self.loop.run_in_executor(None, self.socket.connect, address) + + self.socket.setblocking(False) + + reader = Reader(limit=_HTTP2_LIMIT, loop=self.loop) + + protocol = TLSProtocol(reader, loop=self.loop) + + self.transport, _ = await self.loop.create_connection( + lambda: protocol, + sock=self.socket, + family=socket_family + ) + + ssl_protocol = SSLProtocol( + self.loop, + protocol, + ssl, + None, + False, + hostname, + ssl_handshake_timeout=ssl_timeout, + call_connection_made=False + ) + + # Pause early so that "ssl_protocol.data_received()" doesn't + # have a chance to get called before "ssl_protocol.connection_made()". + self.transport.pause_reading() + + self.transport.set_protocol(ssl_protocol) + await self.loop.run_in_executor(None, ssl_protocol.connection_made, self.transport) + self.transport.resume_reading() + + self.transport = ssl_protocol._app_transport + + reader = Reader(limit=_HTTP2_LIMIT, loop=self.loop) + + protocol.upgrade_reader(reader) # update reader + protocol.connection_made(self.transport) # update transport + + self._writer = Writer(self.transport, ssl_protocol, reader, self.loop) # update writer + + return reader, self._writer + + async def close(self): + + try: + self.transport._ssl_protocol.pause_writing() + self.transport.close() + + except Exception: + pass \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/tcp/protocol.py b/hyperscale/core/engines/types/common/protocols/tcp/protocol.py new file mode 100644 index 0000000..e1b2163 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/tcp/protocol.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from asyncio import Protocol, Transport +from asyncio.coroutines import iscoroutine +from weakref import ref + +from hyperscale.core.engines.types.common.protocols.shared import FlowControlMixin +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer + + +class TCPProtocol(FlowControlMixin, Protocol): + """Helper class to adapt between Protocol and StreamReader. + (This is a helper class instead of making StreamReader itself a + Protocol subclass, because the StreamReader has other potential + uses, and to prevent the user of the StreamReader to accidentally + call inappropriate methods of the protocol.) + """ + + __slots__ = ( + '_source_traceback', + '_reject_connection', + '_stream_writer', + '_transport', + '_client_connected_cb', + '_over_ssl', + '_closed', + '_stream_reader_wr' + ) + + def __init__(self, stream_reader: Reader, client_connected_cb=None, loop=None): + super().__init__(loop=loop) + self._source_traceback = None + + if stream_reader is not None: + self._stream_reader_wr: Reader = ref(stream_reader) + self._source_traceback = stream_reader._source_traceback + else: + self._stream_reader_wr = None + if client_connected_cb is not None: + # This is a stream created by the `create_server()` function. + # Keep a strong reference to the reader until a connection + # is established. + self._strong_reader = stream_reader + self._reject_connection = False + self._stream_writer: Writer = None + self._transport: Transport = None + self._client_connected_cb = client_connected_cb + self._over_ssl = False + self._closed = self._loop.create_future() + + @property + def _stream_reader(self) -> Reader: + if self._stream_reader_wr is None: + return None + return self._stream_reader_wr() + + def _replace_writer(self, writer: Writer): + transport = writer.transport + self._stream_writer = writer + self._transport = transport + self._over_ssl = transport.get_extra_info('sslcontext') is not None + + def connection_made(self, transport: Transport): + if self._reject_connection: + context = { + 'message': ('An open stream was garbage collected prior to ' + 'establishing network connection; ' + 'call "stream.close()" explicitly.') + } + if self._source_traceback: + context['source_traceback'] = self._source_traceback + self._loop.call_exception_handler(context) + transport.abort() + return + self._transport = transport + reader: Reader = self._stream_reader + if reader is not None: + reader.set_transport(transport) + self._over_ssl = transport.get_extra_info('sslcontext') is not None + if self._client_connected_cb is not None: + self._stream_writer = Reader(transport, self, + reader, + self._loop) + res = self._client_connected_cb(reader, + self._stream_writer) + if iscoroutine(res): + self._loop.create_task(res) + self._strong_reader = None + + def connection_lost(self, exc): + reader: Reader = self._stream_reader + if reader is not None: + if exc is None: + reader.feed_eof() + else: + reader.set_exception(exc) + if not self._closed.done(): + if exc is None: + self._closed.set_result(None) + else: + self._closed.set_exception(exc) + super().connection_lost(exc) + self._stream_reader_wr = None + self._stream_writer = None + self._transport = None + + def data_received(self, data): + reader = self._stream_reader + if reader is not None: + reader.feed_data(data) + + def eof_received(self): + reader: Reader = self._stream_reader + if reader is not None: + reader.feed_eof() + if self._over_ssl: + # Prevent a warning in SSLProtocol.eof_received: + # "returning true from eof_received() + # has no effect when using ssl" + return False + return True + + def _get_close_waiter(self, stream): + return self._closed + + def __del__(self): + # Prevent reports about unhandled exceptions. + # Better than self._closed._log_traceback = False hack + try: + closed = self._closed + except AttributeError: + pass # failed constructor + else: + if closed.done() and not closed.cancelled(): + closed.exception() + diff --git a/hyperscale/core/engines/types/common/protocols/tcp/tls_protocol.py b/hyperscale/core/engines/types/common/protocols/tcp/tls_protocol.py new file mode 100644 index 0000000..3dddd1a --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/tcp/tls_protocol.py @@ -0,0 +1,16 @@ +from weakref import ref + +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader + +from .protocol import TCPProtocol + + +class TLSProtocol(TCPProtocol): + + def upgrade_reader(self, reader: Reader): + + if self._stream_reader: + self._stream_reader.set_exception(Exception('upgraded connection to TLS, this reader is obsolete now.')) + + self._stream_reader_wr = ref(reader) + self._source_traceback = reader._source_traceback \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/udp/__init__.py b/hyperscale/core/engines/types/common/protocols/udp/__init__.py new file mode 100644 index 0000000..38f16fb --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/udp/__init__.py @@ -0,0 +1 @@ +from .connection import UDPConnection \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/udp/connection.py b/hyperscale/core/engines/types/common/protocols/udp/connection.py new file mode 100644 index 0000000..46aab31 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/udp/connection.py @@ -0,0 +1,135 @@ +import asyncio +import socket +from typing import Callable, Optional + +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer +from hyperscale.core.engines.types.common.types import RequestTypes + +from .protocol import UDPProtocol +from .quic_protocol import QuicProtocol + +try: + from aioquic.h3.connection import H3_ALPN + from aioquic.quic.configuration import QuicConfiguration + from aioquic.quic.connection import QuicConnection + +except ImportError: + H3_ALPN = [] + QuicConnection = object + QuicConfiguration = object + +from hyperscale.core.engines.types.common.protocols.shared.constants import ( + _DEFAULT_LIMIT, +) + +QuicStreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], None] + + +class UDPConnection: + + def __init__(self, factory_type: RequestTypes = RequestTypes.HTTP) -> None: + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + self.transport: asyncio.DatagramTransport = None + self.factory_type = factory_type + self._connection = None + self.socket: socket.socket = None + self._writer = None + + async def create_udp(self, socket_config=None, *, limit=_DEFAULT_LIMIT, tls=None): + + self.loop = asyncio.get_event_loop() + + family, type_, _, _, address = socket_config + + self.socket = socket.socket(family=family, type=type_) + + await self.loop.run_in_executor(None, self.socket.connect, address) + + self.socket.setblocking(False) + + reader = Reader(limit=limit, loop=self.loop) + reader_protocol = UDPProtocol(reader, loop=self.loop) + + self.transport, _ = await self.loop.create_datagram_endpoint( + lambda: reader_protocol, + sock=self.socket + ) + + # TODO: Enable DTLS - This appears to be a *significant* amount of work + # but would allow for VOIP testing, etc. + + self._writer = Writer(self.transport, reader_protocol, reader, self.loop) + + return reader, self._writer + + async def create_http3( + self, + socket_config=None, + server_name: str=None, + configuration: Optional[QuicConfiguration] = None, + stream_handler: Optional[QuicStreamHandler] = None, + local_port: int = 0, + ): + + _, _, _, _, address = socket_config + if len(address) == 2: + address = ("::ffff:" + address[0], address[1], 0, 0) + + local_host = "::" + + # keep compatibility for Python 3.7 on Windows + if not hasattr(socket, "IPPROTO_IPV6"): + socket.IPPROTO_IPV6 = 41 + + configuration = QuicConfiguration( + is_client=True, alpn_protocols=H3_ALPN + ) + + # prepare QUIC connection + if configuration.server_name is None: + configuration.server_name = server_name + + connection = QuicConnection( + configuration=configuration, + session_ticket_handler=lambda handler: None + ) + + # explicitly enable IPv4/IPv6 dual stack + self.socket = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + completed = False + try: + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + self.socket.setblocking(False) + self.socket.bind((local_host, local_port, 0, 0)) + completed = True + finally: + if not completed: + self.socket.close() + # connect + self.loop = asyncio.get_event_loop() + _, protocol = await self.loop.create_datagram_endpoint( + lambda: QuicProtocol( + connection, + stream_handler=stream_handler, + loop=self.loop + ), + sock=self.socket, + ) + + + protocol.init_connection() + + protocol.connect(address) + await protocol.wait_connected() + + return protocol + + async def close(self): + + try: + self.transport.close() + + except Exception: + pass + diff --git a/hyperscale/core/engines/types/common/protocols/udp/protocol.py b/hyperscale/core/engines/types/common/protocols/udp/protocol.py new file mode 100644 index 0000000..f8486e6 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/udp/protocol.py @@ -0,0 +1,116 @@ +from asyncio import Protocol, Transport +from asyncio.coroutines import iscoroutine +from weakref import ref + +from hyperscale.core.engines.types.common.protocols.shared import FlowControlMixin +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer + + +class UDPProtocol(FlowControlMixin, Protocol): + + _source_traceback = None + + def __init__(self, stream_reader, client_connected_cb=None, loop=None): + super().__init__(loop=loop) + + if stream_reader is not None: + self._stream_reader_wr: Reader = ref(stream_reader) + self._source_traceback = stream_reader._source_traceback + else: + self._stream_reader_wr = None + if client_connected_cb is not None: + # This is a stream created by the `create_server()` function. + # Keep a strong reference to the reader until a connection + # is established. + self._strong_reader = stream_reader + self._reject_connection = False + self._stream_writer: Writer = None + self._transport: Transport = None + self._client_connected_cb = client_connected_cb + + self._closed = self._loop.create_future() + + @property + def _stream_reader(self) -> Reader: + if self._stream_reader_wr is None: + return None + return self._stream_reader_wr() + + def _replace_writer(self, writer: Writer): + transport = writer.transport + self._stream_writer = writer + self._transport = transport + + def connection_made(self, transport: Transport): + if self._reject_connection: + context = { + 'message': ('An open stream was garbage collected prior to ' + 'establishing network connection; ' + 'call "stream.close()" explicitly.') + } + if self._source_traceback: + context['source_traceback'] = self._source_traceback + self._loop.call_exception_handler(context) + transport.abort() + return + self._transport = transport + reader: Reader = self._stream_reader + if reader is not None: + reader.set_transport(transport) + + if self._client_connected_cb is not None: + self._stream_writer = Reader(transport, self, + reader, + self._loop) + res = self._client_connected_cb(reader, + self._stream_writer) + if iscoroutine(res): + self._loop.create_task(res) + self._strong_reader = None + + def connection_lost(self, exc): + reader: Reader = self._stream_reader + if reader is not None: + if exc is None: + reader.feed_eof() + else: + reader.set_exception(exc) + if not self._closed.done(): + if exc is None: + self._closed.set_result(None) + else: + self._closed.set_exception(exc) + super().connection_lost(exc) + self._stream_reader_wr = None + self._stream_writer = None + self._transport = None + + def error_received(self, exc): + raise exc + + def datagram_received(self, data): + reader = self._stream_reader + if reader is not None: + reader.feed_data(data) + + def eof_received(self): + reader: Reader = self._stream_reader + if reader is not None: + reader.feed_eof() + + return True + + def _get_close_waiter(self, stream): + return self._closed + + def __del__(self): + # Prevent reports about unhandled exceptions. + # Better than self._closed._log_traceback = False hack + try: + closed = self._closed + except AttributeError: + pass # failed constructor + else: + if closed.done() and not closed.cancelled(): + closed.exception() \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/protocols/udp/quic_protocol.py b/hyperscale/core/engines/types/common/protocols/udp/quic_protocol.py new file mode 100644 index 0000000..4052d98 --- /dev/null +++ b/hyperscale/core/engines/types/common/protocols/udp/quic_protocol.py @@ -0,0 +1,1180 @@ +from __future__ import annotations + +import asyncio +import re +from collections import deque +from enum import Enum, IntEnum +from typing import ( + Any, + Callable, + Deque, + Dict, + FrozenSet, + List, + Optional, + Set, + Text, + Tuple, + Union, + cast, +) + + +def dummy_unit_encode(val: int): + return b'' + + +def dummy_stream_is_unidirectional(val: int): + return False + + +try: + + import pylsqpack + from aioquic.buffer import ( + UINT_VAR_MAX_SIZE, + Buffer, + BufferReadError, + encode_uint_var, + ) + from aioquic.h3.events import ( + DatagramReceived, + DataReceived, + H3Event, + Headers, + HeadersReceived, + PushPromiseReceived, + WebTransportStreamDataReceived, + ) + from aioquic.quic.connection import ( + NetworkAddress, + QuicConnection, + stream_is_unidirectional, + ) + from aioquic.quic.events import ( + ConnectionIdIssued, + ConnectionIdRetired, + ConnectionTerminated, + DatagramFrameReceived, + HandshakeCompleted, + PingAcknowledged, + QuicEvent, + StreamDataReceived, + ) + +except ImportError: + pylsqpack = object + aioquic = object + QuicConnection = object + ConnectionTerminated = object + ConnectionIdRetired = object + ConnectionIdIssued = object + HandshakeCompleted = object + PingAcknowledged = object + DatagramReceived = object + DataReceived = object + H3Event = object + Headers = Any + HeadersReceived = object + PushPromiseReceived = object + WebTransportStreamDataReceived = object + NetworkAddress = Any + UINT_VAR_MAX_SIZE = 0 + Buffer = object + BufferReadError = object + encode_uint_var = dummy_unit_encode + DatagramFrameReceived = object + QuicEvent = object + StreamDataReceived = object + stream_is_unidirectional = dummy_stream_is_unidirectional + + +QuicConnectionIdHandler = Callable[[bytes], None] +QuicStreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], None] + + +USER_AGENT = "hyperscale/client" +RESERVED_SETTINGS = (0x0, 0x2, 0x3, 0x4, 0x5) +UPPERCASE = re.compile(b"[A-Z]") + + +class ErrorCode(IntEnum): + H3_NO_ERROR = 0x100 + H3_GENERAL_PROTOCOL_ERROR = 0x101 + H3_INTERNAL_ERROR = 0x102 + H3_STREAM_CREATION_ERROR = 0x103 + H3_CLOSED_CRITICAL_STREAM = 0x104 + H3_FRAME_UNEXPECTED = 0x105 + H3_FRAME_ERROR = 0x106 + H3_EXCESSIVE_LOAD = 0x107 + H3_ID_ERROR = 0x108 + H3_SETTINGS_ERROR = 0x109 + H3_MISSING_SETTINGS = 0x10A + H3_REQUEST_REJECTED = 0x10B + H3_REQUEST_CANCELLED = 0x10C + H3_REQUEST_INCOMPLETE = 0x10D + H3_MESSAGE_ERROR = 0x10E + H3_CONNECT_ERROR = 0x10F + H3_VERSION_FALLBACK = 0x110 + QPACK_DECOMPRESSION_FAILED = 0x200 + QPACK_ENCODER_STREAM_ERROR = 0x201 + QPACK_DECODER_STREAM_ERROR = 0x202 + + +class Setting(IntEnum): + QPACK_MAX_TABLE_CAPACITY = 0x1 + MAX_FIELD_SECTION_SIZE = 0x6 + QPACK_BLOCKED_STREAMS = 0x7 + + # https://datatracker.ietf.org/doc/html/rfc9220#section-5 + ENABLE_CONNECT_PROTOCOL = 0x8 + # https://datatracker.ietf.org/doc/html/draft-ietf-masque-h3-datagram-05#section-9.1 + H3_DATAGRAM = 0xFFD277 + # https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http2-02#section-10.1 + ENABLE_WEBTRANSPORT = 0x2B603742 + + # Dummy setting to check it is correctly ignored by the peer. + # https://datatracker.ietf.org/doc/html/rfc9114#section-7.2.4.1 + DUMMY = 0x21 + + +def encode_frame(frame_type: int, frame_data: bytes) -> bytes: + frame_length = len(frame_data) + buf = Buffer(capacity=frame_length + 2 * UINT_VAR_MAX_SIZE) + buf.push_uint_var(frame_type) + buf.push_uint_var(frame_length) + buf.push_bytes(frame_data) + return buf.data + + +def validate_headers( + headers: Headers, + allowed_pseudo_headers: FrozenSet[bytes], + required_pseudo_headers: FrozenSet[bytes], +) -> None: + after_pseudo_headers = False + authority: Optional[bytes] = None + path: Optional[bytes] = None + scheme: Optional[bytes] = None + seen_pseudo_headers: Set[bytes] = set() + for key, value in headers: + if UPPERCASE.search(key): + raise MessageError("Header %r contains uppercase letters" % key) + + if key.startswith(b":"): + # pseudo-headers + if after_pseudo_headers: + raise MessageError( + "Pseudo-header %r is not allowed after regular headers" % key + ) + if key not in allowed_pseudo_headers: + raise MessageError("Pseudo-header %r is not valid" % key) + if key in seen_pseudo_headers: + raise MessageError("Pseudo-header %r is included twice" % key) + seen_pseudo_headers.add(key) + + # store value + if key == b":authority": + authority = value + elif key == b":path": + path = value + elif key == b":scheme": + scheme = value + else: + # regular headers + after_pseudo_headers = True + + # check required pseudo-headers are present + missing = required_pseudo_headers.difference(seen_pseudo_headers) + if missing: + raise MessageError("Pseudo-headers %s are missing" % sorted(missing)) + + if scheme in (b"http", b"https"): + if not authority: + raise MessageError("Pseudo-header b':authority' cannot be empty") + if not path: + raise MessageError("Pseudo-header b':path' cannot be empty") + + +class ProtocolError(Exception): + """ + Base class for protocol errors. + + These errors are not exposed to the API user, they are handled + in :meth:`H3Connection.handle_event`. + """ + + error_code = ErrorCode.H3_GENERAL_PROTOCOL_ERROR + + def __init__(self, reason_phrase: str = ""): + self.reason_phrase = reason_phrase + +class QpackDecompressionFailed(ProtocolError): + error_code = ErrorCode.QPACK_DECOMPRESSION_FAILED + + +class QpackDecoderStreamError(ProtocolError): + error_code = ErrorCode.QPACK_DECODER_STREAM_ERROR + + +class QpackEncoderStreamError(ProtocolError): + error_code = ErrorCode.QPACK_ENCODER_STREAM_ERROR + + +class ClosedCriticalStream(ProtocolError): + error_code = ErrorCode.H3_CLOSED_CRITICAL_STREAM + + +class FrameUnexpected(ProtocolError): + error_code = ErrorCode.H3_FRAME_UNEXPECTED + + +class MessageError(ProtocolError): + error_code = ErrorCode.H3_MESSAGE_ERROR + + +class MissingSettingsError(ProtocolError): + error_code = ErrorCode.H3_MISSING_SETTINGS + + +class SettingsError(ProtocolError): + error_code = ErrorCode.H3_SETTINGS_ERROR + + +class StreamCreationError(ProtocolError): + error_code = ErrorCode.H3_STREAM_CREATION_ERROR + +class FrameType(IntEnum): + DATA = 0x0 + HEADERS = 0x1 + PRIORITY = 0x2 + CANCEL_PUSH = 0x3 + SETTINGS = 0x4 + PUSH_PROMISE = 0x5 + GOAWAY = 0x7 + MAX_PUSH_ID = 0xD + DUPLICATE_PUSH = 0xE + WEBTRANSPORT_STREAM = 0x41 + +class StreamType(IntEnum): + CONTROL = 0 + PUSH = 1 + QPACK_ENCODER = 2 + QPACK_DECODER = 3 + WEBTRANSPORT = 0x54 + +class HeadersState(Enum): + INITIAL = 0 + AFTER_HEADERS = 1 + AFTER_TRAILERS = 2 + +class H3Stream: + + __slots__ =( + 'blocked', + 'blocked_frame_size', + 'buffer', + 'ended', + 'frame_size', + 'frame_type', + 'headers_recv_state', + 'headers_send_state', + 'push_id', + 'session_id', + 'stream_id', + 'stream_type' + ) + + def __init__(self, stream_id: int) -> None: + self.blocked = False + self.blocked_frame_size: Optional[int] = None + self.buffer = b"" + self.ended = False + self.frame_size: Optional[int] = None + self.frame_type: Optional[int] = None + self.headers_recv_state: HeadersState = HeadersState.INITIAL + self.headers_send_state: HeadersState = HeadersState.INITIAL + self.push_id: Optional[int] = None + self.session_id: Optional[int] = None + self.stream_id = stream_id + self.stream_type: Optional[int] = None + + +class ResponseFrameCollection: + + __slots__ = ( + 'headers_frame', + 'body' + ) + + def __init__(self) -> None: + self.headers_frame: HeadersReceived = None + self.body = bytearray() + + +class QuicStreamAdapter(asyncio.Transport): + + __slots__ = ( + 'protocol', + 'stream_id' + ) + + def __init__(self, protocol: QuicProtocol, stream_id: int): + self.protocol = protocol + self.stream_id = stream_id + + def can_write_eof(self) -> bool: + return True + + def get_extra_info(self, name: str, default: Any = None) -> Any: + """ + Get information about the underlying QUIC stream. + """ + if name == "stream_id": + return self.stream_id + + def write(self, data): + self.protocol._quic.send_stream_data(self.stream_id, data) + self.protocol._transmit_soon() + + def write_eof(self): + self.protocol._quic.send_stream_data(self.stream_id, b"", end_stream=True) + self.protocol._transmit_soon() + + +class QuicProtocol(asyncio.DatagramProtocol): + + __slots__ = ( + '_loop', + '_request_waiter', + '_closed', + '_connected', + '_connected_waiter', + '_ping_waiters', + '_quic', + '_stream_readers', + '_timer', + '_timer_at', + '_transmit_task', + '_transport', + '_connection_id_issued_handler', + '_connection_id_retired_handler', + '_connection_terminated_handler', + '_stream_handler', + 'pushes', + '_request_events', + '_request_waiter', + '_stream', + '_is_done', + '_max_table_capacity', + '_blocked_streams', + '_decoder', + '_decoder_bytes_received', + '_decoder_bytes_sent', + '_encoder', + '_encoder_bytes_received', + '_encoder_bytes_sent', + '_settings_received', + '_stream', + '_max_push_id', + '_next_push_id', + '_local_control_stream_id', + '_local_decoder_stream_id', + '_local_encoder_stream_id', + '_peer_control_stream_id', + '_peer_decoder_stream_id', + '_peer_encoder_stream_id', + '_received_settings', + '_sent_settings', + 'responses', + '_is_closed' + ) + + def __init__( + self, quic: QuicConnection, + stream_handler: Optional[QuicStreamHandler] = None, + loop: asyncio.AbstractEventLoop = None + ): + + self._loop = loop + + self._request_waiter: Dict[int, asyncio.Future[Deque[H3Event]]] = {} + self._closed = asyncio.Event() + self._connected = False + self._connected_waiter: Optional[asyncio.Future[None]] = None + self._ping_waiters: Dict[int, asyncio.Future[None]] = {} + self._quic = quic + self._stream_readers: Dict[int, asyncio.StreamReader] = {} + self._timer: Optional[asyncio.TimerHandle] = None + self._timer_at: Optional[float] = None + self._transmit_task: Optional[asyncio.Handle] = None + self._transport: Optional[asyncio.DatagramTransport] = None + + # callbacks + self._connection_id_issued_handler: QuicConnectionIdHandler = lambda c: None + self._connection_id_retired_handler: QuicConnectionIdHandler = lambda c: None + self._connection_terminated_handler: Callable[[], None] = lambda: None + if stream_handler is not None: + self._stream_handler = stream_handler + else: + self._stream_handler = lambda r, w: None + + self.pushes: Dict[int, Deque[H3Event]] = {} + self._request_events: Dict[int, Deque[H3Event]] = {} + self._request_waiter: Dict[int, asyncio.Future[Deque[H3Event]]] = {} + self._stream: Dict[int, H3Stream] = {} + self._is_done = False + self._max_table_capacity = 4096 + self._blocked_streams = 128 + + self._decoder = pylsqpack.Decoder( + self._max_table_capacity, self._blocked_streams + ) + self._decoder_bytes_received = 0 + self._decoder_bytes_sent = 0 + self._encoder = pylsqpack.Encoder() + self._encoder_bytes_received = 0 + self._encoder_bytes_sent = 0 + self._settings_received = False + self._stream: Dict[int, H3Stream] = {} + + self._max_push_id: Optional[int] = None + self._next_push_id: int = 0 + + self._local_control_stream_id: Optional[int] = None + self._local_decoder_stream_id: Optional[int] = None + self._local_encoder_stream_id: Optional[int] = None + + self._peer_control_stream_id: Optional[int] = None + self._peer_decoder_stream_id: Optional[int] = None + self._peer_encoder_stream_id: Optional[int] = None + self._received_settings: Optional[Dict[int, int]] = None + self._sent_settings: Optional[Dict[int, int]] = None + self.responses: Dict[int, ResponseFrameCollection] = {} + self._is_closed = False + + + def init_connection(self) -> None: + # send our settings + self._local_control_stream_id = self._create_uni_stream(StreamType.CONTROL) + self._sent_settings = { + Setting.QPACK_MAX_TABLE_CAPACITY: self._max_table_capacity, + Setting.QPACK_BLOCKED_STREAMS: self._blocked_streams, + Setting.ENABLE_CONNECT_PROTOCOL: 1, + Setting.DUMMY: 1, + } + + buf = Buffer(capacity=1024) + + for setting, value in self._sent_settings.items(): + buf.push_uint_var(setting) + buf.push_uint_var(value) + + self._quic.send_stream_data( + self._local_control_stream_id, + encode_frame(FrameType.SETTINGS, buf.data), + ) + if self._max_push_id is not None: + self._quic.send_stream_data( + self._local_control_stream_id, + encode_frame(FrameType.MAX_PUSH_ID, encode_uint_var(self._max_push_id)), + ) + + # create encoder and decoder streams + self._local_encoder_stream_id = self._create_uni_stream( + StreamType.QPACK_ENCODER + ) + self._local_decoder_stream_id = self._create_uni_stream( + StreamType.QPACK_DECODER + ) + + def _create_uni_stream( + self, stream_type: int, push_id: Optional[int] = None + ) -> int: + """ + Create an unidirectional stream of the given type. + """ + stream_id = self._quic.get_next_available_stream_id(is_unidirectional=True) + self._quic.send_stream_data(stream_id, encode_uint_var(stream_type)) + return stream_id + + def change_connection_id(self) -> None: + """ + Change the connection ID used to communicate with the peer. + + The previous connection ID will be retired. + """ + self._quic.change_connection_id() + self.transmit() + + def close(self) -> None: + """ + Close the connection. + """ + self._is_closed = True + self._quic.close() + self.transmit() + + def connect(self, addr: NetworkAddress) -> None: + """ + Initiate the TLS handshake. + + This method can only be called for clients and a single time. + """ + self._quic.connect(addr, now=self._loop.time()) + self.transmit() + + + def _get_or_create_stream(self, stream_id: int) -> H3Stream: + if stream_id not in self._stream: + self._stream[stream_id] = H3Stream(stream_id) + return self._stream[stream_id] + + + async def create_stream( + self, is_unidirectional: bool = False + ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """ + Create a QUIC stream and return a pair of (reader, writer) objects. + + The returned reader and writer objects are instances of :class:`asyncio.StreamReader` + and :class:`asyncio.StreamWriter` classes. + """ + stream_id = self._quic.get_next_available_stream_id( + is_unidirectional=is_unidirectional + ) + return self._create_stream(stream_id) + + def request_key_update(self) -> None: + """ + Request an update of the encryption keys. + """ + self._quic.request_key_update() + self.transmit() + + async def ping(self) -> None: + """ + Ping the peer and wait for the response. + """ + waiter = self._loop.create_future() + uid = id(waiter) + self._ping_waiters[uid] = waiter + self._quic.send_ping(uid) + self.transmit() + await asyncio.shield(waiter) + + def transmit(self) -> None: + """ + Send pending datagrams to the peer and arm the timer if needed. + """ + self._transmit_task = None + + # send datagrams + for data, addr in self._quic.datagrams_to_send(now=self._loop.time()): + self._transport.sendto(data, addr) + + # re-arm timer + timer_at = self._quic.get_timer() + if self._timer is not None and self._timer_at != timer_at: + self._timer.cancel() + self._timer = None + if self._timer is None and timer_at is not None: + self._timer = self._loop.call_at(timer_at, self._handle_timer) + self._timer_at = timer_at + + async def wait_closed(self) -> None: + """ + Wait for the connection to be closed. + """ + await self._closed.wait() + + async def wait_connected(self) -> None: + """ + Wait for the TLS handshake to complete. + """ + assert self._connected_waiter is None, "already awaiting connected" + if not self._connected: + self._connected_waiter = self._loop.create_future() + await self._connected_waiter + + # asyncio.Transport + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self._transport = cast(asyncio.DatagramTransport, transport) + + def datagram_received(self, data: Union[bytes, Text], addr: NetworkAddress) -> None: + self._quic.receive_datagram(cast(bytes, data), addr, now=self._loop.time()) + self._process_events() + self.transmit() + + # overridable + def quic_event_received(self, event: QuicEvent) -> None: + #  pass event to the HTTP layer' + events = [] + if not self._is_done: + try: + if isinstance(event, StreamDataReceived): + stream_id = event.stream_id + stream = self._get_or_create_stream(stream_id) + if stream_is_unidirectional(stream_id): + + http_events: List[H3Event] = [] + + stream.buffer += event.data + if event.end_stream: + stream.ended = True + + buf = Buffer(data=stream.buffer) + consumed = 0 + unblocked_streams: Set[int] = set() + + while ( + stream.stream_type + in (StreamType.PUSH, StreamType.CONTROL, StreamType.WEBTRANSPORT) + or not buf.eof() + ): + # fetch stream type for unidirectional streams + if stream.stream_type is None: + try: + stream.stream_type = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # check unicity + if stream.stream_type == StreamType.CONTROL: + if self._peer_control_stream_id is not None: + raise StreamCreationError("Only one control stream is allowed") + self._peer_control_stream_id = stream.stream_id + elif stream.stream_type == StreamType.QPACK_DECODER: + if self._peer_decoder_stream_id is not None: + raise StreamCreationError( + "Only one QPACK decoder stream is allowed" + ) + self._peer_decoder_stream_id = stream.stream_id + elif stream.stream_type == StreamType.QPACK_ENCODER: + if self._peer_encoder_stream_id is not None: + raise StreamCreationError( + "Only one QPACK encoder stream is allowed" + ) + self._peer_encoder_stream_id = stream.stream_id + + + + if stream.stream_type == StreamType.CONTROL: + if event.end_stream: + raise ClosedCriticalStream("Closing control stream is not allowed") + + # fetch next frame + try: + frame_type = buf.pull_uint_var() + frame_length = buf.pull_uint_var() + frame_data = buf.pull_bytes(frame_length) + except BufferReadError: + break + consumed = buf.tell() + + if frame_type != FrameType.SETTINGS and not self._settings_received: + raise MissingSettingsError + + if frame_type == FrameType.SETTINGS: + if self._settings_received: + raise FrameUnexpected("SETTINGS have already been received") + + buf = Buffer(data=frame_data) + settings: Dict[int, int] = {} + while not buf.eof(): + setting = buf.pull_uint_var() + value = buf.pull_uint_var() + if setting in RESERVED_SETTINGS: + raise SettingsError("Setting identifier 0x%x is reserved" % setting) + if setting in settings: + raise SettingsError("Setting identifier 0x%x is included twice" % setting) + settings[setting] = value + + for setting in [ + Setting.ENABLE_CONNECT_PROTOCOL, + Setting.ENABLE_WEBTRANSPORT, + Setting.H3_DATAGRAM, + ]: + if setting in settings and settings[setting] not in (0, 1): + raise SettingsError(f"{setting.name} setting must be 0 or 1") + + if ( + settings.get(Setting.H3_DATAGRAM) == 1 + and self._quic._remote_max_datagram_frame_size is None + ): + raise SettingsError( + "H3_DATAGRAM requires max_datagram_frame_size transport parameter" + ) + + if ( + settings.get(Setting.ENABLE_WEBTRANSPORT) == 1 + and settings.get(Setting.H3_DATAGRAM) != 1 + ): + raise SettingsError("ENABLE_WEBTRANSPORT requires H3_DATAGRAM") + + self._received_settings = settings + encoder = self._encoder.apply_settings( + max_table_capacity=settings.get(Setting.QPACK_MAX_TABLE_CAPACITY, 0), + blocked_streams=settings.get(Setting.QPACK_BLOCKED_STREAMS, 0), + ) + self._quic.send_stream_data(self._local_encoder_stream_id, encoder) + self._settings_received = True + + elif frame_type in ( + FrameType.DATA, + FrameType.HEADERS, + FrameType.PUSH_PROMISE, + FrameType.DUPLICATE_PUSH, + ): + raise FrameUnexpected("Invalid frame type on control stream") + + elif stream.stream_type == StreamType.PUSH: + # fetch push id + if stream.push_id is None: + try: + stream.push_id = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + return self._receive_request_or_push_data(stream, b"", event.end_stream) + elif stream.stream_type == StreamType.WEBTRANSPORT: + # fetch session id + if stream.session_id is None: + try: + stream.session_id = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + frame_data = stream.buffer[consumed:] + stream.buffer = b"" + + if frame_data or event.end_stream: + http_events.append( + WebTransportStreamDataReceived( + data=frame_data, + session_id=stream.session_id, + stream_ended=stream.ended, + stream_id=stream.stream_id, + ) + ) + return http_events + elif stream.stream_type == StreamType.QPACK_DECODER: + # feed unframed data to decoder + data = buf.pull_bytes(buf.capacity - buf.tell()) + consumed = buf.tell() + try: + self._encoder.feed_decoder(data) + except pylsqpack.DecoderStreamError as exc: + raise QpackDecoderStreamError() from exc + self._decoder_bytes_received += len(data) + elif stream.stream_type == StreamType.QPACK_ENCODER: + # feed unframed data to encoder + data = buf.pull_bytes(buf.capacity - buf.tell()) + consumed = buf.tell() + try: + unblocked_streams.update(self._decoder.feed_encoder(data)) + except pylsqpack.EncoderStreamError as exc: + raise QpackEncoderStreamError() from exc + self._encoder_bytes_received += len(data) + else: + # unknown stream type, discard data + buf.seek(buf.capacity) + consumed = buf.tell() + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + # process unblocked streams + for stream_id in unblocked_streams: + stream = self._stream[stream_id] + + # resume headers + http_events.extend( + self._handle_request_or_push_frame( + frame_type=FrameType.HEADERS, + frame_data=None, + stream=stream, + stream_ended=stream.ended and not stream.buffer, + ) + ) + stream.blocked = False + stream.blocked_frame_size = None + + # resume processing + if stream.buffer: + http_events.extend( + self._receive_request_or_push_data(stream, b"", stream.ended) + ) + + events = http_events + + else: + events = self._receive_request_or_push_data( + stream, event.data, event.end_stream + ) + elif isinstance(event, DatagramFrameReceived): + buf = Buffer(data=event.data) + try: + flow_id = buf.pull_uint_var() + except BufferReadError: + raise ProtocolError("Could not parse flow ID") + + events = [DatagramReceived( + data=event.data[buf.tell() :], + flow_id=flow_id + )] + + except ProtocolError as exc: + self._is_done = True + self._quic.close( + error_code=exc.error_code, reason_phrase=exc.reason_phrase + ) + + for http_event in events: + + if isinstance(http_event, HeadersReceived): + + if self.responses.get(http_event.stream_id) is None: + self.responses[http_event.stream_id] = ResponseFrameCollection() + + self.responses[http_event.stream_id].headers_frame = http_event + + if http_event.push_id in self.pushes: + # push + self.pushes[http_event.push_id].append(http_event) + + + elif isinstance(http_event, DataReceived): + + if self.responses.get(http_event.stream_id) is None: + self.responses[http_event.stream_id] = ResponseFrameCollection() + + self.responses[http_event.stream_id].body.extend(http_event.data) + request_waiter = self._request_waiter.get(http_event.stream_id) + + if http_event.stream_ended and request_waiter is None: + raise Exception('Err. - Stream failed') + + elif http_event.stream_ended and request_waiter.done() is False: + request_waiter.set_result(self.responses.pop(http_event.stream_id)) + + if http_event.push_id in self.pushes: + # push + self.pushes[http_event.push_id].append(http_event) + + elif isinstance(event, PushPromiseReceived): + self.pushes[event.push_id] = deque() + self.pushes[event.push_id].append(event) + + def _receive_request_or_push_data( + self, stream: H3Stream, data: bytes, stream_ended: bool + ) -> List[H3Event]: + """ + Handle data received on a request or push stream. + """ + http_events: List[H3Event] = [] + + stream.buffer += data + if stream_ended: + stream.ended = True + if stream.blocked: + return http_events + + # shortcut for WEBTRANSPORT_STREAM frame fragments + if ( + stream.frame_type == FrameType.WEBTRANSPORT_STREAM + and stream.session_id is not None + ): + http_events.append( + WebTransportStreamDataReceived( + data=stream.buffer, + session_id=stream.session_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + stream.buffer = b"" + return http_events + + # shortcut for DATA frame fragments + if ( + stream.frame_type == FrameType.DATA + and stream.frame_size is not None + and len(stream.buffer) < stream.frame_size + ): + http_events.append( + DataReceived( + data=stream.buffer, + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=False, + ) + ) + stream.frame_size -= len(stream.buffer) + stream.buffer = b"" + return http_events + + # handle lone FIN + if stream_ended and not stream.buffer: + http_events.append( + DataReceived( + data=b"", + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=True, + ) + ) + return http_events + + buf = Buffer(data=stream.buffer) + consumed = 0 + + while not buf.eof(): + # fetch next frame header + if stream.frame_size is None: + try: + stream.frame_type = buf.pull_uint_var() + stream.frame_size = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # WEBTRANSPORT_STREAM frames last until the end of the stream + if stream.frame_type == FrameType.WEBTRANSPORT_STREAM: + stream.session_id = stream.frame_size + stream.frame_size = None + + frame_data = stream.buffer[consumed:] + stream.buffer = b"" + + if frame_data or stream_ended: + http_events.append( + WebTransportStreamDataReceived( + data=frame_data, + session_id=stream.session_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + return http_events + + # check how much data is available + chunk_size = min(stream.frame_size, buf.capacity - consumed) + if stream.frame_type != FrameType.DATA and chunk_size < stream.frame_size: + break + + # read available data + frame_data = buf.pull_bytes(chunk_size) + frame_type = stream.frame_type + consumed = buf.tell() + + # detect end of frame + stream.frame_size -= chunk_size + if not stream.frame_size: + stream.frame_size = None + stream.frame_type = None + + try: + http_events.extend( + self._handle_request_or_push_frame( + frame_type=frame_type, + frame_data=frame_data, + stream=stream, + stream_ended=stream.ended and buf.eof(), + ) + ) + except pylsqpack.StreamBlocked: + stream.blocked = True + stream.blocked_frame_size = len(frame_data) + break + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + return http_events + + def _create_stream( + self, stream_id: int + ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: + adapter = QuicStreamAdapter(self, stream_id) + reader = asyncio.StreamReader() + writer = asyncio.StreamWriter(adapter, None, reader, self._loop) + self._stream_readers[stream_id] = reader + return reader, writer + + def _handle_timer(self) -> None: + now = max(self._timer_at, self._loop.time()) + self._timer = None + self._timer_at = None + self._quic.handle_timer(now=now) + self._process_events() + self.transmit() + + def _process_events(self) -> None: + event = self._quic.next_event() + while event is not None: + if isinstance(event, ConnectionIdIssued): + self._connection_id_issued_handler(event.connection_id) + elif isinstance(event, ConnectionIdRetired): + self._connection_id_retired_handler(event.connection_id) + elif isinstance(event, ConnectionTerminated): + self._connection_terminated_handler() + + # abort connection waiter + if self._connected_waiter is not None: + waiter = self._connected_waiter + self._connected_waiter = None + waiter.set_exception(ConnectionError) + + # abort ping waiters + for waiter in self._ping_waiters.values(): + waiter.set_exception(ConnectionError) + self._ping_waiters.clear() + + self._closed.set() + elif isinstance(event, HandshakeCompleted): + if self._connected_waiter is not None: + waiter = self._connected_waiter + self._connected = True + self._connected_waiter = None + waiter.set_result(None) + elif isinstance(event, PingAcknowledged): + waiter = self._ping_waiters.pop(event.uid, None) + if waiter is not None: + waiter.set_result(None) + self.quic_event_received(event) + event = self._quic.next_event() + + def _transmit_soon(self) -> None: + if self._transmit_task is None: + self._transmit_task = self._loop.call_soon(self.transmit) + + def _handle_request_or_push_frame( + self, + frame_type: int, + frame_data: Optional[bytes], + stream: H3Stream, + stream_ended: bool, + ) -> List[H3Event]: + """ + Handle a frame received on a request or push stream. + """ + http_events: List[H3Event] = [] + + if frame_type == FrameType.DATA: + # check DATA frame is allowed + if stream.headers_recv_state != HeadersState.AFTER_HEADERS: + raise FrameUnexpected("DATA frame is not allowed in this state") + + if stream_ended or frame_data: + http_events.append( + DataReceived( + data=frame_data, + push_id=stream.push_id, + stream_ended=stream_ended, + stream_id=stream.stream_id, + ) + ) + elif frame_type == FrameType.HEADERS: + # check HEADERS frame is allowed + if stream.headers_recv_state == HeadersState.AFTER_TRAILERS: + raise FrameUnexpected("HEADERS frame is not allowed in this state") + + # try to decode HEADERS, may raise pylsqpack.StreamBlocked + headers = self._decode_headers(stream.stream_id, frame_data) + + # validate headers + if stream.headers_recv_state == HeadersState.INITIAL: + validate_headers( + headers, + allowed_pseudo_headers=frozenset((b":status",)), + required_pseudo_headers=frozenset((b":status",)), + ) + + else: + validate_headers( + headers, + allowed_pseudo_headers=frozenset(), + required_pseudo_headers=frozenset(), + ) + + + # update state and emit headers + if stream.headers_recv_state == HeadersState.INITIAL: + stream.headers_recv_state = HeadersState.AFTER_HEADERS + else: + stream.headers_recv_state = HeadersState.AFTER_TRAILERS + http_events.append( + HeadersReceived( + headers=headers, + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + elif frame_type == FrameType.PUSH_PROMISE and stream.push_id is None: + + frame_buf = Buffer(data=frame_data) + push_id = frame_buf.pull_uint_var() + headers = self._decode_headers( + stream.stream_id, frame_data[frame_buf.tell() :] + ) + + # validate headers + validate_headers( + headers, + allowed_pseudo_headers=frozenset( + (b":method", b":scheme", b":authority", b":path") + ), + required_pseudo_headers=frozenset( + (b":method", b":scheme", b":authority", b":path") + ), + ) + + # emit event + http_events.append( + PushPromiseReceived( + headers=headers, push_id=push_id, stream_id=stream.stream_id + ) + ) + elif frame_type in ( + FrameType.PRIORITY, + FrameType.CANCEL_PUSH, + FrameType.SETTINGS, + FrameType.PUSH_PROMISE, + FrameType.GOAWAY, + FrameType.MAX_PUSH_ID, + FrameType.DUPLICATE_PUSH, + ): + raise FrameUnexpected( + "Invalid frame type on request stream" + if stream.push_id is None + else "Invalid frame type on push stream" + ) + + return http_events + + def _decode_headers(self, stream_id: int, frame_data: Optional[bytes]) -> Headers: + """ + Decode a HEADERS block and send decoder updates on the decoder stream. + + This is called with frame_data=None when a stream becomes unblocked. + """ + try: + if frame_data is None: + decoder, headers = self._decoder.resume_header(stream_id) + else: + decoder, headers = self._decoder.feed_header(stream_id, frame_data) + self._decoder_bytes_sent += len(decoder) + self._quic.send_stream_data(self._local_decoder_stream_id, decoder) + except pylsqpack.DecompressionFailed as exc: + raise QpackDecompressionFailed() from exc + + return headers diff --git a/hyperscale/core/engines/types/common/results_set.py b/hyperscale/core/engines/types/common/results_set.py new file mode 100644 index 0000000..d2f3d78 --- /dev/null +++ b/hyperscale/core/engines/types/common/results_set.py @@ -0,0 +1,113 @@ +from collections import defaultdict +from typing import Any, Dict, List, Union + +import psutil + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql import GraphQLResult +from hyperscale.core.engines.types.graphql_http2 import GraphQLHTTP2Result +from hyperscale.core.engines.types.grpc import GRPCResult +from hyperscale.core.engines.types.http import HTTPResult +from hyperscale.core.engines.types.http2 import HTTP2Result +from hyperscale.core.engines.types.playwright import PlaywrightResult +from hyperscale.core.engines.types.task import TaskResult +from hyperscale.core.engines.types.udp import UDPResult +from hyperscale.core.engines.types.websocket import WebsocketResult +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.data.serializers import Serializer +from hyperscale.monitoring import CPUMonitor, MemoryMonitor + +ResultsBatch = Dict[str, Union[List[BaseResult], float]] + +MutationConfig = Dict[str, Union[str, float, List[str]]] + +VariantConfig = Dict[str, Union[int, str, float, List[float], MutationConfig]] + +ExperimentConfig = Dict[str, Union[str, bool, VariantConfig]] + +MemoryMonitorGroup = Dict[str, MemoryMonitor] + +CPUMonitorGroup = Dict[str, CPUMonitor] + +MonitorGroup = Dict[str, Union[CPUMonitorGroup, MemoryMonitorGroup]] + + +class ResultsSet: + + def __init__( + self, + execution_results: Dict[str, Union[int, float, List[ResultsBatch], ExperimentConfig]] + ) -> None: + + self.stage: str = execution_results.get('stage') + self.stage_streamed_analytics: Union[List[StreamAnalytics], None] = execution_results.get('streamed_analytics') + + self.total_elapsed: float = execution_results.get('total_elapsed', 0) + self.total_results: int = execution_results.get('total_results', 0) + + self.stage_batch_size = execution_results.get('stage_batch_size', 0) + self.stage_optimized = execution_results.get('stage_optimized', False) + self.stage_persona_type = execution_results.get('stage_persona_type', 'default') + self.stage_workers = execution_results.get( + 'stage_workers', + psutil.cpu_count(logical=False) + ) + + self.results: List[BaseResult] = execution_results.get('stage_results', []) + + self.serialized_results: List[Dict[str, Any]] = execution_results.get('serialized_results', []) + self.experiment = execution_results.get('experiment') + self.serializer = Serializer() + + self.types = { + RequestTypes.GRAPHQL: GraphQLResult, + RequestTypes.GRAPHQL_HTTP2: GraphQLHTTP2Result, + RequestTypes.GRPC: GRPCResult, + RequestTypes.HTTP: HTTPResult, + RequestTypes.HTTP2: HTTP2Result, + RequestTypes.PLAYWRIGHT: PlaywrightResult, + RequestTypes.TASK: TaskResult, + RequestTypes.UDP: UDPResult, + RequestTypes.WEBSOCKET: WebsocketResult + } + + def __iter__(self): + for result in self.results: + yield result + + + def to_serializable(self): + return { + 'total_elapsed': self.total_elapsed, + 'total_results': self.total_results, + 'results': [ + self.serializer.serialize_result(result) for result in self.results + ] + } + + def load_results(self): + self.results = [ + self.serializer.deserialize_result(result) for result in self.serialized_results + ] + + def group(self) -> Dict[str, List[BaseResult]]: + grouped_results = defaultdict(list) + for result in self.results: + grouped_results[result.name].append(result) + + return grouped_results + + def copy(self): + return ResultsSet({ + 'stage': self.stage, + 'streamed_analytics': list(self.stage_streamed_analytics), + 'stage_batch_size': self.stage_batch_size, + 'stage_persona_type': self.stage_persona_type, + 'stage_workers': self.stage_workers, + 'stage_optimized': self.stage_optimized, + 'total_elapsed': self.total_elapsed, + 'total_results': self.total_results, + 'stage_results': list(self.results), + 'serialized_results': list(self.serialized_results) + }) \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/ssl.py b/hyperscale/core/engines/types/common/ssl.py new file mode 100644 index 0000000..6e3b9be --- /dev/null +++ b/hyperscale/core/engines/types/common/ssl.py @@ -0,0 +1,87 @@ +import ssl + + +def get_http2_ssl_context(): + """ + This function creates an SSLContext object that is suitably configured for + HTTP/2. If you're working with Python TLS directly, you'll want to do the + exact same setup as this function does. + """ + # Get the basic context from the standard library. + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + + # RFC 7540 Section 9.2: Implementations of HTTP/2 MUST use TLS version 1.2 + # or higher. Disable TLS 1.1 and lower. + ctx.options |= ( + ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + ) + + # RFC 7540 Section 9.2.1: A deployment of HTTP/2 over TLS 1.2 MUST disable + # compression. + ctx.options |= ssl.OP_NO_COMPRESSION + + # RFC 7540 Section 9.2.2: "deployments of HTTP/2 that use TLS 1.2 MUST + # support TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256". In practice, the + # blocklist defined in this section allows only the AES GCM and ChaCha20 + # cipher suites with ephemeral key negotiation. + ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20") + + # We want to negotiate using NPN and ALPN. ALPN is mandatory, but NPN may + # be absent, so allow that. This setup allows for negotiation of HTTP/1.1. + ctx.set_alpn_protocols(["h2", "http/1.1"]) + + try: + if hasattr(ctx, '_set_npn_protocols'): + ctx.set_npn_protocols(["h2", "http/1.1"]) + except NotImplementedError: + pass + + + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + +def get_graphql_ssl_context(): + # Get the basic context from the standard library. + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + + # RFC 7540 Section 9.2: Implementations of HTTP/2 MUST use TLS version 1.2 + # or higher. Disable TLS 1.1 and lower. + ctx.options |= ( + ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + ) + + # RFC 7540 Section 9.2.1: A deployment of HTTP/2 over TLS 1.2 MUST disable + # compression. + ctx.options |= ssl.OP_NO_COMPRESSION + + # # RFC 7540 Section 9.2.2: "deployments of HTTP/2 that use TLS 1.2 MUST + # # support TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256". In practice, the + # # blocklist defined in this section allows only the AES GCM and ChaCha20 + # # cipher suites with ephemeral key negotiation. + ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20") + + # # # We want to negotiate using NPN and ALPN. ALPN is mandatory, but NPN may + # # # be absent, so allow that. This setup allows for negotiation of HTTP/1.1. + ctx.set_alpn_protocols(["h2", "http/1.1"]) + + try: + if hasattr(ctx, '_set_npn_protocols'): + ctx.set_npn_protocols(["h2", "http/1.1"]) + except NotImplementedError: + pass + + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + + + +def get_default_ssl_context(): + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx diff --git a/hyperscale/core/engines/types/common/timeouts.py b/hyperscale/core/engines/types/common/timeouts.py new file mode 100644 index 0000000..8b9b9d4 --- /dev/null +++ b/hyperscale/core/engines/types/common/timeouts.py @@ -0,0 +1,17 @@ +class Timeouts: + + __slots__ = ( + 'connect_timeout', + 'socket_read_timeout', + 'total_timeout' + ) + + def __init__( + self, + connect_timeout: int=10, + socket_read_timeout: int=10, + total_timeout: int=60 + ) -> None: + self.connect_timeout = connect_timeout + self.socket_read_timeout = socket_read_timeout + self.total_timeout = total_timeout \ No newline at end of file diff --git a/hyperscale/core/engines/types/common/types.py b/hyperscale/core/engines/types/common/types.py new file mode 100644 index 0000000..d3c49f8 --- /dev/null +++ b/hyperscale/core/engines/types/common/types.py @@ -0,0 +1,61 @@ +import socket + + +class SocketTypes: + DEFAULT = socket.AF_INET + HTTP2 = socket.AF_INET + UDP = socket.AF_INET + HTTP3 = socket.AF_INET6 + NONE = None + + +class SocketProtocols: + DEFAULT = socket.SOCK_STREAM + HTTP2 = socket.SOCK_STREAM + HTTP3 = socket.SOCK_DGRAM + UDP = socket.SOCK_DGRAM + NONE = None + + +class RequestTypes: + HTTP = "HTTP" + HTTP2 = "HTTP2" + HTTP3 = "HTTP3" + WEBSOCKET = "WEBSOCKET" + GRAPHQL = "GRAPHQL" + GRAPHQL_HTTP2 = "GRAPHQL_HTTP2" + GRPC = "GRPC" + PLAYWRIGHT = "PLAYWRIGHT" + UDP = "UDP" + TASK = "TASK" + CUSTOM = "CUSTOM" + + +class ProtocolMap: + def __init__(self) -> None: + self.address_families = { + RequestTypes.HTTP: SocketTypes.DEFAULT, + RequestTypes.HTTP2: SocketTypes.HTTP2, + RequestTypes.HTTP3: SocketTypes.HTTP3, + RequestTypes.WEBSOCKET: SocketTypes.DEFAULT, + RequestTypes.GRAPHQL: SocketTypes.DEFAULT, + RequestTypes.GRAPHQL_HTTP2: SocketTypes.HTTP2, + RequestTypes.GRPC: SocketTypes.HTTP2, + RequestTypes.UDP: SocketTypes.UDP, + RequestTypes.PLAYWRIGHT: SocketTypes.NONE, + } + + self.protocols = { + RequestTypes.HTTP: SocketProtocols.DEFAULT, + RequestTypes.HTTP2: SocketProtocols.HTTP2, + RequestTypes.HTTP3: SocketProtocols.HTTP3, + RequestTypes.WEBSOCKET: SocketProtocols.DEFAULT, + RequestTypes.GRAPHQL: SocketProtocols.DEFAULT, + RequestTypes.GRAPHQL_HTTP2: SocketTypes.HTTP2, + RequestTypes.GRPC: SocketProtocols.HTTP2, + RequestTypes.UDP: SocketProtocols.UDP, + RequestTypes.PLAYWRIGHT: SocketProtocols.NONE, + } + + def __getitem__(self, key: RequestTypes) -> SocketTypes: + return self.address_families.get(key), self.protocols.get(key) diff --git a/hyperscale/core/engines/types/common/url.py b/hyperscale/core/engines/types/common/url.py new file mode 100644 index 0000000..1147bb1 --- /dev/null +++ b/hyperscale/core/engines/types/common/url.py @@ -0,0 +1,172 @@ +import aiodns +import socket +from collections import defaultdict +from ipaddress import ip_address, IPv4Address +from urllib.parse import urlparse +from asyncio.events import get_event_loop +from typing import Dict, List +from .types import SocketProtocols, SocketTypes + + +class URL: + + __slots__ = ( + 'resolver', + 'ip_addr', + 'parsed', + 'is_ssl', + 'port', + 'full', + 'has_ip_addr', + 'socket_config', + 'family', + 'protocol', + 'loop' + ) + + def __init__(self, url: str, port: int=80, family: SocketTypes=SocketTypes.DEFAULT, protocol: SocketProtocols=SocketProtocols.DEFAULT) -> None: + self.resolver = aiodns.DNSResolver() + self.ip_addr = None + self.parsed = urlparse(url) + self.is_ssl = 'https' in url or 'wss' in url + + if self.is_ssl: + port = 443 + + self.port = self.parsed.port if self.parsed.port else port + self.full = url + self.has_ip_addr = False + self.socket_config = None + self.family = family + self.protocol = protocol + self.loop = None + + async def replace(self, url: str): + self.full = url + self.params = urlparse(url) + + async def lookup(self): + + if self.loop is None: + self.loop = get_event_loop() + + infos: Dict[str, List] = defaultdict(list) + + if self.parsed.hostname is None: + + try: + + address = self.full.split(':') + assert len(address) == 2 + + host, port = address + self.port = int(port) + + if isinstance(ip_address(host), IPv4Address): + socket_type = socket.AF_INET + + else: + socket_type = socket.AF_INET6 + + infos[self.full] = [( + socket_type, + self.protocol, + None, + None, + ( + host, + self.port + ) + )] + + except Exception as parse_error: + raise parse_error + + else: + + resolved = await self.resolver.gethostbyname(self.parsed.hostname, self.family) + + for address in resolved.addresses: + + if isinstance(ip_address(address), IPv4Address): + socket_type = socket.AF_INET + + else: + socket_type = socket.AF_INET6 + + info = await self.loop.getaddrinfo( + address, + self.port, + family=self.family, + proto=0, + flags=0 + ) + + if infos.get(address) is None: + infos[address] = [*info] + + else: + infos[address].extend(info) + + return infos + + @property + def params(self): + return self.parsed.params + + @params.setter + def params(self, value: str): + self.parsed = self.parsed._replace(params=value) + + @property + def scheme(self): + return self.parsed.scheme + + @scheme.setter + def scheme(self, value): + self.parsed = self.parsed._replace(scheme=value) + + @property + def hostname(self): + return self.parsed.hostname + + @hostname.setter + def hostname(self, value): + self.parsed = self.parsed._replace(hostname=value) + + @property + def path(self): + url_path = self.parsed.path + url_query = self.parsed.query + url_params = self.parsed.params + + if url_path and len(url_path) == 0: + url_path = "/" + + if url_query and len(url_query) > 0: + url_path += f'?{self.parsed.query}' + + elif url_params and len(url_params) > 0: + url_path += self.parsed.params + + return url_path + + @path.setter + def path(self, value): + self.parsed = self.parsed._replace(path=value) + + @property + def query(self): + return self.parsed.query + + @query.setter + def query(self, value): + self.parsed = self.parsed._replace(query=value) + + @property + def authority(self): + return self.parsed.hostname + + @authority.setter + def authority(self, value): + self.parsed = self.parsed._replace(hostname=value) \ No newline at end of file diff --git a/hyperscale/core/engines/types/custom/__init__.py b/hyperscale/core/engines/types/custom/__init__.py new file mode 100644 index 0000000..2cbc74b --- /dev/null +++ b/hyperscale/core/engines/types/custom/__init__.py @@ -0,0 +1 @@ +from .client import MercuryCustomClient \ No newline at end of file diff --git a/hyperscale/core/engines/types/custom/client.py b/hyperscale/core/engines/types/custom/client.py new file mode 100644 index 0000000..1de2def --- /dev/null +++ b/hyperscale/core/engines/types/custom/client.py @@ -0,0 +1,190 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Dict, TypeVar + +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.concurrency import Semaphore +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.plugins.types.common.types import PluginHooks +from hyperscale.plugins.types.engine.action import Action +from hyperscale.plugins.types.engine.result import Result + +from .connection import CustomConnection +from .pool import CustomPool + +A = TypeVar('A') +R = TypeVar('R') + +class MercuryCustomClient(BaseEngine[A, R]): + + __slots__ = ( + 'session_id', + 'timeouts', + 'registered', + '_hosts', + 'closed', + 'sem', + 'active', + 'waiter', + 'plugin', + '_on_connect', + '_on_execute', + '_on_close', + 'custom_connection', + 'pool' + ) + + def __init__( + self, + plugin: Any, + concurrency: int=10**3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool=False + ) -> None: + + self.session_id = str(uuid.uuid4()) + self.timeouts = timeouts + + self.registered: Dict[str, CustomConnection] = {} + self._hosts = {} + self.closed = False + + self.sem = asyncio.Semaphore(value=concurrency) + + self.active = 0 + self.waiter: asyncio.Future = None + self.plugin: Any = plugin + + self._on_connect = self.plugin.hooks.get(PluginHooks.ON_ENGINE_CONNECT) + self._on_execute = self.plugin.hooks.get(PluginHooks.ON_ENGINE_EXECUTE) + self._on_close = self.plugin.hooks.get(PluginHooks.ON_ENGINE_CLOSE) + + self.custom_connection: CustomConnection = lambda reset_connection: CustomConnection( + security_context=self.plugin.security_context, + reset_connection=reset_connection, + on_connect=self._on_connect.call, + on_execute=self._on_execute.call, + on_close=self._on_close.call + ) + + self.pool = CustomPool( + self.custom_connection, + concurrency, + reset_connections=reset_connections + ) + + self.pool.create_pool() + + async def set_pool(self, concurrency: int): + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = CustomPool( + self.custom_connection, + concurrency, + reset_connections=self.pool.reset_connections + ) + self.pool.create_pool() + + def extend_pool(self, increased_capacity: int): + self.pool.size += increased_capacity + for _ in range(increased_capacity): + self.pool.connections.append( + self.custom_connection(self.pool.reset_connections) + ) + + self.sem = Semaphore(self.pool.size) + + def shrink_pool(self, decrease_capacity: int): + self.pool.size -= decrease_capacity + self.pool.connections = self.pool.connections[:self.pool.size] + self.sem = Semaphore(self.pool.size) + + async def prepare(self, action: Action[A]) -> Coroutine[Any, Any, None]: + try: + connection: CustomConnection = self.custom_connection(self.pool.reset_connections) + + if action.use_security_context: + action.security_context = connection.security_context + + await asyncio.wait_for( + connection.make_connection(action), + timeout=self.timeouts.connect_timeout + ) + + if action.is_setup is False: + action.setup() + + self.registered[action.name] = action + + except Exception as e: + raise e + + async def execute_prepared_request(self, action: Action[A]) -> Coroutine[Any, Any, Result[R]]: + + result: Result[R] = self.plugin.result(action) + + result.times['wait_start'] = time.monotonic() + self.active += 1 + + async with self.sem: + connection = self.pool.connections.pop() + + try: + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action: Action[A] = await self.execute_before(action) + action.setup() + + result.times['start'] = time.monotonic() + + result = await asyncio.wait_for( + connection.execute(action, result), + timeout=self.timeouts.total_timeout + ) + + result.times['complete'] = time.monotonic() + + self.pool.connections.append(connection) + + if action.hooks.after: + result: Result[R] = await self.execute_after(action, result) + action.setup() + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel(result, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + except Exception as e: + result.times['complete'] = time.monotonic() + result.error = str(e) + + self.pool.connections.append( + self.custom_connection(self.pool.reset_connections) + ) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + return result \ No newline at end of file diff --git a/hyperscale/core/engines/types/custom/connection.py b/hyperscale/core/engines/types/custom/connection.py new file mode 100644 index 0000000..be38d0a --- /dev/null +++ b/hyperscale/core/engines/types/custom/connection.py @@ -0,0 +1,31 @@ +from typing import Any, Coroutine + + +class CustomConnection: + + __slots__ = ( + "security_context", + "reset_connection", + "connected", + "pending", + "make_connection", + "execute", + "close", + ) + + def __init__( + self, + security_context: Any, + reset_connection: bool=False, + on_connect: Coroutine=None, + on_execute: Coroutine=None, + on_close: Coroutine=None + ) -> None: + self.security_context = security_context + self.reset_connection = reset_connection + self.connected = False + self.pending = 0 + + self.make_connection = on_connect + self.execute = on_execute + self.close = on_close diff --git a/hyperscale/core/engines/types/custom/pool.py b/hyperscale/core/engines/types/custom/pool.py new file mode 100644 index 0000000..aa66782 --- /dev/null +++ b/hyperscale/core/engines/types/custom/pool.py @@ -0,0 +1,28 @@ +from typing import List, Type +from .connection import CustomConnection + + +class CustomPool: + + __slots__ = ( + 'size', + 'connections', + 'reset_connections', + 'create_connection_type' + ) + + def __init__(self, create_connection_type: Type[CustomConnection], size: int, reset_connections: bool = False) -> None: + self.size = size + self.connections: List[CustomConnection] = [] + self.reset_connections = reset_connections + self.create_connection_type: CustomConnection = create_connection_type + + def create_pool(self) -> None: + for _ in range(self.size): + self.connections.append( + self.create_connection_type(self.reset_connections) + ) + + async def close(self): + for connection in self.connections: + await connection.close() \ No newline at end of file diff --git a/hyperscale/core/engines/types/graphql/__init__.py b/hyperscale/core/engines/types/graphql/__init__.py new file mode 100644 index 0000000..581aba0 --- /dev/null +++ b/hyperscale/core/engines/types/graphql/__init__.py @@ -0,0 +1,3 @@ +from .client import MercuryGraphQLClient +from .action import GraphQLAction +from .result import GraphQLResult \ No newline at end of file diff --git a/hyperscale/core/engines/types/graphql/action.py b/hyperscale/core/engines/types/graphql/action.py new file mode 100644 index 0000000..4001d15 --- /dev/null +++ b/hyperscale/core/engines/types/graphql/action.py @@ -0,0 +1,68 @@ +import json +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http.action import HTTPAction + +try: + from graphql import Source, parse, print_ast + +except ImportError: + Source=None + parse=lambda: None + print_ast=lambda: None + + +class GraphQLAction(HTTPAction): + + def __init__( + self, + name: str, + url: str, + method: str = 'GET', + headers: Dict[str, str] = {}, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str=None, + tags: List[Dict[str, str]] = [], + redirects: int=3 + ) -> None: + + super( + GraphQLAction, + self + ).__init__( + name, + url, + method, + headers, + data, + user, + tags + ) + + self.type = RequestTypes.GRAPHQL + self.redirects = redirects + self.hooks: Hooks[GraphQLAction] = Hooks() + + def _setup_data(self) -> None: + source = Source(self._data.get("query")) + document_node = parse(source) + query_string = print_ast(document_node) + + self.size = len(query_string) + + query = { + "query": query_string + } + + operation_name = self._data.get("operation_name") + variables = self._data.get("variables") + + if operation_name: + query["operationName"] = operation_name + + if variables: + query["variables"] = variables + + self.encoded_data = json.dumps(query).encode() \ No newline at end of file diff --git a/hyperscale/core/engines/types/graphql/client.py b/hyperscale/core/engines/types/graphql/client.py new file mode 100644 index 0000000..bfc108f --- /dev/null +++ b/hyperscale/core/engines/types/graphql/client.py @@ -0,0 +1,327 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Optional, Union + +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.http import MercuryHTTPClient +from hyperscale.core.engines.types.http.connection import HTTPConnection +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession + +from .action import GraphQLAction +from .result import GraphQLResult + + +class MercuryGraphQLClient(MercuryHTTPClient[GraphQLAction, GraphQLResult]): + + def __init__( + self, + concurrency: int = 10 ** 3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + tracing_session: Optional[TraceSession]=None + ) -> None: + + super( + MercuryGraphQLClient, + self + ).__init__( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections, + tracing_session=tracing_session + ) + + self.session_id = str(uuid.uuid4()) + + async def execute_prepared_request(self, action: GraphQLAction) -> Coroutine[Any, Any, GraphQLResult]: + + trace: Union[Trace, None] = None + if self.tracing_session: + trace = self.tracing_session.create_trace() + await trace.on_request_start(action) + + response = GraphQLResult(action) + response.wait_start = time.monotonic() + self.active += 1 + + if trace and trace.on_connection_queued_start: + await trace.on_connection_queued_start( + trace.span, + action, + response + ) + + async with self.sem: + + if trace and trace.on_connection_queued_end: + await trace.on_connection_queued_end( + trace.span, + action, + response + ) + + try: + + connection = self.pool.connections.pop() + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + if trace and trace.on_connection_create_start: + await trace.on_connection_create_start( + trace.span, + action, + response + ) + + + await connection.make_connection( + action.url.hostname, + action.url.ip_addr, + action.url.port, + action.url.socket_config, + timeout=self.timeouts.connect_timeout, + ssl=action.ssl_context + ) + + response.connect_end = time.monotonic() + + if trace and trace.on_connection_create_end: + await trace.on_connection_create_end( + trace.span, + action, + response + ) + + connection.write(action.encoded_headers) + + if trace and trace.on_request_headers_sent: + await trace.on_request_headers_sent( + trace.span, + action, + response + ) + + if action.encoded_data: + if action.is_stream: + action.write_chunks( + connection, + trace + ) + + else: + connection.write(action.encoded_data) + + response.write_end = time.monotonic() + + if action.encoded_data and trace and trace.on_request_data_sent: + await trace.on_request_data_sent( + trace.span, + action, + response + ) + + response.response_code = await asyncio.wait_for( + connection.reader.readline_fast(), + timeout=self.timeouts.socket_read_timeout + ) + + headers = await asyncio.wait_for( + connection.read_headers(), + timeout=self.timeouts.socket_read_timeout + ) + + if trace and trace.on_response_headers_received: + await trace.on_response_headers_received( + trace.span, + action, + response + ) + + status = response.status + + if status >= 300 and status < 400: + + if trace and trace.on_request_redirect: + await trace.on_request_redirect( + trace.span, + action, + response + ) + + elapsed_time = 0 + redirect_time_start = time.time() + + for _ in range(action.redirects): + + if elapsed_time > self.timeouts.total_timeout: + response.status = 408 + raise Exception('Request timed out while redirecting.') + + redirect_url = str(headers.get(b'location')) + if redirect_url.startswith('http') is False: + action.url.path = redirect_url + action.encoded_headers = None + action.setup() + + else: + await action.url.replace(redirect_url) + action.encoded_headers = None + action.is_setup = False + await self.prepare(action) + + await connection.make_connection( + action.url.hostname, + action.url.ip_addr, + action.url.port, + action.url.socket_config, + timeout=self.timeouts.connect_timeout, + ssl=action.ssl_context + ) + + response.connect_end = time.monotonic() + + connection.write(action.encoded_headers) + + if action.encoded_data: + if action.is_stream: + action.write_chunks(connection) + + else: + connection.write(action.encoded_data) + + response.write_end = time.monotonic() + + response.response_code = await asyncio.wait_for( + connection.reader.readline_fast(), + timeout=self.timeouts.socket_read_timeout + ) + + headers = await asyncio.wait_for( + connection.read_headers(), + timeout=self.timeouts.socket_read_timeout + ) + + status = response.status + + if status >= 200 and status < 300: + break + + elapsed_time = time.time() - redirect_time_start + + content_length = headers.get(b'content-length') + transfer_encoding = headers.get(b'transfer-encoding') + + # We require Content-Length or Transfer-Encoding headers to read a + # request body, otherwise it's anyone's guess as to how big the body + # is, and we ain't playing that game. + body = bytearray() + if content_length: + body = await asyncio.wait_for( + connection.readexactly(int(content_length)), + timeout=self.timeouts.socket_read_timeout + ) + + elif transfer_encoding: + + all_chunks_read = False + + while True and not all_chunks_read: + + chunk_size = int((await connection.readuntil()).rstrip(), 16) + + if not chunk_size: + # read last CRLF + body.extend( + await asyncio.wait_for( + connection.readuntil(), + timeout=self.timeouts.socket_read_timeout + ) + ) + + break + + chunk = await asyncio.wait_for( + connection.readexactly(chunk_size + 2), + timeout=self.timeouts.socket_read_timeout + ) + + body.extend( + chunk[:-2] + ) + + if trace and trace.on_response_chunk_received: + await trace.on_response_chunk_received( + trace.span, + action, + response + ) + + all_chunks_read = True + + response.complete = time.monotonic() + + if trace and trace.on_response_data_received: + await trace.on_response_data_received( + trace.span, + action, + response + ) + + response.headers = headers + response.body = body + self.pool.connections.append(connection) + + if action.hooks.after: + response = await self.execute_after(action, response) + action.setup() + + if action.hooks.checks: + response = await self.execute_checks(action, response) + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + except Exception as e: + response.complete = time.monotonic() + response.error = str(e) + + self.pool.connections.append(HTTPConnection(reset_connection=self.pool.reset_connections)) + + if trace and trace.on_request_exception: + await trace.on_request_exception(response) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + if trace and trace.on_request_end: + await trace.on_request_end(response) + + return response \ No newline at end of file diff --git a/hyperscale/core/engines/types/graphql/result.py b/hyperscale/core/engines/types/graphql/result.py new file mode 100644 index 0000000..7776b28 --- /dev/null +++ b/hyperscale/core/engines/types/graphql/result.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http.result import HTTPResult + +from .action import GraphQLAction + + +class GraphQLResult(HTTPResult): + + def __init__(self, action: GraphQLAction, error: Exception = None) -> None: + super().__init__(action, error) + + self.type = RequestTypes.GRAPHQL diff --git a/hyperscale/core/engines/types/graphql_http2/__init__.py b/hyperscale/core/engines/types/graphql_http2/__init__.py new file mode 100644 index 0000000..488b7fc --- /dev/null +++ b/hyperscale/core/engines/types/graphql_http2/__init__.py @@ -0,0 +1,3 @@ +from .client import MercuryGraphQLHTTP2Client +from .action import GraphQLHTTP2Action +from .result import GraphQLHTTP2Result \ No newline at end of file diff --git a/hyperscale/core/engines/types/graphql_http2/action.py b/hyperscale/core/engines/types/graphql_http2/action.py new file mode 100644 index 0000000..fc777c9 --- /dev/null +++ b/hyperscale/core/engines/types/graphql_http2/action.py @@ -0,0 +1,66 @@ +import json +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.action import HTTP2Action + +try: + from graphql import Source, parse, print_ast + +except ImportError: + Source=None + parse=lambda: None + print_ast=lambda: None + + +class GraphQLHTTP2Action(HTTP2Action): + + def __init__( + self, + name: str, + url: str, + method: str = 'GET', + headers: Dict[str, str] = {}, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str=None, + tags: List[Dict[str, str]] = [] + ) -> None: + + super( + GraphQLHTTP2Action, + self + ).__init__( + name, + url, + method, + headers, + data, + user, + tags + ) + + self.type = RequestTypes.GRAPHQL_HTTP2 + self.hooks: Hooks[GraphQLHTTP2Action] = Hooks() + + def _setup_data(self) -> None: + source = Source(self._data.get("query")) + document_node = parse(source) + query_string = print_ast(document_node) + + self.size = len(query_string) + + query = { + "query": query_string + } + + operation_name = self._data.get("operation_name") + variables = self._data.get("variables") + + if operation_name: + query["operationName"] = operation_name + + if variables: + query["variables"] = variables + + self.encoded_data = json.dumps(query).encode() \ No newline at end of file diff --git a/hyperscale/core/engines/types/graphql_http2/client.py b/hyperscale/core/engines/types/graphql_http2/client.py new file mode 100644 index 0000000..de9f650 --- /dev/null +++ b/hyperscale/core/engines/types/graphql_http2/client.py @@ -0,0 +1,183 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Optional, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.http2.client import MercuryHTTP2Client +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession + +from .action import GraphQLHTTP2Action +from .result import GraphQLHTTP2Result + + +class MercuryGraphQLHTTP2Client(MercuryHTTP2Client[GraphQLHTTP2Action, GraphQLHTTP2Result]): + + def __init__( + self, + concurrency: int = 10 ** 3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + tracing_session: Optional[TraceSession]=None + ) -> None: + + super( + MercuryGraphQLHTTP2Client, + self + ).__init__( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections, + tracing_session=tracing_session + ) + + self.session_id = str(uuid.uuid4()) + + async def execute_prepared_request(self, action: GraphQLHTTP2Action) -> Coroutine[Any, Any, GraphQLHTTP2Result]: + + trace: Union[Trace, None] = None + if self.tracing_session: + trace = self.tracing_session.create_trace() + await trace.on_request_start(action) + + response = GraphQLHTTP2Result(action) + response.wait_start = time.monotonic() + self.active += 1 + + if trace and trace.on_connection_queued_start: + await trace.on_connection_queued_start( + trace.span, + action, + response + ) + + async with self.sem: + + if trace and trace.on_connection_queued_end: + await trace.on_connection_queued_end( + trace.span, + action, + response + ) + + pipe = self.pool.pipes.pop() + connection = self.pool.connections.pop() + + try: + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action: GraphQLHTTP2Action = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + if trace and trace.on_connection_create_start: + await trace.on_connection_create_start( + trace.span, + action, + response + ) + + stream = await connection.connect( + action.url.hostname, + action.url.ip_addr, + action.url.port, + action.url.socket_config, + ssl=action.ssl_context, + timeout=self.timeouts.connect_timeout + ) + + stream.encoder = action.hpack_encoder + + response.connect_end = time.monotonic() + + if trace and trace.on_connection_create_end: + await trace.on_connection_create_end( + trace.span, + action, + response + ) + + pipe.send_request_headers(action, stream) + + if trace and trace.on_request_headers_sent: + await trace.on_request_headers_sent( + trace.span, + action, + response + ) + + if action.encoded_data is not None: + await pipe.submit_request_body(action, stream) + + response.write_end = time.monotonic() + + if action.encoded_data and trace and trace.on_request_data_sent: + await trace.on_request_data_sent( + trace.span, + action, + response + ) + + await asyncio.wait_for( + pipe.receive_response( + action, + response, + stream, + trace + ), + timeout=self.timeouts.total_timeout + ) + + response.complete = time.monotonic() + + if action.hooks.after: + response: GraphQLHTTP2Result = await self.execute_after(action, response) + action.setup() + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + self.pool.pipes.append(pipe) + self.pool.connections.append(connection) + + except Exception as e: + response.complete = time.monotonic() + response._status = 400 + response.error = str(e) + + self.pool.reset() + + if trace and trace.on_request_exception: + await trace.on_request_exception(response) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + if trace and trace.on_request_end: + await trace.on_request_end(response) + + return response \ No newline at end of file diff --git a/hyperscale/core/engines/types/graphql_http2/result.py b/hyperscale/core/engines/types/graphql_http2/result.py new file mode 100644 index 0000000..064f18b --- /dev/null +++ b/hyperscale/core/engines/types/graphql_http2/result.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.result import HTTP2Result + +from .action import GraphQLHTTP2Action + + +class GraphQLHTTP2Result(HTTP2Result): + + def __init__(self, action: GraphQLHTTP2Action, error: Exception = None) -> None: + super().__init__(action, error) + self.type = RequestTypes.GRAPHQL_HTTP2 diff --git a/hyperscale/core/engines/types/grpc/__init__.py b/hyperscale/core/engines/types/grpc/__init__.py new file mode 100644 index 0000000..6f48222 --- /dev/null +++ b/hyperscale/core/engines/types/grpc/__init__.py @@ -0,0 +1,3 @@ +from .client import MercuryGRPCClient +from .action import GRPCAction +from .result import GRPCResult \ No newline at end of file diff --git a/hyperscale/core/engines/types/grpc/action.py b/hyperscale/core/engines/types/grpc/action.py new file mode 100644 index 0000000..ec90f73 --- /dev/null +++ b/hyperscale/core/engines/types/grpc/action.py @@ -0,0 +1,56 @@ +import binascii +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.action import HTTP2Action + + +class GRPCAction(HTTP2Action): + + def __init__( + self, + name: str, + url: str, + method: str = 'GET', + headers: Dict[str, str] = {}, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str=None, + tags: List[Dict[str, str]] = [] + ) -> None: + + super( + GRPCAction, + self + ).__init__( + name, + url, + method, + headers, + data, user, + tags + ) + + self.timeout = 60 + self.type = RequestTypes.GRPC + self.hooks: Hooks[GRPCAction] = Hooks() + + def _setup_headers(self): + grpc_headers = { + 'Content-Type': 'application/grpc', + 'Grpc-Timeout': f'{self.timeout}S', + 'TE': 'trailers' + } + self._headers.update(grpc_headers) + + super( + GRPCAction, + self + )._setup_headers() + + def _setup_data(self) -> None: + encoded_protobuf = str(binascii.b2a_hex(self.data.SerializeToString()), encoding='raw_unicode_escape') + encoded_message_length = hex(int(len(encoded_protobuf)/2)).lstrip("0x").zfill(8) + encoded_protobuf = f'00{encoded_message_length}{encoded_protobuf}' + + self.encoded_data = binascii.a2b_hex(encoded_protobuf) \ No newline at end of file diff --git a/hyperscale/core/engines/types/grpc/client.py b/hyperscale/core/engines/types/grpc/client.py new file mode 100644 index 0000000..bc72aee --- /dev/null +++ b/hyperscale/core/engines/types/grpc/client.py @@ -0,0 +1,182 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Optional, Union + +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.http2 import MercuryHTTP2Client +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession + +from .action import GRPCAction +from .result import GRPCResult + + +class MercuryGRPCClient(MercuryHTTP2Client[GRPCAction, GRPCResult]): + + def __init__( + self, + concurrency: int = 10 ** 3, + timeouts: Timeouts = None, + reset_connections: bool=False, + tracing_session: Optional[TraceSession]=None + ) -> None: + super( + MercuryGRPCClient, + self + ).__init__( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections, + tracing_session=tracing_session + ) + + self.session_id = str(uuid.uuid4()) + + async def execute_prepared_request(self, action: GRPCAction) -> Coroutine[Any, Any, GRPCResult]: + + trace: Union[Trace, None] = None + if self.tracing_session: + trace = self.tracing_session.create_trace() + await trace.on_request_start(action) + + response = GRPCResult(action) + response.wait_start = time.monotonic() + self.active += 1 + + if trace and trace.on_connection_queued_start: + await trace.on_connection_queued_start( + trace.span, + action, + response + ) + + async with self.sem: + + if trace and trace.on_connection_queued_end: + await trace.on_connection_queued_end( + trace.span, + action, + response + ) + + pipe = self.pool.pipes.pop() + connection = self.pool.connections.pop() + + try: + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action: GRPCAction = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + if trace and trace.on_connection_create_start: + await trace.on_connection_create_start( + trace.span, + action, + response + ) + + stream = await connection.connect( + action.url.hostname, + action.url.ip_addr, + action.url.port, + action.url.socket_config, + ssl=action.ssl_context, + timeout=self.timeouts.connect_timeout + ) + + stream.encoder = action.hpack_encoder + + response.connect_end = time.monotonic() + + if trace and trace.on_connection_create_end: + await trace.on_connection_create_end( + trace.span, + action, + response + ) + + pipe.send_request_headers(action, stream) + + if trace and trace.on_request_headers_sent: + await trace.on_request_headers_sent( + trace.span, + action, + response + ) + + if action.encoded_data is not None: + await pipe.submit_request_body(action, stream) + + response.write_end = time.monotonic() + + if action.encoded_data and trace and trace.on_request_data_sent: + await trace.on_request_data_sent( + trace.span, + action, + response + ) + + await asyncio.wait_for( + pipe.receive_response( + action, + response, + stream, + trace + ), + timeout=self.timeouts.total_timeout + ) + + response.complete = time.monotonic() + + if action.hooks.after: + response: GRPCResult = await self.execute_after(action, response) + action.setup() + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + self.pool.pipes.append(pipe) + self.pool.connections.append(connection) + + except Exception as e: + response.complete = time.monotonic() + response._status = 400 + response.error = str(e) + + self.pool.reset() + + if trace and trace.on_request_exception: + await trace.on_request_exception(response) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + if trace and trace.on_request_end: + await trace.on_request_end(response) + + return response diff --git a/hyperscale/core/engines/types/grpc/protobuf_registry.py b/hyperscale/core/engines/types/grpc/protobuf_registry.py new file mode 100644 index 0000000..5cbaf2b --- /dev/null +++ b/hyperscale/core/engines/types/grpc/protobuf_registry.py @@ -0,0 +1,23 @@ +from typing import Dict, Any + + +class ProtobufRegistry: + + def __init__(self) -> None: + self._protobufs: Dict[str, Any] + + def __getitem__(self, action_name : str) -> Any: + return self._protobufs.get(action_name) + + def __setitem__(self, action_name: str, protobuf: Any): + self._protobufs[action_name] = protobuf + + def get(self, action_name) -> Any: + return self._protobufs.get(Any) + + +def make_protobuf_registry(): + return ProtobufRegistry() + + +protobuf_registry = make_protobuf_registry() \ No newline at end of file diff --git a/hyperscale/core/engines/types/grpc/result.py b/hyperscale/core/engines/types/grpc/result.py new file mode 100644 index 0000000..4ead7e5 --- /dev/null +++ b/hyperscale/core/engines/types/grpc/result.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import binascii + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.result import HTTP2Result + +from .action import GRPCAction + + +class GRPCResult(HTTP2Result): + + def __init__(self, action: GRPCAction, error: Exception = None) -> None: + super(GRPCResult, self).__init__(action, error) + self.type = RequestTypes.GRPC + + @property + def data(self): + wire_msg = binascii.b2a_hex(self.body) + + message_length = wire_msg[4:10] + msg = wire_msg[10:10+int(message_length, 16)*2] + + return binascii.a2b_hex(msg) + + @data.setter + def data(self, value): + self.body = value + + def to_protobuf(self, protobuf): + protobuf.ParseFromString(self.data) + return protobuf diff --git a/hyperscale/core/engines/types/http/__init__.py b/hyperscale/core/engines/types/http/__init__.py new file mode 100644 index 0000000..af86593 --- /dev/null +++ b/hyperscale/core/engines/types/http/__init__.py @@ -0,0 +1,3 @@ +from .client import MercuryHTTPClient +from .action import HTTPAction +from .result import HTTPResult \ No newline at end of file diff --git a/hyperscale/core/engines/types/http/action.py b/hyperscale/core/engines/types/http/action.py new file mode 100644 index 0000000..e542c73 --- /dev/null +++ b/hyperscale/core/engines/types/http/action.py @@ -0,0 +1,162 @@ +import json +from typing import Any, Dict, Iterator, List, Tuple, Union +from urllib.parse import urlencode + +from hyperscale.core.engines.types.common import URL +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.constants import NEW_LINE +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer +from hyperscale.core.engines.types.common.types import RequestTypes + + +class HTTPAction(BaseAction): + + __slots__ = ( + 'name', + 'action_id', + 'method', + 'listeners', + 'type', + 'url', + 'protocols', + '_headers', + '_data', + 'encoded_data', + 'encoded_headers', + 'is_stream', + 'ssl_context', + 'redirects', + 'action_args', + 'mutations', + '_header_items' + ) + + def __init__( + self, + name: str, + url: str, + method: str = 'GET', + headers: Dict[str, str] = {}, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str=None, + tags: List[Dict[str, str]] = [], + redirects: int=3 + ) -> None: + super(HTTPAction, self).__init__( + name, + user, + tags + ) + + self.method = method.upper() + self.type = RequestTypes.HTTP + + address_family, protocol = self.protocols[self.type] + self.url = URL(url, family=address_family, protocol=protocol) + + self._headers = headers + self._header_items: List[Tuple[str, str]] = list(headers.items()) + self._data = data + + self.encoded_data = None + self.encoded_headers = None + self.is_stream = False + self.ssl_context = None + self.redirects = redirects + self.hooks: Hooks[HTTPAction] = Hooks() + self.action_args: Dict[str, Any] = {} + + @property + def size(self): + if self.encoded_data: + return len(self.encoded_data) + + else: + return 0 + + @property + def data(self): + return self._data + + @data.setter + def data(self, value): + self._data = value + self.encoded_data = None + + @property + def headers(self): + return self._headers + + @headers.setter + def headers(self, value: Dict[str, str]): + self._headers = value + self._header_items: List[Tuple[str, str]] = list(value.items()) + self.encoded_headers = None + + def setup(self): + + if self.encoded_data is None: + self._setup_data() + + if self.encoded_headers is None: + self._setup_headers() + + def _setup_data(self): + if self._data: + if isinstance(self._data, Iterator): + chunks = [] + for chunk in self._data: + chunk_size = hex(len(chunk)).replace("0x", "") + NEW_LINE + encoded_chunk = chunk_size.encode() + chunk + NEW_LINE.encode() + self.size += len(encoded_chunk) + chunks.append(encoded_chunk) + + self.is_stream = True + self.encoded_data = chunks + + else: + + if isinstance(self._data, dict): + self.encoded_data = json.dumps( + self._data + ).encode() + + elif isinstance(self._data, tuple): + self.encoded_data = urlencode( + self._data + ).encode() + + elif isinstance(self._data, str): + self.encoded_data = self._data.encode() + + def _setup_headers(self) -> Union[bytes, Dict[str, str]]: + + get_base = f"{self.method} {self.url.path} HTTP/1.1{NEW_LINE}" + + port = self.url.port or (443 if self.url.scheme == "https" else 80) + + hostname = self.url.parsed.hostname.encode("idna").decode() + + if port not in [80, 443]: + hostname = f'{hostname}:{port}' + + header_items = [ + ("HOST", hostname), + ("User-Agent", "mercury-http"), + ("Keep-Alive", "timeout=60, max=100000"), + ("Content-Length", self.size) + ] + + header_items.extend(self._header_items) + + for key, value in header_items: + get_base += f"{key}: {value}{NEW_LINE}" + + self.encoded_headers = (get_base + NEW_LINE).encode() + + def write_chunks(self, writer: Writer): + for chunk in self.data: + writer.write(chunk) + + writer.write(("0" + NEW_LINE * 2).encode()) diff --git a/hyperscale/core/engines/types/http/client.py b/hyperscale/core/engines/types/http/client.py new file mode 100644 index 0000000..52ea2b1 --- /dev/null +++ b/hyperscale/core/engines/types/http/client.py @@ -0,0 +1,449 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Dict, Optional, TypeVar, Union + +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.concurrency import Semaphore +from hyperscale.core.engines.types.common.ssl import get_default_ssl_context +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger + +from .action import HTTPAction +from .connection import HTTPConnection +from .pool import Pool +from .result import HTTPResult + +A = TypeVar('A') +R = TypeVar('R') + + +class MercuryHTTPClient(BaseEngine[Union[A, HTTPAction], Union[R, HTTPResult]]): + + __slots__ = ( + 'session_id', + 'timeouts', + 'registered', + '_hosts', + 'closed', + 'sem', + 'pool', + 'active', + 'waiter', + 'ssl_context', + 'logger', + 'tracing_session' + ) + + def __init__( + self, + concurrency: int=10**3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool=False, + tracing_session: Optional[TraceSession]=None + ) -> None: + super( + MercuryHTTPClient, + self + ).__init__() + + self.session_id = str(uuid.uuid4()) + self.timeouts = timeouts + + self.registered: Dict[str, HTTPAction] = {} + self._hosts = {} + self.closed = False + + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=reset_connections) + self.tracing_session: Union[TraceSession, None] = tracing_session + self.logger = HyperscaleLogger() + self.logger.initialize() + self.pool.create_pool() + + self.active = 0 + self.waiter = None + + self.ssl_context = get_default_ssl_context() + + def config_to_dict(self): + return { + 'concurrency': self.pool.size, + 'timeouts': { + 'connect_timeout': self.timeouts.connect_timeout, + 'socket_read_timeout': self.timeouts.socket_read_timeout, + 'total_timeout': self.timeouts.total_timeout + }, + 'reset_connections': self.pool.reset_connections + } + + async def set_pool(self, concurrency: int): + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=self.pool.reset_connections) + self.pool.create_pool() + + def extend_pool(self, increased_capacity: int): + self.pool.size += increased_capacity + for _ in range(increased_capacity): + self.pool.connections.append( + HTTPConnection(self.pool.reset_connections) + ) + + self.sem = Semaphore(self.pool.size) + + def shrink_pool(self, decrease_capacity: int): + self.pool.size -= decrease_capacity + self.pool.connections = self.pool.connections[:self.pool.size] + self.sem = Semaphore(self.pool.size) + + async def prepare(self, action: HTTPAction) -> Coroutine[Any, Any, None]: + try: + if action.url.is_ssl: + action.ssl_context = self.ssl_context + + if self._hosts.get(action.url.hostname) is None: + socket_configs = await asyncio.wait_for(action.url.lookup(), timeout=self.timeouts.connect_timeout) + + for ip_addr, configs in socket_configs.items(): + for config in configs: + + connection = HTTPConnection() + + try: + await connection.make_connection( + action.url.hostname, + ip_addr, + action.url.port, + config, + ssl=action.ssl_context, + timeout=self.timeouts.connect_timeout + ) + + action.url.socket_config = config + action.url.ip_addr = ip_addr + action.url.has_ip_addr = True + break + + except Exception: + pass + + if action.url.socket_config: + break + + if action.url.socket_config is None: + raise Exception('Err. - No socket found.') + + self._hosts[action.url.hostname] = { + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config + } + + else: + host_config = self._hosts[action.url.hostname] + action.url.ip_addr = host_config.get('ip_addr') + action.url.socket_config = host_config.get('socket_config') + + if action.is_setup is False: + action.setup() + + self.registered[action.name] = action + + except Exception as e: + raise e + + async def execute_prepared_request(self, action: HTTPAction) -> Coroutine[Any, Any, HTTPResult]: + trace: Union[Trace, None] = None + if self.tracing_session: + trace = self.tracing_session.create_trace() + await trace.on_request_start(action) + + response = HTTPResult(action) + response.wait_start = time.monotonic() + self.active += 1 + + if trace and trace.on_connection_queued_start: + await trace.on_connection_queued_start( + trace.span, + action, + response + ) + + async with self.sem: + + if trace and trace.on_connection_queued_end: + await trace.on_connection_queued_end( + trace.span, + action, + response + ) + + try: + + connection = self.pool.connections.pop() + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + if trace and trace.on_connection_create_start: + await trace.on_connection_create_start( + trace.span, + action, + response + ) + + + await connection.make_connection( + action.url.hostname, + action.url.ip_addr, + action.url.port, + action.url.socket_config, + timeout=self.timeouts.connect_timeout, + ssl=action.ssl_context + ) + + response.connect_end = time.monotonic() + + if trace and trace.on_connection_create_end: + await trace.on_connection_create_end( + trace.span, + action, + response + ) + + connection.write(action.encoded_headers) + + if trace and trace.on_request_headers_sent: + await trace.on_request_headers_sent( + trace.span, + action, + response + ) + + if action.encoded_data: + if action.is_stream: + action.write_chunks( + connection, + trace + ) + + else: + connection.write(action.encoded_data) + + response.write_end = time.monotonic() + + if action.encoded_data and trace and trace.on_request_data_sent: + await trace.on_request_data_sent( + trace.span, + action, + response + ) + + response.response_code = await asyncio.wait_for( + connection.reader.readline_fast(), + timeout=self.timeouts.socket_read_timeout + ) + + headers = await asyncio.wait_for( + connection.read_headers(), + timeout=self.timeouts.socket_read_timeout + ) + + if trace and trace.on_response_headers_received: + await trace.on_response_headers_received( + trace.span, + action, + response + ) + + status = response.status + + if status >= 300 and status < 400: + + if trace and trace.on_request_redirect: + await trace.on_request_redirect( + trace.span, + action, + response + ) + + elapsed_time = 0 + redirect_time_start = time.time() + + for _ in range(action.redirects): + + if elapsed_time > self.timeouts.total_timeout: + response.status = 408 + raise Exception('Request timed out while redirecting.') + + redirect_url = str(headers.get(b'location')) + if redirect_url.startswith('http') is False: + action.url.path = redirect_url + action.encoded_headers = None + action.setup() + + else: + await action.url.replace(redirect_url) + action.encoded_headers = None + action.is_setup = False + await self.prepare(action) + + await connection.make_connection( + action.url.hostname, + action.url.ip_addr, + action.url.port, + action.url.socket_config, + timeout=self.timeouts.connect_timeout, + ssl=action.ssl_context + ) + + response.connect_end = time.monotonic() + + connection.write(action.encoded_headers) + + if action.encoded_data: + if action.is_stream: + action.write_chunks(connection) + + else: + connection.write(action.encoded_data) + + response.write_end = time.monotonic() + + response.response_code = await asyncio.wait_for( + connection.reader.readline_fast(), + timeout=self.timeouts.socket_read_timeout + ) + + headers = await asyncio.wait_for( + connection.read_headers(), + timeout=self.timeouts.socket_read_timeout + ) + + status = response.status + + if status >= 200 and status < 300: + break + + elapsed_time = time.time() - redirect_time_start + + content_length = headers.get(b'content-length') + transfer_encoding = headers.get(b'transfer-encoding') + + # We require Content-Length or Transfer-Encoding headers to read a + # request body, otherwise it's anyone's guess as to how big the body + # is, and we ain't playing that game. + body = bytearray() + if content_length: + body = await asyncio.wait_for( + connection.readexactly(int(content_length)), + timeout=self.timeouts.socket_read_timeout + ) + + elif transfer_encoding: + + all_chunks_read = False + + while True and not all_chunks_read: + + chunk_size = int((await connection.readuntil()).rstrip(), 16) + + if not chunk_size: + # read last CRLF + body.extend( + await asyncio.wait_for( + connection.readuntil(), + timeout=self.timeouts.socket_read_timeout + ) + ) + + break + + chunk = await asyncio.wait_for( + connection.readexactly(chunk_size + 2), + timeout=self.timeouts.socket_read_timeout + ) + + body.extend( + chunk[:-2] + ) + + if trace and trace.on_response_chunk_received: + await trace.on_response_chunk_received( + trace.span, + action, + response + ) + + all_chunks_read = True + + response.complete = time.monotonic() + + if trace and trace.on_response_data_received: + await trace.on_response_data_received( + trace.span, + action, + response + ) + + response.headers = headers + response.body = body + self.pool.connections.append(connection) + + if action.hooks.after: + response = await self.execute_after(action, response) + action.setup() + + if action.hooks.checks: + response = await self.execute_checks(action, response) + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + except Exception as e: + response.complete = time.monotonic() + response.error = str(e) + + self.pool.connections.append(HTTPConnection(reset_connection=self.pool.reset_connections)) + + if trace and trace.on_request_exception: + await trace.on_request_exception(response) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + if trace and trace.on_request_end: + await trace.on_request_end(response) + + return response + + async def close(self): + if self.closed is False: + await self.pool.close() + self.closed = True diff --git a/hyperscale/core/engines/types/http/connection.py b/hyperscale/core/engines/types/http/connection.py new file mode 100644 index 0000000..8eae400 --- /dev/null +++ b/hyperscale/core/engines/types/http/connection.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import asyncio +from ssl import SSLContext +from typing import Optional, Tuple + +from hyperscale.core.engines.types.common.protocols import TCPConnection +from hyperscale.core.engines.types.common.protocols.shared.constants import ( + _DEFAULT_LIMIT, +) +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer + + +class HTTPConnection: + + __slots__ = ( + 'dns_address', + 'port', + 'ssl', + 'ip_addr', + 'lock', + 'reader', + 'writer', + 'connected', + 'reset_connection', + 'pending', + '_connection_factory' + ) + + def __init__(self, reset_connection: bool=False) -> None: + self.dns_address: str = None + self.port: int = None + self.ssl: SSLContext = None + self.ip_addr = None + self.lock = asyncio.Lock() + + self.reader: Reader = None + self.writer: Writer = None + + self.connected = False + self.reset_connection = reset_connection + self.pending = 0 + self._connection_factory = TCPConnection() + + async def make_connection( + self, + hostname: str, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + ssl: Optional[SSLContext]=None, + timeout: Optional[float]=None + ) -> None: + if self.connected is False or self.dns_address != dns_address or self.reset_connection: + try: + reader, writer = await asyncio.wait_for(self._connection_factory.create(hostname, socket_config, ssl=ssl), timeout=timeout) + self.connected = True + + self.reader = reader + self.writer = writer + + self.dns_address = dns_address + self.port = port + self.ssl = ssl + + except asyncio.TimeoutError: + raise Exception('Connection timed out.') + + except (ConnectionResetError, OSError,): + raise Exception('Connection reset.') + + except Exception as e: + raise e + + @property + def empty(self): + return not self.reader._buffer + + def read(self): + return self.reader.read(n=_DEFAULT_LIMIT) + + def readexactly(self, n_bytes: int): + return self.reader.read(n=n_bytes) + + def readuntil(self, sep=b'\n'): + return self.reader.readuntil(separator=sep) + + def write(self, data): + self.writer.write(data) + + def reset_buffer(self): + self.reader._buffer = bytearray() + + def read_headers(self): + return self.reader.read_headers() + + async def close(self): + try: + await self._connection_factory.close() + except Exception: + pass \ No newline at end of file diff --git a/hyperscale/core/engines/types/http/pool.py b/hyperscale/core/engines/types/http/pool.py new file mode 100644 index 0000000..a217b0b --- /dev/null +++ b/hyperscale/core/engines/types/http/pool.py @@ -0,0 +1,27 @@ + +from typing import List +from .connection import HTTPConnection + + +class Pool: + + __slots__ = ( + 'size', + 'connections', + 'reset_connections' + ) + + def __init__(self, size: int, reset_connections: bool = False) -> None: + self.size = size + self.connections: List[HTTPConnection] = [] + self.reset_connections = reset_connections + + def create_pool(self) -> None: + for _ in range(self.size): + self.connections.append( + HTTPConnection(self.reset_connections) + ) + + async def close(self): + for connection in self.connections: + await connection.close() diff --git a/hyperscale/core/engines/types/http/result.py b/hyperscale/core/engines/types/http/result.py new file mode 100644 index 0000000..3c848c4 --- /dev/null +++ b/hyperscale/core/engines/types/http/result.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +from gzip import decompress as gzip_decompress +from typing import Dict, List, Union +from zlib import decompress as zlib_decompress + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.types import RequestTypes + +from .action import HTTPAction + + +class HTTPResult(BaseResult): + + __slots__ = ( + 'action_id', + 'url', + 'ip_addr', + 'method', + 'path', + 'params', + 'query', + 'hostname', + 'headers', + 'body', + 'response_code', + '_version', + '_reason', + '_status' + ) + + def __init__(self, action: HTTPAction, error: Exception=None) -> None: + + super( + HTTPResult, + self + ).__init__( + action.action_id, + action.name, + action.url.hostname, + action.metadata.user, + action.metadata.tags, + RequestTypes.HTTP, + error + ) + + self.url = action.url.full + self.ip_addr = action.url.ip_addr + self.method = action.method + self.path = action.url.path + self.params = action.url.params + self.query = action.url.query + self.hostname = action.url.hostname + + self.headers: Dict[bytes, bytes] = {} + + self.body = bytearray() + self.response_code = None + self._version = None + self._reason = None + self._status = None + + @property + def content_type(self): + return self.headers.get(b'content-type') + + @property + def compression(self): + return self.headers.get(b"content-encoding") + + @property + def size(self): + if self.headers.get(b'content-length'): + return int(self.headers.get(b'content-length')) + + elif self.body: + return len(self.body) + + else: + return 0 + + @property + def data(self) -> Union[str, dict, None]: + data = self.body + try: + if self.compression == b"gzip": + data = gzip_decompress(self.body) + elif self.compression == b"deflate": + data = zlib_decompress(self.body) + + if self.content_type == b"application/json": + data = json.loads(self.body) + + elif isinstance(self.body, (bytes, bytearray)): + data = str(data.decode()) + + except Exception: + pass + + return data + + @data.setter + def data(self, value): + self.body = value + + @property + def version(self) -> Union[str, None]: + try: + if self._version is None and isinstance(self.response_code, (bytes, bytearray)): + status_string: List[bytes] = self.response_code.split() + self._version = status_string[0].decode() + except Exception: + pass + + return self._version + + @version.setter + def version(self, new_version: str): + self._version = new_version + + @property + def status(self) -> Union[int, None]: + try: + + if self._status is None and isinstance(self.response_code, (bytes, bytearray)): + status_string: List[bytes] = self.response_code.split() + self._status = int(status_string[1]) + + except Exception: + pass + + return self._status + + @status.setter + def status(self, new_status: int): + self._status = new_status + + @property + def reason(self) -> Union[str, None]: + + try: + + if self._reason is None and isinstance(self.response_code, (bytes, bytearray)): + status_string: List[bytes] = self.response_code.split() + self._reason = status_string[2].decode() + + except Exception: + pass + + return self._reason + + @reason.setter + def reason(self, new_reason): + self._reason = new_reason diff --git a/hyperscale/core/engines/types/http2/__init__.py b/hyperscale/core/engines/types/http2/__init__.py new file mode 100644 index 0000000..a60ac03 --- /dev/null +++ b/hyperscale/core/engines/types/http2/__init__.py @@ -0,0 +1,3 @@ +from .client import MercuryHTTP2Client +from .action import HTTP2Action +from .result import HTTP2Result \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/action.py b/hyperscale/core/engines/types/http2/action.py new file mode 100644 index 0000000..a732ee7 --- /dev/null +++ b/hyperscale/core/engines/types/http2/action.py @@ -0,0 +1,165 @@ +import json +from typing import Any, Dict, Iterator, List, Tuple, Union +from urllib.parse import urlencode + +from hyperscale.core.engines.types.common import URL +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.constants import NEW_LINE +from hyperscale.core.engines.types.common.encoder import Encoder +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.streams.stream_settings import Settings + + +class HTTP2Action(BaseAction): + + __slots__ = ( + 'action_id', + 'method', + 'type', + 'url', + 'protocols', + '_headers', + '_data', + 'encoded_data', + 'encoded_headers', + 'is_stream', + 'ssl_context', + 'hpack_encoder', + '_remote_settings', + 'event', + 'action_args', + '_header_items', + 'mutations' + ) + + def __init__( + self, + name: str, + url: str, + method: str = 'GET', + headers: Dict[str, str] = {}, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str=None, + tags: List[Dict[str, str]] = [] + ) -> None: + super(HTTP2Action, self).__init__( + name, + user, + tags + ) + + self.method = method.upper() + self.type = RequestTypes.HTTP2 + + address_family, protocol = self.protocols[self.type] + self.url = URL(url, family=address_family, protocol=protocol) + + self._headers = headers + self._header_items: List[Tuple[str, str]] = list(headers.items()) + self._data = data + + self.encoded_data = None + self.encoded_headers = None + self.is_stream = False + self.ssl_context = None + self.hpack_encoder = Encoder() + self._remote_settings = Settings( + client=False + ) + + self.hooks: Hooks[HTTP2Action] = Hooks() + self.action_args: Dict[str, Any] = {} + + @property + def size(self): + if self.encoded_data: + return len(self.encoded_data) + + else: + return 0 + + @property + def data(self): + return self._data + + @data.setter + def data(self, value): + self._data = value + self.encoded_data = None + + @property + def headers(self): + return self._headers + + @headers.setter + def headers(self, value: Dict[str, str]): + self._headers = value + self._header_items = list(value.items()) + self.encoded_headers = None + + def setup(self): + + if self.encoded_data is None: + self._setup_data() + + if self.encoded_headers is None: + self._setup_headers() + + def _setup_data(self): + if self._data: + if isinstance(self._data, Iterator): + chunks = [] + for chunk in self._data: + chunk_size = hex(len(chunk)).replace("0x", "") + NEW_LINE + encoded_chunk = chunk_size.encode() + chunk + NEW_LINE.encode() + self.size += len(encoded_chunk) + chunks.append(encoded_chunk) + + self.is_stream = True + self.encoded_data = chunks + + else: + + if isinstance(self._data, dict): + self.encoded_data = json.dumps( + self._data + ).encode() + + elif isinstance(self._data, tuple): + self.encoded_data = urlencode( + self._data + ).encode() + + elif isinstance(self._data, str): + self.encoded_data = self._data.encode() + + def _setup_headers(self) -> Union[bytes, Dict[str, str]]: + + encoded_headers = [ + (b":method", self.method), + (b":authority", self.url.authority), + (b":scheme", self.url.scheme), + (b":path", self.url.path), + ] + + encoded_headers.extend([ + ( + k.lower(), + v + ) + for k, v in self._header_items + if k.lower() + not in ( + b"host", + b"transfer-encoding", + ) + ]) + + encoded_headers = self.hpack_encoder.encode(encoded_headers) + self.encoded_headers = [ + encoded_headers[i:i+self._remote_settings.max_frame_size] + for i in range( + 0, len(encoded_headers), self._remote_settings.max_frame_size + ) + ] diff --git a/hyperscale/core/engines/types/http2/client.py b/hyperscale/core/engines/types/http2/client.py new file mode 100644 index 0000000..1d13368 --- /dev/null +++ b/hyperscale/core/engines/types/http2/client.py @@ -0,0 +1,310 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Dict, Optional, TypeVar, Union + +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.concurrency import Semaphore +from hyperscale.core.engines.types.common.ssl import get_http2_ssl_context +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.http2.connection import HTTP2Connection +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession + +from .action import HTTP2Action +from .pool import HTTP2Pool +from .result import HTTP2Result + +A = TypeVar('A') +R = TypeVar('R') + + +class MercuryHTTP2Client(BaseEngine[Union[A, HTTP2Action], Union[R, HTTP2Result]]): + + __slots__ = ( + 'session_id', + 'timeouts', + '_hosts', + 'registered', + 'closed', + 'sem', + 'pool', + 'active', + 'waiter', + 'ssl_context', + 'tracing_session' + ) + + def __init__( + self, + concurrency: int = 10**3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool=False, + tracing_session: Optional[TraceSession]=None + ) -> None: + super( + MercuryHTTP2Client, + self + ).__init__() + + self.session_id = str(uuid.uuid4()) + self.timeouts = timeouts + + self._hosts = {} + self.registered: Dict[str, HTTP2Action] = {} + self.closed = False + + self.sem = Semaphore(value=concurrency) + self.pool: HTTP2Pool = HTTP2Pool( + concurrency, + self.timeouts, + reset_connections=reset_connections + ) + self.tracing_session: Union[TraceSession, None] = tracing_session + + self.pool.create_pool() + self.active = 0 + self.waiter = None + + self.ssl_context = get_http2_ssl_context() + + def config_to_dict(self): + return { + 'concurrency': self.pool.size, + 'timeouts': { + 'connect_timeout': self.timeouts.connect_timeout, + 'socket_read_timeout': self.timeouts.socket_read_timeout, + 'total_timeout': self.timeouts.total_timeout + }, + 'reset_connections': self.pool.reset_connections + } + + + async def set_pool(self, concurrency: int): + self.sem = Semaphore(value=concurrency) + self.pool = HTTP2Pool( + concurrency, + self.timeouts, + reset_connections=self.pool.reset_connections + ) + + self.pool.create_pool() + + def extend_pool(self, increased_capacity: int): + self.pool.size += increased_capacity + self.pool.create_pool() + + self.sem = Semaphore(self.pool.size) + + def shrink_pool(self, decrease_capacity: int): + self.pool.size -= decrease_capacity + self.pool.create_pool() + + self.sem = Semaphore(self.pool.size) + + async def prepare(self, request: HTTP2Action) -> Coroutine[Any, Any, None]: + try: + request.ssl_context = self.ssl_context + + if self._hosts.get(request.url.hostname) is None: + socket_configs = await request.url.lookup() + + for ip_addr, configs in socket_configs.items(): + for config in configs: + + connection = HTTP2Connection( + 0, + self.timeouts, + 1, + self.pool.reset_connections, + self.pool.pool_type + ) + + try: + + await connection.connect( + request.url.hostname, + ip_addr, + request.url.port, + config, + ssl=self.ssl_context, + timeout=self.timeouts.connect_timeout + ) + + request.url.socket_config = config + break + + except Exception: + pass + + if request.url.socket_config: + break + + if request.url.socket_config is None: + raise Exception('Err. - No socket found.') + + self._hosts[request.url.hostname] = request.url.ip_addr + + else: + request.url.ip_addr = self._hosts[request.url.hostname] + + if request.is_setup is False: + request.setup() + + self.registered[request.name] = request + + except Exception as e: + raise e + + async def execute_prepared_request(self, action: HTTP2Action) -> Coroutine[Any, Any, HTTP2Result]: + + trace: Union[Trace, None] = None + if self.tracing_session: + trace = self.tracing_session.create_trace() + await trace.on_request_start(action) + + response = HTTP2Result(action) + response.wait_start = time.monotonic() + self.active += 1 + + if trace and trace.on_connection_queued_start: + await trace.on_connection_queued_start( + trace.span, + action, + response + ) + + async with self.sem: + + if trace and trace.on_connection_queued_end: + await trace.on_connection_queued_end( + trace.span, + action, + response + ) + + pipe = self.pool.pipes.pop() + connection = self.pool.connections.pop() + + try: + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action: HTTP2Action = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + if trace and trace.on_connection_create_start: + await trace.on_connection_create_start( + trace.span, + action, + response + ) + + stream = await connection.connect( + action.url.hostname, + action.url.ip_addr, + action.url.port, + action.url.socket_config, + ssl=action.ssl_context, + timeout=self.timeouts.connect_timeout + ) + + stream.encoder = action.hpack_encoder + + response.connect_end = time.monotonic() + + if trace and trace.on_connection_create_end: + await trace.on_connection_create_end( + trace.span, + action, + response + ) + + pipe.send_request_headers(action, stream) + + if trace and trace.on_request_headers_sent: + await trace.on_request_headers_sent( + trace.span, + action, + response + ) + + if action.encoded_data is not None: + await pipe.submit_request_body(action, stream) + + response.write_end = time.monotonic() + + if action.encoded_data and trace and trace.on_request_data_sent: + await trace.on_request_data_sent( + trace.span, + action, + response + ) + + await asyncio.wait_for( + pipe.receive_response( + action, + response, + stream, + trace + ), + timeout=self.timeouts.total_timeout + ) + + response.complete = time.monotonic() + + if action.hooks.after: + response: HTTP2Result = await self.execute_after(action, response) + action.setup() + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + self.pool.pipes.append(pipe) + self.pool.connections.append(connection) + + except Exception as e: + response.complete = time.monotonic() + response._status = 400 + response.error = str(e) + + self.pool.reset() + + if trace and trace.on_request_exception: + await trace.on_request_exception(response) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + if trace and trace.on_request_end: + await trace.on_request_end(response) + + return response + + async def close(self): + if self.closed is False: + await self.pool.close() + self.closed = True diff --git a/hyperscale/core/engines/types/http2/config/__init__.py b/hyperscale/core/engines/types/http2/config/__init__.py new file mode 100644 index 0000000..10b182a --- /dev/null +++ b/hyperscale/core/engines/types/http2/config/__init__.py @@ -0,0 +1 @@ +from .config import H2Configuration \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/config/boolean_configuration_option.py b/hyperscale/core/engines/types/http2/config/boolean_configuration_option.py new file mode 100644 index 0000000..adb7e4d --- /dev/null +++ b/hyperscale/core/engines/types/http2/config/boolean_configuration_option.py @@ -0,0 +1,16 @@ +class BooleanConfigOption: + """ + Descriptor for handling a boolean config option. This will block + attempts to set boolean config options to non-bools. + """ + def __init__(self, name): + self.name = name + self.attr_name = '_%s' % self.name + + def __get__(self, instance, owner): + return getattr(instance, self.attr_name) + + def __set__(self, instance, value): + if not isinstance(value, bool): + raise ValueError("%s must be a bool" % self.name) + setattr(instance, self.attr_name, value) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/config/config.py b/hyperscale/core/engines/types/http2/config/config.py new file mode 100644 index 0000000..cfa71c9 --- /dev/null +++ b/hyperscale/core/engines/types/http2/config/config.py @@ -0,0 +1,121 @@ +from .boolean_configuration_option import BooleanConfigOption + + +class H2Configuration: + """ + An object that controls the way a single HTTP/2 connection behaves. + + This object allows the users to customize behaviour. In particular, it + allows users to enable or disable optional features, or to otherwise handle + various unusual behaviours. + + This object has very little behaviour of its own: it mostly just ensures + that configuration is self-consistent. + + :param client_side: Whether this object is to be used on the client side of + a connection, or on the server side. Affects the logic used by the + state machine, the default settings values, the allowable stream IDs, + and several other properties. Defaults to ``True``. + :type client_side: ``bool`` + + :param header_encoding: Controls whether the headers emitted by this object + in events are transparently decoded to ``unicode`` strings, and what + encoding is used to do that decoding. This defaults to ``None``, + meaning that headers will be returned as bytes. To automatically + decode headers (that is, to return them as unicode strings), this can + be set to the string name of any encoding, e.g. ``'utf-8'``. + + .. versionchanged:: 3.0.0 + Changed default value from ``'utf-8'`` to ``None`` + + :type header_encoding: ``str``, ``False``, or ``None`` + + :param validate_outbound_headers: Controls whether the headers emitted + by this object are validated against the rules in RFC 7540. + Disabling this setting will cause outbound header validation to + be skipped, and allow the object to emit headers that may be illegal + according to RFC 7540. Defaults to ``True``. + :type validate_outbound_headers: ``bool`` + + :param normalize_outbound_headers: Controls whether the headers emitted + by this object are normalized before sending. Disabling this setting + will cause outbound header normalization to be skipped, and allow + the object to emit headers that may be illegal according to + RFC 7540. Defaults to ``True``. + :type normalize_outbound_headers: ``bool`` + + :param validate_inbound_headers: Controls whether the headers received + by this object are validated against the rules in RFC 7540. + Disabling this setting will cause inbound header validation to + be skipped, and allow the object to receive headers that may be illegal + according to RFC 7540. Defaults to ``True``. + :type validate_inbound_headers: ``bool`` + + :param normalize_inbound_headers: Controls whether the headers received by + this object are normalized according to the rules of RFC 7540. + Disabling this setting may lead to h2 emitting header blocks that + some RFCs forbid, e.g. with multiple cookie fields. + + .. versionadded:: 3.0.0 + + :type normalize_inbound_headers: ``bool`` + + :param logger: A logger that conforms to the requirements for this module, + those being no I/O and no context switches, which is needed in order + to run in asynchronous operation. + + .. versionadded:: 2.6.0 + + :type logger: ``logging.Logger`` + """ + client_side = BooleanConfigOption('client_side') + validate_outbound_headers = BooleanConfigOption( + 'validate_outbound_headers' + ) + normalize_outbound_headers = BooleanConfigOption( + 'normalize_outbound_headers' + ) + validate_inbound_headers = BooleanConfigOption( + 'validate_inbound_headers' + ) + normalize_inbound_headers = BooleanConfigOption( + 'normalize_inbound_headers' + ) + + def __init__(self, + client_side=True, + header_encoding=None, + validate_outbound_headers=True, + normalize_outbound_headers=True, + validate_inbound_headers=True, + normalize_inbound_headers=True, + logger=None): + self.client_side = client_side + self.header_encoding = header_encoding + self.validate_outbound_headers = validate_outbound_headers + self.normalize_outbound_headers = normalize_outbound_headers + self.validate_inbound_headers = validate_inbound_headers + self.normalize_inbound_headers = normalize_inbound_headers + + @property + def header_encoding(self): + """ + Controls whether the headers emitted by this object in events are + transparently decoded to ``unicode`` strings, and what encoding is used + to do that decoding. This defaults to ``None``, meaning that headers + will be returned as bytes. To automatically decode headers (that is, to + return them as unicode strings), this can be set to the string name of + any encoding, e.g. ``'utf-8'``. + """ + return self._header_encoding + + @header_encoding.setter + def header_encoding(self, value): + """ + Enforces constraints on the value of header encoding. + """ + if not isinstance(value, (bool, str, type(None))): + raise ValueError("header_encoding must be bool, string, or None") + if value is True: + raise ValueError("header_encoding cannot be True") + self._header_encoding = value \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/connection.py b/hyperscale/core/engines/types/http2/connection.py new file mode 100644 index 0000000..ea979f3 --- /dev/null +++ b/hyperscale/core/engines/types/http2/connection.py @@ -0,0 +1,155 @@ +import asyncio +from ssl import SSLContext +from typing import Optional, Tuple, Union + +from hyperscale.core.engines.types.common.protocols.tcp import TCPConnection +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.streams.stream_settings import Settings +from hyperscale.core.engines.types.http2.streams.stream_settings_codes import ( + SettingCodes, +) + +from .frames import FrameBuffer +from .frames.types.base_frame import Frame +from .stream import Stream + + +class HTTP2Connection: + __slots__ = ( + "timeouts", + "connected", + "init_id", + "reset_connection", + "stream_type", + "init_id", + "stream_id", + "concurrency", + "dns_address", + "port", + "connection", + "lock", + "stream", + "local_settings", + "remote_settings", + "outbound_flow_control_window", + "local_settings_dict", + "remote_settings_dict", + "settings_frame", + "headers_frame", + "window_update_frame", + ) + + def __init__( + self, + stream_id: int, + timeouts: Timeouts, + concurrency: int, + reset_connection: bool, + stream_type: RequestTypes, + ) -> None: + self.timeouts = timeouts + self.connected = False + self.init_id = stream_id + self.reset_connection = reset_connection + self.stream_type = stream_type + + if self.init_id % 2 == 0: + self.init_id += 1 + + self.stream_id = 0 + self.concurrency = concurrency + self.dns_address = None + self.port = None + + self.connection = TCPConnection(stream_type) + self.lock = asyncio.Lock() + self.stream = Stream(self.stream_id, self.timeouts) + + self.local_settings = Settings( + client=True, + initial_values={ + SettingCodes.ENABLE_PUSH: 0, + SettingCodes.MAX_CONCURRENT_STREAMS: concurrency, + SettingCodes.MAX_HEADER_LIST_SIZE: 65535, + }, + ) + self.remote_settings = Settings(client=False) + + self.outbound_flow_control_window = self.remote_settings.initial_window_size + + del self.local_settings[SettingCodes.ENABLE_CONNECT_PROTOCOL] + + self.local_settings_dict = { + setting_name: setting_value + for setting_name, setting_value in self.local_settings.items() + } + self.remote_settings_dict = { + setting_name: setting_value + for setting_name, setting_value in self.remote_settings.items() + } + + self.settings_frame = Frame(0, 0x04, settings=self.local_settings_dict) + self.headers_frame = Frame(self.init_id, 0x01) + self.headers_frame.flags.add("END_HEADERS") + + self.window_update_frame = Frame(self.init_id, 0x08, window_increment=65536) + + self.stream.connection_data.extend(self.settings_frame.serialize()) + + async def connect( + self, + hostname: str, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + ssl: Optional[SSLContext] = None, + timeout: Optional[float] = None, + ) -> Union[Stream, Exception]: + try: + if ( + self.connected is False + or self.dns_address != dns_address + or self.reset_connection + ): + reader, writer = await asyncio.wait_for( + self.connection.create_http2( + hostname, socket_config=socket_config, ssl=ssl + ), + timeout=timeout, + ) + + self.connected = True + self.stream_id = self.init_id + self.dns_address = dns_address + self.port = port + + self.stream.reader = reader + self.stream.writer = writer + + self.stream.headers_frame = self.headers_frame + self.stream.window_frame = self.window_update_frame + + else: + self.stream_id += 2 # self.concurrency + if self.stream_id % 2 == 0: + self.stream_id += 1 + + self.stream.stream_id = self.stream_id + self.stream.headers_frame.stream_id = self.stream_id + self.stream.window_frame.stream_id = self.stream_id + + self.stream.frame_buffer = FrameBuffer() + return self.stream + + except asyncio.TimeoutError: + raise Exception("Connection timed out.") + + except ConnectionResetError: + raise Exception("Connection reset.") + + except Exception as e: + raise e + + async def close(self): + await self.connection.close() diff --git a/hyperscale/core/engines/types/http2/errors/__init__.py b/hyperscale/core/engines/types/http2/errors/__init__.py new file mode 100644 index 0000000..abbfe7e --- /dev/null +++ b/hyperscale/core/engines/types/http2/errors/__init__.py @@ -0,0 +1,2 @@ +from .exceptions import StreamError, StreamResetException, StreamClosedError +from .types import ErrorCodes \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/errors/exceptions.py b/hyperscale/core/engines/types/http2/errors/exceptions.py new file mode 100644 index 0000000..3514a12 --- /dev/null +++ b/hyperscale/core/engines/types/http2/errors/exceptions.py @@ -0,0 +1,47 @@ +from.types import ErrorCodes + + +class StreamError(Exception): + error_code = None + +class StreamResetException(Exception): + pass + + +class NoSuchStreamError(Exception): + """ + A stream-specific action referenced a stream that does not exist. + + .. versionchanged:: 2.0.0 + Became a subclass of :class:`ProtocolError + ` + """ + def __init__(self, stream_id): + #: The stream ID corresponds to the non-existent stream. + self.stream_id = stream_id + + +class StreamClosedError(Exception): + """ + A more specific form of + :class:`NoSuchStreamError `. Indicates + that the stream has since been closed, and that all state relating to that + stream has been removed. + """ + + __slots__ = ( + 'stream_id', + 'error_code', + 'events' + ) + + def __init__(self, stream_id): + #: The stream ID corresponds to the nonexistent stream. + self.stream_id = stream_id + + #: The relevant HTTP/2 error code. + self.error_code = ErrorCodes.STREAM_CLOSED + + # Any events that internal code may need to fire. Not relevant to + # external users that may receive a StreamClosedError. + self._events = [] diff --git a/hyperscale/core/engines/types/http2/errors/types.py b/hyperscale/core/engines/types/http2/errors/types.py new file mode 100644 index 0000000..a7a9590 --- /dev/null +++ b/hyperscale/core/engines/types/http2/errors/types.py @@ -0,0 +1,49 @@ +from enum import IntEnum + +class ErrorCodes(IntEnum): + """ + All known HTTP/2 error codes. + + .. versionadded:: 2.5.0 + """ + #: Graceful shutdown. + NO_ERROR = 0x0 + + #: Protocol error detected. + PROTOCOL_ERROR = 0x1 + + #: Implementation fault. + INTERNAL_ERROR = 0x2 + + #: Flow-control limits exceeded. + FLOW_CONTROL_ERROR = 0x3 + + #: Settings not acknowledged. + SETTINGS_TIMEOUT = 0x4 + + #: Frame received for closed stream. + STREAM_CLOSED = 0x5 + + #: Frame size incorrect. + FRAME_SIZE_ERROR = 0x6 + + #: Stream not processed. + REFUSED_STREAM = 0x7 + + #: Stream cancelled. + CANCEL = 0x8 + + #: Compression state not updated. + COMPRESSION_ERROR = 0x9 + + #: TCP connection error for CONNECT method. + CONNECT_ERROR = 0xa + + #: Processing capacity exceeded. + ENHANCE_YOUR_CALM = 0xb + + #: Negotiated TLS parameters not acceptable. + INADEQUATE_SECURITY = 0xc + + #: Use HTTP/1.1 for the request. + HTTP_1_1_REQUIRED = 0xd \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/__init__.py b/hyperscale/core/engines/types/http2/events/__init__.py new file mode 100644 index 0000000..984bd6d --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/__init__.py @@ -0,0 +1,16 @@ +from .data_received_event import DataReceived +from .headers_sent_event import HeadersSent +from .informational_respose_received_event import InformationalResponseReceived +from .remote_settings_changed_event import RemoteSettingsChanged +from .response_received_event import ResponseReceived +from .response_sent_event import ResponseSent +from .request_received_event import RequestReceived +from .request_sent_event import RequestSent +from .settings_acknowledged_event import SettingsAcknowledged +from .stream_ended_event import StreamEnded +from .stream_reset import StreamReset +from .trailers_sent_event import TrailersSent +from .trailers_received_event import TrailersReceived +from .window_updated_event import WindowUpdated +from .deferred_headers_event import DeferredHeaders +from .connection_terminated_event import ConnectionTerminated \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/base_event.py b/hyperscale/core/engines/types/http2/events/base_event.py new file mode 100644 index 0000000..3e26641 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/base_event.py @@ -0,0 +1,2 @@ +class BaseEvent: + error_code=None \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/connection_terminated_event.py b/hyperscale/core/engines/types/http2/events/connection_terminated_event.py new file mode 100644 index 0000000..de44a33 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/connection_terminated_event.py @@ -0,0 +1,44 @@ +import binascii +from .base_event import BaseEvent + + + +class ConnectionTerminated(BaseEvent): + event_type='CONNECTION_TERMINATED' + + __slots__ = ( + 'error_code', + 'last_stream_id', + 'additional_data' + ) + + """ + The ConnectionTerminated event is fired when a connection is torn down by + the remote peer using a GOAWAY frame. Once received, no further action may + be taken on the connection: a new connection must be established. + """ + def __init__(self): + #: The error code cited when tearing down the connection. Should be + #: one of :class:`ErrorCodes `, but may not be if + #: unknown HTTP/2 extensions are being used. + self.error_code = None + + #: The stream ID of the last stream the remote peer saw. This can + #: provide an indication of what data, if any, never reached the remote + #: peer and so can safely be resent. + self.last_stream_id = None + + #: Additional debug data that can be appended to GOAWAY frame. + self.additional_data = None + + def __repr__(self): + additional_data = b'' + if self.additional_data: + additional_data = binascii.hexlify(self.additional_data[:20]).decode('ascii') + return ( + "" % ( + self.error_code, + self.last_stream_id, + additional_data + )) diff --git a/hyperscale/core/engines/types/http2/events/data_received_event.py b/hyperscale/core/engines/types/http2/events/data_received_event.py new file mode 100644 index 0000000..c9630d0 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/data_received_event.py @@ -0,0 +1,49 @@ +import binascii +from .base_event import BaseEvent + + +class DataReceived(BaseEvent): + event_type='DATA_RECEIVED' + + __slots__ = ( + 'stream_id', + 'data', + 'flow_controlled_length', + 'stream_ended' + ) + + def __init__(self): + #: The Stream ID for the stream this data was received on. + self.stream_id = None + + #: The data itself. + self.data = None + + #: The amount of data received that counts against the flow control + #: window. Note that padding counts against the flow control window, so + #: when adjusting flow control you should always use this field rather + #: than ``len(data)``. + self.flow_controlled_length = None + + #: If this data chunk also completed the stream, the associated + #: :class:`StreamEnded ` event will be available + #: here. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + def __repr__(self): + + decoded_data = None + if self.data: + decoded_data = binascii.hexlify(self.data[:20]).decode('ascii') + + return ( + "" % ( + self.stream_id, + self.flow_controlled_length, + decoded_data, + ) + ) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/deferred_headers_event.py b/hyperscale/core/engines/types/http2/events/deferred_headers_event.py new file mode 100644 index 0000000..bfeb99a --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/deferred_headers_event.py @@ -0,0 +1,59 @@ +from typing import Dict, List, Tuple, Union + +from hyperscale.core.engines.types.common.fast_hpack import Decoder, Encoder + +from .base_event import BaseEvent + + +# Parsing headers mid load-test is *expensive* so we want to defer +# this work until later. +class DeferredHeaders(BaseEvent): + event_type='DEFERRED_HEADERS' + + __slots__ = ( + 'stream_id', + 'hpack_table', + 'raw_headers', + 'stream_ended', + 'end_stream', + 'priority', + 'encoding', + 'priority_updated' + ) + + def __init__(self, encoder: Encoder, frame, encoding: Union[str, None]) -> None: + super().__init__() + self.stream_id = frame.stream_id + self.hpack_table = encoder.header_table + self.raw_headers = frame.data + self.stream_ended = None + self.end_stream = 'END_STREAM' in frame.flags + self.priority = 'PRIORITY' in frame.flags + self.encoding = encoding + self.priority_updated = None + + def parse(self) -> Tuple[int, Dict[bytes, bytes]]: + decoder = Decoder() + decoder.header_table = self.hpack_table + decoder.header_table_size = self.hpack_table.maxsize + headers: List[Tuple[bytes, bytes]] = {} + + try: + headers = decoder.decode(self.raw_headers, raw=True) + + except Exception: + return 400, {} + + status_code = None + headers_dict: Dict[bytes, bytes] = {} + for k, v in headers: + if k == b":status": + status_code = int(v.decode("ascii", errors="ignore")) + elif k.startswith(b":"): + headers_dict[k.strip(b':')] = v + else: + headers_dict[k] = v + + return status_code, headers_dict + + \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/headers_sent_event.py b/hyperscale/core/engines/types/http2/events/headers_sent_event.py new file mode 100644 index 0000000..5fe1614 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/headers_sent_event.py @@ -0,0 +1,13 @@ +from .base_event import BaseEvent + + +class HeadersSent(BaseEvent): + event_type='HEADERS_SENT' + """ + The _HeadersSent event is fired whenever headers are sent. + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass + diff --git a/hyperscale/core/engines/types/http2/events/informational_respose_received_event.py b/hyperscale/core/engines/types/http2/events/informational_respose_received_event.py new file mode 100644 index 0000000..fbf6c80 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/informational_respose_received_event.py @@ -0,0 +1,50 @@ +from .base_event import BaseEvent + + +class InformationalResponseReceived(BaseEvent): + event_type='INFORMATIONAL_RESPONSE_RECEIVED' + __slots__ = ( + 'stream_id', + 'headers', + 'priority_updated' + ) + """ + The InformationalResponseReceived event is fired when an informational + response (that is, one whose status code is a 1XX code) is received from + the remote peer. + + The remote peer may send any number of these, from zero upwards. These + responses are most commonly sent in response to requests that have the + ``expect: 100-continue`` header field present. Most users can safely + ignore this event unless you are intending to use the + ``expect: 100-continue`` flow, or are for any reason expecting a different + 1XX status code. + + .. versionadded:: 2.2.0 + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``priority_updated`` property. + """ + def __init__(self): + #: The Stream ID for the stream this informational response was made + #: on. + self.stream_id = None + + #: The headers for this informational response. + self.headers = None + + #: If this response also had associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/remote_settings_changed_event.py b/hyperscale/core/engines/types/http2/events/remote_settings_changed_event.py new file mode 100644 index 0000000..cc3e5c6 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/remote_settings_changed_event.py @@ -0,0 +1,66 @@ +from hyperscale.core.engines.types.http2.streams.changed_setting import ChangedSetting +from hyperscale.core.engines.types.http2.streams.stream_settings_codes import ( + SettingCodes, +) + +from .base_event import BaseEvent + + +class RemoteSettingsChanged(BaseEvent): + event_type='REMOTE_SETTINGS_CHANGED' + __slots__ = ( + 'changed_settings' + ) + """ + The RemoteSettingsChanged event is fired whenever the remote peer changes + its settings. It contains a complete inventory of changed settings, + including their previous values. + + In HTTP/2, settings changes need to be acknowledged. h2 automatically + acknowledges settings changes for efficiency. However, it is possible that + the caller may not be happy with the changed setting. + + When this event is received, the caller should confirm that the new + settings are acceptable. If they are not acceptable, the user should close + the connection with the error code :data:`PROTOCOL_ERROR + `. + + .. versionchanged:: 2.0.0 + Prior to this version the user needed to acknowledge settings changes. + This is no longer the case: h2 now automatically acknowledges + them. + """ + def __init__(self): + #: A dictionary of setting byte to + #: :class:`ChangedSetting `, representing + #: the changed settings. + self.changed_settings = {} + + @classmethod + def from_settings(cls, old_settings, new_settings): + """ + Build a RemoteSettingsChanged event from a set of changed settings. + + :param old_settings: A complete collection of old settings, in the form + of a dictionary of ``{setting: value}``. + :param new_settings: All the changed settings and their new values, in + the form of a dictionary of ``{setting: value}``. + """ + e = cls() + for setting, new_value in new_settings.items(): + + try: + setting = SettingCodes(setting) + except ValueError: + pass + + original_value = old_settings.get(setting) + change = ChangedSetting(setting, original_value, new_value) + e.changed_settings[setting] = change + + return e + + def __repr__(self): + return "" % ( + ", ".join(repr(cs) for cs in self.changed_settings.values()), + ) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/request_received_event.py b/hyperscale/core/engines/types/http2/events/request_received_event.py new file mode 100644 index 0000000..d1f350c --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/request_received_event.py @@ -0,0 +1,49 @@ +from .base_event import BaseEvent + + +class RequestReceived(BaseEvent): + event_type='REQUEST_RECEIVED' + __slots__ = ( + 'stream_id', + 'headers', + 'stream_ended', + 'priority_updated' + ) + + """ + The RequestReceived event is fired whenever request headers are received. + This event carries the HTTP headers for the given request and the stream ID + of the new stream. + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``stream_ended`` and ``priority_updated`` properties. + """ + def __init__(self): + #: The Stream ID for the stream this request was made on. + self.stream_id = None + + #: The request headers. + self.headers = None + + #: If this request also ended the stream, the associated + #: :class:`StreamEnded ` event will be available + #: here. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + #: If this request also had associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/request_sent_event.py b/hyperscale/core/engines/types/http2/events/request_sent_event.py new file mode 100644 index 0000000..c233c03 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/request_sent_event.py @@ -0,0 +1,13 @@ +from .headers_sent_event import HeadersSent + + +class RequestSent(HeadersSent): + event_type='REQUEST_SENT' + """ + The _RequestSent event is fired whenever request headers are sent + on a stream. + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/response_received_event.py b/hyperscale/core/engines/types/http2/events/response_received_event.py new file mode 100644 index 0000000..e11ef75 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/response_received_event.py @@ -0,0 +1,48 @@ +from .base_event import BaseEvent + + +class ResponseReceived(BaseEvent): + event_type='RESPONSE_RECEIVED' + __slots__ = ( + 'stream_id', + 'headers', + 'stream_ended', + 'priority_updated' + ) + """ + The ResponseReceived event is fired whenever response headers are received. + This event carries the HTTP headers for the given response and the stream + ID of the new stream. + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``stream_ended`` and ``priority_updated`` properties. + """ + def __init__(self): + #: The Stream ID for the stream this response was made on. + self.stream_id = None + + #: The response headers. + self.headers = None + + #: If this response also ended the stream, the associated + #: :class:`StreamEnded ` event will be available + #: here. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + #: If this response also had associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/response_sent_event.py b/hyperscale/core/engines/types/http2/events/response_sent_event.py new file mode 100644 index 0000000..d1a24d8 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/response_sent_event.py @@ -0,0 +1,12 @@ +from .headers_sent_event import HeadersSent + +class ResponseSent(HeadersSent): + event_type='RESPONSE_SENT' + """ + The _ResponseSent event is fired whenever response headers are sent + on a stream. + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass diff --git a/hyperscale/core/engines/types/http2/events/settings_acknowledged_event.py b/hyperscale/core/engines/types/http2/events/settings_acknowledged_event.py new file mode 100644 index 0000000..007889a --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/settings_acknowledged_event.py @@ -0,0 +1,22 @@ +from .base_event import BaseEvent + + +class SettingsAcknowledged(BaseEvent): + event_type='SETTINGS_ACKNOWLEDGED' + __slots__ = ('changed_settings') + """ + The SettingsAcknowledged event is fired whenever a settings ACK is received + from the remote peer. The event carries on it the settings that were + acknowedged, in the same format as + :class:`h2.events.RemoteSettingsChanged`. + """ + def __init__(self): + #: A dictionary of setting byte to + #: :class:`ChangedSetting `, representing + #: the changed settings. + self.changed_settings = {} + + def __repr__(self): + return "" % ( + ", ".join(repr(cs) for cs in self.changed_settings.values()), + ) diff --git a/hyperscale/core/engines/types/http2/events/stream_ended_event.py b/hyperscale/core/engines/types/http2/events/stream_ended_event.py new file mode 100644 index 0000000..6fb6b8a --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/stream_ended_event.py @@ -0,0 +1,17 @@ +from .base_event import BaseEvent + + +class StreamEnded(BaseEvent): + event_type='STREAM_ENDED' + __slots__ = ('stream_id') + """ + The StreamEnded event is fired whenever a stream is ended by a remote + party. The stream may not be fully closed if it has not been closed + locally, but no further data or headers should be expected on that stream. + """ + def __init__(self): + #: The Stream ID of the stream that was closed. + self.stream_id = None + + def __repr__(self): + return "" % self.stream_id diff --git a/hyperscale/core/engines/types/http2/events/stream_reset.py b/hyperscale/core/engines/types/http2/events/stream_reset.py new file mode 100644 index 0000000..bdb8db1 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/stream_reset.py @@ -0,0 +1,29 @@ +from .base_event import BaseEvent + +class StreamReset(BaseEvent): + event_type='STREAM_RESET' + __slots__ = ('stream_id', 'error_code', 'remote_reset') + """ + The StreamReset event is fired in two situations. The first is when the + remote party forcefully resets the stream. The second is when the remote + party has made a protocol error which only affects a single stream. In this + case, h2 will terminate the stream early and return this event. + + .. versionchanged:: 2.0.0 + This event is now fired when h2 automatically resets a stream. + """ + def __init__(self): + #: The Stream ID of the stream that was reset. + self.stream_id = None + + #: The error code given. Either one of :class:`ErrorCodes + #: ` or ``int`` + self.error_code = None + + #: Whether the remote peer sent a RST_STREAM or we did. + self.remote_reset = True + + def __repr__(self): + return "" % ( + self.stream_id, self.error_code, self.remote_reset + ) diff --git a/hyperscale/core/engines/types/http2/events/trailers_received_event.py b/hyperscale/core/engines/types/http2/events/trailers_received_event.py new file mode 100644 index 0000000..6c45e9a --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/trailers_received_event.py @@ -0,0 +1,45 @@ +from .base_event import BaseEvent + + +class TrailersReceived(BaseEvent): + event_type='TRAILERS_RECEIVED' + __slots__ = ('stream_id', 'headers', 'stream_ended', 'priority_updated') + """ + The TrailersReceived event is fired whenever trailers are received on a + stream. Trailers are a set of headers sent after the body of the + request/response, and are used to provide information that wasn't known + ahead of time (e.g. content-length). This event carries the HTTP header + fields that form the trailers and the stream ID of the stream on which they + were received. + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``stream_ended`` and ``priority_updated`` properties. + """ + def __init__(self): + #: The Stream ID for the stream on which these trailers were received. + self.stream_id = None + + #: The trailers themselves. + self.headers = None + + #: Trailers always end streams. This property has the associated + #: :class:`StreamEnded ` in it. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + #: If the trailers also set associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/events/trailers_sent_event.py b/hyperscale/core/engines/types/http2/events/trailers_sent_event.py new file mode 100644 index 0000000..30031a5 --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/trailers_sent_event.py @@ -0,0 +1,15 @@ +from .headers_sent_event import HeadersSent + + +class TrailersSent(HeadersSent): + event_type='TRAILERS_SENT' + """ + The _TrailersSent event is fired whenever trailers are sent on a + stream. Trailers are a set of headers sent after the body of the + request/response, and are used to provide information that wasn't known + ahead of time (e.g. content-length). + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass diff --git a/hyperscale/core/engines/types/http2/events/window_updated_event.py b/hyperscale/core/engines/types/http2/events/window_updated_event.py new file mode 100644 index 0000000..291727a --- /dev/null +++ b/hyperscale/core/engines/types/http2/events/window_updated_event.py @@ -0,0 +1,25 @@ +from .base_event import BaseEvent + + +class WindowUpdated(BaseEvent): + event_type='WINDOW_UPDATED' + __slots__ = ('stream_id', 'delta') + """ + The WindowUpdated event is fired whenever a flow control window changes + size. HTTP/2 defines flow control windows for connections and streams: this + event fires for both connections and streams. The event carries the ID of + the stream to which it applies (set to zero if the window update applies to + the connection), and the delta in the window size. + """ + def __init__(self): + #: The Stream ID of the stream whose flow control window was changed. + #: May be ``0`` if the connection window was changed. + self.stream_id = None + + #: The window delta. + self.delta = None + + def __repr__(self): + return "" % ( + self.stream_id, self.delta + ) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/frames/__init__.py b/hyperscale/core/engines/types/http2/frames/__init__.py new file mode 100644 index 0000000..400ff75 --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/__init__.py @@ -0,0 +1 @@ +from .frame_buffer import FrameBuffer \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/frames/frame_buffer.py b/hyperscale/core/engines/types/http2/frames/frame_buffer.py new file mode 100644 index 0000000..53e5762 --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/frame_buffer.py @@ -0,0 +1,248 @@ +# -*- coding: utf-8 -*- +""" +h2/frame_buffer +~~~~~~~~~~~~~~~ + +A data structure that provides a way to iterate over a byte buffer in terms of +frames. +""" +import struct +from .types.attributes import ( + _STRUCT_HBBBL +) +from .types import * +from hyperframe.exceptions import InvalidFrameError, InvalidDataError +from .types.base_frame import Frame +from .types.attributes import ( + Flag, + Flags, + _STRUCT_HBBBL, + _STRUCT_H, + _STRUCT_B, + _STRUCT_LL, + _STRUCT_LB, + _STRUCT_L, + _STRUCT_HL +) + + +class FrameBuffer: + + __slots__ = ( + 'data', + 'max_frame_size', + '_headers_buffer', + 'frames_map' + ) + + """ + This is a data structure that expects to act as a buffer for HTTP/2 data + that allows iteraton in terms of H2 frames. + """ + def __init__(self): + self.data = bytearray() + self.max_frame_size = 0 + self._headers_buffer = [] + + # The methods below support the iterator protocol. + def __iter__(self): + while len(self.data) >= 9: + try: + + fields = _STRUCT_HBBBL.unpack(self.data[:9]) + + # First 24 bits are frame length. + length = (fields[0] << 8) + fields[1] + type = fields[2] + flags = fields[3] + stream_id = fields[4] & 0x7FFFFFFF + + frame = Frame(stream_id, type, parsed_flag_byte=flags) + + except (InvalidDataError, InvalidFrameError) as e: # pragma: no cover + raise Exception( + "Received frame with invalid header: %s" % str(e) + ) + + # Next, check that we have enough length to parse the frame body. If + # not, bail, leaving the frame header data in the buffer for next time. + if len(self.data) < length + 9: + break + + body_data = self.data[9:9+length] + + if frame.type == 0xA: + # ALTSVC + + origin_len = _STRUCT_H.unpack(body_data[0:2])[0] + frame.origin = body_data[2:2+origin_len] + + if len(frame.origin) != origin_len: + raise Exception("Invalid ALTSVC frame body.") + + frame.field = body_data[2+origin_len:] + frame.body_len = len(body_data) + + elif frame.type == 0x09: + # CONTINUATION + frame.data = body_data + frame.body_len = len(body_data) + + elif frame.type == 0x0: + # DATA + padding_data_offset = 0 + frame.pad_length = 0 + + if 'PADDED' in frame.flags: # type: ignore + frame.pad_length = struct.unpack('!B', body_data[:1])[0] + padding_data_offset = 1 + + data_length = len(body_data) + frame.data = ( + body_data[padding_data_offset:data_length-frame.pad_length] + ) + frame.body_len = data_length + + elif frame.type == 0x07: + # GOAWAY + + frame.last_stream_id, frame.error_code = _STRUCT_LL.unpack(body_data[:8]) + frame.body_len = len(body_data) + + if len(body_data) > 8: + frame.additional_data = body_data[8:] + + elif frame.type == 0x01: + # HEADERS + padding_data_offset = 0 + + if 'PADDED' in frame.flags: # type: ignore + frame.pad_length = struct.unpack('!B', body_data[:1])[0] + padding_data_offset = 1 + + body_data = body_data[padding_data_offset:] + + if 'PRIORITY' in frame.flags: + frame.depends_on, frame.stream_weight = _STRUCT_LB.unpack(body_data[:5]) + frame.exclusive = True if frame.depends_on >> 31 else False + frame.depends_on &= 0x7FFFFFFF + padding_data_offset = 5 + + else: + padding_data_offset = 0 + + data_length = len(body_data) + frame.body_len = data_length + frame.data = body_data[padding_data_offset:data_length-frame.pad_length] + + elif frame.type == 0x06: + # PING + frame.opaque_data = body_data + frame.body_len = 8 + + elif frame.type == 0x02: + # PRIORITY + + try: + frame.depends_on, frame.stream_weight = _STRUCT_LB.unpack(body_data[:5]) + except struct.error: + raise Exception("Invalid Priority data") + + frame.exclusive = True if frame.depends_on >> 31 else False + frame.depends_on &= 0x7FFFFFFF + + frame.body_len = 5 + + elif frame.type == 0x05: + # PUSH PROMISE + padding_data_offset = 0 + frame.pad_length = 0 + if 'PADDED' in frame.flags: # type: ignore + try: + frame.pad_length = struct.unpack('!B', body_data[:1])[0] + except struct.error: + raise Exception("Invalid Padding data") + padding_data_offset = 1 + + frame.promised_stream_id = _STRUCT_L.unpack( + body_data[padding_data_offset:padding_data_offset + 4] + )[0] + + data_len = len(body_data) + frame.data = body_data[padding_data_offset + 4:data_len-frame.pad_length] + frame.body_len = data_len + + elif frame.type == 0x03: + # RESET + frame.error_code = _STRUCT_L.unpack(body_data)[0] + frame.body_len = 4 + + elif frame.type == 0x04: + # SETTINGS + body_len = 0 + for i in range(0, len(body_data), 6): + + name, value = _STRUCT_HL.unpack(body_data[i:i+6]) + + frame.settings[name] = value + body_len += 6 + + frame.body_len = body_len + + elif frame.type == 0x08: + # WINDOW UPDATE + frame.window_increment = _STRUCT_L.unpack(body_data)[0] + frame.body_len = 4 + + # At this point, as we know we'll use or discard the entire frame, we + # can update the data. + del self.data[:9+length] + + # Pass the frame through the heaer buffer. + # f = self._update_header_buffer(f) + is_headers_or_push_promise = frame.frame_type == 'HEADERS' or frame.frame_type == 'PUSHPROMISE' + + if self._headers_buffer: + stream_id = self._headers_buffer[0].stream_id + valid_frame = ( + frame is not None and + frame.frame_type == 'CONTINUATION' and + frame.stream_id == stream_id + ) + + if valid_frame == False: + raise Exception("Invalid frame during header block.") + + # Append the frame to the buffer. + self._headers_buffer.append(frame) + + # If this is the end of the header block, then we want to build a + # mutant HEADERS frame that's massive. Use the original one we got, + # then set END_HEADERS and set its data appopriately. If it's not + # the end of the block, lose the current frame: we can't yield it. + if 'END_HEADERS' in frame.flags: + frame = self._headers_buffer[0] + frame.flags.add('END_HEADERS') + + frame_data = bytearray() + for header_frame in self._headers_buffer: + frame_data.extend(header_frame) + + frame.data = frame_data + self._headers_buffer = [] + + else: + frame = None + elif is_headers_or_push_promise and 'END_HEADERS' not in frame.flags: + # This is the start of a headers block! Save the frame off and then + # act like we didn't receive one. + self._headers_buffer.append(frame) + frame = None + + # If we got a frame we didn't understand or shouldn't yield, rather + # than return None it'd be better if we just tried to get the next + # frame in the sequence instead. Recurse back into ourselves to do + # that. This is safe because the amount of work we have to do here is + # strictly bounded by the length of the buffer. + if frame: + yield frame \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/frames/types/__init__.py b/hyperscale/core/engines/types/http2/frames/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/engines/types/http2/frames/types/attributes/__init__.py b/hyperscale/core/engines/types/http2/frames/types/attributes/__init__.py new file mode 100644 index 0000000..bbb9e81 --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/types/attributes/__init__.py @@ -0,0 +1,22 @@ +from .struct_types import ( + _STRUCT_B, + _STRUCT_H, + _STRUCT_HBBBL, + _STRUCT_HL, + _STRUCT_L, + _STRUCT_LB, + _STRUCT_LL +) +from .stream_associations import ( + _STREAM_ASSOC_EITHER, + _STREAM_ASSOC_HAS_STREAM, + _STREAM_ASSOC_NO_STREAM +) +from .frame_length import ( + FRAME_MAX_ALLOWED_LEN, + FRAME_MAX_LEN +) +from .frame_flags import ( + Flag, + Flags +) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/frames/types/attributes/frame_flags.py b/hyperscale/core/engines/types/http2/frames/types/attributes/frame_flags.py new file mode 100644 index 0000000..4432689 --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/types/attributes/frame_flags.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" +hyperframe/flags +~~~~~~~~~~~~~~~~ + +Defines basic Flag and Flags data structures. +""" +from collections.abc import MutableSet +from typing import ( + NamedTuple, + Iterable, + Set, + Iterator +) + + +class Flag(NamedTuple): + name: str + bit: int + + +class Flags(MutableSet): # type: ignore + __slots__ = ( + '_valid_flags', + '_flags' + ) + + """ + A simple MutableSet implementation that will only accept known flags as + elements. + + Will behave like a regular set(), except that a ValueError will be thrown + when .add()ing unexpected flags. + """ + def __init__(self, defined_flags: Iterable[Flag]): + self._valid_flags = set(flag.name for flag in defined_flags) + self._flags: Set[str] = set() + + def __repr__(self) -> str: + return repr(sorted(list(self._flags))) + + def __contains__(self, x: object) -> bool: + return self._flags.__contains__(x) + + def __iter__(self) -> Iterator[str]: + return self._flags.__iter__() + + def __len__(self) -> int: + return self._flags.__len__() + + def discard(self, value: str) -> None: + return self._flags.discard(value) + + def add(self, value: str) -> None: + # if value not in self._valid_flags: + # raise ValueError( + # "Unexpected flag: {}. Valid flags are: {}".format( + # value, self._valid_flags + # ) + # ) + return self._flags.add(value) diff --git a/hyperscale/core/engines/types/http2/frames/types/attributes/frame_length.py b/hyperscale/core/engines/types/http2/frames/types/attributes/frame_length.py new file mode 100644 index 0000000..e0ba63a --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/types/attributes/frame_length.py @@ -0,0 +1,6 @@ +# The maximum initial length of a frame. Some frames have shorter maximum +# lengths. +FRAME_MAX_LEN = (2 ** 14) + +# The maximum allowed length of a frame. +FRAME_MAX_ALLOWED_LEN = (2 ** 24) - 1 \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/frames/types/attributes/stream_associations.py b/hyperscale/core/engines/types/http2/frames/types/attributes/stream_associations.py new file mode 100644 index 0000000..fcfd85f --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/types/attributes/stream_associations.py @@ -0,0 +1,4 @@ +# Stream association enumerations. +_STREAM_ASSOC_HAS_STREAM = "has-stream" +_STREAM_ASSOC_NO_STREAM = "no-stream" +_STREAM_ASSOC_EITHER = "either" diff --git a/hyperscale/core/engines/types/http2/frames/types/attributes/struct_types.py b/hyperscale/core/engines/types/http2/frames/types/attributes/struct_types.py new file mode 100644 index 0000000..c96bcaa --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/types/attributes/struct_types.py @@ -0,0 +1,10 @@ +import struct + +# Structs for packing and unpacking +_STRUCT_HBBBL = struct.Struct(">HBBBL") +_STRUCT_LL = struct.Struct(">LL") +_STRUCT_HL = struct.Struct(">HL") +_STRUCT_LB = struct.Struct(">LB") +_STRUCT_L = struct.Struct(">L") +_STRUCT_H = struct.Struct(">H") +_STRUCT_B = struct.Struct(">B") \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/frames/types/base_frame.py b/hyperscale/core/engines/types/http2/frames/types/base_frame.py new file mode 100644 index 0000000..849e33a --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/types/base_frame.py @@ -0,0 +1,601 @@ +# -*- coding: utf-8 -*- +""" +hyperframe/frame +~~~~~~~~~~~~~~~~ + +Defines framing logic for HTTP/2. Provides both classes to represent framed +data and logic for aiding the connection when it comes to reading from the +socket. +""" +import sys +from typing import Any, Iterable, List, Optional + +from hyperscale.core.engines.types.http2.errors.exceptions import ( + StreamClosedError, + StreamError, +) +from hyperscale.core.engines.types.http2.errors.types import ErrorCodes +from hyperscale.core.engines.types.http2.events.connection_terminated_event import ( + ConnectionTerminated, +) +from hyperscale.core.engines.types.http2.events.data_received_event import DataReceived +from hyperscale.core.engines.types.http2.events.deferred_headers_event import ( + DeferredHeaders, +) +from hyperscale.core.engines.types.http2.events.remote_settings_changed_event import ( + RemoteSettingsChanged, +) +from hyperscale.core.engines.types.http2.events.settings_acknowledged_event import ( + SettingsAcknowledged, +) +from hyperscale.core.engines.types.http2.events.stream_ended_event import StreamEnded +from hyperscale.core.engines.types.http2.events.stream_reset import StreamReset +from hyperscale.core.engines.types.http2.events.window_updated_event import ( + WindowUpdated, +) +from hyperscale.core.engines.types.http2.stream import Stream +from hyperscale.core.engines.types.http2.streams.stream_closed_by import StreamClosedBy +from hyperscale.core.engines.types.http2.streams.stream_settings_codes import ( + SettingCodes, +) + +from .attributes import ( + _STRUCT_B, + _STRUCT_H, + _STRUCT_HBBBL, + _STRUCT_HL, + _STRUCT_L, + _STRUCT_LB, + _STRUCT_LL, + Flag, + Flags, +) +from .utils import raw_data_repr + + +class Frame: + __slots__ = ( + 'stream_id', + 'flags', + 'body_len', + 'flags', + 'type', + 'frame_type', + 'data', + 'settings', + 'origin', + 'field', + 'error_code', + 'pad_length', + 'last_stream_id', + 'additional_data', + 'depends_on', + 'stream_weight', + 'exclusive', + 'opaque_data', + 'promised_stream_id', + 'window_increment', + 'flag_byte', + 'defined_flags' + ) + + FRAMES = {} + """ + The base class for all HTTP/2 frames. + """ + #: The flags defined on this type of frame. + + # If 'has-stream', the frame's stream_id must be non-zero. If 'no-stream', + # it must be zero. If 'either', it's not checked. + stream_association: Optional[str] = None + frame_types = { + 0xA: sys.intern('ALTSVC'), + 0x09: sys.intern('CONTINUATION'), + 0x0: sys.intern('DATA'), + 0x07: sys.intern('GOAWAY'), + 0x01: sys.intern('HEADERS'), + 0x06: sys.intern('PING'), + 0x02: sys.intern('PRIORITY'), + 0x05: sys.intern('PUSHPROMISE'), + 0x03: sys.intern('RESET'), + 0x04: sys.intern('SETTINGS'), + 0x08: sys.intern('WINDOWUPDATE') + } + + def __init__(self, stream_id: int, frame_type: int, flags: Iterable[str] = (), parsed_flag_byte: int = 0, **kwargs: Any) -> None: + #: The stream identifier for the stream this frame was received on. + #: Set to 0 for frames sent on the connection (stream-id 0). + self.stream_id = stream_id + self.type = frame_type + self.frame_type = self.frame_types.get(self.type) + self.field = b'' + self.data = b'' + self.settings = {} + self.origin = b'' + self.error_code = 0 + self.pad_length = 0 + self.last_stream_id = 0 + self.additional_data = 0 + self.depends_on = 0x0 + self.stream_weight = 0x0 + self.exclusive = False + self.opaque_data = b'' + self.promised_stream_id = 0 + self.window_increment = 0 + self.flag_byte = 0x0 + self.defined_flags: List[Flag] = [] + + #: The flags set for this frame. + self.flags = Flags(self.defined_flags) + + #: The frame length, excluding the nine-byte header. + self.body_len = 0 + + for flag in flags: + self.flags.add(flag) + + if self.type == 0xA: + # ALTSVC + self.origin = kwargs.get('origin', b'') + self.field = kwargs.get('fields', b'') + + elif self.type == 0x09: + # CONTINUATION + + self.defined_flags = [ + Flag('END_HEADERS', 0x04) + ] + + self.data = kwargs.get('data') + + elif self.type == 0x0: + # DATA + + self.defined_flags = [ + Flag('END_STREAM', 0x01), + Flag('PADDED', 0x08), + ] + + self.pad_length = kwargs.get('pad_length', 0) + self.data = kwargs.get('data', b'') + + elif self.type == 0x07: + # GOAWAY + self.last_stream_id = kwargs.get('last_stream_id', 0) + self.additional_data = kwargs.get('additional_data', b'') + self.error_code = kwargs.get('error_code', 0) + + elif self.type == 0x01: + # HEADERS + self.defined_flags = [ + Flag('END_STREAM', 0x01), + Flag('END_HEADERS', 0x04), + Flag('PADDED', 0x08), + Flag('PRIORITY', 0x20), + ] + + self.data = kwargs.get('data', b'') + self.pad_length = kwargs.get('pad_length', 0) + self.depends_on = kwargs.get('depends_on', 0x0) + self.stream_weight = kwargs.get('stream_weight', 0x0) + self.exclusive = kwargs.get('exclusive', False) + + elif self.type == 0x06: + # PING + + self.defined_flags = [ + Flag('ACK', 0x01) + ] + + self.opaque_data = kwargs.get('opaque_data', b'') + + elif self.type == 0x02: + # PRIORITY + self.depends_on = kwargs.get('depends_on', 0x0) + self.stream_weight = kwargs.get('stream_weight', 0x0) + self.exclusive = kwargs.get('exclusive', False) + + elif self.type == 0x05: + # PUSH PROMISE + self.defined_flags = [ + Flag('END_HEADERS', 0x04), + Flag('PADDED', 0x08) + ] + + self.promised_stream_id = kwargs.get('promised_stream_id', 0) + self.pad_length = kwargs.get('pad_length', 0) + self.data = kwargs.get('data', b'') + + elif self.type == 0x03: + # RESET + self.error_code = kwargs.get('error_code', 0) + + elif self.type == 0x04: + # SETTINGS + self.defined_flags = [ + Flag('ACK', 0x01) + ] + + self.settings = kwargs.get('settings', {}) + + elif self.type == 0x08: + # WINDOW UPDATE + self.window_increment = kwargs.get('window_increment', 0) + + else: + # EXTENSION + self.flag_byte = kwargs.get('flag_byte', 0x0) + + for flag, flag_bit in self.defined_flags: + if parsed_flag_byte & flag_bit: + self.flags.add(flag) + + def __repr__(self) -> str: + body_repr = self._body_repr(), + return f"{type(self).__name__}(stream_id={self.stream_id}, flags={repr(self.flags)}): {body_repr}" + + def _body_repr(self) -> str: + # More specific implementation may be provided by subclasses of Frame. + # This fallback shows the serialized (and truncated) body content. + return raw_data_repr(self.serialize()) + + @property + def flow_controlled_length(self) -> int: + """ + The length of the frame that needs to be accounted for when considering + flow control. + """ + padding_len = 0 + if 'PADDED' in self.flags: + # Account for extra 1-byte padding length field, which is still + # present if possibly zero-valued. + padding_len = self.pad_length + 1 + return len(self.data) + padding_len + + def parse_flags(self, flag_byte: int) -> Flags: + + for flag, flag_bit in self.defined_flags: + if flag_byte & flag_bit: + self.flags.add(flag) + + return self.flags + + def serialize(self) -> bytes: + """ + Convert a frame into a bytestring, representing the serialized form of + the frame. + """ + + body = b'' + flags = 0 + + if self.type == 0xA: + # ALTSVC + origin_len = _STRUCT_H.pack(len(self.origin)) + body = origin_len + self.origin + self.field + + elif self.type == 0x09: + # CONTINUATION + + body = self.data + + elif self.type == 0x0: + # DATA + + padding_data = b'' + if 'PADDED' in self.flags: # type: ignore + padding_data = _STRUCT_B.pack(self.pad_length) + + + padding = b'\0' * self.pad_length + body = padding_data + self.data + padding + + elif self.type == 0x07: + # GOAWAY + + self.data = _STRUCT_LL.pack( + self.last_stream_id & 0x7FFFFFFF, + self.error_code + ) + + body = self.data + self.additional_data + + elif self.type == 0x01: + # HEADERS + + padding_data = b'' + if 'PADDED' in self.flags: # type: ignore + padding_data = _STRUCT_B.pack(self.pad_length) + + padding = b'\0' * self.pad_length + + if 'PRIORITY' in self.flags: + priority_data = _STRUCT_LB.pack( + self.depends_on + (0x80000000 if self.exclusive else 0), + self.stream_weight + ) + else: + priority_data = b'' + + body = padding_data + priority_data + self.data + padding + + elif self.type == 0x06: + # PING + + body = self.opaque_data + body += b'\x00' * (8 - len(body)) + + elif self.type == 0x02: + # PRIORITY + body = _STRUCT_LB.pack( + self.depends_on + (0x80000000 if self.exclusive else 0), + self.stream_weight + ) + + elif self.type == 0x05: + # PUSH PROMISE + + padding_data = b'' + if 'PADDED' in self.flags: # type: ignore + padding_data = _STRUCT_B.pack(self.pad_length) + + padding = b'\0' * self.pad_length + promise_data = _STRUCT_L.pack(self.promised_stream_id) + + body = padding_data + promise_data + self.data + padding + + elif self.type == 0x03: + # RESET + body = _STRUCT_L.pack(self.error_code) + + elif self.type == 0x04: + # SETTINGS + for setting, value in self.settings.items(): + body += _STRUCT_HL.pack(setting & 0xFF, value) + + elif self.type == 0x08: + # WINDOW UPDATE + body = _STRUCT_L.pack(self.window_increment & 0x7FFFFFFF) + + else: + # EXTENSION + flags = self.flag_byte + + self.body_len = len(body) + + + for flag, flag_bit in self.defined_flags: + if flag in self.flags: + flags |= flag_bit + + header = _STRUCT_HBBBL.pack( + (self.body_len >> 8) & 0xFFFF, # Length spread over top 24 bits + self.body_len & 0xFF, + self.type, + flags, + self.stream_id & 0x7FFFFFFF # Stream ID is 32 bits. + ) + + return header + body + + def get_events_and_frames(self, stream: Stream, connection): + + if self.type == 0x0: + # DATA + end_stream = 'END_STREAM' in self.flags + flow_controlled_length = self.flow_controlled_length + frame_data = self.data + + frames = [] + data_events = [] + connection._inbound_flow_control_window_manager.window_consumed( + flow_controlled_length + ) + + try: + + stream.inbound.window_consumed(flow_controlled_length) + + event = DataReceived() + event.stream_id = stream.stream_id + + data_events.append(event) + + if end_stream: + event = StreamEnded() + event.stream_id = stream.stream_id + data_events[0].stream_ended = event + data_events.append(event) + + data_events[0].data = frame_data + data_events[0].flow_controlled_length = flow_controlled_length + return frames, data_events + + except StreamClosedError as e: + # This stream is either marked as CLOSED or already gone from our + # internal state. + + conn_manager = connection._inbound_flow_control_window_manager + conn_increment = conn_manager.process_bytes( + flow_controlled_length + ) + + if conn_increment: + f = Frame(0) + f.window_increment = conn_increment + frames.append(f) + + f = Frame(e.stream_id, 0x03) + f.error_code = e.error_code + frames.append(f) + + return frames, data_events + e._events + + elif self.type == 0x07: + # GOAWAY + self._data_to_send = b'' + + new_event = ConnectionTerminated() + new_event.error_code = ErrorCodes(self.error_code) + new_event.last_stream_id = self.last_stream_id + + if self.additional_data: + new_event.additional_data = self.additional_data + + return [], [new_event] + + elif self.type == 0x01: + # HEADERS + + stream_events = [] + deferred_headers = DeferredHeaders( + stream.encoder, + self, + connection._h2_state.config.header_encoding + ) + + stream_events.append(deferred_headers) + + if deferred_headers.end_stream: + event = StreamEnded() + event.stream_id = stream.stream_id + + stream_events[0].stream_ended = event + stream_events.append(event) + + return [], stream_events + + elif self.type == 0x03: + # RESET + + stream.closed_by = StreamClosedBy.RECV_RST_STREAM + reset_event = StreamReset() + reset_event.stream_id = stream.stream_id + reset_event[0].error_code = ErrorCodes(self.error_code) + + return [], [reset_event] + + elif self.type == 0x04: + # SETTINGS + stream_events = [] + if 'ACK' in self.flags: + + changes = connection.local_settings.acknowledge() + + initial_window_size_change = changes.get(SettingCodes.INITIAL_WINDOW_SIZE) + max_header_list_size_change = changes.get(SettingCodes.MAX_HEADER_LIST_SIZE) + max_frame_size_change = changes.get(SettingCodes.MAX_FRAME_SIZE) + header_table_size_change =changes.get(SettingCodes.HEADER_TABLE_SIZE) + + if initial_window_size_change is not None: + + window_delta = initial_window_size_change.new_value - initial_window_size_change.original_value + + new_max_window_size = stream.inbound.max_window_size + window_delta + stream.inbound.window_opened(window_delta) + stream.inbound.max_window_size = new_max_window_size + + if max_header_list_size_change is not None: + connection._decoder.max_header_list_size = max_header_list_size_change.new_value + + if max_frame_size_change is not None: + stream.max_outbound_frame_size = max_frame_size_change.new_value + + if header_table_size_change: + # This is safe across all hpack versions: some versions just won't + # respect it. + connection._decoder.max_allowed_table_size = header_table_size_change.new_value + + ack_event = SettingsAcknowledged() + ack_event.changed_settings = changes + stream_events.append(ack_event) + return [], stream_events + + # Add the new settings. + connection.remote_settings.update(self.settings) + stream_events.append( + RemoteSettingsChanged.from_settings( + connection.remote_settings, self.settings + ) + ) + + changes = connection.remote_settings.acknowledge() + initial_window_size_change = changes.get(SettingCodes.INITIAL_WINDOW_SIZE) + header_table_size_change = changes.get(SettingCodes.HEADER_TABLE_SIZE) + max_frame_size_change = changes.get(SettingCodes.MAX_FRAME_SIZE) + + if initial_window_size_change: + stream.current_outbound_window_size = connection._guard_increment_window( + stream.current_outbound_window_size, + initial_window_size_change.new_value - initial_window_size_change.original_value + ) + + # HEADER_TABLE_SIZE changes by the remote part affect our encoder: cf. + # RFC 7540 Section 6.5.2. + if header_table_size_change: + connection._encoder.header_table_size = header_table_size_change.new_value + + if max_frame_size_change: + stream.max_outbound_frame_size = max_frame_size_change.new_value + + frames = Frame(0, 0x04) + frames.flags.add('ACK') + + return [frames], stream_events + + elif self.type == 0x08: + # WINDOW UPDATE + stream_events = [] + frames = [] + increment = self.window_increment + if self.stream_id: + try: + + + event = WindowUpdated() + event.stream_id = stream.stream_id + + # If we encounter a problem with incrementing the flow control window, + # this should be treated as a *stream* error, not a *connection* error. + # That means we need to catch the error and forcibly close the stream. + event.delta = increment + + try: + connection.outbound_flow_control_window = connection._guard_increment_window( + connection.outbound_flow_control_window, + increment + ) + except StreamError: + # Ok, this is bad. We're going to need to perform a local + # reset. + + event = StreamReset() + event.stream_id = stream.stream_id + event.error_code = ErrorCodes.FLOW_CONTROL_ERROR + event.remote_reset = False + + stream.closed_by = ErrorCodes.FLOW_CONTROL_ERROR + + rsf = Frame(stream.stream_id, 0x03) + rsf.error_code = ErrorCodes.FLOW_CONTROL_ERROR + + frames = [rsf] + + stream_events.append(event) + except Exception: + return [], stream_events + else: + connection.outbound_flow_control_window = connection._guard_increment_window( + connection.outbound_flow_control_window, + increment + ) + # FIXME: Should we split this into one event per active stream? + window_updated_event = WindowUpdated() + window_updated_event.stream_id = 0 + window_updated_event.delta = increment + stream_events.append(window_updated_event) + frames = [] + + return frames, stream_events + + return None, None diff --git a/hyperscale/core/engines/types/http2/frames/types/utils.py b/hyperscale/core/engines/types/http2/frames/types/utils.py new file mode 100644 index 0000000..7c9df88 --- /dev/null +++ b/hyperscale/core/engines/types/http2/frames/types/utils.py @@ -0,0 +1,18 @@ +import binascii +import collections +from typing import Optional + + +def raw_data_repr(data: Optional[bytes]) -> str: + if not data: + return "None" + r = binascii.hexlify(data).decode('ascii') + if len(r) > 20: + r = r[:20] + "..." + return "" + + +HeaderValidationFlags = collections.namedtuple( + 'HeaderValidationFlags', + ['is_client', 'is_trailer', 'is_response_header', 'is_push_promise'] +) diff --git a/hyperscale/core/engines/types/http2/pipe.py b/hyperscale/core/engines/types/http2/pipe.py new file mode 100644 index 0000000..5e0fd98 --- /dev/null +++ b/hyperscale/core/engines/types/http2/pipe.py @@ -0,0 +1,457 @@ +import asyncio +from typing import ( + Dict, + List, + Optional, + Tuple, +) + +from hyperscale.core.engines.types.common.fast_hpack import Decoder, Encoder +from hyperscale.core.engines.types.common.hpack.table import HeaderTable +from hyperscale.core.engines.types.http2.config import H2Configuration +from hyperscale.core.engines.types.http2.errors.exceptions import ( + StreamClosedError, + StreamError, +) +from hyperscale.core.engines.types.http2.errors.types import ErrorCodes +from hyperscale.core.engines.types.http2.events.connection_terminated_event import ( + ConnectionTerminated, +) +from hyperscale.core.engines.types.http2.events.data_received_event import DataReceived +from hyperscale.core.engines.types.http2.events.stream_reset import StreamReset +from hyperscale.core.engines.types.http2.events.window_updated_event import ( + WindowUpdated, +) +from hyperscale.core.engines.types.http2.stream import Stream +from hyperscale.core.engines.types.http2.streams.stream_closed_by import StreamClosedBy +from hyperscale.core.engines.types.http2.streams.stream_settings import Settings +from hyperscale.core.engines.types.http2.streams.stream_settings_codes import ( + SettingCodes, +) +from hyperscale.core.engines.types.tracing.trace_session import Trace + +from .action import HTTP2Action +from .frames.types.base_frame import Frame +from .result import HTTP2Result +from .windows import WindowManager + + +class HTTP2Pipe: + __slots__ = ( + '_h2_state', + 'connected', + 'concurrency', + '_encoder', + '_decoder', + '_init_sent', + 'stream_id', + '_data_to_send', + '_headers_sent', + 'lock', + 'local_settings', + 'remote_settings', + 'outbound_flow_control_window', + 'local_settings', + '_inbound_flow_control_window_manager', + 'local_settings_dict', + 'remote_settings_dict' + ) + + CONFIG = H2Configuration( + validate_inbound_headers=False, + ) + + def __init__(self, concurrency): + self.connected = False + self.concurrency = concurrency + self._encoder = Encoder() + self._decoder = Decoder() + self._decoder.header_table = HeaderTable() + self._decoder.max_allowed_table_size = self._decoder.header_table.maxsize + self._init_sent = False + self.stream_id = None + self._data_to_send = b'' + self._headers_sent = False + self.lock = asyncio.Lock() + + self.local_settings = Settings( + client=True, + initial_values={ + SettingCodes.ENABLE_PUSH: 0, + SettingCodes.MAX_CONCURRENT_STREAMS: concurrency, + SettingCodes.MAX_HEADER_LIST_SIZE: 65535, + } + ) + self.remote_settings = Settings( + client=False + ) + + self.outbound_flow_control_window = self.remote_settings.initial_window_size + + del self.local_settings[SettingCodes.ENABLE_CONNECT_PROTOCOL] + + self._inbound_flow_control_window_manager = WindowManager( + max_window_size=self.local_settings.initial_window_size + ) + + self.local_settings_dict = {setting_name: setting_value for setting_name, setting_value in self.local_settings.items()} + self.remote_settings_dict = {setting_name: setting_value for setting_name, setting_value in self.remote_settings.items()} + + + def _guard_increment_window(self, current, increment): + # The largest value the flow control window may take. + LARGEST_FLOW_CONTROL_WINDOW = 2**31 - 1 + + new_size = current + increment + + if new_size > LARGEST_FLOW_CONTROL_WINDOW: + self.outbound_flow_control_window = ( + self.remote_settings.initial_window_size + ) + + self._inbound_flow_control_window_manager = WindowManager( + max_window_size=self.local_settings.initial_window_size + ) + + return LARGEST_FLOW_CONTROL_WINDOW - current + + def send_request_headers(self, request: HTTP2Action, stream: Stream): + + self._headers_sent = False + + if self._init_sent is False or stream.reset_connection: + + window_increment = 65536 + + self._inbound_flow_control_window_manager.window_opened(window_increment) + + stream.write(bytes(stream.connection_data)) + self._init_sent = True + + self.outbound_flow_control_window = self.remote_settings.initial_window_size + + stream.inbound = WindowManager(self.local_settings.initial_window_size) + stream.outbound = WindowManager(self.remote_settings.initial_window_size) + stream.max_inbound_frame_size = self.local_settings.max_frame_size + stream.max_outbound_frame_size = self.remote_settings.max_frame_size + stream.current_outbound_window_size = self.remote_settings.initial_window_size + + end_stream = request.encoded_data is None + encoded_headers = request.encoded_headers + + if request.hpack_encoder.header_table.maxsize != self._encoder.header_table.maxsize: + request.hpack_encoder.header_table_size = self._encoder.header_table.maxsize + request._setup_headers() + + stream.headers_frame.data = encoded_headers[0] + headers_frame = stream.headers_frame + if end_stream: + headers_frame.flags.add('END_STREAM') + + stream.inbound.window_opened(65536) + + stream.write(headers_frame.serialize()) + self._headers_sent = True + + async def receive_response( + self, + action: HTTP2Action, + response: HTTP2Result, + stream: Stream, + trace: Trace + ): + + done = False + while done is False: + + data = b'' + + try: + + data = await asyncio.wait_for(stream.read(), timeout=stream.timeouts.socket_read_timeout) + + except asyncio.TimeoutError as timeout: + response._status = 408 + response.error = str(timeout) + done = True + + return response + + except asyncio.CancelledError as cancelled: + response._status = 408 + response.error = str(cancelled) + done = True + + return response + + stream.frame_buffer.data.extend(data) + stream.frame_buffer.max_frame_size = stream.max_outbound_frame_size + + write_data = bytearray() + frames = None + stream_events = [] + + for frame in stream.frame_buffer: + try: + + if frame.type == 0x0: + # DATA + + end_stream = 'END_STREAM' in frame.flags + flow_controlled_length = frame.flow_controlled_length + frame_data = frame.data + + frames = [] + self._inbound_flow_control_window_manager.window_consumed( + flow_controlled_length + ) + + try: + + stream.inbound.window_consumed(flow_controlled_length) + + event = DataReceived() + event.stream_id = stream.stream_id + + stream_events.append(event) + + if end_stream: + done = True + + stream_events[0].data = frame_data + stream_events[0].flow_controlled_length = flow_controlled_length + + if trace and trace.on_response_data_received: + await trace.on_response_data_received( + trace.span, + action, + response + ) + + except StreamClosedError as e: + raise Exception(f'Connection - {stream.stream_id} err: {str(e._events[0])}') + + elif frame.type == 0x07: + # GOAWAY + self._data_to_send = b'' + + new_event = ConnectionTerminated() + new_event.error_code = ErrorCodes(frame.error_code) + new_event.last_stream_id = frame.last_stream_id + + if frame.additional_data: + new_event.additional_data = frame.additional_data + + frames = [] + raise Exception(f'Connection - {stream.stream_id} err: {str(new_event)}') + + elif frame.type == 0x01: + # HEADERS + headers: List[Tuple[bytes, bytes]] = {} + + try: + headers = self._decoder.decode(frame.data, raw=True) + + except Exception: + return 400, {} + + status_code: Optional[int] = None + headers_dict: Dict[bytes, bytes] = {} + for k, v in headers: + if k == b":status": + status_code = int(v.decode("ascii", errors="ignore")) + elif k.startswith(b":"): + headers_dict[k.strip(b':')] = v + else: + headers_dict[k] = v + + + + if 'END_STREAM' in frame.flags: + done = True + + frames = [] + response.status = status_code + response.headers = headers_dict + + if trace and trace.on_response_headers_received: + await trace.on_response_headers_received( + trace.span, + action, + response + ) + + elif frame.type == 0x03: + # RESET + + stream.closed_by = StreamClosedBy.RECV_RST_STREAM + reset_event = StreamReset() + reset_event.stream_id = stream.stream_id + + reset_event.error_code = ErrorCodes(frame.error_code) + + raise Exception(f'Connection - {stream.stream_id} err: {str(reset_event)}') + + elif frame.type == 0x04: + # SETTINGS + + if 'ACK' in frame.flags: + + changes = self.local_settings.acknowledge() + + initial_window_size_change = changes.get(SettingCodes.INITIAL_WINDOW_SIZE) + max_header_list_size_change = changes.get(SettingCodes.MAX_HEADER_LIST_SIZE) + max_frame_size_change = changes.get(SettingCodes.MAX_FRAME_SIZE) + header_table_size_change =changes.get(SettingCodes.HEADER_TABLE_SIZE) + + if initial_window_size_change is not None: + + window_delta = initial_window_size_change.new_value - initial_window_size_change.original_value + + new_max_window_size = stream.inbound.max_window_size + window_delta + stream.inbound.window_opened(window_delta) + stream.inbound.max_window_size = new_max_window_size + + if max_header_list_size_change is not None: + self._decoder.max_header_list_size = max_header_list_size_change.new_value + + if max_frame_size_change is not None: + stream.max_outbound_frame_size = max_frame_size_change.new_value + + if header_table_size_change: + # This is safe across all hpack versions: some versions just won't + # respect it. + self._decoder.max_allowed_table_size = header_table_size_change.new_value + + # Add the new settings. + self.remote_settings.update(frame.settings) + + changes = self.remote_settings.acknowledge() + initial_window_size_change = changes.get(SettingCodes.INITIAL_WINDOW_SIZE) + header_table_size_change = changes.get(SettingCodes.HEADER_TABLE_SIZE) + max_frame_size_change = changes.get(SettingCodes.MAX_FRAME_SIZE) + + if initial_window_size_change: + stream.current_outbound_window_size = self._guard_increment_window( + stream.current_outbound_window_size, + initial_window_size_change.new_value - initial_window_size_change.original_value + ) + + # HEADER_TABLE_SIZE changes by the remote part affect our encoder: cf. + # RFC 7540 Section 6.5.2. + if header_table_size_change: + self._encoder.header_table_size = header_table_size_change.new_value + + if max_frame_size_change: + stream.max_outbound_frame_size = max_frame_size_change.new_value + + frame = Frame(0, 0x04) + frame.flags.add('ACK') + + frames = [frame] + + elif frame.type == 0x08: + # WINDOW UPDATE + + frames = [] + increment = frame.window_increment + if frame.stream_id: + try: + + + event = WindowUpdated() + event.stream_id = stream.stream_id + + # If we encounter a problem with incrementing the flow control window, + # this should be treated as a *stream* error, not a *connection* error. + # That means we need to catch the error and forcibly close the stream. + event.delta = increment + + try: + self.outbound_flow_control_window = self._guard_increment_window( + self.outbound_flow_control_window, + increment + ) + except StreamError: + # Ok, this is bad. We're going to need to perform a local + # reset. + + event = StreamReset() + event.stream_id = stream.stream_id + event.error_code = ErrorCodes.FLOW_CONTROL_ERROR + event.remote_reset = False + + stream.closed_by = ErrorCodes.FLOW_CONTROL_ERROR + + raise Exception(f'Connection - {stream.stream_id} err: {str(event)}') + except Exception: + frames = [] + else: + self.outbound_flow_control_window = self._guard_increment_window( + self.outbound_flow_control_window, + increment + ) + # FIXME: Should we split this into one event per active stream? + window_updated_event = WindowUpdated() + window_updated_event.stream_id = 0 + window_updated_event.delta = increment + + frames = [] + + except Exception as e: + raise Exception(f'Connection {stream.stream_id} err- {str(e)}') + + if frames: + for f in frames: + write_data.extend(f.serialize()) + + stream.write(write_data) + + for event in stream_events: + amount = event.flow_controlled_length + + conn_increment = self._inbound_flow_control_window_manager.process_bytes(amount) + + if conn_increment: + stream.write_window_update_frame(0, conn_increment) + + if event.data is None: + event.data = b'' + + response.body.extend(event.data) + + if done: + break + + return response + + async def submit_request_body(self, request: HTTP2Action, stream: Stream) -> None: + data = request.encoded_data + + while data: + local_flow = stream.current_outbound_window_size + max_frame_size = stream.max_outbound_frame_size + flow = min(local_flow, max_frame_size) + while flow == 0: + await self._receive_events(stream) + local_flow = stream.current_outbound_window_size + max_frame_size = stream.max_outbound_frame_size + flow = min(local_flow, max_frame_size) + + max_flow = flow + chunk_size = min(len(data), max_flow) + chunk, data = data[:chunk_size], data[chunk_size:] + + df = Frame(stream.stream_id, 0x0) + df.data = chunk + + # Subtract flow_controlled_length to account for possible padding + self.outbound_flow_control_window -= df.flow_controlled_length + assert self.outbound_flow_control_window >= 0 + + stream.write(df.serialize()) + + df = Frame(stream.stream_id, 0x0) + df.flags.add('END_STREAM') + + stream.write(df.serialize()) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/pool.py b/hyperscale/core/engines/types/http2/pool.py new file mode 100644 index 0000000..d0d4836 --- /dev/null +++ b/hyperscale/core/engines/types/http2/pool.py @@ -0,0 +1,60 @@ +from random import randrange +from typing import List + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes + +from .connection import HTTP2Connection +from .pipe import HTTP2Pipe + + +class HTTP2Pool: + + __slots__ = ( + 'size', + 'connections', + 'pipes', + 'timeouts', + 'reset_connections', + 'pool_type' + ) + + def __init__(self, size: int, timeouts: Timeouts, reset_connections: bool=False) -> None: + self.size = size + self.connections: List[HTTP2Connection] = [] + self.pipes: List[HTTP2Pipe] = [] + self.timeouts = timeouts + self.reset_connections = reset_connections + self.pool_type: RequestTypes = RequestTypes.HTTP2 + + def create_pool(self) -> None: + + self.pipes = [ HTTP2Pipe(self.size) for _ in range(self.size) ] + + self.connections = [ + HTTP2Connection( + randrange(1, 2**20 + 2, 2), + self.timeouts, + self.size, + self.reset_connections, + self.pool_type + ) for _ in range(0, self.size * 2, 2) + ] + + + def reset(self): + self.pipes.append(HTTP2Pipe(self.size)) + + self.connections.append( + HTTP2Connection( + randrange(1, 2**20 + 2, 2), + self.timeouts, + self.size, + self.reset_connections, + self.pool_type + ) + ) + + async def close(self): + for connection in self.connections: + await connection.close() diff --git a/hyperscale/core/engines/types/http2/result.py b/hyperscale/core/engines/types/http2/result.py new file mode 100644 index 0000000..9c9d965 --- /dev/null +++ b/hyperscale/core/engines/types/http2/result.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import json +from gzip import decompress as gzip_decompress +from typing import Dict, Union +from zlib import decompress as zlib_decompress + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.types import RequestTypes + +from .action import HTTP2Action + + +class HTTP2Result(BaseResult): + + __slots__ = ( + 'action_id', + 'url', + 'ip_addr', + 'method', + 'path', + 'params', + 'query', + 'hostname', + 'body', + 'response_code', + 'deferred_headers', + '_headers', + '_compression', + '_content_type', + '_size', + '_version', + '_reason', + '_status' + ) + + def __init__(self, action: HTTP2Action, error: Exception = None) -> None: + super( + HTTP2Result, + self + ).__init__( + action.action_id, + action.name, + action.url.hostname, + action.metadata.user, + action.metadata.tags, + RequestTypes.HTTP2, + error + ) + + self.url = action.url.full + self.ip_addr = action.url.ip_addr + self.method = action.method + self.path = action.url.path + self.params = action.url.params + self.query = action.url.query + self.hostname = action.url.hostname + self.headers: Dict[bytes, bytes] = {} + self.status: int = None + self.body = bytearray() + + self.response_code: str = None + self._compression = None + self._content_type = None + self._size = None + self._version = None + self._reason = None + self._status = None + + @property + def content_type(self): + if len(self.headers) == 0 and self.deferred_headers: + self.headers = self._parse_headers() + self._content_type = self.headers.get(b'content-type') + + return self._content_type + + @content_type.setter + def content_type(self, value: str): + self._content_type = value + + @property + def compression(self): + if len(self.headers) == 0 and self.deferred_headers: + self.headers = self._parse_headers() + self._compression = self.headers.get(b'content-encoding') + + return self._compression + + @compression.setter + def compression(self, value: str): + self._compression = value + + @property + def version(self) -> Union[str, None]: + if len(self.headers) == 0 and self.deferred_headers: + self.headers = self._parse_headers() + self._version = self.headers.get(b'version') + + return self._version + + @version.setter + def version(self, value: str): + self._version = value + + @property + def reason(self) -> Union[str, None]: + if len(self.headers) == 0 and self.deferred_headers: + self.headers = self._parse_headers() + self._reason = self.headers.get(b'reason') + + return self._reason + + @reason.setter + def reason(self, value: str): + self._reason = value + + @property + def size(self): + + if len(self.headers) == 0 and self.deferred_headers: + self.headers = self._parse_headers() + content_length = self.headers.get(b'content-length') + if content_length: + self._size = int(content_length) + + elif len(self.body) > 0: + self._size = len(self.body) + + else: + self._size = 0 + + return self._size + + @size.setter + def size(self, value: int): + self._size = value + + @property + def data(self) -> Union[str, dict, None]: + + if len(self.headers) == 0 and self.deferred_headers: + self._headers = self._parse_headers() + + data = self.body + try: + if self.headers.get(b'content-encoding') == b"gzip": + data = gzip_decompress(self.body) + elif self.headers.get(b'content-encoding') == b"deflate": + data = zlib_decompress(self.body) + + if self.headers.get(b'content-type') == b"application/json": + data = json.loads(self.body) + + elif isinstance(self.body, (bytes, bytearray)): + data = str(self.body.decode()) + + except Exception: + pass + + return data + + @data.setter + def data(self, value): + self.body = value + + def _parse_headers(self): + try: + status, decoded_headers = self.deferred_headers.parse() + decoded_headers['status'] = status + return decoded_headers + + except Exception: + return {} diff --git a/hyperscale/core/engines/types/http2/stream.py b/hyperscale/core/engines/types/http2/stream.py new file mode 100644 index 0000000..6ec1d20 --- /dev/null +++ b/hyperscale/core/engines/types/http2/stream.py @@ -0,0 +1,116 @@ + +import struct + +from hyperscale.core.engines.types.common.encoder import Encoder +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.http2.streams.stream_settings import Settings +from hyperscale.core.engines.types.http2.windows.window_manager import WindowManager + + +class Stream: + READ_NUM_BYTES=65536 + + __slots__ = ( + 'stream_id', + 'reader', + 'writer', + 'timeouts', + 'max_inbound_frame_size', + 'max_outbound_frame_size', + 'current_outbound_window_size', + 'content_length', + 'expected_content_length', + 'reset_connection', + 'inbound', + 'outbound', + 'closed_by', + '_authority', + 'frame_buffer', + 'encoder', + '_remote_settings', + '_remote_settings_dict', + 'settings_frame', + 'headers_frame', + 'window_frame', + 'connection_data', + '_STRUCT_HBBBL', + '_STRUCT_LL', + '_STRUCT_HL', + '_STRUCT_LB', + '_STRUCT_L', + '_STRUCT_H', + '_STRUCT_B' + ) + + def __init__(self, stream_id: int, timeouts: Timeouts) -> None: + self.stream_id = stream_id + self.reader: Reader = None + self.writer: Writer = None + self.timeouts = timeouts + self.max_inbound_frame_size = 0 + self.max_outbound_frame_size = 0 + self.current_outbound_window_size = 0 + self.content_length = 0 + self.expected_content_length = 0 + self.reset_connection = False + self.inbound: WindowManager = None + self.outbound: WindowManager = None + self.closed_by = None + self._authority = None + self.frame_buffer = None + self.encoder: Encoder = None + self._remote_settings = Settings( + client=False + ) + self._remote_settings_dict = { + setting_name: setting_value for setting_name, setting_value in self._remote_settings.items() + } + + self.settings_frame = None + self.headers_frame = None + self.window_frame = None + + self.connection_data = bytearray(b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n') + + self._STRUCT_HBBBL = struct.Struct(">HBBBL") + self._STRUCT_LL = struct.Struct(">LL") + self._STRUCT_HL = struct.Struct(">HL") + self._STRUCT_LB = struct.Struct(">LB") + self._STRUCT_L = struct.Struct(">L") + self._STRUCT_H = struct.Struct(">H") + self._STRUCT_B = struct.Struct(">B") + + def write(self, data: bytes): + self.writer._transport.write(data) + + def read(self, msg_length: int=READ_NUM_BYTES): + return self.reader.read(msg_length) + + def get_raw_buffer(self) -> bytearray: + return self.reader._buffer + + def write_window_update_frame(self, stream_id: int=None, window_increment: int=None): + + if stream_id is None: + stream_id = self.stream_id + + body = self._STRUCT_L.pack(window_increment & 0x7FFFFFFF) + body_len = len(body) + + type = 0x08 + + # Build the common frame header. + # First, get the flags. + flags = 0 + + header = self._STRUCT_HBBBL.pack( + (body_len >> 8) & 0xFFFF, # Length spread over top 24 bits + body_len & 0xFF, + type, + flags, + stream_id & 0x7FFFFFFF # Stream ID is 32 bits. + ) + + self.writer.write(header + body) \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/streams/__init__.py b/hyperscale/core/engines/types/http2/streams/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/engines/types/http2/streams/changed_setting.py b/hyperscale/core/engines/types/http2/streams/changed_setting.py new file mode 100644 index 0000000..fdf34b5 --- /dev/null +++ b/hyperscale/core/engines/types/http2/streams/changed_setting.py @@ -0,0 +1,30 @@ +class ChangedSetting: + + __slots__ = ( + 'setting', + 'original_value', + 'new_value' + ) + + def __init__(self, setting, original_value, new_value): + #: The setting code given. Either one of :class:`SettingCodes + #: ` or ``int`` + #: + #: .. versionchanged:: 2.6.0 + self.setting = setting + + #: The original value before being changed. + self.original_value = original_value + + #: The new value after being changed. + self.new_value = new_value + + def __repr__(self): + return ( + "ChangedSetting(setting=%s, original_value=%s, " + "new_value=%s)" + ) % ( + self.setting, + self.original_value, + self.new_value + ) diff --git a/hyperscale/core/engines/types/http2/streams/stream_closed_by.py b/hyperscale/core/engines/types/http2/streams/stream_closed_by.py new file mode 100644 index 0000000..fb37938 --- /dev/null +++ b/hyperscale/core/engines/types/http2/streams/stream_closed_by.py @@ -0,0 +1,7 @@ +from enum import Enum + +class StreamClosedBy(Enum): + SEND_END_STREAM = 0 + RECV_END_STREAM = 1 + SEND_RST_STREAM = 2 + RECV_RST_STREAM = 3 \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/streams/stream_settings.py b/hyperscale/core/engines/types/http2/streams/stream_settings.py new file mode 100644 index 0000000..3023c49 --- /dev/null +++ b/hyperscale/core/engines/types/http2/streams/stream_settings.py @@ -0,0 +1,248 @@ +from collections import deque +from collections.abc import MutableMapping + +from hyperscale.core.engines.types.http2.errors.types import ErrorCodes + +from .changed_setting import ChangedSetting +from .stream_settings_codes import SettingCodes + + +class Settings(MutableMapping): + """ + An object that encapsulates HTTP/2 settings state. + + HTTP/2 Settings are a complex beast. Each party, remote and local, has its + own settings and a view of the other party's settings. When a settings + frame is emitted by a peer it cannot assume that the new settings values + are in place until the remote peer acknowledges the setting. In principle, + multiple settings changes can be "in flight" at the same time, all with + different values. + + This object encapsulates this mess. It provides a dict-like interface to + settings, which return the *current* values of the settings in question. + Additionally, it keeps track of the stack of proposed values: each time an + acknowledgement is sent/received, it updates the current values with the + stack of proposed values. On top of all that, it validates the values to + make sure they're allowed, and raises :class:`InvalidSettingsValueError + ` if they are not. + + Finally, this object understands what the default values of the HTTP/2 + settings are, and sets those defaults appropriately. + + .. versionchanged:: 2.2.0 + Added the ``initial_values`` parameter. + + .. versionchanged:: 2.5.0 + Added the ``max_header_list_size`` property. + + :param client: (optional) Whether these settings should be defaulted for a + client implementation or a server implementation. Defaults to ``True``. + :type client: ``bool`` + :param initial_values: (optional) Any initial values the user would like + set, rather than RFC 7540's defaults. + :type initial_vales: ``MutableMapping`` + """ + def __init__(self, client=True, initial_values=None): + # Backing object for the settings. This is a dictionary of + # (setting: [list of values]), where the first value in the list is the + # current value of the setting. Strictly this doesn't use lists but + # instead uses collections.deque to avoid repeated memory allocations. + # + # This contains the default values for HTTP/2. + self._settings = { + SettingCodes.HEADER_TABLE_SIZE: deque([4096]), + SettingCodes.ENABLE_PUSH: deque([int(client)]), + SettingCodes.INITIAL_WINDOW_SIZE: deque([65535]), + SettingCodes.MAX_FRAME_SIZE: deque([16384]), + SettingCodes.ENABLE_CONNECT_PROTOCOL: deque([0]), + } + if initial_values is not None: + for key, value in initial_values.items(): + invalid = _validate_setting(key, value) + if invalid: + raise Exception( + "Setting %d has invalid value %d" % (key, value), + error_code=invalid + ) + self._settings[key] = deque([value]) + + def acknowledge(self): + """ + The settings have been acknowledged, either by the user (remote + settings) or by the remote peer (local settings). + + :returns: A dict of {setting: ChangedSetting} that were applied. + """ + changed_settings = {} + + # If there is more than one setting in the list, we have a setting + # value outstanding. Update them. + for k, v in self._settings.items(): + if len(v) > 1: + old_setting = v.popleft() + new_setting = v[0] + changed_settings[k] = ChangedSetting( + k, old_setting, new_setting + ) + + return changed_settings + + # Provide easy-access to well known settings. + @property + def header_table_size(self): + """ + The current value of the :data:`HEADER_TABLE_SIZE + ` setting. + """ + return self[SettingCodes.HEADER_TABLE_SIZE] + + @header_table_size.setter + def header_table_size(self, value): + self[SettingCodes.HEADER_TABLE_SIZE] = value + + @property + def enable_push(self): + """ + The current value of the :data:`ENABLE_PUSH + ` setting. + """ + return self[SettingCodes.ENABLE_PUSH] + + @enable_push.setter + def enable_push(self, value): + self[SettingCodes.ENABLE_PUSH] = value + + @property + def initial_window_size(self): + """ + The current value of the :data:`INITIAL_WINDOW_SIZE + ` setting. + """ + return self[SettingCodes.INITIAL_WINDOW_SIZE] + + @initial_window_size.setter + def initial_window_size(self, value): + self[SettingCodes.INITIAL_WINDOW_SIZE] = value + + @property + def max_frame_size(self): + """ + The current value of the :data:`MAX_FRAME_SIZE + ` setting. + """ + return self[SettingCodes.MAX_FRAME_SIZE] + + @max_frame_size.setter + def max_frame_size(self, value): + self[SettingCodes.MAX_FRAME_SIZE] = value + + @property + def max_concurrent_streams(self): + """ + The current value of the :data:`MAX_CONCURRENT_STREAMS + ` setting. + """ + return self.get(SettingCodes.MAX_CONCURRENT_STREAMS, 2**32+1) + + @max_concurrent_streams.setter + def max_concurrent_streams(self, value): + self[SettingCodes.MAX_CONCURRENT_STREAMS] = value + + @property + def max_header_list_size(self): + """ + The current value of the :data:`MAX_HEADER_LIST_SIZE + ` setting. If not set, + returns ``None``, which means unlimited. + + .. versionadded:: 2.5.0 + """ + return self.get(SettingCodes.MAX_HEADER_LIST_SIZE, None) + + @max_header_list_size.setter + def max_header_list_size(self, value): + self[SettingCodes.MAX_HEADER_LIST_SIZE] = value + + @property + def enable_connect_protocol(self): + """ + The current value of the :data:`ENABLE_CONNECT_PROTOCOL + ` setting. + """ + return self[SettingCodes.ENABLE_CONNECT_PROTOCOL] + + @enable_connect_protocol.setter + def enable_connect_protocol(self, value): + self[SettingCodes.ENABLE_CONNECT_PROTOCOL] = value + + # Implement the MutableMapping API. + def __getitem__(self, key): + val = self._settings[key][0] + + # Things that were created when a setting was received should stay + # KeyError'd. + if val is None: + raise KeyError + + return val + + def __setitem__(self, key, value): + invalid = _validate_setting(key, value) + if invalid: + raise Exception( + "Setting %d has invalid value %d" % (key, value), + error_code=invalid + ) + + try: + items = self._settings[key] + except KeyError: + items = deque([None]) + self._settings[key] = items + + items.append(value) + + def __delitem__(self, key): + del self._settings[key] + + def __iter__(self): + return self._settings.__iter__() + + def __len__(self): + return len(self._settings) + + def __eq__(self, other): + if isinstance(other, Settings): + return self._settings == other._settings + else: + return NotImplemented + + def __ne__(self, other): + if isinstance(other, Settings): + return not self == other + else: + return NotImplemented + + +def _validate_setting(setting, value): # noqa: C901 + """ + Confirms that a specific setting has a well-formed value. If the setting is + invalid, returns an error code. Otherwise, returns 0 (NO_ERROR). + """ + if setting == SettingCodes.ENABLE_PUSH: + if value not in (0, 1): + return ErrorCodes.PROTOCOL_ERROR + elif setting == SettingCodes.INITIAL_WINDOW_SIZE: + if not 0 <= value <= 2147483647: # 2^31 - 1 + return ErrorCodes.FLOW_CONTROL_ERROR + elif setting == SettingCodes.MAX_FRAME_SIZE: + if not 16384 <= value <= 16777215: # 2^14 and 2^24 - 1 + return ErrorCodes.PROTOCOL_ERROR + elif setting == SettingCodes.MAX_HEADER_LIST_SIZE: + if value < 0: + return ErrorCodes.PROTOCOL_ERROR + elif setting == SettingCodes.ENABLE_CONNECT_PROTOCOL: + if value not in (0, 1): + return ErrorCodes.PROTOCOL_ERROR + + return 0 diff --git a/hyperscale/core/engines/types/http2/streams/stream_settings_codes.py b/hyperscale/core/engines/types/http2/streams/stream_settings_codes.py new file mode 100644 index 0000000..06c3377 --- /dev/null +++ b/hyperscale/core/engines/types/http2/streams/stream_settings_codes.py @@ -0,0 +1,24 @@ +from enum import IntEnum + + +class SettingCodes(IntEnum): + """ + All known HTTP/2 setting codes. + + .. versionadded:: 2.6.0 + """ + + #: The byte that signals the SETTINGS_HEADER_TABLE_SIZE setting. + HEADER_TABLE_SIZE = 0x01 + #: The byte that signals the SETTINGS_ENABLE_PUSH setting. + ENABLE_PUSH = 0x02 + #: The byte that signals the SETTINGS_MAX_CONCURRENT_STREAMS setting. + MAX_CONCURRENT_STREAMS = 0x03 + #: The byte that signals the SETTINGS_INITIAL_WINDOW_SIZE setting. + INITIAL_WINDOW_SIZE = 0x04 + #: The byte that signals the SETTINGS_MAX_FRAME_SIZE setting. + MAX_FRAME_SIZE = 0x05 + #: The byte that signals the SETTINGS_MAX_HEADER_LIST_SIZE setting. + MAX_HEADER_LIST_SIZE = 0x06 + #: The byte that signals SETTINGS_ENABLE_CONNECT_PROTOCOL setting. + ENABLE_CONNECT_PROTOCOL = 0x08 diff --git a/hyperscale/core/engines/types/http2/streams/stream_state.py b/hyperscale/core/engines/types/http2/streams/stream_state.py new file mode 100644 index 0000000..8cd1741 --- /dev/null +++ b/hyperscale/core/engines/types/http2/streams/stream_state.py @@ -0,0 +1,10 @@ +from enum import IntEnum + +class StreamState(IntEnum): + IDLE = 0 + RESERVED_REMOTE = 1 + RESERVED_LOCAL = 2 + OPEN = 3 + HALF_CLOSED_REMOTE = 4 + HALF_CLOSED_LOCAL = 5 + CLOSED = 6 \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/streams/stream_state_map.py b/hyperscale/core/engines/types/http2/streams/stream_state_map.py new file mode 100644 index 0000000..510a693 --- /dev/null +++ b/hyperscale/core/engines/types/http2/streams/stream_state_map.py @@ -0,0 +1,22 @@ +from enum import Enum + +class StreamStateMap(Enum): + SEND_HEADERS = 0 + SEND_PUSH_PROMISE = 1 + SEND_RST_STREAM = 2 + SEND_DATA = 3 + SEND_WINDOW_UPDATE = 4 + SEND_END_STREAM = 5 + RECV_HEADERS = 6 + RECV_PUSH_PROMISE = 7 + RECV_RST_STREAM = 8 + RECV_DATA = 9 + RECV_WINDOW_UPDATE = 10 + RECV_END_STREAM = 11 + RECV_CONTINUATION = 12 # Added in 2.0.0 + SEND_INFORMATIONAL_HEADERS = 13 # Added in 2.2.0 + RECV_INFORMATIONAL_HEADERS = 14 # Added in 2.2.0 + SEND_ALTERNATIVE_SERVICE = 15 # Added in 2.3.0 + RECV_ALTERNATIVE_SERVICE = 16 # Added in 2.3.0 + UPGRADE_CLIENT = 17 # Added 2.3.0 + UPGRADE_SERVER = 18 # Added 2.3.0 diff --git a/hyperscale/core/engines/types/http2/windows/__init__.py b/hyperscale/core/engines/types/http2/windows/__init__.py new file mode 100644 index 0000000..415aaad --- /dev/null +++ b/hyperscale/core/engines/types/http2/windows/__init__.py @@ -0,0 +1 @@ +from .window_manager import WindowManager \ No newline at end of file diff --git a/hyperscale/core/engines/types/http2/windows/window_manager.py b/hyperscale/core/engines/types/http2/windows/window_manager.py new file mode 100644 index 0000000..047dda0 --- /dev/null +++ b/hyperscale/core/engines/types/http2/windows/window_manager.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +""" +h2/windows +~~~~~~~~~~ + +Defines tools for managing HTTP/2 flow control windows. + +The objects defined in this module are used to automatically manage HTTP/2 +flow control windows. Specifically, they keep track of what the size of the +window is, how much data has been consumed from that window, and how much data +the user has already used. It then implements a basic algorithm that attempts +to manage the flow control window without user input, trying to ensure that it +does not emit too many WINDOW_UPDATE frames. +""" +from __future__ import division + + + +# The largest acceptable value for a HTTP/2 flow control window. +LARGEST_FLOW_CONTROL_WINDOW = 2**31 - 1 + + +class WindowManager: + + __slots__ = ( + 'max_window_size', + 'current_window_size', + '_bytes_processed' + ) + + """ + A basic HTTP/2 window manager. + + :param max_window_size: The maximum size of the flow control window. + :type max_window_size: ``int`` + """ + def __init__(self, max_window_size): + self.max_window_size = max_window_size + self.current_window_size = max_window_size + self._bytes_processed = 0 + + def window_consumed(self, size): + """ + We have received a certain number of bytes from the remote peer. This + necessarily shrinks the flow control window! + + :param size: The number of flow controlled bytes we received from the + remote peer. + :type size: ``int`` + :returns: Nothing. + :rtype: ``None`` + """ + self.current_window_size -= size + + def window_opened(self, size): + """ + The flow control window has been incremented, either because of manual + flow control management or because of the user changing the flow + control settings. This can have the effect of increasing what we + consider to be the "maximum" flow control window size. + + This does not increase our view of how many bytes have been processed, + only of how much space is in the window. + + :param size: The increment to the flow control window we received. + :type size: ``int`` + :returns: Nothing + :rtype: ``None`` + """ + self.current_window_size += size + + if self.current_window_size > self.max_window_size: + self.max_window_size = self.current_window_size + + def process_bytes(self, size): + """ + The application has informed us that it has processed a certain number + of bytes. This may cause us to want to emit a window update frame. If + we do want to emit a window update frame, this method will return the + number of bytes that we should increment the window by. + + :param size: The number of flow controlled bytes that the application + has processed. + :type size: ``int`` + :returns: The number of bytes to increment the flow control window by, + or ``None``. + :rtype: ``int`` or ``None`` + """ + + if size is None: + size = 0 + + self._bytes_processed += size + if not self._bytes_processed: + return None + + max_increment = (self.max_window_size - self.current_window_size) + increment = 0 + + min_threshold = (self.current_window_size == 0) and (self._bytes_processed > min(1024, self.max_window_size // 4)) + max_threshold = self._bytes_processed >= (self.max_window_size // 2) + + # Note that, even though we may increment less than _bytes_processed, + # we still want to set it to zero whenever we emit an increment. This + # is because we'll always increment up to the maximum we can. + if min_threshold or max_threshold: + increment = min(self._bytes_processed, max_increment) + self._bytes_processed = 0 + + self.current_window_size += increment + return increment \ No newline at end of file diff --git a/hyperscale/core/engines/types/http3/__init__.py b/hyperscale/core/engines/types/http3/__init__.py new file mode 100644 index 0000000..67bbc92 --- /dev/null +++ b/hyperscale/core/engines/types/http3/__init__.py @@ -0,0 +1,3 @@ +from .client import MercuryHTTP3Client +from .action import HTTP3Action +from .result import HTTP3Result \ No newline at end of file diff --git a/hyperscale/core/engines/types/http3/action.py b/hyperscale/core/engines/types/http3/action.py new file mode 100644 index 0000000..5dd13e9 --- /dev/null +++ b/hyperscale/core/engines/types/http3/action.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Dict, Iterator, List, Union + +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.common.url import URL +from hyperscale.core.engines.types.http.action import HTTPAction + + +class HTTP3Action(HTTPAction): + + def __init__( + self, + name: str, + url: str, + method: str = 'GET', + headers: Dict[str, str]={}, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str = None, tags: + List[Dict[str, str]] = ..., redirects: int = 3 + ) -> None: + + super().__init__( + name, + url, + method, + headers, + data, + user, + tags, + redirects + ) + + + self.type = RequestTypes.HTTP3 + address_family, protocol = self.protocols[self.type] + self.url = URL(url, family=address_family, protocol=protocol) + + + self.redirects = redirects + self.hooks: Hooks[HTTP3Action] = Hooks() + + def _setup_headers(self) -> Union[bytes, Dict[str, str]]: + + self.encoded_headers = [ + (b":method", self.method.encode()), + (b":scheme", self.url.scheme.encode()), + (b":authority", self.url.authority.encode()), + (b":path", self.url.full.encode()), + (b"user-agent", 'hyperscale/client'.encode()), + ] + + self.encoded_headers.extend([ + ( + k.encode(), + v.encode() + ) for ( + k, + v + ) in self.headers.items() + ]) + + \ No newline at end of file diff --git a/hyperscale/core/engines/types/http3/client.py b/hyperscale/core/engines/types/http3/client.py new file mode 100644 index 0000000..a28f034 --- /dev/null +++ b/hyperscale/core/engines/types/http3/client.py @@ -0,0 +1,476 @@ +import asyncio +import time +import uuid +from collections import deque +from typing import Any, Coroutine, Dict, Optional, TypeVar, Union + +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.concurrency import Semaphore +from hyperscale.core.engines.types.common.protocols.udp.quic_protocol import ( + FrameType, + HeadersState, + ResponseFrameCollection, + encode_frame, +) +from hyperscale.core.engines.types.common.ssl import get_default_ssl_context +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.logging import HyperscaleLogger +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .action import HTTP3Action +from .connection import HTTP3Connection +from .pool import Pool +from .result import HTTP3Result + +A = TypeVar('A') +R = TypeVar('R') + + +@unstable +class MercuryHTTP3Client(BaseEngine[Union[A, HTTP3Action], Union[R, HTTP3Result]]): + + __slots__ = ( + 'session_id', + 'timeouts', + 'registered', + '_hosts', + 'closed', + 'sem', + 'pool', + 'active', + 'waiter', + 'ssl_context', + 'logger', + 'tracing_session' + ) + + def __init__( + self, + concurrency: int=10**3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool=False, + tracing_session: Optional[TraceSession]=None + ) -> None: + super().__init__() + + self.session_id = str(uuid.uuid4()) + self.timeouts = timeouts + + self.registered: Dict[str, HTTP3Action] = {} + self._hosts = {} + self.closed = False + + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=reset_connections) + self.tracing_session: Union[TraceSession, None] = tracing_session + self.logger = HyperscaleLogger() + self.logger.initialize() + self.pool.create_pool() + self.active = 0 + self.waiter = None + + self.ssl_context = get_default_ssl_context() + + def config_to_dict(self): + return { + 'concurrency': self.pool.size, + 'timeouts': { + 'connect_timeout': self.timeouts.connect_timeout, + 'socket_read_timeout': self.timeouts.socket_read_timeout, + 'total_timeout': self.timeouts.total_timeout + }, + 'reset_connections': self.pool.reset_connections + } + + + async def set_pool(self, concurrency: int): + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=self.pool.reset_connections) + self.pool.create_pool() + + def extend_pool(self, increased_capacity: int): + self.pool.size += increased_capacity + for _ in range(increased_capacity): + self.pool.connections.append( + HTTP3Connection(self.pool.reset_connections) + ) + + self.sem = Semaphore(self.pool.size) + + def shrink_pool(self, decrease_capacity: int): + self.pool.size -= decrease_capacity + self.pool.connections = self.pool.connections[:self.pool.size] + self.sem = Semaphore(self.pool.size) + + async def prepare(self, action: HTTP3Action) -> Coroutine[Any, Any, None]: + try: + + if self._hosts.get(action.url.hostname) is None: + socket_configs = await asyncio.wait_for(action.url.lookup(), timeout=self.timeouts.connect_timeout) + + for ip_addr, configs in socket_configs.items(): + for config in configs: + + connection = HTTP3Connection() + + try: + await connection.make_connection( + ip_addr, + action.url.port, + config, + server_name=action.url.hostname, + timeout=self.timeouts.connect_timeout + ) + + action.url.socket_config = config + action.url.ip_addr = ip_addr + action.url.has_ip_addr = True + break + + except Exception: + pass + + if action.url.socket_config: + break + + if action.url.socket_config is None: + raise Exception('Err. - No socket found.') + + self._hosts[action.url.hostname] = { + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config + } + + else: + host_config = self._hosts[action.url.hostname] + action.url.ip_addr = host_config.get('ip_addr') + action.url.socket_config = host_config.get('socket_config') + + if action.is_setup is False: + action.setup() + + self.registered[action.name] = action + + except Exception as e: + raise e + + async def execute_prepared_request(self, action: HTTP3Action) -> Coroutine[Any, Any, HTTP3Result]: + + trace: Union[Trace, None] = None + if self.tracing_session: + trace = self.tracing_session.create_trace() + await trace.on_request_start(action) + + response = HTTP3Result(action) + response.wait_start = time.monotonic() + self.active += 1 + + if trace and trace.on_connection_queued_start: + await trace.on_connection_queued_start( + trace.span, + action, + response + ) + + async with self.sem: + + if trace and trace.on_connection_queued_end: + await trace.on_connection_queued_end( + trace.span, + action, + response + ) + + try: + + connection = self.pool.connections.pop() + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + if trace and trace.on_connection_create_start: + await trace.on_connection_create_start( + trace.span, + action, + response + ) + + await connection.make_connection( + action.url.ip_addr, + action.url.port, + action.url.socket_config, + server_name=action.url.hostname, + timeout=self.timeouts.connect_timeout + ) + + response.connect_end = time.monotonic() + + if trace and trace.on_connection_create_end: + await trace.on_connection_create_end( + trace.span, + action, + response + ) + + stream_id = connection.protocol._quic.get_next_available_stream_id() + + stream = connection.protocol._get_or_create_stream(stream_id) + if stream.headers_send_state == HeadersState.AFTER_TRAILERS: + raise Exception("HEADERS frame is not allowed in this state") + + encoder, frame_data = connection.protocol._encoder.encode( + stream_id, + action.encoded_headers + ) + + connection.protocol._encoder_bytes_sent += len(encoder) + connection.protocol._quic.send_stream_data( + connection.protocol._local_encoder_stream_id, + encoder + ) + + # update state and send headers + if stream.headers_send_state == HeadersState.INITIAL: + stream.headers_send_state = HeadersState.AFTER_HEADERS + else: + stream.headers_send_state = HeadersState.AFTER_TRAILERS + + connection.protocol._quic.send_stream_data( + stream_id, encode_frame( + FrameType.HEADERS, + frame_data + ), + not action.encoded_data + ) + + if trace and trace.on_request_headers_sent: + await trace.on_request_headers_sent( + trace.span, + action, + response + ) + + if action.encoded_data: + stream = connection.protocol._get_or_create_stream(stream_id) + if stream.headers_send_state != HeadersState.AFTER_HEADERS: + raise Exception("DATA frame is not allowed in this state") + + connection.protocol._quic.send_stream_data( + stream_id, encode_frame( + FrameType.DATA, + action.encoded_data + ), + True + ) + + + waiter = connection.protocol._loop.create_future() + connection.protocol._request_events[stream_id] = deque() + connection.protocol._request_waiter[stream_id] = waiter + connection.protocol.transmit() + + response.write_end = time.monotonic() + + if action.encoded_data and trace and trace.on_request_data_sent: + await trace.on_request_data_sent( + trace.span, + action, + response + ) + + response_frames: ResponseFrameCollection = await asyncio.wait_for( + waiter, + timeout=self.timeouts.total_timeout + ) + + headers: Dict[str, Union[bytes, int]] = {} + for header_key, header_value in response_frames.headers_frame.headers: + headers[header_key] = header_value + + if trace and trace.on_response_headers_received: + await trace.on_response_headers_received( + trace.span, + action, + response + ) + + response.headers = headers + status = response.status + + if status >= 300 and status < 400: + + if trace and trace.on_request_redirect: + await trace.on_request_redirect( + trace.span, + action, + response + ) + + elapsed_time = 0 + redirect_time_start = time.time() + + for redirect_number in range(action.redirects): + + if elapsed_time > self.timeouts.total_timeout: + response.status = 408 + raise Exception('Request timed out while redirecting.') + + redirect_url = str(headers.get(b'location')) + if redirect_url.startswith('http') is False: + action.url.path = redirect_url + action.encoded_headers = None + action.setup() + + else: + await action.url.replace(redirect_url) + action.encoded_headers = None + action.is_setup = False + await self.prepare(action) + + await connection.make_connection( + action.url.ip_addr, + action.url.port, + action.url.socket_config, + server_name=action.url.hostname, + timeout=self.timeouts.connect_timeout + ) + + response.connect_end = time.monotonic() + + stream_id = connection.protocol._quic.get_next_available_stream_id() + + stream = connection.protocol._get_or_create_stream(stream_id) + if stream.headers_send_state == HeadersState.AFTER_TRAILERS: + raise Exception("HEADERS frame is not allowed in this state") + + encoder, frame_data = connection.protocol._encoder.encode( + stream_id, + action.encoded_headers + ) + + connection.protocol._encoder_bytes_sent += len(encoder) + connection.protocol._quic.send_stream_data( + connection.protocol._local_encoder_stream_id, + encoder + ) + + # update state and send headers + if stream.headers_send_state == HeadersState.INITIAL: + stream.headers_send_state = HeadersState.AFTER_HEADERS + else: + stream.headers_send_state = HeadersState.AFTER_TRAILERS + connection.protocol._quic.send_stream_data( + stream_id, encode_frame( + FrameType.HEADERS, + frame_data + ), + not action.encoded_data + ) + + if action.encoded_data: + stream = connection.protocol._get_or_create_stream(stream_id) + if stream.headers_send_state != HeadersState.AFTER_HEADERS: + raise Exception("DATA frame is not allowed in this state") + + connection.protocol._quic.send_stream_data( + stream_id, encode_frame( + FrameType.DATA, + action.encoded_data + ), + True + ) + + + waiter = connection.protocol._loop.create_future() + connection.protocol._request_events[stream_id] = deque() + connection.protocol._request_waiter[stream_id] = waiter + connection.protocol.transmit() + + response.write_end = time.monotonic() + + response_frames: ResponseFrameCollection = await asyncio.wait_for( + waiter, + timeout=self.timeouts.total_timeout + ) + + for header_key, header_value in response_frames.headers_frame.headers: + headers[header_key] = header_value + + response.headers = headers + status = response.status + if status >= 200 and status < 300: + break + + action.redirects -= redirect_number + elapsed_time = time.time() - redirect_time_start + + response.complete = time.monotonic() + + if trace and trace.on_response_data_received: + await trace.on_response_data_received( + trace.span, + action, + response + ) + + response.headers = headers + self.pool.connections.append(connection) + + if action.hooks.after: + response = await self.execute_after(action, response) + action.setup() + + if action.hooks.checks: + response = await self.execute_checks(action, response) + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + except Exception as e: + response.complete = time.monotonic() + response.error = str(e) + + self.pool.connections.append(HTTP3Connection(reset_connection=self.pool.reset_connections)) + + if trace and trace.on_request_exception: + await trace.on_request_exception(response) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + if trace and trace.on_request_end: + await trace.on_request_end(response) + + return response + + async def close(self): + if self.closed is False: + await self.pool.close() + self.closed = True diff --git a/hyperscale/core/engines/types/http3/connection.py b/hyperscale/core/engines/types/http3/connection.py new file mode 100644 index 0000000..a5b88f5 --- /dev/null +++ b/hyperscale/core/engines/types/http3/connection.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import asyncio +from typing import Optional, Tuple + +from hyperscale.core.engines.types.common.protocols import UDPConnection +from hyperscale.core.engines.types.common.protocols.udp.quic_protocol import ( + QuicProtocol, +) + + +class HTTP3Connection: + + __slots__ = ( + 'dns_address', + 'port', + 'ip_addr', + 'lock', + 'protocol', + 'connected', + 'reset_connection', + 'pending', + '_connection_factory' + ) + + def __init__(self, reset_connection: bool=False) -> None: + self.dns_address: str = None + self.port: int = None + self.ip_addr = None + self.lock = asyncio.Lock() + + self.protocol: QuicProtocol = None + self.connected = False + self.reset_connection = reset_connection + self.pending = 0 + self._connection_factory = UDPConnection() + + async def make_connection( + self, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + server_name: str=None, + timeout: Optional[float]=None + ) -> None: + + if self.connected is False or self.dns_address != dns_address or self.reset_connection: + try: + self.protocol = await asyncio.wait_for( + self._connection_factory.create_http3( + socket_config=socket_config, + server_name=server_name + ), + + timeout=timeout + ) + + self.connected = True + + self.dns_address = dns_address + self.port = port + + except asyncio.TimeoutError: + raise Exception('Connection timed out.') + + except ConnectionResetError: + raise Exception('Connection reset.') + + except Exception as e: + raise e + + async def close(self): + if self.protocol: + self.protocol.close() diff --git a/hyperscale/core/engines/types/http3/pool.py b/hyperscale/core/engines/types/http3/pool.py new file mode 100644 index 0000000..e6f5896 --- /dev/null +++ b/hyperscale/core/engines/types/http3/pool.py @@ -0,0 +1,27 @@ + +from typing import List +from .connection import HTTP3Connection + + +class Pool: + + __slots__ = ( + 'size', + 'connections', + 'reset_connections' + ) + + def __init__(self, size: int, reset_connections: bool = False) -> None: + self.size = size + self.connections: List[HTTP3Connection] = [] + self.reset_connections = reset_connections + + def create_pool(self) -> None: + for _ in range(self.size): + self.connections.append( + HTTP3Connection(self.reset_connections) + ) + + async def close(self): + for connection in self.connections: + await connection.close() diff --git a/hyperscale/core/engines/types/http3/result.py b/hyperscale/core/engines/types/http3/result.py new file mode 100644 index 0000000..87b7d8d --- /dev/null +++ b/hyperscale/core/engines/types/http3/result.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Union + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http.result import HTTPResult + +from .action import HTTP3Action + +try: + from aioquic.h3.events import DataReceived, HeadersReceived + +except ImportError: + HeadersReceived = object + DataReceived = object + + +class HTTP3Result(HTTPResult): + + __slots__ = ( + 'action_id', + 'url', + 'ip_addr', + 'method', + 'path', + 'params', + 'query', + 'hostname', + 'headers', + 'headers_frame', + 'body', + 'response_code', + '_version', + '_reason', + '_status' + ) + + def __init__( + self, + action: HTTP3Action, + error: Exception = None + ) -> None: + super().__init__( + action, + error + ) + + self.body = bytearray() + self.headers_frame: HeadersReceived = None + self.body: DataReceived = None + self.type = RequestTypes.HTTP3 + + @property + def status(self) -> Union[int, None]: + try: + response_status = self.headers.get(b':status') + if self._status is None and isinstance(response_status, (bytes, bytearray)): + self._status = int(response_status) + + except Exception: + pass + + return self._status + + \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/__init__.py b/hyperscale/core/engines/types/playwright/__init__.py new file mode 100644 index 0000000..80d204e --- /dev/null +++ b/hyperscale/core/engines/types/playwright/__init__.py @@ -0,0 +1,11 @@ +from .client import MercuryPlaywrightClient +from .command import ( + PlaywrightCommand, + Page, + Input, + URL, + Options +) +from .result import PlaywrightResult + +from .context_config import ContextConfig \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/client.py b/hyperscale/core/engines/types/playwright/client.py new file mode 100644 index 0000000..c611b97 --- /dev/null +++ b/hyperscale/core/engines/types/playwright/client.py @@ -0,0 +1,193 @@ +import asyncio +import uuid +from typing import Any, Coroutine, Dict, List, Union + +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.types import RequestTypes + +from .command import PlaywrightCommand +from .context_config import ContextConfig +from .context_group import ContextGroup +from .pool import ContextPool +from .result import PlaywrightResult + + +class MercuryPlaywrightClient(BaseEngine[PlaywrightCommand, PlaywrightResult]): + + __slots__ = ( + 'session_id', + 'pool', + 'timeouts', + 'registered', + 'closed', + 'config', + 'sem', + 'active', + 'waiter', + '_discarded_context_groups', + '_discarded_contexts', + '_pending_context_groups', + '_playwright_setup' + ) + + def __init__(self, concurrency: int = 500, group_size: int=50, timeouts: Timeouts = Timeouts()) -> None: + super( + MercuryPlaywrightClient, + self + ).__init__() + + self.session_id = str(uuid.uuid4()) + + self.pool = ContextPool(concurrency, group_size) + self.timeouts = timeouts + self.registered: Dict[str, PlaywrightCommand] = {} + self.closed = False + self.config: Union[ContextConfig, None] = None + + self.sem = asyncio.Semaphore(value=concurrency) + self.active = 0 + self.waiter = None + + self._discarded_context_groups: List[ContextGroup] = [] + self._discarded_contexts = [] + self._pending_context_groups: List[ContextGroup] = [] + self._playwright_setup = False + + def config_to_dict(self): + return { + 'concurrency': self.pool.size, + 'group_size': self.pool.group_size, + 'timeouts': self.timeouts, + 'context_config': { + **self.config.data, + 'options': self.config.options + } + } + + async def set_pool(self, concurrency: int): + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = ContextPool(concurrency, reset_connections=self.pool.reset_connections) + + async def setup(self, config: ContextConfig=None): + + if config is None and self.config: + config = self.config + + if self._playwright_setup is False: + self.config = config + self.pool.create_pool(self.config) + for context_group in self.pool: + await context_group.create() + + self._playwright_setup = True + + async def prepare(self, command: PlaywrightCommand) -> Coroutine[Any, Any, None]: + + command.options.extra = { + **command.options.extra, + 'timeout': self.timeouts.total_timeout * 1000 + } + + self.registered[command.name] = command + + def extend_pool(self, increased_capacity: int): + self.pool.size += increased_capacity + for _ in range(increased_capacity): + context_group = ContextGroup( + **self.config, + concurrency=int(self.pool.size/self.pool.group_size) + ) + + self._pending_context_groups.append(context_group) + + self.pool.contexts.append(context_group) + + self.sem = asyncio.Semaphore(self.pool.size) + + def shrink_pool(self, decrease_capacity: int): + self.pool.size -= decrease_capacity + + for context_group in self.pool.contexts[self.pool.size:]: + self._discarded_context_groups.append(context_group) + + self.pool.contexts = self.pool.contexts[:self.pool.size] + + for context_group in self.pool.contexts: + group_size = int(self.pool.size/self.pool.group_size) + + for context in context_group.contexts[group_size:]: + self._discarded_contexts.append(context) + + context_group.contexts = context_group.contexts[:group_size] + context_group.librarians = context_group.librarians[:group_size] + + self.sem = asyncio.Semaphore(self.pool.size) + + + async def execute_prepared_command(self, command: PlaywrightCommand) -> Coroutine[Any, Any, PlaywrightResult]: + + for pending_context in self._pending_context_groups: + await pending_context.create() + + result = PlaywrightResult(command, type=RequestTypes.PLAYWRIGHT) + self.active += 1 + + async with self.sem: + context = self.pool.contexts.pop() + try: + + if command.hooks.listen: + event = asyncio.Event() + command.hooks.channel_events.append(event) + await event.wait() + + if command.hooks.before: + command = await self.execute_before(command) + + result = await context.execute(command) + + if command.hooks.after: + result = await self.execute_after(command, result) + + if command.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(result, command.hooks.listeners) + ) for channel in command.hooks.channels + ]) + + for listener in command.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + self.pool.contexts.append(context) + + except Exception as e: + result.error = e + self.pool.contexts.append(context) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + return result + + async def close(self): + if self.closed is False: + for context_group in self._discarded_context_groups: + await context_group.close() + + for context in self._discarded_contexts: + await context.close() + + self.closed = True + \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/command.py b/hyperscale/core/engines/types/playwright/command.py new file mode 100644 index 0000000..ea1e597 --- /dev/null +++ b/hyperscale/core/engines/types/playwright/command.py @@ -0,0 +1,155 @@ +import uuid +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.metadata import Metadata + +from .hooks import Hooks + + +class Page: + + __slots__ = ( + 'selector', + 'attribute', + 'x_coordinate', + 'y_coordinate', + 'frame' + ) + + def __init__( + self, + selector: str=None, + attribute: str=None, + x_coordinate: int=0, + y_coordinate: int=0, + frame=0 + ) -> None: + self.selector = selector + self.attribute = attribute + self.x_coordinate = x_coordinate + self.y_coordinate = y_coordinate + self.frame = frame + + +class URL: + + __slots__ = ( + 'location', + 'headers' + ) + + def __init__( + self, + location: str=None, + headers: Dict[str, str]={} + ) -> None: + self.location = location + self.headers = headers + + +class Input: + + __slots__ = ( + 'key', + 'text', + 'expression', + 'args', + 'filepath', + 'file', + 'path', + 'option', + 'by_label', + 'by_value' + ) + + def __init__( + self, + key=None, + text=None, + expression: str=None, + args: List[Any]=None, + filepath=None, + file=None, + path: str=None, + option: Any=None, + by_label: bool=False, + by_value: bool=False + ) -> None: + self.key = key + self.text = text + self.expression = expression + self.args = args + self.filepath = filepath + self.file = file + self.path = path + self.option = option + self.by_label = by_label + self.by_value = by_value + + +class Options: + + __slots__ = ( + 'event', + 'option', + 'is_checked', + 'timeout', + 'extra', + 'switch_by' + ) + + def __init__( + self, + event: str=None, + option=None, + is_checked=False, + timeout=10, + extra: Dict[str, Any]={}, + switch_by: str='url' + ) -> None: + self.event = event + self.option = option + self.is_checked = is_checked + self.timeout = timeout + self.extra = extra + self.switch_by = switch_by + + +class PlaywrightCommand: + + __slots__ = ( + 'action_id' + 'name', + 'command', + 'page', + 'url', + 'input', + 'options', + 'metadata', + 'hooks', + 'event', + 'command_args', + 'mutations' + ) + + def __init__(self, + name, + command, + page: Page = Page(), + url: URL = URL(), + input: Input = Input(), + options: Options = Options(), + user: str = None, + tags: List[Dict[str, str]] = [] + ) -> None: + self.action_id = str(uuid.uuid4()) + self.name = name + self.command = command + self.page = page + self.url = url + self.input = input + self.options = options + self.metadata = Metadata(user, tags) + self.hooks: Hooks[PlaywrightCommand] = Hooks() + self.event = None + self.command_args: Dict[str, Any] = {} diff --git a/hyperscale/core/engines/types/playwright/command_librarian.py b/hyperscale/core/engines/types/playwright/command_librarian.py new file mode 100644 index 0000000..87cee67 --- /dev/null +++ b/hyperscale/core/engines/types/playwright/command_librarian.py @@ -0,0 +1,16 @@ +from .command_library import CommandLibrary + + +class CommandLibrarian: + + __slots__ = ( + 'command_library' + ) + + def __init__(self, page) -> None: + self.command_library = CommandLibrary(page) + + def get(self, command_name): + return self.command_library.__getattribute__(command_name) + + \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/command_library.py b/hyperscale/core/engines/types/playwright/command_library.py new file mode 100644 index 0000000..e5296b6 --- /dev/null +++ b/hyperscale/core/engines/types/playwright/command_library.py @@ -0,0 +1,363 @@ + +from .command import PlaywrightCommand + +try: + + from playwright.async_api import Page + +except Exception: + async_playwright = lambda: None + Geolocation = None + + + +class CommandLibrary: + + __slots__ = ( + 'page' + ) + + + def __init__(self, page) -> None: + self.page: Page = page + + async def goto(self, command: PlaywrightCommand): + await self.page.goto( + command.url.location, + **command.options.extra + ) + + async def get_url(self, command: PlaywrightCommand): + return self.page.url + + async def fill(self, command: PlaywrightCommand): + await self.page.fill( + command.page.selector, + command.input.text + ) + + async def check(self, command: PlaywrightCommand): + await self.page.check( + command.page.selector, + **command.options.extra + ) + + async def click(self, command: PlaywrightCommand): + await self.page.click( + command.page.selector, + **command.options.extra + ) + + async def double_click(self, command: PlaywrightCommand): + await self.page.dblclick( + command.page.selector, + **command.options.extra + ) + + async def submit_event(self, command: PlaywrightCommand): + await self.page.dispatch_event( + command.page.selector, + command.options.event, + **command.options.extra + ) + + async def drag_and_drop(self, command: PlaywrightCommand): + await self.page.drag_and_drop( + source_position=command.page.x_coordinate, + target_position=command.page.y_coordinate, + **command.options.extra + ) + + async def switch_active_tab(self, command: PlaywrightCommand): + await self.page.bring_to_front() + + async def evaluate_selector(self, command: PlaywrightCommand): + await self.page.eval_on_selector( + command.page.selector, + command.input.expression, + *command.input.args, + **command.options.extra + ) + + async def evaluate_all_selectors(self, command: PlaywrightCommand): + await self.page.eval_on_selector_all( + command.page.selector, + command.input.expression, + *command.input.args, + **command.options.extra + ) + + async def evaluate_expression(self, command: PlaywrightCommand): + await self.page.evaluate( + command.input.expression, + *command.input.args, + **command.options.extra + ) + + async def evaluate_handle(self, command: PlaywrightCommand): + await self.page.evaluate_handle( + command.input.expression, + *command.input.args, + **command.options.extra + ) + + async def exepect_console_message(self, command: PlaywrightCommand): + await self.page.expect_console_message( + predicate=await self.page.on( + 'console', + command.input.expression + ), + **command.options.extra + ) + + async def expect_download(self, command: PlaywrightCommand): + await self.page.expect_download( + predicate=await self.page.on( + 'download', + command.input.expression + ), + **command.options.extra + ) + + async def expect_event(self, command: PlaywrightCommand): + await self.page.expect_event( + command.options.event, + **command.options.extra + ) + + async def expect_location(self, command: PlaywrightCommand): + await self.page.expect_navigation( + url=command.url.location, + **command.options.extra + ) + + async def expect_popup(self, command: PlaywrightCommand): + await self.page.expect_popup( + predicate=await self.page.on( + 'popup', + command.input.expression + ) + ) + + async def expect_request(self, command: PlaywrightCommand): + return await self.page.expect_request( + command.url.location, + **command.options.extra + ) + + async def expect_request_finished(self, command: PlaywrightCommand): + await self.page.expect_request_finished( + await self.expect_request(command) + ) + + async def expect_response(self, command: PlaywrightCommand): + await self.page.expect_response( + command.url.location, + **command.options.extra + ) + + async def focus(self, command: PlaywrightCommand): + await self.page.focus( + command.page.selector, + **command.options.extra + ) + + async def hover(self, command: PlaywrightCommand): + await self.page.hover( + command.page.selector, + **command.options.extra + ) + + async def get_inner_html(self, command: PlaywrightCommand): + return await self.page.inner_html( + command.page.selector, + **command.options.extra + ) + + async def get_text(self, command: PlaywrightCommand): + return await self.page.inner_text( + command.page.selector, + **command.options.extra + ) + + async def get_input_value(self, command: PlaywrightCommand): + return await self.page.inner_value( + command.page.selector, + **command.options.extra + ) + + async def press_key(self, command: PlaywrightCommand): + await self.page.press( + command.page.selector, + command.input.key, + **command.options.extra + ) + + async def verify_is_enabled(self, command: PlaywrightCommand): + return await self.page.is_enabled( + command.page.selector, + **command.options.extra + ) + + async def verify_is_hidden(self, command: PlaywrightCommand): + return await self.page.is_hidden( + command.page.selector, + **command.options.extra + ) + + async def verify_is_visible(self, command: PlaywrightCommand): + return await self.page.is_hidden( + command.page.selector, + **command.options.extra + ) + + async def verify_is_checked(self, command: PlaywrightCommand): + return await self.page.is_checked( + command.page.selector, + **command.options.extra + ) + + async def get_content(self, command: PlaywrightCommand): + return await self.page.content() + + async def get_element(self, command: PlaywrightCommand): + return await self.page.query_selector( + command.page.selector, + **command.options.extra + ) + + async def get_all_elements(self, command: PlaywrightCommand): + return await self.page.query_selector_all( + command.page.selector, + **command.options.extra + ) + + async def reload_page(self, command: PlaywrightCommand): + await self.page.reload(**command.options.extra) + + async def take_screenshot(self, command: PlaywrightCommand): + await self.page.screenshot( + path=f'{command.input.path}.png', + **command.options.extra + ) + + async def select_option(self, command: PlaywrightCommand): + + if command.input.by_label: + await self.page.select_option( + command.page.selector, + label=command.input.option, + **command.options.extra + ) + + elif command.input.by_value: + await self.page.select_option( + command.page.selector, + value=command.input.option, + **command.options.extra + ) + + else: + await self.page.select_option( + command.page.selector, + command.input.option, + **command.options.extra + ) + + async def set_checked(self, command: PlaywrightCommand): + await self.page.set_checked( + command.page.selector, + command.options.is_checked, + **command.options.extra + ) + + async def set_default_timeout(self, command: PlaywrightCommand): + await self.page.set_default_timeout(command.options.timeout) + + async def set_navigation_timeout(self, command: PlaywrightCommand): + await self.page.set_default_navigation_timeout(command.options.timeout) + + async def set_http_headers(self, command: PlaywrightCommand): + await self.page.set_extra_http_headers(command.url.headers) + + async def tap(self, command: PlaywrightCommand): + await self.page.tap( + command.page.selector, + **command.options.extra + ) + + async def get_text_content(self, command: PlaywrightCommand): + await self.page.text_content( + command.page.selector, + **command.options.extra + ) + + async def get_page_title(self, command: PlaywrightCommand): + await self.page.title() + + async def input_text(self, command: PlaywrightCommand): + await self.page.type( + command.page.selector, + command.input.text, + **command.options.extra + ) + + async def uncheck(self, command: PlaywrightCommand): + await self.page.uncheck( + command.page.selector, + **command.options.extra + ) + + async def wait_for_event(self, command: PlaywrightCommand): + await self.page.wait_for_event( + command.options.event, + **command.options.extra + ) + + async def wait_for_function(self, command: PlaywrightCommand): + await self.page.wait_for_function( + command.input.expression, + *command.input.args, + **command.options.extra + ) + + async def wait_for_page_load_state(self, command: PlaywrightCommand): + await self.page.wait_for_load_state(**command.options.extra) + + async def wait_for_selector(self, command: PlaywrightCommand): + await self.page.wait_for_selector( + command.page.selector, + **command.options.extra + ) + + async def wait_for_timeout(self, command: PlaywrightCommand): + await self.page.wait_for_timeout(command.options.timeout) + + async def wait_for_url(self, command: PlaywrightCommand): + await self.page.wait_for_url( + command.url.location, + **command.options.extra + ) + + async def switch_frame(self, command: PlaywrightCommand): + + if command.url.location == 'url': + await self.page.frame(url=command.url.location) + + else: + await self.page.frame(name=command.page.frame) + + async def get_frames(self, command: PlaywrightCommand): + return await self.page.frames() + + async def get_attribute(self, command: PlaywrightCommand): + return await self.page.get_attribute( + command.page.selector, + command.page.attribute + ) + + async def go_back_page(self, command: PlaywrightCommand): + await self.page.go_back(**command.options.extra) + + async def go_forward_page(self, command: PlaywrightCommand): + await self.page.go_forward(**command.options.extra) \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/context_config.py b/hyperscale/core/engines/types/playwright/context_config.py new file mode 100644 index 0000000..9512d1f --- /dev/null +++ b/hyperscale/core/engines/types/playwright/context_config.py @@ -0,0 +1,29 @@ +from typing import Dict, List, Any + +class ContextConfig: + + __slots__ = ( + 'data', + 'options' + ) + + def __init__( + self, + browser_type: str='chromium', + device_type: str=None, + locale: str=None, + geolocation: Dict[str, float]=None, + permissions: List[str]=[], + color_scheme: str=None, + options: Dict[str, Any]={} + ) -> None: + self.data = { + 'browser_type': browser_type, + 'device_type': device_type, + 'locale': locale, + 'geolocation': geolocation, + 'permissions': permissions, + 'color_scheme': color_scheme + } + + self.options = options \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/context_group.py b/hyperscale/core/engines/types/playwright/context_group.py new file mode 100644 index 0000000..46a16d4 --- /dev/null +++ b/hyperscale/core/engines/types/playwright/context_group.py @@ -0,0 +1,161 @@ + +import asyncio +import time +from typing import Any, Dict, List + +from hyperscale.tools.data_structures import AsyncList + +from .command import PlaywrightCommand +from .command_librarian import CommandLibrarian +from .result import PlaywrightResult + +try: + + from playwright.async_api import Geolocation, async_playwright + +except Exception: + async_playwright = lambda: None + Geolocation = None + +class ContextGroup: + + __slots__ = ( + 'browser_type', + 'device_type', + 'locale', + 'geolocation', + 'permissions', + 'color_scheme', + 'concurrency', + 'librarians', + 'config', + 'options', + 'contexts', + 'sem' + ) + + def __init__( + self, + browser_type: str=None, + device_type: str=None, + locale: str=None, + geolocation: Geolocation=None, + permissions: List[str]=None, + color_scheme: str=None, + concurrency: int=None, + options: Dict[str, Any]=None + ) -> None: + self.browser_type = browser_type + self.device_type = device_type + self.locale = locale + self.geolocation = geolocation + self.permissions = permissions + self.color_scheme = color_scheme + self.concurrency = concurrency + self.librarians: List[CommandLibrarian] = [] + self.config = {} + self.options = options + self.contexts = [] + self.sem = asyncio.Semaphore(value=concurrency) + + async def create(self) -> None: + + playwright = await async_playwright().start() + + if self.browser_type == "safari" or self.browser_type == "webkit": + self.browser = await playwright.webkit.launch() + + elif self.browser_type == "firefox": + self.browser = await playwright.firefox.launch() + + else: + self.browser = await playwright.chromium.launch() + + + self.config = {} + + if self.device_type: + device = playwright.devices[self.device_type] + + self.config = { + **device, + **self.options + } + + if self.locale: + self.config['locale'] = self.locale + + if self.geolocation: + self.config['geolocation']: Geolocation = self.geolocation + + if self.permissions: + self.config['permissions'] = self.permissions + + if self.color_scheme: + self.config['color_scheme'] = self.color_scheme + + + has_options = len(self.config) > 0 + + for _ in range(self.concurrency): + + if has_options: + context = await self.browser.new_context( + **self.config + ) + + else: + context = await self.browser.new_context() + + self.contexts.append(context) + page = await context.new_page() + command_librarian = CommandLibrarian(page) + + self.librarians.append(command_librarian) + + async def execute(self, command: PlaywrightCommand): + result = PlaywrightResult(command) + await self.sem.acquire() + start = 0 + librarian = self.librarians.pop() + + try: + playwright_command = librarian.get(command.command) + + start = time.time() + + result.data = await playwright_command(command) + + elapsed = time.time() - start + + result.time = elapsed + + self.librarians.append(librarian) + self.sem.release() + + return result + + except Exception as e: + elapsed = time.time() - start + result.time = elapsed + result.error = e + + self.librarians.append(librarian) + self.sem.release() + + return result + + async def execute_batch(self, command: PlaywrightCommand, timeout: float=None): + return await asyncio.wait([ + self.execute(command) async for _ in AsyncList(range(self.concurrency)) + ], timeout=timeout) + + async def close(self): + try: + for context in self.contexts: + await context.close() + + await self.browser.close() + + except Exception: + pass diff --git a/hyperscale/core/engines/types/playwright/hooks.py b/hyperscale/core/engines/types/playwright/hooks.py new file mode 100644 index 0000000..2bc3fd2 --- /dev/null +++ b/hyperscale/core/engines/types/playwright/hooks.py @@ -0,0 +1,61 @@ +import asyncio +from typing import ( + Coroutine, + List, + Generic, + TypeVar +) + + +A = TypeVar('A') + + +class Hooks(Generic[A]): + + __slots__ = ( + 'before', + 'after', + 'checks', + 'notify', + 'listen', + 'listeners', + 'channels' + ) + + def __init__(self) -> None: + self.before: Coroutine = None + self.after: Coroutine = None + self.checks: List[Coroutine] = [] + self.notify = False + self.listen = False + self.channel_events: List[asyncio.Event] = [] + self.listeners: List[A] = [] + self.channels: List[Coroutine] = [] + + def to_names(self): + + names = {} + + if self.before: + names['before'] = self.before.name + + if self.after: + names['after'] = self.after.name + + check_names = [] + for check in self.checks: + check_names.append(check.name) + + names['checks'] = check_names + + return names + + def action_to_serializable(self): + return { + 'notify': self.notify, + 'listen': self.listen, + 'listeners': [ + listener.name for listener in self.listeners + ], + 'names': self.to_names() + } \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/pool.py b/hyperscale/core/engines/types/playwright/pool.py new file mode 100644 index 0000000..02fbd53 --- /dev/null +++ b/hyperscale/core/engines/types/playwright/pool.py @@ -0,0 +1,34 @@ +from .context_group import ContextGroup +from .context_config import ContextConfig + + +class ContextPool: + + __slots__ = ( + 'size', + 'group_size', + 'group_count', + 'contexts' + ) + + def __init__(self, pool_size, group_size) -> None: + self.size = pool_size + self.group_size = group_size + self.groups_count = int(pool_size/group_size) + self.contexts = [] + + def __iter__(self): + for context_group in self.contexts: + yield context_group + + async def __aiter__(self): + for context_group in self.contexts: + yield context_group + + def create_pool(self, config: ContextConfig): + self.contexts = [ + ContextGroup( + **config.data, + concurrency=self.group_size + ) for _ in range(self.groups_count) + ] \ No newline at end of file diff --git a/hyperscale/core/engines/types/playwright/result.py b/hyperscale/core/engines/types/playwright/result.py new file mode 100644 index 0000000..393ebcb --- /dev/null +++ b/hyperscale/core/engines/types/playwright/result.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.types import RequestTypes + +from .command import PlaywrightCommand + + +class PlaywrightResult(BaseResult): + + __slots__ = ( + 'action_id', + 'url', + 'headers', + 'command', + 'selector', + 'attribute', + 'x_coord', + 'y_coord', + 'frame', + 'key', + 'text', + 'expression', + 'args', + 'filepath', + 'file', + 'option', + 'event', + 'is_checked' + ) + + def __init__(self, command: PlaywrightCommand, error: Exception=None) -> None: + super( + PlaywrightResult, + self + ).__init__( + command.action_id, + command.name, + command.url.location, + command.metadata.user, + command.metadata.tags, + RequestTypes.PLAYWRIGHT, + error + ) + + self.url = command.url.location + self.headers = command.url.headers + self.command = command.command + self.selector = command.page.selector + self.attribute = command.page.attribute + self.x_coord = command.page.x_coordinate + self.y_coord = command.page.y_coordinate + self.frame = command.page.frame + self.key = command.input.key + self.text = command.input.text + self.expression = command.input.expression + self.args = command.input.args + self.filepath = command.input.filepath + self.file = command.input.file + self.option = command.input.option + self.event = command.options.event + self.timeout = command.options.timeout + self.is_checked = command.options.is_checked diff --git a/hyperscale/core/engines/types/registry.py b/hyperscale/core/engines/types/registry.py new file mode 100644 index 0000000..789ec30 --- /dev/null +++ b/hyperscale/core/engines/types/registry.py @@ -0,0 +1,65 @@ +from .graphql import MercuryGraphQLClient +from .graphql_http2 import MercuryGraphQLHTTP2Client +from .grpc import MercuryGRPCClient +from .http import MercuryHTTPClient +from .http2 import MercuryHTTP2Client +from .http3 import MercuryHTTP3Client +from .playwright import MercuryPlaywrightClient +from .task import MercuryTaskRunner +from .udp import MercuryUDPClient +from .websocket import MercuryWebsocketClient +from .common.types import RequestTypes + + + +registered_engines = { + RequestTypes.HTTP: lambda concurrency, timeouts, reset_connections: MercuryHTTPClient( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.HTTP2: lambda concurrency, timeouts, reset_connections: MercuryHTTP2Client( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.HTTP3: lambda concurrency, timeouts, reset_connections: MercuryHTTP3Client( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.GRPC: lambda concurrency, timeouts, reset_connections: MercuryGRPCClient( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.GRAPHQL: lambda concurrency, timeouts, reset_connections: MercuryGraphQLClient( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.GRAPHQL_HTTP2: lambda concurrency, timeouts, reset_connections: MercuryGraphQLHTTP2Client( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.PLAYWRIGHT: lambda concurrency, timeouts, reset_connections: MercuryPlaywrightClient( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.TASK: lambda concurrency, timeouts: MercuryTaskRunner( + concurrency=concurrency, + timeouts=timeouts + ), + RequestTypes.UDP: lambda concurrency, timeouts, reset_connections: MercuryUDPClient( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), + RequestTypes.WEBSOCKET: lambda concurrency, timeouts, reset_connections: MercuryWebsocketClient( + concurrency=concurrency, + timeouts=timeouts, + reset_connections=reset_connections + ), +} \ No newline at end of file diff --git a/hyperscale/core/engines/types/task/__init__.py b/hyperscale/core/engines/types/task/__init__.py new file mode 100644 index 0000000..08578ec --- /dev/null +++ b/hyperscale/core/engines/types/task/__init__.py @@ -0,0 +1,3 @@ +from .runner import MercuryTaskRunner +from .task import Task +from .result import TaskResult \ No newline at end of file diff --git a/hyperscale/core/engines/types/task/result.py b/hyperscale/core/engines/types/task/result.py new file mode 100644 index 0000000..0e396cb --- /dev/null +++ b/hyperscale/core/engines/types/task/result.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.types import RequestTypes + +from .task import Task + + +class TaskResult(BaseResult): + + __slots__ = ( + 'action_id', + 'wait_start', + 'start', + 'complete', + 'data', + 'error' + ) + + def __init__(self, task: Task, error: Exception=None): + super( + TaskResult, + self + ).__init__( + task.action_id, + task.name, + task.source, + task.metadata.user, + task.metadata.tags, + RequestTypes.TASK, + error + ) + + self.wait_start = 0 + self.start = 0 + self.complete = 0 + + self.data = None + self.error = error diff --git a/hyperscale/core/engines/types/task/runner.py b/hyperscale/core/engines/types/task/runner.py new file mode 100644 index 0000000..2c220eb --- /dev/null +++ b/hyperscale/core/engines/types/task/runner.py @@ -0,0 +1,182 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Dict, Optional, Union + +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.concurrency import Semaphore +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.tracing.trace_session import Trace, TraceSession +from hyperscale.core.hooks.types.base.simple_context import SimpleContext + +from .result import TaskResult +from .task import Task + + +class MercuryTaskRunner(BaseEngine[Task, Union[BaseResult, TaskResult]]): + + __slots__ = ( + 'pool', + 'session_id', + 'timeouts', + 'concurrency', + 'registered', + 'sem', + 'active', + 'waiter', + 'closed', + 'tracing_session' + ) + + def __init__( + self, + concurrency: int=10**3, + timeouts: Timeouts = Timeouts(), + tracing_session: Optional[TraceSession]=None + ) -> None: + super( + MercuryTaskRunner, + self + ).__init__() + + self.session_id = str(uuid.uuid4()) + + self.timeouts = timeouts + self.concurrency = concurrency + + self.registered: Dict[str, Task] = {} + self.closed = False + self.pool = SimpleContext() + self.pool.size = concurrency + self.pool.reset_connections = False + self.pool.create_pool = lambda: None + + self.sem = asyncio.Semaphore(value=concurrency) + self.active = 0 + self.waiter = None + self.tracing_session: TraceSession = tracing_session + + async def set_pool(self, concurrency: int): + self.pool = SimpleContext() + self.pool.size = concurrency + self.pool.reset_connections = False + self.pool.create_pool = lambda: None + + self.sem = asyncio.Semaphore(value=concurrency) + + def extend_pool(self, increased_capacity: int): + self.concurrency += increased_capacity + self.sem = Semaphore(self.concurrency) + + def shrink_pool(self, decrease_capacity: int): + self.concurrency -= decrease_capacity + self.sem = Semaphore(self.concurrency) + + async def execute_prepared_request(self, task: Task) -> Coroutine[Any, Any, Union[BaseResult, TaskResult]]: + + trace: Union[Trace, None] = None + if self.tracing_session: + trace = self.tracing_session.create_trace() + await trace.on_request_start(task) + + result = None + wait_start = time.monotonic() + self.active += 1 + + if trace and trace.on_connection_queued_start: + await trace.on_connection_queued_start( + trace.span, + task, + result + ) + + start = 0 + + async with self.sem: + + if trace and trace.on_connection_queued_end: + await trace.on_connection_queued_end( + trace.span, + task, + result + ) + + try: + + if task.hooks.listen: + event = asyncio.Event() + task.hooks.channel_events.append(event) + await event.wait() + + if task.hooks.before: + task = await self.execute_before(task) + + start = time.monotonic() + + if trace and trace.on_task_start: + await trace.on_task_start( + trace.span, + task, + result + ) + + result: BaseResult = await task.execute(**{ + name: value for name, value in task.task_args.items() if name in task.params + }) + + result.name = task.name + result.source = task.source + result.user = task.metadata.user + result.tags = task.metadata.tags + result.checks = task.hooks.checks + result.wait_start = wait_start + result.start = start + result.complete = time.monotonic() + + if task.hooks.after: + result = await self.execute_after(task, result) + + if task.hooks.notify: + for listener in task.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + except Exception as e: + result = TaskResult(task) + + result.wait_start = wait_start + result.start = start + result.complete = time.monotonic() + result.error = str(e) + + if trace and trace.on_task_error: + await trace.on_task_error( + trace.span, + task, + result + ) + + self.active -= 1 + if self.waiter and self.active <= self.concurrency: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + if trace and trace.on_task_end: + await trace.on_task_end( + trace.span, + task, + result + ) + + return result + + async def close(self): + pass diff --git a/hyperscale/core/engines/types/task/task.py b/hyperscale/core/engines/types/task/task.py new file mode 100644 index 0000000..957eedf --- /dev/null +++ b/hyperscale/core/engines/types/task/task.py @@ -0,0 +1,50 @@ +import inspect +from typing import Any, Coroutine, Dict, List + +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.types import RequestTypes + + +class Task(BaseAction): + + __slots__ = ( + 'action_id', + 'protocols', + 'name', + 'is_setup', + 'metadata', + 'hooks', + 'type', + 'source', + 'execute', + 'event', + 'args', + 'params', + 'task_args', + 'mutations' + ) + + def __init__( + self, + name: str, + task_action: Coroutine, + source: str=None, + user: str=None, + tags: List[Dict[str, str]] = [] + ): + super(Task, self).__init__( + name, + user, + tags + ) + + self.type = RequestTypes.TASK + self.source = source + self.execute = task_action + self.hooks: Hooks[Task] = Hooks() + + + self.args = inspect.signature(task_action) + self.params = self.args.parameters + self.task_args: Dict[str, Any] = {} diff --git a/hyperscale/core/engines/types/tracing/__init__.py b/hyperscale/core/engines/types/tracing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/engines/types/tracing/trace.py b/hyperscale/core/engines/types/tracing/trace.py new file mode 100644 index 0000000..1c528b6 --- /dev/null +++ b/hyperscale/core/engines/types/tracing/trace.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +from typing import Dict, Optional, Union + +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .tracing_types import ( + Request, + RequestHook, + Response, + ResponseHook, + TraceSignal, + UrlFilter, +) +from .url_filters import default_params_strip_filter + + +def skip_import(*args, **kwargs): + pass + +try: + from opentelemetry import context as context_api + from opentelemetry import trace + from opentelemetry.instrumentation.utils import ( + _SUPPRESS_INSTRUMENTATION_KEY, + http_status_to_status_code, + ) + from opentelemetry.propagate import inject + from opentelemetry.semconv.trace import SpanAttributes + from opentelemetry.trace import Span, SpanKind, get_tracer + from opentelemetry.trace.status import Status, StatusCode + from opentelemetry.util.http import remove_url_credentials + +except ImportError: + context_api = object + trace = object + _SUPPRESS_INSTRUMENTATION_KEY=None + http_status_to_status_code = skip_import + inject = skip_import + SpanAttributes = object + SpanKind = object + get_tracer = skip_import + Span = object + Status = object + StatusCode = object + remove_url_credentials = skip_import + + +__name__ = 'hyperscale' +__version__ = "0.7.12" + +OpenTelemetryTracingConfig = Union[ + UrlFilter, + RequestHook, + ResponseHook, + TraceSignal +] + + +@unstable +class Trace: + """First-class used to trace requests launched via ClientSession objects.""" + + __slots__ = ( + 'tracer', + 'span', + 'token', + 'allowed_traces', + 'url_filter', + 'request_hook', + 'response_hook', + 'on_request_chunk_sent', + 'on_request_headers_sent', + 'on_response_chunk_received', + 'on_request_redirect', + 'on_request_data_sent', + 'on_response_data_received', + 'on_response_headers_received', + 'on_connection_queued_start', + 'on_connection_queued_end', + 'on_connection_create_start', + 'on_connection_create_end', + 'on_connection_reuse_connection', + 'on_dns_resolve_host_start', + 'on_dns_resolve_host_end', + 'on_dns_cache_hit', + 'on_dns_cache_miss', + 'on_task_start', + 'on_task_end', + 'on_task_error', + '_context_active' + ) + + def __init__( + self, + url_filter: Optional[UrlFilter]=None, + request_hook: Optional[RequestHook]=None, + response_hook: Optional[ResponseHook]=None, + on_request_headers_sent: Optional[TraceSignal]=None, + on_request_data_sent: Optional[TraceSignal]=None, + on_request_chunk_sent: Optional[TraceSignal]=None, + on_response_headers_received: Optional[TraceSignal]=None, + on_response_data_received: Optional[TraceSignal]=None, + on_response_chunk_received: Optional[TraceSignal]=None, + on_request_redirect: Optional[TraceSignal]=None, + on_connection_queued_start: Optional[TraceSignal]=None, + on_connection_queued_end: Optional[TraceSignal]=None, + on_connection_create_start: Optional[TraceSignal]=None, + on_connection_create_end: Optional[TraceSignal]=None, + on_connection_reuse_connection: Optional[TraceSignal]=None, + on_dns_resolve_host_start: Optional[TraceSignal]=None, + on_dns_resolve_host_end: Optional[TraceSignal]=None, + on_dns_cache_hit: Optional[TraceSignal]=None, + on_dns_cache_miss: Optional[TraceSignal]=None, + on_task_start: Optional[TraceSignal]=None, + on_task_end: Optional[TraceSignal]=None, + on_task_error: Optional[TraceSignal]=None + ) -> None: + + + + self.allowed_traces = [ + 'on_request_chunk_sent', + 'on_request_headers_sent', + 'on_response_chunk_received', + 'on_request_redirect', + 'on_request_data_sent', + 'on_response_data_received', + 'on_response_headers_received', + 'on_connection_queued_start', + 'on_connection_queued_end', + 'on_connection_create_start', + 'on_connection_create_end', + 'on_connection_reuse_connection', + 'on_dns_resolve_host_start', + 'on_dns_resolve_host_end', + 'on_dns_cache_hit', + 'on_dns_cache_miss', + 'on_task_start', + 'on_task_end', + 'on_task_error' + ] + + if url_filter is None: + url_filter = default_params_strip_filter + + self.url_filter = url_filter + + self.request_hook: Union[RequestHook, None] = request_hook + self.response_hook: Union[RequestHook, None] = response_hook + + self.on_request_headers_sent: TraceSignal = on_request_headers_sent + self.on_request_data_sent: TraceSignal = on_request_data_sent + self.on_request_chunk_sent: TraceSignal = on_request_chunk_sent + self.on_request_redirect: TraceSignal = on_request_redirect + self.on_response_headers_received = on_response_headers_received + self.on_response_data_received = on_response_data_received + self.on_response_chunk_received: TraceSignal = on_response_chunk_received + + self.on_connection_queued_start: TraceSignal = on_connection_queued_start + self.on_connection_queued_end: TraceSignal = on_connection_queued_end + self.on_connection_create_start: TraceSignal = on_connection_create_start + self.on_connection_create_end: TraceSignal = on_connection_create_end + self.on_connection_reuse_connection: TraceSignal = on_connection_reuse_connection + + self.on_dns_resolve_host_start: TraceSignal = on_dns_resolve_host_start + self.on_dns_resolve_host_end: TraceSignal = on_dns_resolve_host_end + self.on_dns_cache_hit: TraceSignal = on_dns_cache_hit + self.on_dns_cache_miss: TraceSignal = on_dns_cache_miss + + self.on_task_start = on_task_start + self.on_task_end = on_task_end + self.on_task_error = on_task_error + + self.tracer = get_tracer(__name__, __version__, None) + self.span: Span = None + self.token: object = None + self._context_active = False + + def trace_config_ctx( + self, + **kwargs: Dict[str, TraceSignal] + ) -> Trace: + return Trace( + tracer=self.tracer, + url_filter=self.url_filter, + request_hook=self.request_hook, + response_hook=self.response_hook, + on_request_headers_sent=self.on_request_chunk_sent, + on_request_data_sent=self.on_request_data_sent, + on_request_chunk_sent=self.on_request_chunk_sent, + on_request_redirect=self.on_request_redirect, + on_response_headers_received=self.on_response_headers_received, + on_response_data_received=self.on_response_data_received, + on_response_chunk_received=self.on_response_chunk_received, + on_connection_queued_start=self.on_connection_queued_start, + on_connection_queued_end=self.on_connection_queued_end, + on_connection_create_start=self.on_connection_create_start, + on_connection_create_end=self.on_connection_create_end, + on_connection_reuse_connection=self.on_connection_reuse_connection, + on_dns_resolve_host_start=self.on_dns_resolve_host_start, + on_dns_resolve_host_end=self.on_dns_resolve_host_end, + on_dns_cache_hit=self.on_dns_cache_hit, + on_dns_cache_miss=self.on_dns_cache_miss, + on_task_start=self.on_task_start, + on_task_end=self.on_task_end, + on_task_error=self.on_task_error, + **kwargs + ) + + def to_dict(self) -> Dict[str, OpenTelemetryTracingConfig]: + return { + 'url_filter': self.url_filter, + 'request_hook': self.request_hook, + 'response_hook': self.response_hook, + 'on_request_headers_sent': self.on_request_chunk_sent, + 'on_request_data_sent': self.on_request_data_sent, + 'on_request_chunk_sent': self.on_request_chunk_sent, + 'on_request_redirect': self.on_request_redirect, + 'on_response_headers_received': self.on_response_headers_received, + 'on_response_data_received': self.on_response_data_received, + 'on_response_chunk_received': self.on_response_chunk_received, + 'on_connection_queued_start': self.on_connection_queued_start, + 'on_connection_queued_end': self.on_connection_queued_end, + 'on_connection_create_start': self.on_connection_create_start, + 'on_connection_create_end': self.on_connection_create_end, + 'on_connection_reuse_connection': self.on_connection_reuse_connection, + 'on_dns_resolve_host_start': self.on_dns_resolve_host_start, + 'on_dns_resolve_host_end': self.on_dns_resolve_host_end, + 'on_dns_cache_hit': self.on_dns_cache_hit, + 'on_dns_cache_miss': self.on_dns_cache_miss, + 'on_task_start': self.on_task_start, + 'on_task_end': self.on_task_end, + 'on_task_error': self.on_task_error, + } + + def add_trace(self, trace_signal: TraceSignal) -> None: + trace_name = trace_signal.__name__ + + if trace_name in self.allowed_traces: + object.__setattr__( + self, + trace_name, + trace_signal + ) + + async def on_request_start( + self, + request: Request, + ): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + self.span = None + return + + http_method = request.method.upper() + request_span_name = f"HTTP {http_method}" + + request_url = request.url.full + + if callable(self.url_filter): + request_url = ( + remove_url_credentials( + self.url_filter(request.url) + ) + ) + + else: + remove_url_credentials(request.url.full) + + span_attributes = { + SpanAttributes.HTTP_METHOD: http_method, + SpanAttributes.HTTP_URL: request_url, + } + + self.span = self.tracer.start_span( + request_span_name, kind=SpanKind.CLIENT, attributes=span_attributes + ) + + if callable(self.request_hook): + self.request_hook(self.span, request) + + self.token = context_api.attach( + trace.set_span_in_context(self.span) + ) + + self._context_active = True + + inject(request.headers) + + async def on_request_end( + self, + response: Response, + ): + if self.span is None: + return + + if callable(self.response_hook): + self.response_hook(self.span, response) + + if self.span.is_recording() and response.status: + self.span.set_status( + Status( + http_status_to_status_code(response.status) + ) + ) + + self.span.set_attribute( + SpanAttributes.HTTP_STATUS_CODE, response.status + ) + + self._end_trace() + + async def on_request_exception( + self, + response: Response, + ) -> None: + if self.span is None: + return + + if self.span.is_recording() and response.error: + + if response.status: + self.span.set_status( + Status(StatusCode.ERROR) + ) + + self.span.record_exception( + str(response.error) + ) + + if callable(self.response_hook): + self.response_hook(self.span, response) + + self._end_trace() + + def _end_trace(self) -> None: + # context = context_api.get_current() + + if self._context_active: + context_api.detach(self.token) + self._context_active = False + + self.span.end() diff --git a/hyperscale/core/engines/types/tracing/trace_session.py b/hyperscale/core/engines/types/tracing/trace_session.py new file mode 100644 index 0000000..2687de3 --- /dev/null +++ b/hyperscale/core/engines/types/tracing/trace_session.py @@ -0,0 +1,45 @@ +from typing import Dict + +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .trace import Trace +from .tracing_types import RequestHook, ResponseHook, TraceSignal, UrlFilter + +__name__ = 'hyperscale' +__version__ = "0.7.12" + + +@unstable +class TraceSession: + + __slots__ = ( + 'url_filter', + 'request_hook', + 'response_hook', + 'trace_signals' + ) + + def __init__( + self, + url_filter: UrlFilter = None, + request_hook: RequestHook = None, + response_hook: ResponseHook = None, + **kwargs: Dict[str, TraceSignal] + ) -> None: + self.url_filter: UrlFilter = url_filter + self.request_hook: RequestHook = request_hook + self.response_hook: ResponseHook = response_hook + self.trace_signals: Dict[str, TraceSignal] = kwargs + + def create_trace(self) -> Trace: + + return Trace( + url_filter=self.url_filter, + request_hook=self.request_hook, + response_hook=self.response_hook, + **self.trace_signals + ) + + + + diff --git a/hyperscale/core/engines/types/tracing/tracing_types.py b/hyperscale/core/engines/types/tracing/tracing_types.py new file mode 100644 index 0000000..8125e48 --- /dev/null +++ b/hyperscale/core/engines/types/tracing/tracing_types.py @@ -0,0 +1,69 @@ +from typing import Callable, Coroutine, Dict, Optional + +from hyperscale.core.engines.types.common.url import URL + +try: + + from opentelemetry.trace import Span + +except ImportError: + Span = object + + +class Request: + url: URL + method: str + headers: Dict[str, str] + + +class Response: + url: URL + method: str + headers: Dict[str, str] + status: Optional[int] + error: Exception + + +RequestHook = Optional[ + Callable[ + [ + Span, + Request + ], + None + ] +] + + +ResponseHook = Optional[ + Callable[ + [ + Span, + Response, + ], + None, + ] +] + + +TraceSignal = Callable[ + [ + Span, + Request, + Response + + ], + Coroutine[ + None, + None, + None + ] +] + + +UrlFilter = Callable[ + [ + str + ], + str +] diff --git a/hyperscale/core/engines/types/tracing/url_filters.py b/hyperscale/core/engines/types/tracing/url_filters.py new file mode 100644 index 0000000..cfdd011 --- /dev/null +++ b/hyperscale/core/engines/types/tracing/url_filters.py @@ -0,0 +1,7 @@ +from hyperscale.core.engines.types.common.url import URL + + +def default_params_strip_filter(url: URL) -> str: + return url.parsed._replace( + query=None + ).geturl() diff --git a/hyperscale/core/engines/types/udp/__init__.py b/hyperscale/core/engines/types/udp/__init__.py new file mode 100644 index 0000000..84cd3fa --- /dev/null +++ b/hyperscale/core/engines/types/udp/__init__.py @@ -0,0 +1,5 @@ +from .client import MercuryUDPClient +from .action import UDPAction +from .result import UDPResult + + diff --git a/hyperscale/core/engines/types/udp/action.py b/hyperscale/core/engines/types/udp/action.py new file mode 100644 index 0000000..832d51b --- /dev/null +++ b/hyperscale/core/engines/types/udp/action.py @@ -0,0 +1,108 @@ +import json +from typing import Any, Dict, Iterator, List, Union +from urllib.parse import urlencode + +from hyperscale.core.engines.types.common import URL +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer +from hyperscale.core.engines.types.common.types import RequestTypes + + +class UDPAction(BaseAction): + + __slots__ = ( + 'action_id', + 'protocols', + 'wait_for_response', + 'type', + 'url', + '_data', + 'encoded_data', + 'is_stream', + 'ssl_context', + 'event', + 'action_args', + 'mutations' + ) + + def __init__( + self, + name: str, + url: str, + wait_for_response: bool = False, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str=None, + tags: List[Dict[str, str]] = [] + ) -> None: + super(UDPAction, self).__init__( + name, + user, + tags + ) + + self.wait_for_response = wait_for_response + self.type = RequestTypes.UDP + + address_family, protocol = self.protocols[self.type] + self.url = URL(url, family=address_family, protocol=protocol) + + self._data = data + + self.encoded_data = None + self.is_stream = False + self.ssl_context = None + self.hooks: Hooks[UDPAction] = Hooks() + self.action_args: Dict[str, Any] = {} + + @property + def size(self): + if self.encoded_data: + return len(self.encoded_data) + + else: + return 0 + + @property + def data(self): + return self._data + + @data.setter + def data(self, value): + self._data = value + self.encoded_data = None + + def setup(self): + if self.encoded_data is None: + self._setup_data() + + def _setup_data(self): + if self._data: + + if isinstance(self._data, Iterator): + chunks = bytearray() + for chunk in self._data: + chunks.extend( + chunk.encode() + ) + + self.is_stream = True + self.encoded_data = bytes(chunks) + + else: + if isinstance(self._data, dict): + self.encoded_data = json.dumps( + self._data + ).encode() + + elif isinstance(self._data, tuple): + self.encoded_data = urlencode( + self._data + ).encode() + + elif isinstance(self._data, str): + self.encoded_data = self._data.encode() + + def write_chunks(self, writer: Writer): + for chunk in self.data: + writer.write(chunk) diff --git a/hyperscale/core/engines/types/udp/client.py b/hyperscale/core/engines/types/udp/client.py new file mode 100644 index 0000000..c782f33 --- /dev/null +++ b/hyperscale/core/engines/types/udp/client.py @@ -0,0 +1,221 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.ssl import get_default_ssl_context +from hyperscale.core.engines.types.common.timeouts import Timeouts + +from .action import UDPAction +from .connection import UDPConnection +from .pool import Pool +from .result import UDPResult + + +class MercuryUDPClient(BaseEngine[UDPAction, UDPResult]): + + __slots__ = ( + 'session_id', + 'timeouts', + '_hosts', + 'closed', + 'sem', + 'pool', + 'active', + 'waiter', + 'ssl_context' + ) + + def __init__(self, concurrency: int=10**3, timeouts: Timeouts = Timeouts(), reset_connections: bool=False) -> None: + super( + MercuryUDPClient, + self + ).__init__() + + self.session_id = str(uuid.uuid4()) + self.timeouts = timeouts + + self.registered: Dict[str, UDPConnection] = {} + self._hosts = {} + self.closed = False + + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=reset_connections) + self.pool.create_pool() + self.active = 0 + self.waiter = None + + self.ssl_context = get_default_ssl_context() + + def config_to_dict(self): + return { + 'concurrency': self.pool.size, + 'timeouts': { + 'connect_timeout': self.timeouts.connect_timeout, + 'socket_read_timeout': self.timeouts.socket_read_timeout, + 'total_timeout': self.timeouts.total_timeout + }, + 'reset_connections': self.pool.reset_connections + } + + async def set_pool(self, concurrency: int): + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=self.pool.reset_connections) + self.pool.create_pool() + + async def prepare(self, action: UDPAction) -> Coroutine[Any, Any, None]: + try: + if action.url.is_ssl: + action.ssl_context = self.ssl_context + + if self._hosts.get(action.url.hostname) is None: + + socket_configs = await asyncio.wait_for(action.url.lookup(), timeout=self.timeouts.connect_timeout) + + for ip_addr, configs in socket_configs.items(): + for config in configs: + + connection = UDPConnection() + + try: + await connection.make_connection( + ip_addr, + action.url.port, + config, + timeout=self.timeouts.connect_timeout + ) + + action.url.socket_config = config + action.url.ip_addr = ip_addr + action.url.has_ip_addr = True + break + + except Exception: + pass + + if action.url.socket_config: + break + + if action.url.socket_config is None: + raise Exception('Err. - No socket found.') + + self._hosts[action.url.hostname] = { + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config + } + + else: + host_config = self._hosts[action.url.hostname] + action.url.ip_addr = host_config.get('ip_addr') + action.url.socket_config = host_config.get('socket_config') + + if action.is_setup is False: + action.setup() + + self.registered[action.name] = action + + except Exception as e: + raise e + + def extend_pool(self, increased_capacity: int): + self.pool.size += increased_capacity + for _ in range(increased_capacity): + self.pool.connections.append( + UDPConnection(self.pool.reset_connections) + ) + + self.sem = asyncio.Semaphore(self.pool.size) + + def shrink_pool(self, decrease_capacity: int): + self.pool.size -= decrease_capacity + self.pool.connections = self.pool.connections[:self.pool.size] + self.sem = asyncio.Semaphore(self.pool.size) + + async def execute_prepared_request(self, action: UDPAction) -> Coroutine[Any, Any, UDPResult]: + + response = UDPResult(action) + response.wait_start = time.monotonic() + self.active += 1 + + async with self.sem: + connection = self.pool.connections.pop() + + try: + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + await connection.make_connection( + action.url.ip_addr, + action.url.port, + action.url.socket_config, + timeout=self.timeouts.connect_timeout + ) + + response.connect_end = time.monotonic() + + if action.encoded_data: + if action.is_stream: + action.write_chunks(connection) + + else: + connection.write(action.encoded_data) + + response.write_end = time.monotonic() + + if action.wait_for_response: + response.body = await connection.readuntil() + + response.complete = time.monotonic() + + self.pool.connections.append(connection) + + if action.hooks.after: + response = await self.execute_after(action, response) + action.setup() + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + except Exception as e: + response.complete = time.monotonic() + response.error = str(e) + + self.pool.connections.append(UDPConnection(reset_connection=self.pool.reset_connections)) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + return response + + async def close(self): + if self.closed is False: + await self.pool.close() + self.closed = True diff --git a/hyperscale/core/engines/types/udp/connection.py b/hyperscale/core/engines/types/udp/connection.py new file mode 100644 index 0000000..cde06aa --- /dev/null +++ b/hyperscale/core/engines/types/udp/connection.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import asyncio +from typing import Optional, Tuple + +from hyperscale.core.engines.types.common.protocols import UDPConnection as UDP +from hyperscale.core.engines.types.common.protocols.shared.constants import ( + _DEFAULT_LIMIT, +) +from hyperscale.core.engines.types.common.protocols.shared.reader import Reader +from hyperscale.core.engines.types.common.protocols.shared.writer import Writer + + +class UDPConnection: + + __slots__ = ( + 'dns_address', + 'port', + 'ip_addr', + 'lock', + 'reader', + 'writer', + 'connected', + 'reset_connection', + 'pending', + '_connection_factory' + ) + + def __init__(self, reset_connection: bool=False) -> None: + self.dns_address: str = None + self.port: int = None + self.ip_addr = None + self.lock = asyncio.Lock() + + self.reader: Reader = None + self.writer: Writer = None + + self.connected = False + self.reset_connection = reset_connection + self.pending = 0 + self._connection_factory = UDP() + + async def make_connection( + self, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + timeout: Optional[float]=None + ) -> None: + + if self.connected is False or self.dns_address != dns_address or self.reset_connection: + try: + reader, writer = await asyncio.wait_for( + self._connection_factory.create_udp(socket_config), + timeout=timeout + ) + + self.connected = True + + self.reader = reader + self.writer = writer + + self.dns_address = dns_address + self.port = port + + except asyncio.TimeoutError: + raise Exception('Connection timed out.') + + except ConnectionResetError: + raise Exception('Connection reset.') + + except Exception as e: + raise e + + @property + def empty(self): + return not self.reader._buffer + + def read(self): + return self.reader.read(n=_DEFAULT_LIMIT) + + def readexactly(self, n_bytes: int): + return self.reader.read(n=n_bytes) + + def readuntil(self, sep=b'\n'): + return self.reader.readuntil(separator=sep) + + def write(self, data): + self.writer.send(data) + + def reset_buffer(self): + self.reader._buffer = bytearray() + + def read_headers(self): + return self.reader.read_headers() + + async def close(self): + try: + await self._connection_factory.close() + except Exception: + pass \ No newline at end of file diff --git a/hyperscale/core/engines/types/udp/pool.py b/hyperscale/core/engines/types/udp/pool.py new file mode 100644 index 0000000..a6749e2 --- /dev/null +++ b/hyperscale/core/engines/types/udp/pool.py @@ -0,0 +1,28 @@ + +from typing import List +from .connection import UDPConnection + + +class Pool: + + __slots__ = ( + 'size', + 'connections', + 'reset_connections' + ) + + def __init__(self, size: int, reset_connections: bool = False) -> None: + self.size = size + + self.connections: List[UDPConnection] = [] + self.reset_connections = reset_connections + + def create_pool(self) -> None: + for _ in range(self.size): + self.connections.append( + UDPConnection(self.reset_connections) + ) + + async def close(self): + for connection in self.connections: + await connection.close() diff --git a/hyperscale/core/engines/types/udp/result.py b/hyperscale/core/engines/types/udp/result.py new file mode 100644 index 0000000..e6ac96c --- /dev/null +++ b/hyperscale/core/engines/types/udp/result.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import Union + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.types import RequestTypes + +from .action import UDPAction + + +class UDPResult(BaseResult): + + __slots__ = ( + 'action_id', + 'url', + 'ip_addr', + 'has_response', + 'path', + 'params', + 'query', + 'hostname', + 'body', + 'response_code', + '_version', + '_reason' + '_status' + ) + + def __init__(self, action: UDPAction, error: Exception=None) -> None: + + super( + UDPResult, + self + ).__init__( + action.action_id, + action.name, + action.url.hostname, + action.metadata.user, + action.metadata.tags, + RequestTypes.UDP, + error + ) + + self.url = action.url.full + self.ip_addr = action.url.ip_addr + self.has_response = action.wait_for_response + self.path = action.url.path + self.params = action.url.params + self.query = action.url.query + self.hostname = action.url.hostname + + self.body = bytearray() + self.response_code = None + self._version = None + self._reason = None + self._status = None + + @property + def size(self): + + if self.body: + return len(self.body) + + else: + return 0 + + @property + def data(self) -> Union[str, dict, None]: + return self.body.decode() + + @property + def status(self) -> Union[int, None]: + return self._status + + @status.setter + def status(self, new_status: int): + self._status = new_status diff --git a/hyperscale/core/engines/types/websocket/__init__.py b/hyperscale/core/engines/types/websocket/__init__.py new file mode 100644 index 0000000..72daa69 --- /dev/null +++ b/hyperscale/core/engines/types/websocket/__init__.py @@ -0,0 +1,3 @@ +from .client import MercuryWebsocketClient +from .action import WebsocketAction +from .result import WebsocketResult \ No newline at end of file diff --git a/hyperscale/core/engines/types/websocket/action.py b/hyperscale/core/engines/types/websocket/action.py new file mode 100644 index 0000000..e93be4d --- /dev/null +++ b/hyperscale/core/engines/types/websocket/action.py @@ -0,0 +1,111 @@ +from typing import Any, Dict, Iterator, List, Union + +from hyperscale.core.engines.types.common.hooks import Hooks +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http.action import HTTPAction + +from .constants import WEBSOCKETS_VERSION +from .utils import create_sec_websocket_key, pack_hostname + + +class WebsocketAction(HTTPAction): + + def __init__( + self, + name: str, + url: str, + method: str = 'GET', + headers: Dict[str, str] = {}, + data: Union[str, dict, Iterator, bytes, None] = None, + user: str=None, + tags: List[Dict[str, str]] = [] + ) -> None: + + super( + WebsocketAction, + self + ).__init__( + name, + url, + method, + headers, + data, + user, + tags + ) + + self.action_args: Dict[str, Any] = {} + self.type = RequestTypes.WEBSOCKET + self.hooks: Hooks[WebsocketAction] = Hooks() + + def _setup_headers(self): + + for header_name, header_value in self._headers.items(): + header_name_lowered = header_name.lower() + self._headers[header_name_lowered] = header_value + + headers = [ + "GET %s HTTP/1.1" % self.url.path, + "Upgrade: websocket" + ] + if self.url.port == 80 or self.url.port == 443: + hostport = pack_hostname(self.url.hostname) + else: + hostport = "%s:%d" % (pack_hostname( + self.url.hostname + ), self.url.port) + + host = self._headers.get("host") + if host: + headers.append(f"Host: {host}") + else: + headers.append(f"Host: {hostport}") + + # scheme, url = url.split(":", 1) + if not self._headers.get("suppress_origin"): + + origin = self._headers.get("origin") + + if origin: + headers.append(f"Origin: {origin}") + + elif self.url.scheme == "wss": + headers.append(f"Origin: https://{hostport}") + + else: + headers.append(f"Origin: http://{hostport}") + + key = create_sec_websocket_key() + + header = self._headers.get("header") + if not header or 'Sec-WebSocket-Key' not in header: + headers.append(f"Sec-WebSocket-Key: {key}") + else: + key = self._headers.get("header", {}).get('Sec-WebSocket-Key') + + if not header or 'Sec-WebSocket-Version' not in header: + headers.append(f"Sec-WebSocket-Version: {WEBSOCKETS_VERSION}") + + connection = self._headers.get('connection') + if not connection: + headers.append('Connection: Upgrade') + else: + headers.append(connection) + + subprotocols = self._headers.get("subprotocols") + if subprotocols: + headers.append("Sec-WebSocket-Protocol: %s" % ",".join(subprotocols)) + + if header: + if isinstance(header, dict): + header = [ + ": ".join([k, v]) + for k, v in header.items() + if v is not None + ] + headers.extend(header) + + headers.append("") + headers.append("") + + self.encoded_headers = '\r\n'.join(headers).encode() \ No newline at end of file diff --git a/hyperscale/core/engines/types/websocket/client.py b/hyperscale/core/engines/types/websocket/client.py new file mode 100644 index 0000000..1275124 --- /dev/null +++ b/hyperscale/core/engines/types/websocket/client.py @@ -0,0 +1,240 @@ +import asyncio +import time +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.engines.types.common.ssl import get_default_ssl_context +from hyperscale.core.engines.types.common.timeouts import Timeouts + +from .action import WebsocketAction +from .connection import WebsocketConnection +from .pool import Pool +from .result import WebsocketResult +from .utils import get_header_bits, get_message_buffer_size + + +class MercuryWebsocketClient(BaseEngine[WebsocketAction, WebsocketResult]): + + __slots__ = ( + 'session_id', + 'timeouts', + 'registered', + '_hosts', + 'closed', + 'sem', + 'pool', + 'active', + 'waiter', + 'ssl_context' + ) + + + def __init__(self, concurrency: int = 10 ** 3, timeouts: Timeouts = Timeouts(), reset_connections: bool=False) -> None: + + self.session_id = str(uuid.uuid4()) + self.timeouts = timeouts + + self.registered: Dict[str, WebsocketAction] = {} + self._hosts = {} + self.closed = False + + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=reset_connections) + self.pool.create_pool() + self.active = 0 + self.waiter = None + + self.ssl_context = get_default_ssl_context() + + def config_to_dict(self): + return { + 'concurrency': self.pool.size, + 'timeouts': { + 'connect_timeout': self.timeouts.connect_timeout, + 'socket_read_timeout': self.timeouts.socket_read_timeout, + 'total_timeout': self.timeouts.total_timeout + }, + 'reset_connections': self.pool.reset_connections + } + + async def set_pool(self, concurrency: int): + self.sem = asyncio.Semaphore(value=concurrency) + self.pool = Pool(concurrency, reset_connections=self.pool.reset_connections) + self.pool.create_pool() + + + def extend_pool(self, increased_capacity: int): + self.pool.size += increased_capacity + for _ in range(increased_capacity): + self.pool.connections.append( + WebsocketConnection(self.pool.reset_connections) + ) + + self.sem = asyncio.Semaphore(self.pool.size) + + def shrink_pool(self, decrease_capacity: int): + self.pool.size -= decrease_capacity + self.pool.connections = self.pool.connections[:self.pool.size] + self.sem = asyncio.Semaphore(self.pool.size) + + async def prepare(self, action: WebsocketAction) -> Coroutine[Any, Any, None]: + try: + if action.url.is_ssl: + action.ssl_context = self.ssl_context + + if self._hosts.get(action.url.hostname) is None: + + socket_configs = await action.url.lookup() + for ip_addr, configs in socket_configs.items(): + for config in configs: + try: + connection = WebsocketConnection() + await connection.make_connection( + action.url.hostname, + ip_addr, + action.url.port, + config, + ssl=action.ssl_context + ) + + action.url.socket_config = config + action.url.ip_addr = ip_addr + action.url.has_ip_addr = True + break + + except Exception: + pass + + if action.url.socket_config: + break + + if action.url.socket_config is None: + raise Exception('Err. - No socket found.') + + self._hosts[action.url.hostname] = { + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config + } + + else: + host_config = self._hosts[action.url.hostname] + action.url.ip_addr = host_config.get('ip_addr') + action.url.socket_config = host_config.get('socket_config') + + if action.is_setup is False: + action.setup() + + self.registered[action.name] = action + + return action + + except Exception as e: + raise e + + async def execute_prepared_request(self, action: WebsocketAction) -> Coroutine[Any, Any, WebsocketResult]: + + response = WebsocketResult(action) + response.wait_start = time.monotonic() + self.active += 1 + + async with self.sem: + + connection = self.pool.connections.pop() + + try: + + if action.hooks.listen: + event = asyncio.Event() + action.hooks.channel_events.append(event) + await event.wait() + + if action.hooks.before: + action = await self.execute_before(action) + action.setup() + + response.start = time.monotonic() + + await connection.make_connection( + action.name, + action.url.ip_addr, + action.url.port, + ssl=action.ssl_context, + timeout=self.timeouts.connect_timeout + ) + + response.connect_end = time.monotonic() + + connection.write(action.encoded_headers) + + if action.encoded_data is not None: + if action.is_stream: + action.write_chunks(connection) + + else: + connection.write(action.encoded_data) + + response.write_end = time.monotonic() + + line = await asyncio.wait_for(connection.readuntil(), self.timeouts.socket_read_timeout) + + response.response_code = line + raw_headers = b'' + async for key, value, header_line in connection.iter_headers(connection): + response.headers[key] = value + raw_headers += header_line + + if action.encoded_data is not None: + header_bits = get_header_bits(raw_headers) + header_content_length = get_message_buffer_size(header_bits) + + if action.method == 'GET': + response.body = await asyncio.wait_for(connection.readexactly(min(16384, header_content_length)), self.timeouts.total_timeout) + + response.complete = time.monotonic() + + if action.hooks.after: + response = await self.execute_after(action, response) + action.setup() + + if action.hooks.notify: + await asyncio.gather(*[ + asyncio.create_task( + channel.call(response, action.hooks.listeners) + ) for channel in action.hooks.channels + ]) + + for listener in action.hooks.listeners: + if len(listener.hooks.channel_events) > 0: + listener.setup() + event = listener.hooks.channel_events.pop() + if not event.is_set(): + event.set() + + self.pool.connections.append(connection) + + except Exception as e: + response.complete = time.monotonic() + response.error = str(e) + + self.pool.connections.append( + WebsocketConnection(reset_connection=self.pool.reset_connections) + ) + + self.active -= 1 + if self.waiter and self.active <= self.pool.size: + + try: + self.waiter.set_result(None) + self.waiter = None + + except asyncio.InvalidStateError: + self.waiter = None + + return response + + async def close(self): + if self.closed is False: + await self.pool.close() + self.closed = True + diff --git a/hyperscale/core/engines/types/websocket/connection.py b/hyperscale/core/engines/types/websocket/connection.py new file mode 100644 index 0000000..7968d60 --- /dev/null +++ b/hyperscale/core/engines/types/websocket/connection.py @@ -0,0 +1,11 @@ +from hyperscale.core.engines.types.http.connection import HTTPConnection + + +class WebsocketConnection(HTTPConnection): + + + def __init__(self, reset_connection: bool = False) -> None: + super().__init__(reset_connection) + + def iter_headers(self): + return self.reader.iter_headers() \ No newline at end of file diff --git a/hyperscale/core/engines/types/websocket/constants.py b/hyperscale/core/engines/types/websocket/constants.py new file mode 100644 index 0000000..99baa44 --- /dev/null +++ b/hyperscale/core/engines/types/websocket/constants.py @@ -0,0 +1,2 @@ +WEBSOCKETS_VERSION = 13 +HEADER_LENGTH_INDEX = 6 \ No newline at end of file diff --git a/hyperscale/core/engines/types/websocket/pool.py b/hyperscale/core/engines/types/websocket/pool.py new file mode 100644 index 0000000..60b3923 --- /dev/null +++ b/hyperscale/core/engines/types/websocket/pool.py @@ -0,0 +1,27 @@ + +from typing import List +from .connection import WebsocketConnection + + +class Pool: + + __slots__ = ( + 'size', + 'connections', + 'reset_connections' + ) + + def __init__(self, size: int, reset_connections: bool = False) -> None: + self.size = size + self.connections: List[WebsocketConnection] = [] + self.reset_connections = reset_connections + + def create_pool(self) -> None: + for _ in range(self.size): + self.connections.append( + WebsocketConnection(self.reset_connections) + ) + + async def close(self): + for connection in self.connections: + await connection.close() diff --git a/hyperscale/core/engines/types/websocket/result.py b/hyperscale/core/engines/types/websocket/result.py new file mode 100644 index 0000000..508b519 --- /dev/null +++ b/hyperscale/core/engines/types/websocket/result.py @@ -0,0 +1,11 @@ +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http.result import HTTPResult + +from .action import WebsocketAction + + +class WebsocketResult(HTTPResult): + + def __init__(self, action: WebsocketAction, error: Exception = None) -> None: + super().__init__(action, error) + self.type = RequestTypes.WEBSOCKET \ No newline at end of file diff --git a/hyperscale/core/engines/types/websocket/utils.py b/hyperscale/core/engines/types/websocket/utils.py new file mode 100644 index 0000000..9346bbb --- /dev/null +++ b/hyperscale/core/engines/types/websocket/utils.py @@ -0,0 +1,50 @@ +import os +import struct +from typing import Tuple +from base64 import encodebytes as base64encode +from .connection import WebsocketConnection +from .constants import HEADER_LENGTH_INDEX + +def create_sec_websocket_key(): + randomness = os.urandom(16) + return base64encode(randomness).decode('utf-8').strip() + +def pack_hostname(hostname): + # IPv6 address + if ':' in hostname: + return '[' + hostname + ']' + + return hostname + + +def get_header_bits(raw_headers: bytes): + + b1 = raw_headers[0] + fin = b1 >> 7 & 1 + rsv1 = b1 >> 6 & 1 + rsv2 = b1 >> 5 & 1 + rsv3 = b1 >> 4 & 1 + opcode = b1 & 0xf + b2 = raw_headers[1] + has_mask = b2 >> 7 & 1 + length_bits = b2 & 0x7f + + header_bits = (fin, rsv1, rsv2, rsv3, opcode, has_mask, length_bits) + + return header_bits + + +async def get_message_buffer_size(header_bits: Tuple[int], connection: WebsocketConnection): + bits = header_bits[HEADER_LENGTH_INDEX] + length_bits = bits & 0x7f + length = 0 + if length_bits == 0x7e: + v = await connection.readexactly(2) + length = struct.unpack("!H", v)[0] + elif length_bits == 0x7f: + v = await connection.readexactly(8) + length = struct.unpack("!Q", v)[0] + else: + length = length_bits + + return length diff --git a/hyperscale/core/experiments/__init__.py b/hyperscale/core/experiments/__init__.py new file mode 100644 index 0000000..147e508 --- /dev/null +++ b/hyperscale/core/experiments/__init__.py @@ -0,0 +1,2 @@ +from .experiment import Experiment +from .variant import Variant \ No newline at end of file diff --git a/hyperscale/core/experiments/distribution.py b/hyperscale/core/experiments/distribution.py new file mode 100644 index 0000000..f92ee92 --- /dev/null +++ b/hyperscale/core/experiments/distribution.py @@ -0,0 +1,239 @@ +from typing import Callable, Dict, List, Type + +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .distribution_types import DistributionMap, DistributionTypes +from .distributions.types import ( + AlphaDistribution, + AnglitDistribution, + ArcsineDistribution, + ArgusDistribution, + BetaDistribution, + BetaPrimeDistribution, + BradfordDistribution, + Burr12Distribution, + BurrDistribution, + CauchyDistribution, + ChiDistribution, + ChiSquaredDistribution, + CosineDistribution, + CrystalDistribution, + DGammaDistribution, + DWeibullDistribution, + ErlangDistribution, + ExponentialDistribution, + ExponentialNormalDistribution, + ExponentialPowerDistribution, + FatigueLifeDistribution, + FDistribution, + FiskDistribution, + FoldedCauchyDistribution, + FoldedNormalDistribution, + GammaDistribution, + GaussHypergeometricDistribution, + GeneralizedExponentialDistribution, + GeneralizedExtremeDistribution, + GeneralizedGammaDistribution, + GeneralizedHalfLogisticDistribution, + GeneralizedHyperbolicDistribution, + GeneralizedInverseGaussDistribution, + GeneralizedLogisticDistribution, + GeneralizedNormalDistribution, + GeneralizedParetoDistribution, + GibratDistribution, + GompertzDistribution, + GumbelLDistribution, + GumbelRDistribution, + HalfCauchyDistribution, + HalfGeneralizedNormalDistribution, + HalfLogisticDistribution, + HalfNormalDistribution, + HyperbolicSecantDistribution, + InverseGammaDistribution, + InverseWeibullDistribution, + JohnsonSBDistribution, + JohnsonSUDistribution, + Kappa3Distribution, + Kappa4Distribution, + KSOneDistribution, + KSTwoBinomialGeneralizedDistribution, + KSTwoDistribution, + LaplaceAsymmetricDistribution, + LaplaceDistribution, + LevyDistribution, + LevyLDistribution, + LevyStableDistribution, + LogGammaDistribution, + LogisticDistribution, + LogLaplaceDistribution, + LogUniformDistribution, + LomaxDistribution, + MaxwellDistribution, + MielkeDistribution, + MoyalDistribution, + NakagamiDistribution, + NonCenteralChiSquaredDistribution, + NonCenteralFDistribution, + NonCenteralTDistribution, + NormalDistribution, + NormalInverseGaussDistribution, + ParetoDistribution, + Pearson3Distribution, + PowerlawDistribution, + PowerLogNormalDistribution, + PowerNormalDistribution, + RayleighDistribution, + RDistribution, + ReciprocalInverseGaussDistribution, + RiceDistribution, + SemiCircularDistribution, + SkewedCauchyDistribution, + SkewedNormalDistribution, + StudentRangeDistribution, + TDistribution, + TrapezoidDistribution, + TriangularDistribution, + TruncatedExponentialDistribution, + TruncatedNormalDistribution, + TruncatedParetoDistribution, + TruncatedWeibullMinimumDistribution, + TukeyLambdaDistribution, + UniformDistribution, + VonMisesDistribution, + VonMisesLineDistribution, + WaldDistribution, + WeibullMaxmimumDistribution, + WeibullMinimumDistribution, + WrappedCauchyDistribution, +) +from .distributions.types.base import BaseDistribution + + +@unstable +class Distribution: + + def __init__( + self, + distribution_type: DistributionTypes=DistributionTypes.NORMAL, + intervals: int=None + ) -> None: + + self._distribution_map = DistributionMap() + self._distributions: Dict[str, Callable[..., List[float]]] = { + DistributionTypes.ALPHA: lambda size: AlphaDistribution(size), + DistributionTypes.ANGLIT: lambda size: AnglitDistribution(size), + DistributionTypes.ARCSINE: lambda size: ArcsineDistribution(size), + DistributionTypes.ARGUS: lambda size: ArgusDistribution(size), + DistributionTypes.BETA_PRIME: lambda size: BetaPrimeDistribution(size), + DistributionTypes.BETA: lambda size: BetaDistribution(size), + DistributionTypes.BRADFORD: lambda size: BradfordDistribution(size), + DistributionTypes.BURR: lambda size: BurrDistribution(size), + DistributionTypes.BURR12: lambda size: Burr12Distribution(size), + DistributionTypes.CAUCHY: lambda size: CauchyDistribution(size), + DistributionTypes.CHI_SQUARED: lambda size: ChiSquaredDistribution(size), + DistributionTypes.CHI: lambda size: ChiDistribution(size), + DistributionTypes.COSINE: lambda size: CosineDistribution(size), + DistributionTypes.CRYSTAL_BALL: lambda size: CrystalDistribution(size), + DistributionTypes.DGAMMA: lambda size: DGammaDistribution(size), + DistributionTypes.DWEIBULL: lambda size: DWeibullDistribution(size), + DistributionTypes.ERLANG: lambda size: ErlangDistribution(size), + DistributionTypes.EXPONENTIAL_NORMAL: lambda size: ExponentialNormalDistribution(size), + DistributionTypes.EXPONENTIAL_POWER: lambda size: ExponentialPowerDistribution(size), + DistributionTypes.EXPONENTIAL: lambda size: ExponentialDistribution(size), + DistributionTypes.F_DISTRIBUTION: lambda size: FDistribution(size), + DistributionTypes.FATIGUE_LIFE: lambda size: FatigueLifeDistribution(size), + DistributionTypes.FISK: lambda size: FiskDistribution(size), + DistributionTypes.FOLDED_CAUCHY: lambda size: FoldedCauchyDistribution(size), + DistributionTypes.FOLDED_NORMAL: lambda size: FoldedNormalDistribution(size), + DistributionTypes.GAMMA: lambda size: GammaDistribution(size), + DistributionTypes.GAUSS_HYPERGEOMETRIC: lambda size: GaussHypergeometricDistribution(size), + DistributionTypes.GENERALIZED_EXPONENTIAL: lambda size: GeneralizedExponentialDistribution(size), + DistributionTypes.GENERALIZED_EXTREME: lambda size: GeneralizedExtremeDistribution(size), + DistributionTypes.GENERALIZED_GAMMA: lambda size: GeneralizedGammaDistribution(size), + DistributionTypes.GENERALIZED_HALF_LOGISTIC: lambda size: GeneralizedHalfLogisticDistribution(size), + DistributionTypes.GENERALIZED_HYPERBOLIC: lambda size: GeneralizedHyperbolicDistribution(size), + DistributionTypes.GENERALIZED_INVERSE_GAUSS: lambda size: GeneralizedInverseGaussDistribution(size), + DistributionTypes.GENERALIZED_LOGISTIC: lambda size: GeneralizedLogisticDistribution(size), + DistributionTypes.GENERALIZED_NORMAL: lambda size: GeneralizedNormalDistribution(size), + DistributionTypes.GENERALIZED_PARETO: lambda size: GeneralizedParetoDistribution(size), + DistributionTypes.GIBRAT: lambda size: GibratDistribution(size), + DistributionTypes.GOMPERTZ: lambda size: GompertzDistribution(size), + DistributionTypes.GUMBEL_L: lambda size: GumbelLDistribution(size), + DistributionTypes.GUMBEL_R: lambda size: GumbelRDistribution(size), + DistributionTypes.HALF_CAUCHY: lambda size: HalfCauchyDistribution(size), + DistributionTypes.HALF_GENERALIZED_NORMAL: lambda size: HalfGeneralizedNormalDistribution(size), + DistributionTypes.HALF_LOGISTIC: lambda size: HalfLogisticDistribution(size), + DistributionTypes.HALF_NORMAL: lambda size: HalfNormalDistribution(size), + DistributionTypes.HYPERBOLIC_SECANT: lambda size: HyperbolicSecantDistribution(size), + DistributionTypes.INVERSE_GAMMA: lambda size: InverseGammaDistribution(size), + DistributionTypes.INVERSE_WEIBULL: lambda size: InverseWeibullDistribution(size), + DistributionTypes.JOHNSON_SB: lambda size: JohnsonSBDistribution(size), + DistributionTypes.JOHNSON_SU: lambda size: JohnsonSUDistribution(size), + DistributionTypes.KAPPA_3: lambda size: Kappa3Distribution(size), + DistributionTypes.KAPPA_4: lambda size: Kappa4Distribution(size), + DistributionTypes.KS_ONE: lambda size: KSOneDistribution(size), + DistributionTypes.KS_TWO: lambda size: KSTwoDistribution(size), + DistributionTypes.KS_TWO_BIGENERALIZED: lambda size: KSTwoBinomialGeneralizedDistribution(size), + DistributionTypes.LAPLACE: lambda size: LaplaceDistribution(size), + DistributionTypes.LAPLACE_ASYMMETRIC: lambda size: LaplaceAsymmetricDistribution(size), + DistributionTypes.LEVY: lambda size: LevyDistribution(size), + DistributionTypes.LEVY_L: lambda size: LevyLDistribution(size), + DistributionTypes.LEVY_STABLE: lambda size: LevyStableDistribution(size), + DistributionTypes.LOG_GAMMA: lambda size: LogGammaDistribution(size), + DistributionTypes.LOG_LAPLAPCE: lambda size: LogLaplaceDistribution(size), + DistributionTypes.LOG_UNIFORM: lambda size: LogUniformDistribution(size), + DistributionTypes.LOGISTIC: lambda size: LogisticDistribution(size), + DistributionTypes.LOMAX: lambda size: LomaxDistribution(size), + DistributionTypes.MAXWELL: lambda size: MaxwellDistribution(size), + DistributionTypes.MIELKE: lambda size: MielkeDistribution(size), + DistributionTypes.MOYAL: lambda size: MoyalDistribution(size), + DistributionTypes.NAKAGAMI: lambda size: NakagamiDistribution(size), + DistributionTypes.NON_CENTRAL_CHI_SQUARED: lambda size: NonCenteralChiSquaredDistribution(size), + DistributionTypes.NON_CENTRAL_F_DISTRIBUTION: lambda size: NonCenteralFDistribution(size), + DistributionTypes.NON_CENTRAL_T_DISTRIBUTION: lambda size: NonCenteralTDistribution(size), + DistributionTypes.NORMAL_INVERSE_GAUSS: lambda size: NormalInverseGaussDistribution(size), + DistributionTypes.NORMAL: lambda size: NormalDistribution(size), + DistributionTypes.PARETO: lambda size: ParetoDistribution(size), + DistributionTypes.PEARSON_3: lambda size: Pearson3Distribution(size), + DistributionTypes.POWER_LOG_NORMAL: lambda size: PowerLogNormalDistribution(size), + DistributionTypes.POWER_NORMAL: lambda size: PowerNormalDistribution(size), + DistributionTypes.POWERLAW: lambda size: PowerlawDistribution(size), + DistributionTypes.R_DISTRIBUTION: lambda size: RDistribution(size), + DistributionTypes.RAYLEIGH: lambda size: RayleighDistribution(size), + DistributionTypes.RECIPROCAL_INVERSE_GAUSS: lambda size: ReciprocalInverseGaussDistribution(size), + DistributionTypes.RICE: lambda size: RiceDistribution(size), + DistributionTypes.SEMI_CIRCULAR: lambda size: SemiCircularDistribution(size), + DistributionTypes.SKEWED_CAUCHY: lambda size: SkewedCauchyDistribution(size), + DistributionTypes.SKEWED_NORMAL: lambda size: SkewedNormalDistribution(size), + DistributionTypes.STUDENT_RANGE: lambda size: StudentRangeDistribution(size), + DistributionTypes.T_DISTRIBUTION: lambda size: TDistribution(size), + DistributionTypes.TRAPEZOID: lambda size: TrapezoidDistribution(size), + DistributionTypes.TRIANGULAR: lambda size: TriangularDistribution(size), + DistributionTypes.TRUNCATED_EXPONENTIAL: lambda size: TruncatedExponentialDistribution(size), + DistributionTypes.TRUNCATED_NORMAL: lambda size: TruncatedNormalDistribution(size), + DistributionTypes.TRUNCATED_PARETO: lambda size: TruncatedParetoDistribution(size), + DistributionTypes.TRUNCATED_WEIBULL_MINIMUM: lambda size: TruncatedWeibullMinimumDistribution(size), + DistributionTypes.TUKEY_LAMBDA: lambda size: TukeyLambdaDistribution(size), + DistributionTypes.UNIFORM: lambda size: UniformDistribution(size), + DistributionTypes.VONMISES_LINE: lambda size: VonMisesLineDistribution(size), + DistributionTypes.VONMISES: lambda size: VonMisesDistribution(size), + DistributionTypes.WALD: lambda size: WaldDistribution(size), + DistributionTypes.WEIBULL_MAXIMUM: lambda size: WeibullMaxmimumDistribution(size), + DistributionTypes.WEIBULL_MINIMUM: lambda size: WeibullMinimumDistribution(size), + DistributionTypes.WRAPPED_CAUCHY: lambda size: WrappedCauchyDistribution(size) + } + + self.selected_distribution: DistributionTypes = self._distribution_map.get( + distribution_type + ) + self.disribution_function: Type[BaseDistribution] = self._distributions.get( + self.selected_distribution + ) + + self.intervals = intervals + + def generate(self, batch_size: int): + distribution = self.disribution_function(self.intervals) + return distribution.generate_distribution(batch_size) + + \ No newline at end of file diff --git a/hyperscale/core/experiments/distribution_types.py b/hyperscale/core/experiments/distribution_types.py new file mode 100644 index 0000000..187d6a0 --- /dev/null +++ b/hyperscale/core/experiments/distribution_types.py @@ -0,0 +1,133 @@ +from enum import Enum +from typing import Dict, Iterable + +from hyperscale.versioning.flags.types.unstable.flag import unstable + + +class DistributionTypes(Enum): + ALPHA='alpha' + ANGLIT='anglit' + ARCSINE='arcsine' + ARGUS='argus' + BETA_PRIME='beta-prime' + BETA='beta' + BRADFORD='bradford' + BURR12='burr12' + BURR='burr' + CAUCHY='cauchy' + CHI_SQUARED='chi-squared' + CHI='chi' + COSINE='cosine' + CRYSTAL_BALL='crystal-ball' + DGAMMA='dgamma' + DWEIBULL='dweibull' + ERLANG='erlang' + EXPONENTIAL_NORMAL='exponential-normal' + EXPONENTIAL_POWER='exponential-power' + EXPONENTIAL='exponential' + F_DISTRIBUTION='f-distribution' + FATIGUE_LIFE='fatigue-life' + FISK='fisk' + FOLDED_CAUCHY='folded-cauchy' + FOLDED_NORMAL='folded-normal' + GAMMA='gamma' + GAUSS_HYPERGEOMETRIC='gauss-hypergeometric' + GENERALIZED_EXPONENTIAL='generalized-exponential' + GENERALIZED_EXTREME='generalized-extreme' + GENERALIZED_GAMMA='generalized-gamma' + GENERALIZED_HALF_LOGISTIC='generalized-half-logistic' + GENERALIZED_HYPERBOLIC='generalized-hyperbolic' + GENERALIZED_LOGISTIC='generalized-logistic' + GENERALIZED_NORMAL='generalized-normal' + GENERALIZED_PARETO='generalized-pareto' + GENERALIZED_INVERSE_GAUSS='generalized-inverse-gauss' + GIBRAT='gibrat' + GOMPERTZ='gompertz' + GUMBEL_L='gumbel-l' + GUMBEL_R='gumbel-r' + HALF_CAUCHY='half-cauchy' + HALF_GENERALIZED_NORMAL='half-generalized-normal' + HALF_LOGISTIC='half-logistic' + HALF_NORMAL='half-normal' + HYPERBOLIC_SECANT='hyperbolic-secant' + INVERSE_GAMMA='inverse-gamma' + INVERSE_WEIBULL='inverse-weibull' + JOHNSON_SB='johnson-db' + JOHNSON_SU='johnson-su' + KAPPA_3='kappa-3' + KAPPA_4='kappa-4' + KS_ONE='ks-one' + KS_TWO_BIGENERALIZED='ks-two-bigeneralized' + KS_TWO='ks-two' + LAPLACE_ASYMMETRIC='laplace-asymmetric' + LAPLACE='laplace' + LEVY_L='levy-l' + LEVY_STABLE='levy-stable' + LEVY='levy' + LOG_GAMMA='log-gamma' + LOG_LAPLAPCE='log-laplace' + LOG_UNIFORM='log-uniform' + LOGISTIC='logistic' + LOMAX='lomax' + MAXWELL='maxwell' + MIELKE='mielke' + MOYAL='moyal' + NAKAGAMI='nakagami' + NON_CENTRAL_F_DISTRIBUTION='non-centeral-f' + NON_CENTRAL_CHI_SQUARED='non-central-chisqr' + NON_CENTRAL_T_DISTRIBUTION='non-central-t' + NORMAL_INVERSE_GAUSS='normal=inverse-gauss' + NORMAL='normal' + PARETO='pareto' + PEARSON_3='pearson-3' + POWER_LOG_NORMAL='power-log-normal' + POWER_NORMAL='power-normal' + POWERLAW='powerlaw' + R_DISTRIBUTION='r-distribution' + RAYLEIGH='rayleigh' + RECIPROCAL_INVERSE_GAUSS='reciprocal-inverse-gauss' + RICE='rice' + SEMI_CIRCULAR='semi-circular' + SKEWED_CAUCHY='skewed-cauchy' + SKEWED_NORMAL='skewed-normal' + STUDENT_RANGE='student-range' + T_DISTRIBUTION='t-distribution' + TRAPEZOID='trapezoid' + TRIANGULAR='triangular' + TRUNCATED_EXPONENTIAL='truncated-exponential' + TRUNCATED_NORMAL='truncated-normal' + TRUNCATED_PARETO='truncated-pareto' + TRUNCATED_WEIBULL_MINIMUM='truncated-weibull-minimum' + TUKEY_LAMBDA='tuke-lambda' + UNIFORM='uniform' + VONMISES_LINE='vonmises-line' + VONMISES='vonmises' + WALD='wald' + WEIBULL_MAXIMUM='weibull-maximum' + WEIBULL_MINIMUM='weibull-minimum' + WRAPPED_CAUCHY='wrapped-cauchy' + + +@unstable +class DistributionMap: + + def __init__(self) -> None: + self._types: Dict[str, DistributionTypes] = { + distribution_type.value: distribution_type for distribution_type in DistributionTypes + } + + def __iter__(self) -> Iterable[DistributionTypes]: + for distribution_type in self._types.values(): + yield distribution_type + + def __getitem__(self, distribution_type: str) -> DistributionTypes: + return self._types.get( + distribution_type, + DistributionTypes.NORMAL + ) + + def get(self, distribution_type: str) -> DistributionTypes: + return self._types.get( + distribution_type, + DistributionTypes.NORMAL + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/__init__.py b/hyperscale/core/experiments/distributions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/experiments/distributions/types/__init__.py b/hyperscale/core/experiments/distributions/types/__init__.py new file mode 100644 index 0000000..9480586 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/__init__.py @@ -0,0 +1,101 @@ +from .alpha import AlphaDistribution +from .anglit import AnglitDistribution +from .arcsine import ArcsineDistribution +from .argus import ArgusDistribution +from .beta_prime import BetaPrimeDistribution +from .beta import BetaDistribution +from .bradford import BradfordDistribution +from .burr_12 import Burr12Distribution +from .burr import BurrDistribution +from .cauchy import CauchyDistribution +from .chi_squared import ChiSquaredDistribution +from .chi import ChiDistribution +from .cosine import CosineDistribution +from .crystal_ball import CrystalDistribution +from .dgamma import DGammaDistribution +from .dweibull import DWeibullDistribution +from .erlang import ErlangDistribution +from .exponential_normal import ExponentialNormalDistribution +from .exponential_power import ExponentialPowerDistribution +from .exponential import ExponentialDistribution +from .f_distribution import FDistribution +from .fatigue_life import FatigueLifeDistribution +from .fisk import FiskDistribution +from .folded_cauchy import FoldedCauchyDistribution +from .folded_normal import FoldedNormalDistribution +from .gamma import GammaDistribution +from .gauss_hypergeometric import GaussHypergeometricDistribution +from .generalized_exponential import GeneralizedExponentialDistribution +from .generalized_extreme import GeneralizedExtremeDistribution +from .generalized_gamma import GeneralizedGammaDistribution +from .generalized_half_logistic import GeneralizedHalfLogisticDistribution +from .generalized_hyperbolic import GeneralizedHyperbolicDistribution +from .generalized_inverse_gauss import GeneralizedInverseGaussDistribution +from .generalized_logistic import GeneralizedLogisticDistribution +from .generalized_normal import GeneralizedNormalDistribution +from .generalized_pareto import GeneralizedParetoDistribution +from .gibrat import GibratDistribution +from .gompertz import GompertzDistribution +from .gumbel_l import GumbelLDistribution +from .gumbel_r import GumbelRDistribution +from .half_cauchy import HalfCauchyDistribution +from .half_generalized_normal import HalfGeneralizedNormalDistribution +from .half_logistic import HalfLogisticDistribution +from .half_normal import HalfNormalDistribution +from .hyperbolic_secant import HyperbolicSecantDistribution +from .inverse_gamma import InverseGammaDistribution +from .inverse_weibull import InverseWeibullDistribution +from .johnson_sb import JohnsonSBDistribution +from .johnson_su import JohnsonSUDistribution +from .kappa_3 import Kappa3Distribution +from .kappa_4 import Kappa4Distribution +from .ks_one import KSOneDistribution +from .ks_two_bi_generalized import KSTwoBinomialGeneralizedDistribution +from .ks_two import KSTwoDistribution +from .laplace_asymmetric import LaplaceAsymmetricDistribution +from .laplace import LaplaceDistribution +from .levy_l import LevyLDistribution +from .levy_stable import LevyStableDistribution +from .levy import LevyDistribution +from .log_gamma import LogGammaDistribution +from .log_laplace import LogLaplaceDistribution +from .log_uniform import LogUniformDistribution +from .logistic import LogisticDistribution +from .lomax import LomaxDistribution +from .maxwell import MaxwellDistribution +from .mielke import MielkeDistribution +from .moyal import MoyalDistribution +from .nakagami import NakagamiDistribution +from .non_central_f_distribution import NonCenteralFDistribution +from .noncentral_chi_squared import NonCenteralChiSquaredDistribution +from .noncentral_t_distribution import NonCenteralTDistribution +from .normal_inverse_gauss import NormalInverseGaussDistribution +from .normal import NormalDistribution +from .pareto import ParetoDistribution +from .pearson_3 import Pearson3Distribution +from .power_log_normal import PowerLogNormalDistribution +from .power_normal import PowerNormalDistribution +from .powerlaw import PowerlawDistribution +from .r_distribution import RDistribution +from .rayleigh import RayleighDistribution +from .reciprocal_inverse_gauss import ReciprocalInverseGaussDistribution +from .rice import RiceDistribution +from .semi_circular import SemiCircularDistribution +from .skewed_cauchy import SkewedCauchyDistribution +from .skewed_normal import SkewedNormalDistribution +from .student_range import StudentRangeDistribution +from .t_distribution import TDistribution +from .trapezoid import TrapezoidDistribution +from .triangular import TriangularDistribution +from .truncated_exponential import TruncatedExponentialDistribution +from .truncated_normal import TruncatedNormalDistribution +from .truncated_pareto import TruncatedParetoDistribution +from .truncated_weibull_minimum import TruncatedWeibullMinimumDistribution +from .tukey_lambda import TukeyLambdaDistribution +from .uniform import UniformDistribution +from .vonmises_line import VonMisesLineDistribution +from .vonmises import VonMisesDistribution +from .wald import WaldDistribution +from .weibull_maximum import WeibullMaxmimumDistribution +from .weibull_minimum import WeibullMinimumDistribution +from .wrapped_cauchy import WrappedCauchyDistribution diff --git a/hyperscale/core/experiments/distributions/types/alpha.py b/hyperscale/core/experiments/distributions/types/alpha.py new file mode 100644 index 0000000..ec84bab --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/alpha.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import alpha as alpha_dist +from .base import BaseDistribution + + +class AlphaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ) -> None: + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=alpha_dist( + alpha, + loc=center, + scale=randomness + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/anglit.py b/hyperscale/core/experiments/distributions/types/anglit.py new file mode 100644 index 0000000..52e38c7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/anglit.py @@ -0,0 +1,23 @@ +from typing import Union +from scipy.stats import anglit +from .base import BaseDistribution + + +class AnglitDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=anglit( + loc=center, + scale=randomness + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/arcsine.py b/hyperscale/core/experiments/distributions/types/arcsine.py new file mode 100644 index 0000000..2118324 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/arcsine.py @@ -0,0 +1,23 @@ +from typing import Union +from scipy.stats import arcsine +from .base import BaseDistribution + + +class ArcsineDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=arcsine( + loc=center, + scale=randomness + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/argus.py b/hyperscale/core/experiments/distributions/types/argus.py new file mode 100644 index 0000000..aaa4eff --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/argus.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import argus +from .base import BaseDistribution + + +class ArgusDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=argus( + 1, + center=center, + randomness=randomness, + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/base.py b/hyperscale/core/experiments/distributions/types/base.py new file mode 100644 index 0000000..83435ac --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/base.py @@ -0,0 +1,131 @@ +import math +import numpy +from typing import Union, List, Tuple, Optional +from numpy.random import normal + +class SciPyDistribution: + + def rvs(self, size: int): + return NotImplemented('Err. - This class is a generalized definition') + + +class BaseDistribution: + + def __init__( + self, + size: Union[int,float]=1000, + center: Union[int,float]=0.5, + randomness: Union[int, float]=0.25, + frozen_distribution: SciPyDistribution=None + ) -> None: + self.size = size + self.center = center + self.randomness = randomness + self._frozen_distribution = frozen_distribution + self._noise_scale = 0.01 + self._lower_bound = 0.1 + self._upper_bound = 0.9 + + def generate_distribution(self, batch_size: int) -> List[float]: + return [ + math.ceil( + step_batch_size + ) for step_batch_size in self._generate_distribution( + scale_factor=batch_size + ) + ] + + def generate_non_scaled_distribution(self): + return self._generate_distribution() + + def _generate_distribution(self, scale_factor: Optional[int]=None): + distribution_size, step_size = self._get_distribution_and_step_size() + + distribution_sample = self._frozen_distribution.rvs( + distribution_size + ) * normal( + scale=self._noise_scale, + size=distribution_size + ) + + generated_walk = self._generate_random_walk(distribution_sample) + + smoothed_walk = self._smooth_generated_walk( + generated_walk, + step_size + ) + + if scale_factor: + smoothed_walk *= scale_factor + + return self._apply_averaging( + smoothed_walk, + step_size + ) + + + def _get_distribution_and_step_size(self) -> Tuple[int, int]: + if self.size <= 10: + distribution_size = self.size**3 + step_size = self.size**2 + + elif self.size < 100: + distribution_size = self.size**2 + step_size = self.size + + elif self.size < 1000: + distribution_size = self.size * int(math.sqrt(self.size)) + step_size = int(math.sqrt(self.size)) + + else: + distribution_size = self.size * (1 + int( + math.sqrt(self.size)/self.size + )) + + step_size = (1 + int( + math.sqrt(self.size)/self.size + )) + + return distribution_size, step_size + + def _generate_random_walk(self, distribution_sample: List[float]) -> List[float]: + current_step_value = 0.5 + result = [] + for sample_value in distribution_sample: + + if current_step_value < self._lower_bound: + current_step_value = self._lower_bound + + elif current_step_value > self._upper_bound: + current_step_value = self._upper_bound + + result.append(current_step_value) + current_step_value += sample_value + + return numpy.array(result) + + def _smooth_generated_walk( + self, + generated_walk: List[float], + smoothing_window_size: int + ) -> List[float]: + return numpy.convolve( + generated_walk, + numpy.ones( + (smoothing_window_size,) + )/smoothing_window_size + )[(smoothing_window_size-1):] + + def _apply_averaging( + self, + scaled_walk: List[float], + step_size: int + ) -> List[float]: + averaged_data = [] + + for idx in range(0, len(scaled_walk), step_size): + averaged_data.append( + numpy.mean(scaled_walk[idx:idx + self.size]) + ) + + return averaged_data \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/beta.py b/hyperscale/core/experiments/distributions/types/beta.py new file mode 100644 index 0000000..770ff01 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/beta.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import beta +from .base import BaseDistribution + + +class BetaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha_value: int=5, + beta_value: int=6, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=beta( + alpha_value, + beta_value, + loc=center, + scale=randomness + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/beta_prime.py b/hyperscale/core/experiments/distributions/types/beta_prime.py new file mode 100644 index 0000000..8e27d26 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/beta_prime.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import betaprime +from .base import BaseDistribution + + +class BetaPrimeDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha_value: int=5, + beta_value: int=6, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=betaprime( + alpha_value, + beta_value, + loc=center, + scale=randomness + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/bradford.py b/hyperscale/core/experiments/distributions/types/bradford.py new file mode 100644 index 0000000..cfc548c --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/bradford.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import bradford +from .base import BaseDistribution + + +class BradfordDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + loc=center, + scale=randomness, + frozen_distribution=bradford( + alpha, + loc=center, + scale=randomness + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/burr.py b/hyperscale/core/experiments/distributions/types/burr.py new file mode 100644 index 0000000..dec2c1b --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/burr.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import burr +from .base import BaseDistribution + + +class BurrDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value=2, + d_value=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ) -> None: + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=burr( + c_value, + d_value, + center=center, + randomness=randomness, + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/burr_12.py b/hyperscale/core/experiments/distributions/types/burr_12.py new file mode 100644 index 0000000..de31ee6 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/burr_12.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import burr12 +from .base import BaseDistribution + + +class Burr12Distribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: int=2, + d_value: int=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.4 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozed_distribution=burr12( + c_value, + d_value, + loc=center, + scale=randomness, + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/cauchy.py b/hyperscale/core/experiments/distributions/types/cauchy.py new file mode 100644 index 0000000..b7c5c7c --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/cauchy.py @@ -0,0 +1,23 @@ +from typing import Union +from scipy.stats import cauchy +from .base import BaseDistribution + + +class CauchyDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ) -> None: + + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=cauchy( + loc=center, + scale=randomness + ) + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/distributions/types/chi.py b/hyperscale/core/experiments/distributions/types/chi.py new file mode 100644 index 0000000..5ffa30b --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/chi.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import chi +from .base import BaseDistribution + + +class ChiDistribution(BaseDistribution): + + def __init__( + self, + size: int, + distribution_function: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=chi( + distribution_function, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/chi_squared.py b/hyperscale/core/experiments/distributions/types/chi_squared.py new file mode 100644 index 0000000..b808974 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/chi_squared.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import chi2 +from .base import BaseDistribution + + +class ChiSquaredDistribution(BaseDistribution): + + def __init__( + self, + size: int, + distribution_function: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=chi2( + distribution_function, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/cosine.py b/hyperscale/core/experiments/distributions/types/cosine.py new file mode 100644 index 0000000..25efc7f --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/cosine.py @@ -0,0 +1,23 @@ +from typing import Union +from scipy.stats import cosine +from .base import BaseDistribution + + +class CosineDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=cosine( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/crystal_ball.py b/hyperscale/core/experiments/distributions/types/crystal_ball.py new file mode 100644 index 0000000..465fba7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/crystal_ball.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import crystalball +from .base import BaseDistribution + + +class CrystalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: int=5, + beta: int=6, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=crystalball( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/dgamma.py b/hyperscale/core/experiments/distributions/types/dgamma.py new file mode 100644 index 0000000..1f6a3fa --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/dgamma.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import dgamma +from .base import BaseDistribution + + +class DGammaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=dgamma( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/dweibull.py b/hyperscale/core/experiments/distributions/types/dweibull.py new file mode 100644 index 0000000..96fb325 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/dweibull.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import dweibull +from .base import BaseDistribution + + +class DWeibullDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=dweibull( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/erlang.py b/hyperscale/core/experiments/distributions/types/erlang.py new file mode 100644 index 0000000..afe9c20 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/erlang.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import erlang +from .base import BaseDistribution + + +class ErlangDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=erlang( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/exponential.py b/hyperscale/core/experiments/distributions/types/exponential.py new file mode 100644 index 0000000..b9ef9ba --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/exponential.py @@ -0,0 +1,23 @@ +from typing import Union +from scipy.stats import expon +from .base import BaseDistribution + + +class ExponentialDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=expon( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/exponential_normal.py b/hyperscale/core/experiments/distributions/types/exponential_normal.py new file mode 100644 index 0000000..ae617fc --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/exponential_normal.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import exponnorm +from .base import BaseDistribution + + +class ExponentialNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=exponnorm( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/exponential_power.py b/hyperscale/core/experiments/distributions/types/exponential_power.py new file mode 100644 index 0000000..1e7d0e7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/exponential_power.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import exponpow +from .base import BaseDistribution + + +class ExponentialPowerDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=exponpow( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/f_distribution.py b/hyperscale/core/experiments/distributions/types/f_distribution.py new file mode 100644 index 0000000..ad5f8f4 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/f_distribution.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import f +from .base import BaseDistribution + + +class FDistribution(BaseDistribution): + + def __init__( + self, + size: int, + distribution_function_number: Union[int, float]=10, + distribution_function_density: Union[int, float]=6, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=f( + distribution_function_number, + distribution_function_density, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/fatigue_life.py b/hyperscale/core/experiments/distributions/types/fatigue_life.py new file mode 100644 index 0000000..2637e6f --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/fatigue_life.py @@ -0,0 +1,25 @@ +from typing import Union +from scipy.stats import fatiguelife +from .base import BaseDistribution + + +class FatigueLifeDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=fatiguelife( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/fisk.py b/hyperscale/core/experiments/distributions/types/fisk.py new file mode 100644 index 0000000..4155054 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/fisk.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import fisk +from .base import BaseDistribution + + +class FiskDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=2, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=fisk( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/folded_cauchy.py b/hyperscale/core/experiments/distributions/types/folded_cauchy.py new file mode 100644 index 0000000..0dfcfcb --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/folded_cauchy.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import foldcauchy +from .base import BaseDistribution + + +class FoldedCauchyDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=2, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=foldcauchy( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/folded_normal.py b/hyperscale/core/experiments/distributions/types/folded_normal.py new file mode 100644 index 0000000..4933e9a --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/folded_normal.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import foldnorm +from .base import BaseDistribution + + +class FoldedNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=foldnorm( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/gamma.py b/hyperscale/core/experiments/distributions/types/gamma.py new file mode 100644 index 0000000..7677a4f --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/gamma.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import gamma +from .base import BaseDistribution + + +class GammaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=2, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gamma( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/gauss_hypergeometric.py b/hyperscale/core/experiments/distributions/types/gauss_hypergeometric.py new file mode 100644 index 0000000..1b7c5f2 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/gauss_hypergeometric.py @@ -0,0 +1,31 @@ +import random +from typing import Union +from scipy.stats import gausshyper +from .base import BaseDistribution + + +class GaussHypergeometricDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=random.uniform(0, 1), + b_value: float=random.uniform(0, 1), + c_value: float=random.uniform(0, 1), + z_value: float=random.uniform(0, 1), + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gausshyper( + a_value, + b_value, + c_value, + z_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_exponential.py b/hyperscale/core/experiments/distributions/types/generalized_exponential.py new file mode 100644 index 0000000..d56510d --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_exponential.py @@ -0,0 +1,29 @@ +import random +from typing import Union +from scipy.stats import genexpon +from .base import BaseDistribution + + +class GeneralizedExponentialDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + b_value: float=random.uniform(0, 1), + c_value: float=random.uniform(0, 1), + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=genexpon( + a_value, + b_value, + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_extreme.py b/hyperscale/core/experiments/distributions/types/generalized_extreme.py new file mode 100644 index 0000000..81a4716 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_extreme.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import genextreme +from .base import BaseDistribution + + +class GeneralizedExtremeDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=genextreme( + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_gamma.py b/hyperscale/core/experiments/distributions/types/generalized_gamma.py new file mode 100644 index 0000000..64beaa4 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_gamma.py @@ -0,0 +1,27 @@ +import random +from typing import Union +from scipy.stats import gengamma +from .base import BaseDistribution + + +class GeneralizedGammaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + c_value: float=random.uniform(0, 1), + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gengamma( + a_value, + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_half_logistic.py b/hyperscale/core/experiments/distributions/types/generalized_half_logistic.py new file mode 100644 index 0000000..8c765bb --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_half_logistic.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import genhalflogistic +from .base import BaseDistribution + + +class GeneralizedHalfLogisticDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=genhalflogistic( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_hyperbolic.py b/hyperscale/core/experiments/distributions/types/generalized_hyperbolic.py new file mode 100644 index 0000000..a1fcd31 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_hyperbolic.py @@ -0,0 +1,29 @@ +import random +from typing import Union +from scipy.stats import genhyperbolic +from .base import BaseDistribution + + +class GeneralizedHyperbolicDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + b_value: float=random.uniform(1, 10), + c_value: float=random.uniform(-1, -0.1), + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=genhyperbolic( + a_value, + b_value, + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_inverse_gauss.py b/hyperscale/core/experiments/distributions/types/generalized_inverse_gauss.py new file mode 100644 index 0000000..7eef07c --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_inverse_gauss.py @@ -0,0 +1,27 @@ +import random +from typing import Union +from scipy.stats import gibrat +from .base import BaseDistribution + + +class GeneralizedInverseGaussDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + b_value: Union[int, float]=random.uniform(0, 1), + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gibrat( + a_value, + b_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_logistic.py b/hyperscale/core/experiments/distributions/types/generalized_logistic.py new file mode 100644 index 0000000..4ad576d --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_logistic.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import genlogistic +from .base import BaseDistribution + + +class GeneralizedLogisticDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=genlogistic( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_normal.py b/hyperscale/core/experiments/distributions/types/generalized_normal.py new file mode 100644 index 0000000..c1b8a5c --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_normal.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import gennorm +from .base import BaseDistribution + + +class GeneralizedNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.5 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gennorm( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/generalized_pareto.py b/hyperscale/core/experiments/distributions/types/generalized_pareto.py new file mode 100644 index 0000000..913f1f9 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/generalized_pareto.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import genpareto +from .base import BaseDistribution + + +class GeneralizedParetoDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=genpareto( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/gibrat.py b/hyperscale/core/experiments/distributions/types/gibrat.py new file mode 100644 index 0000000..0c4d404 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/gibrat.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import gibrat +from .base import BaseDistribution + + +class GibratDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gibrat( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/gompertz.py b/hyperscale/core/experiments/distributions/types/gompertz.py new file mode 100644 index 0000000..a05908a --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/gompertz.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import gompertz +from .base import BaseDistribution + + +class GompertzDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gompertz( + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/gumbel_l.py b/hyperscale/core/experiments/distributions/types/gumbel_l.py new file mode 100644 index 0000000..aa8def4 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/gumbel_l.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import gumbel_l +from .base import BaseDistribution + + +class GumbelLDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gumbel_l( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/gumbel_r.py b/hyperscale/core/experiments/distributions/types/gumbel_r.py new file mode 100644 index 0000000..17498e1 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/gumbel_r.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import gumbel_r +from .base import BaseDistribution + + +class GumbelRDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=gumbel_r( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/half_cauchy.py b/hyperscale/core/experiments/distributions/types/half_cauchy.py new file mode 100644 index 0000000..9181d07 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/half_cauchy.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import halfcauchy +from .base import BaseDistribution + + +class HalfCauchyDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=halfcauchy( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/half_generalized_normal.py b/hyperscale/core/experiments/distributions/types/half_generalized_normal.py new file mode 100644 index 0000000..c2f2e91 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/half_generalized_normal.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import halfgennorm +from .base import BaseDistribution + + +class HalfGeneralizedNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + beta: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=halfgennorm( + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/half_logistic.py b/hyperscale/core/experiments/distributions/types/half_logistic.py new file mode 100644 index 0000000..2395d0f --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/half_logistic.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import halflogistic +from .base import BaseDistribution + + +class HalfLogisticDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=halflogistic( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/half_normal.py b/hyperscale/core/experiments/distributions/types/half_normal.py new file mode 100644 index 0000000..483e1db --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/half_normal.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import halfnorm +from .base import BaseDistribution + + +class HalfNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=halfnorm( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/hyperbolic_secant.py b/hyperscale/core/experiments/distributions/types/hyperbolic_secant.py new file mode 100644 index 0000000..fab1994 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/hyperbolic_secant.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import hypsecant +from .base import BaseDistribution + + +class HyperbolicSecantDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=hypsecant( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/inverse_gamma.py b/hyperscale/core/experiments/distributions/types/inverse_gamma.py new file mode 100644 index 0000000..7403bb9 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/inverse_gamma.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import invgamma +from .base import BaseDistribution + + +class InverseGammaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=invgamma( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/inverse_weibull.py b/hyperscale/core/experiments/distributions/types/inverse_weibull.py new file mode 100644 index 0000000..c208ca1 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/inverse_weibull.py @@ -0,0 +1,25 @@ +from typing import Union +from scipy.stats import invweibull +from .base import BaseDistribution + + +class InverseWeibullDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.5, + beta: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=invweibull( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/johnson_sb.py b/hyperscale/core/experiments/distributions/types/johnson_sb.py new file mode 100644 index 0000000..fa1f6fb --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/johnson_sb.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import johnsonsb +from .base import BaseDistribution + + +class JohnsonSBDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.5, + beta: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=johnsonsb( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/johnson_su.py b/hyperscale/core/experiments/distributions/types/johnson_su.py new file mode 100644 index 0000000..0c34f48 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/johnson_su.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import johnsonsu +from .base import BaseDistribution + + +class JohnsonSUDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.5, + beta: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=johnsonsu( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/kappa_3.py b/hyperscale/core/experiments/distributions/types/kappa_3.py new file mode 100644 index 0000000..d3d21ce --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/kappa_3.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import kappa3 +from .base import BaseDistribution + + +class Kappa3Distribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=kappa3( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/kappa_4.py b/hyperscale/core/experiments/distributions/types/kappa_4.py new file mode 100644 index 0000000..e6f61f3 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/kappa_4.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import kappa4 +from .base import BaseDistribution + + +class Kappa4Distribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=kappa4( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/ks_one.py b/hyperscale/core/experiments/distributions/types/ks_one.py new file mode 100644 index 0000000..9c8e2a6 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/ks_one.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import ksone +from .base import BaseDistribution + + +class KSOneDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=ksone( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/ks_two.py b/hyperscale/core/experiments/distributions/types/ks_two.py new file mode 100644 index 0000000..4266698 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/ks_two.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import kstwo +from .base import BaseDistribution + + +class KSTwoDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=kstwo( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/ks_two_bi_generalized.py b/hyperscale/core/experiments/distributions/types/ks_two_bi_generalized.py new file mode 100644 index 0000000..8e2e35e --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/ks_two_bi_generalized.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import kstwobign +from .base import BaseDistribution + + +class KSTwoBinomialGeneralizedDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=kstwobign( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/laplace.py b/hyperscale/core/experiments/distributions/types/laplace.py new file mode 100644 index 0000000..f24e968 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/laplace.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import laplace +from .base import BaseDistribution + + +class LaplaceDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=laplace( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/laplace_asymmetric.py b/hyperscale/core/experiments/distributions/types/laplace_asymmetric.py new file mode 100644 index 0000000..ba50e16 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/laplace_asymmetric.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import laplace_asymmetric +from .base import BaseDistribution + + +class LaplaceAsymmetricDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=laplace_asymmetric( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/levy.py b/hyperscale/core/experiments/distributions/types/levy.py new file mode 100644 index 0000000..0bb0f27 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/levy.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import levy +from .base import BaseDistribution + + +class LevyDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=levy( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/levy_l.py b/hyperscale/core/experiments/distributions/types/levy_l.py new file mode 100644 index 0000000..23b96cc --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/levy_l.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import levy_l +from .base import BaseDistribution + + +class LevyLDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=levy_l( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/levy_stable.py b/hyperscale/core/experiments/distributions/types/levy_stable.py new file mode 100644 index 0000000..1bbe17e --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/levy_stable.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import levy_stable +from .base import BaseDistribution + + +class LevyStableDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int,float]=1, + beta: Union[int,float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=levy_stable( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/log_gamma.py b/hyperscale/core/experiments/distributions/types/log_gamma.py new file mode 100644 index 0000000..dee0c5f --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/log_gamma.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import loggamma +from .base import BaseDistribution + + +class LogGammaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: Union[int, float]=0.1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=loggamma( + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/log_laplace.py b/hyperscale/core/experiments/distributions/types/log_laplace.py new file mode 100644 index 0000000..6dc20d6 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/log_laplace.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import loglaplace +from .base import BaseDistribution + + +class LogLaplaceDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=loglaplace( + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/log_uniform.py b/hyperscale/core/experiments/distributions/types/log_uniform.py new file mode 100644 index 0000000..66edb57 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/log_uniform.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import loguniform +from .base import BaseDistribution + + +class LogUniformDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.1, + beta: Union[int, float]=0.25, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=loguniform( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/logistic.py b/hyperscale/core/experiments/distributions/types/logistic.py new file mode 100644 index 0000000..1e2b3d7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/logistic.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import logistic +from .base import BaseDistribution + + +class LogisticDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=logistic( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/lomax.py b/hyperscale/core/experiments/distributions/types/lomax.py new file mode 100644 index 0000000..81f7d29 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/lomax.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import lomax +from .base import BaseDistribution + + +class LomaxDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=lomax( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/maxwell.py b/hyperscale/core/experiments/distributions/types/maxwell.py new file mode 100644 index 0000000..b625ecc --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/maxwell.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import maxwell +from .base import BaseDistribution + + +class MaxwellDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=maxwell( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/mielke.py b/hyperscale/core/experiments/distributions/types/mielke.py new file mode 100644 index 0000000..2bb0ee3 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/mielke.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import mielke +from .base import BaseDistribution + + +class MielkeDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=0.5, + beta: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=mielke( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/moyal.py b/hyperscale/core/experiments/distributions/types/moyal.py new file mode 100644 index 0000000..a71dca1 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/moyal.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import moyal +from .base import BaseDistribution + + +class MoyalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=moyal( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/nakagami.py b/hyperscale/core/experiments/distributions/types/nakagami.py new file mode 100644 index 0000000..c8080b7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/nakagami.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import nakagami +from .base import BaseDistribution + + +class NakagamiDistribution(BaseDistribution): + + def __init__( + self, + size: int, + mu: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=nakagami( + mu, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/non_central_f_distribution.py b/hyperscale/core/experiments/distributions/types/non_central_f_distribution.py new file mode 100644 index 0000000..28281b1 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/non_central_f_distribution.py @@ -0,0 +1,29 @@ +import random +from typing import Union +from scipy.stats import ncf +from .base import BaseDistribution + + +class NonCenteralFDistribution(BaseDistribution): + + def __init__( + self, + size: int, + distribution_function_number: Union[int, float]=0.5, + distribution_function_density: Union[int, float]=random.uniform(0, 10), + nc_value: Union[int,float]=0.1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=ncf( + distribution_function_number, + distribution_function_density, + nc_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/noncentral_chi_squared.py b/hyperscale/core/experiments/distributions/types/noncentral_chi_squared.py new file mode 100644 index 0000000..2588959 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/noncentral_chi_squared.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import ncx2 +from .base import BaseDistribution + + +class NonCenteralChiSquaredDistribution(BaseDistribution): + + def __init__( + self, + size: int, + distribution_function: Union[int, float]=1, + nc_value: Union[int,float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=ncx2( + distribution_function, + nc_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/noncentral_t_distribution.py b/hyperscale/core/experiments/distributions/types/noncentral_t_distribution.py new file mode 100644 index 0000000..cf4a166 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/noncentral_t_distribution.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import nct +from .base import BaseDistribution + + +class NonCenteralTDistribution(BaseDistribution): + + def __init__( + self, + size: int, + distribution_function: Union[int, float]=1, + nc_value: Union[int,float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=nct( + distribution_function, + nc_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/normal.py b/hyperscale/core/experiments/distributions/types/normal.py new file mode 100644 index 0000000..4eb874c --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/normal.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import norm +from .base import BaseDistribution + + +class NormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=norm( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/normal_inverse_gauss.py b/hyperscale/core/experiments/distributions/types/normal_inverse_gauss.py new file mode 100644 index 0000000..c02d81a --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/normal_inverse_gauss.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import norminvgauss +from .base import BaseDistribution + + +class NormalInverseGaussDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: Union[int, float]=1, + beta: Union[int,float]=0.1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=norminvgauss( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/pareto.py b/hyperscale/core/experiments/distributions/types/pareto.py new file mode 100644 index 0000000..3f1b82a --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/pareto.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import pareto +from .base import BaseDistribution + + +class ParetoDistribution(BaseDistribution): + + def __init__( + self, + size: int, + b_value: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=pareto( + b_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/pearson_3.py b/hyperscale/core/experiments/distributions/types/pearson_3.py new file mode 100644 index 0000000..7ec6691 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/pearson_3.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import pearson3 +from .base import BaseDistribution + + +class Pearson3Distribution(BaseDistribution): + + def __init__( + self, + size: int, + kappa: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=pearson3( + kappa, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/power_log_normal.py b/hyperscale/core/experiments/distributions/types/power_log_normal.py new file mode 100644 index 0000000..d1f4e54 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/power_log_normal.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import powerlognorm +from .base import BaseDistribution + + +class PowerLogNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: Union[int, float]=0.5, + s_value: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=powerlognorm( + c_value, + s_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/power_normal.py b/hyperscale/core/experiments/distributions/types/power_normal.py new file mode 100644 index 0000000..7f09c29 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/power_normal.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import powernorm +from .base import BaseDistribution + + +class PowerNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: Union[int, float]=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=powernorm( + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/powerlaw.py b/hyperscale/core/experiments/distributions/types/powerlaw.py new file mode 100644 index 0000000..5a3f3f5 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/powerlaw.py @@ -0,0 +1,25 @@ +import random +from typing import Union +from scipy.stats import powerlaw +from .base import BaseDistribution + + +class PowerlawDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: Union[int, float]=random.randint(1, 10), + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=powerlaw( + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/r_distribution.py b/hyperscale/core/experiments/distributions/types/r_distribution.py new file mode 100644 index 0000000..c2644b1 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/r_distribution.py @@ -0,0 +1,25 @@ +import random +from typing import Union +from scipy.stats import rdist +from .base import BaseDistribution + + +class RDistribution(BaseDistribution): + + def __init__( + self, + size: int, + c_value: Union[int, float]=random.randint(1, 10), + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=rdist( + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/rayleigh.py b/hyperscale/core/experiments/distributions/types/rayleigh.py new file mode 100644 index 0000000..4ea4eb1 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/rayleigh.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import rayleigh +from .base import BaseDistribution + + +class RayleighDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=rayleigh( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/reciprocal_inverse_gauss.py b/hyperscale/core/experiments/distributions/types/reciprocal_inverse_gauss.py new file mode 100644 index 0000000..fc9f2d3 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/reciprocal_inverse_gauss.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import recipinvgauss +from .base import BaseDistribution + + +class ReciprocalInverseGaussDistribution(BaseDistribution): + + def __init__( + self, + size: int, + mu: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=recipinvgauss( + mu, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/rice.py b/hyperscale/core/experiments/distributions/types/rice.py new file mode 100644 index 0000000..03de492 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/rice.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import rice +from .base import BaseDistribution + + +class RiceDistribution(BaseDistribution): + + def __init__( + self, + size: int, + mu: Union[int, float]=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=rice( + mu, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/semi_circular.py b/hyperscale/core/experiments/distributions/types/semi_circular.py new file mode 100644 index 0000000..9949356 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/semi_circular.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import semicircular +from .base import BaseDistribution + + +class SemiCircularDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=semicircular( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/skewed_cauchy.py b/hyperscale/core/experiments/distributions/types/skewed_cauchy.py new file mode 100644 index 0000000..939fcef --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/skewed_cauchy.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import skewcauchy +from .base import BaseDistribution + + +class SkewedCauchyDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=skewcauchy( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/skewed_normal.py b/hyperscale/core/experiments/distributions/types/skewed_normal.py new file mode 100644 index 0000000..c0aaed7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/skewed_normal.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import skewnorm +from .base import BaseDistribution + + +class SkewedNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=skewnorm( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/student_range.py b/hyperscale/core/experiments/distributions/types/student_range.py new file mode 100644 index 0000000..8655b56 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/student_range.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import studentized_range +from .base import BaseDistribution + + +class StudentRangeDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=3, + beta: float=10, + center: Union[int, float]=0.1, + randomness: Union[int, float]=0.2 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=studentized_range( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/t_distribution.py b/hyperscale/core/experiments/distributions/types/t_distribution.py new file mode 100644 index 0000000..f31d0c7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/t_distribution.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import t +from .base import BaseDistribution + + +class TDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=t( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/trapezoid.py b/hyperscale/core/experiments/distributions/types/trapezoid.py new file mode 100644 index 0000000..2f988b9 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/trapezoid.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import trapezoid +from .base import BaseDistribution + + +class TrapezoidDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.25, + beta: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=trapezoid( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/triangular.py b/hyperscale/core/experiments/distributions/types/triangular.py new file mode 100644 index 0000000..3581a91 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/triangular.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import triang +from .base import BaseDistribution + + +class TriangularDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.25, + beta: float=0.5, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=triang( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/truncated_exponential.py b/hyperscale/core/experiments/distributions/types/truncated_exponential.py new file mode 100644 index 0000000..6e0c2e2 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/truncated_exponential.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import truncexpon +from .base import BaseDistribution + + +class TruncatedExponentialDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=1, + center: Union[int, float]=1, + randomness: Union[int, float]=1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=truncexpon( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/truncated_normal.py b/hyperscale/core/experiments/distributions/types/truncated_normal.py new file mode 100644 index 0000000..e4b0e01 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/truncated_normal.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import truncnorm +from .base import BaseDistribution + + +class TruncatedNormalDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=1, + center: Union[int, float]=1, + randomness: Union[int, float]=1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=truncnorm( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/truncated_pareto.py b/hyperscale/core/experiments/distributions/types/truncated_pareto.py new file mode 100644 index 0000000..a61e631 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/truncated_pareto.py @@ -0,0 +1,26 @@ +from typing import Union +from scipy.stats import truncpareto +from .base import BaseDistribution + + +class TruncatedParetoDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=1., + beta: float=10., + center: Union[int, float]=0.5, + randomness: Union[int, float]=1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=truncpareto( + alpha, + beta, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/truncated_weibull_minimum.py b/hyperscale/core/experiments/distributions/types/truncated_weibull_minimum.py new file mode 100644 index 0000000..833193d --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/truncated_weibull_minimum.py @@ -0,0 +1,28 @@ +from typing import Union +from scipy.stats import truncweibull_min +from .base import BaseDistribution + + +class TruncatedWeibullMinimumDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=2, + b_value: float=0.5, + c_value: float=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=truncweibull_min( + a_value, + b_value, + c_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/tukey_lambda.py b/hyperscale/core/experiments/distributions/types/tukey_lambda.py new file mode 100644 index 0000000..7f82462 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/tukey_lambda.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import tukeylambda +from .base import BaseDistribution + + +class TukeyLambdaDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.5 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=tukeylambda( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/uniform.py b/hyperscale/core/experiments/distributions/types/uniform.py new file mode 100644 index 0000000..93c86a3 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/uniform.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import uniform +from .base import BaseDistribution + + +class UniformDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=uniform( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/vonmises.py b/hyperscale/core/experiments/distributions/types/vonmises.py new file mode 100644 index 0000000..5be609d --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/vonmises.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import vonmises +from .base import BaseDistribution + + +class VonMisesDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=vonmises( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/vonmises_line.py b/hyperscale/core/experiments/distributions/types/vonmises_line.py new file mode 100644 index 0000000..b76531f --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/vonmises_line.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import vonmises_line +from .base import BaseDistribution + + +class VonMisesLineDistribution(BaseDistribution): + + def __init__( + self, + size: int, + alpha: float=0.1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.25 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=vonmises_line( + alpha, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/wald.py b/hyperscale/core/experiments/distributions/types/wald.py new file mode 100644 index 0000000..fd70954 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/wald.py @@ -0,0 +1,22 @@ +from typing import Union +from scipy.stats import wald +from .base import BaseDistribution + + +class WaldDistribution(BaseDistribution): + + def __init__( + self, + size: int, + center: Union[int, float]=0.1, + randomness: Union[int, float]=1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=wald( + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/weibull_maximum.py b/hyperscale/core/experiments/distributions/types/weibull_maximum.py new file mode 100644 index 0000000..4b0d9f2 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/weibull_maximum.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import weibull_max +from .base import BaseDistribution + + +class WeibullMaxmimumDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.5, + center: Union[int, float]=0.1, + randomness: Union[int, float]=1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=weibull_max( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/weibull_minimum.py b/hyperscale/core/experiments/distributions/types/weibull_minimum.py new file mode 100644 index 0000000..7b90e39 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/weibull_minimum.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import weibull_min +from .base import BaseDistribution + + +class WeibullMinimumDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.25, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=weibull_min( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/distributions/types/wrapped_cauchy.py b/hyperscale/core/experiments/distributions/types/wrapped_cauchy.py new file mode 100644 index 0000000..e2216c7 --- /dev/null +++ b/hyperscale/core/experiments/distributions/types/wrapped_cauchy.py @@ -0,0 +1,24 @@ +from typing import Union +from scipy.stats import wrapcauchy +from .base import BaseDistribution + + +class WrappedCauchyDistribution(BaseDistribution): + + def __init__( + self, + size: int, + a_value: float=0.1, + center: Union[int, float]=0.5, + randomness: Union[int, float]=0.1 + ): + super().__init__( + size=size, + center=center, + randomness=randomness, + frozen_distribution=wrapcauchy( + a_value, + loc=center, + scale=randomness + ) + ) diff --git a/hyperscale/core/experiments/experiment.py b/hyperscale/core/experiments/experiment.py new file mode 100644 index 0000000..02f0e47 --- /dev/null +++ b/hyperscale/core/experiments/experiment.py @@ -0,0 +1,95 @@ +import math +import random +from typing import Dict, List, Union + +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .variant import Variant + + +@unstable +class Experiment: + + def __init__( + self, + experiment_name: str, + participants: List[Variant], + random: bool=True + ) -> None: + self.experiment_name = experiment_name + self.participants: Dict[str, Variant] = { + participant.stage_name: participant for participant in participants + } + self.source_batch_size: int = 0 + self.random: bool = random + self.participants_count = len(participants) + + def __iter__(self): + for participant in self.participants.values(): + yield participant + + def assign_weights(self): + + total_weight = 1.0 + missing_weight_participants = [] + + for participant_idx, participant in enumerate(self.participants.values()): + + if participant.weight: + total_weight -= participant.weight + + elif self.random: + weight = round(random.uniform(0.1, 0.9), 2) + + if participant_idx < self.participants_count - 1: + participant.weight = weight + total_weight -= weight + + else: + participant.weight = round(total_weight, 2) + + else: + missing_weight_participants.append(participant.stage_name) + + self.participants[participant.stage_name] = participant + + per_participant_weight = 0 + if len(missing_weight_participants) > 0: + per_participant_weight = total_weight/len(missing_weight_participants) + + for missing_weight_participant in missing_weight_participants: + self.participants[missing_weight_participant].weight = per_participant_weight + + + def is_variant(self, stage_name: str) -> bool: + variant = self.participants.get(stage_name) + return variant is not None + + def get_variant_batch_size(self, stage_name: str) -> int: + variant = self.participants.get(stage_name) + + if variant is None: + raise Exception( + f'Err. - requested variant {stage_name} is not a participant in experiment {self.experiment_name}.' + ) + + return math.ceil(variant.weight * self.source_batch_size) + + def get_variant(self, stage_name: str) -> Union[Variant, None]: + if self.is_variant(stage_name): + variant = self.participants.get(stage_name) + + return variant + + + def calculate_distribution( + self, + stage_name: str, + batch_size: int + ) -> Union[List[float], None]: + variant = self.get_variant(stage_name) + + if variant.distribution: + return variant.distribution.generate(batch_size) + + \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/__init__.py b/hyperscale/core/experiments/mutations/__init__.py new file mode 100644 index 0000000..ef60d03 --- /dev/null +++ b/hyperscale/core/experiments/mutations/__init__.py @@ -0,0 +1,7 @@ +from .types import ( + DeformHeader, + InjectHeader, + InjectJunkData, + InjectPing, + SmuggleRequest +) \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/__init__.py b/hyperscale/core/experiments/mutations/types/__init__.py new file mode 100644 index 0000000..aff2add --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/__init__.py @@ -0,0 +1,5 @@ +from .deform_header import DeformHeader +from .inject_header import InjectHeader +from .inject_junk_data import InjectJunkData +from .inject_ping import InjectPing +from .smuggle_request import SmuggleRequest \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/base/__init__.py b/hyperscale/core/experiments/mutations/types/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/experiments/mutations/types/base/mutation.py b/hyperscale/core/experiments/mutations/types/base/mutation.py new file mode 100644 index 0000000..e58eb77 --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/base/mutation.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace +from typing import Any, Tuple + +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.versioning.flags.types.unstable.flag import unstable_threadsafe + +from .mutation_type import MutationType +from .validator import MutationValidator + + +class Mutation: + + def __init__( + self, + name: str, + chance: float, + mutation_type: MutationType, + *targets: Tuple[str], + ) -> None: + validated_mutation = MutationValidator( + name=name, + chance=chance, + targets=targets, + mutation_type=mutation_type + ) + + self.name = validated_mutation.name + self.chance = validated_mutation.chance + self.targets = list(validated_mutation.targets) + self.stage: Any = SimpleNamespace( + context=SimpleContext() + ) + + self.mutation_type = validated_mutation.mutation_type + + unstable_threadsafe(Mutation) + + async def mutate(self, action: BaseAction=None): + raise NotImplementedError( + 'Err. - mutate() is an abstract method in the base Mutation class.' + ) + + def copy(self): + raise NotImplementedError( + 'Err. - copy() is an abstract method in the base Mutation class.' + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/base/mutation_type.py b/hyperscale/core/experiments/mutations/types/base/mutation_type.py new file mode 100644 index 0000000..5703e3d --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/base/mutation_type.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class MutationType(Enum): + DEFORM_HEADER='DEFORM_HEADER' + INJECT_HEADER='INJECT_HEADER' + INJECT_JUNK_DATA='INJECT_JUNK_DATA' + INJECT_PING='INJECT_PING' + SMUGGLE_REQUEST='SMUGGLE_REQUEST' \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/base/validator.py b/hyperscale/core/experiments/mutations/types/base/validator.py new file mode 100644 index 0000000..3b7a3ed --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/base/validator.py @@ -0,0 +1,23 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictStr, + validator +) +from typing import Tuple +from .mutation_type import MutationType + + +class MutationValidator(BaseModel): + name: StrictStr + chance: StrictFloat + targets: Tuple[StrictStr, ...] + mutation_type: MutationType + + class Config: + arbitrary_types_allowed=True + + @validator('targets') + def validate_targets(cls, val): + assert len(val) > 0, "Mutations must target more than one Action or Task hook." + return val \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/deform_header/__init__.py b/hyperscale/core/experiments/mutations/types/deform_header/__init__.py new file mode 100644 index 0000000..9398afc --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/deform_header/__init__.py @@ -0,0 +1 @@ +from .mutation import DeformHeader \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/deform_header/mutation.py b/hyperscale/core/experiments/mutations/types/deform_header/mutation.py new file mode 100644 index 0000000..c771126 --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/deform_header/mutation.py @@ -0,0 +1,109 @@ + +import random +import string +from typing import List, Optional, Tuple, Union + +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation +from hyperscale.core.experiments.mutations.types.base.mutation_type import MutationType + +from .validator import DeformHeaderValidator + +Request = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action +] + + +class DeformHeader(Mutation): + + def __init__( + self, + name: str, + chance: float, + *targets: Tuple[str, ...], + header_name: str=None, + header_value: Optional[str]=None, + deformation_length: int=5, + character_pool: Optional[List[str]]=[] + ) -> None: + super().__init__( + name, + chance, + MutationType.DEFORM_HEADER, + *targets + ) + + validated_mutation = DeformHeaderValidator( + header_name=header_name, + deformation_length=deformation_length, + character_pool=character_pool, + header_value=header_value + ) + + self.header_name = validated_mutation.header_name + self.header_value = validated_mutation.header_value + self.deformation_length = validated_mutation.deformation_length + + if validated_mutation.character_pool: + self.character_pool = ''.join(validated_mutation.character_pool) + + else: + self.character_pool = ''.join([ + string.ascii_letters, + string.digits, + string.hexdigits, + string.octdigits, + string.punctuation, + string.whitespace + ]) + + self.header_mutation = ''.join( + random.choices( + self.character_pool, + k=self.deformation_length + ) + ) + + async def mutate(self, action: Request=None) -> Request: + + chance_roll = random.uniform(0, 1) + if chance_roll <= self.chance: + return action + + + mutated_header_name = f'{self.header_name} {self.header_mutation}' + + header_value = action.headers.get(self.header_name) + + if self.header_value: + header_value = self.header_value + + del action._headers[self.header_name] + + action._headers[mutated_header_name] = header_value + action._header_items = list(action._headers.items()) + + action._setup_headers() + + return action + + def copy(self): + return DeformHeader( + self.name, + self.chance, + *list(self.targets), + header_name=self.header_name, + header_value=self.header_name, + deformation_length=self.deformation_length, + character_pool=self.character_pool + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/deform_header/validator.py b/hyperscale/core/experiments/mutations/types/deform_header/validator.py new file mode 100644 index 0000000..ed2c280 --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/deform_header/validator.py @@ -0,0 +1,13 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt +) +from typing import Optional, List + + +class DeformHeaderValidator(BaseModel): + header_name: StrictStr + deformation_length: StrictInt + character_pool: Optional[List[StrictStr]] + header_value: Optional[StrictStr] \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_header/__init__.py b/hyperscale/core/experiments/mutations/types/inject_header/__init__.py new file mode 100644 index 0000000..7807b8c --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_header/__init__.py @@ -0,0 +1 @@ +from .mutation import InjectHeader \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_header/mutation.py b/hyperscale/core/experiments/mutations/types/inject_header/mutation.py new file mode 100644 index 0000000..6e05671 --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_header/mutation.py @@ -0,0 +1,79 @@ +import random +from typing import List, Tuple, Union + +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation +from hyperscale.core.experiments.mutations.types.base.mutation_type import MutationType + +from .validator import InjectHeaderValidator + +Request = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action +] + + +class InjectHeader(Mutation): + + def __init__( + self, + name: str, + chance: float, + *targets: Tuple[str, ...], + header_name: str=None, + header_value: Union[str, bytes, bytearray]=None + ) -> None: + super().__init__( + name, + chance, + MutationType.INJECT_HEADER, + *targets + ) + + validated_mutation = InjectHeaderValidator( + header_name=header_name, + header_value=header_value + ) + + self.header_name = validated_mutation.header_name + self.header_value = validated_mutation.header_value + self.original_headers: Union[str, List[Tuple[str, str]]] + + async def mutate(self, action: Request=None) -> Request: + chance_roll = random.uniform(0, 1) + if chance_roll <= self.chance: + return action + + original_headers = list(action._header_items) + + action._header_items: List[Tuple[str, str]] = original_headers + mutation_header = ( + self.header_name, + self.header_value + ) + + if mutation_header not in action._header_items: + action._header_items.append(mutation_header) + action._setup_headers() + + action._headers[self.header_name] = self.header_value + + return action + + def copy(self): + return InjectHeader( + self.name, + self.chance, + *list(self.targets), + header_name=self.header_name, + header_value=self.header_value + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_header/validator.py b/hyperscale/core/experiments/mutations/types/inject_header/validator.py new file mode 100644 index 0000000..df8025b --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_header/validator.py @@ -0,0 +1,14 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictBytes +) +from typing import Union + + +class InjectHeaderValidator(BaseModel): + header_name: StrictStr + header_value: Union[StrictStr, StrictBytes, bytearray] + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_junk_data/__init__.py b/hyperscale/core/experiments/mutations/types/inject_junk_data/__init__.py new file mode 100644 index 0000000..6a11ef8 --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_junk_data/__init__.py @@ -0,0 +1 @@ +from .mutation import InjectJunkData \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_junk_data/mutation.py b/hyperscale/core/experiments/mutations/types/inject_junk_data/mutation.py new file mode 100644 index 0000000..16f2331 --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_junk_data/mutation.py @@ -0,0 +1,93 @@ +import random +import string +from typing import Optional, Tuple, Union + +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation +from hyperscale.core.experiments.mutations.types.base.mutation_type import MutationType + +from .validator import InjectJunkDataValidator + +Request = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action +] + + +class InjectJunkData(Mutation): + + def __init__( + self, + name: str, + chance: float, + *targets: Tuple[str, ...], + junk_size: Optional[int]=None + + ) -> None: + super().__init__( + name, + chance, + MutationType.INJECT_JUNK_DATA, + *targets + ) + + validated_mutation = InjectJunkDataValidator( + junk_size=junk_size + ) + + self.junk_size = validated_mutation.junk_size + + self._character_pool = ''.join([ + string.ascii_letters, + string.digits, + string.hexdigits, + string.octdigits, + string.punctuation, + string.whitespace + ]) + + self.original_data: bytes = None + + junk_data: str = ''.join( + random.choices( + self._character_pool, + k=self.junk_size + ) + ) + + self.junk_data = junk_data.encode() + + async def mutate(self, action: Request=None) -> Request: + + chance_roll = random.uniform(0, 1) + if chance_roll <= self.chance: + return action + + if action.method not in ['POST', 'PUT', 'PATCH']: + return action + + if self.original_data is None: + self.original_data = action.encoded_data + + action.encoded_data = self.original_data + self.junk_data + action._setup_data() + action._setup_headers() + + return action + + def copy(self): + return InjectJunkData( + self.name, + self.chance, + *list(self.targets), + junk_size=self.junk_size + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_junk_data/validator.py b/hyperscale/core/experiments/mutations/types/inject_junk_data/validator.py new file mode 100644 index 0000000..f8af48c --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_junk_data/validator.py @@ -0,0 +1,10 @@ +from pydantic import ( + BaseModel, + StrictInt, + StrictStr +) +from typing import Optional + + +class InjectJunkDataValidator(BaseModel): + junk_size: StrictInt diff --git a/hyperscale/core/experiments/mutations/types/inject_ping/__init__.py b/hyperscale/core/experiments/mutations/types/inject_ping/__init__.py new file mode 100644 index 0000000..40035ef --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_ping/__init__.py @@ -0,0 +1 @@ +from .mutation import InjectPing \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_ping/mutation.py b/hyperscale/core/experiments/mutations/types/inject_ping/mutation.py new file mode 100644 index 0000000..39870ff --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_ping/mutation.py @@ -0,0 +1,83 @@ +import asyncio +import random +from typing import Tuple, Union + +from hyperscale.core.engines.types.common.protocols import PingConnection +from hyperscale.core.engines.types.common.protocols.ping.ping_type import PingTypesMap +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation +from hyperscale.core.experiments.mutations.types.base.mutation_type import MutationType + +from .validator import InjectPingValidator + +Request = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action +] + + +class InjectPing(Mutation): + + def __init__( + self, + name: str, + chance: float, + *targets: Tuple[str, ...], + ping_type: str='icmp', + timeout: int=2 + ) -> None: + super().__init__( + name, + chance, + MutationType.INJECT_PING, + *targets + ) + + validated_mutation = InjectPingValidator( + ping_type=ping_type, + timeout=timeout + ) + + self.ping_connection = PingConnection() + self.types_map = PingTypesMap() + self.ping_type = self.types_map.get(validated_mutation.ping_type) + self.timeout = validated_mutation.timeout + + async def mutate(self, action: Request=None) -> Request: + chance_roll = random.uniform(0, 1) + if chance_roll <= self.chance: + return action + + try: + await asyncio.wait_for( + self.ping_connection.ping( + action.url.socket_config, + ping_type=self.ping_type, + timeout=self.timeout + ), + timeout=self.timeout + ) + except Exception: + pass + + return action + + def copy(self): + return InjectPing( + self.name, + self.chance, + *list(self.targets), + ping_type=self.types_map.get_name( + self.ping_type + ), + timeout=self.timeout + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/inject_ping/validator.py b/hyperscale/core/experiments/mutations/types/inject_ping/validator.py new file mode 100644 index 0000000..c58ece3 --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/inject_ping/validator.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, StrictInt, StrictStr, validator + +from hyperscale.core.engines.types.common.protocols.ping.ping_type import PingTypesMap + + +class InjectPingValidator(BaseModel): + ping_type: StrictStr + timeout: StrictInt + + @validator('ping_type') + def validate_ping_type(cls, val): + types_map = PingTypesMap() + assert types_map.get(val) is not None + + return val + diff --git a/hyperscale/core/experiments/mutations/types/smuggle_request/__init__.py b/hyperscale/core/experiments/mutations/types/smuggle_request/__init__.py new file mode 100644 index 0000000..9426e5c --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/smuggle_request/__init__.py @@ -0,0 +1 @@ +from .mutation import SmuggleRequest \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/smuggle_request/mutation.py b/hyperscale/core/experiments/mutations/types/smuggle_request/mutation.py new file mode 100644 index 0000000..4d484cc --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/smuggle_request/mutation.py @@ -0,0 +1,122 @@ +import json +import random +import string +from typing import Dict, Optional, Tuple, Union + +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation +from hyperscale.core.experiments.mutations.types.base.mutation_type import MutationType + +from .validator import SmuggleRequestValidator + +Request = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action +] + + +class SmuggleRequest(Mutation): + + def __init__( + self, + name: str, + chance: float, + *targets: Tuple[str, ...], + request_size: Optional[int]=None, + smuggled_request: Optional[Union[Dict[str, str], bytes, str]]=None + + ) -> None: + super().__init__( + name, + chance, + MutationType.SMUGGLE_REQUEST, + *targets + ) + + validated_mutation = SmuggleRequestValidator( + request_size=request_size, + smuggled_request=smuggled_request + ) + + self.request_size = validated_mutation.request_size + self.smuggled_request = validated_mutation.smuggled_request + + encoded_smuggled_request = self.smuggled_request + + if isinstance(encoded_smuggled_request, dict): + encoded_smuggled_request = json.dumps(encoded_smuggled_request) + + if isinstance(encoded_smuggled_request, str): + encoded_smuggled_request = encoded_smuggled_request.encode() + + self._encoded_smuggled_request = encoded_smuggled_request + self._character_pool = ''.join([ + string.ascii_letters, + string.digits, + string.hexdigits, + string.octdigits, + string.punctuation, + string.whitespace + ]) + + self.original_data: bytes = None + + async def mutate(self, action: Request=None) -> Request: + + chance_roll = random.uniform(0, 1) + if chance_roll <= self.chance: + return action + + if action.method not in ['POST', 'PUT', 'PATCH']: + return action + + lowered_headers = { + header_name.lower(): header_value for header_name, header_value in action.headers.items() + } + + update_headers = {} + if lowered_headers.get('content-type') is None: + update_headers['Content-Type'] = len(action.encoded_data) + + if lowered_headers.get('transfer-encoding') is None: + update_headers['Transfer-Encoding'] = 'chunked' + + action._headers.update(update_headers) + action._header_items = list(action._headers.items()) + + action._setup_headers() + + if self.smuggled_request is None: + encoded_smuggled_request: str = ''.join( + random.choices( + self._character_pool, + k=self.request_size + ) + ) + + self._encoded_smuggled_request = encoded_smuggled_request.encode() + + if self.original_data is None: + self.original_data = action.encoded_data + + action.encoded_data = self.original_data + self._encoded_smuggled_request + + return action + + def copy(self): + return SmuggleRequest( + self.name, + self.chance, + *list(self.targets), + request_size=self.request_size, + smuggled_request=self.smuggled_request + ) \ No newline at end of file diff --git a/hyperscale/core/experiments/mutations/types/smuggle_request/validator.py b/hyperscale/core/experiments/mutations/types/smuggle_request/validator.py new file mode 100644 index 0000000..9f4699a --- /dev/null +++ b/hyperscale/core/experiments/mutations/types/smuggle_request/validator.py @@ -0,0 +1,31 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictBytes, + Json, + StrictInt, + validator +) + +from typing import Optional, Union + + +class SmuggleRequestValidator(BaseModel): + request_size: Optional[StrictInt] + smuggled_request: Optional[Union[StrictStr, StrictBytes, Json]] + + @validator('request_size') + def validate_request_size(cls, val): + + if val is None: + assert cls.smuggled_request is not None, "Field smuggled_request cannot be None if request_size is None" + + return val + + @validator('smuggled_request') + def validate_smuggled_request(cls, val): + + if val is None: + assert cls.request_size is not None, "Field request_size cannot be None if smuggled_request is None" + + return val \ No newline at end of file diff --git a/hyperscale/core/experiments/variant.py b/hyperscale/core/experiments/variant.py new file mode 100644 index 0000000..6fab071 --- /dev/null +++ b/hyperscale/core/experiments/variant.py @@ -0,0 +1,35 @@ +from typing import List, Optional, Union + +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .distribution import Distribution +from .mutations.types.base.mutation import Mutation + + +@unstable +class Variant: + + def __init__( + self, + stage_name: str, + weight: Optional[float] = None, + distribution: str=None, + distribution_intervals: int=10, + mutations: Optional[List[Mutation]]=None + ) -> None: + self.stage_name = stage_name + self.weight = weight + self.distribution: Optional[Distribution] = None + self.intervals = distribution_intervals + self.mutations: Union[List[Mutation], None] = mutations + + if distribution: + self.distribution = Distribution( + distribution, + intervals=distribution_intervals + ) + + def get_mutations(self) -> List[Mutation]: + return [ + mutation.copy() for mutation in self.mutations + ] diff --git a/hyperscale/core/graphs/__init__.py b/hyperscale/core/graphs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/graph.py b/hyperscale/core/graphs/graph.py new file mode 100644 index 0000000..94d4072 --- /dev/null +++ b/hyperscale/core/graphs/graph.py @@ -0,0 +1,438 @@ + +import asyncio +import itertools +import os +import statistics +import threading +import time +import uuid +from typing import Any, Dict, List, Union + +import networkx + +from hyperscale.core.graphs.stages.base.exceptions.process_killed_error import ( + ProcessKilledError, +) +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.graphs.transitions.transition_group import TransitionGroup +from hyperscale.logging import HyperscaleLogger +from hyperscale.logging.table.table_types import ( + GraphExecutionResults, + GraphResults, + SystemMetricsCollection, +) +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.reporting.metric.metrics_set import MetricsSet +from hyperscale.reporting.metric.stage_metrics_summary import StageMetricsSummary +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup + +from .status import GraphStatus +from .transitions import TransitionAssembler, local_transitions + + +class Graph: + status = GraphStatus.IDLE + + def __init__( + self, + graph_name: str, + stages: List[Stage], + config: Dict[str, Any]={}, + cpus: int=None, + worker_id: int=None + ) -> None: + self.execution_time = 0 + self.core_config = config + + self.graph_name = graph_name + self.graph_path = config.get('graph_path') + self.graph_id = str(uuid.uuid4()) + self.graph_skipped_stages = config.get('graph_skipped_stages', []) + + self.status = GraphStatus.INITIALIZING + self.graph = networkx.DiGraph() + self.logger = HyperscaleLogger() + self._thread_id = threading.current_thread().ident + self._process_id = os.getpid() + self.metadata_string = f'Graph - {self.graph_name}:{self.graph_id} - thread:{self._thread_id} - process:{self._process_id} - ' + + self.logger.initialize() + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Changed status to - {GraphStatus.INITIALIZING.name} - from - {GraphStatus.IDLE.name}') + self.logger.filesystem.sync['hyperscale.core'].info(f'{self.metadata_string} - Changed status to - {GraphStatus.INITIALIZING.name} - {GraphStatus.IDLE.name}') + + self.transitions_graph = [] + self._transitions: List[TransitionGroup] = [] + self._results = None + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Found - {len(stages)} - stages') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Found - {len(stages)} - stages') + + self.stage_types = { + subclass.stage_type: subclass for subclass in Stage.__subclasses__() + } + + self.instances: Dict[StageTypes, List[Stage]] = {} + for stage in self.stage_types.values(): + stage_instances = [stage for stage in stage.__subclasses__() if stage.__module__ == self.core_config.get('graph_module')] + self.instances[stage.stage_type] = stage_instances + + self.stages: Dict[str, Stage] = {stage.__name__: stage for stage in stages} + + self.graph.add_nodes_from([ + ( + stage_name, + {"stage": stage} + ) for stage_name, stage in self.stages.items() + ]) + + for stage in stages: + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Adding dependencies for stage - {stage.__name__}') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Adding dependencies for stage - {stage.__name__}') + + for dependency in stage.dependencies: + if self.graph.nodes.get(dependency.__name__): + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Adding edge from stage - {dependency.__name__} - to stage - {stage.__name__}') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Adding edge from stage - {dependency.__name__} - to stage - {stage.__name__}.') + + self.graph.add_edge(dependency.__name__, stage.__name__) + + self.execution_order = [ + generation for generation in networkx.topological_generations(self.graph) + ] + + self.runner = TransitionAssembler( + local_transitions, + graph_name=self.graph_name, + graph_path=self.graph_path, + graph_id=self.graph_id, + graph_skipped_stages=self.graph_skipped_stages, + cpus=cpus, + worker_id=worker_id, + core_config=self.core_config + ) + + def assemble(self): + + self.status = GraphStatus.ASSEMBLING + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Changed status to - {GraphStatus.ASSEMBLING.name} - from - {GraphStatus.INITIALIZING.name}') + self.logger.filesystem.sync['hyperscale.core'].info(f'{self.metadata_string} - Changed status to - {GraphStatus.ASSEMBLING.name} - from - {GraphStatus.INITIALIZING.name}') + + # A user will never specify an Idle stage. Instead, we prepend one to + # serve as the source node for a graphs, ensuring graphs have a + # valid starting point and preventing the user from forgetting things + # like a Setup stage. + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Prepending {StageTypes.IDLE.name} stage') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Prepending {StageTypes.IDLE.name} stage') + + self._prepend_stage(StageTypes.IDLE) + + # If we haven't specified an Analyze stage for results aggregation, + # append one. + if len(self.instances.get(StageTypes.ANALYZE)) < 1: + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Appending {StageTypes.ANALYZE.name} stage') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Appending {StageTypes.ANALYZE.name} stage') + + self._append_stage(StageTypes.ANALYZE) + + # If we havent specified a Submit stage for save aggregated results, + # append one. + if len(self.instances.get(StageTypes.SUBMIT)) < 1: + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Appending {StageTypes.SUBMIT.name} stage') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Appending {StageTypes.SUBMIT.name} stage') + + self._append_stage(StageTypes.SUBMIT) + + # Like Idle, a user will never specify a Complete stage. We append + # one to serve as the sink node, ensuring all Graphs executed can + # reach a single exit point. + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Appending {StageTypes.COMPLETE.name} stage') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Appending {StageTypes.COMPLETE.name} stage') + + self._append_stage(StageTypes.COMPLETE) + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Generating graph stages and transitions') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Generating graph stages and transitions') + + self.runner.generate_stages(self.stages) + self._transitions = self.runner.build_transitions_graph(self.execution_order, self.graph) + self.runner.map_to_setup_stages(self.graph) + self.runner.apply_config_to_load_hooks(self.graph) + + self.logger.hyperscale.sync.debug(f'{self.metadata_string} - Assembly complete') + self.logger.filesystem.sync['hyperscale.core'].debug(f'{self.metadata_string} - Assembly complete') + + async def run(self) -> GraphResults: + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + execution_start = time.monotonic() + + run_task = asyncio.current_task() + + await self.logger.hyperscale.aio.debug(f'{self.metadata_string} - Changed status to - {GraphStatus.RUNNING.name} - from - {GraphStatus.ASSEMBLING.name}') + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Changed status to - {GraphStatus.RUNNING.name} - from - {GraphStatus.ASSEMBLING.name}') + + self.status = GraphStatus.RUNNING + + summary_output: GraphExecutionResults = {} + submit_stage_system_metrics: SystemMetricsCollection = {} + graph_system_metrics: MonitorGroup = {} + + for transition_group in self._transitions: + + is_idle_transition = False + + transition_stage_types = [transition.edge.source.stage_type for transition in transition_group] + is_idle_transition = StageTypes.IDLE in transition_stage_types + + transition_group.sort_and_map_transitions() + + current_stages = ', '.join( + list(set([transition.from_stage.name for transition in transition_group])) + ) + + self.logger.spinner.logger_enabled = True + if is_idle_transition: + self.logger.spinner.logger_enabled = False + + async with self.logger.spinner as status_spinner: + + if is_idle_transition is False: + await self.logger.spinner.append_message(f"Executing stages - {current_stages}") + + for transition in transition_group: + await status_spinner.system.debug(f'{self.metadata_string} - Executing stage Transtition - {transition.transition_id} - from stage - {transition.from_stage.name} - to stage - {transition.to_stage.name}') + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Executing stage Transition - {transition.transition_id} - from stage - {transition.from_stage.name} - to stage - {transition.to_stage.name}') + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Executing stage - {transition.from_stage.name}:{transition.from_stage.stage_id}') + + results = await transition_group.execute_group() + + for transition in transition_group: + error = transition.edge.exception + + if isinstance(error, ProcessKilledError): + self.status = GraphStatus.CANCELLED + return + + if error: + + source_stage = transition.edge.source + + self.status = GraphStatus.FAILED + + await status_spinner.system.debug(f'{self.metadata_string} - Changed status to - {GraphStatus.FAILED.name} - from - {GraphStatus.RUNNING.name}') + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Changed status to - {GraphStatus.FAILED.name} - from - {GraphStatus.RUNNING.name}') + + await status_spinner.system.error(f'{self.metadata_string} - Encountered error executing stage - {source_stage.name}:{source_stage.stage_id}') + await self.logger.filesystem.aio['hyperscale.core'].error(f'{self.metadata_string} - Encountered error executing stage - {source_stage.name}:{source_stage.stage_id}') + + error_transtiton = self.runner.create_error_transition( + source_stage, + error + ) + + await error_transtiton.execute() + + if transition.edge.source.stage_type == StageTypes.ANALYZE: + stage_name = transition.edge.source.name + submit_stage_context = transition.edge.source.context + + analyze_stage_summary_metrics: Dict[ + str, + Union[ + str, + Dict[str, StageMetricsSummary], + Dict[str, MetricsSet], + SystemMetricsSet + ] + ] = submit_stage_context.get('analyze_stage_summary_metrics') + + if analyze_stage_summary_metrics: + summary_output[stage_name] = analyze_stage_summary_metrics + stage_system_metrics = analyze_stage_summary_metrics.get('system_metrics', {}) + + graph_system_metrics.update(stage_system_metrics.metrics) + + if transition.edge.source.stage_type == StageTypes.SUBMIT: + stage_name = transition.edge.source.name + submit_stage_context = transition.edge.source.context + + submit_stage_system_metrics_set: SystemMetricsSet = submit_stage_context.get('stage_system_metrics') + if submit_stage_system_metrics_set: + submit_stage_system_metrics[stage_name] = submit_stage_system_metrics_set + graph_system_metrics.update(submit_stage_system_metrics_set.metrics) + + if self.status == GraphStatus.FAILED: + status_spinner.finalize() + await status_spinner.fail('Error') + break + + for transition in transition_group: + await status_spinner.system.debug(f'{self.metadata_string} - Completed stage Transtition - {transition.transition_id} - from stage - {transition.from_stage.name} - to stage - {transition.to_stage.name}') + + + if is_idle_transition is False: + completed_transitions_count = len(results) + await status_spinner.system.debug(f'{self.metadata_string} - Completed - {completed_transitions_count} - transitions') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Completed - {completed_transitions_count} - transitions') + + status_spinner.group_finalize() + + await status_spinner.ok('✔') + + results = None + + if self.status == GraphStatus.RUNNING: + await self.logger.spinner.system.debug(f'{self.metadata_string} - Changed status to - {GraphStatus.COMPLETE.name} - from - {GraphStatus.RUNNING.name}') + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Changed status to - {GraphStatus.COMPLETE.name} - from - {GraphStatus.RUNNING.name}') + + self.status = GraphStatus.COMPLETE + + self.execution_time = execution_start - time.monotonic() + + for transition_group in self._transitions: + + group_cpu_metrics: List[List[Union[int, float]]] = [] + group_memory_metrics: List[List[Union[int, float]]]= [] + + for transition in transition_group: + + stage_name = transition.edge.source.name + + transition_system_metrics = graph_system_metrics.get(stage_name) + if transition_system_metrics: + stage_cpu_metrics = graph_system_metrics[stage_name].get('cpu') + stage_memory_metrics = graph_system_metrics[stage_name].get('memory') + + for metrics in stage_cpu_metrics.stage_metrics.values(): + group_cpu_metrics.append(metrics) + + for metrics in stage_memory_metrics.stage_metrics.values(): + group_memory_metrics.append(metrics) + + transition.edge.source.context = None + transition.edge.destination.context = None + transition.edge.history = None + + if len(group_cpu_metrics) > 0 and len(group_memory_metrics) > 0: + group_cpu_metrics = [ + statistics.median(cpu_usage) for cpu_usage in itertools.zip_longest( + *group_cpu_metrics, + fillvalue=0 + ) + ] + + group_memory_metrics = [ + sum(memory_usage) for memory_usage in itertools.zip_longest( + *group_memory_metrics, + fillvalue=0 + ) + ] + + cpu_monitor.collected[self.graph_name].extend(group_cpu_metrics) + memory_monitor.collected[self.graph_name].extend(group_memory_metrics) + + transition_group.destination_groups = None + transition_group.transitions = None + transition_group.transitions_by_type = None + transition_group.edges_by_name = None + transition_group.adjacency_list = None + + pending_tasks = asyncio.all_tasks() + + for task in pending_tasks: + if task != run_task and task.cancelled() is False: + task.cancel() + + cpu_monitor.aggregate_worker_stats() + memory_monitor.aggregate_worker_stats() + + cpu_monitor.stage_metrics[self.graph_name] = cpu_monitor.collected[self.graph_name] + memory_monitor.stage_metrics[self.graph_name] = memory_monitor.collected[self.graph_name] + + cpu_monitor.visibility_filters[self.graph_name] = True + memory_monitor.visibility_filters[self.graph_name] = True + + graph_system_metrics = { + self.graph_name: { + 'cpu': cpu_monitor, + 'memory': memory_monitor + } + } + + system_metrics = SystemMetricsSet( + graph_system_metrics, + {} + ) + + if self.status == GraphStatus.COMPLETE: + system_metrics.generate_system_summaries() + + return { + 'metrics': summary_output, + 'submit_stage_system_metrics': submit_stage_system_metrics, + 'graph_system_metrics': system_metrics + } + + def cleanup(self): + for executor in self.runner.executors: + executor.close() + + for transition_group in self._transitions: + for executor in transition_group._executors: + executor.close() + + def _append_stage(self, stage_type: StageTypes): + + appended_stage = self.stage_types.get(stage_type) + last_cut = self.execution_order[-1] + + appended_stage.dependencies = list() + + for stage_name in last_cut: + stage = self.stages.get(stage_name) + + appended_stage.dependencies.append(stage) + + self.graph.add_node(appended_stage.__name__, stage=appended_stage) + + for stage_name in last_cut: + self.graph.add_edge(stage_name, appended_stage.__name__) + + self.execution_order = [ + generation for generation in networkx.topological_generations(self.graph) + ] + + self.stages[appended_stage.__name__] = appended_stage + self.instances[appended_stage.stage_type].append(appended_stage) + + def _prepend_stage(self, stage_type: StageTypes): + prepended_stage = self.stage_types.get(stage_type) + first_cut = self.execution_order[0] + + prepended_stage.dependencies = list() + + for stage_name in first_cut: + stage = self.stages.get(stage_name) + + stage.dependencies.append(prepended_stage) + + self.graph.add_node(prepended_stage.__name__, stage=prepended_stage) + + for stage_name in first_cut: + self.graph.add_edge(prepended_stage.__name__, stage_name) + + self.execution_order = [ + generation for generation in networkx.topological_generations(self.graph) + ] + + self.stages[prepended_stage.__name__] = prepended_stage + self.instances[prepended_stage.stage_type].append(stage_type) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/__init__.py b/hyperscale/core/graphs/stages/__init__.py new file mode 100644 index 0000000..187d214 --- /dev/null +++ b/hyperscale/core/graphs/stages/__init__.py @@ -0,0 +1,9 @@ +from .act import Act +from .analyze import Analyze +from .execute import Execute +from .optimize import Optimize +from .setup import Setup +from .complete import Complete +from .submit import Submit +from .error import Error +from .idle import Idle \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/act/__init__.py b/hyperscale/core/graphs/stages/act/__init__.py new file mode 100644 index 0000000..5b84f7b --- /dev/null +++ b/hyperscale/core/graphs/stages/act/__init__.py @@ -0,0 +1 @@ +from .act import Act \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/act/act.py b/hyperscale/core/graphs/stages/act/act.py new file mode 100644 index 0000000..c62414b --- /dev/null +++ b/hyperscale/core/graphs/stages/act/act.py @@ -0,0 +1,88 @@ +from typing import Generic, Optional + +from typing_extensions import TypeVarTuple, Unpack + +from hyperscale.core.engines.client import Client +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.internal.decorator import Internal +from hyperscale.monitoring import CPUMonitor, MemoryMonitor + +T = TypeVarTuple('T') + + +class Act(Stage, Generic[Unpack[T]]): + stage_type=StageTypes.ACT + priority: Optional[str]=None + retries=0 + + def __init__(self) -> None: + super().__init__() + self.persona = None + self.client: Client[Unpack[T]] = Client( + self.graph_name, + self.graph_id, + self.name, + self.stage_id + ) + + self.accepted_hook_types = [ + HookType.ACTION, + HookType.CHANNEL, + HookType.CHECK, + HookType.CONDITION, + HookType.CONTEXT, + HookType.EVENT, + HookType.LOAD, + HookType.SAVE, + HookType.TASK, + HookType.TRANSFORM + ] + + self.priority = self.priority + if self.priority is None: + self.priority = 'auto' + + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + self.stage_retries = self.retries + + @Internal() + async def run(self): + await self.setup_events() + self.dispatcher.assemble_execution_graph() + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + main_monitor_name = f'{self.name}.main' + + await cpu_monitor.start_background_monitor(main_monitor_name) + await memory_monitor.start_background_monitor(main_monitor_name) + + await self.dispatcher.dispatch_events(self.name) + + await cpu_monitor.stop_background_monitor(main_monitor_name) + await memory_monitor.stop_background_monitor(main_monitor_name) + + cpu_monitor.close() + memory_monitor.close() + + cpu_monitor.stage_metrics[main_monitor_name] = cpu_monitor.collected[main_monitor_name] + memory_monitor.stage_metrics[main_monitor_name] = memory_monitor.collected[main_monitor_name] + + self.context.update({ + 'act_stage_monitors': { + self.name: { + 'cpu': cpu_monitor, + 'memory': memory_monitor + } + } + }) + + + diff --git a/hyperscale/core/graphs/stages/analyze/__init__.py b/hyperscale/core/graphs/stages/analyze/__init__.py new file mode 100644 index 0000000..6b93565 --- /dev/null +++ b/hyperscale/core/graphs/stages/analyze/__init__.py @@ -0,0 +1 @@ +from .analyze import Analyze \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/analyze/analyze.py b/hyperscale/core/graphs/stages/analyze/analyze.py new file mode 100644 index 0000000..ee8ec62 --- /dev/null +++ b/hyperscale/core/graphs/stages/analyze/analyze.py @@ -0,0 +1,760 @@ + + +import asyncio +import functools +import signal +import statistics +import time +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Optional, Tuple, Union + +import dill + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.condition.decorator import condition +from hyperscale.core.hooks.types.context.decorator import context +from hyperscale.core.hooks.types.event.decorator import event +from hyperscale.core.hooks.types.internal.decorator import Internal +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.logging import logging_manager +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.reporting.experiment.experiment_metrics_set import ExperimentMetricsSet +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.custom_metric import CustomMetric +from hyperscale.reporting.metric.stage_metrics_summary import StageMetricsSummary +from hyperscale.reporting.processed_result import ProcessedResultsGroup, results_types +from hyperscale.reporting.processed_result.types import ( + GraphQLHTTP2ProcessedResult, + GraphQLProcessedResult, + GRPCProcessedResult, + HTTP2ProcessedResult, + HTTPProcessedResult, + PlaywrightProcessedResult, + TaskProcessedResult, + UDPProcessedResult, + WebsocketProcessedResult, +) +from hyperscale.reporting.system import SystemMetricsSet +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + +from .parallel import process_results_batch + +dill.settings['byref'] = True + + +Events = Union[ + GraphQLProcessedResult, + GraphQLHTTP2ProcessedResult, + GRPCProcessedResult, + HTTPProcessedResult, + HTTP2ProcessedResult, + PlaywrightProcessedResult, + TaskProcessedResult, + UDPProcessedResult, + WebsocketProcessedResult +] + +StageConfig = Tuple[str, int, List[Dict[str, List[BaseResult]]]] + +RawResultsSet = Dict[str, ResultsSet] + +RawResultsPairs = List[Tuple[Dict[str, List[Tuple[str, Any]]]]] + +ProcessedResults = Dict[str, Union[Dict[str, Union[int, float, int]], int]] + +ProcessedMetricsSet = Dict[str, MetricsSet] + +ProcessedStageMetricsSet = Dict[str, Union[int, float, Dict[str, ProcessedMetricsSet]]] + +ProcessedResultsSet = List[Tuple[str, StageMetricsSummary]] + +CustomMetricsSet = Dict[str, Dict[str, Dict[str, Union[int, float, Any]]]] + +EventsSet = Dict[str, Dict[str, ProcessedResultsGroup]] + +MonitorResults = Dict[str, List[Union[int, float]]] + + +def handle_loop_stop( + signame, + loop: asyncio.AbstractEventLoop, + executor: ThreadPoolExecutor +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.close() + + except BrokenPipeError: + pass + + except RuntimeError: + pass + + +def deserialize_results(results: List[bytes]) -> List[BaseResult]: + return [ + dill.loads(result) for result in results + ] + + +class Analyze(Stage): + stage_type=StageTypes.ANALYZE + is_parallel=False + handler=None + priority: Optional[str]=None + retries: int = 0 + + def __init__(self) -> None: + super().__init__() + + self.accepted_hook_types = [ + HookType.CONDITION, + HookType.CONTEXT, + HookType.CONDITION, + HookType.EVENT, + HookType.METRIC, + HookType.TRANSFORM, + ] + + self.stage_retries = self.retries + self.requires_shutdown = True + self.allow_parallel = True + self.analysis_execution_time = 0 + self.analysis_execution_time_start = 0 + self._executor = ThreadPoolExecutor(max_workers=self.workers) + self._loop: asyncio.AbstractEventLoop = None + self.source_internal_events = [ + 'initialize_raw_results' + ] + + self.internal_events = [ + 'initialize_results_analysis', + 'partition_results_batches', + 'get_custom_metric_hooks', + 'create_stage_batches', + 'assign_stage_batches', + 'analyze_stage_batches', + 'reduce_stage_contexts', + 'merge_events_groups', + 'calculate_custom_metrics', + 'generate_metrics_sets', + 'generate_summary', + 'complete' + ] + + self.priority = self.priority + if self.priority is None: + self.priority = 'auto' + + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + @Internal() + async def run(self): + self.executor.batch_by_stages = True + + await self.setup_events() + self.dispatcher.assemble_execution_graph() + await self.dispatcher.dispatch_events(self.name) + + @context() + async def initialize_results_analysis( + self, + analyze_stage_raw_results: RawResultsSet={}, + session_stage_monitors: MonitorGroup={}, + session_setup_stage_configs: Dict[str, Config]={} + ): + + await self.logger.filesystem.aio.create_logfile('hyperscale.reporting.log') + self.logger.filesystem.create_filelogger('hyperscale.reporting.log') + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Starting results analysis') + + self.analysis_execution_time_start = time.monotonic() + + engine_plugins = self.plugins_by_type.get(PluginType.ENGINE) + for plugin_name, plugin in engine_plugins.items(): + results_types[plugin_name] = plugin.event + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Generated custom Event - {plugin.event.type} - for Reporter plugin - {plugin_name}') + + self.context.ignore_serialization_filters = [ + 'analyze_stage_all_results', + 'analyze_stage_raw_results', + 'analyze_stage_target_stages', + 'analyze_stage_deserialized_results', + 'session_stage_monitors', + 'analyze_stage_monitors' + ] + + all_results = list(analyze_stage_raw_results.items()) + + total_group_results = 0 + for stage_results in analyze_stage_raw_results.values(): + total_group_results += stage_results.total_results + + return { + 'analyze_stage_raw_results': analyze_stage_raw_results, + 'analyze_stage_all_results': all_results, + 'analyze_stage_stages_count': len(analyze_stage_raw_results), + 'analyze_stage_total_group_results': total_group_results, + 'session_setup_stage_configs': session_setup_stage_configs, + 'session_stage_monitors': session_stage_monitors + } + + @event('initialize_results_analysis') + async def add_shutdown_handler(self): + self._loop = asyncio.get_running_loop() + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._loop, + self.executor + ) + ) + + @condition('add_shutdown_handler') + async def check_if_has_multiple_workers(self): + return { + 'analyze_stage_has_multiple_workers': self.total_pool_cpus > 1 + } + + @event('check_if_has_multiple_workers') + async def generate_deserialized_results( + self, + analyze_stage_raw_results: RawResultsSet={}, + analyze_stage_has_multiple_workers: bool=False + ): + deserialized_results = dict(analyze_stage_raw_results) + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + main_monitor_name = f'{self.name}.main' + + await cpu_monitor.start_background_monitor(main_monitor_name) + await memory_monitor.start_background_monitor(main_monitor_name) + + if analyze_stage_has_multiple_workers: + for results_set_name, results_set in analyze_stage_raw_results.items(): + + results_set_copy = results_set.copy() + results_set_copy.results = await self._loop.run_in_executor( + self._executor, + functools.partial( + deserialize_results, + results_set.results + ) + ) + + deserialized_results[results_set_name] = results_set_copy + + return { + 'analyze_stage_monitors': { + 'cpu': cpu_monitor, + 'memory': memory_monitor + }, + 'analyze_stage_raw_results': analyze_stage_raw_results, + 'analyze_stage_deserialized_results': deserialized_results + } + + + @event('generate_deserialized_results') + async def partition_results_batches( + self, + analyze_stage_raw_results: RawResultsSet={}, + analyze_stage_all_results: List[RawResultsPairs]=[] + ): + batches = self.executor.partion_stage_batches(analyze_stage_all_results) + + elapsed_times = [] + for stage_name, _, _ in batches: + stage_results: ResultsSet = analyze_stage_raw_results.get(stage_name) + elapsed_times.append( + stage_results.total_elapsed + ) + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Paritioned {len(batches)} batches of results') + + return { + 'analyze_stage_batches': batches, + 'analyze_stage_elapsed_times': elapsed_times + } + + @event('partition_results_batches') + async def create_stage_batches( + self, + analyze_stage_raw_results: RawResultsSet=[], + analyze_stage_batches: List[Tuple[str, Any, int]]=[], + session_setup_stage_configs: Dict[str, Config]={} + ): + stage_total_times = {} + stage_batch_sizes = {} + stage_streamed_analytics: Dict[str, List[StreamAnalytics]] = {} + analyze_stage_batch_configs = {} + stage_personas = {} + + for stage_name, _, assigned_workers_count in analyze_stage_batches: + + stage_batches: List[List[Any]] = [] + + stage_results = analyze_stage_raw_results.get(stage_name) + results = stage_results.results + stage_total_time = stage_results.total_elapsed + + stage_total_times[stage_name] = stage_total_time + stage_streamed_analytics[stage_name] = stage_results.stage_streamed_analytics + + results_count = len(results) + + batch_size = int(results_count/assigned_workers_count) + + for worker_idx in range(assigned_workers_count): + + batch_marker = worker_idx * batch_size + + stage_batches.append( + results[batch_marker:batch_marker + batch_size] + ) + + if results_count%assigned_workers_count > 0: + stage_batches[assigned_workers_count-1].extend( + results[assigned_workers_count * batch_size:] + ) + + analyze_stage_batch_configs[stage_name] = stage_batches + stage_batch_sizes[stage_name] = stage_results.stage_batch_size + stage_personas[stage_name] = stage_results.stage_persona_type + + return { + 'analyze_stage_target_stages': {}, + 'analyze_stage_batch_configs': analyze_stage_batch_configs, + 'analyze_stage_total_times': stage_total_times, + 'analyze_stage_batch_sizes': stage_batch_sizes, + 'analyze_stage_personas': stage_personas, + 'analyze_stage_streamed_analytics': stage_streamed_analytics + } + + @context('create_stage_batches') + async def assign_stage_batches( + self, + analyze_stage_batches: List[Tuple[str, Any, int]]=[], + analyze_stage_batch_configs: Dict[str, List[List[Any]]]=[], + analyze_stage_metric_hook_names: List[str]=[], + analyze_stage_has_multiple_workers: bool=False + ): + if analyze_stage_has_multiple_workers: + stage_configs = [] + serializable_context = self.context.as_serializable() + worker_idx = 0 + + for stage_name, _, assigned_workers_count in analyze_stage_batches: + + stage_configs.append(( + stage_name, + assigned_workers_count, + [ + { + 'graph_name': self.graph_name, + 'graph_path': self.graph_path, + 'graph_id': self.graph_id, + 'enable_unstable_features': active_flags[FlagTypes.UNSTABLE_FEATURE], + 'logfiles_directory': logging_manager.logfiles_directory, + 'log_level': logging_manager.log_level_name, + 'source_stage_name': self.name, + 'source_stage_context': { + context_key: context_value for context_key, context_value in serializable_context + }, + 'source_stage_id': self.stage_id, + 'analyze_stage_name': stage_name, + 'analyze_stage_metric_hooks': list(analyze_stage_metric_hook_names), + 'analyze_stage_batched_results': batch, + 'worker_id': worker_idx + 1 + } for batch in analyze_stage_batch_configs[stage_name] + ] + )) + + worker_idx += 1 + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Assigned {assigned_workers_count} to process results from stage - {stage_name}') + + return { + 'analyze_stage_configs': stage_configs, + } + + @condition('assign_stage_batches') + async def check_if_multiple_stages( + self, + analyze_stage_configs: List[Tuple[str, Any, int]]=[], + ): + return { + 'multiple_stages_to_process': len(analyze_stage_configs) > 1 + } + + @event('check_if_multiple_stages') + async def execute_batched_analysis( + self, + analyze_stage_stages_count: int=0, + analyze_stage_elapsed_times: List[float]=[], + analyze_stage_total_group_results: int=0, + analyze_stage_configs: List[StageConfig]=[], + multiple_stages_to_process: bool=False, + analyze_stage_deserialized_results: RawResultsSet = {}, + analyze_stage_has_multiple_workers: bool=False, + ): + + if multiple_stages_to_process and analyze_stage_has_multiple_workers: + await self.logger.spinner.append_message( + f'Calculating results for - {analyze_stage_stages_count} - stages' + ) + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Processing results or - {analyze_stage_stages_count} - stages') + + median_execution_time = round(statistics.median(analyze_stage_elapsed_times)) + await self.logger.spinner.append_message(f'Calculating stats for - {analyze_stage_total_group_results} - actions executed over a median stage execution time of {median_execution_time} seconds') + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Calculating stats for - {analyze_stage_total_group_results} - actions over a median stage execution time of {median_execution_time} seconds') + stage_batch_results = await self.executor.execute_batches( + analyze_stage_configs, + process_results_batch + ) + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Completed parital results aggregation for - {analyze_stage_stages_count} - stages') + + return { + 'analyze_stage_batch_results': stage_batch_results + } + + else: + + + batch_results = [] + events = defaultdict(ProcessedResultsGroup) + + for results_set in analyze_stage_deserialized_results.values(): + + for stage_result in results_set.results: + events[stage_result.name].add( + results_set.stage, + stage_result, + ) + + batch_results.append( + (results_set.stage, events) + ) + + for events_group in events.values(): + events_group.calculate_stats() + + return { + 'analyze_stage_batch_results': batch_results + } + + @event('execute_batched_analysis') + async def merge_events_groups( + self, + analyze_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]]={}, + analyze_stage_batch_results: List[Tuple[str, List[Dict[str, Any]]]]=[], + multiple_stages_to_process: bool=False + ): + + stage_events_set = {} + + stage_cpu_monitor: CPUMonitor = analyze_stage_monitors.get('cpu') + stage_memory_monitor: MemoryMonitor = analyze_stage_monitors.get('memory') + + if multiple_stages_to_process: + + stage_cpu_monitor.stage_type = StageTypes.ANALYZE + stage_memory_monitor.stage_type = StageTypes.ANALYZE + + for stage_name, stage_results in analyze_stage_batch_results: + results = stage_results.pop() + + worker_id = results.get('worker_id') + stage_events_set[stage_name] = results.get('events') + + monitors: Dict[str, MonitorResults] = results.get('monitoring', {}) + cpu_monitor = monitors.get('cpu', {}) + memory_monitor = monitors.get('memory', {}) + + for monitor_name, collection_stats in cpu_monitor.items(): + stage_cpu_monitor.worker_metrics[worker_id][monitor_name] = collection_stats + stage_cpu_monitor.collected[monitor_name].extend(collection_stats) + + for monitor_name, collection_stats in memory_monitor.items(): + stage_memory_monitor.worker_metrics[worker_id][monitor_name] = collection_stats + + stage_cpu_monitor.aggregate_worker_stats() + stage_memory_monitor.aggregate_worker_stats() + + else: + + for stage_name, stage_results in analyze_stage_batch_results: + stage_events_set[stage_name] = stage_results + + main_monitor_name = f'{self.name}.main' + + await stage_cpu_monitor.stop_background_monitor(main_monitor_name) + await stage_memory_monitor.stop_background_monitor(main_monitor_name) + + stage_cpu_monitor.stage_metrics[main_monitor_name] = stage_cpu_monitor.collected[main_monitor_name] + stage_memory_monitor.stage_metrics[main_monitor_name] = stage_memory_monitor.collected[main_monitor_name] + + stage_cpu_monitor.close() + stage_memory_monitor.close() + + return { + 'analyze_stage_events_set': stage_events_set, + 'analyze_stage_monitors': { + 'cpu': stage_cpu_monitor, + 'memory': stage_memory_monitor + } + } + + @event('merge_events_groups') + async def calculate_custom_metrics(self): + + custom_metrics_set = defaultdict(dict) + + metrics = [ + metric_event for metric_event in self.dispatcher.events[EventType.METRIC] + ] + + for metric in metrics: + for context_key, context_value in metric.context: + if isinstance(context_value, CustomMetric): + custom_metrics_set[context_value.metric_group][context_key] = context_value + + return { + 'analyze_stage_custom_metrics_set': custom_metrics_set + } + + @event('calculate_custom_metrics') + async def generate_metrics_sets( + self, + analyze_stage_custom_metrics_set: CustomMetricsSet={}, + analyze_stage_events_set: EventsSet={}, + analyze_stage_total_times: Dict[str, float]={}, + analyze_stage_personas: Dict[str, str]={}, + analyze_stage_batch_sizes: Dict[str, int]={}, + analyze_stage_streamed_analytics: Dict[str, List[StreamAnalytics]] = {} + ): + + processed_results = [] + + for stage_name, stage_events in analyze_stage_events_set.items(): + + stage_total_time = analyze_stage_total_times.get(stage_name) + + persona_type = analyze_stage_personas.get(stage_name) + batch_size = analyze_stage_batch_sizes.get(stage_name) + + stage_metrics_summary = StageMetricsSummary( + stage_name=stage_name, + persona_type=persona_type, + batch_size=batch_size, + total_elapsed=stage_total_time, + stage_streamed_analytics=analyze_stage_streamed_analytics.get(stage_name) + ) + grouped_stats = {} + + for event_group_name, events_group in stage_events.items(): + + custom_metrics = analyze_stage_custom_metrics_set.get(event_group_name, {}) + + events_group.calculate_quantiles() + + metric_data = { + 'total': events_group.total, + 'succeeded': events_group.succeeded, + 'failed': events_group.failed, + 'actions_per_second': round( + events_group.total/stage_total_time, + 2 + ), + 'errors': list([ + { + 'message': error_message, + 'count': error_count + } for error_message, error_count in events_group.errors.items() + ]), + 'groups': events_group.groups, + 'custom': custom_metrics + } + + metric = MetricsSet( + event_group_name, + events_group.source, + stage_name, + metric_data, + events_group.tags + ) + + stage_metrics_summary.metrics_sets[event_group_name] = metric + + grouped_stats[event_group_name] = metric + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Convererted stats for stage - {stage_name} to metrics set') + + stage_metrics_summary.calculate_action_and_task_metrics() + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Calculated results for - {stage_metrics_summary.stage_metrics.total} - actions from stage - {stage_name}') + + processed_results.append(( + stage_name, + stage_metrics_summary + )) + + return { + 'analyze_stage_processed_results': processed_results + } + + @event('generate_metrics_sets') + async def generate_experiment_metrics( + self, + analyze_stage_processed_results: ProcessedResultsSet=[], + analyze_stage_raw_results: RawResultsSet=[], + ): + + experiment_metrics_sets: Dict[str, ExperimentMetricsSet] = {} + + for stage_name, stage_metrics in analyze_stage_processed_results: + + + stage_metrics_sets = stage_metrics.metrics_sets + + raw_results_set = analyze_stage_raw_results.get(stage_name) + experiment = raw_results_set.experiment + + if experiment: + variant = experiment.get('experiment_variant') + variant_name = variant.get('variant_stage') + mutations = variant.get('variant_mutations') + + experiment_name = experiment.get('experiment_name') + + + experiment_metrics_set = experiment_metrics_sets.get(experiment_name) + if experiment_metrics_set is None: + experiment_metrics_set = ExperimentMetricsSet() + + if experiment_metrics_set.experiment_name is None: + experiment_metrics_set.experiment_name = experiment.get('experiment_name') + + + if experiment_metrics_set.randomized is None: + experiment_metrics_set.randomized = experiment.get('experiment_randomized') + + experiment_metrics_set.participants.append(stage_name) + + variant['stage_batch_size'] = raw_results_set.stage_batch_size + variant['stage_optimized'] = raw_results_set.stage_optimized + variant['stage_persona_type'] = raw_results_set.stage_persona_type + variant['stage_workers'] = raw_results_set.stage_workers + + experiment_metrics_set.variants[variant_name] = variant + experiment_metrics_set.mutations[variant_name] = mutations + experiment_metrics_set.metrics[variant_name] = stage_metrics_sets + + experiment_metrics_sets[experiment_name] = experiment_metrics_set + + for experiment_metrics_set in experiment_metrics_sets.values(): + experiment_metrics_set.generate_experiment_summary() + + return { + 'experiment_metrics_sets': experiment_metrics_sets + } + + @event('generate_metrics_sets') + async def generate_system_metrics( + self, + analyze_stage_monitors: MonitorGroup={}, + session_stage_monitors: MonitorGroup={}, + analyze_stage_batch_sizes: Dict[str, int]={}, + ): + + session_stage_monitors.update({ + self.name: { + **analyze_stage_monitors + } + }) + + system_metrics_set = SystemMetricsSet( + session_stage_monitors, + analyze_stage_batch_sizes + ) + system_metrics_set.generate_system_summaries() + + return { + 'analyze_stage_system_metrics': system_metrics_set + } + + @context( + 'generate_experiment_metrics', + 'generate_system_metrics' + ) + async def generate_summary( + self, + analyze_stage_stages_count: int=0, + analyze_stage_total_group_results: int=0, + analyze_stage_processed_results: ProcessedResultsSet=[], + analyze_stage_contexts: Dict[str, Any]={}, + experiment_metrics_sets: Dict[str, ExperimentMetricsSet]={}, + analyze_stage_system_metrics: SystemMetricsSet=None, + analyze_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]] = {} + ): + + self.context[self.name] = analyze_stage_contexts + + summaries: Dict[ + str, + Union[ + str, + Dict[str, StageMetricsSummary], + Dict[str, MetricsSet], + SystemMetricsSet + ] + ] = { + 'stages': {}, + 'source': self.name, + 'experiment_metrics_sets': experiment_metrics_sets, + 'system_metrics': analyze_stage_system_metrics + } + + for stage_name, stage_metrics in analyze_stage_processed_results: + summaries['stages'][stage_name] = stage_metrics + + self.analysis_execution_time = round( + time.monotonic() - self.analysis_execution_time_start + ) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Completed results analysis for - {analyze_stage_stages_count} - stages in - {self.analysis_execution_time} seconds') + await self.logger.spinner.set_default_message(f'Completed results analysis for {analyze_stage_total_group_results} actions and {analyze_stage_stages_count} stages over {self.analysis_execution_time} seconds') + + self.executor.shutdown() + + return { + 'analyze_stage_summary_metrics': summaries, + 'analyze_stage_monitors': { + self.name: { + **analyze_stage_monitors + } + } + } + + @event('generate_summary') + async def complete(self): + return {} diff --git a/hyperscale/core/graphs/stages/analyze/parallel/__init__.py b/hyperscale/core/graphs/stages/analyze/parallel/__init__.py new file mode 100644 index 0000000..4f5effd --- /dev/null +++ b/hyperscale/core/graphs/stages/analyze/parallel/__init__.py @@ -0,0 +1 @@ +from .process_results_batch import process_results_batch \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/analyze/parallel/process_results_batch.py b/hyperscale/core/graphs/stages/analyze/parallel/process_results_batch.py new file mode 100644 index 0000000..dcef1c9 --- /dev/null +++ b/hyperscale/core/graphs/stages/analyze/parallel/process_results_batch.py @@ -0,0 +1,130 @@ + +import os +import threading +import time +from collections import defaultdict +from typing import Any, Dict, List + +import dill + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.graphs.stages.base.exceptions.process_killed_error import ( + ProcessKilledError, +) +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.logging import HyperscaleLogger, LoggerTypes +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.reporting.processed_result.processed_results_group import ( + ProcessedResultsGroup, +) +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + + +def process_results_batch(config: Dict[str, Any]): + + import warnings + warnings.simplefilter("ignore") + + from hyperscale.logging import logging_manager + + graph_name = config.get('graph_name') + graph_id = config.get('graph_id') + logfiles_directory = config.get('logfiles_directory') + log_level = config.get('log_level') + source_stage_name = config.get('source_stage_name') + source_stage_id = config.get('source_stage_id') + enable_unstable_features = config.get('enable_unstable_features', False) + worker_id = config.get('worker_id') + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + cpu_monitor.stage_type = StageTypes.ANALYZE + memory_monitor.stage_type = StageTypes.ANALYZE + + monitor_name = f'{source_stage_name}.worker' + cpu_monitor.start_background_monitor_sync(monitor_name) + memory_monitor.start_background_monitor_sync(monitor_name) + + active_flags[FlagTypes.UNSTABLE_FEATURE] = enable_unstable_features + + logging_manager.disable( + LoggerTypes.DISTRIBUTED, + LoggerTypes.DISTRIBUTED_FILESYSTEM, + LoggerTypes.SPINNER + ) + + logging_manager.update_log_level(log_level) + logging_manager.logfiles_directory = logfiles_directory + + thread_id = threading.current_thread().ident + process_id = os.getpid() + + metadata_string = f'Graph - {graph_name}:{graph_id} - thread:{thread_id} - process:{process_id} - Stage: {source_stage_name}:{source_stage_id} - ' + + stage_name = config.get('analyze_stage_name') + results_batch: List[BaseResult] = config.get('analyze_stage_batched_results', []) + + try: + + events = defaultdict(ProcessedResultsGroup) + + logger = HyperscaleLogger() + logger.initialize() + logger.filesystem.sync.create_logfile('hyperscale.core.log') + logger.filesystem.sync.create_logfile('hyperscale.reporting.log') + + logger.filesystem.sync['hyperscale.core'].info(f'{metadata_string} - Initializing results aggregation') + + start = time.monotonic() + + for result in results_batch: + stage_result: BaseResult = dill.loads(result) + events[stage_result.name].add( + stage_name, + stage_result, + ) + + for events_stage_name, events_group in events.items(): + + logger.filesystem.sync['hyperscale.reporting'].debug( + f'{metadata_string} - Events group - {events_group.events_group_id} - created for Stage - {events_stage_name} - Processed - {events_group.total}' + ) + + events_group.calculate_stats() + + elapsed = time.monotonic() - start + + logger.filesystem.sync['hyperscale.core'].info(f'{metadata_string} - Results aggregation complete - Took: {round(elapsed, 2)} seconds') + + + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + return { + 'worker_id': worker_id, + 'events': events, + 'monitoring': { + 'cpu': cpu_monitor.collected, + 'memory': memory_monitor.collected + } + } + + except BrokenPipeError: + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + raise ProcessKilledError() + + except RuntimeError: + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + raise ProcessKilledError() + + except Exception as e: + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + raise e diff --git a/hyperscale/core/graphs/stages/base/__init__.py b/hyperscale/core/graphs/stages/base/__init__.py new file mode 100644 index 0000000..1fdbcce --- /dev/null +++ b/hyperscale/core/graphs/stages/base/__init__.py @@ -0,0 +1 @@ +from .stage import Stage diff --git a/hyperscale/core/graphs/stages/base/exceptions/hook_validation_error.py b/hyperscale/core/graphs/stages/base/exceptions/hook_validation_error.py new file mode 100644 index 0000000..d5edbff --- /dev/null +++ b/hyperscale/core/graphs/stages/base/exceptions/hook_validation_error.py @@ -0,0 +1,12 @@ +from hyperscale.core.graphs.stages.base.stage import Stage + + +class HookValidationError(Exception): + + def __init__(self, from_stage: Stage, message: str) -> None: + self.from_stage = from_stage + self.to_stage = None + + super().__init__( + f'Hook Validataion Error - Stage {from_stage.name} of type {from_stage.stage_type.name}:\n{message}' + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/exceptions/missing_reserved_method_error.py b/hyperscale/core/graphs/stages/base/exceptions/missing_reserved_method_error.py new file mode 100644 index 0000000..6bdf404 --- /dev/null +++ b/hyperscale/core/graphs/stages/base/exceptions/missing_reserved_method_error.py @@ -0,0 +1,12 @@ +from hyperscale.core.graphs.stages.base.stage import Stage + + +class MissingReservedMethodError(Exception): + + def __init__(self, from_stage: Stage, reserve_method_name: str) -> None: + self.from_stage = from_stage + self.to_stage = None + + super().__init__( + f'Stage Validation Error - Stage {from_stage.name} of type {from_stage.stage_type.name}:\nThe class method name - {reserve_method_name} - is reserved and required by Hedra but was not found on the Stage.' + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/exceptions/process_killed_error.py b/hyperscale/core/graphs/stages/base/exceptions/process_killed_error.py new file mode 100644 index 0000000..0966835 --- /dev/null +++ b/hyperscale/core/graphs/stages/base/exceptions/process_killed_error.py @@ -0,0 +1,6 @@ +class ProcessKilledError(Exception): + + def __init__(self, *args: object) -> None: + super().__init__( + 'Process killed or aborted.' + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/exceptions/reserved_method_error.py b/hyperscale/core/graphs/stages/base/exceptions/reserved_method_error.py new file mode 100644 index 0000000..914458e --- /dev/null +++ b/hyperscale/core/graphs/stages/base/exceptions/reserved_method_error.py @@ -0,0 +1,12 @@ +from hyperscale.core.graphs.stages.base.stage import Stage + + +class ReservedMethodError(Exception): + + def __init__(self, from_stage: Stage, reserve_method_name: str) -> None: + self.from_stage = from_stage + self.to_stage = None + + super().__init__( + f'Stage Validation Error - Stage {from_stage.name} of type {from_stage.stage_type.name}:\nThe class method name - {reserve_method_name} - is reserved by Hedra. Please choose a different method name.' + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/import_tools.py b/hyperscale/core/graphs/stages/base/import_tools.py new file mode 100644 index 0000000..e10d511 --- /dev/null +++ b/hyperscale/core/graphs/stages/base/import_tools.py @@ -0,0 +1,128 @@ +import importlib +import inspect +import ntpath +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.plugins.types.common.plugin import Plugin +from hyperscale.plugins.types.plugin_types import PluginType + +from .stage import Stage + + +def import_from_path(path: str) -> Any: + package_dir = Path(path).resolve().parent + package_dir_path = str(package_dir) + package_dir_module = package_dir_path.split('/')[-1] + + package = ntpath.basename(path) + package_slug = package.split('.')[0] + spec = importlib.util.spec_from_file_location(f'{package_dir_module}.{package_slug}', path) + + if path not in sys.path: + sys.path.append(str(package_dir.parent)) + + module = importlib.util.module_from_spec(spec) + + sys.modules[module.__name__] = module + + spec.loader.exec_module(module) + + return module + + +def import_stages(path: str) -> Dict[str, Stage]: + + module = import_from_path(path) + direct_decendants = list({cls.__name__: cls for cls in Stage.__subclasses__()}.values()) + + discovered = {} + for name, stage_candidate in inspect.getmembers(module): + if inspect.isclass( + stage_candidate + ) and issubclass( + stage_candidate, + Stage + ) and stage_candidate not in direct_decendants: + discovered[name] = stage_candidate + + return discovered + + +def import_plugins(path: str) -> Dict[PluginType, Dict[str, Plugin]]: + module = import_from_path(path) + direct_decendants = list({cls.__name__: cls for cls in Plugin.__subclasses__()}.values()) + + plugins_by_type = defaultdict(dict) + for name, plugin_candidate in inspect.getmembers(module): + if inspect.isclass( + plugin_candidate + ) and issubclass( + plugin_candidate, + Plugin + ) and plugin_candidate not in direct_decendants: + plugins_by_type[plugin_candidate.type][plugin_candidate.name] = plugin_candidate + + return plugins_by_type + + +def set_stage_hooks(stage: Stage, generated_hooks: Dict[str, Hook]) -> Stage: + methods = inspect.getmembers(stage, predicate=inspect.ismethod) + + for _, method in methods: + method_name = method.__qualname__ + hook_set: List[Hook] = registrar.all.get(method_name, []) + + for hook in hook_set: + + if generated_hooks.get(hook) is None: + hook = hook.copy() + hook._call = hook._call.__get__(stage, stage.__class__) + setattr(stage, hook.shortname, hook._call) + + if inspect.ismethod(hook.call) is False: + hook.call = hook.call.__get__(stage, stage.__class__) + setattr(stage, hook.shortname, hook.call) + + generated_hooks[hook] = 'created' + hook.stage = stage.name + + hook.stage_instance: Stage = stage + + hook.name = f'{hook.stage}.{hook.shortname}' + stage.hooks[hook.hook_type].append(hook) + + + elif generated_hooks.get(hook) == 'created': + + copied_hook = hook.copy() + + stage_config: Dict[str, Any] = stage.to_copy_dict() + copied_stage = type(stage)() + + for copied_attribute_name, copied_attribute_value in stage_config.items(): + if inspect.ismethod(copied_attribute_value) is False: + setattr(copied_stage, copied_attribute_name, copied_attribute_value) + + copied_hook.stage = stage.name + copied_hook.stage_instance: Stage = copied_stage + copied_hook.name = f'{stage.name}.{hook.shortname}' + copied_hook._call = method + + copied_hook.name = f'{copied_hook.stage}.{copied_hook.shortname}' + + copied_hook._call = copied_hook._call.__get__(stage, stage.__class__) + setattr(stage, copied_hook.shortname, copied_hook._call) + + if inspect.ismethod(copied_hook.call) is False: + copied_hook.call = copied_hook.call.__get__(stage, stage.__class__) + setattr(stage, copied_hook.shortname, copied_hook.call) + + if copied_hook not in stage.hooks[hook.hook_type]: + stage.hooks[hook.hook_type].append(copied_hook) + + return stage diff --git a/hyperscale/core/graphs/stages/base/parallel/__init__.py b/hyperscale/core/graphs/stages/base/parallel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/stages/base/parallel/batch_executor.py b/hyperscale/core/graphs/stages/base/parallel/batch_executor.py new file mode 100644 index 0000000..3f260df --- /dev/null +++ b/hyperscale/core/graphs/stages/base/parallel/batch_executor.py @@ -0,0 +1,332 @@ +import asyncio +import math +import multiprocessing +import warnings +from concurrent.futures import ProcessPoolExecutor +from concurrent.futures.process import BrokenProcessPool +from multiprocessing import active_children +from types import FunctionType +from typing import Any, Dict, List, Tuple + +import psutil + +from hyperscale.core.graphs.stages.base.exceptions.process_killed_error import ( + ProcessKilledError, +) + +from .stage_priority import StagePriority +from .synchronization import BatchedSemaphore + + +class BatchExecutor: + + def __init__( + self, + max_workers: int = psutil.cpu_count(logical=False), + start_method: str='spawn' + ) -> None: + + cpu_cores = psutil.cpu_count() + if max_workers > cpu_cores: + max_workers = cpu_cores + + self.max_workers = max_workers + self.loop = asyncio.get_event_loop() + self.start_method = start_method + + self.context = multiprocessing.get_context(start_method) + self.sem = BatchedSemaphore(max_workers) + self.pool = ProcessPoolExecutor(max_workers=max_workers, mp_context=self.context) + self.shutdown_task = None + self.batch_by_stages = False + + async def execute_batches(self, batched_stages: List[Tuple[int, List[Any]]], execution_task: FunctionType) -> List[Tuple[str, Any]]: + + return await asyncio.gather(*[ + asyncio.create_task( + self._execute_stage( + stage_name, + assigned_workers_count, + execution_task, + configs + ) + ) for stage_name, assigned_workers_count, configs in batched_stages + ]) + + + async def _execute_stage( + self, + stage_name: str, + assigned_workers_count: int, + execution_task: FunctionType, + configs: List[List[Any]] + ) -> Tuple[str, Any]: + + await self.sem.acquire(assigned_workers_count) + stage_results = await self.execute_stage_batch( + execution_task, + configs + ) + + self.sem.release(assigned_workers_count) + + return (stage_name, stage_results) + + async def execute_stage_batch( + self, + execution_task: FunctionType, + configs: List[Any] + ): + + try: + return await asyncio.gather(*[ + self.loop.run_in_executor( + self.pool, + execution_task, + config + + ) for config in configs + ]) + + except BrokenProcessPool: + raise ProcessKilledError() + + except KeyboardInterrupt: + raise ProcessKilledError() + + def partion_stage_batches(self, stages: List[Any], ) -> List[Tuple[str, Any, int]]: + + # How many batches do we have? For example -> 5 stages over 4 + # CPUs means 2 batches. The first batch will assign one stage to + # each core. The second will assign all four cores to the remaing + # one stage. + + batches = [] + + if self.batch_by_stages is False: + + stages_count = len(stages) + self.batch_sets_count = math.ceil(stages_count/self.max_workers) + if self.batch_sets_count < 1: + self.batch_sets_count = 1 + + self.more_stages_than_cpus = stages_count/self.max_workers > 1 + + if stages_count%self.max_workers > 0 and stages_count > self.max_workers: + + batch_size = self.max_workers + workers_per_stage = int(self.max_workers/batch_size) + batched_stages = [ + [ + workers_per_stage for _ in range(batch_size) + ] for _ in range(self.batch_sets_count - 1) + ] + + else: + batch_size = int(stages_count/self.batch_sets_count) + workers_per_stage = int(self.max_workers/batch_size) + batched_stages = [ + [ + workers_per_stage for _ in range(batch_size) + ] for _ in range(self.batch_sets_count) + ] + + batches_count = len(batched_stages) + + # If we have a remainder batch - i.e. more stages than cores. + last_batch = batched_stages[batches_count-1] + + last_batch_size = stages_count%self.max_workers + if last_batch_size > 0 and self.more_stages_than_cpus: + last_batch_workers = int(self.max_workers/last_batch_size) + batched_stages.append([ + last_batch_workers for _ in range(last_batch_size) + ]) + + last_batch = batched_stages[self.batch_sets_count-1] + last_batch_size = len(last_batch) + last_batch_remainder = self.max_workers%last_batch_size + + + if last_batch_remainder > 0: + for idx in range(last_batch_remainder): + last_batch[idx] += 1 + + stage_idx = 0 + + for batch in batched_stages: + for stage_idx, stage_workers_count in enumerate(batch): + + stage_name, stage = stages[stage_idx] + + batches.append(( + stage_name, + stage, + stage_workers_count + )) + + else: + + for stage_name, stage in stages: + batches.append(( + stage_name, + stage, + 1 + )) + + return batches + + def partion_prioritized_stage_batches(self, transitions: List[Any], ) -> List[Tuple[str, str, str, int]]: + + # How many batches do we have? For example -> 5 stages over 4 + # CPUs means 2 batches. The first batch will assign one stage to + # each core. The second will assign all four cores to the remaing + # one stage. + + batches = [] + seen_transitions: List[Any] = [] + + + sorted_transitions = list(sorted( + transitions, + key=lambda transition: transition.edge.source.priority_level.value + )) + + + bypass_partition_batch: List[Any] = [] + for transition in sorted_transitions: + if transition.edge.skip_stage or transition.edge.source.allow_parallel is False: + bypass_partition_batch.append(( + transition.edge.source.name, + transition.edge.destination.name, + transition.edge.source.priority, + 0 + )) + + seen_transitions.append(transition) + + batches.append(bypass_partition_batch) + + + stages = { + transition.edge.source.name: transition.edge.source for transition in sorted_transitions + } + + parallel_stages_count = len([ + stage for stage in stages.values() if stage.allow_parallel + ]) + + stages_count = len(stages) + + auto_stages_count = len([ + stage for stage in stages.values() if stage.priority_level == StagePriority.AUTO + ]) + + if parallel_stages_count == 1: + + parallel_transitions = [ + transition for transition in sorted_transitions if transition.edge.skip_stage is False + ] + + transition = parallel_transitions.pop() + + transition_group = [( + transition.edge.source.name, + transition.edge.destination.name, + transition.edge.source.priority, + transition.edge.source.workers + )] + + return [transition_group] + + elif auto_stages_count == stages_count and parallel_stages_count > 0: + transition_group = [ + ( + transition.edge.source.name, + transition.edge.destination.name, + transition.edge.source.priority, + transition.edge.source.workers + ) for transition in sorted_transitions if transition.edge.skip_stage is False + ] + + return [transition_group] + + else: + + min_workers_counts: Dict[str, int] = {} + max_workers_counts: Dict[str, int] = {} + + for transition in sorted_transitions: + + if transition.edge.skip_stage is False and transition.edge.source.allow_parallel: + worker_allocation_range: Tuple[int, int] = StagePriority.get_worker_allocation_range( + transition.edge.source.priority_level, + self.max_workers + ) + + minimum_workers, maximum_workers = worker_allocation_range + min_workers_counts[transition.edge.source.name] = minimum_workers + max_workers_counts[transition.edge.source.name] = maximum_workers + + for transition in sorted_transitions: + + if transition not in seen_transitions: + + + # So for example 8 - 4 = 4 we need another stage with 4 + batch_workers_allocated = max_workers_counts.get(transition.edge.source.name) + transition_group = [( + transition.edge.source.name, + transition.edge.destination.name, + transition.edge.source.priority, + batch_workers_allocated + )] + + for other_transition in sorted_transitions: + + if other_transition != transition and transition not in seen_transitions: + + transition_workers_allocated = max_workers_counts.get(other_transition.edge.source.name) + min_workers = min_workers_counts.get(other_transition.edge.source.name) + + current_allocation = batch_workers_allocated + transition_workers_allocated + + while current_allocation > self.max_workers and transition_workers_allocated >= min_workers: + transition_workers_allocated -= 1 + current_allocation = batch_workers_allocated + transition_workers_allocated + + if current_allocation <= self.max_workers and transition_workers_allocated > 0: + + batch_workers_allocated += transition_workers_allocated + transition_group.append(( + other_transition.edge.source.name, + other_transition.edge.destination.name, + other_transition.edge.source.priority, + transition_workers_allocated + )) + + seen_transitions.append(other_transition) + + batches.append(transition_group) + seen_transitions.append(transition) + + return batches + + async def shutdown(self): + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.pool.shutdown(cancel_futures=True) + + child_processes = active_children() + for child in child_processes: + child.kill() + + def close(self): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.pool.shutdown(cancel_futures=True) + + child_processes = active_children() + for child in child_processes: + child.kill() \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/parallel/partition_method.py b/hyperscale/core/graphs/stages/base/parallel/partition_method.py new file mode 100644 index 0000000..d215e0a --- /dev/null +++ b/hyperscale/core/graphs/stages/base/parallel/partition_method.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class PartitionMethod: + JOB_PER_CORE='JOB_PER_CORE' + BATCHES='BATCHES' \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/parallel/stage_priority.py b/hyperscale/core/graphs/stages/base/parallel/stage_priority.py new file mode 100644 index 0000000..78b21c8 --- /dev/null +++ b/hyperscale/core/graphs/stages/base/parallel/stage_priority.py @@ -0,0 +1,68 @@ +from __future__ import annotations +import math +from enum import Enum +from typing import Dict, Tuple, Union + + +class StagePriority(Enum): + LOW=1 + NORMAL=2 + HIGH=3 + EXCLUSIVE=4 + AUTO=5 + + + + @classmethod + def map(cls, priority: Union[str, None]) -> StagePriority: + priority_map: Dict[str, StagePriority] = { + 'low': cls.LOW, + 'normal': cls.NORMAL, + 'high': cls.HIGH, + 'exclusive': cls.EXCLUSIVE, + 'auto': cls.AUTO + } + + if priority is None: + return StagePriority.AUTO + + return priority_map.get( + priority, + StagePriority.AUTO + ) + + @classmethod + def get_worker_allocation_range( + cls, + priority_type: StagePriority, + pool_size: int + ) -> Tuple[int, int]: + + + weight_map: Dict[StagePriority, Tuple[int, int]] = { + StagePriority.LOW: ( + 1, + math.ceil(pool_size * 0.25) + ), + StagePriority.NORMAL: ( + math.ceil(pool_size * 0.25), + math.ceil(pool_size * 0.75) + ), + StagePriority.HIGH: ( + math.ceil(pool_size * 0.75), + pool_size + ), + StagePriority.EXCLUSIVE: ( + pool_size, + pool_size, + ), + StagePriority.AUTO: ( + 1, + pool_size + ) + } + + return weight_map.get(priority_type) + + + diff --git a/hyperscale/core/graphs/stages/base/parallel/synchronization/__init__.py b/hyperscale/core/graphs/stages/base/parallel/synchronization/__init__.py new file mode 100644 index 0000000..0e995a7 --- /dev/null +++ b/hyperscale/core/graphs/stages/base/parallel/synchronization/__init__.py @@ -0,0 +1 @@ +from .batched_semaphore import BatchedSemaphore \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/parallel/synchronization/batched_semaphore.py b/hyperscale/core/graphs/stages/base/parallel/synchronization/batched_semaphore.py new file mode 100644 index 0000000..f8d6291 --- /dev/null +++ b/hyperscale/core/graphs/stages/base/parallel/synchronization/batched_semaphore.py @@ -0,0 +1,83 @@ +__all__ = ('Lock', 'Event', 'Condition', 'Semaphore', 'BoundedSemaphore') + +import collections + +from asyncio import ( + exceptions, + mixins, +) + + +class BatchedSemaphore(mixins._LoopBoundMixin): + """A Semaphore implementation. + + A semaphore manages an internal counter which is decremented by each + acquire() call and incremented by each release() call. The counter + can never go below zero; when acquire() finds that it is zero, it blocks, + waiting until some other thread calls release(). + + Semaphores also support the context management protocol. + + The optional argument gives the initial value for the internal + counter; it defaults to 1. If the value given is less than 0, + ValueError is raised. + """ + + def __init__(self, value=1, *, loop=None): + super().__init__() + if value < 0: + raise ValueError("Semaphore initial value must be >= 0") + self._value = value + self._waiters = collections.deque() + self._wakeup_scheduled = False + + def __repr__(self): + res = super().__repr__() + extra = 'locked' if self.locked() else f'unlocked, value:{self._value}' + if self._waiters: + extra = f'{extra}, waiters:{len(self._waiters)}' + return f'<{res[1:-1]} [{extra}]>' + + def _wake_up_next(self): + while self._waiters: + waiter = self._waiters.popleft() + if not waiter.done(): + waiter.set_result(None) + self._wakeup_scheduled = True + return + + def locked(self): + """Returns True if semaphore can not be acquired immediately.""" + return self._value == 0 + + async def acquire(self, workers_count: int): + """Acquire a semaphore. + + If the internal counter is larger than zero on entry, + decrement it by one and return True immediately. If it is + zero on entry, block, waiting until some other coroutine has + called release() to make it larger than 0, and then return + True. + """ + # _wakeup_scheduled is set if *another* task is scheduled to wakeup + # but its acquire() is not resumed yet + while self._wakeup_scheduled or self._value <= 0: + fut = self._get_loop().create_future() + self._waiters.append(fut) + try: + await fut + # reset _wakeup_scheduled *after* waiting for a future + self._wakeup_scheduled = False + except exceptions.CancelledError: + self._wake_up_next() + raise + self._value -= workers_count + return True + + def release(self, workers_count: int): + """Release a semaphore, incrementing the internal counter by one. + When it was zero on entry and another coroutine is waiting for it to + become larger than zero again, wake up that coroutine. + """ + self._value += workers_count + self._wake_up_next() \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/base/stage.py b/hyperscale/core/graphs/stages/base/stage.py new file mode 100644 index 0000000..5659340 --- /dev/null +++ b/hyperscale/core/graphs/stages/base/stage.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import os +import threading +import uuid +from collections import defaultdict +from typing import Any, Dict, List, Optional + +from hyperscale.core.engines.client.time_parser import TimeParser +from hyperscale.core.graphs.stages.base.parallel.batch_executor import BatchExecutor +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.base.event_dispatch import EventDispatcher +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.core.hooks.types.internal.decorator import Internal +from hyperscale.logging import HyperscaleLogger +from hyperscale.plugins.types.common.plugin import Plugin +from hyperscale.plugins.types.plugin_types import PluginType + + +class Stage: + stage_type=StageTypes + dependencies: List[Stage]=[] + all_dependencies: List[Stage]=[] + next_context: Any = None + stage_timeout=None + plugins: Dict[str, Plugin] = {} + + def __init__(self) -> None: + self.name = self.__class__.__name__ + self.graph_path: str = None + self.stage_id = str(uuid.uuid4()) + + self.state = StageStates.INITIALIZED + self.next_stage: str = None + self.hooks: Dict[HookType, List[Hook]] = defaultdict(list) + self.accepted_hook_types: List[HookType] = [] + self.workers: int = None + self.worker_id: int = 1 + self.generation_stage_names = [] + self.generation_id = 1 + self.requires_shutdown = False + self.allow_parallel = False + self.executor: BatchExecutor = None + self.plugins_by_type: Dict[PluginType, Dict[str, Plugin]] = defaultdict(dict) + self.total_pool_cpus: int = 1 + self.stage_retries: int = 0 + + self.core_config = {} + self.context: Optional[SimpleContext] = None + self.logger: HyperscaleLogger = HyperscaleLogger() + self.logger.initialize() + + self.graph_name: str = None + self.graph_id: str = None + + if self.stage_timeout: + time_parser = TimeParser(self.stage_timeout) + + self.timeout = time_parser.time + + else: + self.timeout = None + + self.internal_hooks = ['run'] + self.dispatcher: EventDispatcher = EventDispatcher() + self.skip = False + + @Internal() + async def run(self): + pass + + @property + def thread_id(self) -> int: + return threading.current_thread().ident + + @property + def process_id(self) -> int: + return os.getpid() + + @property + def metadata_string(self): + return f'Graph - {self.graph_name}:{self.graph_id} - thread:{self.thread_id} - process:{self.process_id} - Stage: {self.name}:{self.stage_id} - ' + + @Internal() + async def setup_events(self): + + for event in self.dispatcher.events_by_name.values(): + event.context.update(self.context) + + if event.source.context: + event.source.context.update(self.context) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Executing events') + + @Internal() + def to_copy_dict(self) -> Dict[str, Any]: + + copy_dict = {} + + for attr_name, value in self.__dict__.items(): + if not attr_name.startswith('__'): + copy_dict[attr_name] = value + + return copy_dict + diff --git a/hyperscale/core/graphs/stages/complete/__init__.py b/hyperscale/core/graphs/stages/complete/__init__.py new file mode 100644 index 0000000..f9c9729 --- /dev/null +++ b/hyperscale/core/graphs/stages/complete/__init__.py @@ -0,0 +1 @@ +from .complete import Complete \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/complete/complete.py b/hyperscale/core/graphs/stages/complete/complete.py new file mode 100644 index 0000000..8e30b51 --- /dev/null +++ b/hyperscale/core/graphs/stages/complete/complete.py @@ -0,0 +1,21 @@ +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.internal.decorator import Internal + + +class Complete(Stage): + stage_type=StageTypes.COMPLETE + + def __init__(self) -> None: + super().__init__() + + self.retries: int = 0 + self.priority = None + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + @Internal() + async def run(self): + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Graph has reached terminal point') diff --git a/hyperscale/core/graphs/stages/error/__init__.py b/hyperscale/core/graphs/stages/error/__init__.py new file mode 100644 index 0000000..c662cea --- /dev/null +++ b/hyperscale/core/graphs/stages/error/__init__.py @@ -0,0 +1 @@ +from .error import Error \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/error/error.py b/hyperscale/core/graphs/stages/error/error.py new file mode 100644 index 0000000..ad027c2 --- /dev/null +++ b/hyperscale/core/graphs/stages/error/error.py @@ -0,0 +1,57 @@ +import inspect + +from hyperscale.core.graphs.stages.base.exceptions.reserved_method_error import ( + ReservedMethodError, +) +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.internal.decorator import Internal + + +class Error(Stage): + stage_type=StageTypes.ERROR + + def __init__(self) -> None: + super().__init__() + self.error = None + self.retries: int = 0 + + self.priority = None + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + base_stage_name = self.__class__.__name__ + self.logger.filesystem.sync['hyperscale.core'].info(f'{self.metadata_string} - Checking internal Hooks for stage - {base_stage_name}') + + for reserved_hook_name in self.internal_hooks: + try: + + hook = registrar.reserved[base_stage_name].get(reserved_hook_name) + + assert hasattr(self, reserved_hook_name) is True + assert isinstance(hook, Hook) is True + assert hook.hook_type == HookType.INTERNAL + + internal_hook = getattr(self, hook.shortname) + assert inspect.getsource(internal_hook) == inspect.getsource(hook._call) + + except AssertionError: + raise ReservedMethodError(self, reserved_hook_name) + + hook._call = hook._call.__get__(self, self.__class__) + setattr(self, reserved_hook_name, hook._call) + + self.logger.filesystem.sync['hyperscale.core'].info(f'{self.metadata_string} - Loading internal Hook - {hook.name} - for stage - {base_stage_name}') + + @Internal() + async def run(self): + await self.logger.spinner.system.error(f'{self.metadata_string} - Encountered error - {self.error}') + await self.logger.filesystem.aio['hyperscale.core'].error(f'{self.metadata_string} - Encountered error - {self.error}') + + + diff --git a/hyperscale/core/graphs/stages/execute/__init__.py b/hyperscale/core/graphs/stages/execute/__init__.py new file mode 100644 index 0000000..f9be9ad --- /dev/null +++ b/hyperscale/core/graphs/stages/execute/__init__.py @@ -0,0 +1 @@ +from .execute import Execute \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/execute/execute.py b/hyperscale/core/graphs/stages/execute/execute.py new file mode 100644 index 0000000..926aa4a --- /dev/null +++ b/hyperscale/core/graphs/stages/execute/execute.py @@ -0,0 +1,597 @@ + +import statistics +import time +from collections import defaultdict +from typing import Any, Dict, Generic, List, Optional, Union + +import dill +from typing_extensions import TypeVarTuple, Unpack + +from hyperscale.core.engines.client import Client +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.playwright import ( + ContextConfig, + MercuryPlaywrightClient, +) +from hyperscale.core.engines.types.registry import registered_engines +from hyperscale.core.graphs.stages.base.parallel.partition_method import PartitionMethod +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.condition.decorator import condition +from hyperscale.core.hooks.types.context.decorator import context +from hyperscale.core.hooks.types.event.decorator import event +from hyperscale.core.hooks.types.internal.decorator import Internal +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.persona_registry import ( + DefaultPersona, + get_persona, + registered_personas, +) +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.data.serializers import Serializer +from hyperscale.logging import logging_manager +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.plugins.types.extension.extension_plugin import ExtensionPlugin +from hyperscale.plugins.types.extension.types import ExtensionType +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.reporting.reporter import ReporterConfig +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + +from .parallel import execute_actions + +MonitorResults = Dict[str, List[Union[int, float]]] + + +T = TypeVarTuple('T') + + +class Execute(Stage, Generic[Unpack[T]]): + stage_type=StageTypes.EXECUTE + priority: Optional[str]=None + retries: int=0 + + def __init__(self) -> None: + super().__init__() + self.persona = None + self.client: Client[Unpack[T]] = Client( + self.graph_name, + self.graph_id, + self.name, + self.stage_id + ) + + self.accepted_hook_types = [ + HookType.ACTION, + HookType.CHANNEL, + HookType.CHECK, + HookType.CONDITION, + HookType.CONTEXT, + HookType.EVENT, + HookType.TASK, + HookType.TRANSFORM + ] + + self.concurrent_pool_aware_stages = 0 + self.execution_stage_id = 0 + self.optimized = False + self.execute_setup_stage = None + self.requires_shutdown = True + self.allow_parallel = True + + self.source_internal_events = [ + 'get_stage_config' + ] + + self.internal_events = [ + 'get_stage_config', + 'get_stage_plugins', + 'check_has_multiple_workers', + 'run_multiple_worker_jobs', + 'aggregate_multiple_worker_results', + 'setup_single_worker_job', + 'run_single_worker_job', + 'complete' + ] + + self.priority = self.priority + if self.priority is None: + self.priority = 'auto' + + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + self.stage_retries = self.retries + self.serializer = Serializer() + + @Internal() + async def run(self): + await self.setup_events() + self.dispatcher.assemble_execution_graph() + await self.dispatcher.dispatch_events(self.name) + + @context() + async def get_stage_config( + self, + execute_stage_stream_configs: List[ReporterConfig] = [], + execute_stage_setup_config: Config=None, + execute_stage_setup_by: str=None, + execute_stage_setup_hooks: List[Union[ActionHook , TaskHook]] = [] + ): + self.context.ignore_serialization_filters = [ + 'execute_stage_setup_hooks', + 'setup_stage_ready_stages', + 'execute_stage_results', + 'execute_stage_streamed_analytics', + 'execute_stage_monitors', + 'session_stage_monitors', + 'execute_stage_loaded_actions' + ] + persona_type_name = '-'.join([ + segment.capitalize() for segment in execute_stage_setup_config.persona_type.split('-') + ]) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Executing - {execute_stage_setup_config.batch_size} - VUs over {self.workers} threads for {execute_stage_setup_config.total_time_string} using - {persona_type_name} - persona') + await self.logger.spinner.append_message(f'Stage {self.name} executing - {execute_stage_setup_config.batch_size} - VUs over {self.workers} threads for {execute_stage_setup_config.total_time_string} using - {persona_type_name} - persona') + + main_monitor_name = f'{self.name}.main' + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + cpu_monitor.visibility_filters[main_monitor_name] = False + memory_monitor.visibility_filters[main_monitor_name] = False + + cpu_monitor.stage_type = StageTypes.EXECUTE + memory_monitor.stage_type = StageTypes.EXECUTE + cpu_monitor.is_execute_stage = True + memory_monitor.is_execute_stage = True + + await cpu_monitor.start_background_monitor(main_monitor_name) + await memory_monitor.start_background_monitor(main_monitor_name) + + return { + 'execute_stage_stream_configs': execute_stage_stream_configs, + 'execute_stage_setup_hooks': execute_stage_setup_hooks, + 'execute_stage_setup_by': execute_stage_setup_by, + 'execute_stage_setup_config': execute_stage_setup_config, + 'execute_stage_monitors': { + 'cpu': cpu_monitor, + 'memory': memory_monitor + } + } + + @event('get_stage_config') + async def collect_loaded_actions(self): + + loaded_actions: List[Union[ActionHook, TaskHook]] = [] + + ignore_context_keys = set() + + for context_key, value in self.context.items(): + + if context_key != 'execute_stage_setup_hooks': + + if isinstance(value, ActionHook): + ignore_context_keys.add(context_key) + loaded_actions.append(value) + + elif isinstance(value, list): + for item in value: + if isinstance(item, ActionHook): + ignore_context_keys.add(context_key) + loaded_actions.append(item) + + elif isinstance(value, dict): + for item in value.values(): + if isinstance(item, ActionHook): + ignore_context_keys.add(context_key) + loaded_actions.append(item) + + + self.context.ignore_serialization_filters.extend( + list(ignore_context_keys) + ) + + return { + 'execute_stage_loaded_actions': loaded_actions + } + + @event('get_stage_config') + async def get_stage_plugins(self): + + engine_plugins = self.plugins_by_type.get(PluginType.ENGINE) + for plugin_name, plugin in engine_plugins.items(): + registered_engines[plugin_name] = plugin + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Loaded Engine plugin - {plugin.name}') + + persona_plugins = self.plugins_by_type.get(PluginType.PERSONA) + for plugin_name, plugin in persona_plugins.items(): + registered_personas[plugin_name] = lambda plugin_config: plugin(plugin_config) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Loaded Persona plugin - {plugin.name}') + + execute_stage_extensions: Dict[str, ExtensionPlugin] = {} + + source_stage_plugins = defaultdict(list) + for plugin in self.plugins.values(): + source_stage_plugins[plugin.type].append(plugin.name) + + return { + 'execute_stage_plugins': source_stage_plugins, + 'execute_stage_extensions': execute_stage_extensions + } + + @event('get_stage_config') + async def get_stage_experiment( + self, + execute_stage_setup_config: Config=None, + ): + + experiment = execute_stage_setup_config.experiment + + if experiment: + + mutations = [] + + if execute_stage_setup_config.mutations: + mutations = [ + { + 'mutation_name': mutation.name, + 'mutation_chance': mutation.chance, + 'mutation_targets': mutation.targets, + 'mutation_type': mutation.mutation_type.name.lower() + } for mutation in execute_stage_setup_config.mutations + ] + + + return { + 'execute_stage_experiment': { + 'experiment_name': experiment.get('experiment_name'), + 'experiment_randomized': experiment.get('random'), + 'experiment_variant': { + 'variant_stage': self.name, + 'variant_weight': experiment.get('weight'), + 'variant_distribution_intervals': experiment.get('intervals'), + 'variant_distribution': experiment.get('distribution'), + 'variant_distribution_type': experiment.get('distribution_type'), + 'variant_distribution_interval_duration': experiment.get('interval_duration'), + 'variant_mutations': mutations + } + } + } + + @condition( + 'collect_loaded_actions', + 'get_stage_plugins', + 'get_stage_experiment' + ) + async def check_has_multiple_workers(self): + return { + 'execute_stage_has_multiple_workers': self.total_pool_cpus > 1 + } + + @event('check_has_multiple_workers') + async def run_multiple_worker_jobs( + self, + execute_stage_loaded_actions: List[ActionHook]=[], + execute_stage_has_multiple_workers: bool = False, + execute_stage_setup_config: Config=None, + execute_stage_plugins: Dict[str, List[Any]]={}, + execute_stage_setup_by: str=None, + execute_stage_stream_configs: List[ReporterConfig] = [] + ): + loaded_actions: List[str] = [] + for action_hook in execute_stage_loaded_actions: + loaded_actions.append( + self.serializer.serialize_action(action_hook) + ) + + if execute_stage_has_multiple_workers: + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Starting execution for - {self.workers} workers') + + serializable_context = self.context.as_serializable() + results_sets = await self.executor.execute_stage_batch( + execute_actions, + [ + dill.dumps({ + 'graph_name': self.graph_name, + 'graph_path': self.graph_path, + 'graph_id': self.graph_id, + 'enable_unstable_features': active_flags[FlagTypes.UNSTABLE_FEATURE], + 'source_stage_name': self.name, + 'logfiles_directory': logging_manager.logfiles_directory, + 'log_level': logging_manager.log_level_name, + 'source_stage_context': { + context_key: context_value for context_key, context_value in serializable_context + }, + 'source_stage_loaded_actions': loaded_actions, + 'source_setup_stage_name': execute_stage_setup_by, + 'source_stage_id': self.stage_id, + 'source_stage_plugins': execute_stage_plugins, + 'source_stage_config': execute_stage_setup_config, + 'source_stage_stream_configs': execute_stage_stream_configs, + 'partition_method': PartitionMethod.BATCHES, + 'workers': self.workers, + 'worker_id': idx + 1 + }) for idx in range(self.workers) + ] + ) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Completed execution for - {self.workers} workers') + + + return { + 'execute_stage_results': results_sets + } + + @context('run_multiple_worker_jobs') + async def aggregate_multiple_worker_results( + self, + execute_stage_has_multiple_workers: bool = False, + execute_stage_results: List[Dict[str, Any]]=[], + execute_stage_experiment: Optional[Dict[str, Any]]=None, + execute_stage_setup_config: Config=None, + execute_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]]={} + ): + if execute_stage_has_multiple_workers: + execute_stage_streamed_analytics: List[StreamAnalytics] = [] + aggregate_results = [] + elapsed_times = [] + stage_contexts = defaultdict(list) + + stage_cpu_monitor: CPUMonitor = execute_stage_monitors.get('cpu') + stage_memory_monitor: MemoryMonitor = execute_stage_monitors.get('memory') + + for result_set in execute_stage_results: + + aggregate_results.extend(result_set.get('results')) + elapsed_times.append(result_set.get('total_elapsed')) + worker_id = result_set.get('worker_id') + + streamed_analytics = result_set.get('streamed_analytics') + if streamed_analytics: + execute_stage_streamed_analytics.append(streamed_analytics) + + pipeline_context: Dict[str, Any] = result_set.get('context', {}) + for context_key, context_value in pipeline_context.items(): + stage_contexts[context_key].append(context_value) + + monitors: Dict[str, MonitorResults] = result_set.get('monitoring', {}) + cpu_monitor = monitors.get('cpu', {}) + memory_monitor = monitors.get('memory', {}) + + for monitor_name, collection_stats in cpu_monitor.items(): + stage_cpu_monitor.worker_metrics[worker_id][monitor_name] = collection_stats + stage_cpu_monitor.collected[monitor_name].extend(collection_stats) + + for monitor_name, collection_stats in memory_monitor.items(): + stage_memory_monitor.worker_metrics[worker_id][monitor_name] = collection_stats + + for monitor_name, collection_stats in cpu_monitor.items(): + stage_cpu_monitor.visibility_filters[monitor_name] = True + + for monitor_name, collection_stats in memory_monitor.items(): + stage_memory_monitor.visibility_filters[monitor_name] = True + + stage_cpu_monitor.aggregate_worker_stats() + stage_memory_monitor.aggregate_worker_stats() + + main_monitor_name = f'{self.name}.main' + + await stage_cpu_monitor.stop_background_monitor(main_monitor_name) + await stage_memory_monitor.stop_background_monitor(main_monitor_name) + + stage_cpu_monitor.close() + stage_memory_monitor.close() + + stage_cpu_monitor.stage_metrics[main_monitor_name] = stage_cpu_monitor.collected[main_monitor_name] + stage_memory_monitor.stage_metrics[main_monitor_name] = stage_memory_monitor.collected[main_monitor_name] + + total_results = len(aggregate_results) + total_elapsed = statistics.mean(elapsed_times) + + await self.logger.filesystem.aio['hyperscale.core'].info( f'{self.metadata_string} - Completed - {total_results} actions at {round(total_results/total_elapsed)} actions/second over {round(total_elapsed)} seconds') + await self.logger.spinner.set_default_message(f'Stage - {self.name} completed {total_results} actions at {round(total_results/total_elapsed)} actions/second over {round(total_elapsed)} seconds') + + stage_name = self.name.lower() + + return { + stage_name: stage_contexts, + 'execute_stage_streamed_analytics': execute_stage_streamed_analytics, + 'execute_stage_results': ResultsSet({ + 'stage': self.name, + 'streamed_analytics': execute_stage_streamed_analytics, + 'stage_batch_size': execute_stage_setup_config.batch_size, + 'stage_persona_type': execute_stage_setup_config.persona_type, + 'stage_workers': self.workers, + 'stage_optimized': self.optimized, + 'stage_results': aggregate_results, + 'total_results': total_results, + 'total_elapsed': total_elapsed, + 'experiment': execute_stage_experiment + }), + 'execute_stage_monitors': { + self.name: { + 'cpu': stage_cpu_monitor, + 'memory': stage_memory_monitor + } + } + } + + @event('check_has_multiple_workers') + async def setup_single_worker_job( + self, + execute_stage_loaded_actions: List[ActionHook]=[], + execute_stage_has_multiple_workers: bool = False, + execute_stage_setup_config: Config=None, + execute_stage_extensions: Dict[str, ExtensionPlugin]={} + ): + + + self.hooks[HookType.ACTION].extend(execute_stage_loaded_actions) + + if execute_stage_has_multiple_workers is False: + + for extension in execute_stage_extensions.values(): + + if extension.extension_type == ExtensionType.GENERATOR: + results = await extension.execute(**{ + 'execute_stage_name': self.name, + 'execute_stage_hooks': self.hooks, + 'persona_config': execute_stage_setup_config + }) + + execute_stage = results.get('execute_stage') + + if execute_stage: + self.hooks = results.get('execute_stage_hooks') + + persona_config = execute_stage_setup_config + persona = get_persona(persona_config) + persona.setup(self.hooks, self.metadata_string) + + action_and_task_hooks: List[Union[ActionHook, TaskHook]] = [ + *self.hooks[HookType.ACTION], + *self.hooks[HookType.TASK] + ] + + for hook in action_and_task_hooks: + if hook.action.type == RequestTypes.PLAYWRIGHT and isinstance(hook.session, MercuryPlaywrightClient): + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Setting up Playwright Session') + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Browser Type: {persona_config.browser_type}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Device Type: {persona_config.device_type}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Locale: {persona_config.locale}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - geolocation: {persona_config.geolocation}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Permissions: {persona_config.permissions}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Color Scheme: {persona_config.color_scheme}') + + + await hook.session.setup( + config=ContextConfig( + browser_type=persona_config.browser_type, + device_type=persona_config.device_type, + locale=persona_config.locale, + geolocation=persona_config.geolocation, + permissions=persona_config.permissions, + color_scheme=persona_config.color_scheme, + options=persona_config.playwright_options + ) + ) + + return { + 'execute_stage_persona': persona + } + + + @context('setup_single_worker_job') + async def run_single_worker_job( + self, + execute_stage_has_multiple_workers: bool = False, + execute_stage_persona: DefaultPersona=None, + execute_stage_experiment: Optional[Dict[str, Any]]=None, + execute_stage_setup_config: Config=None, + execute_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]]={} + ): + if execute_stage_has_multiple_workers is False: + + execution_results = {} + + stage_cpu_monitor: CPUMonitor = execute_stage_monitors.get('cpu') + stage_memory_monitor: MemoryMonitor = execute_stage_monitors.get('memory') + + main_monitor_name = f'{self.name}.main' + + start = time.monotonic() + + results = await execute_stage_persona.execute() + + elapsed = time.monotonic() - start + + await stage_cpu_monitor.stop_background_monitor(main_monitor_name) + await stage_memory_monitor.stop_background_monitor(main_monitor_name) + + stage_cpu_monitor.close() + stage_memory_monitor.close() + + elapsed_idx = int(elapsed) + + stage_cpu_monitor.trim_monitor_samples( + main_monitor_name, + elapsed_idx + ) + + stage_memory_monitor.trim_monitor_samples( + main_monitor_name, + elapsed_idx + ) + + await self.logger.filesystem.aio['hyperscale.core'].info( + f'{self.metadata_string} - Execution complete - Time (including addtional setup) took: {round(elapsed, 2)} seconds' + ) + + total_results = len(results) + total_elapsed = execute_stage_persona.total_elapsed + + await stage_cpu_monitor.stop_background_monitor(main_monitor_name) + await stage_memory_monitor.stop_background_monitor(main_monitor_name) + + stage_cpu_monitor.close() + stage_memory_monitor.close() + + stage_cpu_monitor.collected.update( + execute_stage_persona.cpu_monitor.collected + ) + + stage_memory_monitor.collected.update( + execute_stage_persona.memory_monitor.collected + ) + + stage_cpu_monitor.stage_metrics[main_monitor_name] = stage_cpu_monitor.collected[main_monitor_name] + stage_memory_monitor.stage_metrics[main_monitor_name] = stage_memory_monitor.collected[main_monitor_name] + + + await self.logger.filesystem.aio['hyperscale.core'].info( f'{self.metadata_string} - Completed - {total_results} actions at {round(total_results/total_elapsed)} actions/second over {round(total_elapsed)} seconds') + await self.logger.spinner.set_default_message(f'Stage - {self.name} completed {total_results} actions at {round(total_results/total_elapsed)} actions/second over {round(total_elapsed)} seconds') + + if self.executor: + await self.executor.shutdown() + + execution_results.update({ + 'execute_stage_streamed_analytics': [ + execute_stage_persona.streamed_analytics + ], + 'execute_stage_results': ResultsSet({ + 'stage': self.name, + 'streamed_analytics': [ + execute_stage_persona.streamed_analytics + ], + 'stage_batch_size': execute_stage_setup_config.batch_size, + 'stage_persona_type': execute_stage_setup_config.persona_type, + 'stage_workers': self.workers, + 'stage_optimized': self.optimized, + 'stage_results': results, + 'total_results': total_results, + 'total_elapsed': total_elapsed, + 'experiment': execute_stage_experiment + }), + 'execute_stage_monitors': { + self.name: { + 'cpu': stage_cpu_monitor, + 'memory': stage_memory_monitor + } + } + }) + + return execution_results + + @event('aggregate_multiple_worker_results', 'setup_single_worker_job') + async def complete(self): + return {} \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/execute/parallel/__init__.py b/hyperscale/core/graphs/stages/execute/parallel/__init__.py new file mode 100644 index 0000000..20ab8ac --- /dev/null +++ b/hyperscale/core/graphs/stages/execute/parallel/__init__.py @@ -0,0 +1 @@ +from .execute_actions import execute_actions \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/execute/parallel/execute_actions.py b/hyperscale/core/graphs/stages/execute/parallel/execute_actions.py new file mode 100644 index 0000000..8cab1c7 --- /dev/null +++ b/hyperscale/core/graphs/stages/execute/parallel/execute_actions.py @@ -0,0 +1,447 @@ +import asyncio +import gc +import os +import pickle +import signal +import threading +import time +import warnings +from collections import defaultdict +from typing import Any, Dict, List, Type, Union + +import dill + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.playwright import ( + ContextConfig, + MercuryPlaywrightClient, +) +from hyperscale.core.engines.types.registry import RequestTypes, registered_engines +from hyperscale.core.graphs.stages.base.exceptions.process_killed_error import ( + ProcessKilledError, +) +from hyperscale.core.graphs.stages.base.import_tools import ( + import_plugins, + import_stages, + set_stage_hooks, +) +from hyperscale.core.graphs.stages.base.parallel.partition_method import PartitionMethod +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.setup.setup import Setup +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.event_graph import EventGraph +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.core.hooks.types.load.hook import LoadHook +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas import get_persona +from hyperscale.core.personas.persona_registry import registered_personas +from hyperscale.data.serializers import Serializer +from hyperscale.logging import HyperscaleLogger, LoggerTypes, logging_manager +from hyperscale.plugins.types.engine.engine_plugin import EnginePlugin +from hyperscale.plugins.types.extension.extension_plugin import ExtensionPlugin +from hyperscale.plugins.types.persona.persona_plugin import PersonaPlugin +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.reporting.reporter import ReporterConfig +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + +warnings.simplefilter("ignore") +warnings.filterwarnings("ignore") + + +async def start_execution( + metadata_string: str=None, + persona_config: Config=None, + setup_stage: Setup=None, + workers: int=None, + worker_id: int=None, + source_stage_name: str=None, + source_stage_stream_configs: List[ReporterConfig]=[], + logfiles_directory: str=None, + log_level: str=None, + extensions: Dict[str, ExtensionPlugin]={}, + loaded_actions: List[str]=[] +) -> Dict[str, Any]: + + current_task = asyncio.current_task() + + logging_manager.disable( + LoggerTypes.DISTRIBUTED, + LoggerTypes.DISTRIBUTED_FILESYSTEM, + LoggerTypes.SPINNER + ) + + logging_manager.update_log_level(log_level) + logging_manager.logfiles_directory = logfiles_directory + + + logger = HyperscaleLogger() + logger.logger_directory = logfiles_directory + logger.initialize() + await logger.filesystem.aio.create_logfile('hyperscale.core.log') + logger.filesystem.create_filelogger('hyperscale.core.log') + + start = time.monotonic() + + await setup_stage.run_internal() + + stages: Dict[str, Stage] = setup_stage.context['setup_stage_ready_stages'] + + setup_execute_stage: Stage = stages.get(source_stage_name) + setup_execute_stage.logger = logger + + persona = get_persona(persona_config) + persona.stream_reporter_configs = source_stage_stream_configs + persona.workers = workers + + actions_and_tasks: List[Union[ActionHook, TaskHook]] = [] + + serializer = Serializer() + if len(loaded_actions) > 0: + for serialized_action in loaded_actions: + actions_and_tasks.append( + serializer.deserialize_action(serialized_action) + ) + + actions_and_tasks.extend( + setup_stage.context['execute_stage_setup_hooks'].get(source_stage_name, []) + ) + + execution_hooks_count = len(actions_and_tasks) + await logger.filesystem.aio['hyperscale.core'].info( + f'{metadata_string} - Executing {execution_hooks_count} actions with a batch size of {persona_config.batch_size} for {persona_config.total_time} seconds using Persona - {persona.type.capitalize()}' + ) + + for hook in actions_and_tasks: + + if hook.action.type == RequestTypes.PLAYWRIGHT and isinstance(hook.session, MercuryPlaywrightClient): + + await logger.filesystem.aio['hyperscale.core'].info(f'{metadata_string} - Setting up Playwright Session') + + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Browser Type: {persona_config.browser_type}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Device Type: {persona_config.device_type}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Locale: {persona_config.locale}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - geolocation: {persona_config.geolocation}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Permissions: {persona_config.permissions}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Color Scheme: {persona_config.color_scheme}') + + config = hook.session.config + if config is None: + config = ContextConfig( + browser_type=persona_config.browser_type, + device_type=persona_config.device_type, + locale=persona_config.locale, + geolocation=persona_config.geolocation, + permissions=persona_config.permissions, + color_scheme=persona_config.color_scheme, + options=persona_config.playwright_options + ) + + await hook.session.setup( + config=config + ) + + for load_hook in setup_execute_stage.hooks[HookType.LOAD]: + load_hook: LoadHook = load_hook + load_hook.parser_config = setup_stage.config + + pipeline_stages = { + setup_execute_stage.name: setup_execute_stage + } + + hooks_by_type: Dict[ + HookType, + Union[ + List[ActionHook], + List[TaskHook] + ] + ] = defaultdict(list) + + for hook in actions_and_tasks: + hooks_by_type[hook.hook_type].append(hook) + + persona.setup(hooks_by_type, metadata_string) + + persona.cpu_monitor.stage_type = StageTypes.EXECUTE + persona.memory_monitor.stage_type = StageTypes.EXECUTE + + await logger.filesystem.aio['hyperscale.core'].info(f'{metadata_string} - Starting execution') + + results = await persona.execute() + + elapsed = time.monotonic() - start + + await logger.filesystem.aio['hyperscale.core'].info(f'{metadata_string} - Execution complete - Time (including addtional setup) took: {round(elapsed, 2)} seconds') + + context = {} + + for stage in pipeline_stages.values(): + + for key, value in stage.context.as_serializable(): + try: + dill.dumps(value) + + except ValueError: + stage.context.ignore_serialization_filters.append(key) + + except TypeError: + stage.context.ignore_serialization_filters.append(key) + + except pickle.PicklingError: + stage.context.ignore_serialization_filters.append(key) + + serializable_context = stage.context.as_serializable() + + context.update({ + context_key: context_value for context_key, context_value in serializable_context + }) + + for idx, result in enumerate(results): + results[idx] = dill.dumps(result) + + + results_dict = { + 'worker_idx': worker_id, + 'streamed_analytics': persona.streamed_analytics, + 'results': results, + 'total_results': len(results), + 'total_elapsed': persona.total_elapsed, + 'context': context, + 'monitoring': { + 'memory': persona.memory_monitor.collected, + 'cpu': persona.cpu_monitor.collected + } + } + + pending_tasks = [ + task for task in asyncio.all_tasks() if task != current_task + ] + + for task in pending_tasks: + if not task.cancelled(): + task.cancel() + + return results_dict + + +def execute_actions(parallel_config: str): + + import asyncio + + import uvloop + uvloop.install() + + from hyperscale.logging import logging_manager + + try: + loop = asyncio.get_event_loop() + except Exception: + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + def handle_loop_stop(signame): + try: + pending_events = asyncio.all_tasks() + + for task in pending_events: + if not task.cancelled(): + try: + task.cancel() + except asyncio.CancelledError: + pass + except asyncio.InvalidStateError: + pass + + loop.close() + gc.collect() + + except BrokenPipeError: + pass + + except RuntimeError: + pass + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop(signame) + ) + + try: + parallel_config: Dict[str, Any] = dill.loads(parallel_config) + + graph_name = parallel_config.get('graph_name') + graph_path: str= parallel_config.get('graph_path') + graph_id = parallel_config.get('graph_id') + enable_unstable_features = parallel_config.get('enable_unstable_features', False) + + logfiles_directory = parallel_config.get('logfiles_directory') + log_level = parallel_config.get('log_level') + source_stage_name = parallel_config.get('source_stage_name') + source_stage_context: Dict[str, Any] = parallel_config.get('source_stage_context') + source_stage_plugins = parallel_config.get('source_stage_plugins') + source_stage_config: Config = parallel_config.get('source_stage_config') + source_stage_id = parallel_config.get('source_stage_id') + source_setup_stage_name = parallel_config.get('source_setup_stage_name') + source_stage_stream_configs = parallel_config.get('source_stage_stream_configs') + source_stage_loaded_actions = parallel_config.get('source_stage_loaded_actions') + partition_method = parallel_config.get('partition_method') + worker_id = parallel_config.get('worker_id') + workers = parallel_config.get('workers') + + thread_id = threading.current_thread().ident + process_id = os.getpid() + + active_flags[FlagTypes.UNSTABLE_FEATURE] = enable_unstable_features + + logging_manager.disable( + LoggerTypes.DISTRIBUTED, + LoggerTypes.DISTRIBUTED_FILESYSTEM, + LoggerTypes.SPINNER + ) + + logging_manager.update_log_level(log_level) + logging_manager.logfiles_directory = logfiles_directory + + metadata_string = f'Graph - {graph_name}:{graph_id} - thread:{thread_id} - process:{process_id} - Stage: {source_stage_name}:{source_stage_id} - ' + + + discovered: Dict[str, Stage] = import_stages(graph_path) + plugins_by_type = import_plugins(graph_path) + + initialized_stages = {} + hooks_by_type = defaultdict(dict) + hooks_by_name = {} + hooks_by_shortname = defaultdict(dict) + + generated_hooks = {} + for stage in discovered.values(): + stage: Stage = stage() + + if stage.context is None: + stage.context = SimpleContext() + + stage.graph_name = graph_name + stage.graph_path = graph_path + stage.graph_id = graph_id + + for hook_shortname, hook in registrar.reserved[stage.name].items(): + hook._call = hook._call.__get__(stage, stage.__class__) + setattr(stage, hook_shortname, hook._call) + + initialized_stage = set_stage_hooks( + stage, + generated_hooks + ) + + for hook_type in initialized_stage.hooks: + + for hook in initialized_stage.hooks[hook_type]: + hook.stage_instance = initialized_stage + hooks_by_type[hook_type][hook.name] = hook + hooks_by_name[hook.name] = hook + hooks_by_shortname[hook_type][hook.shortname] = hook + + initialized_stages[initialized_stage.name] = initialized_stage + + execute_stage: Stage = initialized_stages.get(source_stage_name) + execute_stage.context.update(source_stage_context) + + setup_stage: Setup = initialized_stages.get(source_setup_stage_name) + setup_stage.context.update(source_stage_context) + + if partition_method == PartitionMethod.BATCHES and source_stage_config.optimized is False: + if workers == worker_id: + source_stage_config.batch_size = int(source_stage_config.batch_size/workers) + (source_stage_config.batch_size%workers) + + if source_stage_config.experiment: + distribution = source_stage_config.experiment.get('distribution') + + for idx, distribution_value in enumerate(distribution): + distribution[idx] = int(distribution_value/workers) + (distribution_value%workers) + + source_stage_config.experiment['distribution'] = distribution + + else: + source_stage_config.batch_size = int(source_stage_config.batch_size/workers) + + if source_stage_config.experiment: + distribution = source_stage_config.experiment.get('distribution') + + for idx, distribution_value in enumerate(distribution): + distribution[idx] = int(distribution_value/workers) + + source_stage_config.experiment['distribution'] = distribution + + stage_persona_plugins: List[str] = source_stage_plugins[PluginType.PERSONA] + persona_plugins: Dict[str, Type[PersonaPlugin]] = plugins_by_type[PluginType.PERSONA] + + stage_engine_plugins: List[str] = source_stage_plugins[PluginType.ENGINE] + engine_plugins: Dict[str, Type[EnginePlugin]] = plugins_by_type[PluginType.ENGINE] + + stage_extension_plugins: List[str] = source_stage_plugins[PluginType.EXTENSION] + + enabled_extensions: Dict[str, ExtensionPlugin] = {} + + for plugin_name in stage_persona_plugins: + plugin = persona_plugins.get(plugin_name) + plugin.name = plugin_name + registered_personas[plugin_name] = lambda config: plugin(config) + + for plugin_name in stage_engine_plugins: + plugin = engine_plugins.get(plugin_name) + plugin.name = plugin_name + registered_engines[plugin_name] = lambda config: plugin(config) + + events_graph = EventGraph(hooks_by_type) + events_graph.hooks_to_events().assemble_graph().apply_graph_to_events() + + for stage in initialized_stages.values(): + stage.dispatcher.assemble_action_and_task_subgraphs() + + if setup_stage.context is None: + setup_stage.context = SimpleContext() + + setup_stage.config = source_stage_config + setup_stage.generation_setup_candidates = 1 + setup_stage.config = source_stage_config + setup_stage.context['setup_stage_is_primary_thread'] = False + setup_stage.context['setup_stage_target_config'] = source_stage_config + setup_stage.context['setup_stage_target_stages'] = { + execute_stage.name: execute_stage + } + + results = loop.run_until_complete( + start_execution( + metadata_string=metadata_string, + persona_config=source_stage_config, + setup_stage=setup_stage, + workers=workers, + worker_id=worker_id, + source_stage_name=source_stage_name, + source_stage_stream_configs=source_stage_stream_configs, + logfiles_directory=logfiles_directory, + log_level=log_level, + extensions=enabled_extensions, + loaded_actions=source_stage_loaded_actions + ) + ) + + + loop.close() + gc.collect() + + return results + + except BrokenPipeError: + raise ProcessKilledError() + + except RuntimeError: + raise ProcessKilledError() + + except Exception as e: + raise e \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/idle/__init__.py b/hyperscale/core/graphs/stages/idle/__init__.py new file mode 100644 index 0000000..4285090 --- /dev/null +++ b/hyperscale/core/graphs/stages/idle/__init__.py @@ -0,0 +1 @@ +from .idle import Idle \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/idle/idle.py b/hyperscale/core/graphs/stages/idle/idle.py new file mode 100644 index 0000000..3adb329 --- /dev/null +++ b/hyperscale/core/graphs/stages/idle/idle.py @@ -0,0 +1,24 @@ +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.internal.decorator import Internal + + +class Idle(Stage): + stage_type=StageTypes.IDLE + + def __init__(self) -> None: + super().__init__() + self.name = self.__class__.__name__ + self.priority = None + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + self.retries: int = 0 + + @Internal() + async def run(self): + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Starting graph execution') + + diff --git a/hyperscale/core/graphs/stages/optimize/__init__.py b/hyperscale/core/graphs/stages/optimize/__init__.py new file mode 100644 index 0000000..82cd3c7 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/__init__.py @@ -0,0 +1 @@ +from .optimize import Optimize \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/__init__.py b/hyperscale/core/graphs/stages/optimize/optimization/__init__.py new file mode 100644 index 0000000..04238e4 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/__init__.py @@ -0,0 +1,2 @@ +from .optimizer import Optimizer +from .distribution_fit_optimizer import DistributionFitOptimizer \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/__init__.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/__init__.py new file mode 100644 index 0000000..7992990 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/__init__.py @@ -0,0 +1,33 @@ +from typing import ( + List, + Tuple, + Union, + Dict, + Optional +) +from .types import ( + SHGOptimizer, + DualAnnealingOptimizer, + DifferentialEvolutionOptimizer +) + + +registered_algorithms = { + 'shg': SHGOptimizer, + 'dual-annealing': DualAnnealingOptimizer, + 'diff-evolution': DifferentialEvolutionOptimizer +} + + +def get_algorithm( + algorithm_type: str, + config: Dict[str, Union[List[Tuple[Union[int, float]]], int]], + distribution_idx: Optional[int]=None +): + return registered_algorithms.get( + algorithm_type, + SHGOptimizer + )( + config, + distribution_idx=distribution_idx + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/__init__.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/__init__.py new file mode 100644 index 0000000..9423323 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/__init__.py @@ -0,0 +1,4 @@ +from .differential_evolution_optimizer import DifferentialEvolutionOptimizer +from .dual_annealing_optimizer import DualAnnealingOptimizer +from .point_optimizer import PointOptimizer +from .shg_optimizer import SHGOptimizer \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/base_algorithm.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/base_algorithm.py new file mode 100644 index 0000000..1152b3d --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/base_algorithm.py @@ -0,0 +1,147 @@ +import asyncio +import math +from typing import Dict, List, Optional, Tuple, Union + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.graphs.stages.optimize.optimization.parameters.parameter import ( + Parameter, +) +from hyperscale.core.personas.batching.batch import Batch +from hyperscale.core.personas.batching.param_type import ParamType +from hyperscale.core.personas.types.default_persona.default_persona import ( + DefaultPersona, +) + + +class BaseAlgorithm: + + def __init__( + self, + config: Dict[str, Union[List[Tuple[Union[int, float]]], int, Config]], + distribution_idx: int=None + ) -> None: + self.stage_config: Config = config.get('stage_config') + self.max_iter = config.get('iterations', 10) + self.distribution: Optional[List[int]] = None + + if self.stage_config.experiment: + self.distribution = self.stage_config.experiment.get('distribution') + + parameters: List[Parameter] = config.get('params', []) + algorithm_parameters: Dict[str, Parameter] = {} + + for parameter in parameters: + algorithm_parameters[parameter.parameter_name] = parameter + + self.params: Dict[str, Parameter] = algorithm_parameters + self.time_limit = config.get('time_limit', 60) + self.persona_total_time = self.stage_config.total_time + self.batch = Batch(self.stage_config) + self.current_params = {} + self.session = None + self.distribution_idx: Optional[int] = distribution_idx + self._boundaries_by_param_name = {} + + if self.max_iter is None or self.max_iter <= 0: + self.max_iter = 10 + + if self.time_limit is None or self.time_limit <= 0: + self.time_limit = 60 + + self.batch_time = self.time_limit/self.max_iter + + self.param_names = [] + self.bounds = [] + + self.param_values: Dict[str, Union[float, int]] = self.get_params() + self.current_params = {} + + self.optimize_params = [ + param_name for param_name in self.params.keys() if param_name in self.param_values + ] + + if len(self.optimize_params) < 1: + self.optimize_params = [ + 'batch_size' + ] + + for param_name in self.optimize_params: + parameter = self.params.get(param_name) + + param = self.param_values.get(param_name, {}) + value = param.get('value') + params_type = param.get('type') + + if self.distribution_idx is not None: + parameter.minimum = 0.01 + parameter.maximum = 10 + value = self.distribution[self.distribution_idx] + param['value'] = value + + min_range = parameter.minimum + max_range = parameter.maximum + + self.param_names.append(param_name) + + if parameter.feed_forward: + if params_type == ParamType.INTEGER: + min_range = math.floor(min_range * value) + max_range = math.ceil(max_range * value) + + else: + min_range = min_range * value + max_range = max_range * value + + boundaries = (min_range, max_range) + self.bounds.append(boundaries) + self._boundaries_by_param_name[param_name] = boundaries + + self.current_params[param_name] = value + + self.fixed_iters = False + self.iters = 0 + + def get_params(self): + return self.batch.to_params() + + def update_params(self, persona: DefaultPersona) -> DefaultPersona: + + persona.total_time = self.batch_time + + batch_size = int(self.current_params.get( + 'batch_size', + persona.batch.size + )) + + batch_interval = float(self.current_params.get( + 'batch_interval', + persona.batch.interval + )) + + batch_gradient = float(self.current_params.get( + 'batch_gradient', + persona.batch.gradient + )) + + for hook in persona._hooks: + + if batch_size <= psutil.cpu_count(): + batch_size = 1000 + + hook.session.pool.size = batch_size + hook.session.sem = asyncio.Semaphore(batch_size) + hook.session.pool.connections = [] + hook.session.pool.create_pool() + + persona.batch.size = batch_size + persona.batch.interval = batch_interval + persona.batch.gradient = batch_gradient + + return persona + + def optimize(self, func): + raise NotImplementedError( + 'Err. - Base Algorithm is an abstract class and its optimize method should be overridden.' + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/differential_evolution_optimizer.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/differential_evolution_optimizer.py new file mode 100644 index 0000000..1810da3 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/differential_evolution_optimizer.py @@ -0,0 +1,29 @@ +from typing import ( + Dict, + List, + Tuple, + Union, + Optional +) +from scipy.optimize import differential_evolution +from .base_algorithm import BaseAlgorithm + + +class DifferentialEvolutionOptimizer(BaseAlgorithm): + + def __init__( + self, + config: Dict[str, Union[List[Tuple[Union[int, float]]], int]], + distribution_idx: Optional[int]=None + ) -> None: + super().__init__( + config, + distribution_idx=distribution_idx + ) + + def optimize(self, func): + return differential_evolution( + func, + self.bounds, + maxiter=self.max_iter + ) diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/dual_annealing_optimizer.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/dual_annealing_optimizer.py new file mode 100644 index 0000000..3b531b7 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/dual_annealing_optimizer.py @@ -0,0 +1,32 @@ +from typing import ( + Dict, + List, + Tuple, + Union, + Optional +) +from scipy.optimize import dual_annealing +from .base_algorithm import BaseAlgorithm + + +class DualAnnealingOptimizer(BaseAlgorithm): + + def __init__( + self, + config: Dict[str, Union[List[Tuple[Union[int, float]]], int]], + distribution_idx: Optional[int]=None + ) -> None: + super().__init__( + config, + distribution_idx=distribution_idx + ) + + + def optimize(self, func): + return dual_annealing( + func, + self.bounds, + maxiter=self.max_iter, + maxfun=self.max_iter, + no_local_search=True + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/least_squares_optimizer.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/least_squares_optimizer.py new file mode 100644 index 0000000..8d98aee --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/least_squares_optimizer.py @@ -0,0 +1,30 @@ +from typing import Dict, List, Tuple, Union + +from scipy.optimize import least_squares + +from hyperscale.core.experiments.variant import Variant + +from .base_algorithm import BaseAlgorithm + + +class LeastSquaresOptimizer(BaseAlgorithm): + + def __init__( + self, + config: Dict[str, Union[List[Tuple[Union[int, float]]], int]] + ) -> None: + super().__init__(config) + self.initial_distribution: List[float] = [] + + def generate_initial_distribution(self, variant: Variant): + self.initial_distribution = variant.distribution.generate(self.stage_config.batch_size) + + def optimize(self, func): + return least_squares( + func, + self.initial_distribution, + diff_step=[ + 0.5 for _ in self.distribution + ], + max_nfev=self.max_iter + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/point_optimizer.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/point_optimizer.py new file mode 100644 index 0000000..6a82ed6 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/point_optimizer.py @@ -0,0 +1,179 @@ +from collections import defaultdict +from statistics import mean +from typing import Dict, List, Optional, Tuple, Union + +from numpy.random import normal + +from hyperscale.core.personas.batching.param_type import ParamType +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .base_algorithm import BaseAlgorithm + + +@unstable +class PointOptimizer(BaseAlgorithm): + + def __init__( + self, + config: Dict[str, Union[List[Tuple[Union[int, float]]], int]], + distribution_idx: Optional[int]=None + ) -> None: + super().__init__( + config, + distribution_idx=distribution_idx + ) + + self.params_history = defaultdict(list) + self.next_params = {} + self.error_history = defaultdict(list) + self.stop_iteration = False + self.error_batch_size_map = defaultdict(list) + self.param_errors = defaultdict(lambda: defaultdict(list)) + self._noise_scale = 0.1 + self._target_value: int = None + + async def optimize(self, func): + + for param_name, boundary in self._boundaries_by_param_name.items(): + + next_value = mean([boundary[0], boundary[1]]) + self.next_params[param_name] = next_value + + self.params_history[param_name].append(next_value) + + for param_name in self.optimize_params: + for _ in range(self.max_iter): + error, target_value = await func( + list(self.next_params.values()) + ) + + if error: + self._compute_next_params( + error, + target_value, + param_name + ) + + else: + boundary = self._boundaries_by_param_name.get(param_name) + next_value = mean([boundary[0], boundary[1]]) + self.next_params[param_name] = next_value + + self.params_history[param_name].append(next_value) + + final_params = {} + minimized_error_mean = 0 + minimized_parameter = 0 + for param_name in self.optimize_params: + + param_errors = self.param_errors[param_name] + param_error_sums = {} + param_values_by_sum = defaultdict(list) + + for param_value, errors in param_errors.items(): + mean_errors = mean(errors) + param_error_sums[param_value] = mean_errors + param_values_by_sum[mean_errors].append(param_value) + + error_sums = list(param_error_sums.values()) + + if len(error_sums) > 0: + minimized_error_mean = min(error_sums) + + else: + minimized_error_mean = 0 + minimized_parameter = self._target_value + + minimized_parameter = min(param_values_by_sum[minimized_error_mean]) + + + param = self.param_values.get(param_name, {}) + params_type = param.get('type') + + if params_type == ParamType.INTEGER: + minimized_parameter = int(minimized_parameter) + + else: + minimized_parameter = float(minimized_parameter) + + final_params[param_name] = { + 'minimized_distribution_value': minimized_parameter, + 'minimized_error_mean': minimized_error_mean + } + + return final_params + + + def _compute_next_params( + self, + error: float, + target_value: Union[float, int], + optimizing_param_name: str, + ): + + last_param_value = self.params_history[optimizing_param_name][-1] + adjustment_ratio = 1 + absolute_error = abs(error) + completed = error + target_value + + if self._target_value is None: + self._target_value = target_value + + + if error > 0: + + if target_value > absolute_error: + adjustment_ratio = absolute_error/target_value + + else: + adjustment_ratio = target_value/absolute_error + + noise_distribution = normal( + scale=self._noise_scale, + size=1 + ) + + noise_value = noise_distribution[0] + + adjustment_ratio = abs( + round(adjustment_ratio + noise_value, 2) + ) + next_param_value = last_param_value * adjustment_ratio + + else: + adjustment_ratio = absolute_error/target_value + + noise_distribution = normal( + scale=self._noise_scale, + size=1 + ) + + noise_value = noise_distribution[0] + + adjustment_ratio = abs( + round(adjustment_ratio + noise_value, 2) + ) + next_param_value = last_param_value * (1 + adjustment_ratio) + + minimum_boundary, maximum_boundary = self._boundaries_by_param_name[optimizing_param_name] + + if next_param_value < minimum_boundary: + next_param_value = minimum_boundary + + elif next_param_value > maximum_boundary: + next_param_value = maximum_boundary + + if next_param_value < 1: + next_param_value = 1 + + if completed > 0: + self.next_params[optimizing_param_name] = next_param_value + self.params_history[optimizing_param_name].append(next_param_value) + self.error_history[optimizing_param_name].append( + absolute_error + ) + + self.error_batch_size_map[absolute_error].append(last_param_value) + self.param_errors[optimizing_param_name][last_param_value].append(absolute_error) + + diff --git a/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/shg_optimizer.py b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/shg_optimizer.py new file mode 100644 index 0000000..bd44e5a --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/algorithms/types/shg_optimizer.py @@ -0,0 +1,33 @@ +from typing import ( + Dict, + List, + Tuple, + Union, + Optional +) +from scipy.optimize import shgo +from .base_algorithm import BaseAlgorithm + + +class SHGOptimizer(BaseAlgorithm): + + def __init__( + self, + config: Dict[str, Union[List[Tuple[Union[int, float]]], int]], + distribution_idx: Optional[int]=None + ) -> None: + super().__init__( + config, + distribution_idx=distribution_idx + ) + + + def optimize(self, func): + return shgo( + func, + self.bounds, + options={ + 'maxfev': self.max_iter, + 'maxiter': self.max_iter + } + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/distribution_fit_optimizer.py b/hyperscale/core/graphs/stages/optimize/optimization/distribution_fit_optimizer.py new file mode 100644 index 0000000..6cbc6c3 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/distribution_fit_optimizer.py @@ -0,0 +1,206 @@ +import asyncio +import signal +import time +from collections import defaultdict +from statistics import mean +from typing import Any, Dict, List, Tuple, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.experiments.variant import Variant +from hyperscale.core.personas.batching.param_type import ParamType +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.versioning.flags.types.unstable.flag import unstable + +from .algorithms.types.point_optimizer import PointOptimizer +from .optimizer import Optimizer + + +@unstable +class DistributionFitOptimizer(Optimizer): + + def __init__(self, config: Dict[str, Any]) -> None: + super().__init__(config) + + self.optimization_start = 0 + self.algorithm: PointOptimizer = None + + self.variant_weight = self.stage_config.experiment.get('weight') + self.distribution_intervals = self.stage_config.experiment.get('intervals') + self.distribution_type = self.stage_config.experiment.get('distribution_type') + self.distribution = self.stage_config.experiment.get('distribution') + + self.variant = Variant( + self.stage_name, + weight=self.variant_weight, + distribution=self.distribution_type, + ) + + self.algorithms: List[PointOptimizer] = [] + self.target_interval_completions: int = 0 + self.completion_rates = defaultdict(list) + + for distribution_idx in range(len(self.distribution)): + self.algorithms.append(PointOptimizer( + { + **config, + 'stage_config': self.stage_config + }, + distribution_idx=distribution_idx + )) + + + def _setup_persona(self, stage_config: Config) -> DefaultPersona: + persona = DefaultPersona(stage_config) + persona.setup(self.stage_hooks, self.metadata_string) + + return persona + + def optimize(self) -> Dict[str, Union[int, float]]: + + self._event_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._event_loop) + + def handle_loop_stop(signame): + try: + self._event_loop.close() + + except BrokenPipeError: + pass + + except RuntimeError: + pass + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + + self._event_loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop(signame) + ) + + self._event_loop.set_exception_handler(self._handle_async_exception) + + optimization_results = self._event_loop.run_until_complete( + self._optimize_async() + ) + + self._event_loop.close() + + return optimization_results + + + async def _optimize_async(self) -> Dict[str, Union[int, float]]: + + results = None + + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Starting optimization') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Time Limit - {self._optimization_time_limit}') + self.start = 0 + + distribution_optimized_params = [] + distribution_mean_errors = [] + + self.optimization_start = time.time() + + for distribution_value, algorithm in zip(self.distribution, self.algorithms): + self.completion_rates = defaultdict(list) + self.algorithm: PointOptimizer = algorithm + self.algorithm.batch_time = self.stage_config.total_time/self.distribution_intervals + + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Algorithm - {self.algorithm_type}') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Batch Time - {self.algorithm.batch_time}') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Max Iter - {self.algorithm.max_iter}') + + + self.target_interval_completions = distribution_value + + self.elapsed = 0 + self._current_iter = 0 + self.start = time.time() + + results: Dict[str, Dict[str, Union[int, float]]] = await self.algorithm.optimize(self._optimize_iteration) + + optimization_results = results.get('batch_size') + optimized_distribution_value = optimization_results.get('minimized_distribution_value') + optimized_error_mean = optimization_results.get('minimized_error_mean') + + + distribution_optimized_params.append(optimized_distribution_value) + distribution_mean_errors.append(optimized_error_mean) + + self.total_optimization_time = time.time() - self.optimization_start + + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization took - {round(self.total_optimization_time, 2)} - seconds') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization - max actions per second - {self._max_aps}') + + self.optimized_results = { + 'optimize_target_stage': self.target_stage_name, + 'optimized_distribution': distribution_optimized_params, + 'optimization_mean_error': mean(distribution_mean_errors), + 'optimization_iters': self.algorithm.max_iter, + 'optimization_iter_duation': self.algorithm.batch_time, + 'optimization_total_time': self.total_optimization_time, + 'optimization_max_aps': self._max_aps + } + + self.total_optimization_time = time.time() - self.start + + return self.optimized_results + + async def _optimize_iteration(self, xargs: List[Union[int, float]]) -> Tuple[Union[None, float], int]: + + if self.elapsed > self.algorithm.time_limit: + return None, self.target_interval_completions + + persona = self._setup_persona(self.stage_config) + + for idx, param in enumerate(xargs): + param_name = self.algorithm.param_names[idx] + param = self.algorithm.param_values.get(param_name, {}) + param_type = param.get('type') + + if param_type == ParamType.INTEGER: + xargs[idx] = int(xargs[idx]) + + else: + xargs[idx] = float(xargs[idx]) + + param['value'] = xargs[idx] + + self.current_params[param_name] = xargs[idx] + self.algorithm.current_params[param_name] = xargs[idx] + + persona.total_time = self.algorithm.batch_time + persona = self.algorithm.update_params(persona) + await persona.set_concurrency(persona.batch.size) + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter}') + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Batch Size - {persona.batch.size}') + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Batch Interval - {persona.batch.interval}') + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Batch Gradient - {persona.batch.gradient}') + + completed_count = 0 + + try: + results = await persona.execute() + completed_count = len([result for result in results if result.error is None]) + + except Exception: + pass + + elapsed = persona.end - persona.start + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - took - {round(elapsed, 2)} - seconds') + + self.completion_rates[persona.batch.size].append(completed_count) + error = completed_count - self.target_interval_completions + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Target error- {error}') + + self._current_iter += 1 + self.elapsed = time.time() - self.start + + if completed_count < 1: + return -(self.base_batch_size**2), self.target_interval_completions + + return error, self.target_interval_completions diff --git a/hyperscale/core/graphs/stages/optimize/optimization/optimizer.py b/hyperscale/core/graphs/stages/optimize/optimization/optimizer.py new file mode 100644 index 0000000..21b41c3 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/optimizer.py @@ -0,0 +1,227 @@ +import asyncio +import os +import signal +import threading +import time +import uuid +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas import get_persona +from hyperscale.core.personas.batching.param_type import ParamType +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.logging import HyperscaleLogger +from hyperscale.monitoring.base.exceptions import MonitorKilledError +from hyperscale.tools.data_structures import AsyncList + +from .algorithms import get_algorithm +from .algorithms.types.base_algorithm import BaseAlgorithm + + +class Optimizer: + + def __init__(self, config: Dict[str, Any]) -> None: + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.optimizer_id = str(uuid.uuid4()) + self.thread_id = threading.current_thread().ident + self.process_id = os.getpid() + + self.graph_name = config.get('graph_name') + self.graph_id = config.get('graph_id') + self.target_stage_name = config.get('stage_name') + self.source_stage_name = config.get('source_stage_name') + self.source_stage_id = config.get('source_stage_id') + self.stage_config: Config = config.get('stage_config') + self.stage_hooks = config.get('stage_hooks') + self.base_batch_size = self.stage_config.batch_size + + self.metadata_string = f'Graph - {self.graph_name}:{self.graph_id} - thread:{self.thread_id} - process:{self.process_id} - Stage: {self.source_stage_name}:{self.source_stage_id} - Optimizer: {self.optimizer_id} - ' + + self.stage_name = config.get('stage_name') + self.actions = AsyncList() + + self.algorithm_type = config.get('algorithm', 'shg') + + self._optimization_time_limit = config.get('time_limit', 60) + + self.algorithm: BaseAlgorithm = get_algorithm( + self.algorithm_type, + { + **config, + 'stage_config': self.stage_config + } + ) + + self._current_iter = 0 + self.optimized_results = {} + self._max_aps = 0 + self._event_loop: asyncio.AbstractEventLoop = None + + self.current_params = {} + self.start = 0 + self.elapsed = 0 + + def optimize(self) -> Dict[str, Union[int, float]]: + + results = None + + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Starting optimization') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Time Limit - {self._optimization_time_limit}') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Algorithm - {self.algorithm_type}') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Batch Time - {self.algorithm.batch_time}') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization config: Max Iter - {self.algorithm.max_iter}') + + self.start = time.time() + + results = self.algorithm.optimize(self._run_optimize) + + optimized_params = {} + for idx in range(len(results.x)): + param_name = self.algorithm.param_names[idx] + optimiazed_param_name = f'optimized_{param_name}' + param = self.algorithm.param_values.get(param_name, {}) + param_type = param.get('type') + + if param_type == ParamType.INTEGER: + optimized_params[optimiazed_param_name] = int(results.x[idx]) + + else: + optimized_params[optimiazed_param_name] = float(results.x[idx]) + + self.total_optimization_time = time.time() - self.start + + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization took - {round(self.total_optimization_time, 2)} - seconds') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization - max actions per second - {self._max_aps}') + + for optimized_param_name, optimized_value in optimized_params.items(): + param_log_name = optimized_param_name.replace('_', ' ') + self.logger.filesystem.sync['hyperscale.optimize'].info(f'{self.metadata_string} - Optimization - {param_log_name} - {optimized_value}') + + self.optimized_results = { + **optimized_params, + 'optimize_target_stage': self.target_stage_name, + 'optimization_iters': self.algorithm.max_iter, + 'optimization_iter_duation': self.algorithm.batch_time, + 'optimization_total_time': self.total_optimization_time, + 'optimization_max_aps': self._max_aps + } + + self._event_loop.close() + + return self.optimized_results + + def _setup_persona(self, stage_config: Config) -> DefaultPersona: + persona = get_persona(self.stage_config) + persona.optimization_active = True + persona.setup(self.stage_hooks, self.metadata_string) + + return persona + + def _handle_async_exception(self, loop, ctx) -> None: + pass + + async def _optimize(self, xargs: List[Union[int, float]]) -> float: + + if self._current_iter < self.algorithm.max_iter and self.elapsed < self.algorithm.time_limit: + + persona = self._setup_persona(self.stage_config) + + for idx, param in enumerate(xargs): + param_name = self.algorithm.param_names[idx] + param = self.algorithm.param_values.get(param_name, {}) + param_type = param.get('type') + + if param_type == ParamType.INTEGER: + xargs[idx] = int(xargs[idx]) + + else: + xargs[idx] = float(xargs[idx]) + + param['value'] = xargs[idx] + + self.current_params[param_name] = xargs[idx] + self.algorithm.current_params[param_name] = xargs[idx] + + persona = self.algorithm.update_params(persona) + await persona.set_concurrency(persona.batch.size) + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter}') + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Batch Size - {persona.batch.size}') + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Batch Interval - {persona.batch.interval}') + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Batch Gradient - {persona.batch.gradient}') + + completed_count = 0 + try: + results = await persona.execute() + completed_count = len([result for result in results if result.error is None]) + + except RuntimeError as e: + raise e + + except KeyboardInterrupt as e: + raise e + + except Exception: + pass + + elapsed = persona.end - persona.start + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - took - {round(elapsed, 2)} - seconds') + + if completed_count < 1: + completed_count = 1 + + if elapsed < 1: + elapsed = float('inf') + + await self.logger.filesystem.aio['hyperscale.optimize'].debug(f'{self.metadata_string} - Optimizer iteration - {self._current_iter} - Inverted APS score- {elapsed/completed_count}') + + return elapsed/completed_count + + return self.base_batch_size + + def _run_optimize(self, xargs: List[Union[int, float]]) -> float: + + try: + self._event_loop = asyncio.get_event_loop() + except Exception: + self._event_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._event_loop) + + self._event_loop.set_exception_handler(self._handle_async_exception) + + def handle_loop_stop(signame): + try: + self._event_loop.close() + + except KeyboardInterrupt: + raise RuntimeError() + + except BrokenPipeError: + raise RuntimeError() + + except RuntimeError: + raise RuntimeError() + + except MonitorKilledError: + raise RuntimeError() + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + + self._event_loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop(signame) + ) + + inverse_actions_per_second = self._event_loop.run_until_complete( + self._optimize(xargs) + ) + + self._current_iter += 1 + self.elapsed = time.time() - self.start + + return inverse_actions_per_second \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/parameters/__init__.py b/hyperscale/core/graphs/stages/optimize/optimization/parameters/__init__.py new file mode 100644 index 0000000..383a747 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/parameters/__init__.py @@ -0,0 +1 @@ +from .parameter import Parameter \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter.py b/hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter.py new file mode 100644 index 0000000..cb46f00 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter.py @@ -0,0 +1,36 @@ +import math +from typing import Union +from .parameter_range import ParameterRange + + +class Parameter: + + def __init__( + self, + parameter_name: str, + minimum: Union[int, float]=None, + maximum: Union[int, float]=None, + feed_forward: bool=False + ) -> None: + self.parameter_name = parameter_name + self.feed_forward = feed_forward + self.minimum = minimum + self.maximum = maximum + + if minimum is None or minimum <= 0 and self.feed_forward: + minimum = 0.5 + + if maximum is None or maximum <= 0 and self.feed_forward: + maximum = math.ceil(minimum) * 2 + + self.range = ParameterRange( + minimum_range=minimum, + maximum_range=maximum + ) + + assert self.maximum > self.minimum, f"Err. - maximum parameter range value for optimization parameter {self.parameter_name} must be greater than minimum parameter range value." + + if self.feed_forward: + assert self.minimum >= 0.1, f'Err. - Range multiplier for feed-forward optimization parameter {self.parameter_name} cannot exceed minimum of 0.1.' + assert self.maximum <= 10, f'Err. - Range multiplier for feed-forward optimization parameter {self.parameter_name} cannot exceed maximum of 10.' + \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter_range.py b/hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter_range.py new file mode 100644 index 0000000..f2554c2 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimization/parameters/parameter_range.py @@ -0,0 +1,21 @@ +from typing import Union +from pydantic import ( + BaseModel, + StrictInt, + StrictFloat, + validator +) + + +class ParameterRange(BaseModel): + minimum_range: Union[StrictInt, StrictFloat] + maximum_range: Union[StrictInt, StrictFloat] + + class Config: + arbitrary_types_allowed = True + + @validator('minimum_range', 'maximum_range') + def validate_param_range(cls, val): + assert val > 0, "Order and weight values must be greater than zero!" + + return val diff --git a/hyperscale/core/graphs/stages/optimize/optimize.py b/hyperscale/core/graphs/stages/optimize/optimize.py new file mode 100644 index 0000000..462be60 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/optimize.py @@ -0,0 +1,712 @@ +import asyncio +import signal +import time +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Literal, Optional, Tuple, Union + +import dill +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.time_parser import TimeParser +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.playwright import ( + ContextConfig, + MercuryPlaywrightClient, +) +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.execute import Execute +from hyperscale.core.graphs.stages.optimize.optimization import ( + DistributionFitOptimizer, + Optimizer, +) +from hyperscale.core.graphs.stages.optimize.optimization.algorithms import ( + registered_algorithms, +) +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.condition.decorator import condition +from hyperscale.core.hooks.types.context.decorator import context +from hyperscale.core.hooks.types.event.decorator import event +from hyperscale.core.hooks.types.internal.decorator import Internal +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.data.serializers import Serializer +from hyperscale.logging import logging_manager +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.monitoring.base.exceptions import MonitorKilledError +from hyperscale.plugins.types.extension.extension_plugin import ExtensionPlugin +from hyperscale.plugins.types.extension.types import ExtensionType +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + +from .optimization.parameters import Parameter +from .parallel import optimize_stage + +BatchedOptimzationCandidates = List[Tuple[str, Execute, int]] + +OptimizeParameterPair = Tuple[Union[int, float], Union[int, float]] + +MonitorResults = Dict[str, List[Union[int, float]]] + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor +): + try: + executor.shutdown(cancel_futures=True) + + except KeyboardInterrupt: + raise RuntimeError() + + except BrokenPipeError: + raise RuntimeError() + + except RuntimeError: + raise RuntimeError() + + except MonitorKilledError: + raise RuntimeError() + + + +class Optimize(Stage): + stage_type=StageTypes.OPTIMIZE + optimize_iterations=0 + algorithm: Literal[ + 'shg', + 'dual-annealing', + 'diff-evolution' + ]='shg' + time_limit='1m' + optimize_params: List[Parameter]=[ + Parameter( + 'batch_size', + minimum=0.5, + maximum=2, + feed_forward=True + ) + ] + priority: Optional[str]=None + retries: int=0 + + def __init__(self) -> None: + super().__init__() + self.generation_optimization_candidates = 0 + self.execution_stage_id = 0 + + self.results = None + + time_parser = TimeParser(self.time_limit) + self.stage_time_limit = time_parser.time + self.requires_shutdown = True + self.allow_parallel = True + + self.optimization_execution_time_start = 0 + self.optimization_execution_time = 0 + self.accepted_hook_types = [ + HookType.CONTEXT, + HookType.EVENT, + HookType.TRANSFORM + ] + + self.optimize_iterations = self.optimize_iterations + self.algorithm = self.algorithm + self.time_limit = self.time_limit + self.optimize_params = self.optimize_params + + self._loop: Union[asyncio.AbstractEventLoop, None] = None + self._thread_executor: Union[ThreadPoolExecutor, None] = None + + self.priority = self.priority + if self.priority is None: + self.priority = 'auto' + + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + self.stage_retries = self.retries + self.serializer = Serializer() + + @Internal() + async def run(self): + + await self.setup_events() + self.dispatcher.assemble_execution_graph() + await self.dispatcher.dispatch_events(self.name) + + @context() + async def collect_optimization_stages( + self, + setup_stage_configs: Dict[str, Config] = {}, + optimize_stage_candidates: Dict[str, Execute]={}, + setup_stage_experiment_config: Dict[str, Union[str, int, List[float]]]={}, + execute_stage_streamed_analytics: Dict[str, List[StreamAnalytics]]={} + + ): + main_monitor_name = f'{self.name}.main' + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + cpu_monitor.stage_type = StageTypes.OPTIMIZE + memory_monitor.stage_type = StageTypes.OPTIMIZE + + await cpu_monitor.start_background_monitor(main_monitor_name) + await memory_monitor.start_background_monitor(main_monitor_name) + + self.optimization_execution_time_start = time.monotonic() + + self.context.ignore_serialization_filters = [ + 'optimize_stage_workers_map', + 'optimize_stage_batched_stages', + 'optimize_stage_candidates', + 'optimize_stage_monitors', + 'setup_stage_ready_stages', + 'execute_stage_setup_hooks', + 'execute_stage_results', + 'session_stage_monitors' + ] + + stage_names = ', '.join(list(optimize_stage_candidates.keys())) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Optimizing stages {stage_names} using {self.algorithm} algorithm') + await self.logger.spinner.append_message(f'Optimizer - {self.name} optimizing stages {stage_names} using {self.algorithm} algorithm') + + optimize_stages = [( + stage.name, + stage + ) for stage in optimize_stage_candidates.values()] + + + stages_count = len(optimize_stage_candidates) + + # We may have less workers available during the optimize stage than assigned + # to the execute stage, so store the original workers count for later. + stage_workers_map = { + stage.name: stage.workers for stage in optimize_stage_candidates.values() + } + + batched_stages: BatchedOptimzationCandidates = list(self.executor.partion_stage_batches(optimize_stages)) + + return { + 'setup_stage_experiment_config': setup_stage_experiment_config, + 'setup_stage_configs': setup_stage_configs, + 'execute_stage_streamed_analytics': execute_stage_streamed_analytics, + 'optimize_stage_candidates': optimize_stage_candidates, + 'optimize_stage_stage_names': stage_names, + 'optimize_stage_stages_count': stages_count, + 'optimize_stage_workers_map': stage_workers_map, + 'optimize_stage_batched_stages': batched_stages, + 'optimize_stage_monitors': { + 'cpu': cpu_monitor, + 'memory': memory_monitor + } + } + + @event() + async def get_stage_plugins(self): + + optimize_plugins = self.plugins_by_type.get(PluginType.OPTIMIZER) + for plugin_name, plugin in optimize_plugins.items(): + registered_algorithms[plugin_name] = lambda plugin_config: plugin(plugin_config) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Loaded Optimizer plugin - {plugin_name}') + + execute_stage_extensions: Dict[str, ExtensionPlugin] = {} + + source_stage_plugins = defaultdict(list) + for plugin in self.plugins.values(): + source_stage_plugins[plugin.type].append(plugin.name) + + return { + 'optimize_stage_plugins': source_stage_plugins, + 'optimize_stage_extensions': execute_stage_extensions + } + + @event() + async def collect_loaded_actions( + self, + optimize_stage_candidates: Dict[str, Execute]={}, + ): + loaded_actions: Dict[str, List[ActionHook]] = defaultdict(list) + + for candidate_stage in optimize_stage_candidates.values(): + + for value in candidate_stage.context.values(): + + if isinstance(value, ActionHook): + loaded_actions[candidate_stage.name].append(value) + + elif isinstance(value, list): + + for item in value: + if isinstance(item, ActionHook): + loaded_actions[candidate_stage.name].append(item) + + elif isinstance(value, dict): + + for item in value.values(): + if isinstance(item, ActionHook): + loaded_actions[candidate_stage.name].append(item) + + return { + 'optimize_stage_loaded_actions': loaded_actions + } + + + @condition( + 'collect_optimization_stages', + 'get_stage_plugins' + ) + async def check_has_multiple_workers(self): + return { + 'optimize_stage_has_multiple_workers': self.total_pool_cpus > 1 + } + + + @event('check_has_multiple_workers') + async def create_optimization_configs( + self, + setup_stage_configs: Dict[str, Config] = {}, + optimize_stage_stages_count: int=0, + optimize_stage_batched_stages: BatchedOptimzationCandidates=[], + setup_stage_experiment_config: Dict[str, Union[str, int, List[float]]]={}, + execute_stage_streamed_analytics: Dict[str, List[StreamAnalytics]]={}, + optimize_stage_loaded_actions: Dict[str, List[ActionHook]]=[], + optimize_stage_has_multiple_workers: bool = False + ): + if optimize_stage_has_multiple_workers: + + batched_configs = [] + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Batching optimization for - {optimize_stage_stages_count} stages') + + serializable_context = self.context.as_serializable() + + for stage_name, stage, assigned_workers_count in optimize_stage_batched_stages: + + configs = [] + selected_stage_config = setup_stage_configs.get(stage.name) + + batch_size = int(selected_stage_config.batch_size/assigned_workers_count) + + stage_loaded_actions = optimize_stage_loaded_actions.get(stage_name) + loaded_actions: List[str] = [ + self.serializer.serialize_action( + action_hook + ) for action_hook in stage_loaded_actions + ] + + for worker_id in range(assigned_workers_count): + + execute_stage_plugins = defaultdict(list) + + for plugin in stage.plugins.values(): + execute_stage_plugins[plugin.type].append(plugin.name) + + + configs.append({ + 'graph_name': self.graph_name, + 'graph_path': self.graph_path, + 'graph_id': self.graph_id, + 'enable_unstable_features': active_flags[FlagTypes.UNSTABLE_FEATURE], + 'logfiles_directory': logging_manager.logfiles_directory, + 'log_level': logging_manager.log_level_name, + 'worker_id': worker_id, + 'source_stage_context': { + context_key: context_value for context_key, context_value in serializable_context + }, + 'source_stage_name': self.name, + 'source_stage_id': self.stage_id, + 'execute_stage_name': stage_name, + 'setup_stage_experiment_config': setup_stage_experiment_config, + 'execute_stage_loaded_actions': loaded_actions, + 'execute_stage_streamed_analytics': execute_stage_streamed_analytics, + 'execute_stage_generation_count': assigned_workers_count, + 'execute_stage_id': stage.execution_stage_id, + 'execute_stage_config': selected_stage_config, + 'execute_stage_batch_size': batch_size, + 'execute_setup_stage_name': stage.context['execute_stage_setup_by'], + 'execute_stage_plugins': execute_stage_plugins, + 'optimizer_params': self.optimize_params, + 'optimizer_iterations': self.optimize_iterations, + 'optimizer_algorithm': self.algorithm, + 'optimize_stage_workers': self.workers, + 'time_limit': self.stage_time_limit + }) + + configs[assigned_workers_count-1]['execute_stage_batch_size'] += batch_size%assigned_workers_count + + configs = [ + dill.dumps(config) for config in configs + ] + + batched_configs.append(( + stage_name, + assigned_workers_count, + configs + )) + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Provisioned - {assigned_workers_count} - workers for stage - {stage_name}') + + return { + 'optimize_stage_batched_configs': batched_configs + } + + @event('create_optimization_configs') + async def execute_batched_optimization( + self, + optimize_stage_stages_count: int=0, + optimize_stage_batched_configs: Dict[str, Any]={}, + optimize_stage_has_multiple_workers: bool = False + + ): + if optimize_stage_has_multiple_workers: + + optimization_results = [] + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Starting optimizaiton for - {optimize_stage_stages_count} - stages') + + results = await self.executor.execute_batches( + optimize_stage_batched_configs, + optimize_stage + ) + + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Completed optimizaiton for - {optimize_stage_stages_count} - stages') + + for _, result_batch in results: + optimization_results.extend([ + dill.loads(results_set) for results_set in result_batch + ]) + + return { + 'optimize_stage_results': optimization_results + } + + @event('check_has_multiple_workers') + async def setup_single_worker_optimization( + self, + setup_stage_configs: Dict[str, Config]={}, + optimize_stage_extensions: Dict[str, ExtensionPlugin] = {}, + optimize_stage_has_multiple_workers: bool = False, + optimize_stage_candidates: Dict[str, Execute]={}, + + ): + if optimize_stage_has_multiple_workers is False: + + self._loop = asyncio.get_event_loop() + self._thread_executor = ThreadPoolExecutor( + max_workers=psutil.cpu_count(logical=False) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._thread_executor + ) + ) + + for stage_name, persona_config in setup_stage_configs.items(): + + execute_stage: Execute = optimize_stage_candidates.get(stage_name) + + for extension in optimize_stage_extensions.values(): + + if extension.extension_type == ExtensionType.GENERATOR: + results = await extension.execute(**{ + 'execute_stage_name': execute_stage.name, + 'execute_stage_hooks': execute_stage.hooks, + 'persona_config': persona_config + }) + + execute_stage = results.get('execute_stage') + + if execute_stage: + execute_stage.hooks = results.get('execute_stage_hooks') + + action_and_task_hooks: List[Union[ActionHook, TaskHook]] = [ + *execute_stage.hooks[HookType.ACTION], + *execute_stage.hooks[HookType.TASK] + ] + + for hook in action_and_task_hooks: + if hook.action.type == RequestTypes.PLAYWRIGHT and isinstance(hook.session, MercuryPlaywrightClient): + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Setting up Playwright Session') + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Browser Type: {persona_config.browser_type}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Device Type: {persona_config.device_type}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Locale: {persona_config.locale}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - geolocation: {persona_config.geolocation}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Permissions: {persona_config.permissions}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Playwright Session - {hook.session.session_id} - Color Scheme: {persona_config.color_scheme}') + + + await hook.session.setup( + config=ContextConfig( + browser_type=persona_config.browser_type, + device_type=persona_config.device_type, + locale=persona_config.locale, + geolocation=persona_config.geolocation, + permissions=persona_config.permissions, + color_scheme=persona_config.color_scheme, + options=persona_config.playwright_options + ) + ) + + optimize_stage_candidates[stage_name] = execute_stage + + return { + 'optimize_stage_candidates': optimize_stage_candidates + } + + @event('setup_single_worker_optimization') + async def execute_single_worker_optimization( + self, + setup_stage_configs: Dict[str, Config]={}, + optimize_stage_candidates: Dict[str, Execute]={}, + optimize_stage_has_multiple_workers: bool = False, + execute_stage_streamed_analytics: Dict[str, List[StreamAnalytics]]={} + + ): + + optimizers: List[Union[DistributionFitOptimizer, Optimizer]] = [] + + if optimize_stage_has_multiple_workers is False: + for stage_name, persona_config in setup_stage_configs.items(): + + execute_stage: Execute = optimize_stage_candidates.get(stage_name) + + if persona_config.experiment and persona_config.experiment.get('distribution'): + + optimizer = DistributionFitOptimizer({ + 'graph_name': self.graph_name, + 'graph_id': self.graph_id, + 'source_stage_name': self.name, + 'source_stage_id': self.stage_id, + 'params': self.optimize_params, + 'stage_name': stage_name, + 'stage_config': persona_config, + 'stage_hooks': execute_stage.hooks, + 'iterations': self.optimize_iterations, + 'algorithm': self.algorithm, + 'time_limit': self.time_limit, + 'stream_analytics': execute_stage_streamed_analytics + }) + + else: + optimizer = Optimizer({ + 'graph_name': self.graph_name, + 'graph_id': self.graph_id, + 'source_stage_name': self.name, + 'source_stage_id': self.stage_id, + 'params': self.optimize_params, + 'stage_name': stage_name, + 'stage_config': persona_config, + 'stage_hooks': execute_stage.hooks, + 'iterations': self.optimize_iterations, + 'algorithm': self.algorithm, + 'time_limit': self.time_limit, + 'stream_analytics': execute_stage_streamed_analytics + }) + + optimizers.append(optimizer) + + results: List[Dict[str, Union[int, str, float]]] = await asyncio.gather(*[ + self._loop.run_in_executor( + self._thread_executor, + optimizer.optimize + ) for optimizer in optimizers + ]) + + self._thread_executor.shutdown(cancel_futures=True) + + optimization_results: List[Dict[str, Union[int, str, float]]] = [] + for optimization_result in results: + + target_stage_name = optimization_result.get('optimize_target_stage') + stage_config = setup_stage_configs.get(target_stage_name) + + + if stage_config.experiment and optimization_result.get('optimized_distribution'): + stage_config.experiment['distribution'] = optimization_result.get('optimized_distribution') + + stage_config.batch_size = optimization_result.get('optimized_batch_size', stage_config.batch_size) + stage_config.batch_interval = optimization_result.get('optimized_batch_interval', stage_config.batch_interval) + stage_config.batch_gradient = optimization_result.get('optimized_batch_gradient', stage_config.batch_gradient) + + await self.logger.filesystem.aio['hyperscale.optimize'].info(f'{optimizer.metadata_string} - Optimization complete') + + + optimization_results.append({ + 'stage': target_stage_name, + 'config': stage_config, + 'params': results + }) + + return { + 'optimize_stage_results': optimization_results + } + + @event('execute_batched_optimization') + async def collect_optimized_batch_sizes( + self, + optimize_stage_results: List[Dict[str, Any]]=[], + optimize_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]] = {}, + optimize_stage_has_multiple_workers: bool = False + ): + + optimized_batch_sizes = [] + for optimization_result in optimize_stage_results: + optimized_config: Config = optimization_result.get('config') + optimized_batch_sizes.append(optimized_config.batch_size) + + optimized_batch_size = sum(optimized_batch_sizes) + + if optimize_stage_has_multiple_workers: + + stage_cpu_monitor: CPUMonitor = optimize_stage_monitors.get('cpu') + stage_memory_monitor: MemoryMonitor = optimize_stage_monitors.get('memory') + + optimized_batch_sizes = [] + for optimization_result in optimize_stage_results: + monitors: Dict[str, MonitorResults] = optimization_result.get('monitoring', {}) + worker_id = optimization_result.get('worker_id') + + cpu_monitor = monitors.get('cpu', {}) + memory_monitor = monitors.get('memory', {}) + + for monitor_name, collection_stats in cpu_monitor.items(): + stage_cpu_monitor.worker_metrics[worker_id][monitor_name] = collection_stats + stage_cpu_monitor.collected[monitor_name].extend(collection_stats) + + for monitor_name, collection_stats in memory_monitor.items(): + stage_memory_monitor.worker_metrics[worker_id][monitor_name] = collection_stats + + stage_cpu_monitor.aggregate_worker_stats() + stage_memory_monitor.aggregate_worker_stats() + + main_monitor_name = f'{self.name}.main' + + await stage_cpu_monitor.stop_background_monitor(main_monitor_name) + await stage_memory_monitor.stop_background_monitor(main_monitor_name) + + stage_cpu_monitor.close() + stage_memory_monitor.close() + + stage_cpu_monitor.stage_metrics[main_monitor_name] = stage_cpu_monitor.collected[main_monitor_name] + stage_memory_monitor.stage_metrics[main_monitor_name] = stage_memory_monitor.collected[main_monitor_name] + + return { + 'optimize_stage_batch_size': optimized_batch_size, + 'optimize_stage_monitors': { + 'cpu': stage_cpu_monitor, + 'memory': stage_memory_monitor + } + } + + @context('collect_optimized_batch_sizes') + async def set_optimized_configs( + self, + optimize_stage_results: List[Any]=[], + optimize_stage_candidates: Dict[str, Execute]={}, + optimize_stage_batch_size: int=0 + ): + + stage_optimzations = {} + stage_context = defaultdict(list) + optimized_stages = {} + optimzied_hooks = defaultdict(list) + optimized_configs: Dict[str, Any] = {} + stages_setup_by = {} + + for optimization_result in optimize_stage_results: + + stage_name = optimization_result.get('stage') + optimized_config: Config = optimization_result.get('config') + + stage = optimize_stage_candidates.get(stage_name) + optimized_configs[stage.name] = optimized_config + stages_setup_by[stage.name] = stage.context['execute_stage_setup_by'] + + for hook in stage.dispatcher.actions_and_tasks.values(): + if hook.source.session: + hook.source.session.pool.size = optimize_stage_batch_size + hook.source.session.sem = asyncio.Semaphore(optimize_stage_batch_size) + hook.source.session.pool.connections = [] + hook.source.session.pool.create_pool() + + optimzied_hooks[hook.source.stage].append(hook) + + stage.context['execute_stage_setup_hooks'] = list(stage.dispatcher.actions_and_tasks.values()) + + pipeline_context = optimization_result.get('context', {}) + for context_key, context_value in pipeline_context.items(): + stage_context[context_key].append(context_value) + + stage_optimzations[stage_name] = optimize_stage_batch_size + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Stage - {stage_name} - configured to use optimized batch size of - {optimize_stage_batch_size} - VUs') + + optimized_config.optimized = True + stage.optimized = True + + optimized_stages[stage.name] = stage + + return { + 'optimize_stage_optimized_hooks': optimzied_hooks, + 'optimize_stage_optimized_configs': optimized_configs, + 'execute_stage_setup_by': stages_setup_by, + 'optimize_stage_optimzations': stage_optimzations, + 'optimize_stage_context': stage_context + } + + @context('set_optimized_configs') + async def complete_optimization( + self, + optimize_stage_stage_names: str=None, + optimize_stage_results: List[Any]=[], + optimize_stage_workers_map: Dict[str, Execute]={}, + optimize_stage_candidates: Dict[str, Execute]={}, + optimize_stage_optimzations: Dict[str, Union[int, float]]={}, + optimize_stage_context: Dict[str, Any]={}, + optimize_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]]={} + ): + self.context[self.name] = optimize_stage_context + + for stage in optimize_stage_candidates.values(): + stage.workers = optimize_stage_workers_map.get(stage.name) + + self.optimization_execution_time = round(time.monotonic() - self.optimization_execution_time_start) + + optimized_batch_sizes = ', '.join([ + f'{stage_name}: {optimized_batch_size}' for stage_name, optimized_batch_size in optimize_stage_optimzations.items() + ]) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Optimization complete for stages - {optimize_stage_stage_names} - over - {self.optimization_execution_time} - seconds') + await self.logger.spinner.set_default_message(f'Optimized - batch sizes for stages - {optimized_batch_sizes} - over {self.optimization_execution_time} seconds') + + return { + 'optimize_stage_optimized_params': [ + result.get('params') for result in optimize_stage_results + ], + 'optimize_stage_monitors': { + self.name: { + **optimize_stage_monitors + } + } + } + + @event('complete_optimization') + async def complete(self): + await self.executor.shutdown() + diff --git a/hyperscale/core/graphs/stages/optimize/parallel/__init__.py b/hyperscale/core/graphs/stages/optimize/parallel/__init__.py new file mode 100644 index 0000000..9f0eb3d --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/parallel/__init__.py @@ -0,0 +1 @@ +from .optimize_stage import optimize_stage \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/optimize/parallel/optimize_stage.py b/hyperscale/core/graphs/stages/optimize/parallel/optimize_stage.py new file mode 100644 index 0000000..a72b192 --- /dev/null +++ b/hyperscale/core/graphs/stages/optimize/parallel/optimize_stage.py @@ -0,0 +1,441 @@ +import os +import pickle +import threading +from collections import defaultdict +from typing import Any, Dict, List, Union + +import dill + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.playwright import ( + ContextConfig, + MercuryPlaywrightClient, +) +from hyperscale.core.engines.types.registry import RequestTypes, registered_engines +from hyperscale.core.graphs.stages.base.exceptions.process_killed_error import ( + ProcessKilledError, +) +from hyperscale.core.graphs.stages.base.import_tools import ( + import_plugins, + import_stages, + set_stage_hooks, +) +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.execute import Execute +from hyperscale.core.graphs.stages.optimize.optimization import ( + DistributionFitOptimizer, + Optimizer, +) +from hyperscale.core.graphs.stages.optimize.optimization.algorithms import ( + registered_algorithms, +) +from hyperscale.core.graphs.stages.setup.setup import Setup +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.event_graph import EventGraph +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.core.hooks.types.load.hook import LoadHook +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.persona_registry import registered_personas +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.data.serializers import Serializer +from hyperscale.logging import HyperscaleLogger, LoggerTypes +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.plugins.types.engine.engine_plugin import EnginePlugin +from hyperscale.plugins.types.persona.persona_plugin import PersonaPlugin +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.versioning.flags.types.base.active import active_flags +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + +HooksByType = Dict[HookType, Union[List[ActionHook], List[TaskHook]]] + + +async def setup_action_channels_and_playwright( + setup_stage: Setup=None, + execute_stage: Execute=None, + logger: HyperscaleLogger=None, + metadata_string: str=None, + persona_config: Config=None, + loaded_actions: List[str]=[] +) -> Execute: + setup_stage.context = SimpleContext() + setup_stage.generation_setup_candidates = 1 + setup_stage.context['setup_stage_is_primary_thread'] = False + setup_stage.context['setup_stage_target_config'] = persona_config + setup_stage.context['setup_stage_target_stages'] = { + execute_stage.name: execute_stage + } + + for event in setup_stage.dispatcher.events_by_name.values(): + event.source.stage_instance = setup_stage + event.context.update(setup_stage.context) + + await setup_stage.run_internal() + + stages: Dict[str, Stage] = setup_stage.context['setup_stage_ready_stages'] + setup_execute_stage: Stage = stages.get(execute_stage.name) + setup_execute_stage.logger = logger + + + actions_and_tasks: List[Union[ActionHook, TaskHook]] = [] + + serializer = Serializer() + if len(loaded_actions) > 0: + for serialized_action in loaded_actions: + actions_and_tasks.append( + serializer.deserialize_action(serialized_action) + ) + + actions_and_tasks.extend( + setup_stage.context['execute_stage_setup_hooks'].get(execute_stage.name, []) + ) + + for hook in actions_and_tasks: + + if hook.action.type == RequestTypes.PLAYWRIGHT and isinstance(hook.session, MercuryPlaywrightClient): + + await logger.filesystem.aio['hyperscale.core'].info(f'{metadata_string} - Setting up Playwright Session') + + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Browser Type: {persona_config.browser_type}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Device Type: {persona_config.device_type}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Locale: {persona_config.locale}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - geolocation: {persona_config.geolocation}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Permissions: {persona_config.permissions}') + await logger.filesystem.aio['hyperscale.core'].debug(f'{metadata_string} - Playwright Session - {hook.session.session_id} - Color Scheme: {persona_config.color_scheme}') + + config = hook.session.config + if config is None: + config = ContextConfig( + browser_type=persona_config.browser_type, + device_type=persona_config.device_type, + locale=persona_config.locale, + geolocation=persona_config.geolocation, + permissions=persona_config.permissions, + color_scheme=persona_config.color_scheme, + options=persona_config.playwright_options + ) + + await hook.session.setup( + config=config + ) + + hooks_by_type: Dict[ + HookType, + Union[ + List[ActionHook], + List[TaskHook] + ] + ] = defaultdict(list) + + for hook in actions_and_tasks: + hooks_by_type[hook.hook_type].append(hook) + + setup_execute_stage.hooks[HookType.ACTION] = hooks_by_type[HookType.ACTION] + setup_execute_stage.hooks[HookType.TASK] = hooks_by_type[HookType.TASK] + + for load_hook in setup_execute_stage.hooks[HookType.LOAD]: + load_hook: LoadHook = load_hook + load_hook.parser_config = setup_stage.config + + return setup_execute_stage + + +def optimize_stage(serialized_config: str): + + import asyncio + import warnings + + import uvloop + warnings.simplefilter("ignore") + + uvloop.install() + + try: + loop = asyncio.get_event_loop() + except Exception: + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + from hyperscale.logging import logging_manager + + try: + + thread_id = threading.current_thread().ident + process_id = os.getpid() + + optimization_config: Dict[str, Union[str, int, Any]] = dill.loads(serialized_config) + + graph_name: str = optimization_config.get('graph_name') + graph_path: str= optimization_config.get('graph_path') + graph_id: str = optimization_config.get('graph_id') + logfiles_directory = optimization_config.get('logfiles_directory') + log_level = optimization_config.get('log_level') + enable_unstable_features = optimization_config.get('enable_unstable_features', False) + source_stage_name: str = optimization_config.get('source_stage_name') + worker_id = optimization_config.get('worker_id') + + + monitor_name = f'{source_stage_name}.worker' + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + cpu_monitor.stage_type = StageTypes.OPTIMIZE + memory_monitor.stage_type = StageTypes.OPTIMIZE + + cpu_monitor.start_background_monitor_sync(monitor_name) + memory_monitor.start_background_monitor_sync(monitor_name) + + active_flags[FlagTypes.UNSTABLE_FEATURE] = enable_unstable_features + + logfiles_directory = optimization_config.get('logfiles_directory') + log_level = optimization_config.get('log_level') + + logging_manager.disable( + LoggerTypes.DISTRIBUTED, + LoggerTypes.DISTRIBUTED_FILESYSTEM + ) + + logging_manager.update_log_level(log_level) + logging_manager.logfiles_directory = logfiles_directory + + logging_manager.disable( + LoggerTypes.DISTRIBUTED, + LoggerTypes.DISTRIBUTED_FILESYSTEM, + LoggerTypes.SPINNER + ) + + logging_manager.update_log_level(log_level) + logging_manager.logfiles_directory = logfiles_directory + + logger = HyperscaleLogger() + logger.initialize() + logger.filesystem.sync.create_logfile('hyperscale.core.log') + logger.filesystem.sync.create_logfile('hyperscale.optimize.log') + + logger.filesystem.create_filelogger('hyperscale.core.log') + logger.filesystem.create_filelogger('hyperscale.optimize.log') + + source_stage_id: str = optimization_config.get('source_stage_id') + source_stage_context: Dict[str, Any] = optimization_config.get('source_stage_context') + + execute_stage_name: str = optimization_config.get('execute_stage_name') + execute_stage_config: Config = optimization_config.get('execute_stage_config') + execute_setup_stage_name: Config = optimization_config.get('execute_setup_stage_name') + execute_stage_plugins: Dict[PluginType, List[str]] = optimization_config.get('execute_stage_plugins') + execute_stage_streamed_analytics: Dict[str, Dict[str, List[StreamAnalytics]]] = optimization_config.get('execute_stage_streamed_analytics') + + optimizer_params: List[str] = optimization_config.get('optimizer_params') + optimizer_iterations: int = optimization_config.get('optimizer_iterations') + optimizer_algorithm: str = optimization_config.get('optimizer_algorithm') + optimize_stage_workers: int = optimization_config.get('optimize_stage_workers') + time_limit: int = optimization_config.get('time_limit') + batch_size: int = optimization_config.get('execute_stage_batch_size') + execute_stage_loaded_actions = optimization_config.get('execute_stage_loaded_actions') + + metadata_string = f'Graph - {graph_name}:{graph_id} - thread:{thread_id} - process:{process_id} - Stage: {source_stage_name}:{source_stage_id} - ' + discovered: Dict[str, Stage] = import_stages(graph_path) + plugins_by_type = import_plugins(graph_path) + + initialized_stages = {} + hooks_by_type = defaultdict(dict) + hooks_by_name = {} + hooks_by_shortname = defaultdict(dict) + + execute_stage_config.batch_size = batch_size + + generated_hooks = {} + for stage in discovered.values(): + stage: Stage = stage() + stage.context = SimpleContext() + stage.graph_name = graph_name + stage.graph_path = graph_path + stage.graph_id = graph_id + + for hook_shortname, hook in registrar.reserved[stage.name].items(): + hook._call = hook._call.__get__(stage, stage.__class__) + setattr(stage, hook_shortname, hook._call) + + initialized_stage = set_stage_hooks( + stage, + generated_hooks + ) + + for hook_type in initialized_stage.hooks: + + for hook in initialized_stage.hooks[hook_type]: + hooks_by_type[hook_type][hook.name] = hook + hooks_by_name[hook.name] = hook + hooks_by_shortname[hook_type][hook.shortname] = hook + + initialized_stages[initialized_stage.name] = initialized_stage + + execute_stage: Stage = initialized_stages.get(execute_stage_name) + execute_stage.context.update(source_stage_context) + + setup_stage: Setup = initialized_stages.get(execute_setup_stage_name) + setup_stage.context.update(source_stage_context) + + stage_persona_plugins: List[str] = execute_stage_plugins[PluginType.PERSONA] + persona_plugins: Dict[str, PersonaPlugin] = plugins_by_type[PluginType.PERSONA] + + stage_engine_plugins: List[str] = execute_stage_plugins[PluginType.ENGINE] + engine_plugins: Dict[str, EnginePlugin] = plugins_by_type[PluginType.ENGINE] + + for plugin_name in stage_persona_plugins: + plugin = persona_plugins.get(plugin_name) + plugin.name = plugin_name + registered_personas[plugin_name] = lambda config: plugin(config) + + for plugin_name in stage_engine_plugins: + plugin = engine_plugins.get(plugin_name) + plugin.name = plugin_name + registered_engines[plugin_name] = lambda config: plugin(config) + + for plugin_name, plugin in plugins_by_type[PluginType.OPTIMIZER].items(): + registered_algorithms[plugin_name] = plugin + + events_graph = EventGraph(hooks_by_type) + events_graph.hooks_to_events().assemble_graph().apply_graph_to_events() + + for stage in initialized_stages.values(): + stage.dispatcher.assemble_action_and_task_subgraphs() + + setup_stage: Setup = initialized_stages.get(execute_setup_stage_name) + + setup_execute_stage: Execute = loop.run_until_complete(setup_action_channels_and_playwright( + setup_stage=setup_stage, + logger=logger, + metadata_string=metadata_string, + persona_config=execute_stage_config, + execute_stage=execute_stage, + loaded_actions=execute_stage_loaded_actions + )) + + pipeline_stages = { + setup_execute_stage.name: setup_execute_stage + } + + logger.filesystem.sync['hyperscale.optimize'].info(f'{metadata_string} - Setting up Optimization') + + if execute_stage_config.experiment and execute_stage_config.experiment.get('distribution'): + + execute_stage_config.experiment['distribution'] = [ + int(distribution_value/optimize_stage_workers) for distribution_value in execute_stage_config.experiment.get('distribution') + ] + + optimizer = DistributionFitOptimizer({ + 'graph_name': graph_name, + 'graph_id': graph_id, + 'source_stage_name': source_stage_name, + 'source_stage_id': source_stage_id, + 'params': optimizer_params, + 'stage_name': execute_stage_name, + 'stage_config': execute_stage_config, + 'stage_hooks': setup_execute_stage.hooks, + 'iterations': optimizer_iterations, + 'algorithm': optimizer_algorithm, + 'time_limit': time_limit, + 'stream_analytics': execute_stage_streamed_analytics + }) + + else: + optimizer = Optimizer({ + 'graph_name': graph_name, + 'graph_id': graph_id, + 'source_stage_name': source_stage_name, + 'source_stage_id': source_stage_id, + 'params': optimizer_params, + 'stage_name': execute_stage_name, + 'stage_config': execute_stage_config, + 'stage_hooks': setup_execute_stage.hooks, + 'iterations': optimizer_iterations, + 'algorithm': optimizer_algorithm, + 'time_limit': time_limit, + 'stream_analytics': execute_stage_streamed_analytics + }) + + + results = optimizer.optimize() + + if execute_stage_config.experiment and results.get('optimized_distribution'): + execute_stage_config.experiment['distribution'] = results.get('optimized_distribution') + + execute_stage_config.batch_size = results.get('optimized_batch_size', execute_stage_config.batch_size) + execute_stage_config.batch_interval = results.get('optimized_batch_interval', execute_stage_config.batch_gradient) + execute_stage_config.batch_gradient = results.get('optimized_batch_gradient', execute_stage_config.batch_gradient) + + logger.filesystem.sync['hyperscale.optimize'].info(f'{optimizer.metadata_string} - Optimization complete') + + context = {} + for stage in pipeline_stages.values(): + + stage.context.ignore_serialization_filters = [ + 'execute_stage_setup_hooks' + ] + + for key, value in stage.context.as_serializable(): + try: + dill.dumps(value) + + except ValueError: + stage.context.ignore_serialization_filters.append(key) + + except TypeError: + stage.context.ignore_serialization_filters.append(key) + + except pickle.PicklingError: + stage.context.ignore_serialization_filters.append(key) + + serializable_context = stage.context.as_serializable() + context.update({ + context_key: context_value for context_key, context_value in serializable_context + }) + + + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + cpu_monitor.close() + memory_monitor.close() + + loop.close() + + return dill.dumps({ + 'worker_id': worker_id, + 'stage': execute_stage.name, + 'config': execute_stage_config, + 'params': results, + 'context': context, + 'monitoring': { + 'cpu': cpu_monitor.collected, + 'memory': memory_monitor.collected + } + }) + + except KeyboardInterrupt: + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + raise ProcessKilledError() + + except BrokenPipeError: + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + raise ProcessKilledError() + + except RuntimeError: + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + raise ProcessKilledError() + + except Exception as e: + cpu_monitor.stop_background_monitor_sync(monitor_name) + memory_monitor.stop_background_monitor_sync(monitor_name) + + raise e \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/setup/__init__.py b/hyperscale/core/graphs/stages/setup/__init__.py new file mode 100644 index 0000000..67236c6 --- /dev/null +++ b/hyperscale/core/graphs/stages/setup/__init__.py @@ -0,0 +1 @@ +from .setup import Setup \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/setup/exceptions/__init__.py b/hyperscale/core/graphs/stages/setup/exceptions/__init__.py new file mode 100644 index 0000000..3923ed5 --- /dev/null +++ b/hyperscale/core/graphs/stages/setup/exceptions/__init__.py @@ -0,0 +1,2 @@ +from .hook_setup_error import HookSetupError +from .hook_setup_timeout_error import HookSetupTimeoutError \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/setup/exceptions/hook_setup_error.py b/hyperscale/core/graphs/stages/setup/exceptions/hook_setup_error.py new file mode 100644 index 0000000..796d0c7 --- /dev/null +++ b/hyperscale/core/graphs/stages/setup/exceptions/hook_setup_error.py @@ -0,0 +1,13 @@ +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class HookSetupError(Exception): + + def __init__(self, hook: Hook, hook_type: HookType, message: str) -> None: + + hook_type = hook_type.name.lower() + super().__init__( + f'Hook Error - @{hook_type} hook {hook.shortname} from stage {hook.stage}\n{message}' + ) + diff --git a/hyperscale/core/graphs/stages/setup/exceptions/hook_setup_timeout_error.py b/hyperscale/core/graphs/stages/setup/exceptions/hook_setup_timeout_error.py new file mode 100644 index 0000000..0c643cc --- /dev/null +++ b/hyperscale/core/graphs/stages/setup/exceptions/hook_setup_timeout_error.py @@ -0,0 +1,12 @@ +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class HookSetupTimeoutError(Exception): + + def __init__(self, hook: Hook, hook_type: HookType, timeout: float) -> None: + + hook_type = hook_type.name.lower() + super().__init__( + f'Hook Error - @{hook_type} hook {hook.shortname} from stage {hook.stage}\nHook failed to complete setup in specified connection timeout of - {timeout} - seconds.' + ) diff --git a/hyperscale/core/graphs/stages/setup/setup.py b/hyperscale/core/graphs/stages/setup/setup.py new file mode 100644 index 0000000..98ddee8 --- /dev/null +++ b/hyperscale/core/graphs/stages/setup/setup.py @@ -0,0 +1,671 @@ +import asyncio +import traceback +from collections import defaultdict +from typing import Any, Dict, Generic, List, Optional, Union + +import psutil +from typing_extensions import TypeVarTuple, Unpack + +from hyperscale.core.engines.client.client import Client +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.tracing_config import TracingConfig +from hyperscale.core.engines.types.common.action_registry import actions_registry +from hyperscale.core.experiments.experiment import Experiment +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.condition.decorator import condition +from hyperscale.core.hooks.types.context.decorator import context +from hyperscale.core.hooks.types.event.decorator import event +from hyperscale.core.hooks.types.internal.decorator import Internal +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.hooks.types.transform.decorator import transform +from hyperscale.core.personas.types import PersonaTypesMap +from hyperscale.logging import HyperscaleLogger +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.plugins.types.engine.engine_plugin import EnginePlugin +from hyperscale.plugins.types.persona.persona_plugin import PersonaPlugin +from hyperscale.plugins.types.plugin_types import PluginType + +from .exceptions import HookSetupError + +try: + + from playwright.async_api import Geolocation + +except Exception: + Geolocation = None + + +T = TypeVarTuple('T') + + +class SetupCall: + + def __init__(self, hook: Hook, config: Config, retries: int=1) -> None: + self.hook = hook + self.config = config + self.hook_name = self.hook.hook_type.name.capitalize() + self.exception = None + self.action_store = None + self.retries = retries + self.current_try = 1 + self.logger = HyperscaleLogger() + self.logger.initialize() + self.metadata_string:str = None + self.error_traceback: str = None + + async def setup(self): + + for _ in range(self.retries): + try: + + self.hook.stage_instance.client._config = self.config + self.hook.stage_instance.client.set_mutations() + await self.hook.call() + break + + except Exception as setup_exception: + self.exception = setup_exception + + self.error_traceback = str(traceback.format_exc()) + await self.logger.filesystem.aio['hyperscale.core'].error(f'{self.metadata_string} - Encountered connection validation error - {str(setup_exception)} - {self.hook_name} Hook: {self.hook.name}:{self.hook.hook_id}') + + if self.current_try >= self.retries: + self.action_store.waiter.set_result(None) + break + + else: + self.current_try += 1 + + + +class Setup(Stage, Generic[Unpack[T]]): + experiment: Optional[Experiment] = None + stage_type=StageTypes.SETUP + log_level='info' + persona_type='default' + total_time='1m' + batch_size=1000 + batch_interval=0 + action_interval=0 + batch_gradient=0.1 + cpus=int(psutil.cpu_count(logical=False)) + no_run_visuals=False + graceful_stop=1 + connect_timeout=10 + request_timeout=60 + reset_connections=False + apply_to_stages=[] + browser_type: str='chromium' + device_type: str=None + locale: str=None + geolocation: Geolocation=None + permissions: List[str]=[] + playwright_options: Dict[str, Any]={} + tracing: TracingConfig=None + priority: Optional[str]=None + actions_filepaths: Optional[Dict[str, str]]=None + retries: int=0 + + + def __init__(self) -> None: + super().__init__() + self.generation_setup_candidates = 0 + self.stages: Dict[str, Stage] = {} + self.accepted_hook_types = [ + HookType.CONDITION, + HookType.CONTEXT, + HookType.EVENT, + HookType.TRANSFORM + ] + + self.persona_types = PersonaTypesMap() + self.config = Config( + log_level=self.log_level, + persona_type=self.persona_types[self.persona_type], + total_time=self.total_time, + batch_size=self.batch_size, + batch_interval=self.batch_interval, + action_interval=self.action_interval, + batch_gradient=self.batch_gradient, + cpus=self.cpus, + no_run_visuals=self.no_run_visuals, + connect_timeout=self.connect_timeout, + request_timeout=self.request_timeout, + graceful_stop=self.graceful_stop, + reset_connections=self.reset_connections, + browser_type=self.browser_type, + device_type=self.device_type, + locale=self.locale, + geolocation=self.geolocation, + permissions=self.permissions, + playwright_options=self.playwright_options, + tracing=self.tracing, + actions_filepaths=self.actions_filepaths + ) + + self.client = Client( + self.graph_name, + self.graph_id, + self.name, + self.stage_id + ) + self.client._config = self.config + + self.experiment = self.experiment + if self.experiment: + self.experiment.source_batch_size = self.batch_size + + self.source_internal_events = [ + 'collect_target_stages' + ] + + self.internal_events = [ + 'collect_target_stages', + 'configure_target_stages', + 'collect_action_hooks', + 'check_actions_setup_needed', + 'setup_action', + 'collect_task_hooks', + 'check_tasks_setup_needed', + 'setup_task', + 'apply_channels', + 'complete' + ] + + self.tracing = self.tracing + self.priority = self.priority + if self.priority is None: + self.priority = 'auto' + + self.priority = self.priority + if self.priority is None: + self.priority = 'auto' + + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + self.stage_retries = self.retries + + @Internal() + async def run(self): + await self.setup_events() + self.dispatcher.assemble_execution_graph() + await self.dispatcher.dispatch_events(self.name) + + @Internal() + async def run_internal(self): + await self.setup_events() + + initial_events = dict(**self.dispatcher.initial_events) + self.dispatcher.skip_list.extend([ + stage_event.event_name for stage_event in self.dispatcher.events_by_name.values() if stage_event.source.shortname not in self.internal_events + ]) + + self.dispatcher.initial_events = initial_events + self.dispatcher.assemble_execution_graph() + await self.dispatcher.dispatch_events(self.name) + + @context() + async def collect_target_stages( + self, + setup_stage_target_stages: Dict[str, Stage]={}, + setup_stage_target_config: Config=None, + setup_stage_is_primary_thread: bool = True + ): + + bypass_connection_validation = self.core_config.get('bypass_connection_validation', False) + connection_validation_retries = self.core_config.get('connection_validation_retries', 3) + + self.context.ignore_serialization_filters = [ + 'session_stage_monitors' + ] + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Starting setup') + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + main_monitor_name = f'{self.name}.main' + + if setup_stage_is_primary_thread: + await cpu_monitor.start_background_monitor(main_monitor_name) + await memory_monitor.start_background_monitor(main_monitor_name) + + + return { + 'setup_stage_experiment_config': {}, + 'setup_stage_target_stages_count': len(setup_stage_target_stages), + 'setup_stage_target_config': setup_stage_target_config, + 'setup_stage_target_stages': setup_stage_target_stages, + 'setup_stage_monitors': { + 'cpu': cpu_monitor, + 'memory': memory_monitor + }, + 'bypass_connection_validation': bypass_connection_validation, + 'connection_validation_retries': connection_validation_retries, + 'setup_stage_is_primary_thread': setup_stage_is_primary_thread + } + + @event('collect_target_stages') + async def configure_target_stages( + self, + setup_stage_target_stages: Dict[str, Stage]={}, + setup_stage_target_config: Config=None, + setup_stage_is_primary_thread: bool=True, + setup_stage_experiment_config: Dict[str, Union[str, int, List[float]]]={}, + ): + execute_stage_names = ', '.join(list(setup_stage_target_stages.keys())) + + setup_stage_configs = {} + + await self.logger.spinner.append_message(f'Setting up - {execute_stage_names}') + + execute_stage_id = 1 + + if setup_stage_is_primary_thread and self.experiment: + self.experiment.assign_weights() + + for execute_stage_name, execute_stage in setup_stage_target_stages.items(): + + execute_stage.execution_stage_id = execute_stage_id + execute_stage.execute_setup_stage = self.name + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Execute stage - {execute_stage_name} - assigned stage order id - {execute_stage_id}') + + execute_stage_id += 1 + + persona_plugins: Dict[str, PersonaPlugin] = self.plugins_by_type[PluginType.PERSONA] + for plugin_name, plugin in persona_plugins.items(): + plugin.name = plugin_name + self.persona_types.types[plugin_name] = plugin + execute_stage.plugins[plugin_name] = plugin + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Loaded Persona plugin - {plugin.name} - for Execute stae - {execute_stage_name}') + + engine_plugins: Dict[str, EnginePlugin] = self.plugins_by_type[PluginType.ENGINE] + + for plugin_name, plugin in engine_plugins.items(): + execute_stage.client.plugin[plugin_name] = plugin(setup_stage_target_config) + plugin.name = plugin_name + execute_stage.plugins[plugin_name] = plugin + self.plugins_by_type[plugin.type][plugin_name] = plugin + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Loaded Engine plugin - {plugin.name} - for Execute stage - {execute_stage_name}') + + existing_stage_config = setup_stage_configs.get(execute_stage_name) + if existing_stage_config: + return { + 'setup_stage_configs': setup_stage_configs, + 'setup_stage_target_stages': setup_stage_target_stages, + 'setup_stage_experiment_config': setup_stage_experiment_config + } + + config_copy = setup_stage_target_config.copy() + + if self.tracing: + config_copy.tracing = self.tracing + + if self.experiment and self.experiment.is_variant(execute_stage_name): + + + variant = self.experiment.get_variant(execute_stage_name) + + experiment = { + 'experiment_name': self.experiment.experiment_name, + 'random': self.experiment.random, + 'weight': variant.weight + } + + if setup_stage_is_primary_thread: + config_copy.batch_size = self.experiment.get_variant_batch_size( + execute_stage_name + ) + + distribution = self.experiment.calculate_distribution( + execute_stage_name, + config_copy.batch_size + ) + + if distribution is not None: + + + experiment.update({ + 'distribution_type': variant.distribution.selected_distribution, + 'distribution': distribution, + 'intervals': variant.intervals, + 'interval_duration': round( + config_copy.total_time/(variant.intervals - 1), + 2 + ) + }) + + config_copy.experiment = experiment + config_copy.persona_type = self.persona_types['approx-dist'] + + if variant.mutations: + config_copy.mutations = variant.get_mutations() + + setup_stage_experiment_config[execute_stage_name] = experiment + + setup_stage_configs[execute_stage_name] = config_copy + setup_stage_target_stages[execute_stage_name] = execute_stage + + return { + 'setup_stage_configs': setup_stage_configs, + 'setup_stage_target_stages': setup_stage_target_stages, + 'setup_stage_experiment_config': setup_stage_experiment_config + } + + @event('configure_target_stages') + async def collect_action_hooks( + self, + setup_stage_target_stages: Dict[str, Stage]={} + ): + actions: List[ActionHook] = [] + for execute_stage in setup_stage_target_stages.values(): + actions.extend(execute_stage.hooks[HookType.ACTION]) + + return { + 'setup_stage_actions': actions + } + + + @condition('collect_action_hooks') + async def check_actions_setup_needed( + self, + setup_stage_actions: List[ActionHook]=[] + ): + + return { + 'setup_stage_has_actions': len(setup_stage_actions) > 0 + } + + @transform('check_actions_setup_needed') + async def setup_action( + self, + setup_stage_configs: Dict[str, Config] = {}, + setup_stage_actions: ActionHook=None, + setup_stage_has_actions: bool = False, + bypass_connection_validation: bool=False, + connection_validation_retries: int=3, + setup_stage_target_config: Config=None + ): + if setup_stage_has_actions and isinstance(setup_stage_actions, ActionHook) and setup_stage_actions.skip is False: + + hook = setup_stage_actions + hook.skip = setup_stage_actions.skip + hook.stage_instance.client.next_name = hook.name + hook.stage_instance.client.intercept = True + + config_copy = setup_stage_configs.get( + hook.stage + ) + + hook.stage_instance.client._config = config_copy + + execute_stage_name = hook.stage_instance.name + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Client intercept set to {hook.stage_instance.client.intercept} - Action calls for client id - {hook.stage_instance.client.client_id} - will be suspended on execution') + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Setting up Action - {hook.name}:{hook.hook_id} - for Execute stage - {execute_stage_name}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Preparing Action hook - {hook.name}:{hook.hook_id} - for suspension - Execute stage - {execute_stage_name}') + + hook.stage_instance.client.actions.set_waiter(hook.stage_instance.name) + + setup_call = SetupCall( + hook, + config_copy, + retries=connection_validation_retries + ) + + setup_call.metadata_string = self.metadata_string + setup_call.action_store = hook.stage_instance.client.actions + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Executing Action hook call - {hook.name}:{hook.hook_id} - Execute stage - {execute_stage_name}') + + task = asyncio.create_task(setup_call.setup()) + await hook.stage_instance.client.actions.wait_for_ready(setup_call) + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Exiting suspension for Action - {hook.name}:{hook.hook_id} - Execute stage - {execute_stage_name}') + + action = None + session = None + + try: + if setup_call.exception: + raise HookSetupError( + hook, + HookType.ACTION, + str(setup_call.exception) + ) + + task.cancel() + if task.cancelled() is False: + await asyncio.wait_for(task, timeout=0.1) + + except HookSetupError as hook_setup_exception: + if bypass_connection_validation: + + action.hook_type = HookType.TASK + + hook.stage_instance.hooks[HookType.TASK].append(hook) + action_idx = hook.stage_instance.hooks[HookType.ACTION].index(hook) + hook.stage_instance.hooks[HookType.ACTION].pop(action_idx) + + else: + raise hook_setup_exception + + except asyncio.InvalidStateError: + pass + + except asyncio.CancelledError: + pass + + except asyncio.TimeoutError: + pass + + action, session = hook.stage_instance.client.actions.get( + hook.stage_instance.name, + hook.name + ) + + await session.set_pool(setup_stage_target_config.batch_size) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Successfully retrieved prepared Action and Session for action - {hook.name}:{action.action_id} - Execute stage - {execute_stage_name}') + + if len(hook.before) > 0: + action.hooks.before = hook.before + + if len(hook.after) > 0: + action.hooks.after = hook.after + + if len(hook.checks) > 0: + action.hooks.checks = hook.checks + + hook.session = session + hook.action = action + + actions_registry[hook.name] = action + + return { + 'setup_stage_actions': setup_stage_actions + } + + @event('setup_action') + async def collect_task_hooks( + self, + setup_stage_target_stages: Dict[str, Stage]={} + ): + tasks: List[TaskHook] = [] + for execute_stage in setup_stage_target_stages.values(): + tasks.extend(execute_stage.hooks[HookType.TASK]) + + return { + 'setup_stage_tasks': tasks + } + + @condition('collect_task_hooks') + async def check_tasks_setup_needed( + self, + setup_stage_tasks: List[ActionHook]=[] + ): + return { + 'setup_stage_has_tasks': len(setup_stage_tasks) > 0 + } + + @transform('check_tasks_setup_needed') + async def setup_task( + self, + setup_stage_tasks: TaskHook=None, + setup_stage_has_tasks: bool=False, + setup_stage_configs: Dict[str, Config] = {}, + ): + if setup_stage_has_tasks and isinstance(setup_stage_tasks, TaskHook) and setup_stage_tasks.skip is False: + hook = setup_stage_tasks + execute_stage: Stage = hook.stage_instance + execute_stage.client.next_name = hook.name + execute_stage.client.intercept = True + + config_copy = setup_stage_configs.get( + hook.stage + ) + + execute_stage.client._config = config_copy + execute_stage.client.set_mutations() + + execute_stage_name = hook.stage_instance.name + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Loading Task hook - {hook.name}:{hook.hook_id} - to Execute stage - {execute_stage_name}') + + execute_stage.client.next_name = hook.name + task, session = execute_stage.client.task.call( + hook.call, + env=hook.metadata.env, + user=hook.metadata.user, + tags=hook.metadata.tags + ) + + await session.set_pool(config_copy.batch_size) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Successfully retrieved task and session for Task - {hook.name}:{task.action_id} - Execute stage - {execute_stage_name}') + + if len(hook.before) > 0: + task.hooks.before = hook.before + + if len(hook.after) > 0: + task.hooks.after = hook.after + + if len(hook.checks) > 0: + task.hooks.checks = hook.checks + + + if hook.is_notifier: + task.hooks.notify = hook.is_notifier + task.hooks.channels = hook.channels + task.hooks.listeners = { + listener.shortname: listener for listener in hook.listeners + } + + elif hook.is_listener: + task.hooks.listen = hook.is_listener + + hook.session = session + hook.action = task + + actions_registry[hook.name] = task + + return { + 'setup_stage_tasks': setup_stage_tasks + } + + + @context('setup_task') + async def apply_channels( + self, + setup_stage_configs: Dict[str, Config] = {}, + setup_stage_target_stages: Dict[str, Stage]=[], + setup_stage_actions: List[ActionHook]=[], + setup_stage_tasks: List[TaskHook]=[], + setup_stage_target_config: Config=None, + setup_stage_experiment_config: Dict[str, Union[str, int, List[float]]]={}, + setup_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]]={}, + setup_stage_is_primary_thread: bool = True + ): + + stage_cpu_monitor: CPUMonitor = setup_stage_monitors.get('cpu') + stage_memory_monitor: MemoryMonitor = setup_stage_monitors.get('memory') + + if setup_stage_is_primary_thread: + main_monitor_name = f'{self.name}.main' + + await stage_cpu_monitor.stop_background_monitor(main_monitor_name) + await stage_memory_monitor.stop_background_monitor(main_monitor_name) + + stage_cpu_monitor.close() + stage_memory_monitor.close() + + stage_cpu_monitor.stage_metrics[main_monitor_name] = stage_cpu_monitor.collected[main_monitor_name] + stage_memory_monitor.stage_metrics[main_monitor_name] = stage_memory_monitor.collected[main_monitor_name] + + actions_by_stage = defaultdict(list) + tasks_by_stage = defaultdict(list) + + for action in setup_stage_actions: + actions_by_stage[action.stage].append(action) + + for task in setup_stage_tasks: + tasks_by_stage[task.stage].append(task) + + execute_stage_setup_hooks = defaultdict(list) + + for execute_stage in setup_stage_target_stages.values(): + + execute_stage.client.intercept = False + execute_stage.hooks[HookType.ACTION] = actions_by_stage[execute_stage.name] + execute_stage.hooks[HookType.TASK] = tasks_by_stage[execute_stage.name] + execute_stage.context['execute_stage_setup_hooks'] = [ + *actions_by_stage[execute_stage.name], + *tasks_by_stage[execute_stage.name] + ] + + execute_stage_setup_hooks[execute_stage.name].extend([ + *actions_by_stage[execute_stage.name], + *tasks_by_stage[execute_stage.name] + ]) + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Client intercept set to {execute_stage.client.intercept} - Action calls for client id - {execute_stage.client.client_id} - will not be suspended on execution') + + self.stages[execute_stage.name] = execute_stage + + actions_generated_count = len(execute_stage.hooks[HookType.ACTION]) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Generated - {actions_generated_count} - Actions for Execute stage - {execute_stage.name}') + + tasks_generated_count = len(execute_stage.hooks[HookType.TASK]) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Generated - {tasks_generated_count} - Tasks for Execute stage - {execute_stage.name}') + + return { + 'setup_stage_experiment_config': setup_stage_experiment_config, + 'setup_stage_configs': setup_stage_configs, + 'execute_stage_setup_by': self.name, + 'execute_stage_setup_hooks': execute_stage_setup_hooks, + 'execute_stage_setup_config': setup_stage_target_config, + 'setup_stage_ready_stages': setup_stage_target_stages, + 'setup_stage_target_stages': setup_stage_target_stages, + 'setup_stage_monitors': { + self.name: { + 'cpu': stage_cpu_monitor, + 'memory': stage_memory_monitor + } + }, + } + + @event('apply_channels') + async def complete(self): + return {} + diff --git a/hyperscale/core/graphs/stages/submit/__init__.py b/hyperscale/core/graphs/stages/submit/__init__.py new file mode 100644 index 0000000..520e9fc --- /dev/null +++ b/hyperscale/core/graphs/stages/submit/__init__.py @@ -0,0 +1 @@ +from .submit import Submit \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/submit/submit.py b/hyperscale/core/graphs/stages/submit/submit.py new file mode 100644 index 0000000..355b20c --- /dev/null +++ b/hyperscale/core/graphs/stages/submit/submit.py @@ -0,0 +1,387 @@ +from typing import Any, Dict, Generic, List, Optional, TypeVar, Union + +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.condition.decorator import condition +from hyperscale.core.hooks.types.context.decorator import context +from hyperscale.core.hooks.types.event.decorator import event +from hyperscale.core.hooks.types.internal.decorator import Internal +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.reporting import Reporter +from hyperscale.reporting.experiment.experiment_metrics_set import ExperimentMetricsSet +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.system import SystemMetricsSet +from hyperscale.reporting.system.system_metrics_group import SystemMetricsGroup +from hyperscale.reporting.system.system_metrics_set_types import SystemMetricGroupType + +T = TypeVar('T') + + +class Submit(Stage, Generic[T]): + stage_type=StageTypes.SUBMIT + stream: bool = False + config: T= None + priority: Optional[str]=None + retries: int=0 + + def __init__(self) -> None: + super().__init__() + self.accepted_hook_types = [ + HookType.CONDITION, + HookType.CONTEXT, + HookType.EVENT, + HookType.TRANSFORM + ] + + self.source_internal_events = [ + 'collect_process_results_and_metrics' + ] + + self.internal_events = [ + 'collect_process_results_and_metrics', + 'collect_reporter_plugins', + 'initialize_reporter', + 'check_for_events', + 'submit_processed_results', + 'submit_stage_metrics', + 'submit_main_metrics', + 'submit_error_metrics', + 'submit_custom_metrics', + 'complete_submit_session' + ] + + self.stream = self.stream + self.config = self.config + self.priority = self.priority + if self.priority is None: + self.priority = 'auto' + + self.priority_level: StagePriority = StagePriority.map( + self.priority + ) + + self.stage_retries = self.retries + + @Internal() + async def run(self): + + await self.setup_events() + self.dispatcher.assemble_execution_graph() + await self.dispatcher.dispatch_events(self.name) + + @context() + async def collect_process_results_and_metrics( + self, + submit_stage_session_total: int = 0, + submit_stage_events: List[T]=[], + submit_stage_summary_metrics: List[MetricsSet]=[], + submit_stage_experiment_metrics: List[ExperimentMetricsSet]=[], + submit_stage_streamed_metrics: Dict[str, StageStreamsSet] = {}, + submit_stage_system_metrics: List[SystemMetricsSet]=[], + ): + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Initializing results submission') + + self.context.ignore_serialization_filters = [ + 'submit_stage_monitors', + 'session_stage_monitors' + ] + + main_monitor_name = f'{self.name}.main' + + cpu_monitor = CPUMonitor() + memory_monitor = MemoryMonitor() + + cpu_monitor.visibility_filters[main_monitor_name] = False + memory_monitor.visibility_filters[main_monitor_name] = False + + cpu_monitor.stage_type = StageTypes.SUBMIT + memory_monitor.stage_type = StageTypes.SUBMIT + + await cpu_monitor.start_background_monitor(main_monitor_name) + await memory_monitor.start_background_monitor(main_monitor_name) + + return { + 'submit_stage_experiment_metrics': submit_stage_experiment_metrics, + 'submit_stage_session_total': submit_stage_session_total, + 'submit_stage_metrics': submit_stage_summary_metrics, + 'submit_stage_events': submit_stage_events, + 'submit_stage_streamed_metrics': submit_stage_streamed_metrics, + 'submit_stage_monitors': { + 'cpu': cpu_monitor, + 'memory': memory_monitor + }, + 'submit_stage_system_metrics': submit_stage_system_metrics + } + + @event('collect_process_results_and_metrics') + async def collect_reporter_plugins(self): + + reporter_plugins = self.plugins_by_type.get(PluginType.REPORTER) + + for plugin_name, plugin in reporter_plugins.items(): + Reporter.reporters[plugin_name] = plugin + + if isinstance(self.config, plugin.config): + self.config.reporter_type = plugin_name + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Loaded Reporter plugin - {plugin_name}') + + return { + 'submit_stage_reporter_plugins': reporter_plugins + } + + @event('collect_reporter_plugins') + async def initialize_reporter(self): + + reporter = Reporter(self.config) + reporter.graph_name = self.graph_name + reporter.graph_id = self.graph_id + reporter.stage_name = self.name + reporter.stage_id = self.stage_id + + reporter_name = reporter.reporter_type_name + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Submitting results via - {reporter_name}:{reporter.reporter_id} - reporter') + await self.logger.spinner.append_message(f'Submitting results via - {reporter_name} - reporter') + + await reporter.connect() + + + return { + 'submit_stage_reporter': reporter, + 'submit_stage_reporter_name': reporter_name + } + + @condition('initialize_reporter') + async def check_for_streams( + self, + submit_stage_streamed_metrics: Dict[str, StageStreamsSet]={}, + ): + return { + 'submit_stage_has_streams': len(submit_stage_streamed_metrics) > 0 + } + + @event('check_for_streams') + async def submit_streamed_metrics( + self, + submit_stage_has_streams: bool=False, + submit_stage_streamed_metrics: Dict[str, StageStreamsSet]={}, + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None, + ): + if submit_stage_has_streams: + submit_stage_streams_count = len(submit_stage_streamed_metrics) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting - {submit_stage_streams_count} - Streams') + await submit_stage_reporter.submit_streams(submit_stage_streamed_metrics) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitted - {submit_stage_streams_count} - Streams') + + + @condition('initialize_reporter') + async def check_for_experiments( + self, + submit_stage_experiment_metrics: List[ExperimentMetricsSet]=[], + + ): + return { + 'submit_stage_has_experiments': len(submit_stage_experiment_metrics) > 0 + } + + @event('check_for_experiments') + async def submit_experiment_results( + self, + submit_stage_experiment_metrics: List[ExperimentMetricsSet]=[], + submit_stage_has_experiments: bool = False, + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None, + ): + if submit_stage_has_experiments: + submit_stage_experiments_count = len(submit_stage_experiment_metrics) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting - {submit_stage_experiments_count} - Experiments') + await submit_stage_reporter.submit_experiments(submit_stage_experiment_metrics) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitted - {submit_stage_experiments_count} - Experiments') + + + @condition('initialize_reporter') + async def check_for_events( + self, + analyze_stage_events: List[Any]=[] + ): + return { + 'submit_stage_has_events': len(analyze_stage_events) > 0 + } + + @event('check_for_events') + async def submit_processed_results( + self, + submit_stage_has_events: bool=False, + submit_stage_events: List[Any]=[], + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None, + submit_stage_session_total: int=0 + ): + + if submit_stage_has_events and self.stream is False: + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting - {submit_stage_session_total} - Events') + await submit_stage_reporter.submit_events(submit_stage_events) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitted - {submit_stage_session_total} - Events') + + return {} + + @event('initialize_reporter') + async def submit_stage_metrics( + self, + submit_stage_metrics: List[Any]=[], + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None + ): + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting Common Metrics') + await submit_stage_reporter.submit_common(submit_stage_metrics) + + return {} + + @event('initialize_reporter') + async def submit_main_metrics( + self, + submit_stage_metrics: List[Any]=[], + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None + ): + await submit_stage_reporter.submit_metrics(submit_stage_metrics) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting Metrics') + + return {} + + @event('initialize_reporter') + async def submit_error_metrics( + self, + submit_stage_metrics: List[Any]=[], + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None + ): + await submit_stage_reporter.submit_errors(submit_stage_metrics) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting Error Metrics') + + return {} + + @event('initialize_reporter') + async def submit_custom_metrics( + self, + submit_stage_metrics: List[Any]=[], + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None + ): + await submit_stage_reporter.submit_custom(submit_stage_metrics) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting Custom Metrics') + + return {} + + @event( + 'submit_experiment_results', + 'submit_processed_results', + 'submit_stage_metrics', + 'submit_main_metrics', + 'submit_error_metrics', + 'submit_custom_metrics' + ) + async def close_stage_system_monitors( + self, + submit_stage_system_metrics: List[SystemMetricsSet]=[], + submit_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]]={} + ): + stage_cpu_monitor: CPUMonitor = submit_stage_monitors.get('cpu') + stage_memory_monitor: MemoryMonitor = submit_stage_monitors.get('memory') + + main_monitor_name = f'{self.name}.main' + + await stage_cpu_monitor.stop_background_monitor(main_monitor_name) + await stage_memory_monitor.stop_background_monitor(main_monitor_name) + + stage_cpu_monitor.close() + stage_memory_monitor.close() + + stage_cpu_monitor.stage_metrics[main_monitor_name] = stage_cpu_monitor.collected[main_monitor_name] + stage_memory_monitor.stage_metrics[main_monitor_name] = stage_memory_monitor.collected[main_monitor_name] + + submit_stage_monitors = { + self.name: { + 'cpu': stage_cpu_monitor, + 'memory': stage_memory_monitor + } + } + + system_metrics = SystemMetricsSet( + submit_stage_monitors, + {} + ) + + system_metrics.generate_system_summaries() + + for metrics_set in submit_stage_system_metrics: + + metrics_set.metrics.update(submit_stage_monitors) + metrics_set.cpu_metrics_by_stage[self.name] = stage_cpu_monitor + metrics_set.memory_metrics_by_stage[self.name] = stage_memory_monitor + + metrics_set.cpu = SystemMetricsGroup( + metrics_set.cpu_metrics_by_stage, + SystemMetricGroupType.CPU + ) + + metrics_set.memory = SystemMetricsGroup( + metrics_set.memory_metrics_by_stage, + SystemMetricGroupType.MEMORY + ) + + metrics_set.generate_system_summaries() + + return { + 'submit_stage_system_metrics': submit_stage_system_metrics, + 'stage_system_metrics': system_metrics + } + + @event('close_stage_system_monitors') + async def submit_system_metrics( + self, + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None, + submit_stage_system_metrics: List[SystemMetricsSet]=[] + ): + + await submit_stage_reporter.submit_system_metrics(submit_stage_system_metrics) + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Submitting System Metrics') + + return {} + + @context('submit_system_metrics') + async def complete_submit_session( + self, + submit_stage_reporter: Reporter=None, + submit_stage_reporter_name: str=None, + submit_stage_session_total: int=0, + submit_stage_monitors: Dict[str, Union[CPUMonitor, MemoryMonitor]]={}, + stage_system_metrics: SystemMetricsSet=None + ): + + await submit_stage_reporter.close() + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Reporter - {submit_stage_reporter_name}:{submit_stage_reporter.reporter_id} - Completed Metrics submission') + await self.logger.spinner.set_default_message(f'Successfully submitted the results for {submit_stage_session_total} actions via {submit_stage_reporter_name} reporter') + + return { + 'submit_stage_monitors': submit_stage_monitors, + 'stage_system_metrics': stage_system_metrics + } + + @event('complete_submit_session') + async def close_session(self): + return {} \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/types/__init__.py b/hyperscale/core/graphs/stages/types/__init__.py new file mode 100644 index 0000000..07a4c46 --- /dev/null +++ b/hyperscale/core/graphs/stages/types/__init__.py @@ -0,0 +1,2 @@ +from .stage_types import StageTypes +from .stage_states import StageStates \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/types/stage_states.py b/hyperscale/core/graphs/stages/types/stage_states.py new file mode 100644 index 0000000..216389c --- /dev/null +++ b/hyperscale/core/graphs/stages/types/stage_states.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class StageStates(Enum): + ACTING='ACTING' + ACTED='ACTED' + INITIALIZED='INITIALIZED' + SETTING_UP='SETTING_UP' + SETUP='SETUP' + VALIDATING='VALIDATING' + VALIDATED='VALIDATED' + OPTIMIZING='OPTIMIZING' + OPTIMIZED='OPTIMIZED' + EXECUTING='EXECUTING' + EXECUTED='EXECUTED' + ANALYZING='ANALYZING' + ANALYZED='ANALYZED' + CHECKPOINTING='CHECKPOINTING' + CHECKPOINTED='CHECKPOINTED' + SUBMITTING='SUBMITTING' + SUBMITTED='SUBMITTED' + TEARDOWN_INITIALIZED='TEARDOWN_INITIALIZED' + TEARDOWN_COMPLETE='TEARDOWN_COMPLETE' + COMPLETE='COMPLETE' + ERRORED='ERRORED' \ No newline at end of file diff --git a/hyperscale/core/graphs/stages/types/stage_types.py b/hyperscale/core/graphs/stages/types/stage_types.py new file mode 100644 index 0000000..95bcf11 --- /dev/null +++ b/hyperscale/core/graphs/stages/types/stage_types.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class StageTypes(Enum): + IDLE='Idle' + ACT='Act' + ANALYZE='Analyze' + EXECUTE='Execute' + OPTIMIZE='Optimize' + SETUP='Setup' + COMPLETE='Complete' + SUBMIT='Submit' + ERROR='Error' + diff --git a/hyperscale/core/graphs/status.py b/hyperscale/core/graphs/status.py new file mode 100644 index 0000000..1dbdc20 --- /dev/null +++ b/hyperscale/core/graphs/status.py @@ -0,0 +1,11 @@ +from enum import Enum + +class GraphStatus(Enum): + IDLE='IDLE' + INITIALIZING='INITIALIZING' + VALIDATING='VALIDATING' + ASSEMBLING='ASSEMBLING' + RUNNING='RUNNING' + COMPLETE='COMPLETE' + CANCELLED='CANCELLED' + FAILED='FAILED' \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/__init__.py b/hyperscale/core/graphs/transitions/__init__.py new file mode 100644 index 0000000..1d2b2e2 --- /dev/null +++ b/hyperscale/core/graphs/transitions/__init__.py @@ -0,0 +1,2 @@ +from .transition_assembler import TransitionAssembler +from .local_transitions import local_transitions \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/act/__init__.py b/hyperscale/core/graphs/transitions/act/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/act/act_edge.py b/hyperscale/core/graphs/transitions/act/act_edge.py new file mode 100644 index 0000000..6a48911 --- /dev/null +++ b/hyperscale/core/graphs/transitions/act/act_edge.py @@ -0,0 +1,369 @@ +from __future__ import annotations + +import asyncio +import inspect +from collections import defaultdict +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.graphs.stages.act.act import Act +from hyperscale.core.graphs.stages.analyze.analyze import Analyze +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup + +ExecuteHooks = List[Union[ActionHook , TaskHook]] + + +class ActEdge(BaseEdge[Act]): + + def __init__(self, source: Act, destination: BaseEdge[Stage]) -> None: + super( + ActEdge, + self + ).__init__( + source, + destination + ) + + self.requires = [ + 'analyze_stage_summary_metrics', + 'execute_stage_setup_config', + 'execute_stage_setup_by', + 'execute_stage_setup_hooks', + 'execute_stage_results', + 'execute_stage_streamed_analytics', + 'setup_stage_configs', + 'setup_stage_experiment_config', + 'setup_stage_ready_stages', + 'setup_stage_candidates', + 'session_stage_monitors' + ] + + self.provides = [ + 'analyze_stage_summary_metrics', + 'execute_stage_setup_hooks', + 'execute_stage_setup_config', + 'execute_stage_setup_by', + 'execute_stage_results', + 'execute_stage_streamed_analytics', + 'setup_stage_configs', + 'setup_stage_ready_stages', + 'setup_stage_experiment_config', + 'act_stage_monitors', + 'session_stage_monitors' + ] + + self.valid_states = [ + StageStates.SETUP, + StageStates.OPTIMIZED + ] + + self.assigned_candidates = [] + + async def transition(self): + + try: + self.source.state = StageStates.ACTING + + act_stages = self.stages_by_type.get(StageTypes.ACT) + analyze_stages: Dict[str, Stage] = self.generate_analyze_candidates() + + self.source.context.update(self.edge_data) + + for event in self.source.dispatcher.events_by_name.values(): + event.source.stage_instance = self.source + event.context.update(self.edge_data) + + if event.source.context: + event.source.context.update(self.edge_data) + + if self.timeout and self.skip_stage is False: + await asyncio.wait_for(self.source.run(), timeout=self.timeout) + + elif self.skip_stage is False: + await self.source.run() + + for provided in self.provides: + self.edge_data[provided] = self.source.context[provided] + + if self.destination.context is None: + self.destination.context = SimpleContext() + + self._update(self.destination) + + all_paths = self.all_paths.get(self.source.name, []) + + for stage in analyze_stages.values(): + if stage.name in all_paths: + if stage.context is None: + stage.context = SimpleContext() + + self._update(stage) + + if self.destination.stage_type == StageTypes.SETUP: + + act_stages = list(self.stages_by_type.get(StageTypes.ACT).values()) + + for stage in act_stages: + if stage.name not in self.visited and stage.state == StageStates.SETUP: + stage.state = StageStates.INITIALIZED + + self.source.state = StageStates.ACTED + + self.visited.append(self.source.name) + + except Exception as edge_exception: + self.exception = edge_exception + + return None, self.destination.stage_type + + def _update(self, destination: Stage): + + for edge_name in self.history: + + history = self.history[edge_name] + + if self.next_history.get(edge_name) is None: + self.next_history[edge_name] = {} + + self.next_history[edge_name].update({ + key: value for key, value in history.items() if key in self.provides + }) + + next_results = self.next_history.get((self.source.name, destination.name)) + if next_results is None: + next_results = {} + + if self.skip_stage is False: + + session_stage_monitors: MonitorGroup = self.edge_data['session_stage_monitors'] + session_stage_monitors.update( + self.edge_data['act_stage_monitors'] + ) + + next_results.update({ + **self.edge_data, + 'session_stage_monitors': session_stage_monitors + }) + + self.next_history.update({ + (self.source.name, destination.name): next_results + }) + + def split(self, edges: List[ActEdge]) -> None: + + analyze_stage_config: Dict[str, Any] = self.source.to_copy_dict() + + act_stage_copy: Act = type(self.source.name, (Act, ), self.source.__dict__)() + + for copied_attribute_name, copied_attribute_value in analyze_stage_config.items(): + if inspect.ismethod(copied_attribute_value) is False: + setattr( + act_stage_copy, + copied_attribute_name, + copied_attribute_value + ) + + user_hooks: Dict[str, Dict[str, Hook]] = defaultdict(dict) + for hooks in registrar.all.values(): + for hook in hooks: + if hasattr(self.source, hook.shortname) and not hasattr(Act, hook.shortname): + user_hooks[self.source.name][hook.shortname] = hook._call + + act_stage_copy.dispatcher = self.source.dispatcher.copy() + + for event in act_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = act_stage_copy + + minimum_edge_idx = min([edge.transition_idx for edge in edges]) + + act_stage_copy.context = SimpleContext() + for event in act_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = act_stage_copy + event.source.stage_instance.context = act_stage_copy.context + event.source.context = act_stage_copy.context + + if event.source.shortname in user_hooks[act_stage_copy.name]: + hook_call = user_hooks[act_stage_copy.name].get(event.source.shortname) + + hook_call = hook_call.__get__(act_stage_copy, act_stage_copy.__class__) + setattr(act_stage_copy, event.source.shortname, hook_call) + + event.source._call = hook_call + + else: + event.source._call = getattr(act_stage_copy, event.source.shortname) + event.source._call = event.source._call.__get__(act_stage_copy, act_stage_copy.__class__) + setattr(act_stage_copy, event.source.shortname, event.source._call) + + self.source = act_stage_copy + + if minimum_edge_idx < self.transition_idx: + self.skip_stage = True + + def _generate_edge_analyze_candidates(self, edges: List[ActEdge]): + + candidates = [] + + for edge in edges: + if edge.transition_idx != self.transition_idx: + analyze_candidates = edge.generate_analyze_candidates() + destination_path = edge.all_paths.get(edge.destination.name) + candidates.extend([ + candidate_name for candidate_name in analyze_candidates if candidate_name in destination_path + ]) + + return candidates + + def generate_analyze_candidates(self) -> Dict[str, Stage]: + + submit_stages: Dict[str, Analyze] = self.stages_by_type.get(StageTypes.ANALYZE) + analyze_stages = self.stages_by_type.get(StageTypes.ACT).items() + path_lengths: Dict[str, int] = self.path_lengths.get(self.source.name) + + all_paths = self.all_paths.get(self.source.name, []) + + analyze_stages_in_path = {} + for stage_name, stage in analyze_stages: + if stage_name in all_paths and stage_name != self.source.name and stage_name not in self.visited: + analyze_stages_in_path[stage_name] = self.all_paths.get(stage_name) + + submit_candidates: Dict[str, Stage] = {} + + for stage_name, stage in submit_stages.items(): + if stage_name in all_paths: + if len(analyze_stages_in_path) > 0: + for path in analyze_stages_in_path.values(): + if stage_name not in path: + submit_candidates[stage_name] = stage + + else: + submit_candidates[stage_name] = stage + + selected_submit_candidates: Dict[str, Stage] = {} + following_opimize_stage_distances = [ + path_length for stage_name, path_length in path_lengths.items() if stage_name in analyze_stages + ] + + for stage_name in path_lengths.keys(): + stage_distance = path_lengths.get(stage_name) + + if stage_name in submit_candidates: + + if len(following_opimize_stage_distances) > 0 and stage_distance < min(following_opimize_stage_distances): + selected_submit_candidates[stage_name] = submit_candidates.get(stage_name) + + elif len(following_opimize_stage_distances) == 0: + selected_submit_candidates[stage_name] = submit_candidates.get(stage_name) + + return selected_submit_candidates + + def setup(self) -> None: + + max_batch_size = 0 + execute_stage_setup_config: Config = None + execute_stage_setup_hooks: Dict[str, ExecuteHooks] = {} + execute_stage_setup_by: str = None + execute_stage_streamed_analytics: Dict[str, List[StreamAnalytics]]= defaultdict(list) + setup_stage_ready_stages: List[Stage] = [] + setup_stage_candidates: List[Stage] = [] + setup_stage_configs: Dict[str, Config] = {} + setup_stage_experiment_config: Dict[str, Union[str, int, List[float]]] = {} + + for source_stage, destination_stage in self.history: + + previous_history: Dict[str, Any] = self.history[(source_stage, destination_stage)] + + if destination_stage == self.source.name: + execute_config: Config = previous_history.get('execute_stage_setup_config', Config()) + setup_stage_configs.update( + previous_history.get( + 'setup_stage_configs', + {} + ) + ) + + setup_by = previous_history.get('execute_stage_setup_by') + + if execute_config.optimized: + execute_stage_setup_config = execute_config + max_batch_size = execute_config.batch_size + execute_stage_setup_by = setup_by + + elif execute_config.batch_size > max_batch_size: + execute_stage_setup_config = execute_config + max_batch_size = execute_config.batch_size + execute_stage_setup_by = setup_by + + execute_hooks: ExecuteHooks = previous_history.get('execute_stage_setup_hooks', {}) + for setup_hook in execute_hooks: + execute_stage_setup_hooks[setup_hook.name] = setup_hook + + ready_stages = previous_history.get('setup_stage_ready_stages', []) + + for ready_stage in ready_stages: + if ready_stage not in setup_stage_ready_stages: + setup_stage_ready_stages.append(ready_stage) + + stage_candidates: List[Stage] = previous_history.get('setup_stage_candidates', []) + for stage_candidate in stage_candidates: + if stage_candidate not in setup_stage_candidates: + setup_stage_candidates.append(stage_candidate) + + stage_distributions = previous_history.get('setup_stage_experiment_config') + + if stage_distributions: + setup_stage_experiment_config.update(stage_distributions) + + streamed_analytics = previous_history.get('execute_stage_streamed_analytics') + + if streamed_analytics: + execute_stage_streamed_analytics[source_stage].extend(streamed_analytics) + + raw_results = {} + for from_stage_name in self.from_stage_names: + + stage_results = self.history[( + from_stage_name, + self.source.name + )].get('execute_stage_results') + + if stage_results: + raw_results.update(stage_results) + + act_stages = self.stages_by_type.get(StageTypes.ACT) + + results_to_calculate = {} + target_stages = {} + for stage_name in raw_results.keys(): + stage = act_stages.get(stage_name) + all_paths = self.all_paths.get(stage_name) + + in_path = self.source.name in all_paths + + if in_path: + stage.state = StageStates.ANALYZING + results_to_calculate[stage_name] = raw_results.get(stage_name) + target_stages[stage_name] = stage + + self.edge_data = { + 'setup_stage_configs': setup_stage_configs, + 'analyze_stage_raw_results': results_to_calculate, + 'analyze_stage_target_stages': target_stages, + 'analyze_stage_has_results': len(results_to_calculate) > 0, + 'execute_stage_setup_config': execute_stage_setup_config, + 'execute_stage_setup_hooks': execute_stage_setup_hooks, + 'execute_stage_setup_by': execute_stage_setup_by, + 'setup_stage_ready_stages': setup_stage_ready_stages, + 'setup_stage_candidates': setup_stage_candidates + + } \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/analyze/__init__.py b/hyperscale/core/graphs/transitions/analyze/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/analyze/analyze_edge.py b/hyperscale/core/graphs/transitions/analyze/analyze_edge.py new file mode 100644 index 0000000..3a3aef2 --- /dev/null +++ b/hyperscale/core/graphs/transitions/analyze/analyze_edge.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import asyncio +import inspect +from collections import defaultdict +from typing import Any, Dict, List + +from hyperscale.core.graphs.stages.analyze.analyze import Analyze +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.submit.submit import Submit +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup + + +class AnalyzeEdge(BaseEdge[Analyze]): + + def __init__(self, source: Analyze, destination: BaseEdge[Stage]) -> None: + super( + AnalyzeEdge, + self + ).__init__( + source, + destination + ) + + self.requires = [ + 'setup_stage_experiment_config', + 'execute_stage_streamed_analytics', + 'execute_stage_results', + 'session_stage_monitors' + ] + self.provides = [ + 'setup_stage_experiment_config', + 'execute_stage_streamed_analytics', + 'analyze_stage_summary_metrics', + 'session_stage_monitors', + 'analyze_stage_monitors' + ] + + self.valid_states = [ + StageStates.EXECUTED, + StageStates.CHECKPOINTED, + StageStates.TEARDOWN_COMPLETE + ] + + self.assigned_candidates = [] + + async def transition(self): + + try: + self.source.state = StageStates.ANALYZING + submit_candidates = self.generate_submit_candidates() + + self.source.context.update(self.edge_data) + + for event in self.source.dispatcher.events_by_name.values(): + event.source.stage_instance = self.source + event.context.update(self.edge_data) + + if event.source.context: + event.source.context.update(self.edge_data) + + if self.edge_data['analyze_stage_has_results']: + + if self.timeout and self.skip_stage is False: + await asyncio.wait_for(self.source.run(), timeout=self.timeout) + + elif self.skip_stage is False: + await self.source.run() + + for provided in self.provides: + self.edge_data[provided] = self.source.context[provided] + + self.destination.state = StageStates.ANALYZED + + if self.destination.context is None: + self.destination.context = SimpleContext() + + if self.edge_data['analyze_stage_has_results']: + + self._update(self.destination) + + all_paths = [] + for path_set in self.all_paths.get(self.source.name, []): + all_paths.extend(path_set) + + for stage in submit_candidates.values(): + if stage.name in all_paths and stage.state == StageStates.INITIALIZED: + + if stage.context is None: + stage.context = SimpleContext() + + self._update(stage) + + stage.state = StageStates.ANALYZED + + self.source.state = StageStates.ANALYZED + + self.visited.append(self.source.name) + + except Exception as edge_exception: + self.exception = edge_exception + + return None, self.destination.stage_type + + def _update(self, destination: Stage): + + for edge_name in self.history: + + history = self.history[edge_name] + + self.next_history[edge_name] = {} + + self.next_history[edge_name].update({ + key: value for key, value in history.items() if key in self.provides + }) + + if self.skip_stage is False: + + session_stage_monitors: MonitorGroup = self.edge_data['session_stage_monitors'] + session_stage_monitors.update( + self.edge_data['analyze_stage_monitors'] + ) + + self.next_history.update({ + (self.source.name, destination.name): { + 'analyze_stage_summary_metrics': self.edge_data.get( + 'analyze_stage_summary_metrics', + {} + ), + 'session_stage_monitors': session_stage_monitors + } + }) + + def split(self, edges: List[AnalyzeEdge]) -> None: + + analyze_stage_config: Dict[str, Any] = self.source.to_copy_dict() + + analyze_stage_copy = type(self.source.name, (Analyze, ), {})() + + for copied_attribute_name, copied_attribute_value in analyze_stage_config.items(): + if inspect.ismethod(copied_attribute_value) is False: + setattr(analyze_stage_copy, copied_attribute_name, copied_attribute_value) + + user_hooks: Dict[str, Dict[str, Hook]] = defaultdict(dict) + for hooks in registrar.all.values(): + for hook in hooks: + if hasattr(self.source, hook.shortname) and not hasattr(Analyze, hook.shortname): + user_hooks[self.source.name][hook.shortname] = hook._call + + analyze_stage_copy.dispatcher = self.source.dispatcher.copy() + + for event in analyze_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = analyze_stage_copy + + minimum_edge_idx = min([edge.transition_idx for edge in edges]) + + analyze_stage_copy.context = SimpleContext() + for event in analyze_stage_copy.dispatcher.events_by_name.values(): + event.context = analyze_stage_copy.context + event.source.stage_instance.context = analyze_stage_copy.context + event.source.context = analyze_stage_copy.context + + if event.source.shortname in user_hooks[analyze_stage_copy.name]: + hook_call = user_hooks[analyze_stage_copy.name].get(event.source.shortname) + + hook_call = hook_call.__get__(analyze_stage_copy, analyze_stage_copy.__class__) + setattr(analyze_stage_copy, event.source.shortname, hook_call) + + event.source._call = hook_call + + else: + event.source._call = getattr(analyze_stage_copy, event.source.shortname) + event.source._call = event.source._call.__get__(analyze_stage_copy, analyze_stage_copy.__class__) + setattr(analyze_stage_copy, event.source.shortname, event.source._call) + + self.source = analyze_stage_copy + + if minimum_edge_idx < self.transition_idx: + self.skip_stage = True + + def generate_submit_candidates(self) -> Dict[str, Stage]: + + submit_stages: Dict[str, Submit] = self.stages_by_type.get(StageTypes.SUBMIT) + path_lengths: Dict[str, int] = self.path_lengths.get(self.source.name) + + all_paths = self.all_paths.get(self.source.name, []) + + submit_candidates: Dict[str, Stage] = {} + + for stage_name, stage in submit_stages.items(): + if stage_name in all_paths: + submit_candidates[stage_name] = stage + + selected_submit_candidates: Dict[str, Stage] = {} + following_submit_stage_distances = [ + path_length for stage_name, path_length in path_lengths.items() if stage_name in submit_stages + ] + + for stage_name in path_lengths.keys(): + stage_distance = path_lengths.get(stage_name) + + if stage_name in submit_candidates: + + if len(following_submit_stage_distances) > 0 and stage_distance <= min(following_submit_stage_distances): + selected_submit_candidates[stage_name] = submit_candidates.get(stage_name) + + elif len(following_submit_stage_distances) == 0: + selected_submit_candidates[stage_name] = submit_candidates.get(stage_name) + + return selected_submit_candidates + + def setup(self) -> None: + + raw_results = {} + session_stage_monitors: MonitorGroup = {} + + for source_stage, destination_stage in self.history: + + stage_results = {} + + source_history: Dict[str, Any] = self.history.get(( + source_stage, + self.source.name + ), {}) + + if destination_stage == self.source.name: + + stage_results = source_history.get('execute_stage_results', {}) + + stage_monitors = source_history.get('session_stage_monitors') + if stage_monitors: + session_stage_monitors.update(stage_monitors) + + raw_results.update(stage_results) + + execute_stages = self.stages_by_type.get(StageTypes.EXECUTE) + + results_to_calculate = {} + target_stages = {} + for stage_name in raw_results.keys(): + stage = execute_stages.get(stage_name) + all_paths = self.all_paths.get(stage_name) + + in_path = self.source.name in all_paths + + if in_path: + stage.state = StageStates.ANALYZING + results_to_calculate[stage_name] = raw_results.get(stage_name) + target_stages[stage_name] = stage + + self.edge_data = { + 'session_stage_monitors': session_stage_monitors, + 'analyze_stage_raw_results': results_to_calculate, + 'analyze_stage_target_stages': target_stages, + 'analyze_stage_has_results': len(results_to_calculate) > 0 + } diff --git a/hyperscale/core/graphs/transitions/common/__init__.py b/hyperscale/core/graphs/transitions/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/common/base_edge.py b/hyperscale/core/graphs/transitions/common/base_edge.py new file mode 100644 index 0000000..630e40d --- /dev/null +++ b/hyperscale/core/graphs/transitions/common/base_edge.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar, Union + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes + +T = TypeVar('T') + + +class BaseEdge(Generic[T]): + + def __init__(self, source: Union[T, Stage], destination: Stage) -> None: + self.requires = [] + self.provides = [] + + self.to_stage_names: List[str] = [] + self.from_stage_names: List[str] = [] + self.stages_by_type: Dict[StageTypes, Dict[str, Stage]] = {} + self.path_lengths: Dict[str, int] = {} + self.history = {} + self.edge_data = {} + self.next_history = {} + self.visited = [] + self.valid_states = [] + self.descendants = [] + self.all_paths: Dict[str, List[str]] = {} + self.source = source + self.destination = destination + self.timeout = None + self.folded = False + self.transition_idx = 0 + self.exception: Optional[Exception] = None + + self.edges_by_name: Dict[str, Stage] = {} + + for stage_type in StageTypes: + self.stages_by_type[stage_type] = {} + + self.skip_stage = self.source.skip + + def __getitem__(self, key: str): + return self.history.get(key) + + + def __setitem__(self, key: str, value: Any): + self.history[key] = value + + + async def transition(self) -> Tuple[None, StageTypes]: + raise NotImplementedError('Err. - Please implement this method in the Edge class inheriting BaseEdge') + + def update(self, destingation: Stage): + raise NotImplementedError('Err. - Please implement this method in the Edge class inheriting BaseEdge') + + def split(self) -> None: + raise NotImplementedError('Err. - Please implement this method in the Edge class inheriting BaseEdge') + + def merge(self) -> None: + raise NotImplementedError('Err. - Please implement this method in the Edge class inheriting BaseEdge') + + def setup(self) -> None: + pass + #raise NotImplementedError('Err. - Please implement this method in the Edge class inheriting BaseEdge') \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/common/complete_edge.py b/hyperscale/core/graphs/transitions/common/complete_edge.py new file mode 100644 index 0000000..0e60810 --- /dev/null +++ b/hyperscale/core/graphs/transitions/common/complete_edge.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from typing import List + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.complete.complete import Complete +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge + + +class CompleteEdge(BaseEdge[Complete]): + + def __init__(self, source: Complete, destination: BaseEdge[Stage]) -> None: + super( + CompleteEdge, + self + ).__init__( + source, + destination + ) + + async def transition(self): + + await self.source.run() + + self.source.state = StageStates.COMPLETE + + self.visited.append(self.source.name) + + return None, None + + def _update(self, destination: Stage): + self.next_history.update({ + (self.source.name, destination.name): {} + }) + + def split(self, edges: List[CompleteEdge]) -> None: + pass + + diff --git a/hyperscale/core/graphs/transitions/common/error_edge.py b/hyperscale/core/graphs/transitions/common/error_edge.py new file mode 100644 index 0000000..90095e6 --- /dev/null +++ b/hyperscale/core/graphs/transitions/common/error_edge.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import List + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.error.error import Error +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge + + +class ErrorEdge(BaseEdge[Error]): + + def __init__(self, source: Error, destination: BaseEdge[Stage]) -> None: + super( + ErrorEdge, + self + ).__init__( + source, + destination + ) + + async def transition(self): + + await self.source.run() + + self.source.state = StageStates.ERRORED + + self.visited.append(self.source.name) + + return None, None + + def _update(self, destination: Stage): + self.next_history.update({ + (self.source.name, destination.name): {} + }) + + def split(self, edges: List[ErrorEdge]) -> None: + pass diff --git a/hyperscale/core/graphs/transitions/common/transtition_metadata.py b/hyperscale/core/graphs/transitions/common/transtition_metadata.py new file mode 100644 index 0000000..7178d20 --- /dev/null +++ b/hyperscale/core/graphs/transitions/common/transtition_metadata.py @@ -0,0 +1,9 @@ +class TransitionMetadata: + + def __init__( + self, + allow_multiple_edges: bool, + is_valid: bool + ) -> None: + self.allow_multiple_edges = allow_multiple_edges + self.is_valid = is_valid \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/exceptions/__init__.py b/hyperscale/core/graphs/transitions/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/exceptions/exceptions.py b/hyperscale/core/graphs/transitions/exceptions/exceptions.py new file mode 100644 index 0000000..dc45eec --- /dev/null +++ b/hyperscale/core/graphs/transitions/exceptions/exceptions.py @@ -0,0 +1,58 @@ +from hyperscale.core.graphs.stages.base.stage import Stage + + +class InvalidTransitionError(Exception): + + def __init__(self, from_stage: Stage, to_stage: Stage) -> None: + + from_stage_name = from_stage.__class__.__name__ + to_stage_name = to_stage.__class__.__name__ + from_stage_type = from_stage.stage_type.name.capitalize() + to_stage_type = to_stage.stage_type.name.capitalize() + + super().__init__( + f'Error - {from_stage_type} stage {from_stage_name} cannot preceed {to_stage_type} stage {to_stage_name}.' + ) + +class IdleTranstionError(Exception): + def __init__(self, stage: Stage) -> None: + + stage_name = stage.__class__.__name__ + stage_type = stage.stage_type.name.capitalize() + + super().__init__( + f"Error - {stage_type} stage {stage_name} cannot be the initial stage. Make sure you have a Setup class and set it as {stage_name}'s dependency!" + ) + +class IsolatedStageError(Exception): + + def __init__(self, stage: Stage) -> None: + + stage_name = stage.__class__.__name__ + stage_type = stage.stage_type.name.capitalize() + + super().__init__( + f'Error - {stage_type} stage {stage_name} has no dependencies and is thus unreachable by the execution graph.' + ) + + +class StageTimeoutError(Exception): + + def __init__(self, from_stage: Stage) -> None: + self.from_stage = from_stage + + super().__init__( + f'Stage Timeout Exception - Stage {from_stage.name} of type {from_stage.stage_type.name}:\nStage exceeded provided timeout of {from_stage.timeout} seconds.' + ) + + +class StageExecutionError(Exception): + + def __init__(self, from_stage: Stage, to_stage: Stage, message: str) -> None: + + self.from_stage = from_stage + self.to_stage = to_stage + + super().__init__( + f'Stage Execution Exception - Stage {from_stage.name} of type {from_stage.stage_type.name}:\n Encountered error - {message} - while transitioning to {to_stage.stage_type.name} stage - {self.to_stage.name}.' + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/execute/__init__.py b/hyperscale/core/graphs/transitions/execute/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/execute/execute_edge.py b/hyperscale/core/graphs/transitions/execute/execute_edge.py new file mode 100644 index 0000000..6a5a537 --- /dev/null +++ b/hyperscale/core/graphs/transitions/execute/execute_edge.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import asyncio +import inspect +from collections import defaultdict +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.graphs.stages.analyze.analyze import Analyze +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.execute.execute import Execute +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.reporting.reporter import ReporterConfig +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup + +ExecuteHooks = List[Union[ActionHook , TaskHook]] + + +class ExecuteEdge(BaseEdge[Execute]): + + def __init__(self, source: Execute, destination: BaseEdge[Stage]) -> None: + super( + ExecuteEdge, + self + ).__init__( + source, + destination + ) + + self.requires = [ + 'setup_stage_configs', + 'setup_stage_ready_stages', + 'setup_stage_candidates', + 'setup_stage_experiment_config', + 'execute_stage_setup_config', + 'execute_stage_setup_by', + 'execute_stage_setup_hooks', + 'execute_stage_results', + 'execute_stage_streamed_analytics', + 'session_stage_monitors' + ] + + self.provides = [ + 'setup_stage_configs', + 'setup_stage_experiment_config', + 'execute_stage_results', + 'execute_stage_setup_hooks', + 'execute_stage_setup_config', + 'execute_stage_setup_by', + 'setup_stage_ready_stages', + 'execute_stage_skipped', + 'execute_stage_streamed_analytics', + 'execute_stage_monitors', + 'session_stage_monitors' + ] + + self.valid_states = [ + StageStates.SETUP, + StageStates.OPTIMIZED + ] + + self.assigned_candidates = [] + self.execute_stage_stream_configs: List[ReporterConfig] = [] + + async def transition(self): + + try: + self.source.state = StageStates.EXECUTING + + execute_stages = self.stages_by_type.get(StageTypes.EXECUTE) + analyze_stages: Dict[str, Stage] = self.generate_analyze_candidates() + + if len(self.assigned_candidates) > 0: + analyze_stages = { + stage_name: stage for stage_name, stage in analyze_stages.items() if stage_name in self.assigned_candidates + } + + self.source.context.update(self.edge_data) + + for event in self.source.dispatcher.events_by_name.values(): + event.source.stage_instance = self.source + event.context.update(self.edge_data) + + if event.source.context: + event.source.context.update(self.edge_data) + + if self.timeout and self.skip_stage is False: + await asyncio.wait_for(self.source.run(), timeout=self.timeout) + + elif self.skip_stage is False: + await self.source.run() + + for provided in self.provides: + self.edge_data[provided] = self.source.context[provided] + + if self.destination.context is None: + self.destination.context = SimpleContext() + + self._update(self.destination) + + all_paths = self.all_paths.get(self.source.name, []) + + for stage in analyze_stages.values(): + if stage.name in all_paths and stage.name != self.destination.name: + if stage.context is None: + stage.context = SimpleContext() + + self._update(stage) + + if self.destination.stage_type == StageTypes.SETUP: + + execute_stages = list(self.stages_by_type.get(StageTypes.EXECUTE).values()) + + for stage in execute_stages: + if stage.name not in self.visited and stage.state == StageStates.SETUP: + stage.state = StageStates.INITIALIZED + + self.source.state = StageStates.EXECUTED + + self.visited.append(self.source.name) + + except Exception as edge_exception: + self.exception = edge_exception + + return None, self.destination.stage_type + + def _update(self, destination: Stage): + + for edge_name in self.history: + + history = self.history[edge_name] + + if self.next_history.get(edge_name) is None: + self.next_history[edge_name] = {} + + self.next_history[edge_name].update(history) + + next_results = self.next_history.get((self.source.name, destination.name)) + if next_results is None: + next_results = {} + + if self.skip_stage is False: + + session_stage_monitors: MonitorGroup = self.edge_data['session_stage_monitors'] + session_stage_monitors.update( + self.edge_data['execute_stage_monitors'] + ) + + execute_hooks: Dict[str, Hook] = self.edge_data['execute_stage_setup_hooks'] + + next_results.update({ + 'execute_stage_results': { + self.source.name: self.edge_data['execute_stage_results'] + }, + 'session_stage_monitors': session_stage_monitors, + 'execute_stage_streamed_analytics': self.edge_data['execute_stage_streamed_analytics'], + 'execute_stage_setup_config': self.edge_data['execute_stage_setup_config'], + 'execute_stage_setup_hooks': list(execute_hooks.values()), + 'execute_stage_setup_by': self.edge_data['execute_stage_setup_by'], + 'setup_stage_ready_stages': self.edge_data['setup_stage_ready_stages'], + 'setup_stage_candidates': self.edge_data['setup_stage_candidates'], + 'setup_stage_configs': self.edge_data['session_setup_stage_configs'] + }) + + self.next_history.update({ + (self.source.name, destination.name): next_results + }) + + def split(self, edges: List[ExecuteEdge]) -> None: + + execute_stage_config: Dict[str, Any] = self.source.to_copy_dict() + + execute_stage_copy: Execute = type(self.source.name, (Execute, ), self.source.__dict__)() + + for copied_attribute_name, copied_attribute_value in execute_stage_config.items(): + if inspect.ismethod(copied_attribute_value) is False: + setattr( + execute_stage_copy, + copied_attribute_name, + copied_attribute_value + ) + + user_hooks: Dict[str, Dict[str, Hook]] = defaultdict(dict) + for hooks in registrar.all.values(): + for hook in hooks: + if hasattr(self.source, hook.shortname) and not hasattr(Execute, hook.shortname): + user_hooks[self.source.name][hook.shortname] = hook._call + + execute_stage_copy.dispatcher = self.source.dispatcher.copy() + + for event in execute_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = execute_stage_copy + + minimum_edge_idx = min([edge.transition_idx for edge in edges]) + + execute_stage_copy.context = SimpleContext() + for event in execute_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = execute_stage_copy + event.source.stage_instance.context = execute_stage_copy.context + event.source.context = execute_stage_copy.context + + if event.source.shortname in user_hooks[execute_stage_copy.name]: + hook_call = user_hooks[execute_stage_copy.name].get(event.source.shortname) + + hook_call = hook_call.__get__(execute_stage_copy, execute_stage_copy.__class__) + setattr(execute_stage_copy, event.source.shortname, hook_call) + + event.source._call = hook_call + + else: + event.source._call = getattr(execute_stage_copy, event.source.shortname) + event.source._call = event.source._call.__get__(execute_stage_copy, execute_stage_copy.__class__) + setattr(execute_stage_copy, event.source.shortname, event.source._call) + + self.source = execute_stage_copy + + if minimum_edge_idx < self.transition_idx: + self.skip_stage = True + + def generate_analyze_candidates(self) -> Dict[str, Stage]: + + analyze_stages: Dict[str, Analyze] = self.stages_by_type.get(StageTypes.ANALYZE) + path_lengths: Dict[str, int] = self.path_lengths.get(self.source.name) + + all_paths = self.all_paths.get(self.source.name, []) + + analyze_candidates: Dict[str, Stage] = {} + + for stage_name, stage in analyze_stages.items(): + if stage_name in all_paths: + analyze_candidates[stage_name] = stage + + selected_analyze_candidates: Dict[str, Stage] = {} + following_analyze_stage_distances = [ + path_length for stage_name, path_length in path_lengths.items() if stage_name in analyze_stages + ] + + for stage_name in path_lengths.keys(): + stage_distance = path_lengths.get(stage_name) + + if stage_name in analyze_candidates: + + if len(following_analyze_stage_distances) > 0 and stage_distance <= min(following_analyze_stage_distances): + selected_analyze_candidates[stage_name] = analyze_candidates.get(stage_name) + + elif len(following_analyze_stage_distances) == 0: + selected_analyze_candidates[stage_name] = analyze_candidates.get(stage_name) + + return selected_analyze_candidates + + def setup(self) -> None: + max_batch_size = 0 + execute_stage_setup_config: Config = None + execute_stage_setup_hooks: Dict[str, ExecuteHooks] = {} + execute_stage_setup_by: str = None + setup_stage_ready_stages: List[Stage] = [] + setup_stage_candidates: List[Stage] = [] + session_stage_monitors: MonitorGroup = {} + session_setup_stage_configs: Dict[str, Config] = {} + execute_stage_streamed_analytics: Dict[str, List[StreamAnalytics]] = defaultdict(list) + + for source_stage, destination_stage in self.history: + + previous_history: Dict[str, Any] = self.history[(source_stage, destination_stage)] + configs = previous_history.get('setup_stage_configs', {}) + execute_config: Config = configs.get( + self.source.name + ) + + if destination_stage == self.source.name and execute_config: + + setup_stage_configs: Dict[str, Config] = previous_history['setup_stage_configs'] + + execute_config: Config = setup_stage_configs.get( + self.source.name + ) + + setup_by = previous_history['execute_stage_setup_by'] + + if execute_config.optimized: + execute_stage_setup_config = execute_config + max_batch_size = execute_config.batch_size + execute_stage_setup_by = setup_by + + elif execute_config.batch_size > max_batch_size: + execute_stage_setup_config = execute_config + max_batch_size = execute_config.batch_size + execute_stage_setup_by = setup_by + + execute_hooks: ExecuteHooks = previous_history['execute_stage_setup_hooks'] + for setup_hook in execute_hooks: + execute_stage_setup_hooks[setup_hook.name] = setup_hook + + ready_stages = previous_history['setup_stage_ready_stages'] + + for ready_stage in ready_stages: + if ready_stage not in setup_stage_ready_stages: + setup_stage_ready_stages.append(ready_stage) + + stage_candidates: List[Stage] = previous_history['setup_stage_candidates'] + for stage_candidate in stage_candidates: + if stage_candidate not in setup_stage_candidates: + setup_stage_candidates.append(stage_candidate) + + stage_monitors = previous_history.get('session_stage_monitors') + if stage_monitors: + session_stage_monitors.update(stage_monitors) + + streamed_analytics = previous_history.get('execute_stage_streamed_analytics') + if streamed_analytics: + execute_stage_streamed_analytics[source_stage].extend(streamed_analytics) + + stage_configs = previous_history.get('setup_stage_configs') + if stage_configs: + session_setup_stage_configs.update(stage_configs) + + stream_configs = self.source.context.get('execute_stage_stream_configs') + if stream_configs: + self.execute_stage_stream_configs.extend([ + config for config in stream_configs if config not in self.execute_stage_stream_configs + ]) + + self.edge_data = { + 'execute_stage_stream_configs': self.execute_stage_stream_configs, + 'execute_stage_streamed_analytics': execute_stage_streamed_analytics, + 'execute_stage_setup_config': execute_stage_setup_config, + 'execute_stage_setup_hooks': execute_stage_setup_hooks, + 'execute_stage_setup_by': execute_stage_setup_by, + 'session_stage_monitors': session_stage_monitors, + 'setup_stage_ready_stages': setup_stage_ready_stages, + 'setup_stage_candidates': setup_stage_candidates, + 'session_setup_stage_configs': session_setup_stage_configs + } diff --git a/hyperscale/core/graphs/transitions/idle/__init__.py b/hyperscale/core/graphs/transitions/idle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/idle/idle_edge.py b/hyperscale/core/graphs/transitions/idle/idle_edge.py new file mode 100644 index 0000000..9fc0fa0 --- /dev/null +++ b/hyperscale/core/graphs/transitions/idle/idle_edge.py @@ -0,0 +1,49 @@ +from typing import List + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.idle.idle import Idle +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge +from hyperscale.core.hooks.types.base.simple_context import SimpleContext + + +class IdleEdge(BaseEdge[Idle]): + + def __init__(self, source: Idle, destination: Stage) -> None: + super( + IdleEdge, + self + ).__init__( + source, + destination + ) + + async def transition(self): + + await self.source.run() + + if self.destination.context is None: + self.destination.context = SimpleContext() + + self._update(self.destination) + + return None, self.destination.stage_type + + def _update(self, destination: Stage): + for edge_name in self.history: + + history = self.history[edge_name] + + if self.next_history.get(edge_name) is None: + self.next_history[edge_name] = {} + + self.next_history[edge_name].update({ + key: value for key, value in history.items() if key in self.provides + }) + + + self.next_history.update({ + (self.source.name, destination.name): {} + }) + + def split(self, edges: List[BaseEdge]) -> None: + pass \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/local_transitions.py b/hyperscale/core/graphs/transitions/local_transitions.py new file mode 100644 index 0000000..c8b1f16 --- /dev/null +++ b/hyperscale/core/graphs/transitions/local_transitions.py @@ -0,0 +1,106 @@ +from hyperscale.core.graphs.stages.types.stage_types import StageTypes + +from .common.transtition_metadata import TransitionMetadata + +local_transitions = { + + # State: Idle + (StageTypes.IDLE, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=True, is_valid=True), + (StageTypes.IDLE, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=True, is_valid=True), + (StageTypes.IDLE, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=True, is_valid=True), + (StageTypes.IDLE, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=True, is_valid=False), + (StageTypes.IDLE, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=True, is_valid=False), + (StageTypes.IDLE, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=True, is_valid=False), + (StageTypes.IDLE, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=True, is_valid=False), + (StageTypes.IDLE, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=True, is_valid=False), + (StageTypes.IDLE, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=True, is_valid=False), + + # State: Setup + (StageTypes.SETUP, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.SETUP, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SETUP, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.SETUP, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SETUP, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SETUP, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.SETUP, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.SETUP, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.SETUP, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + + # State: Optimize + (StageTypes.OPTIMIZE, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.OPTIMIZE, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.OPTIMIZE, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.OPTIMIZE, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.OPTIMIZE, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.OPTIMIZE, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.OPTIMIZE, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.OPTIMIZE, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.OPTIMIZE, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + + + # State: Act + (StageTypes.ACT, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ACT, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ACT, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ACT, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ACT, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ACT, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ACT, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ACT, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ACT, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + + # State: Execute + (StageTypes.EXECUTE, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.EXECUTE, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.EXECUTE, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.EXECUTE, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.EXECUTE, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.EXECUTE, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.EXECUTE, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.EXECUTE, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.EXECUTE, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + + # State: Analyze + (StageTypes.ANALYZE, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ANALYZE, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ANALYZE, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ANALYZE, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ANALYZE, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ANALYZE, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ANALYZE, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.ANALYZE, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ANALYZE, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + + # State: Submit + (StageTypes.SUBMIT, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SUBMIT, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SUBMIT, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.SUBMIT, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SUBMIT, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SUBMIT, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SUBMIT, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.SUBMIT, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.SUBMIT, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + + # State: Complete + (StageTypes.COMPLETE, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=True), + (StageTypes.COMPLETE, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + + # State: Error + (StageTypes.ERROR, StageTypes.ERROR): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.ACT): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.IDLE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.SETUP): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.OPTIMIZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.EXECUTE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.ANALYZE): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.SUBMIT): TransitionMetadata(allow_multiple_edges=False, is_valid=False), + (StageTypes.ERROR, StageTypes.COMPLETE): TransitionMetadata(allow_multiple_edges=False, is_valid=False) + } \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/optimize/__init__.py b/hyperscale/core/graphs/transitions/optimize/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/optimize/optimize_edge.py b/hyperscale/core/graphs/transitions/optimize/optimize_edge.py new file mode 100644 index 0000000..e06c791 --- /dev/null +++ b/hyperscale/core/graphs/transitions/optimize/optimize_edge.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import asyncio +import inspect +from collections import defaultdict +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.execute.execute import Execute +from hyperscale.core.graphs.stages.optimize.optimize import Optimize +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup + +ExecuteHooks = List[Union[ActionHook , TaskHook]] + + +class OptimizeEdge(BaseEdge[Optimize]): + + def __init__(self, source: Optimize, destination: Stage) -> None: + super( + OptimizeEdge, + self + ).__init__( + source, + destination + ) + + self.requires = [ + 'setup_stage_experiment_config', + 'execute_stage_streamed_analytics', + 'setup_stage_configs', + 'execute_stage_setup_hooks', + 'execute_stage_setup_config', + 'execute_stage_setup_by', + 'setup_stage_ready_stages', + 'setup_stage_candidates', + 'execute_stage_results', + 'session_stage_monitors', + ] + + self.provides = [ + 'setup_stage_experiment_config', + 'execute_stage_streamed_analytics', + 'setup_stage_configs', + 'optimize_stage_optimized_params', + 'optimize_stage_optimized_configs', + 'optimize_stage_optimized_hooks', + 'execute_stage_setup_by', + 'execute_stage_results', + 'optimize_stage_monitors', + 'session_stage_monitors' + ] + + self.assigned_candidates = [] + self.edge_data = {} + + async def transition(self): + + try: + selected_optimization_candidates = self.generate_optimization_candidates() + + self.edge_data['optimize_stage_candidates'] = selected_optimization_candidates + + self.source.context.update(self.edge_data) + + for event in self.source.dispatcher.events_by_name.values(): + self.source.context.update(self.edge_data) + + event.source.stage_instance = self.source + event.context.update(self.edge_data) + + if event.source.context: + event.source.context.update(self.edge_data) + + if len(selected_optimization_candidates) > 0: + self.source.generation_optimization_candidates = len(selected_optimization_candidates) + + if self.timeout and self.skip_stage is False: + await asyncio.wait_for(self.source.run(), timeout=self.timeout) + + elif self.skip_stage is False: + await self.source.run() + + for provided in self.provides: + self.edge_data[provided] = self.source.context[provided] + + if self.destination.context is None: + self.destination.context = SimpleContext() + + self._update(self.destination) + if len(selected_optimization_candidates) > 0: + + for optimization_candidate in selected_optimization_candidates.values(): + + if optimization_candidate.context is None: + optimization_candidate.context = SimpleContext() + + self._update(optimization_candidate) + + optimization_candidate.state = StageStates.OPTIMIZED + + self.visited.append(self.source.name) + + except Exception as edge_exception: + self.exception = edge_exception + + return None, self.destination.stage_type + + + def _update(self, destination: Stage): + + for edge_name in self.history: + + history = self.history[edge_name] + + if self.next_history.get(edge_name) is None: + self.next_history[edge_name] = {} + + self.next_history[edge_name].update(history) + + if self.skip_stage is False: + + if self.next_history.get((self.source.name, destination.name)) is None: + self.next_history[(self.source.name, destination.name)] = {} + + optimized_config: Stage = self.edge_data['optimize_stage_optimized_configs'].get(destination.name) + optimzied_hooks: Stage = self.edge_data['optimize_stage_optimized_hooks'].get(destination.name) + stage_setup_by: str = self.edge_data['execute_stage_setup_by'].get(destination.name) + + session_stage_monitors: MonitorGroup = self.edge_data['session_stage_monitors'] + session_stage_monitors.update( + self.edge_data['optimize_stage_monitors'] + ) + + self.next_history[(self.source.name, destination.name)].update({ + 'setup_stage_configs': self.edge_data['optimize_stage_optimized_configs'], + 'optimize_stage_optimized_params': self.edge_data['optimize_stage_optimized_params'], + 'setup_stage_candidates': list(self.edge_data['optimize_stage_candidates'].keys()), + 'setup_stage_ready_stages': self.edge_data['setup_stage_ready_stages'], + 'execute_stage_setup_config': optimized_config, + 'execute_stage_setup_hooks': optimzied_hooks, + 'execute_stage_setup_by': stage_setup_by, + 'session_stage_monitors': session_stage_monitors + }) + + def split(self, edges: List[OptimizeEdge]) -> None: + + optimize_stage_config: Dict[str, Any] = self.source.to_copy_dict() + + optimize_stage_copy: Optimize = type(self.source.name, (Optimize, ), self.source.__dict__)() + + for copied_attribute_name, copied_attribute_value in optimize_stage_config.items(): + if inspect.ismethod(copied_attribute_value) is False: + setattr( + optimize_stage_copy, + copied_attribute_name, + copied_attribute_value + ) + + user_hooks: Dict[str, Dict[str, Hook]] = defaultdict(dict) + for hooks in registrar.all.values(): + for hook in hooks: + if hasattr(self.source, hook.shortname) and not hasattr(Execute, hook.shortname): + user_hooks[self.source.name][hook.shortname] = hook._call + + optimize_stage_copy.dispatcher = self.source.dispatcher.copy() + + for event in optimize_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = optimize_stage_copy + + minimum_edge_idx = min([edge.transition_idx for edge in edges]) + + optimize_stage_copy.context = SimpleContext() + optimize_stage_copy.context.update(self.source.context) + + for event in optimize_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = optimize_stage_copy + event.source.stage_instance = optimize_stage_copy + event.source.stage_instance.context = optimize_stage_copy.context + event.source.context = optimize_stage_copy.context + + if event.source.shortname in user_hooks[optimize_stage_copy.name]: + hook_call = user_hooks[optimize_stage_copy.name].get(event.source.shortname) + + hook_call = hook_call.__get__(optimize_stage_copy, optimize_stage_copy.__class__) + setattr(optimize_stage_copy, event.source.shortname, hook_call) + + event.source._call = hook_call + + else: + event.source._call = getattr(optimize_stage_copy, event.source.shortname) + event.source._call = event.source._call.__get__(optimize_stage_copy, optimize_stage_copy.__class__) + setattr(optimize_stage_copy, event.source.shortname, event.source._call) + + self.source = optimize_stage_copy + + if minimum_edge_idx < self.transition_idx: + self.skip_stage = True + + def generate_optimization_candidates(self) -> Dict[str, Stage]: + + execute_stages: Dict[str, Execute] = self.stages_by_type.get(StageTypes.EXECUTE) + optimize_stages = self.stages_by_type.get(StageTypes.OPTIMIZE).items() + setup_stages = self.stages_by_type.get(StageTypes.SETUP).items() + path_lengths: Dict[str, int] = self.path_lengths.get(self.source.name) + + all_paths = self.all_paths.get(self.source.name, []) + + optimization_candidates: Dict[str, Stage] = {} + + for stage_name, stage in execute_stages.items(): + if stage_name in all_paths: + stage.context['execute_stage_setup_config'] = self.edge_data['execute_stage_setup_config'] + stage.context['execute_stage_setup_by'] = self.edge_data['execute_stage_setup_by'] + optimization_candidates[stage_name] = stage + + selected_optimization_candidates: Dict[str, Stage] = {} + following_optimize_stage_distances = [ + path_length for stage_name, path_length in path_lengths.items() if stage_name in optimize_stages + ] + + for stage_name in path_lengths.keys(): + stage_distance = path_lengths.get(stage_name) + + if stage_name in optimization_candidates: + + if len(following_optimize_stage_distances) > 0 and stage_distance <= min(following_optimize_stage_distances): + selected_optimization_candidates[stage_name] = optimization_candidates.get(stage_name) + + elif len(following_optimize_stage_distances) == 0: + selected_optimization_candidates[stage_name] = optimization_candidates.get(stage_name) + + return { + candidate: candidate_config for candidate, candidate_config in selected_optimization_candidates.items() if candidate in self.edge_data['setup_stage_configs'] + } + + def setup(self) -> None: + + max_batch_size = 0 + execute_stage_setup_config: Config = None + execute_stage_setup_hooks: Dict[str, ExecuteHooks] = {} + execute_stage_setup_by: str = None + execute_stage_streamed_analytics: Dict[str, List[StreamAnalytics]]= defaultdict(list) + setup_stage_ready_stages: List[Stage] = [] + setup_stage_candidates: List[Stage] = [] + setup_stage_configs: Dict[str, Config] = {} + setup_stage_experiment_config: Dict[str, Union[str, int, List[float]]] = {} + session_stage_monitors: MonitorGroup = {} + + for source_stage, destination_stage in self.history: + + previous_history: Dict[str, Any] = self.history[(source_stage, destination_stage)] + + if destination_stage == self.source.name: + setup_stage_configs.update( + previous_history.get( + 'setup_stage_configs', + {} + ) + ) + + execute_config: Config = previous_history['execute_stage_setup_config'] + setup_by = previous_history['execute_stage_setup_by'] + + if execute_config.optimized: + execute_stage_setup_config = execute_config + max_batch_size = execute_config.batch_size + execute_stage_setup_by = setup_by + + elif execute_config.batch_size > max_batch_size: + execute_stage_setup_config = execute_config + max_batch_size = execute_config.batch_size + execute_stage_setup_by = setup_by + + execute_hooks: ExecuteHooks = previous_history['execute_stage_setup_hooks'] + for setup_hook in execute_hooks: + execute_stage_setup_hooks[setup_hook.name] = setup_hook + + ready_stages = previous_history['setup_stage_ready_stages'] + + for ready_stage in ready_stages: + if ready_stage not in setup_stage_ready_stages: + setup_stage_ready_stages.append(ready_stage) + + stage_candidates: List[Stage] = previous_history['setup_stage_candidates'] + for stage_candidate in stage_candidates: + if stage_candidate not in setup_stage_candidates: + setup_stage_candidates.append(stage_candidate) + + stage_distributions = previous_history.get('setup_stage_experiment_config') + + if stage_distributions: + setup_stage_experiment_config.update(stage_distributions) + + stage_monitors = previous_history.get('session_stage_monitors') + if stage_monitors: + session_stage_monitors.update(stage_monitors) + + streamed_analytics = previous_history.get('execute_stage_streamed_analytics') + if streamed_analytics: + execute_stage_streamed_analytics[source_stage].extend(streamed_analytics) + + self.edge_data = { + 'setup_stage_experiment_config': setup_stage_experiment_config, + 'session_stage_monitors': session_stage_monitors, + 'execute_stage_streamed_analytics': execute_stage_streamed_analytics, + 'setup_stage_configs': setup_stage_configs, + 'execute_stage_setup_config': execute_stage_setup_config, + 'execute_stage_setup_hooks': execute_stage_setup_hooks, + 'execute_stage_setup_by': execute_stage_setup_by, + 'setup_stage_ready_stages': setup_stage_ready_stages, + 'setup_stage_candidates': setup_stage_candidates + + } \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/setup/__init__.py b/hyperscale/core/graphs/transitions/setup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/setup/setup_edge.py b/hyperscale/core/graphs/transitions/setup/setup_edge.py new file mode 100644 index 0000000..6f0a506 --- /dev/null +++ b/hyperscale/core/graphs/transitions/setup/setup_edge.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import asyncio +import inspect +from collections import defaultdict +from typing import Any, Dict, List, Union + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.execute import Execute +from hyperscale.core.graphs.stages.setup.setup import Setup +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup + + +class SetupEdge(BaseEdge[Setup]): + + def __init__(self, source: Setup, destination: BaseEdge[Stage]) -> None: + super( + SetupEdge, + self + ).__init__( + source, + destination + ) + + self.valid_states = [ + StageStates.INITIALIZED, + StageStates.VALIDATED + ] + + self.requires = [ + 'execute_stage_streamed_analytics', + 'execute_stage_results', + 'session_stage_monitors' + ] + + self.provides = [ + 'setup_stage_experiment_config', + 'execute_stage_streamed_analytics', + 'execute_stage_setup_hooks', + 'execute_stage_setup_config', + 'execute_stage_setup_by', + 'setup_stage_ready_stages', + 'setup_stage_candidates', + 'setup_stage_configs', + 'execute_stage_results', + 'session_stage_monitors', + 'setup_stage_monitors' + ] + + self.assigned_candidates = [] + self.assigned_optimize_candidates = [] + + async def transition(self): + + try: + self.source.state = StageStates.SETTING_UP + + setup_candidates = self.get_setup_candidates() + optimize_candidates = self.get_optimize_candidates() + + self.source.generation_setup_candidates = len(setup_candidates) + + for setup_candidate in setup_candidates.values(): + if setup_candidate.context is None: + setup_candidate.context = SimpleContext() + + + for optimize_candidate in optimize_candidates.values(): + if optimize_candidate.context is None: + optimize_candidate.context = SimpleContext() + + self.edge_data['setup_stage_target_stages'] = setup_candidates + self.edge_data['setup_stage_target_config'] = self.source.config + + self.source.context.update(self.edge_data) + + for event in self.source.dispatcher.events_by_name.values(): + event.source.stage_instance = self.source + self.source.context.update(self.edge_data) + event.context.update(self.edge_data) + + if event.source.context: + event.source.context.update(self.edge_data) + + if self.timeout and self.skip_stage is False: + await asyncio.wait_for(self.source.run(), timeout=self.timeout) + elif self.skip_stage is False: + await self.source.run() + + for provided in self.provides: + self.edge_data[provided] = self.source.context[provided] + + self.edge_data['setup_stage_candidates'] = setup_candidates + + self._update(self.destination) + + if self.destination.context is None: + self.destination.context = SimpleContext() + + for execute_stage in setup_candidates.values(): + + if execute_stage.name != self.destination.name: + execute_stage.state = StageStates.SETUP + + if execute_stage.context is None: + execute_stage.context = SimpleContext() + + self._update(execute_stage) + + + for optimize_stage in optimize_candidates.values(): + + if optimize_stage.name != self.destination.name: + optimize_stage.state = StageStates.SETUP + + if optimize_stage.context is None: + optimize_stage.context = SimpleContext() + + self._update(optimize_stage) + + self.visited.append(self.source.name) + + except Exception as edge_exception: + + self.exception = edge_exception + + return None, self.destination.stage_type + + def _update(self, destination: Stage): + + for edge_name in self.history: + + history = self.history[edge_name] + + if self.next_history.get(edge_name) is None: + self.next_history[edge_name] = {} + + self.next_history[edge_name].update(history) + + + if self.next_history.get((self.source.name, destination.name)) is None: + self.next_history[(self.source.name, destination.name)] = {} + + if self.skip_stage is False: + setup_stage_configs = self.edge_data.get('setup_stage_configs', {}) + ready_stages = self.edge_data.get('setup_stage_ready_stages', {}) + setup_candidates = self.edge_data.get('setup_stage_candidates', {}) + setup_config = self.edge_data.get('execute_stage_setup_config') + setup_stage_experiment_config: Dict[str, Union[str, int, List[float]]] = self.edge_data.get('setup_stage_experiment_config', {}) + execute_stage_setup_hooks = [] + setup_execute_stage: Execute = ready_stages.get(self.source.name) + + if setup_execute_stage: + execute_stage_setup_hooks = setup_execute_stage.context['execute_stage_setup_hooks'] + + self.stages_by_type[StageTypes.EXECUTE].update(ready_stages) + + session_stage_monitors = self.edge_data['session_stage_monitors'] + + session_stage_monitors.update( + self.edge_data['setup_stage_monitors'] + ) + + self.next_history[(self.source.name, destination.name)].update({ + 'setup_stage_experiment_config': setup_stage_experiment_config, + 'setup_stage_configs': setup_stage_configs, + 'execute_stage_setup_hooks': execute_stage_setup_hooks, + 'setup_stage_ready_stages': ready_stages, + 'setup_stage_candidates': list(setup_candidates.keys()), + 'execute_stage_setup_config': setup_config, + 'execute_stage_setup_by': self.source.name, + 'session_stage_monitors': session_stage_monitors + }) + + + def split(self, edges: List[SetupEdge]) -> None: + + setup_stage_config: Dict[str, Any] = self.source.to_copy_dict() + + setup_stage_copy = type(self.source.name, (Setup, ), {})() + + for copied_attribute_name, copied_attribute_value in setup_stage_config.items(): + if inspect.ismethod(copied_attribute_value) is False: + setattr(setup_stage_copy, copied_attribute_name, copied_attribute_value) + + user_hooks: Dict[str, Dict[str, Hook]] = defaultdict(dict) + for hooks in registrar.all.values(): + for hook in hooks: + if hasattr(self.source, hook.shortname) and not hasattr(Execute, hook.shortname): + user_hooks[self.source.name][hook.shortname] = hook._call + + setup_stage_copy.dispatcher = self.source.dispatcher.copy() + + minimum_edge_idx = min([edge.transition_idx for edge in edges]) + + setup_stage_copy.context = SimpleContext() + for event in setup_stage_copy.dispatcher.events_by_name.values(): + event.context = setup_stage_copy.context + event.source.stage_instance = setup_stage_copy + event.source.stage_instance.context = setup_stage_copy.context + event.source.context = setup_stage_copy.context + + if event.source.shortname in user_hooks[setup_stage_copy.name]: + hook_call = user_hooks[setup_stage_copy.name].get(event.source.shortname) + + hook_call = hook_call.__get__(setup_stage_copy, setup_stage_copy.__class__) + setattr(setup_stage_copy, event.source.shortname, hook_call) + + event.source._call = hook_call + + else: + event.source._call = getattr(setup_stage_copy, event.source.shortname) + event.source._call = event.source._call.__get__(setup_stage_copy, setup_stage_copy.__class__) + setattr(setup_stage_copy, event.source.shortname, event.source._call) + + self.source = setup_stage_copy + + if minimum_edge_idx < self.transition_idx: + self.skip_stage = True + + def get_setup_candidates(self) -> Dict[str, Execute]: + execute_stages: Dict[str, Execute] = self.stages_by_type.get(StageTypes.EXECUTE) + setup_stages: Dict[str, Setup] = self.stages_by_type.get(StageTypes.SETUP) + path_lengths: Dict[str, int] = self.path_lengths.get(self.source.name) + + all_paths = self.all_paths.get(self.source.name, []) + + execute_candidates: Dict[str, Stage] = {} + + for stage_name, stage in execute_stages.items(): + if stage_name in all_paths: + execute_candidates[stage_name] = stage + + selected_execute_candidates: Dict[str, Execute] = {} + + following_setup_stage_distances = [ + path_length for stage_name, path_length in path_lengths.items() if stage_name in setup_stages + ] + + for stage_name in path_lengths.keys(): + stage_distance = path_lengths.get(stage_name) + + if stage_name in execute_candidates: + + if len(following_setup_stage_distances) > 0 and stage_distance <= min(following_setup_stage_distances): + selected_execute_candidates[stage_name] = execute_candidates.get(stage_name) + + elif len(following_setup_stage_distances) == 0: + selected_execute_candidates[stage_name] = execute_candidates.get(stage_name) + + for candidate in selected_execute_candidates.values(): + actions = [event for event in candidate.dispatcher.actions_and_tasks.values() if event.event_type == EventType.ACTION] + tasks = [event for event in candidate.dispatcher.actions_and_tasks.values() if event.event_type == EventType.TASK] + + candidate.hooks[HookType.ACTION] = actions + candidate.hooks[HookType.TASK] = tasks + + return selected_execute_candidates + + def get_optimize_candidates(self) -> Dict[str, Execute]: + optimize_stages: Dict[str, Execute] = self.stages_by_type.get(StageTypes.OPTIMIZE, {}) + setup_stages: Dict[str, Setup] = self.stages_by_type.get(StageTypes.SETUP) + path_lengths: Dict[str, int] = self.path_lengths.get(self.source.name) + + all_paths = self.all_paths.get(self.source.name, []) + + optimize_candidates: Dict[str, Stage] = {} + + for stage_name, stage in optimize_stages.items(): + if stage_name in all_paths: + optimize_candidates[stage_name] = stage + + selected_optimize_candidates: Dict[str, Execute] = {} + + following_setup_stage_distances = [ + path_length for stage_name, path_length in path_lengths.items() if stage_name in setup_stages or stage_name in optimize_stages + ] + + for stage_name in path_lengths.keys(): + stage_distance = path_lengths.get(stage_name) + + if stage_name in optimize_candidates: + + if len(following_setup_stage_distances) > 0 and stage_distance <= min(following_setup_stage_distances): + selected_optimize_candidates[stage_name] = optimize_candidates.get(stage_name) + + elif len(following_setup_stage_distances) == 0: + selected_optimize_candidates[stage_name] = optimize_candidates.get(stage_name) + + return selected_optimize_candidates + + def setup(self) -> None: + + session_stage_monitors: MonitorGroup = {} + + for source_stage, destination_stage in self.history: + + previous_history: Dict[str, Any] = self.history[(source_stage, destination_stage)] + + stage_monitors = previous_history.get('session_stage_monitors') + if stage_monitors: + session_stage_monitors.update(stage_monitors) + + self.edge_data = { + 'session_stage_monitors': session_stage_monitors + } diff --git a/hyperscale/core/graphs/transitions/submit/__init__.py b/hyperscale/core/graphs/transitions/submit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/graphs/transitions/submit/submit_edge.py b/hyperscale/core/graphs/transitions/submit/submit_edge.py new file mode 100644 index 0000000..9e6d064 --- /dev/null +++ b/hyperscale/core/graphs/transitions/submit/submit_edge.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import asyncio +import inspect +from collections import defaultdict +from typing import Any, Dict, List, Union + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.submit.submit import Submit +from hyperscale.core.graphs.stages.types.stage_states import StageStates +from hyperscale.core.graphs.transitions.common.base_edge import BaseEdge +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.reporting.experiment.experiment_metrics_set import ExperimentMetricsSet +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.custom_metric import CustomMetric +from hyperscale.reporting.metric.stage_metrics_summary import StageMetricsSummary +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet +from hyperscale.reporting.system.system_metrics_set_types import MonitorGroup + +CustomMetricSet = Dict[str, Dict[str, CustomMetric]] +MetricsSetGroup = Dict[str, Union[str, Dict[str, StageMetricsSummary]]] + + +class SubmitEdge(BaseEdge[Submit]): + + def __init__(self, source: Submit, destination: BaseEdge[Stage]) -> None: + super( + SubmitEdge, + self + ).__init__( + source, + destination + ) + + self.requires = [ + 'analyze_stage_session_total', + 'analyze_stage_events', + 'analyze_stage_summary_metrics', + 'session_stage_monitors', + 'submit_stage_session_total' + ] + self.provides = [ + 'submit_stage_metrics', + 'submit_stage_streamed_metrics', + 'submit_stage_experiment_metrics', + 'submit_stage_monitors', + 'session_stage_monitors', + 'submit_stage_system_metrics' + ] + + async def transition(self): + + try: + self.source.state = StageStates.SUBMITTING + + self.source.context.update(self.edge_data) + + for event in self.source.dispatcher.events_by_name.values(): + event.source.stage_instance = self.source + event.context.update(self.edge_data) + + if event.source.context: + event.source.context.update(self.edge_data) + + if self.timeout and self.skip_stage is False: + await asyncio.wait_for(self.source.run(), timeout=self.timeout) + + elif self.skip_stage is False: + await self.source.run() + + for provided in self.provides: + self.edge_data[provided] = self.source.context[provided] + + self._update(self.destination) + + self.source.state = StageStates.SUBMITTED + self.destination.state = StageStates.SUBMITTED + + if self.destination.context is None: + self.destination.context = SimpleContext() + + self.visited.append(self.source.name) + + except Exception as edge_exception: + self.exception = edge_exception + + return None, self.destination.stage_type + + def _update(self, destination: Stage): + + for edge_name in self.history: + + history = self.history[edge_name] + + if self.next_history.get(edge_name) is None: + self.next_history[edge_name] = {} + + self.next_history[edge_name].update(history) + + session_stage_monitors: MonitorGroup = self.edge_data['session_stage_monitors'] + session_stage_monitors.update( + self.edge_data['submit_stage_monitors'] + ) + + if self.skip_stage is False: + self.next_history.update({ + (self.source.name, destination.name): { + 'submit_stage_metrics': self.edge_data['submit_stage_metrics'], + 'submit_stage_experiment_metrics': self.edge_data['submit_stage_experiment_metrics'], + 'submit_stage_streamed_metrics': self.edge_data['submit_stage_streamed_metrics'], + 'submit_stage_system_metrics': self.edge_data['submit_stage_system_metrics'] + } + }) + + def split(self, edges: List[SubmitEdge]) -> None: + + submit_stage_config: Dict[str, Any] = self.source.to_copy_dict() + submit_stage_copy: Submit = type(self.source.name, (Submit, ), self.source.__dict__)() + + for copied_attribute_name, copied_attribute_value in submit_stage_config.items(): + if inspect.ismethod(copied_attribute_value) is False: + setattr( + submit_stage_copy, + copied_attribute_name, + copied_attribute_value + ) + + user_hooks: Dict[str, Dict[str, Hook]] = defaultdict(dict) + for hooks in registrar.all.values(): + for hook in hooks: + if hasattr(self.source, hook.shortname) and not hasattr(Submit, hook.shortname): + user_hooks[self.source.name][hook.shortname] = hook._call + + submit_stage_copy.dispatcher = self.source.dispatcher.copy() + + for event in submit_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = submit_stage_copy + + submit_stage_copy.context = SimpleContext() + for event in submit_stage_copy.dispatcher.events_by_name.values(): + event.source.stage_instance = submit_stage_copy + event.source.stage_instance.context = submit_stage_copy.context + event.source.context = submit_stage_copy.context + + if event.source.shortname in user_hooks[submit_stage_copy.name]: + hook_call = user_hooks[submit_stage_copy.name].get(event.source.shortname) + + hook_call = hook_call.__get__(submit_stage_copy, submit_stage_copy.__class__) + setattr(submit_stage_copy, event.source.shortname, hook_call) + + event.source._call = hook_call + + else: + event.source._call = getattr(submit_stage_copy, event.source.shortname) + event.source._call = event.source._call.__get__(submit_stage_copy, submit_stage_copy.__class__) + setattr(submit_stage_copy, event.source.shortname, event.source._call) + + self.source = submit_stage_copy + + transition_idxs = [edge.transition_idx for edge in edges] + + if self.transition_idx != min(transition_idxs): + self.skip_stage = True + + def setup(self): + + events: List[BaseProcessedResult] = [] + metrics: List[MetricsSet] = [] + streamed_metrics: Dict[str, StageStreamsSet] = {} + system_metrics: List[SystemMetricsSet] = [] + experiments: List[ExperimentMetricsSet] = [] + session_total: int = 0 + session_stage_monitors: MonitorGroup = {} + + for source_stage, destination_stage in self.history: + if destination_stage == self.source.name: + + previous_history: Dict[str, Any] = self.history[(source_stage, self.source.name)] + + analyze_stage_summary_metrics: MetricsSetGroup = previous_history.get( + 'analyze_stage_summary_metrics' + ) + analyze_stage_events: List[BaseProcessedResult] = previous_history.get( + 'analyze_stage_events', + [] + ) + + events.extend(analyze_stage_events) + + + stage_metrics_summaries = analyze_stage_summary_metrics.get('stages', {}) + for stage_metrics_summary in stage_metrics_summaries.values(): + metrics.extend(list( + stage_metrics_summary.metrics_sets.values() + )) + + session_total += stage_metrics_summary.stage_metrics.total + + experiment_metrics_sets = analyze_stage_summary_metrics.get('experiment_metrics_sets', {}) + experiments.extend(list( + experiment_metrics_sets.values() + )) + + for stage_metrics_summary in stage_metrics_summaries.values(): + streams = stage_metrics_summary.stage_streamed_analytics + + if streams and len(streams) > 0: + streamed_metrics[stage_metrics_summary.stage_metrics.name] = stage_metrics_summary.streams + + analyze_stage_system_metrics = analyze_stage_summary_metrics.get('system_metrics') + if analyze_stage_system_metrics: + system_metrics.append(analyze_stage_system_metrics) + + stage_monitors = previous_history.get('session_stage_monitors') + if stage_monitors: + session_stage_monitors.update(stage_monitors) + + self.edge_data = { + 'session_stage_monitors': session_stage_monitors, + 'submit_stage_events': events, + 'submit_stage_experiment_metrics': experiments, + 'submit_stage_streamed_metrics': streamed_metrics, + 'submit_stage_system_metrics': system_metrics, + 'submit_stage_summary_metrics': metrics, + 'submit_stage_session_total': session_total + } diff --git a/hyperscale/core/graphs/transitions/transition.py b/hyperscale/core/graphs/transitions/transition.py new file mode 100644 index 0000000..f638b75 --- /dev/null +++ b/hyperscale/core/graphs/transitions/transition.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import uuid +from typing import Any, Dict, List, Tuple, Union + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.hooks.types.base.simple_context import SimpleContext + +from .act.act_edge import ActEdge +from .analyze.analyze_edge import AnalyzeEdge, BaseEdge +from .common.complete_edge import CompleteEdge +from .common.error_edge import ErrorEdge +from .common.transtition_metadata import TransitionMetadata +from .execute.execute_edge import ExecuteEdge +from .idle.idle_edge import IdleEdge +from .optimize.optimize_edge import OptimizeEdge +from .setup.setup_edge import SetupEdge +from .submit.submit_edge import SubmitEdge + +HistoryUpdate = Dict[Tuple[str, str], Any] + + +class Transition: + + def __init__(self, metadata: TransitionMetadata, from_stage: Stage, to_stage: Stage) -> None: + self.transition_id = str(uuid.uuid4()) + self.metadata = metadata + self.from_stage = from_stage + self.to_stage = to_stage + self.edges: List[BaseEdge] = [] + self.edges_by_name: Dict[Tuple[str, str], BaseEdge] = {} + self.adjacency_list: Dict[str, List[Transition]] = [] + self.predecessors = [] + self.descendants = [] + self.destinations: List[str] = [] + self.transition_idx = 0 + self.retries = from_stage.stage_retries + edge_types = { + StageTypes.ACT: ActEdge, + StageTypes.ANALYZE: AnalyzeEdge, + StageTypes.COMPLETE: CompleteEdge, + StageTypes.ERROR: ErrorEdge, + StageTypes.EXECUTE: ExecuteEdge, + StageTypes.IDLE: IdleEdge, + StageTypes.OPTIMIZE: OptimizeEdge, + StageTypes.SETUP: SetupEdge, + StageTypes.SUBMIT: SubmitEdge, + } + + self.edge: BaseEdge = edge_types.get(from_stage.stage_type)( + from_stage, + to_stage + ) + + async def execute(self): + + self.edge.setup() + + if self.edge.source.context is None: + self.edge.source.context = SimpleContext() + + result: Union[None, Tuple[None, StageTypes]] = None + if self.retries > 0: + for _ in range(self.retries): + result = await self.edge.transition() + + if self.edge.exception is None: + break + + else: + result = await self.edge.transition() + + self.edge.descendants = { + descendant: self.edges_by_name.get(( + self.edge.source.name, + descendant + )) for descendant in self.descendants + } + + skip_next_stages = [ + StageTypes.COMPLETE, + StageTypes.ERROR, + ] + + is_ignored_stage = self.to_stage.stage_type in skip_next_stages + stage_skipped = self.edge.source.skip is True and self.edge.skip_stage is False + invalid_transition = self.metadata.is_valid is False + has_exception = self.edge.exception is not None + + pass_to_next = ( + is_ignored_stage or stage_skipped or invalid_transition or has_exception + ) is False + + if pass_to_next: + source_name = self.edge.source.name + selected_edge = self.edge + + if self.edge.skip_stage: + selected_edge_idx = min([ + edge.transition_idx for edge in self.edges + ]) + + selected_edge = self.edges[selected_edge_idx] + source_name = self.edges[selected_edge_idx].source.name + + destination_name = self.edge.destination.name + + neighbors: List[Tuple[str, str]] = [] + transition_source_histories: Dict[str, HistoryUpdate] = {} + + source_edge_name = ( + source_name, + destination_name + ) + + transition_source_history: HistoryUpdate = selected_edge.next_history.get( + source_edge_name, {} + ) + + for destination in self.destinations: + for transition in self.adjacency_list[destination]: + + destination_edge_name = ( + destination, + transition.edge.destination.name + ) + + neighbors.extend([ + ( + destination, + transition.edge.destination.name + ) for transition in self.adjacency_list[destination] + ]) + + transition_source_histories[destination_edge_name] = transition_source_history + + for neighbor in neighbors: + required_keys = self.edges_by_name[neighbor].requires + + if source_name not in self.edges_by_name[neighbor].from_stage_names: + self.edges_by_name[neighbor].from_stage_names.append(source_name) + + + for edge_name in self.edge.next_history: + source_history: HistoryUpdate = self.edge.next_history[edge_name] + + self.edges_by_name[neighbor].history.update({ + edge_name: { + key: value for key, value in source_history.items() if key in required_keys + } + }) + + neighbor_edge_source = self.edges_by_name[neighbor].source.name + + previous_edge = (source_name, neighbor_edge_source) + + for history in transition_source_histories.values(): + if self.edges_by_name[neighbor].history.get(previous_edge) is None and len(history) > 0: + self.edges_by_name[neighbor].history.update({ + previous_edge: { + key: value for key, value in history.items() if key in required_keys + } + }) + + + self.edge.edge_data = {} + + return result diff --git a/hyperscale/core/graphs/transitions/transition_assembler.py b/hyperscale/core/graphs/transitions/transition_assembler.py new file mode 100644 index 0000000..2f1e515 --- /dev/null +++ b/hyperscale/core/graphs/transitions/transition_assembler.py @@ -0,0 +1,427 @@ +import asyncio +import os +import threading +from collections import defaultdict +from typing import Any, Coroutine, Dict, List, Tuple, Union + +import networkx + +from hyperscale.core.graphs.stages.base.import_tools import set_stage_hooks +from hyperscale.core.graphs.stages.base.parallel.batch_executor import BatchExecutor +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.error import Error +from hyperscale.core.graphs.stages.types.stage_types import StageTypes +from hyperscale.core.graphs.transitions.exceptions.exceptions import ( + InvalidTransitionError, + IsolatedStageError, +) +from hyperscale.core.hooks.types.base.event_graph import EventGraph +from hyperscale.core.hooks.types.base.hook import Hook, HookType +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.core.hooks.types.load.hook import LoadHook +from hyperscale.logging import HyperscaleLogger +from hyperscale.plugins.types.engine.engine_plugin import EnginePlugin +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.plugins.types.reporter.reporter_plugin import ReporterPlugin + +from .common.base_edge import BaseEdge +from .common.transtition_metadata import TransitionMetadata +from .transition import Transition +from .transition_group import TransitionGroup + + +class TransitionAssembler: + + def __init__( + self, + transition_types, + graph_name: str=None, + graph_path: str=None, + graph_id: str=None, + graph_skipped_stages: List[str]=[], + cpus: int=None, + worker_id: int=None, + core_config: Dict[str, Any]={} + ) -> None: + self.transition_types: Dict[Tuple[StageTypes, StageTypes], Coroutine] = transition_types + self.core_config = core_config + self.graph_name = graph_name + self.graph_path = graph_path + self.graph_id = graph_id + self.graph_skipped_stages: List[str] = graph_skipped_stages + self.generated_stages = {} + self.transitions = {} + self.instances_by_type: Dict[str, List[Stage]] = {} + self.cpus = cpus + self.worker_id = worker_id + self.loop = asyncio.get_event_loop() + self.hooks_by_type: Dict[HookType, Dict[str,Hook]] = defaultdict(dict) + + self.logging = HyperscaleLogger() + self.logging.initialize() + + self._thread_id = threading.current_thread().ident + self._process_id = os.getpid() + self.all_hooks = [] + self.edges_by_name: Dict[Tuple[str, str], BaseEdge] = {} + self.adjacency_list: Dict[str, List[Transition]] = defaultdict(list) + self.execute_stages: List[Stage] = [] + self.streaming_submit_stages: List[Stage] = [] + self.executors: List[BatchExecutor] = [] + self.all_paths: Dict[str, List[str]] = {} + + self._graph_metadata_log_string = f'Graph - {self.graph_name}:{self.graph_id} - thread:{self._thread_id} - process:{self._process_id} - ' + + def generate_stages(self, stages: Dict[str, Stage]) -> None: + + stages_count = len(stages) + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Generating - {stages_count} - stages') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Generating - {stages_count} - stages') + + self.instances_by_type = {} + + for stage in stages.values(): + self.instances_by_type[stage.stage_type] = [] + + stage_types_count = len(self.instances_by_type) + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Found - {stage_types_count} - unique stage types') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Found - {stage_types_count} - unique stage types') + + self.generated_stages: Dict[str, Stage] = { + stage_name: stage() for stage_name, stage in stages.items() + } + + generated_hooks = {} + for stage in self.generated_stages.values(): + + if stage.name in self.graph_skipped_stages: + stage.skip = True + + stage.core_config = self.core_config + stage.graph_name = self.graph_name + stage.graph_path = self.graph_path + stage.graph_id = self.graph_id + + stage.workers = self.cpus + stage.worker_id = self.worker_id + + for hook_shortname, hook in registrar.reserved[stage.name].items(): + hook._call = hook._call.__get__(stage, stage.__class__) + setattr(stage, hook_shortname, hook._call) + + stage = set_stage_hooks(stage, generated_hooks) + + for hook_type in stage.hooks: + for hook in stage.hooks[hook_type]: + self.hooks_by_type[hook.hook_type][hook.name] = hook + + self.instances_by_type[stage.stage_type].append(stage) + + events_graph = EventGraph(self.hooks_by_type) + events_graph.hooks_to_events().assemble_graph().apply_graph_to_events() + + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Successfully generated - {stages_count} - stages') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Successfully generated - {stages_count} - stages') + + def build_transitions_graph(self, topological_generations: List[List[str]], graph: networkx.DiGraph) -> List[TransitionGroup]: + + self.logging.hyperscale.sync.debug('Buiding transitions matrix') + self.logging.filesystem.sync['hyperscale.core'].debug('Buiding transitions matrix') + + transitions: List[TransitionGroup] = [] + plugins: Dict[PluginType, Dict[str, Union[EnginePlugin, ReporterPlugin]]] = { + PluginType.ENGINE: {}, + PluginType.OPTIMIZER: {}, + PluginType.PERSONA: {}, + PluginType.REPORTER: {} + } + + for isolate_stage_name in networkx.isolates(graph): + raise IsolatedStageError( + self.generated_stages.get(isolate_stage_name) + ) + + for generation in topological_generations: + + generation_transitions = TransitionGroup() + generation_transitions.cpu_pool_size = self.cpus + + stage_pool_size = self.cpus + + stages = { + stage_name: self.generated_stages.get(stage_name) for stage_name in generation + } + parallel_stages = [] + + no_workers_stages = [ + StageTypes.IDLE + ] + + stages_count = len(stages) + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Provisioning workers - {stages_count} - stages') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Provisioning workers- {stages_count} - stages') + + for stage in stages.values(): + + stage.total_pool_cpus = self.cpus + + for plugin_name, plugin in stage.plugins.items(): + plugins[plugin.type][plugin_name] = plugin + + if stage.allow_parallel is False and stage.stage_type not in no_workers_stages: + stage.workers = 1 + stage_pool_size -= 1 + + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Stage - {stage.name} - provisioned - {stage.workers} - workers') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Stage - {stage.name} - provisioned - {stage.workers} - workers') + + stage.executor = BatchExecutor(stage.workers) + self.executors.append(stage.executor) + + else: + parallel_stages.append(( + stage.name, + stage + )) + + if len(parallel_stages) > 0: + batch_executor = BatchExecutor(max_workers=stage_pool_size) + self.executors.append(batch_executor) + + batched_stages: List[Tuple[str, Stage, int]] = batch_executor.partion_stage_batches(parallel_stages) + + for _, stage, assigned_workers_count in batched_stages: + + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Stage - {stage.name} - provisioned - {assigned_workers_count} - workers') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Stage - {stage.name} - provisioned - {assigned_workers_count} - workers') + + stage.workers = assigned_workers_count + stage.executor = BatchExecutor(max_workers=assigned_workers_count) + self.executors.append(stage.executor) + + stages[stage.name] = stage + + batch_executor.close() + + for stage in stages.values(): + + stage.plugins_by_type = plugins + stage.dispatcher.assemble_action_and_task_subgraphs() + + neighbors = list(graph.neighbors(stage.name)) + + neighbors_count = len(neighbors) + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Discovered - {neighbors_count} - neighboring stages for stage - {stage.name}') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Discovered - {neighbors_count} - neighboring stages for stage - {stage.name}') + + for neighbor in neighbors: + neighbor_stage = self.generated_stages.get(neighbor) + + transition_action: TransitionMetadata = self.transition_types.get(( + stage.stage_type, + neighbor_stage.stage_type + )) + + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Created transition from - {stage.name} - to - {neighbor_stage.name}') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Created transition from - {stage.name} - to - {neighbor_stage.name}') + + transition = Transition( + transition_action, + stage, + neighbor_stage + ) + + if stage.stage_type == StageTypes.EXECUTE: + self.execute_stages.append( + stage + ) + + if stage.stage_type == StageTypes.SUBMIT and stage.stream: + self.streaming_submit_stages.append( + stage + ) + + + if transition_action.is_valid is False: + raise InvalidTransitionError( + transition.from_stage, + transition.to_stage + ) + + transition.predecessors = list(graph.predecessors(stage.name)) + transition.descendants = list(graph.successors(stage.name)) + + self.adjacency_list[stage.name].append(transition) + + self.edges_by_name[(transition.from_stage.name, transition.to_stage.name)] = transition.edge + + generation_transitions.add_transition(transition) + + if generation_transitions.count > 0: + transitions.append(generation_transitions) + + for transition_group in transitions: + transition_group.adjacency_list = self.adjacency_list + transition_group.edges_by_name = self.edges_by_name + + for transition in transition_group: + transition.adjacency_list = self.adjacency_list + transition.edges_by_name = self.edges_by_name + + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Transition matrix assemmbly complete') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Transition matrix assemmbly complete') + + return transitions + + def map_to_setup_stages(self, graph: networkx.DiGraph) -> None: + + + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Mapping stages to requisite Setup stages') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Mapping stages to requisite Setup stages') + + idle_stages = self.instances_by_type.get(StageTypes.IDLE) + for idle_stage in idle_stages: + idle_stage.context = SimpleContext() + idle_stage.context.stages = {} + idle_stage.context.visited = [] + idle_stage.context.results = {} + idle_stage.context.results_stages = [] + idle_stage.context.summaries = {} + idle_stage.context.paths = {} + idle_stage.context.path_lengths = {} + + idle_stage.name = idle_stage.__class__.__name__ + + + complete_stage = self.instances_by_type.get(StageTypes.COMPLETE)[0] + + stages_by_type = defaultdict(dict) + for stage_type in self.instances_by_type: + for stage in self.instances_by_type[stage_type]: + stages_by_type[stage_type][stage.name] = stage + + self.all_paths: Dict[str, List[str]] = {} + + for execute_stage in self.execute_stages: + + if execute_stage.context is None: + execute_stage.context = SimpleContext() + + for streaming_submit_stage in self.streaming_submit_stages: + + has_path = networkx.has_path( + graph, + execute_stage.name, + streaming_submit_stage.name + ) + + if has_path: + if execute_stage.context['execute_stage_stream_configs'] is None: + execute_stage.context['execute_stage_stream_configs'] = [ + streaming_submit_stage.config + ] + + else: + execute_stage.context['execute_stage_stream_configs'].append( + streaming_submit_stage.config + ) + + + for stage_type in StageTypes: + + idle_stage.context.stages[stage_type] = {} + + for stage in self.instances_by_type.get(stage_type, []): + + stage_name = stage.__class__.__name__ + + for neighbor in self.adjacency_list[stage.name]: + self.edges_by_name[(stage.name, neighbor.edge.destination.name)].stages_by_type = stages_by_type + paths = networkx.all_simple_paths(graph, stage_name, complete_stage.name) + + stage_paths = [] + for path in paths: + stage_paths.extend(path) + + self.all_paths[stage_name] = stage_paths + + path_lengths = networkx.all_pairs_shortest_path_length(graph) + + stage_path_lengths = {} + for path_stage_name, path_lengths_set in path_lengths: + + del path_lengths_set[path_stage_name] + stage_path_lengths[path_stage_name] = path_lengths_set + + self.edges_by_name[(stage.name, neighbor.edge.destination.name)].path_lengths[stage_name] = stage_path_lengths.get(stage_name) + + for stage in self.generated_stages.values(): + for neighbor in self.adjacency_list[stage.name]: + self.edges_by_name[(stage.name, neighbor.edge.destination.name)].all_paths = self.all_paths + + self.logging.hyperscale.sync.debug(f'{self._graph_metadata_log_string} - Mapped stages to requisite Setup stages') + self.logging.filesystem.sync['hyperscale.core'].debug(f'{self._graph_metadata_log_string} - Mapped stages to requisite Setup stages') + + def apply_config_to_load_hooks( + self, + graph: networkx.DiGraph + ): + + setup_stages = self.instances_by_type.get(StageTypes.SETUP) + + for stage in setup_stages: + for load_hook in stage.hooks[HookType.LOAD]: + load_hook: LoadHook = load_hook + + load_hook.parser_config = stage.config + + non_setup_stages: List[Stage] = [] + for stage in self.generated_stages.values(): + if stage not in setup_stages: + non_setup_stages.append(stage) + + path_lengths = dict(networkx.all_pairs_shortest_path_length(graph)) + + for stage in non_setup_stages: + + setup_to_stage_path_lengths: Dict[str, int] = {} + + for setup_stage in setup_stages: + if networkx.has_path(graph, setup_stage.name, stage.name): + path_length = path_lengths[setup_stage.name][stage.name] + setup_to_stage_path_lengths[setup_stage.name] = path_length + + if len(setup_to_stage_path_lengths): + minimum_distance_setup_stage = min( + setup_to_stage_path_lengths, + key=lambda stage_name: setup_to_stage_path_lengths.get(stage_name) + ) + + setup_stage = self.generated_stages.get(minimum_distance_setup_stage) + + for load_hook in stage.hooks[HookType.LOAD]: + load_hook.parser_config = setup_stage.config + + + def create_error_transition( + self, + source_stage: Stage, + error: Exception + ) -> Transition: + + error_transition = self.transition_types.get(( + source_stage.stage_type, + StageTypes.ERROR + )) + + error_stage = Error() + error_stage.graph_name = self.graph_name + error_stage.graph_id = self.graph_id + error_stage.error = error + + return Transition( + error_transition, + error_stage, + error_stage + ) \ No newline at end of file diff --git a/hyperscale/core/graphs/transitions/transition_group.py b/hyperscale/core/graphs/transitions/transition_group.py new file mode 100644 index 0000000..b98b39f --- /dev/null +++ b/hyperscale/core/graphs/transitions/transition_group.py @@ -0,0 +1,156 @@ +import asyncio +from collections import defaultdict +from typing import Any, Dict, List, Tuple + +from hyperscale.core.graphs.stages.base.parallel.batch_executor import BatchExecutor +from hyperscale.core.graphs.stages.base.parallel.stage_priority import StagePriority +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.core.graphs.stages.types.stage_types import StageTypes + +from .common.base_edge import BaseEdge +from .transition import Transition + +HistoryUpdate = Dict[Tuple[str, str], Any] + + +class TransitionGroup: + + def __init__(self) -> None: + self.transitions: List[Transition] = [] + self.transitions_by_type: Dict[StageTypes, List[Transition]] = defaultdict(list) + self.destination_groups: Dict[Tuple[str, StageTypes], List[Transition]] = defaultdict(list) + self.targets: Dict[str, Stage] = {} + self.transitions_by_edge: Dict[Tuple[str, str,], Transition] = {} + + self.edges_by_name: Dict[str, BaseEdge] = {} + self.adjacency_list: Dict[str, List[Transition]] = [] + self.transition_idx = 0 + self.cpu_pool_size = 0 + self._batched_transitions: List[List[Transition]] = [] + self._transition_configs: Dict[Tuple[str, str, str, int], int] = {} + self._executors: List[BatchExecutor] = [] + + @property + def count(self): + return len(self.transitions_by_edge) + + def __iter__(self): + for transition in self.transitions_by_edge.values(): + yield transition + + def add_transition(self, transition: Transition): + transition.edge.transition_idx = self.transition_idx + self.destination_groups[(transition.from_stage.name, transition.to_stage.stage_type)].append(transition) + self.transitions_by_type[transition.from_stage.stage_type].append(transition) + self.transitions_by_edge[(transition.edge.source.name, transition.edge.destination.name)] = transition + + self.transition_idx += 1 + + def sort_and_map_transitions(self): + + for edge_key, transition in self.transitions_by_edge.items(): + + transition.edges = [ + group_transition.edge for group_transition in self.transitions_by_edge.values() + ] + + destinations = self.adjacency_list[transition.edge.source.name] + transition.destinations = [ + transition.edge.destination.name + ] + + if len(destinations)> 1: + transition.edge.setup() + transition.edge.split([transition.edge for transition in destinations]) + + transition.destinations = [ + destination_transition.edge.destination.name for destination_transition in destinations + ] + + self.transitions_by_edge[edge_key] = transition + + executor = BatchExecutor(max_workers=self.cpu_pool_size) + batched_transitions = executor.partion_prioritized_stage_batches( + list(self.transitions_by_edge.values()) + ) + + prioritized_groups: List[Tuple[str, str, str, int]] = [] + for group in batched_transitions: + + group_priorities: List[int] = [] + for transition_config in group: + source, destination, _, workers = transition_config + + transition = self.transitions_by_edge.get(( + source, + destination + )) + + transition_priority: StagePriority = transition.edge.source.priority_level + + if transition_priority == StagePriority.AUTO: + group_priorities.append(0) + + else: + group_priorities.append(transition_priority.value) + + group_priority = 0 + if len(group_priorities) > 0: + group_priority = max(group_priorities) + prioritized_groups.append(( + group_priority, + group + )) + + sorted_batches = list(sorted( + prioritized_groups, + key=lambda prioritized_group: prioritized_group[0], + reverse=True + )) + + self._batched_transitions = [ + group for _, group in sorted_batches + ] + + for group in self._batched_transitions: + for source, destination, _, workers in group: + self._transition_configs[(source, destination)] = workers + + executor.close() + + async def execute_group(self): + results: List[Any] = [] + + for group in self._batched_transitions: + sorted_group = sorted( + group, + key=lambda transition: transition[3], + reverse=True + ) + + group_results = await asyncio.gather(*[ + asyncio.create_task( + self._execute_transition(transition_config) + ) for transition_config in sorted_group + ]) + + results.extend(group_results) + + return results + + async def _execute_transition(self, transition_config: Tuple[str, str, str, int]) -> Any: + + source, destination, _, workers = transition_config + + transition = self.transitions_by_edge.get(( + source, + destination + )) + + if workers > 0: + transition.edge.source.workers = workers + executor = BatchExecutor(max_workers=workers) + transition.edge.source.executor = executor + self._executors.append(executor) + + return await transition.execute() \ No newline at end of file diff --git a/hyperscale/core/hooks/__init__.py b/hyperscale/core/hooks/__init__.py new file mode 100644 index 0000000..9e5ac68 --- /dev/null +++ b/hyperscale/core/hooks/__init__.py @@ -0,0 +1,14 @@ +from .types import ( + action, + channel, + check, + condition, + context, + depends, + event, + metric, + load, + save, + task, + transform, +) diff --git a/hyperscale/core/hooks/types/__init__.py b/hyperscale/core/hooks/types/__init__.py new file mode 100644 index 0000000..74150ef --- /dev/null +++ b/hyperscale/core/hooks/types/__init__.py @@ -0,0 +1,13 @@ +from .action.decorator import action +from .channel.decorator import channel +from .check.decorator import check +from .condition.decorator import condition +from .context.decorator import context +from .depends.decorator import depends +from .event.decorator import event +from .internal.decorator import Internal +from .metric.decorator import metric +from .load.decorator import load +from .save.decorator import save +from .task.decorator import task +from .transform.decorator import transform \ No newline at end of file diff --git a/hyperscale/core/hooks/types/action/__init__.py b/hyperscale/core/hooks/types/action/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/action/decorator.py b/hyperscale/core/hooks/types/action/decorator.py new file mode 100644 index 0000000..1563e0d --- /dev/null +++ b/hyperscale/core/hooks/types/action/decorator.py @@ -0,0 +1,35 @@ +import functools +from typing import Dict, Tuple, Union + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import ActionHookValidator + + +@registrar(HookType.ACTION) +def action( + *names: Tuple[str, ...], + weight: int=1, + order: int=1, + metadata: Dict[str, Union[str, int]]={}, + skip: bool=False +): + ActionHookValidator( + names=names, + weight=weight, + order=order, + metadata=metadata, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/action/event.py b/hyperscale/core/hooks/types/action/event.py new file mode 100644 index 0000000..4648cf9 --- /dev/null +++ b/hyperscale/core/hooks/types/action/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook + + +class ActionEvent(BaseEvent[ActionHook]): + + def __init__(self, target: Hook, source: ActionHook) -> None: + super( + ActionEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.ACTION + + def copy(self): + action_event = ActionEvent( + self.target.copy(), + self.source.copy() + ) + + action_event.execution_path = list(self.execution_path) + action_event.previous_map = list(self.previous_map) + action_event.next_map = list(self.next_map) + action_event.next_args = defaultdict(dict) + + return action_event diff --git a/hyperscale/core/hooks/types/action/hook.py b/hyperscale/core/hooks/types/action/hook.py new file mode 100644 index 0000000..8309ce2 --- /dev/null +++ b/hyperscale/core/hooks/types/action/hook.py @@ -0,0 +1,101 @@ +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Tuple, + Union, +) + +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_metadata import HookMetadata +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class ActionHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Tuple[str, ...], + weight: int=1, + order: int=1, + skip: bool=False, + metadata: Dict[str, Union[str, int]]={} + ) -> None: + super().__init__( + name, + shortname, + call, + skip=skip, + order=order, + hook_type=HookType.ACTION, + ) + + self.names = list(set(names)) + self.session: BaseEngine = None + self.action: BaseAction = None + self.checks: List[Any] = [] + self.before: List[str] = [] + self.after: List[str] = [] + self.is_notifier = False + self.is_listener = False + self.channels: List[Any] = [] + self.notifiers: List[Any] = [] + self.listeners: List[Any] = [] + self.metadata = HookMetadata( + weight=weight, + order=order, + **metadata + ) + + def copy(self): + action_hook = ActionHook( + self.name, + self.shortname, + self._call, + weight=self.metadata.weight, + order=self.order, + skip=self.skip, + metadata={ + **self.metadata.copy() + } + ) + + action_hook.checks = list(self.checks) + action_hook.stage = self.stage + + return action_hook + + async def call(self, *args, **kwargs): + return await self._call(*args, **kwargs) + + def to_dict(self) -> str: + return { + 'name': self.name, + 'shortname': self.shortname, + 'skip': self.skip, + 'hook_type': HookType.ACTION, + 'names': self.names, + 'checks': [ + check if isinstance(check, str) else check.name for check in self.checks + ], + 'channels': [ + channel if isinstance(channel, str) else channel.name for channel in self.channels + ], + 'notifiers': [ + notifier if isinstance(notifier, str) else notifier.name for notifier in self.notifiers + ], + 'listeners': [ + listener if isinstance(listener, str) else listener.name for listener in self.listeners + ], + 'order': self.metadata.order, + 'weight': self.metadata.weight, + 'user': self.metadata.user, + 'tags': self.metadata.tags + } diff --git a/hyperscale/core/hooks/types/action/validator.py b/hyperscale/core/hooks/types/action/validator.py new file mode 100644 index 0000000..3675cd0 --- /dev/null +++ b/hyperscale/core/hooks/types/action/validator.py @@ -0,0 +1,25 @@ +from typing import Optional, Dict, Union, Tuple, Any +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBool, + validator +) + + +class ActionHookValidator(BaseModel): + names: Tuple[StrictStr, ...] + weight: StrictInt + order: StrictInt + metadata: Optional[Dict[str, Union[StrictStr, StrictInt, StrictFloat]]] + skip: StrictBool + + class Config: + arbitrary_types_allowed = True + + @validator('weight', 'order') + def validate_weight_and_order(cls, val): + assert val > 0, "Order and weight values must be greater than zero!" + return val diff --git a/hyperscale/core/hooks/types/base/__init__.py b/hyperscale/core/hooks/types/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/base/event.py b/hyperscale/core/hooks/types/base/event.py new file mode 100644 index 0000000..64d247f --- /dev/null +++ b/hyperscale/core/hooks/types/base/event.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Dict, Generic, List, TypeVar + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext + +from .event_types import EventType + +T = TypeVar('T') + + +class BaseEvent(Generic[T]): + + __slots__ = ( + 'target', + 'event_type', + 'source', + 'as_hook', + 'event_name', + 'event_order', + 'target_is_event', + 'context', + 'events', + 'execution_path', + 'next_args', + 'previous_map', + 'next_map' + ) + + def __init__(self, target: Hook, source: Hook) -> None: + + self.target = target + self.target_is_event = False + + if isinstance(target, BaseEvent): + self.target_is_event = True + + self.event_type = EventType.EVENT + self.event_name = source.name + self.event_order = source.order + self.event_skip = source.skip + + self.source: T = source + + if target: + self.target_name = self.target.name + self.target_shortname = self.target.shortname + + self.as_hook = False + self.context: SimpleContext = SimpleContext() + self.events: Dict[str, BaseEvent] = {} + self.execution_path: List[List[str]] = [] + self.previous_map: List[str] = [] + self.next_map: List[str] = [] + self.next_args: Dict[str, Dict[str,Any]] = defaultdict(dict) + + def __getattribute__(self, name: str) -> Any: + + source = None + event_attrs = [ + 'call', + 'event_type', + 'target', + 'source', + 'as_hook', + 'event_name', + 'event_order', + 'target_is_event', + 'context', + 'events', + 'execution_path', + 'next_args', + 'previous_map', + 'next_map', + 'copy' + ] + + source = object.__getattribute__(self, 'source') + + if source and hasattr(source, name) and name not in event_attrs: + return getattr(source, name) + + return object.__getattribute__(self, name) + + def __setattr__(self, name: str, value: Any) -> None: + + try: + + source = object.__getattribute__(self, 'source') + + event_attrs = [ + 'call', + 'event_type', + 'target', + 'source', + 'as_hook', + 'event_name', + 'event_order', + 'target_is_event', + 'context', + 'events', + 'execution_path', + 'next_args', + 'previous_map', + 'next_map', + 'copy' + ] + + if source and hasattr(source, name) and name not in event_attrs: + return setattr(source, name, value) + + except AttributeError: + pass + + return super().__setattr__(name, value) + + async def call(self, **kwargs) -> Dict[str, Any]: + + if len(self.next_args[self.event_name]) == 0: + self.next_args[self.event_name] = kwargs + + if isinstance(self.source, BaseEvent): + self.source.context = self.context + + results = await self.source.call(**self.next_args[self.event_name]) + + + self.context.update(results) + self.source.stage_instance.context.update(results) + + if self.source.context: + self.source.context.update(results) + + next_events = [ + self.events.get(event_name) for event_name in self.next_map if self.events.get(event_name) is not None + ] + + for event in next_events: + event.context = self.context + + self.next_args[event.event_name].update(results) + + return { + self.event_name: results + } + + async def execute_pre(self, *hook_args: List[Any]): + results = None + for source_name in self.previous_map: + source: BaseEvent = self.events.get(source_name) + source.context = self.context + results = await source.call(*hook_args) + + return results + + async def execute_post(self, results): + results = None + for source_name in self.previous_map: + source: BaseEvent = self.events.get(source_name) + source.context = self.context + results = await source.call(results) + + return results + + def copy(self) -> BaseEvent[Hook]: + base_event = BaseEvent( + self.target.copy(), + self.source.copy() + ) + + base_event.execution_path = self.execution_path + base_event.previous_map = self.previous_map + base_event.next_map = self.next_map + base_event.next_args = self.next_args + + return base_event diff --git a/hyperscale/core/hooks/types/base/event_dispatch.py b/hyperscale/core/hooks/types/base/event_dispatch.py new file mode 100644 index 0000000..0340ce9 --- /dev/null +++ b/hyperscale/core/hooks/types/base/event_dispatch.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import asyncio +from collections import OrderedDict, defaultdict +from typing import Any, Dict, List, Tuple, Union + +import networkx + +from hyperscale.core.hooks.types.action.event import ActionEvent +from hyperscale.core.hooks.types.channel.event import ChannelEvent +from hyperscale.core.hooks.types.task.event import TaskEvent + +from .event import BaseEvent +from .event_types import EventType +from .simple_context import SimpleContext + + +class EventDispatcher: + + def __init__(self, timeout: Union[int, float]=None) -> None: + self.events: OrderedDict[EventType, List[BaseEvent]]= OrderedDict() + self.priority_map = { + EventType.CONTEXT: 0, + EventType.LOAD: 1, + EventType.EVENT: 2, + EventType.TRANSFORM: 2, + EventType.CONDITION: 2, + EventType.SAVE: 3, + EventType.ACTION: 4, + EventType.TASK: 4, + EventType.CHECK: 5, + EventType.CHANNEL: 5, + EventType.METRIC: 6 + } + + event_orderings = list(sorted( + list(self.priority_map.items()), + key=lambda event: event[1] + )) + + for event_type_name, _ in event_orderings: + self.events[event_type_name] = [] + + self.events_by_name: Dict[str, BaseEvent] = {} + self.timeout = timeout + self.initial_events: Dict[str, List[BaseEvent]]= defaultdict(list) + self.actions_and_tasks: Dict[str, Union[ActionEvent, TaskEvent]] = {} + self.channels: Dict[str, ChannelEvent] = {} + self.skip_list = [] + self.source_name = None + self.graph: networkx.DiGraph = networkx.DiGraph() + + def __iter__(self): + for event_type in self.events: + for event in self.events[event_type]: + yield event + + def __getitem__(self, event_type: EventType): + return self.events[event_type] + + def copy(self): + dispatcher = EventDispatcher(timeout=self.timeout) + + for event in self.events_by_name.values(): + dispatcher.add_event( + event.copy() + ) + + for event in self.events_by_name.values(): + event.context = SimpleContext() + event.source.context = SimpleContext() + + for graph_event_name in event.events: + + if graph_event_name in dispatcher.events_by_name: + dispatcher.events_by_name[event.event_name].events[graph_event_name] = dispatcher.events_by_name.get(graph_event_name) + + else: + dispatcher.events_by_name[event.event_name].events[graph_event_name] = event.events.get(graph_event_name) + + for stage in self.initial_events: + initial_event_names = [ + event.event_name for event in self.initial_events[stage] + ] + + dispatcher.initial_events[stage] = [ + event for event in dispatcher.events_by_name.values() if event.event_name in initial_event_names + ] + + dispatcher.assemble_action_and_task_subgraphs() + dispatcher.assemble_execution_graph() + + return dispatcher + + def set_events(self, events: List[BaseEvent]) -> None: + for event in events: + self.events_by_name[event.event_name] = event + self.events[event.event_type].append(event) + + def add_event(self, event: BaseEvent): + + if isinstance(event, (ActionEvent, TaskEvent)): + + self.actions_and_tasks[event.event_name] = event + + elif isinstance(event, ChannelEvent): + self.channels[event.event_name] = event + self.skip_list.append(event.event_name) + + self.events_by_name[event.event_name] = event + self.events[event.event_type].append(event) + + self.graph.add_node( + event.event_name, + event=event + ) + + def assemble_execution_graph(self): + for event in self.events_by_name.values(): + for target in event.next_map: + self.graph.add_edge(event.event_name, target) + + def assemble_action_and_task_subgraphs(self): + for event_name, event in self.actions_and_tasks.items(): + self.skip_list.append(event_name) + + for dependency_name in event.source.names: + self.skip_list.append(dependency_name) + + dependency_event = self.events_by_name.get(dependency_name) + # If we specify a Channel hook as a dependency of an Action + # or Task - that Action/Task listens to the Channel. + if isinstance(dependency_event, ChannelEvent): + event.source.is_listener = True + dependency_event.source.listeners.append(event) + + event.source.before.extend( + self._prepend_action_or_task_event( + dependency_event, + event + ) + ) + + for event_name, event in self.events_by_name.items(): + for dependency_name in event.source.names: + action_or_task_event = self.actions_and_tasks.get(dependency_name) + if action_or_task_event: + self.skip_list.append(event_name) + action_or_task_event.source.after.extend( + self._append_action_or_task_event( + event, + action_or_task_event + ) + ) + + if isinstance(event, ChannelEvent): + action_or_task_event.source.is_notifier = True + action_or_task_event.source.channels.append(dependency_event) + event.source.notifiers.append(action_or_task_event) + + + for event in self.actions_and_tasks.values(): + for idx, layer in enumerate(event.before): + event.source.before[idx] = [ + self.events_by_name.get(event_name) for event_name in layer + ] + + for idx, layer in enumerate(event.after): + event.source.after[idx] = [ + self.events_by_name.get(event_name) for event_name in layer + ] + + for idx, layer in enumerate(event.checks): + event.checks[idx] = [ + self.events_by_name.get(event_name) for event_name in layer + ] + + # Add listeners for a channel to the channel's notifiers + for channel_event_name, channel_event in self.channels.items(): + for notifier in channel_event.source.notifiers: + notifier.listeners.extend(channel_event.listeners) + + self.channels[channel_event_name] = channel_event + + async def dispatch_events(self, stage_name: str): + + await self._execute_batch() + + def _prepend_action_or_task_event(self, dependency_event: BaseEvent, action_or_task: BaseEvent): + execution_path = list(dependency_event.execution_path) + + event_layer_found = False + for idx, layer in enumerate(execution_path): + + if event_layer_found: + dependency_event.execution_path.remove(layer) + + if action_or_task.event_name in layer: + event_layer_found = True + dependency_event.execution_path[idx].remove(action_or_task.event_name) + + if len(layer) < 1: + dependency_event.execution_path.remove(layer) + + return dependency_event.execution_path + + def _append_action_or_task_event(self, dependant_event: BaseEvent, action_or_task: BaseEvent): + execution_path = [] + + if dependant_event.event_type == EventType.CHECK: + for idx, layer in enumerate(dependant_event.execution_path): + if idx >= len(action_or_task.checks): + action_or_task.checks.append(layer) + + else: + action_or_task.checks[idx].extend([ + node for node in layer if node not in action_or_task.checks[idx] + ]) + + else: + for idx, layer in enumerate(dependant_event.execution_path): + if idx >= len(action_or_task.after): + action_or_task.after.append(layer) + + else: + action_or_task.after[idx].extend([ + node for node in layer if node not in action_or_task.after[idx] + ]) + + return execution_path + + async def _execute_batch(self): + for layer in networkx.topological_generations(self.graph): + layer_events = [ + self.events_by_name.get(event_name) for event_name in layer if event_name not in self.skip_list + ] + + results: List[Dict[str, Any]] = await asyncio.gather(*[ + asyncio.create_task( + asyncio.wait_for( + event.call(**event.next_args), + timeout=self.timeout + ) if self.timeout else event.call() + ) for event in layer_events + ]) + + result_events: List[Tuple[BaseEvent, Any]] = [] + for result in results: + for event_name, result in result.items(): + event = self.events_by_name.get(event_name) + result_events.append((event, result)) + + for event, result in result_events: + next_events = [ + event.events.get(event_name) for event_name in event.next_map if event.events.get(event_name) is not None + ] + + event.context.update(event.next_args[event.event_name]) + event.source.stage_instance.context.update(event.next_args[event.event_name]) + + + for next_event in next_events: + next_event.context.update(event.context) + + next_event.next_args[next_event.event_name].update(result) + event.context.update(next_event.next_args[next_event.event_name]) + event.source.stage_instance.context.update(next_event.next_args[next_event.event_name]) + + diff --git a/hyperscale/core/hooks/types/base/event_graph.py b/hyperscale/core/hooks/types/base/event_graph.py new file mode 100644 index 0000000..cce2409 --- /dev/null +++ b/hyperscale/core/hooks/types/base/event_graph.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import Dict, List, Union + +import networkx + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.hook import Hook, HookType + +from .event_dispatch import EventDispatcher +from .get_event import get_event + + +class EventGraph: + + def __init__(self, hooks_by_type: Dict[HookType, Dict[str, Hook]]) -> None: + self.hooks_by_type = hooks_by_type + self.hooks_by_name = {} + self.hooks_by_shortname: Dict[str, Dict[str, Hook]] = defaultdict(dict) + + for hook_type in self.hooks_by_type: + for hook in self.hooks_by_type[hook_type].values(): + self.hooks_by_name[hook.name] = hook + self.hooks_by_shortname[hook.stage][hook.shortname] = hook + + self.hooks_graph = networkx.DiGraph() + + self.hooks_graph.add_nodes_from([ + ( + hook_name, + {'hook': hook} + ) for hook_name, hook in self.hooks_by_name.items() + ]) + + self.event_hooks: List[Hook] = [ + *list(self.hooks_by_type.get( + HookType.ACTION, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.CHANNEL, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.CHECK, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.CONDITION, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.CONTEXT, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.EVENT, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.LOAD, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.METRIC, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.SAVE, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.TASK, + {} + ).values()), + *list(self.hooks_by_type.get( + HookType.TRANSFORM, + {} + ).values()), + ] + + nodes = [self.hooks_by_name.get(node) for node in self.hooks_graph.nodes()] + self.events: Dict[str, BaseEvent] = {} + self.base_stages = list(set([node.stage_instance.__class__.__base__.__name__ for node in nodes])) + self.removal_targets = [] + self.action_and_task_events = {} + + def hooks_to_events(self) -> EventGraph: + for event_hook in self.event_hooks: + for idx, target_hook_name in enumerate(event_hook.names): + target: Union[Hook, None] = self.hooks_by_name.get(target_hook_name) + + if target is None: + target: Hook = self.hooks_by_shortname[event_hook.stage].get(target_hook_name) + event_hook.names[idx] = target.name + + event = get_event(target, event_hook) + + if isinstance(target, Hook): + + target: BaseEvent = get_event(target, target) + self.events[target.event_name] = target + + self.hooks_graph.update(nodes=[( + target.event_name, + {'hook': target} + )]) + + + self.events[event.event_name] = event + + self.hooks_graph.update(nodes=[( + event.event_name, + {'hook': event} + )]) + + if self.events.get(event_hook.name) is None: + + event = get_event(event_hook, event_hook) + + self.events[event_hook.name] = event + self.hooks_graph.update(nodes=[( + event_hook.name, + {'hook': event} + )]) + + return self + + def assemble_graph(self) -> EventGraph: + for event_hook in self.event_hooks: + for target_hook_name in event_hook.names: + target: BaseEvent = self.events.get(target_hook_name) + + if target is None: + target_hook: Hook = self.hooks_by_shortname[event_hook.stage].get(target_hook_name) + target_name = target_hook.name + + else: + target_name = target.event_name + + self.hooks_graph.add_edge(target_name, event_hook.name) + + + return self + + def apply_graph_to_events(self) -> None: + + for event in self.events.values(): + + event.execution_path = [edge for edge in networkx.bfs_layers(self.hooks_graph, event.event_name)] + event.previous_map = [node for node in self.hooks_graph.predecessors(event.event_name)] + event.next_map = [node for node in self.hooks_graph.successors(event.event_name)] + + for path_layer in event.execution_path: + event.events.update({ + event_name: self.events.get(event_name) for event_name in path_layer + }) + + hook_names = [hook.name for hook in event.source.stage_instance.hooks[event.hook_type]] + + if event.event_name in hook_names: + hook_idx = hook_names.index(event.event_name) + event.source.stage_instance.hooks[event.hook_type][hook_idx] = event + + else: + event.source.stage_instance.hooks[event.hook_type].append(event) + + if event.source.stage_instance.dispatcher is None: + event.source.stage_instance.dispatcher = EventDispatcher() + + if len(event.previous_map) < 1 and event.event_name in hook_names: + + event.source.stage_instance.dispatcher.source_name = event.stage + event.source.stage_instance.dispatcher.initial_events[event.source.stage].append(event) + + for layer in event.execution_path: + for event_name in layer: + next_event = self.events.get(event_name) + event.source.stage_instance.dispatcher.add_event(next_event) + + + event.source.stage_instance.dispatcher.add_event(event) + + \ No newline at end of file diff --git a/hyperscale/core/hooks/types/base/event_types.py b/hyperscale/core/hooks/types/base/event_types.py new file mode 100644 index 0000000..51314f2 --- /dev/null +++ b/hyperscale/core/hooks/types/base/event_types.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class EventType(Enum): + ACTION='ACTION' + CHANNEL='CHANNEL' + CHECK='CHECK' + CONDITION='CONDITION' + CONTEXT='CONTEXT' + EVENT='EVENT' + LOAD='LOAD' + METRIC='METRIC' + SAVE='SAVE' + TASK='TASK' + TRANSFORM='TRANSFORM' diff --git a/hyperscale/core/hooks/types/base/get_event.py b/hyperscale/core/hooks/types/base/get_event.py new file mode 100644 index 0000000..63e9e24 --- /dev/null +++ b/hyperscale/core/hooks/types/base/get_event.py @@ -0,0 +1,46 @@ +from typing import Dict, Union + +from hyperscale.core.hooks.types.action.event import ActionEvent +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.channel.event import ChannelEvent +from hyperscale.core.hooks.types.check.event import CheckEvent +from hyperscale.core.hooks.types.condition.event import ConditionEvent +from hyperscale.core.hooks.types.context.event import ContextEvent +from hyperscale.core.hooks.types.event.event import Event +from hyperscale.core.hooks.types.load.event import LoadEvent +from hyperscale.core.hooks.types.metric.event import MetricEvent +from hyperscale.core.hooks.types.save.event import SaveEvent +from hyperscale.core.hooks.types.task.event import TaskEvent +from hyperscale.core.hooks.types.transform.event import TransformEvent + +HedraEvent = Union[ + Event, + TransformEvent, + ConditionEvent, + ContextEvent, + SaveEvent, + LoadEvent, + CheckEvent +] + + +def get_event(target: Hook, source: Hook) -> HedraEvent: + event_types: Dict[HookType, HedraEvent] = { + HookType.ACTION: ActionEvent, + HookType.CHANNEL: ChannelEvent, + HookType.CHECK: CheckEvent, + HookType.CONDITION: ConditionEvent, + HookType.CONTEXT: ContextEvent, + HookType.EVENT: Event, + HookType.LOAD: LoadEvent, + HookType.METRIC: MetricEvent, + HookType.SAVE: SaveEvent, + HookType.TASK: TaskEvent, + HookType.TRANSFORM: TransformEvent + } + + return event_types.get( + source.hook_type, + Event + )(target, source) \ No newline at end of file diff --git a/hyperscale/core/hooks/types/base/hook.py b/hyperscale/core/hooks/types/base/hook.py new file mode 100644 index 0000000..1d71719 --- /dev/null +++ b/hyperscale/core/hooks/types/base/hook.py @@ -0,0 +1,77 @@ +import inspect +import uuid +from typing import Any, Awaitable, Callable, List + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.simple_context import SimpleContext + + +class Hook: + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + stage: str = None, + order: int=None, + skip: bool=False, + hook_type: HookType=None + ) -> None: + self.hook_id = str(uuid.uuid4()) + self.name = name + self.shortname = shortname + + self._call: Callable[..., Awaitable[Any]] = call + self.stage = stage + self.hook_type = hook_type + self.stage_instance: Any = None + self.conditions: List[Callable[..., Any]] = [] + + if call is None: + call = self._default_call + + self.args = inspect.signature(call) + self.params = self.args.parameters + self.context: SimpleContext = SimpleContext() + self.order: int = order + self.skip = skip + + + async def call(self, **kwargs): + + hook_args = {name: value for name, value in kwargs.items() if name in self.params} + execute = await self._execute_call(**hook_args) + + if execute: + result = await self._call(**hook_args) + + if isinstance(result, dict): + return result + + return { + **kwargs, + self.name: result + } + + + async def _execute_call(self, **hook_args): + execute = True + for condition in self.conditions: + execute = await condition(**{name: value for name, value in hook_args.items() if name in self.params}) + + return execute + + async def _default_call(self, **kwargs): + pass + + def copy(self): + return Hook( + self.name, + self.shortname, + self._call, + stage=self.stage, + order=self.order, + skip=self.skip, + hook_type=self.hook_type + ) diff --git a/hyperscale/core/hooks/types/base/hook_metadata.py b/hyperscale/core/hooks/types/base/hook_metadata.py new file mode 100644 index 0000000..63dbef8 --- /dev/null +++ b/hyperscale/core/hooks/types/base/hook_metadata.py @@ -0,0 +1,25 @@ +from typing import List + + +class HookMetadata: + + def __init__( + self, + weight: int = 1, + order: int = 1, + env: str = None, + user: str = None, + tags: List[str] = [] + ) -> None: + self.weight = weight + self.order = order + self.env = env + self.user = user + self.tags = tags + + def copy(self): + return { + 'env': self.env, + 'user': self.user, + 'tags': list(self.tags) + } \ No newline at end of file diff --git a/hyperscale/core/hooks/types/base/hook_type.py b/hyperscale/core/hooks/types/base/hook_type.py new file mode 100644 index 0000000..9f191fa --- /dev/null +++ b/hyperscale/core/hooks/types/base/hook_type.py @@ -0,0 +1,16 @@ +from enum import Enum + + +class HookType(Enum): + ACTION='ACTION' + CHANNEL='CHANNEL' + CHECK='CHECK' + CONTEXT='CONTEXT' + CONDITION='CONDITION' + EVENT='EVENT' + INTERNAL='INTERNAL' + METRIC='METRIC' + LOAD='LOAD' + SAVE='SAVE' + TASK='TASK' + TRANSFORM='TRANSFORM' diff --git a/hyperscale/core/hooks/types/base/registrar.py b/hyperscale/core/hooks/types/base/registrar.py new file mode 100644 index 0000000..9e882ed --- /dev/null +++ b/hyperscale/core/hooks/types/base/registrar.py @@ -0,0 +1,91 @@ +from collections import defaultdict +from typing import Dict, List + +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.channel.hook import ChannelHook +from hyperscale.core.hooks.types.check.hook import CheckHook +from hyperscale.core.hooks.types.condition.hook import ConditionHook +from hyperscale.core.hooks.types.context.hook import ContextHook +from hyperscale.core.hooks.types.event.hook import EventHook +from hyperscale.core.hooks.types.load.hook import LoadHook +from hyperscale.core.hooks.types.metric.hook import MetricHook +from hyperscale.core.hooks.types.save.hook import SaveHook +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.hooks.types.transform.hook import TransformHook + +from .hook import Hook +from .hook_type import HookType + + +class Registrar: + all: Dict[str, List[Hook]] = {} + reserved: Dict[str, Dict[str, Hook]] = defaultdict(dict) + module_paths: Dict[str, str] = {} + + def __init__(self, hook_type) -> None: + self.hook_type = hook_type + self.hook_types = { + HookType.ACTION: lambda *args, **kwargs: ActionHook(*args, **kwargs), + HookType.CHANNEL: lambda *args, **kwargs: ChannelHook(*args, **kwargs), + HookType.CHECK: lambda *args, **kwargs: CheckHook(*args, **kwargs), + HookType.CONDITION: lambda *args, **kwargs: ConditionHook(*args, **kwargs), + HookType.CONTEXT: lambda *args, **kwargs: ContextHook(*args, **kwargs), + HookType.EVENT: lambda *args, **kwargs: EventHook(*args, **kwargs), + HookType.METRIC: lambda *args, **kwargs: MetricHook(*args, **kwargs), + HookType.LOAD: lambda *args, **kwargs: LoadHook(*args, **kwargs), + HookType.SAVE: lambda *args, **kwargs: SaveHook(*args, **kwargs), + HookType.TASK: lambda *args, **kwargs: TaskHook(*args, **kwargs), + HookType.TRANSFORM: lambda *args, **kwargs: TransformHook(*args, **kwargs), + } + + def __call__(self, hook): + self.module_paths[hook.__name__] = hook.__module__ + + + def wrap_hook(*args, **kwargs): + + def wrapped_method(func): + + hook_name = func.__qualname__ + hook_shortname = func.__name__ + + hook = self.hook_types[self.hook_type] + + hook_args = args + args_count = len(args) + + if args_count < 1: + hook_args = [] + + if hook_name not in self.all: + self.all[hook_name] = [ + hook( + hook_name, + hook_shortname, + func, + *hook_args, + **kwargs + ) + ] + + else: + self.all[hook_name].append(hook( + hook_name, + hook_shortname, + func, + *hook_args, + **kwargs + )) + + return func + + return wrapped_method + + return wrap_hook + + +def makeRegistrar(): + return Registrar + + +registrar = makeRegistrar() \ No newline at end of file diff --git a/hyperscale/core/hooks/types/base/simple_context.py b/hyperscale/core/hooks/types/base/simple_context.py new file mode 100644 index 0000000..e4ebb32 --- /dev/null +++ b/hyperscale/core/hooks/types/base/simple_context.py @@ -0,0 +1,127 @@ +from __future__ import annotations +from typing import Any, Optional, List, Union, Dict + + +class SimpleContext: + + def __init__(self, **kwargs) -> None: + + self.known_keys = [ + 'stages', + 'visited', + 'results', + 'results_stages', + 'summaries', + 'paths', + 'path_lengths', + 'known_keys' + ] + + self.ignore_serialization_filters = [] + + for kwarg_name, kwarg in kwargs.items(): + object.__setattr__(self, kwarg_name, kwarg) + + def __iter__(self): + + ignore_items = [ + *self.known_keys, + *self.ignore_serialization_filters + ] + + for key, value in self.__dict__.items(): + if key.startswith('__') is False and key not in ignore_items: + yield key, value + + def __getattribute__(self, __name: str) -> Any: + return object.__getattribute__(self, __name) + + def __setattr__(self, __name: str, __value: Any) -> None: + object.__setattr__(self, __name, __value) + + def __getitem__(self, name: str) -> Optional[Any]: + if hasattr(self, name): + return object.__getattribute__(self, name) + + return None + + def __setitem__(self, name: str, value: Any) -> None: + object.__setattr__(self, name, value) + + def get(self, name: str) -> Optional[Any]: + return self.__getitem__(name) + + def keys(self) -> List[str]: + return [key for key in self.__dict__.keys() if key.startswith('__') is False] + + def values(self) -> List[Any]: + return [value for key, value in self.__dict__.items() if key.startswith('__') is False] + + def items(self): + return [ + ( + key, + value + ) for key, value in self.__dict__.items() if key.startswith('__') is False + ] + + def remove(self, name: str): + if name.startswith('__') is False: + object.__delattr__(self, name) + + def update(self, update_context: Union[SimpleContext, Dict[str, Any]]): + for context_key, context_value in update_context.items(): + object.__setattr__(self, context_key, context_value) + + def as_serializable(self): + + ignore_items = [ + *self.known_keys, + *self.ignore_serialization_filters + ] + + serialization_items = [] + for key, value in self.__dict__.items(): + if key.startswith('__') is False and key not in ignore_items: + serialization_items.append(( + key, + value + )) + + return serialization_items + + def create_or_update( + self, + context_key: str, + value: Any, + default: Any + ): + + if hasattr(self, context_key): + if isinstance(value, dict): + exitsting_value: dict = self.__getitem__(context_key) + exitsting_value.update(value) + + self.__setitem__(context_key, exitsting_value) + + elif isinstance(value, list): + exitsting_value: list = self.__getitem__(context_key) + exitsting_value.extend(value) + + self.__setitem__(context_key, exitsting_value) + + else: + self.__setitem__(context_key, value) + + else: + self.__setitem__(context_key, default) + + def create_if_not_exists( + self, + context_key: str, + value: Any + ): + + if hasattr(self, context_key) is False: + self.__setitem__(context_key, value) + \ No newline at end of file diff --git a/hyperscale/core/hooks/types/channel/__init__.py b/hyperscale/core/hooks/types/channel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/channel/decorator.py b/hyperscale/core/hooks/types/channel/decorator.py new file mode 100644 index 0000000..1fa1430 --- /dev/null +++ b/hyperscale/core/hooks/types/channel/decorator.py @@ -0,0 +1,32 @@ +import functools +from typing import Tuple + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import ChannelHookValidator + + +@registrar(HookType.CHANNEL) +def channel( + *names: Tuple[str, ...], + order: int=1, + skip: bool=False +): + + ChannelHookValidator( + names=names, + order=order, + skip=skip + ) + + def wrapper(func) -> Hook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/channel/event.py b/hyperscale/core/hooks/types/channel/event.py new file mode 100644 index 0000000..abb7705 --- /dev/null +++ b/hyperscale/core/hooks/types/channel/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.channel.hook import ChannelHook + + +class ChannelEvent(BaseEvent[ChannelHook]): + + def __init__(self, target: Hook, source: ChannelHook) -> None: + super( + ChannelEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.CHANNEL + + def copy(self): + channel_event = ChannelEvent( + self.target.copy(), + self.source.copy() + ) + + channel_event.execution_path = list(self.execution_path) + channel_event.previous_map = list(self.previous_map) + channel_event.next_map = list(self.next_map) + channel_event.next_args = defaultdict(dict) + + return channel_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/channel/hook.py b/hyperscale/core/hooks/types/channel/hook.py new file mode 100644 index 0000000..af57fcb --- /dev/null +++ b/hyperscale/core/hooks/types/channel/hook.py @@ -0,0 +1,63 @@ +from typing import Any, Awaitable, Callable, List, Tuple + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class ChannelHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Tuple[str, ...], + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + skip=skip, + order=order, + hook_type=HookType.CHANNEL, + ) + + self.notifiers: List[Any] = [] + self.listeners: List[Any] = [] + self.names = list(set(names)) + + async def call(self, **kwargs): + + if self.skip: + return kwargs + + result = await super().call(**{ + name: value for name, value in kwargs.items() if name in self.params + }) + + if isinstance(result, dict): + return { + **kwargs, + **result + } + + return { + **kwargs, + self.shortname: result + } + + def copy(self): + channel_hook = ChannelHook( + self.name, + self.shortname, + self._call, + *self.names, + order=self.order, + skip=self.skip, + ) + + channel_hook.stage = self.stage + + return channel_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/channel/validator.py b/hyperscale/core/hooks/types/channel/validator.py new file mode 100644 index 0000000..3cbe3b3 --- /dev/null +++ b/hyperscale/core/hooks/types/channel/validator.py @@ -0,0 +1,20 @@ +from typing import Tuple +from pydantic import ( + BaseModel, + validator, + StrictStr, + StrictInt, + StrictBool +) + +class ChannelHookValidator(BaseModel): + names: Tuple[StrictStr, ...] + order: StrictInt + skip: StrictBool + + @validator('names') + def validate_names(cls, vals): + assert len(vals) > 0 + assert len(vals) == len(set(vals)) + + return vals diff --git a/hyperscale/core/hooks/types/check/__init__.py b/hyperscale/core/hooks/types/check/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/check/decorator.py b/hyperscale/core/hooks/types/check/decorator.py new file mode 100644 index 0000000..889f87f --- /dev/null +++ b/hyperscale/core/hooks/types/check/decorator.py @@ -0,0 +1,32 @@ +import functools + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import CheckHookValidator + + +@registrar(HookType.CHECK) +def check( + *names, + message: str='Did not return True.', + order: int=1, + skip: bool=False +): + + CheckHookValidator( + names=names, + message=message, + order=order, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/check/event.py b/hyperscale/core/hooks/types/check/event.py new file mode 100644 index 0000000..8104685 --- /dev/null +++ b/hyperscale/core/hooks/types/check/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.check.hook import CheckHook + + +class CheckEvent(BaseEvent[CheckHook]): + + def __init__(self, target: Hook, source: CheckHook) -> None: + super( + CheckEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.CHECK + + def copy(self): + check_event = CheckEvent( + self.target.copy(), + self.source.copy() + ) + + check_event.execution_path = list(self.execution_path) + check_event.previous_map = list(self.previous_map) + check_event.next_map = list(self.next_map) + check_event.next_args = defaultdict(dict) + + return check_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/check/hook.py b/hyperscale/core/hooks/types/check/hook.py new file mode 100644 index 0000000..88cbe9f --- /dev/null +++ b/hyperscale/core/hooks/types/check/hook.py @@ -0,0 +1,68 @@ +from typing import Any, Awaitable, Callable, List + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class CheckHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: List[str], + message: str='Did not return True.', + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.CHECK + ) + + self.message = message + self.names = list(set(names)) + + async def call(self, **kwargs): + + if self.skip: + return kwargs + + passed = await self._call(**{ + name: value for name, value in kwargs.items() if name in self.params + }) + + result = kwargs.get('result') + + if passed is False and result: + result.error = f'Check - {self.name} - failed. Context - {self.message}' + + if isinstance(passed, dict): + return { + **kwargs, + **passed + } + + return { + **kwargs, + self.shortname: passed + } + + def copy(self): + check_hook = CheckHook( + self.name, + self.shortname, + self._call, + *self.names, + order=self.order, + skip=self.skip, + ) + + check_hook.stage = self.stage + + return check_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/check/validator.py b/hyperscale/core/hooks/types/check/validator.py new file mode 100644 index 0000000..907b6c5 --- /dev/null +++ b/hyperscale/core/hooks/types/check/validator.py @@ -0,0 +1,22 @@ +from typing import Tuple +from pydantic import ( + BaseModel, + validator, + StrictStr, + StrictInt, + StrictBool +) + + +class CheckHookValidator(BaseModel): + names: Tuple[StrictStr, ...] + message: StrictStr + order: StrictInt + skip: StrictBool + + @validator('names') + def validate_names(cls, vals): + assert len(vals) > 0 + assert len(vals) == len(set(vals)) + + return vals diff --git a/hyperscale/core/hooks/types/condition/__init__.py b/hyperscale/core/hooks/types/condition/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/condition/decorator.py b/hyperscale/core/hooks/types/condition/decorator.py new file mode 100644 index 0000000..a52d777 --- /dev/null +++ b/hyperscale/core/hooks/types/condition/decorator.py @@ -0,0 +1,29 @@ +import functools + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import ConditionHookValidator + + +@registrar(HookType.CONDITION) +def condition( + *names, + order: int=1, + skip: bool=False +): + ConditionHookValidator( + names=names, + order=order, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/condition/event.py b/hyperscale/core/hooks/types/condition/event.py new file mode 100644 index 0000000..91b81f2 --- /dev/null +++ b/hyperscale/core/hooks/types/condition/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.condition.hook import ConditionHook + + +class ConditionEvent(BaseEvent[ConditionHook]): + + def __init__(self, target: Hook, source: ConditionHook) -> None: + super( + ConditionEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.CONDITION + + def copy(self): + condtion_event = ConditionEvent( + self.target.copy(), + self.source.copy() + ) + + condtion_event.execution_path = list(self.execution_path) + condtion_event.previous_map = list(self.previous_map) + condtion_event.next_map = list(self.next_map) + condtion_event.next_args = defaultdict(dict) + + return condtion_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/condition/hook.py b/hyperscale/core/hooks/types/condition/hook.py new file mode 100644 index 0000000..8576c6e --- /dev/null +++ b/hyperscale/core/hooks/types/condition/hook.py @@ -0,0 +1,62 @@ +from typing import Any, Awaitable, Callable, Coroutine, Dict, Tuple + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class ConditionHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Tuple[str, ...], + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.CONDITION + ) + + self.names = list(set(names)) + self.pre: bool = True + self.key: str = None + self.events: Dict[str, Coroutine] = {} + + async def call(self, **kwargs): + + if self.skip: + return kwargs + + execute = await super().call(**{name: value for name, value in kwargs.items() if name in self.params}) + + if isinstance(execute, dict): + return { + **kwargs, + **execute + } + + return { + **kwargs, + self.shortname: execute + } + + def copy(self): + condition_hook = ConditionHook( + self.name, + self.shortname, + self._call, + *self.names, + order=self.order, + skip=self.skip, + ) + + condition_hook.stage = self.stage + + return condition_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/condition/validator.py b/hyperscale/core/hooks/types/condition/validator.py new file mode 100644 index 0000000..a90a9f9 --- /dev/null +++ b/hyperscale/core/hooks/types/condition/validator.py @@ -0,0 +1,21 @@ +from typing import Tuple +from pydantic import ( + BaseModel, + validator, + StrictStr, + StrictInt, + StrictBool +) + + +class ConditionHookValidator(BaseModel): + names: Tuple[StrictStr, ...] + order: StrictInt + skip: StrictBool + + @validator('names') + def validate_names(cls, vals): + assert len(vals) == len(set(vals)) + + return vals + diff --git a/hyperscale/core/hooks/types/context/__init__.py b/hyperscale/core/hooks/types/context/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/context/decorator.py b/hyperscale/core/hooks/types/context/decorator.py new file mode 100644 index 0000000..9af9857 --- /dev/null +++ b/hyperscale/core/hooks/types/context/decorator.py @@ -0,0 +1,31 @@ +import functools +from typing import Tuple + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import ContextHookValidator + + +@registrar(HookType.CONTEXT) +def context( + *names: Tuple[str], + order: int=1, + skip: bool=False +): + ContextHookValidator( + names=names, + order=order, + skip=skip + ) + + def wrapper(func) -> Hook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/context/event.py b/hyperscale/core/hooks/types/context/event.py new file mode 100644 index 0000000..bd9d9d2 --- /dev/null +++ b/hyperscale/core/hooks/types/context/event.py @@ -0,0 +1,34 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.context.hook import ContextHook + + +class ContextEvent(BaseEvent[ContextHook]): + + def __init__(self, target: Hook, source: ContextHook) -> None: + + super( + ContextEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.CONTEXT + + def copy(self): + context_event = ContextEvent( + self.target.copy(), + self.source.copy() + ) + + context_event.execution_path = list(self.execution_path) + context_event.previous_map = list(self.previous_map) + context_event.next_map = list(self.next_map) + context_event.next_args = defaultdict(dict) + + return context_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/context/hook.py b/hyperscale/core/hooks/types/context/hook.py new file mode 100644 index 0000000..8ff1aed --- /dev/null +++ b/hyperscale/core/hooks/types/context/hook.py @@ -0,0 +1,97 @@ +import inspect +from typing import Any, Awaitable, Callable, Optional, Tuple + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class ContextHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Optional[Tuple[str, ...]], + store: Optional[str]=None, + load: Optional[str]=None, + pre: bool=False, + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + hook_type=HookType.CONTEXT + ) + + self.names = list(set(names)) + self.store = store + self.load = load + self.pre = pre + self.order = order + + self.args = inspect.signature(call) + self.params = self.args.parameters + + async def call(self, **kwargs) -> None: + + if self.skip: + return kwargs + + hook_args = {name: value for name, value in kwargs.items() if name in self.params} + + hook_args = hook_args + + for param_name in self.params.keys(): + + context_value = self.context[param_name] + + if param_name != 'self' and context_value is not None: + hook_args[param_name] = context_value + + if 'results' in list(self.params.keys()): + results = [] + + for stage_results in context_value.values(): + results.extend( + stage_results.results + ) + + context_value['results'] = results + + context_result = await self._call(**{name: value for name, value in hook_args.items() if name in self.params}) + + if isinstance(context_result, dict): + + for context_key, value in context_result.items(): + self.context[context_key] = value + + return { + **kwargs, + **context_result + } + + self.context[self.name] = context_result + return { + **kwargs, + self.shortname: context_result + } + + def copy(self): + context_hook = ContextHook( + self.name, + self.shortname, + self._call, + *self.names, + store=self.store, + load=self.load, + pre=self.pre, + order=self.order, + skip=self.skip, + ) + + context_hook.stage = self.stage + + return context_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/context/validator.py b/hyperscale/core/hooks/types/context/validator.py new file mode 100644 index 0000000..becc167 --- /dev/null +++ b/hyperscale/core/hooks/types/context/validator.py @@ -0,0 +1,13 @@ +from typing import Optional, Tuple +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictBool +) + + +class ContextHookValidator(BaseModel): + names: Optional[Tuple[StrictStr, ...]] + order: StrictInt + skip: StrictBool diff --git a/hyperscale/core/hooks/types/depends/decorator.py b/hyperscale/core/hooks/types/depends/decorator.py new file mode 100644 index 0000000..16ed9f5 --- /dev/null +++ b/hyperscale/core/hooks/types/depends/decorator.py @@ -0,0 +1,18 @@ +from .validator import DependsValidator + + +def depends(*stages): + DependsValidator(stages=stages) + + def wrapper(cls): + + def decorator(): + + direct_dependencies = [stage for stage in stages] + cls.dependencies = direct_dependencies + + return cls + + return decorator() + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/depends/validator.py b/hyperscale/core/hooks/types/depends/validator.py new file mode 100644 index 0000000..8e9e566 --- /dev/null +++ b/hyperscale/core/hooks/types/depends/validator.py @@ -0,0 +1,22 @@ +from typing import Any, Tuple + +from pydantic import BaseModel, validator + +from hyperscale.core.graphs.stages.base.stage import Stage + + +class DependsValidator(BaseModel): + stages: Tuple[Any, ...] + + class Config: + arbitrary_types_allowed = True + + @validator('stages') + def validate_stages(cls, vals): + assert len(vals) > 0 + assert len(vals) == len(set(vals)) + + for val in vals: + assert issubclass(val, Stage) + + return vals diff --git a/hyperscale/core/hooks/types/event/decorator.py b/hyperscale/core/hooks/types/event/decorator.py new file mode 100644 index 0000000..263646d --- /dev/null +++ b/hyperscale/core/hooks/types/event/decorator.py @@ -0,0 +1,29 @@ +import functools + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import EventHookValidator + + +@registrar(HookType.EVENT) +def event( + *names, + order: int=1, + skip: bool=False +): + EventHookValidator( + names=names, + order=order, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/event/event.py b/hyperscale/core/hooks/types/event/event.py new file mode 100644 index 0000000..d3146c2 --- /dev/null +++ b/hyperscale/core/hooks/types/event/event.py @@ -0,0 +1,34 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.event.hook import EventHook + + +class Event(BaseEvent[EventHook]): + + def __init__(self, target: Hook, source: EventHook) -> None: + super( + Event, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.EVENT + + def copy(self): + event = Event( + self.target.copy(), + self.source.copy() + ) + + event.execution_path = list(self.execution_path) + event.previous_map = list(self.previous_map) + event.next_map = list(self.next_map) + event.next_args = defaultdict(dict) + + + return event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/event/hook.py b/hyperscale/core/hooks/types/event/hook.py new file mode 100644 index 0000000..3f66815 --- /dev/null +++ b/hyperscale/core/hooks/types/event/hook.py @@ -0,0 +1,66 @@ +from typing import Any, Awaitable, Callable, Coroutine, Dict, Optional, Tuple + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class EventHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Optional[Tuple[str, ...]], + pre: bool=False, + key: Optional[str]=None, + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.EVENT + ) + + self.names = list(set(names)) + self.pre = pre + self.key = key + self.events: Dict[str, Coroutine] = {} + + async def call(self, **kwargs): + + if self.skip: + return kwargs + + result = await super().call(**{name: value for name, value in kwargs.items() if name in self.params}) + + if isinstance(result, dict): + return { + **kwargs, + **result + } + + return { + **kwargs, + self.shortname: result + } + + def copy(self): + event_hook = EventHook( + self.name, + self.shortname, + self._call, + *self.names, + pre=self.pre, + key=self.key, + order=self.order, + skip=self.skip, + ) + + event_hook.stage = self.stage + + return event_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/event/validator.py b/hyperscale/core/hooks/types/event/validator.py new file mode 100644 index 0000000..ff8d7b8 --- /dev/null +++ b/hyperscale/core/hooks/types/event/validator.py @@ -0,0 +1,13 @@ +from typing import Optional, Tuple +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictBool +) + + +class EventHookValidator(BaseModel): + names: Optional[Tuple[StrictStr, ...]] + order: StrictInt + skip: StrictBool diff --git a/hyperscale/core/hooks/types/internal/decorator.py b/hyperscale/core/hooks/types/internal/decorator.py new file mode 100644 index 0000000..8f03d91 --- /dev/null +++ b/hyperscale/core/hooks/types/internal/decorator.py @@ -0,0 +1,29 @@ +from typing import Callable + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .hook import InternallHook + + +class Internal: + is_internal = True + + def __init__(self) -> None: + pass + + def __call__(self, call: Callable=None) -> InternallHook: + + hook_fullname = call.__qualname__ + stage_name, hook_shortname = hook_fullname.split('.') + + hook = InternallHook( + hook_fullname, + call.__name__, + call, + hook_type=HookType.INTERNAL + ) + + registrar.reserved[stage_name][hook_shortname] = hook + + return call \ No newline at end of file diff --git a/hyperscale/core/hooks/types/internal/hook.py b/hyperscale/core/hooks/types/internal/hook.py new file mode 100644 index 0000000..e10ed43 --- /dev/null +++ b/hyperscale/core/hooks/types/internal/hook.py @@ -0,0 +1,23 @@ +from typing import Any, Awaitable, Callable + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class InternallHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + stage: str = None, + hook_type: HookType=HookType.INTERNAL + ) -> None: + super().__init__( + name, + shortname, + call, + stage=stage, + hook_type=hook_type + ) diff --git a/hyperscale/core/hooks/types/internal/internal_hook.py b/hyperscale/core/hooks/types/internal/internal_hook.py new file mode 100644 index 0000000..e10ed43 --- /dev/null +++ b/hyperscale/core/hooks/types/internal/internal_hook.py @@ -0,0 +1,23 @@ +from typing import Any, Awaitable, Callable + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class InternallHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + stage: str = None, + hook_type: HookType=HookType.INTERNAL + ) -> None: + super().__init__( + name, + shortname, + call, + stage=stage, + hook_type=hook_type + ) diff --git a/hyperscale/core/hooks/types/load/__init__.py b/hyperscale/core/hooks/types/load/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/load/decorator.py b/hyperscale/core/hooks/types/load/decorator.py new file mode 100644 index 0000000..9cc0c68 --- /dev/null +++ b/hyperscale/core/hooks/types/load/decorator.py @@ -0,0 +1,70 @@ +import functools +from typing import Tuple, Union + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.data.connectors.connector import ( + AWSLambdaConnectorConfig, + BigTableConnectorConfig, + CassandraConnectorConfig, + CosmosDBConnectorConfig, + CSVConnectorConfig, + GoogleCloudStorageConnectorConfig, + HARConnectorConfig, + JSONConnectorConfig, + KafkaConnectorConfig, + MongoDBConnectorConfig, + MySQLConnectorConfig, + PostgresConnectorConfig, + RedisConnectorConfig, + S3ConnectorConfig, + SnowflakeConnectorConfig, + SQLiteConnectorConfig, + XMLConnectorConfig, +) + +from .validator import LoadHookValidator + + +@registrar(HookType.LOAD) +def load( + *names: Tuple[str, ...], + loader: Union[ + AWSLambdaConnectorConfig, + BigTableConnectorConfig, + CassandraConnectorConfig, + CosmosDBConnectorConfig, + CSVConnectorConfig, + GoogleCloudStorageConnectorConfig, + HARConnectorConfig, + JSONConnectorConfig, + KafkaConnectorConfig, + MongoDBConnectorConfig, + MySQLConnectorConfig, + PostgresConnectorConfig, + RedisConnectorConfig, + S3ConnectorConfig, + SnowflakeConnectorConfig, + SQLiteConnectorConfig, + XMLConnectorConfig + ]=None, + order: int=1, + skip: bool=False +): + + LoadHookValidator( + names=names, + loader=loader, + order=order, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/load/event.py b/hyperscale/core/hooks/types/load/event.py new file mode 100644 index 0000000..26b6dd1 --- /dev/null +++ b/hyperscale/core/hooks/types/load/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.load.hook import LoadHook + + +class LoadEvent(BaseEvent[LoadHook]): + + def __init__(self, target: Hook, source: LoadHook) -> None: + super( + LoadEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.LOAD + + def copy(self): + load_event = LoadEvent( + self.target.copy(), + self.source.copy() + ) + + load_event.execution_path = list(self.execution_path) + load_event.previous_map = list(self.previous_map) + load_event.next_map = list(self.next_map) + load_event.next_args = defaultdict(dict) + + return load_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/load/hook.py b/hyperscale/core/hooks/types/load/hook.py new file mode 100644 index 0000000..bd2f942 --- /dev/null +++ b/hyperscale/core/hooks/types/load/hook.py @@ -0,0 +1,177 @@ +from typing import Any, Awaitable, Callable, Dict, Tuple, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.action_registry import actions_registry +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.data.connectors.aws_lambda.aws_lambda_connector_config import ( + AWSLambdaConnectorConfig, +) +from hyperscale.data.connectors.bigtable.bigtable_connector_config import ( + BigTableConnectorConfig, +) +from hyperscale.data.connectors.cassandra.cassandra_connector_config import ( + CassandraConnectorConfig, +) +from hyperscale.data.connectors.connector import Connector +from hyperscale.data.connectors.cosmosdb.cosmos_connector_config import ( + CosmosDBConnectorConfig, +) +from hyperscale.data.connectors.csv.csv_connector_config import CSVConnectorConfig +from hyperscale.data.connectors.google_cloud_storage.google_cloud_storage_connector_config import ( + GoogleCloudStorageConnectorConfig, +) +from hyperscale.data.connectors.har.har_connector_config import HARConnectorConfig +from hyperscale.data.connectors.json.json_connector_config import JSONConnectorConfig +from hyperscale.data.connectors.kafka.kafka_connector_config import KafkaConnectorConfig +from hyperscale.data.connectors.mongodb.mongodb_connector_config import ( + MongoDBConnectorConfig, +) +from hyperscale.data.connectors.mysql.mysql_connector_config import MySQLConnectorConfig +from hyperscale.data.connectors.postgres.postgres_connector_config import ( + PostgresConnectorConfig, +) +from hyperscale.data.connectors.redis.redis_connector_config import RedisConnectorConfig +from hyperscale.data.connectors.s3.s3_connector_config import S3ConnectorConfig +from hyperscale.data.connectors.snowflake.snowflake_connector_config import ( + SnowflakeConnectorConfig, +) +from hyperscale.data.connectors.sqlite.sqlite_connector_config import ( + SQLiteConnectorConfig, +) +from hyperscale.data.connectors.xml.xml_connector_config import XMLConnectorConfig + +ActionType = ( + ActionHook, + TaskHook +) + + +def register_loaded_actions( + load_result: Union[Dict[str, Any], Any] + ): + if isinstance(load_result, ActionType): + actions_registry[load_result.name] = load_result + + elif isinstance(load_result, list): + for item in load_result: + if isinstance(item, ActionType): + actions_registry[item.name] = item + + elif isinstance(load_result, dict): + for item in load_result.values(): + if isinstance(item, ActionType): + actions_registry[item.name] = item + + +class LoadHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Tuple[str, ...], + loader: Union[ + AWSLambdaConnectorConfig, + BigTableConnectorConfig, + CassandraConnectorConfig, + CosmosDBConnectorConfig, + CSVConnectorConfig, + GoogleCloudStorageConnectorConfig, + HARConnectorConfig, + JSONConnectorConfig, + KafkaConnectorConfig, + MongoDBConnectorConfig, + MySQLConnectorConfig, + PostgresConnectorConfig, + RedisConnectorConfig, + S3ConnectorConfig, + SnowflakeConnectorConfig, + SQLiteConnectorConfig, + XMLConnectorConfig + + ]=None, + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.LOAD + ) + + self.names = list(set(names)) + self.loader_config = loader + self.parser_config: Union[Config, None] = None + self.connector: Union[Connector, None] = Connector( + self.stage, + self.loader_config, + self.parser_config + ) + + self.loaded = False + + async def call(self, **kwargs) -> None: + + condition_result = await self._execute_call(**kwargs) + + if self.skip or self.loaded or condition_result is False: + return kwargs + + if self.connector.connected is False: + self.connector.selected.stage = self.stage + self.connector.selected.parser_config = self.parser_config + + await self.connector.connect() + + hook_args = { + name: value for name, value in kwargs.items() if name in self.params + } + + load_result: Union[Dict[str, Any], Any] = await self._call(**{ + **hook_args, + 'connector': self.connector + }) + + await self.connector.close() + + self.loaded = True + + if isinstance(load_result, dict): + + for value in load_result.values(): + register_loaded_actions(value) + + return { + **kwargs, + **load_result + } + + register_loaded_actions(load_result) + + return { + **kwargs, + self.shortname: load_result + } + + def copy(self): + load_hook = LoadHook( + self.name, + self.shortname, + self._call, + *self.names, + loader=self.loader_config, + order=self.order, + skip=self.skip + ) + + load_hook.stage = self.stage + load_hook.parser_config = self.parser_config + + return load_hook diff --git a/hyperscale/core/hooks/types/load/validator.py b/hyperscale/core/hooks/types/load/validator.py new file mode 100644 index 0000000..d218fac --- /dev/null +++ b/hyperscale/core/hooks/types/load/validator.py @@ -0,0 +1,25 @@ +from typing import Optional, Tuple, Union + +from pydantic import BaseModel, StrictBool, StrictInt, StrictStr + +from hyperscale.data.connectors.aws_lambda.aws_lambda_connector_config import ( + AWSLambdaConnectorConfig, +) +from hyperscale.data.connectors.bigtable.bigtable_connector_config import ( + BigTableConnectorConfig, +) + + +class LoadHookValidator(BaseModel): + names: Optional[Tuple[StrictStr, ...]] + loader: Union[ + AWSLambdaConnectorConfig, + BigTableConnectorConfig, + ] + order: StrictInt + skip: StrictBool + + class Config: + arbitrary_types_allowed=True + + diff --git a/hyperscale/core/hooks/types/metric/decorator.py b/hyperscale/core/hooks/types/metric/decorator.py new file mode 100644 index 0000000..b6e3076 --- /dev/null +++ b/hyperscale/core/hooks/types/metric/decorator.py @@ -0,0 +1,33 @@ +import functools +from typing import Optional + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import MetricHookValidator + + +@registrar(HookType.METRIC) +def metric( + metric_type: str, + group: Optional[str]='user_metrics', + order: int=1, + skip: bool=False +): + + MetricHookValidator( + metric_type=metric_type, + group=group, + order=order, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/metric/event.py b/hyperscale/core/hooks/types/metric/event.py new file mode 100644 index 0000000..5df22f2 --- /dev/null +++ b/hyperscale/core/hooks/types/metric/event.py @@ -0,0 +1,34 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook + +from .hook import MetricHook + + +class MetricEvent(BaseEvent[MetricHook]): + + def __init__(self, target: Hook, source: MetricHook) -> None: + super( + MetricEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.METRIC + + def copy(self): + metric_event = MetricEvent( + self.target.copy(), + self.source.copy() + ) + + metric_event.execution_path = list(self.execution_path) + metric_event.previous_map = list(self.previous_map) + metric_event.next_map = list(self.next_map) + metric_event.next_args = defaultdict(dict) + + return metric_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/metric/hook.py b/hyperscale/core/hooks/types/metric/hook.py new file mode 100644 index 0000000..47d32ac --- /dev/null +++ b/hyperscale/core/hooks/types/metric/hook.py @@ -0,0 +1,186 @@ +import asyncio +import functools +import signal +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Awaitable, Callable, Dict, List, Optional + +import dill +import psutil + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.reporting.metric.custom_metric import CustomMetric + +RawResultsSet = Dict[str, ResultsSet] +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor + ): + try: + executor.shutdown(wait=False, cancel_futures=True) + + except BrokenPipeError: + pass + + except RuntimeError: + pass + +class MetricHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + metric_type: str, + group: Optional[str] = None, + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.METRIC + ) + + self.names = [] + self.metric_type = metric_type + self.group = group + self.order = order + self.executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=True)) + self.loop: asyncio.AbstractEventLoop = None + + async def call(self, **kwargs): + + if self.skip: + return kwargs + + results: RawResultsSet = self.context['analyze_stage_raw_results'] + self.loop = asyncio.get_running_loop() + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self.loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self.executor + ) + ) + + + args_by_hook = {} + + if 'results' in self.params: + stage_results = await self.loop.run_in_executor( + self.executor, + functools.partial( + self._generate_deserialized_results, + results + ) + ) + + for result_set_name, results_set in stage_results.items(): + args_by_hook[result_set_name] = { + **kwargs, + 'results': results_set + } + + custom_metric_results: List[Dict[str, CustomMetric]] = await asyncio.gather(*[ + asyncio.create_task( + self._calculate_stage_custom_metric( + args_by_hook[set_name], + set_name + ) + ) for set_name in args_by_hook.keys() + ]) + + metric_results = {} + + for custom_metric in custom_metric_results: + metric_results.update(custom_metric) + + self.executor.shutdown(wait=False, cancel_futures=True) + + return { + **kwargs, + **metric_results + } + + async def _calculate_stage_custom_metric( + self, + hook_kwargs: Dict[str, Any], + set_name: Optional[str]=None + ) -> Dict[str, CustomMetric]: + + metric_result = await self._call(**{ + name: value for name, value in hook_kwargs.items() if name in self.params + }) + + if isinstance(metric_result, dict): + for metric_name, metric_value in metric_result.items(): + + assert isinstance(metric_name, str) + assert isinstance(metric_value, (float, int)) + + metric_fullname = f'{set_name}_{metric_name}' + + custom_metric = CustomMetric( + metric_fullname, + metric_name, + metric_value, + metric_group=set_name, + metric_type=self.metric_type + ) + + metric_result[metric_name] = custom_metric + self.context[metric_name] = custom_metric + + return metric_result + + assert isinstance(metric_result, (float, int)) + + metric_fullname = f'{set_name}_{self.shortname}' + custom_metric = CustomMetric( + metric_fullname, + self.shortname, + metric_result, + metric_group=set_name, + metric_type=self.metric_type + + ) + + self.context[metric_fullname] = custom_metric + + return { + metric_fullname: custom_metric + } + + def _generate_deserialized_results(self, results: RawResultsSet) -> Dict[str, List[BaseResult]]: + stage_results: Dict[str, List[BaseResult]] = defaultdict(list) + + for results_set in results.values(): + for result in results_set.results: + stage_result: BaseResult = dill.loads(result) + stage_results[stage_result.name].append(stage_result) + + return stage_results + + + def copy(self): + metric_hook = MetricHook( + self.name, + self.shortname, + self._call, + self.group, + order=self.order, + skip=self.skip, + ) + + metric_hook.stage = self.stage + + return metric_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/metric/validator.py b/hyperscale/core/hooks/types/metric/validator.py new file mode 100644 index 0000000..83ed226 --- /dev/null +++ b/hyperscale/core/hooks/types/metric/validator.py @@ -0,0 +1,28 @@ +from pydantic import ( + BaseModel, + Field, + StrictStr, + StrictBool, + validator +) + + +class MetricHookValidator(BaseModel): + metric_type: StrictStr + group: StrictStr=Field(..., min_length=1) + skip: StrictBool + + @validator('metric_type') + def validate_names(cls, val): + + valid_metric_types = [ + "count", + "rate", + "distribution", + "sample" + ] + + assert val in valid_metric_types + + return val + diff --git a/hyperscale/core/hooks/types/save/__init__.py b/hyperscale/core/hooks/types/save/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core/hooks/types/save/decorator.py b/hyperscale/core/hooks/types/save/decorator.py new file mode 100644 index 0000000..ccad4ac --- /dev/null +++ b/hyperscale/core/hooks/types/save/decorator.py @@ -0,0 +1,32 @@ +import functools +from typing import Tuple + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import SaveHookValidator + + +@registrar(HookType.SAVE) +def save( + *names: Tuple[str, ...], + save_path: str=None, + order: int=1, + skip: bool=False +): + SaveHookValidator( + names=names, + save_path=save_path, + order=order, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/save/event.py b/hyperscale/core/hooks/types/save/event.py new file mode 100644 index 0000000..069ba17 --- /dev/null +++ b/hyperscale/core/hooks/types/save/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.save.hook import SaveHook + + +class SaveEvent(BaseEvent[SaveHook]): + + def __init__(self, target: Hook, source: SaveHook) -> None: + super( + SaveEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.SAVE + + def copy(self): + save_event = SaveEvent( + self.target.copy(), + self.source.copy() + ) + + save_event.execution_path = list(self.execution_path) + save_event.previous_map = list(self.previous_map) + save_event.next_map = list(self.next_map) + save_event.next_args = defaultdict(dict) + + return save_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/save/hook.py b/hyperscale/core/hooks/types/save/hook.py new file mode 100644 index 0000000..2b4d3fe --- /dev/null +++ b/hyperscale/core/hooks/types/save/hook.py @@ -0,0 +1,148 @@ +from typing import Any, Awaitable, Callable, Dict, Tuple, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.action_registry import actions_registry +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.data.connectors.aws_lambda.aws_lambda_connector_config import ( + AWSLambdaConnectorConfig, +) +from hyperscale.data.connectors.bigtable.bigtable_connector_config import ( + BigTableConnectorConfig, +) +from hyperscale.data.connectors.cassandra.cassandra_connector_config import ( + CassandraConnectorConfig, +) +from hyperscale.data.connectors.connector import Connector +from hyperscale.data.connectors.cosmosdb.cosmos_connector_config import ( + CosmosDBConnectorConfig, +) +from hyperscale.data.connectors.csv.csv_connector_config import CSVConnectorConfig +from hyperscale.data.connectors.google_cloud_storage.google_cloud_storage_connector_config import ( + GoogleCloudStorageConnectorConfig, +) +from hyperscale.data.connectors.har.har_connector_config import HARConnectorConfig +from hyperscale.data.connectors.json.json_connector_config import JSONConnectorConfig +from hyperscale.data.connectors.kafka.kafka_connector_config import KafkaConnectorConfig +from hyperscale.data.connectors.mongodb.mongodb_connector_config import ( + MongoDBConnectorConfig, +) +from hyperscale.data.connectors.mysql.mysql_connector_config import MySQLConnectorConfig +from hyperscale.data.connectors.postgres.postgres_connector_config import ( + PostgresConnectorConfig, +) +from hyperscale.data.connectors.redis.redis_connector_config import RedisConnectorConfig +from hyperscale.data.connectors.s3.s3_connector_config import S3ConnectorConfig +from hyperscale.data.connectors.snowflake.snowflake_connector_config import ( + SnowflakeConnectorConfig, +) +from hyperscale.data.connectors.sqlite.sqlite_connector_config import ( + SQLiteConnectorConfig, +) +from hyperscale.data.connectors.xml.xml_connector_config import XMLConnectorConfig + + +class SaveHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Tuple[str, ...], + loader: Union[ + AWSLambdaConnectorConfig, + BigTableConnectorConfig, + CassandraConnectorConfig, + CosmosDBConnectorConfig, + CSVConnectorConfig, + GoogleCloudStorageConnectorConfig, + HARConnectorConfig, + JSONConnectorConfig, + KafkaConnectorConfig, + MongoDBConnectorConfig, + MySQLConnectorConfig, + PostgresConnectorConfig, + RedisConnectorConfig, + S3ConnectorConfig, + SnowflakeConnectorConfig, + SQLiteConnectorConfig, + XMLConnectorConfig + + ]=None, + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.SAVE + ) + + self.names = list(set(names)) + self.loader_config = loader + self.parser_config: Union[Config, None] = None + self.connector: Union[Connector, None] = Connector( + self.stage, + self.loader_config, + self.parser_config + ) + + self.saved = False + + async def call(self, **kwargs) -> None: + + condition_result = await self._execute_call(**kwargs) + + if self.skip or self.saved or condition_result is False: + return kwargs + + if self.connector.connected is False: + self.connector.selected.stage = self.stage + self.connector.selected.parser_config = self.parser_config + + await self.connector.connect() + + hook_args = { + name: value for name, value in kwargs.items() if name in self.params + } + + load_result: Union[Dict[str, Any], Any] = await self._call(**{ + **hook_args, + 'actions': actions_registry.actions(), + 'connector': self.connector + }) + + await self.connector.close() + + + self.saved = True + + if isinstance(load_result, dict): + return { + **kwargs, + **load_result + } + + return { + **kwargs, + self.shortname: load_result + } + + def copy(self): + save_hook = SaveHook( + self.name, + self.shortname, + self._call, + *self.names, + loader=self.loader_config, + order=self.order, + skip=self.skip, + ) + + save_hook.stage = self.stage + + return save_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/save/validator.py b/hyperscale/core/hooks/types/save/validator.py new file mode 100644 index 0000000..d8a3d7d --- /dev/null +++ b/hyperscale/core/hooks/types/save/validator.py @@ -0,0 +1,15 @@ +from typing import Tuple, Optional +from pydantic import ( + BaseModel, + Field, + StrictStr, + StrictInt, + StrictBool +) + + +class SaveHookValidator(BaseModel): + names: Optional[Tuple[StrictStr, ...]] + save_path: StrictStr=Field(..., min_length=1) + order: StrictInt + skip: StrictBool diff --git a/hyperscale/core/hooks/types/task/decorator.py b/hyperscale/core/hooks/types/task/decorator.py new file mode 100644 index 0000000..ee39e25 --- /dev/null +++ b/hyperscale/core/hooks/types/task/decorator.py @@ -0,0 +1,37 @@ +import functools +from typing import Dict, Tuple, Union + +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import TaskHookValidator + + +@registrar(HookType.TASK) +def task( + *names: Tuple[str, ...], + weight: int=1, + order: int=1, + skip: bool=False, + metadata: Dict[str, Union[str, int]]={} +): + + TaskHookValidator( + names=names, + weight=weight, + order=order, + skip=skip, + metadata=metadata + ) + + def wrapper(func) -> Hook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/task/event.py b/hyperscale/core/hooks/types/task/event.py new file mode 100644 index 0000000..087b48a --- /dev/null +++ b/hyperscale/core/hooks/types/task/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.task.hook import TaskHook + + +class TaskEvent(BaseEvent[TaskHook]): + + def __init__(self, target: Hook, source: TaskHook) -> None: + super( + TaskEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.TASK + + def copy(self): + task_event = TaskEvent( + self.target.copy(), + self.source.copy() + ) + + task_event.execution_path = list(self.execution_path) + task_event.previous_map = list(self.previous_map) + task_event.next_map = list(self.next_map) + task_event.next_args = defaultdict(dict) + + return task_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/task/hook.py b/hyperscale/core/hooks/types/task/hook.py new file mode 100644 index 0000000..2cdbe0a --- /dev/null +++ b/hyperscale/core/hooks/types/task/hook.py @@ -0,0 +1,70 @@ +from typing import Any, Awaitable, Callable, Dict, List, Tuple, Type, Union + +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.base_engine import BaseEngine +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_metadata import HookMetadata +from hyperscale.core.hooks.types.base.hook_type import HookType + + +class TaskHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Tuple[str, ...], + weight: int=1, + order: int=1, + skip: bool=False, + metadata: Dict[str, Union[str, int]]={} + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.TASK + ) + + self.names = list(set(names)) + self.call: Type[self._call] = self._call + self.session: BaseEngine = None + self.action: BaseAction = None + self.order = order + self.before: List[Any] = [] + self.after: List[Any] = [] + self.is_notifier = False + self.is_listener = False + self.checks: List[Any] = [] + self.channels: List[Any] = [] + self.notifiers: List[Any] = [] + self.listeners: List[Any] = [] + self.metadata = HookMetadata( + weight=weight, + order=order, + **metadata + ) + + def copy(self): + task_hook = TaskHook( + self.name, + self.shortname, + self._call, + weight=self.metadata.weight, + order=self.order, + skip=self.skip, + metadata={ + **self.metadata.copy() + } + ) + + task_hook.checks = list(self.checks) + task_hook.stage = self.stage + + return task_hook + + async def call(self, *args, **kwargs): + return await self._call(*args, **kwargs) diff --git a/hyperscale/core/hooks/types/task/validator.py b/hyperscale/core/hooks/types/task/validator.py new file mode 100644 index 0000000..8ad7f1b --- /dev/null +++ b/hyperscale/core/hooks/types/task/validator.py @@ -0,0 +1,26 @@ +from typing import Optional, Dict, Union, Tuple +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBool, + validator +) + + +class TaskHookValidator(BaseModel): + names: Tuple[StrictStr, ...] + weight: StrictInt + order: StrictInt + skip: StrictBool + metadata: Optional[Dict[str, Union[StrictStr, StrictInt, StrictFloat]]] + + class Config: + arbitrary_types_allowed = True + + @validator('weight', 'order') + def validate_weight_and_order(cls, val): + assert val > 0 + + return val diff --git a/hyperscale/core/hooks/types/transform/decorator.py b/hyperscale/core/hooks/types/transform/decorator.py new file mode 100644 index 0000000..814adbd --- /dev/null +++ b/hyperscale/core/hooks/types/transform/decorator.py @@ -0,0 +1,30 @@ +import functools + +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.registrar import registrar + +from .validator import TransformHookValidator + + +@registrar(HookType.TRANSFORM) +def transform( + *names, + order: int=1, + skip: bool=False +): + + TransformHookValidator( + names=names, + order=order, + skip=skip + ) + + def wrapper(func): + + @functools.wraps(func) + def decorator(*args, **kwargs): + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/core/hooks/types/transform/event.py b/hyperscale/core/hooks/types/transform/event.py new file mode 100644 index 0000000..2f54317 --- /dev/null +++ b/hyperscale/core/hooks/types/transform/event.py @@ -0,0 +1,33 @@ +from collections import defaultdict + +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.core.hooks.types.base.event_types import EventType +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.transform.hook import TransformHook + + +class TransformEvent(BaseEvent[TransformHook]): + + def __init__(self, target: Hook, source: TransformHook) -> None: + super( + TransformEvent, + self + ).__init__( + target, + source + ) + + self.event_type = EventType.TRANSFORM + + def copy(self): + transform_event = TransformEvent( + self.target.copy(), + self.source.copy() + ) + + transform_event.execution_path = list(self.execution_path) + transform_event.previous_map = list(self.previous_map) + transform_event.next_map = list(self.next_map) + transform_event.next_args = defaultdict(dict) + + return transform_event \ No newline at end of file diff --git a/hyperscale/core/hooks/types/transform/hook.py b/hyperscale/core/hooks/types/transform/hook.py new file mode 100644 index 0000000..c172649 --- /dev/null +++ b/hyperscale/core/hooks/types/transform/hook.py @@ -0,0 +1,117 @@ +import asyncio +from collections import defaultdict +from typing import Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Tuple + +from hyperscale.core.engines.client.time_parser import TimeParser +from hyperscale.core.hooks.types.base.hook import Hook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.base.simple_context import SimpleContext + + +class TransformHook(Hook): + + def __init__( + self, + name: str, + shortname: str, + call: Callable[..., Awaitable[Any]], + *names: Optional[Tuple[str, ...]], + timeout: Optional[float]='1m', + pre: bool=False, + order: int=1, + skip: bool=False + ) -> None: + super().__init__( + name, + shortname, + call, + order=order, + skip=skip, + hook_type=HookType.TRANSFORM + ) + + parser = TimeParser(time_amount=timeout) + + self.timeout = parser.time + self.names = list(set(names)) + self.pre = pre + self.events: Dict[str, Coroutine] = {} + self.context: Optional[SimpleContext] = None + self.conditions: Optional[List[Callable[..., bool]]] = [] + self._timeout_as_string = timeout + + async def call(self, **kwargs): + + if self.skip: + return kwargs + + batchable_args: List[Dict[str, Any]] = [] + for name, arg in kwargs.items(): + if isinstance(arg, (list, tuple)): + batchable_args.extend([ + {**kwargs, name: item} for item in arg + ]) + + if len(batchable_args) > 0: + + result = await asyncio.wait_for( + asyncio.gather(*[ + asyncio.create_task( + self._call(**{name: value for name, value in call_kwargs.items() if name in self.params}) + ) for call_kwargs in batchable_args if ( + await self._execute_call(**call_kwargs) is True + ) + ]), + timeout=self.timeout + ) + + aggregated_transformm = defaultdict(list) + + for data_item in result: + if isinstance(data_item, dict): + for name, value in data_item.items(): + if isinstance(value, (list, tuple)): + aggregated_transformm[name].extend(value) + + else: + aggregated_transformm[name].append(value) + + elif data_item is not None: + aggregated_transformm[self.shortname].append(data_item) + + + return { + **kwargs, + **dict(aggregated_transformm) + } + + else: + + result = await self._call(**{name: value for name, value in kwargs.items() if name in self.params}) + + if isinstance(result, dict): + return { + **kwargs, + **result + } + + return { + **kwargs, + self.shortname: result + } + + def copy(self): + transform_hook = TransformHook( + self.name, + self.shortname, + self._call, + *self.names, + timeout=self._timeout_as_string, + pre=self.pre, + order=self.order, + skip=self.skip, + ) + + transform_hook.stage = self.stage + + return transform_hook \ No newline at end of file diff --git a/hyperscale/core/hooks/types/transform/validator.py b/hyperscale/core/hooks/types/transform/validator.py new file mode 100644 index 0000000..371bd88 --- /dev/null +++ b/hyperscale/core/hooks/types/transform/validator.py @@ -0,0 +1,20 @@ +from typing import Tuple, Optional +from pydantic import ( + BaseModel, + validator, + StrictStr, + StrictInt, + StrictBool +) + + +class TransformHookValidator(BaseModel): + names: Optional[Tuple[StrictStr, ...]] + order: Optional[StrictInt] + skip: StrictBool + + @validator('names') + def validate_names(cls, vals): + assert len(vals) == len(set(vals)) + + return vals diff --git a/hyperscale/core/personas/__init__.py b/hyperscale/core/personas/__init__.py new file mode 100644 index 0000000..0fb71f8 --- /dev/null +++ b/hyperscale/core/personas/__init__.py @@ -0,0 +1 @@ +from .persona_registry import get_persona \ No newline at end of file diff --git a/hyperscale/core/personas/batching/__init__.py b/hyperscale/core/personas/batching/__init__.py new file mode 100644 index 0000000..83f635a --- /dev/null +++ b/hyperscale/core/personas/batching/__init__.py @@ -0,0 +1 @@ +from .batch import Batch \ No newline at end of file diff --git a/hyperscale/core/personas/batching/batch.py b/hyperscale/core/personas/batching/batch.py new file mode 100644 index 0000000..2bf663c --- /dev/null +++ b/hyperscale/core/personas/batching/batch.py @@ -0,0 +1,57 @@ +from typing import Dict, Union + +from hyperscale.core.engines.client.config import Config + +from .param_type import ParamType + + +class Batch: + + __slots__ = ( + 'gradient', + 'size', + 'interval', + 'deferred' + ) + + def __init__(self, config: Config) -> None: + self.gradient = config.batch_gradient + + self.size = config.batch_size + + self.interval = config.batch_interval + + self.deferred = [] + + def to_params(self): + return { + 'batch_gradient': { + 'value': self.gradient, + 'type': ParamType.FLOAT + }, + 'batch_size': { + 'value': self.size, + 'type': ParamType.INTEGER + }, + 'batch_interval': { + 'value': self.size, + 'type': ParamType.FLOAT + } + } + + def update(self, values: Dict[str, Union[int, float]]): + gradient = values.get('batch_gradient') + if gradient is None: + gradient = self.gradient + + size = values.get('batch_size') + if size is None: + size = self.size + + interval = values.get('batch_interval') + if interval is None: + interval = self.interval + + self.gradient = gradient + self.size = size + self.interval = interval diff --git a/hyperscale/core/personas/batching/param_type.py b/hyperscale/core/personas/batching/param_type.py new file mode 100644 index 0000000..3515af3 --- /dev/null +++ b/hyperscale/core/personas/batching/param_type.py @@ -0,0 +1,5 @@ +from enum import Enum + +class ParamType(Enum): + FLOAT='FLOAT' + INTEGER='INTEGER' \ No newline at end of file diff --git a/hyperscale/core/personas/persona_registry.py b/hyperscale/core/personas/persona_registry.py new file mode 100644 index 0000000..c2ed963 --- /dev/null +++ b/hyperscale/core/personas/persona_registry.py @@ -0,0 +1,32 @@ +from hyperscale.core.engines.client.config import Config + +from .types import PersonaTypes +from .types.approximate_distribution import ApproximateDistributionPersona +from .types.batched_persona import BatchedPersona +from .types.constant_arrival_rate_persona import ConstantArrivalPersona +from .types.constant_spawn_rate_persona import ConstantSpawnPersona +from .types.cyclic_nowait_persona import CyclicNoWaitPersona +from .types.default_persona import DefaultPersona +from .types.ramped_interval_persona import RampedIntervalPersona +from .types.ramped_persona import RampedPersona +from .types.sequenced_persona import SequencedPersona +from .types.weighted_selection_persona import WeightedSelectionPersona + +registered_personas = { + PersonaTypes.APPROXIMATE_DISTRIBUTION: lambda config: ApproximateDistributionPersona(config), + PersonaTypes.DEFAULT: lambda config: DefaultPersona(config), + PersonaTypes.BATCHED: lambda config: BatchedPersona(config), + PersonaTypes.RAMPED: lambda config: RampedPersona(config), + PersonaTypes.RAMPED_INTERVAL: lambda config: RampedIntervalPersona(config), + PersonaTypes.CONSTANT_ARRIVAL: lambda config: ConstantArrivalPersona(config), + PersonaTypes.CONSTANT_SPAWN: lambda config: ConstantSpawnPersona(config), + PersonaTypes.SEQUENCE: lambda config: SequencedPersona(config), + PersonaTypes.WEIGHTED: lambda config: WeightedSelectionPersona(config), + PersonaTypes.NO_WAIT: lambda config: CyclicNoWaitPersona(config) +} + +def get_persona(config: Config): + return registered_personas.get( + config.persona_type, + DefaultPersona + )(config) diff --git a/hyperscale/core/personas/streaming/__init__.py b/hyperscale/core/personas/streaming/__init__.py new file mode 100644 index 0000000..8084978 --- /dev/null +++ b/hyperscale/core/personas/streaming/__init__.py @@ -0,0 +1,2 @@ +from .stream_analytics import StreamAnalytics +from .stream import Stream \ No newline at end of file diff --git a/hyperscale/core/personas/streaming/stream.py b/hyperscale/core/personas/streaming/stream.py new file mode 100644 index 0000000..eb3eea9 --- /dev/null +++ b/hyperscale/core/personas/streaming/stream.py @@ -0,0 +1,98 @@ +from typing import Dict, List, Union + +import numpy + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.versioning.flags.types.unstable.flag import unstable + + +@unstable +class Stream: + + __slots__ = ( + 'last_completed', + 'last_batch_size', + 'action', + 'completed' + ) + + def __init__(self) -> None: + self.last_completed = 0 + self.last_batch_size = 0 + self.action = None + self.completed: List[BaseResult] = [] + + @property + def completed_count(self): + return len(self.completed) + + @property + def succeeded(self) -> int: + return len([ + result for result in self.completed if result.error is None + ]) + + @property + def failed(self) -> int: + succeeded = self.succeeded + return len(self.completed) - succeeded + + @property + def timings(self) -> List[Dict[str, float]]: + + timings = { + 'total': [], + 'waiting': [], + 'connecting': [], + 'writing': [], + 'reading': [] + } + + for result in self.completed: + timings['total'].append(result.complete - result.start) + timings['waiting'].append(result.start - result.wait_start) + timings['connecting'].append(result.connect_end - result.start) + timings['writing'].append(result.write_end - result.connect_end) + timings['reading'].append(result.complete - result.write_end) + + stream_timings = {} + + for timing_group_name, timing_group in timings.items(): + if len(timing_group) > 0: + stream_timings[timing_group_name] = { + 'maximum': max(timing_group), + 'minimum': min(timing_group), + 'median': numpy.median(timing_group), + 'mean': numpy.mean(timing_group), + 'stdev': numpy.std(timing_group), + 'variance': numpy.var(timing_group) + } + + else: + stream_timings[timing_group_name] = { + 'maximum': 0, + 'minimum': 0, + 'median': 0, + 'mean': 0, + 'stdev': 0, + 'variance': 0 + } + + + + return stream_timings + + + async def execute_action(self, hook: Union[ActionHook, TaskHook]): + try: + result: BaseResult = await hook.session.execute_prepared_request( + hook.action + ) + except RuntimeError as runtime_error: + result = runtime_error + + self.completed.append(result) + + return result \ No newline at end of file diff --git a/hyperscale/core/personas/streaming/stream_analytics.py b/hyperscale/core/personas/streaming/stream_analytics.py new file mode 100644 index 0000000..7b32760 --- /dev/null +++ b/hyperscale/core/personas/streaming/stream_analytics.py @@ -0,0 +1,42 @@ +from typing import List, Dict +from .stream import Stream + + +class StreamAnalytics: + + __slots__ = ( + 'interval_completion_rates', + 'interval_timings', + 'interval_completed_counts', + 'interval_succeeded_counts', + 'interval_failed_counts', + 'interval_batch_timings' + ) + + def __init__(self) -> None: + self.interval_completion_rates: List[float] = [] + self.interval_timings: List[Dict[str, float]] = [] + self.interval_completed_counts: List[int] = [] + self.interval_succeeded_counts: List[int] = [] + self.interval_failed_counts: List[int] = [] + self.interval_batch_timings: List[float] = [] + + def add( + self, + stream: Stream, + batch_elapsed: float + ): + + completed_count = len(stream.completed) + self.interval_completion_rates.append( + completed_count/batch_elapsed + ) + + self.interval_completed_counts.append(completed_count) + self.interval_succeeded_counts.append(stream.succeeded) + self.interval_failed_counts.append(stream.failed) + self.interval_timings.append(stream.timings) + self.interval_batch_timings.append(batch_elapsed) + + + diff --git a/hyperscale/core/personas/types/__init__.py b/hyperscale/core/personas/types/__init__.py new file mode 100644 index 0000000..c5be58b --- /dev/null +++ b/hyperscale/core/personas/types/__init__.py @@ -0,0 +1 @@ +from .types import PersonaTypes, PersonaTypesMap \ No newline at end of file diff --git a/hyperscale/core/personas/types/approximate_distribution/__init__.py b/hyperscale/core/personas/types/approximate_distribution/__init__.py new file mode 100644 index 0000000..d0d2c71 --- /dev/null +++ b/hyperscale/core/personas/types/approximate_distribution/__init__.py @@ -0,0 +1 @@ +from .approximate_distribution_persona import ApproximateDistributionPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/approximate_distribution/approximate_distribution_persona.py b/hyperscale/core/personas/types/approximate_distribution/approximate_distribution_persona.py new file mode 100644 index 0000000..5123c30 --- /dev/null +++ b/hyperscale/core/personas/types/approximate_distribution/approximate_distribution_persona.py @@ -0,0 +1,124 @@ +import asyncio +import math +import time +import uuid +from typing import List + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.types.default_persona.default_persona import ( + DefaultPersona, +) +from hyperscale.core.personas.types.types import PersonaTypes +from hyperscale.versioning.flags.types.unstable.flag import unstable + + +@unstable +class ApproximateDistributionPersona(DefaultPersona): + + __slots__ = ( + 'metadata_string', + 'persona_id', + 'logger', + 'type', + 'workers', + 'actions', + '_hooks', + 'batch', + '_stream', + 'total_time', + 'duration', + 'total_actions', + 'total_elapsed', + 'start', + 'end', + 'completed_actions', + 'pending_actions', + 'completed_time', + 'run_timer', + 'actions_count', + 'graceful_stop', + 'is_timed', + '_stream_thread', + '_loop', + 'current_action_idx', + 'optimized_params', + '_executor', + 'stream', + 'execution_metrics', + 'stream_reporter_configs', + 'stream_reporters', + 'stage_name', + 'distribution', + 'collection_interval' + ) + + def __init__(self, config: Config): + super().__init__(config) + + self.distribution: List[float] = config.experiment.get('distribution') + self.collection_interval = config.experiment.get('interval_duration') + self.persona_id = str(uuid.uuid4()) + self.type = PersonaTypes.APPROXIMATE_DISTRIBUTION + + async def generator(self, total_time): + elapsed = 0 + idx = 0 + action_idx = 0 + current_interval_idx = 0 + + distribution = self.distribution + + intervals_count = len(distribution) + time_interval_amount = math.floor(total_time/intervals_count) + + interval_periods = [ + time_interval_amount for _ in distribution + ] + + remainder_period = total_time%intervals_count + if remainder_period > 0: + interval_periods[-1] += remainder_period + + + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False) * 2)/self.workers) + + interval_period = interval_periods[current_interval_idx] + interval_time_limit = interval_period + distribution_batch_size = distribution[current_interval_idx] + + for action in self._hooks: + await action.session.set_pool(distribution_batch_size) + + start = time.time() + while elapsed < total_time: + yield action_idx + + await asyncio.sleep(0) + elapsed = time.time() - start + idx += 1 + + if idx%self.batch.size == 0: + await asyncio.sleep(self.batch.interval) + + action_idx = (action_idx + 1)%self.actions_count + + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass + + if elapsed >= interval_time_limit and elapsed < total_time: + + current_interval_idx += 1 + interval_time_limit += interval_periods[current_interval_idx] + distribution_batch_size = distribution[current_interval_idx] + + for action in self._hooks: + await action.session.set_pool(distribution_batch_size) diff --git a/hyperscale/core/personas/types/batched_persona/__init__.py b/hyperscale/core/personas/types/batched_persona/__init__.py new file mode 100644 index 0000000..a9c474c --- /dev/null +++ b/hyperscale/core/personas/types/batched_persona/__init__.py @@ -0,0 +1 @@ +from .batched_persona import BatchedPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/batched_persona/batched_persona.py b/hyperscale/core/personas/types/batched_persona/batched_persona.py new file mode 100644 index 0000000..55d5cec --- /dev/null +++ b/hyperscale/core/personas/types/batched_persona/batched_persona.py @@ -0,0 +1,51 @@ +import asyncio +import math +import time +import uuid + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.core.personas.types.types import PersonaTypes + + +class BatchedPersona(DefaultPersona): + + def __init__(self, config: Config): + super().__init__(config) + + self.persona_id = str(uuid.uuid4()) + + self.type = PersonaTypes.BATCHED + + async def generator(self, total_time): + elapsed = 0 + idx = 0 + action_idx = 0 + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False) * 2)/self.workers) + + start = time.time() + while elapsed < total_time: + yield action_idx + + await asyncio.sleep(0) + elapsed = time.time() - start + idx += 1 + + if idx%self.batch.size == 0: + action_idx = (action_idx + 1)%self.actions_count + await asyncio.sleep(self.batch.interval) + + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass + + + self.start = start diff --git a/hyperscale/core/personas/types/constant_arrival_rate_persona/__init__.py b/hyperscale/core/personas/types/constant_arrival_rate_persona/__init__.py new file mode 100644 index 0000000..1428277 --- /dev/null +++ b/hyperscale/core/personas/types/constant_arrival_rate_persona/__init__.py @@ -0,0 +1 @@ +from .constant_arrival_rate_persona import ConstantArrivalPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/constant_arrival_rate_persona/constant_arrival_rate_persona.py b/hyperscale/core/personas/types/constant_arrival_rate_persona/constant_arrival_rate_persona.py new file mode 100644 index 0000000..63ec509 --- /dev/null +++ b/hyperscale/core/personas/types/constant_arrival_rate_persona/constant_arrival_rate_persona.py @@ -0,0 +1,170 @@ +import asyncio +import math +import time +import uuid + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.streaming.stream import Stream +from hyperscale.core.personas.types.default_persona.default_persona import ( + DefaultPersona, + cancel_pending, +) +from hyperscale.core.personas.types.types import PersonaTypes + + +class ConstantArrivalPersona(DefaultPersona): + + def __init__(self, config: Config): + super().__init__(config) + + self.persona_id = str(uuid.uuid4()) + + self.stream = Stream() + self.type = PersonaTypes.CONSTANT_ARRIVAL + + async def execute(self): + hooks = self._hooks + hook_names = ', '.join([ + hook.name for hook in hooks + ]) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Executing {self.actions_count} Hooks: {hook_names}') + + total_time = self.total_time + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Executing for a total of - {total_time} - seconds') + loop = asyncio.get_running_loop() + + if self._stream: + + for reporter in self.stream_reporters: + await reporter.connect() + reporter.logger.filesystem.aio['hyperscale.reporting'].logger_enabled = False + reporter.selected_reporter.logger.filesystem.aio['hyperscale.reporting'].logger_enabled = False + + await self.start_stream() + + self.start = time.monotonic() + completed, pending = await asyncio.wait([ + loop.create_task( + self.stream.execute_action( + hooks[action_idx] + ) + ) async for action_idx in self.generator(total_time) + ], timeout=self.graceful_stop) + + self.end = time.monotonic() + + self.execution_metrics = await self.stop_stream() + + for reporter in self.stream_reporters: + await reporter.close() + + else: + + self.start = time.monotonic() + completed, pending = await asyncio.wait([ + loop.create_task( + self.stream.execute_action( + hooks[action_idx] + ) + ) async for action_idx in self.generator(total_time) + ], timeout=self.graceful_stop) + + self.end = time.monotonic() + + self.pending_actions = len(pending) + await self.logger.filesystem.aio['hyperscale.core'].debug( + f'{self.metadata_string} - Execution completed with - {self.pending_actions} - actions left pending' + ) + + results = await asyncio.gather(*completed) + + cleanup_start = time.monotonic() + + await asyncio.gather(*[ + asyncio.create_task( + cancel_pending(pend) + ) for pend in pending + ]) + + cleanup_elapsed = time.monotonic() - cleanup_start + await self.logger.filesystem.aio['hyperscale.core'].info( + f'{self.metadata_string} - Cleanup completed - Resolved {self.pending_actions} pending actions in {round(cleanup_elapsed, 2)} seconds' + ) + + for hook in hooks: + + session_closed_start = time.monotonic() + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Closing session - {hook.session.session_id} - for Hook - {hook.name}:{hook.hook_id}') + await hook.session.close() + + session_closed_elapsed = time.monotonic() - session_closed_start + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Closed session - {hook.session.session_id} - for Hook - {hook.name}:{hook.hook_id}. Took: {round(session_closed_elapsed, 2)} seconds') + + self.total_actions = len(set(results)) + self.total_elapsed = self.end - self.start + self.optimized_params = None + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Completed execution') + + return results + + async def generator(self, total_time): + elapsed = 0 + idx = 0 + action_idx = 0 + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False) * 2)/self.workers) + self.stream.last_batch_size = self.batch.size + + start = time.time() + while elapsed < total_time: + yield action_idx + + await asyncio.sleep(0) + elapsed = time.time() - start + idx += 1 + + if idx%self._hooks[action_idx].session.pool.size == 0: + + if self.stream.completed_count > 0: + + if self.stream.completed_count < self.batch.size: + increase_percentage = (self.batch.size - self.stream.completed_count)/self.batch.size + increase_amount = math.ceil(increase_percentage * self.stream.last_batch_size) + + self.stream.last_completed = self.stream.completed_count + self.stream.last_batch_size = self.stream.last_batch_size + increase_amount + + self._hooks[action_idx].session.extend_pool(increase_amount) + await asyncio.sleep(0) + + elif self.stream.completed_count > self.batch.size: + decrease_percentage = (self.stream.completed_count - self.batch.size)/self.stream.completed_count + decrease_amount = math.ceil(decrease_percentage * self.stream.last_batch_size) + + self.stream.last_completed = self.stream.completed_count + self.stream.last_batch_size = self.stream.last_batch_size - decrease_amount + + self._hooks[action_idx].session.shrink_pool(decrease_amount) + await asyncio.sleep(0) + + self.stream.completed_count = 0 + + await asyncio.sleep(self.batch.interval) + + action_idx = (action_idx + 1) % self.actions_count + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass + diff --git a/hyperscale/core/personas/types/constant_spawn_rate_persona/__init__.py b/hyperscale/core/personas/types/constant_spawn_rate_persona/__init__.py new file mode 100644 index 0000000..cec67cd --- /dev/null +++ b/hyperscale/core/personas/types/constant_spawn_rate_persona/__init__.py @@ -0,0 +1 @@ +from .constant_spawn_rate_persona import ConstantSpawnPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/constant_spawn_rate_persona/constant_spawn_rate_persona.py b/hyperscale/core/personas/types/constant_spawn_rate_persona/constant_spawn_rate_persona.py new file mode 100644 index 0000000..3618ca5 --- /dev/null +++ b/hyperscale/core/personas/types/constant_spawn_rate_persona/constant_spawn_rate_persona.py @@ -0,0 +1,85 @@ +import asyncio +import math +import time +import uuid + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.types.default_persona.default_persona import ( + DefaultPersona, +) +from hyperscale.core.personas.types.types import PersonaTypes + + +class ConstantSpawnPersona(DefaultPersona): + __slots__ = ( + 'metadata_string', + 'persona_id', + 'logger', + 'type', + 'workers', + 'actions', + '_hooks', + 'batch', + '_stream', + 'total_time', + 'duration', + 'total_actions', + 'total_elapsed', + 'start', + 'end', + 'completed_actions', + 'pending_actions', + 'completed_time', + 'run_timer', + 'actions_count', + 'graceful_stop', + 'is_timed', + '_stream_thread', + '_loop', + 'current_action_idx', + 'optimized_params', + '_executor', + 'stream', + 'execution_metrics', + 'stream_reporter_configs', + 'stream_reporters', + 'stage_name', + 'action_interval' + ) + + def __init__(self, config: Config): + super(ConstantSpawnPersona, self).__init__(config) + + self.persona_id = str(uuid.uuid4()) + self.action_interval = config.action_interval + self.type = PersonaTypes.CONSTANT_SPAWN + + async def generator(self, total_time): + elapsed = 0 + idx = 0 + action_idx = 0 + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False) * 2)/self.workers) + + start = time.time() + while elapsed < total_time: + yield action_idx + + await asyncio.sleep(self.action_interval) + elapsed = time.time() - start + idx += 1 + + if idx%self.batch.size == 0: + await asyncio.sleep(self.batch.interval) + + action_idx = (action_idx + 1)%self.actions_count + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass diff --git a/hyperscale/core/personas/types/cyclic_nowait_persona/__init__.py b/hyperscale/core/personas/types/cyclic_nowait_persona/__init__.py new file mode 100644 index 0000000..2429fa4 --- /dev/null +++ b/hyperscale/core/personas/types/cyclic_nowait_persona/__init__.py @@ -0,0 +1 @@ +from .cyclic_nowait_persona import CyclicNoWaitPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/cyclic_nowait_persona/cyclic_nowait_persona.py b/hyperscale/core/personas/types/cyclic_nowait_persona/cyclic_nowait_persona.py new file mode 100644 index 0000000..86f7b67 --- /dev/null +++ b/hyperscale/core/personas/types/cyclic_nowait_persona/cyclic_nowait_persona.py @@ -0,0 +1,28 @@ +import asyncio +import time +import uuid + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.core.personas.types.types import PersonaTypes + + +class CyclicNoWaitPersona(DefaultPersona): + + def __init__(self, config: Config): + super().__init__(config) + + self.persona_id = str(uuid.uuid4()) + self.type = PersonaTypes.NO_WAIT + + async def generator(self, total_time): + elapsed = 0 + idx = 0 + + start = time.monotonic() + while elapsed < total_time: + yield idx%self.actions_count + + await asyncio.sleep(0) + elapsed = time.monotonic() - start + idx += 1 diff --git a/hyperscale/core/personas/types/default_persona/__init__.py b/hyperscale/core/personas/types/default_persona/__init__.py new file mode 100644 index 0000000..7145d8e --- /dev/null +++ b/hyperscale/core/personas/types/default_persona/__init__.py @@ -0,0 +1 @@ +from .default_persona import DefaultPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/default_persona/default_persona.py b/hyperscale/core/personas/types/default_persona/default_persona.py new file mode 100644 index 0000000..ab0775e --- /dev/null +++ b/hyperscale/core/personas/types/default_persona/default_persona.py @@ -0,0 +1,389 @@ +import asyncio +import math +import time +import uuid +from asyncio import Task +from typing import Dict, List, Union + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.batching.batch import Batch +from hyperscale.core.personas.streaming import Stream, StreamAnalytics +from hyperscale.core.personas.types.types import PersonaTypes +from hyperscale.logging import HyperscaleLogger +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.reporting.processed_result.results import results_types +from hyperscale.reporting.reporter import Reporter, ReporterConfig + + +async def cancel_pending(pend: Task): + try: + if pend.done(): + pend.exception() + + return pend + + pend.cancel() + await asyncio.sleep(0) + if not pend.cancelled(): + await pend + + return pend + + except asyncio.CancelledError as cancelled_error: + return cancelled_error + + except asyncio.TimeoutError as timeout_error: + return timeout_error + + except asyncio.InvalidStateError as invalid_state: + return invalid_state + + +class DefaultPersona: + + __slots__ = ( + 'metadata_string', + 'persona_id', + 'logger', + 'type', + 'workers', + 'actions', + '_hooks', + 'batch', + '_stream', + 'total_time', + 'duration', + 'total_actions', + 'total_elapsed', + 'start', + 'end', + 'completed_actions', + 'pending_actions', + 'completed_time', + 'run_timer', + 'actions_count', + 'graceful_stop', + 'is_timed', + '_stream_thread', + '_loop', + 'current_action_idx', + 'optimized_params', + '_executor', + 'stream', + 'streamed_analytics', + 'stream_reporter_configs', + 'stream_reporters', + 'stage_name', + 'optimization_active', + 'pending', + 'collect_analytics', + 'collection_interval', + 'bypass_cleanup', + 'cpu_monitor', + 'memory_monitor' + ) + + def __init__(self, config: Config): + self.persona_id = str(uuid.uuid4()) + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.metadata_string: str = None + + self.type = PersonaTypes.DEFAULT + self.workers = 1 + + self.actions = [] + self._hooks: List[Union[ActionHook, TaskHook]] = {} + self.batch = Batch(config) + self._loop: asyncio.AbstractEventLoop = None + + self._stream = False + self.optimization_active = False + self.total_time = config.total_time + self.duration = 0 + self.total_actions = 0 + self.total_elapsed = 0 + self.start = 0 + self.end = 0 + self.completed_actions = 0 + self.pending_actions = 0 + self.completed_time = 0 + self.run_timer = False + self.actions_count = 0 + self.graceful_stop = config.graceful_stop + self.stream: Stream = None + + self.is_timed = True + self._stream_thread = None + + self.current_action_idx = 0 + self.optimized_params = None + self.streamed_analytics: StreamAnalytics = None + self.stream_reporter_configs: List[ReporterConfig] = [] + self.stream_reporters: List[Reporter] = [] + self.stage_name: str = None + self.collect_analytics: bool = False + self.collection_interval: int = 1 + self.pending: List[asyncio.Task] = [] + self.bypass_cleanup: bool = False + self.cpu_monitor = CPUMonitor() + self.memory_monitor = MemoryMonitor() + + def setup( + self, + hooks: Dict[HookType, List[Union[ActionHook, TaskHook]]], + metadata_string: str + ): + + self._setup( + hooks, + metadata_string + ) + + self.actions_count = len(self._hooks) + + def _setup( + self, + hooks: Dict[HookType, List[Union[ActionHook, TaskHook]]], + metadata_string: str + ): + self.metadata_string = f'{metadata_string} Persona: {self.type.capitalize()}:{self.persona_id} - ' + + actions_and_tasks = list([ + *hooks.get(HookType.ACTION), + *hooks.get(HookType.TASK, []) + ]) + + self._hooks = [ + hook for hook in actions_and_tasks if not hook.skip + ] + + for hook in self._hooks: + if self.stage_name is None: + self.stage_name = hook.stage + + for reporter_config in self.stream_reporter_configs: + self.stream_reporters.append( + Reporter(reporter_config) + ) + + self._stream = len(self.stream_reporters) > 0 and self.optimization_active is False + + if self._stream or self.collect_analytics: + self.stream = Stream() + + async def set_concurrency(self, concurrency: int): + for hook in self._hooks: + await hook.session.set_pool(concurrency) + + async def execute(self): + hooks = self._hooks + hook_names = ', '.join([ + hook.name for hook in hooks + ]) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Executing {self.actions_count} Hooks: {hook_names}') + + total_time = self.total_time + + monitor_name = f'{self.stage_name}.persona' + + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Executing for a total of - {total_time} - seconds') + loop = asyncio.get_running_loop() + if self._stream or self.collect_analytics: + + for reporter in self.stream_reporters: + reporter.logger.filesystem.aio['hyperscale.reporting'].logger_enabled = False + reporter.selected_reporter.logger.filesystem.aio['hyperscale.reporting'].logger_enabled = False + await reporter.connect() + + + await self.cpu_monitor.start_background_monitor(monitor_name) + await self.memory_monitor.start_background_monitor(monitor_name) + + await self.start_stream() + + self.start = time.monotonic() + completed, pending = await asyncio.wait([ + loop.create_task( + self.stream.execute_action( + hooks[action_idx] + ) + ) async for action_idx in self.generator(total_time) + ], timeout=self.graceful_stop) + + self.end = time.monotonic() + + self.streamed_analytics = await self.stop_stream() + + await self.cpu_monitor.stop_background_monitor(monitor_name) + await self.memory_monitor.stop_background_monitor(monitor_name) + + for reporter in self.stream_reporters: + await reporter.close() + + else: + + await self.cpu_monitor.start_background_monitor(monitor_name) + await self.memory_monitor.start_background_monitor(monitor_name) + + self.start = time.monotonic() + completed, pending = await asyncio.wait([ + loop.create_task( + hooks[action_idx].session.execute_prepared_request( + hooks[action_idx].action + ) + ) async for action_idx in self.generator(total_time) + ], timeout=self.graceful_stop) + + self.end = time.monotonic() + + await self.cpu_monitor.stop_background_monitor(monitor_name) + await self.memory_monitor.stop_background_monitor(monitor_name) + + self.cpu_monitor.close() + self.memory_monitor.close() + + execution_elapsed = int(self.end - self.start) + + self.cpu_monitor.trim_monitor_samples( + monitor_name, + execution_elapsed + ) + + self.memory_monitor.trim_monitor_samples( + monitor_name, + execution_elapsed + ) + + self.pending_actions = len(pending) + await self.logger.filesystem.aio['hyperscale.core'].debug( + f'{self.metadata_string} - Execution completed with - {self.pending_actions} - actions left pending' + ) + + results = await asyncio.gather(*completed) + + cleanup_start = time.monotonic() + + await asyncio.gather(*[ + asyncio.create_task( + cancel_pending(pend) + ) for pend in pending + ]) + + cleanup_elapsed = time.monotonic() - cleanup_start + await self.logger.filesystem.aio['hyperscale.core'].info( + f'{self.metadata_string} - Cleanup completed - Resolved {self.pending_actions} pending actions in {round(cleanup_elapsed, 2)} seconds' + ) + + for hook in hooks: + + session_closed_start = time.monotonic() + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Closing session - {hook.session.session_id} - for Hook - {hook.name}:{hook.hook_id}') + await hook.session.close() + + session_closed_elapsed = time.monotonic() - session_closed_start + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Closed session - {hook.session.session_id} - for Hook - {hook.name}:{hook.hook_id}. Took: {round(session_closed_elapsed, 2)} seconds') + + self.total_actions = len(set(results)) + self.total_elapsed = self.end - self.start + self.optimized_params = None + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Completed execution') + + return results + + async def generator(self, total_time): + elapsed = 0 + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False)**2)/self.workers) + action_idx = 0 + + start = time.monotonic() + while elapsed < total_time: + yield action_idx + await asyncio.sleep(0) + elapsed = time.monotonic() - start + + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass + + action_idx = (action_idx+1)%self.actions_count + + async def start_stream(self): + self._loop = asyncio.get_event_loop() + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Live Updates enabled') + self._stream_thread = asyncio.create_task( + self._run_stream() + ) + + async def stop_stream(self) -> StreamAnalytics: + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Live Updates stopped') + self.run_timer = False + return await self._stream_thread + + async def _run_stream(self) -> StreamAnalytics: + self.run_timer = True + + stream_analytics = StreamAnalytics() + + stream_submission_tasks = [] + collection_stop_time = self.total_time - 1 + + start = time.time() + batch_start = time.time() + + while self.completed_time < collection_stop_time and self.run_timer: + self.completed_time = time.time() - start + await asyncio.sleep(self.collection_interval) + + batch_elapsed = time.time() - batch_start + + stream_analytics.add( + self.stream, + batch_elapsed + ) + + if self._stream: + processed_results = [ + results_types.get( + result.type + )( + self.stage_name, + result + ) for result in self.stream.completed + ] + + for reporter in self.stream_reporters: + stream_submission_tasks.append( + asyncio.create_task( + reporter.submit_events(processed_results) + ) + ) + + batch_start = time.time() + self.stream.completed = [] + + self.stream.completed = [] + + if self._stream: + await asyncio.gather(*stream_submission_tasks) + + self.completed_time = time.time() - start + + return stream_analytics + \ No newline at end of file diff --git a/hyperscale/core/personas/types/ramped_interval_persona/__init__.py b/hyperscale/core/personas/types/ramped_interval_persona/__init__.py new file mode 100644 index 0000000..c808e14 --- /dev/null +++ b/hyperscale/core/personas/types/ramped_interval_persona/__init__.py @@ -0,0 +1 @@ +from .ramped_interval_persona import RampedIntervalPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/ramped_interval_persona/ramped_interval_persona.py b/hyperscale/core/personas/types/ramped_interval_persona/ramped_interval_persona.py new file mode 100644 index 0000000..2157e9b --- /dev/null +++ b/hyperscale/core/personas/types/ramped_interval_persona/ramped_interval_persona.py @@ -0,0 +1,58 @@ +import asyncio +import math +import time +import uuid + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.core.personas.types.types import PersonaTypes + + +class RampedIntervalPersona(DefaultPersona): + + def __init__(self, config: Config): + super(RampedIntervalPersona, self).__init__(config) + + self.persona_id = str(uuid.uuid4()) + self.type = PersonaTypes.RAMPED_INTERVAL + + async def generator(self, total_time): + elapsed = 0 + idx = 0 + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False) * 2)/self.workers) + generation_batch_interval = self.batch.interval * self.batch.gradient + + start = time.time() + batch_start = time.time() + while elapsed < total_time: + action_idx = idx%self.actions_count + yield action_idx + + await asyncio.sleep(0) + elapsed = time.time() - start + batch_elapsed = time.time() - batch_start + idx += 1 + + if batch_elapsed >= generation_batch_interval: + increase_amount = (self.batch.interval * self.batch.gradient) + next_batch_time = generation_batch_interval + increase_amount + + if next_batch_time < self.batch.interval: + generation_batch_interval = next_batch_time + else: + generation_batch_interval = self.batch.interval + + await asyncio.sleep(self.batch.interval) + batch_start = time.time() + + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass diff --git a/hyperscale/core/personas/types/ramped_persona/__init__.py b/hyperscale/core/personas/types/ramped_persona/__init__.py new file mode 100644 index 0000000..6511833 --- /dev/null +++ b/hyperscale/core/personas/types/ramped_persona/__init__.py @@ -0,0 +1 @@ +from .ramped_persona import RampedPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/ramped_persona/ramped_persona.py b/hyperscale/core/personas/types/ramped_persona/ramped_persona.py new file mode 100644 index 0000000..031d5bd --- /dev/null +++ b/hyperscale/core/personas/types/ramped_persona/ramped_persona.py @@ -0,0 +1,65 @@ +import asyncio +import math +import time +import uuid + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.core.personas.types.types import PersonaTypes + + +class RampedPersona(DefaultPersona): + + def __init__(self, config: Config): + super(RampedPersona, self).__init__(config) + + self.persona_id = str(uuid.uuid4()) + self.type = PersonaTypes.RAMPED + + async def generator(self, total_time): + elapsed = 0 + idx = 0 + action_idx = 0 + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False) * 2)/self.workers) + generation_batch_size = int(self.batch.size * self.batch.gradient) + self._hooks[action_idx].session.shrink_pool(self.batch.size - generation_batch_size) + + start = time.time() + while elapsed < total_time: + yield action_idx + + await asyncio.sleep(0) + elapsed = time.time() - start + idx += 1 + + if idx%generation_batch_size == 0: + increase_amount = int(self.batch.gradient * self.batch.size) + next_batch_size = generation_batch_size + increase_amount + + if next_batch_size < self.batch.size: + self._hooks[action_idx].session.extend_pool(increase_amount) + generation_batch_size = next_batch_size + + elif next_batch_size > self.batch.size: + increase_amount = self.batch.size - generation_batch_size + + next_batch_size = generation_batch_size + increase_amount + + self._hooks[action_idx].session.extend_pool(increase_amount) + generation_batch_size = next_batch_size + + await asyncio.sleep(self.batch.interval) + + action_idx = (action_idx + 1) % self.actions_count + + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass diff --git a/hyperscale/core/personas/types/sequenced_persona/__init__.py b/hyperscale/core/personas/types/sequenced_persona/__init__.py new file mode 100644 index 0000000..5142542 --- /dev/null +++ b/hyperscale/core/personas/types/sequenced_persona/__init__.py @@ -0,0 +1 @@ +from .sequenced_persona import SequencedPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/sequenced_persona/sequenced_persona.py b/hyperscale/core/personas/types/sequenced_persona/sequenced_persona.py new file mode 100644 index 0000000..6a4401e --- /dev/null +++ b/hyperscale/core/personas/types/sequenced_persona/sequenced_persona.py @@ -0,0 +1,30 @@ +import uuid +from typing import Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.core.personas.types.types import PersonaTypes + + +class SequencedPersona(DefaultPersona): + + def __init__(self, config: Config): + super(SequencedPersona, self).__init__(config) + + self.persona_id = str(uuid.uuid4()) + self.type = PersonaTypes.SEQUENCE + + def setup(self, hooks: Dict[HookType, List[Union[ActionHook, TaskHook]]], metadata_string: str): + + + self._setup(hooks, metadata_string) + + sequence = sorted(self._hooks, + key=lambda action: action.metadata.order + ) + + self._hooks = sequence + self.actions_count = len(sequence) diff --git a/hyperscale/core/personas/types/types.py b/hyperscale/core/personas/types/types.py new file mode 100644 index 0000000..0ad2810 --- /dev/null +++ b/hyperscale/core/personas/types/types.py @@ -0,0 +1,33 @@ +from enum import Enum + + +class PersonaTypes: + APPROXIMATE_DISTRIBUTION='APPROXIMATE-DISTRIBUTION' + BATCHED='BATCHED' + DEFAULT='DEFAULT' + RAMPED='RAMPED' + RAMPED_INTERVAL='RAMPED-INTERVAL' + CONSTANT_ARRIVAL='CONSTANT-ARRIVAL' + CONSTANT_SPAWN='CONSTANT-SPAWN' + SEQUENCE='SEQUENCE' + WEIGHTED='WEIGHTED' + NO_WAIT='NO-WAIT' + + +class PersonaTypesMap: + + def __init__(self) -> None: + self.types = { + 'approx-dist': PersonaTypes.APPROXIMATE_DISTRIBUTION, + 'batched': PersonaTypes.BATCHED, + 'default': PersonaTypes.DEFAULT, + 'ramped': PersonaTypes.RAMPED, + 'constant-arrival': PersonaTypes.CONSTANT_ARRIVAL, + 'constant-spawn': PersonaTypes.CONSTANT_SPAWN, + 'sequence': PersonaTypes.SEQUENCE, + 'weighted': PersonaTypes.WEIGHTED, + 'no-wait': PersonaTypes.NO_WAIT + } + + def __getitem__(self, persona_type: str): + return self.types.get(persona_type, PersonaTypes.DEFAULT) \ No newline at end of file diff --git a/hyperscale/core/personas/types/weighted_selection_persona/__init__.py b/hyperscale/core/personas/types/weighted_selection_persona/__init__.py new file mode 100644 index 0000000..7038e79 --- /dev/null +++ b/hyperscale/core/personas/types/weighted_selection_persona/__init__.py @@ -0,0 +1 @@ +from .weighted_selection_persona import WeightedSelectionPersona \ No newline at end of file diff --git a/hyperscale/core/personas/types/weighted_selection_persona/weighted_selection_persona.py b/hyperscale/core/personas/types/weighted_selection_persona/weighted_selection_persona.py new file mode 100644 index 0000000..e0b48cd --- /dev/null +++ b/hyperscale/core/personas/types/weighted_selection_persona/weighted_selection_persona.py @@ -0,0 +1,87 @@ +import asyncio +import math +import random +import time +import uuid +from typing import Dict, List, Union + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.core.personas.types.default_persona import DefaultPersona +from hyperscale.core.personas.types.types import PersonaTypes + + +class WeightedSelectionPersona(DefaultPersona): + + __slots__ = ( + 'weights', + 'indexes', + 'sample' + ) + + def __init__(self, config: Config): + super().__init__(config) + + self.persona_id = str(uuid.uuid4()) + self.weights: List[int] = [] + self.indexes: List[int] = [] + self.sample: List[int] = [] + self.type = PersonaTypes.WEIGHTED + + @classmethod + def about(cls): + return ''' + Weighted Persona - (sequence) + + Executes batches of actions of the batch size specified by the --batch-size argument. Actions for each batch are resampled each iteration according + to their specified "weight". As with other personas, the Weighted persona will execute for the total amount of time specified by the --total-time + argument. You may specify a wait between batches (between each step) by specifying an integer number of seconds via the --batch-interval argument. + ''' + + def setup(self, hooks: Dict[HookType, List[Union[ActionHook, TaskHook]]], metadata_string: str): + + self._setup(hooks, metadata_string) + + self.actions_count = len(self._hooks) + + self.weights = [action.metadata.weight for action in self._hooks] + self.indexes = [idx for idx in range(self.actions_count)] + + self.sample = random.choices( + self.indexes, + weights=self.weights, + k=self.batch.size + ) + + + async def generator(self, total_time): + elapsed = 0 + max_pool_size = math.ceil(self.batch.size * (psutil.cpu_count(logical=False) * 2)/self.workers) + + start = time.time() + while elapsed < total_time: + for action_idx in self.sample: + yield action_idx + + await asyncio.sleep(0) + elapsed = time.time() - start + + if self._hooks[action_idx].session.active > max_pool_size: + try: + max_wait = total_time - elapsed + await asyncio.wait_for( + self._hooks[action_idx].session.wait_for_active_threshold(), + timeout=max_wait + ) + except asyncio.TimeoutError: + pass + + self.sample = random.choices( + self.indexes, + weights=self.weights, + k=self.batch.size + ) diff --git a/hyperscale/core_rewrite/__init__.py b/hyperscale/core_rewrite/__init__.py new file mode 100644 index 0000000..16e91d2 --- /dev/null +++ b/hyperscale/core_rewrite/__init__.py @@ -0,0 +1,3 @@ +from .graph import Graph +from .hooks import step +from .workflow import Workflow \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/__init__.py b/hyperscale/core_rewrite/engines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/__init__.py b/hyperscale/core_rewrite/engines/client/__init__.py new file mode 100644 index 0000000..40fb403 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/__init__.py @@ -0,0 +1,3 @@ +from .client import Client as Client +from .time_parser import TimeParser as TimeParser +from .tracing_config import TracingConfig as TracingConfig diff --git a/hyperscale/core_rewrite/engines/client/client.py b/hyperscale/core_rewrite/engines/client/client.py new file mode 100644 index 0000000..53ec74d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/client.py @@ -0,0 +1,52 @@ +import uuid +from typing import Generic, Optional + +from typing_extensions import TypeVarTuple, Unpack + +from .config import Config +from .graphql import MercurySyncGraphQLConnection +from .graphql_http2 import MercurySyncGraphQLHTTP2Connection +from .grpc import MercurySyncGRPCConnection +from .http import MercurySyncHTTPConnection +from .http2 import MercurySyncHTTP2Connection +from .http3 import MercurySyncHTTP3Connection +from .playwright import MercurySyncPlaywrightConnection +from .udp import MercurySyncUDPConnection +from .websocket import MercurySyncWebsocketConnection + +T = TypeVarTuple("T") + +config_registry = [] + + +class Client(Generic[Unpack[T]]): + def __init__( + self, + graph_name: str, + graph_id: str, + stage_name: str, + stage_id: str, + config: Optional[Config] = None, + ) -> None: + self.client_id = str(uuid.uuid4()) + self.graph_name = graph_name + self.graph_id = graph_id + self.stage_name = stage_name + self.stage_id = stage_id + + self.next_name = None + self.suspend = False + + self._config: Config = config + + self.graphql = MercurySyncGraphQLConnection(pool_size=config.vus) + self.graphqlh2 = MercurySyncGraphQLHTTP2Connection(pool_size=config.vus) + self.grpc = MercurySyncGRPCConnection(pool_size=config.vus) + self.http = MercurySyncHTTPConnection(pool_size=config.vus) + self.http2 = MercurySyncHTTP2Connection(pool_size=config.vus) + self.http3 = MercurySyncHTTP3Connection(pool_size=config.vus) + self.playwright = MercurySyncPlaywrightConnection( + pool_size=config.vus, pages=config.pages + ) + self.udp = MercurySyncUDPConnection(pool_size=config.vus) + self.websocket = MercurySyncWebsocketConnection(pool_size=config.vus) diff --git a/hyperscale/core_rewrite/engines/client/config.py b/hyperscale/core_rewrite/engines/client/config.py new file mode 100644 index 0000000..d4433a1 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/config.py @@ -0,0 +1,60 @@ +from typing import Dict, List, Union + +import psutil + +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation + +from .shared.timeouts import Timeouts +from .time_parser import TimeParser +from .tracing_config import TracingConfig + + +class Config: + + def __init__(self, **kwargs) -> None: + + for config_option_name, config_option_value in dict(kwargs).items(): + if config_option_value is None: + del kwargs[config_option_name] + + self.total_time_string = kwargs.get('total_time', '1m') + parsed_time = TimeParser(self.total_time_string) + + self.log_level = kwargs.get('log_level', 'info') + self.persona_type = kwargs.get('persona_type', 'default') + self.total_time = parsed_time.time + self.vus = kwargs.get('vus', 1000) + self.pages: int = kwargs.get('pages', 1) + self.batch_interval = kwargs.get('batch_interval') + self.action_interval = kwargs.get('action_interval', 0) + self.optimize_iterations = kwargs.get('optimize_iterations', 0) + self.optimizer_type = kwargs.get('optimizer_type', 'shg') + self.batch_gradient = kwargs.get('batch_gradient', 0.1) + self.cpus = kwargs.get('cpus', psutil.cpu_count(logical=False)) + self.no_run_visuals = kwargs.get('no_run_visuals', False) + + self.timeouts = Timeouts( + connect_timeout=kwargs.get('connect_timeout', 15), + read_timeout=kwargs.get('read_timeout', 5), + write_timeout=kwargs.get('write_timeout', 5), + request_timeout=kwargs.get('request_timeout', 60), + total_time=self.total_time + ) + + self.reset_connections = kwargs.get('reset_connections') + self.graceful_stop = kwargs.get('graceful_stop', 1) + self.optimized = False + + self.browser_type = kwargs.get('browser_type', 'chromium') + self.device_type = kwargs.get('device_type') + self.locale = kwargs.get('locale') + self.geolocation = kwargs.get('geolocation') + self.permissions: List[str] = kwargs.get('permissions', []) + self.color_scheme = kwargs.get('color_scheme') + self.group_size = kwargs.get('group_size') + + self.experiment: Dict[str, Union[str, int, List[float]]] = kwargs.get('experiment', {}) + self.tracing: Union[TracingConfig, None] = kwargs.get('tracing') + self.mutations: Union[List[Mutation], None] = kwargs.get('mutations', []) + self.actions_filepaths: Union[Dict[str, str], None] = kwargs.get('actions_filepaths') + diff --git a/hyperscale/core_rewrite/engines/client/graphql/__init__.py b/hyperscale/core_rewrite/engines/client/graphql/__init__.py new file mode 100644 index 0000000..5394a8a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql/__init__.py @@ -0,0 +1,6 @@ +from .mercury_sync_graphql_connection import ( + MercurySyncGraphQLConnection as MercurySyncGraphQLConnection, +) +from .models.graphql import GraphQLRequest as GraphQLRequest +from .models.graphql import GraphQLResponse as GraphQLResponse +from .models.graphql import OptimizedGraphQLRequest as OptimizedGraphQLRequest diff --git a/hyperscale/core_rewrite/engines/client/graphql/mercury_sync_graphql_connection.py b/hyperscale/core_rewrite/engines/client/graphql/mercury_sync_graphql_connection.py new file mode 100644 index 0000000..c2d44e9 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql/mercury_sync_graphql_connection.py @@ -0,0 +1,575 @@ +import asyncio +import time +import uuid +from typing import ( + Any, + Dict, + List, + Literal, + Optional, + Tuple, + Union, +) +from urllib.parse import ( + ParseResult, + urlparse, +) + +import orjson + +from hyperscale.core_rewrite.engines.client.http import MercurySyncHTTPConnection +from hyperscale.core_rewrite.engines.client.http.protocols import HTTPConnection +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + Cookies, + HTTPCookie, + HTTPEncodableValue, + Metadata, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.protocols import NEW_LINE +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.graphql import ( + GraphQLResponse, +) + + +def mock_fn(): + return None + + +try: + from graphql import Source, parse, print_ast + +except ImportError: + Source = None + parse = mock_fn + print_ast = mock_fn + + +class MercurySyncGraphQLConnection(MercurySyncHTTPConnection): + def __init__( + self, + pool_size: int = 10**3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + super(MercurySyncGraphQLConnection, self).__init__( + pool_size=pool_size, + timeouts=timeouts, + reset_connections=reset_connections, + ) + + self.session_id = str(uuid.uuid4()) + + async def query( + self, + url: str, + query: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ) -> GraphQLResponse: + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url=url, + method="GET", + cookies=cookies, + auth=auth, + headers=headers, + data={ + "query": query, + }, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return GraphQLResponse( + metadata=Metadata(), + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="GET", + status=408, + status_message="Request timed out.", + ) + + async def mutate( + self, + url: str, + query: str, + operation_name: str = None, + variables: Dict[str, Any] = None, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ) -> GraphQLResponse: + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url=url, + method="POST", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data={ + "query": query, + "operation_name": operation_name, + "variables": variables, + }, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return GraphQLResponse( + metadata=Metadata(), + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="POST", + status=408, + status_message="Request timed out.", + ) + + async def _request( + self, + url: str, + method: Literal["GET", "POST"], + cookies: Optional[List[HTTPCookie]] = None, + auth: Optional[Tuple[str, str]] = None, + headers: Optional[Dict[str, str]] = {}, + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) = None, + redirects: Optional[int] = 3, + ): + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = { + "request_start": None, + "connect_start": None, + "connect_end": None, + "write_start": None, + "write_end": None, + "read_start": None, + "read_end": None, + "request_end": None, + } + timings["request_start"] = time.monotonic() + + result, redirect, timings = await self._execute( + url, + method, + cookies=cookies, + auth=auth, + headers=headers, + data=data, + timings=timings, + ) + + if redirect: + location = result.headers.get(b"location").decode() + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + for _ in range(redirects): + result, redirect, timings = await self._execute( + url, + method, + cookies=cookies, + auth=auth, + headers=headers, + data=data, + upgrade_ssl=upgrade_ssl, + redirect_url=location, + timings=timings, + ) + + if redirect is False: + break + + location = result.headers.get(b"location").decode() + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + timings["request_end"] = time.monotonic() + result.timings.update(timings) + + return result + + async def _execute( + self, + request_url: str, + method: Literal["GET", "POST"], + cookies: Optional[List[HTTPCookie]] = None, + auth: Optional[Tuple[str, str]] = None, + headers: Optional[Dict[str, str]] = {}, + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) = None, + upgrade_ssl: bool = False, + redirect_url: Optional[str] = None, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ) -> Tuple[ + GraphQLResponse, + bool, + Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ], + ]: + if redirect_url: + request_url = redirect_url + + try: + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + (connection, url, upgrade_ssl) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=request_url if upgrade_ssl else None + ), + timeout=self.timeouts.connect_timeout, + ) + + if upgrade_ssl: + ssl_redirect_url = request_url.replace("http://", "https://") + + connection, url, _ = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=ssl_redirect_url + ), + timeout=self.timeouts.connect_timeout, + ) + + request_url = ssl_redirect_url + + if connection.reader is None: + timings["connect_end"] = time.monotonic() + self._connections.append( + HTTPConnection( + reset_connections=self.reset_connections, + ) + ) + + return ( + GraphQLResponse( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + method=method, + status=400, + headers=headers, + timings=timings, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + if timings["write_start"] is None: + timings["write_start"] = time.monotonic() + + if method == "GET": + encoded_headers = self.encode_headers( + url, + method, + data, + headers=headers, + cookies=cookies, + ) + + connection.write(encoded_headers) + + else: + encoded_data = self.encode_data(data) + + encoded_headers = self.encode_headers( + url, + method, + data, + headers=headers, + cookies=cookies, + encoded_data=encoded_data, + ) + + connection.write(encoded_headers) + connection.write(encoded_data) + + timings["write_end"] = time.monotonic() + + if timings["read_start"] is None: + timings["read_start"] = time.monotonic() + + response_code = await asyncio.wait_for( + connection.reader.readline(), timeout=self.timeouts.read_timeout + ) + + headers: Dict[bytes, bytes] = await asyncio.wait_for( + connection.read_headers(), timeout=self.timeouts.read_timeout + ) + + status_string: List[bytes] = response_code.split() + status = int(status_string[1]) + + if status >= 300 and status < 400: + timings["read_end"] = time.monotonic() + self._connections.append(connection) + + return ( + GraphQLResponse( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + method=method, + status=status, + headers=headers, + timings=timings, + ), + True, + timings, + ) + + content_length = headers.get(b"content-length") + transfer_encoding = headers.get(b"transfer-encoding") + + cookies: Union[Cookies, None] = None + cookies_data: Union[bytes, None] = headers.get(b"set-cookie") + if cookies_data: + cookies = Cookies() + cookies.update(cookies_data) + + # We require Content-Length or Transfer-Encoding headers to read a + # request body, otherwise it's anyone's guess as to how big the body + # is, and we ain't playing that game. + + if content_length: + body = await asyncio.wait_for( + connection.readexactly(int(content_length)), + timeout=self.timeouts.read_timeout, + ) + + elif transfer_encoding: + body = bytearray() + all_chunks_read = False + + while True and not all_chunks_read: + chunk_size = int( + ( + await asyncio.wait_for( + connection.readline(), + timeout=self.timeouts.read_timeout, + ) + ).rstrip(), + 16, + ) + + if not chunk_size: + # read last CRLF + await asyncio.wait_for( + connection.readline(), timeout=self.timeouts.read_timeout + ) + break + + chunk = await asyncio.wait_for( + connection.readexactly(chunk_size + 2), + self.timeouts.read_timeout, + ) + body.extend(chunk[:-2]) + + all_chunks_read = True + + self._connections.append(connection) + + timings["read_end"] = time.monotonic() + + return ( + GraphQLResponse( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + cookies=cookies, + method=method, + status=status, + headers=headers, + content=body, + timings=timings, + ), + False, + timings, + ) + + except Exception as request_exception: + self._connections.append( + HTTPConnection(reset_connections=self.reset_connections) + ) + + if isinstance(request_url, str): + request_url: ParseResult = urlparse(request_url) + + timings["read_end"] = time.monotonic() + + return ( + GraphQLResponse( + url=URLMetadata( + host=request_url.hostname, + path=request_url.path, + ), + method=method, + status=400, + status_message=str(request_exception), + timings=timings, + ), + False, + timings, + ) + + def encode_data( + self, + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ), + ): + source = Source(data.get("query")) + document_node = parse(source) + query_string = print_ast(document_node) + + query = {"query": query_string} + + operation_name = data.get("operation_name") + variables = data.get("variables") + + if operation_name: + query["operationName"] = operation_name + + if variables: + query["variables"] = variables + + return orjson.dumps(query) + + def encode_headers( + self, + url: URL, + method: Literal["GET", "POST"], + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ), + headers: Optional[Dict[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + encoded_data: Optional[bytes] = None, + ): + url_path = url.path + + if method == "GET": + query_string = data.get("query") + query_string = "".join(query_string.replace("query", "").split()) + + url_path += f"?query={{{query_string}}}" + + get_base = f"{method} {url_path} HTTP/1.1{NEW_LINE}" + + port = url.port or (443 if url.scheme == "https" else 80) + + hostname = url.hostname.encode("idna").decode() + + if port not in [80, 443]: + hostname = f"{hostname}:{port}" + + header_items = { + "user-agent": "hyperscale", + } + + if method == "POST" and encoded_data: + header_items["content-length"] = len(encoded_data) + header_items["content-type"] = "application/json" + + if headers: + header_items.update(headers) + + for key, value in header_items.items(): + get_base += f"{key}: {value}{NEW_LINE}" + + if cookies: + cookies = [] + + for cookie_data in cookies: + if len(cookie_data) == 1: + cookies.append(cookie_data[0]) + + elif len(cookie_data) == 2: + cookie_name, cookie_value = cookie_data + cookies.append(f"{cookie_name}={cookie_value}") + + cookies = "; ".join(cookies) + get_base += f"cookie: {cookies}{NEW_LINE}" + + return (get_base + NEW_LINE).encode(), data diff --git a/hyperscale/core_rewrite/engines/client/graphql/models/__init__.py b/hyperscale/core_rewrite/engines/client/graphql/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/graphql/models/graphql/__init__.py b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/__init__.py new file mode 100644 index 0000000..276136a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/__init__.py @@ -0,0 +1,5 @@ +from .graphql_request import GraphQLRequest as GraphQLRequest +from .graphql_response import GraphQLResponse as GraphQLResponse +from .optimized_graphql_request import ( + OptimizedGraphQLRequest as OptimizedGraphQLRequest, +) diff --git a/hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_request.py b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_request.py new file mode 100644 index 0000000..c599ed3 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_request.py @@ -0,0 +1,113 @@ +from typing import ( + Dict, + List, + Literal, + Optional, + Tuple, +) + +import orjson +from pydantic import BaseModel, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import URL, HTTPCookie +from hyperscale.core_rewrite.engines.client.shared.protocols import NEW_LINE + + +def mock_fn(): + return None + + +try: + from graphql import Source, parse, print_ast + +except ImportError: + Source = None + parse = mock_fn + print_ast = mock_fn + + +class GraphQLRequest(BaseModel): + url: StrictStr + method: Literal[ + "GET", + "POST", + ] + cookies: Optional[List[HTTPCookie]] = None + auth: Optional[Tuple[str, str]] = None + cookies: Optional[List[HTTPCookie]] = None + headers: Dict[str, str] = {} + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True + + def prepare(self, url: URL): + url_path = url.path + if self.method == "GET": + query_string = self.data.get("query") + query_string = "".join(query_string.replace("query", "").split()) + + url_path += f"?query={{{query_string}}}" + + get_base = f"{self.method} {url_path} HTTP/1.1{NEW_LINE}" + + port = url.port or (443 if url.scheme == "https" else 80) + + hostname = url.hostname.encode("idna").decode() + + if port not in [80, 443]: + hostname = f"{hostname}:{port}" + + data: Optional[bytes] = None + size: int = 0 + + if self.method == "POST": + source = Source(self.data.get("query")) + document_node = parse(source) + query_string = print_ast(document_node) + + query = {"query": query_string} + + operation_name = self.data.get("operation_name") + variables = self.data.get("variables") + + if operation_name: + query["operationName"] = operation_name + + if variables: + query["variables"] = variables + + data = orjson.dumps(query) + size = len(data) + + header_items = [] + + if size > 0: + header_items = [ + ("Content-Length", size), + ] + + header_items.extend(self.headers.items()) + + for key, value in header_items: + get_base += f"{key}: {value}{NEW_LINE}" + + if self.cookies: + cookies = [] + + for cookie_data in self.cookies: + if len(cookie_data) == 1: + cookies.append(cookie_data[0]) + + elif len(cookie_data) == 2: + cookie_name, cookie_value = cookie_data + cookies.append(f"{cookie_name}={cookie_value}") + + cookies = "; ".join(cookies) + get_base += f"cookie: {cookies}{NEW_LINE}" + + return (get_base + NEW_LINE).encode(), data diff --git a/hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_response.py b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_response.py new file mode 100644 index 0000000..e94553a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/graphql_response.py @@ -0,0 +1,7 @@ +from hyperscale.core_rewrite.engines.client.http.models.http import HTTPResponse + + +class GraphQLResponse(HTTPResponse): + + class Config: + arbitrary_types_allowed=True diff --git a/hyperscale/core_rewrite/engines/client/graphql/models/graphql/optimized_graphql_request.py b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/optimized_graphql_request.py new file mode 100644 index 0000000..ff151a1 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql/models/graphql/optimized_graphql_request.py @@ -0,0 +1,37 @@ +from typing import List, Literal, Optional, Tuple + +from pydantic import ( + BaseModel, + StrictBytes, + StrictInt, + StrictStr, +) + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedGraphQLRequest(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + method: Literal["GET", "POST"] + encoded_params: Optional[StrictStr | StrictBytes] = None + encoded_auth: Optional[StrictStr | StrictBytes] = None + encoded_cookies: Optional[ + StrictStr + | StrictBytes + | Tuple[StrictStr, StrictStr] + | Tuple[StrictBytes, StrictBytes] + ] = None + encoded_headers: Optional[ + StrictBytes + | StrictStr + | List[StrictStr] + | List[StrictBytes] + | List[Tuple[StrictStr, StrictStr]] + | List[Tuple[StrictBytes, StrictBytes]] + ] = None + encoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/graphql_http2/__init__.py b/hyperscale/core_rewrite/engines/client/graphql_http2/__init__.py new file mode 100644 index 0000000..aedbabf --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql_http2/__init__.py @@ -0,0 +1,8 @@ +from .mercury_sync_graphql_http2_connection import ( + MercurySyncGraphQLHTTP2Connection as MercurySyncGraphQLHTTP2Connection, +) +from .models.graphql_http2 import GraphQLHTTP2Request as GraphQLHTTP2Request +from .models.graphql_http2 import GraphQLHTTP2Response as GraphQLHTTP2Response +from .models.graphql_http2 import ( + OptimizedGraphQLHTTP2Request as OptimizedGraphQLHTTP2Request, +) diff --git a/hyperscale/core_rewrite/engines/client/graphql_http2/mercury_sync_graphql_http2_connection.py b/hyperscale/core_rewrite/engines/client/graphql_http2/mercury_sync_graphql_http2_connection.py new file mode 100644 index 0000000..c69d663 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql_http2/mercury_sync_graphql_http2_connection.py @@ -0,0 +1,533 @@ +import asyncio +import time +import uuid +from random import randrange +from typing import ( + Any, + Dict, + List, + Literal, + Optional, + Tuple, + TypeVar, + Union, +) +from urllib.parse import ParseResult, urlparse + +import orjson + +from hyperscale.core_rewrite.engines.client.http2 import MercurySyncHTTP2Connection +from hyperscale.core_rewrite.engines.client.http2.pipe import HTTP2Pipe +from hyperscale.core_rewrite.engines.client.http2.protocols import HTTP2Connection +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + Cookies, + HTTPCookie, + HTTPEncodableValue, + Metadata, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.graphql_http2 import ( + GraphQLHTTP2Response, +) + +T = TypeVar("T") + + +def mock_fn(): + return None + + +try: + from graphql import Source, parse, print_ast + +except ImportError: + Source = None + parse = mock_fn + print_ast = mock_fn + + +class MercurySyncGraphQLHTTP2Connection(MercurySyncHTTP2Connection): + def __init__( + self, + pool_size: int = 10**3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + super(MercurySyncGraphQLHTTP2Connection, self).__init__( + pool_size=pool_size, + timeouts=timeouts, + reset_connections=reset_connections, + ) + + self.session_id = str(uuid.uuid4()) + + async def query( + self, + url: str, + query: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ) -> GraphQLHTTP2Response: + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url=url, + method="GET", + cookies=cookies, + auth=auth, + headers=headers, + data={ + "query": query, + }, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return GraphQLHTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="POST", + status=408, + status_message="Request timed out.", + ) + + async def mutate( + self, + url: str, + query: str, + operation_name: str = None, + variables: Dict[str, Any] = None, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ) -> GraphQLHTTP2Response: + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url=url, + method="POST", + cookies=cookies, + auth=auth, + headers=headers, + data={ + "query": query, + "operation_name": operation_name, + "variables": variables, + }, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return GraphQLHTTP2Response( + metadata=Metadata(), + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="POST", + status=408, + status_message="Request timed out.", + ) + + async def _request( + self, + url: str, + method: Literal["GET", "POST"], + cookies: Optional[List[HTTPCookie]] = None, + auth: Optional[Tuple[str, str]] = None, + headers: Optional[Dict[str, str]] = {}, + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) = None, + redirects: Optional[int] = 3, + ): + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = { + "request_start": None, + "connect_start": None, + "connect_end": None, + "write_start": None, + "write_end": None, + "read_start": None, + "read_end": None, + "request_end": None, + } + timings["request_start"] = time.monotonic() + + result, redirect, timings = await self._execute( + url, + method, + cookies=cookies, + auth=auth, + headers=headers, + data=data, + timings=timings, + ) + + if redirect: + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + for _ in range(redirects): + result, redirect, timings = await self._execute( + url, + method, + cookies=cookies, + auth=auth, + headers=headers, + data=data, + timings=timings, + upgrade_ssl=upgrade_ssl, + redirect_url=location, + ) + + if redirect is False: + break + + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + timings["request_end"] = time.monotonic() + result.timings.update(timings) + + return result + + async def _execute( + self, + request_url: str, + method: Literal["GET", "POST"], + cookies: Optional[List[HTTPCookie]] = None, + auth: Optional[Tuple[str, str]] = None, + headers: Optional[Dict[str, str]] = {}, + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) = None, + upgrade_ssl: bool = False, + redirect_url: Optional[str] = None, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ): + if redirect_url: + request_url = redirect_url + + connection: HTTP2Connection = None + + try: + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + (error, connection, pipe, url, upgrade_ssl) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=request_url if upgrade_ssl else None + ), + timeout=self.timeouts.connect_timeout, + ) + + if upgrade_ssl: + ssl_redirect_url = request_url.replace("http://", "https://") + + (error, connection, pipe, url, _) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=ssl_redirect_url + ), + timeout=self.timeouts.connect_timeout, + ) + + request_url = ssl_redirect_url + + if error: + timings["connect_end"] = time.monotonic() + + self._connections.append( + HTTP2Connection( + self._concurrency, + stream_id=randrange(1, 2**20 + 2, 2), + reset_connections=self._reset_connections, + ) + ) + + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + return ( + GraphQLHTTP2Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + method=method, + status=400, + status_message=str(error), + headers={ + key.encode(): value.encode() + for key, value in headers.items() + } + if headers + else {}, + timings=timings, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + if timings["write_start"] is None: + timings["write_start"] = time.monotonic() + + connection = pipe.send_preamble(connection) + + encoded_headers = self._encode_headers( + url, + method, + data, + headers=headers, + ) + + connection = pipe.send_request_headers( + encoded_headers, + data, + connection, + ) + + if method == "POST": + encoded_data = self._encode_data(data) + + connection = await asyncio.wait_for( + pipe.submit_request_body( + encoded_data, + connection, + ), + timeout=self.timeouts.write_timeout, + ) + + timings["write_end"] = time.monotonic() + + if timings["read_start"] is None: + timings["read_start"] = time.monotonic() + + (status, headers, body, error) = await asyncio.wait_for( + pipe.receive_response(connection), timeout=self.timeouts.read_timeout + ) + + if status >= 300 and status < 400: + timings["read_end"] = time.monotonic() + + self._connections.append( + HTTP2Connection( + self._concurrency, + stream_id=randrange(1, 2**20 + 2, 2), + reset_connections=self._reset_connections, + ) + ) + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + return ( + GraphQLHTTP2Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + method=method, + status=status, + headers=headers, + timings=timings, + ), + True, + timings, + ) + + if error: + raise error + + cookies: Union[Cookies, None] = None + cookies_data: Union[bytes, None] = headers.get("set-cookie") + if cookies_data: + cookies = Cookies() + cookies.update(cookies_data) + + self._connections.append(connection) + self._pipes.append(pipe) + + timings["read_end"] = time.monotonic() + + return ( + GraphQLHTTP2Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + cookies=cookies, + method=method, + status=status, + headers=headers, + content=body, + timings=timings, + ), + False, + timings, + ) + + except Exception as request_exception: + self._connections.append( + HTTP2Connection( + self._concurrency, + stream_id=randrange(1, 2**20 + 2, 2), + reset_connections=self._reset_connections, + ) + ) + + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + if isinstance(request_url, str): + request_url: ParseResult = urlparse(request_url) + + timings["read_end"] = time.monotonic() + + return ( + GraphQLHTTP2Response( + url=URLMetadata( + host=request_url.hostname, + path=request_url.path, + params=request_url.params, + query=request_url.query, + ), + method=method, + status=400, + status_message=str(request_exception), + timings=timings, + ), + False, + timings, + ) + + def _encode_data( + self, + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) = None, + ): + data: Dict[Literal["query", "operation_name", "variables"], str] = self.data + + source = Source(data.get("query")) + document_node = parse(source) + query_string = print_ast(document_node) + + query = {"query": query_string} + + operation_name = data.get("operation_name") + variables = data.get("variables") + + if operation_name: + query["operationName"] = operation_name + + if variables: + query["variables"] = variables + + encoded_data = orjson.dumps(query) + + return encoded_data + + def _encode_headers( + self, + url: URL, + method: Literal["GET", "POST"], + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) = None, + headers: Optional[Dict[str, str]] = None, + ): + url_path = url.path + if method == "GET": + query_string = data.get("query") + query_string = "".join(query_string.replace("query", "").split()) + + url_path += f"?query={{{query_string}}}" + + encoded_headers: List[Tuple[bytes, bytes]] = [ + (b":method", method.encode()), + (b":authority", url.hostname.encode()), + (b":scheme", url.scheme.encode()), + (b":path", url_path.encode()), + (b"user-agent", b"hyperscale"), + (b"content-type", b""), + ] + + if headers: + encoded_headers.extend( + [ + (k.lower().encode(), v.encode()) + for k, v in headers.items() + if k.lower() + not in ( + "host", + "transfer-encoding", + ) + ] + ) + + encoded_headers: bytes = self._encoder.encode(encoded_headers) + encoded_headers: List[bytes] = [ + encoded_headers[i : i + self._settings.max_frame_size] + for i in range(0, len(encoded_headers), self._settings.max_frame_size) + ] + + return encoded_headers[0] diff --git a/hyperscale/core_rewrite/engines/client/graphql_http2/models/__init__.py b/hyperscale/core_rewrite/engines/client/graphql_http2/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/__init__.py b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/__init__.py new file mode 100644 index 0000000..a6ac9df --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/__init__.py @@ -0,0 +1,5 @@ +from .graphql_http2_request import GraphQLHTTP2Request as GraphQLHTTP2Request +from .graphql_http2_response import GraphQLHTTP2Response as GraphQLHTTP2Response +from .optimized_http2_request import ( + OptimizedGraphQLHTTP2Request as OptimizedGraphQLHTTP2Request, +) diff --git a/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_request.py b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_request.py new file mode 100644 index 0000000..199aa9d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_request.py @@ -0,0 +1,97 @@ +from typing import ( + Dict, + List, + Literal, + Optional, + Tuple, +) +from urllib.parse import urlparse + +import orjson + +from hyperscale.core_rewrite.engines.client.http2.models.http2 import HTTP2Request +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, +) + + +def mock_fn(): + return None + + +try: + from graphql import Source, parse, print_ast + +except ImportError: + Source = None + parse = mock_fn + print_ast = mock_fn + + +class GraphQLHTTP2Request(HTTP2Request): + data: ( + Dict[Literal["query"], str] + | Dict[Literal["query", "operation_name", "variables"], str] + ) + + class Config: + arbitrary_types_allowed = True + + def parse_url(self): + return urlparse(self.url) + + def encode_headers(self, url: URL) -> List[Tuple[bytes, bytes]]: + url_path = url.path + + if self.method == "GET": + data: Dict[Literal["query", "operation_name", "variables"], str] = self.data + query_string = data.get("query") + query_string = "".join(query_string.replace("query", "").split()) + + url_path += f"?query={{{query_string}}}" + + encoded_headers = [ + (b":method", self.method.encode()), + (b":authority", url.hostname.encode()), + (b":scheme", url.scheme.encode()), + (b":path", url_path.encode()), + ] + + encoded_headers.extend( + [ + (k.lower().encode(), v.encode()) + for k, v in self.headers.items() + if k.lower() + not in ( + b"host", + b"transfer-encoding", + ) + ] + ) + + return encoded_headers + + def encode_data(self): + encoded_data: Optional[bytes] = None + + if self.method == "POST": + data: Dict[Literal["query", "operation_name", "variables"], str] = self.data + + source = Source(data.get("query")) + document_node = parse(source) + query_string = print_ast(document_node) + + query = {"query": query_string} + + operation_name = data.get("operation_name") + variables = data.get("variables") + + if operation_name: + query["operationName"] = operation_name + + if variables: + query["variables"] = variables + + encoded_data = orjson.dumps(query) + + return encoded_data diff --git a/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_response.py b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_response.py new file mode 100644 index 0000000..d315708 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/graphql_http2_response.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from hyperscale.core_rewrite.engines.client.http2.models.http2 import HTTP2Response + + +class GraphQLHTTP2Response(HTTP2Response): + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/optimized_http2_request.py b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/optimized_http2_request.py new file mode 100644 index 0000000..cd02ab3 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/graphql_http2/models/graphql_http2/optimized_http2_request.py @@ -0,0 +1,30 @@ +from typing import Literal, Optional, Tuple + +from pydantic import ( + BaseModel, + StrictBytes, + StrictInt, + StrictStr, +) + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedGraphQLHTTP2Request(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + method: Literal["GET", "POST"] + encoded_params: Optional[StrictStr | StrictBytes] = None + encoded_auth: Optional[StrictStr | StrictBytes] = None + encoded_cookies: Optional[ + StrictStr + | StrictBytes + | Tuple[StrictStr, StrictStr] + | Tuple[StrictBytes, StrictBytes] + ] = None + encoded_headers: Optional[StrictBytes] = None + ecoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/grpc/__init__.py b/hyperscale/core_rewrite/engines/client/grpc/__init__.py new file mode 100644 index 0000000..cde3da0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/grpc/__init__.py @@ -0,0 +1,6 @@ +from .mercury_sync_grpc_connection import ( + MercurySyncGRPCConnection as MercurySyncGRPCConnection, +) +from .models.grpc import GRPCRequest as GRPCRequest +from .models.grpc import GRPCResponse as GRPCResponse +from .models.grpc import OptimizedGRPCRequest as OptimizedGRPCRequest diff --git a/hyperscale/core_rewrite/engines/client/grpc/mercury_sync_grpc_connection.py b/hyperscale/core_rewrite/engines/client/grpc/mercury_sync_grpc_connection.py new file mode 100644 index 0000000..56d0d5a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/grpc/mercury_sync_grpc_connection.py @@ -0,0 +1,429 @@ +import asyncio +import ssl +import time +import uuid +from typing import ( + Dict, + Literal, + Optional, + Tuple, + TypeVar, + Union, +) +from urllib.parse import urlparse + +from hyperscale.core_rewrite.engines.client.http2 import MercurySyncHTTP2Connection +from hyperscale.core_rewrite.engines.client.http2.pipe import HTTP2Pipe +from hyperscale.core_rewrite.engines.client.http2.protocols import HTTP2Connection +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.grpc import ( + GRPCRequest, + GRPCResponse, +) + +T = TypeVar("T") + + +class MercurySyncGRPCConnection(MercurySyncHTTP2Connection): + def __init__( + self, + pool_size: int = 10**3, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + super(MercurySyncGRPCConnection, self).__init__( + pool_size=pool_size, + timeouts=timeouts, + reset_connections=reset_connections, + ) + + self.session_id = str(uuid.uuid4()) + + async def send( + self, + url: str, + protobuf: T, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ) -> GRPCResponse: + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + GRPCRequest(url=url, protobuf=protobuf, redirects=redirects), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return GRPCResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + status=408, + status_message="Request timed out.", + ) + + async def _request(self, request: GRPCRequest): + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = { + "request_start": None, + "connect_start": None, + "connect_end": None, + "write_start": None, + "write_end": None, + "read_start": None, + "read_end": None, + "request_end": None, + } + timings["request_start"] = time.monotonic() + + result, redirect, timings = await self._execute(request, timings=timings) + + if redirect: + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in request.url: + upgrade_ssl = True + + for _ in range(request.redirects): + result, redirect, timings = await self._execute( + request, + upgrade_ssl=upgrade_ssl, + redirect_url=location, + timings=timings, + ) + + if redirect is False: + break + + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in request.url: + upgrade_ssl = True + + timings["request_end"] = time.monotonic() + result.timings.update(timings) + + return result + + async def _execute( + self, + request: GRPCRequest, + upgrade_ssl: bool = False, + redirect_url: Optional[str] = None, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ): + if redirect_url: + request_url = redirect_url + + else: + request_url = request.url + + try: + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + (error, connection, pipe, url, upgrade_ssl) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=request_url if upgrade_ssl else None + ), + timeout=self.timeouts.connect_timeout, + ) + + if upgrade_ssl: + ssl_redirect_url = request_url.replace("http://", "https://") + + (error, connection, pipe, url, _) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=ssl_redirect_url + ), + timeout=self.timeouts.connect_timeout, + ) + + request_url = ssl_redirect_url + + headers = request.encode_headers(url) + + if error: + timings["connect_end"] = time.monotonic() + + self._connections.append( + HTTP2Connection( + self._concurrency, + stream_id=connection.stream.stream_id, + reset_connection=connection.reset_connection, + ) + ) + + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + return ( + GRPCResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method="POST", + status=400, + headers={ + key.encode(): value.encode() + for key, value in request.headers.items() + }, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + if timings["write_start"] is None: + timings["write_start"] = time.monotonic() + + connection = pipe.send_preamble(connection) + data = request.encode_data() + + connection = pipe.send_request_headers(headers, data, connection) + + if data: + connection = await asyncio.wait_for( + pipe.submit_request_body(data, connection), + timeout=self.timeouts.write_timeout, + ) + + timings["write_end"] = time.monotonic() + + if timings["read_start"] is None: + timings["read_start"] = time.monotonic() + + (status, headers, body, error) = await asyncio.wait_for( + pipe.receive_response(connection), timeout=self.timeouts.read_timeout + ) + + if status >= 300 and status < 400: + timings["read_end"] = time.monotonic() + + self._connections.append( + HTTP2Connection( + self._concurrency, + connection.stream.stream_id, + reset_connection=connection.reset_connection, + ) + ) + + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + return ( + GRPCResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method="POST", + status=status, + headers=headers, + ), + True, + timings, + ) + + if error: + raise error + + self._connections.append(connection) + self._pipes.append(pipe) + + timings["read_end"] = time.monotonic() + + return ( + GRPCResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method="POST", + status=status, + headers=headers, + content=body, + ), + False, + timings, + ) + + except Exception as request_exception: + self._connections.append( + HTTP2Connection( + self._concurrency, + connection.stream.stream_id, + reset_connection=connection.reset_connection, + ) + ) + + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + if isinstance(request_url, str): + request_url = urlparse(request_url) + + timings["read_end"] = time.monotonic() + + return ( + GRPCResponse( + url=URLMetadata(host=request_url.hostname, path=request_url.path), + method="POST", + status=400, + status_message=str(request_exception), + ), + False, + timings, + ) + + def _create_http2_ssl_context(self): + """ + This function creates an SSLContext object that is suitably configured for + HTTP/2. If you're working with Python TLS directly, you'll want to do the + exact same setup as this function does. + """ + # Get the basic context from the standard library. + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + + # RFC 7540 Section 9.2: Implementations of HTTP/2 MUST use TLS version 1.2 + # or higher. Disable TLS 1.1 and lower. + ctx.options |= ( + ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + ) + + # RFC 7540 Section 9.2.1: A deployment of HTTP/2 over TLS 1.2 MUST disable + # compression. + ctx.options |= ssl.OP_NO_COMPRESSION + + # RFC 7540 Section 9.2.2: "deployments of HTTP/2 that use TLS 1.2 MUST + # support TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256". In practice, the + # blocklist defined in this section allows only the AES GCM and ChaCha20 + # cipher suites with ephemeral key negotiation. + ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20") + + # We want to negotiate using NPN and ALPN. ALPN is mandatory, but NPN may + # be absent, so allow that. This setup allows for negotiation of HTTP/1.1. + ctx.set_alpn_protocols(["h2", "http/1.1"]) + + try: + if hasattr(ctx, "_set_npn_protocols"): + ctx.set_npn_protocols(["h2", "http/1.1"]) + except NotImplementedError: + pass + + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + + async def _connect_to_url_location( + self, request_url: str, ssl_redirect_url: Optional[str] = None + ) -> Tuple[Exception, HTTP2Connection, HTTP2Pipe, URL, bool]: + if ssl_redirect_url: + parsed_url = URL(ssl_redirect_url) + + else: + parsed_url = URL(request_url) + + url = self._url_cache.get(parsed_url.hostname) + dns_lock = self._dns_lock[parsed_url.hostname] + dns_waiter = self._dns_waiters[parsed_url.hostname] + + do_dns_lookup = url is None or ssl_redirect_url + + if do_dns_lookup and dns_lock.locked() is False: + await dns_lock.acquire() + url = parsed_url + await url.lookup() + + self._dns_lock[parsed_url.hostname] = dns_lock + self._url_cache[parsed_url.hostname] = url + + dns_waiter = self._dns_waiters[parsed_url.hostname] + + if dns_waiter.done() is False: + dns_waiter.set_result(None) + + dns_lock.release() + + elif do_dns_lookup: + await dns_waiter + url = self._url_cache.get(parsed_url.hostname) + + connection = self._connections.pop() + pipe = self._pipes.pop() + + connection_error: Optional[Exception] = None + + if url.address is None or ssl_redirect_url: + for address, ip_info in url: + try: + await connection.make_connection( + url.hostname, + address, + url.port, + ip_info, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + url.address = address + url.socket_config = ip_info + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return (None, None, None, parsed_url, True) + + else: + try: + await connection.make_connection( + url.hostname, + url.address, + url.port, + url.socket_config, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return (None, None, None, parsed_url, True) + + raise connection_error + + return (connection_error, connection, pipe, parsed_url, False) diff --git a/hyperscale/core_rewrite/engines/client/grpc/models/__init__.py b/hyperscale/core_rewrite/engines/client/grpc/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/grpc/models/grpc/__init__.py b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/__init__.py new file mode 100644 index 0000000..16e4bdf --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/__init__.py @@ -0,0 +1,3 @@ +from .grpc_request import GRPCRequest as GRPCRequest +from .grpc_response import GRPCResponse as GRPCResponse +from .optimized_grpc_request import OptimizedGRPCRequest as OptimizedGRPCRequest diff --git a/hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_request.py b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_request.py new file mode 100644 index 0000000..b39a733 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_request.py @@ -0,0 +1,62 @@ +import binascii +from typing import Any, Dict, List, Tuple +from urllib.parse import urlparse + +from google.protobuf.message import Message +from pydantic import BaseModel, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import URL + +NEW_LINE = "\r\n" + + +class GRPCRequest(BaseModel): + url: StrictStr + headers: Dict[str, str] = {} + protobuf: Any | Message = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True + + def parse_url(self): + return urlparse(self.url) + + def encode_data(self): + encoded_protobuf = str( + binascii.b2a_hex(self.protobuf.SerializeToString()), + encoding="raw_unicode_escape", + ) + encoded_message_length = ( + hex(int(len(encoded_protobuf) / 2)).lstrip("0x").zfill(8) + ) + encoded_protobuf = f"00{encoded_message_length}{encoded_protobuf}" + + return binascii.a2b_hex(encoded_protobuf) + + def encode_headers( + self, url: URL, timeout: int | float = 60 + ) -> List[Tuple[bytes, bytes]]: + encoded_headers = [ + (b":method", b"POST"), + (b":authority", url.hostname.encode()), + (b":scheme", url.scheme.encode()), + (b":path", url.path.encode()), + (b"Content-Type", b"application/grpc"), + (b"Grpc-Timeout", f"{timeout}".encode()), + (b"TE", b"trailers"), + ] + + encoded_headers.extend( + [ + (k.lower().encode(), v.encode()) + for k, v in self.headers.items() + if k.lower() + not in ( + b"host", + b"transfer-encoding", + ) + ] + ) + + return encoded_headers diff --git a/hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_response.py b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_response.py new file mode 100644 index 0000000..fe3b209 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/grpc_response.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import binascii +from typing import ( + Dict, + Literal, + Optional, +) + +from google.protobuf.message import Message +from pydantic import StrictBytes, StrictFloat, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.http2.models.http2 import HTTP2Response +from hyperscale.core_rewrite.engines.client.shared.models import ( + URLMetadata, +) + + +class GRPCResponse(HTTP2Response): + url: URLMetadata + method: Optional[ + Literal["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] + ] = FileNotFoundError + status: Optional[StrictInt] = None + status_message: Optional[StrictStr] = None + headers: Dict[StrictStr, StrictStr] = {} + content: StrictBytes = b"" + timings: Dict[StrictStr, StrictFloat] = {} + + class Config: + arbitrary_types_allowed = True + + def check_success(self) -> bool: + return self.status and self.status >= 200 and self.status < 300 + + @property + def data(self): + wire_msg = binascii.b2a_hex(self.content) + + message_length = wire_msg[4:10] + msg = wire_msg[10 : 10 + int(message_length, 16) * 2] + + return binascii.a2b_hex(msg) + + def parse(self, protobuf: Message): + protobuf.ParseFromString(self.data) + return protobuf diff --git a/hyperscale/core_rewrite/engines/client/grpc/models/grpc/optimized_grpc_request.py b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/optimized_grpc_request.py new file mode 100644 index 0000000..5762f6d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/grpc/models/grpc/optimized_grpc_request.py @@ -0,0 +1,17 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, StrictBytes, StrictInt + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedGRPCRequest(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + method: Literal["GET", "POST"] + encoded_headers: Optional[StrictBytes] = None + encoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/http/__init__.py b/hyperscale/core_rewrite/engines/client/http/__init__.py new file mode 100644 index 0000000..dc57438 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/__init__.py @@ -0,0 +1,6 @@ +from .mercury_sync_http_connection import ( + MercurySyncHTTPConnection as MercurySyncHTTPConnection, +) +from .models.http import HTTPRequest as HTTPRequest +from .models.http import HTTPResponse as HTTPResponse +from .models.http import OptimizedHTTPRequest as OptimizedHTTPRequest diff --git a/hyperscale/core_rewrite/engines/client/http/mercury_sync_http_connection.py b/hyperscale/core_rewrite/engines/client/http/mercury_sync_http_connection.py new file mode 100644 index 0000000..7dc7003 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/mercury_sync_http_connection.py @@ -0,0 +1,870 @@ +from __future__ import annotations + +import asyncio +import ssl +import time +from collections import defaultdict +from typing import ( + Dict, + List, + Literal, + Optional, + Tuple, + Union, +) +from urllib.parse import ( + ParseResult, + urlencode, + urlparse, +) + +import orjson +from pydantic import BaseModel + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + Cookies, + HTTPCookie, + HTTPEncodableValue, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.protocols import NEW_LINE +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.http import ( + HTTPRequest, + HTTPResponse, +) +from .protocols import HTTPConnection + + +class MercurySyncHTTPConnection: + def __init__( + self, + pool_size: Optional[int] = None, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + if pool_size is None: + pool_size = 100 + + self.timeouts = timeouts + self.reset_connections = reset_connections + + self._client_ssl_context = self._create_general_client_ssl_context() + + self._dns_lock: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self._dns_waiters: Dict[str, asyncio.Future] = defaultdict(asyncio.Future) + self._pending_queue: List[asyncio.Future] = [] + + self._client_waiters: Dict[asyncio.Transport, asyncio.Future] = {} + self._connections: List[HTTPConnection] = [ + HTTPConnection() for _ in range(pool_size) + ] + + self._hosts: Dict[str, Tuple[str, int]] = {} + + self._connections_count: Dict[str, List[asyncio.Transport]] = defaultdict(list) + self._locks: Dict[asyncio.Transport, asyncio.Lock] = {} + + self._max_concurrency = pool_size + + self._semaphore = asyncio.Semaphore(self._max_concurrency) + self._connection_waiters: List[asyncio.Future] = [] + + self._url_cache: Dict[str, URL] = {} + + def _create_general_client_ssl_context(self): + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + + async def head( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTPRequest( + url=url, + method="HEAD", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTPResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="HEAD", + status=408, + status_message="Request timed out.", + ) + + async def options( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTPRequest( + url=url, + method="OPTIONS", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTPResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="OPTIONS", + status=408, + status_message="Request timed out.", + ) + + async def get( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTPRequest( + url=url, + method="GET", + cookies=cookies, + data=None, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ) + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTPResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="GET", + status=408, + status_message="Request timed out.", + ) + + async def post( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTPRequest( + url=url, + method="POST", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTPResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="POST", + status=408, + status_message="Request timed out.", + ) + + async def put( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTPRequest( + url=url, + method="PUT", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTPResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="PUT", + status=408, + status_message="Request timed out.", + ) + + async def patch( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTPRequest( + url=url, + method="PATCH", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTPResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="PATCH", + status=408, + status_message="Request timed out.", + ) + + async def delete( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTPRequest( + url=url, + method="DELETE", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTPResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="DELETE", + status=408, + status_message="Request timed out.", + ) + + async def _request( + self, + url: str, + method: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = { + "request_start": None, + "connect_start": None, + "connect_end": None, + "write_start": None, + "write_end": None, + "read_start": None, + "read_end": None, + "request_end": None, + } + timings["request_start"] = time.monotonic() + + result, redirect, timings = await self._execute( + url, + method, + auth=auth, + cookies=cookies, + headers=headers, + params=params, + data=data, + timings=timings, + ) + + if redirect: + location = result.headers.get(b"location").decode() + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + for _ in range(redirects): + result, redirect, timings = await self._execute( + url, + method, + auth=auth, + cookies=cookies, + headers=headers, + params=params, + data=data, + upgrade_ssl=upgrade_ssl, + redirect_url=location, + timings=timings, + ) + + if redirect is False: + break + + location = result.headers.get(b"location").decode() + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + timings["request_end"] = time.monotonic() + result.timings.update(timings) + + return result + + async def _execute( + self, + request_url: str, + method: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + upgrade_ssl: bool = False, + redirect_url: Optional[str] = None, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ) -> Tuple[ + HTTPResponse, + bool, + Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ], + ]: + if redirect_url: + request_url = redirect_url + + try: + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + (connection, url, upgrade_ssl) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=request_url if upgrade_ssl else None + ), + timeout=self.timeouts.connect_timeout, + ) + + if upgrade_ssl: + ssl_redirect_url = request_url.replace("http://", "https://") + + connection, url, _ = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=ssl_redirect_url + ), + timeout=self.timeouts.connect_timeout, + ) + + request_url = ssl_redirect_url + + encoded_data: Optional[bytes] = None + content_type: Optional[str] = None + + if data: + encoded_data, content_type = self.encode_data(data) + + encoded_headers = self.encode_headers( + url, + method, + params=params, + headers=headers, + cookies=cookies, + data=encoded_data, + content_type=content_type, + ) + + if connection.reader is None: + timings["connect_end"] = time.monotonic() + self._connections.append( + HTTPConnection( + reset_connections=self.reset_connections, + ) + ) + + return ( + HTTPResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method=method, + status=400, + headers=headers, + timings=timings, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + if timings["write_start"] is None: + timings["write_start"] = time.monotonic() + + connection.write(encoded_headers) + + if data: + connection.write(encoded_data) + + timings["write_end"] = time.monotonic() + + if timings["read_start"] is None: + timings["read_start"] = time.monotonic() + + response_code = await asyncio.wait_for( + connection.reader.readline(), timeout=self.timeouts.read_timeout + ) + + headers: Dict[bytes, bytes] = await asyncio.wait_for( + connection.read_headers(), timeout=self.timeouts.read_timeout + ) + + status_string: List[bytes] = response_code.split() + status = int(status_string[1]) + + if status >= 300 and status < 400: + timings["read_end"] = time.monotonic() + self._connections.append(connection) + + return ( + HTTPResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method=method, + status=status, + headers=headers, + timings=timings, + ), + True, + timings, + ) + + content_length = headers.get(b"content-length") + transfer_encoding = headers.get(b"transfer-encoding") + + cookies: Union[Cookies, None] = None + cookies_data: Union[bytes, None] = headers.get(b"set-cookie") + if cookies_data: + cookies = Cookies() + cookies.update(cookies_data) + + # We require Content-Length or Transfer-Encoding headers to read a + # request body, otherwise it's anyone's guess as to how big the body + # is, and we ain't playing that game. + + if content_length: + body = await asyncio.wait_for( + connection.readexactly(int(content_length)), + timeout=self.timeouts.read_timeout, + ) + + elif transfer_encoding: + body = bytearray() + all_chunks_read = False + + while True and not all_chunks_read: + chunk_size = int( + ( + await asyncio.wait_for( + connection.readline(), + timeout=self.timeouts.read_timeout, + ) + ).rstrip(), + 16, + ) + + if not chunk_size: + # read last CRLF + await asyncio.wait_for( + connection.readline(), timeout=self.timeouts.read_timeout + ) + break + + chunk = await asyncio.wait_for( + connection.readexactly(chunk_size + 2), + self.timeouts.read_timeout, + ) + body.extend(chunk[:-2]) + + all_chunks_read = True + + self._connections.append(connection) + + timings["read_end"] = time.monotonic() + + return ( + HTTPResponse( + url=URLMetadata(host=url.hostname, path=url.path), + cookies=cookies, + method=method, + status=status, + headers=headers, + content=body, + timings=timings, + ), + False, + timings, + ) + + except Exception as request_exception: + self._connections.append( + HTTPConnection( + reset_connections=self.reset_connections, + ) + ) + + if isinstance(request_url, str): + request_url: ParseResult = urlparse(request_url) + + timings["read_end"] = time.monotonic() + + return ( + HTTPResponse( + url=URLMetadata(host=request_url.hostname, path=request_url.path), + method=method, + status=400, + status_message=str(request_exception), + timings=timings, + ), + False, + timings, + ) + + async def _connect_to_url_location( + self, + request_url: str, + ssl_redirect_url: Optional[str] = None, + ) -> Tuple[HTTPConnection, URL, bool]: + if ssl_redirect_url: + parsed_url = URL(ssl_redirect_url) + + else: + parsed_url = URL(request_url) + + url = self._url_cache.get(parsed_url.hostname) + dns_lock = self._dns_lock[parsed_url.hostname] + dns_waiter = self._dns_waiters[parsed_url.hostname] + + do_dns_lookup = url is None or ssl_redirect_url + + if do_dns_lookup and dns_lock.locked() is False: + await dns_lock.acquire() + url = parsed_url + await url.lookup() + + self._dns_lock[parsed_url.hostname] = dns_lock + self._url_cache[parsed_url.hostname] = url + + dns_waiter = self._dns_waiters[parsed_url.hostname] + + if dns_waiter.done() is False: + dns_waiter.set_result(None) + + dns_lock.release() + + elif do_dns_lookup: + await dns_waiter + url = self._url_cache.get(parsed_url.hostname) + + connection = self._connections.pop() + + if url.address is None or ssl_redirect_url: + for address, ip_info in url: + try: + await connection.make_connection( + url.hostname, + address, + url.port, + ip_info, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + url.address = address + url.socket_config = ip_info + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return None, parsed_url, True + + else: + try: + await connection.make_connection( + url.hostname, + url.address, + url.port, + url.socket_config, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return None, parsed_url, True + + raise connection_error + + return connection, parsed_url, False + + def encode_data( + self, + data: str | bytes | BaseModel | bytes, + ): + content_type: Optional[str] = None + if isinstance(data, BaseModel): + data = orjson.dumps(data.model_dump()) + content_type = "application/json" + + elif isinstance(data, (dict, list)): + data = orjson.dumps(data) + content_type = "application/json" + + elif isinstance(data, str): + data = data.encode() + + elif isinstance(data, (memoryview, bytearray)): + data = bytes(data) + + return data, content_type + + def encode_headers( + self, + url: URL, + method: str, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + headers: Optional[Dict[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + data: Optional[bytes] = None, + content_type: Optional[str] = None, + ): + url_path = url.path + + if params and len(params) > 0: + url_params = urlencode(params) + url_path += f"?{url_params}" + + get_base = f"{method} {url.path} HTTP/1.1{NEW_LINE}" + + port = url.port or (443 if url.scheme == "https" else 80) + + hostname = url.hostname.encode("idna").decode() + + if port not in [80, 443]: + hostname = f"{hostname}:{port}" + + header_items = { + "user-agent": "hyperscale", + } + + if data: + header_items["content-length"] = len(data) + + if content_type: + header_items["content-type"] = content_type + + if headers: + header_items.update(headers) + + for key, value in header_items.items(): + get_base += f"{key}: {value}{NEW_LINE}" + + if cookies: + cookies = [] + + for cookie_data in cookies: + if len(cookie_data) == 1: + cookies.append(cookie_data[0]) + + elif len(cookie_data) == 2: + cookie_name, cookie_value = cookie_data + cookies.append(f"{cookie_name}={cookie_value}") + + cookies = "; ".join(cookies) + get_base += f"cookie: {cookies}{NEW_LINE}" + + return (get_base + NEW_LINE).encode(), data diff --git a/hyperscale/core_rewrite/engines/client/http/models/__init__.py b/hyperscale/core_rewrite/engines/client/http/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http/models/http/__init__.py b/hyperscale/core_rewrite/engines/client/http/models/http/__init__.py new file mode 100644 index 0000000..ad00160 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/models/http/__init__.py @@ -0,0 +1,3 @@ +from .http_request import HTTPRequest as HTTPRequest +from .http_response import HTTPResponse as HTTPResponse +from .optimized_http_request import OptimizedHTTPRequest as OptimizedHTTPRequest diff --git a/hyperscale/core_rewrite/engines/client/http/models/http/http_request.py b/hyperscale/core_rewrite/engines/client/http/models/http/http_request.py new file mode 100644 index 0000000..78c6169 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/models/http/http_request.py @@ -0,0 +1,94 @@ +from typing import Dict, List, Literal, Optional, Tuple, Union +from urllib.parse import urlencode + +import orjson +from pydantic import BaseModel, StrictBytes, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + HTTPCookie, + HTTPEncodableValue, +) +from hyperscale.core_rewrite.engines.client.shared.protocols import NEW_LINE + + +class HTTPRequest(BaseModel): + url: StrictStr + method: Literal["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] + cookies: Optional[List[HTTPCookie]] = None + auth: Optional[Tuple[str, str]] = None + params: Optional[Dict[str, HTTPEncodableValue]] = None + headers: Dict[str, str] = {} + data: Union[Optional[StrictStr], Optional[StrictBytes], Optional[BaseModel]] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True + + def prepare(self, url: URL): + url_path = url.path + + if self.params and len(self.params) > 0: + url_params = urlencode(self.params) + url_path += f"?{url_params}" + + get_base = f"{self.method} {url.path} HTTP/1.1{NEW_LINE}" + + port = url.port or (443 if url.scheme == "https" else 80) + + hostname = url.hostname.encode("idna").decode() + + if port not in [80, 443]: + hostname = f"{hostname}:{port}" + + if isinstance(self.data, BaseModel): + data = orjson.dumps( + { + name: value + for name, value in self.data.__dict__.items() + if value is not None + } + ) + self.headers["content-type"] = "application/json" + + size = len(data) + + elif isinstance(self.data, str): + data = self.data.encode() + size = len(data) + + elif self.data: + data = self.data + size = len(self.data) + + else: + data = self.data + size = 0 + + header_items = [] + + if size > 0: + header_items = [ + ("Content-Length", size), + ] + + header_items.extend(self.headers.items()) + + for key, value in header_items: + get_base += f"{key}: {value}{NEW_LINE}" + + if self.cookies: + cookies = [] + + for cookie_data in self.cookies: + if len(cookie_data) == 1: + cookies.append(cookie_data[0]) + + elif len(cookie_data) == 2: + cookie_name, cookie_value = cookie_data + cookies.append(f"{cookie_name}={cookie_value}") + + cookies = "; ".join(cookies) + get_base += f"cookie: {cookies}{NEW_LINE}" + + return (get_base + NEW_LINE).encode(), data diff --git a/hyperscale/core_rewrite/engines/client/http/models/http/http_response.py b/hyperscale/core_rewrite/engines/client/http/models/http/http_response.py new file mode 100644 index 0000000..68dbdf2 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/models/http/http_response.py @@ -0,0 +1,99 @@ +import gzip +import re +from typing import Dict, Literal, Optional, Type, TypeVar, Union + +import orjson +from pydantic import BaseModel, StrictBytes, StrictFloat, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + Cookies, + URLMetadata, +) + +space_pattern = re.compile(r"\s+") + + +T = TypeVar('T', bound=BaseModel) + + +class HTTPResponse(BaseModel): + url: URLMetadata + method: Optional[ + Literal[ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "PATCH", + "DELETE" + ] + ]=None + cookies: Union[ + Optional[Cookies], + Optional[None] + ]=None + status: Optional[StrictInt]=None + status_message: Optional[StrictStr]=None + headers: Dict[StrictBytes, StrictBytes]={} + content: StrictBytes=b'' + timings: Dict[StrictStr, StrictFloat]={} + + class Config: + arbitrary_types_allowed=True + + def check_success(self) -> bool: + return ( + self.status and self.status >= 200 and self.status < 300 + ) + + def json(self): + + if self.content: + return orjson.loads( + self.content + ) + + return {} + + def text(self): + return self.content.decode() + + def to_model( + self, + model: Type[T] + ) -> T: + return model(**orjson.loads( + self.content + )) + + @property + def data( + self, + model: Optional[Type[T]]=None + ): + + content_type = self.headers.get('content-type') + + if model: + return self.to_model(model) + + try: + match content_type: + + case 'application/json': + return self.json() + + case 'text/plain': + return self.text() + + case 'application/gzip': + return gzip.decompress(self.content) + + case _: + return self.content + + except Exception: + return self.content + + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http/models/http/optimized_http_request.py b/hyperscale/core_rewrite/engines/client/http/models/http/optimized_http_request.py new file mode 100644 index 0000000..6adac00 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/models/http/optimized_http_request.py @@ -0,0 +1,38 @@ +from typing import Literal, Optional, Tuple + +from pydantic import ( + BaseModel, + StrictBytes, + StrictInt, + StrictStr, +) + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedHTTPRequest(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + method: Literal[ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "PATCH", + "DELETE", + ] + encoded_params: Optional[StrictStr | StrictBytes] = None + encoded_auth: Optional[StrictStr | StrictBytes] = None + encoded_cookies: Optional[ + StrictStr + | StrictBytes + | Tuple[StrictStr, StrictStr] + | Tuple[StrictBytes, StrictBytes] + ] = None + encoded_headers: Optional[StrictBytes] = None + ecoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/http/protocols/__init__.py b/hyperscale/core_rewrite/engines/client/http/protocols/__init__.py new file mode 100644 index 0000000..c42972b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/protocols/__init__.py @@ -0,0 +1 @@ +from .connection import HTTPConnection as HTTPConnection \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http/protocols/connection.py b/hyperscale/core_rewrite/engines/client/http/protocols/connection.py new file mode 100644 index 0000000..5e5c844 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/protocols/connection.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +from ssl import SSLContext +from typing import Dict, Optional, Tuple + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + _DEFAULT_LIMIT, + Reader, + Writer, +) + +from .tcp import TCPConnection + + +class HTTPConnection: + __slots__ = ( + "dns_address", + "port", + "ssl", + "ip_addr", + "lock", + "reader", + "writer", + "connected", + "reset_connections", + "pending", + "_connection_factory", + "_reader_and_writer", + "reset_connection", + ) + + def __init__(self, reset_connections: bool = False) -> None: + self.dns_address: str = None + self.port: int = None + self.ssl: SSLContext = None + self.ip_addr = None + self.lock = asyncio.Lock() + + self.reader: Reader = None + self.writer: Writer = None + + self._reader_and_writer: Dict[str, Tuple[Reader, Writer]] = {} + + self.connected = False + self.reset_connection = reset_connections + self.pending = 0 + self._connection_factory = TCPConnection() + + async def make_connection( + self, + hostname: str, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + ssl: Optional[SSLContext] = None, + timeout: Optional[float] = None, + ssl_upgrade: bool = False, + ) -> None: + if self._reader_and_writer.get(hostname) is None or ssl_upgrade: + reader, writer = await self._connection_factory.create( + hostname, socket_config, ssl=ssl + ) + + self.reader = reader + self.writer = writer + + self._reader_and_writer[hostname] = (reader, writer) + + self.dns_address = dns_address + self.port = port + self.ssl = ssl + else: + reader, writer = self._reader_and_writer.get(hostname) + + self.reader = reader + self.writer = writer + + @property + def empty(self): + return not self.reader._buffer + + def read(self): + return self.reader.read(n=_DEFAULT_LIMIT) + + def readexactly(self, n_bytes: int): + return self.reader.readexactly(n=n_bytes) + + def readuntil(self, sep=b"\n"): + return self.reader.readuntil(separator=sep) + + def readline(self): + return self.reader.readline() + + def write(self, data): + self.writer.write(data) + + def reset_buffer(self): + self.reader._buffer = bytearray() + + def read_headers(self): + return self.reader.read_headers() + + async def close(self): + try: + await self._connection_factory.close() + except Exception: + pass diff --git a/hyperscale/core_rewrite/engines/client/http/protocols/tcp/__init__.py b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/__init__.py new file mode 100644 index 0000000..85966f2 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/__init__.py @@ -0,0 +1 @@ +from .connection import TCPConnection diff --git a/hyperscale/core_rewrite/engines/client/http/protocols/tcp/connection.py b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/connection.py new file mode 100644 index 0000000..a8bca43 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/connection.py @@ -0,0 +1,68 @@ +import asyncio +import socket + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + _DEFAULT_LIMIT, + Reader, + Writer, +) + +from .protocol import TCPProtocol + + +class TCPConnection: + + def __init__(self) -> None: + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + self.transport = None + self._connection = None + self.socket: socket.socket = None + self._writer = None + + async def create( + self, + hostname=None, + socket_config=None, + *, + limit=_DEFAULT_LIMIT, + ssl=None + ): + self.loop = asyncio.get_event_loop() + + family, type_, proto, _, address = socket_config + + socket_family = socket.AF_INET6 if family == 2 else socket.AF_INET + + self.socket = socket.socket(family=family, type=type_, proto=proto) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + await self.loop.run_in_executor(None, self.socket.connect, address) + + self.socket.setblocking(False) + + reader = Reader(limit=limit, loop=self.loop) + reader_protocol = TCPProtocol(reader, loop=self.loop) + + if ssl is None: + hostname = None + + self.transport, _ = await self.loop.create_connection( + lambda: reader_protocol, + sock=self.socket, + family=socket_family, + server_hostname=hostname, + ssl=ssl + ) + + self._writer = Writer(self.transport, reader_protocol, reader, self.loop) + + return reader, self._writer + + async def close(self): + + try: + self.transport._ssl_protocol.pause_writing() + self.transport.close() + + except Exception: + pass \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http/protocols/tcp/protocol.py b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/protocol.py new file mode 100644 index 0000000..844f544 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/protocol.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from asyncio import Protocol, Transport +from asyncio.coroutines import iscoroutine +from weakref import ref + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + FlowControlMixin, + Reader, + Writer, +) + + +class TCPProtocol(FlowControlMixin, Protocol): + """Helper class to adapt between Protocol and StreamReader. + (This is a helper class instead of making StreamReader itself a + Protocol subclass, because the StreamReader has other potential + uses, and to prevent the user of the StreamReader to accidentally + call inappropriate methods of the protocol.) + """ + + __slots__ = ( + '_source_traceback', + '_reject_connection', + '_stream_writer', + '_transport', + '_client_connected_cb', + '_over_ssl', + '_closed', + '_stream_reader_wr' + ) + + def __init__(self, stream_reader: Reader, client_connected_cb=None, loop=None): + super().__init__(loop=loop) + self._source_traceback = None + + if stream_reader is not None: + self._stream_reader_wr: Reader = ref(stream_reader) + self._source_traceback = stream_reader._source_traceback + else: + self._stream_reader_wr = None + if client_connected_cb is not None: + # This is a stream created by the `create_server()` function. + # Keep a strong reference to the reader until a connection + # is established. + self._strong_reader = stream_reader + self._reject_connection = False + self._stream_writer: Writer = None + self._transport: Transport = None + self._client_connected_cb = client_connected_cb + self._over_ssl = False + self._closed = self._loop.create_future() + + @property + def _stream_reader(self) -> Reader: + if self._stream_reader_wr is None: + return None + return self._stream_reader_wr() + + def _replace_writer(self, writer: Writer): + transport = writer.transport + self._stream_writer = writer + self._transport = transport + self._over_ssl = transport.get_extra_info('sslcontext') is not None + + def connection_made(self, transport: Transport): + if self._reject_connection: + context = { + 'message': ('An open stream was garbage collected prior to ' + 'establishing network connection; ' + 'call "stream.close()" explicitly.') + } + if self._source_traceback: + context['source_traceback'] = self._source_traceback + self._loop.call_exception_handler(context) + transport.abort() + return + self._transport = transport + reader: Reader = self._stream_reader + if reader is not None: + reader.set_transport(transport) + self._over_ssl = transport.get_extra_info('sslcontext') is not None + if self._client_connected_cb is not None: + self._stream_writer = Reader(transport, self, + reader, + self._loop) + res = self._client_connected_cb(reader, + self._stream_writer) + if iscoroutine(res): + self._loop.create_task(res) + self._strong_reader = None + + def connection_lost(self, exc): + reader: Reader = self._stream_reader + if reader is not None: + if exc is None: + reader.feed_eof() + else: + reader.set_exception(exc) + if not self._closed.done(): + if exc is None: + self._closed.set_result(None) + else: + self._closed.set_exception(exc) + super().connection_lost(exc) + self._stream_reader_wr = None + self._stream_writer = None + self._transport = None + + def data_received(self, data): + reader = self._stream_reader + if reader is not None: + reader.feed_data(data) + + def eof_received(self): + reader: Reader = self._stream_reader + if reader is not None: + reader.feed_eof() + if self._over_ssl: + # Prevent a warning in SSLProtocol.eof_received: + # "returning true from eof_received() + # has no effect when using ssl" + return False + return True + + def _get_close_waiter(self, stream): + return self._closed + + def __del__(self): + # Prevent reports about unhandled exceptions. + # Better than self._closed._log_traceback = False hack + try: + closed = self._closed + except AttributeError: + pass # failed constructor + else: + if closed.done() and not closed.cancelled(): + closed.exception() + diff --git a/hyperscale/core_rewrite/engines/client/http/protocols/tcp/tls_protocol.py b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/tls_protocol.py new file mode 100644 index 0000000..1b1084c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http/protocols/tcp/tls_protocol.py @@ -0,0 +1,16 @@ +from weakref import ref + +from hyperscale.core_rewrite.engines.client.http.protocols.shared import Reader + +from .protocol import TCPProtocol + + +class TLSProtocol(TCPProtocol): + + def upgrade_reader(self, reader: Reader): + + if self._stream_reader: + self._stream_reader.set_exception(Exception('upgraded connection to TLS, this reader is obsolete now.')) + + self._stream_reader_wr = ref(reader) + self._source_traceback = reader._source_traceback \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/__init__.py b/hyperscale/core_rewrite/engines/client/http2/__init__.py new file mode 100644 index 0000000..41f8328 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/__init__.py @@ -0,0 +1,6 @@ +from .mercury_sync_http2_connection import ( + MercurySyncHTTP2Connection as MercurySyncHTTP2Connection, +) +from .models.http2 import HTTP2Request as HTTP2Request +from .models.http2 import HTTP2Response as HTTP2Response +from .models.http2 import OptimizedHTTP2Request as OptimizedHTTP2Request diff --git a/hyperscale/core_rewrite/engines/client/http2/config/__init__.py b/hyperscale/core_rewrite/engines/client/http2/config/__init__.py new file mode 100644 index 0000000..10b182a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/config/__init__.py @@ -0,0 +1 @@ +from .config import H2Configuration \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/config/boolean_configuration_option.py b/hyperscale/core_rewrite/engines/client/http2/config/boolean_configuration_option.py new file mode 100644 index 0000000..adb7e4d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/config/boolean_configuration_option.py @@ -0,0 +1,16 @@ +class BooleanConfigOption: + """ + Descriptor for handling a boolean config option. This will block + attempts to set boolean config options to non-bools. + """ + def __init__(self, name): + self.name = name + self.attr_name = '_%s' % self.name + + def __get__(self, instance, owner): + return getattr(instance, self.attr_name) + + def __set__(self, instance, value): + if not isinstance(value, bool): + raise ValueError("%s must be a bool" % self.name) + setattr(instance, self.attr_name, value) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/config/config.py b/hyperscale/core_rewrite/engines/client/http2/config/config.py new file mode 100644 index 0000000..cfa71c9 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/config/config.py @@ -0,0 +1,121 @@ +from .boolean_configuration_option import BooleanConfigOption + + +class H2Configuration: + """ + An object that controls the way a single HTTP/2 connection behaves. + + This object allows the users to customize behaviour. In particular, it + allows users to enable or disable optional features, or to otherwise handle + various unusual behaviours. + + This object has very little behaviour of its own: it mostly just ensures + that configuration is self-consistent. + + :param client_side: Whether this object is to be used on the client side of + a connection, or on the server side. Affects the logic used by the + state machine, the default settings values, the allowable stream IDs, + and several other properties. Defaults to ``True``. + :type client_side: ``bool`` + + :param header_encoding: Controls whether the headers emitted by this object + in events are transparently decoded to ``unicode`` strings, and what + encoding is used to do that decoding. This defaults to ``None``, + meaning that headers will be returned as bytes. To automatically + decode headers (that is, to return them as unicode strings), this can + be set to the string name of any encoding, e.g. ``'utf-8'``. + + .. versionchanged:: 3.0.0 + Changed default value from ``'utf-8'`` to ``None`` + + :type header_encoding: ``str``, ``False``, or ``None`` + + :param validate_outbound_headers: Controls whether the headers emitted + by this object are validated against the rules in RFC 7540. + Disabling this setting will cause outbound header validation to + be skipped, and allow the object to emit headers that may be illegal + according to RFC 7540. Defaults to ``True``. + :type validate_outbound_headers: ``bool`` + + :param normalize_outbound_headers: Controls whether the headers emitted + by this object are normalized before sending. Disabling this setting + will cause outbound header normalization to be skipped, and allow + the object to emit headers that may be illegal according to + RFC 7540. Defaults to ``True``. + :type normalize_outbound_headers: ``bool`` + + :param validate_inbound_headers: Controls whether the headers received + by this object are validated against the rules in RFC 7540. + Disabling this setting will cause inbound header validation to + be skipped, and allow the object to receive headers that may be illegal + according to RFC 7540. Defaults to ``True``. + :type validate_inbound_headers: ``bool`` + + :param normalize_inbound_headers: Controls whether the headers received by + this object are normalized according to the rules of RFC 7540. + Disabling this setting may lead to h2 emitting header blocks that + some RFCs forbid, e.g. with multiple cookie fields. + + .. versionadded:: 3.0.0 + + :type normalize_inbound_headers: ``bool`` + + :param logger: A logger that conforms to the requirements for this module, + those being no I/O and no context switches, which is needed in order + to run in asynchronous operation. + + .. versionadded:: 2.6.0 + + :type logger: ``logging.Logger`` + """ + client_side = BooleanConfigOption('client_side') + validate_outbound_headers = BooleanConfigOption( + 'validate_outbound_headers' + ) + normalize_outbound_headers = BooleanConfigOption( + 'normalize_outbound_headers' + ) + validate_inbound_headers = BooleanConfigOption( + 'validate_inbound_headers' + ) + normalize_inbound_headers = BooleanConfigOption( + 'normalize_inbound_headers' + ) + + def __init__(self, + client_side=True, + header_encoding=None, + validate_outbound_headers=True, + normalize_outbound_headers=True, + validate_inbound_headers=True, + normalize_inbound_headers=True, + logger=None): + self.client_side = client_side + self.header_encoding = header_encoding + self.validate_outbound_headers = validate_outbound_headers + self.normalize_outbound_headers = normalize_outbound_headers + self.validate_inbound_headers = validate_inbound_headers + self.normalize_inbound_headers = normalize_inbound_headers + + @property + def header_encoding(self): + """ + Controls whether the headers emitted by this object in events are + transparently decoded to ``unicode`` strings, and what encoding is used + to do that decoding. This defaults to ``None``, meaning that headers + will be returned as bytes. To automatically decode headers (that is, to + return them as unicode strings), this can be set to the string name of + any encoding, e.g. ``'utf-8'``. + """ + return self._header_encoding + + @header_encoding.setter + def header_encoding(self, value): + """ + Enforces constraints on the value of header encoding. + """ + if not isinstance(value, (bool, str, type(None))): + raise ValueError("header_encoding must be bool, string, or None") + if value is True: + raise ValueError("header_encoding cannot be True") + self._header_encoding = value \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/errors/__init__.py b/hyperscale/core_rewrite/engines/client/http2/errors/__init__.py new file mode 100644 index 0000000..abbfe7e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/errors/__init__.py @@ -0,0 +1,2 @@ +from .exceptions import StreamError, StreamResetException, StreamClosedError +from .types import ErrorCodes \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/errors/exceptions.py b/hyperscale/core_rewrite/engines/client/http2/errors/exceptions.py new file mode 100644 index 0000000..3514a12 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/errors/exceptions.py @@ -0,0 +1,47 @@ +from.types import ErrorCodes + + +class StreamError(Exception): + error_code = None + +class StreamResetException(Exception): + pass + + +class NoSuchStreamError(Exception): + """ + A stream-specific action referenced a stream that does not exist. + + .. versionchanged:: 2.0.0 + Became a subclass of :class:`ProtocolError + ` + """ + def __init__(self, stream_id): + #: The stream ID corresponds to the non-existent stream. + self.stream_id = stream_id + + +class StreamClosedError(Exception): + """ + A more specific form of + :class:`NoSuchStreamError `. Indicates + that the stream has since been closed, and that all state relating to that + stream has been removed. + """ + + __slots__ = ( + 'stream_id', + 'error_code', + 'events' + ) + + def __init__(self, stream_id): + #: The stream ID corresponds to the nonexistent stream. + self.stream_id = stream_id + + #: The relevant HTTP/2 error code. + self.error_code = ErrorCodes.STREAM_CLOSED + + # Any events that internal code may need to fire. Not relevant to + # external users that may receive a StreamClosedError. + self._events = [] diff --git a/hyperscale/core_rewrite/engines/client/http2/errors/types.py b/hyperscale/core_rewrite/engines/client/http2/errors/types.py new file mode 100644 index 0000000..a7a9590 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/errors/types.py @@ -0,0 +1,49 @@ +from enum import IntEnum + +class ErrorCodes(IntEnum): + """ + All known HTTP/2 error codes. + + .. versionadded:: 2.5.0 + """ + #: Graceful shutdown. + NO_ERROR = 0x0 + + #: Protocol error detected. + PROTOCOL_ERROR = 0x1 + + #: Implementation fault. + INTERNAL_ERROR = 0x2 + + #: Flow-control limits exceeded. + FLOW_CONTROL_ERROR = 0x3 + + #: Settings not acknowledged. + SETTINGS_TIMEOUT = 0x4 + + #: Frame received for closed stream. + STREAM_CLOSED = 0x5 + + #: Frame size incorrect. + FRAME_SIZE_ERROR = 0x6 + + #: Stream not processed. + REFUSED_STREAM = 0x7 + + #: Stream cancelled. + CANCEL = 0x8 + + #: Compression state not updated. + COMPRESSION_ERROR = 0x9 + + #: TCP connection error for CONNECT method. + CONNECT_ERROR = 0xa + + #: Processing capacity exceeded. + ENHANCE_YOUR_CALM = 0xb + + #: Negotiated TLS parameters not acceptable. + INADEQUATE_SECURITY = 0xc + + #: Use HTTP/1.1 for the request. + HTTP_1_1_REQUIRED = 0xd \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/__init__.py b/hyperscale/core_rewrite/engines/client/http2/events/__init__.py new file mode 100644 index 0000000..fce2e6f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/__init__.py @@ -0,0 +1,15 @@ +from .connection_terminated_event import ConnectionTerminated +from .data_received_event import DataReceived +from .headers_sent_event import HeadersSent +from .informational_respose_received_event import InformationalResponseReceived +from .remote_settings_changed_event import RemoteSettingsChanged +from .request_received_event import RequestReceived +from .request_sent_event import RequestSent +from .response_received_event import ResponseReceived +from .response_sent_event import ResponseSent +from .settings_acknowledged_event import SettingsAcknowledged +from .stream_ended_event import StreamEnded +from .stream_reset import StreamReset +from .trailers_received_event import TrailersReceived +from .trailers_sent_event import TrailersSent +from .window_updated_event import WindowUpdated diff --git a/hyperscale/core_rewrite/engines/client/http2/events/base_event.py b/hyperscale/core_rewrite/engines/client/http2/events/base_event.py new file mode 100644 index 0000000..3e26641 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/base_event.py @@ -0,0 +1,2 @@ +class BaseEvent: + error_code=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/connection_terminated_event.py b/hyperscale/core_rewrite/engines/client/http2/events/connection_terminated_event.py new file mode 100644 index 0000000..de44a33 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/connection_terminated_event.py @@ -0,0 +1,44 @@ +import binascii +from .base_event import BaseEvent + + + +class ConnectionTerminated(BaseEvent): + event_type='CONNECTION_TERMINATED' + + __slots__ = ( + 'error_code', + 'last_stream_id', + 'additional_data' + ) + + """ + The ConnectionTerminated event is fired when a connection is torn down by + the remote peer using a GOAWAY frame. Once received, no further action may + be taken on the connection: a new connection must be established. + """ + def __init__(self): + #: The error code cited when tearing down the connection. Should be + #: one of :class:`ErrorCodes `, but may not be if + #: unknown HTTP/2 extensions are being used. + self.error_code = None + + #: The stream ID of the last stream the remote peer saw. This can + #: provide an indication of what data, if any, never reached the remote + #: peer and so can safely be resent. + self.last_stream_id = None + + #: Additional debug data that can be appended to GOAWAY frame. + self.additional_data = None + + def __repr__(self): + additional_data = b'' + if self.additional_data: + additional_data = binascii.hexlify(self.additional_data[:20]).decode('ascii') + return ( + "" % ( + self.error_code, + self.last_stream_id, + additional_data + )) diff --git a/hyperscale/core_rewrite/engines/client/http2/events/data_received_event.py b/hyperscale/core_rewrite/engines/client/http2/events/data_received_event.py new file mode 100644 index 0000000..c9630d0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/data_received_event.py @@ -0,0 +1,49 @@ +import binascii +from .base_event import BaseEvent + + +class DataReceived(BaseEvent): + event_type='DATA_RECEIVED' + + __slots__ = ( + 'stream_id', + 'data', + 'flow_controlled_length', + 'stream_ended' + ) + + def __init__(self): + #: The Stream ID for the stream this data was received on. + self.stream_id = None + + #: The data itself. + self.data = None + + #: The amount of data received that counts against the flow control + #: window. Note that padding counts against the flow control window, so + #: when adjusting flow control you should always use this field rather + #: than ``len(data)``. + self.flow_controlled_length = None + + #: If this data chunk also completed the stream, the associated + #: :class:`StreamEnded ` event will be available + #: here. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + def __repr__(self): + + decoded_data = None + if self.data: + decoded_data = binascii.hexlify(self.data[:20]).decode('ascii') + + return ( + "" % ( + self.stream_id, + self.flow_controlled_length, + decoded_data, + ) + ) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/headers_sent_event.py b/hyperscale/core_rewrite/engines/client/http2/events/headers_sent_event.py new file mode 100644 index 0000000..5fe1614 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/headers_sent_event.py @@ -0,0 +1,13 @@ +from .base_event import BaseEvent + + +class HeadersSent(BaseEvent): + event_type='HEADERS_SENT' + """ + The _HeadersSent event is fired whenever headers are sent. + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass + diff --git a/hyperscale/core_rewrite/engines/client/http2/events/informational_respose_received_event.py b/hyperscale/core_rewrite/engines/client/http2/events/informational_respose_received_event.py new file mode 100644 index 0000000..fbf6c80 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/informational_respose_received_event.py @@ -0,0 +1,50 @@ +from .base_event import BaseEvent + + +class InformationalResponseReceived(BaseEvent): + event_type='INFORMATIONAL_RESPONSE_RECEIVED' + __slots__ = ( + 'stream_id', + 'headers', + 'priority_updated' + ) + """ + The InformationalResponseReceived event is fired when an informational + response (that is, one whose status code is a 1XX code) is received from + the remote peer. + + The remote peer may send any number of these, from zero upwards. These + responses are most commonly sent in response to requests that have the + ``expect: 100-continue`` header field present. Most users can safely + ignore this event unless you are intending to use the + ``expect: 100-continue`` flow, or are for any reason expecting a different + 1XX status code. + + .. versionadded:: 2.2.0 + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``priority_updated`` property. + """ + def __init__(self): + #: The Stream ID for the stream this informational response was made + #: on. + self.stream_id = None + + #: The headers for this informational response. + self.headers = None + + #: If this response also had associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/remote_settings_changed_event.py b/hyperscale/core_rewrite/engines/client/http2/events/remote_settings_changed_event.py new file mode 100644 index 0000000..4151803 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/remote_settings_changed_event.py @@ -0,0 +1,66 @@ +from hyperscale.core_rewrite.engines.client.http2.settings import ( + ChangedSetting, + SettingCodes, +) + +from .base_event import BaseEvent + + +class RemoteSettingsChanged(BaseEvent): + event_type='REMOTE_SETTINGS_CHANGED' + __slots__ = ( + 'changed_settings' + ) + """ + The RemoteSettingsChanged event is fired whenever the remote peer changes + its settings. It contains a complete inventory of changed settings, + including their previous values. + + In HTTP/2, settings changes need to be acknowledged. h2 automatically + acknowledges settings changes for efficiency. However, it is possible that + the caller may not be happy with the changed setting. + + When this event is received, the caller should confirm that the new + settings are acceptable. If they are not acceptable, the user should close + the connection with the error code :data:`PROTOCOL_ERROR + `. + + .. versionchanged:: 2.0.0 + Prior to this version the user needed to acknowledge settings changes. + This is no longer the case: h2 now automatically acknowledges + them. + """ + def __init__(self): + #: A dictionary of setting byte to + #: :class:`ChangedSetting `, representing + #: the changed settings. + self.changed_settings = {} + + @classmethod + def from_settings(cls, old_settings, new_settings): + """ + Build a RemoteSettingsChanged event from a set of changed settings. + + :param old_settings: A complete collection of old settings, in the form + of a dictionary of ``{setting: value}``. + :param new_settings: All the changed settings and their new values, in + the form of a dictionary of ``{setting: value}``. + """ + e = cls() + for setting, new_value in new_settings.items(): + + try: + setting = SettingCodes(setting) + except ValueError: + pass + + original_value = old_settings.get(setting) + change = ChangedSetting(setting, original_value, new_value) + e.changed_settings[setting] = change + + return e + + def __repr__(self): + return "" % ( + ", ".join(repr(cs) for cs in self.changed_settings.values()), + ) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/request_received_event.py b/hyperscale/core_rewrite/engines/client/http2/events/request_received_event.py new file mode 100644 index 0000000..d1f350c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/request_received_event.py @@ -0,0 +1,49 @@ +from .base_event import BaseEvent + + +class RequestReceived(BaseEvent): + event_type='REQUEST_RECEIVED' + __slots__ = ( + 'stream_id', + 'headers', + 'stream_ended', + 'priority_updated' + ) + + """ + The RequestReceived event is fired whenever request headers are received. + This event carries the HTTP headers for the given request and the stream ID + of the new stream. + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``stream_ended`` and ``priority_updated`` properties. + """ + def __init__(self): + #: The Stream ID for the stream this request was made on. + self.stream_id = None + + #: The request headers. + self.headers = None + + #: If this request also ended the stream, the associated + #: :class:`StreamEnded ` event will be available + #: here. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + #: If this request also had associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/request_sent_event.py b/hyperscale/core_rewrite/engines/client/http2/events/request_sent_event.py new file mode 100644 index 0000000..c233c03 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/request_sent_event.py @@ -0,0 +1,13 @@ +from .headers_sent_event import HeadersSent + + +class RequestSent(HeadersSent): + event_type='REQUEST_SENT' + """ + The _RequestSent event is fired whenever request headers are sent + on a stream. + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/response_received_event.py b/hyperscale/core_rewrite/engines/client/http2/events/response_received_event.py new file mode 100644 index 0000000..e11ef75 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/response_received_event.py @@ -0,0 +1,48 @@ +from .base_event import BaseEvent + + +class ResponseReceived(BaseEvent): + event_type='RESPONSE_RECEIVED' + __slots__ = ( + 'stream_id', + 'headers', + 'stream_ended', + 'priority_updated' + ) + """ + The ResponseReceived event is fired whenever response headers are received. + This event carries the HTTP headers for the given response and the stream + ID of the new stream. + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``stream_ended`` and ``priority_updated`` properties. + """ + def __init__(self): + #: The Stream ID for the stream this response was made on. + self.stream_id = None + + #: The response headers. + self.headers = None + + #: If this response also ended the stream, the associated + #: :class:`StreamEnded ` event will be available + #: here. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + #: If this response also had associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/response_sent_event.py b/hyperscale/core_rewrite/engines/client/http2/events/response_sent_event.py new file mode 100644 index 0000000..d1a24d8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/response_sent_event.py @@ -0,0 +1,12 @@ +from .headers_sent_event import HeadersSent + +class ResponseSent(HeadersSent): + event_type='RESPONSE_SENT' + """ + The _ResponseSent event is fired whenever response headers are sent + on a stream. + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass diff --git a/hyperscale/core_rewrite/engines/client/http2/events/settings_acknowledged_event.py b/hyperscale/core_rewrite/engines/client/http2/events/settings_acknowledged_event.py new file mode 100644 index 0000000..007889a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/settings_acknowledged_event.py @@ -0,0 +1,22 @@ +from .base_event import BaseEvent + + +class SettingsAcknowledged(BaseEvent): + event_type='SETTINGS_ACKNOWLEDGED' + __slots__ = ('changed_settings') + """ + The SettingsAcknowledged event is fired whenever a settings ACK is received + from the remote peer. The event carries on it the settings that were + acknowedged, in the same format as + :class:`h2.events.RemoteSettingsChanged`. + """ + def __init__(self): + #: A dictionary of setting byte to + #: :class:`ChangedSetting `, representing + #: the changed settings. + self.changed_settings = {} + + def __repr__(self): + return "" % ( + ", ".join(repr(cs) for cs in self.changed_settings.values()), + ) diff --git a/hyperscale/core_rewrite/engines/client/http2/events/stream_ended_event.py b/hyperscale/core_rewrite/engines/client/http2/events/stream_ended_event.py new file mode 100644 index 0000000..6fb6b8a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/stream_ended_event.py @@ -0,0 +1,17 @@ +from .base_event import BaseEvent + + +class StreamEnded(BaseEvent): + event_type='STREAM_ENDED' + __slots__ = ('stream_id') + """ + The StreamEnded event is fired whenever a stream is ended by a remote + party. The stream may not be fully closed if it has not been closed + locally, but no further data or headers should be expected on that stream. + """ + def __init__(self): + #: The Stream ID of the stream that was closed. + self.stream_id = None + + def __repr__(self): + return "" % self.stream_id diff --git a/hyperscale/core_rewrite/engines/client/http2/events/stream_reset.py b/hyperscale/core_rewrite/engines/client/http2/events/stream_reset.py new file mode 100644 index 0000000..bdb8db1 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/stream_reset.py @@ -0,0 +1,29 @@ +from .base_event import BaseEvent + +class StreamReset(BaseEvent): + event_type='STREAM_RESET' + __slots__ = ('stream_id', 'error_code', 'remote_reset') + """ + The StreamReset event is fired in two situations. The first is when the + remote party forcefully resets the stream. The second is when the remote + party has made a protocol error which only affects a single stream. In this + case, h2 will terminate the stream early and return this event. + + .. versionchanged:: 2.0.0 + This event is now fired when h2 automatically resets a stream. + """ + def __init__(self): + #: The Stream ID of the stream that was reset. + self.stream_id = None + + #: The error code given. Either one of :class:`ErrorCodes + #: ` or ``int`` + self.error_code = None + + #: Whether the remote peer sent a RST_STREAM or we did. + self.remote_reset = True + + def __repr__(self): + return "" % ( + self.stream_id, self.error_code, self.remote_reset + ) diff --git a/hyperscale/core_rewrite/engines/client/http2/events/trailers_received_event.py b/hyperscale/core_rewrite/engines/client/http2/events/trailers_received_event.py new file mode 100644 index 0000000..6c45e9a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/trailers_received_event.py @@ -0,0 +1,45 @@ +from .base_event import BaseEvent + + +class TrailersReceived(BaseEvent): + event_type='TRAILERS_RECEIVED' + __slots__ = ('stream_id', 'headers', 'stream_ended', 'priority_updated') + """ + The TrailersReceived event is fired whenever trailers are received on a + stream. Trailers are a set of headers sent after the body of the + request/response, and are used to provide information that wasn't known + ahead of time (e.g. content-length). This event carries the HTTP header + fields that form the trailers and the stream ID of the stream on which they + were received. + + .. versionchanged:: 2.3.0 + Changed the type of ``headers`` to :class:`HeaderTuple + `. This has no effect on current users. + + .. versionchanged:: 2.4.0 + Added ``stream_ended`` and ``priority_updated`` properties. + """ + def __init__(self): + #: The Stream ID for the stream on which these trailers were received. + self.stream_id = None + + #: The trailers themselves. + self.headers = None + + #: Trailers always end streams. This property has the associated + #: :class:`StreamEnded ` in it. + #: + #: .. versionadded:: 2.4.0 + self.stream_ended = None + + #: If the trailers also set associated priority information, the + #: associated :class:`PriorityUpdated ` + #: event will be available here. + #: + #: .. versionadded:: 2.4.0 + self.priority_updated = None + + def __repr__(self): + return "" % ( + self.stream_id, self.headers + ) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/events/trailers_sent_event.py b/hyperscale/core_rewrite/engines/client/http2/events/trailers_sent_event.py new file mode 100644 index 0000000..30031a5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/trailers_sent_event.py @@ -0,0 +1,15 @@ +from .headers_sent_event import HeadersSent + + +class TrailersSent(HeadersSent): + event_type='TRAILERS_SENT' + """ + The _TrailersSent event is fired whenever trailers are sent on a + stream. Trailers are a set of headers sent after the body of the + request/response, and are used to provide information that wasn't known + ahead of time (e.g. content-length). + + This is an internal event, used to determine validation steps on + outgoing header blocks. + """ + pass diff --git a/hyperscale/core_rewrite/engines/client/http2/events/window_updated_event.py b/hyperscale/core_rewrite/engines/client/http2/events/window_updated_event.py new file mode 100644 index 0000000..291727a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/events/window_updated_event.py @@ -0,0 +1,25 @@ +from .base_event import BaseEvent + + +class WindowUpdated(BaseEvent): + event_type='WINDOW_UPDATED' + __slots__ = ('stream_id', 'delta') + """ + The WindowUpdated event is fired whenever a flow control window changes + size. HTTP/2 defines flow control windows for connections and streams: this + event fires for both connections and streams. The event carries the ID of + the stream to which it applies (set to zero if the window update applies to + the connection), and the delta in the window size. + """ + def __init__(self): + #: The Stream ID of the stream whose flow control window was changed. + #: May be ``0`` if the connection window was changed. + self.stream_id = None + + #: The window delta. + self.delta = None + + def __repr__(self): + return "" % ( + self.stream_id, self.delta + ) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/fast_hpack/__init__.py b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/__init__.py new file mode 100644 index 0000000..8bfbb8f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +hpack +~~~~~ + +HTTP/2 header encoding for Python. +""" +from .hpack import Encoder, Decoder, HeaderTable + +__all__ = [ + 'Encoder', + 'Decoder', + 'HeaderTable' +] + +__version__ = '4.1.0+dev' diff --git a/hyperscale/core_rewrite/engines/client/http2/fast_hpack/hpack.py b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/hpack.py new file mode 100644 index 0000000..c2df836 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/hpack.py @@ -0,0 +1,5053 @@ +# -*- coding: utf-8 -*- +""" +hpack/hpack +~~~~~~~~~~~ + +Implements the HPACK header compression algorithm as detailed by the IETF. +""" +from .huffman import HuffmanEncoder +from .table import HeaderTable + +# Precompute 2^i for 1-8 for use in prefix calcs. +# Zero index is not used but there to save a subtraction +# as prefix numbers are not zero indexed. +_PREFIX_BIT_MAX_NUMBERS = [(2 ** i) - 1 for i in range(9)] +_HUFFMAN_TABLE = [ + # Node 0 (Root Node, never emits symbols.) + (4, 0, 0), + (5, 0, 0), + (7, 0, 0), + (8, 0, 0), + (11, 0, 0), + (12, 0, 0), + (16, 0, 0), + (19, 0, 0), + (25, 0, 0), + (28, 0, 0), + (32, 0, 0), + (35, 0, 0), + (42, 0, 0), + (49, 0, 0), + (57, 0, 0), + (64, 1, 0), + + # Node 1 + (0, 1 | (1 << 1), 48), + (0, 1 | (1 << 1), 49), + (0, 1 | (1 << 1), 50), + (0, 1 | (1 << 1), 97), + (0, 1 | (1 << 1), 99), + (0, 1 | (1 << 1), 101), + (0, 1 | (1 << 1), 105), + (0, 1 | (1 << 1), 111), + (0, 1 | (1 << 1), 115), + (0, 1 | (1 << 1), 116), + (13, 0, 0), + (14, 0, 0), + (17, 0, 0), + (18, 0, 0), + (20, 0, 0), + (21, 0, 0), + + # Node 2 + (1, (1 << 1), 48), + (22, 1 | (1 << 1), 48), + (1, (1 << 1), 49), + (22, 1 | (1 << 1), 49), + (1, (1 << 1), 50), + (22, 1 | (1 << 1), 50), + (1, (1 << 1), 97), + (22, 1 | (1 << 1), 97), + (1, (1 << 1), 99), + (22, 1 | (1 << 1), 99), + (1, (1 << 1), 101), + (22, 1 | (1 << 1), 101), + (1, (1 << 1), 105), + (22, 1 | (1 << 1), 105), + (1, (1 << 1), 111), + (22, 1 | (1 << 1), 111), + + # Node 3 + (2, (1 << 1), 48), + (9, (1 << 1), 48), + (23, (1 << 1), 48), + (40, 1 | (1 << 1), 48), + (2, (1 << 1), 49), + (9, (1 << 1), 49), + (23, (1 << 1), 49), + (40, 1 | (1 << 1), 49), + (2, (1 << 1), 50), + (9, (1 << 1), 50), + (23, (1 << 1), 50), + (40, 1 | (1 << 1), 50), + (2, (1 << 1), 97), + (9, (1 << 1), 97), + (23, (1 << 1), 97), + (40, 1 | (1 << 1), 97), + + # Node 4 + (3, (1 << 1), 48), + (6, (1 << 1), 48), + (10, (1 << 1), 48), + (15, (1 << 1), 48), + (24, (1 << 1), 48), + (31, (1 << 1), 48), + (41, (1 << 1), 48), + (56, 1 | (1 << 1), 48), + (3, (1 << 1), 49), + (6, (1 << 1), 49), + (10, (1 << 1), 49), + (15, (1 << 1), 49), + (24, (1 << 1), 49), + (31, (1 << 1), 49), + (41, (1 << 1), 49), + (56, 1 | (1 << 1), 49), + + # Node 5 + (3, (1 << 1), 50), + (6, (1 << 1), 50), + (10, (1 << 1), 50), + (15, (1 << 1), 50), + (24, (1 << 1), 50), + (31, (1 << 1), 50), + (41, (1 << 1), 50), + (56, 1 | (1 << 1), 50), + (3, (1 << 1), 97), + (6, (1 << 1), 97), + (10, (1 << 1), 97), + (15, (1 << 1), 97), + (24, (1 << 1), 97), + (31, (1 << 1), 97), + (41, (1 << 1), 97), + (56, 1 | (1 << 1), 97), + + # Node 6 + (2, (1 << 1), 99), + (9, (1 << 1), 99), + (23, (1 << 1), 99), + (40, 1 | (1 << 1), 99), + (2, (1 << 1), 101), + (9, (1 << 1), 101), + (23, (1 << 1), 101), + (40, 1 | (1 << 1), 101), + (2, (1 << 1), 105), + (9, (1 << 1), 105), + (23, (1 << 1), 105), + (40, 1 | (1 << 1), 105), + (2, (1 << 1), 111), + (9, (1 << 1), 111), + (23, (1 << 1), 111), + (40, 1 | (1 << 1), 111), + + # Node 7 + (3, (1 << 1), 99), + (6, (1 << 1), 99), + (10, (1 << 1), 99), + (15, (1 << 1), 99), + (24, (1 << 1), 99), + (31, (1 << 1), 99), + (41, (1 << 1), 99), + (56, 1 | (1 << 1), 99), + (3, (1 << 1), 101), + (6, (1 << 1), 101), + (10, (1 << 1), 101), + (15, (1 << 1), 101), + (24, (1 << 1), 101), + (31, (1 << 1), 101), + (41, (1 << 1), 101), + (56, 1 | (1 << 1), 101), + + # Node 8 + (3, (1 << 1), 105), + (6, (1 << 1), 105), + (10, (1 << 1), 105), + (15, (1 << 1), 105), + (24, (1 << 1), 105), + (31, (1 << 1), 105), + (41, (1 << 1), 105), + (56, 1 | (1 << 1), 105), + (3, (1 << 1), 111), + (6, (1 << 1), 111), + (10, (1 << 1), 111), + (15, (1 << 1), 111), + (24, (1 << 1), 111), + (31, (1 << 1), 111), + (41, (1 << 1), 111), + (56, 1 | (1 << 1), 111), + + # Node 9 + (1, (1 << 1), 115), + (22, 1 | (1 << 1), 115), + (1, (1 << 1), 116), + (22, 1 | (1 << 1), 116), + (0, 1 | (1 << 1), 32), + (0, 1 | (1 << 1), 37), + (0, 1 | (1 << 1), 45), + (0, 1 | (1 << 1), 46), + (0, 1 | (1 << 1), 47), + (0, 1 | (1 << 1), 51), + (0, 1 | (1 << 1), 52), + (0, 1 | (1 << 1), 53), + (0, 1 | (1 << 1), 54), + (0, 1 | (1 << 1), 55), + (0, 1 | (1 << 1), 56), + (0, 1 | (1 << 1), 57), + + # Node 10 + (2, (1 << 1), 115), + (9, (1 << 1), 115), + (23, (1 << 1), 115), + (40, 1 | (1 << 1), 115), + (2, (1 << 1), 116), + (9, (1 << 1), 116), + (23, (1 << 1), 116), + (40, 1 | (1 << 1), 116), + (1, (1 << 1), 32), + (22, 1 | (1 << 1), 32), + (1, (1 << 1), 37), + (22, 1 | (1 << 1), 37), + (1, (1 << 1), 45), + (22, 1 | (1 << 1), 45), + (1, (1 << 1), 46), + (22, 1 | (1 << 1), 46), + + # Node 11 + (3, (1 << 1), 115), + (6, (1 << 1), 115), + (10, (1 << 1), 115), + (15, (1 << 1), 115), + (24, (1 << 1), 115), + (31, (1 << 1), 115), + (41, (1 << 1), 115), + (56, 1 | (1 << 1), 115), + (3, (1 << 1), 116), + (6, (1 << 1), 116), + (10, (1 << 1), 116), + (15, (1 << 1), 116), + (24, (1 << 1), 116), + (31, (1 << 1), 116), + (41, (1 << 1), 116), + (56, 1 | (1 << 1), 116), + + # Node 12 + (2, (1 << 1), 32), + (9, (1 << 1), 32), + (23, (1 << 1), 32), + (40, 1 | (1 << 1), 32), + (2, (1 << 1), 37), + (9, (1 << 1), 37), + (23, (1 << 1), 37), + (40, 1 | (1 << 1), 37), + (2, (1 << 1), 45), + (9, (1 << 1), 45), + (23, (1 << 1), 45), + (40, 1 | (1 << 1), 45), + (2, (1 << 1), 46), + (9, (1 << 1), 46), + (23, (1 << 1), 46), + (40, 1 | (1 << 1), 46), + + # Node 13 + (3, (1 << 1), 32), + (6, (1 << 1), 32), + (10, (1 << 1), 32), + (15, (1 << 1), 32), + (24, (1 << 1), 32), + (31, (1 << 1), 32), + (41, (1 << 1), 32), + (56, 1 | (1 << 1), 32), + (3, (1 << 1), 37), + (6, (1 << 1), 37), + (10, (1 << 1), 37), + (15, (1 << 1), 37), + (24, (1 << 1), 37), + (31, (1 << 1), 37), + (41, (1 << 1), 37), + (56, 1 | (1 << 1), 37), + + # Node 14 + (3, (1 << 1), 45), + (6, (1 << 1), 45), + (10, (1 << 1), 45), + (15, (1 << 1), 45), + (24, (1 << 1), 45), + (31, (1 << 1), 45), + (41, (1 << 1), 45), + (56, 1 | (1 << 1), 45), + (3, (1 << 1), 46), + (6, (1 << 1), 46), + (10, (1 << 1), 46), + (15, (1 << 1), 46), + (24, (1 << 1), 46), + (31, (1 << 1), 46), + (41, (1 << 1), 46), + (56, 1 | (1 << 1), 46), + + # Node 15 + (1, (1 << 1), 47), + (22, 1 | (1 << 1), 47), + (1, (1 << 1), 51), + (22, 1 | (1 << 1), 51), + (1, (1 << 1), 52), + (22, 1 | (1 << 1), 52), + (1, (1 << 1), 53), + (22, 1 | (1 << 1), 53), + (1, (1 << 1), 54), + (22, 1 | (1 << 1), 54), + (1, (1 << 1), 55), + (22, 1 | (1 << 1), 55), + (1, (1 << 1), 56), + (22, 1 | (1 << 1), 56), + (1, (1 << 1), 57), + (22, 1 | (1 << 1), 57), + + # Node 16 + (2, (1 << 1), 47), + (9, (1 << 1), 47), + (23, (1 << 1), 47), + (40, 1 | (1 << 1), 47), + (2, (1 << 1), 51), + (9, (1 << 1), 51), + (23, (1 << 1), 51), + (40, 1 | (1 << 1), 51), + (2, (1 << 1), 52), + (9, (1 << 1), 52), + (23, (1 << 1), 52), + (40, 1 | (1 << 1), 52), + (2, (1 << 1), 53), + (9, (1 << 1), 53), + (23, (1 << 1), 53), + (40, 1 | (1 << 1), 53), + + # Node 17 + (3, (1 << 1), 47), + (6, (1 << 1), 47), + (10, (1 << 1), 47), + (15, (1 << 1), 47), + (24, (1 << 1), 47), + (31, (1 << 1), 47), + (41, (1 << 1), 47), + (56, 1 | (1 << 1), 47), + (3, (1 << 1), 51), + (6, (1 << 1), 51), + (10, (1 << 1), 51), + (15, (1 << 1), 51), + (24, (1 << 1), 51), + (31, (1 << 1), 51), + (41, (1 << 1), 51), + (56, 1 | (1 << 1), 51), + + # Node 18 + (3, (1 << 1), 52), + (6, (1 << 1), 52), + (10, (1 << 1), 52), + (15, (1 << 1), 52), + (24, (1 << 1), 52), + (31, (1 << 1), 52), + (41, (1 << 1), 52), + (56, 1 | (1 << 1), 52), + (3, (1 << 1), 53), + (6, (1 << 1), 53), + (10, (1 << 1), 53), + (15, (1 << 1), 53), + (24, (1 << 1), 53), + (31, (1 << 1), 53), + (41, (1 << 1), 53), + (56, 1 | (1 << 1), 53), + + # Node 19 + (2, (1 << 1), 54), + (9, (1 << 1), 54), + (23, (1 << 1), 54), + (40, 1 | (1 << 1), 54), + (2, (1 << 1), 55), + (9, (1 << 1), 55), + (23, (1 << 1), 55), + (40, 1 | (1 << 1), 55), + (2, (1 << 1), 56), + (9, (1 << 1), 56), + (23, (1 << 1), 56), + (40, 1 | (1 << 1), 56), + (2, (1 << 1), 57), + (9, (1 << 1), 57), + (23, (1 << 1), 57), + (40, 1 | (1 << 1), 57), + + # Node 20 + (3, (1 << 1), 54), + (6, (1 << 1), 54), + (10, (1 << 1), 54), + (15, (1 << 1), 54), + (24, (1 << 1), 54), + (31, (1 << 1), 54), + (41, (1 << 1), 54), + (56, 1 | (1 << 1), 54), + (3, (1 << 1), 55), + (6, (1 << 1), 55), + (10, (1 << 1), 55), + (15, (1 << 1), 55), + (24, (1 << 1), 55), + (31, (1 << 1), 55), + (41, (1 << 1), 55), + (56, 1 | (1 << 1), 55), + + # Node 21 + (3, (1 << 1), 56), + (6, (1 << 1), 56), + (10, (1 << 1), 56), + (15, (1 << 1), 56), + (24, (1 << 1), 56), + (31, (1 << 1), 56), + (41, (1 << 1), 56), + (56, 1 | (1 << 1), 56), + (3, (1 << 1), 57), + (6, (1 << 1), 57), + (10, (1 << 1), 57), + (15, (1 << 1), 57), + (24, (1 << 1), 57), + (31, (1 << 1), 57), + (41, (1 << 1), 57), + (56, 1 | (1 << 1), 57), + + # Node 22 + (26, 0, 0), + (27, 0, 0), + (29, 0, 0), + (30, 0, 0), + (33, 0, 0), + (34, 0, 0), + (36, 0, 0), + (37, 0, 0), + (43, 0, 0), + (46, 0, 0), + (50, 0, 0), + (53, 0, 0), + (58, 0, 0), + (61, 0, 0), + (65, 0, 0), + (68, 1, 0), + + # Node 23 + (0, 1 | (1 << 1), 61), + (0, 1 | (1 << 1), 65), + (0, 1 | (1 << 1), 95), + (0, 1 | (1 << 1), 98), + (0, 1 | (1 << 1), 100), + (0, 1 | (1 << 1), 102), + (0, 1 | (1 << 1), 103), + (0, 1 | (1 << 1), 104), + (0, 1 | (1 << 1), 108), + (0, 1 | (1 << 1), 109), + (0, 1 | (1 << 1), 110), + (0, 1 | (1 << 1), 112), + (0, 1 | (1 << 1), 114), + (0, 1 | (1 << 1), 117), + (38, 0, 0), + (39, 0, 0), + + # Node 24 + (1, (1 << 1), 61), + (22, 1 | (1 << 1), 61), + (1, (1 << 1), 65), + (22, 1 | (1 << 1), 65), + (1, (1 << 1), 95), + (22, 1 | (1 << 1), 95), + (1, (1 << 1), 98), + (22, 1 | (1 << 1), 98), + (1, (1 << 1), 100), + (22, 1 | (1 << 1), 100), + (1, (1 << 1), 102), + (22, 1 | (1 << 1), 102), + (1, (1 << 1), 103), + (22, 1 | (1 << 1), 103), + (1, (1 << 1), 104), + (22, 1 | (1 << 1), 104), + + # Node 25 + (2, (1 << 1), 61), + (9, (1 << 1), 61), + (23, (1 << 1), 61), + (40, 1 | (1 << 1), 61), + (2, (1 << 1), 65), + (9, (1 << 1), 65), + (23, (1 << 1), 65), + (40, 1 | (1 << 1), 65), + (2, (1 << 1), 95), + (9, (1 << 1), 95), + (23, (1 << 1), 95), + (40, 1 | (1 << 1), 95), + (2, (1 << 1), 98), + (9, (1 << 1), 98), + (23, (1 << 1), 98), + (40, 1 | (1 << 1), 98), + + # Node 26 + (3, (1 << 1), 61), + (6, (1 << 1), 61), + (10, (1 << 1), 61), + (15, (1 << 1), 61), + (24, (1 << 1), 61), + (31, (1 << 1), 61), + (41, (1 << 1), 61), + (56, 1 | (1 << 1), 61), + (3, (1 << 1), 65), + (6, (1 << 1), 65), + (10, (1 << 1), 65), + (15, (1 << 1), 65), + (24, (1 << 1), 65), + (31, (1 << 1), 65), + (41, (1 << 1), 65), + (56, 1 | (1 << 1), 65), + + # Node 27 + (3, (1 << 1), 95), + (6, (1 << 1), 95), + (10, (1 << 1), 95), + (15, (1 << 1), 95), + (24, (1 << 1), 95), + (31, (1 << 1), 95), + (41, (1 << 1), 95), + (56, 1 | (1 << 1), 95), + (3, (1 << 1), 98), + (6, (1 << 1), 98), + (10, (1 << 1), 98), + (15, (1 << 1), 98), + (24, (1 << 1), 98), + (31, (1 << 1), 98), + (41, (1 << 1), 98), + (56, 1 | (1 << 1), 98), + + # Node 28 + (2, (1 << 1), 100), + (9, (1 << 1), 100), + (23, (1 << 1), 100), + (40, 1 | (1 << 1), 100), + (2, (1 << 1), 102), + (9, (1 << 1), 102), + (23, (1 << 1), 102), + (40, 1 | (1 << 1), 102), + (2, (1 << 1), 103), + (9, (1 << 1), 103), + (23, (1 << 1), 103), + (40, 1 | (1 << 1), 103), + (2, (1 << 1), 104), + (9, (1 << 1), 104), + (23, (1 << 1), 104), + (40, 1 | (1 << 1), 104), + + # Node 29 + (3, (1 << 1), 100), + (6, (1 << 1), 100), + (10, (1 << 1), 100), + (15, (1 << 1), 100), + (24, (1 << 1), 100), + (31, (1 << 1), 100), + (41, (1 << 1), 100), + (56, 1 | (1 << 1), 100), + (3, (1 << 1), 102), + (6, (1 << 1), 102), + (10, (1 << 1), 102), + (15, (1 << 1), 102), + (24, (1 << 1), 102), + (31, (1 << 1), 102), + (41, (1 << 1), 102), + (56, 1 | (1 << 1), 102), + + # Node 30 + (3, (1 << 1), 103), + (6, (1 << 1), 103), + (10, (1 << 1), 103), + (15, (1 << 1), 103), + (24, (1 << 1), 103), + (31, (1 << 1), 103), + (41, (1 << 1), 103), + (56, 1 | (1 << 1), 103), + (3, (1 << 1), 104), + (6, (1 << 1), 104), + (10, (1 << 1), 104), + (15, (1 << 1), 104), + (24, (1 << 1), 104), + (31, (1 << 1), 104), + (41, (1 << 1), 104), + (56, 1 | (1 << 1), 104), + + # Node 31 + (1, (1 << 1), 108), + (22, 1 | (1 << 1), 108), + (1, (1 << 1), 109), + (22, 1 | (1 << 1), 109), + (1, (1 << 1), 110), + (22, 1 | (1 << 1), 110), + (1, (1 << 1), 112), + (22, 1 | (1 << 1), 112), + (1, (1 << 1), 114), + (22, 1 | (1 << 1), 114), + (1, (1 << 1), 117), + (22, 1 | (1 << 1), 117), + (0, 1 | (1 << 1), 58), + (0, 1 | (1 << 1), 66), + (0, 1 | (1 << 1), 67), + (0, 1 | (1 << 1), 68), + + # Node 32 + (2, (1 << 1), 108), + (9, (1 << 1), 108), + (23, (1 << 1), 108), + (40, 1 | (1 << 1), 108), + (2, (1 << 1), 109), + (9, (1 << 1), 109), + (23, (1 << 1), 109), + (40, 1 | (1 << 1), 109), + (2, (1 << 1), 110), + (9, (1 << 1), 110), + (23, (1 << 1), 110), + (40, 1 | (1 << 1), 110), + (2, (1 << 1), 112), + (9, (1 << 1), 112), + (23, (1 << 1), 112), + (40, 1 | (1 << 1), 112), + + # Node 33 + (3, (1 << 1), 108), + (6, (1 << 1), 108), + (10, (1 << 1), 108), + (15, (1 << 1), 108), + (24, (1 << 1), 108), + (31, (1 << 1), 108), + (41, (1 << 1), 108), + (56, 1 | (1 << 1), 108), + (3, (1 << 1), 109), + (6, (1 << 1), 109), + (10, (1 << 1), 109), + (15, (1 << 1), 109), + (24, (1 << 1), 109), + (31, (1 << 1), 109), + (41, (1 << 1), 109), + (56, 1 | (1 << 1), 109), + + # Node 34 + (3, (1 << 1), 110), + (6, (1 << 1), 110), + (10, (1 << 1), 110), + (15, (1 << 1), 110), + (24, (1 << 1), 110), + (31, (1 << 1), 110), + (41, (1 << 1), 110), + (56, 1 | (1 << 1), 110), + (3, (1 << 1), 112), + (6, (1 << 1), 112), + (10, (1 << 1), 112), + (15, (1 << 1), 112), + (24, (1 << 1), 112), + (31, (1 << 1), 112), + (41, (1 << 1), 112), + (56, 1 | (1 << 1), 112), + + # Node 35 + (2, (1 << 1), 114), + (9, (1 << 1), 114), + (23, (1 << 1), 114), + (40, 1 | (1 << 1), 114), + (2, (1 << 1), 117), + (9, (1 << 1), 117), + (23, (1 << 1), 117), + (40, 1 | (1 << 1), 117), + (1, (1 << 1), 58), + (22, 1 | (1 << 1), 58), + (1, (1 << 1), 66), + (22, 1 | (1 << 1), 66), + (1, (1 << 1), 67), + (22, 1 | (1 << 1), 67), + (1, (1 << 1), 68), + (22, 1 | (1 << 1), 68), + + # Node 36 + (3, (1 << 1), 114), + (6, (1 << 1), 114), + (10, (1 << 1), 114), + (15, (1 << 1), 114), + (24, (1 << 1), 114), + (31, (1 << 1), 114), + (41, (1 << 1), 114), + (56, 1 | (1 << 1), 114), + (3, (1 << 1), 117), + (6, (1 << 1), 117), + (10, (1 << 1), 117), + (15, (1 << 1), 117), + (24, (1 << 1), 117), + (31, (1 << 1), 117), + (41, (1 << 1), 117), + (56, 1 | (1 << 1), 117), + + # Node 37 + (2, (1 << 1), 58), + (9, (1 << 1), 58), + (23, (1 << 1), 58), + (40, 1 | (1 << 1), 58), + (2, (1 << 1), 66), + (9, (1 << 1), 66), + (23, (1 << 1), 66), + (40, 1 | (1 << 1), 66), + (2, (1 << 1), 67), + (9, (1 << 1), 67), + (23, (1 << 1), 67), + (40, 1 | (1 << 1), 67), + (2, (1 << 1), 68), + (9, (1 << 1), 68), + (23, (1 << 1), 68), + (40, 1 | (1 << 1), 68), + + # Node 38 + (3, (1 << 1), 58), + (6, (1 << 1), 58), + (10, (1 << 1), 58), + (15, (1 << 1), 58), + (24, (1 << 1), 58), + (31, (1 << 1), 58), + (41, (1 << 1), 58), + (56, 1 | (1 << 1), 58), + (3, (1 << 1), 66), + (6, (1 << 1), 66), + (10, (1 << 1), 66), + (15, (1 << 1), 66), + (24, (1 << 1), 66), + (31, (1 << 1), 66), + (41, (1 << 1), 66), + (56, 1 | (1 << 1), 66), + + # Node 39 + (3, (1 << 1), 67), + (6, (1 << 1), 67), + (10, (1 << 1), 67), + (15, (1 << 1), 67), + (24, (1 << 1), 67), + (31, (1 << 1), 67), + (41, (1 << 1), 67), + (56, 1 | (1 << 1), 67), + (3, (1 << 1), 68), + (6, (1 << 1), 68), + (10, (1 << 1), 68), + (15, (1 << 1), 68), + (24, (1 << 1), 68), + (31, (1 << 1), 68), + (41, (1 << 1), 68), + (56, 1 | (1 << 1), 68), + + # Node 40 + (44, 0, 0), + (45, 0, 0), + (47, 0, 0), + (48, 0, 0), + (51, 0, 0), + (52, 0, 0), + (54, 0, 0), + (55, 0, 0), + (59, 0, 0), + (60, 0, 0), + (62, 0, 0), + (63, 0, 0), + (66, 0, 0), + (67, 0, 0), + (69, 0, 0), + (72, 1, 0), + + # Node 41 + (0, 1 | (1 << 1), 69), + (0, 1 | (1 << 1), 70), + (0, 1 | (1 << 1), 71), + (0, 1 | (1 << 1), 72), + (0, 1 | (1 << 1), 73), + (0, 1 | (1 << 1), 74), + (0, 1 | (1 << 1), 75), + (0, 1 | (1 << 1), 76), + (0, 1 | (1 << 1), 77), + (0, 1 | (1 << 1), 78), + (0, 1 | (1 << 1), 79), + (0, 1 | (1 << 1), 80), + (0, 1 | (1 << 1), 81), + (0, 1 | (1 << 1), 82), + (0, 1 | (1 << 1), 83), + (0, 1 | (1 << 1), 84), + + # Node 42 + (1, (1 << 1), 69), + (22, 1 | (1 << 1), 69), + (1, (1 << 1), 70), + (22, 1 | (1 << 1), 70), + (1, (1 << 1), 71), + (22, 1 | (1 << 1), 71), + (1, (1 << 1), 72), + (22, 1 | (1 << 1), 72), + (1, (1 << 1), 73), + (22, 1 | (1 << 1), 73), + (1, (1 << 1), 74), + (22, 1 | (1 << 1), 74), + (1, (1 << 1), 75), + (22, 1 | (1 << 1), 75), + (1, (1 << 1), 76), + (22, 1 | (1 << 1), 76), + + # Node 43 + (2, (1 << 1), 69), + (9, (1 << 1), 69), + (23, (1 << 1), 69), + (40, 1 | (1 << 1), 69), + (2, (1 << 1), 70), + (9, (1 << 1), 70), + (23, (1 << 1), 70), + (40, 1 | (1 << 1), 70), + (2, (1 << 1), 71), + (9, (1 << 1), 71), + (23, (1 << 1), 71), + (40, 1 | (1 << 1), 71), + (2, (1 << 1), 72), + (9, (1 << 1), 72), + (23, (1 << 1), 72), + (40, 1 | (1 << 1), 72), + + # Node 44 + (3, (1 << 1), 69), + (6, (1 << 1), 69), + (10, (1 << 1), 69), + (15, (1 << 1), 69), + (24, (1 << 1), 69), + (31, (1 << 1), 69), + (41, (1 << 1), 69), + (56, 1 | (1 << 1), 69), + (3, (1 << 1), 70), + (6, (1 << 1), 70), + (10, (1 << 1), 70), + (15, (1 << 1), 70), + (24, (1 << 1), 70), + (31, (1 << 1), 70), + (41, (1 << 1), 70), + (56, 1 | (1 << 1), 70), + + # Node 45 + (3, (1 << 1), 71), + (6, (1 << 1), 71), + (10, (1 << 1), 71), + (15, (1 << 1), 71), + (24, (1 << 1), 71), + (31, (1 << 1), 71), + (41, (1 << 1), 71), + (56, 1 | (1 << 1), 71), + (3, (1 << 1), 72), + (6, (1 << 1), 72), + (10, (1 << 1), 72), + (15, (1 << 1), 72), + (24, (1 << 1), 72), + (31, (1 << 1), 72), + (41, (1 << 1), 72), + (56, 1 | (1 << 1), 72), + + # Node 46 + (2, (1 << 1), 73), + (9, (1 << 1), 73), + (23, (1 << 1), 73), + (40, 1 | (1 << 1), 73), + (2, (1 << 1), 74), + (9, (1 << 1), 74), + (23, (1 << 1), 74), + (40, 1 | (1 << 1), 74), + (2, (1 << 1), 75), + (9, (1 << 1), 75), + (23, (1 << 1), 75), + (40, 1 | (1 << 1), 75), + (2, (1 << 1), 76), + (9, (1 << 1), 76), + (23, (1 << 1), 76), + (40, 1 | (1 << 1), 76), + + # Node 47 + (3, (1 << 1), 73), + (6, (1 << 1), 73), + (10, (1 << 1), 73), + (15, (1 << 1), 73), + (24, (1 << 1), 73), + (31, (1 << 1), 73), + (41, (1 << 1), 73), + (56, 1 | (1 << 1), 73), + (3, (1 << 1), 74), + (6, (1 << 1), 74), + (10, (1 << 1), 74), + (15, (1 << 1), 74), + (24, (1 << 1), 74), + (31, (1 << 1), 74), + (41, (1 << 1), 74), + (56, 1 | (1 << 1), 74), + + # Node 48 + (3, (1 << 1), 75), + (6, (1 << 1), 75), + (10, (1 << 1), 75), + (15, (1 << 1), 75), + (24, (1 << 1), 75), + (31, (1 << 1), 75), + (41, (1 << 1), 75), + (56, 1 | (1 << 1), 75), + (3, (1 << 1), 76), + (6, (1 << 1), 76), + (10, (1 << 1), 76), + (15, (1 << 1), 76), + (24, (1 << 1), 76), + (31, (1 << 1), 76), + (41, (1 << 1), 76), + (56, 1 | (1 << 1), 76), + + # Node 49 + (1, (1 << 1), 77), + (22, 1 | (1 << 1), 77), + (1, (1 << 1), 78), + (22, 1 | (1 << 1), 78), + (1, (1 << 1), 79), + (22, 1 | (1 << 1), 79), + (1, (1 << 1), 80), + (22, 1 | (1 << 1), 80), + (1, (1 << 1), 81), + (22, 1 | (1 << 1), 81), + (1, (1 << 1), 82), + (22, 1 | (1 << 1), 82), + (1, (1 << 1), 83), + (22, 1 | (1 << 1), 83), + (1, (1 << 1), 84), + (22, 1 | (1 << 1), 84), + + # Node 50 + (2, (1 << 1), 77), + (9, (1 << 1), 77), + (23, (1 << 1), 77), + (40, 1 | (1 << 1), 77), + (2, (1 << 1), 78), + (9, (1 << 1), 78), + (23, (1 << 1), 78), + (40, 1 | (1 << 1), 78), + (2, (1 << 1), 79), + (9, (1 << 1), 79), + (23, (1 << 1), 79), + (40, 1 | (1 << 1), 79), + (2, (1 << 1), 80), + (9, (1 << 1), 80), + (23, (1 << 1), 80), + (40, 1 | (1 << 1), 80), + + # Node 51 + (3, (1 << 1), 77), + (6, (1 << 1), 77), + (10, (1 << 1), 77), + (15, (1 << 1), 77), + (24, (1 << 1), 77), + (31, (1 << 1), 77), + (41, (1 << 1), 77), + (56, 1 | (1 << 1), 77), + (3, (1 << 1), 78), + (6, (1 << 1), 78), + (10, (1 << 1), 78), + (15, (1 << 1), 78), + (24, (1 << 1), 78), + (31, (1 << 1), 78), + (41, (1 << 1), 78), + (56, 1 | (1 << 1), 78), + + # Node 52 + (3, (1 << 1), 79), + (6, (1 << 1), 79), + (10, (1 << 1), 79), + (15, (1 << 1), 79), + (24, (1 << 1), 79), + (31, (1 << 1), 79), + (41, (1 << 1), 79), + (56, 1 | (1 << 1), 79), + (3, (1 << 1), 80), + (6, (1 << 1), 80), + (10, (1 << 1), 80), + (15, (1 << 1), 80), + (24, (1 << 1), 80), + (31, (1 << 1), 80), + (41, (1 << 1), 80), + (56, 1 | (1 << 1), 80), + + # Node 53 + (2, (1 << 1), 81), + (9, (1 << 1), 81), + (23, (1 << 1), 81), + (40, 1 | (1 << 1), 81), + (2, (1 << 1), 82), + (9, (1 << 1), 82), + (23, (1 << 1), 82), + (40, 1 | (1 << 1), 82), + (2, (1 << 1), 83), + (9, (1 << 1), 83), + (23, (1 << 1), 83), + (40, 1 | (1 << 1), 83), + (2, (1 << 1), 84), + (9, (1 << 1), 84), + (23, (1 << 1), 84), + (40, 1 | (1 << 1), 84), + + # Node 54 + (3, (1 << 1), 81), + (6, (1 << 1), 81), + (10, (1 << 1), 81), + (15, (1 << 1), 81), + (24, (1 << 1), 81), + (31, (1 << 1), 81), + (41, (1 << 1), 81), + (56, 1 | (1 << 1), 81), + (3, (1 << 1), 82), + (6, (1 << 1), 82), + (10, (1 << 1), 82), + (15, (1 << 1), 82), + (24, (1 << 1), 82), + (31, (1 << 1), 82), + (41, (1 << 1), 82), + (56, 1 | (1 << 1), 82), + + # Node 55 + (3, (1 << 1), 83), + (6, (1 << 1), 83), + (10, (1 << 1), 83), + (15, (1 << 1), 83), + (24, (1 << 1), 83), + (31, (1 << 1), 83), + (41, (1 << 1), 83), + (56, 1 | (1 << 1), 83), + (3, (1 << 1), 84), + (6, (1 << 1), 84), + (10, (1 << 1), 84), + (15, (1 << 1), 84), + (24, (1 << 1), 84), + (31, (1 << 1), 84), + (41, (1 << 1), 84), + (56, 1 | (1 << 1), 84), + + # Node 56 + (0, 1 | (1 << 1), 85), + (0, 1 | (1 << 1), 86), + (0, 1 | (1 << 1), 87), + (0, 1 | (1 << 1), 89), + (0, 1 | (1 << 1), 106), + (0, 1 | (1 << 1), 107), + (0, 1 | (1 << 1), 113), + (0, 1 | (1 << 1), 118), + (0, 1 | (1 << 1), 119), + (0, 1 | (1 << 1), 120), + (0, 1 | (1 << 1), 121), + (0, 1 | (1 << 1), 122), + (70, 0, 0), + (71, 0, 0), + (73, 0, 0), + (74, 1, 0), + + # Node 57 + (1, (1 << 1), 85), + (22, 1 | (1 << 1), 85), + (1, (1 << 1), 86), + (22, 1 | (1 << 1), 86), + (1, (1 << 1), 87), + (22, 1 | (1 << 1), 87), + (1, (1 << 1), 89), + (22, 1 | (1 << 1), 89), + (1, (1 << 1), 106), + (22, 1 | (1 << 1), 106), + (1, (1 << 1), 107), + (22, 1 | (1 << 1), 107), + (1, (1 << 1), 113), + (22, 1 | (1 << 1), 113), + (1, (1 << 1), 118), + (22, 1 | (1 << 1), 118), + + # Node 58 + (2, (1 << 1), 85), + (9, (1 << 1), 85), + (23, (1 << 1), 85), + (40, 1 | (1 << 1), 85), + (2, (1 << 1), 86), + (9, (1 << 1), 86), + (23, (1 << 1), 86), + (40, 1 | (1 << 1), 86), + (2, (1 << 1), 87), + (9, (1 << 1), 87), + (23, (1 << 1), 87), + (40, 1 | (1 << 1), 87), + (2, (1 << 1), 89), + (9, (1 << 1), 89), + (23, (1 << 1), 89), + (40, 1 | (1 << 1), 89), + + # Node 59 + (3, (1 << 1), 85), + (6, (1 << 1), 85), + (10, (1 << 1), 85), + (15, (1 << 1), 85), + (24, (1 << 1), 85), + (31, (1 << 1), 85), + (41, (1 << 1), 85), + (56, 1 | (1 << 1), 85), + (3, (1 << 1), 86), + (6, (1 << 1), 86), + (10, (1 << 1), 86), + (15, (1 << 1), 86), + (24, (1 << 1), 86), + (31, (1 << 1), 86), + (41, (1 << 1), 86), + (56, 1 | (1 << 1), 86), + + # Node 60 + (3, (1 << 1), 87), + (6, (1 << 1), 87), + (10, (1 << 1), 87), + (15, (1 << 1), 87), + (24, (1 << 1), 87), + (31, (1 << 1), 87), + (41, (1 << 1), 87), + (56, 1 | (1 << 1), 87), + (3, (1 << 1), 89), + (6, (1 << 1), 89), + (10, (1 << 1), 89), + (15, (1 << 1), 89), + (24, (1 << 1), 89), + (31, (1 << 1), 89), + (41, (1 << 1), 89), + (56, 1 | (1 << 1), 89), + + # Node 61 + (2, (1 << 1), 106), + (9, (1 << 1), 106), + (23, (1 << 1), 106), + (40, 1 | (1 << 1), 106), + (2, (1 << 1), 107), + (9, (1 << 1), 107), + (23, (1 << 1), 107), + (40, 1 | (1 << 1), 107), + (2, (1 << 1), 113), + (9, (1 << 1), 113), + (23, (1 << 1), 113), + (40, 1 | (1 << 1), 113), + (2, (1 << 1), 118), + (9, (1 << 1), 118), + (23, (1 << 1), 118), + (40, 1 | (1 << 1), 118), + + # Node 62 + (3, (1 << 1), 106), + (6, (1 << 1), 106), + (10, (1 << 1), 106), + (15, (1 << 1), 106), + (24, (1 << 1), 106), + (31, (1 << 1), 106), + (41, (1 << 1), 106), + (56, 1 | (1 << 1), 106), + (3, (1 << 1), 107), + (6, (1 << 1), 107), + (10, (1 << 1), 107), + (15, (1 << 1), 107), + (24, (1 << 1), 107), + (31, (1 << 1), 107), + (41, (1 << 1), 107), + (56, 1 | (1 << 1), 107), + + # Node 63 + (3, (1 << 1), 113), + (6, (1 << 1), 113), + (10, (1 << 1), 113), + (15, (1 << 1), 113), + (24, (1 << 1), 113), + (31, (1 << 1), 113), + (41, (1 << 1), 113), + (56, 1 | (1 << 1), 113), + (3, (1 << 1), 118), + (6, (1 << 1), 118), + (10, (1 << 1), 118), + (15, (1 << 1), 118), + (24, (1 << 1), 118), + (31, (1 << 1), 118), + (41, (1 << 1), 118), + (56, 1 | (1 << 1), 118), + + # Node 64 + (1, (1 << 1), 119), + (22, 1 | (1 << 1), 119), + (1, (1 << 1), 120), + (22, 1 | (1 << 1), 120), + (1, (1 << 1), 121), + (22, 1 | (1 << 1), 121), + (1, (1 << 1), 122), + (22, 1 | (1 << 1), 122), + (0, 1 | (1 << 1), 38), + (0, 1 | (1 << 1), 42), + (0, 1 | (1 << 1), 44), + (0, 1 | (1 << 1), 59), + (0, 1 | (1 << 1), 88), + (0, 1 | (1 << 1), 90), + (75, 0, 0), + (78, 0, 0), + + # Node 65 + (2, (1 << 1), 119), + (9, (1 << 1), 119), + (23, (1 << 1), 119), + (40, 1 | (1 << 1), 119), + (2, (1 << 1), 120), + (9, (1 << 1), 120), + (23, (1 << 1), 120), + (40, 1 | (1 << 1), 120), + (2, (1 << 1), 121), + (9, (1 << 1), 121), + (23, (1 << 1), 121), + (40, 1 | (1 << 1), 121), + (2, (1 << 1), 122), + (9, (1 << 1), 122), + (23, (1 << 1), 122), + (40, 1 | (1 << 1), 122), + + # Node 66 + (3, (1 << 1), 119), + (6, (1 << 1), 119), + (10, (1 << 1), 119), + (15, (1 << 1), 119), + (24, (1 << 1), 119), + (31, (1 << 1), 119), + (41, (1 << 1), 119), + (56, 1 | (1 << 1), 119), + (3, (1 << 1), 120), + (6, (1 << 1), 120), + (10, (1 << 1), 120), + (15, (1 << 1), 120), + (24, (1 << 1), 120), + (31, (1 << 1), 120), + (41, (1 << 1), 120), + (56, 1 | (1 << 1), 120), + + # Node 67 + (3, (1 << 1), 121), + (6, (1 << 1), 121), + (10, (1 << 1), 121), + (15, (1 << 1), 121), + (24, (1 << 1), 121), + (31, (1 << 1), 121), + (41, (1 << 1), 121), + (56, 1 | (1 << 1), 121), + (3, (1 << 1), 122), + (6, (1 << 1), 122), + (10, (1 << 1), 122), + (15, (1 << 1), 122), + (24, (1 << 1), 122), + (31, (1 << 1), 122), + (41, (1 << 1), 122), + (56, 1 | (1 << 1), 122), + + # Node 68 + (1, (1 << 1), 38), + (22, 1 | (1 << 1), 38), + (1, (1 << 1), 42), + (22, 1 | (1 << 1), 42), + (1, (1 << 1), 44), + (22, 1 | (1 << 1), 44), + (1, (1 << 1), 59), + (22, 1 | (1 << 1), 59), + (1, (1 << 1), 88), + (22, 1 | (1 << 1), 88), + (1, (1 << 1), 90), + (22, 1 | (1 << 1), 90), + (76, 0, 0), + (77, 0, 0), + (79, 0, 0), + (81, 0, 0), + + # Node 69 + (2, (1 << 1), 38), + (9, (1 << 1), 38), + (23, (1 << 1), 38), + (40, 1 | (1 << 1), 38), + (2, (1 << 1), 42), + (9, (1 << 1), 42), + (23, (1 << 1), 42), + (40, 1 | (1 << 1), 42), + (2, (1 << 1), 44), + (9, (1 << 1), 44), + (23, (1 << 1), 44), + (40, 1 | (1 << 1), 44), + (2, (1 << 1), 59), + (9, (1 << 1), 59), + (23, (1 << 1), 59), + (40, 1 | (1 << 1), 59), + + # Node 70 + (3, (1 << 1), 38), + (6, (1 << 1), 38), + (10, (1 << 1), 38), + (15, (1 << 1), 38), + (24, (1 << 1), 38), + (31, (1 << 1), 38), + (41, (1 << 1), 38), + (56, 1 | (1 << 1), 38), + (3, (1 << 1), 42), + (6, (1 << 1), 42), + (10, (1 << 1), 42), + (15, (1 << 1), 42), + (24, (1 << 1), 42), + (31, (1 << 1), 42), + (41, (1 << 1), 42), + (56, 1 | (1 << 1), 42), + + # Node 71 + (3, (1 << 1), 44), + (6, (1 << 1), 44), + (10, (1 << 1), 44), + (15, (1 << 1), 44), + (24, (1 << 1), 44), + (31, (1 << 1), 44), + (41, (1 << 1), 44), + (56, 1 | (1 << 1), 44), + (3, (1 << 1), 59), + (6, (1 << 1), 59), + (10, (1 << 1), 59), + (15, (1 << 1), 59), + (24, (1 << 1), 59), + (31, (1 << 1), 59), + (41, (1 << 1), 59), + (56, 1 | (1 << 1), 59), + + # Node 72 + (2, (1 << 1), 88), + (9, (1 << 1), 88), + (23, (1 << 1), 88), + (40, 1 | (1 << 1), 88), + (2, (1 << 1), 90), + (9, (1 << 1), 90), + (23, (1 << 1), 90), + (40, 1 | (1 << 1), 90), + (0, 1 | (1 << 1), 33), + (0, 1 | (1 << 1), 34), + (0, 1 | (1 << 1), 40), + (0, 1 | (1 << 1), 41), + (0, 1 | (1 << 1), 63), + (80, 0, 0), + (82, 0, 0), + (84, 0, 0), + + # Node 73 + (3, (1 << 1), 88), + (6, (1 << 1), 88), + (10, (1 << 1), 88), + (15, (1 << 1), 88), + (24, (1 << 1), 88), + (31, (1 << 1), 88), + (41, (1 << 1), 88), + (56, 1 | (1 << 1), 88), + (3, (1 << 1), 90), + (6, (1 << 1), 90), + (10, (1 << 1), 90), + (15, (1 << 1), 90), + (24, (1 << 1), 90), + (31, (1 << 1), 90), + (41, (1 << 1), 90), + (56, 1 | (1 << 1), 90), + + # Node 74 + (1, (1 << 1), 33), + (22, 1 | (1 << 1), 33), + (1, (1 << 1), 34), + (22, 1 | (1 << 1), 34), + (1, (1 << 1), 40), + (22, 1 | (1 << 1), 40), + (1, (1 << 1), 41), + (22, 1 | (1 << 1), 41), + (1, (1 << 1), 63), + (22, 1 | (1 << 1), 63), + (0, 1 | (1 << 1), 39), + (0, 1 | (1 << 1), 43), + (0, 1 | (1 << 1), 124), + (83, 0, 0), + (85, 0, 0), + (88, 0, 0), + + # Node 75 + (2, (1 << 1), 33), + (9, (1 << 1), 33), + (23, (1 << 1), 33), + (40, 1 | (1 << 1), 33), + (2, (1 << 1), 34), + (9, (1 << 1), 34), + (23, (1 << 1), 34), + (40, 1 | (1 << 1), 34), + (2, (1 << 1), 40), + (9, (1 << 1), 40), + (23, (1 << 1), 40), + (40, 1 | (1 << 1), 40), + (2, (1 << 1), 41), + (9, (1 << 1), 41), + (23, (1 << 1), 41), + (40, 1 | (1 << 1), 41), + + # Node 76 + (3, (1 << 1), 33), + (6, (1 << 1), 33), + (10, (1 << 1), 33), + (15, (1 << 1), 33), + (24, (1 << 1), 33), + (31, (1 << 1), 33), + (41, (1 << 1), 33), + (56, 1 | (1 << 1), 33), + (3, (1 << 1), 34), + (6, (1 << 1), 34), + (10, (1 << 1), 34), + (15, (1 << 1), 34), + (24, (1 << 1), 34), + (31, (1 << 1), 34), + (41, (1 << 1), 34), + (56, 1 | (1 << 1), 34), + + # Node 77 + (3, (1 << 1), 40), + (6, (1 << 1), 40), + (10, (1 << 1), 40), + (15, (1 << 1), 40), + (24, (1 << 1), 40), + (31, (1 << 1), 40), + (41, (1 << 1), 40), + (56, 1 | (1 << 1), 40), + (3, (1 << 1), 41), + (6, (1 << 1), 41), + (10, (1 << 1), 41), + (15, (1 << 1), 41), + (24, (1 << 1), 41), + (31, (1 << 1), 41), + (41, (1 << 1), 41), + (56, 1 | (1 << 1), 41), + + # Node 78 + (2, (1 << 1), 63), + (9, (1 << 1), 63), + (23, (1 << 1), 63), + (40, 1 | (1 << 1), 63), + (1, (1 << 1), 39), + (22, 1 | (1 << 1), 39), + (1, (1 << 1), 43), + (22, 1 | (1 << 1), 43), + (1, (1 << 1), 124), + (22, 1 | (1 << 1), 124), + (0, 1 | (1 << 1), 35), + (0, 1 | (1 << 1), 62), + (86, 0, 0), + (87, 0, 0), + (89, 0, 0), + (90, 0, 0), + + # Node 79 + (3, (1 << 1), 63), + (6, (1 << 1), 63), + (10, (1 << 1), 63), + (15, (1 << 1), 63), + (24, (1 << 1), 63), + (31, (1 << 1), 63), + (41, (1 << 1), 63), + (56, 1 | (1 << 1), 63), + (2, (1 << 1), 39), + (9, (1 << 1), 39), + (23, (1 << 1), 39), + (40, 1 | (1 << 1), 39), + (2, (1 << 1), 43), + (9, (1 << 1), 43), + (23, (1 << 1), 43), + (40, 1 | (1 << 1), 43), + + # Node 80 + (3, (1 << 1), 39), + (6, (1 << 1), 39), + (10, (1 << 1), 39), + (15, (1 << 1), 39), + (24, (1 << 1), 39), + (31, (1 << 1), 39), + (41, (1 << 1), 39), + (56, 1 | (1 << 1), 39), + (3, (1 << 1), 43), + (6, (1 << 1), 43), + (10, (1 << 1), 43), + (15, (1 << 1), 43), + (24, (1 << 1), 43), + (31, (1 << 1), 43), + (41, (1 << 1), 43), + (56, 1 | (1 << 1), 43), + + # Node 81 + (2, (1 << 1), 124), + (9, (1 << 1), 124), + (23, (1 << 1), 124), + (40, 1 | (1 << 1), 124), + (1, (1 << 1), 35), + (22, 1 | (1 << 1), 35), + (1, (1 << 1), 62), + (22, 1 | (1 << 1), 62), + (0, 1 | (1 << 1), 0), + (0, 1 | (1 << 1), 36), + (0, 1 | (1 << 1), 64), + (0, 1 | (1 << 1), 91), + (0, 1 | (1 << 1), 93), + (0, 1 | (1 << 1), 126), + (91, 0, 0), + (92, 0, 0), + + # Node 82 + (3, (1 << 1), 124), + (6, (1 << 1), 124), + (10, (1 << 1), 124), + (15, (1 << 1), 124), + (24, (1 << 1), 124), + (31, (1 << 1), 124), + (41, (1 << 1), 124), + (56, 1 | (1 << 1), 124), + (2, (1 << 1), 35), + (9, (1 << 1), 35), + (23, (1 << 1), 35), + (40, 1 | (1 << 1), 35), + (2, (1 << 1), 62), + (9, (1 << 1), 62), + (23, (1 << 1), 62), + (40, 1 | (1 << 1), 62), + + # Node 83 + (3, (1 << 1), 35), + (6, (1 << 1), 35), + (10, (1 << 1), 35), + (15, (1 << 1), 35), + (24, (1 << 1), 35), + (31, (1 << 1), 35), + (41, (1 << 1), 35), + (56, 1 | (1 << 1), 35), + (3, (1 << 1), 62), + (6, (1 << 1), 62), + (10, (1 << 1), 62), + (15, (1 << 1), 62), + (24, (1 << 1), 62), + (31, (1 << 1), 62), + (41, (1 << 1), 62), + (56, 1 | (1 << 1), 62), + + # Node 84 + (1, (1 << 1), 0), + (22, 1 | (1 << 1), 0), + (1, (1 << 1), 36), + (22, 1 | (1 << 1), 36), + (1, (1 << 1), 64), + (22, 1 | (1 << 1), 64), + (1, (1 << 1), 91), + (22, 1 | (1 << 1), 91), + (1, (1 << 1), 93), + (22, 1 | (1 << 1), 93), + (1, (1 << 1), 126), + (22, 1 | (1 << 1), 126), + (0, 1 | (1 << 1), 94), + (0, 1 | (1 << 1), 125), + (93, 0, 0), + (94, 0, 0), + + # Node 85 + (2, (1 << 1), 0), + (9, (1 << 1), 0), + (23, (1 << 1), 0), + (40, 1 | (1 << 1), 0), + (2, (1 << 1), 36), + (9, (1 << 1), 36), + (23, (1 << 1), 36), + (40, 1 | (1 << 1), 36), + (2, (1 << 1), 64), + (9, (1 << 1), 64), + (23, (1 << 1), 64), + (40, 1 | (1 << 1), 64), + (2, (1 << 1), 91), + (9, (1 << 1), 91), + (23, (1 << 1), 91), + (40, 1 | (1 << 1), 91), + + # Node 86 + (3, (1 << 1), 0), + (6, (1 << 1), 0), + (10, (1 << 1), 0), + (15, (1 << 1), 0), + (24, (1 << 1), 0), + (31, (1 << 1), 0), + (41, (1 << 1), 0), + (56, 1 | (1 << 1), 0), + (3, (1 << 1), 36), + (6, (1 << 1), 36), + (10, (1 << 1), 36), + (15, (1 << 1), 36), + (24, (1 << 1), 36), + (31, (1 << 1), 36), + (41, (1 << 1), 36), + (56, 1 | (1 << 1), 36), + + # Node 87 + (3, (1 << 1), 64), + (6, (1 << 1), 64), + (10, (1 << 1), 64), + (15, (1 << 1), 64), + (24, (1 << 1), 64), + (31, (1 << 1), 64), + (41, (1 << 1), 64), + (56, 1 | (1 << 1), 64), + (3, (1 << 1), 91), + (6, (1 << 1), 91), + (10, (1 << 1), 91), + (15, (1 << 1), 91), + (24, (1 << 1), 91), + (31, (1 << 1), 91), + (41, (1 << 1), 91), + (56, 1 | (1 << 1), 91), + + # Node 88 + (2, (1 << 1), 93), + (9, (1 << 1), 93), + (23, (1 << 1), 93), + (40, 1 | (1 << 1), 93), + (2, (1 << 1), 126), + (9, (1 << 1), 126), + (23, (1 << 1), 126), + (40, 1 | (1 << 1), 126), + (1, (1 << 1), 94), + (22, 1 | (1 << 1), 94), + (1, (1 << 1), 125), + (22, 1 | (1 << 1), 125), + (0, 1 | (1 << 1), 60), + (0, 1 | (1 << 1), 96), + (0, 1 | (1 << 1), 123), + (95, 0, 0), + + # Node 89 + (3, (1 << 1), 93), + (6, (1 << 1), 93), + (10, (1 << 1), 93), + (15, (1 << 1), 93), + (24, (1 << 1), 93), + (31, (1 << 1), 93), + (41, (1 << 1), 93), + (56, 1 | (1 << 1), 93), + (3, (1 << 1), 126), + (6, (1 << 1), 126), + (10, (1 << 1), 126), + (15, (1 << 1), 126), + (24, (1 << 1), 126), + (31, (1 << 1), 126), + (41, (1 << 1), 126), + (56, 1 | (1 << 1), 126), + + # Node 90 + (2, (1 << 1), 94), + (9, (1 << 1), 94), + (23, (1 << 1), 94), + (40, 1 | (1 << 1), 94), + (2, (1 << 1), 125), + (9, (1 << 1), 125), + (23, (1 << 1), 125), + (40, 1 | (1 << 1), 125), + (1, (1 << 1), 60), + (22, 1 | (1 << 1), 60), + (1, (1 << 1), 96), + (22, 1 | (1 << 1), 96), + (1, (1 << 1), 123), + (22, 1 | (1 << 1), 123), + (96, 0, 0), + (110, 0, 0), + + # Node 91 + (3, (1 << 1), 94), + (6, (1 << 1), 94), + (10, (1 << 1), 94), + (15, (1 << 1), 94), + (24, (1 << 1), 94), + (31, (1 << 1), 94), + (41, (1 << 1), 94), + (56, 1 | (1 << 1), 94), + (3, (1 << 1), 125), + (6, (1 << 1), 125), + (10, (1 << 1), 125), + (15, (1 << 1), 125), + (24, (1 << 1), 125), + (31, (1 << 1), 125), + (41, (1 << 1), 125), + (56, 1 | (1 << 1), 125), + + # Node 92 + (2, (1 << 1), 60), + (9, (1 << 1), 60), + (23, (1 << 1), 60), + (40, 1 | (1 << 1), 60), + (2, (1 << 1), 96), + (9, (1 << 1), 96), + (23, (1 << 1), 96), + (40, 1 | (1 << 1), 96), + (2, (1 << 1), 123), + (9, (1 << 1), 123), + (23, (1 << 1), 123), + (40, 1 | (1 << 1), 123), + (97, 0, 0), + (101, 0, 0), + (111, 0, 0), + (133, 0, 0), + + # Node 93 + (3, (1 << 1), 60), + (6, (1 << 1), 60), + (10, (1 << 1), 60), + (15, (1 << 1), 60), + (24, (1 << 1), 60), + (31, (1 << 1), 60), + (41, (1 << 1), 60), + (56, 1 | (1 << 1), 60), + (3, (1 << 1), 96), + (6, (1 << 1), 96), + (10, (1 << 1), 96), + (15, (1 << 1), 96), + (24, (1 << 1), 96), + (31, (1 << 1), 96), + (41, (1 << 1), 96), + (56, 1 | (1 << 1), 96), + + # Node 94 + (3, (1 << 1), 123), + (6, (1 << 1), 123), + (10, (1 << 1), 123), + (15, (1 << 1), 123), + (24, (1 << 1), 123), + (31, (1 << 1), 123), + (41, (1 << 1), 123), + (56, 1 | (1 << 1), 123), + (98, 0, 0), + (99, 0, 0), + (102, 0, 0), + (105, 0, 0), + (112, 0, 0), + (119, 0, 0), + (134, 0, 0), + (153, 0, 0), + + # Node 95 + (0, 1 | (1 << 1), 92), + (0, 1 | (1 << 1), 195), + (0, 1 | (1 << 1), 208), + (100, 0, 0), + (103, 0, 0), + (104, 0, 0), + (106, 0, 0), + (107, 0, 0), + (113, 0, 0), + (116, 0, 0), + (120, 0, 0), + (126, 0, 0), + (135, 0, 0), + (142, 0, 0), + (154, 0, 0), + (169, 0, 0), + + # Node 96 + (1, (1 << 1), 92), + (22, 1 | (1 << 1), 92), + (1, (1 << 1), 195), + (22, 1 | (1 << 1), 195), + (1, (1 << 1), 208), + (22, 1 | (1 << 1), 208), + (0, 1 | (1 << 1), 128), + (0, 1 | (1 << 1), 130), + (0, 1 | (1 << 1), 131), + (0, 1 | (1 << 1), 162), + (0, 1 | (1 << 1), 184), + (0, 1 | (1 << 1), 194), + (0, 1 | (1 << 1), 224), + (0, 1 | (1 << 1), 226), + (108, 0, 0), + (109, 0, 0), + + # Node 97 + (2, (1 << 1), 92), + (9, (1 << 1), 92), + (23, (1 << 1), 92), + (40, 1 | (1 << 1), 92), + (2, (1 << 1), 195), + (9, (1 << 1), 195), + (23, (1 << 1), 195), + (40, 1 | (1 << 1), 195), + (2, (1 << 1), 208), + (9, (1 << 1), 208), + (23, (1 << 1), 208), + (40, 1 | (1 << 1), 208), + (1, (1 << 1), 128), + (22, 1 | (1 << 1), 128), + (1, (1 << 1), 130), + (22, 1 | (1 << 1), 130), + + # Node 98 + (3, (1 << 1), 92), + (6, (1 << 1), 92), + (10, (1 << 1), 92), + (15, (1 << 1), 92), + (24, (1 << 1), 92), + (31, (1 << 1), 92), + (41, (1 << 1), 92), + (56, 1 | (1 << 1), 92), + (3, (1 << 1), 195), + (6, (1 << 1), 195), + (10, (1 << 1), 195), + (15, (1 << 1), 195), + (24, (1 << 1), 195), + (31, (1 << 1), 195), + (41, (1 << 1), 195), + (56, 1 | (1 << 1), 195), + + # Node 99 + (3, (1 << 1), 208), + (6, (1 << 1), 208), + (10, (1 << 1), 208), + (15, (1 << 1), 208), + (24, (1 << 1), 208), + (31, (1 << 1), 208), + (41, (1 << 1), 208), + (56, 1 | (1 << 1), 208), + (2, (1 << 1), 128), + (9, (1 << 1), 128), + (23, (1 << 1), 128), + (40, 1 | (1 << 1), 128), + (2, (1 << 1), 130), + (9, (1 << 1), 130), + (23, (1 << 1), 130), + (40, 1 | (1 << 1), 130), + + # Node 100 + (3, (1 << 1), 128), + (6, (1 << 1), 128), + (10, (1 << 1), 128), + (15, (1 << 1), 128), + (24, (1 << 1), 128), + (31, (1 << 1), 128), + (41, (1 << 1), 128), + (56, 1 | (1 << 1), 128), + (3, (1 << 1), 130), + (6, (1 << 1), 130), + (10, (1 << 1), 130), + (15, (1 << 1), 130), + (24, (1 << 1), 130), + (31, (1 << 1), 130), + (41, (1 << 1), 130), + (56, 1 | (1 << 1), 130), + + # Node 101 + (1, (1 << 1), 131), + (22, 1 | (1 << 1), 131), + (1, (1 << 1), 162), + (22, 1 | (1 << 1), 162), + (1, (1 << 1), 184), + (22, 1 | (1 << 1), 184), + (1, (1 << 1), 194), + (22, 1 | (1 << 1), 194), + (1, (1 << 1), 224), + (22, 1 | (1 << 1), 224), + (1, (1 << 1), 226), + (22, 1 | (1 << 1), 226), + (0, 1 | (1 << 1), 153), + (0, 1 | (1 << 1), 161), + (0, 1 | (1 << 1), 167), + (0, 1 | (1 << 1), 172), + + # Node 102 + (2, (1 << 1), 131), + (9, (1 << 1), 131), + (23, (1 << 1), 131), + (40, 1 | (1 << 1), 131), + (2, (1 << 1), 162), + (9, (1 << 1), 162), + (23, (1 << 1), 162), + (40, 1 | (1 << 1), 162), + (2, (1 << 1), 184), + (9, (1 << 1), 184), + (23, (1 << 1), 184), + (40, 1 | (1 << 1), 184), + (2, (1 << 1), 194), + (9, (1 << 1), 194), + (23, (1 << 1), 194), + (40, 1 | (1 << 1), 194), + + # Node 103 + (3, (1 << 1), 131), + (6, (1 << 1), 131), + (10, (1 << 1), 131), + (15, (1 << 1), 131), + (24, (1 << 1), 131), + (31, (1 << 1), 131), + (41, (1 << 1), 131), + (56, 1 | (1 << 1), 131), + (3, (1 << 1), 162), + (6, (1 << 1), 162), + (10, (1 << 1), 162), + (15, (1 << 1), 162), + (24, (1 << 1), 162), + (31, (1 << 1), 162), + (41, (1 << 1), 162), + (56, 1 | (1 << 1), 162), + + # Node 104 + (3, (1 << 1), 184), + (6, (1 << 1), 184), + (10, (1 << 1), 184), + (15, (1 << 1), 184), + (24, (1 << 1), 184), + (31, (1 << 1), 184), + (41, (1 << 1), 184), + (56, 1 | (1 << 1), 184), + (3, (1 << 1), 194), + (6, (1 << 1), 194), + (10, (1 << 1), 194), + (15, (1 << 1), 194), + (24, (1 << 1), 194), + (31, (1 << 1), 194), + (41, (1 << 1), 194), + (56, 1 | (1 << 1), 194), + + # Node 105 + (2, (1 << 1), 224), + (9, (1 << 1), 224), + (23, (1 << 1), 224), + (40, 1 | (1 << 1), 224), + (2, (1 << 1), 226), + (9, (1 << 1), 226), + (23, (1 << 1), 226), + (40, 1 | (1 << 1), 226), + (1, (1 << 1), 153), + (22, 1 | (1 << 1), 153), + (1, (1 << 1), 161), + (22, 1 | (1 << 1), 161), + (1, (1 << 1), 167), + (22, 1 | (1 << 1), 167), + (1, (1 << 1), 172), + (22, 1 | (1 << 1), 172), + + # Node 106 + (3, (1 << 1), 224), + (6, (1 << 1), 224), + (10, (1 << 1), 224), + (15, (1 << 1), 224), + (24, (1 << 1), 224), + (31, (1 << 1), 224), + (41, (1 << 1), 224), + (56, 1 | (1 << 1), 224), + (3, (1 << 1), 226), + (6, (1 << 1), 226), + (10, (1 << 1), 226), + (15, (1 << 1), 226), + (24, (1 << 1), 226), + (31, (1 << 1), 226), + (41, (1 << 1), 226), + (56, 1 | (1 << 1), 226), + + # Node 107 + (2, (1 << 1), 153), + (9, (1 << 1), 153), + (23, (1 << 1), 153), + (40, 1 | (1 << 1), 153), + (2, (1 << 1), 161), + (9, (1 << 1), 161), + (23, (1 << 1), 161), + (40, 1 | (1 << 1), 161), + (2, (1 << 1), 167), + (9, (1 << 1), 167), + (23, (1 << 1), 167), + (40, 1 | (1 << 1), 167), + (2, (1 << 1), 172), + (9, (1 << 1), 172), + (23, (1 << 1), 172), + (40, 1 | (1 << 1), 172), + + # Node 108 + (3, (1 << 1), 153), + (6, (1 << 1), 153), + (10, (1 << 1), 153), + (15, (1 << 1), 153), + (24, (1 << 1), 153), + (31, (1 << 1), 153), + (41, (1 << 1), 153), + (56, 1 | (1 << 1), 153), + (3, (1 << 1), 161), + (6, (1 << 1), 161), + (10, (1 << 1), 161), + (15, (1 << 1), 161), + (24, (1 << 1), 161), + (31, (1 << 1), 161), + (41, (1 << 1), 161), + (56, 1 | (1 << 1), 161), + + # Node 109 + (3, (1 << 1), 167), + (6, (1 << 1), 167), + (10, (1 << 1), 167), + (15, (1 << 1), 167), + (24, (1 << 1), 167), + (31, (1 << 1), 167), + (41, (1 << 1), 167), + (56, 1 | (1 << 1), 167), + (3, (1 << 1), 172), + (6, (1 << 1), 172), + (10, (1 << 1), 172), + (15, (1 << 1), 172), + (24, (1 << 1), 172), + (31, (1 << 1), 172), + (41, (1 << 1), 172), + (56, 1 | (1 << 1), 172), + + # Node 110 + (114, 0, 0), + (115, 0, 0), + (117, 0, 0), + (118, 0, 0), + (121, 0, 0), + (123, 0, 0), + (127, 0, 0), + (130, 0, 0), + (136, 0, 0), + (139, 0, 0), + (143, 0, 0), + (146, 0, 0), + (155, 0, 0), + (162, 0, 0), + (170, 0, 0), + (180, 0, 0), + + # Node 111 + (0, 1 | (1 << 1), 176), + (0, 1 | (1 << 1), 177), + (0, 1 | (1 << 1), 179), + (0, 1 | (1 << 1), 209), + (0, 1 | (1 << 1), 216), + (0, 1 | (1 << 1), 217), + (0, 1 | (1 << 1), 227), + (0, 1 | (1 << 1), 229), + (0, 1 | (1 << 1), 230), + (122, 0, 0), + (124, 0, 0), + (125, 0, 0), + (128, 0, 0), + (129, 0, 0), + (131, 0, 0), + (132, 0, 0), + + # Node 112 + (1, (1 << 1), 176), + (22, 1 | (1 << 1), 176), + (1, (1 << 1), 177), + (22, 1 | (1 << 1), 177), + (1, (1 << 1), 179), + (22, 1 | (1 << 1), 179), + (1, (1 << 1), 209), + (22, 1 | (1 << 1), 209), + (1, (1 << 1), 216), + (22, 1 | (1 << 1), 216), + (1, (1 << 1), 217), + (22, 1 | (1 << 1), 217), + (1, (1 << 1), 227), + (22, 1 | (1 << 1), 227), + (1, (1 << 1), 229), + (22, 1 | (1 << 1), 229), + + # Node 113 + (2, (1 << 1), 176), + (9, (1 << 1), 176), + (23, (1 << 1), 176), + (40, 1 | (1 << 1), 176), + (2, (1 << 1), 177), + (9, (1 << 1), 177), + (23, (1 << 1), 177), + (40, 1 | (1 << 1), 177), + (2, (1 << 1), 179), + (9, (1 << 1), 179), + (23, (1 << 1), 179), + (40, 1 | (1 << 1), 179), + (2, (1 << 1), 209), + (9, (1 << 1), 209), + (23, (1 << 1), 209), + (40, 1 | (1 << 1), 209), + + # Node 114 + (3, (1 << 1), 176), + (6, (1 << 1), 176), + (10, (1 << 1), 176), + (15, (1 << 1), 176), + (24, (1 << 1), 176), + (31, (1 << 1), 176), + (41, (1 << 1), 176), + (56, 1 | (1 << 1), 176), + (3, (1 << 1), 177), + (6, (1 << 1), 177), + (10, (1 << 1), 177), + (15, (1 << 1), 177), + (24, (1 << 1), 177), + (31, (1 << 1), 177), + (41, (1 << 1), 177), + (56, 1 | (1 << 1), 177), + + # Node 115 + (3, (1 << 1), 179), + (6, (1 << 1), 179), + (10, (1 << 1), 179), + (15, (1 << 1), 179), + (24, (1 << 1), 179), + (31, (1 << 1), 179), + (41, (1 << 1), 179), + (56, 1 | (1 << 1), 179), + (3, (1 << 1), 209), + (6, (1 << 1), 209), + (10, (1 << 1), 209), + (15, (1 << 1), 209), + (24, (1 << 1), 209), + (31, (1 << 1), 209), + (41, (1 << 1), 209), + (56, 1 | (1 << 1), 209), + + # Node 116 + (2, (1 << 1), 216), + (9, (1 << 1), 216), + (23, (1 << 1), 216), + (40, 1 | (1 << 1), 216), + (2, (1 << 1), 217), + (9, (1 << 1), 217), + (23, (1 << 1), 217), + (40, 1 | (1 << 1), 217), + (2, (1 << 1), 227), + (9, (1 << 1), 227), + (23, (1 << 1), 227), + (40, 1 | (1 << 1), 227), + (2, (1 << 1), 229), + (9, (1 << 1), 229), + (23, (1 << 1), 229), + (40, 1 | (1 << 1), 229), + + # Node 117 + (3, (1 << 1), 216), + (6, (1 << 1), 216), + (10, (1 << 1), 216), + (15, (1 << 1), 216), + (24, (1 << 1), 216), + (31, (1 << 1), 216), + (41, (1 << 1), 216), + (56, 1 | (1 << 1), 216), + (3, (1 << 1), 217), + (6, (1 << 1), 217), + (10, (1 << 1), 217), + (15, (1 << 1), 217), + (24, (1 << 1), 217), + (31, (1 << 1), 217), + (41, (1 << 1), 217), + (56, 1 | (1 << 1), 217), + + # Node 118 + (3, (1 << 1), 227), + (6, (1 << 1), 227), + (10, (1 << 1), 227), + (15, (1 << 1), 227), + (24, (1 << 1), 227), + (31, (1 << 1), 227), + (41, (1 << 1), 227), + (56, 1 | (1 << 1), 227), + (3, (1 << 1), 229), + (6, (1 << 1), 229), + (10, (1 << 1), 229), + (15, (1 << 1), 229), + (24, (1 << 1), 229), + (31, (1 << 1), 229), + (41, (1 << 1), 229), + (56, 1 | (1 << 1), 229), + + # Node 119 + (1, (1 << 1), 230), + (22, 1 | (1 << 1), 230), + (0, 1 | (1 << 1), 129), + (0, 1 | (1 << 1), 132), + (0, 1 | (1 << 1), 133), + (0, 1 | (1 << 1), 134), + (0, 1 | (1 << 1), 136), + (0, 1 | (1 << 1), 146), + (0, 1 | (1 << 1), 154), + (0, 1 | (1 << 1), 156), + (0, 1 | (1 << 1), 160), + (0, 1 | (1 << 1), 163), + (0, 1 | (1 << 1), 164), + (0, 1 | (1 << 1), 169), + (0, 1 | (1 << 1), 170), + (0, 1 | (1 << 1), 173), + + # Node 120 + (2, (1 << 1), 230), + (9, (1 << 1), 230), + (23, (1 << 1), 230), + (40, 1 | (1 << 1), 230), + (1, (1 << 1), 129), + (22, 1 | (1 << 1), 129), + (1, (1 << 1), 132), + (22, 1 | (1 << 1), 132), + (1, (1 << 1), 133), + (22, 1 | (1 << 1), 133), + (1, (1 << 1), 134), + (22, 1 | (1 << 1), 134), + (1, (1 << 1), 136), + (22, 1 | (1 << 1), 136), + (1, (1 << 1), 146), + (22, 1 | (1 << 1), 146), + + # Node 121 + (3, (1 << 1), 230), + (6, (1 << 1), 230), + (10, (1 << 1), 230), + (15, (1 << 1), 230), + (24, (1 << 1), 230), + (31, (1 << 1), 230), + (41, (1 << 1), 230), + (56, 1 | (1 << 1), 230), + (2, (1 << 1), 129), + (9, (1 << 1), 129), + (23, (1 << 1), 129), + (40, 1 | (1 << 1), 129), + (2, (1 << 1), 132), + (9, (1 << 1), 132), + (23, (1 << 1), 132), + (40, 1 | (1 << 1), 132), + + # Node 122 + (3, (1 << 1), 129), + (6, (1 << 1), 129), + (10, (1 << 1), 129), + (15, (1 << 1), 129), + (24, (1 << 1), 129), + (31, (1 << 1), 129), + (41, (1 << 1), 129), + (56, 1 | (1 << 1), 129), + (3, (1 << 1), 132), + (6, (1 << 1), 132), + (10, (1 << 1), 132), + (15, (1 << 1), 132), + (24, (1 << 1), 132), + (31, (1 << 1), 132), + (41, (1 << 1), 132), + (56, 1 | (1 << 1), 132), + + # Node 123 + (2, (1 << 1), 133), + (9, (1 << 1), 133), + (23, (1 << 1), 133), + (40, 1 | (1 << 1), 133), + (2, (1 << 1), 134), + (9, (1 << 1), 134), + (23, (1 << 1), 134), + (40, 1 | (1 << 1), 134), + (2, (1 << 1), 136), + (9, (1 << 1), 136), + (23, (1 << 1), 136), + (40, 1 | (1 << 1), 136), + (2, (1 << 1), 146), + (9, (1 << 1), 146), + (23, (1 << 1), 146), + (40, 1 | (1 << 1), 146), + + # Node 124 + (3, (1 << 1), 133), + (6, (1 << 1), 133), + (10, (1 << 1), 133), + (15, (1 << 1), 133), + (24, (1 << 1), 133), + (31, (1 << 1), 133), + (41, (1 << 1), 133), + (56, 1 | (1 << 1), 133), + (3, (1 << 1), 134), + (6, (1 << 1), 134), + (10, (1 << 1), 134), + (15, (1 << 1), 134), + (24, (1 << 1), 134), + (31, (1 << 1), 134), + (41, (1 << 1), 134), + (56, 1 | (1 << 1), 134), + + # Node 125 + (3, (1 << 1), 136), + (6, (1 << 1), 136), + (10, (1 << 1), 136), + (15, (1 << 1), 136), + (24, (1 << 1), 136), + (31, (1 << 1), 136), + (41, (1 << 1), 136), + (56, 1 | (1 << 1), 136), + (3, (1 << 1), 146), + (6, (1 << 1), 146), + (10, (1 << 1), 146), + (15, (1 << 1), 146), + (24, (1 << 1), 146), + (31, (1 << 1), 146), + (41, (1 << 1), 146), + (56, 1 | (1 << 1), 146), + + # Node 126 + (1, (1 << 1), 154), + (22, 1 | (1 << 1), 154), + (1, (1 << 1), 156), + (22, 1 | (1 << 1), 156), + (1, (1 << 1), 160), + (22, 1 | (1 << 1), 160), + (1, (1 << 1), 163), + (22, 1 | (1 << 1), 163), + (1, (1 << 1), 164), + (22, 1 | (1 << 1), 164), + (1, (1 << 1), 169), + (22, 1 | (1 << 1), 169), + (1, (1 << 1), 170), + (22, 1 | (1 << 1), 170), + (1, (1 << 1), 173), + (22, 1 | (1 << 1), 173), + + # Node 127 + (2, (1 << 1), 154), + (9, (1 << 1), 154), + (23, (1 << 1), 154), + (40, 1 | (1 << 1), 154), + (2, (1 << 1), 156), + (9, (1 << 1), 156), + (23, (1 << 1), 156), + (40, 1 | (1 << 1), 156), + (2, (1 << 1), 160), + (9, (1 << 1), 160), + (23, (1 << 1), 160), + (40, 1 | (1 << 1), 160), + (2, (1 << 1), 163), + (9, (1 << 1), 163), + (23, (1 << 1), 163), + (40, 1 | (1 << 1), 163), + + # Node 128 + (3, (1 << 1), 154), + (6, (1 << 1), 154), + (10, (1 << 1), 154), + (15, (1 << 1), 154), + (24, (1 << 1), 154), + (31, (1 << 1), 154), + (41, (1 << 1), 154), + (56, 1 | (1 << 1), 154), + (3, (1 << 1), 156), + (6, (1 << 1), 156), + (10, (1 << 1), 156), + (15, (1 << 1), 156), + (24, (1 << 1), 156), + (31, (1 << 1), 156), + (41, (1 << 1), 156), + (56, 1 | (1 << 1), 156), + + # Node 129 + (3, (1 << 1), 160), + (6, (1 << 1), 160), + (10, (1 << 1), 160), + (15, (1 << 1), 160), + (24, (1 << 1), 160), + (31, (1 << 1), 160), + (41, (1 << 1), 160), + (56, 1 | (1 << 1), 160), + (3, (1 << 1), 163), + (6, (1 << 1), 163), + (10, (1 << 1), 163), + (15, (1 << 1), 163), + (24, (1 << 1), 163), + (31, (1 << 1), 163), + (41, (1 << 1), 163), + (56, 1 | (1 << 1), 163), + + # Node 130 + (2, (1 << 1), 164), + (9, (1 << 1), 164), + (23, (1 << 1), 164), + (40, 1 | (1 << 1), 164), + (2, (1 << 1), 169), + (9, (1 << 1), 169), + (23, (1 << 1), 169), + (40, 1 | (1 << 1), 169), + (2, (1 << 1), 170), + (9, (1 << 1), 170), + (23, (1 << 1), 170), + (40, 1 | (1 << 1), 170), + (2, (1 << 1), 173), + (9, (1 << 1), 173), + (23, (1 << 1), 173), + (40, 1 | (1 << 1), 173), + + # Node 131 + (3, (1 << 1), 164), + (6, (1 << 1), 164), + (10, (1 << 1), 164), + (15, (1 << 1), 164), + (24, (1 << 1), 164), + (31, (1 << 1), 164), + (41, (1 << 1), 164), + (56, 1 | (1 << 1), 164), + (3, (1 << 1), 169), + (6, (1 << 1), 169), + (10, (1 << 1), 169), + (15, (1 << 1), 169), + (24, (1 << 1), 169), + (31, (1 << 1), 169), + (41, (1 << 1), 169), + (56, 1 | (1 << 1), 169), + + # Node 132 + (3, (1 << 1), 170), + (6, (1 << 1), 170), + (10, (1 << 1), 170), + (15, (1 << 1), 170), + (24, (1 << 1), 170), + (31, (1 << 1), 170), + (41, (1 << 1), 170), + (56, 1 | (1 << 1), 170), + (3, (1 << 1), 173), + (6, (1 << 1), 173), + (10, (1 << 1), 173), + (15, (1 << 1), 173), + (24, (1 << 1), 173), + (31, (1 << 1), 173), + (41, (1 << 1), 173), + (56, 1 | (1 << 1), 173), + + # Node 133 + (137, 0, 0), + (138, 0, 0), + (140, 0, 0), + (141, 0, 0), + (144, 0, 0), + (145, 0, 0), + (147, 0, 0), + (150, 0, 0), + (156, 0, 0), + (159, 0, 0), + (163, 0, 0), + (166, 0, 0), + (171, 0, 0), + (174, 0, 0), + (181, 0, 0), + (190, 0, 0), + + # Node 134 + (0, 1 | (1 << 1), 178), + (0, 1 | (1 << 1), 181), + (0, 1 | (1 << 1), 185), + (0, 1 | (1 << 1), 186), + (0, 1 | (1 << 1), 187), + (0, 1 | (1 << 1), 189), + (0, 1 | (1 << 1), 190), + (0, 1 | (1 << 1), 196), + (0, 1 | (1 << 1), 198), + (0, 1 | (1 << 1), 228), + (0, 1 | (1 << 1), 232), + (0, 1 | (1 << 1), 233), + (148, 0, 0), + (149, 0, 0), + (151, 0, 0), + (152, 0, 0), + + # Node 135 + (1, (1 << 1), 178), + (22, 1 | (1 << 1), 178), + (1, (1 << 1), 181), + (22, 1 | (1 << 1), 181), + (1, (1 << 1), 185), + (22, 1 | (1 << 1), 185), + (1, (1 << 1), 186), + (22, 1 | (1 << 1), 186), + (1, (1 << 1), 187), + (22, 1 | (1 << 1), 187), + (1, (1 << 1), 189), + (22, 1 | (1 << 1), 189), + (1, (1 << 1), 190), + (22, 1 | (1 << 1), 190), + (1, (1 << 1), 196), + (22, 1 | (1 << 1), 196), + + # Node 136 + (2, (1 << 1), 178), + (9, (1 << 1), 178), + (23, (1 << 1), 178), + (40, 1 | (1 << 1), 178), + (2, (1 << 1), 181), + (9, (1 << 1), 181), + (23, (1 << 1), 181), + (40, 1 | (1 << 1), 181), + (2, (1 << 1), 185), + (9, (1 << 1), 185), + (23, (1 << 1), 185), + (40, 1 | (1 << 1), 185), + (2, (1 << 1), 186), + (9, (1 << 1), 186), + (23, (1 << 1), 186), + (40, 1 | (1 << 1), 186), + + # Node 137 + (3, (1 << 1), 178), + (6, (1 << 1), 178), + (10, (1 << 1), 178), + (15, (1 << 1), 178), + (24, (1 << 1), 178), + (31, (1 << 1), 178), + (41, (1 << 1), 178), + (56, 1 | (1 << 1), 178), + (3, (1 << 1), 181), + (6, (1 << 1), 181), + (10, (1 << 1), 181), + (15, (1 << 1), 181), + (24, (1 << 1), 181), + (31, (1 << 1), 181), + (41, (1 << 1), 181), + (56, 1 | (1 << 1), 181), + + # Node 138 + (3, (1 << 1), 185), + (6, (1 << 1), 185), + (10, (1 << 1), 185), + (15, (1 << 1), 185), + (24, (1 << 1), 185), + (31, (1 << 1), 185), + (41, (1 << 1), 185), + (56, 1 | (1 << 1), 185), + (3, (1 << 1), 186), + (6, (1 << 1), 186), + (10, (1 << 1), 186), + (15, (1 << 1), 186), + (24, (1 << 1), 186), + (31, (1 << 1), 186), + (41, (1 << 1), 186), + (56, 1 | (1 << 1), 186), + + # Node 139 + (2, (1 << 1), 187), + (9, (1 << 1), 187), + (23, (1 << 1), 187), + (40, 1 | (1 << 1), 187), + (2, (1 << 1), 189), + (9, (1 << 1), 189), + (23, (1 << 1), 189), + (40, 1 | (1 << 1), 189), + (2, (1 << 1), 190), + (9, (1 << 1), 190), + (23, (1 << 1), 190), + (40, 1 | (1 << 1), 190), + (2, (1 << 1), 196), + (9, (1 << 1), 196), + (23, (1 << 1), 196), + (40, 1 | (1 << 1), 196), + + # Node 140 + (3, (1 << 1), 187), + (6, (1 << 1), 187), + (10, (1 << 1), 187), + (15, (1 << 1), 187), + (24, (1 << 1), 187), + (31, (1 << 1), 187), + (41, (1 << 1), 187), + (56, 1 | (1 << 1), 187), + (3, (1 << 1), 189), + (6, (1 << 1), 189), + (10, (1 << 1), 189), + (15, (1 << 1), 189), + (24, (1 << 1), 189), + (31, (1 << 1), 189), + (41, (1 << 1), 189), + (56, 1 | (1 << 1), 189), + + # Node 141 + (3, (1 << 1), 190), + (6, (1 << 1), 190), + (10, (1 << 1), 190), + (15, (1 << 1), 190), + (24, (1 << 1), 190), + (31, (1 << 1), 190), + (41, (1 << 1), 190), + (56, 1 | (1 << 1), 190), + (3, (1 << 1), 196), + (6, (1 << 1), 196), + (10, (1 << 1), 196), + (15, (1 << 1), 196), + (24, (1 << 1), 196), + (31, (1 << 1), 196), + (41, (1 << 1), 196), + (56, 1 | (1 << 1), 196), + + # Node 142 + (1, (1 << 1), 198), + (22, 1 | (1 << 1), 198), + (1, (1 << 1), 228), + (22, 1 | (1 << 1), 228), + (1, (1 << 1), 232), + (22, 1 | (1 << 1), 232), + (1, (1 << 1), 233), + (22, 1 | (1 << 1), 233), + (0, 1 | (1 << 1), 1), + (0, 1 | (1 << 1), 135), + (0, 1 | (1 << 1), 137), + (0, 1 | (1 << 1), 138), + (0, 1 | (1 << 1), 139), + (0, 1 | (1 << 1), 140), + (0, 1 | (1 << 1), 141), + (0, 1 | (1 << 1), 143), + + # Node 143 + (2, (1 << 1), 198), + (9, (1 << 1), 198), + (23, (1 << 1), 198), + (40, 1 | (1 << 1), 198), + (2, (1 << 1), 228), + (9, (1 << 1), 228), + (23, (1 << 1), 228), + (40, 1 | (1 << 1), 228), + (2, (1 << 1), 232), + (9, (1 << 1), 232), + (23, (1 << 1), 232), + (40, 1 | (1 << 1), 232), + (2, (1 << 1), 233), + (9, (1 << 1), 233), + (23, (1 << 1), 233), + (40, 1 | (1 << 1), 233), + + # Node 144 + (3, (1 << 1), 198), + (6, (1 << 1), 198), + (10, (1 << 1), 198), + (15, (1 << 1), 198), + (24, (1 << 1), 198), + (31, (1 << 1), 198), + (41, (1 << 1), 198), + (56, 1 | (1 << 1), 198), + (3, (1 << 1), 228), + (6, (1 << 1), 228), + (10, (1 << 1), 228), + (15, (1 << 1), 228), + (24, (1 << 1), 228), + (31, (1 << 1), 228), + (41, (1 << 1), 228), + (56, 1 | (1 << 1), 228), + + # Node 145 + (3, (1 << 1), 232), + (6, (1 << 1), 232), + (10, (1 << 1), 232), + (15, (1 << 1), 232), + (24, (1 << 1), 232), + (31, (1 << 1), 232), + (41, (1 << 1), 232), + (56, 1 | (1 << 1), 232), + (3, (1 << 1), 233), + (6, (1 << 1), 233), + (10, (1 << 1), 233), + (15, (1 << 1), 233), + (24, (1 << 1), 233), + (31, (1 << 1), 233), + (41, (1 << 1), 233), + (56, 1 | (1 << 1), 233), + + # Node 146 + (1, (1 << 1), 1), + (22, 1 | (1 << 1), 1), + (1, (1 << 1), 135), + (22, 1 | (1 << 1), 135), + (1, (1 << 1), 137), + (22, 1 | (1 << 1), 137), + (1, (1 << 1), 138), + (22, 1 | (1 << 1), 138), + (1, (1 << 1), 139), + (22, 1 | (1 << 1), 139), + (1, (1 << 1), 140), + (22, 1 | (1 << 1), 140), + (1, (1 << 1), 141), + (22, 1 | (1 << 1), 141), + (1, (1 << 1), 143), + (22, 1 | (1 << 1), 143), + + # Node 147 + (2, (1 << 1), 1), + (9, (1 << 1), 1), + (23, (1 << 1), 1), + (40, 1 | (1 << 1), 1), + (2, (1 << 1), 135), + (9, (1 << 1), 135), + (23, (1 << 1), 135), + (40, 1 | (1 << 1), 135), + (2, (1 << 1), 137), + (9, (1 << 1), 137), + (23, (1 << 1), 137), + (40, 1 | (1 << 1), 137), + (2, (1 << 1), 138), + (9, (1 << 1), 138), + (23, (1 << 1), 138), + (40, 1 | (1 << 1), 138), + + # Node 148 + (3, (1 << 1), 1), + (6, (1 << 1), 1), + (10, (1 << 1), 1), + (15, (1 << 1), 1), + (24, (1 << 1), 1), + (31, (1 << 1), 1), + (41, (1 << 1), 1), + (56, 1 | (1 << 1), 1), + (3, (1 << 1), 135), + (6, (1 << 1), 135), + (10, (1 << 1), 135), + (15, (1 << 1), 135), + (24, (1 << 1), 135), + (31, (1 << 1), 135), + (41, (1 << 1), 135), + (56, 1 | (1 << 1), 135), + + # Node 149 + (3, (1 << 1), 137), + (6, (1 << 1), 137), + (10, (1 << 1), 137), + (15, (1 << 1), 137), + (24, (1 << 1), 137), + (31, (1 << 1), 137), + (41, (1 << 1), 137), + (56, 1 | (1 << 1), 137), + (3, (1 << 1), 138), + (6, (1 << 1), 138), + (10, (1 << 1), 138), + (15, (1 << 1), 138), + (24, (1 << 1), 138), + (31, (1 << 1), 138), + (41, (1 << 1), 138), + (56, 1 | (1 << 1), 138), + + # Node 150 + (2, (1 << 1), 139), + (9, (1 << 1), 139), + (23, (1 << 1), 139), + (40, 1 | (1 << 1), 139), + (2, (1 << 1), 140), + (9, (1 << 1), 140), + (23, (1 << 1), 140), + (40, 1 | (1 << 1), 140), + (2, (1 << 1), 141), + (9, (1 << 1), 141), + (23, (1 << 1), 141), + (40, 1 | (1 << 1), 141), + (2, (1 << 1), 143), + (9, (1 << 1), 143), + (23, (1 << 1), 143), + (40, 1 | (1 << 1), 143), + + # Node 151 + (3, (1 << 1), 139), + (6, (1 << 1), 139), + (10, (1 << 1), 139), + (15, (1 << 1), 139), + (24, (1 << 1), 139), + (31, (1 << 1), 139), + (41, (1 << 1), 139), + (56, 1 | (1 << 1), 139), + (3, (1 << 1), 140), + (6, (1 << 1), 140), + (10, (1 << 1), 140), + (15, (1 << 1), 140), + (24, (1 << 1), 140), + (31, (1 << 1), 140), + (41, (1 << 1), 140), + (56, 1 | (1 << 1), 140), + + # Node 152 + (3, (1 << 1), 141), + (6, (1 << 1), 141), + (10, (1 << 1), 141), + (15, (1 << 1), 141), + (24, (1 << 1), 141), + (31, (1 << 1), 141), + (41, (1 << 1), 141), + (56, 1 | (1 << 1), 141), + (3, (1 << 1), 143), + (6, (1 << 1), 143), + (10, (1 << 1), 143), + (15, (1 << 1), 143), + (24, (1 << 1), 143), + (31, (1 << 1), 143), + (41, (1 << 1), 143), + (56, 1 | (1 << 1), 143), + + # Node 153 + (157, 0, 0), + (158, 0, 0), + (160, 0, 0), + (161, 0, 0), + (164, 0, 0), + (165, 0, 0), + (167, 0, 0), + (168, 0, 0), + (172, 0, 0), + (173, 0, 0), + (175, 0, 0), + (177, 0, 0), + (182, 0, 0), + (185, 0, 0), + (191, 0, 0), + (207, 0, 0), + + # Node 154 + (0, 1 | (1 << 1), 147), + (0, 1 | (1 << 1), 149), + (0, 1 | (1 << 1), 150), + (0, 1 | (1 << 1), 151), + (0, 1 | (1 << 1), 152), + (0, 1 | (1 << 1), 155), + (0, 1 | (1 << 1), 157), + (0, 1 | (1 << 1), 158), + (0, 1 | (1 << 1), 165), + (0, 1 | (1 << 1), 166), + (0, 1 | (1 << 1), 168), + (0, 1 | (1 << 1), 174), + (0, 1 | (1 << 1), 175), + (0, 1 | (1 << 1), 180), + (0, 1 | (1 << 1), 182), + (0, 1 | (1 << 1), 183), + + # Node 155 + (1, (1 << 1), 147), + (22, 1 | (1 << 1), 147), + (1, (1 << 1), 149), + (22, 1 | (1 << 1), 149), + (1, (1 << 1), 150), + (22, 1 | (1 << 1), 150), + (1, (1 << 1), 151), + (22, 1 | (1 << 1), 151), + (1, (1 << 1), 152), + (22, 1 | (1 << 1), 152), + (1, (1 << 1), 155), + (22, 1 | (1 << 1), 155), + (1, (1 << 1), 157), + (22, 1 | (1 << 1), 157), + (1, (1 << 1), 158), + (22, 1 | (1 << 1), 158), + + # Node 156 + (2, (1 << 1), 147), + (9, (1 << 1), 147), + (23, (1 << 1), 147), + (40, 1 | (1 << 1), 147), + (2, (1 << 1), 149), + (9, (1 << 1), 149), + (23, (1 << 1), 149), + (40, 1 | (1 << 1), 149), + (2, (1 << 1), 150), + (9, (1 << 1), 150), + (23, (1 << 1), 150), + (40, 1 | (1 << 1), 150), + (2, (1 << 1), 151), + (9, (1 << 1), 151), + (23, (1 << 1), 151), + (40, 1 | (1 << 1), 151), + + # Node 157 + (3, (1 << 1), 147), + (6, (1 << 1), 147), + (10, (1 << 1), 147), + (15, (1 << 1), 147), + (24, (1 << 1), 147), + (31, (1 << 1), 147), + (41, (1 << 1), 147), + (56, 1 | (1 << 1), 147), + (3, (1 << 1), 149), + (6, (1 << 1), 149), + (10, (1 << 1), 149), + (15, (1 << 1), 149), + (24, (1 << 1), 149), + (31, (1 << 1), 149), + (41, (1 << 1), 149), + (56, 1 | (1 << 1), 149), + + # Node 158 + (3, (1 << 1), 150), + (6, (1 << 1), 150), + (10, (1 << 1), 150), + (15, (1 << 1), 150), + (24, (1 << 1), 150), + (31, (1 << 1), 150), + (41, (1 << 1), 150), + (56, 1 | (1 << 1), 150), + (3, (1 << 1), 151), + (6, (1 << 1), 151), + (10, (1 << 1), 151), + (15, (1 << 1), 151), + (24, (1 << 1), 151), + (31, (1 << 1), 151), + (41, (1 << 1), 151), + (56, 1 | (1 << 1), 151), + + # Node 159 + (2, (1 << 1), 152), + (9, (1 << 1), 152), + (23, (1 << 1), 152), + (40, 1 | (1 << 1), 152), + (2, (1 << 1), 155), + (9, (1 << 1), 155), + (23, (1 << 1), 155), + (40, 1 | (1 << 1), 155), + (2, (1 << 1), 157), + (9, (1 << 1), 157), + (23, (1 << 1), 157), + (40, 1 | (1 << 1), 157), + (2, (1 << 1), 158), + (9, (1 << 1), 158), + (23, (1 << 1), 158), + (40, 1 | (1 << 1), 158), + + # Node 160 + (3, (1 << 1), 152), + (6, (1 << 1), 152), + (10, (1 << 1), 152), + (15, (1 << 1), 152), + (24, (1 << 1), 152), + (31, (1 << 1), 152), + (41, (1 << 1), 152), + (56, 1 | (1 << 1), 152), + (3, (1 << 1), 155), + (6, (1 << 1), 155), + (10, (1 << 1), 155), + (15, (1 << 1), 155), + (24, (1 << 1), 155), + (31, (1 << 1), 155), + (41, (1 << 1), 155), + (56, 1 | (1 << 1), 155), + + # Node 161 + (3, (1 << 1), 157), + (6, (1 << 1), 157), + (10, (1 << 1), 157), + (15, (1 << 1), 157), + (24, (1 << 1), 157), + (31, (1 << 1), 157), + (41, (1 << 1), 157), + (56, 1 | (1 << 1), 157), + (3, (1 << 1), 158), + (6, (1 << 1), 158), + (10, (1 << 1), 158), + (15, (1 << 1), 158), + (24, (1 << 1), 158), + (31, (1 << 1), 158), + (41, (1 << 1), 158), + (56, 1 | (1 << 1), 158), + + # Node 162 + (1, (1 << 1), 165), + (22, 1 | (1 << 1), 165), + (1, (1 << 1), 166), + (22, 1 | (1 << 1), 166), + (1, (1 << 1), 168), + (22, 1 | (1 << 1), 168), + (1, (1 << 1), 174), + (22, 1 | (1 << 1), 174), + (1, (1 << 1), 175), + (22, 1 | (1 << 1), 175), + (1, (1 << 1), 180), + (22, 1 | (1 << 1), 180), + (1, (1 << 1), 182), + (22, 1 | (1 << 1), 182), + (1, (1 << 1), 183), + (22, 1 | (1 << 1), 183), + + # Node 163 + (2, (1 << 1), 165), + (9, (1 << 1), 165), + (23, (1 << 1), 165), + (40, 1 | (1 << 1), 165), + (2, (1 << 1), 166), + (9, (1 << 1), 166), + (23, (1 << 1), 166), + (40, 1 | (1 << 1), 166), + (2, (1 << 1), 168), + (9, (1 << 1), 168), + (23, (1 << 1), 168), + (40, 1 | (1 << 1), 168), + (2, (1 << 1), 174), + (9, (1 << 1), 174), + (23, (1 << 1), 174), + (40, 1 | (1 << 1), 174), + + # Node 164 + (3, (1 << 1), 165), + (6, (1 << 1), 165), + (10, (1 << 1), 165), + (15, (1 << 1), 165), + (24, (1 << 1), 165), + (31, (1 << 1), 165), + (41, (1 << 1), 165), + (56, 1 | (1 << 1), 165), + (3, (1 << 1), 166), + (6, (1 << 1), 166), + (10, (1 << 1), 166), + (15, (1 << 1), 166), + (24, (1 << 1), 166), + (31, (1 << 1), 166), + (41, (1 << 1), 166), + (56, 1 | (1 << 1), 166), + + # Node 165 + (3, (1 << 1), 168), + (6, (1 << 1), 168), + (10, (1 << 1), 168), + (15, (1 << 1), 168), + (24, (1 << 1), 168), + (31, (1 << 1), 168), + (41, (1 << 1), 168), + (56, 1 | (1 << 1), 168), + (3, (1 << 1), 174), + (6, (1 << 1), 174), + (10, (1 << 1), 174), + (15, (1 << 1), 174), + (24, (1 << 1), 174), + (31, (1 << 1), 174), + (41, (1 << 1), 174), + (56, 1 | (1 << 1), 174), + + # Node 166 + (2, (1 << 1), 175), + (9, (1 << 1), 175), + (23, (1 << 1), 175), + (40, 1 | (1 << 1), 175), + (2, (1 << 1), 180), + (9, (1 << 1), 180), + (23, (1 << 1), 180), + (40, 1 | (1 << 1), 180), + (2, (1 << 1), 182), + (9, (1 << 1), 182), + (23, (1 << 1), 182), + (40, 1 | (1 << 1), 182), + (2, (1 << 1), 183), + (9, (1 << 1), 183), + (23, (1 << 1), 183), + (40, 1 | (1 << 1), 183), + + # Node 167 + (3, (1 << 1), 175), + (6, (1 << 1), 175), + (10, (1 << 1), 175), + (15, (1 << 1), 175), + (24, (1 << 1), 175), + (31, (1 << 1), 175), + (41, (1 << 1), 175), + (56, 1 | (1 << 1), 175), + (3, (1 << 1), 180), + (6, (1 << 1), 180), + (10, (1 << 1), 180), + (15, (1 << 1), 180), + (24, (1 << 1), 180), + (31, (1 << 1), 180), + (41, (1 << 1), 180), + (56, 1 | (1 << 1), 180), + + # Node 168 + (3, (1 << 1), 182), + (6, (1 << 1), 182), + (10, (1 << 1), 182), + (15, (1 << 1), 182), + (24, (1 << 1), 182), + (31, (1 << 1), 182), + (41, (1 << 1), 182), + (56, 1 | (1 << 1), 182), + (3, (1 << 1), 183), + (6, (1 << 1), 183), + (10, (1 << 1), 183), + (15, (1 << 1), 183), + (24, (1 << 1), 183), + (31, (1 << 1), 183), + (41, (1 << 1), 183), + (56, 1 | (1 << 1), 183), + + # Node 169 + (0, 1 | (1 << 1), 188), + (0, 1 | (1 << 1), 191), + (0, 1 | (1 << 1), 197), + (0, 1 | (1 << 1), 231), + (0, 1 | (1 << 1), 239), + (176, 0, 0), + (178, 0, 0), + (179, 0, 0), + (183, 0, 0), + (184, 0, 0), + (186, 0, 0), + (187, 0, 0), + (192, 0, 0), + (199, 0, 0), + (208, 0, 0), + (223, 0, 0), + + # Node 170 + (1, (1 << 1), 188), + (22, 1 | (1 << 1), 188), + (1, (1 << 1), 191), + (22, 1 | (1 << 1), 191), + (1, (1 << 1), 197), + (22, 1 | (1 << 1), 197), + (1, (1 << 1), 231), + (22, 1 | (1 << 1), 231), + (1, (1 << 1), 239), + (22, 1 | (1 << 1), 239), + (0, 1 | (1 << 1), 9), + (0, 1 | (1 << 1), 142), + (0, 1 | (1 << 1), 144), + (0, 1 | (1 << 1), 145), + (0, 1 | (1 << 1), 148), + (0, 1 | (1 << 1), 159), + + # Node 171 + (2, (1 << 1), 188), + (9, (1 << 1), 188), + (23, (1 << 1), 188), + (40, 1 | (1 << 1), 188), + (2, (1 << 1), 191), + (9, (1 << 1), 191), + (23, (1 << 1), 191), + (40, 1 | (1 << 1), 191), + (2, (1 << 1), 197), + (9, (1 << 1), 197), + (23, (1 << 1), 197), + (40, 1 | (1 << 1), 197), + (2, (1 << 1), 231), + (9, (1 << 1), 231), + (23, (1 << 1), 231), + (40, 1 | (1 << 1), 231), + + # Node 172 + (3, (1 << 1), 188), + (6, (1 << 1), 188), + (10, (1 << 1), 188), + (15, (1 << 1), 188), + (24, (1 << 1), 188), + (31, (1 << 1), 188), + (41, (1 << 1), 188), + (56, 1 | (1 << 1), 188), + (3, (1 << 1), 191), + (6, (1 << 1), 191), + (10, (1 << 1), 191), + (15, (1 << 1), 191), + (24, (1 << 1), 191), + (31, (1 << 1), 191), + (41, (1 << 1), 191), + (56, 1 | (1 << 1), 191), + + # Node 173 + (3, (1 << 1), 197), + (6, (1 << 1), 197), + (10, (1 << 1), 197), + (15, (1 << 1), 197), + (24, (1 << 1), 197), + (31, (1 << 1), 197), + (41, (1 << 1), 197), + (56, 1 | (1 << 1), 197), + (3, (1 << 1), 231), + (6, (1 << 1), 231), + (10, (1 << 1), 231), + (15, (1 << 1), 231), + (24, (1 << 1), 231), + (31, (1 << 1), 231), + (41, (1 << 1), 231), + (56, 1 | (1 << 1), 231), + + # Node 174 + (2, (1 << 1), 239), + (9, (1 << 1), 239), + (23, (1 << 1), 239), + (40, 1 | (1 << 1), 239), + (1, (1 << 1), 9), + (22, 1 | (1 << 1), 9), + (1, (1 << 1), 142), + (22, 1 | (1 << 1), 142), + (1, (1 << 1), 144), + (22, 1 | (1 << 1), 144), + (1, (1 << 1), 145), + (22, 1 | (1 << 1), 145), + (1, (1 << 1), 148), + (22, 1 | (1 << 1), 148), + (1, (1 << 1), 159), + (22, 1 | (1 << 1), 159), + + # Node 175 + (3, (1 << 1), 239), + (6, (1 << 1), 239), + (10, (1 << 1), 239), + (15, (1 << 1), 239), + (24, (1 << 1), 239), + (31, (1 << 1), 239), + (41, (1 << 1), 239), + (56, 1 | (1 << 1), 239), + (2, (1 << 1), 9), + (9, (1 << 1), 9), + (23, (1 << 1), 9), + (40, 1 | (1 << 1), 9), + (2, (1 << 1), 142), + (9, (1 << 1), 142), + (23, (1 << 1), 142), + (40, 1 | (1 << 1), 142), + + # Node 176 + (3, (1 << 1), 9), + (6, (1 << 1), 9), + (10, (1 << 1), 9), + (15, (1 << 1), 9), + (24, (1 << 1), 9), + (31, (1 << 1), 9), + (41, (1 << 1), 9), + (56, 1 | (1 << 1), 9), + (3, (1 << 1), 142), + (6, (1 << 1), 142), + (10, (1 << 1), 142), + (15, (1 << 1), 142), + (24, (1 << 1), 142), + (31, (1 << 1), 142), + (41, (1 << 1), 142), + (56, 1 | (1 << 1), 142), + + # Node 177 + (2, (1 << 1), 144), + (9, (1 << 1), 144), + (23, (1 << 1), 144), + (40, 1 | (1 << 1), 144), + (2, (1 << 1), 145), + (9, (1 << 1), 145), + (23, (1 << 1), 145), + (40, 1 | (1 << 1), 145), + (2, (1 << 1), 148), + (9, (1 << 1), 148), + (23, (1 << 1), 148), + (40, 1 | (1 << 1), 148), + (2, (1 << 1), 159), + (9, (1 << 1), 159), + (23, (1 << 1), 159), + (40, 1 | (1 << 1), 159), + + # Node 178 + (3, (1 << 1), 144), + (6, (1 << 1), 144), + (10, (1 << 1), 144), + (15, (1 << 1), 144), + (24, (1 << 1), 144), + (31, (1 << 1), 144), + (41, (1 << 1), 144), + (56, 1 | (1 << 1), 144), + (3, (1 << 1), 145), + (6, (1 << 1), 145), + (10, (1 << 1), 145), + (15, (1 << 1), 145), + (24, (1 << 1), 145), + (31, (1 << 1), 145), + (41, (1 << 1), 145), + (56, 1 | (1 << 1), 145), + + # Node 179 + (3, (1 << 1), 148), + (6, (1 << 1), 148), + (10, (1 << 1), 148), + (15, (1 << 1), 148), + (24, (1 << 1), 148), + (31, (1 << 1), 148), + (41, (1 << 1), 148), + (56, 1 | (1 << 1), 148), + (3, (1 << 1), 159), + (6, (1 << 1), 159), + (10, (1 << 1), 159), + (15, (1 << 1), 159), + (24, (1 << 1), 159), + (31, (1 << 1), 159), + (41, (1 << 1), 159), + (56, 1 | (1 << 1), 159), + + # Node 180 + (0, 1 | (1 << 1), 171), + (0, 1 | (1 << 1), 206), + (0, 1 | (1 << 1), 215), + (0, 1 | (1 << 1), 225), + (0, 1 | (1 << 1), 236), + (0, 1 | (1 << 1), 237), + (188, 0, 0), + (189, 0, 0), + (193, 0, 0), + (196, 0, 0), + (200, 0, 0), + (203, 0, 0), + (209, 0, 0), + (216, 0, 0), + (224, 0, 0), + (238, 0, 0), + + # Node 181 + (1, (1 << 1), 171), + (22, 1 | (1 << 1), 171), + (1, (1 << 1), 206), + (22, 1 | (1 << 1), 206), + (1, (1 << 1), 215), + (22, 1 | (1 << 1), 215), + (1, (1 << 1), 225), + (22, 1 | (1 << 1), 225), + (1, (1 << 1), 236), + (22, 1 | (1 << 1), 236), + (1, (1 << 1), 237), + (22, 1 | (1 << 1), 237), + (0, 1 | (1 << 1), 199), + (0, 1 | (1 << 1), 207), + (0, 1 | (1 << 1), 234), + (0, 1 | (1 << 1), 235), + + # Node 182 + (2, (1 << 1), 171), + (9, (1 << 1), 171), + (23, (1 << 1), 171), + (40, 1 | (1 << 1), 171), + (2, (1 << 1), 206), + (9, (1 << 1), 206), + (23, (1 << 1), 206), + (40, 1 | (1 << 1), 206), + (2, (1 << 1), 215), + (9, (1 << 1), 215), + (23, (1 << 1), 215), + (40, 1 | (1 << 1), 215), + (2, (1 << 1), 225), + (9, (1 << 1), 225), + (23, (1 << 1), 225), + (40, 1 | (1 << 1), 225), + + # Node 183 + (3, (1 << 1), 171), + (6, (1 << 1), 171), + (10, (1 << 1), 171), + (15, (1 << 1), 171), + (24, (1 << 1), 171), + (31, (1 << 1), 171), + (41, (1 << 1), 171), + (56, 1 | (1 << 1), 171), + (3, (1 << 1), 206), + (6, (1 << 1), 206), + (10, (1 << 1), 206), + (15, (1 << 1), 206), + (24, (1 << 1), 206), + (31, (1 << 1), 206), + (41, (1 << 1), 206), + (56, 1 | (1 << 1), 206), + + # Node 184 + (3, (1 << 1), 215), + (6, (1 << 1), 215), + (10, (1 << 1), 215), + (15, (1 << 1), 215), + (24, (1 << 1), 215), + (31, (1 << 1), 215), + (41, (1 << 1), 215), + (56, 1 | (1 << 1), 215), + (3, (1 << 1), 225), + (6, (1 << 1), 225), + (10, (1 << 1), 225), + (15, (1 << 1), 225), + (24, (1 << 1), 225), + (31, (1 << 1), 225), + (41, (1 << 1), 225), + (56, 1 | (1 << 1), 225), + + # Node 185 + (2, (1 << 1), 236), + (9, (1 << 1), 236), + (23, (1 << 1), 236), + (40, 1 | (1 << 1), 236), + (2, (1 << 1), 237), + (9, (1 << 1), 237), + (23, (1 << 1), 237), + (40, 1 | (1 << 1), 237), + (1, (1 << 1), 199), + (22, 1 | (1 << 1), 199), + (1, (1 << 1), 207), + (22, 1 | (1 << 1), 207), + (1, (1 << 1), 234), + (22, 1 | (1 << 1), 234), + (1, (1 << 1), 235), + (22, 1 | (1 << 1), 235), + + # Node 186 + (3, (1 << 1), 236), + (6, (1 << 1), 236), + (10, (1 << 1), 236), + (15, (1 << 1), 236), + (24, (1 << 1), 236), + (31, (1 << 1), 236), + (41, (1 << 1), 236), + (56, 1 | (1 << 1), 236), + (3, (1 << 1), 237), + (6, (1 << 1), 237), + (10, (1 << 1), 237), + (15, (1 << 1), 237), + (24, (1 << 1), 237), + (31, (1 << 1), 237), + (41, (1 << 1), 237), + (56, 1 | (1 << 1), 237), + + # Node 187 + (2, (1 << 1), 199), + (9, (1 << 1), 199), + (23, (1 << 1), 199), + (40, 1 | (1 << 1), 199), + (2, (1 << 1), 207), + (9, (1 << 1), 207), + (23, (1 << 1), 207), + (40, 1 | (1 << 1), 207), + (2, (1 << 1), 234), + (9, (1 << 1), 234), + (23, (1 << 1), 234), + (40, 1 | (1 << 1), 234), + (2, (1 << 1), 235), + (9, (1 << 1), 235), + (23, (1 << 1), 235), + (40, 1 | (1 << 1), 235), + + # Node 188 + (3, (1 << 1), 199), + (6, (1 << 1), 199), + (10, (1 << 1), 199), + (15, (1 << 1), 199), + (24, (1 << 1), 199), + (31, (1 << 1), 199), + (41, (1 << 1), 199), + (56, 1 | (1 << 1), 199), + (3, (1 << 1), 207), + (6, (1 << 1), 207), + (10, (1 << 1), 207), + (15, (1 << 1), 207), + (24, (1 << 1), 207), + (31, (1 << 1), 207), + (41, (1 << 1), 207), + (56, 1 | (1 << 1), 207), + + # Node 189 + (3, (1 << 1), 234), + (6, (1 << 1), 234), + (10, (1 << 1), 234), + (15, (1 << 1), 234), + (24, (1 << 1), 234), + (31, (1 << 1), 234), + (41, (1 << 1), 234), + (56, 1 | (1 << 1), 234), + (3, (1 << 1), 235), + (6, (1 << 1), 235), + (10, (1 << 1), 235), + (15, (1 << 1), 235), + (24, (1 << 1), 235), + (31, (1 << 1), 235), + (41, (1 << 1), 235), + (56, 1 | (1 << 1), 235), + + # Node 190 + (194, 0, 0), + (195, 0, 0), + (197, 0, 0), + (198, 0, 0), + (201, 0, 0), + (202, 0, 0), + (204, 0, 0), + (205, 0, 0), + (210, 0, 0), + (213, 0, 0), + (217, 0, 0), + (220, 0, 0), + (225, 0, 0), + (231, 0, 0), + (239, 0, 0), + (246, 0, 0), + + # Node 191 + (0, 1 | (1 << 1), 192), + (0, 1 | (1 << 1), 193), + (0, 1 | (1 << 1), 200), + (0, 1 | (1 << 1), 201), + (0, 1 | (1 << 1), 202), + (0, 1 | (1 << 1), 205), + (0, 1 | (1 << 1), 210), + (0, 1 | (1 << 1), 213), + (0, 1 | (1 << 1), 218), + (0, 1 | (1 << 1), 219), + (0, 1 | (1 << 1), 238), + (0, 1 | (1 << 1), 240), + (0, 1 | (1 << 1), 242), + (0, 1 | (1 << 1), 243), + (0, 1 | (1 << 1), 255), + (206, 0, 0), + + # Node 192 + (1, (1 << 1), 192), + (22, 1 | (1 << 1), 192), + (1, (1 << 1), 193), + (22, 1 | (1 << 1), 193), + (1, (1 << 1), 200), + (22, 1 | (1 << 1), 200), + (1, (1 << 1), 201), + (22, 1 | (1 << 1), 201), + (1, (1 << 1), 202), + (22, 1 | (1 << 1), 202), + (1, (1 << 1), 205), + (22, 1 | (1 << 1), 205), + (1, (1 << 1), 210), + (22, 1 | (1 << 1), 210), + (1, (1 << 1), 213), + (22, 1 | (1 << 1), 213), + + # Node 193 + (2, (1 << 1), 192), + (9, (1 << 1), 192), + (23, (1 << 1), 192), + (40, 1 | (1 << 1), 192), + (2, (1 << 1), 193), + (9, (1 << 1), 193), + (23, (1 << 1), 193), + (40, 1 | (1 << 1), 193), + (2, (1 << 1), 200), + (9, (1 << 1), 200), + (23, (1 << 1), 200), + (40, 1 | (1 << 1), 200), + (2, (1 << 1), 201), + (9, (1 << 1), 201), + (23, (1 << 1), 201), + (40, 1 | (1 << 1), 201), + + # Node 194 + (3, (1 << 1), 192), + (6, (1 << 1), 192), + (10, (1 << 1), 192), + (15, (1 << 1), 192), + (24, (1 << 1), 192), + (31, (1 << 1), 192), + (41, (1 << 1), 192), + (56, 1 | (1 << 1), 192), + (3, (1 << 1), 193), + (6, (1 << 1), 193), + (10, (1 << 1), 193), + (15, (1 << 1), 193), + (24, (1 << 1), 193), + (31, (1 << 1), 193), + (41, (1 << 1), 193), + (56, 1 | (1 << 1), 193), + + # Node 195 + (3, (1 << 1), 200), + (6, (1 << 1), 200), + (10, (1 << 1), 200), + (15, (1 << 1), 200), + (24, (1 << 1), 200), + (31, (1 << 1), 200), + (41, (1 << 1), 200), + (56, 1 | (1 << 1), 200), + (3, (1 << 1), 201), + (6, (1 << 1), 201), + (10, (1 << 1), 201), + (15, (1 << 1), 201), + (24, (1 << 1), 201), + (31, (1 << 1), 201), + (41, (1 << 1), 201), + (56, 1 | (1 << 1), 201), + + # Node 196 + (2, (1 << 1), 202), + (9, (1 << 1), 202), + (23, (1 << 1), 202), + (40, 1 | (1 << 1), 202), + (2, (1 << 1), 205), + (9, (1 << 1), 205), + (23, (1 << 1), 205), + (40, 1 | (1 << 1), 205), + (2, (1 << 1), 210), + (9, (1 << 1), 210), + (23, (1 << 1), 210), + (40, 1 | (1 << 1), 210), + (2, (1 << 1), 213), + (9, (1 << 1), 213), + (23, (1 << 1), 213), + (40, 1 | (1 << 1), 213), + + # Node 197 + (3, (1 << 1), 202), + (6, (1 << 1), 202), + (10, (1 << 1), 202), + (15, (1 << 1), 202), + (24, (1 << 1), 202), + (31, (1 << 1), 202), + (41, (1 << 1), 202), + (56, 1 | (1 << 1), 202), + (3, (1 << 1), 205), + (6, (1 << 1), 205), + (10, (1 << 1), 205), + (15, (1 << 1), 205), + (24, (1 << 1), 205), + (31, (1 << 1), 205), + (41, (1 << 1), 205), + (56, 1 | (1 << 1), 205), + + # Node 198 + (3, (1 << 1), 210), + (6, (1 << 1), 210), + (10, (1 << 1), 210), + (15, (1 << 1), 210), + (24, (1 << 1), 210), + (31, (1 << 1), 210), + (41, (1 << 1), 210), + (56, 1 | (1 << 1), 210), + (3, (1 << 1), 213), + (6, (1 << 1), 213), + (10, (1 << 1), 213), + (15, (1 << 1), 213), + (24, (1 << 1), 213), + (31, (1 << 1), 213), + (41, (1 << 1), 213), + (56, 1 | (1 << 1), 213), + + # Node 199 + (1, (1 << 1), 218), + (22, 1 | (1 << 1), 218), + (1, (1 << 1), 219), + (22, 1 | (1 << 1), 219), + (1, (1 << 1), 238), + (22, 1 | (1 << 1), 238), + (1, (1 << 1), 240), + (22, 1 | (1 << 1), 240), + (1, (1 << 1), 242), + (22, 1 | (1 << 1), 242), + (1, (1 << 1), 243), + (22, 1 | (1 << 1), 243), + (1, (1 << 1), 255), + (22, 1 | (1 << 1), 255), + (0, 1 | (1 << 1), 203), + (0, 1 | (1 << 1), 204), + + # Node 200 + (2, (1 << 1), 218), + (9, (1 << 1), 218), + (23, (1 << 1), 218), + (40, 1 | (1 << 1), 218), + (2, (1 << 1), 219), + (9, (1 << 1), 219), + (23, (1 << 1), 219), + (40, 1 | (1 << 1), 219), + (2, (1 << 1), 238), + (9, (1 << 1), 238), + (23, (1 << 1), 238), + (40, 1 | (1 << 1), 238), + (2, (1 << 1), 240), + (9, (1 << 1), 240), + (23, (1 << 1), 240), + (40, 1 | (1 << 1), 240), + + # Node 201 + (3, (1 << 1), 218), + (6, (1 << 1), 218), + (10, (1 << 1), 218), + (15, (1 << 1), 218), + (24, (1 << 1), 218), + (31, (1 << 1), 218), + (41, (1 << 1), 218), + (56, 1 | (1 << 1), 218), + (3, (1 << 1), 219), + (6, (1 << 1), 219), + (10, (1 << 1), 219), + (15, (1 << 1), 219), + (24, (1 << 1), 219), + (31, (1 << 1), 219), + (41, (1 << 1), 219), + (56, 1 | (1 << 1), 219), + + # Node 202 + (3, (1 << 1), 238), + (6, (1 << 1), 238), + (10, (1 << 1), 238), + (15, (1 << 1), 238), + (24, (1 << 1), 238), + (31, (1 << 1), 238), + (41, (1 << 1), 238), + (56, 1 | (1 << 1), 238), + (3, (1 << 1), 240), + (6, (1 << 1), 240), + (10, (1 << 1), 240), + (15, (1 << 1), 240), + (24, (1 << 1), 240), + (31, (1 << 1), 240), + (41, (1 << 1), 240), + (56, 1 | (1 << 1), 240), + + # Node 203 + (2, (1 << 1), 242), + (9, (1 << 1), 242), + (23, (1 << 1), 242), + (40, 1 | (1 << 1), 242), + (2, (1 << 1), 243), + (9, (1 << 1), 243), + (23, (1 << 1), 243), + (40, 1 | (1 << 1), 243), + (2, (1 << 1), 255), + (9, (1 << 1), 255), + (23, (1 << 1), 255), + (40, 1 | (1 << 1), 255), + (1, (1 << 1), 203), + (22, 1 | (1 << 1), 203), + (1, (1 << 1), 204), + (22, 1 | (1 << 1), 204), + + # Node 204 + (3, (1 << 1), 242), + (6, (1 << 1), 242), + (10, (1 << 1), 242), + (15, (1 << 1), 242), + (24, (1 << 1), 242), + (31, (1 << 1), 242), + (41, (1 << 1), 242), + (56, 1 | (1 << 1), 242), + (3, (1 << 1), 243), + (6, (1 << 1), 243), + (10, (1 << 1), 243), + (15, (1 << 1), 243), + (24, (1 << 1), 243), + (31, (1 << 1), 243), + (41, (1 << 1), 243), + (56, 1 | (1 << 1), 243), + + # Node 205 + (3, (1 << 1), 255), + (6, (1 << 1), 255), + (10, (1 << 1), 255), + (15, (1 << 1), 255), + (24, (1 << 1), 255), + (31, (1 << 1), 255), + (41, (1 << 1), 255), + (56, 1 | (1 << 1), 255), + (2, (1 << 1), 203), + (9, (1 << 1), 203), + (23, (1 << 1), 203), + (40, 1 | (1 << 1), 203), + (2, (1 << 1), 204), + (9, (1 << 1), 204), + (23, (1 << 1), 204), + (40, 1 | (1 << 1), 204), + + # Node 206 + (3, (1 << 1), 203), + (6, (1 << 1), 203), + (10, (1 << 1), 203), + (15, (1 << 1), 203), + (24, (1 << 1), 203), + (31, (1 << 1), 203), + (41, (1 << 1), 203), + (56, 1 | (1 << 1), 203), + (3, (1 << 1), 204), + (6, (1 << 1), 204), + (10, (1 << 1), 204), + (15, (1 << 1), 204), + (24, (1 << 1), 204), + (31, (1 << 1), 204), + (41, (1 << 1), 204), + (56, 1 | (1 << 1), 204), + + # Node 207 + (211, 0, 0), + (212, 0, 0), + (214, 0, 0), + (215, 0, 0), + (218, 0, 0), + (219, 0, 0), + (221, 0, 0), + (222, 0, 0), + (226, 0, 0), + (228, 0, 0), + (232, 0, 0), + (235, 0, 0), + (240, 0, 0), + (243, 0, 0), + (247, 0, 0), + (250, 0, 0), + + # Node 208 + (0, 1 | (1 << 1), 211), + (0, 1 | (1 << 1), 212), + (0, 1 | (1 << 1), 214), + (0, 1 | (1 << 1), 221), + (0, 1 | (1 << 1), 222), + (0, 1 | (1 << 1), 223), + (0, 1 | (1 << 1), 241), + (0, 1 | (1 << 1), 244), + (0, 1 | (1 << 1), 245), + (0, 1 | (1 << 1), 246), + (0, 1 | (1 << 1), 247), + (0, 1 | (1 << 1), 248), + (0, 1 | (1 << 1), 250), + (0, 1 | (1 << 1), 251), + (0, 1 | (1 << 1), 252), + (0, 1 | (1 << 1), 253), + + # Node 209 + (1, (1 << 1), 211), + (22, 1 | (1 << 1), 211), + (1, (1 << 1), 212), + (22, 1 | (1 << 1), 212), + (1, (1 << 1), 214), + (22, 1 | (1 << 1), 214), + (1, (1 << 1), 221), + (22, 1 | (1 << 1), 221), + (1, (1 << 1), 222), + (22, 1 | (1 << 1), 222), + (1, (1 << 1), 223), + (22, 1 | (1 << 1), 223), + (1, (1 << 1), 241), + (22, 1 | (1 << 1), 241), + (1, (1 << 1), 244), + (22, 1 | (1 << 1), 244), + + # Node 210 + (2, (1 << 1), 211), + (9, (1 << 1), 211), + (23, (1 << 1), 211), + (40, 1 | (1 << 1), 211), + (2, (1 << 1), 212), + (9, (1 << 1), 212), + (23, (1 << 1), 212), + (40, 1 | (1 << 1), 212), + (2, (1 << 1), 214), + (9, (1 << 1), 214), + (23, (1 << 1), 214), + (40, 1 | (1 << 1), 214), + (2, (1 << 1), 221), + (9, (1 << 1), 221), + (23, (1 << 1), 221), + (40, 1 | (1 << 1), 221), + + # Node 211 + (3, (1 << 1), 211), + (6, (1 << 1), 211), + (10, (1 << 1), 211), + (15, (1 << 1), 211), + (24, (1 << 1), 211), + (31, (1 << 1), 211), + (41, (1 << 1), 211), + (56, 1 | (1 << 1), 211), + (3, (1 << 1), 212), + (6, (1 << 1), 212), + (10, (1 << 1), 212), + (15, (1 << 1), 212), + (24, (1 << 1), 212), + (31, (1 << 1), 212), + (41, (1 << 1), 212), + (56, 1 | (1 << 1), 212), + + # Node 212 + (3, (1 << 1), 214), + (6, (1 << 1), 214), + (10, (1 << 1), 214), + (15, (1 << 1), 214), + (24, (1 << 1), 214), + (31, (1 << 1), 214), + (41, (1 << 1), 214), + (56, 1 | (1 << 1), 214), + (3, (1 << 1), 221), + (6, (1 << 1), 221), + (10, (1 << 1), 221), + (15, (1 << 1), 221), + (24, (1 << 1), 221), + (31, (1 << 1), 221), + (41, (1 << 1), 221), + (56, 1 | (1 << 1), 221), + + # Node 213 + (2, (1 << 1), 222), + (9, (1 << 1), 222), + (23, (1 << 1), 222), + (40, 1 | (1 << 1), 222), + (2, (1 << 1), 223), + (9, (1 << 1), 223), + (23, (1 << 1), 223), + (40, 1 | (1 << 1), 223), + (2, (1 << 1), 241), + (9, (1 << 1), 241), + (23, (1 << 1), 241), + (40, 1 | (1 << 1), 241), + (2, (1 << 1), 244), + (9, (1 << 1), 244), + (23, (1 << 1), 244), + (40, 1 | (1 << 1), 244), + + # Node 214 + (3, (1 << 1), 222), + (6, (1 << 1), 222), + (10, (1 << 1), 222), + (15, (1 << 1), 222), + (24, (1 << 1), 222), + (31, (1 << 1), 222), + (41, (1 << 1), 222), + (56, 1 | (1 << 1), 222), + (3, (1 << 1), 223), + (6, (1 << 1), 223), + (10, (1 << 1), 223), + (15, (1 << 1), 223), + (24, (1 << 1), 223), + (31, (1 << 1), 223), + (41, (1 << 1), 223), + (56, 1 | (1 << 1), 223), + + # Node 215 + (3, (1 << 1), 241), + (6, (1 << 1), 241), + (10, (1 << 1), 241), + (15, (1 << 1), 241), + (24, (1 << 1), 241), + (31, (1 << 1), 241), + (41, (1 << 1), 241), + (56, 1 | (1 << 1), 241), + (3, (1 << 1), 244), + (6, (1 << 1), 244), + (10, (1 << 1), 244), + (15, (1 << 1), 244), + (24, (1 << 1), 244), + (31, (1 << 1), 244), + (41, (1 << 1), 244), + (56, 1 | (1 << 1), 244), + + # Node 216 + (1, (1 << 1), 245), + (22, 1 | (1 << 1), 245), + (1, (1 << 1), 246), + (22, 1 | (1 << 1), 246), + (1, (1 << 1), 247), + (22, 1 | (1 << 1), 247), + (1, (1 << 1), 248), + (22, 1 | (1 << 1), 248), + (1, (1 << 1), 250), + (22, 1 | (1 << 1), 250), + (1, (1 << 1), 251), + (22, 1 | (1 << 1), 251), + (1, (1 << 1), 252), + (22, 1 | (1 << 1), 252), + (1, (1 << 1), 253), + (22, 1 | (1 << 1), 253), + + # Node 217 + (2, (1 << 1), 245), + (9, (1 << 1), 245), + (23, (1 << 1), 245), + (40, 1 | (1 << 1), 245), + (2, (1 << 1), 246), + (9, (1 << 1), 246), + (23, (1 << 1), 246), + (40, 1 | (1 << 1), 246), + (2, (1 << 1), 247), + (9, (1 << 1), 247), + (23, (1 << 1), 247), + (40, 1 | (1 << 1), 247), + (2, (1 << 1), 248), + (9, (1 << 1), 248), + (23, (1 << 1), 248), + (40, 1 | (1 << 1), 248), + + # Node 218 + (3, (1 << 1), 245), + (6, (1 << 1), 245), + (10, (1 << 1), 245), + (15, (1 << 1), 245), + (24, (1 << 1), 245), + (31, (1 << 1), 245), + (41, (1 << 1), 245), + (56, 1 | (1 << 1), 245), + (3, (1 << 1), 246), + (6, (1 << 1), 246), + (10, (1 << 1), 246), + (15, (1 << 1), 246), + (24, (1 << 1), 246), + (31, (1 << 1), 246), + (41, (1 << 1), 246), + (56, 1 | (1 << 1), 246), + + # Node 219 + (3, (1 << 1), 247), + (6, (1 << 1), 247), + (10, (1 << 1), 247), + (15, (1 << 1), 247), + (24, (1 << 1), 247), + (31, (1 << 1), 247), + (41, (1 << 1), 247), + (56, 1 | (1 << 1), 247), + (3, (1 << 1), 248), + (6, (1 << 1), 248), + (10, (1 << 1), 248), + (15, (1 << 1), 248), + (24, (1 << 1), 248), + (31, (1 << 1), 248), + (41, (1 << 1), 248), + (56, 1 | (1 << 1), 248), + + # Node 220 + (2, (1 << 1), 250), + (9, (1 << 1), 250), + (23, (1 << 1), 250), + (40, 1 | (1 << 1), 250), + (2, (1 << 1), 251), + (9, (1 << 1), 251), + (23, (1 << 1), 251), + (40, 1 | (1 << 1), 251), + (2, (1 << 1), 252), + (9, (1 << 1), 252), + (23, (1 << 1), 252), + (40, 1 | (1 << 1), 252), + (2, (1 << 1), 253), + (9, (1 << 1), 253), + (23, (1 << 1), 253), + (40, 1 | (1 << 1), 253), + + # Node 221 + (3, (1 << 1), 250), + (6, (1 << 1), 250), + (10, (1 << 1), 250), + (15, (1 << 1), 250), + (24, (1 << 1), 250), + (31, (1 << 1), 250), + (41, (1 << 1), 250), + (56, 1 | (1 << 1), 250), + (3, (1 << 1), 251), + (6, (1 << 1), 251), + (10, (1 << 1), 251), + (15, (1 << 1), 251), + (24, (1 << 1), 251), + (31, (1 << 1), 251), + (41, (1 << 1), 251), + (56, 1 | (1 << 1), 251), + + # Node 222 + (3, (1 << 1), 252), + (6, (1 << 1), 252), + (10, (1 << 1), 252), + (15, (1 << 1), 252), + (24, (1 << 1), 252), + (31, (1 << 1), 252), + (41, (1 << 1), 252), + (56, 1 | (1 << 1), 252), + (3, (1 << 1), 253), + (6, (1 << 1), 253), + (10, (1 << 1), 253), + (15, (1 << 1), 253), + (24, (1 << 1), 253), + (31, (1 << 1), 253), + (41, (1 << 1), 253), + (56, 1 | (1 << 1), 253), + + # Node 223 + (0, 1 | (1 << 1), 254), + (227, 0, 0), + (229, 0, 0), + (230, 0, 0), + (233, 0, 0), + (234, 0, 0), + (236, 0, 0), + (237, 0, 0), + (241, 0, 0), + (242, 0, 0), + (244, 0, 0), + (245, 0, 0), + (248, 0, 0), + (249, 0, 0), + (251, 0, 0), + (252, 0, 0), + + # Node 224 + (1, (1 << 1), 254), + (22, 1 | (1 << 1), 254), + (0, 1 | (1 << 1), 2), + (0, 1 | (1 << 1), 3), + (0, 1 | (1 << 1), 4), + (0, 1 | (1 << 1), 5), + (0, 1 | (1 << 1), 6), + (0, 1 | (1 << 1), 7), + (0, 1 | (1 << 1), 8), + (0, 1 | (1 << 1), 11), + (0, 1 | (1 << 1), 12), + (0, 1 | (1 << 1), 14), + (0, 1 | (1 << 1), 15), + (0, 1 | (1 << 1), 16), + (0, 1 | (1 << 1), 17), + (0, 1 | (1 << 1), 18), + + # Node 225 + (2, (1 << 1), 254), + (9, (1 << 1), 254), + (23, (1 << 1), 254), + (40, 1 | (1 << 1), 254), + (1, (1 << 1), 2), + (22, 1 | (1 << 1), 2), + (1, (1 << 1), 3), + (22, 1 | (1 << 1), 3), + (1, (1 << 1), 4), + (22, 1 | (1 << 1), 4), + (1, (1 << 1), 5), + (22, 1 | (1 << 1), 5), + (1, (1 << 1), 6), + (22, 1 | (1 << 1), 6), + (1, (1 << 1), 7), + (22, 1 | (1 << 1), 7), + + # Node 226 + (3, (1 << 1), 254), + (6, (1 << 1), 254), + (10, (1 << 1), 254), + (15, (1 << 1), 254), + (24, (1 << 1), 254), + (31, (1 << 1), 254), + (41, (1 << 1), 254), + (56, 1 | (1 << 1), 254), + (2, (1 << 1), 2), + (9, (1 << 1), 2), + (23, (1 << 1), 2), + (40, 1 | (1 << 1), 2), + (2, (1 << 1), 3), + (9, (1 << 1), 3), + (23, (1 << 1), 3), + (40, 1 | (1 << 1), 3), + + # Node 227 + (3, (1 << 1), 2), + (6, (1 << 1), 2), + (10, (1 << 1), 2), + (15, (1 << 1), 2), + (24, (1 << 1), 2), + (31, (1 << 1), 2), + (41, (1 << 1), 2), + (56, 1 | (1 << 1), 2), + (3, (1 << 1), 3), + (6, (1 << 1), 3), + (10, (1 << 1), 3), + (15, (1 << 1), 3), + (24, (1 << 1), 3), + (31, (1 << 1), 3), + (41, (1 << 1), 3), + (56, 1 | (1 << 1), 3), + + # Node 228 + (2, (1 << 1), 4), + (9, (1 << 1), 4), + (23, (1 << 1), 4), + (40, 1 | (1 << 1), 4), + (2, (1 << 1), 5), + (9, (1 << 1), 5), + (23, (1 << 1), 5), + (40, 1 | (1 << 1), 5), + (2, (1 << 1), 6), + (9, (1 << 1), 6), + (23, (1 << 1), 6), + (40, 1 | (1 << 1), 6), + (2, (1 << 1), 7), + (9, (1 << 1), 7), + (23, (1 << 1), 7), + (40, 1 | (1 << 1), 7), + + # Node 229 + (3, (1 << 1), 4), + (6, (1 << 1), 4), + (10, (1 << 1), 4), + (15, (1 << 1), 4), + (24, (1 << 1), 4), + (31, (1 << 1), 4), + (41, (1 << 1), 4), + (56, 1 | (1 << 1), 4), + (3, (1 << 1), 5), + (6, (1 << 1), 5), + (10, (1 << 1), 5), + (15, (1 << 1), 5), + (24, (1 << 1), 5), + (31, (1 << 1), 5), + (41, (1 << 1), 5), + (56, 1 | (1 << 1), 5), + + # Node 230 + (3, (1 << 1), 6), + (6, (1 << 1), 6), + (10, (1 << 1), 6), + (15, (1 << 1), 6), + (24, (1 << 1), 6), + (31, (1 << 1), 6), + (41, (1 << 1), 6), + (56, 1 | (1 << 1), 6), + (3, (1 << 1), 7), + (6, (1 << 1), 7), + (10, (1 << 1), 7), + (15, (1 << 1), 7), + (24, (1 << 1), 7), + (31, (1 << 1), 7), + (41, (1 << 1), 7), + (56, 1 | (1 << 1), 7), + + # Node 231 + (1, (1 << 1), 8), + (22, 1 | (1 << 1), 8), + (1, (1 << 1), 11), + (22, 1 | (1 << 1), 11), + (1, (1 << 1), 12), + (22, 1 | (1 << 1), 12), + (1, (1 << 1), 14), + (22, 1 | (1 << 1), 14), + (1, (1 << 1), 15), + (22, 1 | (1 << 1), 15), + (1, (1 << 1), 16), + (22, 1 | (1 << 1), 16), + (1, (1 << 1), 17), + (22, 1 | (1 << 1), 17), + (1, (1 << 1), 18), + (22, 1 | (1 << 1), 18), + + # Node 232 + (2, (1 << 1), 8), + (9, (1 << 1), 8), + (23, (1 << 1), 8), + (40, 1 | (1 << 1), 8), + (2, (1 << 1), 11), + (9, (1 << 1), 11), + (23, (1 << 1), 11), + (40, 1 | (1 << 1), 11), + (2, (1 << 1), 12), + (9, (1 << 1), 12), + (23, (1 << 1), 12), + (40, 1 | (1 << 1), 12), + (2, (1 << 1), 14), + (9, (1 << 1), 14), + (23, (1 << 1), 14), + (40, 1 | (1 << 1), 14), + + # Node 233 + (3, (1 << 1), 8), + (6, (1 << 1), 8), + (10, (1 << 1), 8), + (15, (1 << 1), 8), + (24, (1 << 1), 8), + (31, (1 << 1), 8), + (41, (1 << 1), 8), + (56, 1 | (1 << 1), 8), + (3, (1 << 1), 11), + (6, (1 << 1), 11), + (10, (1 << 1), 11), + (15, (1 << 1), 11), + (24, (1 << 1), 11), + (31, (1 << 1), 11), + (41, (1 << 1), 11), + (56, 1 | (1 << 1), 11), + + # Node 234 + (3, (1 << 1), 12), + (6, (1 << 1), 12), + (10, (1 << 1), 12), + (15, (1 << 1), 12), + (24, (1 << 1), 12), + (31, (1 << 1), 12), + (41, (1 << 1), 12), + (56, 1 | (1 << 1), 12), + (3, (1 << 1), 14), + (6, (1 << 1), 14), + (10, (1 << 1), 14), + (15, (1 << 1), 14), + (24, (1 << 1), 14), + (31, (1 << 1), 14), + (41, (1 << 1), 14), + (56, 1 | (1 << 1), 14), + + # Node 235 + (2, (1 << 1), 15), + (9, (1 << 1), 15), + (23, (1 << 1), 15), + (40, 1 | (1 << 1), 15), + (2, (1 << 1), 16), + (9, (1 << 1), 16), + (23, (1 << 1), 16), + (40, 1 | (1 << 1), 16), + (2, (1 << 1), 17), + (9, (1 << 1), 17), + (23, (1 << 1), 17), + (40, 1 | (1 << 1), 17), + (2, (1 << 1), 18), + (9, (1 << 1), 18), + (23, (1 << 1), 18), + (40, 1 | (1 << 1), 18), + + # Node 236 + (3, (1 << 1), 15), + (6, (1 << 1), 15), + (10, (1 << 1), 15), + (15, (1 << 1), 15), + (24, (1 << 1), 15), + (31, (1 << 1), 15), + (41, (1 << 1), 15), + (56, 1 | (1 << 1), 15), + (3, (1 << 1), 16), + (6, (1 << 1), 16), + (10, (1 << 1), 16), + (15, (1 << 1), 16), + (24, (1 << 1), 16), + (31, (1 << 1), 16), + (41, (1 << 1), 16), + (56, 1 | (1 << 1), 16), + + # Node 237 + (3, (1 << 1), 17), + (6, (1 << 1), 17), + (10, (1 << 1), 17), + (15, (1 << 1), 17), + (24, (1 << 1), 17), + (31, (1 << 1), 17), + (41, (1 << 1), 17), + (56, 1 | (1 << 1), 17), + (3, (1 << 1), 18), + (6, (1 << 1), 18), + (10, (1 << 1), 18), + (15, (1 << 1), 18), + (24, (1 << 1), 18), + (31, (1 << 1), 18), + (41, (1 << 1), 18), + (56, 1 | (1 << 1), 18), + + # Node 238 + (0, 1 | (1 << 1), 19), + (0, 1 | (1 << 1), 20), + (0, 1 | (1 << 1), 21), + (0, 1 | (1 << 1), 23), + (0, 1 | (1 << 1), 24), + (0, 1 | (1 << 1), 25), + (0, 1 | (1 << 1), 26), + (0, 1 | (1 << 1), 27), + (0, 1 | (1 << 1), 28), + (0, 1 | (1 << 1), 29), + (0, 1 | (1 << 1), 30), + (0, 1 | (1 << 1), 31), + (0, 1 | (1 << 1), 127), + (0, 1 | (1 << 1), 220), + (0, 1 | (1 << 1), 249), + (253, 0, 0), + + # Node 239 + (1, (1 << 1), 19), + (22, 1 | (1 << 1), 19), + (1, (1 << 1), 20), + (22, 1 | (1 << 1), 20), + (1, (1 << 1), 21), + (22, 1 | (1 << 1), 21), + (1, (1 << 1), 23), + (22, 1 | (1 << 1), 23), + (1, (1 << 1), 24), + (22, 1 | (1 << 1), 24), + (1, (1 << 1), 25), + (22, 1 | (1 << 1), 25), + (1, (1 << 1), 26), + (22, 1 | (1 << 1), 26), + (1, (1 << 1), 27), + (22, 1 | (1 << 1), 27), + + # Node 240 + (2, (1 << 1), 19), + (9, (1 << 1), 19), + (23, (1 << 1), 19), + (40, 1 | (1 << 1), 19), + (2, (1 << 1), 20), + (9, (1 << 1), 20), + (23, (1 << 1), 20), + (40, 1 | (1 << 1), 20), + (2, (1 << 1), 21), + (9, (1 << 1), 21), + (23, (1 << 1), 21), + (40, 1 | (1 << 1), 21), + (2, (1 << 1), 23), + (9, (1 << 1), 23), + (23, (1 << 1), 23), + (40, 1 | (1 << 1), 23), + + # Node 241 + (3, (1 << 1), 19), + (6, (1 << 1), 19), + (10, (1 << 1), 19), + (15, (1 << 1), 19), + (24, (1 << 1), 19), + (31, (1 << 1), 19), + (41, (1 << 1), 19), + (56, 1 | (1 << 1), 19), + (3, (1 << 1), 20), + (6, (1 << 1), 20), + (10, (1 << 1), 20), + (15, (1 << 1), 20), + (24, (1 << 1), 20), + (31, (1 << 1), 20), + (41, (1 << 1), 20), + (56, 1 | (1 << 1), 20), + + # Node 242 + (3, (1 << 1), 21), + (6, (1 << 1), 21), + (10, (1 << 1), 21), + (15, (1 << 1), 21), + (24, (1 << 1), 21), + (31, (1 << 1), 21), + (41, (1 << 1), 21), + (56, 1 | (1 << 1), 21), + (3, (1 << 1), 23), + (6, (1 << 1), 23), + (10, (1 << 1), 23), + (15, (1 << 1), 23), + (24, (1 << 1), 23), + (31, (1 << 1), 23), + (41, (1 << 1), 23), + (56, 1 | (1 << 1), 23), + + # Node 243 + (2, (1 << 1), 24), + (9, (1 << 1), 24), + (23, (1 << 1), 24), + (40, 1 | (1 << 1), 24), + (2, (1 << 1), 25), + (9, (1 << 1), 25), + (23, (1 << 1), 25), + (40, 1 | (1 << 1), 25), + (2, (1 << 1), 26), + (9, (1 << 1), 26), + (23, (1 << 1), 26), + (40, 1 | (1 << 1), 26), + (2, (1 << 1), 27), + (9, (1 << 1), 27), + (23, (1 << 1), 27), + (40, 1 | (1 << 1), 27), + + # Node 244 + (3, (1 << 1), 24), + (6, (1 << 1), 24), + (10, (1 << 1), 24), + (15, (1 << 1), 24), + (24, (1 << 1), 24), + (31, (1 << 1), 24), + (41, (1 << 1), 24), + (56, 1 | (1 << 1), 24), + (3, (1 << 1), 25), + (6, (1 << 1), 25), + (10, (1 << 1), 25), + (15, (1 << 1), 25), + (24, (1 << 1), 25), + (31, (1 << 1), 25), + (41, (1 << 1), 25), + (56, 1 | (1 << 1), 25), + + # Node 245 + (3, (1 << 1), 26), + (6, (1 << 1), 26), + (10, (1 << 1), 26), + (15, (1 << 1), 26), + (24, (1 << 1), 26), + (31, (1 << 1), 26), + (41, (1 << 1), 26), + (56, 1 | (1 << 1), 26), + (3, (1 << 1), 27), + (6, (1 << 1), 27), + (10, (1 << 1), 27), + (15, (1 << 1), 27), + (24, (1 << 1), 27), + (31, (1 << 1), 27), + (41, (1 << 1), 27), + (56, 1 | (1 << 1), 27), + + # Node 246 + (1, (1 << 1), 28), + (22, 1 | (1 << 1), 28), + (1, (1 << 1), 29), + (22, 1 | (1 << 1), 29), + (1, (1 << 1), 30), + (22, 1 | (1 << 1), 30), + (1, (1 << 1), 31), + (22, 1 | (1 << 1), 31), + (1, (1 << 1), 127), + (22, 1 | (1 << 1), 127), + (1, (1 << 1), 220), + (22, 1 | (1 << 1), 220), + (1, (1 << 1), 249), + (22, 1 | (1 << 1), 249), + (254, 0, 0), + (255, 0, 0), + + # Node 247 + (2, (1 << 1), 28), + (9, (1 << 1), 28), + (23, (1 << 1), 28), + (40, 1 | (1 << 1), 28), + (2, (1 << 1), 29), + (9, (1 << 1), 29), + (23, (1 << 1), 29), + (40, 1 | (1 << 1), 29), + (2, (1 << 1), 30), + (9, (1 << 1), 30), + (23, (1 << 1), 30), + (40, 1 | (1 << 1), 30), + (2, (1 << 1), 31), + (9, (1 << 1), 31), + (23, (1 << 1), 31), + (40, 1 | (1 << 1), 31), + + # Node 248 + (3, (1 << 1), 28), + (6, (1 << 1), 28), + (10, (1 << 1), 28), + (15, (1 << 1), 28), + (24, (1 << 1), 28), + (31, (1 << 1), 28), + (41, (1 << 1), 28), + (56, 1 | (1 << 1), 28), + (3, (1 << 1), 29), + (6, (1 << 1), 29), + (10, (1 << 1), 29), + (15, (1 << 1), 29), + (24, (1 << 1), 29), + (31, (1 << 1), 29), + (41, (1 << 1), 29), + (56, 1 | (1 << 1), 29), + + # Node 249 + (3, (1 << 1), 30), + (6, (1 << 1), 30), + (10, (1 << 1), 30), + (15, (1 << 1), 30), + (24, (1 << 1), 30), + (31, (1 << 1), 30), + (41, (1 << 1), 30), + (56, 1 | (1 << 1), 30), + (3, (1 << 1), 31), + (6, (1 << 1), 31), + (10, (1 << 1), 31), + (15, (1 << 1), 31), + (24, (1 << 1), 31), + (31, (1 << 1), 31), + (41, (1 << 1), 31), + (56, 1 | (1 << 1), 31), + + # Node 250 + (2, (1 << 1), 127), + (9, (1 << 1), 127), + (23, (1 << 1), 127), + (40, 1 | (1 << 1), 127), + (2, (1 << 1), 220), + (9, (1 << 1), 220), + (23, (1 << 1), 220), + (40, 1 | (1 << 1), 220), + (2, (1 << 1), 249), + (9, (1 << 1), 249), + (23, (1 << 1), 249), + (40, 1 | (1 << 1), 249), + (0, 1 | (1 << 1), 10), + (0, 1 | (1 << 1), 13), + (0, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + + # Node 251 + (3, (1 << 1), 127), + (6, (1 << 1), 127), + (10, (1 << 1), 127), + (15, (1 << 1), 127), + (24, (1 << 1), 127), + (31, (1 << 1), 127), + (41, (1 << 1), 127), + (56, 1 | (1 << 1), 127), + (3, (1 << 1), 220), + (6, (1 << 1), 220), + (10, (1 << 1), 220), + (15, (1 << 1), 220), + (24, (1 << 1), 220), + (31, (1 << 1), 220), + (41, (1 << 1), 220), + (56, 1 | (1 << 1), 220), + + # Node 252 + (3, (1 << 1), 249), + (6, (1 << 1), 249), + (10, (1 << 1), 249), + (15, (1 << 1), 249), + (24, (1 << 1), 249), + (31, (1 << 1), 249), + (41, (1 << 1), 249), + (56, 1 | (1 << 1), 249), + (1, (1 << 1), 10), + (22, 1 | (1 << 1), 10), + (1, (1 << 1), 13), + (22, 1 | (1 << 1), 13), + (1, (1 << 1), 22), + (22, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + + # Node 253 + (2, (1 << 1), 10), + (9, (1 << 1), 10), + (23, (1 << 1), 10), + (40, 1 | (1 << 1), 10), + (2, (1 << 1), 13), + (9, (1 << 1), 13), + (23, (1 << 1), 13), + (40, 1 | (1 << 1), 13), + (2, (1 << 1), 22), + (9, (1 << 1), 22), + (23, (1 << 1), 22), + (40, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + + # Node 254 + (3, (1 << 1), 10), + (6, (1 << 1), 10), + (10, (1 << 1), 10), + (15, (1 << 1), 10), + (24, (1 << 1), 10), + (31, (1 << 1), 10), + (41, (1 << 1), 10), + (56, 1 | (1 << 1), 10), + (3, (1 << 1), 13), + (6, (1 << 1), 13), + (10, (1 << 1), 13), + (15, (1 << 1), 13), + (24, (1 << 1), 13), + (31, (1 << 1), 13), + (41, (1 << 1), 13), + (56, 1 | (1 << 1), 13), + + # Node 255 + (3, (1 << 1), 22), + (6, (1 << 1), 22), + (10, (1 << 1), 22), + (15, (1 << 1), 22), + (24, (1 << 1), 22), + (31, (1 << 1), 22), + (41, (1 << 1), 22), + (56, 1 | (1 << 1), 22), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), + (0, (1 << 2), 0), +] + + +class Encoder: + """ + An HPACK encoder object. This object takes HTTP headers and emits encoded + HTTP/2 header blocks. + """ + __slots__ = ( + 'header_table', + 'huffman_coder', + 'table_size_changes' + ) + + def __init__(self): + self.header_table = HeaderTable() + self.huffman_coder = HuffmanEncoder() + self.table_size_changes = [] + + @property + def header_table_size(self): + """ + Controls the size of the HPACK header table. + """ + return self.header_table.maxsize + + @header_table_size.setter + def header_table_size(self, value): + self.header_table.maxsize = value + if self.header_table.resized: + self.table_size_changes.append(value) + + def encode(self, headers, huffman=True): + header_block = bytearray() + + # Signal table size changes if the header table has been resized + if self.header_table.resized: + block = bytearray() + for size_bytes in self.table_size_changes: + size_bytes = self._encode_integer(size_bytes, 5) + size_bytes[0] |= 0x20 + block += size_bytes + self.table_size_changes = [] + header_block.extend(block) + self.header_table.resized = False + + for header in headers: + + if len(header) > 2: + name, value, sensitive = header + else: + name, value = header + sensitive = False + + indexbit = b'\x40' if not sensitive else b'\x10' + match = self.header_table.search(name, value) + + if match is None: + if huffman: + name = self.huffman_coder.encode(name) + value = self.huffman_coder.encode(value) + + name_len = self._encode_integer(len(name), 7) + value_len = self._encode_integer(len(value), 7) + + if huffman: + name_len[0] |= 0x80 + value_len[0] |= 0x80 + + encoded = indexbit + bytes(name_len) + name + bytes(value_len) + value + + if not sensitive: + self.header_table.add(name, value) + + else: + index, _, perfect = match + + if perfect: + field = self._encode_integer(index, 7) + field[0] |= 0x80 + encoded = bytes(field) + else: + prefix_bits = 4 if indexbit != b'\x40' else 6 + prefix = self._encode_integer(index, prefix_bits) + prefix[0] |= ord(indexbit) + + if huffman: + value = self.huffman_coder.encode(value) + + value_len = self._encode_integer(len(value), 7) + + if huffman: + value_len[0] |= 0x80 + + encoded = prefix + bytes(value_len) + value + if not sensitive: + self.header_table.add(name, value) + + header_block += encoded + + return bytes(header_block) + + def _encode_integer(self, integer, prefix_bits): + """ + This encodes an integer according to the wacky integer encoding rules + defined in the HPACK spec. + """ + + max_number = _PREFIX_BIT_MAX_NUMBERS[prefix_bits] + + if integer < max_number: + return bytearray([integer & 127]) # Clear the highest bit for small integers + + elements = bytearray() + integer -= max_number + + while integer >= 128: + elements.append((integer & 127) + 128) + integer >>= 7 + + elements.append(integer) + + return elements + + def _encode_indexed(self, index): + """ + Encodes a header using the indexed representation. + """ + field = self._encode_integer(index, 7) + field[0] |= 0x80 # we set the top bit + return bytes(field) + +class Decoder: + __slots__ = ( + 'header_table', + 'max_header_list_size', + 'max_allowed_table_size' + ) + """ + An HPACK decoder object. + + .. versionchanged:: 2.3.0 + Added ``max_header_list_size`` argument. + + :param max_header_list_size: The maximum decompressed size we will allow + for any single header block. This is a protection against DoS attacks + that attempt to force the application to expand a relatively small + amount of data into a really large header list, allowing enormous + amounts of memory to be allocated. + + If this amount of data is exceeded, a `OversizedHeaderListError + ` exception will be raised. At this + point the connection should be shut down, as the HPACK state will no + longer be usable. + + Defaults to 64kB. + :type max_header_list_size: ``int`` + """ + def __init__( + self, + max_header_list_size=2**16 + ): + #: The maximum decompressed size we will allow for any single header + #: block. This is a protection against DoS attacks that attempt to + #: force the application to expand a relatively small amount of data + #: into a really large header list, allowing enormous amounts of memory + #: to be allocated. + #: + #: If this amount of data is exceeded, a `OversizedHeaderListError + #: ` exception will be raised. At this + #: point the connection should be shut down, as the HPACK state will no + #: longer be usable. + #: + #: Defaults to 64kB. + #: + #: .. versionadded:: 2.3.0 + self.header_table = HeaderTable() + self.max_header_list_size = max_header_list_size + + #: Maximum allowed header table size. + #: + #: A HTTP/2 implementation should set this to the most recent value of + #: SETTINGS_HEADER_TABLE_SIZE that it sent *and has received an ACK + #: for*. Once this setting is set, the actual header table size will be + #: checked at the end of each decoding run and whenever it is changed, + #: to confirm that it fits in this size. + self.max_allowed_table_size = self.header_table.maxsize + + + @property + def header_table_size(self): + """ + Controls the size of the HPACK header table. + """ + return self.header_table.maxsize + + @header_table_size.setter + def header_table_size(self, value): + self.header_table.maxsize = value + + def decode(self, data, raw=False): + """ + Takes an HPACK-encoded header block and decodes it into a header set. + + :param data: A bytestring representing a complete HPACK-encoded header + block. + :param raw: (optional) Whether to return the headers as tuples of raw + byte strings or to decode them as UTF-8 before returning + them. The default value is False, which returns tuples of + Unicode strings + :returns: A list of two-tuples of ``(name, value)`` representing the + HPACK-encoded headers, in the order they were decoded. + :raises HPACKDecodingError: If an error is encountered while decoding + the header block. + """ + + data_mem = memoryview(data) + headers = [] + data_len = len(data) + inflated_size = 0 + current_index = 0 + + while current_index < data_len: + # Work out what kind of header we're decoding. + # If the high bit is 1, it's an indexed field. + current = data[current_index] + indexed = True if current & 0x80 else False + + # Otherwise, if the second-highest bit is 1 it's a field that does + # alter the header table. + literal_index = True if current & 0x40 else False + + # Otherwise, if the third-highest bit is 1 it's an encoding context + # update. + encoding_update = True if current & 0x20 else False + + if indexed: + index, consumed = self._decode_integer( + data_mem[current_index:], + 7 + ) + header = self.header_table.get_by_index(index) + + elif literal_index: + # It's a literal header that does affect the header table. + header, consumed = self._decode_literal( + data_mem[current_index:], + True + ) + elif encoding_update: + + new_size, consumed = self._decode_integer( + data_mem[current_index:], + 5 + ) + if new_size > self.max_allowed_table_size: + raise Exception( + "Encoder exceeded max allowable table size" + ) + + self.header_table_size = new_size + header = None + else: + # It's a literal header that does not affect the header table. + header, consumed = self._decode_literal( + data_mem[current_index:], + False + ) + + if header: + headers.append(header) + name, value = header + inflated_size += ( + 32 + len(name) + len(value) + ) + + current_index += consumed + + return [ + ( + str(header[0], 'utf-8'), + str(header[1], 'utf-8') + + ) if raw else header for header in headers + ] + + def _decode_integer( + self, + data, + prefix_bits + ): + """ + This decodes an integer according to the wacky integer encoding rules + defined in the HPACK spec. Returns a tuple of the decoded integer and the + number of bytes that were consumed from ``data`` in order to get that + integer. + """ + if prefix_bits < 1 or prefix_bits > 8: + raise ValueError( + "Prefix bits must be between 1 and 8, got %s" % prefix_bits + ) + + max_number = _PREFIX_BIT_MAX_NUMBERS[prefix_bits] + index = 1 + shift = 0 + mask = (0xFF >> (8 - prefix_bits)) + + number = data[0] & mask + if number == max_number: + while True: + next_byte = data[index] + index += 1 + + if next_byte >= 128: + number += (next_byte - 128) << shift + else: + number += next_byte << shift + break + shift += 7 + + return number, index + + def _decode_literal(self, data, should_index): + """ + Decodes a header represented with a literal. + """ + total_consumed = 0 + + # When should_index is true, if the low six bits of the first byte are + # nonzero, the header name is indexed. + # When should_index is false, if the low four bits of the first byte + # are nonzero the header name is indexed. + if should_index: + indexed_name = data[0] & 0x3F + name_len = 6 + not_indexable = False + else: + high_byte = data[0] + indexed_name = high_byte & 0x0F + name_len = 4 + not_indexable = high_byte & 0x10 + + if indexed_name: + # Indexed header name. + index, consumed = self._decode_integer(data, name_len) + name = self.header_table.get_by_index(index)[0] + + total_consumed = consumed + length = 0 + else: + # Literal header name. The first byte was consumed, so we need to + # move forward. + data = data[1:] + + length, consumed = self._decode_integer(data, 7) + name = data[consumed:consumed + length] + + if data[0] & 0x80: + if not name: + name = b'' + + state = 0 + flags = 0 + decoded_bytes = bytearray() + # This loop is unrolled somewhat. Because we use a nibble, not a byte, we + # need to handle each nibble twice. We unroll that: it makes the loop body + # a bit longer, but that's ok. + for input_byte in name: + index = (state * 16) + (input_byte >> 4) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + index = (state * 16) + (input_byte & 0x0F) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + name = bytes(decoded_bytes) + + total_consumed = consumed + length + 1 # Since we moved forward 1. + + data = data[consumed + length:] + + # The header value is definitely length-based. + length, consumed = self._decode_integer(data, 7) + value = data[consumed:consumed + length] + + if data[0] & 0x80: + if not value: + value = b'' + + state = 0 + flags = 0 + decoded_bytes = bytearray() + + # This loop is unrolled somewhat. Because we use a nibble, not a byte, we + # need to handle each nibble twice. We unroll that: it makes the loop body + # a bit longer, but that's ok. + for input_byte in value: + index = (state * 16) + (input_byte >> 4) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + index = (state * 16) + (input_byte & 0x0F) + state, flags, output_byte = _HUFFMAN_TABLE[index] + + if flags & (1 << 1): + decoded_bytes.append(output_byte) + + value = bytes(decoded_bytes) + + # Updated the total consumed length. + total_consumed += length + consumed + + # If we have been told never to index the header field, encode that in + # the tuple we use. + if not_indexable: + header = (name, value) + else: + header = (name, value) + + # If we've been asked to index this, add it to the header table. + if should_index: + self.header_table.add(name, value) + + return header, total_consumed \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/fast_hpack/huffman.py b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/huffman.py new file mode 100644 index 0000000..59a3e77 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/huffman.py @@ -0,0 +1,329 @@ +# -*- coding: utf-8 -*- +""" +hpack/huffman_decoder +~~~~~~~~~~~~~~~~~~~~~ + +An implementation of a bitwise prefix tree specially built for decoding +Huffman-coded content where we already know the Huffman table. +""" +huffman_code_list = [ + 0x1ff8, + 0x7fffd8, + 0xfffffe2, + 0xfffffe3, + 0xfffffe4, + 0xfffffe5, + 0xfffffe6, + 0xfffffe7, + 0xfffffe8, + 0xffffea, + 0x3ffffffc, + 0xfffffe9, + 0xfffffea, + 0x3ffffffd, + 0xfffffeb, + 0xfffffec, + 0xfffffed, + 0xfffffee, + 0xfffffef, + 0xffffff0, + 0xffffff1, + 0xffffff2, + 0x3ffffffe, + 0xffffff3, + 0xffffff4, + 0xffffff5, + 0xffffff6, + 0xffffff7, + 0xffffff8, + 0xffffff9, + 0xffffffa, + 0xffffffb, + 0x14, + 0x3f8, + 0x3f9, + 0xffa, + 0x1ff9, + 0x15, + 0xf8, + 0x7fa, + 0x3fa, + 0x3fb, + 0xf9, + 0x7fb, + 0xfa, + 0x16, + 0x17, + 0x18, + 0x0, + 0x1, + 0x2, + 0x19, + 0x1a, + 0x1b, + 0x1c, + 0x1d, + 0x1e, + 0x1f, + 0x5c, + 0xfb, + 0x7ffc, + 0x20, + 0xffb, + 0x3fc, + 0x1ffa, + 0x21, + 0x5d, + 0x5e, + 0x5f, + 0x60, + 0x61, + 0x62, + 0x63, + 0x64, + 0x65, + 0x66, + 0x67, + 0x68, + 0x69, + 0x6a, + 0x6b, + 0x6c, + 0x6d, + 0x6e, + 0x6f, + 0x70, + 0x71, + 0x72, + 0xfc, + 0x73, + 0xfd, + 0x1ffb, + 0x7fff0, + 0x1ffc, + 0x3ffc, + 0x22, + 0x7ffd, + 0x3, + 0x23, + 0x4, + 0x24, + 0x5, + 0x25, + 0x26, + 0x27, + 0x6, + 0x74, + 0x75, + 0x28, + 0x29, + 0x2a, + 0x7, + 0x2b, + 0x76, + 0x2c, + 0x8, + 0x9, + 0x2d, + 0x77, + 0x78, + 0x79, + 0x7a, + 0x7b, + 0x7ffe, + 0x7fc, + 0x3ffd, + 0x1ffd, + 0xffffffc, + 0xfffe6, + 0x3fffd2, + 0xfffe7, + 0xfffe8, + 0x3fffd3, + 0x3fffd4, + 0x3fffd5, + 0x7fffd9, + 0x3fffd6, + 0x7fffda, + 0x7fffdb, + 0x7fffdc, + 0x7fffdd, + 0x7fffde, + 0xffffeb, + 0x7fffdf, + 0xffffec, + 0xffffed, + 0x3fffd7, + 0x7fffe0, + 0xffffee, + 0x7fffe1, + 0x7fffe2, + 0x7fffe3, + 0x7fffe4, + 0x1fffdc, + 0x3fffd8, + 0x7fffe5, + 0x3fffd9, + 0x7fffe6, + 0x7fffe7, + 0xffffef, + 0x3fffda, + 0x1fffdd, + 0xfffe9, + 0x3fffdb, + 0x3fffdc, + 0x7fffe8, + 0x7fffe9, + 0x1fffde, + 0x7fffea, + 0x3fffdd, + 0x3fffde, + 0xfffff0, + 0x1fffdf, + 0x3fffdf, + 0x7fffeb, + 0x7fffec, + 0x1fffe0, + 0x1fffe1, + 0x3fffe0, + 0x1fffe2, + 0x7fffed, + 0x3fffe1, + 0x7fffee, + 0x7fffef, + 0xfffea, + 0x3fffe2, + 0x3fffe3, + 0x3fffe4, + 0x7ffff0, + 0x3fffe5, + 0x3fffe6, + 0x7ffff1, + 0x3ffffe0, + 0x3ffffe1, + 0xfffeb, + 0x7fff1, + 0x3fffe7, + 0x7ffff2, + 0x3fffe8, + 0x1ffffec, + 0x3ffffe2, + 0x3ffffe3, + 0x3ffffe4, + 0x7ffffde, + 0x7ffffdf, + 0x3ffffe5, + 0xfffff1, + 0x1ffffed, + 0x7fff2, + 0x1fffe3, + 0x3ffffe6, + 0x7ffffe0, + 0x7ffffe1, + 0x3ffffe7, + 0x7ffffe2, + 0xfffff2, + 0x1fffe4, + 0x1fffe5, + 0x3ffffe8, + 0x3ffffe9, + 0xffffffd, + 0x7ffffe3, + 0x7ffffe4, + 0x7ffffe5, + 0xfffec, + 0xfffff3, + 0xfffed, + 0x1fffe6, + 0x3fffe9, + 0x1fffe7, + 0x1fffe8, + 0x7ffff3, + 0x3fffea, + 0x3fffeb, + 0x1ffffee, + 0x1ffffef, + 0xfffff4, + 0xfffff5, + 0x3ffffea, + 0x7ffff4, + 0x3ffffeb, + 0x7ffffe6, + 0x3ffffec, + 0x3ffffed, + 0x7ffffe7, + 0x7ffffe8, + 0x7ffffe9, + 0x7ffffea, + 0x7ffffeb, + 0xffffffe, + 0x7ffffec, + 0x7ffffed, + 0x7ffffee, + 0x7ffffef, + 0x7fffff0, + 0x3ffffee, + 0x3fffffff, +] +huffman_code_lengths = [ + 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28, + 28, 28, 28, 28, 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28, + 6, 10, 10, 12, 13, 6, 8, 11, 10, 10, 8, 11, 8, 6, 6, 6, + 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, 15, 6, 12, 10, + 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6, + 15, 5, 6, 5, 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5, + 6, 7, 6, 5, 5, 6, 7, 7, 7, 7, 7, 15, 11, 14, 13, 28, + 20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, 23, 23, 24, 23, + 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24, + 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23, + 21, 21, 22, 21, 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23, + 26, 26, 20, 19, 22, 23, 22, 25, 26, 26, 26, 27, 27, 26, 24, 25, + 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, 28, 27, 27, 27, + 20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23, + 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26, + 30, +] + + +class HuffmanEncoder: + huffman_codes = list(zip( + huffman_code_list, + huffman_code_lengths + )) + + """ + Encodes a string according to the Huffman encoding table defined in the + HPACK specification. + """ + + def encode(self, bytes_to_encode: bytes): + """ + Given a string of bytes, encodes them according to the HPACK Huffman + specification. + """ + # If handed the empty string, just immediately return. + if not bytes_to_encode: + return b'' + + final_num = 0 + final_int_len = 0 + + # Turn each byte into its huffman code. These codes aren't necessarily + # octet aligned, so keep track of how far through an octet we are. To + # handle this cleanly, just use a single giant integer. + for byte in bytes_to_encode: + huffman_bit, bin_int_len = self.huffman_codes[byte] + bin_int = huffman_bit & ((1 << bin_int_len) - 1) + final_num = (final_num << bin_int_len) | bin_int + final_int_len += bin_int_len + + # Pad out to an octet with ones. + bits_to_be_padded = (8 - (final_int_len % 8)) % 8 + final_num = (final_num << bits_to_be_padded) | ((1 << bits_to_be_padded) - 1) + + # Calculate the number of bytes required and directly convert the integer to bytes + total_bytes = (final_int_len + bits_to_be_padded) // 8 + encoded_bytes = final_num.to_bytes(total_bytes, byteorder='big') + + return encoded_bytes \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/fast_hpack/table.py b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/table.py new file mode 100644 index 0000000..8193d8e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/fast_hpack/table.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# flake8: noqa +from collections import deque +from typing import Dict, Tuple, Deque + + +class HeaderTable: + """ + Implements the combined static and dynamic header table + + The name and value arguments for all the functions + should ONLY be byte strings (b'') however this is not + strictly enforced in the interface. + + See RFC7541 Section 2.3 + """ + #: Default maximum size of the dynamic table. See + #: RFC7540 Section 6.5.2. + DEFAULT_SIZE = 4096 + + #: Constant list of static headers. See RFC7541 Section + #: 2.3.1 and Appendix A + STATIC_TABLE = ( + (b':authority' , b'' ), # noqa + (b':method' , b'GET' ), # noqa + (b':method' , b'POST' ), # noqa + (b':path' , b'/' ), # noqa + (b':path' , b'/index.html' ), # noqa + (b':scheme' , b'http' ), # noqa + (b':scheme' , b'https' ), # noqa + (b':status' , b'200' ), # noqa + (b':status' , b'204' ), # noqa + (b':status' , b'206' ), # noqa + (b':status' , b'304' ), # noqa + (b':status' , b'400' ), # noqa + (b':status' , b'404' ), # noqa + (b':status' , b'500' ), # noqa + (b'accept-charset' , b'' ), # noqa + (b'accept-encoding' , b'gzip, deflate'), # noqa + (b'accept-language' , b'' ), # noqa + (b'accept-ranges' , b'' ), # noqa + (b'accept' , b'' ), # noqa + (b'access-control-allow-origin' , b'' ), # noqa + (b'age' , b'' ), # noqa + (b'allow' , b'' ), # noqa + (b'authorization' , b'' ), # noqa + (b'cache-control' , b'' ), # noqa + (b'content-disposition' , b'' ), # noqa + (b'content-encoding' , b'' ), # noqa + (b'content-language' , b'' ), # noqa + (b'content-length' , b'' ), # noqa + (b'content-location' , b'' ), # noqa + (b'content-range' , b'' ), # noqa + (b'content-type' , b'' ), # noqa + (b'cookie' , b'' ), # noqa + (b'date' , b'' ), # noqa + (b'etag' , b'' ), # noqa + (b'expect' , b'' ), # noqa + (b'expires' , b'' ), # noqa + (b'from' , b'' ), # noqa + (b'host' , b'' ), # noqa + (b'if-match' , b'' ), # noqa + (b'if-modified-since' , b'' ), # noqa + (b'if-none-match' , b'' ), # noqa + (b'if-range' , b'' ), # noqa + (b'if-unmodified-since' , b'' ), # noqa + (b'last-modified' , b'' ), # noqa + (b'link' , b'' ), # noqa + (b'location' , b'' ), # noqa + (b'max-forwards' , b'' ), # noqa + (b'proxy-authenticate' , b'' ), # noqa + (b'proxy-authorization' , b'' ), # noqa + (b'range' , b'' ), # noqa + (b'referer' , b'' ), # noqa + (b'refresh' , b'' ), # noqa + (b'retry-after' , b'' ), # noqa + (b'server' , b'' ), # noqa + (b'set-cookie' , b'' ), # noqa + (b'strict-transport-security' , b'' ), # noqa + (b'transfer-encoding' , b'' ), # noqa + (b'user-agent' , b'' ), # noqa + (b'vary' , b'' ), # noqa + (b'via' , b'' ), # noqa + (b'www-authenticate' , b'' ), # noqa + ) # noqa + + STATIC_TABLE_LENGTH = len(STATIC_TABLE) + STATIC_TABLE_MAPPING: Dict[bytes, Tuple[int, Dict[bytes, int]]]={} + + def __init__(self): + self._maxsize = HeaderTable.DEFAULT_SIZE + self._current_size = 0 + self.resized = False + self.dynamic_entries: Deque[Tuple[bytes, bytes]] = deque() + + def get_by_index(self, index): + """ + Returns the entry specified by index + + Note that the table is 1-based ie an index of 0 is + invalid. This is due to the fact that a zero value + index signals that a completely unindexed header + follows. + + The entry will either be from the static table or + the dynamic table depending on the value of index. + """ + original_index = index + index -= 1 + + if index < 0: + raise Exception("Invalid table index %d" % original_index) + + if index < HeaderTable.STATIC_TABLE_LENGTH: + return HeaderTable.STATIC_TABLE[index] + + index -= HeaderTable.STATIC_TABLE_LENGTH + if index < len(self.dynamic_entries): + return self.dynamic_entries[index] + + def __repr__(self): + return "HeaderTable(%d, %s, %r)" % ( + self._maxsize, + self.resized, + self.dynamic_entries + ) + + def add(self, name, value): + """ + Adds a new entry to the table + + We reduce the table size if the entry will make the + table size greater than maxsize. + """ + # We just clear the table if the entry is too big + size = 32 + len(name) + len(value) + if size > self._maxsize: + self.dynamic_entries.clear() + self._current_size = 0 + else: + # Add new entry + self.dynamic_entries.appendleft((name, value)) + self._current_size += size + + # Remove entries until the size is within the limit + while self._current_size > self._maxsize: + name, value = self.dynamic_entries.pop() + self._current_size -= 32 + len(name) + len(value) + + def search(self, name, value): + """ + Searches the table for the entry specified by name + and value + + Returns one of the following: + - ``None``, no match at all + - ``(index, name, None)`` for partial matches on name only. + - ``(index, name, value)`` for perfect matches. + """ + + if ( + header_name_search_result := HeaderTable.STATIC_TABLE_MAPPING.get(name) + ): + if ( + index := header_name_search_result[1].get(value) + ): + return index, name, value + else: + partial = (header_name_search_result[0], name, None) + + else: + partial = None + + offset = HeaderTable.STATIC_TABLE_LENGTH + 1 + + for (i, (n, v)) in enumerate(self.dynamic_entries): + + if n == name: + if v == value: + return i + offset, n, v + elif partial is None: + partial = (i + offset, n, None) + + return partial + + @property + def maxsize(self): + return self._maxsize + + @maxsize.setter + def maxsize(self, newmax): + newmax = int(newmax) + oldmax = self._maxsize + self._maxsize = newmax + self.resized = (newmax != oldmax) + if newmax <= 0: + self.dynamic_entries.clear() + self._current_size = 0 + + elif oldmax > newmax: + cursize = self._current_size + while cursize > self._maxsize: + name, value = self.dynamic_entries.pop() + cursize -= ( + 32 + len(name) + len(value) + ) + self._current_size = cursize + +def _build_static_table_mapping(): + """ + Build static table mapping from header name to tuple with next structure: + (, ). + + static_table_mapping used for hash searching. + """ + static_table_mapping = {} + for index, (name, value) in enumerate(HeaderTable.STATIC_TABLE, 1): + header_name_search_result = static_table_mapping.setdefault(name, (index, {})) + header_name_search_result[1][value] = index + return static_table_mapping + + +HeaderTable.STATIC_TABLE_MAPPING = _build_static_table_mapping() diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/__init__.py b/hyperscale/core_rewrite/engines/client/http2/frames/__init__.py new file mode 100644 index 0000000..755fd6d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/__init__.py @@ -0,0 +1,2 @@ +from .frame_buffer import FrameBuffer as FrameBuffer +from .types.base_frame import Frame as Frame \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/exceptions.py b/hyperscale/core_rewrite/engines/client/http2/frames/exceptions.py new file mode 100644 index 0000000..6254f53 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/exceptions.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +""" +hyperframe/exceptions +~~~~~~~~~~~~~~~~~~~~~ + +Defines the exceptions that can be thrown by hyperframe. +""" + + +class HyperframeError(Exception): + """ + The base class for all exceptions for the hyperframe module. + + .. versionadded:: 6.0.0 + """ + + +class UnknownFrameError(HyperframeError): + """ + A frame of unknown type was received. + + .. versionchanged:: 6.0.0 + Changed base class from `ValueError` to :class:`HyperframeError` + """ + + def __init__(self, frame_type: int, length: int) -> None: + #: The type byte of the unknown frame that was received. + self.frame_type = frame_type + + #: The length of the data portion of the unknown frame. + self.length = length + + def __str__(self) -> str: + return ( + "UnknownFrameError: Unknown frame type 0x%X received, " + "length %d bytes" % (self.frame_type, self.length) + ) + + +class InvalidPaddingError(HyperframeError): + """ + A frame with invalid padding was received. + + .. versionchanged:: 6.0.0 + Changed base class from `ValueError` to :class:`HyperframeError` + """ + + pass + + +class InvalidFrameError(HyperframeError): + """ + Parsing a frame failed because the data was not laid out appropriately. + + .. versionadded:: 3.0.2 + + .. versionchanged:: 6.0.0 + Changed base class from `ValueError` to :class:`HyperframeError` + """ + + pass + + +class InvalidDataError(HyperframeError): + """ + Content or data of a frame was is invalid or violates the specification. + + .. versionadded:: 6.0.0 + """ + + pass diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/frame_buffer.py b/hyperscale/core_rewrite/engines/client/http2/frames/frame_buffer.py new file mode 100644 index 0000000..1f745b5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/frame_buffer.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +""" +h2/frame_buffer +~~~~~~~~~~~~~~~ + +A data structure that provides a way to iterate over a byte buffer in terms of +frames. +""" + +import struct + +from .exceptions import InvalidDataError, InvalidFrameError +from .types import * +from .types.attributes import ( + _STRUCT_H, + _STRUCT_HBBBL, + _STRUCT_HL, + _STRUCT_L, + _STRUCT_LB, + _STRUCT_LL, +) +from .types.base_frame import Frame + + +class FrameBuffer: + __slots__ = ("data", "max_frame_size", "_headers_buffer", "frames_map") + + """ + This is a data structure that expects to act as a buffer for HTTP/2 data + that allows iteraton in terms of H2 frames. + """ + + def __init__(self): + self.data = bytearray() + self.max_frame_size = 0 + self._headers_buffer = [] + + # The methods below support the iterator protocol. + def __iter__(self): + while len(self.data) >= 9: + try: + fields = _STRUCT_HBBBL.unpack(self.data[:9]) + + # First 24 bits are frame length. + length = (fields[0] << 8) + fields[1] + type = fields[2] + flags = fields[3] + stream_id = fields[4] & 0x7FFFFFFF + + frame = Frame(stream_id, type, parsed_flag_byte=flags) + + except (InvalidDataError, InvalidFrameError) as e: # pragma: no cover + raise Exception("Received frame with invalid header: %s" % str(e)) + + # Next, check that we have enough length to parse the frame body. If + # not, bail, leaving the frame header data in the buffer for next time. + if len(self.data) < length + 9: + break + + body_data = self.data[9 : 9 + length] + + if frame.type == 0xA: + # ALTSVC + + origin_len = _STRUCT_H.unpack(body_data[0:2])[0] + frame.origin = body_data[2 : 2 + origin_len] + + if len(frame.origin) != origin_len: + raise Exception("Invalid ALTSVC frame body.") + + frame.field = body_data[2 + origin_len :] + frame.body_len = len(body_data) + + elif frame.type == 0x09: + # CONTINUATION + frame.data = body_data + frame.body_len = len(body_data) + + elif frame.type == 0x0: + # DATA + padding_data_offset = 0 + frame.pad_length = 0 + + if "PADDED" in frame.flags: # type: ignore + frame.pad_length = struct.unpack("!B", body_data[:1])[0] + padding_data_offset = 1 + + data_length = len(body_data) + frame.data = body_data[ + padding_data_offset : data_length - frame.pad_length + ] + frame.body_len = data_length + + elif frame.type == 0x07: + # GOAWAY + + frame.last_stream_id, frame.error_code = _STRUCT_LL.unpack( + body_data[:8] + ) + frame.body_len = len(body_data) + + if len(body_data) > 8: + frame.additional_data = body_data[8:] + + elif frame.type == 0x01: + # HEADERS + padding_data_offset = 0 + + if "PADDED" in frame.flags: # type: ignore + frame.pad_length = struct.unpack("!B", body_data[:1])[0] + padding_data_offset = 1 + + body_data = body_data[padding_data_offset:] + + if "PRIORITY" in frame.flags: + frame.depends_on, frame.stream_weight = _STRUCT_LB.unpack( + body_data[:5] + ) + frame.exclusive = True if frame.depends_on >> 31 else False + frame.depends_on &= 0x7FFFFFFF + padding_data_offset = 5 + + else: + padding_data_offset = 0 + + data_length = len(body_data) + frame.body_len = data_length + frame.data = body_data[ + padding_data_offset : data_length - frame.pad_length + ] + + elif frame.type == 0x06: + # PING + frame.opaque_data = body_data + frame.body_len = 8 + + elif frame.type == 0x02: + # PRIORITY + + try: + frame.depends_on, frame.stream_weight = _STRUCT_LB.unpack( + body_data[:5] + ) + except struct.error: + raise Exception("Invalid Priority data") + + frame.exclusive = True if frame.depends_on >> 31 else False + frame.depends_on &= 0x7FFFFFFF + + frame.body_len = 5 + + elif frame.type == 0x05: + # PUSH PROMISE + padding_data_offset = 0 + frame.pad_length = 0 + if "PADDED" in frame.flags: # type: ignore + try: + frame.pad_length = struct.unpack("!B", body_data[:1])[0] + except struct.error: + raise Exception("Invalid Padding data") + padding_data_offset = 1 + + frame.promised_stream_id = _STRUCT_L.unpack( + body_data[padding_data_offset : padding_data_offset + 4] + )[0] + + data_len = len(body_data) + frame.data = body_data[ + padding_data_offset + 4 : data_len - frame.pad_length + ] + frame.body_len = data_len + + elif frame.type == 0x03: + # RESET + frame.error_code = _STRUCT_L.unpack(body_data)[0] + frame.body_len = 4 + + elif frame.type == 0x04: + # SETTINGS + body_len = 0 + for i in range(0, len(body_data), 6): + name, value = _STRUCT_HL.unpack(body_data[i : i + 6]) + + frame.settings[name] = value + body_len += 6 + + frame.body_len = body_len + + elif frame.type == 0x08: + # WINDOW UPDATE + frame.window_increment = _STRUCT_L.unpack(body_data)[0] + frame.body_len = 4 + + # At this point, as we know we'll use or discard the entire frame, we + # can update the data. + del self.data[: 9 + length] + + # Pass the frame through the heaer buffer. + # f = self._update_header_buffer(f) + is_headers_or_push_promise = ( + frame.frame_type == "HEADERS" or frame.frame_type == "PUSHPROMISE" + ) + + if self._headers_buffer: + stream_id = self._headers_buffer[0].stream_id + valid_frame = ( + frame is not None + and frame.frame_type == "CONTINUATION" + and frame.stream_id == stream_id + ) + + if valid_frame == False: + raise Exception("Invalid frame during header block.") + + # Append the frame to the buffer. + self._headers_buffer.append(frame) + + # If this is the end of the header block, then we want to build a + # mutant HEADERS frame that's massive. Use the original one we got, + # then set END_HEADERS and set its data appopriately. If it's not + # the end of the block, lose the current frame: we can't yield it. + if "END_HEADERS" in frame.flags: + frame = self._headers_buffer[0] + frame.flags.add("END_HEADERS") + + frame_data = bytearray() + for header_frame in self._headers_buffer: + frame_data.extend(header_frame) + + frame.data = frame_data + self._headers_buffer = [] + + else: + frame = None + elif is_headers_or_push_promise and "END_HEADERS" not in frame.flags: + # This is the start of a headers block! Save the frame off and then + # act like we didn't receive one. + self._headers_buffer.append(frame) + frame = None + + # If we got a frame we didn't understand or shouldn't yield, rather + # than return None it'd be better if we just tried to get the next + # frame in the sequence instead. Recurse back into ourselves to do + # that. This is safe because the amount of work we have to do here is + # strictly bounded by the length of the buffer. + if frame: + yield frame diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/__init__.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/__init__.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/__init__.py new file mode 100644 index 0000000..bbb9e81 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/__init__.py @@ -0,0 +1,22 @@ +from .struct_types import ( + _STRUCT_B, + _STRUCT_H, + _STRUCT_HBBBL, + _STRUCT_HL, + _STRUCT_L, + _STRUCT_LB, + _STRUCT_LL +) +from .stream_associations import ( + _STREAM_ASSOC_EITHER, + _STREAM_ASSOC_HAS_STREAM, + _STREAM_ASSOC_NO_STREAM +) +from .frame_length import ( + FRAME_MAX_ALLOWED_LEN, + FRAME_MAX_LEN +) +from .frame_flags import ( + Flag, + Flags +) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_flags.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_flags.py new file mode 100644 index 0000000..4432689 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_flags.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +""" +hyperframe/flags +~~~~~~~~~~~~~~~~ + +Defines basic Flag and Flags data structures. +""" +from collections.abc import MutableSet +from typing import ( + NamedTuple, + Iterable, + Set, + Iterator +) + + +class Flag(NamedTuple): + name: str + bit: int + + +class Flags(MutableSet): # type: ignore + __slots__ = ( + '_valid_flags', + '_flags' + ) + + """ + A simple MutableSet implementation that will only accept known flags as + elements. + + Will behave like a regular set(), except that a ValueError will be thrown + when .add()ing unexpected flags. + """ + def __init__(self, defined_flags: Iterable[Flag]): + self._valid_flags = set(flag.name for flag in defined_flags) + self._flags: Set[str] = set() + + def __repr__(self) -> str: + return repr(sorted(list(self._flags))) + + def __contains__(self, x: object) -> bool: + return self._flags.__contains__(x) + + def __iter__(self) -> Iterator[str]: + return self._flags.__iter__() + + def __len__(self) -> int: + return self._flags.__len__() + + def discard(self, value: str) -> None: + return self._flags.discard(value) + + def add(self, value: str) -> None: + # if value not in self._valid_flags: + # raise ValueError( + # "Unexpected flag: {}. Valid flags are: {}".format( + # value, self._valid_flags + # ) + # ) + return self._flags.add(value) diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_length.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_length.py new file mode 100644 index 0000000..e0ba63a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/frame_length.py @@ -0,0 +1,6 @@ +# The maximum initial length of a frame. Some frames have shorter maximum +# lengths. +FRAME_MAX_LEN = (2 ** 14) + +# The maximum allowed length of a frame. +FRAME_MAX_ALLOWED_LEN = (2 ** 24) - 1 \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/stream_associations.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/stream_associations.py new file mode 100644 index 0000000..fcfd85f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/stream_associations.py @@ -0,0 +1,4 @@ +# Stream association enumerations. +_STREAM_ASSOC_HAS_STREAM = "has-stream" +_STREAM_ASSOC_NO_STREAM = "no-stream" +_STREAM_ASSOC_EITHER = "either" diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/struct_types.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/struct_types.py new file mode 100644 index 0000000..c96bcaa --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/types/attributes/struct_types.py @@ -0,0 +1,10 @@ +import struct + +# Structs for packing and unpacking +_STRUCT_HBBBL = struct.Struct(">HBBBL") +_STRUCT_LL = struct.Struct(">LL") +_STRUCT_HL = struct.Struct(">HL") +_STRUCT_LB = struct.Struct(">LB") +_STRUCT_L = struct.Struct(">L") +_STRUCT_H = struct.Struct(">H") +_STRUCT_B = struct.Struct(">B") \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/base_frame.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/base_frame.py new file mode 100644 index 0000000..dc4e596 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/types/base_frame.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +""" +hyperframe/frame +~~~~~~~~~~~~~~~~ + +Defines framing logic for HTTP/2. Provides both classes to represent framed +data and logic for aiding the connection when it comes to reading from the +socket. +""" +import sys +from typing import Any, Iterable, List, Optional + +from .attributes import ( + _STRUCT_B, + _STRUCT_H, + _STRUCT_HBBBL, + _STRUCT_HL, + _STRUCT_L, + _STRUCT_LB, + _STRUCT_LL, + Flag, + Flags, +) +from .utils import raw_data_repr + + +class Frame: + __slots__ = ( + 'stream_id', + 'flags', + 'body_len', + 'flags', + 'type', + 'frame_type', + 'data', + 'settings', + 'origin', + 'field', + 'error_code', + 'pad_length', + 'last_stream_id', + 'additional_data', + 'depends_on', + 'stream_weight', + 'exclusive', + 'opaque_data', + 'promised_stream_id', + 'window_increment', + 'flag_byte', + 'defined_flags' + ) + + FRAMES = {} + """ + The base class for all HTTP/2 frames. + """ + #: The flags defined on this type of frame. + + # If 'has-stream', the frame's stream_id must be non-zero. If 'no-stream', + # it must be zero. If 'either', it's not checked. + stream_association: Optional[str] = None + frame_types = { + 0xA: sys.intern('ALTSVC'), + 0x09: sys.intern('CONTINUATION'), + 0x0: sys.intern('DATA'), + 0x07: sys.intern('GOAWAY'), + 0x01: sys.intern('HEADERS'), + 0x06: sys.intern('PING'), + 0x02: sys.intern('PRIORITY'), + 0x05: sys.intern('PUSHPROMISE'), + 0x03: sys.intern('RESET'), + 0x04: sys.intern('SETTINGS'), + 0x08: sys.intern('WINDOWUPDATE') + } + + def __init__(self, stream_id: int, frame_type: int, flags: Iterable[str] = (), parsed_flag_byte: int = 0, **kwargs: Any) -> None: + #: The stream identifier for the stream this frame was received on. + #: Set to 0 for frames sent on the connection (stream-id 0). + self.stream_id = stream_id + self.type = frame_type + self.frame_type = self.frame_types.get(self.type) + self.field = b'' + self.data = b'' + self.settings = {} + self.origin = b'' + self.error_code = 0 + self.pad_length = 0 + self.last_stream_id = 0 + self.additional_data = 0 + self.depends_on = 0x0 + self.stream_weight = 0x0 + self.exclusive = False + self.opaque_data = b'' + self.promised_stream_id = 0 + self.window_increment = 0 + self.flag_byte = 0x0 + self.defined_flags: List[Flag] = [] + + #: The flags set for this frame. + self.flags = Flags(self.defined_flags) + + #: The frame length, excluding the nine-byte header. + self.body_len = 0 + + for flag in flags: + self.flags.add(flag) + + if self.type == 0xA: + # ALTSVC + self.origin = kwargs.get('origin', b'') + self.field = kwargs.get('fields', b'') + + elif self.type == 0x09: + # CONTINUATION + + self.defined_flags = [ + Flag('END_HEADERS', 0x04) + ] + + self.data = kwargs.get('data') + + elif self.type == 0x0: + # DATA + + self.defined_flags = [ + Flag('END_STREAM', 0x01), + Flag('PADDED', 0x08), + ] + + self.pad_length = kwargs.get('pad_length', 0) + self.data = kwargs.get('data', b'') + + elif self.type == 0x07: + # GOAWAY + self.last_stream_id = kwargs.get('last_stream_id', 0) + self.additional_data = kwargs.get('additional_data', b'') + self.error_code = kwargs.get('error_code', 0) + + elif self.type == 0x01: + # HEADERS + self.defined_flags = [ + Flag('END_STREAM', 0x01), + Flag('END_HEADERS', 0x04), + Flag('PADDED', 0x08), + Flag('PRIORITY', 0x20), + ] + + self.data = kwargs.get('data', b'') + self.pad_length = kwargs.get('pad_length', 0) + self.depends_on = kwargs.get('depends_on', 0x0) + self.stream_weight = kwargs.get('stream_weight', 0x0) + self.exclusive = kwargs.get('exclusive', False) + + elif self.type == 0x06: + # PING + + self.defined_flags = [ + Flag('ACK', 0x01) + ] + + self.opaque_data = kwargs.get('opaque_data', b'') + + elif self.type == 0x02: + # PRIORITY + self.depends_on = kwargs.get('depends_on', 0x0) + self.stream_weight = kwargs.get('stream_weight', 0x0) + self.exclusive = kwargs.get('exclusive', False) + + elif self.type == 0x05: + # PUSH PROMISE + self.defined_flags = [ + Flag('END_HEADERS', 0x04), + Flag('PADDED', 0x08) + ] + + self.promised_stream_id = kwargs.get('promised_stream_id', 0) + self.pad_length = kwargs.get('pad_length', 0) + self.data = kwargs.get('data', b'') + + elif self.type == 0x03: + # RESET + self.error_code = kwargs.get('error_code', 0) + + elif self.type == 0x04: + # SETTINGS + self.defined_flags = [ + Flag('ACK', 0x01) + ] + + self.settings = kwargs.get('settings', {}) + + elif self.type == 0x08: + # WINDOW UPDATE + self.window_increment = kwargs.get('window_increment', 0) + + else: + # EXTENSION + self.flag_byte = kwargs.get('flag_byte', 0x0) + + for flag, flag_bit in self.defined_flags: + if parsed_flag_byte & flag_bit: + self.flags.add(flag) + + def __repr__(self) -> str: + body_repr = self._body_repr(), + return f"{type(self).__name__}(stream_id={self.stream_id}, flags={repr(self.flags)}): {body_repr}" + + def _body_repr(self) -> str: + # More specific implementation may be provided by subclasses of Frame. + # This fallback shows the serialized (and truncated) body content. + return raw_data_repr(self.serialize()) + + @property + def flow_controlled_length(self) -> int: + """ + The length of the frame that needs to be accounted for when considering + flow control. + """ + padding_len = 0 + if 'PADDED' in self.flags: + # Account for extra 1-byte padding length field, which is still + # present if possibly zero-valued. + padding_len = self.pad_length + 1 + return len(self.data) + padding_len + + def parse_flags(self, flag_byte: int) -> Flags: + + for flag, flag_bit in self.defined_flags: + if flag_byte & flag_bit: + self.flags.add(flag) + + return self.flags + + def serialize(self) -> bytes: + """ + Convert a frame into a bytestring, representing the serialized form of + the frame. + """ + + body = b'' + flags = 0 + + if self.type == 0xA: + # ALTSVC + origin_len = _STRUCT_H.pack(len(self.origin)) + body = origin_len + self.origin + self.field + + elif self.type == 0x09: + # CONTINUATION + + body = self.data + + elif self.type == 0x0: + # DATA + + padding_data = b'' + if 'PADDED' in self.flags: # type: ignore + padding_data = _STRUCT_B.pack(self.pad_length) + + + padding = b'\0' * self.pad_length + body = padding_data + self.data + padding + + elif self.type == 0x07: + # GOAWAY + + self.data = _STRUCT_LL.pack( + self.last_stream_id & 0x7FFFFFFF, + self.error_code + ) + + body = self.data + self.additional_data + + elif self.type == 0x01: + # HEADERS + + padding_data = b'' + if 'PADDED' in self.flags: # type: ignore + padding_data = _STRUCT_B.pack(self.pad_length) + + padding = b'\0' * self.pad_length + + if 'PRIORITY' in self.flags: + priority_data = _STRUCT_LB.pack( + self.depends_on + (0x80000000 if self.exclusive else 0), + self.stream_weight + ) + else: + priority_data = b'' + + body = padding_data + priority_data + self.data + padding + + elif self.type == 0x06: + # PING + + body = self.opaque_data + body += b'\x00' * (8 - len(body)) + + elif self.type == 0x02: + # PRIORITY + body = _STRUCT_LB.pack( + self.depends_on + (0x80000000 if self.exclusive else 0), + self.stream_weight + ) + + elif self.type == 0x05: + # PUSH PROMISE + + padding_data = b'' + if 'PADDED' in self.flags: # type: ignore + padding_data = _STRUCT_B.pack(self.pad_length) + + padding = b'\0' * self.pad_length + promise_data = _STRUCT_L.pack(self.promised_stream_id) + + body = padding_data + promise_data + self.data + padding + + elif self.type == 0x03: + # RESET + body = _STRUCT_L.pack(self.error_code) + + elif self.type == 0x04: + # SETTINGS + for setting, value in self.settings.items(): + body += _STRUCT_HL.pack(setting & 0xFF, value) + + elif self.type == 0x08: + # WINDOW UPDATE + body = _STRUCT_L.pack(self.window_increment & 0x7FFFFFFF) + + else: + # EXTENSION + flags = self.flag_byte + + self.body_len = len(body) + + + for flag, flag_bit in self.defined_flags: + if flag in self.flags: + flags |= flag_bit + + header = _STRUCT_HBBBL.pack( + (self.body_len >> 8) & 0xFFFF, # Length spread over top 24 bits + self.body_len & 0xFF, + self.type, + flags, + self.stream_id & 0x7FFFFFFF # Stream ID is 32 bits. + ) + + return header + body diff --git a/hyperscale/core_rewrite/engines/client/http2/frames/types/utils.py b/hyperscale/core_rewrite/engines/client/http2/frames/types/utils.py new file mode 100644 index 0000000..7c9df88 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/frames/types/utils.py @@ -0,0 +1,18 @@ +import binascii +import collections +from typing import Optional + + +def raw_data_repr(data: Optional[bytes]) -> str: + if not data: + return "None" + r = binascii.hexlify(data).decode('ascii') + if len(r) > 20: + r = r[:20] + "..." + return "" + + +HeaderValidationFlags = collections.namedtuple( + 'HeaderValidationFlags', + ['is_client', 'is_trailer', 'is_response_header', 'is_push_promise'] +) diff --git a/hyperscale/core_rewrite/engines/client/http2/mercury_sync_http2_connection.py b/hyperscale/core_rewrite/engines/client/http2/mercury_sync_http2_connection.py new file mode 100644 index 0000000..560ef62 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/mercury_sync_http2_connection.py @@ -0,0 +1,895 @@ +import asyncio +import ssl +import time +import uuid +from collections import defaultdict +from random import randrange +from typing import ( + Dict, + Iterator, + List, + Literal, + Optional, + Tuple, + TypeVar, + Union, +) +from urllib.parse import ( + ParseResult, + urlencode, + urlparse, +) + +import orjson +from pydantic import BaseModel + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + Cookies, + HTTPCookie, + HTTPEncodableValue, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.protocols import NEW_LINE +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .fast_hpack import Encoder +from .models.http2 import ( + HTTP2Response, +) +from .pipe import HTTP2Pipe +from .protocols import HTTP2Connection +from .settings import Settings + +A = TypeVar("A") +R = TypeVar("R") + + +class MercurySyncHTTP2Connection: + def __init__( + self, + pool_size: int = 128, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + self.session_id = str(uuid.uuid4()) + self.timeouts = timeouts + + self.closed = False + self._concurrency = pool_size + self._reset_connections = reset_connections + + self._semaphore = asyncio.Semaphore(pool_size) + + self._dns_lock: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self._dns_waiters: Dict[str, asyncio.Future] = defaultdict(asyncio.Future) + self._pending_queue: List[asyncio.Future] = [] + + self._client_waiters: Dict[asyncio.Transport, asyncio.Future] = {} + self._connections: List[HTTP2Connection] = [ + HTTP2Connection( + pool_size, + stream_id=randrange(1, 2**20 + 2, 2), + reset_connections=reset_connections, + ) + for _ in range(pool_size) + ] + + self._pipes = [HTTP2Pipe(pool_size) for _ in range(pool_size)] + + self._url_cache: Dict[str, URL] = {} + + self._hosts: Dict[str, Tuple[str, int]] = {} + + self._locks: Dict[asyncio.Transport, asyncio.Lock] = {} + + self._max_concurrency = pool_size + self._connection_waiters: List[asyncio.Future] = [] + + self.active = 0 + self.waiter = None + + self._encoder = Encoder() + self._settings = Settings(client=False) + + self._client_ssl_context = self._create_http2_ssl_context() + + async def head( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url, + "HEAD", + auth=auth, + cookies=cookies, + headers=headers, + params=params, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="HEAD", + status=408, + status_message="Request timed out.", + ) + + async def options( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url, + "OPTIONS", + auth=auth, + cookies=cookies, + headers=headers, + params=params, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="OPTIONS", + status=408, + status_message="Request timed out.", + ) + + async def get( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url, + "GET", + auth=auth, + cookies=cookies, + headers=headers, + params=params, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="GET", + status=408, + status_message="Request timed out.", + ) + + async def post( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url, + "POST", + auth=auth, + cookies=cookies, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="POST", + status=408, + status_message="Request timed out.", + ) + + async def put( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url, + "PUT", + auth=auth, + cookies=cookies, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="PUT", + status=408, + status_message="Request timed out.", + ) + + async def patch( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url, + "PATCH", + auth=auth, + cookies=cookies, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="PATCH", + status=408, + status_message="Request timed out.", + ) + + async def delete( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + url, + "DELETE", + auth=auth, + cookies=cookies, + headers=headers, + params=params, + redirects=redirects, + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP2Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="DELETE", + status=408, + status_message="Request timed out.", + ) + + async def _request( + self, + url: str, + method: str, + cookies: Optional[List[HTTPCookie]] = None, + auth: Optional[Tuple[str, str]] = None, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + headers: Optional[Dict[str, str]] = {}, + data: Union[Optional[str], Optional[bytes], Optional[BaseModel]] = None, + redirects: Optional[int] = 3, + ): + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = { + "request_start": None, + "connect_start": None, + "connect_end": None, + "write_start": None, + "write_end": None, + "read_start": None, + "read_end": None, + "request_end": None, + } + timings["request_start"] = time.monotonic() + + result, redirect, timings = await self._execute( + url, + method, + cookies=cookies, + auth=auth, + params=params, + headers=headers, + data=data, + timings=timings, + ) + + if redirect: + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + for _ in range(redirects): + result, redirect, timings = await self._execute( + url, + method, + cookies=cookies, + auth=auth, + params=params, + headers=headers, + data=data, + timings=timings, + upgrade_ssl=upgrade_ssl, + redirect_url=location, + ) + + if redirect is False: + break + + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in url: + upgrade_ssl = True + + timings["request_end"] = time.monotonic() + result.timings.update(timings) + + return result + + async def _execute( + self, + request_url: str, + method: str, + cookies: Optional[List[HTTPCookie]] = None, + auth: Optional[Tuple[str, str]] = None, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + headers: Optional[Dict[str, str]] = {}, + data: Union[ + Optional[str], + Optional[bytes], + Optional[BaseModel], + ] = None, + upgrade_ssl: bool = False, + redirect_url: Optional[str] = None, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ): + if redirect_url: + request_url = redirect_url + + connection: HTTP2Connection = None + + try: + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + (error, connection, pipe, url, upgrade_ssl) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=request_url if upgrade_ssl else None + ), + timeout=self.timeouts.connect_timeout, + ) + + if upgrade_ssl: + ssl_redirect_url = request_url.replace("http://", "https://") + + (error, connection, pipe, url, _) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=ssl_redirect_url + ), + timeout=self.timeouts.connect_timeout, + ) + + request_url = ssl_redirect_url + + if error: + timings["connect_end"] = time.monotonic() + + self._connections.append( + HTTP2Connection( + self._concurrency, + stream_id=randrange(1, 2**20 + 2, 2), + reset_connections=self._reset_connections, + ) + ) + + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + return ( + HTTP2Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + method=method, + status=400, + status_message=str(error), + headers={ + key.encode(): value.encode() + for key, value in headers.items() + } + if headers + else {}, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + if timings["write_start"] is None: + timings["write_start"] = time.monotonic() + + connection = pipe.send_preamble(connection) + + encoded_headers = self._encode_headers( + url, + method, + params=params, + headers=headers, + ) + + connection = pipe.send_request_headers( + encoded_headers, + data, + connection, + ) + + if data: + encoded_data = self._encode_data(data) + + connection = await asyncio.wait_for( + pipe.submit_request_body( + encoded_data, + connection, + ), + timeout=self.timeouts.write_timeout, + ) + + timings["write_end"] = time.monotonic() + + if timings["read_start"] is None: + timings["read_start"] = time.monotonic() + + (status, headers, body, error) = await asyncio.wait_for( + pipe.receive_response(connection), timeout=self.timeouts.read_timeout + ) + + if status >= 300 and status < 400: + timings["read_end"] = time.monotonic() + + self._connections.append( + HTTP2Connection( + self._concurrency, + stream_id=randrange(1, 2**20 + 2, 2), + reset_connections=self._reset_connections, + ) + ) + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + return ( + HTTP2Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + method=method, + status=status, + headers=headers, + ), + True, + timings, + ) + + if error: + raise error + + cookies: Union[Cookies, None] = None + cookies_data: Union[bytes, None] = headers.get("set-cookie") + if cookies_data: + cookies = Cookies() + cookies.update(cookies_data) + + self._connections.append(connection) + self._pipes.append(pipe) + + timings["read_end"] = time.monotonic() + + return ( + HTTP2Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + ), + cookies=cookies, + method=method, + status=status, + headers=headers, + content=body, + ), + False, + timings, + ) + + except Exception as request_exception: + self._connections.append( + HTTP2Connection( + self._concurrency, + stream_id=randrange(1, 2**20 + 2, 2), + reset_connections=self._reset_connections, + ) + ) + + self._pipes.append(HTTP2Pipe(self._max_concurrency)) + + if isinstance(request_url, str): + request_url: ParseResult = urlparse(request_url) + + timings["read_end"] = time.monotonic() + + return ( + HTTP2Response( + url=URLMetadata( + host=request_url.hostname, + path=request_url.path, + params=request_url.params, + query=request_url.query, + ), + method=method, + status=400, + status_message=str(request_exception), + ), + False, + timings, + ) + + def _encode_data( + self, + data: Union[ + Optional[str], + Optional[bytes], + Optional[BaseModel], + ] = None, + ): + encoded_data: Optional[bytes] = None + size = 0 + + if isinstance(data, Iterator): + chunks = [] + for chunk in data: + chunk_size = hex(len(chunk)).replace("0x", "") + NEW_LINE + encoded_chunk = chunk_size.encode() + chunk + NEW_LINE.encode() + size += len(encoded_chunk) + chunks.append(encoded_chunk) + + encoded_data = chunks + + else: + if isinstance(data, dict): + encoded_data = orjson.dumps(data) + + elif isinstance(data, BaseModel): + return data.model_dump_json().encode() + + elif isinstance(data, tuple): + encoded_data = urlencode(data).encode() + + elif isinstance(data, str): + encoded_data = data.encode() + + return encoded_data + + def _encode_headers( + self, + url: URL, + method: str, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + headers: Optional[Dict[str, str]] = None, + ): + url_path = url.path + if params: + url_params = urlencode(params) + url_path += f"?{url_params}" + + encoded_headers: List[Tuple[bytes, bytes]] = [ + (b":method", method.encode()), + (b":authority", url.hostname.encode()), + (b":scheme", url.scheme.encode()), + (b":path", url_path.encode()), + (b"user-agent", b"hyperscale"), + ] + + if headers: + encoded_headers.extend( + [ + (k.lower().encode(), v.encode()) + for k, v in headers.items() + if k.lower() + not in ( + "host", + "transfer-encoding", + ) + ] + ) + + encoded_headers: bytes = self._encoder.encode(encoded_headers) + encoded_headers: List[bytes] = [ + encoded_headers[i : i + self._settings.max_frame_size] + for i in range(0, len(encoded_headers), self._settings.max_frame_size) + ] + + return encoded_headers[0] + + def _create_http2_ssl_context(self): + """ + This function creates an SSLContext object that is suitably configured for + HTTP/2. If you're working with Python TLS directly, you'll want to do the + exact same setup as this function does. + """ + # Get the basic context from the standard library. + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + + # RFC 7540 Section 9.2: Implementations of HTTP/2 MUST use TLS version 1.2 + # or higher. Disable TLS 1.1 and lower. + ctx.options |= ( + ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 + ) + + # RFC 7540 Section 9.2.1: A deployment of HTTP/2 over TLS 1.2 MUST disable + # compression. + ctx.options |= ssl.OP_NO_COMPRESSION + + # RFC 7540 Section 9.2.2: "deployments of HTTP/2 that use TLS 1.2 MUST + # support TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256". In practice, the + # blocklist defined in this section allows only the AES GCM and ChaCha20 + # cipher suites with ephemeral key negotiation. + ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20") + + # We want to negotiate using NPN and ALPN. ALPN is mandatory, but NPN may + # be absent, so allow that. This setup allows for negotiation of HTTP/1.1. + ctx.set_alpn_protocols(["h2", "http/1.1"]) + + try: + if hasattr(ctx, "_set_npn_protocols"): + ctx.set_npn_protocols(["h2", "http/1.1"]) + except NotImplementedError: + pass + + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + + async def _connect_to_url_location( + self, request_url: str, ssl_redirect_url: Optional[str] = None + ) -> Tuple[Optional[Exception], HTTP2Connection, HTTP2Pipe, URL, bool]: + if ssl_redirect_url: + parsed_url = URL(ssl_redirect_url) + + else: + parsed_url = URL(request_url) + + url = self._url_cache.get(parsed_url.hostname) + dns_lock = self._dns_lock[parsed_url.hostname] + dns_waiter = self._dns_waiters[parsed_url.hostname] + + do_dns_lookup = url is None or ssl_redirect_url + + if do_dns_lookup and dns_lock.locked() is False: + await dns_lock.acquire() + url = parsed_url + await url.lookup() + + self._dns_lock[parsed_url.hostname] = dns_lock + self._url_cache[parsed_url.hostname] = url + + dns_waiter = self._dns_waiters[parsed_url.hostname] + + if dns_waiter.done() is False: + dns_waiter.set_result(None) + + dns_lock.release() + + elif do_dns_lookup: + await dns_waiter + url = self._url_cache.get(parsed_url.hostname) + + connection = self._connections.pop() + pipe = self._pipes.pop() + + connection_error: Optional[Exception] = None + + if url.address is None or ssl_redirect_url: + for address, ip_info in url: + try: + await connection.make_connection( + url.hostname, + address, + url.port, + ip_info, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + url.address = address + url.socket_config = ip_info + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return (None, None, None, parsed_url, True) + + else: + try: + await connection.make_connection( + url.hostname, + url.address, + url.port, + url.socket_config, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return (None, None, None, parsed_url, True) + + raise connection_error + + return ( + connection_error, + connection, + pipe, + parsed_url, + False, + ) diff --git a/hyperscale/core_rewrite/engines/client/http2/models/__init__.py b/hyperscale/core_rewrite/engines/client/http2/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http2/models/http2/__init__.py b/hyperscale/core_rewrite/engines/client/http2/models/http2/__init__.py new file mode 100644 index 0000000..c17fe2b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/models/http2/__init__.py @@ -0,0 +1,3 @@ +from .http2_request import HTTP2Request as HTTP2Request +from .http2_response import HTTP2Response as HTTP2Response +from .optimized_http2_request import OptimizedHTTP2Request as OptimizedHTTP2Request diff --git a/hyperscale/core_rewrite/engines/client/http2/models/http2/http2_request.py b/hyperscale/core_rewrite/engines/client/http2/models/http2/http2_request.py new file mode 100644 index 0000000..86b9f28 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/models/http2/http2_request.py @@ -0,0 +1,80 @@ +from typing import Dict, Iterator, List, Literal, Optional, Tuple, Union +from urllib.parse import urlencode, urlparse + +import orjson +from pydantic import BaseModel, StrictBytes, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + HTTPCookie, + HTTPEncodableValue, +) +from hyperscale.core_rewrite.engines.client.shared.protocols import NEW_LINE + + +class HTTP2Request(BaseModel): + url: StrictStr + method: Literal["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] + cookies: Optional[List[HTTPCookie]] = None + auth: Optional[Tuple[str, str]] = None + params: Optional[Dict[str, HTTPEncodableValue]] = None + headers: Dict[str, str] = {} + data: Union[Optional[StrictStr], Optional[StrictBytes], Optional[BaseModel]] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True + + def parse_url(self): + return urlparse(self.url) + + def encode_data(self): + encoded_data: Optional[bytes] = None + size = 0 + if self.data: + if isinstance(self.data, Iterator): + chunks = [] + for chunk in self.data: + chunk_size = hex(len(chunk)).replace("0x", "") + NEW_LINE + encoded_chunk = chunk_size.encode() + chunk + NEW_LINE.encode() + size += len(encoded_chunk) + chunks.append(encoded_chunk) + + encoded_data = chunks + + else: + if isinstance(self.data, dict): + encoded_data = orjson.dumps(self.data) + + elif isinstance(self.data, BaseModel): + return self.data.model_dump_json().encode() + + elif isinstance(self.data, tuple): + encoded_data = urlencode(self.data).encode() + + elif isinstance(self.data, str): + encoded_data = self.data.encode() + + return encoded_data + + def encode_headers(self, url: URL) -> List[Tuple[bytes, bytes]]: + encoded_headers = [ + (b":method", self.method.encode()), + (b":authority", url.hostname.encode()), + (b":scheme", url.scheme.encode()), + (b":path", url.path.encode()), + ] + + encoded_headers.extend( + [ + (k.lower().encode(), v.encode()) + for k, v in self.headers.items() + if k.lower() + not in ( + "host", + "transfer-encoding", + ) + ] + ) + + return encoded_headers diff --git a/hyperscale/core_rewrite/engines/client/http2/models/http2/http2_response.py b/hyperscale/core_rewrite/engines/client/http2/models/http2/http2_response.py new file mode 100644 index 0000000..735cf9c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/models/http2/http2_response.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import gzip +import zlib +from typing import Dict, Literal, Optional, Type, TypeVar, Union + +import orjson +from pydantic import BaseModel, StrictBytes, StrictFloat, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + Cookies, + URLMetadata, +) + +T = TypeVar("T") + + +class HTTP2Response(BaseModel): + url: URLMetadata + method: Optional[ + Literal["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] + ] = None + cookies: Union[Optional[Cookies], Optional[None]] = None + status: Optional[StrictInt] = None + status_message: Optional[StrictStr] = None + headers: Dict[StrictStr, StrictStr] = {} + content: StrictBytes = b"" + timings: Dict[StrictStr, StrictFloat] = {} + + class Config: + arbitrary_types_allowed = True + + def check_success(self) -> bool: + return self.status and self.status >= 200 and self.status < 300 + + @property + def content_type(self): + return self.headers.get(b"content-type", "application/text") + + @property + def compression(self): + return self.headers.get(b"content-encoding") + + @property + def version(self) -> Union[str, None]: + return self.headers.get(b"version") + + @property + def reason(self) -> Union[str, None]: + return self.headers.get(b"reason") + + @property + def size(self): + content_length = self.headers.get(b"content-length") + if content_length: + self._size = int(content_length) + + elif len(self.content) > 0: + self._size = len(self.content) + + else: + self._size = 0 + + return self._size + + def json(self): + return orjson.loads(self.body) + + def text(self): + return self.body.decode() + + def to_model(self, model: Type[T]) -> T: + return model(**orjson.loads(self.body)) + + @property + def body(self) -> bytes: + data = self.content + + if self.compression == b"gzip": + data = gzip.decompress(self.content) + + elif self.compression == b"deflate": + data = zlib.decompress(self.content) + + return data + + @property + def data(self): + match self.content_type: + case "application/json": + return self.json() + + case "application/text": + return self.text() + + case _: + return self.body diff --git a/hyperscale/core_rewrite/engines/client/http2/models/http2/optimized_http2_request.py b/hyperscale/core_rewrite/engines/client/http2/models/http2/optimized_http2_request.py new file mode 100644 index 0000000..292d273 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/models/http2/optimized_http2_request.py @@ -0,0 +1,38 @@ +from typing import Literal, Optional, Tuple + +from pydantic import ( + BaseModel, + StrictBytes, + StrictInt, + StrictStr, +) + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedHTTP2Request(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + method: Literal[ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "PATCH", + "DELETE", + ] + encoded_params: Optional[StrictStr | StrictBytes] = None + encoded_auth: Optional[StrictStr | StrictBytes] = None + encoded_cookies: Optional[ + StrictStr + | StrictBytes + | Tuple[StrictStr, StrictStr] + | Tuple[StrictBytes, StrictBytes] + ] = None + encoded_headers: Optional[StrictBytes] = None + encoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/http2/pipe.py b/hyperscale/core_rewrite/engines/client/http2/pipe.py new file mode 100644 index 0000000..c5250a0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/pipe.py @@ -0,0 +1,459 @@ +import asyncio +from typing import ( + Dict, + List, + Optional, + Tuple, +) + +from .config import H2Configuration +from .errors import ( + ErrorCodes, + StreamClosedError, + StreamError, +) +from .events import ( + ConnectionTerminated, + DataReceived, + StreamReset, + WindowUpdated, +) +from .fast_hpack import Decoder, Encoder, HeaderTable +from .frames.types.base_frame import Frame +from .protocols import HTTP2Connection +from .settings import SettingCodes, Settings, StreamClosedBy +from .windows import WindowManager + + +class HTTP2Pipe: + CONFIG = H2Configuration( + validate_inbound_headers=False, + ) + + def __init__(self, concurrency): + self.connected = False + self.concurrency = concurrency + self._encoder = Encoder() + self._decoder = Decoder() + self._decoder.header_table = HeaderTable() + self._decoder.max_allowed_table_size = self._decoder.header_table.maxsize + self._init_sent = False + self._data_to_send = b"" + self._headers_sent = False + self.lock = asyncio.Lock() + + self.local_settings = Settings( + client=True, + initial_values={ + SettingCodes.ENABLE_PUSH: 0, + SettingCodes.MAX_CONCURRENT_STREAMS: concurrency, + SettingCodes.MAX_HEADER_LIST_SIZE: 65535, + }, + ) + self.remote_settings = Settings(client=False) + + self.outbound_flow_control_window = self.remote_settings.initial_window_size + + del self.local_settings[SettingCodes.ENABLE_CONNECT_PROTOCOL] + + self._inbound_flow_control_window_manager = WindowManager( + max_window_size=self.local_settings.initial_window_size + ) + + self.local_settings_dict = { + setting_name: setting_value + for setting_name, setting_value in self.local_settings.items() + } + self.remote_settings_dict = { + setting_name: setting_value + for setting_name, setting_value in self.remote_settings.items() + } + + def _guard_increment_window(self, current, increment): + # The largest value the flow control window may take. + LARGEST_FLOW_CONTROL_WINDOW = 2**31 - 1 + + new_size = current + increment + + if new_size > LARGEST_FLOW_CONTROL_WINDOW: + self.outbound_flow_control_window = self.remote_settings.initial_window_size + + self._inbound_flow_control_window_manager = WindowManager( + max_window_size=self.local_settings.initial_window_size + ) + + return LARGEST_FLOW_CONTROL_WINDOW - current + + def send_preamble(self, connection: HTTP2Connection): + if self._init_sent is False: + window_increment = 65536 + + self._inbound_flow_control_window_manager.window_opened(window_increment) + + connection.write(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n") + self._init_sent = True + + self.outbound_flow_control_window = self.remote_settings.initial_window_size + + return connection + + def send_request_headers( + self, + headers: bytes, + data: Optional[bytes], + connection: HTTP2Connection, + ): + connection.stream.inbound = WindowManager( + self.local_settings.initial_window_size + ) + connection.stream.outbound = WindowManager( + self.remote_settings.initial_window_size + ) + connection.stream.max_inbound_frame_size = self.local_settings.max_frame_size + connection.stream.max_outbound_frame_size = self.remote_settings.max_frame_size + connection.stream.current_outbound_window_size = ( + self.remote_settings.initial_window_size + ) + + headers_frame = Frame(connection.stream.stream_id, 0x01) + headers_frame.flags.add("END_HEADERS") + + if data is None: + headers_frame.flags.add("END_STREAM") + + connection.stream.inbound.window_opened(65536) + + connection.write(headers_frame.serialize()) + + return connection + + async def receive_response(self, connection: HTTP2Connection): + body_data = bytearray() + status_code: Optional[int] = 200 + headers_dict: Dict[bytes, bytes] = {} + error: Optional[Exception] = None + + done = False + while done is False: + data = b"" + + try: + data = await connection.read() + + except Exception as err: + return ( + 400, + headers_dict, + body_data, + err, + ) + + if data == b"": + done = True + + connection.stream.frame_buffer.data.extend(data) + connection.stream.frame_buffer.max_frame_size = ( + connection.stream.max_outbound_frame_size + ) + + write_data = bytearray() + frames = None + stream_events: List[Frame] = [] + + for frame in connection.stream.frame_buffer: + try: + if frame.type == 0x0: + # DATA + + end_stream = "END_STREAM" in frame.flags + flow_controlled_length = frame.flow_controlled_length + frame_data = frame.data + + frames = [] + self._inbound_flow_control_window_manager.window_consumed( + flow_controlled_length + ) + + try: + connection.stream.inbound.window_consumed( + flow_controlled_length + ) + + event = DataReceived() + event.stream_id = connection.stream.stream_id + + stream_events.append(event) + + if end_stream: + done = True + + stream_events[0].data = frame_data + stream_events[ + 0 + ].flow_controlled_length = flow_controlled_length + + except StreamClosedError as e: + status_code = status_code or 400 + error = Exception( + f"Connection - {connection.stream.stream_id} err: {str(e._events[0])}" + ) + + elif frame.type == 0x07: + # GOAWAY + self._data_to_send = b"" + + new_event = ConnectionTerminated() + new_event.error_code = ErrorCodes(frame.error_code) + new_event.last_stream_id = frame.last_stream_id + + if frame.additional_data: + new_event.additional_data = frame.additional_data + + frames = [] + done = True + + elif frame.type == 0x01: + # HEADERS + headers: List[Tuple[bytes, bytes]] = {} + + try: + headers = self._decoder.decode(frame.data, raw=True) + + except Exception as headers_read_err: + status_code = status_code or 400 + error = headers_read_err + + for k, v in headers: + if k == ":status": + status_code = int(v) + elif k.startswith(":"): + headers_dict[k.strip(":")] = v + else: + headers_dict[k] = v + + if "END_STREAM" in frame.flags: + done = True + + frames = [] + + elif frame.type == 0x03: + # RESET + + self.closed_by = StreamClosedBy.RECV_RST_STREAM + reset_event = StreamReset() + reset_event.stream_id = connection.stream.stream_id + + reset_event.error_code = ErrorCodes(frame.error_code) + + status_code = 400 + error = Exception( + f"Connection - {connection.stream.stream_id} - err: {str(reset_event)}" + ) + + elif frame.type == 0x04: + # SETTINGS + + if "ACK" in frame.flags: + changes = self.local_settings.acknowledge() + + initial_window_size_change = changes.get( + SettingCodes.INITIAL_WINDOW_SIZE + ) + max_header_list_size_change = changes.get( + SettingCodes.MAX_HEADER_LIST_SIZE + ) + max_frame_size_change = changes.get( + SettingCodes.MAX_FRAME_SIZE + ) + header_table_size_change = changes.get( + SettingCodes.HEADER_TABLE_SIZE + ) + + if initial_window_size_change is not None: + window_delta = ( + initial_window_size_change.new_value + - initial_window_size_change.original_value + ) + + new_max_window_size = ( + connection.stream.inbound.max_window_size + + window_delta + ) + connection.stream.inbound.window_opened(window_delta) + connection.stream.inbound.max_window_size = ( + new_max_window_size + ) + + if max_header_list_size_change is not None: + self._decoder.max_header_list_size = ( + max_header_list_size_change.new_value + ) + + if max_frame_size_change is not None: + self.max_outbound_frame_size = ( + max_frame_size_change.new_value + ) + + if header_table_size_change: + # This is safe across all hpack versions: some versions just won't + # respect it. + self._decoder.max_allowed_table_size = ( + header_table_size_change.new_value + ) + + # Add the new settings. + self.remote_settings.update(frame.settings) + + changes = self.remote_settings.acknowledge() + initial_window_size_change = changes.get( + SettingCodes.INITIAL_WINDOW_SIZE + ) + header_table_size_change = changes.get( + SettingCodes.HEADER_TABLE_SIZE + ) + max_frame_size_change = changes.get(SettingCodes.MAX_FRAME_SIZE) + + if initial_window_size_change: + connection.stream.current_outbound_window_size = ( + self._guard_increment_window( + connection.stream.current_outbound_window_size, + initial_window_size_change.new_value + - initial_window_size_change.original_value, + ) + ) + + # HEADER_TABLE_SIZE changes by the remote part affect our encoder: cf. + # RFC 7540 Section 6.5.2. + if header_table_size_change: + self._encoder.header_table_size = ( + header_table_size_change.new_value + ) + + if max_frame_size_change: + self.max_outbound_frame_size = ( + max_frame_size_change.new_value + ) + + frame = Frame(0, 0x04) + frame.flags.add("ACK") + + frames = [frame] + + elif frame.type == 0x08: + # WINDOW UPDATE + + frames = [] + increment = frame.window_increment + if frame.stream_id: + try: + event = WindowUpdated() + event.stream_id = connection.stream.stream_id + + # If we encounter a problem with incrementing the flow control window, + # this should be treated as a *stream* error, not a *connection* error. + # That means we need to catch the error and forcibly close the stream. + event.delta = increment + + try: + self.outbound_flow_control_window = ( + self._guard_increment_window( + self.outbound_flow_control_window, increment + ) + ) + except StreamError: + # Ok, this is bad. We're going to need to perform a local + # reset. + + event = StreamReset() + event.stream_id = connection.stream.stream_id + event.error_code = ErrorCodes.FLOW_CONTROL_ERROR + event.remote_reset = False + + self.closed_by = ErrorCodes.FLOW_CONTROL_ERROR + + status_code = 400 + error = Exception( + f"Connection - {connection.stream.stream_id} err: {str(event)}" + ) + + except Exception: + frames = [] + + else: + self.outbound_flow_control_window = ( + self._guard_increment_window( + self.outbound_flow_control_window, increment + ) + ) + # FIXME: Should we split this into one event per active stream? + window_updated_event = WindowUpdated() + window_updated_event.stream_id = 0 + window_updated_event.delta = increment + + frames = [] + + except Exception as e: + status_code = status_code or 400 + error = Exception( + f"Connection - {connection.stream.stream_id} err- {str(e)}" + ) + + if frames: + for f in frames: + write_data.extend(f.serialize()) + + connection.write(write_data) + + for event in stream_events: + amount = event.flow_controlled_length + + conn_increment = ( + self._inbound_flow_control_window_manager.process_bytes(amount) + ) + + if conn_increment: + connection.stream.write_window_update_frame( + stream_id=0, window_increment=conn_increment + ) + + if event.data is None: + event.data = b"" + + body_data.extend(event.data) + + if done: + break + + return (status_code, headers_dict, bytes(body_data), error) + + async def submit_request_body(self, data: bytes, connection: HTTP2Connection): + while data: + local_flow = self.current_outbound_window_size + max_frame_size = self.max_outbound_frame_size + flow = min(local_flow, max_frame_size) + while flow == 0: + await self.receive_response(connection) + + local_flow = self.current_outbound_window_size + max_frame_size = self.max_outbound_frame_size + flow = min(local_flow, max_frame_size) + + max_flow = flow + chunk_size = min(len(data), max_flow) + chunk, data = data[:chunk_size], data[chunk_size:] + + df = Frame(connection.stream.stream_id, 0x0) + df.data = chunk + + # Subtract flow_controlled_length to account for possible padding + self.outbound_flow_control_window -= df.flow_controlled_length + assert self.outbound_flow_control_window >= 0 + + connection.write(df.serialize()) + + df = Frame(connection.stream.stream_id, 0x0) + df.flags.add("END_STREAM") + + connection.write(df.serialize()) + + return connection diff --git a/hyperscale/core_rewrite/engines/client/http2/protocols/__init__.py b/hyperscale/core_rewrite/engines/client/http2/protocols/__init__.py new file mode 100644 index 0000000..8bbe478 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/protocols/__init__.py @@ -0,0 +1 @@ +from .connection import HTTP2Connection \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/protocols/connection.py b/hyperscale/core_rewrite/engines/client/http2/protocols/connection.py new file mode 100644 index 0000000..425e960 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/protocols/connection.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import asyncio +from ssl import SSLContext +from typing import Optional, Tuple + +from hyperscale.core_rewrite.engines.client.http2.streams import Stream +from hyperscale.core_rewrite.engines.client.shared.protocols import _DEFAULT_LIMIT + +from .tcp import TCPConnection + + +class HTTP2Connection: + def __init__( + self, + concurrency: int, + stream_id: int = 1, + reset_connections: bool = False, + ) -> None: + if stream_id % 2 == 0: + stream_id += 1 + + self.dns_address: str = None + self.port: int = None + self.ssl: SSLContext = None + self.stream_id = stream_id + self.lock = asyncio.Lock() + + self.stream = Stream( + concurrency, + stream_id=stream_id, + reset_connections=reset_connections, + ) + + self.connected = False + self.reset_connections = reset_connections + self.pending = 0 + self._connection_factory = TCPConnection() + + async def make_connection( + self, + hostname: str, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + ssl: Optional[SSLContext] = None, + timeout: Optional[float] = None, + ssl_upgrade: bool = False, + ): + if self.connected is False or self.dns_address != dns_address or ssl_upgrade: + reader, writer = await self._connection_factory.create_http2( + hostname, socket_config, ssl=ssl + ) + + self.stream.reader = reader + self.stream.writer = writer + + self.connected = True + self.dns_address = dns_address + self.port = port + self.ssl = ssl + else: + self.stream.update_stream_id() + + @property + def empty(self): + return not self.stream.reader._buffer + + def read(self): + return self.stream.reader.read(n=_DEFAULT_LIMIT) + + def readexactly(self, n_bytes: int): + return self.stream.reader.readexactly(n=n_bytes) + + def readuntil(self, sep=b"\n"): + return self.stream.reader.readuntil(separator=sep) + + def readline(self): + return self.stream.reader.readline() + + def write(self, data): + self.stream.writer.write(data) + + def reset_buffer(self): + self.stream.reader._buffer = bytearray() + + def read_headers(self): + return self.stream.reader.read_headers() + + async def close(self): + try: + await self._connection_factory.close() + except Exception: + pass diff --git a/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/__init__.py b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/__init__.py new file mode 100644 index 0000000..85966f2 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/__init__.py @@ -0,0 +1 @@ +from .connection import TCPConnection diff --git a/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/connection.py b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/connection.py new file mode 100644 index 0000000..b6a9eaa --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/connection.py @@ -0,0 +1,91 @@ +import asyncio +import socket +from asyncio.constants import SSL_HANDSHAKE_TIMEOUT +from asyncio.sslproto import SSLProtocol +from ssl import SSLContext +from typing import Optional + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + HTTP2_LIMIT, + Reader, + Writer, +) + +from .tls_protocol import TLSProtocol + + +class TCPConnection: + + def __init__(self) -> None: + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + self.transport = None + self._connection = None + self.socket: socket.socket = None + self._writer = None + + async def create_http2(self, hostname=None, socket_config=None, ssl: Optional[SSLContext] = None, ssl_timeout: int = SSL_HANDSHAKE_TIMEOUT): + # this does the same as loop.open_connection(), but TLS upgrade is done + # manually after connection be established. + + self.loop = asyncio.get_event_loop() + + family, type_, proto, _, address = socket_config + + socket_family = socket.AF_INET6 if family == 2 else socket.AF_INET + + self.socket = socket.socket(family=family, type=type_, proto=proto) + self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + await self.loop.run_in_executor(None, self.socket.connect, address) + + self.socket.setblocking(False) + + reader = Reader(limit=HTTP2_LIMIT, loop=self.loop) + + protocol = TLSProtocol(reader, loop=self.loop) + + self.transport, _ = await self.loop.create_connection( + lambda: protocol, + sock=self.socket, + family=socket_family + ) + + ssl_protocol = SSLProtocol( + self.loop, + protocol, + ssl, + None, + False, + hostname, + ssl_handshake_timeout=ssl_timeout, + call_connection_made=False + ) + + # Pause early so that "ssl_protocol.data_received()" doesn't + # have a chance to get called before "ssl_protocol.connection_made()". + self.transport.pause_reading() + + self.transport.set_protocol(ssl_protocol) + + await self.loop.run_in_executor(None, ssl_protocol.connection_made, self.transport) + self.transport.resume_reading() + + self.transport = ssl_protocol._app_transport + + reader = Reader(limit=HTTP2_LIMIT, loop=self.loop) + + protocol.upgrade_reader(reader) # update reader + protocol.connection_made(self.transport) # update transport + + self._writer = Writer(self.transport, ssl_protocol, reader, self.loop) # update writer + + return reader, self._writer + + async def close(self): + + try: + self.transport._ssl_protocol.pause_writing() + self.transport.close() + + except Exception: + pass \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/protocol.py b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/protocol.py new file mode 100644 index 0000000..844f544 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/protocol.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from asyncio import Protocol, Transport +from asyncio.coroutines import iscoroutine +from weakref import ref + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + FlowControlMixin, + Reader, + Writer, +) + + +class TCPProtocol(FlowControlMixin, Protocol): + """Helper class to adapt between Protocol and StreamReader. + (This is a helper class instead of making StreamReader itself a + Protocol subclass, because the StreamReader has other potential + uses, and to prevent the user of the StreamReader to accidentally + call inappropriate methods of the protocol.) + """ + + __slots__ = ( + '_source_traceback', + '_reject_connection', + '_stream_writer', + '_transport', + '_client_connected_cb', + '_over_ssl', + '_closed', + '_stream_reader_wr' + ) + + def __init__(self, stream_reader: Reader, client_connected_cb=None, loop=None): + super().__init__(loop=loop) + self._source_traceback = None + + if stream_reader is not None: + self._stream_reader_wr: Reader = ref(stream_reader) + self._source_traceback = stream_reader._source_traceback + else: + self._stream_reader_wr = None + if client_connected_cb is not None: + # This is a stream created by the `create_server()` function. + # Keep a strong reference to the reader until a connection + # is established. + self._strong_reader = stream_reader + self._reject_connection = False + self._stream_writer: Writer = None + self._transport: Transport = None + self._client_connected_cb = client_connected_cb + self._over_ssl = False + self._closed = self._loop.create_future() + + @property + def _stream_reader(self) -> Reader: + if self._stream_reader_wr is None: + return None + return self._stream_reader_wr() + + def _replace_writer(self, writer: Writer): + transport = writer.transport + self._stream_writer = writer + self._transport = transport + self._over_ssl = transport.get_extra_info('sslcontext') is not None + + def connection_made(self, transport: Transport): + if self._reject_connection: + context = { + 'message': ('An open stream was garbage collected prior to ' + 'establishing network connection; ' + 'call "stream.close()" explicitly.') + } + if self._source_traceback: + context['source_traceback'] = self._source_traceback + self._loop.call_exception_handler(context) + transport.abort() + return + self._transport = transport + reader: Reader = self._stream_reader + if reader is not None: + reader.set_transport(transport) + self._over_ssl = transport.get_extra_info('sslcontext') is not None + if self._client_connected_cb is not None: + self._stream_writer = Reader(transport, self, + reader, + self._loop) + res = self._client_connected_cb(reader, + self._stream_writer) + if iscoroutine(res): + self._loop.create_task(res) + self._strong_reader = None + + def connection_lost(self, exc): + reader: Reader = self._stream_reader + if reader is not None: + if exc is None: + reader.feed_eof() + else: + reader.set_exception(exc) + if not self._closed.done(): + if exc is None: + self._closed.set_result(None) + else: + self._closed.set_exception(exc) + super().connection_lost(exc) + self._stream_reader_wr = None + self._stream_writer = None + self._transport = None + + def data_received(self, data): + reader = self._stream_reader + if reader is not None: + reader.feed_data(data) + + def eof_received(self): + reader: Reader = self._stream_reader + if reader is not None: + reader.feed_eof() + if self._over_ssl: + # Prevent a warning in SSLProtocol.eof_received: + # "returning true from eof_received() + # has no effect when using ssl" + return False + return True + + def _get_close_waiter(self, stream): + return self._closed + + def __del__(self): + # Prevent reports about unhandled exceptions. + # Better than self._closed._log_traceback = False hack + try: + closed = self._closed + except AttributeError: + pass # failed constructor + else: + if closed.done() and not closed.cancelled(): + closed.exception() + diff --git a/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/tls_protocol.py b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/tls_protocol.py new file mode 100644 index 0000000..9d81f8a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/protocols/tcp/tls_protocol.py @@ -0,0 +1,16 @@ +from weakref import ref + +from hyperscale.core_rewrite.engines.client.shared.protocols import Reader + +from .protocol import TCPProtocol + + +class TLSProtocol(TCPProtocol): + + def upgrade_reader(self, reader: Reader): + + if self._stream_reader: + self._stream_reader.set_exception(Exception('upgraded connection to TLS, this reader is obsolete now.')) + + self._stream_reader_wr = ref(reader) + self._source_traceback = reader._source_traceback \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/settings/__init__.py b/hyperscale/core_rewrite/engines/client/http2/settings/__init__.py new file mode 100644 index 0000000..fc8c4ad --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/settings/__init__.py @@ -0,0 +1,4 @@ +from .changed_setting import ChangedSetting as ChangedSetting +from .stream_closed_by import StreamClosedBy as StreamClosedBy +from .stream_settings import Settings as Settings +from .stream_settings_codes import SettingCodes as SettingCodes diff --git a/hyperscale/core_rewrite/engines/client/http2/settings/changed_setting.py b/hyperscale/core_rewrite/engines/client/http2/settings/changed_setting.py new file mode 100644 index 0000000..fdf34b5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/settings/changed_setting.py @@ -0,0 +1,30 @@ +class ChangedSetting: + + __slots__ = ( + 'setting', + 'original_value', + 'new_value' + ) + + def __init__(self, setting, original_value, new_value): + #: The setting code given. Either one of :class:`SettingCodes + #: ` or ``int`` + #: + #: .. versionchanged:: 2.6.0 + self.setting = setting + + #: The original value before being changed. + self.original_value = original_value + + #: The new value after being changed. + self.new_value = new_value + + def __repr__(self): + return ( + "ChangedSetting(setting=%s, original_value=%s, " + "new_value=%s)" + ) % ( + self.setting, + self.original_value, + self.new_value + ) diff --git a/hyperscale/core_rewrite/engines/client/http2/settings/stream_closed_by.py b/hyperscale/core_rewrite/engines/client/http2/settings/stream_closed_by.py new file mode 100644 index 0000000..fb37938 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/settings/stream_closed_by.py @@ -0,0 +1,7 @@ +from enum import Enum + +class StreamClosedBy(Enum): + SEND_END_STREAM = 0 + RECV_END_STREAM = 1 + SEND_RST_STREAM = 2 + RECV_RST_STREAM = 3 \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/settings/stream_settings.py b/hyperscale/core_rewrite/engines/client/http2/settings/stream_settings.py new file mode 100644 index 0000000..ade76a0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/settings/stream_settings.py @@ -0,0 +1,248 @@ +from collections import deque +from collections.abc import MutableMapping + +from hyperscale.core_rewrite.engines.client.http2.errors.types import ErrorCodes + +from .changed_setting import ChangedSetting +from .stream_settings_codes import SettingCodes + + +class Settings(MutableMapping): + """ + An object that encapsulates HTTP/2 settings state. + + HTTP/2 Settings are a complex beast. Each party, remote and local, has its + own settings and a view of the other party's settings. When a settings + frame is emitted by a peer it cannot assume that the new settings values + are in place until the remote peer acknowledges the setting. In principle, + multiple settings changes can be "in flight" at the same time, all with + different values. + + This object encapsulates this mess. It provides a dict-like interface to + settings, which return the *current* values of the settings in question. + Additionally, it keeps track of the stack of proposed values: each time an + acknowledgement is sent/received, it updates the current values with the + stack of proposed values. On top of all that, it validates the values to + make sure they're allowed, and raises :class:`InvalidSettingsValueError + ` if they are not. + + Finally, this object understands what the default values of the HTTP/2 + settings are, and sets those defaults appropriately. + + .. versionchanged:: 2.2.0 + Added the ``initial_values`` parameter. + + .. versionchanged:: 2.5.0 + Added the ``max_header_list_size`` property. + + :param client: (optional) Whether these settings should be defaulted for a + client implementation or a server implementation. Defaults to ``True``. + :type client: ``bool`` + :param initial_values: (optional) Any initial values the user would like + set, rather than RFC 7540's defaults. + :type initial_vales: ``MutableMapping`` + """ + def __init__(self, client=True, initial_values=None): + # Backing object for the settings. This is a dictionary of + # (setting: [list of values]), where the first value in the list is the + # current value of the setting. Strictly this doesn't use lists but + # instead uses collections.deque to avoid repeated memory allocations. + # + # This contains the default values for HTTP/2. + self._settings = { + SettingCodes.HEADER_TABLE_SIZE: deque([4096]), + SettingCodes.ENABLE_PUSH: deque([int(client)]), + SettingCodes.INITIAL_WINDOW_SIZE: deque([65535]), + SettingCodes.MAX_FRAME_SIZE: deque([16384]), + SettingCodes.ENABLE_CONNECT_PROTOCOL: deque([0]), + } + if initial_values is not None: + for key, value in initial_values.items(): + invalid = _validate_setting(key, value) + if invalid: + raise Exception( + "Setting %d has invalid value %d" % (key, value), + error_code=invalid + ) + self._settings[key] = deque([value]) + + def acknowledge(self): + """ + The settings have been acknowledged, either by the user (remote + settings) or by the remote peer (local settings). + + :returns: A dict of {setting: ChangedSetting} that were applied. + """ + changed_settings = {} + + # If there is more than one setting in the list, we have a setting + # value outstanding. Update them. + for k, v in self._settings.items(): + if len(v) > 1: + old_setting = v.popleft() + new_setting = v[0] + changed_settings[k] = ChangedSetting( + k, old_setting, new_setting + ) + + return changed_settings + + # Provide easy-access to well known settings. + @property + def header_table_size(self): + """ + The current value of the :data:`HEADER_TABLE_SIZE + ` setting. + """ + return self[SettingCodes.HEADER_TABLE_SIZE] + + @header_table_size.setter + def header_table_size(self, value): + self[SettingCodes.HEADER_TABLE_SIZE] = value + + @property + def enable_push(self): + """ + The current value of the :data:`ENABLE_PUSH + ` setting. + """ + return self[SettingCodes.ENABLE_PUSH] + + @enable_push.setter + def enable_push(self, value): + self[SettingCodes.ENABLE_PUSH] = value + + @property + def initial_window_size(self): + """ + The current value of the :data:`INITIAL_WINDOW_SIZE + ` setting. + """ + return self[SettingCodes.INITIAL_WINDOW_SIZE] + + @initial_window_size.setter + def initial_window_size(self, value): + self[SettingCodes.INITIAL_WINDOW_SIZE] = value + + @property + def max_frame_size(self): + """ + The current value of the :data:`MAX_FRAME_SIZE + ` setting. + """ + return self[SettingCodes.MAX_FRAME_SIZE] + + @max_frame_size.setter + def max_frame_size(self, value): + self[SettingCodes.MAX_FRAME_SIZE] = value + + @property + def max_concurrent_streams(self): + """ + The current value of the :data:`MAX_CONCURRENT_STREAMS + ` setting. + """ + return self.get(SettingCodes.MAX_CONCURRENT_STREAMS, 2**32+1) + + @max_concurrent_streams.setter + def max_concurrent_streams(self, value): + self[SettingCodes.MAX_CONCURRENT_STREAMS] = value + + @property + def max_header_list_size(self): + """ + The current value of the :data:`MAX_HEADER_LIST_SIZE + ` setting. If not set, + returns ``None``, which means unlimited. + + .. versionadded:: 2.5.0 + """ + return self.get(SettingCodes.MAX_HEADER_LIST_SIZE, None) + + @max_header_list_size.setter + def max_header_list_size(self, value): + self[SettingCodes.MAX_HEADER_LIST_SIZE] = value + + @property + def enable_connect_protocol(self): + """ + The current value of the :data:`ENABLE_CONNECT_PROTOCOL + ` setting. + """ + return self[SettingCodes.ENABLE_CONNECT_PROTOCOL] + + @enable_connect_protocol.setter + def enable_connect_protocol(self, value): + self[SettingCodes.ENABLE_CONNECT_PROTOCOL] = value + + # Implement the MutableMapping API. + def __getitem__(self, key): + val = self._settings[key][0] + + # Things that were created when a setting was received should stay + # KeyError'd. + if val is None: + raise KeyError + + return val + + def __setitem__(self, key, value): + invalid = _validate_setting(key, value) + if invalid: + raise Exception( + "Setting %d has invalid value %d" % (key, value), + error_code=invalid + ) + + try: + items = self._settings[key] + except KeyError: + items = deque([None]) + self._settings[key] = items + + items.append(value) + + def __delitem__(self, key): + del self._settings[key] + + def __iter__(self): + return self._settings.__iter__() + + def __len__(self): + return len(self._settings) + + def __eq__(self, other): + if isinstance(other, Settings): + return self._settings == other._settings + else: + return NotImplemented + + def __ne__(self, other): + if isinstance(other, Settings): + return not self == other + else: + return NotImplemented + + +def _validate_setting(setting, value): # noqa: C901 + """ + Confirms that a specific setting has a well-formed value. If the setting is + invalid, returns an error code. Otherwise, returns 0 (NO_ERROR). + """ + if setting == SettingCodes.ENABLE_PUSH: + if value not in (0, 1): + return ErrorCodes.PROTOCOL_ERROR + elif setting == SettingCodes.INITIAL_WINDOW_SIZE: + if not 0 <= value <= 2147483647: # 2^31 - 1 + return ErrorCodes.FLOW_CONTROL_ERROR + elif setting == SettingCodes.MAX_FRAME_SIZE: + if not 16384 <= value <= 16777215: # 2^14 and 2^24 - 1 + return ErrorCodes.PROTOCOL_ERROR + elif setting == SettingCodes.MAX_HEADER_LIST_SIZE: + if value < 0: + return ErrorCodes.PROTOCOL_ERROR + elif setting == SettingCodes.ENABLE_CONNECT_PROTOCOL: + if value not in (0, 1): + return ErrorCodes.PROTOCOL_ERROR + + return 0 diff --git a/hyperscale/core_rewrite/engines/client/http2/settings/stream_settings_codes.py b/hyperscale/core_rewrite/engines/client/http2/settings/stream_settings_codes.py new file mode 100644 index 0000000..06c3377 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/settings/stream_settings_codes.py @@ -0,0 +1,24 @@ +from enum import IntEnum + + +class SettingCodes(IntEnum): + """ + All known HTTP/2 setting codes. + + .. versionadded:: 2.6.0 + """ + + #: The byte that signals the SETTINGS_HEADER_TABLE_SIZE setting. + HEADER_TABLE_SIZE = 0x01 + #: The byte that signals the SETTINGS_ENABLE_PUSH setting. + ENABLE_PUSH = 0x02 + #: The byte that signals the SETTINGS_MAX_CONCURRENT_STREAMS setting. + MAX_CONCURRENT_STREAMS = 0x03 + #: The byte that signals the SETTINGS_INITIAL_WINDOW_SIZE setting. + INITIAL_WINDOW_SIZE = 0x04 + #: The byte that signals the SETTINGS_MAX_FRAME_SIZE setting. + MAX_FRAME_SIZE = 0x05 + #: The byte that signals the SETTINGS_MAX_HEADER_LIST_SIZE setting. + MAX_HEADER_LIST_SIZE = 0x06 + #: The byte that signals SETTINGS_ENABLE_CONNECT_PROTOCOL setting. + ENABLE_CONNECT_PROTOCOL = 0x08 diff --git a/hyperscale/core_rewrite/engines/client/http2/settings/stream_state.py b/hyperscale/core_rewrite/engines/client/http2/settings/stream_state.py new file mode 100644 index 0000000..8cd1741 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/settings/stream_state.py @@ -0,0 +1,10 @@ +from enum import IntEnum + +class StreamState(IntEnum): + IDLE = 0 + RESERVED_REMOTE = 1 + RESERVED_LOCAL = 2 + OPEN = 3 + HALF_CLOSED_REMOTE = 4 + HALF_CLOSED_LOCAL = 5 + CLOSED = 6 \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/settings/stream_state_map.py b/hyperscale/core_rewrite/engines/client/http2/settings/stream_state_map.py new file mode 100644 index 0000000..510a693 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/settings/stream_state_map.py @@ -0,0 +1,22 @@ +from enum import Enum + +class StreamStateMap(Enum): + SEND_HEADERS = 0 + SEND_PUSH_PROMISE = 1 + SEND_RST_STREAM = 2 + SEND_DATA = 3 + SEND_WINDOW_UPDATE = 4 + SEND_END_STREAM = 5 + RECV_HEADERS = 6 + RECV_PUSH_PROMISE = 7 + RECV_RST_STREAM = 8 + RECV_DATA = 9 + RECV_WINDOW_UPDATE = 10 + RECV_END_STREAM = 11 + RECV_CONTINUATION = 12 # Added in 2.0.0 + SEND_INFORMATIONAL_HEADERS = 13 # Added in 2.2.0 + RECV_INFORMATIONAL_HEADERS = 14 # Added in 2.2.0 + SEND_ALTERNATIVE_SERVICE = 15 # Added in 2.3.0 + RECV_ALTERNATIVE_SERVICE = 16 # Added in 2.3.0 + UPGRADE_CLIENT = 17 # Added 2.3.0 + UPGRADE_SERVER = 18 # Added 2.3.0 diff --git a/hyperscale/core_rewrite/engines/client/http2/streams/__init__.py b/hyperscale/core_rewrite/engines/client/http2/streams/__init__.py new file mode 100644 index 0000000..5202eac --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/streams/__init__.py @@ -0,0 +1 @@ +from .stream import Stream as Stream diff --git a/hyperscale/core_rewrite/engines/client/http2/streams/stream.py b/hyperscale/core_rewrite/engines/client/http2/streams/stream.py new file mode 100644 index 0000000..c6b244b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/streams/stream.py @@ -0,0 +1,110 @@ +import struct +from typing import Optional + +from hyperscale.core_rewrite.engines.client.http2.frames import Frame, FrameBuffer +from hyperscale.core_rewrite.engines.client.http2.settings import SettingCodes, Settings +from hyperscale.core_rewrite.engines.client.http2.windows import WindowManager +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + Reader, + Writer, +) + + +class Stream: + READ_NUM_BYTES = 65536 + + def __init__( + self, concurrency: int, stream_id: int = 1, reset_connections: bool = False + ) -> None: + self.buffer = FrameBuffer() + self.stream_id = stream_id + self.reader: Optional[Reader] = None + self.writer: Optional[Writer] = None + self.inbound: WindowManager = None + self.outbound: WindowManager = None + self.reset_connections = reset_connections + + self.max_inbound_frame_size = 0 + self.max_outbound_frame_size = 0 + self.current_outbound_window_size = 0 + self.content_length = 0 + self.expected_content_length = 0 + + self._STRUCT_HBBBL = struct.Struct(">HBBBL") + self._STRUCT_LL = struct.Struct(">LL") + self._STRUCT_HL = struct.Struct(">HL") + self._STRUCT_LB = struct.Struct(">LB") + self._STRUCT_L = struct.Struct(">L") + self._STRUCT_H = struct.Struct(">H") + self._STRUCT_B = struct.Struct(">B") + self.frame_buffer = FrameBuffer() + + self.local_settings = Settings( + client=True, + initial_values={ + SettingCodes.ENABLE_PUSH: 0, + SettingCodes.MAX_CONCURRENT_STREAMS: concurrency, + SettingCodes.MAX_HEADER_LIST_SIZE: 65535, + }, + ) + self.remote_settings = Settings(client=False) + + self.outbound_flow_control_window = self.remote_settings.initial_window_size + + del self.local_settings[SettingCodes.ENABLE_CONNECT_PROTOCOL] + + self.local_settings_dict = { + setting_name: setting_value + for setting_name, setting_value in self.local_settings.items() + } + self.remote_settings_dict = { + setting_name: setting_value + for setting_name, setting_value in self.remote_settings.items() + } + + self.settings_fsrame = Frame(0, 0x04, settings=self.local_settings_dict) + self.window_frame = Frame(stream_id, 0x08, window_increment=65536) + + def update_stream_id(self): + self.stream_id += 2 # self.concurrency + if self.stream_id % 2 == 0: + self.stream_id += 1 + + self.window_frame.stream_id = self.stream_id + self.frame_buffer = FrameBuffer() + + self.window_frame = Frame(self.stream_id, 0x08, window_increment=65536) + + def write(self, data: bytes): + self.writer._transport.write(data) + + def read(self, msg_length: int = READ_NUM_BYTES): + return self.reader.read(msg_length) + + def get_raw_buffer(self) -> bytearray: + return self.reader._buffer + + def write_window_update_frame( + self, stream_id: int = None, window_increment: int = None + ): + if stream_id is None: + stream_id = self.stream_id + + body = self._STRUCT_L.pack(window_increment & 0x7FFFFFFF) + body_len = len(body) + + type = 0x08 + + # Build the common frame header. + # First, get the flags. + flags = 0 + + header = self._STRUCT_HBBBL.pack( + (body_len >> 8) & 0xFFFF, # Length spread over top 24 bits + body_len & 0xFF, + type, + flags, + stream_id & 0x7FFFFFFF, # Stream ID is 32 bits. + ) + + self.writer.write(header + body) diff --git a/hyperscale/core_rewrite/engines/client/http2/windows/__init__.py b/hyperscale/core_rewrite/engines/client/http2/windows/__init__.py new file mode 100644 index 0000000..415aaad --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/windows/__init__.py @@ -0,0 +1 @@ +from .window_manager import WindowManager \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http2/windows/window_manager.py b/hyperscale/core_rewrite/engines/client/http2/windows/window_manager.py new file mode 100644 index 0000000..047dda0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http2/windows/window_manager.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +""" +h2/windows +~~~~~~~~~~ + +Defines tools for managing HTTP/2 flow control windows. + +The objects defined in this module are used to automatically manage HTTP/2 +flow control windows. Specifically, they keep track of what the size of the +window is, how much data has been consumed from that window, and how much data +the user has already used. It then implements a basic algorithm that attempts +to manage the flow control window without user input, trying to ensure that it +does not emit too many WINDOW_UPDATE frames. +""" +from __future__ import division + + + +# The largest acceptable value for a HTTP/2 flow control window. +LARGEST_FLOW_CONTROL_WINDOW = 2**31 - 1 + + +class WindowManager: + + __slots__ = ( + 'max_window_size', + 'current_window_size', + '_bytes_processed' + ) + + """ + A basic HTTP/2 window manager. + + :param max_window_size: The maximum size of the flow control window. + :type max_window_size: ``int`` + """ + def __init__(self, max_window_size): + self.max_window_size = max_window_size + self.current_window_size = max_window_size + self._bytes_processed = 0 + + def window_consumed(self, size): + """ + We have received a certain number of bytes from the remote peer. This + necessarily shrinks the flow control window! + + :param size: The number of flow controlled bytes we received from the + remote peer. + :type size: ``int`` + :returns: Nothing. + :rtype: ``None`` + """ + self.current_window_size -= size + + def window_opened(self, size): + """ + The flow control window has been incremented, either because of manual + flow control management or because of the user changing the flow + control settings. This can have the effect of increasing what we + consider to be the "maximum" flow control window size. + + This does not increase our view of how many bytes have been processed, + only of how much space is in the window. + + :param size: The increment to the flow control window we received. + :type size: ``int`` + :returns: Nothing + :rtype: ``None`` + """ + self.current_window_size += size + + if self.current_window_size > self.max_window_size: + self.max_window_size = self.current_window_size + + def process_bytes(self, size): + """ + The application has informed us that it has processed a certain number + of bytes. This may cause us to want to emit a window update frame. If + we do want to emit a window update frame, this method will return the + number of bytes that we should increment the window by. + + :param size: The number of flow controlled bytes that the application + has processed. + :type size: ``int`` + :returns: The number of bytes to increment the flow control window by, + or ``None``. + :rtype: ``int`` or ``None`` + """ + + if size is None: + size = 0 + + self._bytes_processed += size + if not self._bytes_processed: + return None + + max_increment = (self.max_window_size - self.current_window_size) + increment = 0 + + min_threshold = (self.current_window_size == 0) and (self._bytes_processed > min(1024, self.max_window_size // 4)) + max_threshold = self._bytes_processed >= (self.max_window_size // 2) + + # Note that, even though we may increment less than _bytes_processed, + # we still want to set it to zero whenever we emit an increment. This + # is because we'll always increment up to the maximum we can. + if min_threshold or max_threshold: + increment = min(self._bytes_processed, max_increment) + self._bytes_processed = 0 + + self.current_window_size += increment + return increment \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http3/__init__.py b/hyperscale/core_rewrite/engines/client/http3/__init__.py new file mode 100644 index 0000000..d5ad36f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/__init__.py @@ -0,0 +1,6 @@ +from .mercury_sync_http3_connection import ( + MercurySyncHTTP3Connection as MercurySyncHTTP3Connection, +) +from .models.http3 import HTTP3Request as HTTP3Request +from .models.http3 import HTTP3Response as HTTP3Response +from .models.http3 import OptimizedHTTP3Request as OptimizedHTTP3Request diff --git a/hyperscale/core_rewrite/engines/client/http3/mercury_sync_http3_connection.py b/hyperscale/core_rewrite/engines/client/http3/mercury_sync_http3_connection.py new file mode 100644 index 0000000..dff0c00 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/mercury_sync_http3_connection.py @@ -0,0 +1,755 @@ +import asyncio +import ssl +import time +from collections import defaultdict, deque +from typing import ( + Dict, + List, + Literal, + Optional, + Tuple, + TypeVar, + Union, +) +from urllib.parse import urlparse + +from pydantic import BaseModel + +from hyperscale.core_rewrite.engines.client.http3.protocols.quic_protocol import ( + FrameType, + HeadersState, + ResponseFrameCollection, + encode_frame, +) +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + Cookies, + HTTPCookie, + HTTPEncodableValue, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.http3 import HTTP3Request, HTTP3Response +from .protocols import HTTP3Connection + +A = TypeVar("A") +R = TypeVar("R") + + +class MercurySyncHTTP3Connection: + def __init__( + self, + pool_size: Optional[int] = None, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + if pool_size is None: + pool_size = 100 + + self.timeouts = timeouts + self.reset_connections = reset_connections + + self._client_ssl_context = self._create_general_client_ssl_context() + + self._dns_lock: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self._dns_waiters: Dict[str, asyncio.Future] = defaultdict(asyncio.Future) + self._pending_queue: List[asyncio.Future] = [] + + self._client_waiters: Dict[asyncio.Transport, asyncio.Future] = {} + self._connections: List[HTTP3Connection] = [ + HTTP3Connection(reset_connections=reset_connections) + for _ in range(pool_size) + ] + + self._hosts: Dict[str, Tuple[str, int]] = {} + + self._connections_count: Dict[str, List[asyncio.Transport]] = defaultdict(list) + self._locks: Dict[asyncio.Transport, asyncio.Lock] = {} + + self._max_concurrency = pool_size + + self._semaphore = asyncio.Semaphore(self._max_concurrency) + self._connection_waiters: List[asyncio.Future] = [] + + self._url_cache: Dict[str, URL] = {} + + def _create_general_client_ssl_context(self): + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + + async def head( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTP3Request( + url=url, + method="HEAD", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP3Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="HEAD", + status=408, + status_message="Request timed out.", + ) + + async def options( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTP3Request( + url=url, + method="OPTIONS", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP3Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="OPTIONS", + status=408, + status_message="Request timed out.", + ) + + async def get( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTP3Request( + url=url, + method="GET", + cookies=cookies, + data=None, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ) + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP3Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="GET", + status=408, + status_message="Request timed out.", + ) + + async def post( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTP3Request( + url=url, + method="POST", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP3Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="POST", + status=408, + status_message="Request timed out.", + ) + + async def put( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTP3Request( + url=url, + method="PUT", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP3Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="PUT", + status=408, + status_message="Request timed out.", + ) + + async def patch( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[Optional[str], Optional[BaseModel]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTP3Request( + url=url, + method="PATCH", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP3Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="PATCH", + status=408, + status_message="Request timed out.", + ) + + async def delete( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + HTTP3Request( + url=url, + method="DELETE", + cookies=cookies, + auth=auth, + headers=headers, + params=params, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return HTTP3Response( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="DELETE", + status=408, + status_message="Request timed out.", + ) + + async def _request(self, request: HTTP3Request): + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = { + "request_start": None, + "connect_start": None, + "connect_end": None, + "write_start": None, + "write_end": None, + "read_start": None, + "read_end": None, + "request_end": None, + } + timings["request_start"] = time.monotonic() + + result, redirect, timings = await self._execute(request, timings=timings) + + if redirect: + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in request.url: + upgrade_ssl = True + + for _ in range(request.redirects): + result, redirect, timings = await self._execute( + request, + upgrade_ssl=upgrade_ssl, + redirect_url=location, + timings=timings, + ) + + if redirect is False: + break + + location = result.headers.get("location") + + upgrade_ssl = False + if "https" in location and "https" not in request.url: + upgrade_ssl = True + + timings["request_end"] = time.monotonic() + result.timings.update(timings) + + return result + + async def _execute( + self, + request: HTTP3Request, + upgrade_ssl: bool = False, + redirect_url: Optional[str] = None, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ) -> Tuple[ + HTTP3Response, + bool, + Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ], + ]: + if redirect_url: + request_url = redirect_url + + else: + request_url = request.url + + try: + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + (connection, url, upgrade_ssl) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=request_url if upgrade_ssl else None + ), + timeout=self.timeouts.connect_timeout, + ) + + if upgrade_ssl: + ssl_redirect_url = request_url.replace("http://", "https://") + + connection, url, _ = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=ssl_redirect_url + ), + timeout=self.timeouts.connect_timeout, + ) + + request_url = ssl_redirect_url + + encoded_headers = request.encode_headers(url) + + if connection.protocol is None: + timings["connect_end"] = time.monotonic() + self._connections.append( + HTTP3Connection( + reset_connections=self.reset_connections, + ) + ) + + return ( + HTTP3Response( + url=URLMetadata(host=url.hostname, path=url.path), + method=request.method, + status=400, + headers=request.headers, + timings=timings, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + if timings["write_start"] is None: + timings["write_start"] = time.monotonic() + + stream_id = connection.protocol.quic.get_next_available_stream_id() + + stream = connection.protocol.get_or_create_stream(stream_id) + if stream.headers_send_state == HeadersState.AFTER_TRAILERS: + raise Exception("HEADERS frame is not allowed in this state") + + encoder, frame_data = connection.protocol.encoder.encode( + stream_id, encoded_headers + ) + + connection.protocol.encoder_bytes_sent += len(encoder) + connection.protocol.quic.send_stream_data( + connection.protocol._local_encoder_stream_id, encoder + ) + + # update state and send headers + if stream.headers_send_state == HeadersState.INITIAL: + stream.headers_send_state = HeadersState.AFTER_HEADERS + else: + stream.headers_send_state = HeadersState.AFTER_TRAILERS + + connection.protocol.quic.send_stream_data( + stream_id, + encode_frame(FrameType.HEADERS, frame_data), + end_stream=not request.data, + ) + + if request.data: + data = request.encode_data() + + stream = connection.protocol.get_or_create_stream(stream_id) + if stream.headers_send_state != HeadersState.AFTER_HEADERS: + raise Exception("DATA frame is not allowed in this state") + + connection.protocol.quic.send_stream_data( + stream_id, encode_frame(FrameType.DATA, data), True + ) + + waiter = connection.protocol.loop.create_future() + connection.protocol.request_events[stream_id] = deque() + connection.protocol._request_waiter[stream_id] = waiter + connection.protocol.transmit() + + if timings["write_end"] is None: + timings["write_end"] = time.monotonic() + + if timings["read_start"] is None: + timings["read_start"] = time.monotonic() + + response_frames: ResponseFrameCollection = await asyncio.wait_for( + waiter, timeout=self.timeouts.total_timeout + ) + + headers: Dict[str, Union[bytes, int]] = {} + for header_key, header_value in response_frames.headers_frame.headers: + headers[header_key] = header_value + + status = int(headers.get(b":status", b"400")) + + if status >= 300 and status < 400: + timings["read_end"] = time.monotonic() + self._connections.append(connection) + + return ( + HTTP3Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + params=url.params, + query=url.query, + ), + method=request.method, + status=status, + headers=headers, + timings=timings, + ), + True, + timings, + ) + + cookies: Union[Cookies, None] = None + cookies_data: Union[bytes, None] = headers.get(b"set-cookie") + if cookies_data: + cookies = Cookies() + cookies.update(cookies_data) + + self._connections.append(connection) + + timings["read_end"] = time.monotonic() + + return ( + HTTP3Response( + url=URLMetadata( + host=url.hostname, + path=url.path, + params=url.params, + query=url.query, + ), + cookies=cookies, + method=request.method, + status=status, + headers=headers, + content=response_frames.body, + timings=timings, + ), + False, + timings, + ) + + except Exception as request_exception: + self._connections.append( + HTTP3Connection(reset_connections=self.reset_connections) + ) + + if isinstance(request_url, str): + request_url = urlparse(request_url) + + timings["read_end"] = time.monotonic() + + return ( + HTTP3Response( + url=URLMetadata( + host=request_url.hostname, + path=request_url.path, + params=request_url.params, + query=request_url.query, + ), + method=request.method, + status=400, + status_message=str(request_exception), + timings=timings, + ), + False, + timings, + ) + + async def _connect_to_url_location( + self, request_url: str, ssl_redirect_url: Optional[str] = None + ) -> Tuple[HTTP3Connection, URL, bool]: + if ssl_redirect_url: + parsed_url = URL(ssl_redirect_url) + + else: + parsed_url = URL(request_url) + + url = self._url_cache.get(parsed_url.hostname) + dns_lock = self._dns_lock[parsed_url.hostname] + dns_waiter = self._dns_waiters[parsed_url.hostname] + + do_dns_lookup = url is None or ssl_redirect_url + + if do_dns_lookup and dns_lock.locked() is False: + await dns_lock.acquire() + url = parsed_url + await url.lookup() + + self._dns_lock[parsed_url.hostname] = dns_lock + self._url_cache[parsed_url.hostname] = url + + dns_waiter = self._dns_waiters[parsed_url.hostname] + + if dns_waiter.done() is False: + dns_waiter.set_result(None) + + dns_lock.release() + + elif do_dns_lookup: + await dns_waiter + url = self._url_cache.get(parsed_url.hostname) + + connection = self._connections.pop() + + if url.address is None or ssl_redirect_url: + for address, ip_info in url: + try: + await connection.make_connection( + url.hostname, + address, + url.port, + ip_info, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + url.address = address + url.socket_config = ip_info + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return None, parsed_url, True + + else: + try: + await connection.make_connection( + url.hostname, + url.address, + url.port, + url.socket_config, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return None, parsed_url, True + + raise connection_error + + return connection, parsed_url, False diff --git a/hyperscale/core_rewrite/engines/client/http3/models/__init__.py b/hyperscale/core_rewrite/engines/client/http3/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http3/models/http3/__init__.py b/hyperscale/core_rewrite/engines/client/http3/models/http3/__init__.py new file mode 100644 index 0000000..adb9a64 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/models/http3/__init__.py @@ -0,0 +1,3 @@ +from .http3_request import HTTP3Request as HTTP3Request +from .http3_response import HTTP3Response as HTTP3Response +from .optimized_http3_request import OptimizedHTTP3Request as OptimizedHTTP3Request diff --git a/hyperscale/core_rewrite/engines/client/http3/models/http3/http3_request.py b/hyperscale/core_rewrite/engines/client/http3/models/http3/http3_request.py new file mode 100644 index 0000000..1dfd49d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/models/http3/http3_request.py @@ -0,0 +1,80 @@ +from typing import ( + Dict, + Iterator, + List, + Literal, + Optional, + Tuple, + Union, +) +from urllib.parse import urlencode + +import orjson +from pydantic import BaseModel, StrictBytes, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + HTTPCookie, + HTTPEncodableValue, +) + +NEW_LINE = "\r\n" + + +class HTTP3Request(BaseModel): + url: StrictStr + method: Literal["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] + cookies: Optional[List[HTTPCookie]] = None + auth: Optional[Tuple[str, str]] = None + params: Optional[Dict[str, HTTPEncodableValue]] = None + headers: Dict[str, str] = {} + data: Union[Optional[StrictStr], Optional[StrictBytes], Optional[BaseModel]] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True + + def encode_headers(self, url: URL): + encoded_headers = [ + (b":method", self.method.encode()), + (b":scheme", url.scheme.encode()), + (b":authority", url.authority.encode()), + (b":path", url.full.encode()), + (b"user-agent", "hyperscale/client".encode()), + ] + + encoded_headers.extend( + [(k.encode(), v.encode()) for (k, v) in self.headers.items()] + ) + + return encoded_headers + + def encode_data(self): + encoded_data: Optional[bytes] = None + size = 0 + if self.data: + if isinstance(self.data, Iterator): + chunks = [] + for chunk in self.data: + chunk_size = hex(len(chunk)).replace("0x", "") + NEW_LINE + encoded_chunk = chunk_size.encode() + chunk + NEW_LINE.encode() + size += len(encoded_chunk) + chunks.append(encoded_chunk) + + self.is_stream = True + encoded_data = chunks + + else: + if isinstance(self.data, dict): + encoded_data = orjson.dumps(self.data) + + elif isinstance(self.data, BaseModel): + return self.data.model_dump_json().encode() + + elif isinstance(self.data, tuple): + encoded_data = urlencode(self.data).encode() + + elif isinstance(self.data, str): + encoded_data = self.data.encode() + + return encoded_data diff --git a/hyperscale/core_rewrite/engines/client/http3/models/http3/http3_response.py b/hyperscale/core_rewrite/engines/client/http3/models/http3/http3_response.py new file mode 100644 index 0000000..7db1065 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/models/http3/http3_response.py @@ -0,0 +1,99 @@ +import gzip +import re +from typing import Dict, Literal, Optional, Type, TypeVar, Union + +import orjson +from pydantic import BaseModel, StrictBytes, StrictFloat, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + Cookies, + URLMetadata, +) + +space_pattern = re.compile(r"\s+") + + +T = TypeVar('T', bound=BaseModel) + + +class HTTP3Response(BaseModel): + url: URLMetadata + method: Optional[ + Literal[ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "PATCH", + "DELETE" + ] + ]=None + cookies: Union[ + Optional[Cookies], + Optional[None] + ]=None + status: Optional[StrictInt]=None + status_message: Optional[StrictStr]=None + headers: Dict[StrictBytes, StrictBytes]={} + content: StrictBytes=b'' + timings: Dict[StrictStr, StrictFloat]={} + + class Config: + arbitrary_types_allowed=True + + def check_success(self) -> bool: + return ( + self.status and self.status >= 200 and self.status < 300 + ) + + def json(self): + + if self.content: + return orjson.loads( + self.content + ) + + return {} + + def text(self): + return self.content.decode() + + def to_model( + self, + model: Type[T] + ) -> T: + return model(**orjson.loads( + self.content + )) + + @property + def data( + self, + model: Optional[Type[T]]=None + ): + + content_type = self.headers.get('content-type') + + if model: + return self.to_model(model) + + try: + match content_type: + + case 'application/json': + return self.json() + + case 'text/plain': + return self.text() + + case 'application/gzip': + return gzip.decompress(self.content) + + case _: + return self.content + + except Exception: + return self.content + + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http3/models/http3/optimized_http3_request.py b/hyperscale/core_rewrite/engines/client/http3/models/http3/optimized_http3_request.py new file mode 100644 index 0000000..c6fe311 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/models/http3/optimized_http3_request.py @@ -0,0 +1,38 @@ +from typing import Literal, Optional, Tuple + +from pydantic import ( + BaseModel, + StrictBytes, + StrictInt, + StrictStr, +) + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedHTTP3Request(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + method: Literal[ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "PATCH", + "DELETE", + ] + encoded_params: Optional[StrictStr | StrictBytes] = None + encoded_auth: Optional[StrictStr | StrictBytes] = None + encoded_cookies: Optional[ + StrictStr + | StrictBytes + | Tuple[StrictStr, StrictStr] + | Tuple[StrictBytes, StrictBytes] + ] = None + encoded_headers: Optional[StrictBytes] = None + encoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/__init__.py b/hyperscale/core_rewrite/engines/client/http3/protocols/__init__.py new file mode 100644 index 0000000..7496f77 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/__init__.py @@ -0,0 +1 @@ +from .http3_connection import HTTP3Connection as HTTP3Connection \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/http3_connection.py b/hyperscale/core_rewrite/engines/client/http3/protocols/http3_connection.py new file mode 100644 index 0000000..318d239 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/http3_connection.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import asyncio +from typing import Optional, Tuple + +from .quic_protocol import QuicProtocol +from .udp_connection import UDPConnection + + +class HTTP3Connection: + + def __init__(self, reset_connections: bool=False) -> None: + self.dns_address: str = None + self.port: int = None + self.ip_addr = None + self.lock = asyncio.Lock() + + self.protocol: Optional[QuicProtocol] = None + self.connected = False + self.reset_connections = reset_connections + self.pending = 0 + self._connection_factory = UDPConnection() + + async def make_connection( + self, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + server_name: str=None, + timeout: Optional[float]=None + ) -> None: + + if self.connected is False or self.dns_address != dns_address or self.reset_connection: + try: + self.protocol = await asyncio.wait_for( + self._connection_factory.create_http3( + socket_config=socket_config, + server_name=server_name + ), + + timeout=timeout + ) + + self.connected = True + + self.dns_address = dns_address + self.port = port + + except asyncio.TimeoutError: + raise Exception('Connection timed out.') + + except ConnectionResetError: + raise Exception('Connection reset.') + + except Exception as e: + raise e + + async def close(self): + if self.protocol: + self.protocol.close() diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/__init__.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/__init__.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/__init__.py new file mode 100644 index 0000000..3fda69d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/__init__.py @@ -0,0 +1,3 @@ +from .client import connect # noqa +from .protocol import QuicConnectionProtocol # noqa +from .server import serve # noqa diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/client.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/client.py new file mode 100644 index 0000000..a8e4170 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/client.py @@ -0,0 +1,93 @@ +import asyncio +import socket +from contextlib import asynccontextmanager +from typing import AsyncGenerator, Callable, Optional, cast + +from ..quic.configuration import QuicConfiguration +from ..quic.connection import QuicConnection, QuicTokenHandler +from ..tls import SessionTicketHandler +from .protocol import QuicConnectionProtocol, QuicStreamHandler + +__all__ = ["connect"] + + +@asynccontextmanager +async def connect( + host: str, + port: int, + *, + configuration: Optional[QuicConfiguration] = None, + create_protocol: Optional[Callable] = QuicConnectionProtocol, + session_ticket_handler: Optional[SessionTicketHandler] = None, + stream_handler: Optional[QuicStreamHandler] = None, + token_handler: Optional[QuicTokenHandler] = None, + wait_connected: bool = True, + local_port: int = 0, +) -> AsyncGenerator[QuicConnectionProtocol, None]: + """ + Connect to a QUIC server at the given `host` and `port`. + + :meth:`connect()` returns an awaitable. Awaiting it yields a + :class:`~aioquic.asyncio.QuicConnectionProtocol` which can be used to + create streams. + + :func:`connect` also accepts the following optional arguments: + + * ``configuration`` is a :class:`~aioquic.quic.configuration.QuicConfiguration` + configuration object. + * ``create_protocol`` allows customizing the :class:`~asyncio.Protocol` that + manages the connection. It should be a callable or class accepting the same + arguments as :class:`~aioquic.asyncio.QuicConnectionProtocol` and returning + an instance of :class:`~aioquic.asyncio.QuicConnectionProtocol` or a subclass. + * ``session_ticket_handler`` is a callback which is invoked by the TLS + engine when a new session ticket is received. + * ``stream_handler`` is a callback which is invoked whenever a stream is + created. It must accept two arguments: a :class:`asyncio.StreamReader` + and a :class:`asyncio.StreamWriter`. + * ``local_port`` is the UDP port number that this client wants to bind. + """ + loop = asyncio.get_event_loop() + local_host = "::" + + # lookup remote address + infos = await loop.getaddrinfo(host, port, type=socket.SOCK_DGRAM) + addr = infos[0][4] + if len(addr) == 2: + addr = ("::ffff:" + addr[0], addr[1], 0, 0) + + # prepare QUIC connection + if configuration is None: + configuration = QuicConfiguration(is_client=True) + if configuration.server_name is None: + configuration.server_name = host + connection = QuicConnection( + configuration=configuration, + session_ticket_handler=session_ticket_handler, + token_handler=token_handler, + ) + + # explicitly enable IPv4/IPv6 dual stack + sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + completed = False + try: + sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + sock.bind((local_host, local_port, 0, 0)) + completed = True + finally: + if not completed: + sock.close() + # connect + transport, protocol = await loop.create_datagram_endpoint( + lambda: create_protocol(connection, stream_handler=stream_handler), + sock=sock, + ) + protocol = cast(QuicConnectionProtocol, protocol) + try: + protocol.connect(addr) + if wait_connected: + await protocol.wait_connected() + yield protocol + finally: + protocol.close() + await protocol.wait_closed() + transport.close() diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/protocol.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/protocol.py new file mode 100644 index 0000000..3cbc18f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/protocol.py @@ -0,0 +1,258 @@ +import asyncio +from typing import Any, Callable, Dict, Optional, Text, Tuple, Union, cast + +from ..quic import events +from ..quic.connection import NetworkAddress, QuicConnection + +QuicConnectionIdHandler = Callable[[bytes], None] +QuicStreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], None] + + +class QuicConnectionProtocol(asyncio.DatagramProtocol): + def __init__( + self, quic: QuicConnection, stream_handler: Optional[QuicStreamHandler] = None + ): + loop = asyncio.get_event_loop() + + self._closed = asyncio.Event() + self._connected = False + self._connected_waiter: Optional[asyncio.Future[None]] = None + self._loop = loop + self._ping_waiters: Dict[int, asyncio.Future[None]] = {} + self._quic = quic + self._stream_readers: Dict[int, asyncio.StreamReader] = {} + self._timer: Optional[asyncio.TimerHandle] = None + self._timer_at: Optional[float] = None + self._transmit_task: Optional[asyncio.Handle] = None + self._transport: Optional[asyncio.DatagramTransport] = None + + # callbacks + self._connection_id_issued_handler: QuicConnectionIdHandler = lambda c: None + self._connection_id_retired_handler: QuicConnectionIdHandler = lambda c: None + self._connection_terminated_handler: Callable[[], None] = lambda: None + if stream_handler is not None: + self._stream_handler = stream_handler + else: + self._stream_handler = lambda r, w: None + + def change_connection_id(self) -> None: + """ + Change the connection ID used to communicate with the peer. + + The previous connection ID will be retired. + """ + self._quic.change_connection_id() + self.transmit() + + def close(self) -> None: + """ + Close the connection. + """ + self._quic.close() + self.transmit() + + def connect(self, addr: NetworkAddress) -> None: + """ + Initiate the TLS handshake. + + This method can only be called for clients and a single time. + """ + self._quic.connect(addr, now=self._loop.time()) + self.transmit() + + async def create_stream( + self, is_unidirectional: bool = False + ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """ + Create a QUIC stream and return a pair of (reader, writer) objects. + + The returned reader and writer objects are instances of + :class:`asyncio.StreamReader` and :class:`asyncio.StreamWriter` classes. + """ + stream_id = self._quic.get_next_available_stream_id( + is_unidirectional=is_unidirectional + ) + return self._create_stream(stream_id) + + def request_key_update(self) -> None: + """ + Request an update of the encryption keys. + """ + self._quic.request_key_update() + self.transmit() + + async def ping(self) -> None: + """ + Ping the peer and wait for the response. + """ + waiter = self._loop.create_future() + uid = id(waiter) + self._ping_waiters[uid] = waiter + self._quic.send_ping(uid) + self.transmit() + await asyncio.shield(waiter) + + def transmit(self) -> None: + """ + Send pending datagrams to the peer and arm the timer if needed. + + This method is called automatically when data is received from the peer + or when a timer goes off. If you interact directly with the underlying + :class:`~aioquic.quic.connection.QuicConnection`, make sure you call this + method whenever data needs to be sent out to the network. + """ + self._transmit_task = None + + # send datagrams + for data, addr in self._quic.datagrams_to_send(now=self._loop.time()): + self._transport.sendto(data, addr) + + # re-arm timer + timer_at = self._quic.get_timer() + if self._timer is not None and self._timer_at != timer_at: + self._timer.cancel() + self._timer = None + if self._timer is None and timer_at is not None: + self._timer = self._loop.call_at(timer_at, self._handle_timer) + self._timer_at = timer_at + + async def wait_closed(self) -> None: + """ + Wait for the connection to be closed. + """ + await self._closed.wait() + + async def wait_connected(self) -> None: + """ + Wait for the TLS handshake to complete. + """ + assert self._connected_waiter is None, "already awaiting connected" + if not self._connected: + self._connected_waiter = self._loop.create_future() + await asyncio.shield(self._connected_waiter) + + # asyncio.Transport + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """:meta private:""" + self._transport = cast(asyncio.DatagramTransport, transport) + + def datagram_received(self, data: Union[bytes, Text], addr: NetworkAddress) -> None: + """:meta private:""" + self._quic.receive_datagram(cast(bytes, data), addr, now=self._loop.time()) + self._process_events() + self.transmit() + + # overridable + + def quic_event_received(self, event: events.QuicEvent) -> None: + """ + Called when a QUIC event is received. + + Reimplement this in your subclass to handle the events. + """ + # FIXME: move this to a subclass + if isinstance(event, events.ConnectionTerminated): + for reader in self._stream_readers.values(): + reader.feed_eof() + elif isinstance(event, events.StreamDataReceived): + reader = self._stream_readers.get(event.stream_id, None) + if reader is None: + reader, writer = self._create_stream(event.stream_id) + self._stream_handler(reader, writer) + reader.feed_data(event.data) + if event.end_stream: + reader.feed_eof() + + # private + + def _create_stream( + self, stream_id: int + ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: + adapter = QuicStreamAdapter(self, stream_id) + reader = asyncio.StreamReader() + protocol = asyncio.streams.StreamReaderProtocol(reader) + writer = asyncio.StreamWriter(adapter, protocol, reader, self._loop) + self._stream_readers[stream_id] = reader + return reader, writer + + def _handle_timer(self) -> None: + now = max(self._timer_at, self._loop.time()) + self._timer = None + self._timer_at = None + self._quic.handle_timer(now=now) + self._process_events() + self.transmit() + + def _process_events(self) -> None: + event = self._quic.next_event() + while event is not None: + if isinstance(event, events.ConnectionIdIssued): + self._connection_id_issued_handler(event.connection_id) + elif isinstance(event, events.ConnectionIdRetired): + self._connection_id_retired_handler(event.connection_id) + elif isinstance(event, events.ConnectionTerminated): + self._connection_terminated_handler() + + # abort connection waiter + if self._connected_waiter is not None: + waiter = self._connected_waiter + self._connected_waiter = None + waiter.set_exception(ConnectionError) + + # abort ping waiters + for waiter in self._ping_waiters.values(): + waiter.set_exception(ConnectionError) + self._ping_waiters.clear() + + self._closed.set() + elif isinstance(event, events.HandshakeCompleted): + if self._connected_waiter is not None: + waiter = self._connected_waiter + self._connected = True + self._connected_waiter = None + waiter.set_result(None) + elif isinstance(event, events.PingAcknowledged): + waiter = self._ping_waiters.pop(event.uid, None) + if waiter is not None: + waiter.set_result(None) + self.quic_event_received(event) + event = self._quic.next_event() + + def _transmit_soon(self) -> None: + if self._transmit_task is None: + self._transmit_task = self._loop.call_soon(self.transmit) + + +class QuicStreamAdapter(asyncio.Transport): + def __init__(self, protocol: QuicConnectionProtocol, stream_id: int): + self.protocol = protocol + self.stream_id = stream_id + self._closing = False + + def can_write_eof(self) -> bool: + return True + + def get_extra_info(self, name: str, default: Any = None) -> Any: + """ + Get information about the underlying QUIC stream. + """ + if name == "stream_id": + return self.stream_id + + def write(self, data): + self.protocol._quic.send_stream_data(self.stream_id, data) + self.protocol._transmit_soon() + + def write_eof(self): + if self._closing: + return + self._closing = True + self.protocol._quic.send_stream_data(self.stream_id, b"", end_stream=True) + self.protocol._transmit_soon() + + def close(self): + self.write_eof() + + def is_closing(self) -> bool: + return self._closing diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/server.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/server.py new file mode 100644 index 0000000..b5f6cd8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/asyncio/server.py @@ -0,0 +1,215 @@ +import asyncio +import os +from functools import partial +from typing import Callable, Dict, Optional, Text, Union, cast + +from ..buffer import Buffer +from ..quic.configuration import SMALLEST_MAX_DATAGRAM_SIZE, QuicConfiguration +from ..quic.connection import NetworkAddress, QuicConnection +from ..quic.packet import ( + PACKET_TYPE_INITIAL, + encode_quic_retry, + encode_quic_version_negotiation, + pull_quic_header, +) +from ..quic.retry import QuicRetryTokenHandler +from ..tls import SessionTicketFetcher, SessionTicketHandler +from .protocol import QuicConnectionProtocol, QuicStreamHandler + +__all__ = ["serve"] + + +class QuicServer(asyncio.DatagramProtocol): + def __init__( + self, + *, + configuration: QuicConfiguration, + create_protocol: Callable = QuicConnectionProtocol, + session_ticket_fetcher: Optional[SessionTicketFetcher] = None, + session_ticket_handler: Optional[SessionTicketHandler] = None, + retry: bool = False, + stream_handler: Optional[QuicStreamHandler] = None, + ) -> None: + self._configuration = configuration + self._create_protocol = create_protocol + self._loop = asyncio.get_event_loop() + self._protocols: Dict[bytes, QuicConnectionProtocol] = {} + self._session_ticket_fetcher = session_ticket_fetcher + self._session_ticket_handler = session_ticket_handler + self._transport: Optional[asyncio.DatagramTransport] = None + + self._stream_handler = stream_handler + + if retry: + self._retry = QuicRetryTokenHandler() + else: + self._retry = None + + def close(self): + for protocol in set(self._protocols.values()): + protocol.close() + self._protocols.clear() + self._transport.close() + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self._transport = cast(asyncio.DatagramTransport, transport) + + def datagram_received(self, data: Union[bytes, Text], addr: NetworkAddress) -> None: + data = cast(bytes, data) + buf = Buffer(data=data) + + try: + header = pull_quic_header( + buf, host_cid_length=self._configuration.connection_id_length + ) + except ValueError: + return + + # version negotiation + if ( + header.version is not None + and header.version not in self._configuration.supported_versions + ): + self._transport.sendto( + encode_quic_version_negotiation( + source_cid=header.destination_cid, + destination_cid=header.source_cid, + supported_versions=self._configuration.supported_versions, + ), + addr, + ) + return + + protocol = self._protocols.get(header.destination_cid, None) + original_destination_connection_id: Optional[bytes] = None + retry_source_connection_id: Optional[bytes] = None + if ( + protocol is None + and len(data) >= SMALLEST_MAX_DATAGRAM_SIZE + and header.packet_type == PACKET_TYPE_INITIAL + ): + # retry + if self._retry is not None: + if not header.token: + # create a retry token + source_cid = os.urandom(8) + self._transport.sendto( + encode_quic_retry( + version=header.version, + source_cid=source_cid, + destination_cid=header.source_cid, + original_destination_cid=header.destination_cid, + retry_token=self._retry.create_token( + addr, header.destination_cid, source_cid + ), + ), + addr, + ) + return + else: + # validate retry token + try: + ( + original_destination_connection_id, + retry_source_connection_id, + ) = self._retry.validate_token(addr, header.token) + except ValueError: + return + else: + original_destination_connection_id = header.destination_cid + + # create new connection + connection = QuicConnection( + configuration=self._configuration, + original_destination_connection_id=original_destination_connection_id, + retry_source_connection_id=retry_source_connection_id, + session_ticket_fetcher=self._session_ticket_fetcher, + session_ticket_handler=self._session_ticket_handler, + ) + protocol = self._create_protocol( + connection, stream_handler=self._stream_handler + ) + protocol.connection_made(self._transport) + + # register callbacks + protocol._connection_id_issued_handler = partial( + self._connection_id_issued, protocol=protocol + ) + protocol._connection_id_retired_handler = partial( + self._connection_id_retired, protocol=protocol + ) + protocol._connection_terminated_handler = partial( + self._connection_terminated, protocol=protocol + ) + + self._protocols[header.destination_cid] = protocol + self._protocols[connection.host_cid] = protocol + + if protocol is not None: + protocol.datagram_received(data, addr) + + def _connection_id_issued(self, cid: bytes, protocol: QuicConnectionProtocol): + self._protocols[cid] = protocol + + def _connection_id_retired( + self, cid: bytes, protocol: QuicConnectionProtocol + ) -> None: + assert self._protocols[cid] == protocol + del self._protocols[cid] + + def _connection_terminated(self, protocol: QuicConnectionProtocol): + for cid, proto in list(self._protocols.items()): + if proto == protocol: + del self._protocols[cid] + + +async def serve( + host: str, + port: int, + *, + configuration: QuicConfiguration, + create_protocol: Callable = QuicConnectionProtocol, + session_ticket_fetcher: Optional[SessionTicketFetcher] = None, + session_ticket_handler: Optional[SessionTicketHandler] = None, + retry: bool = False, + stream_handler: QuicStreamHandler = None, +) -> QuicServer: + """ + Start a QUIC server at the given `host` and `port`. + + :func:`serve` requires a :class:`~aioquic.quic.configuration.QuicConfiguration` + containing TLS certificate and private key as the ``configuration`` argument. + + :func:`serve` also accepts the following optional arguments: + + * ``create_protocol`` allows customizing the :class:`~asyncio.Protocol` that + manages the connection. It should be a callable or class accepting the same + arguments as :class:`~aioquic.asyncio.QuicConnectionProtocol` and returning + an instance of :class:`~aioquic.asyncio.QuicConnectionProtocol` or a subclass. + * ``session_ticket_fetcher`` is a callback which is invoked by the TLS + engine when a session ticket is presented by the peer. It should return + the session ticket with the specified ID or `None` if it is not found. + * ``session_ticket_handler`` is a callback which is invoked by the TLS + engine when a new session ticket is issued. It should store the session + ticket for future lookup. + * ``retry`` specifies whether client addresses should be validated prior to + the cryptographic handshake using a retry packet. + * ``stream_handler`` is a callback which is invoked whenever a stream is + created. It must accept two arguments: a :class:`asyncio.StreamReader` + and a :class:`asyncio.StreamWriter`. + """ + + loop = asyncio.get_event_loop() + + _, protocol = await loop.create_datagram_endpoint( + lambda: QuicServer( + configuration=configuration, + create_protocol=create_protocol, + session_ticket_fetcher=session_ticket_fetcher, + session_ticket_handler=session_ticket_handler, + retry=retry, + stream_handler=stream_handler, + ), + local_addr=(host, port), + ) + return protocol diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/buffer.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/buffer.py new file mode 100644 index 0000000..226e9ba --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/buffer.py @@ -0,0 +1,30 @@ +from aioquic._buffer import Buffer, BufferReadError, BufferWriteError # noqa + +UINT_VAR_MAX = 0x3FFFFFFFFFFFFFFF +UINT_VAR_MAX_SIZE = 8 + + +def encode_uint_var(value: int) -> bytes: + """ + Encode a variable-length unsigned integer. + """ + buf = Buffer(capacity=UINT_VAR_MAX_SIZE) + buf.push_uint_var(value) + return buf.data + + +def size_uint_var(value: int) -> int: + """ + Return the number of bytes required to encode the given value + as a QUIC variable-length unsigned integer. + """ + if value <= 0x3F: + return 1 + elif value <= 0x3FFF: + return 2 + elif value <= 0x3FFFFFFF: + return 4 + elif value <= 0x3FFFFFFFFFFFFFFF: + return 8 + else: + raise ValueError("Integer is too big for a variable-length integer") diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h0/__init__.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h0/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h0/connection.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h0/connection.py new file mode 100644 index 0000000..077fd4d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h0/connection.py @@ -0,0 +1,78 @@ +from typing import Dict, List + +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.h3.events import ( + DataReceived, + H3Event, + Headers, + HeadersReceived, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.quic.connection import ( + QuicConnection, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.quic.events import ( + QuicEvent, + StreamDataReceived, +) + +H0_ALPN = ["hq-interop", "hq-32", "hq-31", "hq-30", "hq-29"] + + +class H0Connection: + """ + An HTTP/0.9 connection object. + """ + + def __init__(self, quic: QuicConnection): + self._buffer: Dict[int, bytes] = {} + self._headers_received: Dict[int, bool] = {} + self._is_client = quic.configuration.is_client + self._quic = quic + + def handle_event(self, event: QuicEvent) -> List[H3Event]: + http_events: List[H3Event] = [] + + if isinstance(event, StreamDataReceived) and (event.stream_id % 4) == 0: + data = self._buffer.pop(event.stream_id, b"") + event.data + if not self._headers_received.get(event.stream_id, False): + if self._is_client: + http_events.append( + HeadersReceived( + headers=[], stream_ended=False, stream_id=event.stream_id + ) + ) + elif data.endswith(b"\r\n") or event.end_stream: + method, path = data.rstrip().split(b" ", 1) + http_events.append( + HeadersReceived( + headers=[(b":method", method), (b":path", path)], + stream_ended=False, + stream_id=event.stream_id, + ) + ) + data = b"" + else: + # incomplete request, stash the data + self._buffer[event.stream_id] = data + return http_events + self._headers_received[event.stream_id] = True + + http_events.append( + DataReceived( + data=data, stream_ended=event.end_stream, stream_id=event.stream_id + ) + ) + + return http_events + + def send_data(self, stream_id: int, data: bytes, end_stream: bool) -> None: + self._quic.send_stream_data(stream_id, data, end_stream) + + def send_headers( + self, stream_id: int, headers: Headers, end_stream: bool = False + ) -> None: + if self._is_client: + headers_dict = dict(headers) + data = headers_dict[b":method"] + b" " + headers_dict[b":path"] + b"\r\n" + else: + data = b"" + self._quic.send_stream_data(stream_id, data, end_stream) diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/__init__.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/connection.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/connection.py new file mode 100644 index 0000000..8192a20 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/connection.py @@ -0,0 +1,1235 @@ +import logging +import re +from enum import Enum, IntEnum +from typing import Dict, FrozenSet, List, Optional, Set + +import pylsqpack + +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.buffer import ( + UINT_VAR_MAX_SIZE, + Buffer, + BufferReadError, + encode_uint_var, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.h3.events import ( + DatagramReceived, + DataReceived, + H3Event, + Headers, + HeadersReceived, + PushPromiseReceived, + WebTransportStreamDataReceived, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.h3.exceptions import ( + InvalidStreamTypeError, + NoAvailablePushIDError, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.quic.connection import ( + QuicConnection, + stream_is_unidirectional, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.quic.events import ( + DatagramFrameReceived, + QuicEvent, + StreamDataReceived, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic.quic.logger import ( + QuicLoggerTrace, +) + +logger = logging.getLogger("http3") + +H3_ALPN = ["h3", "h3-32", "h3-31", "h3-30", "h3-29"] +RESERVED_SETTINGS = (0x0, 0x2, 0x3, 0x4, 0x5) +UPPERCASE = re.compile(b"[A-Z]") +COLON = 0x3A +NUL = 0x00 +LF = 0x0A +CR = 0x0D +SP = 0x20 +HTAB = 0x09 +WHITESPACE = (SP, HTAB) + + +class ErrorCode(IntEnum): + H3_DATAGRAM_ERROR = 0x33 + H3_NO_ERROR = 0x100 + H3_GENERAL_PROTOCOL_ERROR = 0x101 + H3_INTERNAL_ERROR = 0x102 + H3_STREAM_CREATION_ERROR = 0x103 + H3_CLOSED_CRITICAL_STREAM = 0x104 + H3_FRAME_UNEXPECTED = 0x105 + H3_FRAME_ERROR = 0x106 + H3_EXCESSIVE_LOAD = 0x107 + H3_ID_ERROR = 0x108 + H3_SETTINGS_ERROR = 0x109 + H3_MISSING_SETTINGS = 0x10A + H3_REQUEST_REJECTED = 0x10B + H3_REQUEST_CANCELLED = 0x10C + H3_REQUEST_INCOMPLETE = 0x10D + H3_MESSAGE_ERROR = 0x10E + H3_CONNECT_ERROR = 0x10F + H3_VERSION_FALLBACK = 0x110 + QPACK_DECOMPRESSION_FAILED = 0x200 + QPACK_ENCODER_STREAM_ERROR = 0x201 + QPACK_DECODER_STREAM_ERROR = 0x202 + + +class FrameType(IntEnum): + DATA = 0x0 + HEADERS = 0x1 + PRIORITY = 0x2 + CANCEL_PUSH = 0x3 + SETTINGS = 0x4 + PUSH_PROMISE = 0x5 + GOAWAY = 0x7 + MAX_PUSH_ID = 0xD + DUPLICATE_PUSH = 0xE + WEBTRANSPORT_STREAM = 0x41 + + +class HeadersState(Enum): + INITIAL = 0 + AFTER_HEADERS = 1 + AFTER_TRAILERS = 2 + + +class Setting(IntEnum): + QPACK_MAX_TABLE_CAPACITY = 0x1 + MAX_FIELD_SECTION_SIZE = 0x6 + QPACK_BLOCKED_STREAMS = 0x7 + + # https://datatracker.ietf.org/doc/html/rfc9220#section-5 + ENABLE_CONNECT_PROTOCOL = 0x8 + # https://datatracker.ietf.org/doc/html/rfc9297#section-5.1 + H3_DATAGRAM = 0x33 + # https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http2-02#section-10.1 + ENABLE_WEBTRANSPORT = 0x2B603742 + + # Dummy setting to check it is correctly ignored by the peer. + # https://datatracker.ietf.org/doc/html/rfc9114#section-7.2.4.1 + DUMMY = 0x21 + + +class StreamType(IntEnum): + CONTROL = 0 + PUSH = 1 + QPACK_ENCODER = 2 + QPACK_DECODER = 3 + WEBTRANSPORT = 0x54 + + +class ProtocolError(Exception): + """ + Base class for protocol errors. + + These errors are not exposed to the API user, they are handled + in :meth:`H3Connection.handle_event`. + """ + + error_code = ErrorCode.H3_GENERAL_PROTOCOL_ERROR + + def __init__(self, reason_phrase: str = ""): + self.reason_phrase = reason_phrase + + +class QpackDecompressionFailed(ProtocolError): + error_code = ErrorCode.QPACK_DECOMPRESSION_FAILED + + +class QpackDecoderStreamError(ProtocolError): + error_code = ErrorCode.QPACK_DECODER_STREAM_ERROR + + +class QpackEncoderStreamError(ProtocolError): + error_code = ErrorCode.QPACK_ENCODER_STREAM_ERROR + + +class ClosedCriticalStream(ProtocolError): + error_code = ErrorCode.H3_CLOSED_CRITICAL_STREAM + + +class DatagramError(ProtocolError): + error_code = ErrorCode.H3_DATAGRAM_ERROR + + +class FrameUnexpected(ProtocolError): + error_code = ErrorCode.H3_FRAME_UNEXPECTED + + +class MessageError(ProtocolError): + error_code = ErrorCode.H3_MESSAGE_ERROR + + +class MissingSettingsError(ProtocolError): + error_code = ErrorCode.H3_MISSING_SETTINGS + + +class SettingsError(ProtocolError): + error_code = ErrorCode.H3_SETTINGS_ERROR + + +class StreamCreationError(ProtocolError): + error_code = ErrorCode.H3_STREAM_CREATION_ERROR + + +def encode_frame(frame_type: int, frame_data: bytes) -> bytes: + frame_length = len(frame_data) + buf = Buffer(capacity=frame_length + 2 * UINT_VAR_MAX_SIZE) + buf.push_uint_var(frame_type) + buf.push_uint_var(frame_length) + buf.push_bytes(frame_data) + return buf.data + + +def encode_settings(settings: Dict[int, int]) -> bytes: + buf = Buffer(capacity=1024) + for setting, value in settings.items(): + buf.push_uint_var(setting) + buf.push_uint_var(value) + return buf.data + + +def parse_max_push_id(data: bytes) -> int: + buf = Buffer(data=data) + max_push_id = buf.pull_uint_var() + assert buf.eof() + return max_push_id + + +def parse_settings(data: bytes) -> Dict[int, int]: + buf = Buffer(data=data) + settings: Dict[int, int] = {} + while not buf.eof(): + setting = buf.pull_uint_var() + value = buf.pull_uint_var() + if setting in RESERVED_SETTINGS: + raise SettingsError("Setting identifier 0x%x is reserved" % setting) + if setting in settings: + raise SettingsError("Setting identifier 0x%x is included twice" % setting) + settings[setting] = value + return dict(settings) + + +def stream_is_request_response(stream_id: int): + """ + Returns True if the stream is a client-initiated bidirectional stream. + """ + return stream_id % 4 == 0 + + +def validate_header_name(key: bytes) -> None: + """ + Validate a header name as specified by RFC 9113 section 8.2.1. + """ + for i, c in enumerate(key): + if c <= 0x20 or (c >= 0x41 and c <= 0x5A) or c >= 0x7F: + raise MessageError("Header %r contains invalid characters" % key) + if c == COLON and i != 0: + # Colon not at start, definitely bad. Keys starting with a colon + # will be checked in pseudo-header validation code. + raise MessageError("Header %r contains a non-initial colon" % key) + + +def validate_header_value(key: bytes, value: bytes): + """ + Validate a header value as specified by RFC 9113 section 8.2.1. + """ + for c in value: + if c == NUL or c == LF or c == CR: + raise MessageError("Header %r value has forbidden characters" % key) + if len(value) > 0: + first = value[0] + if first in WHITESPACE: + raise MessageError("Header %r value starts with whitespace" % key) + if len(value) > 1: + last = value[-1] + if last in WHITESPACE: + raise MessageError("Header %r value ends with whitespace" % key) + + +def validate_headers( + headers: Headers, + allowed_pseudo_headers: FrozenSet[bytes], + required_pseudo_headers: FrozenSet[bytes], + stream: Optional["H3Stream"] = None, +) -> None: + after_pseudo_headers = False + authority: Optional[bytes] = None + path: Optional[bytes] = None + scheme: Optional[bytes] = None + seen_pseudo_headers: Set[bytes] = set() + for key, value in headers: + validate_header_name(key) + validate_header_value(key, value) + + if key.startswith(b":"): + # pseudo-headers + if after_pseudo_headers: + raise MessageError( + "Pseudo-header %r is not allowed after regular headers" % key + ) + if key not in allowed_pseudo_headers: + raise MessageError("Pseudo-header %r is not valid" % key) + if key in seen_pseudo_headers: + raise MessageError("Pseudo-header %r is included twice" % key) + seen_pseudo_headers.add(key) + + # store value + if key == b":authority": + authority = value + elif key == b":path": + path = value + elif key == b":scheme": + scheme = value + else: + # regular headers + after_pseudo_headers = True + # a few more semantic checks + if key == b"content-length": + try: + content_length = int(value) + if content_length < 0: + raise ValueError + except ValueError: + raise MessageError("content-length is not a non-negative integer") + if stream: + stream.expected_content_length = content_length + elif key == b"transfer-encoding" and value != b"trailers": + raise MessageError( + "The only valid value for transfer-encoding is trailers" + ) + + # check required pseudo-headers are present + missing = required_pseudo_headers.difference(seen_pseudo_headers) + if missing: + raise MessageError("Pseudo-headers %s are missing" % sorted(missing)) + + if scheme in (b"http", b"https"): + if not authority: + raise MessageError("Pseudo-header b':authority' cannot be empty") + if not path: + raise MessageError("Pseudo-header b':path' cannot be empty") + + +def validate_push_promise_headers(headers: Headers) -> None: + validate_headers( + headers, + allowed_pseudo_headers=frozenset( + (b":method", b":scheme", b":authority", b":path") + ), + required_pseudo_headers=frozenset( + (b":method", b":scheme", b":authority", b":path") + ), + ) + + +def validate_request_headers( + headers: Headers, stream: Optional["H3Stream"] = None +) -> None: + validate_headers( + headers, + allowed_pseudo_headers=frozenset( + # FIXME: The pseudo-header :protocol is not actually defined, but + # we use it for the WebSocket demo. + (b":method", b":scheme", b":authority", b":path", b":protocol") + ), + required_pseudo_headers=frozenset((b":method", b":authority")), + stream=stream, + ) + + +def validate_response_headers( + headers: Headers, stream: Optional["H3Stream"] = None +) -> None: + validate_headers( + headers, + allowed_pseudo_headers=frozenset((b":status",)), + required_pseudo_headers=frozenset((b":status",)), + stream=stream, + ) + + +def validate_trailers(headers: Headers) -> None: + validate_headers( + headers, + allowed_pseudo_headers=frozenset(), + required_pseudo_headers=frozenset(), + ) + + +class H3Stream: + def __init__(self, stream_id: int) -> None: + self.blocked = False + self.blocked_frame_size: Optional[int] = None + self.buffer = b"" + self.ended = False + self.frame_size: Optional[int] = None + self.frame_type: Optional[int] = None + self.headers_recv_state: HeadersState = HeadersState.INITIAL + self.headers_send_state: HeadersState = HeadersState.INITIAL + self.push_id: Optional[int] = None + self.session_id: Optional[int] = None + self.stream_id = stream_id + self.stream_type: Optional[int] = None + self.expected_content_length: Optional[int] = None + self.content_length: int = 0 + + +class H3Connection: + """ + A low-level HTTP/3 connection object. + + :param quic: A :class:`~aioquic.quic.connection.QuicConnection` instance. + """ + + def __init__(self, quic: QuicConnection, enable_webtransport: bool = False) -> None: + # settings + self._max_table_capacity = 4096 + self._blocked_streams = 16 + self._enable_webtransport = enable_webtransport + + self._is_client = quic.configuration.is_client + self._is_done = False + self._quic = quic + self._quic_logger: Optional[QuicLoggerTrace] = quic._quic_logger + self._decoder = pylsqpack.Decoder( + self._max_table_capacity, self._blocked_streams + ) + self._decoder_bytes_received = 0 + self._decoder_bytes_sent = 0 + self._encoder = pylsqpack.Encoder() + self._encoder_bytes_received = 0 + self._encoder_bytes_sent = 0 + self._settings_received = False + self._stream: Dict[int, H3Stream] = {} + + self._max_push_id: Optional[int] = 8 if self._is_client else None + self._next_push_id: int = 0 + + self._local_control_stream_id: Optional[int] = None + self._local_decoder_stream_id: Optional[int] = None + self._local_encoder_stream_id: Optional[int] = None + + self._peer_control_stream_id: Optional[int] = None + self._peer_decoder_stream_id: Optional[int] = None + self._peer_encoder_stream_id: Optional[int] = None + self._received_settings: Optional[Dict[int, int]] = None + self._sent_settings: Optional[Dict[int, int]] = None + + self._init_connection() + + def create_webtransport_stream( + self, session_id: int, is_unidirectional: bool = False + ) -> int: + """ + Create a WebTransport stream and return the stream ID. + + .. aioquic_transmit:: + + :param session_id: The WebTransport session identifier. + :param is_unidirectional: Whether to create a unidirectional stream. + """ + if is_unidirectional: + stream_id = self._create_uni_stream(StreamType.WEBTRANSPORT) + self._quic.send_stream_data(stream_id, encode_uint_var(session_id)) + else: + stream_id = self._quic.get_next_available_stream_id() + self._log_stream_type( + stream_id=stream_id, stream_type=StreamType.WEBTRANSPORT + ) + self._quic.send_stream_data( + stream_id, + encode_uint_var(FrameType.WEBTRANSPORT_STREAM) + + encode_uint_var(session_id), + ) + return stream_id + + def handle_event(self, event: QuicEvent) -> List[H3Event]: + """ + Handle a QUIC event and return a list of HTTP events. + + :param event: The QUIC event to handle. + """ + + if not self._is_done: + try: + if isinstance(event, StreamDataReceived): + stream_id = event.stream_id + stream = self._get_or_create_stream(stream_id) + if stream_is_unidirectional(stream_id): + return self._receive_stream_data_uni( + stream, event.data, event.end_stream + ) + else: + return self._receive_request_or_push_data( + stream, event.data, event.end_stream + ) + elif isinstance(event, DatagramFrameReceived): + return self._receive_datagram(event.data) + except ProtocolError as exc: + self._is_done = True + self._quic.close( + error_code=exc.error_code, reason_phrase=exc.reason_phrase + ) + + return [] + + def send_datagram(self, stream_id: int, data: bytes) -> None: + """ + Send a datagram for the specified stream. + + If the stream ID is not a client-initiated bidirectional stream, an + :class:`~aioquic.h3.exceptions.InvalidStreamTypeError` exception is raised. + + .. aioquic_transmit:: + + :param stream_id: The stream ID. + :param data: The HTTP/3 datagram payload. + """ + + # check stream ID is valid + if not stream_is_request_response(stream_id): + raise InvalidStreamTypeError( + "Datagrams can only be sent for client-initiated bidirectional streams" + ) + + self._quic.send_datagram_frame(encode_uint_var(stream_id // 4) + data) + + def send_push_promise(self, stream_id: int, headers: Headers) -> int: + """ + Send a push promise related to the specified stream. + + Returns the stream ID on which headers and data can be sent. + + If the stream ID is not a client-initiated bidirectional stream, an + :class:`~aioquic.h3.exceptions.InvalidStreamTypeError` exception is raised. + + If there are not available push IDs, an + :class:`~aioquic.h3.exceptions.NoAvailablePushIDError` exception is raised. + + .. aioquic_transmit:: + + :param stream_id: The stream ID on which to send the data. + :param headers: The HTTP request headers for this push. + """ + assert not self._is_client, "Only servers may send a push promise." + + # check stream ID is valid + if not stream_is_request_response(stream_id): + raise InvalidStreamTypeError( + "Push promises can only be sent for client-initiated bidirectional " + "streams" + ) + + # check a push ID is available + if self._max_push_id is None or self._next_push_id >= self._max_push_id: + raise NoAvailablePushIDError + + # send push promise + push_id = self._next_push_id + self._next_push_id += 1 + self._quic.send_stream_data( + stream_id, + encode_frame( + FrameType.PUSH_PROMISE, + encode_uint_var(push_id) + self._encode_headers(stream_id, headers), + ), + ) + + #  create push stream + push_stream_id = self._create_uni_stream(StreamType.PUSH, push_id=push_id) + self._quic.send_stream_data(push_stream_id, encode_uint_var(push_id)) + + return push_stream_id + + def send_data(self, stream_id: int, data: bytes, end_stream: bool) -> None: + """ + Send data on the given stream. + + .. aioquic_transmit:: + + :param stream_id: The stream ID on which to send the data. + :param data: The data to send. + :param end_stream: Whether to end the stream. + """ + # check DATA frame is allowed + stream = self._get_or_create_stream(stream_id) + if stream.headers_send_state != HeadersState.AFTER_HEADERS: + raise FrameUnexpected("DATA frame is not allowed in this state") + + # log frame + if self._quic_logger is not None: + self._quic_logger.log_event( + category="http", + event="frame_created", + data=self._quic_logger.encode_http3_data_frame( + length=len(data), stream_id=stream_id + ), + ) + + self._quic.send_stream_data( + stream_id, encode_frame(FrameType.DATA, data), end_stream + ) + + def send_headers( + self, stream_id: int, headers: Headers, end_stream: bool = False + ) -> None: + """ + Send headers on the given stream. + + .. aioquic_transmit:: + + :param stream_id: The stream ID on which to send the headers. + :param headers: The HTTP headers to send. + :param end_stream: Whether to end the stream. + """ + # check HEADERS frame is allowed + stream = self._get_or_create_stream(stream_id) + if stream.headers_send_state == HeadersState.AFTER_TRAILERS: + raise FrameUnexpected("HEADERS frame is not allowed in this state") + + frame_data = self._encode_headers(stream_id, headers) + + # log frame + if self._quic_logger is not None: + self._quic_logger.log_event( + category="http", + event="frame_created", + data=self._quic_logger.encode_http3_headers_frame( + length=len(frame_data), headers=headers, stream_id=stream_id + ), + ) + + # update state and send headers + if stream.headers_send_state == HeadersState.INITIAL: + stream.headers_send_state = HeadersState.AFTER_HEADERS + else: + stream.headers_send_state = HeadersState.AFTER_TRAILERS + self._quic.send_stream_data( + stream_id, encode_frame(FrameType.HEADERS, frame_data), end_stream + ) + + @property + def received_settings(self) -> Optional[Dict[int, int]]: + """ + Return the received SETTINGS frame, or None. + """ + return self._received_settings + + @property + def sent_settings(self) -> Optional[Dict[int, int]]: + """ + Return the sent SETTINGS frame, or None. + """ + return self._sent_settings + + def _create_uni_stream( + self, stream_type: int, push_id: Optional[int] = None + ) -> int: + """ + Create an unidirectional stream of the given type. + """ + stream_id = self._quic.get_next_available_stream_id(is_unidirectional=True) + self._log_stream_type( + push_id=push_id, stream_id=stream_id, stream_type=stream_type + ) + self._quic.send_stream_data(stream_id, encode_uint_var(stream_type)) + return stream_id + + def _decode_headers(self, stream_id: int, frame_data: Optional[bytes]) -> Headers: + """ + Decode a HEADERS block and send decoder updates on the decoder stream. + + This is called with frame_data=None when a stream becomes unblocked. + """ + try: + if frame_data is None: + decoder, headers = self._decoder.resume_header(stream_id) + else: + decoder, headers = self._decoder.feed_header(stream_id, frame_data) + self._decoder_bytes_sent += len(decoder) + self._quic.send_stream_data(self._local_decoder_stream_id, decoder) + except pylsqpack.DecompressionFailed as exc: + raise QpackDecompressionFailed() from exc + + return headers + + def _encode_headers(self, stream_id: int, headers: Headers) -> bytes: + """ + Encode a HEADERS block and send encoder updates on the encoder stream. + """ + encoder, frame_data = self._encoder.encode(stream_id, headers) + self._encoder_bytes_sent += len(encoder) + self._quic.send_stream_data(self._local_encoder_stream_id, encoder) + return frame_data + + def _get_or_create_stream(self, stream_id: int) -> H3Stream: + if stream_id not in self._stream: + self._stream[stream_id] = H3Stream(stream_id) + return self._stream[stream_id] + + def _get_local_settings(self) -> Dict[int, int]: + """ + Return the local HTTP/3 settings. + """ + settings: Dict[int, int] = { + Setting.QPACK_MAX_TABLE_CAPACITY: self._max_table_capacity, + Setting.QPACK_BLOCKED_STREAMS: self._blocked_streams, + Setting.ENABLE_CONNECT_PROTOCOL: 1, + Setting.DUMMY: 1, + } + if self._enable_webtransport: + settings[Setting.H3_DATAGRAM] = 1 + settings[Setting.ENABLE_WEBTRANSPORT] = 1 + return settings + + def _handle_control_frame(self, frame_type: int, frame_data: bytes) -> None: + """ + Handle a frame received on the peer's control stream. + """ + if frame_type != FrameType.SETTINGS and not self._settings_received: + raise MissingSettingsError + + if frame_type == FrameType.SETTINGS: + if self._settings_received: + raise FrameUnexpected("SETTINGS have already been received") + settings = parse_settings(frame_data) + self._validate_settings(settings) + self._received_settings = settings + encoder = self._encoder.apply_settings( + max_table_capacity=settings.get(Setting.QPACK_MAX_TABLE_CAPACITY, 0), + blocked_streams=settings.get(Setting.QPACK_BLOCKED_STREAMS, 0), + ) + self._quic.send_stream_data(self._local_encoder_stream_id, encoder) + self._settings_received = True + elif frame_type == FrameType.MAX_PUSH_ID: + if self._is_client: + raise FrameUnexpected("Servers must not send MAX_PUSH_ID") + self._max_push_id = parse_max_push_id(frame_data) + elif frame_type in ( + FrameType.DATA, + FrameType.HEADERS, + FrameType.PUSH_PROMISE, + FrameType.DUPLICATE_PUSH, + ): + raise FrameUnexpected("Invalid frame type on control stream") + + def _check_content_length(self, stream: H3Stream): + if ( + stream.expected_content_length is not None + and stream.content_length != stream.expected_content_length + ): + raise MessageError("content-length does not match data size") + + def _handle_request_or_push_frame( + self, + frame_type: int, + frame_data: Optional[bytes], + stream: H3Stream, + stream_ended: bool, + ) -> List[H3Event]: + """ + Handle a frame received on a request or push stream. + """ + http_events: List[H3Event] = [] + + if frame_type == FrameType.DATA: + # check DATA frame is allowed + if stream.headers_recv_state != HeadersState.AFTER_HEADERS: + raise FrameUnexpected("DATA frame is not allowed in this state") + + if frame_data is not None: + stream.content_length += len(frame_data) + + if stream_ended: + self._check_content_length(stream) + + if stream_ended or frame_data: + http_events.append( + DataReceived( + data=frame_data, + push_id=stream.push_id, + stream_ended=stream_ended, + stream_id=stream.stream_id, + ) + ) + elif frame_type == FrameType.HEADERS: + # check HEADERS frame is allowed + if stream.headers_recv_state == HeadersState.AFTER_TRAILERS: + raise FrameUnexpected("HEADERS frame is not allowed in this state") + + # try to decode HEADERS, may raise pylsqpack.StreamBlocked + headers = self._decode_headers(stream.stream_id, frame_data) + + # validate headers + if stream.headers_recv_state == HeadersState.INITIAL: + if self._is_client: + validate_response_headers(headers, stream) + else: + validate_request_headers(headers, stream) + else: + validate_trailers(headers) + + # content-length needs checking even when there is no data + if stream_ended: + self._check_content_length(stream) + + # log frame + if self._quic_logger is not None: + self._quic_logger.log_event( + category="http", + event="frame_parsed", + data=self._quic_logger.encode_http3_headers_frame( + length=( + stream.blocked_frame_size + if frame_data is None + else len(frame_data) + ), + headers=headers, + stream_id=stream.stream_id, + ), + ) + + # update state and emit headers + if stream.headers_recv_state == HeadersState.INITIAL: + stream.headers_recv_state = HeadersState.AFTER_HEADERS + else: + stream.headers_recv_state = HeadersState.AFTER_TRAILERS + http_events.append( + HeadersReceived( + headers=headers, + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + elif frame_type == FrameType.PUSH_PROMISE and stream.push_id is None: + if not self._is_client: + raise FrameUnexpected("Clients must not send PUSH_PROMISE") + frame_buf = Buffer(data=frame_data) + push_id = frame_buf.pull_uint_var() + headers = self._decode_headers( + stream.stream_id, frame_data[frame_buf.tell() :] + ) + + # validate headers + validate_push_promise_headers(headers) + + # log frame + if self._quic_logger is not None: + self._quic_logger.log_event( + category="http", + event="frame_parsed", + data=self._quic_logger.encode_http3_push_promise_frame( + length=len(frame_data), + headers=headers, + push_id=push_id, + stream_id=stream.stream_id, + ), + ) + + # emit event + http_events.append( + PushPromiseReceived( + headers=headers, push_id=push_id, stream_id=stream.stream_id + ) + ) + elif frame_type in ( + FrameType.PRIORITY, + FrameType.CANCEL_PUSH, + FrameType.SETTINGS, + FrameType.PUSH_PROMISE, + FrameType.GOAWAY, + FrameType.MAX_PUSH_ID, + FrameType.DUPLICATE_PUSH, + ): + raise FrameUnexpected( + "Invalid frame type on request stream" + if stream.push_id is None + else "Invalid frame type on push stream" + ) + + return http_events + + def _init_connection(self) -> None: + # send our settings + self._local_control_stream_id = self._create_uni_stream(StreamType.CONTROL) + self._sent_settings = self._get_local_settings() + self._quic.send_stream_data( + self._local_control_stream_id, + encode_frame(FrameType.SETTINGS, encode_settings(self._sent_settings)), + ) + if self._is_client and self._max_push_id is not None: + self._quic.send_stream_data( + self._local_control_stream_id, + encode_frame(FrameType.MAX_PUSH_ID, encode_uint_var(self._max_push_id)), + ) + + # create encoder and decoder streams + self._local_encoder_stream_id = self._create_uni_stream( + StreamType.QPACK_ENCODER + ) + self._local_decoder_stream_id = self._create_uni_stream( + StreamType.QPACK_DECODER + ) + + def _log_stream_type( + self, stream_id: int, stream_type: int, push_id: Optional[int] = None + ) -> None: + if self._quic_logger is not None: + type_name = { + 0: "control", + 1: "push", + 2: "qpack_encoder", + 3: "qpack_decoder", + 0x54: "webtransport", # NOTE: not standardized yet + }.get(stream_type, "unknown") + + data = {"new": type_name, "stream_id": stream_id} + if push_id is not None: + data["associated_push_id"] = push_id + + self._quic_logger.log_event( + category="http", + event="stream_type_set", + data=data, + ) + + def _receive_datagram(self, data: bytes) -> List[H3Event]: + """ + Handle a datagram. + """ + buf = Buffer(data=data) + try: + quarter_stream_id = buf.pull_uint_var() + except BufferReadError: + raise DatagramError("Could not parse quarter stream ID") + return [ + DatagramReceived(data=data[buf.tell() :], stream_id=quarter_stream_id * 4) + ] + + def _receive_request_or_push_data( + self, stream: H3Stream, data: bytes, stream_ended: bool + ) -> List[H3Event]: + """ + Handle data received on a request or push stream. + """ + http_events: List[H3Event] = [] + + stream.buffer += data + if stream_ended: + stream.ended = True + if stream.blocked: + return http_events + + # shortcut for WEBTRANSPORT_STREAM frame fragments + if ( + stream.frame_type == FrameType.WEBTRANSPORT_STREAM + and stream.session_id is not None + ): + http_events.append( + WebTransportStreamDataReceived( + data=stream.buffer, + session_id=stream.session_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + stream.buffer = b"" + return http_events + + # shortcut for DATA frame fragments + if ( + stream.frame_type == FrameType.DATA + and stream.frame_size is not None + and len(stream.buffer) < stream.frame_size + ): + stream.content_length += len(stream.buffer) + http_events.append( + DataReceived( + data=stream.buffer, + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=False, + ) + ) + stream.frame_size -= len(stream.buffer) + stream.buffer = b"" + return http_events + + # handle lone FIN + if stream_ended and not stream.buffer: + self._check_content_length(stream) + + http_events.append( + DataReceived( + data=b"", + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=True, + ) + ) + return http_events + + buf = Buffer(data=stream.buffer) + consumed = 0 + + while not buf.eof(): + # fetch next frame header + if stream.frame_size is None: + try: + stream.frame_type = buf.pull_uint_var() + stream.frame_size = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # WEBTRANSPORT_STREAM frames last until the end of the stream + if stream.frame_type == FrameType.WEBTRANSPORT_STREAM: + stream.session_id = stream.frame_size + stream.frame_size = None + + frame_data = stream.buffer[consumed:] + stream.buffer = b"" + + self._log_stream_type( + stream_id=stream.stream_id, stream_type=StreamType.WEBTRANSPORT + ) + + if frame_data or stream_ended: + http_events.append( + WebTransportStreamDataReceived( + data=frame_data, + session_id=stream.session_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + return http_events + + # log frame + if ( + self._quic_logger is not None + and stream.frame_type == FrameType.DATA + ): + self._quic_logger.log_event( + category="http", + event="frame_parsed", + data=self._quic_logger.encode_http3_data_frame( + length=stream.frame_size, stream_id=stream.stream_id + ), + ) + + # check how much data is available + chunk_size = min(stream.frame_size, buf.capacity - consumed) + if stream.frame_type != FrameType.DATA and chunk_size < stream.frame_size: + break + + # read available data + frame_data = buf.pull_bytes(chunk_size) + frame_type = stream.frame_type + consumed = buf.tell() + + # detect end of frame + stream.frame_size -= chunk_size + if not stream.frame_size: + stream.frame_size = None + stream.frame_type = None + + try: + http_events.extend( + self._handle_request_or_push_frame( + frame_type=frame_type, + frame_data=frame_data, + stream=stream, + stream_ended=stream.ended and buf.eof(), + ) + ) + except pylsqpack.StreamBlocked: + stream.blocked = True + stream.blocked_frame_size = len(frame_data) + break + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + return http_events + + def _receive_stream_data_uni( + self, stream: H3Stream, data: bytes, stream_ended: bool + ) -> List[H3Event]: + http_events: List[H3Event] = [] + + stream.buffer += data + if stream_ended: + stream.ended = True + + buf = Buffer(data=stream.buffer) + consumed = 0 + unblocked_streams: Set[int] = set() + + while ( + stream.stream_type + in (StreamType.PUSH, StreamType.CONTROL, StreamType.WEBTRANSPORT) + or not buf.eof() + ): + # fetch stream type for unidirectional streams + if stream.stream_type is None: + try: + stream.stream_type = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # check unicity + if stream.stream_type == StreamType.CONTROL: + if self._peer_control_stream_id is not None: + raise StreamCreationError("Only one control stream is allowed") + self._peer_control_stream_id = stream.stream_id + elif stream.stream_type == StreamType.QPACK_DECODER: + if self._peer_decoder_stream_id is not None: + raise StreamCreationError( + "Only one QPACK decoder stream is allowed" + ) + self._peer_decoder_stream_id = stream.stream_id + elif stream.stream_type == StreamType.QPACK_ENCODER: + if self._peer_encoder_stream_id is not None: + raise StreamCreationError( + "Only one QPACK encoder stream is allowed" + ) + self._peer_encoder_stream_id = stream.stream_id + + # for PUSH, logging is performed once the push_id is known + if stream.stream_type != StreamType.PUSH: + self._log_stream_type( + stream_id=stream.stream_id, stream_type=stream.stream_type + ) + + if stream.stream_type == StreamType.CONTROL: + if stream_ended: + raise ClosedCriticalStream("Closing control stream is not allowed") + + # fetch next frame + try: + frame_type = buf.pull_uint_var() + frame_length = buf.pull_uint_var() + frame_data = buf.pull_bytes(frame_length) + except BufferReadError: + break + consumed = buf.tell() + + self._handle_control_frame(frame_type, frame_data) + elif stream.stream_type == StreamType.PUSH: + # fetch push id + if stream.push_id is None: + try: + stream.push_id = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + self._log_stream_type( + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_type=stream.stream_type, + ) + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + return self._receive_request_or_push_data(stream, b"", stream_ended) + elif stream.stream_type == StreamType.WEBTRANSPORT: + # fetch session id + if stream.session_id is None: + try: + stream.session_id = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + frame_data = stream.buffer[consumed:] + stream.buffer = b"" + + if frame_data or stream_ended: + http_events.append( + WebTransportStreamDataReceived( + data=frame_data, + session_id=stream.session_id, + stream_ended=stream.ended, + stream_id=stream.stream_id, + ) + ) + return http_events + elif stream.stream_type == StreamType.QPACK_DECODER: + # feed unframed data to decoder + data = buf.pull_bytes(buf.capacity - buf.tell()) + consumed = buf.tell() + try: + self._encoder.feed_decoder(data) + except pylsqpack.DecoderStreamError as exc: + raise QpackDecoderStreamError() from exc + self._decoder_bytes_received += len(data) + elif stream.stream_type == StreamType.QPACK_ENCODER: + # feed unframed data to encoder + data = buf.pull_bytes(buf.capacity - buf.tell()) + consumed = buf.tell() + try: + unblocked_streams.update(self._decoder.feed_encoder(data)) + except pylsqpack.EncoderStreamError as exc: + raise QpackEncoderStreamError() from exc + self._encoder_bytes_received += len(data) + else: + # unknown stream type, discard data + buf.seek(buf.capacity) + consumed = buf.tell() + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + # process unblocked streams + for stream_id in unblocked_streams: + stream = self._stream[stream_id] + + # resume headers + http_events.extend( + self._handle_request_or_push_frame( + frame_type=FrameType.HEADERS, + frame_data=None, + stream=stream, + stream_ended=stream.ended and not stream.buffer, + ) + ) + stream.blocked = False + stream.blocked_frame_size = None + + # resume processing + if stream.buffer: + http_events.extend( + self._receive_request_or_push_data(stream, b"", stream.ended) + ) + + return http_events + + def _validate_settings(self, settings: Dict[int, int]) -> None: + for setting in [ + Setting.ENABLE_CONNECT_PROTOCOL, + Setting.ENABLE_WEBTRANSPORT, + Setting.H3_DATAGRAM, + ]: + if setting in settings and settings[setting] not in (0, 1): + raise SettingsError(f"{setting.name} setting must be 0 or 1") + + if ( + settings.get(Setting.H3_DATAGRAM) == 1 + and self._quic._remote_max_datagram_frame_size is None + ): + raise SettingsError( + "H3_DATAGRAM requires max_datagram_frame_size transport parameter" + ) + + if ( + settings.get(Setting.ENABLE_WEBTRANSPORT) == 1 + and settings.get(Setting.H3_DATAGRAM) != 1 + ): + raise SettingsError("ENABLE_WEBTRANSPORT requires H3_DATAGRAM") diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/events.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/events.py new file mode 100644 index 0000000..d02f3ac --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/events.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass +from typing import List, Optional, Tuple + +Headers = List[Tuple[bytes, bytes]] + + +class H3Event: + """ + Base class for HTTP/3 events. + """ + + +@dataclass +class DataReceived(H3Event): + """ + The DataReceived event is fired whenever data is received on a stream from + the remote peer. + """ + + data: bytes + "The data which was received." + + stream_id: int + "The ID of the stream the data was received for." + + stream_ended: bool + "Whether the STREAM frame had the FIN bit set." + + push_id: Optional[int] = None + "The Push ID or `None` if this is not a push." + + +@dataclass +class DatagramReceived(H3Event): + """ + The DatagramReceived is fired whenever a datagram is received from the + the remote peer. + """ + + data: bytes + "The data which was received." + + stream_id: int + "The ID of the stream the data was received for." + + +@dataclass +class HeadersReceived(H3Event): + """ + The HeadersReceived event is fired whenever headers are received. + """ + + headers: Headers + "The headers." + + stream_id: int + "The ID of the stream the headers were received for." + + stream_ended: bool + "Whether the STREAM frame had the FIN bit set." + + push_id: Optional[int] = None + "The Push ID or `None` if this is not a push." + + +@dataclass +class PushPromiseReceived(H3Event): + """ + The PushedStreamReceived event is fired whenever a pushed stream has been + received from the remote peer. + """ + + headers: Headers + "The request headers." + + push_id: int + "The Push ID of the push promise." + + stream_id: int + "The Stream ID of the stream that the push is related to." + + +@dataclass +class WebTransportStreamDataReceived(H3Event): + """ + The WebTransportStreamDataReceived is fired whenever data is received + for a WebTransport stream. + """ + + data: bytes + "The data which was received." + + stream_id: int + "The ID of the stream the data was received for." + + stream_ended: bool + "Whether the STREAM frame had the FIN bit set." + + session_id: int + "The ID of the session the data was received for." diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/exceptions.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/exceptions.py new file mode 100644 index 0000000..fac8c69 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/h3/exceptions.py @@ -0,0 +1,17 @@ +class H3Error(Exception): + """ + Base class for HTTP/3 exceptions. + """ + + +class InvalidStreamTypeError(H3Error): + """ + An action was attempted on an invalid stream type. + """ + + +class NoAvailablePushIDError(H3Error): + """ + There are no available push IDs left, or push is not supported + by the remote party. + """ diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/__init__.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/configuration.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/configuration.py new file mode 100644 index 0000000..80738ea --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/configuration.py @@ -0,0 +1,164 @@ +from dataclasses import dataclass, field +from os import PathLike +from re import split +from typing import Any, List, Optional, TextIO, Union + +from ..tls import ( + CipherSuite, + SessionTicket, + load_pem_private_key, + load_pem_x509_certificates, +) +from .logger import QuicLogger +from .packet import QuicProtocolVersion + +SMALLEST_MAX_DATAGRAM_SIZE = 1200 + + +@dataclass +class QuicConfiguration: + """ + A QUIC configuration. + """ + + alpn_protocols: Optional[List[str]] = None + """ + A list of supported ALPN protocols. + """ + + congestion_control_algorithm: str = "reno" + """ + The name of the congestion control algorithm to use. + + Currently supported algorithms: `"reno", `"cubic"`. + """ + + connection_id_length: int = 8 + """ + The length in bytes of local connection IDs. + """ + + idle_timeout: float = 60.0 + """ + The idle timeout in seconds. + + The connection is terminated if nothing is received for the given duration. + """ + + is_client: bool = True + """ + Whether this is the client side of the QUIC connection. + """ + + max_data: int = 1048576 + """ + Connection-wide flow control limit. + """ + + max_datagram_size: int = SMALLEST_MAX_DATAGRAM_SIZE + """ + The maximum QUIC payload size in bytes to send, excluding UDP or IP overhead. + """ + + max_stream_data: int = 1048576 + """ + Per-stream flow control limit. + """ + + quic_logger: Optional[QuicLogger] = None + """ + The :class:`~aioquic.quic.logger.QuicLogger` instance to log events to. + """ + + secrets_log_file: TextIO = None + """ + A file-like object in which to log traffic secrets. + + This is useful to analyze traffic captures with Wireshark. + """ + + server_name: Optional[str] = None + """ + The server name to use when verifying the server's TLS certificate, which + can either be a DNS name or an IP address. + + If it is a DNS name, it is also sent during the TLS handshake in the + Server Name Indication (SNI) extension. + + .. note:: This is only used by clients. + """ + + session_ticket: Optional[SessionTicket] = None + """ + The TLS session ticket which should be used for session resumption. + """ + + token: bytes = b"" + """ + The address validation token that can be used to validate future connections. + + .. note:: This is only used by clients. + """ + + cadata: Optional[bytes] = None + cafile: Optional[str] = None + capath: Optional[str] = None + certificate: Any = None + certificate_chain: List[Any] = field(default_factory=list) + cipher_suites: Optional[List[CipherSuite]] = None + initial_rtt: float = 0.1 + max_datagram_frame_size: Optional[int] = None + private_key: Any = None + quantum_readiness_test: bool = False + supported_versions: List[int] = field( + default_factory=lambda: [ + QuicProtocolVersion.VERSION_1, + QuicProtocolVersion.DRAFT_32, + QuicProtocolVersion.DRAFT_31, + QuicProtocolVersion.DRAFT_30, + QuicProtocolVersion.DRAFT_29, + ] + ) + verify_mode: Optional[int] = None + + def load_cert_chain( + self, + certfile: PathLike, + keyfile: Optional[PathLike] = None, + password: Optional[Union[bytes, str]] = None, + ) -> None: + """ + Load a private key and the corresponding certificate. + """ + with open(certfile, "rb") as fp: + boundary = b"-----BEGIN PRIVATE KEY-----\n" + chunks = split(b"\n" + boundary, fp.read()) + certificates = load_pem_x509_certificates(chunks[0]) + if len(chunks) == 2: + private_key = boundary + chunks[1] + self.private_key = load_pem_private_key(private_key) + self.certificate = certificates[0] + self.certificate_chain = certificates[1:] + + if keyfile is not None: + with open(keyfile, "rb") as fp: + self.private_key = load_pem_private_key( + fp.read(), + password=password.encode("utf8") + if isinstance(password, str) + else password, + ) + + def load_verify_locations( + self, + cafile: Optional[str] = None, + capath: Optional[str] = None, + cadata: Optional[bytes] = None, + ) -> None: + """ + Load a set of "certification authority" (CA) certificates used to + validate other peers' certificates. + """ + self.cafile = cafile + self.capath = capath + self.cadata = cadata diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/__init__.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/base.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/base.py new file mode 100644 index 0000000..3ff2e7f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/base.py @@ -0,0 +1,128 @@ +import abc +from typing import Any, Dict, Iterable, Optional, Protocol + +from ..packet_builder import QuicSentPacket + +K_GRANULARITY = 0.001 # seconds +K_INITIAL_WINDOW = 10 +K_MINIMUM_WINDOW = 2 + + +class QuicCongestionControl(abc.ABC): + """ + Base class for congestion control implementations. + """ + + bytes_in_flight: int = 0 + congestion_window: int = 0 + ssthresh: Optional[int] = None + + def __init__(self, *, max_datagram_size: int) -> None: + self.congestion_window = K_INITIAL_WINDOW * max_datagram_size + + @abc.abstractmethod + def on_packet_acked(self, *, now: float, packet: QuicSentPacket) -> None: ... + + @abc.abstractmethod + def on_packet_sent(self, *, packet: QuicSentPacket) -> None: ... + + @abc.abstractmethod + def on_packets_expired(self, *, packets: Iterable[QuicSentPacket]) -> None: ... + + @abc.abstractmethod + def on_packets_lost( + self, *, now: float, packets: Iterable[QuicSentPacket] + ) -> None: ... + + @abc.abstractmethod + def on_rtt_measurement(self, *, now: float, rtt: float) -> None: ... + + def get_log_data(self) -> Dict[str, Any]: + data = {"cwnd": self.congestion_window, "bytes_in_flight": self.bytes_in_flight} + if self.ssthresh is not None: + data["ssthresh"] = self.ssthresh + return data + + +class QuicCongestionControlFactory(Protocol): + def __call__(self, *, max_datagram_size: int) -> QuicCongestionControl: ... + + +class QuicRttMonitor: + """ + Roundtrip time monitor for HyStart. + """ + + def __init__(self) -> None: + self._increases = 0 + self._last_time = None + self._ready = False + self._size = 5 + + self._filtered_min: Optional[float] = None + + self._sample_idx = 0 + self._sample_max: Optional[float] = None + self._sample_min: Optional[float] = None + self._sample_time = 0.0 + self._samples = [0.0 for i in range(self._size)] + + def add_rtt(self, *, rtt: float) -> None: + self._samples[self._sample_idx] = rtt + self._sample_idx += 1 + + if self._sample_idx >= self._size: + self._sample_idx = 0 + self._ready = True + + if self._ready: + self._sample_max = self._samples[0] + self._sample_min = self._samples[0] + for sample in self._samples[1:]: + if sample < self._sample_min: + self._sample_min = sample + elif sample > self._sample_max: + self._sample_max = sample + + def is_rtt_increasing(self, *, now: float, rtt: float) -> bool: + if now > self._sample_time + K_GRANULARITY: + self.add_rtt(rtt=rtt) + self._sample_time = now + + if self._ready: + if self._filtered_min is None or self._filtered_min > self._sample_max: + self._filtered_min = self._sample_max + + delta = self._sample_min - self._filtered_min + if delta * 4 >= self._filtered_min: + self._increases += 1 + if self._increases >= self._size: + return True + elif delta > 0: + self._increases = 0 + return False + + +_factories: Dict[str, QuicCongestionControlFactory] = {} + + +def create_congestion_control( + name: str, *, max_datagram_size: int +) -> QuicCongestionControl: + """ + Create an instance of the `name` congestion control algorithm. + """ + try: + factory = _factories[name] + except KeyError: + raise Exception(f"Unknown congestion control algorithm: {name}") + return factory(max_datagram_size=max_datagram_size) + + +def register_congestion_control( + name: str, factory: QuicCongestionControlFactory +) -> None: + """ + Register a congestion control algorithm named `name`. + """ + _factories[name] = factory diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/cubic.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/cubic.py new file mode 100644 index 0000000..c7a7740 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/cubic.py @@ -0,0 +1,212 @@ +from typing import Any, Dict, Iterable + +from ..packet_builder import QuicSentPacket +from .base import ( + K_INITIAL_WINDOW, + K_MINIMUM_WINDOW, + QuicCongestionControl, + QuicRttMonitor, + register_congestion_control, +) + +# cubic specific variables (see https://www.rfc-editor.org/rfc/rfc9438.html#name-definitions) +K_CUBIC_C = 0.4 +K_CUBIC_LOSS_REDUCTION_FACTOR = 0.7 +K_CUBIC_MAX_IDLE_TIME = 2 # reset the cwnd after 2 seconds of inactivity + + +def better_cube_root(x: float) -> float: + if x < 0: + # avoid precision errors that make the cube root returns an imaginary number + return -((-x) ** (1.0 / 3.0)) + else: + return (x) ** (1.0 / 3.0) + + +class CubicCongestionControl(QuicCongestionControl): + """ + Cubic congestion control implementation for aioquic + """ + + def __init__(self, max_datagram_size: int) -> None: + super().__init__(max_datagram_size=max_datagram_size) + # increase by one segment + self.additive_increase_factor: int = max_datagram_size + self._max_datagram_size: int = max_datagram_size + self._congestion_recovery_start_time = 0.0 + + self._rtt_monitor = QuicRttMonitor() + + self.rtt = 0.02 # starting RTT is considered to be 20ms + + self.reset() + + self.last_ack = 0.0 + + def W_cubic(self, t) -> int: + W_max_segments = self._W_max / self._max_datagram_size + target_segments = K_CUBIC_C * (t - self.K) ** 3 + (W_max_segments) + return int(target_segments * self._max_datagram_size) + + def is_reno_friendly(self, t) -> bool: + return self.W_cubic(t) < self._W_est + + def is_concave(self) -> bool: + return self.congestion_window < self._W_max + + def reset(self) -> None: + self.congestion_window = K_INITIAL_WINDOW * self._max_datagram_size + self.ssthresh = None + + self._first_slow_start = True + self._starting_congestion_avoidance = False + self.K: float = 0.0 + self._W_est = 0 + self._cwnd_epoch = 0 + self._t_epoch = 0.0 + self._W_max = self.congestion_window + + def on_packet_acked(self, *, now: float, packet: QuicSentPacket) -> None: + self.bytes_in_flight -= packet.sent_bytes + self.last_ack = packet.sent_time + + if self.ssthresh is None or self.congestion_window < self.ssthresh: + # slow start + self.congestion_window += packet.sent_bytes + else: + # congestion avoidance + if self._first_slow_start and not self._starting_congestion_avoidance: + # exiting slow start without having a loss + self._first_slow_start = False + self._W_max = self.congestion_window + self._t_epoch = now + self._cwnd_epoch = self.congestion_window + self._W_est = self._cwnd_epoch + # calculate K + W_max_segments = self._W_max / self._max_datagram_size + cwnd_epoch_segments = self._cwnd_epoch / self._max_datagram_size + self.K = better_cube_root( + (W_max_segments - cwnd_epoch_segments) / K_CUBIC_C + ) + + # initialize the variables used at start of congestion avoidance + if self._starting_congestion_avoidance: + self._starting_congestion_avoidance = False + self._first_slow_start = False + self._t_epoch = now + self._cwnd_epoch = self.congestion_window + self._W_est = self._cwnd_epoch + # calculate K + W_max_segments = self._W_max / self._max_datagram_size + cwnd_epoch_segments = self._cwnd_epoch / self._max_datagram_size + self.K = better_cube_root( + (W_max_segments - cwnd_epoch_segments) / K_CUBIC_C + ) + + self._W_est = int( + self._W_est + + self.additive_increase_factor + * (packet.sent_bytes / self.congestion_window) + ) + + t = now - self._t_epoch + + target: int = 0 + W_cubic = self.W_cubic(t + self.rtt) + if W_cubic < self.congestion_window: + target = self.congestion_window + elif W_cubic > 1.5 * self.congestion_window: + target = int(self.congestion_window * 1.5) + else: + target = W_cubic + + if self.is_reno_friendly(t): + # reno friendly region of cubic + # (https://www.rfc-editor.org/rfc/rfc9438.html#name-reno-friendly-region) + self.congestion_window = self._W_est + elif self.is_concave(): + # concave region of cubic + # (https://www.rfc-editor.org/rfc/rfc9438.html#name-concave-region) + self.congestion_window = int( + self.congestion_window + + ( + (target - self.congestion_window) + * (self._max_datagram_size / self.congestion_window) + ) + ) + else: + # convex region of cubic + # (https://www.rfc-editor.org/rfc/rfc9438.html#name-convex-region) + self.congestion_window = int( + self.congestion_window + + ( + (target - self.congestion_window) + * (self._max_datagram_size / self.congestion_window) + ) + ) + + def on_packet_sent(self, *, packet: QuicSentPacket) -> None: + self.bytes_in_flight += packet.sent_bytes + if self.last_ack == 0.0: + return + elapsed_idle = packet.sent_time - self.last_ack + if elapsed_idle >= K_CUBIC_MAX_IDLE_TIME: + self.reset() + + def on_packets_expired(self, *, packets: Iterable[QuicSentPacket]) -> None: + for packet in packets: + self.bytes_in_flight -= packet.sent_bytes + + def on_packets_lost(self, *, now: float, packets: Iterable[QuicSentPacket]) -> None: + lost_largest_time = 0.0 + for packet in packets: + self.bytes_in_flight -= packet.sent_bytes + lost_largest_time = packet.sent_time + + # start a new congestion event if packet was sent after the + # start of the previous congestion recovery period. + if lost_largest_time > self._congestion_recovery_start_time: + self._congestion_recovery_start_time = now + + # Normal congestion handle, can't be used in same time as fast convergence + # self._W_max = self.congestion_window + + # fast convergence + if self._W_max is not None and self.congestion_window < self._W_max: + self._W_max = int( + self.congestion_window * (1 + K_CUBIC_LOSS_REDUCTION_FACTOR) / 2 + ) + else: + self._W_max = self.congestion_window + + # normal congestion MD + flight_size = self.bytes_in_flight + new_ssthresh = max( + int(flight_size * K_CUBIC_LOSS_REDUCTION_FACTOR), + K_MINIMUM_WINDOW * self._max_datagram_size, + ) + self.ssthresh = new_ssthresh + self.congestion_window = max( + self.ssthresh, K_MINIMUM_WINDOW * self._max_datagram_size + ) + + # restart a new congestion avoidance phase + self._starting_congestion_avoidance = True + + def on_rtt_measurement(self, *, now: float, rtt: float) -> None: + self.rtt = rtt + # check whether we should exit slow start + if self.ssthresh is None and self._rtt_monitor.is_rtt_increasing( + rtt=rtt, now=now + ): + self.ssthresh = self.congestion_window + + def get_log_data(self) -> Dict[str, Any]: + data = super().get_log_data() + + data["cubic-wmax"] = int(self._W_max) + + return data + + +register_congestion_control("cubic", CubicCongestionControl) diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/reno.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/reno.py new file mode 100644 index 0000000..0ccf079 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/congestion/reno.py @@ -0,0 +1,77 @@ +from typing import Iterable + +from ..packet_builder import QuicSentPacket +from .base import ( + K_MINIMUM_WINDOW, + QuicCongestionControl, + QuicRttMonitor, + register_congestion_control, +) + +K_LOSS_REDUCTION_FACTOR = 0.5 + + +class RenoCongestionControl(QuicCongestionControl): + """ + New Reno congestion control. + """ + + def __init__(self, *, max_datagram_size: int) -> None: + super().__init__(max_datagram_size=max_datagram_size) + self._max_datagram_size = max_datagram_size + self._congestion_recovery_start_time = 0.0 + self._congestion_stash = 0 + self._rtt_monitor = QuicRttMonitor() + + def on_packet_acked(self, *, now: float, packet: QuicSentPacket) -> None: + self.bytes_in_flight -= packet.sent_bytes + + # don't increase window in congestion recovery + if packet.sent_time <= self._congestion_recovery_start_time: + return + + if self.ssthresh is None or self.congestion_window < self.ssthresh: + # slow start + self.congestion_window += packet.sent_bytes + else: + # congestion avoidance + self._congestion_stash += packet.sent_bytes + count = self._congestion_stash // self.congestion_window + if count: + self._congestion_stash -= count * self.congestion_window + self.congestion_window += count * self._max_datagram_size + + def on_packet_sent(self, *, packet: QuicSentPacket) -> None: + self.bytes_in_flight += packet.sent_bytes + + def on_packets_expired(self, *, packets: Iterable[QuicSentPacket]) -> None: + for packet in packets: + self.bytes_in_flight -= packet.sent_bytes + + def on_packets_lost(self, *, now: float, packets: Iterable[QuicSentPacket]) -> None: + lost_largest_time = 0.0 + for packet in packets: + self.bytes_in_flight -= packet.sent_bytes + lost_largest_time = packet.sent_time + + # start a new congestion event if packet was sent after the + # start of the previous congestion recovery period. + if lost_largest_time > self._congestion_recovery_start_time: + self._congestion_recovery_start_time = now + self.congestion_window = max( + int(self.congestion_window * K_LOSS_REDUCTION_FACTOR), + K_MINIMUM_WINDOW * self._max_datagram_size, + ) + self.ssthresh = self.congestion_window + + # TODO : collapse congestion window if persistent congestion + + def on_rtt_measurement(self, *, now: float, rtt: float) -> None: + # check whether we should exit slow start + if self.ssthresh is None and self._rtt_monitor.is_rtt_increasing( + now=now, rtt=rtt + ): + self.ssthresh = self.congestion_window + + +register_congestion_control("reno", RenoCongestionControl) diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/connection.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/connection.py new file mode 100644 index 0000000..796feec --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/connection.py @@ -0,0 +1,3411 @@ +import binascii +import logging +import os +from collections import deque +from dataclasses import dataclass +from enum import Enum +from functools import partial +from typing import ( + Any, + Callable, + Deque, + Dict, + FrozenSet, + List, + Optional, + Sequence, + Set, + Tuple, +) + +from .. import tls +from ..buffer import ( + UINT_VAR_MAX, + UINT_VAR_MAX_SIZE, + Buffer, + BufferReadError, + size_uint_var, +) +from . import events +from .configuration import SMALLEST_MAX_DATAGRAM_SIZE, QuicConfiguration +from .congestion.base import K_GRANULARITY +from .crypto import CryptoError, CryptoPair, KeyUnavailableError +from .logger import QuicLoggerTrace +from .packet import ( + CONNECTION_ID_MAX_SIZE, + NON_ACK_ELICITING_FRAME_TYPES, + PACKET_TYPE_HANDSHAKE, + PACKET_TYPE_INITIAL, + PACKET_TYPE_ONE_RTT, + PACKET_TYPE_RETRY, + PACKET_TYPE_ZERO_RTT, + PROBING_FRAME_TYPES, + RETRY_INTEGRITY_TAG_SIZE, + STATELESS_RESET_TOKEN_SIZE, + QuicErrorCode, + QuicFrameType, + QuicProtocolVersion, + QuicStreamFrame, + QuicTransportParameters, + get_retry_integrity_tag, + get_spin_bit, + is_draft_version, + is_long_header, + pull_ack_frame, + pull_quic_header, + pull_quic_transport_parameters, + push_ack_frame, + push_quic_transport_parameters, +) +from .packet_builder import ( + QuicDeliveryState, + QuicPacketBuilder, + QuicPacketBuilderStop, +) +from .recovery import QuicPacketRecovery, QuicPacketSpace +from .stream import FinalSizeError, QuicStream, StreamFinishedError + +logger = logging.getLogger("quic") + +CRYPTO_BUFFER_SIZE = 16384 +EPOCH_SHORTCUTS = { + "I": tls.Epoch.INITIAL, + "H": tls.Epoch.HANDSHAKE, + "0": tls.Epoch.ZERO_RTT, + "1": tls.Epoch.ONE_RTT, +} +MAX_EARLY_DATA = 0xFFFFFFFF +SECRETS_LABELS = [ + [ + None, + "CLIENT_EARLY_TRAFFIC_SECRET", + "CLIENT_HANDSHAKE_TRAFFIC_SECRET", + "CLIENT_TRAFFIC_SECRET_0", + ], + [ + None, + None, + "SERVER_HANDSHAKE_TRAFFIC_SECRET", + "SERVER_TRAFFIC_SECRET_0", + ], +] +STREAM_FLAGS = 0x07 +STREAM_COUNT_MAX = 0x1000000000000000 +UDP_HEADER_SIZE = 8 +MAX_PENDING_RETIRES = 100 + +NetworkAddress = Any + +# frame sizes +ACK_FRAME_CAPACITY = 64 # FIXME: this is arbitrary! +APPLICATION_CLOSE_FRAME_CAPACITY = 1 + 2 * UINT_VAR_MAX_SIZE # + reason length +CONNECTION_LIMIT_FRAME_CAPACITY = 1 + UINT_VAR_MAX_SIZE +HANDSHAKE_DONE_FRAME_CAPACITY = 1 +MAX_STREAM_DATA_FRAME_CAPACITY = 1 + 2 * UINT_VAR_MAX_SIZE +NEW_CONNECTION_ID_FRAME_CAPACITY = ( + 1 + 2 * UINT_VAR_MAX_SIZE + 1 + CONNECTION_ID_MAX_SIZE + STATELESS_RESET_TOKEN_SIZE +) +PATH_CHALLENGE_FRAME_CAPACITY = 1 + 8 +PATH_RESPONSE_FRAME_CAPACITY = 1 + 8 +PING_FRAME_CAPACITY = 1 +RESET_STREAM_FRAME_CAPACITY = 1 + 3 * UINT_VAR_MAX_SIZE +RETIRE_CONNECTION_ID_CAPACITY = 1 + UINT_VAR_MAX_SIZE +STOP_SENDING_FRAME_CAPACITY = 1 + 2 * UINT_VAR_MAX_SIZE +STREAMS_BLOCKED_CAPACITY = 1 + UINT_VAR_MAX_SIZE +TRANSPORT_CLOSE_FRAME_CAPACITY = 1 + 3 * UINT_VAR_MAX_SIZE # + reason length + + +def EPOCHS(shortcut: str) -> FrozenSet[tls.Epoch]: + return frozenset(EPOCH_SHORTCUTS[i] for i in shortcut) + + +def dump_cid(cid: bytes) -> str: + return binascii.hexlify(cid).decode("ascii") + + +def get_epoch(packet_type: int) -> tls.Epoch: + if packet_type == PACKET_TYPE_INITIAL: + return tls.Epoch.INITIAL + elif packet_type == PACKET_TYPE_ZERO_RTT: + return tls.Epoch.ZERO_RTT + elif packet_type == PACKET_TYPE_HANDSHAKE: + return tls.Epoch.HANDSHAKE + else: + return tls.Epoch.ONE_RTT + + +def get_transport_parameters_extension(version: int) -> tls.ExtensionType: + if is_draft_version(version): + return tls.ExtensionType.QUIC_TRANSPORT_PARAMETERS_DRAFT + else: + return tls.ExtensionType.QUIC_TRANSPORT_PARAMETERS + + +def stream_is_client_initiated(stream_id: int) -> bool: + """ + Returns True if the stream is client initiated. + """ + return not (stream_id & 1) + + +def stream_is_unidirectional(stream_id: int) -> bool: + """ + Returns True if the stream is unidirectional. + """ + return bool(stream_id & 2) + + +class Limit: + def __init__(self, frame_type: int, name: str, value: int): + self.frame_type = frame_type + self.name = name + self.sent = value + self.used = 0 + self.value = value + + +class QuicConnectionError(Exception): + def __init__(self, error_code: int, frame_type: int, reason_phrase: str): + self.error_code = error_code + self.frame_type = frame_type + self.reason_phrase = reason_phrase + + def __str__(self) -> str: + s = "Error: %d, reason: %s" % (self.error_code, self.reason_phrase) + if self.frame_type is not None: + s += ", frame_type: %s" % self.frame_type + return s + + +class QuicConnectionAdapter(logging.LoggerAdapter): + def process(self, msg: str, kwargs: Any) -> Tuple[str, Any]: + return "[%s] %s" % (self.extra["id"], msg), kwargs + + +@dataclass +class QuicConnectionId: + cid: bytes + sequence_number: int + stateless_reset_token: bytes = b"" + was_sent: bool = False + + +class QuicConnectionState(Enum): + FIRSTFLIGHT = 0 + CONNECTED = 1 + CLOSING = 2 + DRAINING = 3 + TERMINATED = 4 + + +@dataclass +class QuicNetworkPath: + addr: NetworkAddress + bytes_received: int = 0 + bytes_sent: int = 0 + is_validated: bool = False + local_challenge: Optional[bytes] = None + remote_challenge: Optional[bytes] = None + + def can_send(self, size: int) -> bool: + return self.is_validated or (self.bytes_sent + size) <= 3 * self.bytes_received + + +@dataclass +class QuicReceiveContext: + epoch: tls.Epoch + host_cid: bytes + network_path: QuicNetworkPath + quic_logger_frames: Optional[List[Any]] + time: float + + +QuicTokenHandler = Callable[[bytes], None] + +END_STATES = frozenset( + [ + QuicConnectionState.CLOSING, + QuicConnectionState.DRAINING, + QuicConnectionState.TERMINATED, + ] +) + + +class QuicConnection: + """ + A QUIC connection. + + The state machine is driven by three kinds of sources: + + - the API user requesting data to be send out (see :meth:`connect`, + :meth:`reset_stream`, :meth:`send_ping`, :meth:`send_datagram_frame` + and :meth:`send_stream_data`) + - data being received from the network (see :meth:`receive_datagram`) + - a timer firing (see :meth:`handle_timer`) + + :param configuration: The QUIC configuration to use. + """ + + def __init__( + self, + *, + configuration: QuicConfiguration, + original_destination_connection_id: Optional[bytes] = None, + retry_source_connection_id: Optional[bytes] = None, + session_ticket_fetcher: Optional[tls.SessionTicketFetcher] = None, + session_ticket_handler: Optional[tls.SessionTicketHandler] = None, + token_handler: Optional[QuicTokenHandler] = None, + ) -> None: + assert configuration.max_datagram_size >= SMALLEST_MAX_DATAGRAM_SIZE, ( + "The smallest allowed maximum datagram size is " + f"{SMALLEST_MAX_DATAGRAM_SIZE} bytes" + ) + if configuration.is_client: + assert ( + original_destination_connection_id is None + ), "Cannot set original_destination_connection_id for a client" + assert ( + retry_source_connection_id is None + ), "Cannot set retry_source_connection_id for a client" + else: + assert token_handler is None, "Cannot set `token_handler` for a server" + assert ( + configuration.token == b"" + ), "Cannot set `configuration.token` for a server" + assert ( + configuration.certificate is not None + ), "SSL certificate is required for a server" + assert ( + configuration.private_key is not None + ), "SSL private key is required for a server" + assert ( + original_destination_connection_id is not None + ), "original_destination_connection_id is required for a server" + + # configuration + self._configuration = configuration + self._is_client = configuration.is_client + + self._ack_delay = K_GRANULARITY + self._close_at: Optional[float] = None + self._close_event: Optional[events.ConnectionTerminated] = None + self._connect_called = False + self._cryptos: Dict[tls.Epoch, CryptoPair] = {} + self._crypto_buffers: Dict[tls.Epoch, Buffer] = {} + self._crypto_retransmitted = False + self._crypto_streams: Dict[tls.Epoch, QuicStream] = {} + self._events: Deque[events.QuicEvent] = deque() + self._handshake_complete = False + self._handshake_confirmed = False + self._host_cids = [ + QuicConnectionId( + cid=os.urandom(configuration.connection_id_length), + sequence_number=0, + stateless_reset_token=os.urandom(16) if not self._is_client else None, + was_sent=True, + ) + ] + self.host_cid = self._host_cids[0].cid + self._host_cid_seq = 1 + self._local_ack_delay_exponent = 3 + self._local_active_connection_id_limit = 8 + self._local_initial_source_connection_id = self._host_cids[0].cid + self._local_max_data = Limit( + frame_type=QuicFrameType.MAX_DATA, + name="max_data", + value=configuration.max_data, + ) + self._local_max_stream_data_bidi_local = configuration.max_stream_data + self._local_max_stream_data_bidi_remote = configuration.max_stream_data + self._local_max_stream_data_uni = configuration.max_stream_data + self._local_max_streams_bidi = Limit( + frame_type=QuicFrameType.MAX_STREAMS_BIDI, + name="max_streams_bidi", + value=128, + ) + self._local_max_streams_uni = Limit( + frame_type=QuicFrameType.MAX_STREAMS_UNI, name="max_streams_uni", value=128 + ) + self._local_next_stream_id_bidi = 0 if self._is_client else 1 + self._local_next_stream_id_uni = 2 if self._is_client else 3 + self._loss_at: Optional[float] = None + self._max_datagram_size = configuration.max_datagram_size + self._network_paths: List[QuicNetworkPath] = [] + self._pacing_at: Optional[float] = None + self._packet_number = 0 + self._parameters_received = False + self._peer_cid = QuicConnectionId( + cid=os.urandom(configuration.connection_id_length), sequence_number=None + ) + self._peer_cid_available: List[QuicConnectionId] = [] + self._peer_cid_sequence_numbers: Set[int] = set([0]) + self._peer_retire_prior_to = 0 + self._peer_token = configuration.token + self._quic_logger: Optional[QuicLoggerTrace] = None + self._remote_ack_delay_exponent = 3 + self._remote_active_connection_id_limit = 2 + self._remote_initial_source_connection_id: Optional[bytes] = None + self._remote_max_idle_timeout: Optional[float] = None # seconds + self._remote_max_data = 0 + self._remote_max_data_used = 0 + self._remote_max_datagram_frame_size: Optional[int] = None + self._remote_max_stream_data_bidi_local = 0 + self._remote_max_stream_data_bidi_remote = 0 + self._remote_max_stream_data_uni = 0 + self._remote_max_streams_bidi = 0 + self._remote_max_streams_uni = 0 + self._retry_count = 0 + self._retry_source_connection_id = retry_source_connection_id + self._spaces: Dict[tls.Epoch, QuicPacketSpace] = {} + self._spin_bit = False + self._spin_highest_pn = 0 + self._state = QuicConnectionState.FIRSTFLIGHT + self._streams: Dict[int, QuicStream] = {} + self._streams_queue: List[QuicStream] = [] + self._streams_blocked_bidi: List[QuicStream] = [] + self._streams_blocked_uni: List[QuicStream] = [] + self._streams_finished: Set[int] = set() + self._version: Optional[int] = None + self._version_negotiation_count = 0 + + if self._is_client: + self._original_destination_connection_id = self._peer_cid.cid + else: + self._original_destination_connection_id = ( + original_destination_connection_id + ) + + # logging + self._logger = QuicConnectionAdapter( + logger, {"id": dump_cid(self._original_destination_connection_id)} + ) + if configuration.quic_logger: + self._quic_logger = configuration.quic_logger.start_trace( + is_client=configuration.is_client, + odcid=self._original_destination_connection_id, + ) + + # loss recovery + self._loss = QuicPacketRecovery( + congestion_control_algorithm=configuration.congestion_control_algorithm, + initial_rtt=configuration.initial_rtt, + max_datagram_size=self._max_datagram_size, + peer_completed_address_validation=not self._is_client, + quic_logger=self._quic_logger, + send_probe=self._send_probe, + logger=self._logger, + ) + + # things to send + self._close_pending = False + self._datagrams_pending: Deque[bytes] = deque() + self._handshake_done_pending = False + self._ping_pending: List[int] = [] + self._probe_pending = False + self._retire_connection_ids: List[int] = [] + self._streams_blocked_pending = False + + # callbacks + self._session_ticket_fetcher = session_ticket_fetcher + self._session_ticket_handler = session_ticket_handler + self._token_handler = token_handler + + # frame handlers + self.__frame_handlers = { + 0x00: (self._handle_padding_frame, EPOCHS("IH01")), + 0x01: (self._handle_ping_frame, EPOCHS("IH01")), + 0x02: (self._handle_ack_frame, EPOCHS("IH1")), + 0x03: (self._handle_ack_frame, EPOCHS("IH1")), + 0x04: (self._handle_reset_stream_frame, EPOCHS("01")), + 0x05: (self._handle_stop_sending_frame, EPOCHS("01")), + 0x06: (self._handle_crypto_frame, EPOCHS("IH1")), + 0x07: (self._handle_new_token_frame, EPOCHS("1")), + 0x08: (self._handle_stream_frame, EPOCHS("01")), + 0x09: (self._handle_stream_frame, EPOCHS("01")), + 0x0A: (self._handle_stream_frame, EPOCHS("01")), + 0x0B: (self._handle_stream_frame, EPOCHS("01")), + 0x0C: (self._handle_stream_frame, EPOCHS("01")), + 0x0D: (self._handle_stream_frame, EPOCHS("01")), + 0x0E: (self._handle_stream_frame, EPOCHS("01")), + 0x0F: (self._handle_stream_frame, EPOCHS("01")), + 0x10: (self._handle_max_data_frame, EPOCHS("01")), + 0x11: (self._handle_max_stream_data_frame, EPOCHS("01")), + 0x12: (self._handle_max_streams_bidi_frame, EPOCHS("01")), + 0x13: (self._handle_max_streams_uni_frame, EPOCHS("01")), + 0x14: (self._handle_data_blocked_frame, EPOCHS("01")), + 0x15: (self._handle_stream_data_blocked_frame, EPOCHS("01")), + 0x16: (self._handle_streams_blocked_frame, EPOCHS("01")), + 0x17: (self._handle_streams_blocked_frame, EPOCHS("01")), + 0x18: (self._handle_new_connection_id_frame, EPOCHS("01")), + 0x19: (self._handle_retire_connection_id_frame, EPOCHS("01")), + 0x1A: (self._handle_path_challenge_frame, EPOCHS("01")), + 0x1B: (self._handle_path_response_frame, EPOCHS("01")), + 0x1C: (self._handle_connection_close_frame, EPOCHS("IH01")), + 0x1D: (self._handle_connection_close_frame, EPOCHS("01")), + 0x1E: (self._handle_handshake_done_frame, EPOCHS("1")), + 0x30: (self._handle_datagram_frame, EPOCHS("01")), + 0x31: (self._handle_datagram_frame, EPOCHS("01")), + } + + @property + def configuration(self) -> QuicConfiguration: + return self._configuration + + @property + def original_destination_connection_id(self) -> bytes: + return self._original_destination_connection_id + + def change_connection_id(self) -> None: + """ + Switch to the next available connection ID and retire + the previous one. + + .. aioquic_transmit:: + """ + if self._peer_cid_available: + # retire previous CID + self._retire_peer_cid(self._peer_cid) + + # assign new CID + self._consume_peer_cid() + + def close( + self, + error_code: int = QuicErrorCode.NO_ERROR, + frame_type: Optional[int] = None, + reason_phrase: str = "", + ) -> None: + """ + Close the connection. + + .. aioquic_transmit:: + + :param error_code: An error code indicating why the connection is + being closed. + :param reason_phrase: A human-readable explanation of why the + connection is being closed. + """ + if self._close_event is None and self._state not in END_STATES: + self._close_event = events.ConnectionTerminated( + error_code=error_code, + frame_type=frame_type, + reason_phrase=reason_phrase, + ) + self._close_pending = True + + def connect(self, addr: NetworkAddress, now: float) -> None: + """ + Initiate the TLS handshake. + + This method can only be called for clients and a single time. + + .. aioquic_transmit:: + + :param addr: The network address of the remote peer. + :param now: The current time. + """ + assert ( + self._is_client and not self._connect_called + ), "connect() can only be called for clients and a single time" + self._connect_called = True + + self._network_paths = [QuicNetworkPath(addr, is_validated=True)] + self._version = self._configuration.supported_versions[0] + self._connect(now=now) + + def datagrams_to_send(self, now: float) -> List[Tuple[bytes, NetworkAddress]]: + """ + Return a list of `(data, addr)` tuples of datagrams which need to be + sent, and the network address to which they need to be sent. + + After calling this method call :meth:`get_timer` to know when the next + timer needs to be set. + + :param now: The current time. + """ + network_path = self._network_paths[0] + + if self._state in END_STATES: + return [] + + # build datagrams + builder = QuicPacketBuilder( + host_cid=self.host_cid, + is_client=self._is_client, + max_datagram_size=self._max_datagram_size, + packet_number=self._packet_number, + peer_cid=self._peer_cid.cid, + peer_token=self._peer_token, + quic_logger=self._quic_logger, + spin_bit=self._spin_bit, + version=self._version, + ) + if self._close_pending: + epoch_packet_types = [] + if not self._handshake_confirmed: + epoch_packet_types += [ + (tls.Epoch.INITIAL, PACKET_TYPE_INITIAL), + (tls.Epoch.HANDSHAKE, PACKET_TYPE_HANDSHAKE), + ] + epoch_packet_types.append((tls.Epoch.ONE_RTT, PACKET_TYPE_ONE_RTT)) + for epoch, packet_type in epoch_packet_types: + crypto = self._cryptos[epoch] + if crypto.send.is_valid(): + builder.start_packet(packet_type, crypto) + self._write_connection_close_frame( + builder=builder, + epoch=epoch, + error_code=self._close_event.error_code, + frame_type=self._close_event.frame_type, + reason_phrase=self._close_event.reason_phrase, + ) + self._logger.info( + "Connection close sent (code 0x%X, reason %s)", + self._close_event.error_code, + self._close_event.reason_phrase, + ) + self._close_pending = False + self._close_begin(is_initiator=True, now=now) + else: + # congestion control + builder.max_flight_bytes = ( + self._loss.congestion_window - self._loss.bytes_in_flight + ) + if ( + self._probe_pending + and builder.max_flight_bytes < self._max_datagram_size + ): + builder.max_flight_bytes = self._max_datagram_size + + # limit data on un-validated network paths + if not network_path.is_validated: + builder.max_total_bytes = ( + network_path.bytes_received * 3 - network_path.bytes_sent + ) + + try: + if not self._handshake_confirmed: + for epoch in [tls.Epoch.INITIAL, tls.Epoch.HANDSHAKE]: + self._write_handshake(builder, epoch, now) + self._write_application(builder, network_path, now) + except QuicPacketBuilderStop: + pass + + datagrams, packets = builder.flush() + + if datagrams: + self._packet_number = builder.packet_number + + # register packets + sent_handshake = False + for packet in packets: + packet.sent_time = now + self._loss.on_packet_sent( + packet=packet, space=self._spaces[packet.epoch] + ) + if packet.epoch == tls.Epoch.HANDSHAKE: + sent_handshake = True + + # log packet + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_sent", + data={ + "frames": packet.quic_logger_frames, + "header": { + "packet_number": packet.packet_number, + "packet_type": self._quic_logger.packet_type( + packet.packet_type + ), + "scid": ( + dump_cid(self.host_cid) + if is_long_header(packet.packet_type) + else "" + ), + "dcid": dump_cid(self._peer_cid.cid), + }, + "raw": {"length": packet.sent_bytes}, + }, + ) + + # check if we can discard initial keys + if sent_handshake and self._is_client: + self._discard_epoch(tls.Epoch.INITIAL) + + # return datagrams to send and the destination network address + ret = [] + for datagram in datagrams: + payload_length = len(datagram) + network_path.bytes_sent += payload_length + ret.append((datagram, network_path.addr)) + + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="datagrams_sent", + data={ + "count": 1, + "raw": [ + { + "length": UDP_HEADER_SIZE + payload_length, + "payload_length": payload_length, + } + ], + }, + ) + return ret + + def get_next_available_stream_id(self, is_unidirectional=False) -> int: + """ + Return the stream ID for the next stream created by this endpoint. + """ + if is_unidirectional: + return self._local_next_stream_id_uni + else: + return self._local_next_stream_id_bidi + + def get_timer(self) -> Optional[float]: + """ + Return the time at which the timer should fire or None if no timer is needed. + """ + timer_at = self._close_at + if self._state not in END_STATES: + # ack timer + for space in self._loss.spaces: + if space.ack_at is not None and space.ack_at < timer_at: + timer_at = space.ack_at + + # loss detection timer + self._loss_at = self._loss.get_loss_detection_time() + if self._loss_at is not None and self._loss_at < timer_at: + timer_at = self._loss_at + + # pacing timer + if self._pacing_at is not None and self._pacing_at < timer_at: + timer_at = self._pacing_at + + return timer_at + + def handle_timer(self, now: float) -> None: + """ + Handle the timer. + + .. aioquic_transmit:: + + :param now: The current time. + """ + # end of closing period or idle timeout + if now >= self._close_at: + if self._close_event is None: + self._close_event = events.ConnectionTerminated( + error_code=QuicErrorCode.INTERNAL_ERROR, + frame_type=QuicFrameType.PADDING, + reason_phrase="Idle timeout", + ) + self._close_end() + return + + # loss detection timeout + if self._loss_at is not None and now >= self._loss_at: + self._logger.debug("Loss detection triggered") + self._loss.on_loss_detection_timeout(now=now) + + def next_event(self) -> Optional[events.QuicEvent]: + """ + Retrieve the next event from the event buffer. + + Returns `None` if there are no buffered events. + """ + try: + return self._events.popleft() + except IndexError: + return None + + def _idle_timeout(self) -> float: + # RFC 9000 section 10.1 + + # Start with our local timeout. + idle_timeout = self._configuration.idle_timeout + if self._remote_max_idle_timeout is not None: + # Our peer has a preference too, so pick the smaller timeout. + idle_timeout = min(idle_timeout, self._remote_max_idle_timeout) + # But not too small! + return max(idle_timeout, 3 * self._loss.get_probe_timeout()) + + def receive_datagram(self, data: bytes, addr: NetworkAddress, now: float) -> None: + """ + Handle an incoming datagram. + + .. aioquic_transmit:: + + :param data: The datagram which was received. + :param addr: The network address from which the datagram was received. + :param now: The current time. + """ + # stop handling packets when closing + if self._state in END_STATES: + return + + # log datagram + if self._quic_logger is not None: + payload_length = len(data) + self._quic_logger.log_event( + category="transport", + event="datagrams_received", + data={ + "count": 1, + "raw": [ + { + "length": UDP_HEADER_SIZE + payload_length, + "payload_length": payload_length, + } + ], + }, + ) + + # for servers, arm the idle timeout on the first datagram + if self._close_at is None: + self._close_at = now + self._idle_timeout() + + buf = Buffer(data=data) + while not buf.eof(): + start_off = buf.tell() + try: + header = pull_quic_header( + buf, host_cid_length=self._configuration.connection_id_length + ) + except ValueError: + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_dropped", + data={ + "trigger": "header_parse_error", + "raw": {"length": buf.capacity - start_off}, + }, + ) + return + + # RFC 9000 section 14.1 requires servers to drop all initial packets + # contained in a datagram smaller than 1200 bytes. + if ( + not self._is_client + and header.packet_type == PACKET_TYPE_INITIAL + and len(data) < SMALLEST_MAX_DATAGRAM_SIZE + ): + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_dropped", + data={ + "trigger": "initial_packet_datagram_too_small", + "raw": {"length": buf.capacity - start_off}, + }, + ) + return + + # check destination CID matches + destination_cid_seq: Optional[int] = None + for connection_id in self._host_cids: + if header.destination_cid == connection_id.cid: + destination_cid_seq = connection_id.sequence_number + break + if ( + self._is_client or header.packet_type == PACKET_TYPE_HANDSHAKE + ) and destination_cid_seq is None: + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_dropped", + data={"trigger": "unknown_connection_id"}, + ) + return + + # check protocol version + if ( + self._is_client + and self._state == QuicConnectionState.FIRSTFLIGHT + and header.version == QuicProtocolVersion.NEGOTIATION + and not self._version_negotiation_count + ): + # version negotiation + versions = [] + while not buf.eof(): + versions.append(buf.pull_uint32()) + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_received", + data={ + "frames": [], + "header": { + "packet_type": "version_negotiation", + "scid": dump_cid(header.source_cid), + "dcid": dump_cid(header.destination_cid), + }, + "raw": {"length": buf.tell() - start_off}, + }, + ) + if self._version in versions: + self._logger.warning( + "Version negotiation packet contains %s" % self._version + ) + return + common = [ + x for x in self._configuration.supported_versions if x in versions + ] + chosen_version = common[0] if common else None + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="version_information", + data={ + "server_versions": versions, + "client_versions": self._configuration.supported_versions, + "chosen_version": chosen_version, + }, + ) + if chosen_version is None: + self._logger.error("Could not find a common protocol version") + self._close_event = events.ConnectionTerminated( + error_code=QuicErrorCode.INTERNAL_ERROR, + frame_type=QuicFrameType.PADDING, + reason_phrase="Could not find a common protocol version", + ) + self._close_end() + return + self._packet_number = 0 + self._version = QuicProtocolVersion(chosen_version) + self._version_negotiation_count += 1 + self._logger.info("Retrying with %s", self._version) + self._connect(now=now) + return + elif ( + header.version is not None + and header.version not in self._configuration.supported_versions + ): + # unsupported version + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_dropped", + data={"trigger": "unsupported_version"}, + ) + return + + # handle retry packet + if header.packet_type == PACKET_TYPE_RETRY: + if ( + self._is_client + and not self._retry_count + and header.destination_cid == self.host_cid + and header.integrity_tag + == get_retry_integrity_tag( + buf.data_slice( + start_off, buf.tell() - RETRY_INTEGRITY_TAG_SIZE + ), + self._peer_cid.cid, + version=header.version, + ) + ): + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_received", + data={ + "frames": [], + "header": { + "packet_type": "retry", + "scid": dump_cid(header.source_cid), + "dcid": dump_cid(header.destination_cid), + }, + "raw": {"length": buf.tell() - start_off}, + }, + ) + + self._peer_cid.cid = header.source_cid + self._peer_token = header.token + self._retry_count += 1 + self._retry_source_connection_id = header.source_cid + self._logger.info( + "Retrying with token (%d bytes)" % len(header.token) + ) + self._connect(now=now) + else: + # unexpected or invalid retry packet + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_dropped", + data={"trigger": "unexpected_packet"}, + ) + return + + crypto_frame_required = False + network_path = self._find_network_path(addr) + + # server initialization + if not self._is_client and self._state == QuicConnectionState.FIRSTFLIGHT: + assert ( + header.packet_type == PACKET_TYPE_INITIAL + ), "first packet must be INITIAL" + crypto_frame_required = True + self._network_paths = [network_path] + self._version = QuicProtocolVersion(header.version) + self._initialize(header.destination_cid) + + # determine crypto and packet space + epoch = get_epoch(header.packet_type) + crypto = self._cryptos[epoch] + if epoch == tls.Epoch.ZERO_RTT: + space = self._spaces[tls.Epoch.ONE_RTT] + else: + space = self._spaces[epoch] + + # decrypt packet + encrypted_off = buf.tell() - start_off + end_off = buf.tell() + header.rest_length + buf.seek(end_off) + + try: + plain_header, plain_payload, packet_number = crypto.decrypt_packet( + data[start_off:end_off], encrypted_off, space.expected_packet_number + ) + except KeyUnavailableError as exc: + self._logger.debug(exc) + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_dropped", + data={"trigger": "key_unavailable"}, + ) + + # If a client receives HANDSHAKE or 1-RTT packets before it has + # handshake keys, it can assume that the server's INITIAL was lost. + if ( + self._is_client + and epoch in (tls.Epoch.HANDSHAKE, tls.Epoch.ONE_RTT) + and not self._crypto_retransmitted + ): + self._loss.reschedule_data(now=now) + self._crypto_retransmitted = True + continue + except CryptoError as exc: + self._logger.debug(exc) + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="packet_dropped", + data={"trigger": "payload_decrypt_error"}, + ) + continue + + # check reserved bits + if header.is_long_header: + reserved_mask = 0x0C + else: + reserved_mask = 0x18 + if plain_header[0] & reserved_mask: + self.close( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=QuicFrameType.PADDING, + reason_phrase="Reserved bits must be zero", + ) + return + + # log packet + quic_logger_frames: Optional[List[Dict]] = None + if self._quic_logger is not None: + quic_logger_frames = [] + self._quic_logger.log_event( + category="transport", + event="packet_received", + data={ + "frames": quic_logger_frames, + "header": { + "packet_number": packet_number, + "packet_type": self._quic_logger.packet_type( + header.packet_type + ), + "dcid": dump_cid(header.destination_cid), + "scid": dump_cid(header.source_cid), + }, + "raw": {"length": end_off - start_off}, + }, + ) + + # raise expected packet number + if packet_number > space.expected_packet_number: + space.expected_packet_number = packet_number + 1 + + # discard initial keys and packet space + if not self._is_client and epoch == tls.Epoch.HANDSHAKE: + self._discard_epoch(tls.Epoch.INITIAL) + + # update state + if self._peer_cid.sequence_number is None: + self._peer_cid.cid = header.source_cid + self._peer_cid.sequence_number = 0 + + if self._state == QuicConnectionState.FIRSTFLIGHT: + self._remote_initial_source_connection_id = header.source_cid + self._set_state(QuicConnectionState.CONNECTED) + + # update spin bit + if not header.is_long_header and packet_number > self._spin_highest_pn: + spin_bit = get_spin_bit(plain_header[0]) + if self._is_client: + self._spin_bit = not spin_bit + else: + self._spin_bit = spin_bit + self._spin_highest_pn = packet_number + + if self._quic_logger is not None: + self._quic_logger.log_event( + category="connectivity", + event="spin_bit_updated", + data={"state": self._spin_bit}, + ) + + # handle payload + context = QuicReceiveContext( + epoch=epoch, + host_cid=header.destination_cid, + network_path=network_path, + quic_logger_frames=quic_logger_frames, + time=now, + ) + try: + is_ack_eliciting, is_probing = self._payload_received( + context, plain_payload, crypto_frame_required=crypto_frame_required + ) + except QuicConnectionError as exc: + self._logger.warning(exc) + self.close( + error_code=exc.error_code, + frame_type=exc.frame_type, + reason_phrase=exc.reason_phrase, + ) + if self._state in END_STATES or self._close_pending: + return + + # update idle timeout + self._close_at = now + self._idle_timeout() + + # handle migration + if ( + not self._is_client + and context.host_cid != self.host_cid + and epoch == tls.Epoch.ONE_RTT + ): + self._logger.debug( + "Peer switching to CID %s (%d)", + dump_cid(context.host_cid), + destination_cid_seq, + ) + self.host_cid = context.host_cid + self.change_connection_id() + + # update network path + if not network_path.is_validated and epoch == tls.Epoch.HANDSHAKE: + self._logger.debug( + "Network path %s validated by handshake", network_path.addr + ) + network_path.is_validated = True + network_path.bytes_received += end_off - start_off + if network_path not in self._network_paths: + self._network_paths.append(network_path) + idx = self._network_paths.index(network_path) + if idx and not is_probing and packet_number > space.largest_received_packet: + self._logger.debug("Network path %s promoted", network_path.addr) + self._network_paths.pop(idx) + self._network_paths.insert(0, network_path) + + # record packet as received + if not space.discarded: + if packet_number > space.largest_received_packet: + space.largest_received_packet = packet_number + space.largest_received_time = now + space.ack_queue.add(packet_number) + if is_ack_eliciting and space.ack_at is None: + space.ack_at = now + self._ack_delay + + def request_key_update(self) -> None: + """ + Request an update of the encryption keys. + + .. aioquic_transmit:: + """ + assert self._handshake_complete, "cannot change key before handshake completes" + self._cryptos[tls.Epoch.ONE_RTT].update_key() + + def reset_stream(self, stream_id: int, error_code: int) -> None: + """ + Abruptly terminate the sending part of a stream. + + .. aioquic_transmit:: + + :param stream_id: The stream's ID. + :param error_code: An error code indicating why the stream is being reset. + """ + stream = self._get_or_create_stream_for_send(stream_id) + stream.sender.reset(error_code) + + def send_ping(self, uid: int) -> None: + """ + Send a PING frame to the peer. + + .. aioquic_transmit:: + + :param uid: A unique ID for this PING. + """ + self._ping_pending.append(uid) + + def send_datagram_frame(self, data: bytes) -> None: + """ + Send a DATAGRAM frame. + + .. aioquic_transmit:: + + :param data: The data to be sent. + """ + self._datagrams_pending.append(data) + + def send_stream_data( + self, stream_id: int, data: bytes, end_stream: bool = False + ) -> None: + """ + Send data on the specific stream. + + .. aioquic_transmit:: + + :param stream_id: The stream's ID. + :param data: The data to be sent. + :param end_stream: If set to `True`, the FIN bit will be set. + """ + stream = self._get_or_create_stream_for_send(stream_id) + stream.sender.write(data, end_stream=end_stream) + + def stop_stream(self, stream_id: int, error_code: int) -> None: + """ + Request termination of the receiving part of a stream. + + .. aioquic_transmit:: + + :param stream_id: The stream's ID. + :param error_code: An error code indicating why the stream is being stopped. + """ + if not self._stream_can_receive(stream_id): + raise ValueError( + "Cannot stop receiving on a local-initiated unidirectional stream" + ) + + stream = self._streams.get(stream_id, None) + if stream is None: + raise ValueError("Cannot stop receiving on an unknown stream") + + stream.receiver.stop(error_code) + + # Private + + def _alpn_handler(self, alpn_protocol: str) -> None: + """ + Callback which is invoked by the TLS engine when ALPN negotiation completes. + """ + self._events.append(events.ProtocolNegotiated(alpn_protocol=alpn_protocol)) + + def _assert_stream_can_receive(self, frame_type: int, stream_id: int) -> None: + """ + Check the specified stream can receive data or raises a QuicConnectionError. + """ + if not self._stream_can_receive(stream_id): + raise QuicConnectionError( + error_code=QuicErrorCode.STREAM_STATE_ERROR, + frame_type=frame_type, + reason_phrase="Stream is send-only", + ) + + def _assert_stream_can_send(self, frame_type: int, stream_id: int) -> None: + """ + Check the specified stream can send data or raises a QuicConnectionError. + """ + if not self._stream_can_send(stream_id): + raise QuicConnectionError( + error_code=QuicErrorCode.STREAM_STATE_ERROR, + frame_type=frame_type, + reason_phrase="Stream is receive-only", + ) + + def _consume_peer_cid(self) -> None: + """ + Update the destination connection ID by taking the next + available connection ID provided by the peer. + """ + + self._peer_cid = self._peer_cid_available.pop(0) + self._logger.debug( + "Switching to CID %s (%d)", + dump_cid(self._peer_cid.cid), + self._peer_cid.sequence_number, + ) + + def _close_begin(self, is_initiator: bool, now: float) -> None: + """ + Begin the close procedure. + """ + self._close_at = now + 3 * self._loss.get_probe_timeout() + if is_initiator: + self._set_state(QuicConnectionState.CLOSING) + else: + self._set_state(QuicConnectionState.DRAINING) + + def _close_end(self) -> None: + """ + End the close procedure. + """ + self._close_at = None + for epoch in self._spaces.keys(): + self._discard_epoch(epoch) + self._events.append(self._close_event) + self._set_state(QuicConnectionState.TERMINATED) + + # signal log end + if self._quic_logger is not None: + self._configuration.quic_logger.end_trace(self._quic_logger) + self._quic_logger = None + + def _connect(self, now: float) -> None: + """ + Start the client handshake. + """ + assert self._is_client + + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="version_information", + data={ + "client_versions": self._configuration.supported_versions, + "chosen_version": self._version, + }, + ) + self._quic_logger.log_event( + category="transport", + event="alpn_information", + data={"client_alpns": self._configuration.alpn_protocols}, + ) + + self._close_at = now + self._idle_timeout() + self._initialize(self._peer_cid.cid) + + self.tls.handle_message(b"", self._crypto_buffers) + self._push_crypto_data() + + def _discard_epoch(self, epoch: tls.Epoch) -> None: + if not self._spaces[epoch].discarded: + self._logger.debug("Discarding epoch %s", epoch) + self._cryptos[epoch].teardown() + self._loss.discard_space(self._spaces[epoch]) + self._spaces[epoch].discarded = True + + def _find_network_path(self, addr: NetworkAddress) -> QuicNetworkPath: + # check existing network paths + for idx, network_path in enumerate(self._network_paths): + if network_path.addr == addr: + return network_path + + # new network path + network_path = QuicNetworkPath(addr) + self._logger.debug("Network path %s discovered", network_path.addr) + return network_path + + def _get_or_create_stream(self, frame_type: int, stream_id: int) -> QuicStream: + """ + Get or create a stream in response to a received frame. + """ + if stream_id in self._streams_finished: + # the stream was created, but its state was since discarded + raise StreamFinishedError + + stream = self._streams.get(stream_id, None) + if stream is None: + # check initiator + if stream_is_client_initiated(stream_id) == self._is_client: + raise QuicConnectionError( + error_code=QuicErrorCode.STREAM_STATE_ERROR, + frame_type=frame_type, + reason_phrase="Wrong stream initiator", + ) + + # determine limits + if stream_is_unidirectional(stream_id): + max_stream_data_local = self._local_max_stream_data_uni + max_stream_data_remote = 0 + max_streams = self._local_max_streams_uni + else: + max_stream_data_local = self._local_max_stream_data_bidi_remote + max_stream_data_remote = self._remote_max_stream_data_bidi_local + max_streams = self._local_max_streams_bidi + + # check max streams + stream_count = (stream_id // 4) + 1 + if stream_count > max_streams.value: + raise QuicConnectionError( + error_code=QuicErrorCode.STREAM_LIMIT_ERROR, + frame_type=frame_type, + reason_phrase="Too many streams open", + ) + elif stream_count > max_streams.used: + max_streams.used = stream_count + + # create stream + self._logger.debug("Stream %d created by peer" % stream_id) + stream = self._streams[stream_id] = QuicStream( + stream_id=stream_id, + max_stream_data_local=max_stream_data_local, + max_stream_data_remote=max_stream_data_remote, + writable=not stream_is_unidirectional(stream_id), + ) + self._streams_queue.append(stream) + return stream + + def _get_or_create_stream_for_send(self, stream_id: int) -> QuicStream: + """ + Get or create a QUIC stream in order to send data to the peer. + + This always occurs as a result of an API call. + """ + if not self._stream_can_send(stream_id): + raise ValueError("Cannot send data on peer-initiated unidirectional stream") + + stream = self._streams.get(stream_id, None) + if stream is None: + # check initiator + if stream_is_client_initiated(stream_id) != self._is_client: + raise ValueError("Cannot send data on unknown peer-initiated stream") + + # determine limits + if stream_is_unidirectional(stream_id): + max_stream_data_local = 0 + max_stream_data_remote = self._remote_max_stream_data_uni + max_streams = self._remote_max_streams_uni + streams_blocked = self._streams_blocked_uni + else: + max_stream_data_local = self._local_max_stream_data_bidi_local + max_stream_data_remote = self._remote_max_stream_data_bidi_remote + max_streams = self._remote_max_streams_bidi + streams_blocked = self._streams_blocked_bidi + + # create stream + is_unidirectional = stream_is_unidirectional(stream_id) + stream = self._streams[stream_id] = QuicStream( + stream_id=stream_id, + max_stream_data_local=max_stream_data_local, + max_stream_data_remote=max_stream_data_remote, + readable=not is_unidirectional, + ) + self._streams_queue.append(stream) + if is_unidirectional: + self._local_next_stream_id_uni = stream_id + 4 + else: + self._local_next_stream_id_bidi = stream_id + 4 + + # mark stream as blocked if needed + if stream_id // 4 >= max_streams: + stream.is_blocked = True + streams_blocked.append(stream) + self._streams_blocked_pending = True + return stream + + def _handle_session_ticket(self, session_ticket: tls.SessionTicket) -> None: + if ( + session_ticket.max_early_data_size is not None + and session_ticket.max_early_data_size != MAX_EARLY_DATA + ): + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="Invalid max_early_data value %s" + % session_ticket.max_early_data_size, + ) + self._session_ticket_handler(session_ticket) + + def _initialize(self, peer_cid: bytes) -> None: + # TLS + self.tls = tls.Context( + alpn_protocols=self._configuration.alpn_protocols, + cadata=self._configuration.cadata, + cafile=self._configuration.cafile, + capath=self._configuration.capath, + cipher_suites=self.configuration.cipher_suites, + is_client=self._is_client, + logger=self._logger, + max_early_data=None if self._is_client else MAX_EARLY_DATA, + server_name=self._configuration.server_name, + verify_mode=self._configuration.verify_mode, + ) + self.tls.certificate = self._configuration.certificate + self.tls.certificate_chain = self._configuration.certificate_chain + self.tls.certificate_private_key = self._configuration.private_key + self.tls.handshake_extensions = [ + ( + get_transport_parameters_extension(self._version), + self._serialize_transport_parameters(), + ) + ] + + # TLS session resumption + session_ticket = self._configuration.session_ticket + if ( + self._is_client + and session_ticket is not None + and session_ticket.is_valid + and session_ticket.server_name == self._configuration.server_name + ): + self.tls.session_ticket = self._configuration.session_ticket + + # parse saved QUIC transport parameters - for 0-RTT + if session_ticket.max_early_data_size == MAX_EARLY_DATA: + for ext_type, ext_data in session_ticket.other_extensions: + if ext_type == get_transport_parameters_extension(self._version): + self._parse_transport_parameters( + ext_data, from_session_ticket=True + ) + break + + # TLS callbacks + self.tls.alpn_cb = self._alpn_handler + if self._session_ticket_fetcher is not None: + self.tls.get_session_ticket_cb = self._session_ticket_fetcher + if self._session_ticket_handler is not None: + self.tls.new_session_ticket_cb = self._handle_session_ticket + self.tls.update_traffic_key_cb = self._update_traffic_key + + # packet spaces + def create_crypto_pair(epoch: tls.Epoch) -> CryptoPair: + epoch_name = ["initial", "0rtt", "handshake", "1rtt"][epoch.value] + secret_names = [ + "server_%s_secret" % epoch_name, + "client_%s_secret" % epoch_name, + ] + recv_secret_name = secret_names[not self._is_client] + send_secret_name = secret_names[self._is_client] + return CryptoPair( + recv_setup_cb=partial(self._log_key_updated, recv_secret_name), + recv_teardown_cb=partial(self._log_key_retired, recv_secret_name), + send_setup_cb=partial(self._log_key_updated, send_secret_name), + send_teardown_cb=partial(self._log_key_retired, send_secret_name), + ) + + self._cryptos = dict( + (epoch, create_crypto_pair(epoch)) + for epoch in ( + tls.Epoch.INITIAL, + tls.Epoch.ZERO_RTT, + tls.Epoch.HANDSHAKE, + tls.Epoch.ONE_RTT, + ) + ) + self._crypto_buffers = { + tls.Epoch.INITIAL: Buffer(capacity=CRYPTO_BUFFER_SIZE), + tls.Epoch.HANDSHAKE: Buffer(capacity=CRYPTO_BUFFER_SIZE), + tls.Epoch.ONE_RTT: Buffer(capacity=CRYPTO_BUFFER_SIZE), + } + self._crypto_streams = { + tls.Epoch.INITIAL: QuicStream(), + tls.Epoch.HANDSHAKE: QuicStream(), + tls.Epoch.ONE_RTT: QuicStream(), + } + self._spaces = { + tls.Epoch.INITIAL: QuicPacketSpace(), + tls.Epoch.HANDSHAKE: QuicPacketSpace(), + tls.Epoch.ONE_RTT: QuicPacketSpace(), + } + + self._cryptos[tls.Epoch.INITIAL].setup_initial( + cid=peer_cid, is_client=self._is_client, version=self._version + ) + + self._loss.spaces = list(self._spaces.values()) + + def _handle_ack_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle an ACK frame. + """ + ack_rangeset, ack_delay_encoded = pull_ack_frame(buf) + if frame_type == QuicFrameType.ACK_ECN: + buf.pull_uint_var() + buf.pull_uint_var() + buf.pull_uint_var() + ack_delay = (ack_delay_encoded << self._remote_ack_delay_exponent) / 1000000 + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_ack_frame(ack_rangeset, ack_delay) + ) + + # check whether peer completed address validation + if not self._loss.peer_completed_address_validation and context.epoch in ( + tls.Epoch.HANDSHAKE, + tls.Epoch.ONE_RTT, + ): + self._loss.peer_completed_address_validation = True + + self._loss.on_ack_received( + ack_rangeset=ack_rangeset, + ack_delay=ack_delay, + now=context.time, + space=self._spaces[context.epoch], + ) + + def _handle_connection_close_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a CONNECTION_CLOSE frame. + """ + error_code = buf.pull_uint_var() + if frame_type == QuicFrameType.TRANSPORT_CLOSE: + frame_type = buf.pull_uint_var() + else: + frame_type = None + reason_length = buf.pull_uint_var() + try: + reason_phrase = buf.pull_bytes(reason_length).decode("utf8") + except UnicodeDecodeError: + reason_phrase = "" + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_connection_close_frame( + error_code=error_code, + frame_type=frame_type, + reason_phrase=reason_phrase, + ) + ) + + self._logger.info( + "Connection close received (code 0x%X, reason %s)", + error_code, + reason_phrase, + ) + if self._close_event is None: + self._close_event = events.ConnectionTerminated( + error_code=error_code, + frame_type=frame_type, + reason_phrase=reason_phrase, + ) + self._close_begin(is_initiator=False, now=context.time) + + def _handle_crypto_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a CRYPTO frame. + """ + offset = buf.pull_uint_var() + length = buf.pull_uint_var() + if offset + length > UINT_VAR_MAX: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="offset + length cannot exceed 2^62 - 1", + ) + frame = QuicStreamFrame(offset=offset, data=buf.pull_bytes(length)) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_crypto_frame(frame) + ) + + stream = self._crypto_streams[context.epoch] + event = stream.receiver.handle_frame(frame) + if event is not None: + # pass data to TLS layer + try: + self.tls.handle_message(event.data, self._crypto_buffers) + self._push_crypto_data() + except tls.Alert as exc: + raise QuicConnectionError( + error_code=QuicErrorCode.CRYPTO_ERROR + int(exc.description), + frame_type=frame_type, + reason_phrase=str(exc), + ) + + # parse transport parameters + if ( + not self._parameters_received + and self.tls.received_extensions is not None + ): + for ext_type, ext_data in self.tls.received_extensions: + if ext_type == get_transport_parameters_extension(self._version): + self._parse_transport_parameters(ext_data) + self._parameters_received = True + break + if not self._parameters_received: + raise QuicConnectionError( + error_code=QuicErrorCode.CRYPTO_ERROR + + tls.AlertDescription.missing_extension, + frame_type=frame_type, + reason_phrase="No QUIC transport parameters received", + ) + + # update current epoch + if not self._handshake_complete and self.tls.state in [ + tls.State.CLIENT_POST_HANDSHAKE, + tls.State.SERVER_POST_HANDSHAKE, + ]: + self._handshake_complete = True + + # for servers, the handshake is now confirmed + if not self._is_client: + self._discard_epoch(tls.Epoch.HANDSHAKE) + self._handshake_confirmed = True + self._handshake_done_pending = True + + self._replenish_connection_ids() + self._events.append( + events.HandshakeCompleted( + alpn_protocol=self.tls.alpn_negotiated, + early_data_accepted=self.tls.early_data_accepted, + session_resumed=self.tls.session_resumed, + ) + ) + self._unblock_streams(is_unidirectional=False) + self._unblock_streams(is_unidirectional=True) + self._logger.info( + "ALPN negotiated protocol %s", self.tls.alpn_negotiated + ) + else: + self._logger.info( + "Duplicate CRYPTO data received for epoch %s", context.epoch + ) + + # if a server receives duplicate CRYPTO in an INITIAL packet, + # it can assume the client did not receive the server's CRYPTO + if ( + not self._is_client + and context.epoch == tls.Epoch.INITIAL + and not self._crypto_retransmitted + ): + self._loss.reschedule_data(now=context.time) + self._crypto_retransmitted = True + + def _handle_data_blocked_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a DATA_BLOCKED frame. + """ + limit = buf.pull_uint_var() + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_data_blocked_frame(limit=limit) + ) + + def _handle_datagram_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a DATAGRAM frame. + """ + start = buf.tell() + if frame_type == QuicFrameType.DATAGRAM_WITH_LENGTH: + length = buf.pull_uint_var() + else: + length = buf.capacity - start + data = buf.pull_bytes(length) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_datagram_frame(length=length) + ) + + # check frame is allowed + if ( + self._configuration.max_datagram_frame_size is None + or buf.tell() - start >= self._configuration.max_datagram_frame_size + ): + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Unexpected DATAGRAM frame", + ) + + self._events.append(events.DatagramFrameReceived(data=data)) + + def _handle_handshake_done_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a HANDSHAKE_DONE frame. + """ + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_handshake_done_frame() + ) + + if not self._is_client: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Clients must not send HANDSHAKE_DONE frames", + ) + + # for clients, the handshake is now confirmed + if not self._handshake_confirmed: + self._discard_epoch(tls.Epoch.HANDSHAKE) + self._handshake_confirmed = True + self._loss.peer_completed_address_validation = True + + def _handle_max_data_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a MAX_DATA frame. + + This adjusts the total amount of we can send to the peer. + """ + max_data = buf.pull_uint_var() + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_connection_limit_frame( + frame_type=frame_type, maximum=max_data + ) + ) + + if max_data > self._remote_max_data: + self._logger.debug("Remote max_data raised to %d", max_data) + self._remote_max_data = max_data + + def _handle_max_stream_data_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a MAX_STREAM_DATA frame. + + This adjusts the amount of data we can send on a specific stream. + """ + stream_id = buf.pull_uint_var() + max_stream_data = buf.pull_uint_var() + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_max_stream_data_frame( + maximum=max_stream_data, stream_id=stream_id + ) + ) + + # check stream direction + self._assert_stream_can_send(frame_type, stream_id) + + stream = self._get_or_create_stream(frame_type, stream_id) + if max_stream_data > stream.max_stream_data_remote: + self._logger.debug( + "Stream %d remote max_stream_data raised to %d", + stream_id, + max_stream_data, + ) + stream.max_stream_data_remote = max_stream_data + + def _handle_max_streams_bidi_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a MAX_STREAMS_BIDI frame. + + This raises number of bidirectional streams we can initiate to the peer. + """ + max_streams = buf.pull_uint_var() + if max_streams > STREAM_COUNT_MAX: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="Maximum Streams cannot exceed 2^60", + ) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_connection_limit_frame( + frame_type=frame_type, maximum=max_streams + ) + ) + + if max_streams > self._remote_max_streams_bidi: + self._logger.debug("Remote max_streams_bidi raised to %d", max_streams) + self._remote_max_streams_bidi = max_streams + self._unblock_streams(is_unidirectional=False) + + def _handle_max_streams_uni_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a MAX_STREAMS_UNI frame. + + This raises number of unidirectional streams we can initiate to the peer. + """ + max_streams = buf.pull_uint_var() + if max_streams > STREAM_COUNT_MAX: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="Maximum Streams cannot exceed 2^60", + ) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_connection_limit_frame( + frame_type=frame_type, maximum=max_streams + ) + ) + + if max_streams > self._remote_max_streams_uni: + self._logger.debug("Remote max_streams_uni raised to %d", max_streams) + self._remote_max_streams_uni = max_streams + self._unblock_streams(is_unidirectional=True) + + def _handle_new_connection_id_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a NEW_CONNECTION_ID frame. + """ + sequence_number = buf.pull_uint_var() + retire_prior_to = buf.pull_uint_var() + length = buf.pull_uint8() + connection_id = buf.pull_bytes(length) + stateless_reset_token = buf.pull_bytes(STATELESS_RESET_TOKEN_SIZE) + if not connection_id or len(connection_id) > CONNECTION_ID_MAX_SIZE: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="Length must be greater than 0 and less than 20", + ) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_new_connection_id_frame( + connection_id=connection_id, + retire_prior_to=retire_prior_to, + sequence_number=sequence_number, + stateless_reset_token=stateless_reset_token, + ) + ) + + # sanity check + if retire_prior_to > sequence_number: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Retire Prior To is greater than Sequence Number", + ) + + # only accept retire_prior_to if it is bigger than the one we know + self._peer_retire_prior_to = max(retire_prior_to, self._peer_retire_prior_to) + + # determine which CIDs to retire + change_cid = False + retire = [ + cid + for cid in self._peer_cid_available + if cid.sequence_number < self._peer_retire_prior_to + ] + if self._peer_cid.sequence_number < self._peer_retire_prior_to: + change_cid = True + retire.insert(0, self._peer_cid) + + # update available CIDs + self._peer_cid_available = [ + cid + for cid in self._peer_cid_available + if cid.sequence_number >= self._peer_retire_prior_to + ] + if ( + sequence_number >= self._peer_retire_prior_to + and sequence_number not in self._peer_cid_sequence_numbers + ): + self._peer_cid_available.append( + QuicConnectionId( + cid=connection_id, + sequence_number=sequence_number, + stateless_reset_token=stateless_reset_token, + ) + ) + self._peer_cid_sequence_numbers.add(sequence_number) + + # retire previous CIDs + for quic_connection_id in retire: + self._retire_peer_cid(quic_connection_id) + + # assign new CID if we retired the active one + if change_cid: + self._consume_peer_cid() + + # check number of active connection IDs, including the selected one + if 1 + len(self._peer_cid_available) > self._local_active_connection_id_limit: + raise QuicConnectionError( + error_code=QuicErrorCode.CONNECTION_ID_LIMIT_ERROR, + frame_type=frame_type, + reason_phrase="Too many active connection IDs", + ) + + # Check the number of retired connection IDs pending, though with a safer limit + # than the 2x recommended in section 5.1.2 of the RFC. Note that we are doing + # the check here and not in _retire_peer_cid() because we know the frame type to + # use here, and because it is the new connection id path that is potentially + # dangerous. We may transiently go a bit over the limit due to unacked frames + # getting added back to the list, but that's ok as it is bounded. + if len(self._retire_connection_ids) > min( + self._local_active_connection_id_limit * 4, MAX_PENDING_RETIRES + ): + raise QuicConnectionError( + error_code=QuicErrorCode.CONNECTION_ID_LIMIT_ERROR, + frame_type=frame_type, + reason_phrase="Too many pending retired connection IDs", + ) + + def _handle_new_token_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a NEW_TOKEN frame. + """ + length = buf.pull_uint_var() + token = buf.pull_bytes(length) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_new_token_frame(token=token) + ) + + if not self._is_client: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Clients must not send NEW_TOKEN frames", + ) + + if self._token_handler is not None: + self._token_handler(token) + + def _handle_padding_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a PADDING frame. + """ + # consume padding + pos = buf.tell() + for byte in buf.data_slice(pos, buf.capacity): + if byte: + break + pos += 1 + buf.seek(pos) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append(self._quic_logger.encode_padding_frame()) + + def _handle_path_challenge_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a PATH_CHALLENGE frame. + """ + data = buf.pull_bytes(8) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_path_challenge_frame(data=data) + ) + + context.network_path.remote_challenge = data + + def _handle_path_response_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a PATH_RESPONSE frame. + """ + data = buf.pull_bytes(8) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_path_response_frame(data=data) + ) + + if data != context.network_path.local_challenge: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Response does not match challenge", + ) + self._logger.debug( + "Network path %s validated by challenge", context.network_path.addr + ) + context.network_path.is_validated = True + + def _handle_ping_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a PING frame. + """ + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append(self._quic_logger.encode_ping_frame()) + + def _handle_reset_stream_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a RESET_STREAM frame. + """ + stream_id = buf.pull_uint_var() + error_code = buf.pull_uint_var() + final_size = buf.pull_uint_var() + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_reset_stream_frame( + error_code=error_code, final_size=final_size, stream_id=stream_id + ) + ) + + # check stream direction + self._assert_stream_can_receive(frame_type, stream_id) + + # check flow-control limits + stream = self._get_or_create_stream(frame_type, stream_id) + if final_size > stream.max_stream_data_local: + raise QuicConnectionError( + error_code=QuicErrorCode.FLOW_CONTROL_ERROR, + frame_type=frame_type, + reason_phrase="Over stream data limit", + ) + newly_received = max(0, final_size - stream.receiver.highest_offset) + if self._local_max_data.used + newly_received > self._local_max_data.value: + raise QuicConnectionError( + error_code=QuicErrorCode.FLOW_CONTROL_ERROR, + frame_type=frame_type, + reason_phrase="Over connection data limit", + ) + + # process reset + self._logger.info( + "Stream %d reset by peer (error code %d, final size %d)", + stream_id, + error_code, + final_size, + ) + try: + event = stream.receiver.handle_reset( + error_code=error_code, final_size=final_size + ) + except FinalSizeError as exc: + raise QuicConnectionError( + error_code=QuicErrorCode.FINAL_SIZE_ERROR, + frame_type=frame_type, + reason_phrase=str(exc), + ) + if event is not None: + self._events.append(event) + self._local_max_data.used += newly_received + + def _handle_retire_connection_id_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a RETIRE_CONNECTION_ID frame. + """ + sequence_number = buf.pull_uint_var() + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_retire_connection_id_frame(sequence_number) + ) + + if sequence_number >= self._host_cid_seq: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Cannot retire unknown connection ID", + ) + + # find the connection ID by sequence number + for index, connection_id in enumerate(self._host_cids): + if connection_id.sequence_number == sequence_number: + if connection_id.cid == context.host_cid: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Cannot retire current connection ID", + ) + self._logger.debug( + "Peer retiring CID %s (%d)", + dump_cid(connection_id.cid), + connection_id.sequence_number, + ) + del self._host_cids[index] + self._events.append( + events.ConnectionIdRetired(connection_id=connection_id.cid) + ) + break + + # issue a new connection ID + self._replenish_connection_ids() + + def _handle_stop_sending_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a STOP_SENDING frame. + """ + stream_id = buf.pull_uint_var() + error_code = buf.pull_uint_var() # application error code + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_stop_sending_frame( + error_code=error_code, stream_id=stream_id + ) + ) + + # check stream direction + self._assert_stream_can_send(frame_type, stream_id) + + # reset the stream + stream = self._get_or_create_stream(frame_type, stream_id) + stream.sender.reset(error_code=QuicErrorCode.NO_ERROR) + + self._events.append( + events.StopSendingReceived(error_code=error_code, stream_id=stream_id) + ) + + def _handle_stream_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a STREAM frame. + """ + stream_id = buf.pull_uint_var() + if frame_type & 4: + offset = buf.pull_uint_var() + else: + offset = 0 + if frame_type & 2: + length = buf.pull_uint_var() + else: + length = buf.capacity - buf.tell() + if offset + length > UINT_VAR_MAX: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="offset + length cannot exceed 2^62 - 1", + ) + frame = QuicStreamFrame( + offset=offset, data=buf.pull_bytes(length), fin=bool(frame_type & 1) + ) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_stream_frame(frame, stream_id=stream_id) + ) + + # check stream direction + self._assert_stream_can_receive(frame_type, stream_id) + + # check flow-control limits + stream = self._get_or_create_stream(frame_type, stream_id) + if offset + length > stream.max_stream_data_local: + raise QuicConnectionError( + error_code=QuicErrorCode.FLOW_CONTROL_ERROR, + frame_type=frame_type, + reason_phrase="Over stream data limit", + ) + newly_received = max(0, offset + length - stream.receiver.highest_offset) + if self._local_max_data.used + newly_received > self._local_max_data.value: + raise QuicConnectionError( + error_code=QuicErrorCode.FLOW_CONTROL_ERROR, + frame_type=frame_type, + reason_phrase="Over connection data limit", + ) + + # process data + try: + event = stream.receiver.handle_frame(frame) + except FinalSizeError as exc: + raise QuicConnectionError( + error_code=QuicErrorCode.FINAL_SIZE_ERROR, + frame_type=frame_type, + reason_phrase=str(exc), + ) + if event is not None: + self._events.append(event) + self._local_max_data.used += newly_received + + def _handle_stream_data_blocked_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a STREAM_DATA_BLOCKED frame. + """ + stream_id = buf.pull_uint_var() + limit = buf.pull_uint_var() + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_stream_data_blocked_frame( + limit=limit, stream_id=stream_id + ) + ) + + # check stream direction + self._assert_stream_can_receive(frame_type, stream_id) + + self._get_or_create_stream(frame_type, stream_id) + + def _handle_streams_blocked_frame( + self, context: QuicReceiveContext, frame_type: int, buf: Buffer + ) -> None: + """ + Handle a STREAMS_BLOCKED frame. + """ + limit = buf.pull_uint_var() + if limit > STREAM_COUNT_MAX: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="Maximum Streams cannot exceed 2^60", + ) + + # log frame + if self._quic_logger is not None: + context.quic_logger_frames.append( + self._quic_logger.encode_streams_blocked_frame( + is_unidirectional=frame_type == QuicFrameType.STREAMS_BLOCKED_UNI, + limit=limit, + ) + ) + + def _log_key_retired(self, key_type: str, trigger: str) -> None: + """ + Log a key retirement. + """ + if self._quic_logger is not None: + self._quic_logger.log_event( + category="security", + event="key_retired", + data={"key_type": key_type, "trigger": trigger}, + ) + + def _log_key_updated(self, key_type: str, trigger: str) -> None: + """ + Log a key update. + """ + if self._quic_logger is not None: + self._quic_logger.log_event( + category="security", + event="key_updated", + data={"key_type": key_type, "trigger": trigger}, + ) + + def _on_ack_delivery( + self, delivery: QuicDeliveryState, space: QuicPacketSpace, highest_acked: int + ) -> None: + """ + Callback when an ACK frame is acknowledged or lost. + """ + if delivery == QuicDeliveryState.ACKED: + space.ack_queue.subtract(0, highest_acked + 1) + + def _on_connection_limit_delivery( + self, delivery: QuicDeliveryState, limit: Limit + ) -> None: + """ + Callback when a MAX_DATA or MAX_STREAMS frame is acknowledged or lost. + """ + if delivery != QuicDeliveryState.ACKED: + limit.sent = 0 + + def _on_handshake_done_delivery(self, delivery: QuicDeliveryState) -> None: + """ + Callback when a HANDSHAKE_DONE frame is acknowledged or lost. + """ + if delivery != QuicDeliveryState.ACKED: + self._handshake_done_pending = True + + def _on_max_stream_data_delivery( + self, delivery: QuicDeliveryState, stream: QuicStream + ) -> None: + """ + Callback when a MAX_STREAM_DATA frame is acknowledged or lost. + """ + if delivery != QuicDeliveryState.ACKED: + stream.max_stream_data_local_sent = 0 + + def _on_new_connection_id_delivery( + self, delivery: QuicDeliveryState, connection_id: QuicConnectionId + ) -> None: + """ + Callback when a NEW_CONNECTION_ID frame is acknowledged or lost. + """ + if delivery != QuicDeliveryState.ACKED: + connection_id.was_sent = False + + def _on_ping_delivery( + self, delivery: QuicDeliveryState, uids: Sequence[int] + ) -> None: + """ + Callback when a PING frame is acknowledged or lost. + """ + if delivery == QuicDeliveryState.ACKED: + self._logger.debug("Received PING%s response", "" if uids else " (probe)") + for uid in uids: + self._events.append(events.PingAcknowledged(uid=uid)) + else: + self._ping_pending.extend(uids) + + def _on_retire_connection_id_delivery( + self, delivery: QuicDeliveryState, sequence_number: int + ) -> None: + """ + Callback when a RETIRE_CONNECTION_ID frame is acknowledged or lost. + """ + if delivery != QuicDeliveryState.ACKED: + self._retire_connection_ids.append(sequence_number) + + def _payload_received( + self, + context: QuicReceiveContext, + plain: bytes, + crypto_frame_required: bool = False, + ) -> Tuple[bool, bool]: + """ + Handle a QUIC packet payload. + """ + buf = Buffer(data=plain) + + crypto_frame_found = False + frame_found = False + is_ack_eliciting = False + is_probing = None + while not buf.eof(): + # get frame type + try: + frame_type = buf.pull_uint_var() + except BufferReadError: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=None, + reason_phrase="Malformed frame type", + ) + + # check frame type is known + try: + frame_handler, frame_epochs = self.__frame_handlers[frame_type] + except KeyError: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="Unknown frame type", + ) + + # check frame type is allowed for the epoch + if context.epoch not in frame_epochs: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=frame_type, + reason_phrase="Unexpected frame type", + ) + + # handle the frame + try: + frame_handler(context, frame_type, buf) + except BufferReadError: + raise QuicConnectionError( + error_code=QuicErrorCode.FRAME_ENCODING_ERROR, + frame_type=frame_type, + reason_phrase="Failed to parse frame", + ) + except StreamFinishedError: + # we lack the state for the stream, ignore the frame + pass + + # update ACK only / probing flags + frame_found = True + + if frame_type == QuicFrameType.CRYPTO: + crypto_frame_found = True + + if frame_type not in NON_ACK_ELICITING_FRAME_TYPES: + is_ack_eliciting = True + + if frame_type not in PROBING_FRAME_TYPES: + is_probing = False + elif is_probing is None: + is_probing = True + + if not frame_found: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=QuicFrameType.PADDING, + reason_phrase="Packet contains no frames", + ) + + # RFC 9000 - 17.2.2. Initial Packet + # The first packet sent by a client always includes a CRYPTO frame. + if crypto_frame_required and not crypto_frame_found: + raise QuicConnectionError( + error_code=QuicErrorCode.PROTOCOL_VIOLATION, + frame_type=QuicFrameType.PADDING, + reason_phrase="Packet contains no CRYPTO frame", + ) + + return is_ack_eliciting, bool(is_probing) + + def _replenish_connection_ids(self) -> None: + """ + Generate new connection IDs. + """ + while len(self._host_cids) < min(8, self._remote_active_connection_id_limit): + self._host_cids.append( + QuicConnectionId( + cid=os.urandom(self._configuration.connection_id_length), + sequence_number=self._host_cid_seq, + stateless_reset_token=os.urandom(16), + ) + ) + self._host_cid_seq += 1 + + def _retire_peer_cid(self, connection_id: QuicConnectionId) -> None: + """ + Retire a destination connection ID. + """ + self._logger.debug( + "Retiring CID %s (%d) [%d]", + dump_cid(connection_id.cid), + connection_id.sequence_number, + len(self._retire_connection_ids) + 1, + ) + self._retire_connection_ids.append(connection_id.sequence_number) + + def _push_crypto_data(self) -> None: + for epoch, buf in self._crypto_buffers.items(): + self._crypto_streams[epoch].sender.write(buf.data) + buf.seek(0) + + def _send_probe(self) -> None: + self._probe_pending = True + + def _parse_transport_parameters( + self, data: bytes, from_session_ticket: bool = False + ) -> None: + """ + Parse and apply remote transport parameters. + + `from_session_ticket` is `True` when restoring saved transport parameters, + and `False` when handling received transport parameters. + """ + + try: + quic_transport_parameters = pull_quic_transport_parameters( + Buffer(data=data) + ) + except ValueError: + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="Could not parse QUIC transport parameters", + ) + + # log event + if self._quic_logger is not None and not from_session_ticket: + self._quic_logger.log_event( + category="transport", + event="parameters_set", + data=self._quic_logger.encode_transport_parameters( + owner="remote", parameters=quic_transport_parameters + ), + ) + + # validate remote parameters + if not self._is_client: + for attr in [ + "original_destination_connection_id", + "preferred_address", + "retry_source_connection_id", + "stateless_reset_token", + ]: + if getattr(quic_transport_parameters, attr) is not None: + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="%s is not allowed for clients" % attr, + ) + + if not from_session_ticket: + if ( + quic_transport_parameters.initial_source_connection_id + != self._remote_initial_source_connection_id + ): + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="initial_source_connection_id does not match", + ) + if self._is_client and ( + quic_transport_parameters.original_destination_connection_id + != self._original_destination_connection_id + ): + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="original_destination_connection_id does not match", + ) + if self._is_client and ( + quic_transport_parameters.retry_source_connection_id + != self._retry_source_connection_id + ): + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="retry_source_connection_id does not match", + ) + if ( + quic_transport_parameters.active_connection_id_limit is not None + and quic_transport_parameters.active_connection_id_limit < 2 + ): + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="active_connection_id_limit must be no less than 2", + ) + if ( + quic_transport_parameters.ack_delay_exponent is not None + and quic_transport_parameters.ack_delay_exponent > 20 + ): + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="ack_delay_exponent must be <= 20", + ) + if ( + quic_transport_parameters.max_ack_delay is not None + and quic_transport_parameters.max_ack_delay >= 2**14 + ): + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase="max_ack_delay must be < 2^14", + ) + if quic_transport_parameters.max_udp_payload_size is not None and ( + quic_transport_parameters.max_udp_payload_size + < SMALLEST_MAX_DATAGRAM_SIZE + ): + raise QuicConnectionError( + error_code=QuicErrorCode.TRANSPORT_PARAMETER_ERROR, + frame_type=QuicFrameType.CRYPTO, + reason_phrase=( + f"max_udp_payload_size must be >= {SMALLEST_MAX_DATAGRAM_SIZE}" + ), + ) + + # store remote parameters + if not from_session_ticket: + if quic_transport_parameters.ack_delay_exponent is not None: + self._remote_ack_delay_exponent = self._remote_ack_delay_exponent + if quic_transport_parameters.max_ack_delay is not None: + self._loss.max_ack_delay = ( + quic_transport_parameters.max_ack_delay / 1000.0 + ) + if ( + self._is_client + and self._peer_cid.sequence_number == 0 + and quic_transport_parameters.stateless_reset_token is not None + ): + self._peer_cid.stateless_reset_token = ( + quic_transport_parameters.stateless_reset_token + ) + + if quic_transport_parameters.active_connection_id_limit is not None: + self._remote_active_connection_id_limit = ( + quic_transport_parameters.active_connection_id_limit + ) + if quic_transport_parameters.max_idle_timeout is not None: + self._remote_max_idle_timeout = ( + quic_transport_parameters.max_idle_timeout / 1000.0 + ) + self._remote_max_datagram_frame_size = ( + quic_transport_parameters.max_datagram_frame_size + ) + for param in [ + "max_data", + "max_stream_data_bidi_local", + "max_stream_data_bidi_remote", + "max_stream_data_uni", + "max_streams_bidi", + "max_streams_uni", + ]: + value = getattr(quic_transport_parameters, "initial_" + param) + if value is not None: + setattr(self, "_remote_" + param, value) + + def _serialize_transport_parameters(self) -> bytes: + quic_transport_parameters = QuicTransportParameters( + ack_delay_exponent=self._local_ack_delay_exponent, + active_connection_id_limit=self._local_active_connection_id_limit, + max_idle_timeout=int(self._configuration.idle_timeout * 1000), + initial_max_data=self._local_max_data.value, + initial_max_stream_data_bidi_local=self._local_max_stream_data_bidi_local, + initial_max_stream_data_bidi_remote=self._local_max_stream_data_bidi_remote, + initial_max_stream_data_uni=self._local_max_stream_data_uni, + initial_max_streams_bidi=self._local_max_streams_bidi.value, + initial_max_streams_uni=self._local_max_streams_uni.value, + initial_source_connection_id=self._local_initial_source_connection_id, + max_ack_delay=25, + max_datagram_frame_size=self._configuration.max_datagram_frame_size, + quantum_readiness=( + b"Q" * SMALLEST_MAX_DATAGRAM_SIZE + if self._configuration.quantum_readiness_test + else None + ), + stateless_reset_token=self._host_cids[0].stateless_reset_token, + ) + if not self._is_client: + quic_transport_parameters.original_destination_connection_id = ( + self._original_destination_connection_id + ) + quic_transport_parameters.retry_source_connection_id = ( + self._retry_source_connection_id + ) + + # log event + if self._quic_logger is not None: + self._quic_logger.log_event( + category="transport", + event="parameters_set", + data=self._quic_logger.encode_transport_parameters( + owner="local", parameters=quic_transport_parameters + ), + ) + + buf = Buffer(capacity=3 * self._max_datagram_size) + push_quic_transport_parameters(buf, quic_transport_parameters) + return buf.data + + def _set_state(self, state: QuicConnectionState) -> None: + self._logger.debug("%s -> %s", self._state, state) + self._state = state + + def _stream_can_receive(self, stream_id: int) -> bool: + return stream_is_client_initiated( + stream_id + ) != self._is_client or not stream_is_unidirectional(stream_id) + + def _stream_can_send(self, stream_id: int) -> bool: + return stream_is_client_initiated( + stream_id + ) == self._is_client or not stream_is_unidirectional(stream_id) + + def _unblock_streams(self, is_unidirectional: bool) -> None: + if is_unidirectional: + max_stream_data_remote = self._remote_max_stream_data_uni + max_streams = self._remote_max_streams_uni + streams_blocked = self._streams_blocked_uni + else: + max_stream_data_remote = self._remote_max_stream_data_bidi_remote + max_streams = self._remote_max_streams_bidi + streams_blocked = self._streams_blocked_bidi + + while streams_blocked and streams_blocked[0].stream_id // 4 < max_streams: + stream = streams_blocked.pop(0) + stream.is_blocked = False + stream.max_stream_data_remote = max_stream_data_remote + + if not self._streams_blocked_bidi and not self._streams_blocked_uni: + self._streams_blocked_pending = False + + def _update_traffic_key( + self, + direction: tls.Direction, + epoch: tls.Epoch, + cipher_suite: tls.CipherSuite, + secret: bytes, + ) -> None: + """ + Callback which is invoked by the TLS engine when new traffic keys are + available. + """ + secrets_log_file = self._configuration.secrets_log_file + if secrets_log_file is not None: + label_row = self._is_client == (direction == tls.Direction.DECRYPT) + label = SECRETS_LABELS[label_row][epoch.value] + secrets_log_file.write( + "%s %s %s\n" % (label, self.tls.client_random.hex(), secret.hex()) + ) + secrets_log_file.flush() + + crypto = self._cryptos[epoch] + if direction == tls.Direction.ENCRYPT: + crypto.send.setup( + cipher_suite=cipher_suite, secret=secret, version=self._version + ) + else: + crypto.recv.setup( + cipher_suite=cipher_suite, secret=secret, version=self._version + ) + + def _write_application( + self, builder: QuicPacketBuilder, network_path: QuicNetworkPath, now: float + ) -> None: + crypto_stream: Optional[QuicStream] = None + if self._cryptos[tls.Epoch.ONE_RTT].send.is_valid(): + crypto = self._cryptos[tls.Epoch.ONE_RTT] + crypto_stream = self._crypto_streams[tls.Epoch.ONE_RTT] + packet_type = PACKET_TYPE_ONE_RTT + elif self._cryptos[tls.Epoch.ZERO_RTT].send.is_valid(): + crypto = self._cryptos[tls.Epoch.ZERO_RTT] + packet_type = PACKET_TYPE_ZERO_RTT + else: + return + space = self._spaces[tls.Epoch.ONE_RTT] + + while True: + # apply pacing, except if we have ACKs to send + if space.ack_at is None or space.ack_at >= now: + self._pacing_at = self._loss._pacer.next_send_time(now=now) + if self._pacing_at is not None: + break + builder.start_packet(packet_type, crypto) + + if self._handshake_complete: + # ACK + if space.ack_at is not None and space.ack_at <= now: + self._write_ack_frame(builder=builder, space=space, now=now) + + # HANDSHAKE_DONE + if self._handshake_done_pending: + self._write_handshake_done_frame(builder=builder) + self._handshake_done_pending = False + + # PATH CHALLENGE + if ( + not network_path.is_validated + and network_path.local_challenge is None + ): + challenge = os.urandom(8) + self._write_path_challenge_frame( + builder=builder, challenge=challenge + ) + network_path.local_challenge = challenge + + # PATH RESPONSE + if network_path.remote_challenge is not None: + self._write_path_response_frame( + builder=builder, challenge=network_path.remote_challenge + ) + network_path.remote_challenge = None + + # NEW_CONNECTION_ID + for connection_id in self._host_cids: + if not connection_id.was_sent: + self._write_new_connection_id_frame( + builder=builder, connection_id=connection_id + ) + + # RETIRE_CONNECTION_ID + for sequence_number in self._retire_connection_ids[:]: + self._write_retire_connection_id_frame( + builder=builder, sequence_number=sequence_number + ) + self._retire_connection_ids.pop(0) + + # STREAMS_BLOCKED + if self._streams_blocked_pending: + if self._streams_blocked_bidi: + self._write_streams_blocked_frame( + builder=builder, + frame_type=QuicFrameType.STREAMS_BLOCKED_BIDI, + limit=self._remote_max_streams_bidi, + ) + if self._streams_blocked_uni: + self._write_streams_blocked_frame( + builder=builder, + frame_type=QuicFrameType.STREAMS_BLOCKED_UNI, + limit=self._remote_max_streams_uni, + ) + self._streams_blocked_pending = False + + # MAX_DATA and MAX_STREAMS + self._write_connection_limits(builder=builder, space=space) + + # stream-level limits + for stream in self._streams.values(): + self._write_stream_limits(builder=builder, space=space, stream=stream) + + # PING (user-request) + if self._ping_pending: + self._write_ping_frame(builder, self._ping_pending) + self._ping_pending.clear() + + # PING (probe) + if self._probe_pending: + self._write_ping_frame(builder, comment="probe") + self._probe_pending = False + + # CRYPTO + if crypto_stream is not None and not crypto_stream.sender.buffer_is_empty: + self._write_crypto_frame( + builder=builder, space=space, stream=crypto_stream + ) + + # DATAGRAM + while self._datagrams_pending: + try: + self._write_datagram_frame( + builder=builder, + data=self._datagrams_pending[0], + frame_type=QuicFrameType.DATAGRAM_WITH_LENGTH, + ) + self._datagrams_pending.popleft() + except QuicPacketBuilderStop: + break + + sent: Set[QuicStream] = set() + discarded: Set[QuicStream] = set() + try: + for stream in self._streams_queue: + # if the stream is finished, discard it + if stream.is_finished: + self._logger.debug("Stream %d discarded", stream.stream_id) + self._streams.pop(stream.stream_id) + self._streams_finished.add(stream.stream_id) + discarded.add(stream) + continue + + if stream.receiver.stop_pending: + # STOP_SENDING + self._write_stop_sending_frame(builder=builder, stream=stream) + + if stream.sender.reset_pending: + # RESET_STREAM + self._write_reset_stream_frame(builder=builder, stream=stream) + elif not stream.is_blocked and not stream.sender.buffer_is_empty: + # STREAM + used = self._write_stream_frame( + builder=builder, + space=space, + stream=stream, + max_offset=min( + stream.sender.highest_offset + + self._remote_max_data + - self._remote_max_data_used, + stream.max_stream_data_remote, + ), + ) + self._remote_max_data_used += used + if used > 0: + sent.add(stream) + + finally: + # Make a new stream service order, putting served ones at the end. + # + # This method of updating the streams queue ensures that discarded + # streams are removed and ones which sent are moved to the end even + # if an exception occurs in the loop. + self._streams_queue = [ + stream + for stream in self._streams_queue + if not (stream in discarded or stream in sent) + ] + self._streams_queue.extend(sent) + + if builder.packet_is_empty: + break + else: + self._loss._pacer.update_after_send(now=now) + + def _write_handshake( + self, builder: QuicPacketBuilder, epoch: tls.Epoch, now: float + ) -> None: + crypto = self._cryptos[epoch] + if not crypto.send.is_valid(): + return + + crypto_stream = self._crypto_streams[epoch] + space = self._spaces[epoch] + + while True: + if epoch == tls.Epoch.INITIAL: + packet_type = PACKET_TYPE_INITIAL + else: + packet_type = PACKET_TYPE_HANDSHAKE + builder.start_packet(packet_type, crypto) + + # ACK + if space.ack_at is not None: + self._write_ack_frame(builder=builder, space=space, now=now) + + # CRYPTO + if not crypto_stream.sender.buffer_is_empty: + if self._write_crypto_frame( + builder=builder, space=space, stream=crypto_stream + ): + self._probe_pending = False + + # PING (probe) + if ( + self._probe_pending + and not self._handshake_complete + and ( + epoch == tls.Epoch.HANDSHAKE + or not self._cryptos[tls.Epoch.HANDSHAKE].send.is_valid() + ) + ): + self._write_ping_frame(builder, comment="probe") + self._probe_pending = False + + if builder.packet_is_empty: + break + + def _write_ack_frame( + self, builder: QuicPacketBuilder, space: QuicPacketSpace, now: float + ) -> None: + # calculate ACK delay + ack_delay = now - space.largest_received_time + ack_delay_encoded = int(ack_delay * 1000000) >> self._local_ack_delay_exponent + + buf = builder.start_frame( + QuicFrameType.ACK, + capacity=ACK_FRAME_CAPACITY, + handler=self._on_ack_delivery, + handler_args=(space, space.largest_received_packet), + ) + ranges = push_ack_frame(buf, space.ack_queue, ack_delay_encoded) + space.ack_at = None + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_ack_frame( + ranges=space.ack_queue, delay=ack_delay + ) + ) + + # check if we need to trigger an ACK-of-ACK + if ranges > 1 and builder.packet_number % 8 == 0: + self._write_ping_frame(builder, comment="ACK-of-ACK trigger") + + def _write_connection_close_frame( + self, + builder: QuicPacketBuilder, + epoch: tls.Epoch, + error_code: int, + frame_type: Optional[int], + reason_phrase: str, + ) -> None: + # convert application-level close to transport-level close in early stages + if frame_type is None and epoch in (tls.Epoch.INITIAL, tls.Epoch.HANDSHAKE): + error_code = QuicErrorCode.APPLICATION_ERROR + frame_type = QuicFrameType.PADDING + reason_phrase = "" + + reason_bytes = reason_phrase.encode("utf8") + reason_length = len(reason_bytes) + + if frame_type is None: + buf = builder.start_frame( + QuicFrameType.APPLICATION_CLOSE, + capacity=APPLICATION_CLOSE_FRAME_CAPACITY + reason_length, + ) + buf.push_uint_var(error_code) + buf.push_uint_var(reason_length) + buf.push_bytes(reason_bytes) + else: + buf = builder.start_frame( + QuicFrameType.TRANSPORT_CLOSE, + capacity=TRANSPORT_CLOSE_FRAME_CAPACITY + reason_length, + ) + buf.push_uint_var(error_code) + buf.push_uint_var(frame_type) + buf.push_uint_var(reason_length) + buf.push_bytes(reason_bytes) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_connection_close_frame( + error_code=error_code, + frame_type=frame_type, + reason_phrase=reason_phrase, + ) + ) + + def _write_connection_limits( + self, builder: QuicPacketBuilder, space: QuicPacketSpace + ) -> None: + """ + Raise MAX_DATA or MAX_STREAMS if needed. + """ + for limit in ( + self._local_max_data, + self._local_max_streams_bidi, + self._local_max_streams_uni, + ): + if limit.used * 2 > limit.value: + limit.value *= 2 + self._logger.debug("Local %s raised to %d", limit.name, limit.value) + if limit.value != limit.sent: + buf = builder.start_frame( + limit.frame_type, + capacity=CONNECTION_LIMIT_FRAME_CAPACITY, + handler=self._on_connection_limit_delivery, + handler_args=(limit,), + ) + buf.push_uint_var(limit.value) + limit.sent = limit.value + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_connection_limit_frame( + frame_type=limit.frame_type, + maximum=limit.value, + ) + ) + + def _write_crypto_frame( + self, builder: QuicPacketBuilder, space: QuicPacketSpace, stream: QuicStream + ) -> bool: + frame_overhead = 3 + size_uint_var(stream.sender.next_offset) + frame = stream.sender.get_frame(builder.remaining_flight_space - frame_overhead) + if frame is not None: + buf = builder.start_frame( + QuicFrameType.CRYPTO, + capacity=frame_overhead, + handler=stream.sender.on_data_delivery, + handler_args=(frame.offset, frame.offset + len(frame.data), False), + ) + buf.push_uint_var(frame.offset) + buf.push_uint16(len(frame.data) | 0x4000) + buf.push_bytes(frame.data) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_crypto_frame(frame) + ) + return True + + return False + + def _write_datagram_frame( + self, builder: QuicPacketBuilder, data: bytes, frame_type: QuicFrameType + ) -> bool: + """ + Write a DATAGRAM frame. + + Returns True if the frame was processed, False otherwise. + """ + assert frame_type == QuicFrameType.DATAGRAM_WITH_LENGTH + length = len(data) + frame_size = 1 + size_uint_var(length) + length + + buf = builder.start_frame(frame_type, capacity=frame_size) + buf.push_uint_var(length) + buf.push_bytes(data) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_datagram_frame(length=length) + ) + + return True + + def _write_handshake_done_frame(self, builder: QuicPacketBuilder) -> None: + builder.start_frame( + QuicFrameType.HANDSHAKE_DONE, + capacity=HANDSHAKE_DONE_FRAME_CAPACITY, + handler=self._on_handshake_done_delivery, + ) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_handshake_done_frame() + ) + + def _write_new_connection_id_frame( + self, builder: QuicPacketBuilder, connection_id: QuicConnectionId + ) -> None: + retire_prior_to = 0 # FIXME + + buf = builder.start_frame( + QuicFrameType.NEW_CONNECTION_ID, + capacity=NEW_CONNECTION_ID_FRAME_CAPACITY, + handler=self._on_new_connection_id_delivery, + handler_args=(connection_id,), + ) + buf.push_uint_var(connection_id.sequence_number) + buf.push_uint_var(retire_prior_to) + buf.push_uint8(len(connection_id.cid)) + buf.push_bytes(connection_id.cid) + buf.push_bytes(connection_id.stateless_reset_token) + + connection_id.was_sent = True + self._events.append(events.ConnectionIdIssued(connection_id=connection_id.cid)) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_new_connection_id_frame( + connection_id=connection_id.cid, + retire_prior_to=retire_prior_to, + sequence_number=connection_id.sequence_number, + stateless_reset_token=connection_id.stateless_reset_token, + ) + ) + + def _write_path_challenge_frame( + self, builder: QuicPacketBuilder, challenge: bytes + ) -> None: + buf = builder.start_frame( + QuicFrameType.PATH_CHALLENGE, capacity=PATH_CHALLENGE_FRAME_CAPACITY + ) + buf.push_bytes(challenge) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_path_challenge_frame(data=challenge) + ) + + def _write_path_response_frame( + self, builder: QuicPacketBuilder, challenge: bytes + ) -> None: + buf = builder.start_frame( + QuicFrameType.PATH_RESPONSE, capacity=PATH_RESPONSE_FRAME_CAPACITY + ) + buf.push_bytes(challenge) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_path_response_frame(data=challenge) + ) + + def _write_ping_frame( + self, builder: QuicPacketBuilder, uids: List[int] = [], comment="" + ): + builder.start_frame( + QuicFrameType.PING, + capacity=PING_FRAME_CAPACITY, + handler=self._on_ping_delivery, + handler_args=(tuple(uids),), + ) + self._logger.debug( + "Sending PING%s in packet %d", + " (%s)" % comment if comment else "", + builder.packet_number, + ) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append(self._quic_logger.encode_ping_frame()) + + def _write_reset_stream_frame( + self, + builder: QuicPacketBuilder, + stream: QuicStream, + ) -> None: + buf = builder.start_frame( + frame_type=QuicFrameType.RESET_STREAM, + capacity=RESET_STREAM_FRAME_CAPACITY, + handler=stream.sender.on_reset_delivery, + ) + frame = stream.sender.get_reset_frame() + buf.push_uint_var(frame.stream_id) + buf.push_uint_var(frame.error_code) + buf.push_uint_var(frame.final_size) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_reset_stream_frame( + error_code=frame.error_code, + final_size=frame.final_size, + stream_id=frame.stream_id, + ) + ) + + def _write_retire_connection_id_frame( + self, builder: QuicPacketBuilder, sequence_number: int + ) -> None: + buf = builder.start_frame( + QuicFrameType.RETIRE_CONNECTION_ID, + capacity=RETIRE_CONNECTION_ID_CAPACITY, + handler=self._on_retire_connection_id_delivery, + handler_args=(sequence_number,), + ) + buf.push_uint_var(sequence_number) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_retire_connection_id_frame(sequence_number) + ) + + def _write_stop_sending_frame( + self, + builder: QuicPacketBuilder, + stream: QuicStream, + ) -> None: + buf = builder.start_frame( + frame_type=QuicFrameType.STOP_SENDING, + capacity=STOP_SENDING_FRAME_CAPACITY, + handler=stream.receiver.on_stop_sending_delivery, + ) + frame = stream.receiver.get_stop_frame() + buf.push_uint_var(frame.stream_id) + buf.push_uint_var(frame.error_code) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_stop_sending_frame( + error_code=frame.error_code, stream_id=frame.stream_id + ) + ) + + def _write_stream_frame( + self, + builder: QuicPacketBuilder, + space: QuicPacketSpace, + stream: QuicStream, + max_offset: int, + ) -> int: + # the frame data size is constrained by our peer's MAX_DATA and + # the space available in the current packet + frame_overhead = ( + 3 + + size_uint_var(stream.stream_id) + + ( + size_uint_var(stream.sender.next_offset) + if stream.sender.next_offset + else 0 + ) + ) + previous_send_highest = stream.sender.highest_offset + frame = stream.sender.get_frame( + builder.remaining_flight_space - frame_overhead, max_offset + ) + + if frame is not None: + frame_type = QuicFrameType.STREAM_BASE | 2 # length + if frame.offset: + frame_type |= 4 + if frame.fin: + frame_type |= 1 + buf = builder.start_frame( + frame_type, + capacity=frame_overhead, + handler=stream.sender.on_data_delivery, + handler_args=(frame.offset, frame.offset + len(frame.data), frame.fin), + ) + buf.push_uint_var(stream.stream_id) + if frame.offset: + buf.push_uint_var(frame.offset) + buf.push_uint16(len(frame.data) | 0x4000) + buf.push_bytes(frame.data) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_stream_frame( + frame, stream_id=stream.stream_id + ) + ) + + return stream.sender.highest_offset - previous_send_highest + else: + return 0 + + def _write_stream_limits( + self, builder: QuicPacketBuilder, space: QuicPacketSpace, stream: QuicStream + ) -> None: + """ + Raise MAX_STREAM_DATA if needed. + + The only case where `stream.max_stream_data_local` is zero is for + locally created unidirectional streams. We skip such streams to avoid + spurious logging. + """ + if ( + stream.max_stream_data_local + and stream.receiver.highest_offset * 2 > stream.max_stream_data_local + ): + stream.max_stream_data_local *= 2 + self._logger.debug( + "Stream %d local max_stream_data raised to %d", + stream.stream_id, + stream.max_stream_data_local, + ) + if stream.max_stream_data_local_sent != stream.max_stream_data_local: + buf = builder.start_frame( + QuicFrameType.MAX_STREAM_DATA, + capacity=MAX_STREAM_DATA_FRAME_CAPACITY, + handler=self._on_max_stream_data_delivery, + handler_args=(stream,), + ) + buf.push_uint_var(stream.stream_id) + buf.push_uint_var(stream.max_stream_data_local) + stream.max_stream_data_local_sent = stream.max_stream_data_local + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_max_stream_data_frame( + maximum=stream.max_stream_data_local, stream_id=stream.stream_id + ) + ) + + def _write_streams_blocked_frame( + self, builder: QuicPacketBuilder, frame_type: QuicFrameType, limit: int + ) -> None: + buf = builder.start_frame(frame_type, capacity=STREAMS_BLOCKED_CAPACITY) + buf.push_uint_var(limit) + + # log frame + if self._quic_logger is not None: + builder.quic_logger_frames.append( + self._quic_logger.encode_streams_blocked_frame( + is_unidirectional=frame_type == QuicFrameType.STREAMS_BLOCKED_UNI, + limit=limit, + ) + ) diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/crypto.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/crypto.py new file mode 100644 index 0000000..013c69f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/crypto.py @@ -0,0 +1,231 @@ +import binascii +from typing import Callable, Optional, Tuple + +from .._crypto import AEAD, CryptoError, HeaderProtection +from ..tls import CipherSuite, cipher_suite_hash, hkdf_expand_label, hkdf_extract +from .packet import decode_packet_number, is_draft_version, is_long_header + +CIPHER_SUITES = { + CipherSuite.AES_128_GCM_SHA256: (b"aes-128-ecb", b"aes-128-gcm"), + CipherSuite.AES_256_GCM_SHA384: (b"aes-256-ecb", b"aes-256-gcm"), + CipherSuite.CHACHA20_POLY1305_SHA256: (b"chacha20", b"chacha20-poly1305"), +} +INITIAL_CIPHER_SUITE = CipherSuite.AES_128_GCM_SHA256 +INITIAL_SALT_DRAFT_29 = binascii.unhexlify("afbfec289993d24c9e9786f19c6111e04390a899") +INITIAL_SALT_VERSION_1 = binascii.unhexlify("38762cf7f55934b34d179ae6a4c80cadccbb7f0a") +SAMPLE_SIZE = 16 + + +Callback = Callable[[str], None] + + +def NoCallback(trigger: str) -> None: + pass + + +class KeyUnavailableError(CryptoError): + pass + + +def derive_key_iv_hp( + cipher_suite: CipherSuite, secret: bytes +) -> Tuple[bytes, bytes, bytes]: + algorithm = cipher_suite_hash(cipher_suite) + if cipher_suite in [ + CipherSuite.AES_256_GCM_SHA384, + CipherSuite.CHACHA20_POLY1305_SHA256, + ]: + key_size = 32 + else: + key_size = 16 + return ( + hkdf_expand_label(algorithm, secret, b"quic key", b"", key_size), + hkdf_expand_label(algorithm, secret, b"quic iv", b"", 12), + hkdf_expand_label(algorithm, secret, b"quic hp", b"", key_size), + ) + + +class CryptoContext: + def __init__( + self, + key_phase: int = 0, + setup_cb: Callback = NoCallback, + teardown_cb: Callback = NoCallback, + ) -> None: + self.aead: Optional[AEAD] = None + self.cipher_suite: Optional[CipherSuite] = None + self.hp: Optional[HeaderProtection] = None + self.key_phase = key_phase + self.secret: Optional[bytes] = None + self.version: Optional[int] = None + self._setup_cb = setup_cb + self._teardown_cb = teardown_cb + + def decrypt_packet( + self, packet: bytes, encrypted_offset: int, expected_packet_number: int + ) -> Tuple[bytes, bytes, int, bool]: + if self.aead is None: + raise KeyUnavailableError("Decryption key is not available") + + # header protection + plain_header, packet_number = self.hp.remove(packet, encrypted_offset) + first_byte = plain_header[0] + + # packet number + pn_length = (first_byte & 0x03) + 1 + packet_number = decode_packet_number( + packet_number, pn_length * 8, expected_packet_number + ) + + # detect key phase change + crypto = self + if not is_long_header(first_byte): + key_phase = (first_byte & 4) >> 2 + if key_phase != self.key_phase: + crypto = next_key_phase(self) + + # payload protection + payload = crypto.aead.decrypt( + packet[len(plain_header) :], plain_header, packet_number + ) + + return plain_header, payload, packet_number, crypto != self + + def encrypt_packet( + self, plain_header: bytes, plain_payload: bytes, packet_number: int + ) -> bytes: + assert self.is_valid(), "Encryption key is not available" + + # payload protection + protected_payload = self.aead.encrypt( + plain_payload, plain_header, packet_number + ) + + # header protection + return self.hp.apply(plain_header, protected_payload) + + def is_valid(self) -> bool: + return self.aead is not None + + def setup(self, cipher_suite: CipherSuite, secret: bytes, version: int) -> None: + hp_cipher_name, aead_cipher_name = CIPHER_SUITES[cipher_suite] + + key, iv, hp = derive_key_iv_hp(cipher_suite, secret) + self.aead = AEAD(aead_cipher_name, key, iv) + self.cipher_suite = cipher_suite + self.hp = HeaderProtection(hp_cipher_name, hp) + self.secret = secret + self.version = version + + # trigger callback + self._setup_cb("tls") + + def teardown(self) -> None: + self.aead = None + self.cipher_suite = None + self.hp = None + self.secret = None + + # trigger callback + self._teardown_cb("tls") + + +def apply_key_phase(self: CryptoContext, crypto: CryptoContext, trigger: str) -> None: + self.aead = crypto.aead + self.key_phase = crypto.key_phase + self.secret = crypto.secret + + # trigger callback + self._setup_cb(trigger) + + +def next_key_phase(self: CryptoContext) -> CryptoContext: + algorithm = cipher_suite_hash(self.cipher_suite) + + crypto = CryptoContext(key_phase=int(not self.key_phase)) + crypto.setup( + cipher_suite=self.cipher_suite, + secret=hkdf_expand_label( + algorithm, self.secret, b"quic ku", b"", algorithm.digest_size + ), + version=self.version, + ) + return crypto + + +class CryptoPair: + def __init__( + self, + recv_setup_cb: Callback = NoCallback, + recv_teardown_cb: Callback = NoCallback, + send_setup_cb: Callback = NoCallback, + send_teardown_cb: Callback = NoCallback, + ) -> None: + self.aead_tag_size = 16 + self.recv = CryptoContext(setup_cb=recv_setup_cb, teardown_cb=recv_teardown_cb) + self.send = CryptoContext(setup_cb=send_setup_cb, teardown_cb=send_teardown_cb) + self._update_key_requested = False + + def decrypt_packet( + self, packet: bytes, encrypted_offset: int, expected_packet_number: int + ) -> Tuple[bytes, bytes, int]: + plain_header, payload, packet_number, update_key = self.recv.decrypt_packet( + packet, encrypted_offset, expected_packet_number + ) + if update_key: + self._update_key("remote_update") + return plain_header, payload, packet_number + + def encrypt_packet( + self, plain_header: bytes, plain_payload: bytes, packet_number: int + ) -> bytes: + if self._update_key_requested: + self._update_key("local_update") + return self.send.encrypt_packet(plain_header, plain_payload, packet_number) + + def setup_initial(self, cid: bytes, is_client: bool, version: int) -> None: + if is_client: + recv_label, send_label = b"server in", b"client in" + else: + recv_label, send_label = b"client in", b"server in" + + if is_draft_version(version): + initial_salt = INITIAL_SALT_DRAFT_29 + else: + initial_salt = INITIAL_SALT_VERSION_1 + + algorithm = cipher_suite_hash(INITIAL_CIPHER_SUITE) + initial_secret = hkdf_extract(algorithm, initial_salt, cid) + self.recv.setup( + cipher_suite=INITIAL_CIPHER_SUITE, + secret=hkdf_expand_label( + algorithm, initial_secret, recv_label, b"", algorithm.digest_size + ), + version=version, + ) + self.send.setup( + cipher_suite=INITIAL_CIPHER_SUITE, + secret=hkdf_expand_label( + algorithm, initial_secret, send_label, b"", algorithm.digest_size + ), + version=version, + ) + + def teardown(self) -> None: + self.recv.teardown() + self.send.teardown() + + def update_key(self) -> None: + self._update_key_requested = True + + @property + def key_phase(self) -> int: + if self._update_key_requested: + return int(not self.recv.key_phase) + else: + return self.recv.key_phase + + def _update_key(self, trigger: str) -> None: + apply_key_phase(self.recv, next_key_phase(self.recv), trigger=trigger) + apply_key_phase(self.send, next_key_phase(self.send), trigger=trigger) + self._update_key_requested = False diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/events.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/events.py new file mode 100644 index 0000000..8534eae --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/events.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +from typing import Optional + + +class QuicEvent: + """ + Base class for QUIC events. + """ + + pass + + +@dataclass +class ConnectionIdIssued(QuicEvent): + connection_id: bytes + + +@dataclass +class ConnectionIdRetired(QuicEvent): + connection_id: bytes + + +@dataclass +class ConnectionTerminated(QuicEvent): + """ + The ConnectionTerminated event is fired when the QUIC connection is terminated. + """ + + error_code: int + "The error code which was specified when closing the connection." + + frame_type: Optional[int] + "The frame type which caused the connection to be closed, or `None`." + + reason_phrase: str + "The human-readable reason for which the connection was closed." + + +@dataclass +class DatagramFrameReceived(QuicEvent): + """ + The DatagramFrameReceived event is fired when a DATAGRAM frame is received. + """ + + data: bytes + "The data which was received." + + +@dataclass +class HandshakeCompleted(QuicEvent): + """ + The HandshakeCompleted event is fired when the TLS handshake completes. + """ + + alpn_protocol: Optional[str] + "The protocol which was negotiated using ALPN, or `None`." + + early_data_accepted: bool + "Whether early (0-RTT) data was accepted by the remote peer." + + session_resumed: bool + "Whether a TLS session was resumed." + + +@dataclass +class PingAcknowledged(QuicEvent): + """ + The PingAcknowledged event is fired when a PING frame is acknowledged. + """ + + uid: int + "The unique ID of the PING." + + +@dataclass +class ProtocolNegotiated(QuicEvent): + """ + The ProtocolNegotiated event is fired when ALPN negotiation completes. + """ + + alpn_protocol: Optional[str] + "The protocol which was negotiated using ALPN, or `None`." + + +@dataclass +class StopSendingReceived(QuicEvent): + """ + The StopSendingReceived event is fired when the remote peer requests + stopping data transmission on a stream. + """ + + error_code: int + "The error code that was sent from the peer." + + stream_id: int + "The ID of the stream that the peer requested stopping data transmission." + + +@dataclass +class StreamDataReceived(QuicEvent): + """ + The StreamDataReceived event is fired whenever data is received on a + stream. + """ + + data: bytes + "The data which was received." + + end_stream: bool + "Whether the STREAM frame had the FIN bit set." + + stream_id: int + "The ID of the stream the data was received for." + + +@dataclass +class StreamReset(QuicEvent): + """ + The StreamReset event is fired when the remote peer resets a stream. + """ + + error_code: int + "The error code that triggered the reset." + + stream_id: int + "The ID of the stream that was reset." diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/logger.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/logger.py new file mode 100644 index 0000000..8deede6 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/logger.py @@ -0,0 +1,333 @@ +import binascii +import json +import os +import time +from collections import deque +from typing import Any, Deque, Dict, List, Optional + +from ..h3.events import Headers +from .packet import ( + PACKET_TYPE_HANDSHAKE, + PACKET_TYPE_INITIAL, + PACKET_TYPE_MASK, + PACKET_TYPE_ONE_RTT, + PACKET_TYPE_RETRY, + PACKET_TYPE_ZERO_RTT, + QuicFrameType, + QuicStreamFrame, + QuicTransportParameters, +) +from .rangeset import RangeSet + +PACKET_TYPE_NAMES = { + PACKET_TYPE_INITIAL: "initial", + PACKET_TYPE_HANDSHAKE: "handshake", + PACKET_TYPE_ZERO_RTT: "0RTT", + PACKET_TYPE_ONE_RTT: "1RTT", + PACKET_TYPE_RETRY: "retry", +} +QLOG_VERSION = "0.3" + + +def hexdump(data: bytes) -> str: + return binascii.hexlify(data).decode("ascii") + + +class QuicLoggerTrace: + """ + A QUIC event trace. + + Events are logged in the format defined by qlog. + + See: + - https://datatracker.ietf.org/doc/html/draft-ietf-quic-qlog-main-schema-02 + - https://datatracker.ietf.org/doc/html/draft-marx-quic-qlog-quic-events + - https://datatracker.ietf.org/doc/html/draft-marx-quic-qlog-h3-events + """ + + def __init__(self, *, is_client: bool, odcid: bytes) -> None: + self._odcid = odcid + self._events: Deque[Dict[str, Any]] = deque() + self._vantage_point = { + "name": "aioquic", + "type": "client" if is_client else "server", + } + + # QUIC + + def encode_ack_frame(self, ranges: RangeSet, delay: float) -> Dict: + return { + "ack_delay": self.encode_time(delay), + "acked_ranges": [[x.start, x.stop - 1] for x in ranges], + "frame_type": "ack", + } + + def encode_connection_close_frame( + self, error_code: int, frame_type: Optional[int], reason_phrase: str + ) -> Dict: + attrs = { + "error_code": error_code, + "error_space": "application" if frame_type is None else "transport", + "frame_type": "connection_close", + "raw_error_code": error_code, + "reason": reason_phrase, + } + if frame_type is not None: + attrs["trigger_frame_type"] = frame_type + + return attrs + + def encode_connection_limit_frame(self, frame_type: int, maximum: int) -> Dict: + if frame_type == QuicFrameType.MAX_DATA: + return {"frame_type": "max_data", "maximum": maximum} + else: + return { + "frame_type": "max_streams", + "maximum": maximum, + "stream_type": "unidirectional" + if frame_type == QuicFrameType.MAX_STREAMS_UNI + else "bidirectional", + } + + def encode_crypto_frame(self, frame: QuicStreamFrame) -> Dict: + return { + "frame_type": "crypto", + "length": len(frame.data), + "offset": frame.offset, + } + + def encode_data_blocked_frame(self, limit: int) -> Dict: + return {"frame_type": "data_blocked", "limit": limit} + + def encode_datagram_frame(self, length: int) -> Dict: + return {"frame_type": "datagram", "length": length} + + def encode_handshake_done_frame(self) -> Dict: + return {"frame_type": "handshake_done"} + + def encode_max_stream_data_frame(self, maximum: int, stream_id: int) -> Dict: + return { + "frame_type": "max_stream_data", + "maximum": maximum, + "stream_id": stream_id, + } + + def encode_new_connection_id_frame( + self, + connection_id: bytes, + retire_prior_to: int, + sequence_number: int, + stateless_reset_token: bytes, + ) -> Dict: + return { + "connection_id": hexdump(connection_id), + "frame_type": "new_connection_id", + "length": len(connection_id), + "reset_token": hexdump(stateless_reset_token), + "retire_prior_to": retire_prior_to, + "sequence_number": sequence_number, + } + + def encode_new_token_frame(self, token: bytes) -> Dict: + return { + "frame_type": "new_token", + "length": len(token), + "token": hexdump(token), + } + + def encode_padding_frame(self) -> Dict: + return {"frame_type": "padding"} + + def encode_path_challenge_frame(self, data: bytes) -> Dict: + return {"data": hexdump(data), "frame_type": "path_challenge"} + + def encode_path_response_frame(self, data: bytes) -> Dict: + return {"data": hexdump(data), "frame_type": "path_response"} + + def encode_ping_frame(self) -> Dict: + return {"frame_type": "ping"} + + def encode_reset_stream_frame( + self, error_code: int, final_size: int, stream_id: int + ) -> Dict: + return { + "error_code": error_code, + "final_size": final_size, + "frame_type": "reset_stream", + "stream_id": stream_id, + } + + def encode_retire_connection_id_frame(self, sequence_number: int) -> Dict: + return { + "frame_type": "retire_connection_id", + "sequence_number": sequence_number, + } + + def encode_stream_data_blocked_frame(self, limit: int, stream_id: int) -> Dict: + return { + "frame_type": "stream_data_blocked", + "limit": limit, + "stream_id": stream_id, + } + + def encode_stop_sending_frame(self, error_code: int, stream_id: int) -> Dict: + return { + "frame_type": "stop_sending", + "error_code": error_code, + "stream_id": stream_id, + } + + def encode_stream_frame(self, frame: QuicStreamFrame, stream_id: int) -> Dict: + return { + "fin": frame.fin, + "frame_type": "stream", + "length": len(frame.data), + "offset": frame.offset, + "stream_id": stream_id, + } + + def encode_streams_blocked_frame(self, is_unidirectional: bool, limit: int) -> Dict: + return { + "frame_type": "streams_blocked", + "limit": limit, + "stream_type": "unidirectional" if is_unidirectional else "bidirectional", + } + + def encode_time(self, seconds: float) -> float: + """ + Convert a time to milliseconds. + """ + return seconds * 1000 + + def encode_transport_parameters( + self, owner: str, parameters: QuicTransportParameters + ) -> Dict[str, Any]: + data: Dict[str, Any] = {"owner": owner} + for param_name, param_value in parameters.__dict__.items(): + if isinstance(param_value, bool): + data[param_name] = param_value + elif isinstance(param_value, bytes): + data[param_name] = hexdump(param_value) + elif isinstance(param_value, int): + data[param_name] = param_value + return data + + def packet_type(self, packet_type: int) -> str: + return PACKET_TYPE_NAMES.get(packet_type & PACKET_TYPE_MASK, "1RTT") + + # HTTP/3 + + def encode_http3_data_frame(self, length: int, stream_id: int) -> Dict: + return { + "frame": {"frame_type": "data"}, + "length": length, + "stream_id": stream_id, + } + + def encode_http3_headers_frame( + self, length: int, headers: Headers, stream_id: int + ) -> Dict: + return { + "frame": { + "frame_type": "headers", + "headers": self._encode_http3_headers(headers), + }, + "length": length, + "stream_id": stream_id, + } + + def encode_http3_push_promise_frame( + self, length: int, headers: Headers, push_id: int, stream_id: int + ) -> Dict: + return { + "frame": { + "frame_type": "push_promise", + "headers": self._encode_http3_headers(headers), + "push_id": push_id, + }, + "length": length, + "stream_id": stream_id, + } + + def _encode_http3_headers(self, headers: Headers) -> List[Dict]: + return [ + {"name": h[0].decode("utf8"), "value": h[1].decode("utf8")} for h in headers + ] + + # CORE + + def log_event(self, *, category: str, event: str, data: Dict) -> None: + self._events.append( + { + "data": data, + "name": category + ":" + event, + "time": self.encode_time(time.time()), + } + ) + + def to_dict(self) -> Dict[str, Any]: + """ + Return the trace as a dictionary which can be written as JSON. + """ + return { + "common_fields": { + "ODCID": hexdump(self._odcid), + }, + "events": list(self._events), + "vantage_point": self._vantage_point, + } + + +class QuicLogger: + """ + A QUIC event logger which stores traces in memory. + """ + + def __init__(self) -> None: + self._traces: List[QuicLoggerTrace] = [] + + def start_trace(self, is_client: bool, odcid: bytes) -> QuicLoggerTrace: + trace = QuicLoggerTrace(is_client=is_client, odcid=odcid) + self._traces.append(trace) + return trace + + def end_trace(self, trace: QuicLoggerTrace) -> None: + assert trace in self._traces, "QuicLoggerTrace does not belong to QuicLogger" + + def to_dict(self) -> Dict[str, Any]: + """ + Return the traces as a dictionary which can be written as JSON. + """ + return { + "qlog_format": "JSON", + "qlog_version": QLOG_VERSION, + "traces": [trace.to_dict() for trace in self._traces], + } + + +class QuicFileLogger(QuicLogger): + """ + A QUIC event logger which writes one trace per file. + """ + + def __init__(self, path: str) -> None: + if not os.path.isdir(path): + raise ValueError("QUIC log output directory '%s' does not exist" % path) + self.path = path + super().__init__() + + def end_trace(self, trace: QuicLoggerTrace) -> None: + trace_dict = trace.to_dict() + trace_path = os.path.join( + self.path, trace_dict["common_fields"]["ODCID"] + ".qlog" + ) + with open(trace_path, "w") as logger_fp: + json.dump( + { + "qlog_format": "JSON", + "qlog_version": QLOG_VERSION, + "traces": [trace_dict], + }, + logger_fp, + ) + self._traces.remove(trace) diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet.py new file mode 100644 index 0000000..60c639b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet.py @@ -0,0 +1,515 @@ +import binascii +import ipaddress +import os +from dataclasses import dataclass +from enum import IntEnum +from typing import List, Optional, Tuple + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from ..buffer import Buffer +from .rangeset import RangeSet + +PACKET_LONG_HEADER = 0x80 +PACKET_FIXED_BIT = 0x40 +PACKET_SPIN_BIT = 0x20 + +PACKET_TYPE_INITIAL = PACKET_LONG_HEADER | PACKET_FIXED_BIT | 0x00 +PACKET_TYPE_ZERO_RTT = PACKET_LONG_HEADER | PACKET_FIXED_BIT | 0x10 +PACKET_TYPE_HANDSHAKE = PACKET_LONG_HEADER | PACKET_FIXED_BIT | 0x20 +PACKET_TYPE_RETRY = PACKET_LONG_HEADER | PACKET_FIXED_BIT | 0x30 +PACKET_TYPE_ONE_RTT = PACKET_FIXED_BIT +PACKET_TYPE_MASK = 0xF0 + +CONNECTION_ID_MAX_SIZE = 20 +PACKET_NUMBER_MAX_SIZE = 4 +RETRY_AEAD_KEY_DRAFT_29 = binascii.unhexlify("ccce187ed09a09d05728155a6cb96be1") +RETRY_AEAD_KEY_VERSION_1 = binascii.unhexlify("be0c690b9f66575a1d766b54e368c84e") +RETRY_AEAD_NONCE_DRAFT_29 = binascii.unhexlify("e54930f97f2136f0530a8c1c") +RETRY_AEAD_NONCE_VERSION_1 = binascii.unhexlify("461599d35d632bf2239825bb") +RETRY_INTEGRITY_TAG_SIZE = 16 +STATELESS_RESET_TOKEN_SIZE = 16 + + +class QuicErrorCode(IntEnum): + NO_ERROR = 0x0 + INTERNAL_ERROR = 0x1 + CONNECTION_REFUSED = 0x2 + FLOW_CONTROL_ERROR = 0x3 + STREAM_LIMIT_ERROR = 0x4 + STREAM_STATE_ERROR = 0x5 + FINAL_SIZE_ERROR = 0x6 + FRAME_ENCODING_ERROR = 0x7 + TRANSPORT_PARAMETER_ERROR = 0x8 + CONNECTION_ID_LIMIT_ERROR = 0x9 + PROTOCOL_VIOLATION = 0xA + INVALID_TOKEN = 0xB + APPLICATION_ERROR = 0xC + CRYPTO_BUFFER_EXCEEDED = 0xD + KEY_UPDATE_ERROR = 0xE + AEAD_LIMIT_REACHED = 0xF + CRYPTO_ERROR = 0x100 + + +class QuicProtocolVersion(IntEnum): + NEGOTIATION = 0 + VERSION_1 = 0x00000001 + DRAFT_29 = 0xFF00001D + DRAFT_30 = 0xFF00001E + DRAFT_31 = 0xFF00001F + DRAFT_32 = 0xFF000020 + + +@dataclass +class QuicHeader: + is_long_header: bool + version: Optional[int] + packet_type: int + destination_cid: bytes + source_cid: bytes + token: bytes = b"" + integrity_tag: bytes = b"" + rest_length: int = 0 + + +def decode_packet_number(truncated: int, num_bits: int, expected: int) -> int: + """ + Recover a packet number from a truncated packet number. + + See: Appendix A - Sample Packet Number Decoding Algorithm + """ + window = 1 << num_bits + half_window = window // 2 + candidate = (expected & ~(window - 1)) | truncated + if candidate <= expected - half_window and candidate < (1 << 62) - window: + return candidate + window + elif candidate > expected + half_window and candidate >= window: + return candidate - window + else: + return candidate + + +def get_retry_integrity_tag( + packet_without_tag: bytes, original_destination_cid: bytes, version: int +) -> bytes: + """ + Calculate the integrity tag for a RETRY packet. + """ + # build Retry pseudo packet + buf = Buffer(capacity=1 + len(original_destination_cid) + len(packet_without_tag)) + buf.push_uint8(len(original_destination_cid)) + buf.push_bytes(original_destination_cid) + buf.push_bytes(packet_without_tag) + assert buf.eof() + + if is_draft_version(version): + aead_key = RETRY_AEAD_KEY_DRAFT_29 + aead_nonce = RETRY_AEAD_NONCE_DRAFT_29 + else: + aead_key = RETRY_AEAD_KEY_VERSION_1 + aead_nonce = RETRY_AEAD_NONCE_VERSION_1 + + # run AES-128-GCM + aead = AESGCM(aead_key) + integrity_tag = aead.encrypt(aead_nonce, b"", buf.data) + assert len(integrity_tag) == RETRY_INTEGRITY_TAG_SIZE + return integrity_tag + + +def get_spin_bit(first_byte: int) -> bool: + return bool(first_byte & PACKET_SPIN_BIT) + + +def is_draft_version(version: int) -> bool: + return version in ( + QuicProtocolVersion.DRAFT_29, + QuicProtocolVersion.DRAFT_30, + QuicProtocolVersion.DRAFT_31, + QuicProtocolVersion.DRAFT_32, + ) + + +def is_long_header(first_byte: int) -> bool: + return bool(first_byte & PACKET_LONG_HEADER) + + +def pull_quic_header(buf: Buffer, host_cid_length: Optional[int] = None) -> QuicHeader: + first_byte = buf.pull_uint8() + + integrity_tag = b"" + token = b"" + if is_long_header(first_byte): + # long header packet + version = buf.pull_uint32() + + destination_cid_length = buf.pull_uint8() + if destination_cid_length > CONNECTION_ID_MAX_SIZE: + raise ValueError( + "Destination CID is too long (%d bytes)" % destination_cid_length + ) + destination_cid = buf.pull_bytes(destination_cid_length) + + source_cid_length = buf.pull_uint8() + if source_cid_length > CONNECTION_ID_MAX_SIZE: + raise ValueError("Source CID is too long (%d bytes)" % source_cid_length) + source_cid = buf.pull_bytes(source_cid_length) + + if version == QuicProtocolVersion.NEGOTIATION: + # version negotiation + packet_type = None + rest_length = buf.capacity - buf.tell() + else: + if not (first_byte & PACKET_FIXED_BIT): + raise ValueError("Packet fixed bit is zero") + + packet_type = first_byte & PACKET_TYPE_MASK + if packet_type == PACKET_TYPE_INITIAL: + token_length = buf.pull_uint_var() + token = buf.pull_bytes(token_length) + rest_length = buf.pull_uint_var() + elif packet_type == PACKET_TYPE_RETRY: + token_length = buf.capacity - buf.tell() - RETRY_INTEGRITY_TAG_SIZE + token = buf.pull_bytes(token_length) + integrity_tag = buf.pull_bytes(RETRY_INTEGRITY_TAG_SIZE) + rest_length = 0 + else: + rest_length = buf.pull_uint_var() + + # check remainder length + if rest_length > buf.capacity - buf.tell(): + raise ValueError("Packet payload is truncated") + + return QuicHeader( + is_long_header=True, + version=version, + packet_type=packet_type, + destination_cid=destination_cid, + source_cid=source_cid, + token=token, + integrity_tag=integrity_tag, + rest_length=rest_length, + ) + else: + # short header packet + if not (first_byte & PACKET_FIXED_BIT): + raise ValueError("Packet fixed bit is zero") + + packet_type = first_byte & PACKET_TYPE_MASK + destination_cid = buf.pull_bytes(host_cid_length) + return QuicHeader( + is_long_header=False, + version=None, + packet_type=packet_type, + destination_cid=destination_cid, + source_cid=b"", + token=b"", + rest_length=buf.capacity - buf.tell(), + ) + + +def encode_quic_retry( + version: int, + source_cid: bytes, + destination_cid: bytes, + original_destination_cid: bytes, + retry_token: bytes, +) -> bytes: + buf = Buffer( + capacity=7 + + len(destination_cid) + + len(source_cid) + + len(retry_token) + + RETRY_INTEGRITY_TAG_SIZE + ) + buf.push_uint8(PACKET_TYPE_RETRY) + buf.push_uint32(version) + buf.push_uint8(len(destination_cid)) + buf.push_bytes(destination_cid) + buf.push_uint8(len(source_cid)) + buf.push_bytes(source_cid) + buf.push_bytes(retry_token) + buf.push_bytes( + get_retry_integrity_tag(buf.data, original_destination_cid, version=version) + ) + assert buf.eof() + return buf.data + + +def encode_quic_version_negotiation( + source_cid: bytes, destination_cid: bytes, supported_versions: List[int] +) -> bytes: + buf = Buffer( + capacity=7 + + len(destination_cid) + + len(source_cid) + + 4 * len(supported_versions) + ) + buf.push_uint8(os.urandom(1)[0] | PACKET_LONG_HEADER) + buf.push_uint32(QuicProtocolVersion.NEGOTIATION) + buf.push_uint8(len(destination_cid)) + buf.push_bytes(destination_cid) + buf.push_uint8(len(source_cid)) + buf.push_bytes(source_cid) + for version in supported_versions: + buf.push_uint32(version) + return buf.data + + +# TLS EXTENSION + + +@dataclass +class QuicPreferredAddress: + ipv4_address: Optional[Tuple[str, int]] + ipv6_address: Optional[Tuple[str, int]] + connection_id: bytes + stateless_reset_token: bytes + + +@dataclass +class QuicTransportParameters: + original_destination_connection_id: Optional[bytes] = None + max_idle_timeout: Optional[int] = None + stateless_reset_token: Optional[bytes] = None + max_udp_payload_size: Optional[int] = None + initial_max_data: Optional[int] = None + initial_max_stream_data_bidi_local: Optional[int] = None + initial_max_stream_data_bidi_remote: Optional[int] = None + initial_max_stream_data_uni: Optional[int] = None + initial_max_streams_bidi: Optional[int] = None + initial_max_streams_uni: Optional[int] = None + ack_delay_exponent: Optional[int] = None + max_ack_delay: Optional[int] = None + disable_active_migration: Optional[bool] = False + preferred_address: Optional[QuicPreferredAddress] = None + active_connection_id_limit: Optional[int] = None + initial_source_connection_id: Optional[bytes] = None + retry_source_connection_id: Optional[bytes] = None + max_datagram_frame_size: Optional[int] = None + quantum_readiness: Optional[bytes] = None + + +PARAMS = { + 0x00: ("original_destination_connection_id", bytes), + 0x01: ("max_idle_timeout", int), + 0x02: ("stateless_reset_token", bytes), + 0x03: ("max_udp_payload_size", int), + 0x04: ("initial_max_data", int), + 0x05: ("initial_max_stream_data_bidi_local", int), + 0x06: ("initial_max_stream_data_bidi_remote", int), + 0x07: ("initial_max_stream_data_uni", int), + 0x08: ("initial_max_streams_bidi", int), + 0x09: ("initial_max_streams_uni", int), + 0x0A: ("ack_delay_exponent", int), + 0x0B: ("max_ack_delay", int), + 0x0C: ("disable_active_migration", bool), + 0x0D: ("preferred_address", QuicPreferredAddress), + 0x0E: ("active_connection_id_limit", int), + 0x0F: ("initial_source_connection_id", bytes), + 0x10: ("retry_source_connection_id", bytes), + # extensions + 0x0020: ("max_datagram_frame_size", int), + 0x0C37: ("quantum_readiness", bytes), +} + + +def pull_quic_preferred_address(buf: Buffer) -> QuicPreferredAddress: + ipv4_address = None + ipv4_host = buf.pull_bytes(4) + ipv4_port = buf.pull_uint16() + if ipv4_host != bytes(4): + ipv4_address = (str(ipaddress.IPv4Address(ipv4_host)), ipv4_port) + + ipv6_address = None + ipv6_host = buf.pull_bytes(16) + ipv6_port = buf.pull_uint16() + if ipv6_host != bytes(16): + ipv6_address = (str(ipaddress.IPv6Address(ipv6_host)), ipv6_port) + + connection_id_length = buf.pull_uint8() + connection_id = buf.pull_bytes(connection_id_length) + stateless_reset_token = buf.pull_bytes(16) + + return QuicPreferredAddress( + ipv4_address=ipv4_address, + ipv6_address=ipv6_address, + connection_id=connection_id, + stateless_reset_token=stateless_reset_token, + ) + + +def push_quic_preferred_address( + buf: Buffer, preferred_address: QuicPreferredAddress +) -> None: + if preferred_address.ipv4_address is not None: + buf.push_bytes(ipaddress.IPv4Address(preferred_address.ipv4_address[0]).packed) + buf.push_uint16(preferred_address.ipv4_address[1]) + else: + buf.push_bytes(bytes(6)) + + if preferred_address.ipv6_address is not None: + buf.push_bytes(ipaddress.IPv6Address(preferred_address.ipv6_address[0]).packed) + buf.push_uint16(preferred_address.ipv6_address[1]) + else: + buf.push_bytes(bytes(18)) + + buf.push_uint8(len(preferred_address.connection_id)) + buf.push_bytes(preferred_address.connection_id) + buf.push_bytes(preferred_address.stateless_reset_token) + + +def pull_quic_transport_parameters(buf: Buffer) -> QuicTransportParameters: + params = QuicTransportParameters() + while not buf.eof(): + param_id = buf.pull_uint_var() + param_len = buf.pull_uint_var() + param_start = buf.tell() + if param_id in PARAMS: + # parse known parameter + param_name, param_type = PARAMS[param_id] + if param_type == int: + setattr(params, param_name, buf.pull_uint_var()) + elif param_type == bytes: + setattr(params, param_name, buf.pull_bytes(param_len)) + elif param_type == QuicPreferredAddress: + setattr(params, param_name, pull_quic_preferred_address(buf)) + else: + setattr(params, param_name, True) + else: + # skip unknown parameter + buf.pull_bytes(param_len) + assert buf.tell() == param_start + param_len + + return params + + +def push_quic_transport_parameters( + buf: Buffer, params: QuicTransportParameters +) -> None: + for param_id, (param_name, param_type) in PARAMS.items(): + param_value = getattr(params, param_name) + if param_value is not None and param_value is not False: + param_buf = Buffer(capacity=65536) + if param_type == int: + param_buf.push_uint_var(param_value) + elif param_type == bytes: + param_buf.push_bytes(param_value) + elif param_type == QuicPreferredAddress: + push_quic_preferred_address(param_buf, param_value) + buf.push_uint_var(param_id) + buf.push_uint_var(param_buf.tell()) + buf.push_bytes(param_buf.data) + + +# FRAMES + + +class QuicFrameType(IntEnum): + PADDING = 0x00 + PING = 0x01 + ACK = 0x02 + ACK_ECN = 0x03 + RESET_STREAM = 0x04 + STOP_SENDING = 0x05 + CRYPTO = 0x06 + NEW_TOKEN = 0x07 + STREAM_BASE = 0x08 + MAX_DATA = 0x10 + MAX_STREAM_DATA = 0x11 + MAX_STREAMS_BIDI = 0x12 + MAX_STREAMS_UNI = 0x13 + DATA_BLOCKED = 0x14 + STREAM_DATA_BLOCKED = 0x15 + STREAMS_BLOCKED_BIDI = 0x16 + STREAMS_BLOCKED_UNI = 0x17 + NEW_CONNECTION_ID = 0x18 + RETIRE_CONNECTION_ID = 0x19 + PATH_CHALLENGE = 0x1A + PATH_RESPONSE = 0x1B + TRANSPORT_CLOSE = 0x1C + APPLICATION_CLOSE = 0x1D + HANDSHAKE_DONE = 0x1E + DATAGRAM = 0x30 + DATAGRAM_WITH_LENGTH = 0x31 + + +NON_ACK_ELICITING_FRAME_TYPES = frozenset( + [ + QuicFrameType.ACK, + QuicFrameType.ACK_ECN, + QuicFrameType.PADDING, + QuicFrameType.TRANSPORT_CLOSE, + QuicFrameType.APPLICATION_CLOSE, + ] +) +NON_IN_FLIGHT_FRAME_TYPES = frozenset( + [ + QuicFrameType.ACK, + QuicFrameType.ACK_ECN, + QuicFrameType.TRANSPORT_CLOSE, + QuicFrameType.APPLICATION_CLOSE, + ] +) + +PROBING_FRAME_TYPES = frozenset( + [ + QuicFrameType.PATH_CHALLENGE, + QuicFrameType.PATH_RESPONSE, + QuicFrameType.PADDING, + QuicFrameType.NEW_CONNECTION_ID, + ] +) + + +@dataclass +class QuicResetStreamFrame: + error_code: int + final_size: int + stream_id: int + + +@dataclass +class QuicStopSendingFrame: + error_code: int + stream_id: int + + +@dataclass +class QuicStreamFrame: + data: bytes = b"" + fin: bool = False + offset: int = 0 + + +def pull_ack_frame(buf: Buffer) -> Tuple[RangeSet, int]: + rangeset = RangeSet() + end = buf.pull_uint_var() # largest acknowledged + delay = buf.pull_uint_var() + ack_range_count = buf.pull_uint_var() + ack_count = buf.pull_uint_var() # first ack range + rangeset.add(end - ack_count, end + 1) + end -= ack_count + for _ in range(ack_range_count): + end -= buf.pull_uint_var() + 2 + ack_count = buf.pull_uint_var() + rangeset.add(end - ack_count, end + 1) + end -= ack_count + return rangeset, delay + + +def push_ack_frame(buf: Buffer, rangeset: RangeSet, delay: int) -> int: + ranges = len(rangeset) + index = ranges - 1 + r = rangeset[index] + buf.push_uint_var(r.stop - 1) + buf.push_uint_var(delay) + buf.push_uint_var(index) + buf.push_uint_var(r.stop - 1 - r.start) + start = r.start + while index > 0: + index -= 1 + r = rangeset[index] + buf.push_uint_var(start - r.stop - 1) + buf.push_uint_var(r.stop - r.start - 1) + start = r.start + return ranges diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet_builder.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet_builder.py new file mode 100644 index 0000000..77f7ec5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/packet_builder.py @@ -0,0 +1,359 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + +from ..buffer import Buffer, size_uint_var +from ..tls import Epoch +from .crypto import CryptoPair +from .logger import QuicLoggerTrace +from .packet import ( + NON_ACK_ELICITING_FRAME_TYPES, + NON_IN_FLIGHT_FRAME_TYPES, + PACKET_NUMBER_MAX_SIZE, + PACKET_TYPE_HANDSHAKE, + PACKET_TYPE_INITIAL, + PACKET_TYPE_MASK, + QuicFrameType, + is_long_header, +) + +PACKET_LENGTH_SEND_SIZE = 2 +PACKET_NUMBER_SEND_SIZE = 2 + + +QuicDeliveryHandler = Callable[..., None] + + +class QuicDeliveryState(Enum): + ACKED = 0 + LOST = 1 + + +@dataclass +class QuicSentPacket: + epoch: Epoch + in_flight: bool + is_ack_eliciting: bool + is_crypto_packet: bool + packet_number: int + packet_type: int + sent_time: Optional[float] = None + sent_bytes: int = 0 + + delivery_handlers: List[Tuple[QuicDeliveryHandler, Any]] = field( + default_factory=list + ) + quic_logger_frames: List[Dict] = field(default_factory=list) + + +class QuicPacketBuilderStop(Exception): + pass + + +class QuicPacketBuilder: + """ + Helper for building QUIC packets. + """ + + def __init__( + self, + *, + host_cid: bytes, + peer_cid: bytes, + version: int, + is_client: bool, + max_datagram_size: int, + packet_number: int = 0, + peer_token: bytes = b"", + quic_logger: Optional[QuicLoggerTrace] = None, + spin_bit: bool = False, + ): + self.max_flight_bytes: Optional[int] = None + self.max_total_bytes: Optional[int] = None + self.quic_logger_frames: Optional[List[Dict]] = None + + self._host_cid = host_cid + self._is_client = is_client + self._peer_cid = peer_cid + self._peer_token = peer_token + self._quic_logger = quic_logger + self._spin_bit = spin_bit + self._version = version + + # assembled datagrams and packets + self._datagrams: List[bytes] = [] + self._datagram_flight_bytes = 0 + self._datagram_init = True + self._packets: List[QuicSentPacket] = [] + self._flight_bytes = 0 + self._total_bytes = 0 + + # current packet + self._header_size = 0 + self._packet: Optional[QuicSentPacket] = None + self._packet_crypto: Optional[CryptoPair] = None + self._packet_long_header = False + self._packet_number = packet_number + self._packet_start = 0 + self._packet_type = 0 + + self._buffer = Buffer(max_datagram_size) + self._buffer_capacity = max_datagram_size + self._flight_capacity = max_datagram_size + + @property + def packet_is_empty(self) -> bool: + """ + Returns `True` if the current packet is empty. + """ + assert self._packet is not None + packet_size = self._buffer.tell() - self._packet_start + return packet_size <= self._header_size + + @property + def packet_number(self) -> int: + """ + Returns the packet number for the next packet. + """ + return self._packet_number + + @property + def remaining_buffer_space(self) -> int: + """ + Returns the remaining number of bytes which can be used in + the current packet. + """ + return ( + self._buffer_capacity + - self._buffer.tell() + - self._packet_crypto.aead_tag_size + ) + + @property + def remaining_flight_space(self) -> int: + """ + Returns the remaining number of bytes which can be used in + the current packet. + """ + return ( + self._flight_capacity + - self._buffer.tell() + - self._packet_crypto.aead_tag_size + ) + + def flush(self) -> Tuple[List[bytes], List[QuicSentPacket]]: + """ + Returns the assembled datagrams. + """ + if self._packet is not None: + self._end_packet() + self._flush_current_datagram() + + datagrams = self._datagrams + packets = self._packets + self._datagrams = [] + self._packets = [] + return datagrams, packets + + def start_frame( + self, + frame_type: int, + capacity: int = 1, + handler: Optional[QuicDeliveryHandler] = None, + handler_args: Sequence[Any] = [], + ) -> Buffer: + """ + Starts a new frame. + """ + if self.remaining_buffer_space < capacity or ( + frame_type not in NON_IN_FLIGHT_FRAME_TYPES + and self.remaining_flight_space < capacity + ): + raise QuicPacketBuilderStop + + self._buffer.push_uint_var(frame_type) + if frame_type not in NON_ACK_ELICITING_FRAME_TYPES: + self._packet.is_ack_eliciting = True + if frame_type not in NON_IN_FLIGHT_FRAME_TYPES: + self._packet.in_flight = True + if frame_type == QuicFrameType.CRYPTO: + self._packet.is_crypto_packet = True + if handler is not None: + self._packet.delivery_handlers.append((handler, handler_args)) + return self._buffer + + def start_packet(self, packet_type: int, crypto: CryptoPair) -> None: + """ + Starts a new packet. + """ + buf = self._buffer + + # finish previous datagram + if self._packet is not None: + self._end_packet() + + # if there is too little space remaining, start a new datagram + # FIXME: the limit is arbitrary! + packet_start = buf.tell() + if self._buffer_capacity - packet_start < 128: + self._flush_current_datagram() + packet_start = 0 + + # initialize datagram if needed + if self._datagram_init: + if self.max_total_bytes is not None: + remaining_total_bytes = self.max_total_bytes - self._total_bytes + if remaining_total_bytes < self._buffer_capacity: + self._buffer_capacity = remaining_total_bytes + + self._flight_capacity = self._buffer_capacity + if self.max_flight_bytes is not None: + remaining_flight_bytes = self.max_flight_bytes - self._flight_bytes + if remaining_flight_bytes < self._flight_capacity: + self._flight_capacity = remaining_flight_bytes + self._datagram_flight_bytes = 0 + self._datagram_init = False + + # calculate header size + packet_long_header = is_long_header(packet_type) + if packet_long_header: + header_size = 11 + len(self._peer_cid) + len(self._host_cid) + if (packet_type & PACKET_TYPE_MASK) == PACKET_TYPE_INITIAL: + token_length = len(self._peer_token) + header_size += size_uint_var(token_length) + token_length + else: + header_size = 3 + len(self._peer_cid) + + # check we have enough space + if packet_start + header_size >= self._buffer_capacity: + raise QuicPacketBuilderStop + + # determine ack epoch + if packet_type == PACKET_TYPE_INITIAL: + epoch = Epoch.INITIAL + elif packet_type == PACKET_TYPE_HANDSHAKE: + epoch = Epoch.HANDSHAKE + else: + epoch = Epoch.ONE_RTT + + self._header_size = header_size + self._packet = QuicSentPacket( + epoch=epoch, + in_flight=False, + is_ack_eliciting=False, + is_crypto_packet=False, + packet_number=self._packet_number, + packet_type=packet_type, + ) + self._packet_crypto = crypto + self._packet_long_header = packet_long_header + self._packet_start = packet_start + self._packet_type = packet_type + self.quic_logger_frames = self._packet.quic_logger_frames + + buf.seek(self._packet_start + self._header_size) + + def _end_packet(self) -> None: + """ + Ends the current packet. + """ + buf = self._buffer + packet_size = buf.tell() - self._packet_start + if packet_size > self._header_size: + # padding to ensure sufficient sample size + padding_size = ( + PACKET_NUMBER_MAX_SIZE + - PACKET_NUMBER_SEND_SIZE + + self._header_size + - packet_size + ) + + # Padding for initial packets; see RFC 9000 section + # 14.1. + if ( + (self._is_client or self._packet.is_ack_eliciting) + and self._packet_type == PACKET_TYPE_INITIAL + and self.remaining_flight_space + and self.remaining_flight_space > padding_size + ): + padding_size = self.remaining_flight_space + + # write padding + if padding_size > 0: + buf.push_bytes(bytes(padding_size)) + packet_size += padding_size + self._packet.in_flight = True + + # log frame + if self._quic_logger is not None: + self._packet.quic_logger_frames.append( + self._quic_logger.encode_padding_frame() + ) + + # write header + if self._packet_long_header: + length = ( + packet_size + - self._header_size + + PACKET_NUMBER_SEND_SIZE + + self._packet_crypto.aead_tag_size + ) + + buf.seek(self._packet_start) + buf.push_uint8(self._packet_type | (PACKET_NUMBER_SEND_SIZE - 1)) + buf.push_uint32(self._version) + buf.push_uint8(len(self._peer_cid)) + buf.push_bytes(self._peer_cid) + buf.push_uint8(len(self._host_cid)) + buf.push_bytes(self._host_cid) + if (self._packet_type & PACKET_TYPE_MASK) == PACKET_TYPE_INITIAL: + buf.push_uint_var(len(self._peer_token)) + buf.push_bytes(self._peer_token) + buf.push_uint16(length | 0x4000) + buf.push_uint16(self._packet_number & 0xFFFF) + else: + buf.seek(self._packet_start) + buf.push_uint8( + self._packet_type + | (self._spin_bit << 5) + | (self._packet_crypto.key_phase << 2) + | (PACKET_NUMBER_SEND_SIZE - 1) + ) + buf.push_bytes(self._peer_cid) + buf.push_uint16(self._packet_number & 0xFFFF) + + # encrypt in place + plain = buf.data_slice(self._packet_start, self._packet_start + packet_size) + buf.seek(self._packet_start) + buf.push_bytes( + self._packet_crypto.encrypt_packet( + plain[0 : self._header_size], + plain[self._header_size : packet_size], + self._packet_number, + ) + ) + self._packet.sent_bytes = buf.tell() - self._packet_start + self._packets.append(self._packet) + if self._packet.in_flight: + self._datagram_flight_bytes += self._packet.sent_bytes + + # short header packets cannot be coalesced, we need a new datagram + if not self._packet_long_header: + self._flush_current_datagram() + + self._packet_number += 1 + else: + # "cancel" the packet + buf.seek(self._packet_start) + + self._packet = None + self.quic_logger_frames = None + + def _flush_current_datagram(self) -> None: + datagram_bytes = self._buffer.tell() + if datagram_bytes: + self._datagrams.append(self._buffer.data) + self._flight_bytes += self._datagram_flight_bytes + self._total_bytes += datagram_bytes + self._datagram_init = True + self._buffer.seek(0) diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/rangeset.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/rangeset.py new file mode 100644 index 0000000..86086c9 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/rangeset.py @@ -0,0 +1,98 @@ +from collections.abc import Sequence +from typing import Any, Iterable, List, Optional + + +class RangeSet(Sequence): + def __init__(self, ranges: Iterable[range] = []): + self.__ranges: List[range] = [] + for r in ranges: + assert r.step == 1 + self.add(r.start, r.stop) + + def add(self, start: int, stop: Optional[int] = None) -> None: + if stop is None: + stop = start + 1 + assert stop > start + + for i, r in enumerate(self.__ranges): + # the added range is entirely before current item, insert here + if stop < r.start: + self.__ranges.insert(i, range(start, stop)) + return + + # the added range is entirely after current item, keep looking + if start > r.stop: + continue + + # the added range touches the current item, merge it + start = min(start, r.start) + stop = max(stop, r.stop) + while i < len(self.__ranges) - 1 and self.__ranges[i + 1].start <= stop: + stop = max(self.__ranges[i + 1].stop, stop) + self.__ranges.pop(i + 1) + self.__ranges[i] = range(start, stop) + return + + # the added range is entirely after all existing items, append it + self.__ranges.append(range(start, stop)) + + def bounds(self) -> range: + return range(self.__ranges[0].start, self.__ranges[-1].stop) + + def shift(self) -> range: + return self.__ranges.pop(0) + + def subtract(self, start: int, stop: int) -> None: + assert stop > start + + i = 0 + while i < len(self.__ranges): + r = self.__ranges[i] + + # the removed range is entirely before current item, stop here + if stop <= r.start: + return + + # the removed range is entirely after current item, keep looking + if start >= r.stop: + i += 1 + continue + + # the removed range completely covers the current item, remove it + if start <= r.start and stop >= r.stop: + self.__ranges.pop(i) + continue + + # the removed range touches the current item + if start > r.start: + self.__ranges[i] = range(r.start, start) + if stop < r.stop: + self.__ranges.insert(i + 1, range(stop, r.stop)) + else: + self.__ranges[i] = range(stop, r.stop) + + i += 1 + + def __bool__(self) -> bool: + raise NotImplementedError + + def __contains__(self, val: Any) -> bool: + for r in self.__ranges: + if val in r: + return True + return False + + def __eq__(self, other: object) -> bool: + if not isinstance(other, RangeSet): + return NotImplemented + + return self.__ranges == other.__ranges + + def __getitem__(self, key: Any) -> range: + return self.__ranges[key] + + def __len__(self) -> int: + return len(self.__ranges) + + def __repr__(self) -> str: + return "RangeSet({})".format(repr(self.__ranges)) diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/recovery.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/recovery.py new file mode 100644 index 0000000..6ee6593 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/recovery.py @@ -0,0 +1,389 @@ +import logging +import math +from typing import Any, Callable, Dict, Iterable, List, Optional + +from .congestion import cubic, reno # noqa +from .congestion.base import K_GRANULARITY, create_congestion_control +from .logger import QuicLoggerTrace +from .packet_builder import QuicDeliveryState, QuicSentPacket +from .rangeset import RangeSet + +# loss detection +K_PACKET_THRESHOLD = 3 +K_TIME_THRESHOLD = 9 / 8 +K_MICRO_SECOND = 0.000001 +K_SECOND = 1.0 + + +class QuicPacketSpace: + def __init__(self) -> None: + self.ack_at: Optional[float] = None + self.ack_queue = RangeSet() + self.discarded = False + self.expected_packet_number = 0 + self.largest_received_packet = -1 + self.largest_received_time: Optional[float] = None + + # sent packets and loss + self.ack_eliciting_in_flight = 0 + self.largest_acked_packet = 0 + self.loss_time: Optional[float] = None + self.sent_packets: Dict[int, QuicSentPacket] = {} + + +class QuicPacketPacer: + def __init__(self, *, max_datagram_size: int) -> None: + self._max_datagram_size = max_datagram_size + self.bucket_max: float = 0.0 + self.bucket_time: float = 0.0 + self.evaluation_time: float = 0.0 + self.packet_time: Optional[float] = None + + def next_send_time(self, now: float) -> float: + if self.packet_time is not None: + self.update_bucket(now=now) + if self.bucket_time <= 0: + return now + self.packet_time + return None + + def update_after_send(self, now: float) -> None: + if self.packet_time is not None: + self.update_bucket(now=now) + if self.bucket_time < self.packet_time: + self.bucket_time = 0.0 + else: + self.bucket_time -= self.packet_time + + def update_bucket(self, now: float) -> None: + if now > self.evaluation_time: + self.bucket_time = min( + self.bucket_time + (now - self.evaluation_time), self.bucket_max + ) + self.evaluation_time = now + + def update_rate(self, congestion_window: int, smoothed_rtt: float) -> None: + pacing_rate = congestion_window / max(smoothed_rtt, K_MICRO_SECOND) + self.packet_time = max( + K_MICRO_SECOND, min(self._max_datagram_size / pacing_rate, K_SECOND) + ) + + self.bucket_max = ( + max( + 2 * self._max_datagram_size, + min(congestion_window // 4, 16 * self._max_datagram_size), + ) + / pacing_rate + ) + if self.bucket_time > self.bucket_max: + self.bucket_time = self.bucket_max + + +class QuicPacketRecovery: + """ + Packet loss and congestion controller. + """ + + def __init__( + self, + *, + congestion_control_algorithm: str, + initial_rtt: float, + max_datagram_size: int, + peer_completed_address_validation: bool, + send_probe: Callable[[], None], + logger: Optional[logging.LoggerAdapter] = None, + quic_logger: Optional[QuicLoggerTrace] = None, + ) -> None: + self.max_ack_delay = 0.025 + self.peer_completed_address_validation = peer_completed_address_validation + self.spaces: List[QuicPacketSpace] = [] + + # callbacks + self._logger = logger + self._quic_logger = quic_logger + self._send_probe = send_probe + + # loss detection + self._pto_count = 0 + self._rtt_initial = initial_rtt + self._rtt_initialized = False + self._rtt_latest = 0.0 + self._rtt_min = math.inf + self._rtt_smoothed = 0.0 + self._rtt_variance = 0.0 + self._time_of_last_sent_ack_eliciting_packet = 0.0 + + # congestion control + self._cc = create_congestion_control( + congestion_control_algorithm, max_datagram_size=max_datagram_size + ) + self._pacer = QuicPacketPacer(max_datagram_size=max_datagram_size) + + @property + def bytes_in_flight(self) -> int: + return self._cc.bytes_in_flight + + @property + def congestion_window(self) -> int: + return self._cc.congestion_window + + def discard_space(self, space: QuicPacketSpace) -> None: + assert space in self.spaces + + self._cc.on_packets_expired( + packets=filter(lambda x: x.in_flight, space.sent_packets.values()) + ) + space.sent_packets.clear() + + space.ack_at = None + space.ack_eliciting_in_flight = 0 + space.loss_time = None + + # reset PTO count + self._pto_count = 0 + + if self._quic_logger is not None: + self._log_metrics_updated() + + def get_loss_detection_time(self) -> float: + # loss timer + loss_space = self._get_loss_space() + if loss_space is not None: + return loss_space.loss_time + + # packet timer + if ( + not self.peer_completed_address_validation + or sum(space.ack_eliciting_in_flight for space in self.spaces) > 0 + ): + timeout = self.get_probe_timeout() * (2**self._pto_count) + return self._time_of_last_sent_ack_eliciting_packet + timeout + + return None + + def get_probe_timeout(self) -> float: + if not self._rtt_initialized: + return 2 * self._rtt_initial + return ( + self._rtt_smoothed + + max(4 * self._rtt_variance, K_GRANULARITY) + + self.max_ack_delay + ) + + def on_ack_received( + self, + *, + ack_rangeset: RangeSet, + ack_delay: float, + now: float, + space: QuicPacketSpace, + ) -> None: + """ + Update metrics as the result of an ACK being received. + """ + is_ack_eliciting = False + largest_acked = ack_rangeset.bounds().stop - 1 + largest_newly_acked = None + largest_sent_time = None + + if largest_acked > space.largest_acked_packet: + space.largest_acked_packet = largest_acked + + for packet_number in sorted(space.sent_packets.keys()): + if packet_number > largest_acked: + break + if packet_number in ack_rangeset: + # remove packet and update counters + packet = space.sent_packets.pop(packet_number) + if packet.is_ack_eliciting: + is_ack_eliciting = True + space.ack_eliciting_in_flight -= 1 + if packet.in_flight: + self._cc.on_packet_acked(packet=packet, now=now) + largest_newly_acked = packet_number + largest_sent_time = packet.sent_time + + # trigger callbacks + for handler, args in packet.delivery_handlers: + handler(QuicDeliveryState.ACKED, *args) + + # nothing to do if there are no newly acked packets + if largest_newly_acked is None: + return + + if largest_acked == largest_newly_acked and is_ack_eliciting: + latest_rtt = now - largest_sent_time + log_rtt = True + + # limit ACK delay to max_ack_delay + ack_delay = min(ack_delay, self.max_ack_delay) + + # update RTT estimate, which cannot be < 1 ms + self._rtt_latest = max(latest_rtt, 0.001) + if self._rtt_latest < self._rtt_min: + self._rtt_min = self._rtt_latest + if self._rtt_latest > self._rtt_min + ack_delay: + self._rtt_latest -= ack_delay + + if not self._rtt_initialized: + self._rtt_initialized = True + self._rtt_variance = latest_rtt / 2 + self._rtt_smoothed = latest_rtt + else: + self._rtt_variance = 3 / 4 * self._rtt_variance + 1 / 4 * abs( + self._rtt_min - self._rtt_latest + ) + self._rtt_smoothed = ( + 7 / 8 * self._rtt_smoothed + 1 / 8 * self._rtt_latest + ) + + # inform congestion controller + self._cc.on_rtt_measurement(now=now, rtt=latest_rtt) + self._pacer.update_rate( + congestion_window=self._cc.congestion_window, + smoothed_rtt=self._rtt_smoothed, + ) + + else: + log_rtt = False + + self._detect_loss(now=now, space=space) + + # reset PTO count + self._pto_count = 0 + + if self._quic_logger is not None: + self._log_metrics_updated(log_rtt=log_rtt) + + def on_loss_detection_timeout(self, *, now: float) -> None: + loss_space = self._get_loss_space() + if loss_space is not None: + self._detect_loss(now=now, space=loss_space) + else: + self._pto_count += 1 + self.reschedule_data(now=now) + + def on_packet_sent(self, *, packet: QuicSentPacket, space: QuicPacketSpace) -> None: + space.sent_packets[packet.packet_number] = packet + + if packet.is_ack_eliciting: + space.ack_eliciting_in_flight += 1 + if packet.in_flight: + if packet.is_ack_eliciting: + self._time_of_last_sent_ack_eliciting_packet = packet.sent_time + + # add packet to bytes in flight + self._cc.on_packet_sent(packet=packet) + + if self._quic_logger is not None: + self._log_metrics_updated() + + def reschedule_data(self, *, now: float) -> None: + """ + Schedule some data for retransmission. + """ + # if there is any outstanding CRYPTO, retransmit it + crypto_scheduled = False + for space in self.spaces: + packets = tuple( + filter(lambda i: i.is_crypto_packet, space.sent_packets.values()) + ) + if packets: + self._on_packets_lost(now=now, packets=packets, space=space) + crypto_scheduled = True + if crypto_scheduled and self._logger is not None: + self._logger.debug("Scheduled CRYPTO data for retransmission") + + # ensure an ACK-elliciting packet is sent + self._send_probe() + + def _detect_loss(self, *, now: float, space: QuicPacketSpace) -> None: + """ + Check whether any packets should be declared lost. + """ + loss_delay = K_TIME_THRESHOLD * ( + max(self._rtt_latest, self._rtt_smoothed) + if self._rtt_initialized + else self._rtt_initial + ) + packet_threshold = space.largest_acked_packet - K_PACKET_THRESHOLD + time_threshold = now - loss_delay + + lost_packets = [] + space.loss_time = None + for packet_number, packet in space.sent_packets.items(): + if packet_number > space.largest_acked_packet: + break + + if packet_number <= packet_threshold or packet.sent_time <= time_threshold: + lost_packets.append(packet) + else: + packet_loss_time = packet.sent_time + loss_delay + if space.loss_time is None or space.loss_time > packet_loss_time: + space.loss_time = packet_loss_time + + self._on_packets_lost(now=now, packets=lost_packets, space=space) + + def _get_loss_space(self) -> Optional[QuicPacketSpace]: + loss_space = None + for space in self.spaces: + if space.loss_time is not None and ( + loss_space is None or space.loss_time < loss_space.loss_time + ): + loss_space = space + return loss_space + + def _log_metrics_updated(self, log_rtt=False) -> None: + data: Dict[str, Any] = self._cc.get_log_data() + + if log_rtt: + data.update( + { + "latest_rtt": self._quic_logger.encode_time(self._rtt_latest), + "min_rtt": self._quic_logger.encode_time(self._rtt_min), + "smoothed_rtt": self._quic_logger.encode_time(self._rtt_smoothed), + "rtt_variance": self._quic_logger.encode_time(self._rtt_variance), + } + ) + + self._quic_logger.log_event( + category="recovery", event="metrics_updated", data=data + ) + + def _on_packets_lost( + self, *, now: float, packets: Iterable[QuicSentPacket], space: QuicPacketSpace + ) -> None: + lost_packets_cc = [] + for packet in packets: + del space.sent_packets[packet.packet_number] + + if packet.in_flight: + lost_packets_cc.append(packet) + + if packet.is_ack_eliciting: + space.ack_eliciting_in_flight -= 1 + + if self._quic_logger is not None: + self._quic_logger.log_event( + category="recovery", + event="packet_lost", + data={ + "type": self._quic_logger.packet_type(packet.packet_type), + "packet_number": packet.packet_number, + }, + ) + self._log_metrics_updated() + + # trigger callbacks + for handler, args in packet.delivery_handlers: + handler(QuicDeliveryState.LOST, *args) + + # inform congestion controller + if lost_packets_cc: + self._cc.on_packets_lost(now=now, packets=lost_packets_cc) + self._pacer.update_rate( + congestion_window=self._cc.congestion_window, + smoothed_rtt=self._rtt_smoothed, + ) + if self._quic_logger is not None: + self._log_metrics_updated() diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/retry.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/retry.py new file mode 100644 index 0000000..d72ceb8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/retry.py @@ -0,0 +1,53 @@ +import ipaddress +from typing import Tuple + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +from ..buffer import Buffer +from ..tls import pull_opaque, push_opaque +from .connection import NetworkAddress + + +def encode_address(addr: NetworkAddress) -> bytes: + return ipaddress.ip_address(addr[0]).packed + bytes([addr[1] >> 8, addr[1] & 0xFF]) + + +class QuicRetryTokenHandler: + def __init__(self) -> None: + self._key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + + def create_token( + self, + addr: NetworkAddress, + original_destination_connection_id: bytes, + retry_source_connection_id: bytes, + ) -> bytes: + buf = Buffer(capacity=512) + push_opaque(buf, 1, encode_address(addr)) + push_opaque(buf, 1, original_destination_connection_id) + push_opaque(buf, 1, retry_source_connection_id) + return self._key.public_key().encrypt( + buf.data, + padding.OAEP( + mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None + ), + ) + + def validate_token(self, addr: NetworkAddress, token: bytes) -> Tuple[bytes, bytes]: + buf = Buffer( + data=self._key.decrypt( + token, + padding.OAEP( + mgf=padding.MGF1(hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + ) + encoded_addr = pull_opaque(buf, 1) + original_destination_connection_id = pull_opaque(buf, 1) + retry_source_connection_id = pull_opaque(buf, 1) + if encoded_addr != encode_address(addr): + raise ValueError("Remote address does not match.") + return original_destination_connection_id, retry_source_connection_id diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/stream.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/stream.py new file mode 100644 index 0000000..95f578b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/quic/stream.py @@ -0,0 +1,361 @@ +from typing import Optional + +from . import events +from .packet import ( + QuicErrorCode, + QuicResetStreamFrame, + QuicStopSendingFrame, + QuicStreamFrame, +) +from .packet_builder import QuicDeliveryState +from .rangeset import RangeSet + + +class FinalSizeError(Exception): + pass + + +class StreamFinishedError(Exception): + pass + + +class QuicStreamReceiver: + """ + The receive part of a QUIC stream. + + It finishes: + - immediately for a send-only stream + - upon reception of a STREAM_RESET frame + - upon reception of a data frame with the FIN bit set + """ + + def __init__(self, stream_id: Optional[int], readable: bool) -> None: + self.highest_offset = 0 # the highest offset ever seen + self.is_finished = False + self.stop_pending = False + + self._buffer = bytearray() + self._buffer_start = 0 # the offset for the start of the buffer + self._final_size: Optional[int] = None + self._ranges = RangeSet() + self._stream_id = stream_id + self._stop_error_code: Optional[int] = None + + def get_stop_frame(self) -> QuicStopSendingFrame: + self.stop_pending = False + return QuicStopSendingFrame( + error_code=self._stop_error_code, + stream_id=self._stream_id, + ) + + def handle_frame( + self, frame: QuicStreamFrame + ) -> Optional[events.StreamDataReceived]: + """ + Handle a frame of received data. + """ + pos = frame.offset - self._buffer_start + count = len(frame.data) + frame_end = frame.offset + count + + # we should receive no more data beyond FIN! + if self._final_size is not None: + if frame_end > self._final_size: + raise FinalSizeError("Data received beyond final size") + elif frame.fin and frame_end != self._final_size: + raise FinalSizeError("Cannot change final size") + if frame.fin: + self._final_size = frame_end + if frame_end > self.highest_offset: + self.highest_offset = frame_end + + # fast path: new in-order chunk + if pos == 0 and count and not self._buffer: + self._buffer_start += count + if frame.fin: + # all data up to the FIN has been received, we're done receiving + self.is_finished = True + return events.StreamDataReceived( + data=frame.data, end_stream=frame.fin, stream_id=self._stream_id + ) + + # discard duplicate data + if pos < 0: + frame.data = frame.data[-pos:] + frame.offset -= pos + pos = 0 + count = len(frame.data) + + # marked received range + if frame_end > frame.offset: + self._ranges.add(frame.offset, frame_end) + + # add new data + gap = pos - len(self._buffer) + if gap > 0: + self._buffer += bytearray(gap) + self._buffer[pos : pos + count] = frame.data + + # return data from the front of the buffer + data = self._pull_data() + end_stream = self._buffer_start == self._final_size + if end_stream: + # all data up to the FIN has been received, we're done receiving + self.is_finished = True + if data or end_stream: + return events.StreamDataReceived( + data=data, end_stream=end_stream, stream_id=self._stream_id + ) + else: + return None + + def handle_reset( + self, *, final_size: int, error_code: int = QuicErrorCode.NO_ERROR + ) -> Optional[events.StreamReset]: + """ + Handle an abrupt termination of the receiving part of the QUIC stream. + """ + if self._final_size is not None and final_size != self._final_size: + raise FinalSizeError("Cannot change final size") + + # we are done receiving + self._final_size = final_size + self.is_finished = True + return events.StreamReset(error_code=error_code, stream_id=self._stream_id) + + def on_stop_sending_delivery(self, delivery: QuicDeliveryState) -> None: + """ + Callback when a STOP_SENDING is ACK'd. + """ + if delivery != QuicDeliveryState.ACKED: + self.stop_pending = True + + def stop(self, error_code: int = QuicErrorCode.NO_ERROR) -> None: + """ + Request the peer stop sending data on the QUIC stream. + """ + self._stop_error_code = error_code + self.stop_pending = True + + def _pull_data(self) -> bytes: + """ + Remove data from the front of the buffer. + """ + try: + has_data_to_read = self._ranges[0].start == self._buffer_start + except IndexError: + has_data_to_read = False + if not has_data_to_read: + return b"" + + r = self._ranges.shift() + pos = r.stop - r.start + data = bytes(self._buffer[:pos]) + del self._buffer[:pos] + self._buffer_start = r.stop + return data + + +class QuicStreamSender: + """ + The send part of a QUIC stream. + + It finishes: + - immediately for a receive-only stream + - upon acknowledgement of a STREAM_RESET frame + - upon acknowledgement of a data frame with the FIN bit set + """ + + def __init__(self, stream_id: Optional[int], writable: bool) -> None: + self.buffer_is_empty = True + self.highest_offset = 0 + self.is_finished = not writable + self.reset_pending = False + + self._acked = RangeSet() + self._acked_fin = False + self._buffer = bytearray() + self._buffer_fin: Optional[int] = None + self._buffer_start = 0 # the offset for the start of the buffer + self._buffer_stop = 0 # the offset for the stop of the buffer + self._pending = RangeSet() + self._pending_eof = False + self._reset_error_code: Optional[int] = None + self._stream_id = stream_id + + @property + def next_offset(self) -> int: + """ + The offset for the next frame to send. + + This is used to determine the space needed for the frame's `offset` field. + """ + try: + return self._pending[0].start + except IndexError: + return self._buffer_stop + + def get_frame( + self, max_size: int, max_offset: Optional[int] = None + ) -> Optional[QuicStreamFrame]: + """ + Get a frame of data to send. + """ + assert self._reset_error_code is None, "cannot call get_frame() after reset()" + + # get the first pending data range + try: + r = self._pending[0] + except IndexError: + if self._pending_eof: + # FIN only + self._pending_eof = False + return QuicStreamFrame(fin=True, offset=self._buffer_fin) + + self.buffer_is_empty = True + return None + + # apply flow control + start = r.start + stop = min(r.stop, start + max_size) + if max_offset is not None and stop > max_offset: + stop = max_offset + if stop <= start: + return None + + # create frame + frame = QuicStreamFrame( + data=bytes( + self._buffer[start - self._buffer_start : stop - self._buffer_start] + ), + offset=start, + ) + self._pending.subtract(start, stop) + + # track the highest offset ever sent + if stop > self.highest_offset: + self.highest_offset = stop + + # if the buffer is empty and EOF was written, set the FIN bit + if self._buffer_fin == stop: + frame.fin = True + self._pending_eof = False + + return frame + + def get_reset_frame(self) -> QuicResetStreamFrame: + self.reset_pending = False + return QuicResetStreamFrame( + error_code=self._reset_error_code, + final_size=self.highest_offset, + stream_id=self._stream_id, + ) + + def on_data_delivery( + self, delivery: QuicDeliveryState, start: int, stop: int, fin: bool + ) -> None: + """ + Callback when sent data is ACK'd. + """ + # If the frame had the FIN bit set, its end MUST match otherwise + # we have a programming error. + assert ( + not fin or stop == self._buffer_fin + ), "on_data_delivered() was called with inconsistent fin / stop" + + # If a reset has been requested, stop processing data delivery. + # The transition to the finished state only depends on the reset + # being acknowledged. + if self._reset_error_code is not None: + return + + if delivery == QuicDeliveryState.ACKED: + if stop > start: + # Some data has been ACK'd, discard it. + self._acked.add(start, stop) + first_range = self._acked[0] + if first_range.start == self._buffer_start: + size = first_range.stop - first_range.start + self._acked.shift() + self._buffer_start += size + del self._buffer[:size] + + if fin: + # The FIN has been ACK'd. + self._acked_fin = True + + if self._buffer_start == self._buffer_fin and self._acked_fin: + # All data and the FIN have been ACK'd, we're done sending. + self.is_finished = True + else: + if stop > start: + # Some data has been lost, reschedule it. + self.buffer_is_empty = False + self._pending.add(start, stop) + + if fin: + # The FIN has been lost, reschedule it. + self.buffer_is_empty = False + self._pending_eof = True + + def on_reset_delivery(self, delivery: QuicDeliveryState) -> None: + """ + Callback when a reset is ACK'd. + """ + if delivery == QuicDeliveryState.ACKED: + # The reset has been ACK'd, we're done sending. + self.is_finished = True + else: + # The reset has been lost, reschedule it. + self.reset_pending = True + + def reset(self, error_code: int) -> None: + """ + Abruptly terminate the sending part of the QUIC stream. + """ + assert self._reset_error_code is None, "cannot call reset() more than once" + self._reset_error_code = error_code + self.reset_pending = True + + # Prevent any more data from being sent or re-sent. + self.buffer_is_empty = True + + def write(self, data: bytes, end_stream: bool = False) -> None: + """ + Write some data bytes to the QUIC stream. + """ + assert self._buffer_fin is None, "cannot call write() after FIN" + assert self._reset_error_code is None, "cannot call write() after reset()" + size = len(data) + + if size: + self.buffer_is_empty = False + self._pending.add(self._buffer_stop, self._buffer_stop + size) + self._buffer += data + self._buffer_stop += size + if end_stream: + self.buffer_is_empty = False + self._buffer_fin = self._buffer_stop + self._pending_eof = True + + +class QuicStream: + def __init__( + self, + stream_id: Optional[int] = None, + max_stream_data_local: int = 0, + max_stream_data_remote: int = 0, + readable: bool = True, + writable: bool = True, + ) -> None: + self.is_blocked = False + self.max_stream_data_local = max_stream_data_local + self.max_stream_data_local_sent = max_stream_data_local + self.max_stream_data_remote = max_stream_data_remote + self.receiver = QuicStreamReceiver(stream_id=stream_id, readable=readable) + self.sender = QuicStreamSender(stream_id=stream_id, writable=writable) + self.stream_id = stream_id + + @property + def is_finished(self) -> bool: + return self.receiver.is_finished and self.sender.is_finished diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic/tls.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/tls.py new file mode 100644 index 0000000..9b34970 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic/tls.py @@ -0,0 +1,2174 @@ +import datetime +import ipaddress +import logging +import os +import ssl +import struct +from contextlib import contextmanager +from dataclasses import dataclass, field +from enum import Enum, IntEnum +from functools import partial +from typing import ( + Any, + Callable, + Dict, + Generator, + List, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) + +import certifi +import service_identity +from cryptography import x509 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes, hmac, serialization +from cryptography.hazmat.primitives.asymmetric import ( + dsa, + ec, + ed448, + ed25519, + padding, + rsa, + x448, + x25519, +) +from cryptography.hazmat.primitives.asymmetric.types import ( + CertificateIssuerPublicKeyTypes, + PrivateKeyTypes, +) +from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from OpenSSL import crypto + +from .buffer import Buffer, BufferReadError + +TLS_VERSION_1_2 = 0x0303 +TLS_VERSION_1_3 = 0x0304 +TLS_VERSION_1_3_DRAFT_28 = 0x7F1C +TLS_VERSION_1_3_DRAFT_27 = 0x7F1B +TLS_VERSION_1_3_DRAFT_26 = 0x7F1A + +CLIENT_CONTEXT_STRING = b"TLS 1.3, client CertificateVerify" +SERVER_CONTEXT_STRING = b"TLS 1.3, server CertificateVerify" + +T = TypeVar("T") + + +# facilitate mocking for the test suite +def utcnow() -> datetime.datetime: + return datetime.datetime.now(datetime.timezone.utc) + + +class AlertDescription(IntEnum): + close_notify = 0 + unexpected_message = 10 + bad_record_mac = 20 + record_overflow = 22 + handshake_failure = 40 + bad_certificate = 42 + unsupported_certificate = 43 + certificate_revoked = 44 + certificate_expired = 45 + certificate_unknown = 46 + illegal_parameter = 47 + unknown_ca = 48 + access_denied = 49 + decode_error = 50 + decrypt_error = 51 + protocol_version = 70 + insufficient_security = 71 + internal_error = 80 + inappropriate_fallback = 86 + user_canceled = 90 + missing_extension = 109 + unsupported_extension = 110 + unrecognized_name = 112 + bad_certificate_status_response = 113 + unknown_psk_identity = 115 + certificate_required = 116 + no_application_protocol = 120 + + +class Alert(Exception): + description: AlertDescription + + +class AlertBadCertificate(Alert): + description = AlertDescription.bad_certificate + + +class AlertCertificateExpired(Alert): + description = AlertDescription.certificate_expired + + +class AlertDecodeError(Alert): + description = AlertDescription.decode_error + + +class AlertDecryptError(Alert): + description = AlertDescription.decrypt_error + + +class AlertHandshakeFailure(Alert): + description = AlertDescription.handshake_failure + + +class AlertIllegalParameter(Alert): + description = AlertDescription.illegal_parameter + + +class AlertInternalError(Alert): + description = AlertDescription.internal_error + + +class AlertProtocolVersion(Alert): + description = AlertDescription.protocol_version + + +class AlertUnexpectedMessage(Alert): + description = AlertDescription.unexpected_message + + +class Direction(Enum): + DECRYPT = 0 + ENCRYPT = 1 + + +class Epoch(Enum): + INITIAL = 0 + ZERO_RTT = 1 + HANDSHAKE = 2 + ONE_RTT = 3 + + +class State(Enum): + CLIENT_HANDSHAKE_START = 0 + CLIENT_EXPECT_SERVER_HELLO = 1 + CLIENT_EXPECT_ENCRYPTED_EXTENSIONS = 2 + CLIENT_EXPECT_CERTIFICATE_REQUEST_OR_CERTIFICATE = 3 + CLIENT_EXPECT_CERTIFICATE = 4 + CLIENT_EXPECT_CERTIFICATE_VERIFY = 5 + CLIENT_EXPECT_FINISHED = 6 + CLIENT_POST_HANDSHAKE = 7 + + SERVER_EXPECT_CLIENT_HELLO = 8 + SERVER_EXPECT_CERTIFICATE = 9 + SERVER_EXPECT_CERTIFICATE_VERIFY = 10 + SERVER_EXPECT_FINISHED = 11 + SERVER_POST_HANDSHAKE = 12 + + +def hkdf_label(label: bytes, hash_value: bytes, length: int) -> bytes: + full_label = b"tls13 " + label + return ( + struct.pack("!HB", length, len(full_label)) + + full_label + + struct.pack("!B", len(hash_value)) + + hash_value + ) + + +def hkdf_expand_label( + algorithm: hashes.HashAlgorithm, + secret: bytes, + label: bytes, + hash_value: bytes, + length: int, +) -> bytes: + return HKDFExpand( + algorithm=algorithm, + length=length, + info=hkdf_label(label, hash_value, length), + ).derive(secret) + + +def hkdf_extract( + algorithm: hashes.HashAlgorithm, salt: bytes, key_material: bytes +) -> bytes: + h = hmac.HMAC(salt, algorithm) + h.update(key_material) + return h.finalize() + + +def load_pem_private_key( + data: bytes, password: Optional[bytes] = None +) -> PrivateKeyTypes: + """ + Load a PEM-encoded private key. + """ + return serialization.load_pem_private_key(data, password=password) + + +def load_pem_x509_certificates(data: bytes) -> List[x509.Certificate]: + """ + Load a chain of PEM-encoded X509 certificates. + """ + boundary = b"-----END CERTIFICATE-----\n" + certificates = [] + for chunk in data.split(boundary): + if chunk: + certificates.append(x509.load_pem_x509_certificate(chunk + boundary)) + return certificates + + +def verify_certificate( + certificate: x509.Certificate, + chain: List[x509.Certificate] = [], + server_name: Optional[str] = None, + cadata: Optional[bytes] = None, + cafile: Optional[str] = None, + capath: Optional[str] = None, +) -> None: + # verify dates + now = utcnow() + if now < certificate.not_valid_before_utc: + raise AlertCertificateExpired("Certificate is not valid yet") + if now > certificate.not_valid_after_utc: + raise AlertCertificateExpired("Certificate is no longer valid") + + # verify subject + if server_name is not None: + try: + ipaddress.ip_address(server_name) + except ValueError: + is_ip = False + else: + is_ip = True + + try: + if is_ip: + service_identity.cryptography.verify_certificate_ip_address( + certificate, server_name + ) + else: + service_identity.cryptography.verify_certificate_hostname( + certificate, server_name + ) + + except ( + service_identity.CertificateError, + service_identity.VerificationError, + ) as exc: + patterns = service_identity.cryptography.extract_patterns(certificate) + if len(patterns) == 0: + errmsg = str(exc) + elif len(patterns) == 1: + errmsg = f"hostname {server_name!r} doesn't match {patterns[0]!r}" + else: + patterns_repr = ", ".join(repr(pattern) for pattern in patterns) + errmsg = ( + f"hostname {server_name!r} doesn't match " + f"either of {patterns_repr}" + ) + + raise AlertBadCertificate(errmsg) from exc + + # load CAs + store = crypto.X509Store() + + if cadata is None and cafile is None and capath is None: + # Load defaults from certifi. + store.load_locations(certifi.where()) + + if cadata is not None: + for cert in load_pem_x509_certificates(cadata): + store.add_cert(crypto.X509.from_cryptography(cert)) + + if cafile is not None or capath is not None: + store.load_locations(cafile, capath) + + # verify certificate chain + store_ctx = crypto.X509StoreContext( + store, + crypto.X509.from_cryptography(certificate), + [crypto.X509.from_cryptography(cert) for cert in chain], + ) + try: + store_ctx.verify_certificate() + except crypto.X509StoreContextError as exc: + raise AlertBadCertificate(exc.args[0]) + + +class CipherSuite(IntEnum): + AES_128_GCM_SHA256 = 0x1301 + AES_256_GCM_SHA384 = 0x1302 + CHACHA20_POLY1305_SHA256 = 0x1303 + EMPTY_RENEGOTIATION_INFO_SCSV = 0x00FF + + +class CompressionMethod(IntEnum): + NULL = 0 + + +class ExtensionType(IntEnum): + SERVER_NAME = 0 + STATUS_REQUEST = 5 + SUPPORTED_GROUPS = 10 + SIGNATURE_ALGORITHMS = 13 + ALPN = 16 + COMPRESS_CERTIFICATE = 27 + PRE_SHARED_KEY = 41 + EARLY_DATA = 42 + SUPPORTED_VERSIONS = 43 + COOKIE = 44 + PSK_KEY_EXCHANGE_MODES = 45 + KEY_SHARE = 51 + QUIC_TRANSPORT_PARAMETERS = 0x0039 + QUIC_TRANSPORT_PARAMETERS_DRAFT = 0xFFA5 + ENCRYPTED_SERVER_NAME = 65486 + + +class Group(IntEnum): + SECP256R1 = 0x0017 + SECP384R1 = 0x0018 + SECP521R1 = 0x0019 + X25519 = 0x001D + X448 = 0x001E + GREASE = 0xAAAA + + +class HandshakeType(IntEnum): + CLIENT_HELLO = 1 + SERVER_HELLO = 2 + NEW_SESSION_TICKET = 4 + END_OF_EARLY_DATA = 5 + ENCRYPTED_EXTENSIONS = 8 + CERTIFICATE = 11 + CERTIFICATE_REQUEST = 13 + CERTIFICATE_VERIFY = 15 + FINISHED = 20 + KEY_UPDATE = 24 + COMPRESSED_CERTIFICATE = 25 + MESSAGE_HASH = 254 + + +class NameType(IntEnum): + HOST_NAME = 0 + + +class PskKeyExchangeMode(IntEnum): + PSK_KE = 0 + PSK_DHE_KE = 1 + + +class SignatureAlgorithm(IntEnum): + ECDSA_SECP256R1_SHA256 = 0x0403 + ECDSA_SECP384R1_SHA384 = 0x0503 + ECDSA_SECP521R1_SHA512 = 0x0603 + ED25519 = 0x0807 + ED448 = 0x0808 + RSA_PKCS1_SHA256 = 0x0401 + RSA_PKCS1_SHA384 = 0x0501 + RSA_PKCS1_SHA512 = 0x0601 + RSA_PSS_PSS_SHA256 = 0x0809 + RSA_PSS_PSS_SHA384 = 0x080A + RSA_PSS_PSS_SHA512 = 0x080B + RSA_PSS_RSAE_SHA256 = 0x0804 + RSA_PSS_RSAE_SHA384 = 0x0805 + RSA_PSS_RSAE_SHA512 = 0x0806 + + # legacy + RSA_PKCS1_SHA1 = 0x0201 + SHA1_DSA = 0x0202 + ECDSA_SHA1 = 0x0203 + + +# BLOCKS + + +@contextmanager +def pull_block(buf: Buffer, capacity: int) -> Generator: + length = int.from_bytes(buf.pull_bytes(capacity), byteorder="big") + end = buf.tell() + length + yield length + if buf.tell() != end: + # There was trailing garbage or our parsing was bad. + raise AlertDecodeError("extra bytes at the end of a block") + + +@contextmanager +def push_block(buf: Buffer, capacity: int) -> Generator: + """ + Context manager to push a variable-length block, with `capacity` bytes + to write the length. + """ + start = buf.tell() + capacity + buf.seek(start) + yield + end = buf.tell() + length = end - start + buf.seek(start - capacity) + buf.push_bytes(length.to_bytes(capacity, byteorder="big")) + buf.seek(end) + + +# LISTS + + +class SkipItem(Exception): + "There is nothing to append for this invocation of a pull_list() func" + + +def pull_list(buf: Buffer, capacity: int, func: Callable[[], T]) -> List[T]: + """ + Pull a list of items. + + If the callable raises SkipItem, then iteration continues but nothing + is added to the list. + """ + items = [] + with pull_block(buf, capacity) as length: + end = buf.tell() + length + while buf.tell() < end: + try: + items.append(func()) + except SkipItem: + pass + return items + + +def push_list( + buf: Buffer, capacity: int, func: Callable[[T], None], values: Sequence[T] +) -> None: + """ + Push a list of items. + """ + with push_block(buf, capacity): + for value in values: + func(value) + + +def pull_opaque(buf: Buffer, capacity: int) -> bytes: + """ + Pull an opaque value prefixed by a length. + """ + with pull_block(buf, capacity) as length: + return buf.pull_bytes(length) + + +def push_opaque(buf: Buffer, capacity: int, value: bytes) -> None: + """ + Push an opaque value prefix by a length. + """ + with push_block(buf, capacity): + buf.push_bytes(value) + + +@contextmanager +def push_extension(buf: Buffer, extension_type: int) -> Generator: + buf.push_uint16(extension_type) + with push_block(buf, 2): + yield + + +# ServerName + + +def pull_server_name(buf: Buffer) -> str: + with pull_block(buf, 2): + name_type = buf.pull_uint8() + if name_type != NameType.HOST_NAME: + # We don't know this name_type. + raise AlertIllegalParameter( + f"ServerName has an unknown name type {name_type}" + ) + return pull_opaque(buf, 2).decode("ascii") + + +def push_server_name(buf: Buffer, server_name: str) -> None: + with push_block(buf, 2): + buf.push_uint8(NameType.HOST_NAME) + push_opaque(buf, 2, server_name.encode("ascii")) + + +# KeyShareEntry + + +KeyShareEntry = Tuple[int, bytes] + + +def pull_key_share(buf: Buffer) -> KeyShareEntry: + group = buf.pull_uint16() + data = pull_opaque(buf, 2) + return (group, data) + + +def push_key_share(buf: Buffer, value: KeyShareEntry) -> None: + buf.push_uint16(value[0]) + push_opaque(buf, 2, value[1]) + + +# ALPN + + +def pull_alpn_protocol(buf: Buffer) -> str: + try: + return pull_opaque(buf, 1).decode("ascii") + except UnicodeDecodeError: + # We can get arbitrary bytes values for alpns from greasing, + # but we expect them to be strings in the rest of the API, so + # we ignore them if they don't decode as ASCII. + raise SkipItem + + +def push_alpn_protocol(buf: Buffer, protocol: str) -> None: + push_opaque(buf, 1, protocol.encode("ascii")) + + +# PRE SHARED KEY + +PskIdentity = Tuple[bytes, int] + + +@dataclass +class OfferedPsks: + identities: List[PskIdentity] + binders: List[bytes] + + +def pull_psk_identity(buf: Buffer) -> PskIdentity: + identity = pull_opaque(buf, 2) + obfuscated_ticket_age = buf.pull_uint32() + return (identity, obfuscated_ticket_age) + + +def push_psk_identity(buf: Buffer, entry: PskIdentity) -> None: + push_opaque(buf, 2, entry[0]) + buf.push_uint32(entry[1]) + + +def pull_psk_binder(buf: Buffer) -> bytes: + return pull_opaque(buf, 1) + + +def push_psk_binder(buf: Buffer, binder: bytes) -> None: + push_opaque(buf, 1, binder) + + +def pull_offered_psks(buf: Buffer) -> OfferedPsks: + return OfferedPsks( + identities=pull_list(buf, 2, partial(pull_psk_identity, buf)), + binders=pull_list(buf, 2, partial(pull_psk_binder, buf)), + ) + + +def push_offered_psks(buf: Buffer, pre_shared_key: OfferedPsks) -> None: + push_list( + buf, + 2, + partial(push_psk_identity, buf), + pre_shared_key.identities, + ) + push_list( + buf, + 2, + partial(push_psk_binder, buf), + pre_shared_key.binders, + ) + + +# MESSAGES + +Extension = Tuple[int, bytes] + + +@dataclass +class ClientHello: + random: bytes + legacy_session_id: bytes + cipher_suites: List[int] + legacy_compression_methods: List[int] + + # extensions + alpn_protocols: Optional[List[str]] = None + early_data: bool = False + key_share: Optional[List[KeyShareEntry]] = None + pre_shared_key: Optional[OfferedPsks] = None + psk_key_exchange_modes: Optional[List[int]] = None + server_name: Optional[str] = None + signature_algorithms: Optional[List[int]] = None + supported_groups: Optional[List[int]] = None + supported_versions: Optional[List[int]] = None + + other_extensions: List[Extension] = field(default_factory=list) + + +def pull_handshake_type(buf: Buffer, expected_type: HandshakeType) -> None: + """ + Pull the message type and assert it is the expected one. + + If it is not, we have a programming error. + """ + message_type = buf.pull_uint8() + assert message_type == expected_type + + +def pull_client_hello(buf: Buffer) -> ClientHello: + pull_handshake_type(buf, HandshakeType.CLIENT_HELLO) + with pull_block(buf, 3): + if buf.pull_uint16() != TLS_VERSION_1_2: + raise AlertDecodeError("ClientHello version is not 1.2") + + hello = ClientHello( + random=buf.pull_bytes(32), + legacy_session_id=pull_opaque(buf, 1), + cipher_suites=pull_list(buf, 2, buf.pull_uint16), + legacy_compression_methods=pull_list(buf, 1, buf.pull_uint8), + ) + + # extensions + after_psk = False + + def pull_extension() -> None: + # pre_shared_key MUST be last + nonlocal after_psk + if after_psk: + # the alert is Illegal Parameter per RFC 8446 section 4.2.11. + raise AlertIllegalParameter("PreSharedKey is not the last extension") + + extension_type = buf.pull_uint16() + extension_length = buf.pull_uint16() + if extension_type == ExtensionType.KEY_SHARE: + hello.key_share = pull_list(buf, 2, partial(pull_key_share, buf)) + elif extension_type == ExtensionType.SUPPORTED_VERSIONS: + hello.supported_versions = pull_list(buf, 1, buf.pull_uint16) + elif extension_type == ExtensionType.SIGNATURE_ALGORITHMS: + hello.signature_algorithms = pull_list(buf, 2, buf.pull_uint16) + elif extension_type == ExtensionType.SUPPORTED_GROUPS: + hello.supported_groups = pull_list(buf, 2, buf.pull_uint16) + elif extension_type == ExtensionType.PSK_KEY_EXCHANGE_MODES: + hello.psk_key_exchange_modes = pull_list(buf, 1, buf.pull_uint8) + elif extension_type == ExtensionType.SERVER_NAME: + hello.server_name = pull_server_name(buf) + elif extension_type == ExtensionType.ALPN: + hello.alpn_protocols = pull_list( + buf, 2, partial(pull_alpn_protocol, buf) + ) + elif extension_type == ExtensionType.EARLY_DATA: + hello.early_data = True + elif extension_type == ExtensionType.PRE_SHARED_KEY: + hello.pre_shared_key = pull_offered_psks(buf) + after_psk = True + else: + hello.other_extensions.append( + (extension_type, buf.pull_bytes(extension_length)) + ) + + pull_list(buf, 2, pull_extension) + + return hello + + +def push_client_hello(buf: Buffer, hello: ClientHello) -> None: + buf.push_uint8(HandshakeType.CLIENT_HELLO) + with push_block(buf, 3): + buf.push_uint16(TLS_VERSION_1_2) + buf.push_bytes(hello.random) + push_opaque(buf, 1, hello.legacy_session_id) + push_list(buf, 2, buf.push_uint16, hello.cipher_suites) + push_list(buf, 1, buf.push_uint8, hello.legacy_compression_methods) + + # extensions + with push_block(buf, 2): + with push_extension(buf, ExtensionType.KEY_SHARE): + push_list(buf, 2, partial(push_key_share, buf), hello.key_share) + + with push_extension(buf, ExtensionType.SUPPORTED_VERSIONS): + push_list(buf, 1, buf.push_uint16, hello.supported_versions) + + with push_extension(buf, ExtensionType.SIGNATURE_ALGORITHMS): + push_list(buf, 2, buf.push_uint16, hello.signature_algorithms) + + with push_extension(buf, ExtensionType.SUPPORTED_GROUPS): + push_list(buf, 2, buf.push_uint16, hello.supported_groups) + + if hello.psk_key_exchange_modes is not None: + with push_extension(buf, ExtensionType.PSK_KEY_EXCHANGE_MODES): + push_list(buf, 1, buf.push_uint8, hello.psk_key_exchange_modes) + + if hello.server_name is not None: + with push_extension(buf, ExtensionType.SERVER_NAME): + push_server_name(buf, hello.server_name) + + if hello.alpn_protocols is not None: + with push_extension(buf, ExtensionType.ALPN): + push_list( + buf, 2, partial(push_alpn_protocol, buf), hello.alpn_protocols + ) + + for extension_type, extension_value in hello.other_extensions: + with push_extension(buf, extension_type): + buf.push_bytes(extension_value) + + if hello.early_data: + with push_extension(buf, ExtensionType.EARLY_DATA): + pass + + # pre_shared_key MUST be last + if hello.pre_shared_key is not None: + with push_extension(buf, ExtensionType.PRE_SHARED_KEY): + push_offered_psks(buf, hello.pre_shared_key) + + +@dataclass +class ServerHello: + random: bytes + legacy_session_id: bytes + cipher_suite: int + compression_method: int + + # extensions + key_share: Optional[KeyShareEntry] = None + pre_shared_key: Optional[int] = None + supported_version: Optional[int] = None + other_extensions: List[Tuple[int, bytes]] = field(default_factory=list) + + +def pull_server_hello(buf: Buffer) -> ServerHello: + pull_handshake_type(buf, HandshakeType.SERVER_HELLO) + with pull_block(buf, 3): + if buf.pull_uint16() != TLS_VERSION_1_2: + raise AlertDecodeError("ServerHello version is not 1.2") + + hello = ServerHello( + random=buf.pull_bytes(32), + legacy_session_id=pull_opaque(buf, 1), + cipher_suite=buf.pull_uint16(), + compression_method=buf.pull_uint8(), + ) + + # extensions + def pull_extension() -> None: + extension_type = buf.pull_uint16() + extension_length = buf.pull_uint16() + if extension_type == ExtensionType.SUPPORTED_VERSIONS: + hello.supported_version = buf.pull_uint16() + elif extension_type == ExtensionType.KEY_SHARE: + hello.key_share = pull_key_share(buf) + elif extension_type == ExtensionType.PRE_SHARED_KEY: + hello.pre_shared_key = buf.pull_uint16() + else: + hello.other_extensions.append( + (extension_type, buf.pull_bytes(extension_length)) + ) + + pull_list(buf, 2, pull_extension) + + return hello + + +def push_server_hello(buf: Buffer, hello: ServerHello) -> None: + buf.push_uint8(HandshakeType.SERVER_HELLO) + with push_block(buf, 3): + buf.push_uint16(TLS_VERSION_1_2) + buf.push_bytes(hello.random) + + push_opaque(buf, 1, hello.legacy_session_id) + buf.push_uint16(hello.cipher_suite) + buf.push_uint8(hello.compression_method) + + # extensions + with push_block(buf, 2): + if hello.supported_version is not None: + with push_extension(buf, ExtensionType.SUPPORTED_VERSIONS): + buf.push_uint16(hello.supported_version) + + if hello.key_share is not None: + with push_extension(buf, ExtensionType.KEY_SHARE): + push_key_share(buf, hello.key_share) + + if hello.pre_shared_key is not None: + with push_extension(buf, ExtensionType.PRE_SHARED_KEY): + buf.push_uint16(hello.pre_shared_key) + + for extension_type, extension_value in hello.other_extensions: + with push_extension(buf, extension_type): + buf.push_bytes(extension_value) + + +@dataclass +class NewSessionTicket: + ticket_lifetime: int = 0 + ticket_age_add: int = 0 + ticket_nonce: bytes = b"" + ticket: bytes = b"" + + # extensions + max_early_data_size: Optional[int] = None + other_extensions: List[Tuple[int, bytes]] = field(default_factory=list) + + +def pull_new_session_ticket(buf: Buffer) -> NewSessionTicket: + new_session_ticket = NewSessionTicket() + + pull_handshake_type(buf, HandshakeType.NEW_SESSION_TICKET) + with pull_block(buf, 3): + new_session_ticket.ticket_lifetime = buf.pull_uint32() + new_session_ticket.ticket_age_add = buf.pull_uint32() + new_session_ticket.ticket_nonce = pull_opaque(buf, 1) + new_session_ticket.ticket = pull_opaque(buf, 2) + + def pull_extension() -> None: + extension_type = buf.pull_uint16() + extension_length = buf.pull_uint16() + if extension_type == ExtensionType.EARLY_DATA: + new_session_ticket.max_early_data_size = buf.pull_uint32() + else: + new_session_ticket.other_extensions.append( + (extension_type, buf.pull_bytes(extension_length)) + ) + + pull_list(buf, 2, pull_extension) + + return new_session_ticket + + +def push_new_session_ticket(buf: Buffer, new_session_ticket: NewSessionTicket) -> None: + buf.push_uint8(HandshakeType.NEW_SESSION_TICKET) + with push_block(buf, 3): + buf.push_uint32(new_session_ticket.ticket_lifetime) + buf.push_uint32(new_session_ticket.ticket_age_add) + push_opaque(buf, 1, new_session_ticket.ticket_nonce) + push_opaque(buf, 2, new_session_ticket.ticket) + + with push_block(buf, 2): + if new_session_ticket.max_early_data_size is not None: + with push_extension(buf, ExtensionType.EARLY_DATA): + buf.push_uint32(new_session_ticket.max_early_data_size) + + for extension_type, extension_value in new_session_ticket.other_extensions: + with push_extension(buf, extension_type): + buf.push_bytes(extension_value) + + +@dataclass +class EncryptedExtensions: + alpn_protocol: Optional[str] = None + early_data: bool = False + + other_extensions: List[Tuple[int, bytes]] = field(default_factory=list) + + +def pull_encrypted_extensions(buf: Buffer) -> EncryptedExtensions: + extensions = EncryptedExtensions() + + pull_handshake_type(buf, HandshakeType.ENCRYPTED_EXTENSIONS) + with pull_block(buf, 3): + + def pull_extension() -> None: + extension_type = buf.pull_uint16() + extension_length = buf.pull_uint16() + if extension_type == ExtensionType.ALPN: + extensions.alpn_protocol = pull_list( + buf, 2, partial(pull_alpn_protocol, buf) + )[0] + elif extension_type == ExtensionType.EARLY_DATA: + extensions.early_data = True + else: + extensions.other_extensions.append( + (extension_type, buf.pull_bytes(extension_length)) + ) + + pull_list(buf, 2, pull_extension) + + return extensions + + +def push_encrypted_extensions(buf: Buffer, extensions: EncryptedExtensions) -> None: + buf.push_uint8(HandshakeType.ENCRYPTED_EXTENSIONS) + with push_block(buf, 3): + with push_block(buf, 2): + if extensions.alpn_protocol is not None: + with push_extension(buf, ExtensionType.ALPN): + push_list( + buf, + 2, + partial(push_alpn_protocol, buf), + [extensions.alpn_protocol], + ) + + if extensions.early_data: + with push_extension(buf, ExtensionType.EARLY_DATA): + pass + + for extension_type, extension_value in extensions.other_extensions: + with push_extension(buf, extension_type): + buf.push_bytes(extension_value) + + +CertificateEntry = Tuple[bytes, bytes] + + +@dataclass +class Certificate: + request_context: bytes = b"" + certificates: List[CertificateEntry] = field(default_factory=list) + + +def pull_certificate(buf: Buffer) -> Certificate: + certificate = Certificate() + + pull_handshake_type(buf, HandshakeType.CERTIFICATE) + with pull_block(buf, 3): + certificate.request_context = pull_opaque(buf, 1) + + def pull_certificate_entry(buf: Buffer) -> CertificateEntry: + data = pull_opaque(buf, 3) + extensions = pull_opaque(buf, 2) + return (data, extensions) + + certificate.certificates = pull_list( + buf, 3, partial(pull_certificate_entry, buf) + ) + + return certificate + + +def push_certificate(buf: Buffer, certificate: Certificate) -> None: + buf.push_uint8(HandshakeType.CERTIFICATE) + with push_block(buf, 3): + push_opaque(buf, 1, certificate.request_context) + + def push_certificate_entry(buf: Buffer, entry: CertificateEntry) -> None: + push_opaque(buf, 3, entry[0]) + push_opaque(buf, 2, entry[1]) + + push_list( + buf, 3, partial(push_certificate_entry, buf), certificate.certificates + ) + + +@dataclass +class CertificateRequest: + request_context: bytes = b"" + signature_algorithms: Optional[List[int]] = None + other_extensions: List[Tuple[int, bytes]] = field(default_factory=list) + + +def pull_certificate_request(buf: Buffer) -> CertificateRequest: + certificate_request = CertificateRequest() + + pull_handshake_type(buf, HandshakeType.CERTIFICATE_REQUEST) + with pull_block(buf, 3): + certificate_request.request_context = pull_opaque(buf, 1) + + def pull_extension() -> None: + extension_type = buf.pull_uint16() + extension_length = buf.pull_uint16() + if extension_type == ExtensionType.SIGNATURE_ALGORITHMS: + certificate_request.signature_algorithms = pull_list( + buf, 2, buf.pull_uint16 + ) + else: + certificate_request.other_extensions.append( + (extension_type, buf.pull_bytes(extension_length)) + ) + + pull_list(buf, 2, pull_extension) + + return certificate_request + + +def push_certificate_request( + buf: Buffer, certificate_request: CertificateRequest +) -> None: + buf.push_uint8(HandshakeType.CERTIFICATE_REQUEST) + with push_block(buf, 3): + push_opaque(buf, 1, certificate_request.request_context) + + with push_block(buf, 2): + with push_extension(buf, ExtensionType.SIGNATURE_ALGORITHMS): + push_list( + buf, 2, buf.push_uint16, certificate_request.signature_algorithms + ) + + for extension_type, extension_value in certificate_request.other_extensions: + with push_extension(buf, extension_type): + buf.push_bytes(extension_value) + + +@dataclass +class CertificateVerify: + algorithm: int + signature: bytes + + +def pull_certificate_verify(buf: Buffer) -> CertificateVerify: + pull_handshake_type(buf, HandshakeType.CERTIFICATE_VERIFY) + with pull_block(buf, 3): + algorithm = buf.pull_uint16() + signature = pull_opaque(buf, 2) + + return CertificateVerify(algorithm=algorithm, signature=signature) + + +def push_certificate_verify(buf: Buffer, verify: CertificateVerify) -> None: + buf.push_uint8(HandshakeType.CERTIFICATE_VERIFY) + with push_block(buf, 3): + buf.push_uint16(verify.algorithm) + push_opaque(buf, 2, verify.signature) + + +@dataclass +class Finished: + verify_data: bytes = b"" + + +def pull_finished(buf: Buffer) -> Finished: + finished = Finished() + + pull_handshake_type(buf, HandshakeType.FINISHED) + finished.verify_data = pull_opaque(buf, 3) + + return finished + + +def push_finished(buf: Buffer, finished: Finished) -> None: + buf.push_uint8(HandshakeType.FINISHED) + push_opaque(buf, 3, finished.verify_data) + + +# CONTEXT + + +class KeySchedule: + def __init__(self, cipher_suite: CipherSuite): + self.algorithm = cipher_suite_hash(cipher_suite) + self.cipher_suite = cipher_suite + self.generation = 0 + self.hash = hashes.Hash(self.algorithm) + self.hash_empty_value = self.hash.copy().finalize() + self.secret = bytes(self.algorithm.digest_size) + + def certificate_verify_data(self, context_string: bytes) -> bytes: + return b" " * 64 + context_string + b"\x00" + self.hash.copy().finalize() + + def finished_verify_data(self, secret: bytes) -> bytes: + hmac_key = hkdf_expand_label( + algorithm=self.algorithm, + secret=secret, + label=b"finished", + hash_value=b"", + length=self.algorithm.digest_size, + ) + + h = hmac.HMAC(hmac_key, algorithm=self.algorithm) + h.update(self.hash.copy().finalize()) + return h.finalize() + + def derive_secret(self, label: bytes) -> bytes: + return hkdf_expand_label( + algorithm=self.algorithm, + secret=self.secret, + label=label, + hash_value=self.hash.copy().finalize(), + length=self.algorithm.digest_size, + ) + + def extract(self, key_material: Optional[bytes] = None) -> None: + if key_material is None: + key_material = bytes(self.algorithm.digest_size) + + if self.generation: + self.secret = hkdf_expand_label( + algorithm=self.algorithm, + secret=self.secret, + label=b"derived", + hash_value=self.hash_empty_value, + length=self.algorithm.digest_size, + ) + + self.generation += 1 + self.secret = hkdf_extract( + algorithm=self.algorithm, salt=self.secret, key_material=key_material + ) + + def update_hash(self, data: bytes) -> None: + self.hash.update(data) + + +class KeyScheduleProxy: + def __init__(self, cipher_suites: List[CipherSuite]): + self.__schedules = dict(map(lambda c: (c, KeySchedule(c)), cipher_suites)) + + def extract(self, key_material: Optional[bytes] = None) -> None: + for k in self.__schedules.values(): + k.extract(key_material) + + def select(self, cipher_suite: CipherSuite) -> KeySchedule: + return self.__schedules[cipher_suite] + + def update_hash(self, data: bytes) -> None: + for k in self.__schedules.values(): + k.update_hash(data) + + +CIPHER_SUITES: Dict = { + CipherSuite.AES_128_GCM_SHA256: hashes.SHA256, + CipherSuite.AES_256_GCM_SHA384: hashes.SHA384, + CipherSuite.CHACHA20_POLY1305_SHA256: hashes.SHA256, +} + +SIGNATURE_ALGORITHMS: Dict = { + SignatureAlgorithm.ECDSA_SECP256R1_SHA256: (None, hashes.SHA256), + SignatureAlgorithm.ECDSA_SECP384R1_SHA384: (None, hashes.SHA384), + SignatureAlgorithm.ECDSA_SECP521R1_SHA512: (None, hashes.SHA512), + SignatureAlgorithm.RSA_PKCS1_SHA1: (padding.PKCS1v15, hashes.SHA1), + SignatureAlgorithm.RSA_PKCS1_SHA256: (padding.PKCS1v15, hashes.SHA256), + SignatureAlgorithm.RSA_PKCS1_SHA384: (padding.PKCS1v15, hashes.SHA384), + SignatureAlgorithm.RSA_PKCS1_SHA512: (padding.PKCS1v15, hashes.SHA512), + SignatureAlgorithm.RSA_PSS_RSAE_SHA256: (padding.PSS, hashes.SHA256), + SignatureAlgorithm.RSA_PSS_RSAE_SHA384: (padding.PSS, hashes.SHA384), + SignatureAlgorithm.RSA_PSS_RSAE_SHA512: (padding.PSS, hashes.SHA512), +} + +GROUP_TO_CURVE: Dict = { + Group.SECP256R1: ec.SECP256R1, + Group.SECP384R1: ec.SECP384R1, + Group.SECP521R1: ec.SECP521R1, +} +CURVE_TO_GROUP = dict((v, k) for k, v in GROUP_TO_CURVE.items()) + + +def cipher_suite_hash(cipher_suite: CipherSuite) -> hashes.HashAlgorithm: + return CIPHER_SUITES[cipher_suite]() + + +def decode_public_key( + key_share: KeyShareEntry, +) -> Union[ec.EllipticCurvePublicKey, x25519.X25519PublicKey, x448.X448PublicKey, None]: + if key_share[0] == Group.X25519: + return x25519.X25519PublicKey.from_public_bytes(key_share[1]) + elif key_share[0] == Group.X448: + return x448.X448PublicKey.from_public_bytes(key_share[1]) + elif key_share[0] in GROUP_TO_CURVE: + return ec.EllipticCurvePublicKey.from_encoded_point( + GROUP_TO_CURVE[key_share[0]](), key_share[1] + ) + else: + return None + + +def encode_public_key( + public_key: Union[ + ec.EllipticCurvePublicKey, x25519.X25519PublicKey, x448.X448PublicKey + ], +) -> KeyShareEntry: + if isinstance(public_key, x25519.X25519PublicKey): + return (Group.X25519, public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)) + elif isinstance(public_key, x448.X448PublicKey): + return (Group.X448, public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)) + return ( + CURVE_TO_GROUP[public_key.curve.__class__], + public_key.public_bytes(Encoding.X962, PublicFormat.UncompressedPoint), + ) + + +def negotiate( + supported: List[T], offered: Optional[List[Any]], exc: Optional[Alert] = None +) -> T: + if offered is not None: + for c in supported: + if c in offered: + return c + + if exc is not None: + raise exc + return None + + +def signature_algorithm_params(signature_algorithm: int) -> Tuple: + if signature_algorithm in (SignatureAlgorithm.ED25519, SignatureAlgorithm.ED448): + return tuple() + + padding_cls, algorithm_cls = SIGNATURE_ALGORITHMS[signature_algorithm] + algorithm = algorithm_cls() + if padding_cls is None: + return (ec.ECDSA(algorithm),) + elif padding_cls == padding.PSS: + padding_obj = padding_cls( + mgf=padding.MGF1(algorithm), salt_length=algorithm.digest_size + ) + else: + padding_obj = padding_cls() + return padding_obj, algorithm + + +@contextmanager +def push_message( + key_schedule: Union[KeySchedule, KeyScheduleProxy], buf: Buffer +) -> Generator: + hash_start = buf.tell() + yield + key_schedule.update_hash(buf.data_slice(hash_start, buf.tell())) + + +# callback types + + +@dataclass +class SessionTicket: + """ + A TLS session ticket for session resumption. + """ + + age_add: int + cipher_suite: CipherSuite + not_valid_after: datetime.datetime + not_valid_before: datetime.datetime + resumption_secret: bytes + server_name: str + ticket: bytes + + max_early_data_size: Optional[int] = None + other_extensions: List[Tuple[int, bytes]] = field(default_factory=list) + + @property + def is_valid(self) -> bool: + now = utcnow() + return now >= self.not_valid_before and now <= self.not_valid_after + + @property + def obfuscated_age(self) -> int: + age = int((utcnow() - self.not_valid_before).total_seconds() * 1000) + return (age + self.age_add) % (1 << 32) + + +AlpnHandler = Callable[[str], None] +SessionTicketFetcher = Callable[[bytes], Optional[SessionTicket]] +SessionTicketHandler = Callable[[SessionTicket], None] + + +class Context: + def __init__( + self, + is_client: bool, + alpn_protocols: Optional[List[str]] = None, + cadata: Optional[bytes] = None, + cafile: Optional[str] = None, + capath: Optional[str] = None, + cipher_suites: Optional[List[CipherSuite]] = None, + logger: Optional[Union[logging.Logger, logging.LoggerAdapter]] = None, + max_early_data: Optional[int] = None, + server_name: Optional[str] = None, + verify_mode: Optional[int] = None, + ): + # configuration + self._alpn_protocols = alpn_protocols + self._cadata = cadata + self._cafile = cafile + self._capath = capath + self.certificate: Optional[x509.Certificate] = None + self.certificate_chain: List[x509.Certificate] = [] + self.certificate_private_key: Optional[ + Union[dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, rsa.RSAPrivateKey] + ] = None + self.handshake_extensions: List[Extension] = [] + self._is_client = is_client + self._max_early_data = max_early_data + self.session_ticket: Optional[SessionTicket] = None + self._request_client_certificate = False # For test purposes only + self._server_name = server_name + if verify_mode is not None: + self._verify_mode = verify_mode + else: + self._verify_mode = ssl.CERT_REQUIRED if is_client else ssl.CERT_NONE + + # callbacks + self.alpn_cb: Optional[AlpnHandler] = None + self.get_session_ticket_cb: Optional[SessionTicketFetcher] = None + self.new_session_ticket_cb: Optional[SessionTicketHandler] = None + self.update_traffic_key_cb: Callable[ + [Direction, Epoch, CipherSuite, bytes], None + ] = lambda d, e, c, s: None + + # supported parameters + if cipher_suites is not None: + self._cipher_suites = cipher_suites + else: + self._cipher_suites = [ + CipherSuite.AES_256_GCM_SHA384, + CipherSuite.AES_128_GCM_SHA256, + CipherSuite.CHACHA20_POLY1305_SHA256, + ] + self._legacy_compression_methods: List[int] = [CompressionMethod.NULL] + self._psk_key_exchange_modes: List[int] = [PskKeyExchangeMode.PSK_DHE_KE] + self._signature_algorithms: List[int] = [ + SignatureAlgorithm.RSA_PSS_RSAE_SHA256, + SignatureAlgorithm.ECDSA_SECP256R1_SHA256, + SignatureAlgorithm.RSA_PKCS1_SHA256, + SignatureAlgorithm.RSA_PKCS1_SHA1, + ] + if default_backend().ed25519_supported(): + self._signature_algorithms.append(SignatureAlgorithm.ED25519) + if default_backend().ed448_supported(): + self._signature_algorithms.append(SignatureAlgorithm.ED448) + self._supported_groups = [Group.SECP256R1] + if default_backend().x25519_supported(): + self._supported_groups.append(Group.X25519) + if default_backend().x448_supported(): + self._supported_groups.append(Group.X448) + self._supported_versions = [TLS_VERSION_1_3] + + # state + self.alpn_negotiated: Optional[str] = None + self.early_data_accepted = False + self.key_schedule: Optional[KeySchedule] = None + self.received_extensions: Optional[List[Extension]] = None + self._certificate_request: Optional[CertificateRequest] = None + self._key_schedule_psk: Optional[KeySchedule] = None + self._key_schedule_proxy: Optional[KeyScheduleProxy] = None + self._new_session_ticket: Optional[NewSessionTicket] = None + self._peer_certificate: Optional[x509.Certificate] = None + self._peer_certificate_chain: List[x509.Certificate] = [] + self._psk_key_exchange_mode: Optional[int] = None + self._receive_buffer = b"" + self._session_resumed = False + self._enc_key: Optional[bytes] = None + self._dec_key: Optional[bytes] = None + self.__logger = logger + + self._ec_private_key: Optional[ec.EllipticCurvePrivateKey] = None + self._x25519_private_key: Optional[x25519.X25519PrivateKey] = None + self._x448_private_key: Optional[x448.X448PrivateKey] = None + + if is_client: + self.client_random = os.urandom(32) + self.legacy_session_id = b"" + self.state = State.CLIENT_HANDSHAKE_START + else: + self.client_random = None + self.legacy_session_id = None + self.state = State.SERVER_EXPECT_CLIENT_HELLO + + @property + def session_resumed(self) -> bool: + """ + Returns True if session resumption was successfully used. + """ + return self._session_resumed + + def handle_message( + self, input_data: bytes, output_buf: Dict[Epoch, Buffer] + ) -> None: + if self.state == State.CLIENT_HANDSHAKE_START: + self._client_send_hello(output_buf[Epoch.INITIAL]) + return + + self._receive_buffer += input_data + while len(self._receive_buffer) >= 4: + # determine message length + message_type = self._receive_buffer[0] + message_length = 4 + int.from_bytes( + self._receive_buffer[1:4], byteorder="big" + ) + + # check message is complete + if len(self._receive_buffer) < message_length: + break + message = self._receive_buffer[:message_length] + self._receive_buffer = self._receive_buffer[message_length:] + + # process the message + try: + self._handle_reassembled_message( + message_type=message_type, + input_buf=Buffer(data=message), + output_buf=output_buf, + ) + except BufferReadError: + raise AlertDecodeError("Could not parse TLS message") + + def _handle_reassembled_message( + self, message_type: int, input_buf: Buffer, output_buf: Dict[Epoch, Buffer] + ) -> None: + # client states + + if self.state == State.CLIENT_EXPECT_SERVER_HELLO: + if message_type == HandshakeType.SERVER_HELLO: + self._client_handle_hello(input_buf, output_buf[Epoch.INITIAL]) + else: + raise AlertUnexpectedMessage + elif self.state == State.CLIENT_EXPECT_ENCRYPTED_EXTENSIONS: + if message_type == HandshakeType.ENCRYPTED_EXTENSIONS: + self._client_handle_encrypted_extensions(input_buf) + else: + raise AlertUnexpectedMessage + elif self.state == State.CLIENT_EXPECT_CERTIFICATE_REQUEST_OR_CERTIFICATE: + if message_type == HandshakeType.CERTIFICATE: + self._client_handle_certificate(input_buf) + elif message_type == HandshakeType.CERTIFICATE_REQUEST: + self._client_handle_certificate_request(input_buf) + else: + raise AlertUnexpectedMessage + elif self.state == State.CLIENT_EXPECT_CERTIFICATE: + if message_type == HandshakeType.CERTIFICATE: + self._client_handle_certificate(input_buf) + else: + raise AlertUnexpectedMessage + elif self.state == State.CLIENT_EXPECT_CERTIFICATE_VERIFY: + if message_type == HandshakeType.CERTIFICATE_VERIFY: + self._client_handle_certificate_verify(input_buf) + else: + raise AlertUnexpectedMessage + elif self.state == State.CLIENT_EXPECT_FINISHED: + if message_type == HandshakeType.FINISHED: + self._client_handle_finished(input_buf, output_buf[Epoch.HANDSHAKE]) + else: + raise AlertUnexpectedMessage + elif self.state == State.CLIENT_POST_HANDSHAKE: + if message_type == HandshakeType.NEW_SESSION_TICKET: + self._client_handle_new_session_ticket(input_buf) + else: + raise AlertUnexpectedMessage + + # server states + + elif self.state == State.SERVER_EXPECT_CLIENT_HELLO: + if message_type == HandshakeType.CLIENT_HELLO: + self._server_handle_hello( + input_buf, + output_buf[Epoch.INITIAL], + output_buf[Epoch.HANDSHAKE], + output_buf[Epoch.ONE_RTT], + ) + else: + raise AlertUnexpectedMessage + elif self.state == State.SERVER_EXPECT_CERTIFICATE: + if message_type == HandshakeType.CERTIFICATE: + self._server_handle_certificate(input_buf, output_buf[Epoch.ONE_RTT]) + else: + raise AlertUnexpectedMessage + elif self.state == State.SERVER_EXPECT_CERTIFICATE_VERIFY: + if message_type == HandshakeType.CERTIFICATE_VERIFY: + self._server_handle_certificate_verify( + input_buf, output_buf[Epoch.ONE_RTT] + ) + else: + raise AlertUnexpectedMessage + elif self.state == State.SERVER_EXPECT_FINISHED: + if message_type == HandshakeType.FINISHED: + self._server_handle_finished(input_buf, output_buf[Epoch.ONE_RTT]) + else: + raise AlertUnexpectedMessage + elif self.state == State.SERVER_POST_HANDSHAKE: + raise AlertUnexpectedMessage + + # This condition should never be reached, because if the message + # contains any extra bytes, the `pull_block` inside the message + # parser will raise `AlertDecodeError`. + assert input_buf.eof() + + def _build_session_ticket( + self, new_session_ticket: NewSessionTicket, other_extensions: List[Extension] + ) -> SessionTicket: + resumption_master_secret = self.key_schedule.derive_secret(b"res master") + resumption_secret = hkdf_expand_label( + algorithm=self.key_schedule.algorithm, + secret=resumption_master_secret, + label=b"resumption", + hash_value=new_session_ticket.ticket_nonce, + length=self.key_schedule.algorithm.digest_size, + ) + + timestamp = utcnow() + return SessionTicket( + age_add=new_session_ticket.ticket_age_add, + cipher_suite=self.key_schedule.cipher_suite, + max_early_data_size=new_session_ticket.max_early_data_size, + not_valid_after=timestamp + + datetime.timedelta(seconds=new_session_ticket.ticket_lifetime), + not_valid_before=timestamp, + other_extensions=other_extensions, + resumption_secret=resumption_secret, + server_name=self._server_name, + ticket=new_session_ticket.ticket, + ) + + def _check_certificate_verify_signature(self, verify: CertificateVerify) -> None: + if verify.algorithm not in self._signature_algorithms: + raise AlertDecryptError( + "CertificateVerify has a signature algorithm we did not advertise" + ) + + try: + # The type of public_key() is CertificatePublicKeyTypes, but along with + # ed25519 and ed448, which are fine, this type includes + # x25519 and x448 which can be public keys but can't sign. We know + # we won't get x25519 and x448 as they are not on our list of + # signature algorithms, so we can cast public key to + # CertificateIssuerPublicKeyTypes safely and make mypy happy. + public_key = cast( + CertificateIssuerPublicKeyTypes, self._peer_certificate.public_key() + ) + public_key.verify( + verify.signature, + self.key_schedule.certificate_verify_data( + SERVER_CONTEXT_STRING if self._is_client else CLIENT_CONTEXT_STRING + ), + *signature_algorithm_params(verify.algorithm), + ) + except InvalidSignature: + raise AlertDecryptError + + def _client_send_hello(self, output_buf: Buffer) -> None: + key_share: List[KeyShareEntry] = [] + supported_groups: List[int] = [] + + for group in self._supported_groups: + if group == Group.SECP256R1: + self._ec_private_key = ec.generate_private_key( + GROUP_TO_CURVE[Group.SECP256R1]() + ) + key_share.append(encode_public_key(self._ec_private_key.public_key())) + supported_groups.append(Group.SECP256R1) + elif group == Group.X25519: + self._x25519_private_key = x25519.X25519PrivateKey.generate() + key_share.append( + encode_public_key(self._x25519_private_key.public_key()) + ) + supported_groups.append(Group.X25519) + elif group == Group.X448: + self._x448_private_key = x448.X448PrivateKey.generate() + key_share.append(encode_public_key(self._x448_private_key.public_key())) + supported_groups.append(Group.X448) + elif group == Group.GREASE: + key_share.append((Group.GREASE, b"\x00")) + supported_groups.append(Group.GREASE) + + assert len(key_share), "no key share entries" + + # Literal IPv4 and IPv6 addresses are not permitted in + # Server Name Indication (SNI) hostname. + try: + ipaddress.ip_address(self._server_name) + except ValueError: + server_name = self._server_name + else: + server_name = None + + hello = ClientHello( + random=self.client_random, + legacy_session_id=self.legacy_session_id, + cipher_suites=[int(x) for x in self._cipher_suites], + legacy_compression_methods=self._legacy_compression_methods, + alpn_protocols=self._alpn_protocols, + key_share=key_share, + psk_key_exchange_modes=( + self._psk_key_exchange_modes + if (self.session_ticket or self.new_session_ticket_cb is not None) + else None + ), + server_name=server_name, + signature_algorithms=self._signature_algorithms, + supported_groups=supported_groups, + supported_versions=self._supported_versions, + other_extensions=self.handshake_extensions, + ) + + # PSK + if self.session_ticket and self.session_ticket.is_valid: + self._key_schedule_psk = KeySchedule(self.session_ticket.cipher_suite) + self._key_schedule_psk.extract(self.session_ticket.resumption_secret) + binder_key = self._key_schedule_psk.derive_secret(b"res binder") + binder_length = self._key_schedule_psk.algorithm.digest_size + + # update hello + if self.session_ticket.max_early_data_size is not None: + hello.early_data = True + hello.pre_shared_key = OfferedPsks( + identities=[ + (self.session_ticket.ticket, self.session_ticket.obfuscated_age) + ], + binders=[bytes(binder_length)], + ) + + # serialize hello without binder + tmp_buf = Buffer(capacity=1024) + push_client_hello(tmp_buf, hello) + + # calculate binder + hash_offset = tmp_buf.tell() - binder_length - 3 + self._key_schedule_psk.update_hash(tmp_buf.data_slice(0, hash_offset)) + binder = self._key_schedule_psk.finished_verify_data(binder_key) + hello.pre_shared_key.binders[0] = binder + self._key_schedule_psk.update_hash( + tmp_buf.data_slice(hash_offset, hash_offset + 3) + binder + ) + + # calculate early data key + if hello.early_data: + early_key = self._key_schedule_psk.derive_secret(b"c e traffic") + self.update_traffic_key_cb( + Direction.ENCRYPT, + Epoch.ZERO_RTT, + self._key_schedule_psk.cipher_suite, + early_key, + ) + + self._key_schedule_proxy = KeyScheduleProxy(self._cipher_suites) + self._key_schedule_proxy.extract(None) + + with push_message(self._key_schedule_proxy, output_buf): + push_client_hello(output_buf, hello) + + self._set_state(State.CLIENT_EXPECT_SERVER_HELLO) + + def _client_handle_hello(self, input_buf: Buffer, output_buf: Buffer) -> None: + peer_hello = pull_server_hello(input_buf) + + cipher_suite = negotiate( + self._cipher_suites, + [peer_hello.cipher_suite], + AlertHandshakeFailure("Unsupported cipher suite"), + ) + if peer_hello.compression_method not in self._legacy_compression_methods: + raise AlertIllegalParameter( + "ServerHello has a compression method we did not advertise" + ) + if peer_hello.supported_version not in self._supported_versions: + raise AlertIllegalParameter( + "ServerHello has a version we did not advertise" + ) + + # select key schedule + if peer_hello.pre_shared_key is not None: + if ( + self._key_schedule_psk is None + or peer_hello.pre_shared_key != 0 + or cipher_suite != self._key_schedule_psk.cipher_suite + ): + raise AlertIllegalParameter + self.key_schedule = self._key_schedule_psk + self._session_resumed = True + else: + self.key_schedule = self._key_schedule_proxy.select(cipher_suite) + self._key_schedule_psk = None + self._key_schedule_proxy = None + + # perform key exchange + peer_public_key = decode_public_key(peer_hello.key_share) + shared_key: Optional[bytes] = None + if ( + isinstance(peer_public_key, x25519.X25519PublicKey) + and self._x25519_private_key is not None + ): + shared_key = self._x25519_private_key.exchange(peer_public_key) + elif ( + isinstance(peer_public_key, x448.X448PublicKey) + and self._x448_private_key is not None + ): + shared_key = self._x448_private_key.exchange(peer_public_key) + elif ( + isinstance(peer_public_key, ec.EllipticCurvePublicKey) + and self._ec_private_key is not None + and self._ec_private_key.public_key().curve.__class__ + == peer_public_key.curve.__class__ + ): + shared_key = self._ec_private_key.exchange(ec.ECDH(), peer_public_key) + assert shared_key is not None + + self.key_schedule.update_hash(input_buf.data) + self.key_schedule.extract(shared_key) + + self._setup_traffic_protection( + Direction.DECRYPT, Epoch.HANDSHAKE, b"s hs traffic" + ) + + self._set_state(State.CLIENT_EXPECT_ENCRYPTED_EXTENSIONS) + + def _client_handle_encrypted_extensions(self, input_buf: Buffer) -> None: + encrypted_extensions = pull_encrypted_extensions(input_buf) + + self.alpn_negotiated = encrypted_extensions.alpn_protocol + self.early_data_accepted = encrypted_extensions.early_data + self.received_extensions = encrypted_extensions.other_extensions + if self.alpn_cb: + self.alpn_cb(self.alpn_negotiated) + + self._setup_traffic_protection( + Direction.ENCRYPT, Epoch.HANDSHAKE, b"c hs traffic" + ) + self.key_schedule.update_hash(input_buf.data) + + # if the server accepted our PSK we are done, other we want its certificate + if self._session_resumed: + self._set_state(State.CLIENT_EXPECT_FINISHED) + else: + self._set_state(State.CLIENT_EXPECT_CERTIFICATE_REQUEST_OR_CERTIFICATE) + + def _client_handle_certificate_request(self, input_buf: Buffer) -> None: + self._certificate_request = pull_certificate_request(input_buf) + self.key_schedule.update_hash(input_buf.data) + self._set_state(State.CLIENT_EXPECT_CERTIFICATE) + + def _client_handle_certificate(self, input_buf: Buffer) -> None: + certificate = pull_certificate(input_buf) + self.key_schedule.update_hash(input_buf.data) + + self._set_peer_certificate(certificate) + self._set_state(State.CLIENT_EXPECT_CERTIFICATE_VERIFY) + + def _client_handle_certificate_verify(self, input_buf: Buffer) -> None: + verify = pull_certificate_verify(input_buf) + + # check signature + self._check_certificate_verify_signature(verify) + + # check certificate + if self._verify_mode != ssl.CERT_NONE: + verify_certificate( + cadata=self._cadata, + cafile=self._cafile, + capath=self._capath, + certificate=self._peer_certificate, + chain=self._peer_certificate_chain, + server_name=self._server_name, + ) + + self.key_schedule.update_hash(input_buf.data) + self._set_state(State.CLIENT_EXPECT_FINISHED) + + def _client_handle_finished(self, input_buf: Buffer, output_buf: Buffer) -> None: + finished = pull_finished(input_buf) + + # check verify data + expected_verify_data = self.key_schedule.finished_verify_data(self._dec_key) + if finished.verify_data != expected_verify_data: + raise AlertDecryptError + self.key_schedule.update_hash(input_buf.data) + + # prepare traffic keys + assert self.key_schedule.generation == 2 + self.key_schedule.extract(None) + self._setup_traffic_protection( + Direction.DECRYPT, Epoch.ONE_RTT, b"s ap traffic" + ) + next_enc_key = self.key_schedule.derive_secret(b"c ap traffic") + + if self._certificate_request is not None: + # check whether we have a suitable signature algorithm + if ( + self.certificate is not None + and self.certificate_private_key is not None + ): + signature_algorithm = negotiate( + self._signature_algorithms_for_private_key(), + self._certificate_request.signature_algorithms, + ) + else: + signature_algorithm = None + + # send certificate + with push_message(self.key_schedule, output_buf): + push_certificate( + output_buf, + Certificate( + request_context=self._certificate_request.request_context, + certificates=( + [ + (x.public_bytes(Encoding.DER), b"") + for x in [self.certificate] + self.certificate_chain + ] + if signature_algorithm + else [] + ), + ), + ) + + # send certificate verify + if signature_algorithm: + signature = self.certificate_private_key.sign( + self.key_schedule.certificate_verify_data(CLIENT_CONTEXT_STRING), + *signature_algorithm_params(signature_algorithm), + ) + with push_message(self.key_schedule, output_buf): + push_certificate_verify( + output_buf, + CertificateVerify( + algorithm=signature_algorithm, signature=signature + ), + ) + + # send finished + with push_message(self.key_schedule, output_buf): + push_finished( + output_buf, + Finished( + verify_data=self.key_schedule.finished_verify_data(self._enc_key) + ), + ) + + # commit traffic key + self._enc_key = next_enc_key + self.update_traffic_key_cb( + Direction.ENCRYPT, + Epoch.ONE_RTT, + self.key_schedule.cipher_suite, + self._enc_key, + ) + + self._set_state(State.CLIENT_POST_HANDSHAKE) + + def _client_handle_new_session_ticket(self, input_buf: Buffer) -> None: + new_session_ticket = pull_new_session_ticket(input_buf) + + # notify application + if self.new_session_ticket_cb is not None: + ticket = self._build_session_ticket( + new_session_ticket, self.received_extensions + ) + self.new_session_ticket_cb(ticket) + + def _server_expect_finished(self, onertt_buf: Buffer): + # anticipate client's FINISHED + self._expected_verify_data = self.key_schedule.finished_verify_data( + self._dec_key + ) + buf = Buffer(capacity=64) + push_finished(buf, Finished(verify_data=self._expected_verify_data)) + self.key_schedule.update_hash(buf.data) + + # create a new session ticket + if ( + self.new_session_ticket_cb is not None + and self._psk_key_exchange_mode is not None + ): + self._new_session_ticket = NewSessionTicket( + ticket_lifetime=86400, + ticket_age_add=struct.unpack("I", os.urandom(4))[0], + ticket_nonce=b"", + ticket=os.urandom(64), + max_early_data_size=self._max_early_data, + ) + + # send message + push_new_session_ticket(onertt_buf, self._new_session_ticket) + + # notify application + ticket = self._build_session_ticket( + self._new_session_ticket, self.handshake_extensions + ) + self.new_session_ticket_cb(ticket) + + self._set_state(State.SERVER_EXPECT_FINISHED) + + def _server_handle_hello( + self, + input_buf: Buffer, + initial_buf: Buffer, + handshake_buf: Buffer, + onertt_buf: Buffer, + ) -> None: + peer_hello = pull_client_hello(input_buf) + + # negotiate parameters + cipher_suite = negotiate( + self._cipher_suites, + peer_hello.cipher_suites, + AlertHandshakeFailure("No supported cipher suite"), + ) + compression_method = negotiate( + self._legacy_compression_methods, + peer_hello.legacy_compression_methods, + AlertHandshakeFailure("No supported compression method"), + ) + psk_key_exchange_mode = negotiate( + self._psk_key_exchange_modes, peer_hello.psk_key_exchange_modes + ) + signature_algorithm = negotiate( + self._signature_algorithms_for_private_key(), + peer_hello.signature_algorithms, + AlertHandshakeFailure("No supported signature algorithm"), + ) + supported_version = negotiate( + self._supported_versions, + peer_hello.supported_versions, + AlertProtocolVersion("No supported protocol version"), + ) + + # negotiate ALPN + if self._alpn_protocols is not None: + self.alpn_negotiated = negotiate( + self._alpn_protocols, + peer_hello.alpn_protocols, + AlertHandshakeFailure("No common ALPN protocols"), + ) + if self.alpn_cb: + self.alpn_cb(self.alpn_negotiated) + + self.client_random = peer_hello.random + self.server_random = os.urandom(32) + self.legacy_session_id = peer_hello.legacy_session_id + self.received_extensions = peer_hello.other_extensions + + # select key schedule + pre_shared_key = None + if ( + self.get_session_ticket_cb is not None + and psk_key_exchange_mode is not None + and peer_hello.pre_shared_key is not None + and len(peer_hello.pre_shared_key.identities) == 1 + and len(peer_hello.pre_shared_key.binders) == 1 + ): + # ask application to find session ticket + identity = peer_hello.pre_shared_key.identities[0] + session_ticket = self.get_session_ticket_cb(identity[0]) + + # validate session ticket + if ( + session_ticket is not None + and session_ticket.is_valid + and session_ticket.cipher_suite == cipher_suite + ): + self.key_schedule = KeySchedule(cipher_suite) + self.key_schedule.extract(session_ticket.resumption_secret) + + binder_key = self.key_schedule.derive_secret(b"res binder") + binder_length = self.key_schedule.algorithm.digest_size + + hash_offset = input_buf.tell() - binder_length - 3 + binder = input_buf.data_slice( + hash_offset + 3, hash_offset + 3 + binder_length + ) + + self.key_schedule.update_hash(input_buf.data_slice(0, hash_offset)) + expected_binder = self.key_schedule.finished_verify_data(binder_key) + + if binder != expected_binder: + raise AlertHandshakeFailure("PSK validation failed") + + self.key_schedule.update_hash( + input_buf.data_slice(hash_offset, hash_offset + 3 + binder_length) + ) + self._session_resumed = True + + # calculate early data key + if peer_hello.early_data: + early_key = self.key_schedule.derive_secret(b"c e traffic") + self.early_data_accepted = True + self.update_traffic_key_cb( + Direction.DECRYPT, + Epoch.ZERO_RTT, + self.key_schedule.cipher_suite, + early_key, + ) + + pre_shared_key = 0 + + # if PSK is not used, initialize key schedule + if pre_shared_key is None: + self.key_schedule = KeySchedule(cipher_suite) + self.key_schedule.extract(None) + self.key_schedule.update_hash(input_buf.data) + + # perform key exchange + public_key: Union[ + ec.EllipticCurvePublicKey, x25519.X25519PublicKey, x448.X448PublicKey + ] + shared_key: Optional[bytes] = None + for key_share in peer_hello.key_share: + peer_public_key = decode_public_key(key_share) + if isinstance(peer_public_key, x25519.X25519PublicKey): + self._x25519_private_key = x25519.X25519PrivateKey.generate() + public_key = self._x25519_private_key.public_key() + shared_key = self._x25519_private_key.exchange(peer_public_key) + break + elif isinstance(peer_public_key, x448.X448PublicKey): + self._x448_private_key = x448.X448PrivateKey.generate() + public_key = self._x448_private_key.public_key() + shared_key = self._x448_private_key.exchange(peer_public_key) + break + elif isinstance(peer_public_key, ec.EllipticCurvePublicKey): + self._ec_private_key = ec.generate_private_key( + GROUP_TO_CURVE[key_share[0]]() + ) + public_key = self._ec_private_key.public_key() + shared_key = self._ec_private_key.exchange(ec.ECDH(), peer_public_key) + break + assert shared_key is not None + + # send hello + hello = ServerHello( + random=self.server_random, + legacy_session_id=self.legacy_session_id, + cipher_suite=cipher_suite, + compression_method=compression_method, + key_share=encode_public_key(public_key), + pre_shared_key=pre_shared_key, + supported_version=supported_version, + ) + with push_message(self.key_schedule, initial_buf): + push_server_hello(initial_buf, hello) + self.key_schedule.extract(shared_key) + + self._setup_traffic_protection( + Direction.ENCRYPT, Epoch.HANDSHAKE, b"s hs traffic" + ) + self._setup_traffic_protection( + Direction.DECRYPT, Epoch.HANDSHAKE, b"c hs traffic" + ) + + # send encrypted extensions + with push_message(self.key_schedule, handshake_buf): + push_encrypted_extensions( + handshake_buf, + EncryptedExtensions( + alpn_protocol=self.alpn_negotiated, + early_data=self.early_data_accepted, + other_extensions=self.handshake_extensions, + ), + ) + + if pre_shared_key is None: + # send certificate request + if self._request_client_certificate: + with push_message(self.key_schedule, handshake_buf): + push_certificate_request( + handshake_buf, + CertificateRequest( + request_context=b"", + signature_algorithms=self._signature_algorithms, + ), + ) + + # send certificate + with push_message(self.key_schedule, handshake_buf): + push_certificate( + handshake_buf, + Certificate( + request_context=b"", + certificates=[ + (x.public_bytes(Encoding.DER), b"") + for x in [self.certificate] + self.certificate_chain + ], + ), + ) + + # send certificate verify + signature = self.certificate_private_key.sign( + self.key_schedule.certificate_verify_data(SERVER_CONTEXT_STRING), + *signature_algorithm_params(signature_algorithm), + ) + with push_message(self.key_schedule, handshake_buf): + push_certificate_verify( + handshake_buf, + CertificateVerify( + algorithm=signature_algorithm, signature=signature + ), + ) + + # send finished + with push_message(self.key_schedule, handshake_buf): + push_finished( + handshake_buf, + Finished( + verify_data=self.key_schedule.finished_verify_data(self._enc_key) + ), + ) + + # prepare traffic keys + assert self.key_schedule.generation == 2 + self.key_schedule.extract(None) + self._setup_traffic_protection( + Direction.ENCRYPT, Epoch.ONE_RTT, b"s ap traffic" + ) + self._next_dec_key = self.key_schedule.derive_secret(b"c ap traffic") + + self._psk_key_exchange_mode = psk_key_exchange_mode + if self._request_client_certificate: + self._set_state(State.SERVER_EXPECT_CERTIFICATE) + else: + self._server_expect_finished(onertt_buf) + + def _server_handle_certificate(self, input_buf: Buffer, output_buf: Buffer) -> None: + certificate = pull_certificate(input_buf) + self.key_schedule.update_hash(input_buf.data) + + if certificate.certificates: + self._set_peer_certificate(certificate) + self._set_state(State.SERVER_EXPECT_CERTIFICATE_VERIFY) + else: + self._server_expect_finished(output_buf) + + def _server_handle_certificate_verify( + self, input_buf: Buffer, output_buf: Buffer + ) -> None: + verify = pull_certificate_verify(input_buf) + + # check signature + self._check_certificate_verify_signature(verify) + + self.key_schedule.update_hash(input_buf.data) + self._server_expect_finished(output_buf) + + def _server_handle_finished(self, input_buf: Buffer, output_buf: Buffer) -> None: + finished = pull_finished(input_buf) + + # check verify data + if finished.verify_data != self._expected_verify_data: + raise AlertDecryptError + + # commit traffic key + self._dec_key = self._next_dec_key + self._next_dec_key = None + self.update_traffic_key_cb( + Direction.DECRYPT, + Epoch.ONE_RTT, + self.key_schedule.cipher_suite, + self._dec_key, + ) + + self._set_state(State.SERVER_POST_HANDSHAKE) + + def _setup_traffic_protection( + self, direction: Direction, epoch: Epoch, label: bytes + ) -> None: + key = self.key_schedule.derive_secret(label) + + if direction == Direction.ENCRYPT: + self._enc_key = key + else: + self._dec_key = key + + self.update_traffic_key_cb( + direction, epoch, self.key_schedule.cipher_suite, key + ) + + def _set_peer_certificate(self, certificate: Certificate) -> None: + self._peer_certificate = x509.load_der_x509_certificate( + certificate.certificates[0][0] + ) + self._peer_certificate_chain = [ + x509.load_der_x509_certificate(certificate.certificates[i][0]) + for i in range(1, len(certificate.certificates)) + ] + + def _set_state(self, state: State) -> None: + if self.__logger: + self.__logger.debug("TLS %s -> %s", self.state, state) + self.state = state + + def _signature_algorithms_for_private_key(self) -> List[SignatureAlgorithm]: + signature_algorithms: List[SignatureAlgorithm] = [] + if isinstance(self.certificate_private_key, rsa.RSAPrivateKey): + signature_algorithms = [ + SignatureAlgorithm.RSA_PSS_RSAE_SHA256, + SignatureAlgorithm.RSA_PKCS1_SHA256, + SignatureAlgorithm.RSA_PKCS1_SHA1, + ] + elif isinstance( + self.certificate_private_key, ec.EllipticCurvePrivateKey + ) and isinstance(self.certificate_private_key.curve, ec.SECP256R1): + signature_algorithms = [SignatureAlgorithm.ECDSA_SECP256R1_SHA256] + elif isinstance(self.certificate_private_key, ed25519.Ed25519PrivateKey): + signature_algorithms = [SignatureAlgorithm.ED25519] + elif isinstance(self.certificate_private_key, ed448.Ed448PrivateKey): + signature_algorithms = [SignatureAlgorithm.ED448] + return signature_algorithms diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/quic_protocol.py b/hyperscale/core_rewrite/engines/client/http3/protocols/quic_protocol.py new file mode 100644 index 0000000..47d5e6c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/quic_protocol.py @@ -0,0 +1,1137 @@ +from __future__ import annotations + +import asyncio +import re +from collections import deque +from enum import Enum, IntEnum +from typing import ( + Any, + Callable, + Deque, + Dict, + FrozenSet, + List, + Optional, + Set, + Text, + Tuple, + Union, + cast, +) + +import pylsqpack +from aioquic.buffer import UINT_VAR_MAX_SIZE, Buffer, BufferReadError, encode_uint_var +from aioquic.h3.events import ( + DatagramReceived, + DataReceived, + H3Event, + Headers, + HeadersReceived, + PushPromiseReceived, + WebTransportStreamDataReceived, +) +from aioquic.quic.connection import ( + NetworkAddress, + QuicConnection, + stream_is_unidirectional, +) +from aioquic.quic.events import ( + ConnectionIdIssued, + ConnectionIdRetired, + ConnectionTerminated, + DatagramFrameReceived, + HandshakeCompleted, + PingAcknowledged, + QuicEvent, + StreamDataReceived, +) + +QuicConnectionIdHandler = Callable[[bytes], None] +QuicStreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], None] + + +USER_AGENT = "hyperscale/client" +RESERVED_SETTINGS = (0x0, 0x2, 0x3, 0x4, 0x5) +UPPERCASE = re.compile(b"[A-Z]") + + +class ErrorCode(IntEnum): + H3_NO_ERROR = 0x100 + H3_GENERAL_PROTOCOL_ERROR = 0x101 + H3_INTERNAL_ERROR = 0x102 + H3_STREAM_CREATION_ERROR = 0x103 + H3_CLOSED_CRITICAL_STREAM = 0x104 + H3_FRAME_UNEXPECTED = 0x105 + H3_FRAME_ERROR = 0x106 + H3_EXCESSIVE_LOAD = 0x107 + H3_ID_ERROR = 0x108 + H3_SETTINGS_ERROR = 0x109 + H3_MISSING_SETTINGS = 0x10A + H3_REQUEST_REJECTED = 0x10B + H3_REQUEST_CANCELLED = 0x10C + H3_REQUEST_INCOMPLETE = 0x10D + H3_MESSAGE_ERROR = 0x10E + H3_CONNECT_ERROR = 0x10F + H3_VERSION_FALLBACK = 0x110 + QPACK_DECOMPRESSION_FAILED = 0x200 + QPACK_ENCODER_STREAM_ERROR = 0x201 + QPACK_DECODER_STREAM_ERROR = 0x202 + + +class Setting(IntEnum): + QPACK_MAX_TABLE_CAPACITY = 0x1 + MAX_FIELD_SECTION_SIZE = 0x6 + QPACK_BLOCKED_STREAMS = 0x7 + + # https://datatracker.ietf.org/doc/html/rfc9220#section-5 + ENABLE_CONNECT_PROTOCOL = 0x8 + # https://datatracker.ietf.org/doc/html/draft-ietf-masque-h3-datagram-05#section-9.1 + H3_DATAGRAM = 0xFFD277 + # https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http2-02#section-10.1 + ENABLE_WEBTRANSPORT = 0x2B603742 + + # Dummy setting to check it is correctly ignored by the peer. + # https://datatracker.ietf.org/doc/html/rfc9114#section-7.2.4.1 + DUMMY = 0x21 + + +def encode_frame(frame_type: int, frame_data: bytes) -> bytes: + frame_length = len(frame_data) + buf = Buffer(capacity=frame_length + 2 * UINT_VAR_MAX_SIZE) + buf.push_uint_var(frame_type) + buf.push_uint_var(frame_length) + buf.push_bytes(frame_data) + return buf.data + + +def validate_headers( + headers: Headers, + allowed_pseudo_headers: FrozenSet[bytes], + required_pseudo_headers: FrozenSet[bytes], +) -> None: + after_pseudo_headers = False + authority: Optional[bytes] = None + path: Optional[bytes] = None + scheme: Optional[bytes] = None + seen_pseudo_headers: Set[bytes] = set() + for key, value in headers: + if UPPERCASE.search(key): + raise MessageError("Header %r contains uppercase letters" % key) + + if key.startswith(b":"): + # pseudo-headers + if after_pseudo_headers: + raise MessageError( + "Pseudo-header %r is not allowed after regular headers" % key + ) + if key not in allowed_pseudo_headers: + raise MessageError("Pseudo-header %r is not valid" % key) + if key in seen_pseudo_headers: + raise MessageError("Pseudo-header %r is included twice" % key) + seen_pseudo_headers.add(key) + + # store value + if key == b":authority": + authority = value + elif key == b":path": + path = value + elif key == b":scheme": + scheme = value + else: + # regular headers + after_pseudo_headers = True + + # check required pseudo-headers are present + missing = required_pseudo_headers.difference(seen_pseudo_headers) + if missing: + raise MessageError("Pseudo-headers %s are missing" % sorted(missing)) + + if scheme in (b"http", b"https"): + if not authority: + raise MessageError("Pseudo-header b':authority' cannot be empty") + if not path: + raise MessageError("Pseudo-header b':path' cannot be empty") + + +class ProtocolError(Exception): + """ + Base class for protocol errors. + + These errors are not exposed to the API user, they are handled + in :meth:`H3Connection.handle_event`. + """ + + error_code = ErrorCode.H3_GENERAL_PROTOCOL_ERROR + + def __init__(self, reason_phrase: str = ""): + self.reason_phrase = reason_phrase + +class QpackDecompressionFailed(ProtocolError): + error_code = ErrorCode.QPACK_DECOMPRESSION_FAILED + + +class QpackDecoderStreamError(ProtocolError): + error_code = ErrorCode.QPACK_DECODER_STREAM_ERROR + + +class QpackEncoderStreamError(ProtocolError): + error_code = ErrorCode.QPACK_ENCODER_STREAM_ERROR + + +class ClosedCriticalStream(ProtocolError): + error_code = ErrorCode.H3_CLOSED_CRITICAL_STREAM + + +class FrameUnexpected(ProtocolError): + error_code = ErrorCode.H3_FRAME_UNEXPECTED + + +class MessageError(ProtocolError): + error_code = ErrorCode.H3_MESSAGE_ERROR + + +class MissingSettingsError(ProtocolError): + error_code = ErrorCode.H3_MISSING_SETTINGS + + +class SettingsError(ProtocolError): + error_code = ErrorCode.H3_SETTINGS_ERROR + + +class StreamCreationError(ProtocolError): + error_code = ErrorCode.H3_STREAM_CREATION_ERROR + +class FrameType(IntEnum): + DATA = 0x0 + HEADERS = 0x1 + PRIORITY = 0x2 + CANCEL_PUSH = 0x3 + SETTINGS = 0x4 + PUSH_PROMISE = 0x5 + GOAWAY = 0x7 + MAX_PUSH_ID = 0xD + DUPLICATE_PUSH = 0xE + WEBTRANSPORT_STREAM = 0x41 + +class StreamType(IntEnum): + CONTROL = 0 + PUSH = 1 + QPACK_ENCODER = 2 + QPACK_DECODER = 3 + WEBTRANSPORT = 0x54 + +class HeadersState(Enum): + INITIAL = 0 + AFTER_HEADERS = 1 + AFTER_TRAILERS = 2 + +class H3Stream: + + __slots__ =( + 'blocked', + 'blocked_frame_size', + 'buffer', + 'ended', + 'frame_size', + 'frame_type', + 'headers_recv_state', + 'headers_send_state', + 'push_id', + 'session_id', + 'stream_id', + 'stream_type' + ) + + def __init__(self, stream_id: int) -> None: + self.blocked = False + self.blocked_frame_size: Optional[int] = None + self.buffer = b"" + self.ended = False + self.frame_size: Optional[int] = None + self.frame_type: Optional[int] = None + self.headers_recv_state: HeadersState = HeadersState.INITIAL + self.headers_send_state: HeadersState = HeadersState.INITIAL + self.push_id: Optional[int] = None + self.session_id: Optional[int] = None + self.stream_id = stream_id + self.stream_type: Optional[int] = None + + +class ResponseFrameCollection: + + __slots__ = ( + 'headers_frame', + 'body' + ) + + def __init__(self) -> None: + self.headers_frame: HeadersReceived = None + self.body = bytearray() + + +class QuicStreamAdapter(asyncio.Transport): + + __slots__ = ( + 'protocol', + 'stream_id' + ) + + def __init__(self, protocol: QuicProtocol, stream_id: int): + self.protocol = protocol + self.stream_id = stream_id + + def can_write_eof(self) -> bool: + return True + + def get_extra_info(self, name: str, default: Any = None) -> Any: + """ + Get information about the underlying QUIC stream. + """ + if name == "stream_id": + return self.stream_id + + def write(self, data): + self.protocol.quic.send_stream_data(self.stream_id, data) + self.protocol._transmit_soon() + + def write_eof(self): + self.protocol.quic.send_stream_data(self.stream_id, b"", end_stream=True) + self.protocol._transmit_soon() + + +class QuicProtocol(asyncio.DatagramProtocol): + + __slots__ = ( + 'loop', + '_request_waiter', + '_closed', + '_connected', + '_connected_waiter', + '_ping_waiters', + 'quic', + '_stream_readers', + '_timer', + '_timer_at', + '_transmit_task', + '_transport', + '_connection_id_issued_handler', + '_connection_id_retired_handler', + '_connection_terminated_handler', + '_stream_handler', + 'pushes', + 'request_events', + '_request_waiter', + '_stream', + '_is_done', + '_max_table_capacity', + '_blocked_streams', + '_decoder', + '_decoder_bytes_received', + '_decoder_bytes_sent', + 'encoder', + 'encoder_bytes_received', + 'encoder_bytes_sent', + '_settings_received', + '_stream', + '_max_push_id', + '_next_push_id', + '_local_control_stream_id', + '_local_decoder_stream_id', + '_local_encoder_stream_id', + '_peer_control_stream_id', + '_peer_decoder_stream_id', + '_peer_encoder_stream_id', + '_received_settings', + '_sent_settings', + 'responses', + '_is_closed' + ) + + def __init__( + self, quic: QuicConnection, + stream_handler: Optional[QuicStreamHandler] = None, + loop: asyncio.AbstractEventLoop = None + ): + + self.loop = loop + + self._request_waiter: Dict[int, asyncio.Future[Deque[H3Event]]] = {} + self._closed = asyncio.Event() + self._connected = False + self._connected_waiter: Optional[asyncio.Future[None]] = None + self._ping_waiters: Dict[int, asyncio.Future[None]] = {} + self.quic = quic + self._stream_readers: Dict[int, asyncio.StreamReader] = {} + self._timer: Optional[asyncio.TimerHandle] = None + self._timer_at: Optional[float] = None + self._transmit_task: Optional[asyncio.Handle] = None + self._transport: Optional[asyncio.DatagramTransport] = None + + # callbacks + self._connection_id_issued_handler: QuicConnectionIdHandler = lambda c: None + self._connection_id_retired_handler: QuicConnectionIdHandler = lambda c: None + self._connection_terminated_handler: Callable[[], None] = lambda: None + if stream_handler is not None: + self._stream_handler = stream_handler + else: + self._stream_handler = lambda r, w: None + + self.pushes: Dict[int, Deque[H3Event]] = {} + self.request_events: Dict[int, Deque[H3Event]] = {} + self._request_waiter: Dict[int, asyncio.Future[Deque[H3Event]]] = {} + self._stream: Dict[int, H3Stream] = {} + self._is_done = False + self._max_table_capacity = 4096 + self._blocked_streams = 128 + + self._decoder = pylsqpack.Decoder( + self._max_table_capacity, self._blocked_streams + ) + self._decoder_bytes_received = 0 + self._decoder_bytes_sent = 0 + self.encoder = pylsqpack.Encoder() + self.encoder_bytes_received = 0 + self.encoder_bytes_sent = 0 + self._settings_received = False + self._stream: Dict[int, H3Stream] = {} + + self._max_push_id: Optional[int] = None + self._next_push_id: int = 0 + + self._local_control_stream_id: Optional[int] = None + self._local_decoder_stream_id: Optional[int] = None + self._local_encoder_stream_id: Optional[int] = None + + self._peer_control_stream_id: Optional[int] = None + self._peer_decoder_stream_id: Optional[int] = None + self._peer_encoder_stream_id: Optional[int] = None + self._received_settings: Optional[Dict[int, int]] = None + self._sent_settings: Optional[Dict[int, int]] = None + self.responses: Dict[int, ResponseFrameCollection] = {} + self._is_closed = False + + + def init_connection(self) -> None: + # send our settings + self._local_control_stream_id = self._create_uni_stream(StreamType.CONTROL) + self._sent_settings = { + Setting.QPACK_MAX_TABLE_CAPACITY: self._max_table_capacity, + Setting.QPACK_BLOCKED_STREAMS: self._blocked_streams, + Setting.ENABLE_CONNECT_PROTOCOL: 1, + Setting.DUMMY: 1, + } + + buf = Buffer(capacity=1024) + + for setting, value in self._sent_settings.items(): + buf.push_uint_var(setting) + buf.push_uint_var(value) + + self.quic.send_stream_data( + self._local_control_stream_id, + encode_frame(FrameType.SETTINGS, buf.data), + ) + if self._max_push_id is not None: + self.quic.send_stream_data( + self._local_control_stream_id, + encode_frame(FrameType.MAX_PUSH_ID, encode_uint_var(self._max_push_id)), + ) + + # create encoder and decoder streams + self._local_encoder_stream_id = self._create_uni_stream( + StreamType.QPACK_ENCODER + ) + self._local_decoder_stream_id = self._create_uni_stream( + StreamType.QPACK_DECODER + ) + + def _create_uni_stream( + self, stream_type: int, push_id: Optional[int] = None + ) -> int: + """ + Create an unidirectional stream of the given type. + """ + stream_id = self.quic.get_next_available_stream_id(is_unidirectional=True) + self.quic.send_stream_data(stream_id, encode_uint_var(stream_type)) + return stream_id + + def change_connection_id(self) -> None: + """ + Change the connection ID used to communicate with the peer. + + The previous connection ID will be retired. + """ + self.quic.change_connection_id() + self.transmit() + + def close(self) -> None: + """ + Close the connection. + """ + self._is_closed = True + self.quic.close() + self.transmit() + + def connect(self, addr: NetworkAddress) -> None: + """ + Initiate the TLS handshake. + + This method can only be called for clients and a single time. + """ + self.quic.connect(addr, now=self.loop.time()) + self.transmit() + + + def get_or_create_stream(self, stream_id: int) -> H3Stream: + if stream_id not in self._stream: + self._stream[stream_id] = H3Stream(stream_id) + return self._stream[stream_id] + + + async def create_stream( + self, is_unidirectional: bool = False + ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: + """ + Create a QUIC stream and return a pair of (reader, writer) objects. + + The returned reader and writer objects are instances of :class:`asyncio.StreamReader` + and :class:`asyncio.StreamWriter` classes. + """ + stream_id = self.quic.get_next_available_stream_id( + is_unidirectional=is_unidirectional + ) + return self._create_stream(stream_id) + + def request_key_update(self) -> None: + """ + Request an update of the encryption keys. + """ + self.quic.request_key_update() + self.transmit() + + async def ping(self) -> None: + """ + Ping the peer and wait for the response. + """ + waiter = self.loop.create_future() + uid = id(waiter) + self._ping_waiters[uid] = waiter + self.quic.send_ping(uid) + self.transmit() + await asyncio.shield(waiter) + + def transmit(self) -> None: + """ + Send pending datagrams to the peer and arm the timer if needed. + """ + self._transmit_task = None + + # send datagrams + for data, addr in self.quic.datagrams_to_send(now=self.loop.time()): + self._transport.sendto(data, addr) + + # re-arm timer + timer_at = self.quic.get_timer() + if self._timer is not None and self._timer_at != timer_at: + self._timer.cancel() + self._timer = None + if self._timer is None and timer_at is not None: + self._timer = self.loop.call_at(timer_at, self._handle_timer) + self._timer_at = timer_at + + async def wait_closed(self) -> None: + """ + Wait for the connection to be closed. + """ + await self._closed.wait() + + async def wait_connected(self) -> None: + """ + Wait for the TLS handshake to complete. + """ + assert self._connected_waiter is None, "already awaiting connected" + if not self._connected: + self._connected_waiter = self.loop.create_future() + await self._connected_waiter + + # asyncio.Transport + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self._transport = cast(asyncio.DatagramTransport, transport) + + def datagram_received(self, data: Union[bytes, Text], addr: NetworkAddress) -> None: + self.quic.receive_datagram(cast(bytes, data), addr, now=self.loop.time()) + self._process_events() + self.transmit() + + # overridable + def quic_event_received(self, event: QuicEvent) -> None: + #  pass event to the HTTP layer' + events = [] + if not self._is_done: + try: + if isinstance(event, StreamDataReceived): + stream_id = event.stream_id + stream = self.get_or_create_stream(stream_id) + if stream_is_unidirectional(stream_id): + + http_events: List[H3Event] = [] + + stream.buffer += event.data + if event.end_stream: + stream.ended = True + + buf = Buffer(data=stream.buffer) + consumed = 0 + unblocked_streams: Set[int] = set() + + while ( + stream.stream_type + in (StreamType.PUSH, StreamType.CONTROL, StreamType.WEBTRANSPORT) + or not buf.eof() + ): + # fetch stream type for unidirectional streams + if stream.stream_type is None: + try: + stream.stream_type = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # check unicity + if stream.stream_type == StreamType.CONTROL: + if self._peer_control_stream_id is not None: + raise StreamCreationError("Only one control stream is allowed") + self._peer_control_stream_id = stream.stream_id + elif stream.stream_type == StreamType.QPACK_DECODER: + if self._peer_decoder_stream_id is not None: + raise StreamCreationError( + "Only one QPACK decoder stream is allowed" + ) + self._peer_decoder_stream_id = stream.stream_id + elif stream.stream_type == StreamType.QPACK_ENCODER: + if self._peer_encoder_stream_id is not None: + raise StreamCreationError( + "Only one QPACK encoder stream is allowed" + ) + self._peer_encoder_stream_id = stream.stream_id + + + + if stream.stream_type == StreamType.CONTROL: + if event.end_stream: + raise ClosedCriticalStream("Closing control stream is not allowed") + + # fetch next frame + try: + frame_type = buf.pull_uint_var() + frame_length = buf.pull_uint_var() + frame_data = buf.pull_bytes(frame_length) + except BufferReadError: + break + consumed = buf.tell() + + if frame_type != FrameType.SETTINGS and not self._settings_received: + raise MissingSettingsError + + if frame_type == FrameType.SETTINGS: + if self._settings_received: + raise FrameUnexpected("SETTINGS have already been received") + + buf = Buffer(data=frame_data) + settings: Dict[int, int] = {} + while not buf.eof(): + setting = buf.pull_uint_var() + value = buf.pull_uint_var() + if setting in RESERVED_SETTINGS: + raise SettingsError("Setting identifier 0x%x is reserved" % setting) + if setting in settings: + raise SettingsError("Setting identifier 0x%x is included twice" % setting) + settings[setting] = value + + for setting in [ + Setting.ENABLE_CONNECT_PROTOCOL, + Setting.ENABLE_WEBTRANSPORT, + Setting.H3_DATAGRAM, + ]: + if setting in settings and settings[setting] not in (0, 1): + raise SettingsError(f"{setting.name} setting must be 0 or 1") + + if ( + settings.get(Setting.H3_DATAGRAM) == 1 + and self.quic._remote_max_datagram_frame_size is None + ): + raise SettingsError( + "H3_DATAGRAM requires max_datagram_frame_size transport parameter" + ) + + if ( + settings.get(Setting.ENABLE_WEBTRANSPORT) == 1 + and settings.get(Setting.H3_DATAGRAM) != 1 + ): + raise SettingsError("ENABLE_WEBTRANSPORT requires H3_DATAGRAM") + + self._received_settings = settings + encoder = self.encoder.apply_settings( + max_table_capacity=settings.get(Setting.QPACK_MAX_TABLE_CAPACITY, 0), + blocked_streams=settings.get(Setting.QPACK_BLOCKED_STREAMS, 0), + ) + self.quic.send_stream_data(self._local_encoder_stream_id, encoder) + self._settings_received = True + + elif frame_type in ( + FrameType.DATA, + FrameType.HEADERS, + FrameType.PUSH_PROMISE, + FrameType.DUPLICATE_PUSH, + ): + raise FrameUnexpected("Invalid frame type on control stream") + + elif stream.stream_type == StreamType.PUSH: + # fetch push id + if stream.push_id is None: + try: + stream.push_id = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + return self._receive_request_or_push_data(stream, b"", event.end_stream) + elif stream.stream_type == StreamType.WEBTRANSPORT: + # fetch session id + if stream.session_id is None: + try: + stream.session_id = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + frame_data = stream.buffer[consumed:] + stream.buffer = b"" + + if frame_data or event.end_stream: + http_events.append( + WebTransportStreamDataReceived( + data=frame_data, + session_id=stream.session_id, + stream_ended=stream.ended, + stream_id=stream.stream_id, + ) + ) + return http_events + elif stream.stream_type == StreamType.QPACK_DECODER: + # feed unframed data to decoder + data = buf.pull_bytes(buf.capacity - buf.tell()) + consumed = buf.tell() + try: + self.encoder.feed_decoder(data) + except pylsqpack.DecoderStreamError as exc: + raise QpackDecoderStreamError() from exc + self._decoder_bytes_received += len(data) + elif stream.stream_type == StreamType.QPACK_ENCODER: + # feed unframed data to encoder + data = buf.pull_bytes(buf.capacity - buf.tell()) + consumed = buf.tell() + try: + unblocked_streams.update(self._decoder.feed_encoder(data)) + except pylsqpack.EncoderStreamError as exc: + raise QpackEncoderStreamError() from exc + self.encoder_bytes_received += len(data) + else: + # unknown stream type, discard data + buf.seek(buf.capacity) + consumed = buf.tell() + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + # process unblocked streams + for stream_id in unblocked_streams: + stream = self._stream[stream_id] + + # resume headers + http_events.extend( + self._handle_request_or_push_frame( + frame_type=FrameType.HEADERS, + frame_data=None, + stream=stream, + stream_ended=stream.ended and not stream.buffer, + ) + ) + stream.blocked = False + stream.blocked_frame_size = None + + # resume processing + if stream.buffer: + http_events.extend( + self._receive_request_or_push_data(stream, b"", stream.ended) + ) + + events = http_events + + else: + events = self._receive_request_or_push_data( + stream, event.data, event.end_stream + ) + elif isinstance(event, DatagramFrameReceived): + buf = Buffer(data=event.data) + try: + flow_id = buf.pull_uint_var() + except BufferReadError: + raise ProtocolError("Could not parse flow ID") + + events = [DatagramReceived( + data=event.data[buf.tell() :], + flow_id=flow_id + )] + + except ProtocolError as exc: + self._is_done = True + self.quic.close( + error_code=exc.error_code, reason_phrase=exc.reason_phrase + ) + + for http_event in events: + + if isinstance(http_event, HeadersReceived): + + if self.responses.get(http_event.stream_id) is None: + self.responses[http_event.stream_id] = ResponseFrameCollection() + + self.responses[http_event.stream_id].headers_frame = http_event + + if http_event.push_id in self.pushes: + # push + self.pushes[http_event.push_id].append(http_event) + + + elif isinstance(http_event, DataReceived): + + if self.responses.get(http_event.stream_id) is None: + self.responses[http_event.stream_id] = ResponseFrameCollection() + + self.responses[http_event.stream_id].body.extend(http_event.data) + request_waiter = self._request_waiter.get(http_event.stream_id) + + if http_event.stream_ended and request_waiter is None: + raise Exception('Err. - Stream failed') + + elif http_event.stream_ended and request_waiter.done() is False: + request_waiter.set_result(self.responses.pop(http_event.stream_id)) + + if http_event.push_id in self.pushes: + # push + self.pushes[http_event.push_id].append(http_event) + + elif isinstance(event, PushPromiseReceived): + self.pushes[event.push_id] = deque() + self.pushes[event.push_id].append(event) + + def _receive_request_or_push_data( + self, stream: H3Stream, data: bytes, stream_ended: bool + ) -> List[H3Event]: + """ + Handle data received on a request or push stream. + """ + http_events: List[H3Event] = [] + + stream.buffer += data + if stream_ended: + stream.ended = True + if stream.blocked: + return http_events + + # shortcut for WEBTRANSPORT_STREAM frame fragments + if ( + stream.frame_type == FrameType.WEBTRANSPORT_STREAM + and stream.session_id is not None + ): + http_events.append( + WebTransportStreamDataReceived( + data=stream.buffer, + session_id=stream.session_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + stream.buffer = b"" + return http_events + + # shortcut for DATA frame fragments + if ( + stream.frame_type == FrameType.DATA + and stream.frame_size is not None + and len(stream.buffer) < stream.frame_size + ): + http_events.append( + DataReceived( + data=stream.buffer, + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=False, + ) + ) + stream.frame_size -= len(stream.buffer) + stream.buffer = b"" + return http_events + + # handle lone FIN + if stream_ended and not stream.buffer: + http_events.append( + DataReceived( + data=b"", + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=True, + ) + ) + return http_events + + buf = Buffer(data=stream.buffer) + consumed = 0 + + while not buf.eof(): + # fetch next frame header + if stream.frame_size is None: + try: + stream.frame_type = buf.pull_uint_var() + stream.frame_size = buf.pull_uint_var() + except BufferReadError: + break + consumed = buf.tell() + + # WEBTRANSPORT_STREAM frames last until the end of the stream + if stream.frame_type == FrameType.WEBTRANSPORT_STREAM: + stream.session_id = stream.frame_size + stream.frame_size = None + + frame_data = stream.buffer[consumed:] + stream.buffer = b"" + + if frame_data or stream_ended: + http_events.append( + WebTransportStreamDataReceived( + data=frame_data, + session_id=stream.session_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + return http_events + + # check how much data is available + chunk_size = min(stream.frame_size, buf.capacity - consumed) + if stream.frame_type != FrameType.DATA and chunk_size < stream.frame_size: + break + + # read available data + frame_data = buf.pull_bytes(chunk_size) + frame_type = stream.frame_type + consumed = buf.tell() + + # detect end of frame + stream.frame_size -= chunk_size + if not stream.frame_size: + stream.frame_size = None + stream.frame_type = None + + try: + http_events.extend( + self._handle_request_or_push_frame( + frame_type=frame_type, + frame_data=frame_data, + stream=stream, + stream_ended=stream.ended and buf.eof(), + ) + ) + except pylsqpack.StreamBlocked: + stream.blocked = True + stream.blocked_frame_size = len(frame_data) + break + + # remove processed data from buffer + stream.buffer = stream.buffer[consumed:] + + return http_events + + def _create_stream( + self, stream_id: int + ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: + adapter = QuicStreamAdapter(self, stream_id) + reader = asyncio.StreamReader() + writer = asyncio.StreamWriter(adapter, None, reader, self.loop) + self._stream_readers[stream_id] = reader + return reader, writer + + def _handle_timer(self) -> None: + now = max(self._timer_at, self.loop.time()) + self._timer = None + self._timer_at = None + self.quic.handle_timer(now=now) + self._process_events() + self.transmit() + + def _process_events(self) -> None: + event = self.quic.next_event() + while event is not None: + if isinstance(event, ConnectionIdIssued): + self._connection_id_issued_handler(event.connection_id) + elif isinstance(event, ConnectionIdRetired): + self._connection_id_retired_handler(event.connection_id) + elif isinstance(event, ConnectionTerminated): + self._connection_terminated_handler() + + # abort connection waiter + if self._connected_waiter is not None: + waiter = self._connected_waiter + self._connected_waiter = None + waiter.set_exception(ConnectionError) + + # abort ping waiters + for waiter in self._ping_waiters.values(): + waiter.set_exception(ConnectionError) + self._ping_waiters.clear() + + self._closed.set() + elif isinstance(event, HandshakeCompleted): + if self._connected_waiter is not None: + waiter = self._connected_waiter + self._connected = True + self._connected_waiter = None + waiter.set_result(None) + elif isinstance(event, PingAcknowledged): + waiter = self._ping_waiters.pop(event.uid, None) + if waiter is not None: + waiter.set_result(None) + self.quic_event_received(event) + event = self.quic.next_event() + + def _transmit_soon(self) -> None: + if self._transmit_task is None: + self._transmit_task = self.loop.call_soon(self.transmit) + + def _handle_request_or_push_frame( + self, + frame_type: int, + frame_data: Optional[bytes], + stream: H3Stream, + stream_ended: bool, + ) -> List[H3Event]: + """ + Handle a frame received on a request or push stream. + """ + http_events: List[H3Event] = [] + + if frame_type == FrameType.DATA: + # check DATA frame is allowed + if stream.headers_recv_state != HeadersState.AFTER_HEADERS: + raise FrameUnexpected("DATA frame is not allowed in this state") + + if stream_ended or frame_data: + http_events.append( + DataReceived( + data=frame_data, + push_id=stream.push_id, + stream_ended=stream_ended, + stream_id=stream.stream_id, + ) + ) + elif frame_type == FrameType.HEADERS: + # check HEADERS frame is allowed + if stream.headers_recv_state == HeadersState.AFTER_TRAILERS: + raise FrameUnexpected("HEADERS frame is not allowed in this state") + + # try to decode HEADERS, may raise pylsqpack.StreamBlocked + headers = self._decode_headers(stream.stream_id, frame_data) + + # validate headers + if stream.headers_recv_state == HeadersState.INITIAL: + validate_headers( + headers, + allowed_pseudo_headers=frozenset((b":status",)), + required_pseudo_headers=frozenset((b":status",)), + ) + + else: + validate_headers( + headers, + allowed_pseudo_headers=frozenset(), + required_pseudo_headers=frozenset(), + ) + + + # update state and emit headers + if stream.headers_recv_state == HeadersState.INITIAL: + stream.headers_recv_state = HeadersState.AFTER_HEADERS + else: + stream.headers_recv_state = HeadersState.AFTER_TRAILERS + http_events.append( + HeadersReceived( + headers=headers, + push_id=stream.push_id, + stream_id=stream.stream_id, + stream_ended=stream_ended, + ) + ) + elif frame_type == FrameType.PUSH_PROMISE and stream.push_id is None: + + frame_buf = Buffer(data=frame_data) + push_id = frame_buf.pull_uint_var() + headers = self._decode_headers( + stream.stream_id, frame_data[frame_buf.tell() :] + ) + + # validate headers + validate_headers( + headers, + allowed_pseudo_headers=frozenset( + (b":method", b":scheme", b":authority", b":path") + ), + required_pseudo_headers=frozenset( + (b":method", b":scheme", b":authority", b":path") + ), + ) + + # emit event + http_events.append( + PushPromiseReceived( + headers=headers, push_id=push_id, stream_id=stream.stream_id + ) + ) + elif frame_type in ( + FrameType.PRIORITY, + FrameType.CANCEL_PUSH, + FrameType.SETTINGS, + FrameType.PUSH_PROMISE, + FrameType.GOAWAY, + FrameType.MAX_PUSH_ID, + FrameType.DUPLICATE_PUSH, + ): + raise FrameUnexpected( + "Invalid frame type on request stream" + if stream.push_id is None + else "Invalid frame type on push stream" + ) + + return http_events + + def _decode_headers(self, stream_id: int, frame_data: Optional[bytes]) -> Headers: + """ + Decode a HEADERS block and send decoder updates on the decoder stream. + + This is called with frame_data=None when a stream becomes unblocked. + """ + try: + if frame_data is None: + decoder, headers = self._decoder.resume_header(stream_id) + else: + decoder, headers = self._decoder.feed_header(stream_id, frame_data) + self._decoder_bytes_sent += len(decoder) + self.quic.send_stream_data(self._local_decoder_stream_id, decoder) + except pylsqpack.DecompressionFailed as exc: + raise QpackDecompressionFailed() from exc + + return headers diff --git a/hyperscale/core_rewrite/engines/client/http3/protocols/udp_connection.py b/hyperscale/core_rewrite/engines/client/http3/protocols/udp_connection.py new file mode 100644 index 0000000..a8eeef7 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/http3/protocols/udp_connection.py @@ -0,0 +1,92 @@ +import asyncio +import socket +from typing import Callable, Optional + +from aioquic.h3.connection import H3_ALPN +from aioquic.quic.configuration import QuicConfiguration +from aioquic.quic.connection import QuicConnection + +from .quic_protocol import QuicProtocol + +QuicStreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], None] + + +class UDPConnection: + + def __init__(self,) -> None: + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + self.transport: asyncio.DatagramTransport = None + self._connection = None + self.socket: socket.socket = None + self._writer = None + + async def create_http3( + self, + socket_config=None, + server_name: str=None, + configuration: Optional[QuicConfiguration] = None, + stream_handler: Optional[QuicStreamHandler] = None, + local_port: int = 0, + ) -> QuicProtocol: + + _, _, _, _, address = socket_config + if len(address) == 2: + address = ("::ffff:" + address[0], address[1], 0, 0) + + local_host = "::" + + # keep compatibility for Python 3.7 on Windows + if not hasattr(socket, "IPPROTO_IPV6"): + socket.IPPROTO_IPV6 = 41 + + configuration = QuicConfiguration( + is_client=True, alpn_protocols=H3_ALPN + ) + + # prepare QUIC connection + if configuration.server_name is None: + configuration.server_name = server_name + + connection = QuicConnection( + configuration=configuration, + session_ticket_handler=lambda handler: None + ) + + # explicitly enable IPv4/IPv6 dual stack + self.socket = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM) + completed = False + try: + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + self.socket.setblocking(False) + self.socket.bind((local_host, local_port, 0, 0)) + completed = True + finally: + if not completed: + self.socket.close() + # connect + self.loop = asyncio.get_event_loop() + _, protocol = await self.loop.create_datagram_endpoint( + lambda: QuicProtocol( + connection, + stream_handler=stream_handler, + loop=self.loop + ), + sock=self.socket, + ) + + + protocol.init_connection() + + protocol.connect(address) + await protocol.wait_connected() + + return protocol + + async def close(self): + + try: + self.transport.close() + + except Exception: + pass + diff --git a/hyperscale/core_rewrite/engines/client/playwright/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/__init__.py new file mode 100644 index 0000000..18364d4 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/__init__.py @@ -0,0 +1,3 @@ +from .mercury_sync_playwright_connection import ( + MercurySyncPlaywrightConnection as MercurySyncPlaywrightConnection, +) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_file_chooser.py b/hyperscale/core_rewrite/engines/client/playwright/browser_file_chooser.py new file mode 100644 index 0000000..a94d993 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_file_chooser.py @@ -0,0 +1,76 @@ +import time +from pathlib import Path +from typing import Dict, Literal, Optional, Sequence + +from playwright.async_api import FileChooser, FilePayload + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.browser import BrowserMetadata +from .models.commands.file_chooser import SetFilesCommand +from .models.results import PlaywrightResult + + +class BrowserFileChooser: + def __init__( + self, + file_chooser: FileChooser, + timeouts: Timeouts, + metadata: BrowserMetadata, + url: str, + ) -> None: + self.file_chooser = file_chooser + self.timeouts = timeouts + self.metadata = metadata + self.url = url + + async def set_files( + self, + files: str | Path | FilePayload | Sequence[str | Path] | Sequence[FilePayload], + no_wait_after: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetFilesCommand( + files=files, + no_wait_after=no_wait_after, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.file_chooser.set_files( + command.files, + no_wait_after=command.no_wait_after, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_files", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_files", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_frame.py b/hyperscale/core_rewrite/engines/client/playwright/browser_frame.py new file mode 100644 index 0000000..33bbd74 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_frame.py @@ -0,0 +1,1417 @@ +import asyncio +import time +from pathlib import Path +from typing import ( + Any, + Callable, + Dict, + Literal, + Optional, + Pattern, +) + +from playwright.async_api import ( + ElementHandle, + Frame, + FrameLocator, + JSHandle, + Locator, + Position, + Response, +) + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .browser_js_handle import BrowserJSHandle +from .browser_locator import BrowserLocator +from .models.browser import BrowserMetadata +from .models.commands.frame import FrameElementCommand +from .models.commands.page import ( + AddScriptTagCommand, + AddStyleTagCommand, + ContentCommand, + DOMCommand, + DragAndDropCommand, + EvaluateCommand, + FrameLocatorCommand, + GetByRoleCommand, + GetByTestIdCommand, + GetByTextCommand, + GoToCommand, + LocatorCommand, + SetContentCommand, + TitleCommand, + WaitForFunctionCommand, + WaitForLoadStateCommand, + WaitForUrlCommand, +) +from .models.results import PlaywrightResult + + +class BrowserFrame: + def __init__( + self, frame: Frame, timeouts: Timeouts, metadata: BrowserMetadata + ) -> None: + self.frame = frame + self.name = frame.name + self.url = frame.url + self.timeouts = timeouts + self.metadata = metadata + + async def add_script_tag( + self, + url: Optional[str] = None, + path: Optional[str | Path] = None, + content: Optional[str] = None, + tag_type: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AddScriptTagCommand( + url=url, path=path, content=content, tag_type=tag_type, timeout=timeout + ) + + result: ElementHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.frame.add_script_tag( + url=command.url, + path=command.path, + content=command.content, + type=command.tag_type, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_script_tag", + command_args=command, + metadata=self.metadata, + frame=self.name, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_script_tag", + command_args=command, + metadata=self.metadata, + frame=self.name, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + async def add_style_tag( + self, + url: Optional[str] = None, + path: Optional[str | Path] = None, + content: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AddStyleTagCommand( + url=url, path=path, content=content, timeout=timeout + ) + + result: ElementHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.frame.add_style_tag( + url=command.url, path=command.path, content=command.content + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_style_tag", + command_args=command, + metadata=self.metadata, + frame=self.name, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_style_tag", + command_args=command, + metadata=self.metadata, + frame=self.name, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + async def content( + self, + timeout: Optional[int | float] = None, + ) -> PlaywrightResult[Exception] | PlaywrightResult[str]: + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ContentCommand(timeout=timeout) + + result: str = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await self.frame.content(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="content", + command_args=command, + metadata=self.metadata, + frame=self.name, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="content", + command_args=command, + result=result, + metadata=self.metadata, + frame=self.name, + timings=timings, + source="frame", + url=self.url, + ) + + async def drag_and_drop( + self, + source: str, + target: str, + source_position: Optional[Position] = None, + target_position: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DragAndDropCommand( + source=source, + target=target, + source_position=source_position, + target_position=target_position, + force=force, + no_wait_after=no_wait_after, + strict=strict, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await self.frame.drag_and_drop( + command.source, + command.target, + source_position=command.source_position, + target_position=command.target_position, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="drag_and_drop", + command_args=command, + metadata=self.metadata, + frame=self.name, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="drag_and_drop", + command_args=command, + metadata=self.metadata, + frame=self.name, + result=None, + timings=timings, + source="frame", + url=self.url, + ) + + async def evaluate( + self, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression=expression, arg=arg, timeout=timeout) + + result: Any = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.frame.evaluate( + command.expression, + arg=command.arg, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + source="frame", + url=self.url, + ) + + async def evaluate_handle( + self, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression=expression, arg=arg, timeout=timeout) + + result: JSHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.frame.evaluate_handle( + command.expression, + arg=command.arg, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + result=BrowserJSHandle(result, self.timeouts, self.metadata, self.url), + metadata=self.metadata, + timings=timings, + source="frame", + url=self.url, + ) + + async def frame_element( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FrameElementCommand(timeout=timeout) + + result: ElementHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.frame.frame_element(), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_element", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_element", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + source="frame", + url=self.url, + ) + + async def frame_locator(self, selector: str, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FrameLocatorCommand(selector=selector, timeout=timeout) + + result: FrameLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.frame.frame_locator(command.selector), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_locator", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_locator", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + def get_by_alt_text( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.frame.get_by_alt_text(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_alt_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_alt_text", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + def get_by_label( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.frame.get_by_label(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_label", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_label", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + def get_by_placeholder( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.frame.get_by_placeholder(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_placeholder", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_placeholder", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + def get_by_role( + self, + role: Literal[ + "alert", + "alertdialog", + "application", + "article", + "banner", + "blockquote", + "button", + "caption", + "cell", + "checkbox", + "code", + "columnheader", + "combobox", + "complementary", + "contentinfo", + "definition", + "deletion", + "dialog", + "directory", + "document", + "emphasis", + "feed", + "figure", + "form", + "generic", + "grid", + "gridcell", + "group", + "heading", + "img", + "insertion", + "link", + "list", + "listbox", + "listitem", + "log", + "main", + "marquee", + "math", + "menu", + "menubar", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "meter", + "navigation", + "none", + "note", + "option", + "paragraph", + "presentation", + "progressbar", + "radio", + "radiogroup", + "region", + "row", + "rowgroup", + "rowheader", + "scrollbar", + "search", + "searchbox", + "separator", + "slider", + "spinbutton", + "status", + "strong", + "subscript", + "superscript", + "switch", + "tab", + "table", + "tablist", + "tabpanel", + "term", + "textbox", + "time", + "timer", + "toolbar", + "tooltip", + "tree", + "treegrid", + "treeitem", + ], + checked: Optional[bool] = None, + disabled: Optional[bool] = None, + expanded: Optional[bool] = None, + include_hidden: Optional[bool] = None, + level: Optional[int] = None, + name: Optional[str | Pattern[str]] = None, + pressed: Optional[bool] = None, + selected: Optional[bool] = None, + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByRoleCommand( + role=role, + checked=checked, + disabled=disabled, + expanded=expanded, + include_hidden=include_hidden, + level=level, + name=name, + pressed=pressed, + selected=selected, + exact=exact, + timeout=timeout, + ) + + result: BrowserLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.frame.get_by_role( + command.role, + checked=command.checked, + disabled=command.disabled, + expanded=command.expanded, + include_hidden=command.include_hidden, + level=command.level, + name=command.name, + pressed=command.pressed, + selected=command.selected, + ), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_role", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_role", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + def get_by_test_id( + self, test_id: str | Pattern[str], timeout: Optional[int | float] = None + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTestIdCommand(test_id=test_id, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.frame.get_by_test_id(command.test_id), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_test_id", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_test_id", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_text( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.frame.get_by_text(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_text", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + def get_by_title( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.frame.get_by_title(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_title", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_title", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + async def goto( + self, + url: str, + wait_util: Optional[ + Literal[ + "commit", + "domcontentloaded", + "load", + "networkidle", + ] + ] = None, + referrer: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GoToCommand( + url=url, timeout=timeout, wait_util=wait_util, referrer=referrer + ) + + result: Optional[Response] = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await self.frame.goto( + command.url, + timeout=command.timeout, + wait_until=command.wait_util, + referer=command.referrer, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="goto", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="goto", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + async def is_enabled( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await self.frame.is_enabled( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_enabled", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_enabled", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + source="frame", + url=self.url, + ) + + async def locator( + self, + selector: str, + has_text: Optional[str | Pattern[str]] = None, + has_not_text: Optional[str | Pattern[str]] = None, + has: Optional[Locator] = None, + has_not: Optional[Locator] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = LocatorCommand( + selector=selector, + has=has, + has_not=has_not, + has_text=has_text, + has_not_text=has_not_text, + timeout=timeout, + ) + + result: BrowserLocator = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + locator_result = await asyncio.wait_for( + self.frame.locator( + command.selector, + has_text=command.has_text, + has_not_text=command.has_not_text, + has=command.has, + has_not=command.has_not, + ), + timeout=command.timeout, + ) + + result = BrowserLocator( + locator_result, + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="locator", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="locator", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + source="frame", + url=self.url, + ) + + async def set_content( + self, + html: str, + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetContentCommand(html=html, wait_until=wait_until, timeout=timeout) + + err: Exception = None + + timings["command_start"] = time.monotonic() + + try: + await self.frame.set_content( + command.html, wait_until=command.wait_until, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + source="frame", + url=self.url, + ) + + async def title(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TitleCommand(timeout=timeout) + + result: str = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for(self.frame.title(), timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="title", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="title", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + source="frame", + url=self.url, + ) + + async def wait_for_function( + self, + expression: str, + arg: Optional[Any] = None, + polling: Optional[float | Literal["raf"]] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForFunctionCommand( + expression=expression, arg=arg, polling=polling, timeout=timeout + ) + + result: JSHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await self.frame.wait_for_function( + command.expression, + arg=command.arg, + polling=command.polling, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_function", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_function", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + source="frame", + url=self.url, + ) + + async def wait_for_load_state( + self, + state: Optional[Literal["domcontentloaded", "load", "networkidle"]] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForLoadStateCommand(state=state, timeout=timeout) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await self.frame.wait_for_load_state( + state=command.state, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_load_state", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_load_state", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + source="frame", + url=self.url, + ) + + async def wait_for_url( + self, + url: str | Pattern[str] | Callable[[str], bool], + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForUrlCommand(url=url, wait_until=wait_until, timeout=timeout) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await self.frame.wait_for_url( + command.url, wait_until=command.wait_until, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_url", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + source="frame", + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_url", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_js_handle.py b/hyperscale/core_rewrite/engines/client/playwright/browser_js_handle.py new file mode 100644 index 0000000..e150709 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_js_handle.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Any, Dict, Literal, Optional + +from playwright.async_api import JSHandle + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.browser import BrowserMetadata +from .models.commands.js_handle import EvaluateCommand +from .models.results import PlaywrightResult + + +class BrowserJSHandle: + def __init__( + self, + js_handle: JSHandle, + timeouts: Timeouts, + metadata: BrowserMetadata, + url: str, + ) -> None: + self.js_handle = js_handle + self.timeouts = timeouts + self.metadata = metadata + self.url = url + + async def get_properties(self): + properties: Dict[str, BrowserJSHandle] = {} + handle_properties = await self.js_handle.get_properties() + + for property, handle in handle_properties.items(): + properties[property] = BrowserJSHandle( + handle, + self.timeouts, + self.metadata, + self.url, + ) + + return properties + + async def get_property(self, name: str): + handle_property = await self.js_handle.get_property(name) + return BrowserJSHandle(handle_property, self.timeouts, self.metadata, self.url) + + async def json_value(self): + return await self.js_handle.json_value() + + async def dispose(self): + await self.js_handle.dispose() + + async def evaluate( + self, + expression: str, + arg: Optional[Any] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression, arg=arg, timeout=timeout) + + result: Any = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.js_handle.evaluate(command.expression, arg=command.arg), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def evaluate_handle( + self, + expression: str, + arg: Optional[Any] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression, arg=arg, timeout=timeout) + + result: BrowserJSHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.js_handle.evaluate_handle(command.expression, arg=command.arg), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_keyboard.py b/hyperscale/core_rewrite/engines/client/playwright/browser_keyboard.py new file mode 100644 index 0000000..0031d3a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_keyboard.py @@ -0,0 +1,263 @@ +import asyncio +import time +from typing import Dict, Literal, Optional + +from playwright.async_api import Keyboard + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.browser import BrowserMetadata +from .models.commands.keyboard import ( + KeyCommand, + PressCommand, + TypeCommand, +) +from .models.results import PlaywrightResult + + +class BrowserKeyboard: + def __init__( + self, + keyboard: Keyboard, + timeouts: Timeouts, + metadata: BrowserMetadata, + url: str, + ) -> None: + self.keyboard = keyboard + self.timeouts = timeouts + self.metadata = metadata + self.url = url + + async def down(self, key: str, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = KeyCommand( + key=key, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.keyboard.down(command.key), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="down", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="down", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def up(self, key: str, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = KeyCommand( + key=key, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.keyboard.up(command.key), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="up", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="up", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def press( + self, + key: str, + delay: Optional[int | float] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = PressCommand( + key=key, + delay=delay, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.keyboard.press(command.key, delay=command.delay), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def type_text( + self, + text: str, + delay: Optional[int | float] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TypeCommand( + text=text, + delay=delay, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.keyboard.type(command.text, delay=command.delay), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="type_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="type_text", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def insert_text(self, text: str, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TypeCommand( + text=text, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.keyboard.insert_text(command.text), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="insert_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="insert_text", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_locator.py b/hyperscale/core_rewrite/engines/client/playwright/browser_locator.py new file mode 100644 index 0000000..615ce9e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_locator.py @@ -0,0 +1,2851 @@ +from __future__ import annotations + +import asyncio +import time +from pathlib import Path +from typing import ( + Any, + Dict, + List, + Literal, + Optional, + Pattern, + Sequence, +) + +from playwright.async_api import ( + ElementHandle, + FilePayload, + FloatRect, + FrameLocator, + JSHandle, + Locator, + Position, +) + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .browser_js_handle import BrowserJSHandle +from .models.browser import BrowserMetadata +from .models.commands.locator import ( + AllTextsCommand, + AndMatchingCommand, + BoundingBoxCommand, + CheckCommand, + ClickCommand, + CountCommand, + DispatchEventCommand, + DOMCommand, + DragToCommand, + FillCommand, + FilterCommand, + FocusCommand, + GetAttributeCommand, + HighlightCommand, + HoverCommand, + NthCommand, + OrMatchingCommand, + PressCommand, + PressSequentiallyCommand, + ScrollIntoViewIfNeeded, + SelectOptionCommand, + SelectTextCommand, + SetCheckedCommand, + SetInputFilesCommand, + TapCommand, + WaitForCommand, +) +from .models.commands.page import ( + EvaluateCommand, + FrameLocatorCommand, + GetByRoleCommand, + GetByTestIdCommand, + GetByTextCommand, + LocatorCommand, + ScreenshotCommand, +) +from .models.results import PlaywrightResult + + +class BrowserLocator: + def __init__( + self, + locator: Locator, + timeouts: Timeouts, + metadata: BrowserMetadata, + url: str, + ) -> None: + self.locator = locator + self.timeouts = timeouts + self.metadata = metadata + self.url = url + + def filter( + self, + has: Optional[BrowserLocator] = None, + has_not: Optional[BrowserLocator] = None, + has_text: Optional[str | Pattern[str]] = None, + has_not_text: Optional[str | Pattern[str]] = None, + ): + command = FilterCommand( + has=has, has_not=has_not, has_text=has_text, has_not_text=has_not_text + ) + return BrowserLocator( + self.locator.filter( + has=command.has, + has_not=command.has_not, + has_text=command.has_text, + has_not_text=command.has_not_text, + ), + self.timeouts, + self.metadata, + self.url, + ) + + @property + def first(self): + return BrowserLocator( + self.locator.first, + self.timeouts, + self.metadata, + self.url, + ) + + def nth( + self, + index: int, + ): + command = NthCommand(index=index) + + return BrowserLocator( + self.locator.nth(command.index), + self.timeouts, + self.metadata, + self.url, + ) + + async def all(self): + return [ + BrowserLocator(locator, self.timeouts, self.metadata, self.url) + for locator in (await self.locator.all()) + ] + + async def all_inner_texts(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AllTextsCommand(timeout=timeout) + + result: List[str] = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.all_inner_texts(), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="all_inner_texts", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="all_inner_texts", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def all_text_contents(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AllTextsCommand(timeout=timeout) + + result: List[str] = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.all_text_contents(), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="all_text_contents", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="all_text_contents", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def and_matching( + self, locator: BrowserLocator, timeout: Optional[int | float] = None + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AndMatchingCommand(locator=locator.locator, timeout=timeout) + + result: List[str] = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.and_(command.locator), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="and_matching", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="and_matching", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def blur(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = BoundingBoxCommand(timeout=timeout) + + timings["command_start"] = time.monotonic() + + try: + await self.locator.blur(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="blur", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="blur", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def bounding_box(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = BoundingBoxCommand(timeout=timeout) + + result: FloatRect = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.bounding_box(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="bounding_box", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="bounding_box", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def check( + self, + postion: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CheckCommand( + postion=postion, + no_wait_after=no_wait_after, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.check( + position=command.postion, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="check", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="check", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def clear( + self, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CheckCommand( + no_wait_after=no_wait_after, + force=force, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.clear( + force=command.force, + no_wait_after=command.no_wait_after, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="clear", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="clear", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def click( + self, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + delay: Optional[int | float] = None, + button: Optional[Literal["left", "middle", "right"]] = None, + click_count: Optional[int] = None, + postion: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ClickCommand( + modifiers=modifiers, + delay=delay, + button=button, + click_count=click_count, + postion=postion, + no_wait_after=no_wait_after, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.click( + position=command.postion, + modifiers=command.modifiers, + delay=command.delay, + button=command.button, + click_count=command.click_count, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="click", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="click", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def count(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CountCommand(timeout=timeout) + + result: int = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.count(), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="count", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="count", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def double_click( + self, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + delay: Optional[int | float] = None, + button: Optional[Literal["left", "middle", "right"]] = None, + postion: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ClickCommand( + modifiers=modifiers, + delay=delay, + button=button, + postion=postion, + no_wait_after=no_wait_after, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.dblclick( + position=command.postion, + modifiers=command.modifiers, + delay=command.delay, + button=command.button, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="double_click", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="double_click", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def dispatch_event( + self, + event_type: str, + event_init: Optional[Dict[str, Any]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DispatchEventCommand( + event_type=event_type, event_init=event_init, timeout=timeout + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.dispatch_event( + type=command.event_type, + event_init=command.event_init, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="dispatch_event", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="dispatch_event", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def drag_to( + self, + target: BrowserLocator, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + source_position: Optional[Position] = None, + target_position: Optional[Position] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DragToCommand( + target=target, + force=force, + no_wait_after=no_wait_after, + trial=trial, + source_position=source_position, + target_position=target_position, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.locator.drag_to( + command.target, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + source_position=command.source_position, + target_position=command.target_position, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="drag_to", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="drag_to", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def evaluate( + self, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression=expression, arg=arg, timeout=timeout) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.evaluate( + command.expression, + arg=command.arg, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def evaluate_all( + self, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression=expression, arg=arg, timeout=timeout) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.evaluate_all( + command.expression, + arg=command.arg, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_all", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_all", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def evaluate_handle( + self, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression=expression, arg=arg, timeout=timeout) + + result: JSHandle = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.evaluate_handle( + command.expression, + arg=command.arg, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + result=BrowserJSHandle(result, self.timeouts, self.metadata, self.url), + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def fill( + self, + value: str, + no_wait_after: Optional[bool] = None, + force: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FillCommand( + value=value, + no_wait_after=no_wait_after, + force=force, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.fill( + command.value, + no_wait_after=command.no_wait_after, + force=command.force, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="fill", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="fill", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def focus( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FocusCommand(timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.focus(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="focus", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="focus", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def frame_locator(self, selector: str, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FrameLocatorCommand(selector=selector, timeout=timeout) + + result: FrameLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.frame_locator(command.selector), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_locator", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_locator", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_alt_text( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.get_by_alt_text(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_alt_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_alt_text", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_label( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.get_by_label(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_label", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_label", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_placeholder( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.get_by_placeholder(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_placeholder", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_placeholder", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_role( + self, + role: Literal[ + "alert", + "alertdialog", + "application", + "article", + "banner", + "blockquote", + "button", + "caption", + "cell", + "checkbox", + "code", + "columnheader", + "combobox", + "complementary", + "contentinfo", + "definition", + "deletion", + "dialog", + "directory", + "document", + "emphasis", + "feed", + "figure", + "form", + "generic", + "grid", + "gridcell", + "group", + "heading", + "img", + "insertion", + "link", + "list", + "listbox", + "listitem", + "log", + "main", + "marquee", + "math", + "menu", + "menubar", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "meter", + "navigation", + "none", + "note", + "option", + "paragraph", + "presentation", + "progressbar", + "radio", + "radiogroup", + "region", + "row", + "rowgroup", + "rowheader", + "scrollbar", + "search", + "searchbox", + "separator", + "slider", + "spinbutton", + "status", + "strong", + "subscript", + "superscript", + "switch", + "tab", + "table", + "tablist", + "tabpanel", + "term", + "textbox", + "time", + "timer", + "toolbar", + "tooltip", + "tree", + "treegrid", + "treeitem", + ], + checked: Optional[bool] = None, + disabled: Optional[bool] = None, + expanded: Optional[bool] = None, + include_hidden: Optional[bool] = None, + level: Optional[int] = None, + name: Optional[str | Pattern[str]] = None, + pressed: Optional[bool] = None, + selected: Optional[bool] = None, + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByRoleCommand( + role=role, + checked=checked, + disabled=disabled, + expanded=expanded, + include_hidden=include_hidden, + level=level, + name=name, + pressed=pressed, + selected=selected, + exact=exact, + timeout=timeout, + ) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.get_by_role( + command.role, + checked=command.checked, + disabled=command.disabled, + expanded=command.expanded, + include_hidden=command.include_hidden, + level=command.level, + name=command.name, + pressed=command.pressed, + selected=command.selected, + ), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_role", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_role", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_test_id( + self, test_id: str | Pattern[str], timeout: Optional[int | float] = None + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTestIdCommand(test_id=test_id, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.get_by_test_id(command.test_id), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_test_id", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_test_id", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_text( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.get_by_text(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_text", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_title( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.get_by_title(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_title", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_title", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def get_attribute(self, name: str, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetAttributeCommand( + name=name, + timeout=timeout, + ) + + result: Optional[str] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.get_attribute( + command.name, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_attribute", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_attribute", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def highlight(self, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = HighlightCommand( + timeout=timeout, + ) + + result: Optional[str] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.highlight(), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="highlight", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="highlight", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def hover( + self, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + postion: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = HoverCommand( + modifiers=modifiers, + postion=postion, + force=force, + no_wait_after=no_wait_after, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.hover( + modifiers=command.modifiers, + position=command.postion, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="hover", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="hover", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def inner_html( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.inner_html(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_html", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_html", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def inner_text( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.inner_text(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_text", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def input_value( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.input_value(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="input_value", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="input_value", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_enabled( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.is_enabled(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_enabled", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_enabled", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_hidden( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.is_hidden(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_hidden", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_hidden", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_visible( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.is_visible(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_visible", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_visible", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_checked( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.is_checked(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_checked", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_checked", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_disabled(self, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: bool = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.is_disabled(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_disabled", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_disabled", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def is_editable(self, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: bool = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.is_editable(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_editable", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_editable", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def create_locator( + self, + selector: str, + has_text: Optional[str | Pattern[str]] = None, + has_not_text: Optional[str | Pattern[str]] = None, + has: Optional[Locator] = None, + has_not: Optional[Locator] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = LocatorCommand( + selector=selector, + has=has, + has_not=has_not, + has_text=has_text, + has_not_text=has_not_text, + timeout=timeout, + ) + + result: BrowserLocator = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.locator.locator( + command.selector, + has_text=command.has_text, + has_not_text=command.has_not_text, + has=command.has, + has_not=command.has_not, + ), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="locator", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="locator", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def or_matching( + self, locator: BrowserLocator, timeout: Optional[int | float] = None + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = OrMatchingCommand(locator=locator.locator, timeout=timeout) + + result: List[str] = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.locator.or_(command.locator), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="or_matching", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="or_matching", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def press( + self, + key: str, + delay: Optional[int | float] = None, + no_wait_after: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = PressCommand( + key=key, delay=delay, no_wait_after=no_wait_after, timeout=timeout + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.press( + command.key, + delay=command.delay, + no_wait_after=command.no_wait_after, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def press_sequentially( + self, + text: str, + delay: Optional[int | float] = None, + no_wait_after: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = PressSequentiallyCommand( + text=text, delay=delay, no_wait_after=no_wait_after, timeout=timeout + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.press_sequentially( + command.key, + delay=command.delay, + no_wait_after=command.no_wait_after, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def screenshot( + self, + path: str | Path, + image_type: Optional[Literal["jpeg", "png"]] = None, + quality: Optional[int] = None, + omit_background: Optional[bool] = None, + full_page: Optional[bool] = None, + clip: Optional[FloatRect] = None, + animations: Optional[Literal["allow", "disabled"]] = None, + caret: Optional[Literal["hide", "initial"]] = None, + scale: Optional[Literal["css", "device"]] = None, + mask: Optional[Sequence[Locator]] = None, + mask_color: Optional[str] = None, + style: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ScreenshotCommand( + path=path, + image_type=image_type, + quality=quality, + omit_background=omit_background, + full_page=full_page, + clip=clip, + animations=animations, + caret=caret, + scale=scale, + mask=mask, + mask_color=mask_color, + style=style, + timeout=timeout, + ) + + result: bytes = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.screenshot( + path=command.path, + type=command.image_type, + quality=command.quality, + omit_background=command.omit_background, + full_page=command.full_page, + clip=command.clip, + animations=command.animations, + caret=command.caret, + scale=command.scale, + mask=command.mask, + mask_color=command.mask_color, + style=command.style, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="screenshot", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="screenshot", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def scroll_into_view_if_needed( + self, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ScrollIntoViewIfNeeded(timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.scroll_into_view_if_needed(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="scroll_into_view_if_needed", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="scroll_into_view_if_needed", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def select_option( + self, + value: Optional[str | Sequence[str]] = None, + index: Optional[int | Sequence[int]] = None, + label: Optional[str | Sequence[str]] = None, + element: Optional[ElementHandle | Sequence[ElementHandle]] = None, + no_wait_after: Optional[bool] = None, + force: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SelectOptionCommand( + value=value, + index=index, + label=label, + element=element, + no_wait_after=no_wait_after, + force=force, + timeout=timeout, + ) + + result: List[str] = [] + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.select_option( + value=command.value, + index=command.index, + label=command.label, + element=command.element, + no_wait_after=command.no_wait_after, + force=command.force, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="select_option", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="select_option", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def select_text( + self, + force: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SelectTextCommand(force=force, timeout=timeout) + + result: List[str] = [] + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.select_text(force=command.force, timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="select_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="select_text", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def set_checked( + self, + checked: bool, + position: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetCheckedCommand( + checked=checked, + position=position, + force=force, + no_wait_after=no_wait_after, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.set_checked( + command.checked, + position=command.position, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_checked", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_checked", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def set_input_files( + self, + files: str | Path | FilePayload | Sequence[str | Path] | Sequence[FilePayload], + no_wait_after: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetInputFilesCommand( + files=files, no_wait_after=no_wait_after, timeout=timeout + ) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.set_input_files( + command.files, + no_wait_after=command.no_wait_after, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def tap( + self, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + position: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TapCommand( + modifiers=modifiers, + position=position, + force=force, + no_wait_after=no_wait_after, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.tap( + modifiers=command.modifiers, + position=command.position, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="tap", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="tap", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def text_content(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(timeout=timeout) + + result: Optional[str] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.locator.text_content(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="text_content", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="text_content", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def uncheck( + self, + postion: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + trial: Optional[bool] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CheckCommand( + postion=postion, + no_wait_after=no_wait_after, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.uncheck( + position=command.postion, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="uncheck", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="uncheck", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def wait_for( + self, + state: Literal["attached", "detached", "visible", "hidden"] = "visible", + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForCommand( + state=state, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.locator.wait_for(state=command.state, timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_mouse.py b/hyperscale/core_rewrite/engines/client/playwright/browser_mouse.py new file mode 100644 index 0000000..9447fbb --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_mouse.py @@ -0,0 +1,359 @@ +import asyncio +import time +from typing import Dict, Literal, Optional + +from playwright.async_api import Mouse + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.browser import BrowserMetadata +from .models.commands.mouse import ( + ButtonCommand, + ClickCommand, + MoveCommand, + WheelCommand, +) +from .models.results import PlaywrightResult + + +class BrowserMouse: + def __init__( + self, mouse: Mouse, timeouts: Timeouts, metadata: BrowserMetadata, url: str + ) -> None: + self.mouse = mouse + self.timeouts = timeouts + self.metadata = metadata + self.url = url + + async def click( + self, + x_position: int | float, + y_position: int | float, + delay: Optional[int | float] = None, + button: Optional[Literal["left", "middle", "right"]] = "left", + click_count: Optional[int] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ClickCommand( + x_position=x_position, + y_position=y_position, + delay=delay, + button=button, + click_count=click_count, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.mouse.click( + x=command.x_position, + y=command.y_position, + delay=command.delay, + button=command.button, + click_count=command.click_count, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="click", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="click", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def double_click( + self, + x_position: int | float, + y_position: int | float, + delay: Optional[int | float] = None, + button: Optional[Literal["left", "middle", "right"]] = "left", + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ClickCommand( + x_position=x_position, + y_position=y_position, + delay=delay, + button=button, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.mouse.dblclick( + x=command.x_position, + y=command.y_position, + delay=command.delay, + button=command.button, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="double_click", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="double_click", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def move( + self, + x_position: int | float, + y_position: int | float, + steps: int = 1, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = MoveCommand( + x_position=x_position, y_position=y_position, steps=steps, timeout=timeout + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.mouse.move( + x=command.x_position, y=command.y_position, steps=command.steps + ), + timeout=command.timeout, + ) + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="move", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="move", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def down( + self, + button: Optional[Literal["left", "middle", "right"]] = "left", + click_count: Optional[int] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ButtonCommand(button=button, click_count=click_count, timeout=timeout) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.mouse.down(button=command.button, click_count=command.click_count), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="down", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="down", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def up( + self, + button: Optional[Literal["left", "middle", "right"]] = "left", + click_count: Optional[int] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ButtonCommand(button=button, click_count=click_count, timeout=timeout) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.mouse.up(button=command.button, click_count=command.click_count), + command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="up", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="up", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def wheel( + self, + delta_x: int | float, + delta_y: int | float, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WheelCommand(delta_x=delta_x, delta_y=delta_y, timeout=timeout) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.mouse.wheel(delta_x=command.delta_x, delta_y=command.delta_y), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wheel", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wheel", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_page.py b/hyperscale/core_rewrite/engines/client/playwright/browser_page.py new file mode 100644 index 0000000..74c2372 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_page.py @@ -0,0 +1,5141 @@ +import asyncio +import time +from pathlib import Path +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Optional, + Pattern, + Sequence, +) + +from playwright._impl._async_base import AsyncEventContextManager +from playwright.async_api import ( + ConsoleMessage, + Dialog, + Download, + ElementHandle, + Error, + FileChooser, + FilePayload, + FloatRect, + Frame, + FrameLocator, + JSHandle, + Locator, + Page, + PdfMargins, + Position, + Request, + Response, + Route, + ViewportSize, + WebSocket, + Worker, +) + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .browser_frame import BrowserFrame +from .browser_js_handle import BrowserJSHandle +from .browser_keyboard import BrowserKeyboard +from .browser_locator import BrowserLocator +from .browser_mouse import BrowserMouse +from .browser_touchscreen import BrowserTouchscreen +from .models.browser import BrowserMetadata +from .models.commands.page import ( + AddInitScriptCommand, + AddLocatorHandlerCommand, + AddScriptTagCommand, + AddStyleTagCommand, + BringToFrontCommand, + CheckCommand, + ClickCommand, + CloseCommand, + ContentCommand, + DispatchEventCommand, + DOMCommand, + DoubleClickCommand, + DragAndDropCommand, + EmulateMediaCommand, + EvaluateCommand, + EvaluateOnSelectorCommand, + ExpectConsoleMessageCommand, + ExpectDownloadCommand, + ExpectEventCommand, + ExpectFileChooserCommand, + ExpectNavigationCommand, + ExpectPopupCommand, + ExpectRequestCommand, + ExpectRequestFinishedCommand, + ExpectResponseCommand, + ExpectWebsocketCommand, + ExpectWorkerCommand, + ExposeBindingCommand, + ExposeFunctionCommand, + FillCommand, + FocusCommand, + FrameCommand, + FrameLocatorCommand, + GetAttributeCommand, + GetByRoleCommand, + GetByTestIdCommand, + GetByTextCommand, + GetUrlCommand, + GoCommand, + GoToCommand, + HoverCommand, + IsClosedCommand, + LocatorCommand, + OnCommand, + OpenerCommand, + PauseCommand, + PdfCommand, + PressCommand, + ReloadCommand, + RemoveLocatorHandlerCommand, + RouteCommand, + RouteFromHarCommand, + ScreenshotCommand, + SelectOptionCommand, + SetCheckedCommand, + SetContentCommand, + SetExtraHTTPHeadersCommand, + SetInputFilesCommand, + SetTimeoutCommand, + SetViewportSize, + TapCommand, + TitleCommand, + TypeCommand, + WaitForFunctionCommand, + WaitForLoadStateCommand, + WaitForSelectorCommand, + WaitForTimeoutCommand, + WaitForUrlCommand, +) +from .models.results import PlaywrightResult + + +class BrowserPage: + def __init__( + self, page: Page, timeouts: Timeouts, metadata: BrowserMetadata + ) -> None: + self.page = page + self.url = self.page.url + self.timeouts = timeouts + self.metadata = metadata + + @property + def keyboard(self): + return BrowserKeyboard( + self.page.keyboard, self.timeouts, self.metadata, self.page.url + ) + + @property + def frame( + self, + name: Optional[str] = None, + url: Optional[str | Pattern[str] | Callable[[str], bool]] = None, + ): + command = FrameCommand(name=name, url=url) + + return BrowserFrame( + self.page.frame(name=command.name, url=command.url), + self.timeouts, + self.metadata, + ) + + @property + def frames(self): + return [ + BrowserFrame(frame, self.timeouts, self.metadata) + for frame in self.page.frames + ] + + @property + def mouse(self): + return BrowserMouse(self.page.mouse, self.timeouts, self.metadata, self.url) + + @property + def touchscreen(self): + return BrowserTouchscreen( + self.page.touchscreen, + self.timeouts, + self.metadata, + self.url, + ) + + async def add_script_tag( + self, + url: Optional[str] = None, + path: Optional[str | Path] = None, + content: Optional[str] = None, + tag_type: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AddScriptTagCommand( + url=url, path=path, content=content, tag_type=tag_type, timeout=timeout + ) + + result: ElementHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.add_script_tag( + url=command.url, + path=command.path, + content=command.content, + type=command.tag_type, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_script_tag", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_script_tag", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def add_init_script( + self, + script: Optional[str] = None, + path: Optional[str | Path] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AddInitScriptCommand(script=script, path=path, timeout=timeout) + + result: ElementHandle = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.add_init_script( + script=command.script, + path=command.path, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_init_script", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_init_script", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def add_locator_handler( + self, + locator: BrowserLocator, + handler: Callable[[Locator], Any] | Callable[[], Any], + no_wait_after: Optional[bool] = None, + times: Optional[int] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AddLocatorHandlerCommand( + locator=locator.locator, + handler=handler, + no_wait_after=no_wait_after, + times=times, + timeout=timeout, + ) + + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.add_locator_handler( + command.locator, + command.handler, + no_wait_after=command.no_wait_after, + times=command.times, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_locator_handler", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_locator_handler", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def add_style_tag( + self, + url: Optional[str] = None, + path: Optional[str | Path] = None, + content: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = AddStyleTagCommand( + url=url, path=path, content=content, timeout=timeout + ) + + result: ElementHandle = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.add_style_tag( + url=command.url, path=command.path, content=command.content + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_style_tag", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="add_style_tag", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def emulate_media( + self, + media: Optional[Literal["null", "print", "screen"]] = None, + color_scheme: Optional[ + Literal["dark", "light", "no-preference", "null"] + ] = None, + reduced_motion: Optional[Literal["no-preference", "null", "reduce"]] = None, + forced_colors: Optional[Literal["active", "none", "null"]] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EmulateMediaCommand( + media=media, + color_scheme=color_scheme, + reduced_motion=reduced_motion, + forced_colors=forced_colors, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.emulate_media( + media=command.media, + color_scheme=command.color_scheme, + reduced_motion=command.reduced_motion, + forced_colors=command.forced_colors, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="emulate_media", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="emulate_media", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def expect_websocket( + self, + predicate: Optional[Callable[[WebSocket], bool]], + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectWebsocketCommand(predicate=predicate, timeout=timeout) + + response: WebSocket = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + websocket_response: AsyncEventContextManager[ + WebSocket + ] = await self.page.expect_websocket( + predicate=command.predicate, timeout=command.timeout + ) + + async with websocket_response as websocket: + response = websocket + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_websocket", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_websocket", + command_args=command, + metadata=self.metadata, + result=response, + timings=timings, + url=self.url, + ) + + async def expect_worker( + self, + predicate: Optional[Callable[[Worker], bool]], + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectWorkerCommand(predicate=predicate, timeout=timeout) + + response: Worker = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + websocket_response: AsyncEventContextManager[ + Worker + ] = await self.page.expect_worker( + predicate=command.predicate, timeout=command.timeout + ) + + async with websocket_response as websocket: + response = websocket + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_worker", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_worker", + command_args=command, + metadata=self.metadata, + result=response, + timings=timings, + url=self.url, + ) + + async def expose_binding( + self, + name: str, + callback: Callable[..., Any], + handle: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExposeBindingCommand( + name=name, callback=callback, handle=handle, timeout=timeout + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.expose_binding( + command.name, + command.callback, + handle=command.handle, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expose_binding", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expose_binding", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def expose_function( + self, + name: str, + callback: Callable[..., Any], + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExposeFunctionCommand(name=name, callback=callback, timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.expose_function(command.name, command.callback), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expose_function", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expose_function", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def frame_locator(self, selector: str, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FrameLocatorCommand(selector=selector, timeout=timeout) + + result: FrameLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.frame_locator(command.selector), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_locator", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="frame_locator", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_alt_text( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.get_by_alt_text(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_alt_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_alt_text", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_label( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.get_by_label(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_label", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_label", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_placeholder( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.get_by_placeholder(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_placeholder", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_placeholder", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_role( + self, + role: Literal[ + "alert", + "alertdialog", + "application", + "article", + "banner", + "blockquote", + "button", + "caption", + "cell", + "checkbox", + "code", + "columnheader", + "combobox", + "complementary", + "contentinfo", + "definition", + "deletion", + "dialog", + "directory", + "document", + "emphasis", + "feed", + "figure", + "form", + "generic", + "grid", + "gridcell", + "group", + "heading", + "img", + "insertion", + "link", + "list", + "listbox", + "listitem", + "log", + "main", + "marquee", + "math", + "menu", + "menubar", + "menuitem", + "menuitemcheckbox", + "menuitemradio", + "meter", + "navigation", + "none", + "note", + "option", + "paragraph", + "presentation", + "progressbar", + "radio", + "radiogroup", + "region", + "row", + "rowgroup", + "rowheader", + "scrollbar", + "search", + "searchbox", + "separator", + "slider", + "spinbutton", + "status", + "strong", + "subscript", + "superscript", + "switch", + "tab", + "table", + "tablist", + "tabpanel", + "term", + "textbox", + "time", + "timer", + "toolbar", + "tooltip", + "tree", + "treegrid", + "treeitem", + ], + checked: Optional[bool] = None, + disabled: Optional[bool] = None, + expanded: Optional[bool] = None, + include_hidden: Optional[bool] = None, + level: Optional[int] = None, + name: Optional[str | Pattern[str]] = None, + pressed: Optional[bool] = None, + selected: Optional[bool] = None, + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByRoleCommand( + role=role, + checked=checked, + disabled=disabled, + expanded=expanded, + include_hidden=include_hidden, + level=level, + name=name, + pressed=pressed, + selected=selected, + exact=exact, + timeout=timeout, + ) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.get_by_role( + command.role, + checked=command.checked, + disabled=command.disabled, + expanded=command.expanded, + include_hidden=command.include_hidden, + level=command.level, + name=command.name, + pressed=command.pressed, + selected=command.selected, + ), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_role", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_role", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_test_id( + self, + test_id: str | Pattern[str], + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTestIdCommand(test_id=test_id, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.get_by_test_id(command.test_id), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_test_id", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_test_id", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_text( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.get_by_text(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_text", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def get_by_title( + self, + text: str | Pattern[str], + exact: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetByTextCommand(text=text, exact=exact, timeout=timeout) + + result: BrowserLocator = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.get_by_title(command.text, exact=command.exact), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_title", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_by_title", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def is_closed(self, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = IsClosedCommand(timeout=timeout) + + result: bool = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.is_closed(), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_closed", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_closed", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def is_disabled( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: bool = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.is_disabled( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_disabled", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_disabled", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def is_editable( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: bool = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.is_editable( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_editable", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_editable", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def opener(self, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = OpenerCommand(timeout=timeout) + + result: Optional[Page] = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for(self.page.opener(), timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="opener", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="opener", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def on( + self, + event: Literal[ + "close", + "console", + "crash", + "dialog", + "domcontentloaded", + "download", + "filechooser", + "frameattached", + "framedetached", + "framenavigated", + "load", + "pageerror", + "popup", + "request", + "requestfailed", + "requestfinished", + "response", + "websocket", + "worker", + ], + function: Callable[[Page], Awaitable[None] | None] + | Callable[[ConsoleMessage], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Dialog], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Download], Awaitable[None] | None] + | Callable[[FileChooser], Awaitable[None] | None] + | Callable[[Frame], Awaitable[None] | None] + | Callable[[Frame], Awaitable[None] | None] + | Callable[[Frame], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Error], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Request], Awaitable[None] | None] + | Callable[[Request], Awaitable[None] | None] + | Callable[[Request], Awaitable[None] | None] + | Callable[[Response], Awaitable[None] | None] + | Callable[[WebSocket], Awaitable[None] | None] + | Callable[[Worker], Awaitable[None] | None], + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = OnCommand(event=event, function=function, timeout=timeout) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await self.page.on(command.event, command.function, timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="on", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="on", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def once( + self, + event: Literal[ + "close", + "console", + "crash", + "dialog", + "domcontentloaded", + "download", + "filechooser", + "frameattached", + "framedetached", + "framenavigated", + "load", + "pageerror", + "popup", + "request", + "requestfailed", + "requestfinished", + "response", + "websocket", + "worker", + ], + function: Callable[[Page], Awaitable[None] | None] + | Callable[[ConsoleMessage], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Dialog], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Download], Awaitable[None] | None] + | Callable[[FileChooser], Awaitable[None] | None] + | Callable[[Frame], Awaitable[None] | None] + | Callable[[Frame], Awaitable[None] | None] + | Callable[[Frame], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Error], Awaitable[None] | None] + | Callable[[Page], Awaitable[None] | None] + | Callable[[Request], Awaitable[None] | None] + | Callable[[Request], Awaitable[None] | None] + | Callable[[Request], Awaitable[None] | None] + | Callable[[Response], Awaitable[None] | None] + | Callable[[WebSocket], Awaitable[None] | None] + | Callable[[Worker], Awaitable[None] | None], + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = OnCommand(event=event, function=function, timeout=timeout) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await self.page.once( + command.event, command.function, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="once", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="once", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def pause(self, timeout: Optional[int | float] = None): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = PauseCommand(timeout=timeout) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for(self.page.pause(), timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="pause", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="pause", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def pdf( + self, + scale: Optional[float] = None, + display_header_footer: Optional[bool] = None, + header_template: Optional[str] = None, + footer_template: Optional[str] = None, + print_background: Optional[bool] = None, + landscape: Optional[bool] = None, + page_ranges: Optional[str] = None, + pdf_format: Optional[str] = None, + width: Optional[str | float] = None, + height: Optional[str | float] = None, + prefer_css_page_size: Optional[bool] = None, + margin: Optional[PdfMargins] = None, + path: Optional[str | Path] = None, + outline: Optional[bool] = None, + tagged: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = PdfCommand( + scale=scale, + display_header_footer=display_header_footer, + header_template=header_template, + footer_template=footer_template, + print_background=print_background, + landscape=landscape, + page_ranges=page_ranges, + pdf_format=pdf_format, + width=width, + height=height, + prefer_css_page_size=prefer_css_page_size, + margin=margin, + path=path, + outline=outline, + tagged=tagged, + timeout=timeout, + ) + + result: bytes = None + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.pdf( + scale=command.scale, + display_header_footer=command.display_header_footer, + header_template=command.header_template, + footer_template=command.footer_template, + print_background=command.print_background, + landscape=command.landscape, + page_ranges=command.page_ranges, + pdf_format=command.pdf_format, + width=command.width, + height=command.height, + prefer_css_page_size=command.prefer_css_page_size, + margin=command.margin, + path=command.path, + outline=command.outline, + tagged=command.tagged, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="pdf", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="pdf", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def remove_locator_handler( + self, + locator: BrowserLocator, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = RemoveLocatorHandlerCommand( + locator=locator.locator, + timeout=timeout, + ) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.remove_locator_handler(command.locator), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="remove_locator_handler", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="remove_locator_handler", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def route( + self, + url: str | Pattern[str] | Callable[[str], bool], + handler: Callable[[Route], Any] | Callable[[Route, Request], Any], + times: Optional[int] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = RouteCommand(url=url, handler=handler, times=times, timeout=timeout) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.route(command.url, command.handler, times=command.times), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="route", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="route", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def route_from_har( + self, + har: Path | str, + url: Optional[str | Pattern[str] | Callable[[str], bool]] = None, + not_found: Literal["abort", "fallback"] = "abort", + update: Optional[bool] = None, + update_content: Optional[Literal["attach", "embed"]] = None, + update_mode: Optional[Literal["full", "minimal"]] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = RouteFromHarCommand( + har=har, + url=url, + not_found=not_found, + update=update, + update_content=update_content, + update_mode=update_mode, + timeout=timeout, + ) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.route_from_har( + command.har, + url=command.url, + not_found=command.not_found, + update=command.update, + update_content=command.update_content, + update_mode=command.update_mode, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="route_from_har", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="route_from_har", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def set_content( + self, + html: str, + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetContentCommand(html=html, wait_until=wait_until, timeout=timeout) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await self.page.set_content( + command.html, wait_until=command.wait_until, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def set_input_files( + self, + selector: str, + files: str | Path | FilePayload | Sequence[str | Path] | Sequence[FilePayload], + strict: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetInputFilesCommand( + selector=selector, + files=files, + strict=strict, + no_wait_after=no_wait_after, + timeout=timeout, + ) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await self.page.set_input_files( + command.selector, + command.files, + strict=command.strict, + no_wait_after=command.no_wait_after, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_content", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def set_viewport_size( + self, viewport_size: ViewportSize, timeout: Optional[int | float] = None + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetViewportSize(viewport_size=viewport_size, timeout=timeout) + + err: Exception = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.set_viewport_size(command.viewport_size), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_viewport_size", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_viewport_size", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def goto( + self, + url: str, + wait_util: Optional[ + Literal[ + "commit", + "domcontentloaded", + "load", + "networkidle", + ] + ] = None, + referrer: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GoToCommand( + url=url, timeout=timeout, wait_util=wait_util, referrer=referrer + ) + + result: Optional[Response] = None + err: Optional[Exception] = None + + timings["command_start"] = time.monotonic() + + try: + result = await self.page.goto( + command.url, + timeout=command.timeout, + wait_until=command.wait_util, + referer=command.referrer, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="goto", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="goto", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def get_url( + self, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetUrlCommand(timeout=timeout) + + timings["command_start"] = time.monotonic() + + page_url = self.url + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_url", + command_args=command, + metadata=self.metadata, + result=page_url, + timings=timings, + url=self.url, + ) + + async def fill( + self, + selector: str, + value: str, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + force: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FillCommand( + selector=selector, + value=value, + no_wait_after=no_wait_after, + strict=strict, + force=force, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.fill( + command.selector, + command.value, + no_wait_after=command.no_wait_after, + strict=command.strict, + force=command.force, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="fill", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="fill", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def check( + self, + selector: str, + postion: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CheckCommand( + selector=selector, + postion=postion, + no_wait_after=no_wait_after, + strict=strict, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.check( + command.selector, + position=command.postion, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="check", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="check", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def click( + self, + selector: str, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + delay: Optional[int | float] = None, + button: Literal["left", "middle", "right"] = "left", + click_count: Optional[int] = None, + postion: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ClickCommand( + selector=selector, + modifiers=modifiers, + delay=delay, + button=button, + click_count=click_count, + postion=postion, + no_wait_after=no_wait_after, + strict=strict, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.click( + command.selector, + position=command.postion, + modifiers=command.modifiers, + delay=command.delay, + button=command.button, + click_count=command.click_count, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="click", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="click", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def double_click( + self, + selector: str, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + delay: Optional[int | float] = None, + button: Literal["left", "middle", "right"] = "left", + postion: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DoubleClickCommand( + selector=selector, + modifiers=modifiers, + delay=delay, + button=button, + postion=postion, + no_wait_after=no_wait_after, + strict=strict, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.dblclick( + command.selector, + position=command.postion, + modifiers=command.modifiers, + delay=command.delay, + button=command.button, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="double_click", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="double_click", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def dispatch_event( + self, + selector: str, + event_type: str, + event_init: Optional[Dict[str, Any]] = None, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DispatchEventCommand( + selector=selector, + event_type=event_type, + event_init=event_init, + strict=strict, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.dispatch_event( + command.selector, + type=command.event_type, + event_init=command.event_init, + strict=command.strict, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="dispatch_event", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="dispatch_event", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def drag_and_drop( + self, + source: str, + target: str, + source_position: Optional[Position] = None, + target_position: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DragAndDropCommand( + source=source, + target=target, + source_position=source_position, + target_position=target_position, + force=force, + no_wait_after=no_wait_after, + strict=strict, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.drag_and_drop( + command.source, + command.target, + source_position=command.source_position, + target_position=command.target_position, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="drag_and_drop", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="drag_and_drop", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def bring_to_front(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = BringToFrontCommand(timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for(self.page.bring_to_front(), timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="bring_to_front", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="bring_to_front", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def evaluate_on_selector( + self, + selector: str, + expression: str, + arg: Any, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateOnSelectorCommand( + selector=selector, + expression=expression, + arg=arg, + strict=strict, + timeout=timeout, + ) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.eval_on_selector( + command.selector, + command.expression, + arg=command.arg, + strict=command.strict, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="eval_on_selector", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="eval_on_selector", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def evaluate_on_selectors_all( + self, + selector: str, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateOnSelectorCommand( + selector=selector, expression=expression, arg=arg, timeout=timeout + ) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.eval_on_selector_all( + command.selector, + command.expression, + arg=command.arg, + strict=command.strict, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="eval_on_selector_all", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="eval_on_selector_all", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def evaluate( + self, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression=expression, arg=arg, timeout=timeout) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.evaluate( + command.expression, + arg=command.arg, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def evaluate_handle( + self, + expression: str, + arg: Any, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = EvaluateCommand(expression=expression, arg=arg, timeout=timeout) + + result: JSHandle = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.evaluate_handle( + command.expression, + arg=command.arg, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="evaluate_handle", + command_args=command, + result=BrowserJSHandle(result, self.timeouts, self.metadata, self.url), + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_console_message( + self, + predicate: Optional[Callable[[ConsoleMessage], bool | Awaitable[bool]]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectConsoleMessageCommand(predicate=predicate, timeout=timeout) + + result: ConsoleMessage = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.predicate is None: + message_response: AsyncEventContextManager[ + ConsoleMessage + ] = await self.page.expect_console_message(timeout=command.timeout) + + async with message_response as message: + result = message + + else: + message_response: AsyncEventContextManager[ + ConsoleMessage + ] = await self.page.expect_console_message( + predicate=command.predicate, timeout=command.timeout + ) + + async with message_response as message: + result = message + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_console_message", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_console_message", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_download( + self, + predicate: Optional[Callable[[Download], bool]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectDownloadCommand(predicate=predicate, timeout=timeout) + + result: Download = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.predicate is None: + download_response: AsyncEventContextManager[ + Download + ] = await self.page.expect_download(timeout=command.timeout) + + async with download_response as download: + result = download + + else: + download_response: AsyncEventContextManager[ + Download + ] = await self.page.expect_download( + predicate=command.predicate, timeout=command.timeout + ) + + async with download_response as download: + result = download + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_download", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_download", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_event( + self, + event: str, + predicate: Optional[Callable[[ConsoleMessage], bool]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectEventCommand(event=event, predicate=predicate, timeout=timeout) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.predicate is None: + event_response: AsyncEventContextManager[ + Any + ] = await self.page.expect_event(command.event, timeout=command.timeout) + + async with event_response as event: + result = event + + else: + event_response: AsyncEventContextManager[ + Any + ] = await self.page.expect_event( + command.event, predicate=command.predicate, timeout=command.timeout + ) + + async with event_response as event: + result = event + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_event", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_event", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_file_chooser( + self, + predicate: Optional[Callable[[FileChooser], bool]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectFileChooserCommand(predicate=predicate, timeout=timeout) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.predicate is None: + file_chooser_response: AsyncEventContextManager[ + Any + ] = await self.page.expect_file_chooser(timeout=command.timeout) + + async with file_chooser_response as file_chooser: + result = file_chooser + + else: + file_chooser_response: AsyncEventContextManager[ + Any + ] = await self.page.expect_file_chooser( + predicate=command.predicate, timeout=command.timeout + ) + + async with file_chooser_response as file_chooser: + result = file_chooser + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_file_chooser", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_file_chooser", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_navigation( + self, + url: str, + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectNavigationCommand( + url=url, wait_until=wait_until, timeout=timeout + ) + + result: Response = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + navigation_response: AsyncEventContextManager[ + Response + ] = await self.page.expect_navigation( + command.url, wait_until=command.wait_until, timeout=command.timeout + ) + + async with navigation_response as navigation: + result = navigation + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_navigation", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_navigation", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_popup( + self, + predicate: Optional[Callable[[Page], bool]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectPopupCommand(predicate=predicate, timeout=timeout) + + result: Page = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.predicate is None: + popup_response: AsyncEventContextManager[ + Page + ] = await self.page.expect_popup(timeout=command.timeout) + + async with popup_response as popup: + result = popup + + else: + popup_response: AsyncEventContextManager[ + Page + ] = await self.page.expect_popup( + predicate=command.predicate, timeout=command.timeout + ) + + async with popup_response as popup: + result = popup + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_popup", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_popup", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_request( + self, + url_or_predicate: Optional[str | Pattern[str] | Callable[[Page], bool]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectRequestCommand( + url_or_predicate=url_or_predicate, timeout=timeout + ) + + result: Request = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.url_or_predicate is None: + response: AsyncEventContextManager[ + Request + ] = await self.page.expect_request(timeout=command.timeout) + + async with response as resp: + result = resp + + else: + response: AsyncEventContextManager[ + Request + ] = await self.page.expect_request( + url_or_predicate=command.url_or_predicate, timeout=command.timeout + ) + + async with response as resp: + result = resp + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_request", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_request", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_request_finished( + self, + predicate: Optional[Callable[[Request], bool]] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectRequestFinishedCommand(predicate=predicate, timeout=timeout) + + result: Request = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.predicate is None: + request: AsyncEventContextManager[ + Request + ] = await self.page.expect_request_finished(timeout=command.timeout) + + async with request as req: + result = req + + else: + request: AsyncEventContextManager[ + Request + ] = await self.page.expect_request_finished( + predicate=command.predicate, timeout=command.timeout + ) + + async with request as req: + result = req + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_request_finished", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_request_finished", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def expect_response( + self, + url_or_predicate: Optional[ + str | Pattern[str] | Callable[[Response], bool] + ] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectResponseCommand( + url_or_predicate=url_or_predicate, timeout=timeout + ) + + result: Response = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + if command.url_or_predicate is None: + response: AsyncEventContextManager[ + Response + ] = await self.page.expect_response(timeout=command.timeout) + + async with response as resp: + result = resp + + else: + response: AsyncEventContextManager[ + Response + ] = await self.page.expect_response( + url_or_predicate=command.url_or_predicate, timeout=command.timeout + ) + + async with response as resp: + result = resp + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_response", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="expect_response", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def focus( + self, + selector: str, + strict: Optional[bool] = False, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = FocusCommand(selector=selector, strict=strict, timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.focus( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="focus", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="focus", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def hover( + self, + selector: str, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + postion: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = HoverCommand( + selector=selector, + modifiers=modifiers, + postion=postion, + force=force, + no_wait_after=no_wait_after, + trial=trial, + strict=strict, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.hover( + command.selector, + modifiers=command.modifiers, + position=command.postion, + force=command.force, + no_wait_after=command.no_wait_after, + trial=command.trial, + strict=command.strict, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="hover", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="hover", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def inner_html( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.inner_html( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_html", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_html", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def inner_text( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.inner_text( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="inner_text", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def input_value( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.input_value( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="input_value", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="input_value", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def press( + self, + selector: str, + key: str, + delay: Optional[int | float] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = PressCommand( + selector=selector, + key=key, + delay=delay, + no_wait_after=no_wait_after, + strict=strict, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.press( + command.selector, + command.key, + delay=command.delay, + no_wait_after=command.no_wait_after, + strict=command.strict, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="press", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def is_enabled( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.is_enabled( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_enabled", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_enabled", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_hidden( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.is_hidden( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_hidden", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_hidden", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_visible( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.is_visible( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_visible", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_visible", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def is_checked( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: bool = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.is_checked( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_checked", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="is_checked", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def content( + self, + timeout: Optional[int | float] = None, + ) -> PlaywrightResult[Exception] | PlaywrightResult[str]: + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ContentCommand(timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.content(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="content", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="content", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def query_selector( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: Optional[ElementHandle] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.query_selector(command.selector, strict=command.strict), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="query_selector", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="query_selector", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def get_all_elements( + self, + selector: str, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, timeout=timeout) + + result: List[ElementHandle] = [] + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for( + self.page.query_selector_all(command.selector), timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="query_selector_all", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="query_selector_all", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def reload_page( + self, + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ReloadCommand(wait_util=wait_until, timeout=timeout) + + result: Optional[Response] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.reload( + wait_until=command.wait_util, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="reload", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="reload", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def screenshot( + self, + path: str | Path, + image_type: Literal["jpeg", "png"] = "png", + quality: Optional[int] = None, + omit_background: Optional[bool] = None, + full_page: Optional[bool] = None, + clip: Optional[FloatRect] = None, + animations: Literal["allow", "disabled"] = "allow", + caret: Literal["hide", "initial"] = "hide", + scale: Literal["css", "device"] = "device", + mask: Optional[Sequence[Locator]] = None, + mask_color: Optional[str] = None, + style: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ScreenshotCommand( + path=path, + image_type=image_type, + quality=quality, + omit_background=omit_background, + full_page=full_page, + clip=clip, + animations=animations, + caret=caret, + scale=scale, + mask=mask, + mask_color=mask_color, + style=style, + timeout=timeout, + ) + + result: bytes = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.screenshot( + path=command.path, + type=command.image_type, + quality=command.quality, + omit_background=command.omit_background, + full_page=command.full_page, + clip=command.clip, + animations=command.animations, + caret=command.caret, + scale=command.scale, + mask=command.mask, + mask_color=command.mask_color, + style=command.style, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="screenshot", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="screenshot", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def select_option( + self, + selector: str, + value: Optional[str | Sequence[str]] = None, + index: Optional[int | Sequence[int]] = None, + label: Optional[str | Sequence[str]] = None, + element: Optional[ElementHandle | Sequence[ElementHandle]] = None, + no_wait_after: Optional[bool] = None, + force: Optional[bool] = None, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SelectOptionCommand( + selector=selector, + value=value, + index=index, + label=label, + element=element, + no_wait_after=no_wait_after, + force=force, + strict=strict, + timeout=timeout, + ) + + result: List[str] = [] + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.select_option( + command.selector, + value=command.value, + index=command.index, + label=command.label, + element=command.element, + no_wait_after=command.no_wait_after, + force=command.force, + strict=command.strict, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="select_option", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="select_option", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def set_checked( + self, + selector: str, + checked: bool, + position: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetCheckedCommand( + selector=selector, + checked=checked, + position=position, + force=force, + no_wait_after=no_wait_after, + strict=strict, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.set_checked( + command.selector, + command.checked, + position=command.position, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_checked", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_checked", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def set_default_timeout(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetTimeoutCommand(timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.set_default_timeout(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_default_timeout", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_default_timeout", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def set_default_navigation_timeout( + self, timeout: Optional[int | float] = None + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetTimeoutCommand(timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.set_default_navigation_timeout(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_default_navigation_timeout", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_default_navigation_timeout", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def set_extra_http_headers( + self, headers: Dict[str, str], timeout: Optional[int | float] = None + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = SetExtraHTTPHeadersCommand(headers=headers, timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.page.set_extra_http_headers(command.headers), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_extra_http_headers", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="set_extra_http_headers", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def tap( + self, + selector: str, + modifiers: Optional[ + Sequence[Literal["Alt", "Control", "ControlOrMeta", "Meta", "Shift"]] + ] = None, + position: Optional[Position] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TapCommand( + selector=selector, + modifiers=modifiers, + position=position, + force=force, + no_wait_after=no_wait_after, + strict=strict, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.tap( + command.selector, + modifiers=command.modifiers, + position=command.position, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="tap", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="tap", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def text_content( + self, + selector: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = DOMCommand(selector=selector, strict=strict, timeout=timeout) + + result: Optional[str] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.text_content( + command.selector, strict=command.strict, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="text_content", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="text_content", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def title(self, timeout: Optional[int | float] = None): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TitleCommand(timeout=timeout) + + result: str = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await asyncio.wait_for(self.page.title(), timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="title", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="title", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def type_text( + self, + selector: str, + text: str, + delay: Optional[int | float] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TypeCommand( + selector=selector, + text=text, + delay=delay, + no_wait_after=no_wait_after, + strict=strict, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.type( + command.selector, + command.text, + delay=command.delay, + no_wait_after=command.no_wait_after, + strict=command.strict, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="type_text", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="type_text", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def uncheck( + self, + selector: str, + postion: Optional[Position] = None, + timeout: Optional[int | float] = None, + force: Optional[bool] = None, + no_wait_after: Optional[bool] = None, + strict: Optional[bool] = None, + trial: Optional[bool] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CheckCommand( + selector=selector, + postion=postion, + no_wait_after=no_wait_after, + strict=strict, + force=force, + trial=trial, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.uncheck( + command.selector, + position=command.postion, + force=command.force, + no_wait_after=command.no_wait_after, + strict=command.strict, + trial=command.trial, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="uncheck", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="uncheck", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def wait_for_event( + self, + event: str, + predicate: Optional[Callable[[ConsoleMessage], bool]] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = ExpectEventCommand( + event=event, + predicate=predicate, + timeout=timeout, + ) + + result: Any = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.wait_for_event( + event=command.event, + predicate=command.predicate, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_event", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_event", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def wait_for_function( + self, + expression: str, + arg: Optional[Any] = None, + polling: Optional[float | Literal["raf"]] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForFunctionCommand( + expression=expression, arg=arg, polling=polling, timeout=timeout + ) + + result: JSHandle = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.wait_for_function( + command.expression, + arg=command.arg, + polling=command.polling, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_function", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_function", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def wait_for_load_state( + self, + state: Optional[Literal["domcontentloaded", "load", "networkidle"]] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForLoadStateCommand(state=state, timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.wait_for_load_state( + state=command.state, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_load_state", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_load_state", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def wait_for_selector( + self, + selector: str, + state: Literal["attached", "detached", "hidden", "visible"] = "visible", + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForSelectorCommand( + selector=selector, state=state, strict=strict, timeout=timeout + ) + + result: Optional[ElementHandle] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.wait_for_selector( + command.selector, + state=command.state, + strict=command.strict, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_selector", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_selector", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def wait_for_timeout( + self, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForTimeoutCommand(timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.wait_for_timeout(timeout=command.timeout) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_timeout", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_timeout", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def wait_for_url( + self, + url: str | Pattern[str] | Callable[[str], bool], + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = WaitForUrlCommand(url=url, wait_until=wait_until, timeout=timeout) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await self.page.wait_for_url( + command.url, wait_until=command.wait_until, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_url", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="wait_for_url", + command_args=command, + metadata=self.metadata, + result=None, + timings=timings, + url=self.url, + ) + + async def get_attribute( + self, + selector: str, + name: str, + strict: Optional[bool] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GetAttributeCommand( + selector=selector, + name=name, + strict=strict, + timeout=timeout, + ) + + result: Optional[str] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.get_attribute( + command.selector, + command.name, + strict=command.strict, + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_attribute", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="get_attribute", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def go_back( + self, + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GoCommand( + wait_until=wait_until, + timeout=timeout, + ) + + result: Optional[Response] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.go_back( + wait_until=command.wait_until, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="go_back", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="go_back", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + async def go_forward( + self, + wait_until: Optional[ + Literal["commit", "domcontentloaded", "load", "networkidle"] + ] = None, + timeout: Optional[int | float] = None, + ): + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = GoCommand( + wait_until=wait_until, + timeout=timeout, + ) + + result: Optional[Response] = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = await self.page.go_forward( + wait_until=command.wait_until, timeout=command.timeout + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="go_forward", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="go_forward", + command_args=command, + metadata=self.metadata, + result=result, + timings=timings, + url=self.url, + ) + + def locator( + self, + selector: str, + has_text: Optional[str | Pattern[str]] = None, + has_not_text: Optional[str | Pattern[str]] = None, + has: Optional[Locator] = None, + has_not: Optional[Locator] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = LocatorCommand( + selector=selector, + has=has, + has_not=has_not, + has_text=has_text, + has_not_text=has_not_text, + timeout=timeout, + ) + + result: BrowserLocator = None + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + result = BrowserLocator( + self.page.locator( + command.selector, + has_text=command.has_text, + has_not_text=command.has_not_text, + has=command.has, + has_not=command.has_not, + ), + self.timeouts, + self.metadata, + self.url, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="locator", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + url=self.url, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="locator", + command_args=command, + result=result, + metadata=self.metadata, + timings=timings, + url=self.url, + ) + + async def close( + self, + run_before_unload: Optional[bool] = None, + reason: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CloseCommand( + run_before_unload=run_before_unload, reason=reason, timeout=timeout + ) + + await asyncio.wait_for( + self.page.close( + run_before_unload=command.run_before_unload, reason=command.reason + ), + timeout=command.timeout, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_session.py b/hyperscale/core_rewrite/engines/client/playwright/browser_session.py new file mode 100644 index 0000000..831e577 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_session.py @@ -0,0 +1,134 @@ +import asyncio +from typing import Any, Dict, List, Literal, Optional + +from playwright.async_api import Browser, BrowserContext, Geolocation, Page, Playwright + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .browser_page import BrowserPage +from .models.browser import BrowserMetadata + + +class BrowserSession: + def __init__( + self, playwright: Playwright, pool_size: int, timeouts: Timeouts + ) -> None: + self.playwright = playwright + self.pool_size = pool_size + self.browser: Browser = None + self.context: BrowserContext = None + self.pages: asyncio.Queue[BrowserPage] = asyncio.Queue(maxsize=pool_size) + self.metadata: BrowserMetadata = None + self.config: Dict[str, Any] = {} + self.timeouts = timeouts + + async def open( + self, + browser_type: Optional[ + Literal["safari", "webkit", "firefox", "chrome", "chromium"] + ] = None, + device_type: Optional[str] = None, + locale: Optional[str] = None, + geolocation: Optional[Geolocation] = None, + permissions: Optional[List[str]] = None, + color_scheme: Optional[str] = None, + options: Dict[str, Any] = {}, + timeout: int | float = 60, + ): + self.metadata = BrowserMetadata( + browser_type=browser_type, + device_type=device_type, + locale=locale, + geolocation=geolocation, + permissions=permissions, + color_scheme=color_scheme, + ) + + match self.metadata.browser_type: + case "safari" | "webkit": + self.browser = await self.playwright.webkit.launch() + + case "firefox": + self.browser = await self.playwright.firefox.launch() + + case "chrome" | "chromium": + self.browser = await self.playwright.chromium.launch() + + case _: + self.browser = await self.playwright.chromium.launch() + + if self.metadata.device_type: + device = self.playwright.devices[self.metadata.device_type] + + self.config = {**device, **options} + + if self.metadata.locale: + self.config["locale"] = locale + + if self.metadata.geolocation: + self.config["geolocation"] = geolocation + + if self.metadata.permissions: + self.config["permissions"] = permissions + + if self.metadata.color_scheme: + self.config["color_scheme"] = color_scheme + + has_options = len(self.config) > 0 + + if has_options: + self.context = await asyncio.wait_for( + self.browser.new_context(**self.config), timeout=timeout + ) + + else: + self.context = await asyncio.wait_for( + self.browser.new_context(), timeout=timeout + ) + + pages = await asyncio.gather( + *[ + asyncio.wait_for(self.context.new_page(), timeout=timeout) + for _ in range(self.pool_size) + ] + ) + + for page in pages: + self.pages.put_nowait(BrowserPage(page, self.timeouts, self.metadata)) + + async def next_page(self): + return await self.pages.get() + + async def close( + self, + run_before_unload: Optional[bool] = None, + reason: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + await asyncio.gather( + *[ + self._close( + self.pages, + run_before_unload=run_before_unload, + reason=reason, + timeout=timeout, + ) + for _ in range(self.pages.qsize()) + ] + ) + + async def _close( + self, + pages: asyncio.Queue[BrowserPage], + run_before_unload: Optional[bool] = None, + reason: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + page = await pages.get() + + await page.close( + run_before_unload=run_before_unload, reason=reason, timeout=timeout + ) + + def return_page(self, page: Page): + self.pages.put_nowait(page) diff --git a/hyperscale/core_rewrite/engines/client/playwright/browser_touchscreen.py b/hyperscale/core_rewrite/engines/client/playwright/browser_touchscreen.py new file mode 100644 index 0000000..e312b00 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/browser_touchscreen.py @@ -0,0 +1,84 @@ +import asyncio +import time +from typing import ( + Dict, + Literal, + Optional, +) + +from playwright.async_api import Touchscreen + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.browser import BrowserMetadata +from .models.commands.touchscreen import TapCommand +from .models.results import PlaywrightResult + + +class BrowserTouchscreen: + def __init__( + self, + touchscreen: Touchscreen, + timeouts: Timeouts, + metadata: BrowserMetadata, + url: str, + ) -> None: + self.touchscreen = touchscreen + self.timeouts = timeouts + self.metadata = metadata + self.url = url + + async def tap( + self, + x_position: int | float, + y_position: int | float, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + timings: Dict[Literal["command_start", "command_end"], float] = {} + + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = TapCommand( + x_position=x_position, + y_position=y_position, + timeout=timeout, + ) + + err: Optional[Exception] = None + timings["command_start"] = time.monotonic() + + try: + await asyncio.wait_for( + self.touchscreen.tap( + x=command.x_position, + y=command.y_position, + ), + timeout=command.timeout, + ) + + except Exception as err: + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="tap", + command_args=command, + metadata=self.metadata, + result=err, + error=str(err), + timings=timings, + ) + + timings["command_end"] = time.monotonic() + + return PlaywrightResult( + command="tap", + command_args=command, + result=None, + metadata=self.metadata, + timings=timings, + url=self.url, + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/mercury_sync_playwright_connection.py b/hyperscale/core_rewrite/engines/client/playwright/mercury_sync_playwright_connection.py new file mode 100644 index 0000000..8e553fa --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/mercury_sync_playwright_connection.py @@ -0,0 +1,138 @@ +import asyncio +from collections import deque +from typing import ( + Any, + Deque, + Dict, + List, + Literal, + Optional, + Tuple, + Type, +) + +from playwright.async_api import ( + BrowserContext, + Geolocation, + async_playwright, +) + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .browser_page import BrowserPage +from .browser_session import BrowserSession +from .models.commands.page import CloseCommand +from .models.results import PlaywrightResult + + +class MercurySyncPlaywrightConnection: + def __init__( + self, + pool_size: int = 10**3, + pages: int = 1, + timeouts: Timeouts = Timeouts(), + ) -> None: + self.pool_size = pool_size + self.pages = pages + self.config = {} + self.context: Optional[BrowserContext] = None + self.sessions: Deque[BrowserSession] = deque() + self.sem = asyncio.Semaphore(self.pool_size) + self.timeouts = timeouts + self.results: List[PlaywrightResult] = [] + self._active: Deque[Tuple[BrowserSession, BrowserPage]] = deque() + + async def open_page(self): + await self.sem.acquire() + + session = self.sessions.popleft() + page = await session.next_page() + + self._active.append((session, page)) + + return page + + def close_page(self): + session, page = self._active.popleft() + + session.return_page(page) + self.sessions.append(session) + + self.sem.release() + + async def __aenter__(self): + await self.sem.acquire() + + session = self.sessions.popleft() + page = await session.next_page() + + self._active.append((session, page)) + + return page + + async def __aexit__(self, exc_t: Type[Exception], exc_v: Exception, exc_tb: str): + session, page = self._active.popleft() + + session.return_page(page) + self.sessions.append(session) + + self.sem.release() + + async def start( + self, + browser_type: Literal["safari", "webkit", "firefox", "chrome"] = None, + device_type: str = None, + locale: str = None, + geolocation: Geolocation = None, + permissions: List[str] = None, + color_scheme: str = None, + options: Dict[str, Any] = {}, + ): + playwright = await async_playwright().start() + + self.sessions.extend( + [ + BrowserSession(playwright, self.pages, self.timeouts) + for _ in range(self.pool_size) + ] + ) + + await asyncio.gather( + *[ + session.open( + browser_type=browser_type, + device_type=device_type, + locale=locale, + geolocation=geolocation, + permissions=permissions, + color_scheme=color_scheme, + options=options, + timeout=self.timeouts.request_timeout, + ) + for session in self.sessions + ] + ) + + async def close( + self, + run_before_unload: Optional[bool] = None, + reason: Optional[str] = None, + timeout: Optional[int | float] = None, + ): + if timeout is None: + timeout = self.timeouts.request_timeout * 1000 + + command = CloseCommand( + run_before_unload=run_before_unload, reason=reason, timeout=timeout + ) + + await asyncio.gather( + *[ + session.close( + run_before_unload=command.run_before_unload, + reason=command.reason, + timeout=self.timeouts.request_timeout, + ) + for session in self.sessions + ] + ) diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/browser/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/browser/__init__.py new file mode 100644 index 0000000..d8bea97 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/browser/__init__.py @@ -0,0 +1 @@ +from .browser_metadata import BrowserMetadata \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/browser/browser_metadata.py b/hyperscale/core_rewrite/engines/client/playwright/models/browser/browser_metadata.py new file mode 100644 index 0000000..ec2deae --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/browser/browser_metadata.py @@ -0,0 +1,24 @@ +from typing import List, Literal, Optional + +from playwright.async_api import Geolocation +from pydantic import BaseModel, StrictStr + + +class BrowserMetadata(BaseModel): + browser_type: Optional[ + Literal[ + 'safari', + 'webkit', + 'firefox', + 'chrome', + 'chromium' + ] + ]=None, + device_type: Optional[StrictStr]=None, + locale: Optional[StrictStr]=None + geolocation: Optional[Geolocation]=None + permissions: Optional[List[StrictStr]]=None + color_scheme: Optional[StrictStr]=None + + class Config: + arbitrary_types_allowed=True diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/__init__.py new file mode 100644 index 0000000..879496f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/__init__.py @@ -0,0 +1 @@ +from .set_files_command import SetFilesCommand as SetFilesCommand \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/set_files_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/set_files_command.py new file mode 100644 index 0000000..f1d813c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/file_chooser/set_files_command.py @@ -0,0 +1,16 @@ +from pathlib import Path +from typing import Optional, Sequence + +from playwright.async_api import FilePayload +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class SetFilesCommand(BaseModel): + files: StrictStr | Path | FilePayload | Sequence[StrictStr | Path] | Sequence[FilePayload] + no_wait_after: Optional[bool]=None + timeout: Optional[StrictInt | StrictFloat]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/__init__.py new file mode 100644 index 0000000..d38b62a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/__init__.py @@ -0,0 +1 @@ +from .frame_element_command import FrameElementCommand \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/frame_element_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/frame_element_command.py new file mode 100644 index 0000000..765fae1 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/frame/frame_element_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class FrameElementCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/__init__.py new file mode 100644 index 0000000..b73bff5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/__init__.py @@ -0,0 +1 @@ +from .evaluate_command import EvaluateCommand as EvaluateCommand \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/evaluate_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/evaluate_command.py new file mode 100644 index 0000000..c781663 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/js_handle/evaluate_command.py @@ -0,0 +1,14 @@ +from typing import Any, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class EvaluateCommand(BaseModel): + expression: StrictStr + arg: Optional[Any]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/__init__.py new file mode 100644 index 0000000..5446feb --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/__init__.py @@ -0,0 +1,3 @@ +from .key_command import KeyCommand as KeyCommand +from .press_command import PressCommand as PressCommand +from .type_command import TypeCommand as TypeCommand diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/key_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/key_command.py new file mode 100644 index 0000000..c7fc2fc --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/key_command.py @@ -0,0 +1,11 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class KeyCommand(BaseModel): + key: StrictStr + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/press_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/press_command.py new file mode 100644 index 0000000..1c26826 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/press_command.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class PressCommand(BaseModel): + key: StrictStr + delay: Optional[StrictInt | StrictFloat]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/type_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/type_command.py new file mode 100644 index 0000000..166dd57 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/keyboard/type_command.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class TypeCommand(BaseModel): + text: StrictStr + delay: Optional[StrictInt | StrictFloat]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/__init__.py new file mode 100644 index 0000000..9c1b487 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/__init__.py @@ -0,0 +1,30 @@ +from .all_texts_command import AllTextsCommand as AllTextsCommand +from .and_matching_command import AndMatchingCommand as AndMatchingCommand +from .blur_command import BlurCommand as BlurCommand +from .bounding_box_command import BoundingBoxCommand as BoundingBoxCommand +from .check_command import CheckCommand as CheckCommand +from .clear_command import ClearCommand as ClearCommand +from .click_command import ClickCommand as ClickCommand +from .count_command import CountCommand as CountCommand +from .dispatch_event_command import DispatchEventCommand as DispatchEventCommand +from .dom_command import DOMCommand as DOMCommand +from .drag_to_command import DragToCommand as DragToCommand +from .fill_command import FillCommand as FillCommand +from .filter_command import FilterCommand as FilterCommand +from .focus_command import FocusCommand as FocusCommand +from .get_attribute_command import GetAttributeCommand as GetAttributeCommand +from .highlight_command import HighlightCommand as HighlightCommand +from .hover_command import HoverCommand as HoverCommand +from .nth_command import NthCommand as NthCommand +from .or_matching_command import OrMatchingCommand as OrMatchingCommand +from .press_command import PressCommand as PressCommand +from .press_sequentially_command import ( + PressSequentiallyCommand as PressSequentiallyCommand, +) +from .scroll_into_view_if_needed import ScrollIntoViewIfNeeded as ScrollIntoViewIfNeeded +from .select_option_command import SelectOptionCommand as SelectOptionCommand +from .select_text_command import SelectTextCommand as SelectTextCommand +from .set_checked_command import SetCheckedCommand as SetCheckedCommand +from .set_input_files import SetInputFilesCommand as SetInputFilesCommand +from .tap_command import TapCommand as TapCommand +from .wait_for_command import WaitForCommand as WaitForCommand \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/all_texts_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/all_texts_command.py new file mode 100644 index 0000000..f4c43d2 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/all_texts_command.py @@ -0,0 +1,10 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class AllTextsCommand(BaseModel): + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/and_matching_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/and_matching_command.py new file mode 100644 index 0000000..a249191 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/and_matching_command.py @@ -0,0 +1,14 @@ +from playwright.async_api import Locator +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class AndMatchingCommand(BaseModel): + locator: Locator + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/blur_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/blur_command.py new file mode 100644 index 0000000..90d20a0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/blur_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class BlurCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/bounding_box_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/bounding_box_command.py new file mode 100644 index 0000000..fc8f178 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/bounding_box_command.py @@ -0,0 +1,10 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class BoundingBoxCommand(BaseModel): + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/check_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/check_command.py new file mode 100644 index 0000000..15429a7 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/check_command.py @@ -0,0 +1,21 @@ +from typing import Optional + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, +) + + +class CheckCommand(BaseModel): + postion: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/clear_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/clear_command.py new file mode 100644 index 0000000..583238b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/clear_command.py @@ -0,0 +1,10 @@ +from typing import Optional + +from pydantic import BaseModel, StrictBool, StrictFloat, StrictInt + + +class ClearCommand(BaseModel): + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/click_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/click_command.py new file mode 100644 index 0000000..96a8cb6 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/click_command.py @@ -0,0 +1,25 @@ +from typing import Literal, Optional, Sequence + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, +) + + +class ClickCommand(BaseModel): + modifiers: Optional[Sequence[Literal['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']]]=None + delay: Optional[StrictFloat | StrictInt]=None + button: Optional[Literal['left', 'middle', 'right']]=None + click_count: Optional[StrictInt]=None + postion: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/count_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/count_command.py new file mode 100644 index 0000000..82aac2b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/count_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class CountCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dispatch_event_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dispatch_event_command.py new file mode 100644 index 0000000..9d5981d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dispatch_event_command.py @@ -0,0 +1,14 @@ +from typing import Any, Dict, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class DispatchEventCommand(BaseModel): + event_type: StrictStr + event_init: Optional[Dict[StrictStr, Any]]=None + timeout: StrictInt | StrictFloat=None diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dom_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dom_command.py new file mode 100644 index 0000000..72b74cf --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/dom_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class DOMCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/drag_to_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/drag_to_command.py new file mode 100644 index 0000000..7c68d6e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/drag_to_command.py @@ -0,0 +1,20 @@ +from typing import Optional + +from playwright.async_api import ( + Locator, + Position, +) +from pydantic import BaseModel, StrictBool, StrictFloat, StrictInt + + +class DragToCommand(BaseModel): + target: Locator + force: Optional[StrictBool] = None + no_wait_after: Optional[StrictBool] = None + trial: Optional[StrictBool] = None + source_position: Optional[Position] = None + target_position: Optional[Position] = None + timeout: Optional[StrictInt | StrictFloat] = None + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/fill_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/fill_command.py new file mode 100644 index 0000000..fd78588 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/fill_command.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class FillCommand(BaseModel): + value: StrictStr + no_wait_after: Optional[StrictBool]=None + force: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/filter_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/filter_command.py new file mode 100644 index 0000000..b4d305e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/filter_command.py @@ -0,0 +1,17 @@ +from typing import Optional, Pattern + +from playwright.async_api import Locator +from pydantic import ( + BaseModel, + StrictStr, +) + + +class FilterCommand(BaseModel): + has: Optional[Locator] = None + has_not: Optional[Locator] = None + has_text: Optional[StrictStr | Pattern[str]] = None + has_not_text: Optional[StrictStr | Pattern[str]] = None + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/focus_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/focus_command.py new file mode 100644 index 0000000..4f03865 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/focus_command.py @@ -0,0 +1,10 @@ + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class FocusCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/get_attribute_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/get_attribute_command.py new file mode 100644 index 0000000..5de06c1 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/get_attribute_command.py @@ -0,0 +1,12 @@ + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class GetAttributeCommand(BaseModel): + name: StrictStr + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/highlight_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/highlight_command.py new file mode 100644 index 0000000..615a5cd --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/highlight_command.py @@ -0,0 +1,10 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class HighlightCommand(BaseModel): + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/hover_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/hover_command.py new file mode 100644 index 0000000..f71d956 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/hover_command.py @@ -0,0 +1,22 @@ +from typing import Literal, Optional, Sequence + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, +) + + +class HoverCommand(BaseModel): + modifiers: Optional[Sequence[Literal['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']]]=None + postion: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/nth_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/nth_command.py new file mode 100644 index 0000000..c4ef477 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/nth_command.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, StrictInt + + +class NthCommand(BaseModel): + index: StrictInt \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/or_matching_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/or_matching_command.py new file mode 100644 index 0000000..2c7dc3e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/or_matching_command.py @@ -0,0 +1,14 @@ +from playwright.async_api import Locator +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class OrMatchingCommand(BaseModel): + locator: Locator + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_command.py new file mode 100644 index 0000000..b98b62e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_command.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class PressCommand(BaseModel): + key: StrictStr + delay: Optional[StrictInt | StrictFloat]=None + no_wait_after: Optional[StrictBool] = None + timeout: StrictInt | StrictFloat = None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_sequentially_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_sequentially_command.py new file mode 100644 index 0000000..3c98fe0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/press_sequentially_command.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class PressSequentiallyCommand(BaseModel): + key: StrictStr + delay: Optional[StrictInt | StrictFloat]=None + no_wait_after: Optional[StrictBool] = None + timeout: StrictInt | StrictFloat = None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/scroll_into_view_if_needed.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/scroll_into_view_if_needed.py new file mode 100644 index 0000000..fa3ea32 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/scroll_into_view_if_needed.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ScrollIntoViewIfNeeded(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_option_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_option_command.py new file mode 100644 index 0000000..808006b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_option_command.py @@ -0,0 +1,26 @@ +from typing import ( + Optional, + Sequence, +) + +from playwright.async_api import ElementHandle +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class SelectOptionCommand(BaseModel): + value: Optional[StrictStr | Sequence[StrictStr]]=None + index: Optional[StrictInt | Sequence[StrictInt]]=None + label: Optional[StrictStr | Sequence[StrictStr]]=None + element: Optional[ElementHandle | Sequence[ElementHandle]]=None + no_wait_after: Optional[StrictBool]=None + force: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_text_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_text_command.py new file mode 100644 index 0000000..e2c9fcd --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/select_text_command.py @@ -0,0 +1,13 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, +) + + +class SelectTextCommand(BaseModel): + force: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_checked_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_checked_command.py new file mode 100644 index 0000000..d2d31b8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_checked_command.py @@ -0,0 +1,18 @@ +from typing import Optional + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, +) + + +class SetCheckedCommand(BaseModel): + checked: StrictBool + position: Optional[Position]=None + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_input_files.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_input_files.py new file mode 100644 index 0000000..70c02d2 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/set_input_files.py @@ -0,0 +1,18 @@ +from pathlib import Path +from typing import Optional, Sequence + +from playwright.async_api import FilePayload +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class SetInputFilesCommand(BaseModel): + files: StrictStr | Path | FilePayload | Sequence[StrictStr | Path] | Sequence[FilePayload] + no_wait_after: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/tap_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/tap_command.py new file mode 100644 index 0000000..c1922ae --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/tap_command.py @@ -0,0 +1,28 @@ +from typing import Literal, Optional, Sequence + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, +) + + +class TapCommand(BaseModel): + modifiers: Optional[ + Sequence[ + Literal[ + 'Alt', + 'Control', + 'ControlOrMeta', + 'Meta', + 'Shift' + ] + ] + ] = None + position: Optional[Position] = None + force: Optional[StrictBool] = None + no_wait_after: Optional[StrictBool] = None + trial: Optional[StrictBool] = None + timeout: Optional[StrictInt | StrictFloat]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/wait_for_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/wait_for_command.py new file mode 100644 index 0000000..5448f11 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/locator/wait_for_command.py @@ -0,0 +1,8 @@ +from typing import Literal + +from pydantic import BaseModel, StrictFloat, StrictInt + + +class WaitForCommand(BaseModel): + state: Literal['attached', 'detached', 'visible', 'hidden']='visible' + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/__init__.py new file mode 100644 index 0000000..ef95ab3 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/__init__.py @@ -0,0 +1,4 @@ +from .button_command import ButtonCommand as ButtonCommand +from .click_command import ClickCommand as ClickCommand +from .move_command import MoveCommand as MoveCommand +from .wheel_command import WheelCommand as WheelCommand \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/button_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/button_command.py new file mode 100644 index 0000000..2434d8c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/button_command.py @@ -0,0 +1,9 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, StrictFloat, StrictInt + + +class ButtonCommand(BaseModel): + button: Optional[Literal['left', 'middle', 'right']]='left' + click_count: Optional[StrictInt]=1 + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/click_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/click_command.py new file mode 100644 index 0000000..e4de5e7 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/click_command.py @@ -0,0 +1,16 @@ +from typing import Literal, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ClickCommand(BaseModel): + x_position: StrictInt | StrictFloat + y_position: StrictInt | StrictFloat + button: Optional[Literal['left', 'middle', 'right']]='left' + click_count: Optional[StrictInt]=1 + delay: Optional[StrictInt | StrictFloat]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/move_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/move_command.py new file mode 100644 index 0000000..cc7604f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/move_command.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class MoveCommand(BaseModel): + x_position: StrictInt | StrictFloat + y_position: StrictInt | StrictFloat + steps: Optional[StrictInt]=1 + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/wheel_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/wheel_command.py new file mode 100644 index 0000000..963f952 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/mouse/wheel_command.py @@ -0,0 +1,13 @@ + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class WheelCommand(BaseModel): + delta_x: StrictInt | StrictFloat + delta_y: StrictInt | StrictFloat + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/__init__.py new file mode 100644 index 0000000..5cc5dbf --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/__init__.py @@ -0,0 +1,86 @@ +from .add_init_script_command import AddInitScriptCommand as AddInitScriptCommand +from .add_locator_handler_command import ( + AddLocatorHandlerCommand as AddLocatorHandlerCommand, +) +from .add_script_tag_command import AddScriptTagCommand as AddScriptTagCommand +from .add_style_tag_command import AddStyleTagCommand as AddStyleTagCommand +from .bring_to_front_command import BringToFrontCommand as BringToFrontCommand +from .check_command import CheckCommand as CheckCommand +from .click_command import ClickCommand as ClickCommand +from .close_command import CloseCommand as CloseCommand +from .content_command import ContentCommand as ContentCommand +from .dispatch_event_command import DispatchEventCommand as DispatchEventCommand +from .dom_command import DOMCommand as DOMCommand +from .double_click_command import DoubleClickCommand as DoubleClickCommand +from .drag_and_drop_command import DragAndDropCommand as DragAndDropCommand +from .emulate_media_command import EmulateMediaCommand as EmulateMediaCommand +from .evaluate_command import EvaluateCommand as EvaluateCommand +from .evaluate_on_selector_command import ( + EvaluateOnSelectorCommand as EvaluateOnSelectorCommand, +) +from .expect_console_message_command import ( + ExpectConsoleMessageCommand as ExpectConsoleMessageCommand, +) +from .expect_download_command import ExpectDownloadCommand as ExpectDownloadCommand +from .expect_event_command import ExpectEventCommand as ExpectEventCommand +from .expect_file_chooser_command import ( + ExpectFileChooserCommand as ExpectFileChooserCommand, +) +from .expect_navigation_command import ( + ExpectNavigationCommand as ExpectNavigationCommand, +) +from .expect_popup_command import ExpectPopupCommand as ExpectPopupCommand +from .expect_request_command import ExpectRequestCommand as ExpectRequestCommand +from .expect_request_finished_command import ( + ExpectRequestFinishedCommand as ExpectRequestFinishedCommand, +) +from .expect_response_command import ExpectResponseCommand as ExpectResponseCommand +from .expect_websocket_command import ExpectWebsocketCommand as ExpectWebsocketCommand +from .expect_worker_command import ExpectWorkerCommand as ExpectWorkerCommand +from .expose_binding_command import ExposeBindingCommand as ExposeBindingCommand +from .expose_function_command import ExposeFunctionCommand as ExposeFunctionCommand +from .fill_command import FillCommand as FillCommand +from .focus_command import FocusCommand as FocusCommand +from .frame_command import FrameCommand as FrameCommand +from .frame_locator_command import FrameLocatorCommand as FrameLocatorCommand +from .get_attribute_command import GetAttributeCommand as GetAttributeCommand +from .get_by_role_command import GetByRoleCommand as GetByRoleCommand +from .get_by_test_id_command import GetByTestIdCommand as GetByTestIdCommand +from .get_by_text_command import GetByTextCommand as GetByTextCommand +from .get_url_command import GetUrlCommand as GetUrlCommand +from .go_command import GoCommand as GoCommand +from .goto_command import GoToCommand as GoToCommand +from .hover_command import HoverCommand as HoverCommand +from .is_closed_command import IsClosedCommand as IsClosedCommand +from .locator_command import LocatorCommand as LocatorCommand +from .on_command import OnCommand as OnCommand +from .opener_command import OpenerCommand as OpenerCommand +from .pause_command import PauseCommand as PauseCommand +from .pdf_command import PdfCommand as PdfCommand +from .press_command import PressCommand as PressCommand +from .reload_command import ReloadCommand as ReloadCommand +from .remove_locator_handler_command import ( + RemoveLocatorHandlerCommand as RemoveLocatorHandlerCommand, +) +from .route_command import RouteCommand as RouteCommand +from .route_from_har_command import RouteFromHarCommand as RouteFromHarCommand +from .screenshot_command import ScreenshotCommand as ScreenshotCommand +from .select_option_command import SelectOptionCommand as SelectOptionCommand +from .set_checked_command import SetCheckedCommand as SetCheckedCommand +from .set_content_command import SetContentCommand as SetContentCommand +from .set_extra_http_headers_command import ( + SetExtraHTTPHeadersCommand as SetExtraHTTPHeadersCommand, +) +from .set_input_files_command import SetInputFilesCommand as SetInputFilesCommand +from .set_timeout_command import SetTimeoutCommand as SetTimeoutCommand +from .set_viewport_size_command import SetViewportSize as SetViewportSize +from .tap_command import TapCommand as TapCommand +from .title_command import TitleCommand as TitleCommand +from .type_command import TypeCommand as TypeCommand +from .wait_for_function_command import WaitForFunctionCommand as WaitForFunctionCommand +from .wait_for_load_state_command import ( + WaitForLoadStateCommand as WaitForLoadStateCommand, +) +from .wait_for_selector_command import WaitForSelectorCommand as WaitForSelectorCommand +from .wait_for_timeout_command import WaitForTimeoutCommand as WaitForTimeoutCommand +from .wait_for_url_command import WaitForUrlCommand as WaitForUrlCommand diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_init_script_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_init_script_command.py new file mode 100644 index 0000000..454158b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_init_script_command.py @@ -0,0 +1,19 @@ +from pathlib import Path +from typing import Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class AddInitScriptCommand(BaseModel): + script: Optional[StrictStr]=None + path: Optional[StrictStr | Path]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_locator_handler_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_locator_handler_command.py new file mode 100644 index 0000000..71ca048 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_locator_handler_command.py @@ -0,0 +1,27 @@ +from typing import ( + Any, + Callable, + Optional, +) + +from playwright.async_api import Locator +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, +) + + +class AddLocatorHandlerCommand(BaseModel): + locator: Locator + handler: Callable[ + [Locator], + Any + ] | Callable[[], Any] + no_wait_after: Optional[StrictBool]=None + times: Optional[StrictInt]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_script_tag_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_script_tag_command.py new file mode 100644 index 0000000..4a41f5a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_script_tag_command.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class AddScriptTagCommand(BaseModel): + url: Optional[StrictStr] = None + path: Optional[StrictStr | Path] = None + content: Optional[StrictStr] = None + tag_type: Optional[StrictStr] = None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_style_tag_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_style_tag_command.py new file mode 100644 index 0000000..1471734 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/add_style_tag_command.py @@ -0,0 +1,20 @@ +from pathlib import Path +from typing import Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class AddStyleTagCommand(BaseModel): + url: Optional[StrictStr]=None + path: Optional[StrictStr | Path]=None + content: Optional[StrictStr]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/bring_to_front_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/bring_to_front_command.py new file mode 100644 index 0000000..5c13493 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/bring_to_front_command.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, StrictFloat, StrictInt + + +class BringToFrontCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/check_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/check_command.py new file mode 100644 index 0000000..0b34c07 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/check_command.py @@ -0,0 +1,24 @@ +from typing import Optional + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class CheckCommand(BaseModel): + selector: StrictStr + postion: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/click_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/click_command.py new file mode 100644 index 0000000..8c265d2 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/click_command.py @@ -0,0 +1,28 @@ +from typing import Literal, Optional, Sequence + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class ClickCommand(BaseModel): + selector: StrictStr + modifiers: Optional[Sequence[Literal['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']]]=None + delay: Optional[StrictFloat | StrictInt]=None + button: Literal['left', 'middle', 'right']='left' + click_count: Optional[StrictInt]=None + postion: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/close_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/close_command.py new file mode 100644 index 0000000..10f1548 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/close_command.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class CloseCommand(BaseModel): + run_before_unload: Optional[StrictBool]=None + reason: Optional[StrictStr] + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/content_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/content_command.py new file mode 100644 index 0000000..bdf1b1c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/content_command.py @@ -0,0 +1,14 @@ + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ContentCommand(BaseModel): + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dispatch_event_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dispatch_event_command.py new file mode 100644 index 0000000..b2b954f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dispatch_event_command.py @@ -0,0 +1,17 @@ +from typing import Any, Dict, Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class DispatchEventCommand(BaseModel): + selector: StrictStr + event_type: StrictStr + event_init: Optional[Dict[StrictStr, Any]]=None + strict: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat=None diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dom_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dom_command.py new file mode 100644 index 0000000..8ea3ebe --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/dom_command.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class DOMCommand(BaseModel): + selector: StrictStr + strict: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/double_click_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/double_click_command.py new file mode 100644 index 0000000..8849fdd --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/double_click_command.py @@ -0,0 +1,28 @@ +from typing import Literal, Optional, Sequence + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class DoubleClickCommand(BaseModel): + selector: StrictStr + modifiers: Optional[Sequence[Literal['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']]]=None + delay: Optional[StrictFloat | StrictInt]=None + button: Literal['left', 'middle', 'right']='left' + click_count: Optional[StrictInt]=None + postion: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/drag_and_drop_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/drag_and_drop_command.py new file mode 100644 index 0000000..e4ad800 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/drag_and_drop_command.py @@ -0,0 +1,22 @@ +from typing import Optional + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class DragAndDropCommand(BaseModel): + source: StrictStr + target: StrictStr + source_position: Optional[Position]=None + target_position: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + trial: Optional[StrictBool]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/emulate_media_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/emulate_media_command.py new file mode 100644 index 0000000..b898718 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/emulate_media_command.py @@ -0,0 +1,19 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, StrictFloat, StrictInt + + +class EmulateMediaCommand(BaseModel): + media: Optional[ + Literal['null', 'print', 'screen'] + ] = None + color_scheme: Optional[ + Literal['dark', 'light', 'no-preference', 'null'] + ] = None + reduced_motion: Optional[ + Literal['no-preference', 'null', 'reduce'] + ] = None + forced_colors: Optional[ + Literal['active', 'none', 'null'] + ] = None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_command.py new file mode 100644 index 0000000..8e47671 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_command.py @@ -0,0 +1,14 @@ +from typing import Any, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class EvaluateCommand(BaseModel): + expression: StrictStr + arg: Optional[Any]=None + timeout: Optional[StrictInt | StrictFloat]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_on_selector_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_on_selector_command.py new file mode 100644 index 0000000..71201d4 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/evaluate_on_selector_command.py @@ -0,0 +1,17 @@ +from typing import Any, Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class EvaluateOnSelectorCommand(BaseModel): + selector: StrictStr + expression: StrictStr + arg: Optional[Any]=None + strict: Optional[StrictBool]=None + timeout: Optional[StrictInt | StrictFloat]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_console_message_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_console_message_command.py new file mode 100644 index 0000000..804457a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_console_message_command.py @@ -0,0 +1,21 @@ +from typing import Callable, Optional + +from playwright.async_api import ConsoleMessage +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ExpectConsoleMessageCommand(BaseModel): + predicate: Optional[ + Callable[ + [ConsoleMessage], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_download_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_download_command.py new file mode 100644 index 0000000..75edec3 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_download_command.py @@ -0,0 +1,21 @@ +from typing import Callable, Optional + +from playwright.async_api import Download +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ExpectDownloadCommand(BaseModel): + predicate: Optional[ + Callable[ + [Download], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_event_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_event_command.py new file mode 100644 index 0000000..fbc8340 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_event_command.py @@ -0,0 +1,41 @@ +from typing import ( + Callable, + Literal, + Optional, +) + +from pydantic import BaseModel, StrictFloat, StrictInt, StrictStr + + +class ExpectEventCommand(BaseModel): + event: Literal[ + 'close', + 'console', + 'crash', + 'dialog', + 'domcontentloaded', + 'download', + 'filechooser', + 'frameattached', + 'framedetached', + 'framenavigated', + 'load', + 'pageerror', + 'popup', + 'request', + 'requestfailed', + 'requestfinished', + 'response', + 'websocket', + 'worker' + ] + predicate: Optional[ + Callable[ + [StrictStr], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_file_chooser_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_file_chooser_command.py new file mode 100644 index 0000000..9fed328 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_file_chooser_command.py @@ -0,0 +1,21 @@ +from typing import Callable, Optional + +from playwright.async_api import FileChooser +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ExpectFileChooserCommand(BaseModel): + predicate: Optional[ + Callable[ + [FileChooser], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_navigation_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_navigation_command.py new file mode 100644 index 0000000..29f34b3 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_navigation_command.py @@ -0,0 +1,16 @@ +from typing import Literal, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class ExpectNavigationCommand(BaseModel): + url: StrictStr + wait_until: Optional[ + Literal['commit', 'domcontentloaded', 'load', 'networkidle'] + ]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_popup_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_popup_command.py new file mode 100644 index 0000000..54899be --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_popup_command.py @@ -0,0 +1,21 @@ +from typing import Callable, Optional + +from playwright.async_api import Page +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ExpectPopupCommand(BaseModel): + predicate: Optional[ + Callable[ + [Page], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_command.py new file mode 100644 index 0000000..99fd538 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_command.py @@ -0,0 +1,19 @@ +from typing import Callable, Optional, Pattern + +from playwright.async_api import Request +from pydantic import BaseModel, StrictFloat, StrictInt, StrictStr + + +class ExpectRequestCommand(BaseModel): + url_or_predicate: Optional[ + StrictStr | + Pattern[str] | + Callable[ + [Request], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_finished_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_finished_command.py new file mode 100644 index 0000000..7acb4bd --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_request_finished_command.py @@ -0,0 +1,21 @@ +from typing import Callable, Optional + +from playwright.async_api import Request +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ExpectRequestFinishedCommand(BaseModel): + predicate: Optional[ + Callable[ + [Request], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_response_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_response_command.py new file mode 100644 index 0000000..e95a73b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_response_command.py @@ -0,0 +1,23 @@ +from typing import Callable, Optional, Pattern + +from playwright.async_api import Response +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class ExpectResponseCommand(BaseModel): + url_or_predicate: Optional[ + str | + Pattern[str] | + Callable[ + [Response], + bool + ] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_websocket_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_websocket_command.py new file mode 100644 index 0000000..dec01c0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_websocket_command.py @@ -0,0 +1,15 @@ +from typing import Callable, Optional + +from playwright.async_api import WebSocket +from pydantic import BaseModel, StrictBool, StrictFloat, StrictInt + + +class ExpectWebsocketCommand(BaseModel): + predicate: Optional[ + Callable[[WebSocket], StrictBool] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_worker_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_worker_command.py new file mode 100644 index 0000000..a72c355 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expect_worker_command.py @@ -0,0 +1,15 @@ +from typing import Callable, Optional + +from playwright.async_api import Worker +from pydantic import BaseModel, StrictBool, StrictFloat, StrictInt + + +class ExpectWorkerCommand(BaseModel): + predicate: Optional[ + Callable[[Worker], StrictBool] + ]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_binding_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_binding_command.py new file mode 100644 index 0000000..d04d92a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_binding_command.py @@ -0,0 +1,17 @@ +from typing import Any, Callable, Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class ExposeBindingCommand(BaseModel): + name: StrictStr + callback: Callable[..., Any] + handle: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_function_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_function_command.py new file mode 100644 index 0000000..e446004 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/expose_function_command.py @@ -0,0 +1,15 @@ +from typing import Any, Callable + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class ExposeFunctionCommand(BaseModel): + name: StrictStr + callback: Callable[..., Any] + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/fill_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/fill_command.py new file mode 100644 index 0000000..14e06a2 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/fill_command.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class FillCommand(BaseModel): + selector: StrictStr + value: StrictStr + no_wait_after: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + force: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/focus_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/focus_command.py new file mode 100644 index 0000000..ff00d9a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/focus_command.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class FocusCommand(BaseModel): + selector: StrictStr + strict: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_command.py new file mode 100644 index 0000000..31babe4 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_command.py @@ -0,0 +1,12 @@ +from typing import Callable, Optional, Pattern + +from pydantic import ( + BaseModel, + StrictBool, + StrictStr, +) + + +class FrameCommand(BaseModel): + name: Optional[StrictStr]=None + url: Optional[StrictStr | Pattern[str] | Callable[[StrictStr], StrictBool]]=None diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_locator_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_locator_command.py new file mode 100644 index 0000000..fb4816d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/frame_locator_command.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, StrictFloat, StrictInt, StrictStr + + +class FrameLocatorCommand(BaseModel): + selector: StrictStr + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_attribute_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_attribute_command.py new file mode 100644 index 0000000..358fa12 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_attribute_command.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class GetAttributeCommand(BaseModel): + selector: StrictStr + name: StrictStr + strict: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_role_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_role_command.py new file mode 100644 index 0000000..0123afa --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_role_command.py @@ -0,0 +1,98 @@ +from typing import Literal, Optional, Pattern + +from pydantic import BaseModel, StrictBool, StrictFloat, StrictInt, StrictStr + + +class GetByRoleCommand(BaseModel): + role: Literal[ + 'alert', + 'alertdialog', + 'application', + 'article', + 'banner', + 'blockquote', + 'button', + 'caption', + 'cell', + 'checkbox', + 'code', + 'columnheader', + 'combobox', + 'complementary', + 'contentinfo', + 'definition', + 'deletion', + 'dialog', + 'directory', + 'document', + 'emphasis', + 'feed', + 'figure', + 'form', + 'generic', + 'grid', + 'gridcell', + 'group', 'heading', + 'img', 'insertion', + 'link', + 'list', + 'listbox', + 'listitem', + 'log', + 'main', + 'marquee', + 'math', + 'menu', + 'menubar', + 'menuitem', + 'menuitemcheckbox', + 'menuitemradio', + 'meter', + 'navigation', + 'none', + 'note', + 'option', + 'paragraph', + 'presentation', + 'progressbar', + 'radio', + 'radiogroup', + 'region', + 'row', + 'rowgroup', + 'rowheader', + 'scrollbar', + 'search', + 'searchbox', + 'separator', + 'slider', + 'spinbutton', + 'status', + 'strong', + 'subscript', + 'superscript', + 'switch', + 'tab', + 'table', + 'tablist', + 'tabpanel', + 'term', + 'textbox', + 'time', + 'timer', + 'toolbar', + 'tooltip', + 'tree', + 'treegrid', + 'treeitem' + ] + checked: Optional[StrictBool] = None + disabled: Optional[StrictBool] = None + expanded: Optional[StrictBool] = None + include_hidden: Optional[StrictBool] = None + level: Optional[StrictInt] = None + name: Optional[StrictStr | Pattern[str]] = None + pressed: Optional[StrictBool] = None + selected: Optional[StrictBool] = None + exact: Optional[StrictBool] = None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_test_id_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_test_id_command.py new file mode 100644 index 0000000..e7c4626 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_test_id_command.py @@ -0,0 +1,13 @@ +from typing import Pattern + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class GetByTestIdCommand(BaseModel): + test_id: StrictStr | Pattern[str] + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_text_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_text_command.py new file mode 100644 index 0000000..83268d7 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_by_text_command.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class GetByTextCommand(BaseModel): + text: StrictStr + exact: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_url_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_url_command.py new file mode 100644 index 0000000..f121977 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/get_url_command.py @@ -0,0 +1,7 @@ + +from pydantic import BaseModel, StrictFloat, StrictInt + + +class GetUrlCommand(BaseModel): + timeout: StrictInt | StrictFloat + diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/go_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/go_command.py new file mode 100644 index 0000000..4673d79 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/go_command.py @@ -0,0 +1,19 @@ +from typing import Literal, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class GoCommand(BaseModel): + wait_until: Optional[ + Literal[ + 'commit', + 'domcontentloaded', + 'load', + 'networkidle' + ] + ] = None + timeout: StrictInt | StrictFloat diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/goto_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/goto_command.py new file mode 100644 index 0000000..6e4b9d4 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/goto_command.py @@ -0,0 +1,17 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, StrictFloat, StrictInt, StrictStr + + +class GoToCommand(BaseModel): + url: StrictStr + timeout: StrictInt | StrictFloat + wait_util: Optional[ + Literal[ + "commit", + "domcontentloaded", + "load", + "networkidle", + ] + ] = None + referrer: Optional[StrictStr] = None diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/hover_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/hover_command.py new file mode 100644 index 0000000..2cb9457 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/hover_command.py @@ -0,0 +1,25 @@ +from typing import Literal, Optional, Sequence + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class HoverCommand(BaseModel): + selector: StrictStr + modifiers: Optional[Sequence[Literal['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift']]]=None + postion: Optional[Position]=None + timeout: StrictInt | StrictFloat + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + + class Config: + arbitrary_types_allowed=True + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/is_closed_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/is_closed_command.py new file mode 100644 index 0000000..d7242fa --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/is_closed_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class IsClosedCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/locator_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/locator_command.py new file mode 100644 index 0000000..db15e93 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/locator_command.py @@ -0,0 +1,21 @@ +from typing import Optional, Pattern + +from playwright.async_api import Locator +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class LocatorCommand(BaseModel): + selector: StrictStr + has_text: Optional[StrictStr | Pattern[str]]=None + has_not_text: Optional[StrictStr | Pattern[str]]=None + has: Optional[Locator]=None + has_not: Optional[Locator]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/on_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/on_command.py new file mode 100644 index 0000000..f0d590c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/on_command.py @@ -0,0 +1,103 @@ +from typing import Awaitable, Callable, Literal + +from playwright.async_api import ( + ConsoleMessage, + Dialog, + Download, + Error, + FileChooser, + Frame, + Page, + Request, + Response, + WebSocket, + Worker, +) +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class OnCommand(BaseModel): + event: Literal[ + 'close', + 'console', + 'crash', + 'dialog', + 'domcontentloaded', + 'download', + 'filechooser', + 'frameattached', + 'framedetached', + 'framenavigated', + 'load', + 'pageerror', + 'popup', + 'request', + 'requestfailed', + 'requestfinished', + 'response', + 'websocket', + 'worker' + ] + function: Callable[ + [Page], + Awaitable[None] | None + ] | Callable[ + [ConsoleMessage], + Awaitable[None] | None + ] | Callable[ + [Page], + Awaitable[None] | None + ] | Callable[ + [Dialog], + Awaitable[None] | None + ] | Callable[ + [Page], + Awaitable[None] | None + ] | Callable[ + [Download], + Awaitable[None] | None + ] | Callable[ + [FileChooser], + Awaitable[None] | None + ] | Callable[ + [Frame], + Awaitable[None] | None + ] | Callable[ + [Frame], + Awaitable[None] | None + ] | Callable[ + [Frame], + Awaitable[None] | None + ] | Callable[ + [Page], + Awaitable[None] | None + ] | Callable[ + [Error], + Awaitable[None] | None + ] | Callable[ + [Page], + Awaitable[None] | None + ] | Callable[ + [Request], + Awaitable[None] | None + ] | Callable[ + [Request], + Awaitable[None] | None + ] | Callable[ + [Request], + Awaitable[None] | None + ] | Callable[ + [Response], + Awaitable[None] | None + ] | Callable[ + [WebSocket], + Awaitable[None] | None + ] | Callable[ + [Worker], + Awaitable[None] | None + ] + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/opener_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/opener_command.py new file mode 100644 index 0000000..10e05f0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/opener_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class OpenerCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pause_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pause_command.py new file mode 100644 index 0000000..1ed977b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pause_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class PauseCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pdf_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pdf_command.py new file mode 100644 index 0000000..1662c62 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/pdf_command.py @@ -0,0 +1,31 @@ +from pathlib import Path +from typing import Optional + +from playwright.async_api import PdfMargins +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class PdfCommand(BaseModel): + scale: Optional[StrictFloat] = None + display_header_footer: Optional[StrictBool] = None + header_template: Optional[StrictStr]= None + footer_template: Optional[StrictStr] = None + print_background: Optional[StrictBool] = None + landscape: Optional[StrictBool] = None + page_ranges: Optional[StrictStr] = None + pdf_format: Optional[StrictStr] = None + width: Optional[StrictStr | StrictFloat] = None + height: Optional[StrictStr | StrictFloat] = None + prefer_css_page_size: Optional[StrictBool] = None + margin: Optional[PdfMargins] = None + path: Optional[StrictStr | Path] = None + outline: Optional[StrictBool] = None + tagged: Optional[StrictBool] = None + timeout: Optional[StrictInt | StrictFloat]=None + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/press_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/press_command.py new file mode 100644 index 0000000..b7f4bac --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/press_command.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class PressCommand(BaseModel): + selector: StrictStr + key: StrictStr + delay: Optional[StrictInt | StrictFloat]=None + no_wait_after: Optional[StrictBool] = None + strict: Optional[StrictBool]= None + timeout: StrictInt | StrictFloat = None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/reload_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/reload_command.py new file mode 100644 index 0000000..6b4ff3e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/reload_command.py @@ -0,0 +1,13 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, StrictFloat, StrictInt + + +class ReloadCommand(BaseModel): + timeout: StrictInt | StrictFloat + wait_util: Optional[Literal[ + 'commit', + 'domcontentloaded', + 'load', + 'networkidle', + ]]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/remove_locator_handler_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/remove_locator_handler_command.py new file mode 100644 index 0000000..8f6045c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/remove_locator_handler_command.py @@ -0,0 +1,14 @@ +from playwright.async_api import Locator +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class RemoveLocatorHandlerCommand(BaseModel): + locator: Locator + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_command.py new file mode 100644 index 0000000..fff4823 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_command.py @@ -0,0 +1,17 @@ +from typing import Any, Callable, Optional, Pattern + +from playwright.async_api import Request, Route +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class RouteCommand(BaseModel): + url: StrictStr | Pattern[str] | Callable[[StrictStr], StrictBool] + handler: Callable[[Route], Any] | Callable[[Route, Request], Any] + times: Optional[StrictInt] + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_from_har_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_from_har_command.py new file mode 100644 index 0000000..65a8596 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/route_from_har_command.py @@ -0,0 +1,33 @@ +from pathlib import Path +from typing import ( + Callable, + Literal, + Optional, + Pattern, +) + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class RouteFromHarCommand(BaseModel): + har: Path | StrictStr + url: Optional[ + StrictStr | + Pattern[str] | + Callable[[StrictStr], StrictBool] + ]=None + not_found: Literal['abort', 'fallback'] = 'abort' + update: Optional[StrictBool] = None + update_content: Optional[ + Literal['attach', 'embed'] + ] = None + update_mode: Optional[ + Literal['full', 'minimal'] + ] = None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/screenshot_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/screenshot_command.py new file mode 100644 index 0000000..e6863ae --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/screenshot_command.py @@ -0,0 +1,30 @@ +from pathlib import Path +from typing import Literal, Optional, Sequence + +from playwright.async_api import FloatRect, Locator +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class ScreenshotCommand(BaseModel): + path: StrictStr | Path + image_type: Literal['jpeg', 'png']='png' + quality: Optional[StrictInt]= None + omit_background: Optional[StrictBool] = None + full_page: Optional[StrictBool] = None + clip: Optional[FloatRect] = None + animations: Literal['allow', 'disabled'] = 'allow' + caret: Literal['hide', 'initial'] = 'hide' + scale: Literal['css', 'device'] = 'device' + mask: Optional[Sequence[Locator]] = None + mask_color: Optional[StrictStr] = None + style: Optional[StrictStr] = None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/select_option_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/select_option_command.py new file mode 100644 index 0000000..736fd8f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/select_option_command.py @@ -0,0 +1,28 @@ +from typing import ( + Optional, + Sequence, +) + +from playwright.async_api import ElementHandle +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class SelectOptionCommand(BaseModel): + selector: StrictStr + value: Optional[StrictStr | Sequence[StrictStr]]=None + index: Optional[StrictInt | Sequence[StrictInt]]=None + label: Optional[StrictStr | Sequence[StrictStr]]=None + element: Optional[ElementHandle | Sequence[ElementHandle]]=None + no_wait_after: Optional[StrictBool]=None + force: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_checked_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_checked_command.py new file mode 100644 index 0000000..7fb1d2a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_checked_command.py @@ -0,0 +1,21 @@ +from typing import Optional + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class SetCheckedCommand(BaseModel): + selector: StrictStr + checked: StrictBool + position: Optional[Position]=None + force: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + strict: Optional[StrictBool]=None + trial: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_content_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_content_command.py new file mode 100644 index 0000000..a9ae182 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_content_command.py @@ -0,0 +1,21 @@ +from typing import Literal, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class SetContentCommand(BaseModel): + html: StrictStr + wait_until: Optional[ + Literal[ + 'commit', + 'domcontentloaded', + 'load', + 'networkidle' + ] + ]=None + timeout: StrictInt | StrictFloat diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_extra_http_headers_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_extra_http_headers_command.py new file mode 100644 index 0000000..8ec4178 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_extra_http_headers_command.py @@ -0,0 +1,8 @@ +from typing import Dict + +from pydantic import BaseModel, StrictFloat, StrictInt, StrictStr + + +class SetExtraHTTPHeadersCommand(BaseModel): + headers: Dict[StrictStr, StrictStr] + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_input_files_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_input_files_command.py new file mode 100644 index 0000000..896cfab --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_input_files_command.py @@ -0,0 +1,20 @@ +from pathlib import Path +from typing import Optional, Sequence + +from playwright.async_api import FilePayload +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class SetInputFilesCommand(BaseModel): + selector: StrictStr + files: StrictStr | Path | FilePayload | Sequence[StrictStr | Path] | Sequence[FilePayload] + strict: Optional[StrictBool]=None + no_wait_after: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_timeout_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_timeout_command.py new file mode 100644 index 0000000..1da9d0c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_timeout_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class SetTimeoutCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_viewport_size_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_viewport_size_command.py new file mode 100644 index 0000000..9c07cba --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/set_viewport_size_command.py @@ -0,0 +1,11 @@ +from playwright.async_api import ViewportSize +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class SetViewportSize(BaseModel): + viewport_size: ViewportSize + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/tap_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/tap_command.py new file mode 100644 index 0000000..b1e38cb --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/tap_command.py @@ -0,0 +1,31 @@ +from typing import Literal, Optional, Sequence + +from playwright.async_api import Position +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class TapCommand(BaseModel): + selector: StrictStr + modifiers: Optional[ + Sequence[ + Literal[ + 'Alt', + 'Control', + 'ControlOrMeta', + 'Meta', + 'Shift' + ] + ] + ] = None + position: Optional[Position] = None + force: Optional[StrictBool] = None + no_wait_after: Optional[StrictBool] = None + strict: Optional[StrictBool] = None + trial: Optional[StrictBool] = None + timeout: Optional[StrictInt | StrictFloat]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/title_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/title_command.py new file mode 100644 index 0000000..f93fea8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/title_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class TitleCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/type_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/type_command.py new file mode 100644 index 0000000..d59510b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/type_command.py @@ -0,0 +1,19 @@ +from typing import Optional + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class TypeCommand(BaseModel): + selector: StrictStr + text: StrictStr + delay: Optional[StrictInt | StrictFloat]=None + no_wait_after: Optional[StrictInt | StrictFloat]=None + strict: Optional[StrictBool]=None + timeout: StrictInt | StrictFloat + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_function_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_function_command.py new file mode 100644 index 0000000..daf9a32 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_function_command.py @@ -0,0 +1,15 @@ +from typing import Any, Literal, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, + StrictStr, +) + + +class WaitForFunctionCommand(BaseModel): + expression: StrictStr + arg: Optional[Any]=None + polling: Optional[StrictFloat | Literal['raf']]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_load_state_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_load_state_command.py new file mode 100644 index 0000000..98ee652 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_load_state_command.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional + +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class WaitForLoadStateCommand(BaseModel): + state: Optional[ + Literal[ + 'domcontentloaded', + 'load', + 'networkidle' + ] + ]=None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_selector_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_selector_command.py new file mode 100644 index 0000000..6c44399 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_selector_command.py @@ -0,0 +1,17 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, StrictBool, StrictFloat, StrictInt, StrictStr + + +class WaitForSelectorCommand(BaseModel): + selector: StrictStr + state: Optional[ + Literal[ + 'attached', + 'detached', + 'hidden', + 'visible' + ] + ] = 'visible' + strict: Optional[StrictBool] = None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_timeout_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_timeout_command.py new file mode 100644 index 0000000..71d4ea3 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_timeout_command.py @@ -0,0 +1,9 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class WaitForTimeoutCommand(BaseModel): + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_url_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_url_command.py new file mode 100644 index 0000000..067c13b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/page/wait_for_url_command.py @@ -0,0 +1,27 @@ +from typing import ( + Callable, + Literal, + Optional, + Pattern, +) + +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + + +class WaitForUrlCommand(BaseModel): + url: StrictStr | Pattern[str] | Callable[[StrictStr], StrictBool] + wait_until: Optional[ + Literal[ + 'commit', + 'domcontentloaded', + 'load', + 'networkidle' + ] + ] = None + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/__init__.py new file mode 100644 index 0000000..dabc6b8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/__init__.py @@ -0,0 +1 @@ +from .tap_command import TapCommand \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/tap_command.py b/hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/tap_command.py new file mode 100644 index 0000000..b372f8d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/commands/touchscreen/tap_command.py @@ -0,0 +1,11 @@ +from pydantic import ( + BaseModel, + StrictFloat, + StrictInt, +) + + +class TapCommand(BaseModel): + x_position: StrictInt | StrictFloat + y_position: StrictInt | StrictFloat + timeout: StrictInt | StrictFloat \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/results/__init__.py b/hyperscale/core_rewrite/engines/client/playwright/models/results/__init__.py new file mode 100644 index 0000000..20a0e3f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/results/__init__.py @@ -0,0 +1 @@ +from .playwright_result import PlaywrightResult \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/playwright/models/results/playwright_result.py b/hyperscale/core_rewrite/engines/client/playwright/models/results/playwright_result.py new file mode 100644 index 0000000..ed0dab9 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/playwright/models/results/playwright_result.py @@ -0,0 +1,32 @@ +from typing import Dict, Generic, Literal, Optional, TypeVar + +from pydantic import BaseModel, StrictFloat, StrictStr + +from hyperscale.core_rewrite.engines.client.playwright.models.browser import ( + BrowserMetadata, +) + +T = TypeVar('T') + +class PlaywrightResult(BaseModel, Generic[T]): + command: StrictStr + command_args: BaseModel + metadata: BrowserMetadata + url: StrictStr + result: T + error: Optional[StrictStr]=None + timings: Dict[ + Literal[ + 'command_start', + 'command_end' + ], + StrictFloat + ]={ + 'command_start': 0, + 'command_end': 0 + } + frame: Optional[StrictStr]=None + source: Literal['page', 'frame', 'mouse']='page' + + class Config: + arbitrary_types_allowed=True diff --git a/hyperscale/core_rewrite/engines/client/plugins_store.py b/hyperscale/core_rewrite/engines/client/plugins_store.py new file mode 100644 index 0000000..8e3227f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/plugins_store.py @@ -0,0 +1,45 @@ +from typing import Dict, Generic, Union + +from typing_extensions import TypeVarTuple, Unpack + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.experiments.mutations.types.base.mutation import Mutation + +from .store import ActionsStore + +T = TypeVarTuple('T') + + +class PluginsStore(Generic[Unpack[T]]): + + def __init__(self, metadata_string: str): + self._plugins: Dict[str, Union[Unpack[T]]] = {} + self._config: Config = None + self.actions = ActionsStore(metadata_string) + self.next_name: str = None + self.suspend: bool = False + self.metadata_string: str = metadata_string + self.clients = {} + self.mutations: Dict[str, Mutation] = {} + + def __getitem__(self, plugin_name: str) -> Union[Unpack[T]]: + + custom_plugin: Union[Unpack[T]] = self._plugins.get(plugin_name) + + if custom_plugin.initialized is False: + custom_plugin.name = plugin_name + custom_plugin.actions = self.actions + custom_plugin.initialized = True + custom_plugin.mutations.update(self.mutations) + + custom_plugin.metadata_string = self.metadata_string + custom_plugin.next_name = self.next_name + custom_plugin.suspend = self.suspend + + self._plugins[plugin_name] = custom_plugin + self.clients[plugin_name] = custom_plugin + + return custom_plugin + + def __setitem__(self, plugin_name: str, plugin: Union[Unpack[T]]) -> None: + self._plugins[plugin_name] = plugin \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/shared/__init__.py b/hyperscale/core_rewrite/engines/client/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/shared/models/__init__.py b/hyperscale/core_rewrite/engines/client/shared/models/__init__.py new file mode 100644 index 0000000..57fd429 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/__init__.py @@ -0,0 +1,13 @@ +from .cookies import Cookies as Cookies +from .metadata import Metadata as Metadata +from .request_type import RequestType as RequestType +from .socket_protocol import SocketProtocol as SocketProtocol +from .socket_type import SocketType as SocketType +from .types import ( + HTTPCookie as HTTPCookie, +) +from .types import ( + HTTPEncodableValue as HTTPEncodableValue, +) +from .url import URL as URL +from .url_metadata import URLMetadata as URLMetadata diff --git a/hyperscale/core_rewrite/engines/client/shared/models/cookies.py b/hyperscale/core_rewrite/engines/client/shared/models/cookies.py new file mode 100644 index 0000000..b8c65ed --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/cookies.py @@ -0,0 +1,82 @@ +from typing import List, Union, Dict +from .types import ( + HTTPCookie, + HTTPCookieMap, + HTTPEncodableValue +) + +def map_cookie_value(cookie_value: str) -> HTTPEncodableValue: + match cookie_value.lower(): + + case int(): + return int(cookie_value) + + case float(): + return float(cookie_value) + + case 'true': + return True + + case 'false': + return False + + case 'none': + return None + + case _: + return cookie_value + + +class Cookies: + + def __init__(self) -> None: + self._data = b'' + self._split: Union[ + List[HTTPCookie], + None + ]=None + + self._parsed: HTTPCookieMap=None + + def __getitem__(self, name: str): + return self.parsed.get(name) + + def __iter__(self): + for cookie_name, cookie_value in self.parsed.items(): + yield cookie_name, cookie_value + + def update(self, data: str): + self._data += data + + @property + def parsed(self) -> HTTPCookieMap: + + if self._split is None: + self._split = [ + cookie.split( + b'=' + ) for cookie in self._data.split(b';') + ] + + if self._parsed is None: + self._parsed: HTTPCookieMap = {} + + for cookie in self._split: + if len(cookie) == 1: + cookie_name = cookie[0] + self._parsed[cookie_name] = cookie_name + + elif len(cookie) == 2: + cookie_name, cookie_value = cookie + self._parsed[cookie_name] = map_cookie_value( + cookie_value + ) + + else: + cookie_name = cookie[0] + self._parsed[cookie_name] = map_cookie_value( + b'='.join(cookie[1:]) + ) + + + return self._parsed diff --git a/hyperscale/core_rewrite/engines/client/shared/models/ip_address_info.py b/hyperscale/core_rewrite/engines/client/shared/models/ip_address_info.py new file mode 100644 index 0000000..b38b17a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/ip_address_info.py @@ -0,0 +1,50 @@ +import socket +from typing import ( + Literal, + Optional, + Tuple, + Union +) + + +class IpAddressInfo: + + def __init__( + self, + family: Union[ + Literal[socket.AF_INET], + Literal[socket.AF_INET6] + ], + socket_type: Literal[socket.SOCK_STREAM], + protocol: Optional[int], + cannonical_name: Optional[str], + address: Union[ + Tuple[ + str, + int + ], + Tuple[ + str, + int, + int, + int + ] + ] + + ) -> None: + + if family == socket.AF_INET: + host, port = address + + else: + host, port, _, _ = address + + self.family = family + self.socket_type = socket_type + self.protocol = protocol + self.cannonical_name = cannonical_name + self.host = host + self.port = port + self.address = address + + self.is_ipv6 = family == socket.AF_INET6 \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/shared/models/metadata.py b/hyperscale/core_rewrite/engines/client/shared/models/metadata.py new file mode 100644 index 0000000..a108681 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/metadata.py @@ -0,0 +1,8 @@ +from pydantic import ( + BaseModel, + StrictStr +) +from typing import Optional + +class Metadata(BaseModel): + protocol: Optional[StrictStr] \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/shared/models/request_type.py b/hyperscale/core_rewrite/engines/client/shared/models/request_type.py new file mode 100644 index 0000000..7668e6b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/request_type.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class RequestType(Enum): + HTTP = "HTTP" + HTTP2 = "HTTP2" + HTTP3 = "HTTP3" + WEBSOCKET = "WEBSOCKET" + GRAPHQL = "GRAPHQL" + GRAPHQL_HTTP2 = "GRAPHQL_HTTP2" + GRPC = "GRPC" + PLAYWRIGHT = "PLAYWRIGHT" + UDP = "UDP" + TASK = "TASK" + CUSTOM = "CUSTOM" diff --git a/hyperscale/core_rewrite/engines/client/shared/models/socket_protocol.py b/hyperscale/core_rewrite/engines/client/shared/models/socket_protocol.py new file mode 100644 index 0000000..c45d7d5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/socket_protocol.py @@ -0,0 +1,9 @@ +import socket + + +class SocketProtocol: + DEFAULT = socket.SOCK_STREAM + HTTP2 = socket.SOCK_STREAM + HTTP3 = socket.SOCK_DGRAM + UDP = socket.SOCK_DGRAM + NONE = None diff --git a/hyperscale/core_rewrite/engines/client/shared/models/socket_type.py b/hyperscale/core_rewrite/engines/client/shared/models/socket_type.py new file mode 100644 index 0000000..960caae --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/socket_type.py @@ -0,0 +1,9 @@ +import socket + + +class SocketType: + DEFAULT = socket.AF_INET + HTTP2 = socket.AF_INET + UDP = socket.AF_INET + HTTP3 = socket.AF_INET6 + NONE = None diff --git a/hyperscale/core_rewrite/engines/client/shared/models/types.py b/hyperscale/core_rewrite/engines/client/shared/models/types.py new file mode 100644 index 0000000..105eff1 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/types.py @@ -0,0 +1,75 @@ +import socket +from typing import ( + Tuple, + Union, + Dict +) + +HTTPEncodableValue = Union[ + str, + int, + float, + bool, + None +] + +HTTPCookie = Tuple[str, HTTPEncodableValue] +HTTPCookieMap = Dict[str, HTTPEncodableValue] + + +class SocketTypes: + DEFAULT=socket.AF_INET + HTTP2=socket.AF_INET + UDP=socket.AF_INET + HTTP3=socket.AF_INET6 + NONE=None + +class SocketProtocols: + DEFAULT=socket.SOCK_STREAM + HTTP2=socket.SOCK_STREAM + HTTP3=socket.SOCK_DGRAM + UDP=socket.SOCK_DGRAM + NONE=None + + +class RequestTypes: + HTTP='HTTP' + HTTP2='HTTP2' + HTTP3='HTTP3' + WEBSOCKET='WEBSOCKET' + GRAPHQL='GRAPHQL' + GRAPHQL_HTTP2="GRAPHQL_HTTP2" + GRPC='GRPC' + PLAYWRIGHT='PLAYWRIGHT' + UDP='UDP' + TASK='TASK' + CUSTOM='CUSTOM' + + +class ProtocolMap: + + def __init__(self) -> None: + self.address_families = { + RequestTypes.HTTP: SocketTypes.DEFAULT, + RequestTypes.HTTP2: SocketTypes.HTTP2, + RequestTypes.HTTP3: SocketTypes.HTTP3, + RequestTypes.WEBSOCKET: SocketTypes.DEFAULT, + RequestTypes.GRAPHQL: SocketTypes.DEFAULT, + RequestTypes.GRAPHQL_HTTP2: SocketTypes.HTTP2, + RequestTypes.GRPC: SocketTypes.HTTP2, + RequestTypes.UDP: SocketTypes.UDP + } + + self.protocols = { + RequestTypes.HTTP: SocketProtocols.DEFAULT, + RequestTypes.HTTP2: SocketProtocols.HTTP2, + RequestTypes.HTTP3: SocketProtocols.HTTP3, + RequestTypes.WEBSOCKET: SocketProtocols.DEFAULT, + RequestTypes.GRAPHQL: SocketProtocols.DEFAULT, + RequestTypes.GRAPHQL_HTTP2: SocketTypes.HTTP2, + RequestTypes.GRPC: SocketProtocols.HTTP2, + RequestTypes.UDP: SocketProtocols.UDP + } + + def __getitem__(self, key: RequestTypes) -> SocketTypes: + return self.address_families.get(key), self.protocols.get(key) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/shared/models/url.py b/hyperscale/core_rewrite/engines/client/shared/models/url.py new file mode 100644 index 0000000..26201fe --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/url.py @@ -0,0 +1,211 @@ +import socket +from asyncio.events import get_event_loop +from ipaddress import IPv4Address, ip_address +from typing import List, Tuple, Union +from urllib.parse import urlparse + +import aiodns + +from .ip_address_info import IpAddressInfo +from .types import SocketProtocols, SocketTypes + + +class URL: + __slots__ = ( + "resolver", + "ip_addr", + "parsed", + "is_ssl", + "port", + "full", + "has_ip_addr", + "socket_config", + "family", + "protocol", + "loop", + "ip_addresses", + "address", + ) + + def __init__( + self, + url: str, + port: int = 80, + family: SocketTypes = SocketTypes.DEFAULT, + protocol: SocketProtocols = SocketProtocols.DEFAULT, + ) -> None: + self.resolver = aiodns.DNSResolver() + self.parsed = urlparse(url) + self.is_ssl = "https" in url or "wss" in url + + if self.is_ssl: + port = 443 + + self.port = self.parsed.port if self.parsed.port else port + self.full = url + self.has_ip_addr = False + self.family = family + self.protocol = protocol + self.loop = None + self.ip_addresses: List[Tuple[str, IpAddressInfo]] = [] + self.address: Union[str, None] = None + self.socket_config: Union[Tuple[str, int], Tuple[str, int, int, int], None] = ( + None + ) + + async def replace(self, url: str): + self.full = url + self.params = urlparse(url) + + def __iter__(self): + for ip_info in self.ip_addresses: + yield ip_info + + def update( + self, + url: str, + port: int = 80, + family: SocketTypes = SocketTypes.DEFAULT, + protocol: SocketProtocols = SocketProtocols.DEFAULT, + ): + self.parsed = urlparse(url) + self.is_ssl = "https" in url or "wss" in url + + if self.is_ssl: + port = 443 + + self.port = self.parsed.port if self.parsed.port else port + self.full = url + self.has_ip_addr = False + self.socket_config: Union[Tuple[str, int], Tuple[str, int, int, int], None] = ( + None + ) + self.family = family + self.protocol = protocol + self.loop = None + self.ip_addresses: List[Tuple[Tuple[str, int], IpAddressInfo]] = [] + self.address: Union[str, None] = None + + async def lookup(self): + if self.loop is None: + self.loop = get_event_loop() + + if self.parsed.hostname is None: + try: + address = self.full.split(":") + assert len(address) == 2 + + host, port = address + self.port = int(port) + + if isinstance(ip_address(host), IPv4Address): + socket_family = socket.AF_INET + + else: + socket_family = socket.AF_INET6 + + address_info = (host, self.port) + + self.ip_addresses = [ + ( + address_info, + ( + socket_family, + socket.SOCK_STREAM, + 0, + "", + address_info, + ), + ) + ] + + except Exception as parse_error: + raise parse_error + + else: + resolved = await self.resolver.gethostbyname( + self.parsed.hostname, self.family + ) + + for address in resolved.addresses: + if isinstance(ip_address(address), IPv4Address): + socket_family = socket.AF_INET + address_info = (address, self.port) + + else: + socket_family = socket.AF_INET6 + address_info = (address, self.port, 0, 0) + + self.ip_addresses.append( + ( + address, + ( + socket_family, + socket.SOCK_STREAM, + 0, + "", + address_info, + ), + ) + ) + + @property + def params(self): + return self.parsed.params + + @params.setter + def params(self, value: str): + self.parsed = self.parsed._replace(params=value) + + @property + def scheme(self): + return self.parsed.scheme + + @scheme.setter + def scheme(self, value): + self.parsed = self.parsed._replace(scheme=value) + + @property + def hostname(self): + return self.parsed.hostname + + @hostname.setter + def hostname(self, value): + self.parsed = self.parsed._replace(hostname=value) + + @property + def path(self): + url_path = self.parsed.path + url_query = self.parsed.query + url_params = self.parsed.params + + if not url_path or len(url_path) == 0: + url_path = "/" + + if url_query and len(url_query) > 0: + url_path += f"?{self.parsed.query}" + + elif url_params and len(url_params) > 0: + url_path += self.parsed.params + + return url_path + + @path.setter + def path(self, value): + self.parsed = self.parsed._replace(path=value) + + @property + def query(self): + return self.parsed.query + + @query.setter + def query(self, value): + self.parsed = self.parsed._replace(query=value) + + @property + def authority(self): + return self.parsed.hostname + + @authority.setter + def authority(self, value): + self.parsed = self.parsed._replace(hostname=value) diff --git a/hyperscale/core_rewrite/engines/client/shared/models/url_metadata.py b/hyperscale/core_rewrite/engines/client/shared/models/url_metadata.py new file mode 100644 index 0000000..8e4191c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/models/url_metadata.py @@ -0,0 +1,10 @@ +from typing import Optional + +from pydantic import BaseModel, StrictStr + + +class URLMetadata(BaseModel): + host: StrictStr + path: StrictStr + params: Optional[StrictStr]=None + query: Optional[StrictStr]=None \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/shared/protocols/__init__.py b/hyperscale/core_rewrite/engines/client/shared/protocols/__init__.py new file mode 100644 index 0000000..616313a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/protocols/__init__.py @@ -0,0 +1,12 @@ +from .constants import _DEFAULT_LIMIT as _DEFAULT_LIMIT +from .constants import HEADER_LENGTH_INDEX as HEADER_LENGTH_INDEX +from .constants import HTTP2_LIMIT as HTTP2_LIMIT +from .constants import MAX_WINDOW_SIZE as MAX_WINDOW_SIZE +from .constants import NEW_LINE as NEW_LINE +from .constants import NEXT_WINDOW_SIZE as NEXT_WINDOW_SIZE +from .constants import READ_NUM_BYTES as READ_NUM_BYTES +from .constants import WEBSOCKETS_VERSION as WEBSOCKETS_VERSION +from .flow_control_mixin import FlowControlMixin as FlowControlMixin +from .protocol_map import ProtocolMap as ProtocolMap +from .reader import Reader as Reader +from .writer import Writer as Writer diff --git a/hyperscale/core_rewrite/engines/client/shared/protocols/constants.py b/hyperscale/core_rewrite/engines/client/shared/protocols/constants.py new file mode 100644 index 0000000..b7f0877 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/protocols/constants.py @@ -0,0 +1,9 @@ +_DEFAULT_LIMIT = 65536 +HTTP2_LIMIT = _DEFAULT_LIMIT * 1024 +NEW_LINE = "\r\n" +WEBSOCKETS_VERSION = 13 +HEADER_LENGTH_INDEX = 6 + +READ_NUM_BYTES = 64 * 1024 +MAX_WINDOW_SIZE = (2**24) - 1 +NEXT_WINDOW_SIZE = 2**24 diff --git a/hyperscale/core_rewrite/engines/client/shared/protocols/flow_control_mixin.py b/hyperscale/core_rewrite/engines/client/shared/protocols/flow_control_mixin.py new file mode 100644 index 0000000..56d1f61 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/protocols/flow_control_mixin.py @@ -0,0 +1,71 @@ +from asyncio import Future, Protocol, get_event_loop + + +class FlowControlMixin(Protocol): + """Reusable flow control logic for StreamWriter.drain(). + This implements the protocol methods pause_writing(), + resume_writing() and connection_lost(). If the subclass overrides + these it must call the super methods. + StreamWriter.drain() must wait for _drain_helper() coroutine. + """ + __slots__ = ( + '_loop', + '_paused', + '_drain_waiter', + '_connection_lost' + ) + + def __init__(self, loop=None): + + if loop: + self._loop = loop + else: + self._loop = get_event_loop() + + self._paused = False + self._drain_waiter: Future = None + self._connection_lost = False + + def pause_writing(self): + assert not self._paused + self._paused = True + + def resume_writing(self): + assert self._paused + self._paused = False + + waiter = self._drain_waiter + if waiter is not None: + self._drain_waiter = None + if not waiter.done(): + waiter.set_result(None) + + def connection_lost(self, exc): + self._connection_lost = True + # Wake up the writer if currently paused. + if not self._paused: + return + waiter = self._drain_waiter + if waiter is None: + return + self._drain_waiter = None + if waiter.done(): + return + if exc is None: + waiter.set_result(None) + else: + waiter.set_exception(exc) + + async def _drain_helper(self): + if self._connection_lost: + raise ConnectionResetError('Connection lost') + if not self._paused: + return + waiter = self._drain_waiter + assert waiter is None or waiter.cancelled() + waiter = self._loop.create_future() + self._drain_waiter = waiter + await waiter + + def _get_close_waiter(self, stream): + raise NotImplementedError \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/shared/protocols/protocol_map.py b/hyperscale/core_rewrite/engines/client/shared/protocols/protocol_map.py new file mode 100644 index 0000000..34a5ad6 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/protocols/protocol_map.py @@ -0,0 +1,35 @@ +from hyperscale.core_rewrite.engines.client.shared.models import ( + RequestType, + SocketProtocol, + SocketType, +) + + +class ProtocolMap: + def __init__(self) -> None: + self.address_families = { + RequestType.HTTP: SocketType.DEFAULT, + RequestType.HTTP2: SocketType.HTTP2, + RequestType.HTTP3: SocketType.HTTP3, + RequestType.WEBSOCKET: SocketType.DEFAULT, + RequestType.GRAPHQL: SocketType.DEFAULT, + RequestType.GRAPHQL_HTTP2: SocketType.HTTP2, + RequestType.GRPC: SocketType.HTTP2, + RequestType.UDP: SocketType.UDP, + RequestType.PLAYWRIGHT: SocketType.NONE, + } + + self.protocols = { + RequestType.HTTP: SocketProtocol.DEFAULT, + RequestType.HTTP2: SocketProtocol.HTTP2, + RequestType.HTTP3: SocketProtocol.HTTP3, + RequestType.WEBSOCKET: SocketProtocol.DEFAULT, + RequestType.GRAPHQL: SocketProtocol.DEFAULT, + RequestType.GRAPHQL_HTTP2: SocketType.HTTP2, + RequestType.GRPC: SocketProtocol.HTTP2, + RequestType.UDP: SocketProtocol.UDP, + RequestType.PLAYWRIGHT: SocketProtocol.NONE, + } + + def __getitem__(self, key: RequestType) -> SocketType: + return self.address_families.get(key), self.protocols.get(key) diff --git a/hyperscale/core_rewrite/engines/client/shared/protocols/reader.py b/hyperscale/core_rewrite/engines/client/shared/protocols/reader.py new file mode 100644 index 0000000..b10c757 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/protocols/reader.py @@ -0,0 +1,484 @@ +import asyncio +from asyncio import AbstractEventLoop, Future, Transport, get_event_loop +from asyncio.exceptions import LimitOverrunError + +from .constants import _DEFAULT_LIMIT + + +class Reader: + + _source_traceback = None + __slots__ = ( + '_limit', + '_loop', + '_buffer', + '_eof', + '_waiter', + '_exception', + '_transport', + '_paused', + '__weakref__' + ) + + def __init__(self, limit=_DEFAULT_LIMIT, loop=None): + # The line length limit is a security feature; + # it also doubles as half the buffer limit. + + if limit <= 0: + raise ValueError('Limit cannot be <= 0') + + self._limit = limit + if loop is None: + self._loop = get_event_loop() + else: + self._loop: AbstractEventLoop = loop + + self._buffer = bytearray() + self._eof = False # Whether we're done. + self._waiter: Future = None # A future used by _wait_for_data() + self._exception = None + self._transport: Transport = None + self._paused = False + + def exception(self): + return self._exception + + def set_exception(self, exc): + self._exception = exc + + waiter = self._waiter + if waiter is not None: + self._waiter = None + if not waiter.cancelled(): + waiter.set_exception(exc) + + def set_transport(self, transport): + # assert self._transport is None, 'Transport already set' + self._transport = transport + + def _maybe_resume_transport(self): + if self._paused and len(self._buffer) <= self._limit: + self._paused = False + self._transport.resume_reading() + + def feed_eof(self): + self._eof = True + + waiter = self._waiter + if waiter is not None: + self._waiter = None + if not waiter.cancelled(): + waiter.set_result(None) + + def at_eof(self): + """Return True if the buffer is empty and 'feed_eof' was called.""" + return self._eof and not self._buffer + + def feed_data(self, data): + assert not self._eof, 'feed_data after feed_eof' + + if not data: + return + + self._buffer.extend(data) + + waiter = self._waiter + if waiter is not None: + self._waiter = None + if not waiter.cancelled(): + waiter.set_result(None) + + if self._transport and self._paused == False and len(self._buffer) > 2 * self._limit: + try: + self._transport.pause_reading() + except NotImplementedError: + # The transport can't be paused. + # We'll just have to buffer all data. + # Forget the transport so we don't keep trying. + self._transport = None + else: + self._paused = True + + async def _wait_for_data(self, func_name): + """Wait until feed_data() or feed_eof() is called. + If stream was paused, automatically resume it. + """ + # StreamReader uses a future to link the protocol feed_data() method + # to a read coroutine. Running two read coroutines at the same time + # would have an unexpected behaviour. It would not possible to know + # which coroutine would get the next data. + if self._waiter: + raise RuntimeError( + f'{func_name}() called while another coroutine is ' + f'already waiting for incoming data' + ) + + assert not self._eof, '_wait_for_data after EOF' + + # Waiting for data while paused will make deadlock, so prevent it. + # This is essential for readexactly(n) for case when n > self._limit. + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + async def read(self, n=-1): + + if not self._buffer and not self._eof: + await self._wait_for_data('read') + + data = bytes(self._buffer[:n]) + del self._buffer[:n] + + self._maybe_resume_transport() + return data + + async def readline_fast(self, sep=b'\n'): + seplen = len(sep) + if self._exception is not None: + raise self._exception + + if not self._buffer: + + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + isep = self._buffer.find(sep) + if isep < 0: + chunk = bytes(self._buffer) + self._buffer.clear() + return chunk + + + chunk = self._buffer[:isep + seplen] + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + return bytes(chunk) + + async def readline(self): + sep = b'\n' + seplen = len(sep) + try: + line = await self.readuntil(sep) + except asyncio.IncompleteReadError as e: + return e.partial + except asyncio.LimitOverrunError as e: + if self._buffer.startswith(sep, e.consumed): + del self._buffer[:e.consumed + seplen] + else: + self._buffer.clear() + self._maybe_resume_transport() + raise ValueError(e.args[0]) + return line + + async def readuntil(self, separator=b'\n'): + """Read data from the stream until ``separator`` is found. + On success, the data and separator will be removed from the + internal buffer (consumed). Returned data will include the + separator at the end. + Configured stream limit is used to check result. Limit sets the + maximal length of data that can be returned, not counting the + separator. + If an EOF occurs and the complete separator is still not found, + an IncompleteReadError exception will be raised, and the internal + buffer will be reset. The IncompleteReadError.partial attribute + may contain the separator partially. + If the data cannot be read because of over limit, a + LimitOverrunError exception will be raised, and the data + will be left in the internal buffer, so it can be read again. + """ + seplen = len(separator) + + if self._exception is not None: + raise self._exception + + # Consume whole buffer except last bytes, which length is + # one less than seplen. Let's check corner cases with + # separator='SEPARATOR': + # * we have received almost complete separator (without last + # byte). i.e buffer='some textSEPARATO'. In this case we + # can safely consume len(separator) - 1 bytes. + # * last byte of buffer is first byte of separator, i.e. + # buffer='abcdefghijklmnopqrS'. We may safely consume + # everything except that last byte, but this require to + # analyze bytes of buffer that match partial separator. + # This is slow and/or require FSM. For this case our + # implementation is not optimal, since require rescanning + # of data that is known to not belong to separator. In + # real world, separator will not be so long to notice + # performance problems. Even when reading MIME-encoded + # messages :) + + # `offset` is the number of bytes from the beginning of the buffer + # where there is no occurrence of `separator`. + offset = 0 + + # Loop until we find `separator` in the buffer, exceed the buffer size, + # or an EOF has happened. + while True: + buflen = len(self._buffer) + + # Check if we now have enough data in the buffer for `separator` to + # fit. + if buflen - offset >= seplen: + isep = self._buffer.find(separator, offset) + + if isep != -1: + # `separator` is in the buffer. `isep` will be used later + # to retrieve the data. + break + + # see upper comment for explanation. + offset = buflen + 1 - seplen + if offset > self._limit: + raise LimitOverrunError( + 'Separator is not found, and chunk exceed the limit', + offset) + + # Complete message (with full separator) may be present in buffer + # even when EOF flag is set. This may happen when the last chunk + # adds data which makes separator be found. That's why we check for + # EOF *ater* inspecting the buffer. + if self._eof: + chunk = bytes(self._buffer) + self._buffer.clear() + raise Exception('Connection closed.') + + # _wait_for_data() will resume reading if stream was paused. + if not self._buffer: + await self._wait_for_data('readuntil') + + if isep > self._limit: + raise LimitOverrunError( + 'Separator is found, but chunk is longer than limit', isep) + + chunk = self._buffer[:isep + seplen] + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + return bytes(chunk) + + async def read_headers(self, separator=b'\n'): + """Read data from the stream until ``separator`` is found. + On success, the data and separator will be removed from the + internal buffer (consumed). Returned data will include the + separator at the end. + Configured stream limit is used to check result. Limit sets the + maximal length of data that can be returned, not counting the + separator. + If an EOF occurs and the complete separator is still not found, + an IncompleteReadError exception will be raised, and the internal + buffer will be reset. The IncompleteReadError.partial attribute + may contain the separator partially. + If the data cannot be read because of over limit, a + LimitOverrunError exception will be raised, and the data + will be left in the internal buffer, so it can be read again. + """ + headers = {} + seplen = len(separator) + + while True: + + if self._exception is not None: + raise self._exception + + if not self._buffer: + + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + isep = self._buffer.find(separator) + if isep < 0: + chunk = bytes(self._buffer) + self._buffer.clear() + + else: + chunk = bytes(self._buffer[:isep + seplen]) + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + + if b':' not in chunk: + break + + decoded = chunk.strip().split(b':',1) + + key, value = decoded + headers[bytes(key).lower()] = value.strip() + + return headers + + async def iter_headers(self, separator=b'\n'): + """Read data from the stream until ``separator`` is found. + On success, the data and separator will be removed from the + internal buffer (consumed). Returned data will include the + separator at the end. + Configured stream limit is used to check result. Limit sets the + maximal length of data that can be returned, not counting the + separator. + If an EOF occurs and the complete separator is still not found, + an IncompleteReadError exception will be raised, and the internal + buffer will be reset. The IncompleteReadError.partial attribute + may contain the separator partially. + If the data cannot be read because of over limit, a + LimitOverrunError exception will be raised, and the data + will be left in the internal buffer, so it can be read again. + """ + seplen = len(separator) + + while True: + + if self._exception is not None: + raise self._exception + + if not self._buffer: + + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + isep = self._buffer.find(separator) + if isep < 0: + chunk = bytes(self._buffer) + self._buffer.clear() + + else: + chunk = bytes(self._buffer[:isep + seplen]) + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + + if b':' not in chunk: + break + + decoded = chunk.strip().split(b':',1) + + key, value = decoded + yield key.lower(), value, chunk + + async def readexactly(self, n): + """Read exactly `n` bytes. + + Raise an IncompleteReadError if EOF is reached before `n` bytes can be + read. The IncompleteReadError.partial attribute of the exception will + contain the partial read bytes. + + if n is zero, return empty bytes object. + + Returned value is not limited with limit, configured at stream + creation. + + If stream was paused, this function will automatically resume it if + needed. + """ + if n < 0: + raise ValueError('readexactly size can not be less than zero') + + if self._exception is not None: + raise self._exception + + if n == 0: + return b'' + + while len(self._buffer) < n: + if self._eof: + incomplete = bytes(self._buffer) + self._buffer.clear() + raise asyncio.IncompleteReadError(incomplete, n) + + await self._wait_for_data('readexactly') + + if len(self._buffer) == n: + data = bytes(self._buffer) + self._buffer.clear() + else: + data = bytes(memoryview(self._buffer)[:n]) + del self._buffer[:n] + self._maybe_resume_transport() + return data + + async def iter_headers(self, separator=b'\n'): + """Read data from the stream until ``separator`` is found. + On success, the data and separator will be removed from the + internal buffer (consumed). Returned data will include the + separator at the end. + Configured stream limit is used to check result. Limit sets the + maximal length of data that can be returned, not counting the + separator. + If an EOF occurs and the complete separator is still not found, + an IncompleteReadError exception will be raised, and the internal + buffer will be reset. The IncompleteReadError.partial attribute + may contain the separator partially. + If the data cannot be read because of over limit, a + LimitOverrunError exception will be raised, and the data + will be left in the internal buffer, so it can be read again. + """ + headers = {} + seplen = len(separator) + + while True: + + if self._exception is not None: + raise self._exception + + if not self._buffer: + + if self._paused: + self._paused = False + self._transport.resume_reading() + + self._waiter = self._loop.create_future() + try: + await self._waiter + finally: + self._waiter = None + + isep = self._buffer.find(separator) + if isep < 0: + chunk = bytes(self._buffer) + self._buffer.clear() + + else: + chunk = bytes(self._buffer[:isep + seplen]) + del self._buffer[:isep + seplen] + self._maybe_resume_transport() + + if b':' not in chunk: + break + + decoded = chunk.strip().split(b':',1) + + key, value = decoded + yield key.lower(), value, chunk + + async def __aiter__(self): + line = await self.readuntil() + yield line + + async def __anext__(self): + val = await self.readuntil() + if val == b'': + raise StopAsyncIteration + return val diff --git a/hyperscale/core_rewrite/engines/client/shared/protocols/writer.py b/hyperscale/core_rewrite/engines/client/shared/protocols/writer.py new file mode 100644 index 0000000..522c5eb --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/protocols/writer.py @@ -0,0 +1,112 @@ +import asyncio +from asyncio import ( + Transport, + DatagramTransport, + Protocol, + sleep +) + + +class Writer: + __slots__ = ( + '_transport', + '_protocol', + '_reader', + '_loop', + '_complete_fut' + ) + """Wraps a Transport. + This exposes write(), writelines(), [can_]write_eof(), + get_extra_info() and close(). It adds drain() which returns an + optional Future on which you can wait for flow control. It also + adds a transport property which references the Transport + directly. + """ + + def __init__(self, transport, protocol, reader, loop): + self._transport: Transport | DatagramTransport = transport + self._protocol: Protocol = protocol + # drain() expects that the reader has an exception() method + self._reader = reader + self._loop: asyncio.AbstractEventLoop = loop + self._complete_fut = self._loop.create_future() + self._complete_fut.set_result(None) + + def __repr__(self): + info = [self.__class__.__name__, f'transport={self._transport!r}'] + if self._reader is not None: + info.append(f'reader={self._reader!r}') + return '<{}>'.format(' '.join(info)) + + @property + def transport(self): + return self._transport + + def write(self, data): + self._transport.write(data) + + def send(self, data): + self._transport.sendto(data) + + def writelines(self, data): + self._transport.writelines(data) + + def write_eof(self): + return self._transport.write_eof() + + def can_write_eof(self): + return self._transport.can_write_eof() + + def close(self): + return self._transport.close() + + def is_closing(self): + return self._transport.is_closing() + + async def wait_closed(self): + await self._protocol._get_close_waiter(self) + + def get_extra_info(self, name, default=None): + return self._transport.get_extra_info(name, default) + + async def drain(self): + """Flush the write buffer. + The intended use is to write + w.write(data) + await w.drain() + """ + if self._reader is not None: + exc = self._reader.exception() + if exc is not None: + raise exc + if self._transport.is_closing(): + # Wait for protocol.connection_lost() call + # Raise connection closing error if any, + # ConnectionResetError otherwise + # Yield to the event loop so connection_lost() may be + # called. Without this, _drain_helper() would return + # immediately, and code that calls + # write(...); await drain() + # in a loop would never call connection_lost(), so it + # would not see an error when the socket is closed. + await sleep(0) + await self._protocol._drain_helper() + + async def start_tls(self, sslcontext, *, server_hostname=None, ssl_handshake_timeout=None): + + server_side = self._protocol._client_connected_cb is not None + protocol = self._protocol + + await self.drain() + + new_transport = await self._loop.start_tls( # type: ignore + self._transport, + protocol, + sslcontext, + server_side=server_side, + server_hostname=server_hostname, + ssl_handshake_timeout=ssl_handshake_timeout + ) + + self._transport = new_transport + protocol._replace_writer(self) \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/shared/request_types_map.py b/hyperscale/core_rewrite/engines/client/shared/request_types_map.py new file mode 100644 index 0000000..4ca5c41 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/request_types_map.py @@ -0,0 +1,64 @@ +from typing import Dict, Literal + +from .models import RequestType + + +class RequestTypesMap: + def __init__(self) -> None: + self._types: Dict[ + Literal[ + "graphql", + "graphqlh2", + "grpc", + "http", + "http2", + "http3", + "playwright", + "udp", + "websocket", + ], + Literal[ + RequestType.GRAPHQL, + RequestType.GRAPHQL_HTTP2, + RequestType.HTTP, + RequestType.HTTP2, + RequestType.HTTP3, + RequestType.PLAYWRIGHT, + RequestType.UDP, + RequestType.WEBSOCKET, + ], + ] = { + "graphql": RequestType.GRAPHQL, + "graphqlh2": RequestType.GRAPHQL_HTTP2, + "grpc": RequestType.GRPC, + "http": RequestType.HTTP, + "http2": RequestType.HTTP2, + "http3": RequestType.HTTP3, + "playwright": RequestType.PLAYWRIGHT, + "udp": RequestType.UDP, + "websocket": RequestType.WEBSOCKET, + } + + def __getitem__( + self, + key: Literal[ + "graphql", + "graphqlh2", + "http", + "http2", + "http3", + "playwright", + "udp", + "websocket", + ], + ) -> Literal[ + RequestType.GRAPHQL, + RequestType.GRAPHQL_HTTP2, + RequestType.HTTP, + RequestType.HTTP2, + RequestType.HTTP3, + RequestType.PLAYWRIGHT, + RequestType.UDP, + RequestType.WEBSOCKET, + ]: + return self._types[key] diff --git a/hyperscale/core_rewrite/engines/client/shared/timeouts.py b/hyperscale/core_rewrite/engines/client/shared/timeouts.py new file mode 100644 index 0000000..0ba9d2b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/shared/timeouts.py @@ -0,0 +1,33 @@ +class Timeouts: + + __slots__ = ( + 'connect_timeout', + 'read_timeout', + 'write_timeout', + 'request_timeout', + 'total_time' + ) + + def __init__( + self, + connect_timeout: int=10, + read_timeout: int=5, + write_timeout: int=5, + request_timeout: int=60, + total_time: int=60 + ) -> None: + + self.connect_timeout = connect_timeout + self.read_timeout = read_timeout + self.write_timeout = write_timeout + self.request_timeout = request_timeout + self.total_time = total_time + + if self.request_timeout > self.total_time: + self.request_timeout = self.total_time + + sum_timeout = connect_timeout + read_timeout + write_timeout + + if sum_timeout > self.request_timeout: + raise Exception('Err. total connect, read, and write timeout cannot exceed request timeout.') + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/store.py b/hyperscale/core_rewrite/engines/client/store.py new file mode 100644 index 0000000..996e012 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/store.py @@ -0,0 +1,69 @@ +import asyncio +from collections import defaultdict +from typing import Any, Tuple, Union + +from hyperscale.core.engines.types import ( + MercuryGraphQLClient, + MercuryGraphQLHTTP2Client, + MercuryGRPCClient, + MercuryHTTP2Client, + MercuryHTTPClient, + MercuryPlaywrightClient, + MercuryUDPClient, + MercuryWebsocketClient, +) +from hyperscale.core.engines.types.common.base_action import BaseAction + + +class ActionsStore: + + def __init__(self, metadata_string: str) -> None: + self.metadata_string = metadata_string + self.actions = defaultdict(dict) + self.sessions = defaultdict(dict) + self._loop = None + self.current_stage: str = None + self.waiter = None + self.setup_call = None + self.metadata_string: str = None + # self.logger = HyperscaleLogger() + # self.logger.initialize() + + def set_waiter(self, stage: str): + + if self._loop is None: + self._loop = asyncio.get_event_loop() + + self.waiter = self._loop.create_future() + self.current_stage = stage + + async def wait_for_ready(self, setup_call): + # await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Action Store waiting for Action or Task to notify store it is ready') + self.setup_call = setup_call + await self.waiter + + # await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Action Store was notified and is exiting suspension') + + def store(self, request: str, action: Any, session: Any): + + self.actions[self.current_stage][request] = action + self.sessions[self.current_stage][request] = session + + try: + self.waiter.set_result(None) + except asyncio.exceptions.CancelledError: + pass + + except asyncio.exceptions.InvalidStateError: + pass + + def get(self, stage: str, action_name: str) -> Tuple[BaseAction, Union[MercuryGraphQLClient, MercuryGraphQLHTTP2Client, MercuryGRPCClient, MercuryHTTP2Client, MercuryHTTPClient, MercuryPlaywrightClient, MercuryWebsocketClient, MercuryUDPClient]]: + action = self.actions.get( + stage + ).get(action_name) + + session = self.sessions.get( + stage + ).get(action_name) + + return action, session diff --git a/hyperscale/core_rewrite/engines/client/time_parser.py b/hyperscale/core_rewrite/engines/client/time_parser.py new file mode 100644 index 0000000..27a79c1 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/time_parser.py @@ -0,0 +1,25 @@ +import re +from datetime import timedelta + +class TimeParser: + + def __init__(self, time_amount: str) -> None: + self.UNITS = {'s':'seconds', 'm':'minutes', 'h':'hours', 'd':'days', 'w':'weeks'} + self.time = int( + timedelta( + **{ + self.UNITS.get( + m.group( + 'unit' + ).lower(), + 'seconds' + ): float(m.group('val') + ) + for m in re.finditer( + r'(?P\d+(\.\d+)?)(?P[smhdw]?)', + time_amount, + flags=re.I + ) + } + ).total_seconds() + ) diff --git a/hyperscale/core_rewrite/engines/client/tracing_config.py b/hyperscale/core_rewrite/engines/client/tracing_config.py new file mode 100644 index 0000000..e3c326c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/tracing_config.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Dict, Optional, Union + +from hyperscale.core.engines.types.tracing.tracing_types import ( + RequestHook, + ResponseHook, + TraceSignal, + UrlFilter, +) +from hyperscale.core.engines.types.tracing.url_filters import ( + default_params_strip_filter, +) + +OpenTelemetryTracingConfig = Union[ + UrlFilter, + RequestHook, + ResponseHook, + TraceSignal +] + + +class TracingConfig: + + def __init__( + self, + url_filter: Optional[UrlFilter]=None, + request_hook: Optional[RequestHook]=None, + response_hook: Optional[ResponseHook]=None, + on_request_headers_sent: Optional[TraceSignal]=None, + on_request_data_sent: Optional[TraceSignal]=None, + on_request_chunk_sent: Optional[TraceSignal]=None, + on_response_headers_received: Optional[TraceSignal]=None, + on_response_data_received: Optional[TraceSignal]=None, + on_response_chunk_received: Optional[TraceSignal]=None, + on_request_redirect: Optional[TraceSignal]=None, + on_connection_queued_start: Optional[TraceSignal]=None, + on_connection_queued_end: Optional[TraceSignal]=None, + on_connection_create_start: Optional[TraceSignal]=None, + on_connection_create_end: Optional[TraceSignal]=None, + on_connection_reuse_connection: Optional[TraceSignal]=None, + on_dns_resolve_host_start: Optional[TraceSignal]=None, + on_dns_resolve_host_end: Optional[TraceSignal]=None, + on_dns_cache_hit: Optional[TraceSignal]=None, + on_dns_cache_miss: Optional[TraceSignal]=None, + on_task_start: Optional[TraceSignal]=None, + on_task_end: Optional[TraceSignal]=None, + on_task_error: Optional[TraceSignal]=None + ) -> None: + + if url_filter is None: + url_filter = default_params_strip_filter + + self.url_filter = url_filter + self.request_hook = request_hook + self.response_hook = response_hook + + self.on_request_headers_sent: TraceSignal = on_request_headers_sent + self.on_request_data_sent: TraceSignal = on_request_data_sent + self.on_request_chunk_sent: TraceSignal = on_request_chunk_sent + self.on_request_redirect: TraceSignal = on_request_redirect + self.on_response_headers_received = on_response_headers_received + self.on_response_data_received = on_response_data_received + self.on_response_chunk_received: TraceSignal = on_response_chunk_received + + self.on_connection_queued_start: TraceSignal = on_connection_queued_start + self.on_connection_queued_end: TraceSignal = on_connection_queued_end + self.on_connection_create_start: TraceSignal = on_connection_create_start + self.on_connection_create_end: TraceSignal = on_connection_create_end + self.on_connection_reuse_connection: TraceSignal = on_connection_reuse_connection + + self.on_dns_resolve_host_start: TraceSignal = on_dns_resolve_host_start + self.on_dns_resolve_host_end: TraceSignal = on_dns_resolve_host_end + self.on_dns_cache_hit: TraceSignal = on_dns_cache_hit + self.on_dns_cache_miss: TraceSignal = on_dns_cache_miss + + self.on_task_start = on_task_start + self.on_task_end = on_task_end + self.on_task_error = on_task_error + + def copy(self) -> TracingConfig: + return TracingConfig( + url_filter=self.url_filter, + request_hook=self.request_hook, + response_hook=self.response_hook, + on_request_headers_sent=self.on_request_chunk_sent, + on_request_data_sent=self.on_request_data_sent, + on_request_chunk_sent=self.on_request_chunk_sent, + on_request_redirect=self.on_request_redirect, + on_response_headers_received=self.on_response_headers_received, + on_response_data_received=self.on_response_data_received, + on_response_chunk_received=self.on_response_chunk_received, + on_connection_queued_start=self.on_connection_queued_start, + on_connection_queued_end=self.on_connection_queued_end, + on_connection_create_start=self.on_connection_create_start, + on_connection_create_end=self.on_connection_create_end, + on_connection_reuse_connection=self.on_connection_reuse_connection, + on_dns_resolve_host_start=self.on_dns_resolve_host_start, + on_dns_resolve_host_end=self.on_dns_resolve_host_end, + on_dns_cache_hit=self.on_dns_cache_hit, + on_dns_cache_miss=self.on_dns_cache_miss, + on_task_start=self.on_task_start, + on_task_end=self.on_task_end, + on_task_error=self.on_task_error, + ) + + def to_dict(self) -> Dict[str, OpenTelemetryTracingConfig]: + return { + 'url_filter': self.url_filter, + 'request_hook': self.request_hook, + 'response_hook': self.response_hook, + 'on_request_headers_sent': self.on_request_chunk_sent, + 'on_request_data_sent': self.on_request_data_sent, + 'on_request_chunk_sent': self.on_request_chunk_sent, + 'on_request_redirect': self.on_request_redirect, + 'on_response_headers_received': self.on_response_headers_received, + 'on_response_data_received': self.on_response_data_received, + 'on_response_chunk_received': self.on_response_chunk_received, + 'on_connection_queued_start': self.on_connection_queued_start, + 'on_connection_queued_end': self.on_connection_queued_end, + 'on_connection_create_start': self.on_connection_create_start, + 'on_connection_create_end': self.on_connection_create_end, + 'on_connection_reuse_connection': self.on_connection_reuse_connection, + 'on_dns_resolve_host_start': self.on_dns_resolve_host_start, + 'on_dns_resolve_host_end': self.on_dns_resolve_host_end, + 'on_dns_cache_hit': self.on_dns_cache_hit, + 'on_dns_cache_miss': self.on_dns_cache_miss, + 'on_task_start': self.on_task_start, + 'on_task_end': self.on_task_end, + 'on_task_error': self.on_task_error, + } diff --git a/hyperscale/core_rewrite/engines/client/udp/__init__.py b/hyperscale/core_rewrite/engines/client/udp/__init__.py new file mode 100644 index 0000000..327ae4a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/__init__.py @@ -0,0 +1,8 @@ +from .mercury_sync_udp_connection import ( + MercurySyncUDPConnection as MercurySyncUDPConnection, +) +from .models.udp import ( + OptimizedUDPRequest, + UDPRequest, + UDPResponse, +) diff --git a/hyperscale/core_rewrite/engines/client/udp/mercury_sync_udp_connection.py b/hyperscale/core_rewrite/engines/client/udp/mercury_sync_udp_connection.py new file mode 100644 index 0000000..839248d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/mercury_sync_udp_connection.py @@ -0,0 +1,380 @@ +import asyncio +import ssl +import time +from collections import defaultdict +from typing import Dict, List, Literal, Optional, Tuple, Union +from urllib.parse import urlparse + +from pydantic import BaseModel + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .models.udp import UDPRequest, UDPResponse +from .protocols import UDPConnection + + +class MercurySyncUDPConnection: + def __init__( + self, + pool_size: Optional[int] = None, + cert_path: Optional[str] = None, + key_path: Optional[str] = None, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + self.timeouts = timeouts + self.reset_connections = reset_connections + + self._cert_path = cert_path + self._key_path = key_path + + self._udp_ssl_context: Optional[ssl.SSLContext] = None + + if cert_path and key_path: + self._udp_ssl_context = self._create_udp_ssl_context( + cert_path=cert_path, + key_path=key_path, + ) + + self._dns_lock: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self._dns_waiters: Dict[str, asyncio.Future] = defaultdict(asyncio.Future) + self._pending_queue: List[asyncio.Future] = [] + + self._client_waiters: Dict[asyncio.Transport, asyncio.Future] = {} + self._connections: List[UDPConnection] = [ + UDPConnection(reset_connections=reset_connections) for _ in range(pool_size) + ] + + self._hosts: Dict[str, Tuple[str, int]] = {} + + self._connections_count: Dict[str, List[asyncio.Transport]] = defaultdict(list) + self._locks: Dict[asyncio.Transport, asyncio.Lock] = {} + + self._max_concurrency = pool_size + + self._semaphore = asyncio.Semaphore(self._max_concurrency) + self._connection_waiters: List[asyncio.Future] = [] + + self._url_cache: Dict[str, URL] = {} + + def _create_udp_ssl_context( + self, + cert_path: Optional[str] = None, + key_path: Optional[str] = None, + ) -> ssl.SSLContext: + if cert_path is None: + cert_path = self._cert_path + + if key_path is None: + key_path = self._key_path + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS) + ssl_ctx.options |= ssl.OP_NO_TLSv1 + ssl_ctx.options |= ssl.OP_NO_TLSv1_1 + ssl_ctx.options |= ssl.OP_SINGLE_DH_USE + ssl_ctx.options |= ssl.OP_SINGLE_ECDH_USE + ssl_ctx.load_cert_chain(cert_path, keyfile=key_path) + ssl_ctx.load_verify_locations(cafile=cert_path) + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.VerifyMode.CERT_REQUIRED + ssl_ctx.set_ciphers("ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384") + + return ssl_ctx + + async def send( + self, + url: str, + data: str | bytes | BaseModel, + timeout: Union[Optional[int], Optional[float]] = None, + ): + async with self._semaphore: + try: + return await asyncio.wait_for( + self._request( + UDPRequest( + url=url, + method="SEND", + data=data, + ) + ), + timeout=timeout, + ) + + except Exception as err: + url_data = urlparse(url) + + return UDPResponse( + url=URLMetadata(host=url_data.hostname, path=url_data.path), + error=str(err), + ) + + async def receive( + self, + url: str, + delimiter: Optional[str | bytes] = b"\n", + response_size: Optional[int] = None, + timeout: Union[Optional[int], Optional[float]] = None, + ): + async with self._semaphore: + try: + if isinstance(delimiter, str): + delimiter = delimiter.encode() + + return await asyncio.wait_for( + self._request( + UDPRequest( + url=url, + method="RECEIVE", + response_size=response_size, + delimiter=delimiter, + ) + ), + timeout=timeout, + ) + + except Exception as err: + url_data = urlparse(url) + + return UDPResponse( + url=URLMetadata(host=url_data.hostname, path=url_data.path), + error=str(err), + ) + + async def bidirectional( + self, + url: str, + data: str | bytes | BaseModel, + delimiter: Optional[str | bytes] = b"\n", + response_size: Optional[int] = None, + timeout: Union[Optional[int], Optional[float]] = None, + ): + async with self._semaphore: + try: + if isinstance(delimiter, str): + delimiter = delimiter.encode() + + return await asyncio.wait_for( + self._request( + UDPRequest( + url=url, + method="BIDIRECTIONAL", + data=data, + response_size=response_size, + delimiter=delimiter, + ) + ), + timeout=timeout, + ) + + except Exception as err: + url_data = urlparse(url) + + return UDPResponse( + url=URLMetadata(host=url_data.hostname, path=url_data.path), + error=str(err), + ) + + async def _request( + self, + request: UDPRequest, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ): + request_url = request.url + + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + ( + error, + connection, + url, + ) = await asyncio.wait_for( + self._connect_to_url_location(request_url), + timeout=self.timeouts.connect_timeout, + ) + + if connection.reader is None: + timings["connect_end"] = time.monotonic() + self._connections.append( + UDPConnection(reset_connections=self.reset_connections) + ) + + return ( + UDPResponse( + url=URLMetadata(host=url.hostname, path=url.path), + error=str(error), + timings=timings, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + data = request.prepare() + response_data = b"" + + try: + match request.method: + case "BIDIRECTIONAL": + timings["write_start"] = time.monotonic() + + connection.writer.write(data) + + timings["write_end"] = time.monotonic() + timings["read_start"] = time.monotonic() + + if request.response_size: + response_data = await connection.reader.readexactly( + request.response_size + ) + + else: + response_data = await connection.reader.readuntil( + separator=request.delimiter + ) + + timings["read_end"] = time.monotonic() + + case "SEND": + timings["write_start"] = time.monotonic() + connection.writer.write(data) + timings["write_end"] = time.monotonic() + + case "RECEIVE": + timings["read_start"] = time.monotonic() + + if request.response_size: + response_data = await connection.reader.readexactly( + request.response_size + ) + + else: + response_data = await connection.reader.readuntil( + separator=request.delimiter + ) + + timings["read_end"] = time.monotonic() + + case _: + if timings["write_start"]: + timings["write_end"] = time.monotonic() + + if timings["read_start"]: + timings["read_end"] = time.monotonic() + + raise Exception( + "Err. - invalid UDP operation. Must be one of - BIDIRECTIONAL, SEND, or RECEIVE." + ) + + self._connections.append(connection) + + return UDPResponse( + url=URLMetadata(host=url.hostname, path=url.path), + content=response_data, + timings=timings, + ) + + except Exception as err: + self._connections.append( + UDPConnection(reset_connections=self.reset_connections) + ) + + return ( + UDPResponse( + url=URLMetadata(host=url.hostname, path=url.path), + error=str(err), + timings=timings, + ), + False, + timings, + ) + + async def _connect_to_url_location( + self, + request_url: str, + ssl_redirect_url=None, + ) -> Tuple[ + Optional[Exception], + UDPConnection, + URL, + ]: + parsed_url = URL(request_url) + + url = self._url_cache.get(parsed_url.hostname) + dns_lock = self._dns_lock[parsed_url.hostname] + dns_waiter = self._dns_waiters[parsed_url.hostname] + + do_dns_lookup = url is None or ssl_redirect_url + + if do_dns_lookup and dns_lock.locked() is False: + await dns_lock.acquire() + url = parsed_url + await url.lookup() + + self._dns_lock[parsed_url.hostname] = dns_lock + self._url_cache[parsed_url.hostname] = url + + dns_waiter = self._dns_waiters[parsed_url.hostname] + + if dns_waiter.done() is False: + dns_waiter.set_result(None) + + dns_lock.release() + + elif do_dns_lookup: + await dns_waiter + url = self._url_cache.get(parsed_url.hostname) + + connection_error: Optional[Exception] = None + connection = self._connections.pop() + + if url.address is None: + for address, ip_info in url: + try: + await connection.make_connection( + url.address, + url.port, + url.socket_config, + tls=self._udp_ssl_context if "wss" in url.scheme else None, + ) + + url.address = address + url.socket_config = ip_info + + except Exception: + pass + + else: + try: + await connection.make_connection( + url.address, + url.port, + url.socket_config, + tls=self._udp_ssl_context if "wss" in url.scheme else None, + ) + + except Exception as connection_error: + raise connection_error + + return ( + connection_error, + connection, + parsed_url, + ) diff --git a/hyperscale/core_rewrite/engines/client/udp/models/__init__.py b/hyperscale/core_rewrite/engines/client/udp/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/udp/models/udp/__init__.py b/hyperscale/core_rewrite/engines/client/udp/models/udp/__init__.py new file mode 100644 index 0000000..b7d5899 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/models/udp/__init__.py @@ -0,0 +1,3 @@ +from .optimized_udp_request import OptimizedUDPRequest as OptimizedUDPRequest +from .udp_request import UDPRequest as UDPRequest +from .udp_response import UDPResponse as UDPResponse diff --git a/hyperscale/core_rewrite/engines/client/udp/models/udp/optimized_udp_request.py b/hyperscale/core_rewrite/engines/client/udp/models/udp/optimized_udp_request.py new file mode 100644 index 0000000..bcfaed8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/models/udp/optimized_udp_request.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel, StrictBytes, StrictInt + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedUDPRequest(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + encoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/udp/models/udp/udp_request.py b/hyperscale/core_rewrite/engines/client/udp/models/udp/udp_request.py new file mode 100644 index 0000000..8422485 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/models/udp/udp_request.py @@ -0,0 +1,41 @@ +from typing import Literal, Optional, Union + +import orjson +from pydantic import BaseModel, StrictBytes, StrictStr + + +class UDPRequest(BaseModel): + url: StrictStr + method: Literal[ + "BIDIRECTIONAL", + "SEND", + "RECEIVE", + ] + data: Union[ + StrictStr, + StrictBytes, + BaseModel, + ]=b'' + response_size: Optional[int]=None + delimiter: StrictBytes=b'\n' + + class Config: + arbitrary_types_allowed=True + + def prepare(self): + + if isinstance(self.data, BaseModel): + data = orjson.dumps({ + name: value for name, value in self.data.__dict__.items() if value is not None + }) + + elif isinstance(self.data, str): + data = self.data.encode() + + elif self.data: + data = self.data + + else: + data = self.data + + return data diff --git a/hyperscale/core_rewrite/engines/client/udp/models/udp/udp_response.py b/hyperscale/core_rewrite/engines/client/udp/models/udp/udp_response.py new file mode 100644 index 0000000..65e6c77 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/models/udp/udp_response.py @@ -0,0 +1,45 @@ +from typing import Dict, Optional, Type, TypeVar + +import orjson +from pydantic import BaseModel, StrictBytes, StrictFloat, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URLMetadata, +) + +T = TypeVar('T', bound=BaseModel) + + +class UDPResponse(BaseModel): + url: URLMetadata + error: Optional[StrictStr]=None + content: StrictBytes=b'' + timings: Dict[StrictStr, StrictFloat]={} + + class Config: + arbitrary_types_allowed=True + + def json(self): + + if self.content: + return orjson.loads( + self.content + ) + + return {} + + def text(self): + return self.content.decode() + + def to_model( + self, + model: Type[T] + ) -> T: + return model(**orjson.loads( + self.content + )) + + @property + def data(self): + return self.content + \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/__init__.py b/hyperscale/core_rewrite/engines/client/udp/protocols/__init__.py new file mode 100644 index 0000000..50b2e69 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/__init__.py @@ -0,0 +1 @@ +from .connection import UDPConnection as UDPConnection \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/connection.py b/hyperscale/core_rewrite/engines/client/udp/protocols/connection.py new file mode 100644 index 0000000..2e3cdf7 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/connection.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import asyncio +import ssl +from typing import Optional, Tuple + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + Reader, + Writer, +) + +from .dtls import do_patch +from .udp import UDPConnection as UDP + +do_patch() + +class UDPConnection: + + def __init__(self, reset_connections: bool=False) -> None: + self.dns_address: str = None + self.port: int = None + self.ip_addr = None + self.lock = asyncio.Lock() + + self.reader: Reader = None + self.writer: Writer = None + + self.connected = False + self.reset_connections = reset_connections + self.pending = 0 + self._connection_factory = UDP() + + async def make_connection( + self, + dns_address: str, + port: int, + socket_config: Tuple[int, int, int, int, Tuple[int, int]], + tls: Optional[ssl.SSLContext]=None + ) -> None: + + if self.connected is False or self.dns_address != dns_address or self.reset_connections: + try: + reader, writer = self._connection_factory.create_udp( + socket_config, + tls=tls + ) + + self.connected = True + + self.reader = reader + self.writer = writer + + self.dns_address = dns_address + self.port = port + + except asyncio.TimeoutError: + raise Exception('Connection timed out.') + + except ConnectionResetError: + raise Exception('Connection reset.') + + except Exception as e: + raise e + + async def close(self): + try: + await self._connection_factory.close() + except Exception: + pass \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/__init__.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/__init__.py new file mode 100644 index 0000000..8b8998f --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/__init__.py @@ -0,0 +1,65 @@ +# PyDTLS: datagram TLS for Python. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""PyDTLS package + +This package exports OpenSSL's DTLS support to Python. Calling its patch +function will add the constant PROTOCOL_DTLSv1 to the Python standard library's +ssl module. Subsequently passing a datagram socket to that module's +wrap_socket function (or instantiating its SSLSocket class with a datagram +socket) will activate this module's DTLS implementation for the returned +SSLSocket instance. + +Instead of or in addition to invoking the patch functionality, the +SSLConnection class can be used directly for secure communication over datagram +sockets. + +wrap_socket's parameters and their semantics have been maintained. +""" + +VERSION = 1, 2, 3 + +def _prep_bins(): + """ + Support for running straight out of a cloned source directory instead + of an installed distribution + """ + + from os import path + from sys import platform, maxsize + from shutil import copy + bit_suffix = "-x86_64" if maxsize > 2**32 else "-x86" + package_root = path.abspath(path.dirname(__file__)) + prebuilt_path = path.join(package_root, "prebuilt", platform + bit_suffix) + config = {"MANIFEST_DIR": prebuilt_path} + try: + exec(open(path.join(prebuilt_path, "manifest.pycfg")).read(), config) + except IOError: + return # there are no prebuilts for this platform - nothing to do + files = map(lambda x: path.join(prebuilt_path, x), config["FILES"]) + for prebuilt_file in files: + try: + copy(path.join(prebuilt_path, prebuilt_file), package_root) + except IOError: + pass + +_prep_bins() # prepare before module imports + +from .patch import do_patch +from .sslconnection import SSLContext, SSL, SSLConnection +from .demux import force_routing_demux, reset_default_demux diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/__init__.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/__init__.py new file mode 100644 index 0000000..69d9b20 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/__init__.py @@ -0,0 +1,63 @@ +# Demux loader: imports a demux module appropriate for this platform. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""UDP Demux + +A UDP demux is a wrapper for a datagram socket. The demux must be initialized +with an unconnected datagram socket, referred to as the root socket. Once +initialized, the demux will create new connections to peer endpoints upon +arrival of datagrams from a new endpoint. Such a connection is of a +socket-derived type, and will receive datagrams only from the peer endpoint for +which it was created, and that are sent to the root socket. + +Connections must be used for receiving datagrams only. Outgoing traffic should +be sent through the root socket. + +Varying implementations of this functionality are provided for different +platforms. +""" + +import sys + +if sys.platform.startswith('win') or sys.platform.startswith('cygwin'): + from .router import UDPDemux + _routing = True +else: + from .osnet import UDPDemux + _routing = False +_default_demux = None + +def force_routing_demux(): + global _routing + if _routing: + return False # no change - already loaded + global UDPDemux, _default_demux + from . import router + _default_demux = UDPDemux + UDPDemux = router.UDPDemux + _routing = True + return True # new router loaded and switched + +def reset_default_demux(): + global UDPDemux, _routing, _default_demux + if _default_demux: + UDPDemux = _default_demux + _default_demux = None + _routing = not _routing + +__all__ = ["UDPDemux", "force_routing_demux", "reset_default_demux"] diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/osnet.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/osnet.py new file mode 100644 index 0000000..bd57c0e --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/osnet.py @@ -0,0 +1,121 @@ +# OSNet demux: uses the OS network stack to demultiplex incoming datagrams +# among sockets bound to the same ports. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OS Network UDP Demux + +This module implements a demux that uses the OS network stack to demultiplex +datagrams coming from different peers among datagram sockets that are all bound +to the port at which these datagrams are being received. The network stack is +instructed as to which socket an incoming datagram should be sent to by +connecting the destination socket to the peer endpoint. + +The OSNet demux requires operating system functionality that exists in the +Linux kernel, but not in the Windows network stack. + +Classes: + + UDPDemux -- a network stack configuring UDP demux + +Exceptions: + + InvalidSocketError -- exception raised for improper socket objects + KeyError -- raised for unknown peer addresses +""" + +import socket +from logging import getLogger +from ..err import InvalidSocketError + +_logger = getLogger(__name__) + + +class UDPDemux(object): + """OS network stack configuring demux + + This class implements a demux that creates sockets connected to peer + network endpoints, configuring the network stack to demultiplex + incoming datagrams from these endpoints among these sockets. + + Methods: + + get_connection -- create a new connection or retrieve an existing one + service -- this method does nothing for this type of demux + """ + + def __init__(self, datagram_socket): + """Constructor + + Arguments: + datagram_socket -- the root socket; this must be a bound, unconnected + datagram socket + """ + + if (datagram_socket.type & socket.SOCK_DGRAM) != socket.SOCK_DGRAM: + raise InvalidSocketError("datagram_socket is not of " + + "type SOCK_DGRAM") + try: + datagram_socket.getsockname() + except: + raise InvalidSocketError("datagram_socket is unbound") + try: + datagram_socket.getpeername() + except: + pass + else: + raise InvalidSocketError("datagram_socket is connected") + + datagram_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._datagram_socket = datagram_socket + + def get_connection(self, address): + """Create or retrieve a muxed connection + + Arguments: + address -- a peer endpoint in IPv4/v6 address format; None refers + to the connection for unknown peers + + Return: + a bound, connected datagram socket instance, or the root socket + in case address was None + """ + + if not address: + return self._datagram_socket + + # Create a new datagram socket bound to the same interface and port as + # the root socket, but connected to the given peer + conn = socket.socket(self._datagram_socket.family, + self._datagram_socket.type, + self._datagram_socket.proto) + conn.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + conn.bind(self._datagram_socket.getsockname()) + conn.connect(address) + _logger.debug("Created new connection for address: %s", address) + return conn + + @staticmethod + def service(): + """Service the root socket + + This type of demux performs no servicing work on the root socket, + and instead advises the caller to proceed to listening on the root + socket. + """ + + return True diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/router.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/router.py new file mode 100644 index 0000000..e24d65a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/demux/router.py @@ -0,0 +1,189 @@ +# Routing demux: forwards datagrams from the root socket to connected +# sockets bound to ephemeral ports. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Routing UDP Demux + +This module implements an explicitly routing UDP demux. New connections create +datagram sockets bound to ephemeral ports on the loopback interface and +connected to a forwarding socket. The demux services incoming datagrams by +receiving them from the root socket and sending them to the socket belonging to +the connection that is associated with the sending peer. + +A routing UDP demux can be used on any platform. + +Classes: + + UDPDemux -- an explicitly routing UDP demux + +Exceptions: + + InvalidSocketError -- exception raised for improper socket objects + KeyError -- raised for unknown peer addresses +""" + +import socket +from logging import getLogger +from weakref import WeakValueDictionary +from ..err import InvalidSocketError + +_logger = getLogger(__name__) + +UDP_MAX_DGRAM_LENGTH = 65527 + + +class UDPDemux(object): + """Explicitly routing UDP demux + + This class implements a demux that forwards packets from the root + socket to sockets belonging to connections. It does this whenever its + service method is invoked. + + Methods: + + remove_connection -- remove an existing connection + service -- distribute datagrams from the root socket to connections + forward -- forward a stored datagram to a connection + """ + + _forwarding_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + _forwarding_socket.bind(('127.0.0.1', 0)) + + def __init__(self, datagram_socket): + """Constructor + + Arguments: + datagram_socket -- the root socket; this must be a bound, unconnected + datagram socket + """ + + if (datagram_socket.type & socket.SOCK_DGRAM) != socket.SOCK_DGRAM: + raise InvalidSocketError("datagram_socket is not of " + + "type SOCK_DGRAM") + try: + datagram_socket.getsockname() + except: + raise InvalidSocketError("datagram_socket is unbound") + try: + datagram_socket.getpeername() + except: + pass + else: + raise InvalidSocketError("datagram_socket is connected") + + self.datagram_socket = datagram_socket + self.payload = "" + self.payload_peer_address = None + self.connections = WeakValueDictionary() + + def get_connection(self, address): + """Create or retrieve a muxed connection + + Arguments: + address -- a peer endpoint in IPv4/v6 address format; None refers + to the connection for unknown peers + + Return: + a bound, connected datagram socket instance + """ + + if address in self.connections: + return self.connections[address] + + # We need a new datagram socket on a dynamically assigned ephemeral port + conn = socket.socket(self._forwarding_socket.family, + self._forwarding_socket.type, + self._forwarding_socket.proto) + conn.bind((self._forwarding_socket.getsockname()[0], 0)) + conn.connect(self._forwarding_socket.getsockname()) + if not address: + conn.setblocking(0) + self.connections[address] = conn + _logger.debug("Created new connection for address: %s", address) + return conn + + def remove_connection(self, address): + """Remove a muxed connection + + Arguments: + address -- an address that was previously returned by the service + method and whose connection has not yet been removed + + Return: + the socket object whose connection has been removed + """ + + return self.connections.pop(address) + + def service(self): + """Service the root socket + + Read from the root socket and forward one datagram to a + connection. The call will return without forwarding data + if any of the following occurs: + + * An error is encountered while reading from the root socket + * Reading from the root socket times out + * The root socket is non-blocking and has no data available + * An empty payload is received + * A non-empty payload is received from an unknown peer (a peer + for which get_connection has not yet been called); in this case, + the payload is held by this instance and will be forwarded when + the forward method is called + + Return: + if the datagram received was from a new peer, then the peer's + address; otherwise None + """ + + self.payload, self.payload_peer_address = \ + self.datagram_socket.recvfrom(UDP_MAX_DGRAM_LENGTH) + _logger.debug("Received datagram from peer: %s", + self.payload_peer_address) + if not self.payload: + self.payload_peer_address = None + return + if self.payload_peer_address in self.connections: + self.forward() + else: + return self.payload_peer_address + + def forward(self): + """Forward a stored datagram + + When the service method returns the address of a new peer, it holds + the datagram from that peer in this instance. In this case, this + method will perform the forwarding step. The target connection is the + one associated with address None if get_connection has not been called + since the service method returned the new peer's address, and the + connection associated with the new peer's address if it has. + """ + + assert self.payload + assert self.payload_peer_address + if self.payload_peer_address in self.connections: + conn = self.connections[self.payload_peer_address] + default = False + else: + conn = self.connections[None] # propagate exception if not created + default = True + _logger.debug("Forwarding datagram from peer: %s, default: %s", + self.payload_peer_address, default) + self._forwarding_socket.sendto(self.payload, conn.getsockname()) + self.payload = "" + self.payload_peer_address = None diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/err.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/err.py new file mode 100644 index 0000000..94ba7f8 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/err.py @@ -0,0 +1,141 @@ +# DTLS exceptions. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DTLS Errors + +This module defines error functionality and exception types for the dtls +package. + +Classes: + + SSLError -- exception raised for I/O errors + InvalidSocketError -- exception raised for improper socket objects +""" + +from socket import error as socket_error + +SSL_ERROR_NONE = 0 +SSL_ERROR_SSL = 1 +SSL_ERROR_WANT_READ = 2 +SSL_ERROR_WANT_WRITE = 3 +SSL_ERROR_WANT_X509_LOOKUP = 4 +SSL_ERROR_SYSCALL = 5 +SSL_ERROR_ZERO_RETURN = 6 +SSL_ERROR_WANT_CONNECT = 7 +SSL_ERROR_WANT_ACCEPT = 8 + +SSL_ERROR_TEXT = { + 0: 'SSL_ERROR_NONE', + 1: 'SSL_ERROR_SSL', + 2: 'SSL_ERROR_WANT_READ', + 3: 'SSL_ERROR_WANT_WRITE', + 4: 'SSL_ERROR_WANT_X509_LOOKUP', + 5: 'SSL_ERROR_SYSCALL', + 6: 'SSL_ERROR_ZERO_RETURN', + 7: 'SSL_ERROR_WANT_CONNECT', + 8: 'SSL_ERROR_WANT_ACCEPT', +} + +ERR_BOTH_KEY_CERT_FILES = 500 +ERR_BOTH_KEY_CERT_FILES_SVR = 298 +ERR_NO_CERTS = 331 +ERR_NO_CIPHER = 501 +ERR_READ_TIMEOUT = 502 +ERR_WRITE_TIMEOUT = 503 +ERR_HANDSHAKE_TIMEOUT = 504 +ERR_PORT_UNREACHABLE = 505 + +ERR_WRONG_SSL_VERSION = 0x1409210A +ERR_WRONG_VERSION_NUMBER = 0x1408A10B +ERR_COOKIE_MISMATCH = 0x1408A134 +ERR_CERTIFICATE_VERIFY_FAILED = 0x14090086 +ERR_NO_SHARED_CIPHER = 0x1408A0C1 +ERR_SSL_HANDSHAKE_FAILURE = 0x1410C0E5 +ERR_TLSV1_ALERT_UNKNOWN_CA = 0x14102418 +ERR_BAD_SIGNATURE = 0x1417B07B + + +def patch_ssl_errors(): + import ssl + errors = [i for i in globals().items() if type(i[1]) == int and str(i[0]).startswith('ERR_')] + for k, v in errors: + if not hasattr(ssl, k): + setattr(ssl, k, v) + +class SSLError(socket_error): + """This exception is raised by modules in the dtls package.""" + def __init__(self, *args): + super(SSLError, self).__init__(*args) + + +class InvalidSocketError(Exception): + """There is a problem with a socket passed to the dtls package.""" + def __init__(self, *args): + super(InvalidSocketError, self).__init__(*args) + + +def _make_opensslerror_class(): + global _OpenSSLError + class __OpenSSLError(SSLError): + """ + This exception is raised when an error occurs in the OpenSSL library + """ + def __init__(self, ssl_error, errqueue, result, func, args): + self.ssl_error = ssl_error + self.errqueue = errqueue + self.result = result + self.func = func + self.args = args + SSLError.__init__(self, ssl_error, errqueue, + result, func, args) + + _OpenSSLError = __OpenSSLError + +_make_opensslerror_class() + +def openssl_error(): + """Return the OpenSSL error type for use in exception clauses""" + return _OpenSSLError + +def raise_as_ssl_module_error(): + """Exceptions raised from this module are instances of ssl.SSLError""" + import ssl + global SSLError + SSLError = ssl.SSLError + _make_opensslerror_class() + +def raise_ssl_error(code, nested=None): + """Raise an SSL error with the given error code""" + err_string = str(code) + ": " + _ssl_errors[code] + if nested: + raise SSLError(code, err_string + str(nested)) + raise SSLError(code, err_string) + +_ssl_errors = { + ERR_NO_CERTS: "No root certificates specified for verification " + \ + "of other-side certificates", + ERR_BOTH_KEY_CERT_FILES: "Both the key & certificate files " + \ + "must be specified", + ERR_BOTH_KEY_CERT_FILES_SVR: "Both the key & certificate files must be " + \ + "specified for server-side operation", + ERR_NO_CIPHER: "No cipher can be selected.", + ERR_READ_TIMEOUT: "The read operation timed out", + ERR_WRITE_TIMEOUT: "The write operation timed out", + ERR_HANDSHAKE_TIMEOUT: "The handshake operation timed out", + ERR_PORT_UNREACHABLE: "The peer address is not reachable", + } diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/openssl.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/openssl.py new file mode 100644 index 0000000..33b7788 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/openssl.py @@ -0,0 +1,1232 @@ +# OpenSSL library wrapper: provide access to both OpenSSL dynamic libraries +# through ctypes. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OpenSSL Wrapper + +This module provides run-time access to the OpenSSL cryptographic and +protocols libraries. It is designed for use with "from openssl import *". For +this reason, the module variable __all__ contains all of this module's +integer constants, OpenSSL library functions, and wrapper functions. + +Constants and functions are not documented here. See the OpenSSL library +documentation. + +Exceptions: + + OpenSSLError -- exception raised when errors occur in the OpenSSL library +""" + +import sys +import array +import socket +from logging import getLogger +from os import path +from datetime import timedelta +from .err import openssl_error, SSL_ERROR_TEXT +from .err import SSL_ERROR_NONE +from .util import _EC_KEY, _BIO +import ctypes +from ctypes import CDLL +from ctypes import CFUNCTYPE +from ctypes import c_void_p, c_int, c_uint64, c_long, c_uint, c_ulong, c_char_p, c_size_t +from ctypes import c_short, c_ushort, c_ubyte, c_char +from ctypes import byref, POINTER, addressof +from ctypes import Structure, Union +from ctypes import create_string_buffer, sizeof, memmove, cast +from threading import Lock + +# +# Module initialization +# +_logger = getLogger(__name__) + +# +# Library loading +# +if sys.platform.startswith('win'): + dll_path = path.abspath(path.dirname(__file__)) + cryptodll_path = path.join(dll_path, "libcrypto-1_1-x64.dll") + ssldll_path = path.join(dll_path, "libssl-1_1-x64.dll") + libcrypto = CDLL(cryptodll_path) + libssl = CDLL(ssldll_path) +else: + libcrypto = CDLL("libcrypto.so.1.1") + libssl = CDLL("libssl.so.1.1") + +# +# Integer constants - exported +# +BIO_NOCLOSE = 0x00 +BIO_CLOSE = 0x01 +OPENSSL_VERSION = 0 +SSL_OP_NO_QUERY_MTU = 0x00001000 +SSL_OP_NO_COMPRESSION = 0x00020000 +SSL_VERIFY_NONE = 0x00 +SSL_VERIFY_PEER = 0x01 +SSL_VERIFY_FAIL_IF_NO_PEER_CERT = 0x02 +SSL_VERIFY_CLIENT_ONCE = 0x04 +SSL_SESS_CACHE_OFF = 0x0000 +SSL_SESS_CACHE_CLIENT = 0x0001 +SSL_SESS_CACHE_SERVER = 0x0002 +SSL_SESS_CACHE_BOTH = SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_SERVER +SSL_SESS_CACHE_NO_AUTO_CLEAR = 0x0080 +SSL_SESS_CACHE_NO_INTERNAL_LOOKUP = 0x0100 +SSL_SESS_CACHE_NO_INTERNAL_STORE = 0x0200 +SSL_SESS_CACHE_NO_INTERNAL = \ + SSL_SESS_CACHE_NO_INTERNAL_LOOKUP | SSL_SESS_CACHE_NO_INTERNAL_STORE +SSL_BUILD_CHAIN_FLAG_NONE = 0x0 +SSL_BUILD_CHAIN_FLAG_UNTRUSTED = 0x1 +SSL_BUILD_CHAIN_FLAG_NO_ROOT = 0x2 +SSL_BUILD_CHAIN_FLAG_CHECK = 0x4 +SSL_BUILD_CHAIN_FLAG_IGNORE_ERROR = 0x8 +SSL_BUILD_CHAIN_FLAG_CLEAR_ERROR = 0x10 +SSL_FILE_TYPE_PEM = 1 +GEN_DIRNAME = 4 +NID_subject_alt_name = 85 +CRYPTO_LOCK = 1 + +SSL_ST_MASK = 0x0FFF +SSL_ST_CONNECT = 0x1000 +SSL_ST_ACCEPT = 0x2000 +SSL_ST_INIT = (SSL_ST_CONNECT | SSL_ST_ACCEPT) +SSL_ST_BEFORE = 0x4000 + +SSL_ST_OK = 0x03 +SSL_ST_RENEGOTIATE = (0x04 | SSL_ST_INIT) +SSL_ST_ERR = 0x05 + +SSL_CB_LOOP = 0x01 +SSL_CB_EXIT = 0x02 +SSL_CB_READ = 0x04 +SSL_CB_WRITE = 0x08 + +SSL_CB_ALERT = 0x4000 +SSL_CB_READ_ALERT = (SSL_CB_ALERT | SSL_CB_READ) +SSL_CB_WRITE_ALERT = (SSL_CB_ALERT | SSL_CB_WRITE) +SSL_CB_ACCEPT_LOOP = (SSL_ST_ACCEPT | SSL_CB_LOOP) +SSL_CB_ACCEPT_EXIT = (SSL_ST_ACCEPT | SSL_CB_EXIT) +SSL_CB_CONNECT_LOOP = (SSL_ST_CONNECT | SSL_CB_LOOP) +SSL_CB_CONNECT_EXIT = (SSL_ST_CONNECT | SSL_CB_EXIT) +SSL_CB_HANDSHAKE_START = 0x10 +SSL_CB_HANDSHAKE_DONE = 0x20 + +# +# Integer constants - internal +# +SSL_CTRL_SET_TMP_ECDH = 4 +SSL_CTRL_SET_MTU = 17 +SSL_CTRL_OPTIONS = 32 +SSL_CTRL_SET_READ_AHEAD = 41 +SSL_CTRL_SET_SESS_CACHE_MODE = 44 +SSL_CTRL_CLEAR_OPTIONS = 77 +SSL_CTRL_GET_CURVES = 90 +SSL_CTRL_SET_CURVES = 91 +SSL_CTRL_SET_CURVES_LIST = 92 +SSL_CTRL_GET_SHARED_CURVE = 93 +SSL_CTRL_SET_ECDH_AUTO = 94 +SSL_CTRL_SET_SIGALGS = 97 +SSL_CTRL_SET_SIGALGS_LIST = 98 +SSL_CTRL_SET_CLIENT_SIGALGS = 101 +SSL_CTRL_SET_CLIENT_SIGALGS_LIST = 102 +SSL_CTRL_BUILD_CERT_CHAIN = 105 + +BIO_CTRL_INFO = 3 +BIO_CTRL_DGRAM_SET_CONNECTED = 32 +BIO_CTRL_DGRAM_SET_PEER = 44 +BIO_CTRL_DGRAM_GET_PEER = 46 + +BIO_C_SET_NBIO = 102 + +DTLS_CTRL_GET_TIMEOUT = 73 +DTLS_CTRL_HANDLE_TIMEOUT = 74 +DTLS_CTRL_LISTEN = 75 +DTLS_CTRL_SET_LINK_MTU = 120 + +SSL_CTRL_SET_MAX_SEND_FRAGMENT = 52 +SSL_CTRL_SET_SPLIT_SEND_FRAGMENT = 125 +SSL_CTRL_SET_MAX_PIPELINES = 126 + +X509_NAME_MAXLEN = 256 +GETS_MAXLEN = 2048 + + +class _EllipticCurve(object): + _curves = None + + @classmethod + def _get_elliptic_curves(cls): + if cls._curves is None: + # Load once + cls._curves = cls._load_elliptic_curves() + return cls._curves + + @classmethod + def _load_elliptic_curves(cls): + num_curves = EC_get_builtin_curves(None, 0) + if num_curves > 0: + builtin_curves = create_string_buffer(sizeof(EC_builtin_curve) * num_curves) + EC_get_builtin_curves(cast(builtin_curves, POINTER(EC_builtin_curve)), num_curves) + return [cls(c.nid, OBJ_nid2sn(c.nid)) for c in cast(builtin_curves, POINTER(EC_builtin_curve))[:num_curves]] + return [] + + def __init__(self, nid, name): + self.nid = nid + self.name = name + + def __repr__(self): + return "" % (self.nid, self.name) + + def to_EC_KEY(self): + key = _EC_KEY(EC_KEY_new_by_curve_name(self.nid)) + return key if bool(key.value) else None + + +def get_elliptic_curves(): + u''' Return the available curves. If not yet loaded, then load them once. + + :rtype: list + ''' + return _EllipticCurve._get_elliptic_curves() + + +def get_elliptic_curve(name): + u''' Return the curve from the given name. + + :rtype: _EllipticCurve + ''' + for curve in get_elliptic_curves(): + if curve.name == name: + return curve + raise ValueError("unknown curve name", name) + + +# +# Parameter data types +# +class c_long_parm(object): + """Long integer paramter class + + c_long must be distinguishable from c_int, as the latter is associated + with a default error checking routine, while the former is not. + """ + + +class FuncParam(object): + """Function parameter or return type""" + @classmethod + def from_param(cls, value): + if not isinstance(value, cls): + _logger.error("Parameter type mismatch: %s not of type %s", + value, cls) + raise TypeError(repr(value) + " is not of type " + repr(cls)) + return value._as_parameter + + def __init__(self, value): + self._as_parameter = c_void_p(value) + + def __nonzero__(self): + return bool(self._as_parameter) + + @property + def raw(self): + return self._as_parameter.value + + +class DTLS_Method(FuncParam): + def __init__(self, value): + super(DTLS_Method, self).__init__(value) + + +class BIO_METHOD(FuncParam): + def __init__(self, value): + super(BIO_METHOD, self).__init__(value) + + +class SSLCTX(FuncParam): + def __init__(self, value): + super(SSLCTX, self).__init__(value) + + +class SSL(FuncParam): + def __init__(self, value): + super(SSL, self).__init__(value) + + +class BIO(FuncParam): + def __init__(self, value): + super(BIO, self).__init__(value) + + +class EC_KEY(FuncParam): + def __init__(self, value): + super(EC_KEY, self).__init__(value) + + +class X509(FuncParam): + def __init__(self, value): + super(X509, self).__init__(value) + + +class X509_val_st(Structure): + _fields_ = [("notBefore", c_void_p), + ("notAfter", c_void_p)] + + +class X509_cinf_st(Structure): + _fields_ = [("version", c_void_p), + ("serialNumber", c_void_p), + ("signature", c_void_p), + ("issuer", c_void_p), + ("validity", POINTER(X509_val_st))] # remaining fields omitted + + +class X509_st(Structure): + _fields_ = [("cert_info", POINTER(X509_cinf_st),)] # remainder omitted + + +class X509_name_st(Structure): + _fields_ = [("entries", c_void_p)] # remaining fields omitted + + +class ASN1_OBJECT(FuncParam): + def __init__(self, value): + super(ASN1_OBJECT, self).__init__(value) + + +class ASN1_STRING(FuncParam): + def __init__(self, value): + super(ASN1_STRING, self).__init__(value) + + +class ASN1_TIME(FuncParam): + def __init__(self, value): + super(ASN1_TIME, self).__init__(value) + + +class SSL_CIPHER(FuncParam): + def __init__(self, value): + super(SSL_CIPHER, self).__init__(value) + + +class GENERAL_NAME_union_d(Union): + _fields_ = [("ptr", c_char_p), + # entries omitted + ("directoryName", POINTER(X509_name_st))] + # remaining fields omitted + + +class STACK(FuncParam): + def __init__(self, value): + super(STACK, self).__init__(value) + + +class GENERAL_NAME(Structure): + _fields_ = [("type", c_int), + ("d", GENERAL_NAME_union_d)] + + +class GENERAL_NAMES(STACK): + stack_element_type = GENERAL_NAME + + def __init__(self, value): + super(GENERAL_NAMES, self).__init__(value) + + +class STACK_OF_X509(STACK): + stack_element_type = X509 + + def __init__(self, value): + super(STACK_OF_X509, self).__init__(value) + + +class X509_NAME_ENTRY(Structure): + _fields_ = [("object", c_void_p), + ("value", c_void_p), + ("set", c_int), + ("size", c_int)] + + +class ASN1_OCTET_STRING(Structure): + _fields_ = [("length", c_int), + ("type", c_int), + ("data", POINTER(c_ubyte)), + ("flags", c_long)] + + +class X509_EXTENSION(Structure): + _fields_ = [("object", c_void_p), + ("critical", c_int), + ("value", POINTER(ASN1_OCTET_STRING))] + + +class X509V3_EXT_METHOD(Structure): + _fields_ = [("ext_nid", c_int), + ("ext_flags", c_int), + ("it", c_void_p), + ("ext_new", c_int), + ("ext_free", c_int), + ("d2i", c_int), + ("i2d", c_int)] # remaining fields omitted + + +class TIMEVAL(Structure): + _fields_ = [("tv_sec", c_long), + ("tv_usec", c_long)] + + +class EC_builtin_curve(Structure): + _fields_ = [("nid", c_int), + ("comment", c_char_p)] + + +# +# Socket address conversions +# +class sockaddr_storage(Structure): + _fields_ = [("ss_family", c_short), + ("pad", c_char * 126)] + +class sockaddr_in(Structure): + _fields_ = [("sin_family", c_short), + ("sin_port", c_ushort), + ("sin_addr", c_uint * 1), + ("sin_zero", c_char * 8)] + +class sockaddr_in6(Structure): + _fields_ = [("sin6_family", c_short), + ("sin6_port", c_ushort), + ("sin6_flowinfo", c_uint), + ("sin6_addr", c_uint * 4), + ("sin6_scope_id", c_uint)] + +class sockaddr_u(Union): + _fields_ = [("ss", sockaddr_storage), + ("s4", sockaddr_in), + ("s6", sockaddr_in6)] + +py_inet_ntop = getattr(socket, "inet_ntop", None) +if not py_inet_ntop: + windll = getattr(ctypes, "windll", None) + if windll: + wsa_inet_ntop = getattr(windll.ws2_32, "inet_ntop", None) + else: + wsa_inet_ntop = None + +py_inet_pton = getattr(socket, "inet_pton", None) +if not py_inet_pton: + windll = getattr(ctypes, "windll", None) + if windll: + wsa_inet_pton = getattr(windll.ws2_32, "inet_pton", None) + else: + wsa_inet_pton = None + +def inet_ntop(address_family, packed_ip): + if py_inet_ntop: + return py_inet_ntop(address_family, + array.array('I', packed_ip)) + if wsa_inet_ntop: + string_buf = create_string_buffer(47) + wsa_inet_ntop(address_family, packed_ip, + string_buf, sizeof(string_buf)) + if not string_buf.value: + raise ValueError("wsa_inet_ntop failed with: %s" % + array.array('I', packed_ip)) + return string_buf.value + if address_family == socket.AF_INET6: + raise ValueError("Platform does not support IPv6") + return socket.inet_ntoa(array.array('I', packed_ip)) + +def inet_pton(address_family, string_ip): + if address_family == socket.AF_INET6: + ret_packed_ip = (c_uint * 4)() + else: + ret_packed_ip = (c_uint * 1)() + if py_inet_pton: + ret_string = py_inet_pton(address_family, string_ip) + ret_packed_ip[:] = array.array('I', ret_string) + elif wsa_inet_pton: + if wsa_inet_pton(address_family, string_ip, ret_packed_ip) != 1: + raise ValueError("wsa_inet_pton failed with: %s" % string_ip) + else: + if address_family == socket.AF_INET6: + raise ValueError("Platform does not support IPv6") + ret_string = socket.inet_aton(string_ip) + ret_packed_ip[:] = array.array('I', ret_string) + return ret_packed_ip + +def addr_tuple_from_sockaddr_u(su): + if su.ss.ss_family == socket.AF_INET6: + return (inet_ntop(socket.AF_INET6, su.s6.sin6_addr), + socket.ntohs(su.s6.sin6_port), + socket.ntohl(su.s6.sin6_flowinfo), + socket.ntohl(su.s6.sin6_scope_id)) + assert su.ss.ss_family == socket.AF_INET + return inet_ntop(socket.AF_INET, su.s4.sin_addr), \ + socket.ntohs(su.s4.sin_port) + +def sockaddr_u_from_addr_tuple(address): + su = sockaddr_u() + if len(address) > 2: + su.ss.ss_family = socket.AF_INET6 + su.s6.sin6_addr[:] = inet_pton(socket.AF_INET6, address[0]) + su.s6.sin6_port = socket.htons(address[1]) + su.s6.sin6_flowinfo = socket.htonl(address[2]) + su.s6.sin6_scope_id = socket.htonl(address[3]) + else: + su.ss.ss_family = socket.AF_INET + su.s4.sin_addr[:] = inet_pton(socket.AF_INET, address[0]) + su.s4.sin_port = socket.htons(address[1]) + return su + +# +# Error handling +# +def raise_ssl_error(result, func, args, ssl): + if not ssl: + ssl_error = SSL_ERROR_NONE + else: + ssl_error = _SSL_get_error(ssl, result) + errqueue = [] + while True: + err = _ERR_get_error() + if not err: + break + buf = create_string_buffer(512) + _ERR_error_string_n(err, buf, sizeof(buf)) + errqueue.append((err, buf.value)) + ssl_error_text = SSL_ERROR_TEXT[ssl_error] if ssl_error in SSL_ERROR_TEXT else 'unknown' + _logger.debug("SSL error raised: ssl_error: %d (%s), result: %d, " + + "errqueue: %s, func_name: %s", + ssl_error, ssl_error_text, result, errqueue, func.func_name) + raise openssl_error()(ssl_error, errqueue, result, func, args) + +def find_ssl_arg(args): + for arg in args: + if isinstance(arg, SSL): + return arg + +def errcheck_gte_zero(result, func, args): + if result < 0: + raise_ssl_error(result, func, args, find_ssl_arg(args)) + return args + +def errcheck_ord(result, func, args): + if result <= 0: + raise_ssl_error(result, func, args, find_ssl_arg(args)) + return args + +def errcheck_p(result, func, args): + if not result: + raise_ssl_error(result, func, args, None) + return args + +def errcheck_FuncParam(result, func, args): + if not result: + raise_ssl_error(result, func, args, None) + return func.ret_type(result) + +# +# Function prototypes +# +def _make_function(name, lib, args, export=True, errcheck="default"): + assert len(args) + + def type_subst(map_type): + if map_type in _subst: + return _subst[map_type] + return map_type + + sig = tuple(type_subst(i[0]) for i in args) + # Handle pointer return values (width is architecture-dependent) + if isinstance(sig[0], type) and issubclass(sig[0], FuncParam): + sig = (c_void_p,) + sig[1:] + pointer_return = True + else: + pointer_return = False + if sig not in _sigs: + _sigs[sig] = CFUNCTYPE(*sig) + if export: + glbl_name = name + __all__.append(name) + else: + glbl_name = "_" + name + #print('opensslL564 func_name= ' + name) + func = _sigs[sig]((name, lib), tuple((i[2] if len(i) > 2 else 1, + i[1], + i[3] if len(i) > 3 else None) + [:3 if len(i) > 3 else 2] + for i in args[1:])) + func.func_name = name + if pointer_return: + func.ret_type = args[0][0] # for fix-up during error checking protocol + if errcheck == "default": + # Assign error checker based on return type + if args[0][0] in (c_int,): + errcheck = errcheck_ord + elif args[0][0] in (c_void_p, c_char_p): + errcheck = errcheck_p + elif pointer_return: + errcheck = errcheck_FuncParam + else: + errcheck = None + if errcheck: + func.errcheck = errcheck + globals()[glbl_name] = func + #print('opensslL586 Add to globals ' + str(func.func_name)) + +_subst = {c_long_parm: c_long} +_sigs = {} +__all__ = [ + # Constants + "BIO_NOCLOSE", "BIO_CLOSE", + "OPENSSL_VERSION", + "SSL_OP_NO_QUERY_MTU", "SSL_OP_NO_COMPRESSION", + "SSL_VERIFY_NONE", "SSL_VERIFY_PEER", + "SSL_VERIFY_FAIL_IF_NO_PEER_CERT", "SSL_VERIFY_CLIENT_ONCE", + "SSL_SESS_CACHE_OFF", "SSL_SESS_CACHE_CLIENT", + "SSL_SESS_CACHE_SERVER", "SSL_SESS_CACHE_BOTH", + "SSL_SESS_CACHE_NO_AUTO_CLEAR", "SSL_SESS_CACHE_NO_INTERNAL_LOOKUP", + "SSL_SESS_CACHE_NO_INTERNAL_STORE", "SSL_SESS_CACHE_NO_INTERNAL", + "SSL_ST_MASK", "SSL_ST_CONNECT", "SSL_ST_ACCEPT", "SSL_ST_INIT", "SSL_ST_BEFORE", "SSL_ST_OK", + "SSL_ST_RENEGOTIATE", "SSL_ST_ERR", "SSL_CB_LOOP", "SSL_CB_EXIT", "SSL_CB_READ", "SSL_CB_WRITE", + "SSL_CB_ALERT", "SSL_CB_READ_ALERT", "SSL_CB_WRITE_ALERT", + "SSL_CB_ACCEPT_LOOP", "SSL_CB_ACCEPT_EXIT", + "SSL_CB_CONNECT_LOOP", "SSL_CB_CONNECT_EXIT", + "SSL_CB_HANDSHAKE_START", "SSL_CB_HANDSHAKE_DONE", + "SSL_BUILD_CHAIN_FLAG_NONE", "SSL_BUILD_CHAIN_FLAG_UNTRUSTED", "SSL_BUILD_CHAIN_FLAG_NO_ROOT", + "SSL_BUILD_CHAIN_FLAG_CHECK", "SSL_BUILD_CHAIN_FLAG_IGNORE_ERROR", "SSL_BUILD_CHAIN_FLAG_CLEAR_ERROR", + "SSL_FILE_TYPE_PEM", + "GEN_DIRNAME", "NID_subject_alt_name", + "CRYPTO_LOCK", + # Methods + "DTLSv1_get_timeout", "DTLSv1_handle_timeout", + "DTLSv1_listen", + "DTLS_set_link_mtu", "DTLS_set_timer_cb", + "BIO_gets", "BIO_read", "BIO_get_mem_data", + "BIO_dgram_set_connected", + "BIO_dgram_get_peer", "BIO_dgram_set_peer", + "BIO_set_nbio", + "SSL_CTX_set_session_cache_mode", "SSL_CTX_set_read_ahead", + "SSL_CTX_set_options", "SSL_CTX_clear_options", "SSL_CTX_get_options", + "SSL_CTX_set1_client_sigalgs_list", "SSL_CTX_set1_client_sigalgs", + "SSL_CTX_set1_sigalgs_list", "SSL_CTX_set1_sigalgs", + "SSL_CTX_set1_curves", "SSL_CTX_set1_curves_list", + "SSL_CTX_set_info_callback", + "SSL_CTX_build_cert_chain", + "SSL_CTX_set_ecdh_auto", + "SSL_CTX_set_tmp_ecdh", + "SSL_read", "SSL_write", + "SSL_set_options", "SSL_clear_options", "SSL_get_options", + "SSL_set1_client_sigalgs_list", "SSL_set1_client_sigalgs", + "SSL_set1_sigalgs_list", "SSL_set1_sigalgs", + "SSL_get1_curves", "SSL_get_shared_curve", + "SSL_set1_curves", "SSL_set1_curves_list", + "SSL_set_mtu", + "SSL_state_string_long", "SSL_alert_type_string_long", "SSL_alert_desc_string_long", + "SSL_get_peer_cert_chain", + "SSL_CTX_set_cookie_cb", + "OBJ_obj2txt", "decode_ASN1_STRING", "ASN1_TIME_print", + "OBJ_nid2sn", + "X509_get_notBefore", "X509_get_notAfter", + "ASN1_item_d2i", "GENERAL_NAME_print", + "OPENSSL_sk_value", + "i2d_X509", + "get_elliptic_curves", + "SSL_CTX_set_max_send_fragment", + "SSL_set_max_send_fragment", + "SSL_CTX_set_split_send_fragment", + "SSL_set_split_send_fragment", + "SSL_CTX_set_max_pipelines", + "SSL_set_max_pipelines", + "remove_from_info_callback", "remove_from_timer_callbacks" +] # note: the following map adds to this list + +list(map(lambda x: _make_function(*x), ( + ("OPENSSL_init_ssl", libssl, + ((c_int, "ret"), (c_uint64, "opts"), (c_void_p, "settings"))), + ("OpenSSL_version", libcrypto, + ((c_char_p, "ret"), (c_int, "t"))), + ("OpenSSL_version_num", libcrypto, + ((c_int, "ret"),)), + ("DTLS_server_method", libssl, + ((DTLS_Method, "ret"),)), + ("DTLSv1_server_method", libssl, + ((DTLS_Method, "ret"),)), + ("DTLSv1_2_server_method", libssl, + ((DTLS_Method, "ret"),)), + ("DTLSv1_client_method", libssl, + ((DTLS_Method, "ret"),)), + ("DTLSv1_2_client_method", libssl, + ((DTLS_Method, "ret"),)), + ("DTLSv1_listen", libssl, + ((c_int, "ret"), (SSL, "ssl"), (POINTER(sockaddr_u), "bio_addr")), False, errcheck_gte_zero), + ("DTLS_set_timer_cb", libssl, + ((None, "ret"), (SSL, "ssl"), (c_void_p, "cb")), False), + ("SSL_CTX_new", libssl, + ((SSLCTX, "ret"), (DTLS_Method, "meth"))), + ("SSL_CTX_free", libssl, + ((None, "ret"), (SSLCTX, "ctx"))), + ("SSL_CTX_set_cookie_generate_cb", libssl, + ((None, "ret"), (SSLCTX, "ctx"), (c_void_p, "app_gen_cookie_cb")), False), + ("SSL_CTX_set_cookie_verify_cb", libssl, + ((None, "ret"), (SSLCTX, "ctx"), (c_void_p, "app_verify_cookie_cb")), False), + ("SSL_CTX_set_info_callback", libssl, + ((None, "ret"), (SSLCTX, "ctx"), (c_void_p, "app_info_cb")), False), + ("SSL_new", libssl, + ((SSL, "ret"), (SSLCTX, "ctx"))), + ("SSL_free", libssl, + ((None, "ret"), (SSL, "ssl"))), + ("SSL_set_bio", libssl, + ((None, "ret"), (SSL, "ssl"), (BIO, "rbio"), (BIO, "wbio"))), + ("BIO_new", libcrypto, + ((BIO, "ret"), (BIO_METHOD, "type"))), + ("BIO_s_mem", libcrypto, + ((BIO_METHOD, "ret"),)), + ("BIO_new_file", libcrypto, + ((BIO, "ret"), (c_char_p, "filename"), (c_char_p, "mode"))), + ("BIO_new_dgram", libcrypto, + ((BIO, "ret"), (c_int, "fd"), (c_int, "close_flag"))), + ("BIO_free", libcrypto, + ((c_int, "ret"), (BIO, "a"))), + ("BIO_gets", libcrypto, + ((c_int, "ret"), (BIO, "b"), (POINTER(c_char), "buf"), (c_int, "size")), False), + ("BIO_read", libcrypto, + ((c_int, "ret"), (BIO, "b"), (c_void_p, "buf"), (c_int, "len")), False), + ("SSL_CTX_ctrl", libssl, + ((c_long_parm, "ret"), (SSLCTX, "ctx"), (c_int, "cmd"), (c_long, "larg"), (c_void_p, "parg")), False), + ("BIO_ctrl", libcrypto, + ((c_long_parm, "ret"), (BIO, "bp"), (c_int, "cmd"), (c_long, "larg"), (c_void_p, "parg")), False), + ("SSL_ctrl", libssl, + ((c_long_parm, "ret"), (SSL, "ssl"), (c_int, "cmd"), (c_long, "larg"), (c_void_p, "parg")), False), + ("ERR_get_error", libcrypto, + ((c_long_parm, "ret"),), False), + ("ERR_error_string_n", libcrypto, + ((None, "ret"), (c_ulong, "e"), (c_char_p, "buf"), (c_size_t, "len")), False), + ("SSL_get_error", libssl, + ((c_int, "ret"), (SSL, "ssl"), (c_int, "ret")), False, None), + ("SSL_state_string_long", libssl, + ((c_char_p, "ret"), (SSL, "ssl")), False), + ("SSL_alert_type_string_long", libssl, + ((c_char_p, "ret"), (c_int, "value")), False), + ("SSL_alert_desc_string_long", libssl, + ((c_char_p, "ret"), (c_int, "value")), False), + ("SSL_CTX_set_cipher_list", libssl, + ((c_int, "ret"), (SSLCTX, "ctx"), (c_char_p, "str"))), + ("SSL_CTX_use_certificate_file", libssl, + ((c_int, "ret"), (SSLCTX, "ctx"), (c_char_p, "file"), (c_int, "type"))), + ("SSL_CTX_use_certificate_chain_file", libssl, + ((c_int, "ret"), (SSLCTX, "ctx"), (c_char_p, "file"))), + ("SSL_CTX_use_PrivateKey_file", libssl, + ((c_int, "ret"), (SSLCTX, "ctx"), (c_char_p, "file"), (c_int, "type"))), + ("SSL_CTX_load_verify_locations", libssl, + ((c_int, "ret"), (SSLCTX, "ctx"), (c_char_p, "CAfile"), (c_char_p, "CApath"))), + ("SSL_CTX_set_client_CA_list", libssl, + ((None, "ret"), (SSLCTX, "ctx"), (STACK_OF_X509, "list"))), + ("SSL_CTX_set_verify", libssl, + ((None, "ret"), (SSLCTX, "ctx"), (c_int, "mode"), (c_void_p, "verify_callback", 1, None))), + ("SSL_accept", libssl, + ((c_int, "ret"), (SSL, "ssl"))), + ("SSL_connect", libssl, + ((c_int, "ret"), (SSL, "ssl"))), + ("SSL_set_connect_state", libssl, + ((None, "ret"), (SSL, "ssl"))), + ("SSL_set_accept_state", libssl, + ((None, "ret"), (SSL, "ssl"))), + ("SSL_do_handshake", libssl, + ((c_int, "ret"), (SSL, "ssl"))), + ("SSL_get_peer_certificate", libssl, + ((X509, "ret"), (SSL, "ssl"))), + ("SSL_get_peer_cert_chain", libssl, + ((STACK_OF_X509, "ret"), (SSL, "ssl")), False), + ("SSL_load_client_CA_file", libssl, + ((STACK_OF_X509, "ret"), (c_char_p, "CAfile"))), + ("SSL_read", libssl, + ((c_int, "ret"), (SSL, "ssl"), (c_void_p, "buf"), (c_int, "num")), False), + ("SSL_write", libssl, + ((c_int, "ret"), (SSL, "ssl"), (c_void_p, "buf"), (c_int, "num")), False), + ("SSL_pending", libssl, + ((c_int, "ret"), (SSL, "ssl")), True, None), + ("SSL_shutdown", libssl, + ((c_int, "ret"), (SSL, "ssl"))), + ("SSL_set_read_ahead", libssl, + ((None, "ret"), (SSL, "ssl"), (c_int, "yes"))), + ("SSL_get_rbio", libssl, + ((BIO, "ret"), (SSL, "ssl"))), + ("X509_free", libcrypto, + ((None, "ret"), (X509, "a"))), + ("PEM_read_bio_X509_AUX", libcrypto, + ((X509, "ret"), (BIO, "bp"), (c_void_p, "x", 1, None), (c_void_p, "cb", 1, None), (c_void_p, "u", 1, None))), + ("OBJ_obj2txt", libcrypto, + ((c_int, "ret"), (POINTER(c_char), "buf"), (c_int, "buf_len"), (ASN1_OBJECT, "a"), (c_int, "no_name")), False), + ("OBJ_nid2sn", libcrypto, + ((c_char_p, "ret"), (c_int, "n")), False), + ("CRYPTO_free", libcrypto, + ((None, "ret"), (c_void_p, "ptr"))), + ("ASN1_STRING_to_UTF8", libcrypto, + ((c_int, "ret"), (POINTER(POINTER(c_ubyte)), "out"), (ASN1_STRING, "in")), False), + ("X509_NAME_entry_count", libcrypto, + ((c_int, "ret"), (POINTER(X509_name_st), "name")), True, None), + ("X509_NAME_get_entry", libcrypto, + ((POINTER(X509_NAME_ENTRY), "ret"), (POINTER(X509_name_st), "name"), + (c_int, "loc")), True, errcheck_p), + ("X509_NAME_ENTRY_get_object", libcrypto, + ((ASN1_OBJECT, "ret"), (POINTER(X509_NAME_ENTRY), "ne"))), + ("X509_NAME_ENTRY_get_data", libcrypto, + ((ASN1_STRING, "ret"), (POINTER(X509_NAME_ENTRY), "ne"))), + ("X509_get_subject_name", libcrypto, + ((POINTER(X509_name_st), "ret"), (X509, "a")), True, errcheck_p), + ("X509_get0_notBefore", libcrypto, + ((ASN1_TIME, "ret"), (X509, "a")), False, errcheck_p), + ("X509_get0_notAfter", libcrypto, + ((ASN1_TIME, "ret"), (X509, "a")), False, errcheck_p), + ("ASN1_TIME_print", libcrypto, + ((c_int, "ret"), (BIO, "fp"), (ASN1_TIME, "a")), False), + ("X509_get_ext_by_NID", libcrypto, + ((c_int, "ret"), (X509, "x"), (c_int, "nid"), (c_int, "lastpos")), True, None), + ("X509_get_ext", libcrypto, + ((POINTER(X509_EXTENSION), "ret"), (X509, "x"), (c_int, "loc")), True, errcheck_p), + ("X509V3_EXT_get", libcrypto, + ((POINTER(X509V3_EXT_METHOD), "ret"), (POINTER(X509_EXTENSION), "ext")), True, errcheck_p), + ("ASN1_item_d2i", libcrypto, + ((c_void_p, "ret"), (c_void_p, "val"), (POINTER(POINTER(c_ubyte)), "in"), (c_long, "len"), (c_void_p, "it")), False, None), + ("OPENSSL_sk_num", libcrypto, + ((c_int, "ret"), (STACK, "stack")), True, None), + ("OPENSSL_sk_value", libcrypto, + ((c_void_p, "ret"), (STACK, "stack"), (c_int, "loc")), False), + ("GENERAL_NAME_print", libcrypto, + ((c_int, "ret"), (BIO, "out"), (POINTER(GENERAL_NAME), "gen")), False), + ("i2d_X509_bio", libcrypto, + ((c_int, "ret"), (BIO, "bp"), (X509, "x")), False), + ("SSL_get_current_cipher", libssl, + ((SSL_CIPHER, "ret"), (SSL, "ssl"))), + ("SSL_CIPHER_get_name", libssl, + ((c_char_p, "ret"), (SSL_CIPHER, "cipher"))), + ("SSL_CIPHER_get_version", libssl, + ((c_char_p, "ret"), (SSL_CIPHER, "cipher"))), + ("SSL_CIPHER_get_bits", libssl, + ((c_int, "ret"), (SSL_CIPHER, "cipher"), (POINTER(c_int), "alg_bits", 1, None)), True, None), + ("EC_get_builtin_curves", libcrypto, + ((c_int, "ret"), (POINTER(EC_builtin_curve), "r"), (c_int, "nitems"))), + ("EC_KEY_new_by_curve_name", libcrypto, + ((EC_KEY, "ret"), (c_int, "nid"))), + ("EC_KEY_free", libcrypto, + ((None, "ret"), (EC_KEY, "key"))), + ("EC_curve_nist2nid", libcrypto, + ((c_int, "ret"), (POINTER(c_char), "name")), True, None), + ("EC_curve_nid2nist", libcrypto, + ((c_char_p, "ret"), (c_int, "nid")), True, None), + ))) + +# +# Wrappers - functions generally equivalent to OpenSSL library macros +# +_rvoid_int_int_charp_int = CFUNCTYPE(None, c_int, c_int, c_char_p, c_int) + +def SSL_CTX_set_session_cache_mode(ctx, mode): + # Returns the previous value of mode + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_SESS_CACHE_MODE, mode, None) + +def SSL_CTX_set_read_ahead(ctx, m): + # Returns the previous value of m + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_READ_AHEAD, m, None) + +def SSL_CTX_set_options(ctx, options): + # Returns the new option bitmaks after adding the given options + return _SSL_CTX_ctrl(ctx, SSL_CTRL_OPTIONS, options, None) + +def SSL_CTX_clear_options(ctx, options): + return _SSL_CTX_ctrl(ctx, SSL_CTRL_CLEAR_OPTIONS, options, None) + +def SSL_CTX_get_options(ctx): + return _SSL_CTX_ctrl(ctx, SSL_CTRL_OPTIONS, 0, None) + +def SSL_CTX_set1_client_sigalgs(ctx, slist, slistlen): + _slist = (c_int * len(slist))(*slist) + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_CLIENT_SIGALGS, len(_slist), _slist) + +def SSL_CTX_set1_client_sigalgs_list(ctx, s): + _s = cast(s, POINTER(c_char)) + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_CLIENT_SIGALGS_LIST, 0, _s) + +def SSL_CTX_set1_sigalgs(ctx, slist, slistlen): + _slist = (c_int * len(slist))(*slist) + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_SIGALGS, len(_slist), _slist) + +def SSL_CTX_set1_sigalgs_list(ctx, s): + _s = cast(s, POINTER(c_char)) + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_SIGALGS_LIST, 0, _s) + +def SSL_CTX_set1_curves(ctx, clist, clistlen): + _curves = (c_int * len(clist))(*clist) + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_CURVES, len(_curves), _curves) + +def SSL_CTX_set1_curves_list(ctx, s): + _s = cast(s, POINTER(c_char)) + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_CURVES_LIST, 0, _s) + +_rvoid_voidp_int_int = CFUNCTYPE(None, c_void_p, c_int, c_int) + +_info_callback = dict() +_info_callback_lock = Lock() + +def SSL_CTX_set_info_callback(ctx, app_info_cb): + """ + Set the info callback + + :param callback: The Python callback to use + :return: None + """ + def py_info_callback(ssl, where, ret): + try: + app_info_cb(SSL(ssl), where, ret) + except: + pass + return + + _info_callback_lock.acquire() + global _info_callback + _info_callback[ctx] = _rvoid_voidp_int_int(py_info_callback) + _SSL_CTX_set_info_callback(ctx, _info_callback[ctx]) + _info_callback_lock.release() + +def remove_from_info_callback(ctx): + _info_callback_lock.acquire() + global _info_callback + if ctx in _info_callback: + _SSL_CTX_set_info_callback(ctx, None) + _info_callback.pop(ctx) + _info_callback_lock.release() + +def SSL_CTX_set_max_send_fragment(ctx, m): + return _SSL_CTX_ctrl(ctx,SSL_CTRL_SET_MAX_SEND_FRAGMENT,m, None) + +def SSL_set_max_send_fragment(ssl,m): + return _SSL_ctrl(ssl,SSL_CTRL_SET_MAX_SEND_FRAGMENT,m, None) + +def SSL_CTX_set_split_send_fragment(ctx,m): + return _SSL_CTX_ctrl(ctx,SSL_CTRL_SET_SPLIT_SEND_FRAGMENT,m, None) + +def SSL_set_split_send_fragment(ssl,m): + return _SSL_ctrl(ssl,SSL_CTRL_SET_SPLIT_SEND_FRAGMENT,m,None) + +def SSL_CTX_set_max_pipelines(ctx,m): + return _SSL_CTX_ctrl(ctx,SSL_CTRL_SET_MAX_PIPELINES,m,None) + +def SSL_set_max_pipelines(ssl,m): + return _SSL_ctrl(ssl,SSL_CTRL_SET_MAX_PIPELINES,m,None) + +def SSL_CTX_build_cert_chain(ctx, flags): + return _SSL_CTX_ctrl(ctx, SSL_CTRL_BUILD_CERT_CHAIN, flags, None) + +def SSL_CTX_set_ecdh_auto(ctx, onoff): + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_ECDH_AUTO, onoff, None) + +def SSL_CTX_set_tmp_ecdh(ctx, ec_key): + # return 1 on success and 0 on failure + _ec_key_p = cast(ec_key.raw, c_void_p) + return _SSL_CTX_ctrl(ctx, SSL_CTRL_SET_TMP_ECDH, 0, _ec_key_p) + +_rint_voidp_ubytep_uintp = CFUNCTYPE(c_int, c_void_p, POINTER(c_ubyte), POINTER(c_uint)) +_rint_voidp_ubytep_uint = CFUNCTYPE(c_int, c_void_p, POINTER(c_ubyte), c_uint) + +def SSL_CTX_set_cookie_cb(ctx, generate, verify): + def py_generate_cookie_cb(ssl, cookie, cookie_len): + try: + ret_cookie = generate(SSL(ssl)) + except: + _logger.exception("Cookie generation failed") + return 0 + cookie_len[0] = len(ret_cookie) + memmove(cookie, ret_cookie, cookie_len[0]) + _logger.debug("Returning cookie: %s", cookie[:cookie_len[0]]) + return 1 + + def py_verify_cookie_cb(ssl, cookie, cookie_len): + _logger.debug("Verifying cookie: %s", cookie[:cookie_len]) + try: + verify(SSL(ssl), bytes(cookie[:cookie_len])) + except: + _logger.debug("Cookie verification failed") + return 0 + return 1 + + gen_cb = _rint_voidp_ubytep_uintp(py_generate_cookie_cb) + ver_cb = _rint_voidp_ubytep_uint(py_verify_cookie_cb) + _SSL_CTX_set_cookie_generate_cb(ctx, gen_cb) + _SSL_CTX_set_cookie_verify_cb(ctx, ver_cb) + return gen_cb, ver_cb + +def BIO_dgram_set_connected(bio, peer_address): + su = sockaddr_u_from_addr_tuple(peer_address) + return _BIO_ctrl(bio, BIO_CTRL_DGRAM_SET_CONNECTED, 0, byref(su)) + +def BIO_dgram_get_peer(bio): + su = sockaddr_u() + _BIO_ctrl(bio, BIO_CTRL_DGRAM_GET_PEER, 0, byref(su)) + return addr_tuple_from_sockaddr_u(su) + +def BIO_dgram_set_peer(bio, peer_address): + su = sockaddr_u_from_addr_tuple(peer_address) + return _BIO_ctrl(bio, BIO_CTRL_DGRAM_SET_PEER, 0, byref(su)) + +def BIO_set_nbio(bio, n): + return _BIO_ctrl(bio, BIO_C_SET_NBIO, 1 if n else 0, None) + +def DTLSv1_get_timeout(ssl): + tv = TIMEVAL() + ret = _SSL_ctrl(ssl, DTLS_CTRL_GET_TIMEOUT, 0, byref(tv)) + if ret != 1: + return + return timedelta(seconds=tv.tv_sec, microseconds=tv.tv_usec) + +def DTLSv1_handle_timeout(ssl): + ret = _SSL_ctrl(ssl, DTLS_CTRL_HANDLE_TIMEOUT, 0, None) + if ret == 0: + # It was too early to call: no timer had yet expired + return False + if ret == 1: + # Buffered messages were retransmitted + return True + # There was an error: either too many timeouts have occurred or a + # retransmission failed + assert ret < 0 + if ret > 0: + ret = -10 + return errcheck_p(ret, _SSL_ctrl, (ssl, DTLS_CTRL_HANDLE_TIMEOUT, 0, None)) + +def DTLSv1_listen(ssl): + su = sockaddr_u() + ret = _DTLSv1_listen(ssl, su) + if ret: + return addr_tuple_from_sockaddr_u(su) + return None + +def DTLS_set_link_mtu(ssl, mtu): + return _SSL_ctrl(ssl, DTLS_CTRL_SET_LINK_MTU, mtu, None) + +_ruint_voidp_uint = CFUNCTYPE(c_uint, c_void_p, c_uint) + +_timer_callbacks = dict() +_timer_callbacks_lock = Lock() + +def DTLS_set_timer_cb(ssl, cb): + def py_dtls_timer_cb(_ssl, timer_us): + try: + timer_us = cb(SSL(_ssl), timer_us) + except: + return 0 + return timer_us + + _timer_callbacks_lock.acquire() + global _timer_callbacks + _timer_callbacks[ssl] = _ruint_voidp_uint(py_dtls_timer_cb) + _DTLS_set_timer_cb(ssl, _timer_callbacks[ssl]) + _timer_callbacks_lock.release() + +def remove_from_timer_callbacks(ssl): + _timer_callbacks_lock.acquire() + global _timer_callbacks + if ssl in _timer_callbacks: + _DTLS_set_timer_cb(ssl, None) + _timer_callbacks.pop(ssl) + _timer_callbacks_lock.release() + +def SSL_read(ssl, length, buffer): + if buffer: + length = min(length, len(buffer)) + buf = (c_char * length).from_buffer(buffer) + else: + buf = create_string_buffer(length) + res_len = _SSL_read(ssl, buf, length) + if buffer: + return res_len + return buf.raw[:res_len] + +def SSL_write(ssl, data): + if isinstance(data, str): + data = data.encode() + elif isinstance(data, bytearray): + data = bytes(data) + elif hasattr(data, "tobytes") and callable(data.tobytes): + data = data.tobytes() + elif isinstance(data, ctypes.Array): + data = data.raw + return _SSL_write(ssl, data, len(data)) + +def SSL_set_options(ssl, op): + return _SSL_ctrl(ssl, SSL_CTRL_OPTIONS, op, None) + +def SSL_clear_options(ssl, op): + return _SSL_ctrl(ssl, SSL_CTRL_CLEAR_OPTIONS, op, None) + +def SSL_get_options(ssl): + return _SSL_ctrl(ssl, SSL_CTRL_OPTIONS, 0, None) + +def SSL_set1_client_sigalgs(ssl, slist, slistlen): + _slist = (c_int * len(slist))(*slist) + return _SSL_ctrl(ssl, SSL_CTRL_SET_CLIENT_SIGALGS, len(_slist), _slist) + +def SSL_set1_client_sigalgs_list(ssl, s): + _s = cast(s, POINTER(c_char)) + return _SSL_ctrl(ssl, SSL_CTRL_SET_CLIENT_SIGALGS_LIST, 0, _s) + +def SSL_set1_sigalgs(ssl, slist, slistlen): + _slist = (c_int * len(slist))(*slist) + return _SSL_ctrl(ssl, SSL_CTRL_SET_SIGALGS, len(_slist), _slist) + +def SSL_set1_sigalgs_list(ssl, s): + _s = cast(s, POINTER(c_char)) + return _SSL_ctrl(ssl, SSL_CTRL_SET_SIGALGS_LIST, 0, _s) + +def SSL_get1_curves(ssl, curves=None): + assert curves is None or isinstance(curves, list) + if curves is not None: + cnt = SSL_get1_curves(ssl, None) + if cnt: + mem = create_string_buffer(sizeof(POINTER(c_int)) * cnt) + _SSL_ctrl(ssl, SSL_CTRL_GET_CURVES, 0, mem) + for x in cast(mem, POINTER(c_int))[:cnt]: + curves.append(x) + return cnt + else: + return _SSL_ctrl(ssl, SSL_CTRL_GET_CURVES, 0, None) + +def SSL_get_shared_curve(ssl, n): + return _SSL_ctrl(ssl, SSL_CTRL_GET_SHARED_CURVE, n, 0) + +def SSL_set1_curves(ssl, clist, clistlen): + _curves = (c_int * len(clist))(*clist) + return _SSL_ctrl(ssl, SSL_CTRL_SET_CURVES, len(_curves), _curves) + +def SSL_set1_curves_list(ssl, s): + _s = cast(s, POINTER(c_char)) + return _SSL_ctrl(ssl, SSL_CTRL_SET_CURVES_LIST, 0, _s) + +def SSL_set_mtu(ssl, mtu): + return _SSL_ctrl(ssl, SSL_CTRL_SET_MTU, mtu, None) + +def SSL_state_string_long(ssl): + try: + ret = _SSL_state_string_long(ssl) + except: + pass + return ret + +def SSL_alert_type_string_long(value): + try: + ret = _SSL_alert_type_string_long(value) + except: + pass + return ret + +def SSL_alert_desc_string_long(value): + try: + ret = _SSL_alert_desc_string_long(value) + except: + pass + return ret + +def OBJ_obj2txt(asn1_object, no_name): + buf = create_string_buffer(X509_NAME_MAXLEN) + res_len = _OBJ_obj2txt(buf, sizeof(buf), asn1_object, 1 if no_name else 0) + return buf.raw[:res_len].decode() + +def OBJ_nid2sn(nid): + _name = _OBJ_nid2sn(nid) + return cast(_name, c_char_p).value.decode("ascii") + +def decode_ASN1_STRING(asn1_string): + utf8_buf_ptr = POINTER(c_ubyte)() + res_len = _ASN1_STRING_to_UTF8(byref(utf8_buf_ptr), asn1_string) + try: + return bytes(utf8_buf_ptr[:res_len]).decode() + finally: + CRYPTO_free(utf8_buf_ptr) + +def X509_get_notBefore(x509): + notBefore = _X509_get0_notBefore(x509) + return ASN1_TIME(notBefore) + +def X509_get_notAfter(x509): + notAfter = _X509_get0_notAfter(x509) + return ASN1_TIME(notAfter) + +def BIO_gets(bio): + buf = create_string_buffer(GETS_MAXLEN) + res_len = _BIO_gets(bio, buf, sizeof(buf) - 1) + return buf.raw[:res_len] + +def BIO_read(bio, length): + buf = create_string_buffer(length) + res_len = _BIO_read(bio, buf, sizeof(buf)) + return buf.raw[:res_len] + +def BIO_get_mem_data(bio): + buf = POINTER(c_ubyte)() + res_len = _BIO_ctrl(bio, BIO_CTRL_INFO, 0, byref(buf)) + return bytes(buf[:res_len]) + +def ASN1_TIME_print(asn1_time): + bio = _BIO(BIO_new(BIO_s_mem())) + _ASN1_TIME_print(bio.value, asn1_time) + return BIO_gets(bio.value) + +_rvoidp = CFUNCTYPE(c_void_p) + +def _ASN1_ITEM_ptr(item): + if sys.platform.startswith('win'): + func_ptr = _rvoidp(item) + return func_ptr() + return item + +_rvoidp_voidp_ubytepp_long = CFUNCTYPE(c_void_p, c_void_p, + POINTER(POINTER(c_ubyte)), c_long) + +def ASN1_item_d2i(method, asn1_octet_string): + data_in = POINTER(c_ubyte)(asn1_octet_string.data.contents) + if method.it: + return GENERAL_NAMES(_ASN1_item_d2i(None, byref(data_in), + asn1_octet_string.length, + _ASN1_ITEM_ptr(method.it))) + func_ptr = _rvoidp_voidp_ubytepp_long(method.d2i) + return GENERAL_NAMES(func_ptr(None, byref(data_in), + asn1_octet_string.length)) + +def OPENSSL_sk_value(stack, loc): + return cast(_OPENSSL_sk_value(stack, loc), POINTER(stack.stack_element_type)) + +def GENERAL_NAME_print(general_name): + bio = _BIO(BIO_new(BIO_s_mem())) + _GENERAL_NAME_print(bio.value, general_name) + return BIO_gets(bio.value) + +def i2d_X509(x509): + bio = _BIO(BIO_new(BIO_s_mem())) + _i2d_X509_bio(bio.value, x509) + return BIO_get_mem_data(bio.value) + +def SSL_get_peer_cert_chain(ssl): + stack = _SSL_get_peer_cert_chain(ssl) + num = OPENSSL_sk_num(stack) + certs = [] + if num: + # why not use _OPENSSL_sk_value(): because it doesn't cast correct in this case?! + # certs = [(_OPENSSL_sk_value(stack, i)) for i in range(num)] + certs = [X509(_OPENSSL_sk_value(stack, i)) for i in range(num)] + return stack, num, certs diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/patch.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/patch.py new file mode 100644 index 0000000..c0339df --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/patch.py @@ -0,0 +1,425 @@ +# Patch: patching of the Python stadard library's ssl module for transparent +# use of datagram sockets. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Patch + +This module is used to patch the Python standard library's ssl module. Patching +has the following effects: + + * The constant PROTOCOL_DTLSv1 is added at ssl module level + * DTLSv1's protocol name is added to the ssl module's id-to-name dictionary + * The constants DTLS_OPENSSL_VERSION* are added at the ssl module level + * Instantiation of ssl.SSLSocket with sock.type == socket.SOCK_DGRAM is + supported and leads to substitution of this module's DTLS code paths for + that SSLSocket instance + * Direct instantiation of SSLSocket as well as instantiation through + ssl.wrap_socket are supported + * Invocation of the function get_server_certificate with a value of + PROTOCOL_DTLSv1 for the parameter ssl_version is supported +""" + +import errno +from socket import AF_INET, SOCK_DGRAM, SOCK_STREAM, getaddrinfo, socket +from socket import error as socket_error +from ssl import CERT_NONE, PROTOCOL_SSLv23 +from types import MethodType +from weakref import proxy + +from .err import patch_ssl_errors, raise_as_ssl_module_error +from .sslconnection import ( + DTLS_OPENSSL_VERSION, + DTLS_OPENSSL_VERSION_INFO, + DTLS_OPENSSL_VERSION_NUMBER, + PROTOCOL_DTLS, + SSL_BUILD_CHAIN_FLAG_CHECK, + SSL_BUILD_CHAIN_FLAG_CLEAR_ERROR, + SSL_BUILD_CHAIN_FLAG_IGNORE_ERROR, + SSL_BUILD_CHAIN_FLAG_NO_ROOT, + SSL_BUILD_CHAIN_FLAG_NONE, + SSL_BUILD_CHAIN_FLAG_UNTRUSTED, + PROTOCOL_DTLSv1, + PROTOCOL_DTLSv1_2, + SSLConnection, +) + + +def do_patch(): + import ssl as _ssl # import to be avoided if ssl module is never patched + + global \ + _orig_SSLSocket_init, \ + _orig_get_server_certificate, \ + _orig_SSLSocket_close, \ + _orig_SSLSocket_settimeout, \ + _orig_SSLSocket___del__ + global ssl + ssl = _ssl + if hasattr(ssl, "PROTOCOL_DTLSv1"): + return + _orig_wrap_socket = ssl.SSLContext().wrap_socket + ssl.wrap_socket = _wrap_socket + ssl.PROTOCOL_DTLS = PROTOCOL_DTLS + ssl.PROTOCOL_DTLSv1 = PROTOCOL_DTLSv1 + ssl.PROTOCOL_DTLSv1_2 = PROTOCOL_DTLSv1_2 + ssl._PROTOCOL_NAMES[PROTOCOL_DTLS] = "DTLS" + ssl._PROTOCOL_NAMES[PROTOCOL_DTLSv1] = "DTLSv1" + ssl._PROTOCOL_NAMES[PROTOCOL_DTLSv1_2] = "DTLSv1.2" + ssl.DTLS_OPENSSL_VERSION_NUMBER = DTLS_OPENSSL_VERSION_NUMBER + ssl.DTLS_OPENSSL_VERSION = DTLS_OPENSSL_VERSION + ssl.DTLS_OPENSSL_VERSION_INFO = DTLS_OPENSSL_VERSION_INFO + ssl.SSL_BUILD_CHAIN_FLAG_NONE = SSL_BUILD_CHAIN_FLAG_NONE + ssl.SSL_BUILD_CHAIN_FLAG_UNTRUSTED = SSL_BUILD_CHAIN_FLAG_UNTRUSTED + ssl.SSL_BUILD_CHAIN_FLAG_NO_ROOT = SSL_BUILD_CHAIN_FLAG_NO_ROOT + ssl.SSL_BUILD_CHAIN_FLAG_CHECK = SSL_BUILD_CHAIN_FLAG_CHECK + ssl.SSL_BUILD_CHAIN_FLAG_IGNORE_ERROR = SSL_BUILD_CHAIN_FLAG_IGNORE_ERROR + ssl.SSL_BUILD_CHAIN_FLAG_CLEAR_ERROR = SSL_BUILD_CHAIN_FLAG_CLEAR_ERROR + _orig_SSLSocket_init = ssl.SSLSocket.__init__ + _orig_get_server_certificate = ssl.get_server_certificate + _orig_SSLSocket_close = ssl.SSLSocket.close + _orig_SSLSocket_settimeout = ssl.SSLSocket.settimeout + _orig_SSLSocket___del__ = ssl.SSLSocket.__del__ + ssl.SSLSocket.__init__ = _SSLSocket_init + ssl.get_server_certificate = _get_server_certificate + ssl.SSLSocket.close = _SSLSocket_close + ssl.SSLSocket.settimeout = _SSLSocket_settimeout + ssl.SSLSocket.__del__ = _SSLSocket___del__ + patch_ssl_errors() + raise_as_ssl_module_error() + + +def _wrap_socket( + sock, + keyfile=None, + certfile=None, + server_side=False, + cert_reqs=CERT_NONE, + ssl_version=PROTOCOL_DTLS, + ca_certs=None, + do_handshake_on_connect=True, + suppress_ragged_eofs=True, + ciphers=None, + cb_user_config_ssl_ctx=None, + cb_user_config_ssl=None, +): + return ssl.SSLSocket( + sock, + keyfile=keyfile, + certfile=certfile, + server_side=server_side, + cert_reqs=cert_reqs, + ssl_version=ssl_version, + ca_certs=ca_certs, + do_handshake_on_connect=do_handshake_on_connect, + suppress_ragged_eofs=suppress_ragged_eofs, + ciphers=ciphers, + cb_user_config_ssl_ctx=cb_user_config_ssl_ctx, + cb_user_config_ssl=cb_user_config_ssl, + ) + + +def _get_server_certificate(addr, ssl_version=PROTOCOL_SSLv23, ca_certs=None): + """Retrieve a server certificate + + Retrieve the certificate from the server at the specified address, + and return it as a PEM-encoded string. + If 'ca_certs' is specified, validate the server cert against it. + If 'ssl_version' is specified, use it in the connection attempt. + """ + + if ssl_version not in (PROTOCOL_DTLS, PROTOCOL_DTLSv1, PROTOCOL_DTLSv1_2): + return _orig_get_server_certificate(addr, ssl_version, ca_certs) + + if ca_certs is not None: + cert_reqs = ssl.CERT_REQUIRED + else: + cert_reqs = ssl.CERT_NONE + af = getaddrinfo(addr[0], addr[1])[0][0] + s = ssl.wrap_socket( + socket(af, SOCK_DGRAM), + ssl_version=ssl_version, + cert_reqs=cert_reqs, + ca_certs=ca_certs, + ) + s.connect(addr) + dercert = s.getpeercert(True) + # s = s.unwrap() + s.close() + return ssl.DER_cert_to_PEM_cert(dercert) + + +# _keepalives = [] + + +def _sockclone_kwargs(old): + """Replace socket(_sock=old._sock) with socket(**_sockclone_kwargs(old))""" + # _keepalives.append(old) # old socket would be gc'd and implicitly closed otherwise + return dict(family=old.family, type=old.type, proto=old.proto, fileno=old.fileno()) + + +def _SSLSocket_init( + self, + sock=None, + keyfile=None, + certfile=None, + server_side=False, + cert_reqs=CERT_NONE, + ssl_version=PROTOCOL_DTLS, + ca_certs=None, + do_handshake_on_connect=True, + family=AF_INET, + type=SOCK_STREAM, + proto=0, + fileno=None, + suppress_ragged_eofs=True, + npn_protocols=None, + ciphers=None, + server_hostname=None, + _context=None, + _session=None, + cb_user_config_ssl_ctx=None, + cb_user_config_ssl=None, +): + is_connection = is_datagram = False + if isinstance(sock, SSLConnection): + is_connection = True + elif hasattr(sock, "type") and (sock.type & SOCK_DGRAM) == SOCK_DGRAM: + is_datagram = True + if not is_connection and not is_datagram: + # Non-DTLS code path + return _orig_SSLSocket_init( + self, + sock=sock, + keyfile=keyfile, + certfile=certfile, + server_side=server_side, + cert_reqs=cert_reqs, + ssl_version=ssl_version, + ca_certs=ca_certs, + do_handshake_on_connect=do_handshake_on_connect, + family=family, + type=type, + proto=proto, + fileno=fileno, + suppress_ragged_eofs=suppress_ragged_eofs, + npn_protocols=npn_protocols, + ciphers=ciphers, + server_hostname=server_hostname, + _context=_context, + _session=_session, + ) + # DTLS code paths: datagram socket and newly accepted DTLS connection + if is_datagram: + socket.__init__(self, **_sockclone_kwargs(sock)) + else: + socket.__init__(self, **_sockclone_kwargs(sock.get_socket(True))) + + # hmm, why? + if hasattr(sock, "timeout"): + self.settimeout(sock.timeout) + if isinstance(sock, socket): + sock.detach() + + if certfile and not keyfile: + keyfile = certfile + + class FakeContext(object): + check_hostname = False + + self._context = FakeContext() + self.server_side = server_side + self.keyfile = keyfile + self.certfile = certfile + self.cert_reqs = cert_reqs + self.ssl_version = ssl_version + self.ca_certs = ca_certs + self.ciphers = ciphers + self.do_handshake_on_connect = do_handshake_on_connect + self.suppress_ragged_eofs = suppress_ragged_eofs + self._makefile_refs = 0 + self._user_config_ssl_ctx = cb_user_config_ssl_ctx + self._user_config_ssl = cb_user_config_ssl + + # Perform method substitution and addition (without reference cycle) + self._real_connect = MethodType(_SSLSocket_real_connect, proxy(self)) + self.listen = MethodType(_SSLSocket_listen, proxy(self)) + self.accept = MethodType(_SSLSocket_accept, proxy(self)) + self.get_timeout = MethodType(_SSLSocket_get_timeout, proxy(self)) + self.handle_timeout = MethodType(_SSLSocket_handle_timeout, proxy(self)) + + # Extra + self.getpeercertchain = MethodType(_getpeercertchain, proxy(self)) + + if is_datagram: + self._connected = False + # see if it's connected + try: + socket.getpeername(self) + except socket_error as e: + if e.errno != errno.ENOTCONN: + raise + # no, no connection yet + self._sslobj = None + else: + # yes, create the SSL object + self._sslobj = SSLConnection( + self, + keyfile, + certfile, + server_side, + cert_reqs, + ssl_version, + ca_certs, + do_handshake_on_connect, + suppress_ragged_eofs, + ciphers, + cb_user_config_ssl_ctx=cb_user_config_ssl_ctx, + cb_user_config_ssl=cb_user_config_ssl, + ) + self._connected = True + else: + self._connected = True + self._sslobj = sock + + +def _getpeercertchain(self, binary_form=False): + return self._sslobj.getpeercertchain(binary_form) + + +def _SSLSocket_listen(self, ignored): + if self._connected: + raise ValueError("attempt to listen on connected SSLSocket!") + if self._sslobj: + return + self._sslobj = SSLConnection( + socket(**_sockclone_kwargs(self)), + self.keyfile, + self.certfile, + True, + self.cert_reqs, + self.ssl_version, + self.ca_certs, + self.do_handshake_on_connect, + self.suppress_ragged_eofs, + self.ciphers, + cb_user_config_ssl_ctx=self._user_config_ssl_ctx, + cb_user_config_ssl=self._user_config_ssl, + ) + if hasattr(self, "timeout"): + try: + self._sslobj._sock.settimeout(self.timeout) + except: + pass + + +def _SSLSocket_accept(self): + if self._connected: + raise ValueError("attempt to accept on connected SSLSocket!") + if not self._sslobj: + raise ValueError("attempt to accept on SSLSocket prior to listen!") + acc_ret = self._sslobj.accept() + if not acc_ret: + return + new_conn, addr = acc_ret + new_ssl_sock = ssl.SSLSocket( + new_conn, + self.keyfile, + self.certfile, + True, + self.cert_reqs, + self.ssl_version, + self.ca_certs, + self.do_handshake_on_connect, + self.suppress_ragged_eofs, + self.ciphers, + cb_user_config_ssl_ctx=self._user_config_ssl_ctx, + cb_user_config_ssl=self._user_config_ssl, + ) + return new_ssl_sock, addr + + +def _SSLSocket_real_connect(self, addr, return_errno): + if self._connected: + raise ValueError("attempt to connect already-connected SSLSocket!") + if self._sslobj: + raise RuntimeError("Overwriting SSLConnection?") + self._sslobj = SSLConnection( + socket(**_sockclone_kwargs(self)), + self.keyfile, + self.certfile, + False, + self.cert_reqs, + self.ssl_version, + self.ca_certs, + self.do_handshake_on_connect, + self.suppress_ragged_eofs, + self.ciphers, + cb_user_config_ssl_ctx=self._user_config_ssl_ctx, + cb_user_config_ssl=self._user_config_ssl, + ) + if hasattr(self, "timeout"): + try: + self._sslobj._sock.settimeout(self.timeout) + except: + pass + + try: + self._sslobj.connect(addr) + except socket_error as e: + if return_errno: + return e.errno + else: + self._sslobj = None + raise e + self._connected = True + return 0 + + +def _SSLSocket_get_timeout(self): + return self._sslobj.get_timeout() + + +def _SSLSocket_handle_timeout(self): + return self._sslobj.handle_timeout() + + +def _SSLSocket_close(self): + try: + _orig_SSLSocket_close(self) + except Exception: + pass + + +def _SSLSocket_settimeout(self, timeout): + try: + self._sslobj._sock.settimeout(timeout) + except: + pass + return _orig_SSLSocket_settimeout(self, timeout) + + +def _SSLSocket___del__(self): + _orig_SSLSocket___del__(self) + try: + del self._sslobj + except: + pass + + +if __name__ == "__main__": + do_patch() diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/libcrypto-1_1.dll b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/libcrypto-1_1.dll new file mode 100644 index 0000000000000000000000000000000000000000..7981875d201d47025c7dd75cdb01b359a0f376bd GIT binary patch literal 2508288 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%0Abfdgvt@(&)JidjX!ZMVqm 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 =r%c0Ga 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ΌB zgz)OF$Ion-t>%AA@Ei|tx3FDwFMXolNG~E&^j+ZtE+XgZ#=1_1TBUHpl1a-4%i9SW zp2eNtgk)fS*!A1q3(TcjHDm=AfrE@lu?DpP$32LH;&n?wtedJ={2MLC;2)u(Avdq5 z?5ySHE|CXC-T9lP>;uGaulhcWfj}J{0olT8kro0iF!67bvJRaZIDaumSC(4SeAZGt zdmiz`HgSqLq61_zD;wFpZJoIl+ZP(OyFZT~_TmRCm-pfa$uZQ)X5cd&!F<2^{R}|~ zkbQ(%$zzSO)4(U)_abyGLeQG@U@Dp7kEv%XFec*r_bd8rqI7~B7(jA-*BI=iOMA{_ zxA+3ypOvd0uzV%?NuT5^W(^tR>koqV&VvV6zxs;Yrt!qKCHF5XP?*=hgyXH>Vfj*@ z$Pn8n9xzmKM=4Q-k|+O~sn4S(>*U)IB=_1A#fd!?m%K@h2UAoo#P5_u( zI90}ogHqUL;1nx5DNMcc)i;uu137eP;^JRWO$5t1fS8bOP(3@Iwev&*^t(Tusyms^ z1scgchgRwcHDo9l13g{@#w;4f!4r1C5X6oPsU{IWIZQYSIdcu_wRfQ~ig)2e5QHiQ zWyzC&Ns9kFde)%M{3aF;iHM&}s#fP|ZY~r0iz{U_(5fp%X3m0D=9@h|Of;U<4PF!k zq}ABQ%S7zRBNSwCzu;t4&qeuu z0KYi@@l~sS$FDo|7he24TSj|k@?=J-H!#P5!HztB5k9*oPo?5njgA-e-jj;w7vlj; zN8Ww-0Bzg*CFkWj%bJnHsEL}~VR_c48exJRxl_Rp1RMfM*srqE(15T%`= z4zfbdj(mb-d!mcGtOpNtnjrR1)=U})9b--PRaE(!Mw#MmhA?Q6cO@3qhlLAJ6A0Jn z%XtmA8eN0D?TxK+;?Z!UnL21Xn^N1 zd}(-jR~68ZHqlm7u^Oe;np=1aBmg7E<$a4E#GUOaTz3mAK5U$4{+0(xb`{?SXk%^A z-ioaPp34DR>46uMQ-wG8SWJ@O;9%Da;mCyzasxWZ{A0R->aL&WK8THyCvrn_xf9QA ze4`$ITzI%uE-H9Lstdr7S~go#okq<&R^?no#i!0!-^aR%?KGU4TLE`*TZL*IZa$Q* z_fi}+`c+g|nWxKx3t;Yq^H_N0O*na=?#O}1xmg}y(YS03Mc#n3B2ZvlHVomeBJ8k> z9Fd`B8!`$W^s6Q-J9j~G7E&FT$UL>S;i>uNe!$gj?ys3F#vZ5UvImG)H4UF;;x(2S@5T z<|c3e)s1aCMgX%6lvw;=tlPDGxVb((5aICo7>GAzZ|~`vX$`^w=y3Bp>GWzzf6PuV zMiZ{Mb1;5zYkjbI7m5X(E?~E*H+Bz9g7?a9xCaTY#@QK2P#^67TWnOaM>3g58;lHF z5`IBTAXrMn)WQkU>~0*nA4dKRDF9NAY;0v#jNA_?cV%V4+#Jr~^W?VS{^Mc!j+`cX zRp*F+lv9sNWW#l^u;?lu7RdnHQ{2Lv;ydT%r8%Y0F`4OOx?olsS7_; zesx$TNA4kMhS0tf4THzSaqPF*@D4QGd*FTPqzV4pt=#}^XU|Nx$>(}su8W_%@N|OqDR|!6;Vs4wpLq56*V*m zU2TyM`ku`Avl5|!ELJ_@;n9y^W4yf3{6?DaFMx|Szj|84Z>`e}IgS6?MrQV4tgSEi zmmHCArVD_bqri<>1=8Gmn=xgZ)pk&n^dJSnf0etq%6<=3zH>U*MThNoIMW%Fl>Sv_ z95s??tSO#~Dc)r+YlgF8M!|9@oH9iuKklwGQFD}L03e7vxDOGTydIWG%p@MAKuF~Y*gjF$ldA==`#VS< zQhhnlFM#PG58d{M&z6VO4CtOauK}6p+BzrVJF8;3zQEhj>O<^M;4`j4^}kqf4%y~} zP`FPWp3cLQnFaYYKdXK(;rxK45riN29Oz3M)D40(H?HeM`@u23d^r~$R8zBw_6X{> zqU`tU+*pE02ZaVKBb~`1tFOXl9w96&u*DkEj8AN!-SUZ>hvNVz%m6?Z3-B=AH?T{< zpdFYRa350In{?pzWX&r`4q0nAm~NU6rm^XjJ)0igvuP#7dC;Z>%e~?1t*7aoUT?jr zO*-Poq)@aqUh@gB1xN;w=IZKnzi@&i`g28)(NQ&2V*V$2iM9oK3 z>f3|wm6>m&nHC-i=QtIHHp1Ex3f9GI-cLSX@sJVYLo?Rd?`yRM{!Q`}s1t_S$sw%( zf=U^Ey+Vy;S;^QC>Ya_^!s7sumcg`bU3;oS`#`m}CuZkp|1Ilts+u=PKP-MEc4@aG zGA>b-Q&Cf3#Q40Fb+*fTw0Qga{6u3;YxRjqCNvkguUg(RpASCwcnO%Up_ah z+6GjMVntHS-@EokyY`kn7az8_+CR&&*5P=7#}vGeG#`^9Ib^()bhYeha^uVxIM_r7 z#m20My7D&hvo4ru_M;fC?f|H{$X#c3Cx?_sF{&*_B!^u0eSnHbrZIJfluH5*Od_{+ z@!yHG;}$8J$~X1eXc$`R3Psh1dv8Zx@uO-m<0np(xf?)#!aK(=0Pl2kn`qtws2!{rGGU_V+p7P&M|c7&(+BO?GhLzJWko_GPk?5%=ZQgbFF9|ON#kE6x z52Yw>5_+pCZUHdCN`Zn!4R0jZZDFH)nzY;$Z`A5(S(;rueb~`pmV;tL>W%Y&EGXn$ zIQu%y>`b?Gv9xqnc1us8(iClHn))!5ZpvH>nnN#kp%)F(CK{h66jcp+fu{sb!ib$? zgOq#gE)L*)KpNZPr{)*X|0+}EXJYlsC)(@ZQRq9&{)~;m9Ao1&Cv4QsoVM3o#zx10 z9md8QcqDO406t2*5^%ydNo;H0fNikQ!~5sR<6s`RuwiEJ0J@!<S|SO*e-ZC}~x{>mW0DpGTxV1{%GIe`xf z`m6_W%dmB5YaoXqcWa;zf4!}NTwaB6wFdI|i}-y0dRu2XTLXP#54mWe$Zp2EaK~vD zl>_KO?Bi~uxdpN~kXo9U>B@Z~wz)6f2YoTt-FL<5@MVAfdqdpD#*35F+y3b_Hdf&m z$m_O4E<41Zvyk++OqQMLED{>9lZYr=?XQ%+{TU40AEb%AYQ*uzzuLP_dWgnomBu={ z(W@dn(e5=`U&W~4G_WL5YY$v025wRZn)bk7x3O_5#;d~ExLn2yJ(!vvFMh~)F_6cx z+J#aWtEn10j^-4_%sxo`788R0;9{Fx7~@FkAYEd0&bXhTQ9V|?ZTJGdbjOuF9L1` z+z3oUpv$XMORZh3q?K5o)EeZLS7t`F-_trPx3w?6y{)x5tqc3`^p#?E|J_5}4*c!e zHN^ck{{D%-zu@m_{5^`lW6_(V@HY&9gYh>AfBE>^_b$rd@5^(t|C=SXzHaphkP@~e z7$!F@5=!sKIV|45b2b=v?KAG$iIK%jSoa_graLfrkP;lWf5dHVDzEL;0Ry&K?R$5O z?kL9Z=!c58759(5;p8l_o;L1kv$nUrHgNBgqqpID*np>tpR~4*eggkuIu;L(bvli^ z{%k!5x_4Vow7oV6`5tY1yU&2e=lkjY!nS|72kgZRUKl@dK>LETp)Ugc{@c}k z`w^ucug8Yj;{}}IMSIhEFGmD>xbfCrYpWhDjuS^w@U_F*w)d&gkB@#XSFjE%Nw+h1 zaPITQU5{8F=04o^jxpdvBHMZ%*`Dp0={aWF-S%pqwzmfj*lj&$ZAYFbM(^mE>sjXN zYGS{S8=UtsEi6q^so(#04Op3~SkD~rVkG>dmGzyblMeC`2{ zK>jJ@*tvqR)lyI$!d5HyWa0Sa@m2wx9dP;T-#SGvU|6tPr}Sx^kqhc{>a;1g%rS$j zS?iSi)){^ORT?D+znem^95msY{h!u?#vaObSkGIX6R`dmv+xgg=4YsiPUDPDEQ@$3 zr>T(kKiF+|S?{iQ9t(FZt#H@!Hr%x!-eYWx3{O@(f9WXe(W{J&IY`ZY$k@1f{o3of zO?D2SVr<-1@%-x7(J@bKYu~m{bN9Xgw|lSlH8wg&CCW$LlLQD)e>8f>`pa0t*to4V zA3Lcd>HosmSUonGXiS#1>FOkrj$;0i_#?U5pDOWBqOK_;FV4tGG=5@i?2|ZipEx&+ z&vHuse~*2Trk8@DGJkz?+YIcMH#eV#u!KP`5ib~ZIDn(qVxXI-^A&G}gj){z2z$Mj zYmqq>DUIT6p=#m2e2%}I zv_(#A6PHM@?rhqEj+%7Wo3;RHIp}M>v*;w}P&nJDX>|?_Ff#U!zTm}po~EUeQxyX3 z!g=vh`aE_~5$<1*MlUvU z*0Y$!g;-snbO7CvjIeu{0iTmogxYpmrywZwwo}CTjZ-xD!%o7B6!NRobkZ7@IEPev5xy#wEVt!MYa2BftEvN zd0K17<7Y@~?F9S`ZLOV%pCejpu^*g@jlv}S3~Q~OjGy7*oYvY?5g5@>*IIj;dOh|| zGF;ivF?Bxbb*K-11Z~Iw=vXkqX-?326#5xHzY&JWak}qj}t(h*SVT%e@ z*N-gE-8x11#Eb#3kz-ncqz$yw21z-?nrSyTWw1_|fpUi}M51Ws7qree{GewS|Bg|V z^RDpe@a~1n8tNPG=Rq4((2<`ry5@Y?w8U?H{2hU#BjDQi(mmJWDpKQ|liVEv_pVo; zYCq6%5uPx#Z(yl)mx)W?o+k=g3;?0)OIUd9%xK<-5wNCuteM_~ryCX@IaO9iyw206 zM&`CzGv~*r(pq^@qOLHOtXw`i;hYfbu3SE%A>r&_A5_12Pe2wZb5_llRri5P)QIHb z-67;?MGm+ooLU&HQ*W)~up?{_Jcup#?c#kZEVcNpy=`yj79(Z~y@=35Td`?gq*ND!Kny%QE zLR;te2fO&8QIkFlT0Kz9uU@tn42B zn`cN#8!SQ^Qu~Gz6|`SF3WMc=Q9(hnbhfI)@t?*}Ru@BRgB%^wvnZQBwDIVjOXf3) z1=kq8$)#`v$A8ta(ESJ?QJ=(nB;KP2r{fRN#~}N~KcM3$vY23;aZbV{V~pb zVsJ%^h!RyalOMc<-Pimj>h1bfhGu&Y%^`j3{uQEssQ&=;`Q~IccwqYMpGBWv(PtMn ziSqaoB>jiNJ6weahF~NFK;NC+I{>Ry)gZGZ^{#jg6F}bF}{E z{3(Fp`Cn?K{>fKCB@(TM*O2AF6NaSF>c(@M45%lEIUMvs-7U1`U5IsKJesG`YB>jw z6yjDlgZ~0^uuzhu=-lCe%Nz!|6w7 zpxJ@q-?-u?oCrYq9~m&zE$RWIWjSJGQt&7p@RcYxZoS2}xVuh3d6>l%tQ*fR8_gFW zr(NjOR3Q}btCc6QfD36ZWaHzOs4Fj=uh!x4$etJ;j=QOCA!6*qzF}&WCQno=IAdV|R=Dm% zA+#ejX@SC2Yy{^eo;+ZsR$o0VaHH}!AY3@DJS=Yh3@E_NK2Ana?DYhQ5A%)YE0BbS z3()XvwI!dJ08=!Y&%|C6qgRk`wA3PKTjxnL*(4AN>k*3{hGF-q*Z{)g)bJ4D#S%Tq zl@;BCgd)e`)7JqY9FCrC_!h#Y{`KgGPzM`csC{xX2%W_?*HHHx_?r#7U5vjO{q*l! z{fD}b$MD-vprpe%16tDqiRb$v$M%1b3%W;D1u8TS6iJEGm6>xPb*^wK$Yf2t zp^qofaw01^la`0ny+&pxjTD9v86Q$U-5o3{xNGv!0GX1QAZN7)Q`4#L%=A1hQcpo1 zLkB{s#BrHKzq(T=ZlZD;HEd!BzqB_A%`flkgZ@%V!b_Amg5pDHbeO8D1SUNQuGEZ% z30mV}=;Tgd1)F06e!&4Gk!? z>3YQe={Ee+byS17S+b1J)LYIhFENY4Kis0i$_ze2urB<>!`3m-Ml+wBmUuq%70bl) z+N?kYlQIM)i`RT#8kwKT_CzYJ&~!b_j#XyT{+LShnI1{=XVR`orTI;tq`5L_t5Ru2 zW~rpTDN?L}yD*hjVpdApj!fEAq$Q)JT!%qT4s{u%PLLweMV}XPrU#cbz;PLjgO--6 z5?N(jP?34cUexD#GN7PLcnW)dLTZSbY0YA_$CM*7gAQ58;GgK(_2z{F!B!zGE=3#K zn2(FjN>|t9dUL*{-;_zeA)W5*8d+}!CA}q+zAlwMo|6MFcbI>Ga1~NrhuPy5Id^L8 z<6Ps)daYk|*N|wZQqRc#e7B!;xMHVqSr1%L|@MDtJgbz+4mRa+-PP*Ur#VGpYB9U}uS_MS(YWtbRR zFcG7%>o7D}vc4-5-E&aeV)1LONO7x1}fLx~{1}CFVbsLm9 zLG@#lK?&GvV-cJbQlDVS0c7S6(-4rjQxJqT1T<}aT4%J{;Q~*9feRHCg}EI{W9C7I zj{F${!#C0~RF!PAmbiE*bPehxWJlVN-Au?%G;c~b`irbaKadFv9}Va9Zqll8!rl-C zZ%G%tKC9q;se;GsSFjSV#ke{uAr)d~*T_*4%mS<;ENX9I(41zDEyf#S(?%Y(!G*bp z`HStf*$=6v1C56B8`NE2AMvP1uGy)F&TezR1gSMY!%A;vtH+z1kyJDv(SmK0$e^frK;|bQ=TdF!0ldX;9_#twD`aP8J`T8t3cE1+2foMGZ z>KKTMn1(X7G~e@L&*Q|C)F{bTDK`$tj6t2+B|BglM(+2J$HzRb)Hv3u+)Vo)Y1sMn zs#%a-%WqH%?SQ@X<@^khFbD#Yw*20TPf`a2FxWM4o`PfjPDlC@wRkq@&P@EZh;MnZ zsZOm$jqzH^OO2@d%^c2hO!159Wv>V??H$do7Em?PU+s3u{Oy_#8s@B0->a2~Abjns z!(<+~(KWm|v;_4+etTn-l*Z&d7S*{S+L8yUd8uODOaB?Ph_f;u5Gs#W=_B=LqUu zebx@6K{@J}BqV6>>cv_%wW}W^x<(;Eull8+CPjik5(rRs_1_}#X6@=rW?&@1-j39} z`ZJ&v2c~=M>L06NoL%wE{VGoF0n+{bq5+Mc)Aw}BbFgl8Joe#&751M0VX*ybA9UVO z2^2ylum}Qvwn|{AWN}d?uvE6-?uG1eS|c##AQ}O0xgZq6i6H31{KyE9CtIDZ?gBU< zY>?(F5LsRM(HHb|X3j6zLA@Wi@B=;ml!+beOIn*V(Ch{=gz0{&kN{Byv|)^915~(Y z*Z`Kupn%LAxjKiCNMEplFgzIiC7s;-I_dW8vPbXvQ*N@M%>MNg2( zb%<;z-lblJDjH9~P;OO+W9>pwj~}Kj1(R~3d9%3r<7UP!))guSSwy^iTol{qi40e7 zVmBjfA~HV|R2ABi8>pKFMmoAqtrsQ%iK8+yL>-Sl#`_}iL_EB7Y$dycWf>gEsQC+G zPC&010}ohTuKp3U|eGz)kVy5K!6g zG{KLM-a&O0T0c?n9@aYyQ9-C|k8Imz(_Y5ILu&IIlr!Cy(aiIh2K6o^H(*nb%MXU5 zb+Yz45a@SBx2UgFsiad_#AIa9GvY*LA{@`fAaS=|0KQG}PW_@v>I){zl>ZY>AGh3N zDS+*6H3U*MOQdy=R=3j(2m_alOvbyp zk)y&>w8Z`cjH?{7gq>=ltR&EPV$i4x^Qfnh3IgN9g7Kk9tu@3f>txUOxwWa!}ob8G-DuK9uYimzeG8Eg}!3Om0>%mP_Qo zS6?y)hwgB2@28!xmIl>A)PfqQa{P+B)KXDaw?W^xFGpx5i`-q!r4klpt* z*jlTUqJ%Owns=zb%P|2p8O<{RnP@+u2=ql(<4#Xsa}v&nyDl*o;RmnDi=YAC-xQS- zppbe!6=Q4|m?~YL_+}rYx=R{_=-!-Zpc89{`D>}_)=WVf0a{#ePEMBM)m_Zfu5oDl zr)I0xK;)qP<(+E>7v<4s$05w)KkJP$vzApLySxs`K@V&u6{v>$KV0l!CdtC15`?Ku3 zAl$CQ%r{Y_E6?nflDB6{@;;9Z>@|o@K{!hwq%7I|N94e;_#AgrF|^4GbsRh`2Ti}| znPHwT`3utd z@j?@YEY|oMG*YN>k>q#-b6GmyG}Nwfr;efG1K4n!&6P*>;RXjWAb(K>d|~125a|6s zodx%@PkNs`3p}I=bHfu$QmDEHk{LWe0y!7JT;(4!VEmd#JvvsT(GBWEef+Bz@MnNN zUTlv{MD`sxqX7EIO)+vg+;ZF6rUlfa^I1_Hgw+kV-)#p2KG)Vz2bU zCbmyh7983iT!w85=6#3waMDL!$dDI33Kh64M{K1tCprW)e##q3`t{d!?88IBhwRB% z0}1Bn>?0x!YNgdhLe3SOT9C==h#l;ezd)V++4(o!2c3PE1cvc-nwuVhH^Awlqwmer zHZiSte$S)GKkUHynUGC$bne>x- zmbcp%BQEvQ-(>;JhTm>qr{A}Ky4}7`zi$6@yM3L$dcSn7%}{?*2O1oXkLWiGQI7jP zUxPeZlj5|oRfP_tc?RZ0Y?TioyhV$U8=-L$@*q?qAumE-K`1m~l`}e<=a4tOqa|l2 zw~n|6?@cautDCTq393smT8UYG$Zxha`NLz(>mG;evzy6iuE#%M-H(bInIh^!(ZJQK zDk%9a1>K!rjYAMZONlyO2RzEJ1BGgY4)|0HSUU1}siQxEx=5a18qIzkB;3PvkR2SP zgY020g5>8PJ0T-2_ox|AA7Q$Kg%1x;5IDo<*+9Y@q+f%{j1z^olGO|kPu?%%$49Xm z4}{xN?Da7w%L~cQW!&5a5)@E>k>bL4rb_t()MGjl+64P~wbv;UaUzaG` zGXa#`G(*gOIT(Kf;|6h$m$pmQHBu4Hc+m80UD0oPR5U|Z6sY)M^#iDdUSctTKEPeC z4CytuHZH8ron6?i+YKJQ_^J?_i|FQj$!*Lz5>16##4XOQ3{$)mQdJ6d%Tx zCGlj3J2D8U6-u}bpmg;MpD$nGH7TOl=o2KWTfqT?0?A4d17tt246ByMbjxI>Q;lzNXkg0h}OYB0kTiia@fkC;Zk)qWKb-c zUyPkg|kHLM(ktk)ZafxW;km2RW%2RivwQbne{+jWv(uCP>~$<@Fo zYUiI!WWPEa3y{bZM)MlvmP$Z>$>rX9l}O1kMl(;V>eZFG%uJ#jDSJ1K&{Y`%{)VVe zJh4@N(Eeyh!m(9F2n~}ECk*!rY9gd_jqtvi{ZS5EuSdH84s@lqh>!@O1+U}bMcw3b zLS3qS(mM|GNbTH$BOG3x0pS%Wi_szVES4KI;;UCTij*OvdL3%YZe(Gmk#*7tpRq4h z_hQ4UUb>}T9x#a)-9vB0i_k;Yhk~&v{-DiD+yqpwsZ{2lZiBIcW8|SDX z&j|vRU{|qM_I3p3fIJ)n*W#aepfXF-D*3Iz9L)72gKETQ6atCjM!>BpRUAtUK?7sf zfWl+jpq#6;l~<55vIMd$*cl}J;Ga2oW5s1v`uEF-bT>}msYJ1=+{ssZ6=(TOdco(U zyFMq~xqrHr-{!vvVGC@e1_Dq(i%mQ5R0jJI#!#f;`ZkeYC#mBRb45Js%eXr_S^XFm)Dp6Ek5o&e#6l;u+=*r7r-Mg*D^WqnPp zfubTviO-{EN)pLxsxlEx;h02MRIf_YNkx!0kQr$ZM2?n$Ayo~QXn&nL@fA%{J45QR zKg$qVa4vyWtDs$53w*KDkNpP%)m zYx~o$v|y7H8H6%-h{Qn|Xfm#N`d0r1{os7^)u~57uyx`;xn8{|XIC_8f-UU(IK!w@ zpK9HYwtQ%BP%k64UVR&)UY&YX(s1{SRuKmr!2P?>BgS-Wb~)8ZZ?9w%j=ib!VzNU}#eidDLUib|!Cl zrf!V8Yzw&n8a;>G>-jwKUW7AML>3`(DfY8)Yz(V?j3UZ8uvad{pPy&0i}1J^v~ZUW z3ePf|{|ffJvU3~m%+Nc^m48H_PM!QW%_NlZof{FMuELq6?2+N%+Gt*gsCxCwBsh^8 z1Obcus9HJgP?7pbhL%&Mlsvl>*{$}nhXJPhM|(fE-mrGS)7^uMjio!`N@mGc1e7E8 zvD>)pRRC%fjVaw-2ycmFV5GVB;=F0GZM!Osq9NA<(BhqojeSqs_q$QFEm7q}>CGtg zI8wGOHWqI)ics^5ZHtY+KDyZGYC{Tqe_lBaxi=@OT#`GJ)z0=Jilnp7)maM;A*;!( zpP3I$uZ*?1D;_njJRQ;jQBd*dr6a&>+!c=}rsNo3g;Vk?r&U#KU;Pdf%l5?9xE)bQ zHLzWX*MdVB&AX^XSObUXZgmQj#PM1hU^V|)Vm*vqBY9>nkzgs~yUe%z;NaboJej#?+y zbgHVA9}=0Ru-^a?Vr?>-$0DCq3u1^2GLM<+aKnmAcKWXPw@U8?B|7M@L!AfxDrlyh zHGcvvNw8i`>-R=-Dz!O4&FYsicS@2gJSy-MsS^yzXnqFyc;mlXD7+HXUV`czSP=89 zg@s+d*r%`yu@?Heh9+wEktdg^6NUFut}3^@VP~DXs}ta!ep`?L!vhyGVN1;aX4ff) zoR15_FJM!9Dn7V$BAg23S+#{-g|U6Cz1G)tSR!E8{`64UX;}_jQEqZbm~buuZUS8o zs_9NlBuvF1&I~-VB+G7eAnI0kLD3V&3VjUV0T6}gDn*VGHB{CEJ?iM36}fF%ZUkGa zh`*Zsqk_(pT;WTuD8#czK=%b`jB$0z6;vMjrDkv&0`d#!R1$$t1O}uP;=uv2M-VVB zM`B2w|Fo@Ni|y;TtY2)OVO+im%`&$z#S*m~MUr4N`oyjI3={*;f%?eV#M`kx6hs3h zq70vwyDdPp0@{+!>#2r7AhyqGG}9#`a|a}eZYb5;V}PD*>&OR^@-||w(Xtx^GA2oj z?d}VW7G9cCcK}nU2DfI&q)XJ_O5aEZ01PS;dVc_!SaWA7Dg}y~h6B8EVq|B^aX}^{uh}sR zky2Bkj4RKfDnLd^T>!H{A>NA%tTPHhJn`DX_!*EiAbG$7v9L~kA|xaO@&QIt4M7TO zD@7`n+?O+{bTbYZG0s?wk)yg!Jua#S8ENsIhzUq!I7DkVlT-?s1j;4I01sgChYbuL zK3Fex3ImDRbs`!yR@T+2OEOh1MxxfV-KqM+aFzQ2qdACJ%%hO{0P~Wdgh08;6<)RA zd3LoB`v$E7QZI}C#i_PqR#`h?{@KhkQV6>%yqJb44eW~c3a=xtz$8=;L-`TgBHZd# z^n%l&97!-|Rlk)aftfpqOfTUpXDuQ?EDxr z4|JxnAlc|rv!o=rY~+YqzAU}^u{!mr2v<(I1=MwPow`?ln;ZwdIOm${04MfNZtPef zf0XUv;VbtW4eCarWoOr@@Epl(-h`}n{<1ylWjRyyl;N)BJ^6qOUIrjG(_4V}5V;J5 z726U#m_TztDdhh}{$IgB=h;wYW_7kR2V!0J_pM+c+21>%kj?rA^K;@J<1V9(=libd z@m`WRsL^>3sN8`ZgYn^-B3qC6V4EJ9&VqY)9930$02&p24fzjM1ZXG`n8dcGa=2y)@9O1pA2O%d>z^UclIi6RAXCd@vO5ryN*Wla2G0!aSl0)a23N7L6Wtviy40 zkD=-3E79MRU!PhpzkYR@{1&N8SW_w zwH3d5$A1UJ*<@o-O~d}hOmNmRCZxv8e#ZA1dZR29e`XN=Z|KjC>%G;T3(nTpvOjYlt?`*UT~3W}VHz--Q?s+b3HdsFnmw1^lwB+y#+V2DSass7XauV+ zjFmvfW0J5@gQk{-$GjrhpoT+Jqc}xt zJmw-X<`y1v0k%}+F<-}TPag9+w3XRB<{8mm`WDV3R@Dm_6stSlL}M*HhZzz}I;WU) zMf--&?EHW-dk>tYdiHxc1QbE_Z>dZWtnfx*a))3tIw-tAV=>TqCvq&DchK8|2b&fD zxp>kqfE{MJz=RIB`o(auqCk6Zh&VCOVvG#YF{QHcgkfOtHW>Ut^rEsJHE|^onc+Uf=_e zBEg{EiWJ70in!M(6m~Tz)&wt-u#$`ppxvszXw;5uu+ZKPy1=N!m-xY^1|=Jd)O~i$ zf+7q(VoKC+?3kt!6cx_(nJlHp;ApFUfL8$UKn5v*d<4iF?GJ$LXGhUK;5jwbIy{H5 zb)PCpwGOuupWk?o_ORngwdXlVdtVo=lrz47*M`uZN1b8EGjs>jvDXQ>J97a);B5yMceFb+%uOW;p8eK1wx~Q6v zEze`jfxAS|YPb<}Skp@(+~9##nw*Hc#SPpV6u(^jHtYw~7<5P9qPq;Su}NhPqnT%+ zc&^-Nrd4cg(pZGnB7|E4XX0)IZFOhJuS-qAujMR@_qFn=&@GMEIxXi|T$fSpylH@A z7Vy8y*h5aPgs>neS-k;R^B{p+&%(O+)KYc3TSyFJacq25shPh7f)Mo&9TE?^)|zY= zxbY7(o9X&92SeBy-m^_->zFFaBuxasNO}>;p!?q*((Bzd*uUwjDLbKeu70-kDf3$x zVK0I53VlRIV|sYi19ow+h$V)c&=xzTAD<`Ep)JT)m7~$5beG>^h#A`~f^8M9NPL)M z{5nm| zsUW20ic-EHJWjV&W!?aMVVy674%1%evtatHpe_GLjtq~Mx?pPgF-9Wnw_AD}A7Ir7 zMZ3m#FZtDxp){S|TmH?+M>;)oZmYk0%Twj)3UzvK`FMP<^xpDWmi#RAk(mm*zKndO zzq90J$zPhTpDtPK@tpxmNR4kf#qjVDjY6y~@mYRV^i1#J8)hT*N1}@S|JeH)_$I6C z|Fj7ZNF+f51c?x7&}yp|pQ?oxO=(Fh)DkQ-s7Tp{m_^hIiJ+E3msS%Vf^2h6_TtOt zZ*!a5+;0v+oi;7B1>{Wx5k$ZjyrmORCoLf4|2^kf^r#;?&!uGbuJH8~| z_9gL|UlMQmlK8AIiC50sp1gl2_UYH&zhf5iD!$s|Pi%7B9-sat?aMe}yt1GB`b(H! z`MkW%gL#n-7->krTC{=?O_LLrZj}$Pnv`ZLFra(n$u=n_-))C*mLaafwiTbd;&Vv> zqj(=D+Ss>)pZs8%C*UyW!_aL8Nnh@_%9D)+@)aN|J2ZCIidU#kT7sZA*v6QOSqyJ< zFp6|)pik`Rxy6mg2cL?Qoj8u*wG104ni1r5eNI<49j_mufhd3vsN6O9uxm6gAy#85 zS|xW_dr^tZAHftiV~>_j7_Q}~UNrF2C|QH7D^zdOAd{PhR@>4Hn@zW^o!PA$e_HYMJNw!m(h4l;3o76p`m|Q!C7k<;iRYbpoutf~V+P$M~R`}IM^P_F#6ug&Q z&*rK2Xq(=H?}6edY+`60s5MnDMn(X!0zZdG+}wqPPmY*}2p>6nwLqxOjfeZEYxnvz z+0z}f=hD^MR&4`f&u3){a`}tp&mo%oTZZy68;@vXj? zFBo<5=Xu$b(_vaL482D0dBgoSIw<3Ff=67xA4@wAhzT=oIa-MPa8N@|ypwn}IqB4R z=u8D?|3y2RfRz12KjDfJRNI9cRI{`kP!yB?(GmaC(RM%X8Y816tVf@7%)^VcjkxZO zabuN2|(0D#8h)&oGJek z?B->P5R0O?^8>VlZ(J&w)`Z;YHw-Jnw#((v{@8iwd4GYTFcMlm<&mJ6l>p$=0p?#| zLp8X(yxy5D<`^J6aE{q6B!OW2j^F`T_aV>&?N_6IyA#f&V=p+!sui(0BK-p@fHz?2 z_!9D^r=d%QObSkI135bX6x->Da6lVqbEeg$HE3b}a1*jS+zrlHl6W2Qr4xjXm-w{2 zTR4G=!nIqz328^UO&+Z2GH|C3RYt}ew5Yw%=TX|58}DIvrH9SuBRh)EfL)kdJOSAs z3;@N34qeZX`FRZA9so$Vj|oH_hsS%R4Nl;2cWO zIOO`c)kUon&9TX!UJ8Vy5i)T712_xGi*zxC#bnk*oKBOuC>16U`3TsVf=XwNO zU{(Y0;oM09b~OMW&ixdS5v+HrVf(Y+uJ}6&v}f;_b2+sT1|p!RT&Mh&c6FLai`#oB zw})il_K-cw?IA?T8AxjLIB8%O!V$sGp{-QG@EN)zy@5sd!d-X^9t8+!!#Q#z+-YZK zYu9FrS#-a6PIi+i$gpYX*mc|^imS9Den$~5H2OSkJ9mEtz9*%9j`wuDO$YAM(`e*d z5R$qG@BnyHgLO^?EY@Sel7LejELrgs1fz^v0xrQP0hbLGe{(Wxy?qL>3H> z2X_&~h2M?Mlk!}i#p$Y;~{_66TD4O&X)14R1s?fkOZ@jQaS_Q-sq zME;e76gtE;oAAU4Oh?q4cqNv(UJ1%V&`x@#OY`#C8GAa-r9iI(!E^3fHPJE%ZW8q~S0H&+K(aixD(U9A` zEAYt#2nCnEBtpS$eX&Y&mW~391SJo&Zxjv(lt>Y6pjlr?)kv_pj3nH7w4Zv2p>IWse2=?if_P~;!8Wm-ooT2wRmZA4jyH~hV zW$3*!(5D3B8jqFI--t10Z=5saQm}R~Ex85t*wgm3v#pPbjVpCqcpA1usvT@6&a>}+ zbOxg8Rre zn3iOAlQ#!HO&T5DGpKcI$|}PiIhwMrb$tQ!ZK;MRNGCPoBKs3u12d>~i(y5|ex;0x zS1{CQ;Np^)@vyC2HxiS3lhha#_-ITJA^rg5UZFSn36l685lHN#*Y7e4n+yYfOO0JXeru0M%LZQ?XG_oEr%1moR zPs~zN3boWc-`H<((}DGab-jC_AcA4YXdaopsp4gCH$X@kpZ9y!@wo-!kKBNaJD?WycfP_ME0fJG;@>IYpqjj^Pp=F@=aX{sm zx~8+JMGFy3HMNLW=a_!5ZmwER9P`_x zFMQ$P{IlGURwu8O>ZDbMsMLUzg(q0OV}BUsovbWs^~uX?-EyJ=LBP0g2V+ZZ5tR?} zMvHNKB&IsUG5U-7h@T%FjCfEw;(Htu|7H1A|1Zx%Uqs!uOWUv^gZa};^C@l9&ETm` zH$vmoXetN!ZuE|)mBbHyUd(CFuuQ}YYI5DFhmR%;<=KLbJAA^?{~+H@N`~7x!_4C| zn1wlBgAp|#zeOB5LZJBPiIC<)iW{ZCWsrcojyUlh?O*%RcDGn z;|(?A-7ab=cWpO1_u!k#wMTN2qcN>Vtbc*ZwZUotJKP#mb$66Sn@>r20#NBU1P^LW z50k_Yb3~lJzNCEM11_EtF#@|zEy@92MeIpOU^#0PtSu=W68;s{x0pHv{Ee0NSf-d3 z{+Wt~_=~LJKT%tFAx?gQ(0D|oi(jv&V1RCRFE8%H%i4cwl`#11$LRU74+PP^S;xR z*IdTbN?y!aCWzbrKvn2)&T$7=9<3ia8G3N}U%>3xzSW|*Zq?UMTh#N1ea+Y6SBT#r z{Jy%8`}LHhoZtG@|A78UQ>sV*H1NrbR|~!3$$APJ5$ZRn6cX$+%ApV_Z+;6YgAR?n z1j|_XkHfheS$MEU&Jb35hb64M1y~W6Vr8L{rMAwi0mcgO#G~Fu@IErLf%U{gk6f= zhZefrphMWt5yk{$TDS%U^5Gk#aELwN2r7BLfCzM8f=GgXN})}QcVN7Frp@MRd@^|s zpuPP#c{V{Urj%Yyt}2mWUf^~Q=2Ztg1ijG*kWR?Vy}XyjOZ4S8UyXN+QP|=S@`w#^ zMgs~2kU++P9^RpdY^J%F#1}0M6D(8j!-4M6zbCFjyrqKP5+fIJ6-Z(U1o%2YZ*c3L<&Y02-Q_Tt&w<3=$sQZDC?QPg;J zHm6I#4jWj&`63P{omvjGf|KLS)HIgP6cL-qDiKd@q>7F5mX-nDWHdWIcBg?Yp9i~| zAoQGYEiHZr%0_!8-v-b7+c1E=@w6z0Y&INX+8;T!T>t;&;a_Dx*s7EM5=Ia{XwQvnnAp%XRbWFoauP@`xf#7O*qI{6vajvTyc?DXWRItb#=o=?ER7 z7H?=e-gCr8n$ncX&TB#$Oa~ELa`dpaMC|-CHPQy1GTB1FMKFq^IrLdPL5%k7`pOHv z7sPY}(J^TUEvdSK4UDiI*Xa>WbNSG+`bJB3539K9|gN$ITOW-nYQ5SGmAP&BPcA9dG zI9m)R3KxXfZ`#FD292L#B{X6)#SETRjku>)vFr%$OZMMRf5v!oxKA$kVaVqAj!?WCZ=&nkvv+gdpWu@jIe5jVj$wM4uE`J@DJvx)!38)jK2 zKI}B0qkjX=)Dgkn{YYSqOMq*c*(oD}ywK3CPc$OFhbN&kyRovDcVS{YPj4QN2q2YD zs%DBe!AI0lP(Tqp3M@i2P%w=Gi}&$(W@TcuAQ{hl-V$}*+uAce@6mMD%ll+J1LnQm zcvt7W67jZjypTG{^WN_e*-UP;(NG-5QA9k<_1(EZp8Eb5u{8CWYG}mK^oPODZh)^&EeIO8WRkXF-d=vigMCcZ|PjSC_2wzH*RD6ct{a1=W z-GTE-<}4K#u|%wEPDN&AKvQzWrL!!mG&+@sG)}zu*NOfAX7thKaLYhUnk966#Y%-? z?quPWd$t8_q<$k$zY=0e4!C7y0CDs(M0?<@F-#jFML6-b+(^RwtV#&-&jk9yJ>CS8 z6rAGsj6n7C+U^({l(fj8g- zp_wE|K8+)}!MQL_#fhd2F`j2YoiavD3#%!zO>27OK{~iw;CxnRswP29^rJmYR*R7> z<86rLBT{hp{VUW6+w@71few0Jwh}fky8%0(86ClKq z3NH*iKz|DNxg4%Rt7cpME{!>G5gI0@qXeAu!T|U_mqqta@;!pt`~Cb*kOmY`R_e3K z{s9wWv&@l#e6GEYSGqh*$984@#vaVj5Tqi#8LdZB#g5OY0h}xq$4mQ3$_Wix*6K%@3kL1enFe2w+GO#8CZJTs?_#+|mrlDBOh6shcqX z&~T*-v!KL&8}I}^ltvKfxe+`I&|U@;hk%K^cnSKRIJKG$1=peMk1@1B z(lph-hgVQ7FXtzSigz$%notA{25gmQg^Q|lU^iU-4&u=NfKl;FjUCdfn5wo z^N>CV!Q~tyQk4);PzF+DQMa>OsApml#2eu?p4;0L#ZGFYmq9}nyT|CRmUM| zS}+N)LK@&7yGD4iij};5q{q7r^b*5TPL)IG=Dbo-sjMY{&px1Kr1JV@Kvz@?gh*KD!iO#PxY956& zPW+450K|prLR@qWY^ejzj0PgcjKfQMCSb80+Cp{QeFhg@j8#&U*o61zj8SkJ!XJ;C z2U$6fQDdrZK;jZHm}he`v7!piu?&pwhj@w2%b}-{NUhF@n0wpndYG~U7dN*j_A80o z*{^BAWeTn>VmA|xP9AF^I_yo1+Jn<)rfNOCwlicYHY*>y8Cer<8VBIM25~sJ+}d&- z+HY>b?!}WR2Jcq!7?-DS-U^VGichd8qKpN4D_C^H=~OFo*J%fKF}_D#-m0LVRKE6#=OiM~kUEm(n8awi4k@bFFuh2k=P z7Ky?9%#aR<;g~v^y;Kf{zevbq{|3V&Crq*5P?ijruqr9C_gCytuZBGtT`tWSGo;<(hV4Vap$T(p=Drz9*VXL zC!*=rljj#vGAk`LI#-jZ6Uj+vp^1o!$2DA=xa4^9IJ_o_ht^x$m z(wgjt)DWIE9uCr7rQ&}Qwjgm^* zOPWZ@|JosWqOqs((mV&VU59kdS>WFtzUlmwg@6We_Ves5l^6If@RloPtkbcA`^{?V zJ$p{1Q?bUh!=;h(q~JbnL)Rpi#~VYlOB*nw9lE!RhS(ZJ@}vgcbsBA>TkJ#_fX$Tm zrFN$ACh>VLI^$-Ibn>cl(7fw30Sn#t_(&q=5`_8xRp81#T(OH56846!8sO?mZm?qaPFxmOE6`D; zIO^j~JmnMnp>!I`2-)k9VGf@lq;^`eHwP}Bm!*jmD&BYr#L8v$<>*xoPamv=tuF=y zv)7wymH@Q#lX7!#`}CjiT1-}KmFF#*pOg~|I7a4XZ?5<^%FOnCPXW)pJ}T%l7VnjU zs%rz<+?H*=iAsWLoZyWP34(|8b1r}vTt@dD9R2r>(M_?mI_-YAh%*8UcPYv4;$#at zBr6>U0Gh>T_b6ttbjoLplGev*r*uerO30mEq@rJYWj@Fh3N;PwaY5XMDe=NV5UfDg zIMrxc*c$?}g zeT*d$MO9vgK|z)v=_T5?unID_Zjg7t)&%xMhC0e9y#ec3>?4zG3Ug6& z+W0ub6=`)!W6IlUVtzeYs$4bSc-i39Fjt|(=^L75gyJ{iN)$)g6>3v zsTAWM7nWkg!#=Vn`NTJa*>aDQb(&)5fjU8)rI%sJUc3E&eTe#TX=yFh| zlSu|jBT7m=?M=|53F>(?Q}t5hMKdsu8XLP&Bb4?Oa7$jMXamJP>C{0n z-OLkgD+^qSBesE^w2{xdgHXjmO9D+n4{jmVw=fs=VQgBO2jeApuuI^<*i5}B#IiDS zV-kvi$z@OPqf{xA>+F^vK_&27e_GN(Ho>;Y)b75nWF?#F4~tga3d{pQ_afz$q#`Xy_LjBo9YFKg{)5G^NoKn=fJgj7I~~yE?~_B zSxp7ox|nKS0;pj4(j6)_#+s>`OsBAilrjvC$@Olf1Y{ywP;nsS$ z$i%`3KpqAin}3M+i#S#kFw^Bs0P}Boj!`cXuLpfFU9c~Q$rgExRnB9I*?`m>LktEw ztGC!#hxCTGB&cO3Q41*PkY)Vi8Eom7zI25SRz%N-dj~kV1cz{DKSou4e#aafam`@xROBIh} zg9&6xc%NO>OjrKlo?cVUyWj^HChuVvN*yMzGlw7-eThC#Qt}c9%A;AA_cnN2f_Y4d zNM~NfENU>~6J9q^a;UxJ87LV_y-r0Zp=bst?MLlK0xVguL;Mcj!02qUCpEZNH>2M) z*00J**~tNOFscjI=@2|~U-0M`{t@$&h9{e9{)imGqeuK#%+F044zH{=i#gz^|HApX z$-_q?MTi4pe&hUH?eLLsOF0VxiKd<_ZT~0oL!Mz7ewKwar^RHK5?C&YO^78;XQnzSus8wKl1@1T``@X;h{Vi6{AQH14APcHE~U+l7E{?|o@Fs_+p%D> zjUxtRyQxHc|0$piI}{i}_tSlNG%?W7Jk~?psiC6iV`Mbng~6!{kQ@4m=c|+mS2N7S z&?LC$1k1j!on`-qRaV%!sT|v}S8kZtq*4vh&QQg*J&e zU}9D~1C0Ul@nyw&bY!MwL=vnpO^?vE>Za=d0T2s=ZAR0>WP)j0GH=1-ea$gPjh`N{ zAi{D7_Eh8&l|%|t^(({!E5V|#q`AND&x*?HB|MQJdp|B80$RKWkxCJX$R??IHa_9x zhLtob5^QoY@St`EXwg<>k@#$?sYf%sw40;kC+Vh{ibG4Rk%AaF;H+^lB(#bb*qOIom6oo4i}xRi5`ID@xbQiGE}@1^ z2WNq!p+pwyOE7lCw1Dn*hJz)#wl*^uI~uqE7gL!77H@6m!VaRuU@R6OVM*-zF*D*} zF2`I*@`{59>O)Oz_Wta)IsfuDBr-pPd_2`)mKm{>`Kls>qe<2g?3lZkcYQoLd~;Lh z%fgBtmj(;89dMd6)FbSeG{F5aJaPQ_&@a^wC`ciF&xEoMap6kE83V&}OQO)*Tdo6x zv_^7|VxxbS5o;jDrvpKKS3H%M$grgkK&kUzqS$S)G{W8rNvcK%D%cJdkY5UR2#I=x z0njDb26}d;r7|E_(FdXj5VzrMt1s9M%ze8h$cl~)xBSltJt3&STx_dZ8PK8>Dh};e z5mnEGeg%0Nou7&vz#cAD0y9iK`cb(d6nk0v|}VEnVQmx+w`vwuS`FWqJ+rG_X8zni&H< znC6;qrR=)*D7zw$oPK_EN7Foj@)!LTp7GB(Kh4R_+wf%F(fsR8N+l@uCySzuwFg&BOAgIap}l<+!%(z={bak@b10%@eBa#_Y% z;IG?Kavt82yDaMr;Yl31rAKM3Pv|E@%$N%ZM2Wj#ZDQiOIEa=x&!qnW7$$I$RGt>R znvD+ZLeJ$veyCqI{`AYkHh5qRX_ZOkf)o4)d9}a-glbV?^=Z7i_G+HSQ4Lk*pKBz+ zOoVu6NOvn=4ZLhR*A1R}x~RFRLwC-L*W(bw_(+HqxAE35t3LhypgRLfJKd^l6Dbc8 zq|HF48;5Ed##NRM*i(-xg>d;2KG)6E5|9!M0t?D;cl5X6Hs6%s3S(8S(0C%Q zKsL!%oW*&dQXv;TW`?e}vYv%DhHc&+@j856OGH1knbf(~n%LalUNV$oVE}uI5vtyJ zEA+&Z>u)V%j`8)XIsR`>-d{tn*-04mMR!|*b99=(KrsK}57^O8osqe{3_cQI8MmomQ5(^)1QLwvt#t7YpcP0UTpF`aDeb zai7w8tX9L-u$8yVJ>^o1qVx|qd7T1t+?&teO_%Fi0Q8Q8_O={VsGD z9syy5oFw0DQ5JxKxpJ;v_FCmpXr8H>bY1XPpdW`nX8MDry*LEpU@$pA_oh-(1>3}$ zYFOJrHt&ZaEVseQjabZ@9wRnP9o=%0AXl4Bqt=y8&W=`I?9Ie*SEP1QFY(#~LYocN z98eU*{f36~9LQw}=h33T7|tUSserYGyf#i%WjLTQEBwr-~-Sk z7=sVs_9JDK4e30}Vl%8F{S`(l#35rim&RJsY2UKW`vr8kBruYQ4~}(cH4IBkQKSq) zw-`N(z-|n?Qc<$a_d`wrHnUjS(-o7j}1C~qsYvS#@gqsH&EHRkdwnZc< zZ7UVNnUqLJhcYMEmrC2j%^d^IfDEWk1quX&_)mN>pUQ7{+nOj*u@_n3MQ`(AxLgFs zN*hWh@S#a)nW4R}sfFc((PJ95ejIt25$DZA(h7$&?zk?C9M{doXe6!AWcdh`<*6|o z%eo9;C=BlGMlSJe;!;0yVFwI|f-^HHS>WW(63~YRx^AGa$f)G(ra9xiw8P}hAW+}U z8-jp#22U=QX3|QK-l^MbEcibTrPQ3CDn<>IfiHKA=lOzl_m*Y>HXt{r!Hfd;Tp>f? zdLo?4o4qUI^?1Z{)Uqw0Ps~MEc$?x8pik11paFkX3;QJs%WkWD%^O8{b19Y)I1@kS zB^9}*g%2WLd=Cr~9cGJb9|9N^S+KTWNa@(VwuFN9UI!XY=n*d}?4Ouy7NQVFghS@a zR`JXBIMRWF==*wTL&qwr+GFSx3f$WGG};DI)LO-O3}>}6vXn=d9S*+m$cgBNy2q7@ zhvrZM3$5P?5^4M9`L8&d6fs@_)z=MVAM_AHr8nRZXvSTU^epHy;WMWRK7d+g-gb-+ zpnY)D1OLZnV{gUJO?UQ#Y%c1pI>))e%np$epgPC7!H$3oc>d|#?i}Zaj9}CWM=k$= z@1LIYbr&ceyvP>jQ&1(^={G~ifQB|LB@4TaZT7ARx5BoGprUEW&xpN(?wLh5j2bU_ zS7agre(tA<8^*)vEp4viarRSTV$A#}U(ijq!|Xi9#ZgwjpJ;qf2QI9noDu z{MRNvWYZ*O_H@C3PlHu=juN$u*H*N5I1?*`tl&P~j9%D+)hb)C^*RJ#3pVzJzX)5f z+Q224wEVfW{hF7A9hcZqeF2>}rC@!CQM z_Uyzcy+QgzggZfeh&ceQwTq3!A{X$$4=$%oBW(ZhiHiGwM#W73mNU$+nOQHz3PK}_ z$^b*mMkRE@l(t6f-hU9TPR@{^k(kU6{4)R^1QfgtSV#9F#y&}D{`d{-$om5OVp5fwlY953(O0IIB~ZIhuI zpru2qr<7m=&}gKX(E89VSlnX%$lii03`Qy)o8_Aga9r{$rfM?rVc$~|Fd-1MN<=ed zQ`u@yc)4d7<~GyP4|eWkNS0Y5T9nxWyma9rL=ZHr@Y-XA<%ie;=~%T}^xsD^@Gw*0 zX7E?LJWC&JrXiY~-4#O=CK3%g1Yn3B{K9|v{N&+X14DxckN5}AADJ}Vl@xFU4<7aR znLjdlxGOo(FL*HK*UulR9q!TwdQdlM47T)v?^=AB?He3p6qB@dbcLI(dq25nZc!;3oPt2f^l2lu9NE zse~RI{lOv8{>0A_hCG;ANS9J9?=2CV??5raK(x|U+0QqXYr)W=Dh@B^Dq4oX2PUC^ z0+xy3+Nya)Wluns#2G6gB&_$9b6%{=M*o1Jj;hS@#+?LiXB$j&8mWz(8R$n>nw?@h z>BImp)yM=1@AmjlXsW0mog7dfQ}NPtbV0m+3-gpLB5L%DSPe;Wda(v74^*iqiHuZB zaG!!j_&{H}0iNxO^RY0OO)!HBLhhEj-oI5K%c=0i6maO5ink^;Bfa-fOCv zgAy?7z%{Gfidklch5Y}YeC8R~apuYIIP+ZFapt*9g>D~`_DgY&cVc!I0;?13I!s)U zDXe)11Y<94y{VvykAdeOW%_9*waZzI$8TGf%Z#O6rb8Uz7c8y)N_}A#t(SyfjgG;XoHgqo)e;vlA%WNsZrs4Q= zrsonA{oZrqaeSZVP@tP9hv3m3b1w81C|$e9Hv?U3WF0*p=K@{U6Cg4s;OrXpzC_e~ z4}3w8#Q8BK&Ks4CxtwufBI9khPNuA&;dE__b~A}rIB|GcCm+@~b~p^SpAUnL(y$L} zN^M$lTaxsKX$gWpOD=@I(L4r+wW2NXkJ`E|Ws66qV_2Dd!F20-8dt+A99fm6Fe8w* zDfT09X?$g*G!{IdRZff58Mewz2;kFWaI0!gfrsMOttbfZt?LaN<)#80oYLY1K*|PG zXu*XxSryCB`AiZvA_WHwjc{g6C&sqGsqkt8DsA0>s?Cv+ZH79jjt;ABK^%NWP}}6s z44=pwBV|dgZy9oCuh>TUs3F?= z76C=BqvF$|DUHy=*bVh*YgC#Hpq zdr-HLj$>oHx=Vw`*K$#V=FVU&sj?3g*lzF3r~>EDGrMSt!N#<$4P9Sy@!^P4;X)w7 z8gv6+XJ&iar?Zv&q%6ZQvzlsNKm{NNGma$t{Q8_bO*OwkNN&!Zf$s+!j!3nT|9UT) z-z#_Cp_lwU<{f%Dkmk&8sMwpfAEHK{&&}Q9Gtl07$cN5d9pT9aox>sG!sFxnbchc& z9G#cb^L4-7ce9d^1yZ%3e=TS)NIjoiB5{}b7Qzuq4wh}z94~dqk-){-yeR9-^kT^4 z%Kc(kzdBdhVa{SI=ciRHlVjA`%VfISe>kkkG zmgVM66;g=Mk2G~o`niQXmI`uE(q&31g;a_irP#R?b{q3V$5JRa{U+g8NI$n2)m{pD zvqmdS)y4c`H&u_qi`a+;hh13OnySa)8%E=F-pnm5j0O%LM(ONx0W26x!6+Z*5zQ=& zu%40ZAVo4_5kt$zRPlOyB>C7O^`(OblcPkuDpRkx)c502h^8f|UDzH-L^i0AR=7DT z5hV!)N;SE)7C(VFqqyjP0-tb&c^@_&#k}#H9d^ysnQBUSAV8f15dE{M74Z1YN*8Us zJpcE^MoyB7B;OpS%8Ybm-)G!b=;BqAwv_xh?3Kq1cQE#^l^oT!# z$~3@otB{q3)h zhJ1%{C5~XXl+GiWs+R*Y7;>UFKwB#2z!F~;rI4{~;$C`Jc05hhwEkzGAn!Z6Us0Y8}#Sniv&(H+%%8sC((pK41 zfppSFLleGDc!ij@fkaIif}+{9q=}v=r&cybYFjQcOBlT$!ORw+R(jtDE8it`^oBW3 z`!;f>;?g%IC!UkV7*k{o~0@=HY5OG0p|4pChg0JQ6ekFIiAIf;?d_BdF&@e4Xn3tEpKVxmRbC~f|) zhl5<^RpEfnLg2Pb>mgT|r6?g8Gme>%JRMzG|1Ids6tyeTC)63wR7ptNzd073q{``7S4sBoSm_d|1H8o)(Nq&l{k{@ z$&sScMrKUy97$pH@wtX_|94C>a#==aEM)#yU_k|!I>E-l)p>rD%TiB>g--KA`ZqWh z&ShBJJ7dA|Ux5V`{KrT}$6|rSB#1Ysn4N9^c`T@%P$agK5fn>*=!!Z!TfRkD(4TxP zNLz^+$sXf>9t$TFwT~gFL5ZP&U2Y4DNjgFVmP)DU!PyI}xJ~*P@%u{zcNXJMS8y+* zW+!BRTPJiFr7hA1Xrk=AI0z+iLqfUy)WedDR~w;R9Sg9b+%7y%gmSxv#&wf7$#tP| z2COMU*rcX4ODMNH$IF{TxjpC&F-A4!Jh^T%V0>HXW^E|9XK0*>O#n`R@%3HG?;!9U z1kMNoioW8VtW)SK2Jy=KG}RTl|EuZ>{i)Oy#&40jA}&RoLLef|OZ(#Bzf}Vzm&Ii} zRUoo`!vfK{tS;jc^(nC6_?BQHzRppxkp2y0;at`=@zv!iu#ou;V&Pl}D!%4dv5@mE z!a`ir(isb?x+4EurLKtUzf>#~{a0Y&^wbsZPHTb_sWZ@22`nV^6%~Yu@AMVl z=_}4?CHY3+SjW3nV;RAbGmjT$2D#= z0C9S)leeqwgRz4xx>Fw>ZIofFY>hm2LI1%IqjewA*&Z7in>5M?)3!jzZ_~b2Rlj@p z_Sp=`vu`7t#SjElDFi5>wj_*O5{mA43md|taYh0e06nzqV|{eDRr*|R#=(n84qH`A z=2iJUBR`|RxQv8!*k|81SfrmO{N9bXt}#5?MyQon0gQkkTKJwmy3Z;df;EpV^0Pap zhHPm&v>U2g=wMwmkAq=uCY3~A(RV|Bue9;Vv z{D;^gKd1L|ZePafwl5ApU^lZf?R{jGjxhXSTPN>Gd$+27-8-)gi2Q75O5O+O+`g>S zZJ)d&-a=qF2N>S1kA4hp0u zup1w{`*`fOGW_7d7bAFkRecV4UgYP?7Z`th&hRTd)Aj+sMZj;fmEnb9OT>n`NID|F z3yrab#z&}s|G|-;(cg1!pZiSP2keelrw*a7sxwJf7+x;%G6#>?g+cv5>#A&tJ<>g z=nB|^e@kt>!i;N!)-PIpyO+r5+u z_Vje<iFcWJey#|L7GzNjezSCTV zqbWEg-4tzx4Sm%vq>mAYrVRV>Pi~9uOXf8krk9Th5{9}VTxwHvmk#&%)mQDJg29sv z^+PCkB$HfJYH&OgWgI94 z<0bqffS~aVLAWLreEA2aR~=y3sO0&G+9o$ueMqo5aZY(lv_)sw649M+sH=LP(lIO| zMg<*B5#99)tPG9vCma>stuuU5wVR=;q{89TCUAQt9mN2>8w^LI@9A(Lb_321RJ}*3 zkpxpcMaLjy(eelfD-53*)<9fE3WS?pTxodA@ClrQd^NCK+60`7=laq4=NBC7@quG9 zaJu0sT4YeLZ`~gU11L`Kp_a_ zFGMKck3B`9kC6`sx_Hb8r`_Mf;iO1G5}o-cpUXGlGYGHD%W-*D9PY?A+S2&5ZvW9E_Iu0c}m#y#@djqe9n z#?sarHX>x>zDRM>#t#ig4Dc3>(Sf@z54UcSHwAYNlAAW}qr`HP;qb;iQmwq!(75r# zvdM-`r#*_6=A)P5^*(WXn2ERwYrN!2mrHe0o#d*J9wL;;likwfRJo`0wlsOR^u$vX zboX@W?tJNPa$Dk-ADV|C>7i%ouPZ}xO_W@E=`Z9$On$YN0xQdxa@(72TGi8yb){Q(7snK-!EA zwK7gF4Ml}RA^IX0T}x`0_EXvowa};G3-77Y;h{M04xT*;U+H*v_|oOPOjMmEkI9hW zLCQT2B6Oj66;31MF;fsiU>+~$O+^~JFAdS-9bWMyMZ@EXBom~izY_?2ea4Z?(5alD zQc2*Hx5(9wd5D!;eLdwpQiuQ~JztBzaA!G@Qu!U_avM@n83w;&Hp=xI7C7eNQ_5Un zZKbnTD{S=eJG^Mnc%&sSBt9G5*HGec2CjE~!9k+et88t>ql3-xDWEYrGd0p6**}B278XgCf2zXyu5ynVB9uzex?8-5$L-|vs)u82fr4_!Ua9%zF z%2Dc+@E9jRh@t>LFW|?$0{jZYV~XSgBMO-$kJY2DiGW`LHMz)yK`0l`^Ihd>^49hLUom7iq}(^jz3<6gEk0 zJH&Nm$y=mHo}pxWkT6qv@hM7pJ_>q?68Ifc759fNp$v zjF&+-1!Ydcx^cDyxRr;;_~ilzO39MP+EuujsHtPq@#U6hrJ|WdJ_mQzi>{W7{Rb&} z5~8Q#ZgxaZ@%2D-C)kPAl;{x}MO@5_G{(3j6DK7O3osKH#Z#0NePj?5a|Im|huI^& zNNmuT1?W{!8h9pR=uL&k2*O!Db(?yFDvHA8vk7{wn+$**`r{54SHMpPNTqcn;>U*< z&ZDANhaVyo8`dFgy1c?rr%d1kaFmv>Oyhtt1|sfgW&#Wcc>(|kQFMM-rZG!3ON}VK z2v7`CH;}^~mYG5(fi%JDeAh{N7HNzbl=#m!G=EG!>dcU5I??nj-?a&d`o$9*?U&n- zz$woxXP91s1Qi%V1oAnYpaRj86ZMs5E&EI3QICRAK7R z30HCYHGl(60fo{!(A7jhX^PYkeuQ#_AEx$DoV+HbE@d^~JVkD$euN)pRzrw-gcuE+ zs8A~4zyKUE8GtCIH6Y#m@IoR4sUERT;M-jWpg&ZLpfI~Y<&)d_=PcuVRkHeb<^1O% zpx8&hV!(-Yj70KzWQ#UyAwB8&7R(>fJ#HlvJa867!=l}XUCl~U^^=H01`BdJuuIr& zF__L)ymU1^b6CHa_6)c3_FbTt7f#qIHe7T1hUheI{au16=g!5t#Zp`YSIVb%umhZ8cq0X>o2(9r*2e1Xc zh#;ZT@*EG-i?vv?Lvl6nxs{=o=+H?~9eEHmXE&IBbSHULgFMngf|`Atxa8AVEDcvu z!Ycp*ED%dmf!%ad@Cqb^BO!P-IYw>qA0te4|GPWWqm|Z8R)rpI@S9&<2Z|#awUl^F zgXR%EO10=TU!qIbWK3H`sTLDmf|_MZR0deSM3wUOsZpeDr&6SBi0(-S`qoyxN5ED! zDRt1K2t|KF3zQ*5X&d$!qK3ERHHtn%NvJ4Wv?~n_h7E>IfRe+o73(8&@Q8lK94s{K z!H@L-fmz;w?F>iAAUBzwTcr=>=|Uw*Sk;*EWe$}jtFHjDJ~XBa#s^L-<#hvLS)$Em zXQ)IQ8q+mcuSdyt0&_OjGNvEVt+v1c5aPe8RIJ8Lny86<&^F(L7=+sJZCCLV>JRtT zrswLh#i9>>oD|rH3mb0xFH$itoeClnuXOF`?|ljYP+n78`sxS%A-G#+KNJ!=a&uAc z7Ts7mNRB>x;pThDV$$oPDw~veQ|qDK^CPLz;}-j`!9DH(7}OYlzKUOMzA0|;BYcjHrRI{JnkYj&)rJl%xHhJcx2Hkb@T*0GR_g%Eqi89j~;8XiLT&O1g zv_}Vznw}ErfEe@By=a|vX;!BM^O3-`ByT{d06V667laCi;n6=-FdPqSs304UexU*w zQsngw6^y{6k1r`ya2;MOo{~_(_2S<@*&FjYJ&k#jP_9O-Scsk(pf)9nA~l;eluQqE zXk-#S^`Vi;q0w4-j82Ih$xp;~QDeJ?Mt76P=+#($BDT94+aomEAdfMsvHV2rd1`FW z&}fr9My+-vo}tmbx$0hWv&P)yW7njV zQFp*cVfasL_HoUPF8$GWUV6OIWxQ|vRpv&QdH=3A>Q8iY3%i~w8^i( zPWV^VNko#k!_`#11Q@_g;KQk#8?(a&liFu(mDvz0jdydEtO{Bzz3o2%& zM0CU8XKdyGPej*af?GTwxXIkgqz5Ol%IQ@nNyT}Xve#X zco3U*FYaU-&ZRiy*67}@H3$6Xmrf`V8;~}-yDPdtNpKfOFAp+?ADo0TbxN(M?Hm!a z)KBF1MRLdSTP`are+*$@r^55v7Z}IBBGafgjLCEwAM!^!P3#3$Dj(1adGJ4lByi$KSHWTMkGht4hyUzF^5zRrMF-Z=fgbH{ z@!Q=hJyP#LkJPm|J#zP4M(&-<%$;*txNj~icg4VA+Ld`tZ-k{tuoRb8qT74$sU5-Y&ny zGwV-p2M6Jq(|^s*TUC76(Q=WA76QwJkB@WO@p1nA6X4??SDzz3l>5*vky)v5jMJc# z=H&$sC-`$c__IXxXFz|+<1sESYFXKVKPM9G`GELy!6hLV_;bOB%=7>1-`8Mw~i%l?(Ofk4)jy~$t(Kj@@AM}W7EI$$3UyZ#WGFQ z?J&N-ZLgjA;ZJv|{E)Z{_#v^DI6q|e;xaOKahaL9xGcq&XEcA?%-@eA1rY7o!yYP{{&FRva4CQrWhEi}u~Om4gE(qDWZ zPrEvkJ8nt3`N#N9RcG|{E&sZ*555yK`yCfcci=lqo#AsH9{s{Xd^^>dKL5NcjN9;C z$nzo;pBEPXqhOk;H&k={cOqsTYAXH1>j!vb;3uAhO#<3x#%f?hDZg61)JL~i800yq z4IjK9Ad%pzE1P3g%Ly!JFBP18ls3OSq0@W~UaxD^uz29q5fmGpdKQ43Muwi#%)oP6 z7p%H)kq?&6&^ z&Ge>9D)zLtM~{L|9IC{U`pGpHmf<@gxor5+?my!@Q%Vr3C>&6USG<$`?Eb5VV$ zxH9o!4i(Qyd{{%p6@RnK1y0$MFG^CHVqC?KDJ|#| za`jD!QZ7}{a;bur>jFiPDjuNTRrHN_m0D_2TKd&=)dL${IA|bLp|@lL8$xf%OgH4Z zAXGX)ULBe|NP$>csMr>Vm(qj_LoP+MqnRf@PyrHBy2yi>b^Mpy@dNMLlZ$NM#e9n)pvc!GIW{_Vyy|r7NnUJp8d+L& znps$MT3A+eT3J+d+ISV}w6mb-bg-Q0Ol2|Ana)z8GlPXhXC}*t&MX!Yor>=34CHGk zB#v{EuRE-FFS>|?wG-&8jeL`VmBc?w4?`a}f>((}Ae?D^nUC*>^Lx%2g`@XUrHXL8bcWbhvzegK$8T;N-yihRbnS=*SbuGP^PMp0W7^_fGaH@eAN?wJ zRio4LmlqFR*yyybd-BSOjZT|o)35JnblRW2@y!PtosJ)--DPTYrj86R*EKrRXWsMv ztVU->(=`|R8l9QX*8KYVMrYR3KiT}_MrY2Scc0(uOx9mEfajd(L+M|7YuXp+-=h}| z9gP0fO)Y9d|9)FG;Ysvw+}}#>LI3Vt`qLEj@1J{|H=}?3XJtKx{=FYt_$>PO-gT+? zO8v>Ohf2`DApC*H1zK`=_{(xzjrQwvFDle&wj4`TS_H}ial80 zkmY`bBlB-rayLk9;sxwU$Yl=s9-}lVZAzdIBjcprgx zz51TBSbfRHW|0lX6LmP9XEki;j#^+6fG}hjPT^)0a7_3x3MV6kW5Tl*D*@2&I>-`N zDG2DbN@T1=my1~Cf{Ys4kI$iC(?S~LNq%&9YwtbLmfogkK0Qx@M(I4mA;Y`U5eeJX z^8O@j754%VaEsM5P3GucGhG~Dmc~0$Vbo5>Z7ZaYl2DkP znu)%onrbGHg*}i*zyUHGu=uq|9}h(nU*3ye1n1%wy)G})W$JQ4CR%Qn;O>OWt(_5g|)OoaZ7rKwU7VdkFv`JcNK;P7! zuY#=utT*IUYX7PCWD8~i4B`-=%OtJ6_YpLA^a2dxz2Ai1=itox$J9AMGY2q8O`^t{ zYQ|FEq4knC00LRkM!>)-Z3YU=aDG3@;lR%l;?UWDDOz7JYhgKl4`n>hSv`eoy1~GyKB%eTzk<|47rBYr_Ui zv0vLF?@{d6w(A!g_NaGxLtbI(=)8`6m0d?fUloWWUzoTlu8I_t2doxu(Ui z$329Xo~CD7VK)cQ9f#O=$9czm8;|)mboxeKWJNJO78~{(sJ!l-;+N#YXb$BsGh>jc zmCz`Y-RU)NnDRF)!_J>3?52Ydxa$c7Z8eP|I<`=vaAC$W9SvDl z<9?^@>bvfldUw?)-{r=S3{cu}B$NS9TnfCC5jzG_=nH7Jc z3Fn!^V=Re5aki@AuY}%~W}uIv;yn=qs4_uK@<_c5Um&+wWS?1{L9@P59!b-^RUS#x zJxw}=Ny(nzO%PijNY%&>ILHQ(rXjk0&{RE$c3?-Rvf>mhwkRzcjAkKYrgl|!+sq5; zN~of7qP{`Lw$%_-lkQI?i8r`Bqn<9(LgeodnO4C!mcmAoqa@Umj_-Vs#`R>Y zS!a5tjpPx?54n&fV6RE%6)c91;gBm%7@l*I@a&U>4?juxu#<#mp`n(=hE=E?fX(NY z*`-!oD?6TK87eh%+vzIr_$$A6mkg8@~fD3Koa@5lO)mtNn|>X6_F%j!z`SUgrSxS^wT70 zGu2!lmqfNwzi|r5j9G*(@+QI|6k2gpE|7{hLZ&sivl}vIATm7-Pc0<^u|R}OH&vU6 zmPTfvB)VA_7qWLnTox(>%10)3q=6z7U;(gAOEila*hL94XpeHnP7!zX6mdsR5%#%`A`x$v}3nw1qoB59n(;!c$v%DEFPBn|AGXfcp%nLt071 z0UGH<8AK{X8!Qi);f1IU^_qJ(lYF|N59q`Y1$6*LHfe?7Qz971U}kxrb~UvR?Eq~6 zi8hwhJ~qWARnP~aN@@l%k`#;-ppx_?By|wJkr`BSwL&F1=$b{^0SYM!){&W}nFxgN z8yR^h8)RUr=}p#}L_J_Is5BF0(2d#Wk8|UzC|H~27PIPj1&tUwK9mv8rG(}%9NJ?L zgztBh5woV(d+n3MKSTscNw(Xk@-w?aTE&ei<)GYc^ zQQso)qC39kr6A8(hw`|%-=R39rrAs{7ONlP1{>LC9pjvW*WAY7T&pH<1%w5QIdF;i z1lG57sh{8Gqoe+oQn5%+S?OSI&r)%%(v~s;Sa_C|K~i>n%aksoOg%|Gx>C8^8t8>L z^JL|Ibww@WK{eN6d|86mn`c~6DvJNDG#wWSZcTK;DlD(@5Nqs4c#GG_El5E2#4o-< z-gyUcowi$S>lN>V)n``%bA5AsCUKpWA}mrZPyc$zT_kvkxtBJ_d>S7)E3k+h3#U=q zeBzpvPz9g;asE4w&ntXaa&56|V)yCA#9boh%TU`?DsG@psv=#OP+xF)>d649R*GHV z_AcoYT^i82OLwAv?h^I`>j7P}+Y|5Bvv7?^-SP-;@8%e4P8D~;;L;=S7eBzWRNf*M zZz0G-PW2a;mWWLfl?9RvI6cCNB=L@=b?VsMot-*XPM@e`7^*|B6e~HC*<~(c7@B(J znek=E(~f5bo*8&LC*#F=E}B6F{)=I)xWC&~Un_R5?XVcTi3VnL?R8r6LD~V|2+|$X*6EDP<=9c8m2NVidPgeKG&FAbg`a zkbyi_uZE6Yi0Lqz@M?1UnM*|#%vK2rkvuc3-5(i11=xYBn{hiRbXI3@7YvH%>LWhENUYBm@6M{fzfq>F>Wbix@%DD8_>D8R0Z?BZaZx z!ShTHKZ@~TS`wZ;oc7iuUObd?ZzdalOT=Y%i|Y?C6mNl^)GCxf-mP>k0|7W@ zPG*tPQ-$55!~xM_1zSUd2hD->aR6>#9k(1vyO01@D!$mnSafqO|Bt(68ZEa$ZAS#M@L21=qT`^wJDxy{9|GwYadu9?YLJ#floab+zXR`NR*Kd96 zTiFm1k!zdsW?Y(=9GB|JPd$*|z9adUaL?d~<9kw@ z@-UVrN7F}jet9bSE4+A09f^kY_;8BD$hX-(--1Pb_ZrtuM9BwR;*rt#NTKMjX=aTQ z)-|Jqwc}^_0MQeGs4j0tgb?-S1E}^VhC-CdfAk(g;6eTc8ln3b7Ke^FD+eHSsmf8f zI9R;}?nQvnZayOF!4meS|A0)^aKCpVle^Upuuf3RFyPiPJ||4lt9M=yqR9!j8(^0w z9f5Lv>f)1xrmVE|Z>aBmK=G`;$;WQ>RXpHS-44lh7DoE%9Xk@IHrB^DUP3AlUTd@g z^m=quef1?&+cOHo@$0ESjp{DS@BP3TRg17-D5je5zg}I8|G>5RAT7{+fMUhX?07zo$*9Bq#(&3vio496 zvADyzOZ`>8kg3?Y6Om8j)44J3!>o_mjCTyt8W_rpyYehfaxgMD13X^VakZMd@d@qk zM?DMSs;fYl1J59z>Mx)R*qc!BxhRbO0S7I6bq!S=9HwAyhoWAr-Wre=Azd+y%w6b( z#*UuVb>K2B>0*PfW+YCi7w>U7Y^1e<@w9@eT#a*v$fyfYt`9pvI{pKxjg!EojU^zT z_|o{Y_}S_tpLAt-hb~gYbVA*X@_l6z0Lkctwrjc*AVnMJE&g0gl|OD zs$c$IUaHi8j~Pe{Tro8s)EQOxJrDHaY=YE=QtghY>B9*F2q~VPml$tMvkQwG1D632 zIE28wh+8-t%qF*43Y65MH8tu}r{NN1m>1wNCm3gEGF3{G>LIG24ul3mC?TS*#IjL9 z6;xZGFW`kHbcN~_<-vpZQYqy8Od(DwWSm{d3+NDIrX3qoE5Ue7MTm`A2zBUOLUgs>)TP zL&iAbIxnU_0fdcV6mV{e5SR+IWuZDVo91+#e~G1g)*?+|tWiBF+PmB$??DO>Ja#Vm z(R|n}%~;s%{oFGk;_eI6-E-2;e&=IlkFRi-J?c&MGISP#B;a9h>SqYRX-yY69BWpN zs7Y<=sW7>lzIocFX*^f7%6zbUj?dgS?J09`S~HH)Xb4qw*B7HJOK`&CLU_U&U<@Nr z`vbf_2Qmk{D=NIc+WemGixB?$CaSYFHRe$ljQ}0Mgh`Gut^|$-w&TbaAaY4my@IVZqGVwIH8BClQ9WV`qUv_R zFpL~;dk133NL!++(+*86XS#-{x(*@876cano4*`))koDWA~g6d1cro7^=e5>CiD?_ zqLIuH>M|Kb4{^u1Ce(r3c=QL@7PwtGk~z9c5#t;9I)g#PMoT8|NXgrslXnRc8H+Lr zss`T1J`^LG3Gfa4g#oh>Ft818xxDzJ_rs5YbzFw6?s{0uH})vL9oVPzYffjA0>S43 z%E2z&9aG0bPBWxd-J!Li7JM{dnsH&Mh*A!}AburkVuzR_Zza|SE#^WfRBn784$Dr@KLdo0mT`nU$e99jpW@ZHUL(CY`5p=X=LlECoNQej9?R8nDOkEWtCP`mp{TFAeHmyg;xw z;G>Bfj==Yqpmb7JVV(LIhLDyRRk20{Em38ekh4S)gmRkT;e8DebxXgMc;zVL@2GU} zlPEZmkq>!;&BiMXzSwvP4?VUj2j?1G6`;RH4xVRhV3IS8`!j*Q!9wG|Ghda1#~J^b z^W`zRGhbElE1?00AVo2j0Bw`1gbPGoO7UWRV5e+U9$4Hoi$NP2gxHh5JcD)@#d zMShrvKY&>x)VcE5c#Wo@WpM)}aH3O4fX*?9lVjtxI`Wee>B>feK5<2MOR8r~*`^i8 z#xK^%3`zC};pA|zF6x`M34QEe)a5>)k$-^BF&xFC&Q@(OMIq$C*^V*Rz+zL*&7e5< zKk)YMLyX#Q`SFnP;s-GC{OVyCKQt!bBufV3Q+04<{Ax71$OgC%(7L*64&ai|{&RDM zA9nR=>2uWOaKXjHQW)L6uhR`$BA-$9Cwzb(_s{lYv|yFxz6d;;1@Ke?2w9E|E#yG8;@-g?!5t_H?%&MEvlsTX^r+`@1;vkUTLy*G9&2AY&5hs#ku=AJF;JD?I5H?uc6b13mFI+rvXmXZyOU zu{6)MxU1SF-HVzI(31xj#SVy>6>hUN5L3^gZ^i!Wc<2T;h1~-dpF9Fj-(s7n=%Vva zAGiFtcwD#RQ}GtGBPJ#makjWG)(vv4qns?{PksGOwjUyY^;j0!HBx&w7JoSWgb(1Q zTPT-&)-Q->P?bt|`-hf+ae}E3ObxaK%u2smK^h*RCPE)W44pbYk|Ul4@Kf9)63+L| zc6be6%&%G0N8>^YM5=ert|@8vXVy zaYv%xFkmpi92Q;c9RvtNMFDf3YjK9DFu*23#&ZNr6kSjk`)Mh*Cbw8|KNiX$(3}JL zP8$p06Xy}^6Zp@)*z$(z3O(5h)7N=0*4Z_x`Fk=VgnU>xCbsH3(yMEYjdrVI>POHY z&Fvi5JK&-bB~MsVrNH$wco#v6m<2$7sx85Kb+lFLBPe8WRCvd_$MF)uJfSowqPjHq z?poyTTJ4d#`>TCWi5x8B;KteEN(HKN-|P9zB<&Kfa2%}n`brtKIQ^53aN+FdM%CxA zEJ%Nx8dWRdAJZQbjaO}K#+&0;fkr+n1BGLIK_B#ABM)5{A8FhQ97wnP7Be#(D+~J8 zg#g<@y2QO5TrZ3k`R*-|7GYlDAls8TdtC*xKz}_*@B3uwHp0#^lZCxN*3ZCzZO08tk^ePbOy=>N~^F_yYmh9N(~f?m8TgKG8vXhhC6iz4tpH1rZdE> z4CqrI6y<=HL)cFLlnemy@5JpMRGdE4(ZGYR8V1!rr&&y%L<1&*)hjsWjeMjhaUn8^D2u6QGJ&vAKSHhAr z72Ow4!qlZx`4K!D)k{zT8j-$2{lR(*scqI zNwdIC(}y$wuavf^s1!^bQk9&t68Fit_!6DgH}Ey2jj2uad-ROrR95^G64hOv-nx__$$wYQyKaH+dc{a#!_;&ADe(0X;RIR3>g@KZZsk-9sZ z@K!6~m$C`z3})TM)^DMDTV%9V_e{I)#A2zpSS_`SYyB9TQEeBMIA0Q!s9Bi=MNBX? zlK^wvs00*MC38<4*<(QO#b~`_Fc@F0JEUrGG?rcnF;&K|!N6Q4o&>)^NF^LjYVhMm zH3uD2uP)z!?g1M|ziQJuyBzA^2jc&cl;Ud(hRL3Vs;ws-cp-HG&zlCF24JjtNuNG- zBTxp8YVMAzje0EtPZrz1(j?`FqBbSHkaL$bdl9k#H8%NnETRdW*cf5l+CMdLHH>u9n!|D3hbtiL1tED#R72W$?g4 z$bj*^0Men5c?M5Vh0HX*h@4SVsqK7{{irROw+(m$ogfqwUdb`*p8_}oJg8QEi}RDa z)vb6SmeZ!~pJn0jur0^anvcP5zkp0ii(jsmwbCWY@l%9fq00X?E0Ec=ppIp*^$V%D z#L5uoE6044zFEd%?NLbNw?v6cPhI;8i=qY9)kyADq~?p^F2nm*09-Pw>;J@L0?#67 zP-^bqRGMsq@B~zWoh8wVF@hBsj0&olG{T1gczLv*g>#;-P>m5`N=0K1QU@Z?J+_IW z!l8PuK+Bb)1@-mn1<_=gbdtIQq6z}()#Rx4W6-L>`g*u$-_uj_gP&dp`f#jwAtLhpX&ej4E}ZWov7MhWb}bT@W&9sFzJH!n%nC+Wukhq z*2+|f;&9^9f`BBMhv8QbcUO(>bgtp>wsR(*x3Guk)H%(>Nu<>&_a5=F(2h?S>POa0O?|Cfk;ve3#=L- zs5hT2c5M|^ z(LZ=0#Y0FPL5aj;HF>(?+0fkS-hvBgC3Nvj&ML|IH7h4QR0a1T($VFPsh?qSLR5_v zebH@V`QRZMi28h?_kRj)ffjf{>d`bBgxNzM#EM5L2pkZ95FI_<*pPv!a^OkS&lUi5 zKgU5~ME%5`w7@vogroyML>feS^q7bNMz+pfhL!lnlaIv}@6Xz7lYA|4I$s{r0P=Nne z1dHF;VTU7!UroWJZcKd}%9<2#j@J8xd!kkUBIb#zT>C z1%0PaY~_xvhzn~%TFrf)$ z>6f8z<8e`!z9PbV&*|QK=H+2yulJs)3%5Pu%IdKL-g_3|dtN>db|1j)k9c=7blwQ= z0Opb1OYkw0(OdB{O8$>V900_9m{DFEDPd#mBxB|C2%Q9{S4DBNw@xz7PU4j$<5|PA zC?|n}k3>R0dfi_G2XbvB(W$Z7WZ!SIQR7Sk1`(%Zvne2cIpdAoSS<$dXa-P=zKP;Z#=@|W`1iTbv^^d!jJ1% z7lOv$!<{CK!*Q;Ud3$?Dhu`h(8y$YH_t7J8Wz#Aw;J~i@w*Ir$-uidEEs;ayOL`yh z_C7Q^%nJt+y}hHtTf8~~fM0J4IG_*DQv0|4?{fP4xdg87fmVzbcsFwuxj@y^ZIA=v7C7Hw?+nA{NG zfK_i$jKkY(_*wyD31DoyA#TE;BEVT%~ zCcxIp#RGV^;S=b5InXU}J__iZo3Vy~>(?3x5q<^OO_*>#17H%)0p~XGjuHTbRbe@Z zd-w<-j*#(UorJ&@0#I%JU#r`2o8779HqPEgsm1tJ0jh+_?dt*;>Ma1m0;4qzK6*qJ zaTVBM>PPQ31G;U_Hv}p`2a*GU0J3KMfF{ry`q=|nY5`c428^$@ZT&03Bmo}S268y}Fb{qlsx9nX{J5#HR;UdH z1x5?!DO-zf!X10 ziNIKny2>pW%K=!ChLNx3HjGV3onQ{YXwzg1z$>@<+RwfQL6Y|(iQo^!Sw$A-d;|Sl z<>Z(N>_v>|DiIY8=#?CLtZM7u0_KJq4S}%&FjiPFRsgVK4I^JGY#7OqnwbLt44Ef|T%g>C(}f$8C`RbY&uoQMS@ z@0csqF!D8G!$|hDlsQmX5PfZvWW>T_fOzx+i$y|@OEfjgn90&(5hO2%v>+}c(xjuy zZ2(D(flUG=%`Ht$#XLBZf2sS^%DmnIai8HOvP9NsI!dltD!S1qJn( zDF&QAfsyDQ!V@9Xpjm+L=aA&Ep?WuuvVdNL&M6~DjQb9Q^Fj8gc4fd1*}ZOv7Z2}K_@wJ zDPSzsw23omN|Ckz@D>8zr%?}RG|MaX7^mzLAX{u8aZ*fM{}(>9_M0e}`}&KKvDgBz z7!|f^0Qp)R-Ymz;l-o%WR%*V%&J(q8>lBdCSin35Fyn41%=V17xKT4e)*N$*%`uTE z5a#&tJD&m23xVP#dFvYH)8g%C@>0e)M*_icZfEfcj`2#EYp4XFiG;8zQ& zhzx+K6tI<|$|Are@M0}+j>KLH_$aZpiue?Y352&Ic_{o86~bqL7z#*320V7}qt_o6 z-St_3UKM_hU~hq{#x7^qQ7O#oURonWp*{;6nz$R|MEN zc7wp)*sZxn6m@LM!5UE}sFFP;qdS8?upFYi0V%R9C(9azuplH<7wKc0P3isDNx5P3ojOu%n~acV#rP-;uXDv^rhRrW#}+`VC3p$?UQX@G;1>K*$ z029$5^R>vvE#)H_S1SN!gkNy$1JE4%g&67D=wApxd6o#QR1zsk6EswyQHy$HfD0wS znAo4v(PRrBqPM)Q|2i-`ylL%nxWe9|p%sPQhPE7s@jwnr9>_bu%;ge=bR0v1n$fqEMu-rdlOKng?BKp{$!%ZDaMW@wM)7JkAxFh8xs-Y>- zjGtuE@By}<#R)XG7fTyvZH`yP<^qwuxB%!@Q$l3_xGk~)riBn`>)#FriZ`us4!15W zV`zwV5kcyu%z>sd0;DSt19D&$q-#+ENtWz%2?kh}omNnw3wH>T)e9xG>>s@u9lilI zwzT#C96S|o38CBJ`mv?MM*vhtKPI~qTLaKC0(1);`v5WNmYkL}IgEI-^+AIpowZy1!xFKXIp0*(b4f(O|-(CR@3?Q|2+00z1 zcW+X)_<>*Z1DfmPUacHvKxq)<)L`;l#EL0T^=>(WFJclm0NyIVyG?Wwi28@U;b+4< zzDb)N0_##JY0yUC^LPVwQ{|MGVE9eVK{1*j0`Xf4oXHKH<{Q8weH&x9cHjRXIJNU1 z!$=gV6Cw>7C=CFjZwj@L26Q?cd8?qzQ)#Y%!+@Gb?`ZR7(L&H2Frr^Z$pBGn$^v2& z5c46bCvdqI3>Ubp75*w&&>ovWL7V`ZT|U(E5Zx2tl2FM3-q2bnFj_uEI4wXHg=q-@ zZUDegmr!DR2 zucDs_$pD)0NPtFvBygs+&QrjOMYXd=3_V#E9a>Vf0rUqamGm?T6Hw6^N#jyHY{jC{ z!Ka(?uu%?D}|2_54+a4~?{v`;IX ziaWH;Ay#-fQ{zW%lMzA9D*?GsrIv#L5-K}7vjl*a+RhgM2}M(gRhYC7pq3ucCpbjm zgijFm_!m$&ga&FKt{c+~8m=4DFF*!GU4y#u-b^uvHX{I=q8l1#j_B5w5VTD|TeN?X zJyg|1l|`shyI|V-VUSyjCLJrZHvlMK>4*`JQL*suBIKij!Kl4Ow#C1FS{BkS(j2mO zFO7)CE@%NfWB(dam8}s`+l0>-hV=<})7s{6KcS@?5AhR{fr+jOfElru@V170Apj+_ z9BL)`M}r(RZlP;xXb)f|Wpra|M9?Q;Ym*YSS97=~Z&^u0G}mj1;0{L!VoJ7#tHJ+swSgagfZ9( za*mZ``4EL}wV%`CsSUtwt*9RvD=8wROMag_K>(rNAbcU?NhF@2AH#;{Jfqe)hwI0d z#<`b%Op}m~QbuUoH0WeTMEYu3w0=1%SQa?i4T5t$*VYTMsnR)@EZGqnL z;An6%%ee$$-y+JKrPXT(6X~HDH@!m<>8Ilav<&phiw|wwibKB3$z1Vlf`)@F$5Y5| z>8vS)tELs4JJNk0`~okDMBNA16-~3&IWT3L)-`K|jnzP{qi>N~(sKYUkOfqLv}`3z zVP>>W=qQ-B&Cxm^1t`Aoo8fn04!+ zM1W}<2^34mJTT}SK+_At^ko$S(ub&()QGRHAftfR?nf^cdY~R(HAFY2w-ewJ%0LFn zithWwK?+YFpd|PAad-fsZv(*QVZiwTFjY;Q&;lR}1jurj85V%h=Ri2+KqA%68gw{Z zhnWtSu^A?EgW^x5SuR4z+}eGgxI42xJImuqSmCup+lx%jo^n2SiTM;Dr7tW12$zoC z3YZKHCsrHT)+R7X;YH#jCL08kP~$Zw%gwh1)CPb_RfRe+wDt_B=yFyP?7mN2mGl6k zzC!a+)EJ!dDCihn$#!VRL3RSN6=N65J~YAvXcWX`*9hAIs3}HW)F{iLt{WOA(p<&OHDCUYBf(=z_DngO<#u(*>&+$XaZ@;+cr_<~Imx@fiv z^%suD>>hYQZo`UD7xjZGp$-~&;-}ZG5(a?TIdpRaRq7#&BrrM0e#$h-r`>$WStw2N zSxrL4mUO2MucdpF#nZFx%1C#u=k23i(pP9##_41Cm3ZdZCAsaQ2Uw=_&@Qd>QhuPP zfWwRjbD?gU*b7;e7p*D`^9YCHwthO-bg?9p0IIh%Lg`M60x7F>e{qhOb|%|ZJhZ7A~u+-h1-4NZnpi{8PXNqu?@OWW+aO7|;+WfY@6)KL!PVWAHlRH!`&`oPG}C3(BM zz)gWdx@(HXT~nSFon#gz<{ryk^Bh?ZWTy;gnC|@^%*;TZLLJwt3g%0|P#3oKS$-zN z`2yH~-zeJBlvBd5AY8Q+U~At*dn0T!ehcw~?F_IrYoHI@4=!g80t#RFb?4InvD`^V z5dcM5?D0bL17SGgVS@dGq?Dd?17%pL0E%4Jq#RWhx|jPb*HP;*C{c^p%~?4`iAiQ0 zFb7ekKm0l%Edkeus}uEU1D-R2@Q#zxUpAixbUwgUj^qt|Iw|RqHiTa>hl9D@&vE~lCNDg9`jKwlw8C2h}(RME!xZlQ8&sGG$ajSr5abRVPNVo zoLB&!25|^6XfMIE*0gQyYV}zwIgzsv8Mp$otEK>f-{YH3*A)q1p=ZZcPp8vdZ{4Es;3Eutgn^GR@DT=vVxS(*0Z%m6{n`1lxshxZH(YhMVz6c1{b;<@w`}lc$a$M8?v!+T`3gUY=X^5;Js(o$(^&Y zm1NdQowKLoF|~8{3_L=ev&-<9(m5Mjab`{KoP8c1C*uD3+2`XW7_0A`eS!KEij7yq z`ezv^*P%w_;X3-UsSQDxKsfq_S0d-@d+KK9@#*fIncq3bY0h=&$eHr=@uoi=JF;`m zD08mcij^l~<*qyA9n(2ytU1?X#mW=0M_75sby*E#2Sb8dl^SDwf_(d=D}CN1imsc%{D zoHOYVcO4Gx(?j{XvwDn0Y){_4QTUhDpIe|5pUasT+)0rvV6uAKXMTW=k;0T%&7Nh{r>A*B9c@ z&E-?NUDH#$E0!N0>voO7cJfm%&7L5Y_gRD1SKs{=C=t=q-iRWF8!6&>-LndDP{?1t zMl!^^MTj7N=WEmUsFyEcDIxQn-q%Lru!AF`jrD3B()RvsB)0o83C;%@A4A5j&l)K6 zmy(_*BIvD8fb(=1U?%m`L zKM?K>yS~XZ2dW)btsX+&dv0#RRz;Ggl=vrHy|soRxB-+yinZi~dGEn(QaMQ;po3*h z@1}9#Uc5czmPc3eUwB95Y?l{Zinq(fY34K{8iRHiPdB6mm|hL4(C4Z7^@zK^kW~#i&tL4o3XXc zhYMqoSq9~yW5Ih5%LFW0bUlYV+k3|JK0GeG9dGCgK6>)Q+fi>G+fpq%EjNM636m2; z&?k)vKWol!#iFw3!jFa@{wLuPgxDRoSwr&BINSg#knjOWcxq$;mZbHX^Ap^Qk2*K~ zi)%z}1Lph|v<$fZm)B*+n&G2*IxJ3Bv{~ra^ysmJPa*MMU(Rqrw>*HmPVJB$H!$M) zij1|Gf%Q`?Yui6tlAv3c2As#13eKO#&3pfJi5b|I;AS>y!O{toAVCtY-f>J2;9RhO?@}h_i~rh_mv40%ta{aA0Db zX6V24FypM`FygHEFygG}KsY&E%6^AM7yrcO<_!!zB$m8MDx45(N2(xbyVlL~v#M@anwzF~^E?xas9oRyn@T0$v zFz~+=1KNJ|eB$uguU^|~{Xc2Gy8g}PEAQO-x3P1G#($Uj>N||8i~lgHE;L_xQuX07 z#R?9jx*Vd47CekN3+*3g2d7XCGsOxIBhHErBhHEsBhE_pkF$g8$zeDvJ&ZUjKa4o5 z_$P3-Kl8QfFypM|FygHCFybt7Aen|;TmH@FD~`GNH=3`lGQR&^?)p_9*{}ag?T(MY`UnGu0|VCi z-_hLrX?(nt1zsT@B!qKD$NbiCPR4}UOdK04Q?WDk^d&-^cF2gfH#Xk;^Y3C3776FL z>|i}#i-dZz7B8dQ-iU;M6h%Tzea^8bcW27Mv6uv!Io8g4MB5u*`_lDSB4GdtJ$A4h zAIpSZgxrf8JRD;;wY~BC$t}N_HpnSp+}7OvnY^|?;s|(L$9z%hq4%*^V5}WJAz{xn%iH<0Av58BbK6t9I~k9!p5B}=&V=WDcF)|CTW~ipVm{sN zY3qI92y-@G$jtM{D z)cN7){=Lco7`!9h)$Q-}-jff^=k>nm9@~qTeEY>IFZ%(_dyiGXwjN0VGJ>m-rS8uw zIkceEs*uZ3pJ>9<<4Dxu;d3PBsK5R<4ou#TJLxZos%6*nKya+Wn98Hpd7CDGxZ-Eg zoq{x+tvxoXYH-9dj${w-s#mw%D6i&59OjMNl7?0|33cc+;cvEE-q~3O31*F!mvaWaNNvu?N;e*!}B(73- z@Rg1DxZH*yXJ|L7+kRlRAW_MawQ+fFty}K8fYj&B2h-|fay~xWtG4{yPVAR50*z4> z`Jxp@6kPaYX*W^uB~#z9q;5y1Ms+LxOBs_TiO<|fx}y^pPoU`VxpUWfh=o_`^!)05~i0wtpN)f@?fpf;Vo~;Vm1yhF;@zmVPV3HO2a^*xP;v-qg0AXo~Q* z^EiA}_gNdy-u4}=vH}6-Zg$lp{5(^~1Bu@D`w&piBVX>Uy~?qgV?1!{v5x6RDGdOoCcN1M@HxEK ztJguDGU}J8YgX#+Of*W*alC|LxE|0#P~v=zG+gHJYn;8G;*CqdX&G~IlmVy6QHaZ5 zrrTMF(<#C+1gBSb5|uYdX1BQ!H~n1(tc80nJ0QFGP^=A@E{7v0)w&>BrhY#!SKbzI zkr(gXz*qK0yWZa0h{0g+b zXA}s9_ewb*=-us%sLlmM32)s1{dwDYHC)`G;`4E8eN?5A63GbOpkY4NU6;2uud=-- z`R=sMb->uuw`CGMiv;mJqjODxFRJd(hW9e#qsVx3HYd2zZP^sxVou&(zYPi zuPp#9&jTTdcqd^+B(6nwp*ed##sJ(k(|UQm`WnZUzA#HS3~2&Ub&k}+k#%0&Z`Cl_ zt~qf^rq&O>V;6pu4vYIE>KEWl8Z~}>u^iFJa#eerH$fyX1*WS)Aq-uzNC>h5i02bN z$6Ucad=}{&^$15FN?Vtp1&hX4m?P<7ns}_fS-L4zpz*~bA}ok@6?c@w|PzJtEMGU2sB*gz(K^NA_Ad#&3JX+28g&KfbVtY*q!Uwm?R(FFzvS)+ObR)=-7OCl;)xfQ5ZNI{W^ ztyNgSdoGsbF3W_R1xE2JKpv6d8;XF#7}96FW+!J%KQBx}*j@ZosS8*CE<;^7iUef? zMbeEJvQZ1F!#7T4Jv9W*bfa&Q!-xBI@4zbO^mQ(kUW^;Hi^#2;$YF4o^%Q_)oMwka z)dYNFjq!mOQ0w~}Z>!1ezruRU08 z1L~T_EdCdCt@h!v+pg8FuGQ|Y^Sf62xjudtub}Rl=SeU3r^8Y8-xGwQfbA*iRW3)ec8;tjPhYc(%Q;?2Vluajd8hIm~J-CLMvA&~yZRSYfao4(rZZLb2L(^q@E z?Pc-;)!;0=s0WWE;DmY#KFxU^6L0{UxwvIBG0}M1ZbhTIm{bbc=A;1QOVTNDLuG445JT z9+3UE46Zql%sz@9V(+_9WQjf8-c2J#-H%R0GfCSSNpypi=J1jAVJm518rqdp+PW7< zqi-I$FiX$vq2D8@5jTP;JfR5vCiE85tOAWeg9(5i(Wc-fWWtx!`{=-knkiz;g%rX2 zVeZR4dVhp}AQp``N7U~GOVoaD;?h4+KD9BS#;K^Pn)SR)=F$^8&1QrEks z254`N#F3zuC%#QQyG-tc_YLNUcY*S8u@g?PQ!hi2LKF*kq3Rf7)E!XKd1sQ_^sdb- zzsTFc+i8vOph7+RJnHXtnb8NLN0r9+?O5Eyt3G4tY`AUP+x|LY(koo)q)YuL8c$ex zwY<0eX^C(%;&XNc0crm&B5++TS{YJ1RuDe9#qYlm%%*wUug06X!o$`KT#5JcsJCMQ zp2$&g5RQlLqiUwYmuR;AFsJw--Db{m zWA~0`FXn8IxtJPz&d83aR;XSa*@A&3fB-W61Y&6i%b1Pgq5pox3{~WX+VPm_kNI0 z8!r;)A}h{OyD2>WXa_`;Pk<^K zSO#wvB;d)Uq^R047LDRHWgQPC-Q6wu19R!@s&sW-=rJqZT^IVoy9P=TbU!I&;B>s@ zaEFgxqsDl<+v9%qI}nSdG3jfp)v{)AMU0Tdr_}8Dk{)=>4^B0|u3P zd-%Y5buMa@|W1#T#G)IXRyLBh^gcrWrXor)P4OFy|yhS<q_7$@I}6eekcf<|J>*AxM15zbMPcS1>XDs@=D9HV6{sbEC%gmI2B%{;RpL_o z4epAIL^?fBMLaJQ%v{XgsP6i*P!(F(@egzj zgtRD2uCjk5M%Sx1^#`&PsK4PIFCo=0Jk>3ZVQ;%aF5tQ*(x|qbXSclb2`S5fY|3>P z7&ick&K_ZC5r9{tT3}2-{lYsngLd8u+o;Z@NJIN_v+CZiS3ecR(N;}hh=o$6j^qO4 z3n*3!jTfpDP@-+p)WI8}(GJf>!bW)#=LOjrqH2;BVS`gar9R-=Vg6&lLk5Y`yNdC@ zuq%N7MF6opyaNa*R#!vE#7n7Kh8N>jyDS6&`4?5uUS1C--l*=ze+-;Ps)QzQH>Wec1k=&7t+pg=M+?hWe)29{LL&^+2>cyaQja;EPJ=Zaf)? zp!f00(e1~(3!{aqDZ>y{fc^`i`{4io8V1QO1j!(QB8ET=6c{#l$+BJQZFF(QqM|+L=hgK`7VM;*!x*zsO>tKqZf$#&QF6VJHNHW7rHwyh)U)g^dA86hu^~j+O2!j>ovr4vVUF2tq~@I!FTkBsi5GQBBzb4qGR!djxf;_s`A6 zAJIV3y&X>uDLx^g-SE<37?6cG;$VUALGp+dwR%20OP8+##9}{fg9UZ$jN_8WweLtA zxwca7tsR%@%}+g$4+qvrJL%M7gIHb*? zTk6p@0jq1y8ilSwjkx8uwJO^+^=jwG2$=4gY0@=;SVXM^3t=b~uH@_?9WVz*KB96v z;9MOP?m;iG`PMLb`cotmqO1?5%iy12u{ElqxMc6>Sv|t6b`3r{gtA|Q(ND_$B*twn zWj~E@Q1;+A4_i+jTtW1Adgy+_ArfFUi+M!Ea}|UIcv&5}87{%Ee#YubaFR2j2V7(x zV?Meze?}ge&`m_9TXo$6Wab(>ofjiuh_Q1WLvoCrW%y2QC^`Tq(e>q8&B`Gp4nsVd*VkX6 z_3{dBC|77hxk9vb8iG(NzMMB5?h z6185mpu}-m3p=9T76l1J4gryPPL4A8kQQtH`F=Qe{F5h|P~`ET;{G_E6rCIgRIEIf z`KiYtPMu)m)py}Z!63rS&vI>O`yy&Ysf-NqeIyo=uX?P(o6MvRXHq`{CWI0+^DJ1b zq0h3rk2yaw7AzkA6jq1;Ml3(nA$S^ki zu)s&rPQ$dYQH^V79C%pkavmxZWK=W~YgBK`bR#1!1*7zJ!@PC^Y-sO3XMY`x${45> zUbY}gc0#wNw-hy;UH1I-r%d}h;6y3v#R+o44C5Nr+D>FZ5kM+s6A<4=gs^pu>eG8i zB2j8n`Yh(9xr*k^w3u{Sm07nZMa8F>+u?zuVxgEQbBNm~VeOO2lRp&wE z#+_J!vJzRa9${Uyv9S@Nz6dGad6oM@CsjBf=q|6A_CYG`OdLyI>?boXh}44?!H@*s zi4$bm1;T?7U?u46gDLzrMxewdCq&hyU*HD_aPegeq*;&j?~zZfD@O%k^`7d#cOMUG+!K!u2oiOTCuYdK|j5;AVQv zCH=@qdK(fWX62yKO4EVO1Pf80MSaFfJ0zw)CG`iuEG`2C{|pijkO$pQiFhpJ89c_V zHYGn2!mBVFFU-+rvFvusWgRiPcB*L@?mOEN|;BX)F66;i)KlVAUIz zWW(anvfjk8(;n}h=jwKS1spRSse=>7=W>`6fKsy`DUh~n?0%=%qwzC!N2C9tjad9U zSr9)<2Ui+v(3>EE(ob3>u&9qb$K&;l@{S(~whgYGy1C?%7sQnSo2aELZrz%H?#x=Br zob$R)+*iDD6Oy}`5dFK;^^y>UU}Q*kUXimFfOxmsQhS41rthwhsPN|%}aF4uZ1E$$i# zM9rgy>|5OB`cgKA5eFmgG(9FQ?aZtgzRlg(ite^4V$%{CnWEoQ(d(-54aMdoj|@qF zYfgI09vGfJIwZrD$Y2|j!J>hN}tE2TVOCNt&WZ7 z)kV}!_)>wqA^Dg-3NoAuEx^;Ut94>o@%6`=5uP#b>+<}fUmI*@1-a`dpB>jFA{gv!R}2T75d6lUuuI7qXcZ-vG=IV zl1iKt!kRzaB8uct^gN>RnV6u_**X^ddX!+JbKewV!#C07q}4HGVWn@hTzC1+<|K4M+2fECn=?Hy-&Ge=?~Km$Kp%TxUqZreU&uTf!{!9R zf3g6QX#&|0s+oRfVr)gK*9Eck76#p-KIy1})O)&rF5XA~=(X@qpd+Z%V(pMG?n|=TH&z_{vo3|x8(=-zK*)F zDw>k(&*0JrI0#DlXb%~z(@Qj*gLSHFBBtxOT1Tis*lMR0ps>L_v)jG}u ztAW7~nEuO934*W%1OYOsSO_lu5IFj{Uo4kbc)g<~N((NM(b`&4VM zjejOyq&_Qx$vm9Zr7&cC(;K|0TnVi>1&PprixyboD0!xb3h5_CRXmnixy_prm zU(31e@Q(1S;k5cD+l-~!dX+e6-LEE6!P*XehtsoPMMW?zJWb3jYhT(Cf^-2@_i>Fb z7o3aNV%3|9S!ohWIc8wra3WhOQs);j4l1VirnLyWS@@e@y$L%HCxNmctN!|Z$XBdx zy%LdgVptf+ug@bswIPJrBJ?A=ms)&LS8=2I5ugE(7%T;j%Mxc`F~u~D(}HM!KHH&2 z01QMgUbFyINMeMG>PC3@+cuC``w&>fKq!fv?YlM`b8sa8zbcg_U*P;}KUG?d0q%qi zpuJ^#gDU)2@Q=j|Q?F(g_km=}x&Tn6YA5B6Z$E-Cc8N@mXpVDEThK*I|D}v+J-iDM z1oE=|9om&!rTrcp?#v2+&s)F!YbW}|K3nUXF&@6TC*b#vTDCio=|1o%LRUVvXQR6KjLDqnyXlSUXXmwB6PG$UhupQSz_7~QqYz2#m zsy6v_%cKcAgANrC^c`@$w{2j*{SSHj-qiEqAwpl)BHQ)A+j=(2+<1`PnykXW=i-n6 z`}40bq(z$to`RZ>W2j@@tRFr}3~+OIRP7N@0@l-dJ2s#?5rTrqs0y?Sbwl?bh(uAG zFUW6THt{U9Bw1T)~s^YG+q%#V*)WAgGd1u5ok&QTpgt7Iv6kP!MTD*F%gpy9gpLvGn88ByR^-)5O9%l;0LRT#ej^U0plW$B$RQqW2~)Ze6DRPaOE* zfauw=Y6Jn}_Ny!F)k7leTtZH@>EfuGEe;Y$^2B*j^#`ypP&_`0H6W*0fCTEI79feE zNp5gSUMopKZhNo@_d>@k^@rm?aobn54+e~>!`m;M$sbiQohx;{BhTBxRfaKj%{dyI zR4+PClAtqnDwF%=_NCo_IDa_Q6toZP8^f=5UzW$kXmhgUpi%ue*`NKYoVKl1O!VGa{@3lj}YC?L#F}VuVZ% z-A9I!L6txsrT9wpwp}ZwW zW1XQa=XV{-6x1Q1oa3K9iOl$KJ;d?9ps(La91LmsiL~$UshB6VLBnKEh~5Tr_>uok*_3C&tHgFCP_t)o76zo!Z;+4HVVA+-I(O#h-dC zZ_W8b!`$9a?kI_9Ym!M!-%QzdGIumiIf6asGh~z=iojHEoAh zq#HcVI6ITFSyFz*Ds+M_bl}U#!Q^Z4R1X2=MolJ1u^NQcV^jA;V@@WfQp&o>s&yV~ ztqPqw)v?pzIL}c*+wQw)zR_T3z=$bSw@qU^II-S85-dyBe2qcY?rIN~{nTBCs?H-K zfR(Q55PEQR$bnU#)rH=UrzLq}R{9xpUSXFz{ft350G{1c2MIr&=EvoBUGaPY?k)^W z;5rcj22X-=m)cM`K$eXOM`U%F8wa=o&2WH}kgN$a=<40wPKWw!fL4)OuIbpvIcQ_| z4=4KS)q|I^6u?GxV=i>YyU>2Xun-}2MjDG{;}aw5q7YMIch>3<%5B)#9;!zb4kH3- zhC32ah4gx2q!yay#zE+Qt1y;8Oqlb*%5l7i`bR3a)W7dQIcnR$VQ~mz^^kbP|I5Uw z<`DF)YLJk}M31MGBXMR*7o(VOWKlPGnO8@|n|kSn#s zy=Q+$QceSzls>htnxcKC2DQ3X9o8A5CsMofk}*+$A<}23&d*OwGVabmwZfo54XDny zp<2|3+#dC7P;*4}PRD;^4OmQfbs#<(&LdF>0#YlyU_AFBalG-nOnI$#d9TURIJPne z?)|+AcmNHKx5P=H&GG5${P5aShk$mGgntYZ3=YhCT<1|gI+4xt9Z)?ZV#7VtD_!1i zN&V|;Fk)1}_#yllL1ufX0S~BecZHhpLPL>H=t7}z4~!s}T5%+*J3rssu?{2NJdMK} z`u6Hjcn6mUL=m{=g44b2S0PvFqCodNEDXH^7&WVl(2jAb-LAyw)b0`94lZR zMV2o-+}lx#z%@6xPls}Yx3%*}r}{@D=bCq~O&&QL`E}MK8Rs38kMI)j4?8U69%kLZa@~9&D)7?jADSr!ovTITmCy%HotTom(v8IvGfvFod{p zFvXs9+9I!W#is!3qMPd&gkw=LJu@T{i;+DGMh8ZQfIj0EyD{;~_-c%y_r%2pnjd_) zUcI7+A4mx%wzuO=4w12#Ad}YDs~wOfhR6Pn7sXegfb~*95CyEAzQ~t+2a~}fEvjAV zr-ld&H^!y$W~Tal$^BX6wgtx9!jB8XJp<=KjAC423&2A79~W}VLt;9`865uL{Uwcc z-K+hvdiSBjXDh_Q2YKolSNcFVawg`zP6cxyYs8zZXWD66{UT&StRfj_ZG+)0ZR zA-p;;PZe*liuQJHKnPe=$Vl2fSjW9pNNeWdf1$en6sGkMYSdH23X5b-2K((|{)4|1 zw1a88+MGU1UNaq-1(Q$b-SF|`WoieY!Z>I$F3Ql^RYECs^cA5x!BC>qxRH%8ZpQz( z)0(dK4W5W%Rx@WP^Sp-`v#9TF0x|) zbwK^KP#U(+TtW4BvB-z#Dp3EOldB+G7fO^9Mbv%!%okKEbUtYE<@={sY(ck0%X}Ed zLa)iAQmb(_+D&t?r4M{Jo#*|cfk{28nXmi<#J{@8;cfpD-s;ukGMnO>UX8sdr`+?%#QA&zI3?^oB zVurU#=GRjfX!EB>UB|7fEgq@jj*Qb9L+5%uZF1d)i<49JW| zj=@yfQ7~=5X5|1Z906ul$V^MfC;$hFswZb^g@8WqvGEG4xWV}-fV*)0HE>(R;`x|Y znl00ZFjbJQN~bFvJQ>FYd8J!YyZ7K!klpFze*Lt-H`{_Q9gj+Iy_yO0 zC#oR8$qH!pA5haA-qZX3Ci<{#7`}J#EKVc&Ks)^uqZCnCSKb}TTkEc9e+Z}d?Ca*W zbrg?k;YhC6>4-%5;DB2A6qH#d=>M%fUwH^ZAs)%@OWOty=NVs#&4YfUFQtQ2n%^}N_H{(&lxfI14@s*l=$ zE|WW|_DxIwj?i_09p}F}F%r1?0oY~$j2d#OO077$Wb_tvFXE|a1$FfzMj=Ykt#F^= zxV_{Aq_59tk7YQkM26EPSYxv2k^?qW_~n`#0*>za0UXK$@^mc5I=dpS=@~f&EmSSIw|uJ1qH1xTkdFB5=&bJkn_Y9QeS!1iEy=t#8)&*XD`wwYB@& zytS4Q_4L}RF=Bk(%XY)~dViXV0>-2x%y^vTW)q>1(s3X+L6^_lf9E` zbDafY;M@ro3;O}11PY+1iyIbC*vpnV9XweWH|C@1sDStJ8f<918}89)J+?3Wh%e~* zV!Ypu_tb`v?x%e&usJX~_KMZ?*PI7?#ue&!g-Cp4HXaqi?3?u0N*MT;^qKw;aOue^ z(&ijj7q>J9%rO^txmEH6Cie(r2Ozn1tU^g&=VCt(|&7-X&X(}9cPi=v{aa|30Dr}9D#G6Qw_%YN{ z1IECmkhOS{>w2`!0aRNYH>53%m)%wc32a@IcJYjvAZA!EIGZscc2<$Sj9{7-wikvE zEx_2AksVkL0UJpcxI#lX!PuOMu@=!5S!%pExIpLF++_CZDb4+kd33EyJ+q7QP%Br% zQbvs%A&XQ#T4Pik8St4q)n7!!<8-rqiDz`3n|`T`S%M14rhMvVnc?&Gfu(t9n&9c? z%-g`+f+0IVhT>=7SEmk%9r(%Uq2JeFN-ihM!0~`I_uE}J^!wAp@}D^@|Mtkx^lpfs zobu9V3qDQ)!3tHw3G6OX*J!N=!}%EF+t8%c-@(9*?W6EEfHw>qp_gH}WwDxftHs*C z4T|)t5*Ka9{cLcu(w~t@KS8;WzJJ7%$*JO;1`K2L7l-a%H4gk9Glx0tF?!Ic#^cDW zej^m?erNmfJsnb9G7t|KwO*K}mGKs+EnmT^1Yu13T}_f%Pl)nYa>wZS)7WAtT%UFvKE8=RBKB%C4%1A~)T&aWF) zdhkfTCD1{GZoW+xjnZXovvWn&>&LQi&!WCnsQ=IS{U?4Ktlt(qx8s+CjzfM0-*BZ$ zT}c;2^S)Ma>i7{K5}$W4@$UVXpZ_8Go%@Zq>@8PPdS@dB1D17y7vRW}J8U$@m&QMV z?*A$zmvsNW#=VU=tGp4qyyNHRly`TgyidfJp}gvYDNo}sFIgHjYVG_y2FQd**`8er z7!f-PlzJtK9u(sc=eeR8^dJfiA%<~}>9~V)kIQ=9R4Hc|D%|U-1waupK8_;27jBYL z>y#r0l;aJa4W+BAG`@f_fEq8$f#y@0=qv5$^A80e+dkI#9aEHp-|I6)8*u~G{!Kh; zVgTB`v-i^~&#BA17i=Zd|6k0Mw=Diil=szxDUbb?yDr{%1vt?7@Tl#gtzZf)dAvT_ zk30Zf7Dwr&cIh%V)9;|#+g0P*i-8Sm!jSS?bILy_1AS%uQz(BsRxfDg1u5<7cfhxH z$mIv@@)JktLQn?_u~xPB8SiHrTN(c}D*bq-(qUj{K8L`+PE;6;pFj@$SD~mtrYK!C zd=iHYe^XBRlXJ?y8s)DO?k}o?;0MaZ!IV#6U^imEjnlEZqemH^M`o0{G+QQw{l456 z89dl8#$-BMgf0T=&eK7TuxdRWPe9)}DHBs^wCf6-*$My$)_V%o%<=8xJVNt8nTe~wcm2!eG8kj1{5EbY?>!zXF#SZ!~>cxJ;A|zAqzx`7B zjwkD<`obHagcgdEq150^w~dN#%F-Z$vMYJIF(wmJX>b`iK-nF(K*4roee&TCp*dm8 zz3ix~;*{AMq>QQZBN*%|+n!h+Q)l2KVifO?b;oi>$~Ew~)f+crU{d?#Nh*sGV)!#P zuC#0Xywpf9Jv8Q!VQ^}ImC!+CI}K&Yf^ksn8BVnUy1 zsrQd0wQ=Y8+v343`f$A2^zva(<1d+B3#cZt%S?Z&OiZOgZ3RfY`>f&p`CF;bY@|{CxLOLFE6!$V$ zgwl^2FQ8>YL@}uDFZu|c9ABRWaR>@Ip3OYC4xoV^+1(KGaL&V%zQ!f1%J$Ykq<%Lf zvRPRm{-I&z=EMj08BhMPvcLfba-$7tJZXGR_s5hoGyRbVXO_FGE&vFb#}7bGRc4pcc$hch}+LcPJi^z zE&#Av;4Omu25jIT{c0OH-^}0JXM0;fp8k(B?t)bkV#>LadmQGT?Cs} zLfuz$ezou|7`5t&cV6O*aB+M_L=8OYbg=KR$TLxnz)t<`3<#il4`>8iS`;6n9{(|K4{+1tyD(}`aes4qhNSS#jaC92&7)6)#7b6Z(MJ|6FFRbx^ z?D3J9^tAdb1dH8MtGNjP%X}aZIfKluSr#?c+HxcwR6m=V?VgZvVEk!NB;p&yQuY1@xO9n(tKeFK!rn0X{+F2R1B}CSM-&VbTQr z7kW{uG`(N@GbetZ_HA^Cg8VhmoJF^Z|Bte5mH>gI5HvuLh(W7VDYPKa zIuh|d9_g+Q)69lxl#{-iZNy|R96YY1sWpwk?KKj zlfy=NfKCzkaBwJBguT`xjQC}ivv@SYVKO0Gj4^btUocljkK_Emi|0oRvcomQI~S*A z|JYtmDyd@~)jG`m<#eekZa>{s4^myy2q#6_VAe@!nD~S#Vehr!$%CCg=q@EPf;p7SUHLWed0qvl>5Lb2<@>b z=EC1dc2jiZ`!ta1Y_@o^dbNSMVMnWTlpzY;!;-aJ8KhcfqLzTN7l;hol8K^}9q&b5Sh397mxE454a|R8DVq(3FS0M3W z%V(eQc_BGlH)DRSlpDW%9F1SM7G^vUEzpxf;usZ27tsJ*LsL@s%cw9&`4EM4RG5cJ z{0+F0q=1A60d4+Po}P#ZPZ1*E7!njWJVf~{R>zglBIbx`sKc)#!YfexN4DyS@TyxP z0`C7%{qZ55&=;NU0r&Iv1k=@XajxhUUoh!{h-ItXh~Cs@;@EGT?fHfv9{Sm(r-jeZ z&LA6NF@34D@;-1@WWSsX&7vaL#yE?3A^)hLDpv6BW(i zD=b>I*^DPRtq^xB1O^i8_z6wh56-ON|EC)M$03cCK&R)rm7@n;r4^x5 z!dwcA9gKm-*%;J@0{)Q+6tPMp+E-(!kjvxrZ>$mR+t5GM5kvd8`x(*xUAEMOZ=?Sf z+Q;BW+*u_-5L#IBcgI)t`Wf!e_B7s~ZDLQL9j!I02^KTCkU4=#ZF5q3-K1#buz9Hk zPyW1*!@>k5fh-^GJL9$?cA_o^cPG|C@^oH90VII7woo*v=#UDOj16z6A7-$3^=JS0)HVe;d*l&Bh7uIecZ1+Q-tC@K_B5-8^J8^Kk=yZ~ZA-$To z#^eDc4u|D4mQQk{eP^K;$#W$8x3Jy*b=H5<+7J%xw>7foopgk2+8HZ#E%{icegm#~c-P7j}?WTODg!NdT`UG=1l@ISnwm%KuQ z34GPirV(WI?mh|a)z3=`n;Z%01RsUs#F;&43B@&evQ|!`2@vW&=JrYL%CgV2Qd)}T zx0CCqK8PK>eQ8rarE&d3Q`gUB10B~-V-{DYuAkDl{#QOv*?%s3@V`3je=)(QTk85L zjqATEW&QP(Zh)D>b=}f&dk@lh?&_9uJ}8ab`<`Kv49|K>H@3%ho!oAFynf+!&oXe>3)IZP$A}r5o{w>-sz7_YT(g`yg4OB{SuCD2>~j zoVq8~2(<425 ztiHY_X#bo-wEux55{5}K^dUw5240AIg@(NJsc85eZ+}GU_TPmIA?1SEH2&jiKc2s+ z!}c|NkGKCfRs`zZmEwekTNs>O{3;KFcp^Cboco z`k2}_^YyGsIwFHmat7@`1}k+WMzA>i&WYCJI3Jsu!~Wl$9g^ zwceg6?~vhxUCKM#PUodcM+evw3}u;14s^9)pvzfEoeTXiMQ=y7kOT49Vcw`P$jt`pDC1M7&X1E zR#S~fCzA7WIR36kxMep+pR$ro1)DG<8+rxwS81WFGxW*ZW zZ(l5RJAwGY3`SZF#J`aJB3}diU^n{ps!ap&r+YdDaXl%X#jH00UkubmF#Z%cM2|Gn zF=kHQJ{&c;@IM>>$-$yqBNSvnGSMskMHYoP`!*wqoE(h^E|eW2+eqcN5>SQc5Lc@Z zuK9px8(BE?w9wK9ng{x@u`)WYgBv4v~;mdKm7jvnl ztQfux{UHC}KvgpxowN`g+q9pmJMOydTvuok>cOVT-C9_G1m$acjkUmA&<^;oo02wm z5_t>S#@>QE$|LYD3>OS{kyQe)GFk_mX6>K{B$1-or)=&`fBeSkdO4Z|TnJ#6-0 zO!e)o8UlnSwkVS>VV8+c;gfoOiEboxqyqnQFanE%A5|7(2F_tD^5pGneY`zf{Qn{z z^vqfwpyds!Hc&b8H>NdY@!$|=f5dBxH2qsKe?C8xA}?@tk0=I`5GBBw2oQ6wnDZSd z0X^+Lovz=Li<$k<=&HMc*5bnVs2(B))95{(Oz;xr@B#wq%;fw1gf$qz<2 z#J}WL4IMt0)oGWcL^D4WU|ch=a)LR~thoz8#X~oWj;rT*%2E=NLCSP2FVrGCIUEF8 z<7c)?(s$(T+cfV))~~JdEleexm~MowUN_a4v1#Fn)DgCM;W8*VFCZ@9qg96zg{B9N zGMMPogm&c#ZOvBbaXTi2!IjFd@C~v&3JNg4vJ4I4ko2!<1Q&Neq`ghZV4e$iUn5g3 zUK2_&D0Hai0IG|)G@v?XwzQAK?{R_mgfr24B+h{6tcWK7Nv0`JVhFURoZ|OPw#lHm zFqMo^3~7RM$LM}N?$rPt{NgQebRvQ{7MTebG+uGnTXKS7_J+z*Fp+~`(TSB^rB`;d zu`;?k=N8Yfq-%-M%)@?hsWI0H141j{V~do$J@t%yuiti@-r~X*>3(@8PV&V$I0@C{4*rAfR2uC zKE=R=@#~DmekhO(6XX;95G$Ium9fh#K-Dgg2I5iKhOk|BPCRHKj{}z0S&)VxgbmO$ ztQbc(m@$EGGpV=W0b07sj|XU{icw!{wS)>GPO9?k0n>33gvozM!~^qa!l3yU*PPkR z!vo@1B#I&u?ahU}n5y^3z^N05f89|Ye(NR!pnsxIEGBEvtc*4?jQUK1k0T|;@he`L z72+$(+Ql+#`KuZ>l|AIL<4Vm`(eo?nJ8_%*RD;c;!BQ1Exg#cW(-9so?Ml3;)FFDl zP7(3d{Z;!LmL$0Fc)BS2 zH++4)MXMXT64?Ev#>R6XTyP~k#Bz-9{_o{{D*k{E%i*CnCn>&Y*&nNnl zWG#`8HY=&-V|BW*vl#Vg^!IBBSCOrr)~Azz`y&Ju?_;ON^;rc{-+>{g+{1nf;ere3 z$l^ig%4O$p&`7vKbvZ={qkt+xo&|Q;OXhbmUgrnrb10QM$r2AUvu>B)O{e})?iiuk zewZQU7mr~EF{hH>X>#iUv6kkQcCgsJ(*D^eYFIWaxHEa9_lW_jOG zM0`Wt!6~MYWBLJqGvrAlkd2E1t|7Abboy9z!#`ed!*L~VnPzTL!5&%K5Z?8Gv`F`2w)R9Ur$ZL z2-z+)9a}@}os>&)oy{+1vJ8N=xZ*5+l4HJnPeAiO_wrP1^+w0CbbTIMMpJ8-yaT~O z%IG0ZCybIZI_kz`E8?dd6jMg;UR8%Y#y~ zV)rT)-0l(r%{ule$OlV1VR_ay3@w5K^PQdUm}2VxB> z(C-^odR8F{S2gJV6}jQ0U->AkRiPxWco;Gnl;RGr;zhr(Icq|{)_CqkVq~kd%cjeF zqaow2-bxL$L8BO2f__>6izzIz5iFbx3psc~cd-POgBOG1FOjFZhP0n+X|d|sd_W6K zSP)RAb5GBtp297lPh9*Vz62_U=4hX7?d|9#=?{TIQy~4TyM5w4kWB!0%1hKfv_H#k z@QFWQhYIO*9xAJ++uye0?NgP7T=i=&eijWv%z_5;+E1wnoK50x0p(JbagIlj!zado z?V{QXc=nv2jqB(Zx!3|yjGlQ)(WQc;wl#~P*n>`(*8#mL%ihD=4DW;^csq)H4~*>Q z(%v!?TPXsNS9y^8Xh9!wy92LGV$Y(~bKJL$pTXVxdKMQv7*tPXJIB9|_|~o%%YHt- zuqeAZ`Xxl`o}!S~dkeYV->B!kyP05PiH&mSxTs(f_XH1VY;P&m)^2jmSE>9mc zjR$3L1Jfb?%&Q~OB7@KGNCs5i>Ia?h5{D?_BOFU}37f_ypRGuH%rWEau#==!Y^;9> zOUa4#8~aOrDENRTF7BW30Df%mc<{XeJ63;8c3ybYus!A` z`=jAsiP?yNw_;-uN5Io`1Y8OP>;;Jh1WFk-Uvxx0X3ot$@ zBy`%ITo1Y8Qn}xWO*PL^%Q#T`*lg|G8RHl4XA@^TyoITzlYS zN!9Nn&AOz}23K`EyA(`OT>_-zjBQ@jl!ImNwSNHxW;;@AW8p-u57<{mV~REq}4 zhmeJ*_zgz&?Mx4p)x>Ao$`RI-GrfNkjYXg7 zPh{wSV8#;U6`vn2?#=;JA+bRCloE>{ebEG!$@pa9WiIp>xI*t2!$@~FkC(zZNMx}j zmqi$f!2J-(B1{I)Ra`7N8@y9l#s&XVPqktttOjq6$t_mOP|X?MMh0~*o-EIW3a@KD z)!cdmn+kNpygrOMZ0(_mL4QG30^>$5_FcfMLSZ&7e$Jj*}!YqHWHcW-LN1pUrP}~@T zR6lJ*kQ)$!SJya#%rWMjrR9|fGKKRR5M+cgZ?2Y?5X3JA%aFEyPXwsib-Dx8`(+o< zNuQnc+%C|Wx4-1B6GthD9Md_9PdxP$$75kKNAZab908vemPj)&|N17pgMPdUd6KvM zYLQpXRZCDdY!#nDjR=D*G~0(C+lCMLnSB0CYBv|Z=%?()sByQu0@B1@=zmyRnxmbB zc078%J5ubibQ_n;8F~KH5*Q7j=`;U9O_KouU;X!r$9UsM{cMe$}6GPaTePn2P1zO(MiraAF zc7{5ho$pX!()S1Ru$4Irs7!wJX3}vOzq*O51X)M1?m?_fqCy2L3)qVDJtm-sov z;OG*!a37#wk8tvDwy@(@n!eFY_=WN^O~UBp&4yu}=$|y-YEfBil4N*#EbI?fNFdJ; zwE%^7`*RpJQB+>7&)BCafK-09NEax3pr}_9N~+oSAXI;^XR?$EeB)0szy+; zLA9j;hxIwZVOkHxR<q)G?8!bYeQoLtvqJ_Hhay=&*U|AnB!u$(-iQXqY&_ss!4nHPNW8SkW`~Lz7eUGx5a{ zCdm?#9WvGUcv7omHF%oEDB|(OP$bb)JvZ$!)SoZ_vdYA>X8XkLByGUp%2v4oK*mfs zA=^FoFD?h9SOCsKZ{S@Zo_LIwHB=UtsISByR2};AR0t7!q1-32q9IfWHpw*kj<<si?dyVJ9vJ%G4^ z^^79Ya4um)I;A?kUxt@j;#KK6(O*7El~TX^5GvAtzjy<4gfX?lp5pjXDDk5xu`>i; zq9x6xWf+I4xzsCvrqhoIVExyWdePuOB@!PqUY;H%ni=+vmPZknt)wf;|d)7|aNW(+*Lxqvb{}fenpt$i9=s-F!p_#PVLG62=H^mF0*~OUv+%&&6=s zMV5CCSVOp#BZ!xaeZF*~H%t_2T_K-@n8;pm?mSJOG8|^lb>{7Zq&<@KwZsD-dYr*n zAeM*9DSSEjow{Gz_4Ma*AS<@w4<`bjUiZQB7SF3;E^A7sQ@@@AV9+l%#DZIk88|1q zM5}PWt!^z+amLmgRYI0kON;TYC#j7cB{0BxhoAmMR~}Njk$V_kf6MhzECD}KCJmJ^ z`wnV5wbTo(a9naZ8zQijT(V##X7h^mjIAWM3~eK~WHY08v}R!=-!mY-g|<$jPPuY- z41(A5OdkEG))om4qZrc;%MqdG)0Jv%8n2L=M8x%uF7=9|Ac&|@!x(ibUWG5y0%AAh z5aiL2O6(%qxpcao`s#yJ(%M|gNJi%(|3i;|9^aDdFjxH~S{G|J#Lqv9@!$ep1|ZRP z)?L6-Gv1!J(3+Ya`99Nj%d^N&7XrNaW5pX^P`0t91-V?ihfrx8{Id@)BByYR;2O?K zuB0*+bj7iWvK_d9QqzMcdqscfHS0p2SvVt0-w(VTg~fkq-GnZRKqlG__I#76yZQ@t+n=^lIngfkY53zM0dO{Yg1JFi3j8e~I>O#WkxdAcu zeV$#VpIOJ##nLP1F!9>Qltg@j{FfK!?G!6_Q_Ljtyjlhe1bQ@jffm&vFn>TI(?g|* zQ4%^3TDc`yCD!;V>AKyxN+8`Y=b_Llakg0{UG%ZECm;C55p3L5>mC=R4DVXOp*i?cDs1Q=W8Q)q$E8{%aZM&k7e6}$f+ zy@2KRLX61bb5iVk@YW}?j=>{gp@m~sn6YD7@E4jbV!w;U*B}#&k&3UmEAY-FG9ava zg$nw_^p~=g!M|_-yl^p}hXGqdV-b@VrNfZ-AoTgfXQ;+2iVmZ)DcA}_W}mrYw;XFvjFrhU2_9JCld!V4yh(9ocxD~lu-BYR?An!gz)Q+1ce&VHPsy~B?gl)G zE4v`?n8mp->+r7}Gs~mcDkC)9-$Fd7kvD^aIF=XvFn8*1dV_8zg5}NWOx&_D8mNFk zWr(;<4W%S*%?4{&kN zG#b0wG_%PkqM+FUF%=l2+-SgsKS*3i1LfBU{0|aBTgriC22izJgf1AvSzyw}@McVg zF)T3u#&AYbeQHwv8ZBib4{xi|*#0s%+GD zMCIU@w86a)W0Df_oS9h5O|ZEtr~8U(CMX*{823jOR?G_6S4P8%%fcl!u&}Behc=_!8F{zW4Zy_WvdJ!3e*UCfT@gy961+}&=P=P`$RoU zS{zNU4@Z}dR!j4t7a+2aR;Z3##6xUeg~ftcia7-_%x3V%171_4mKJgu(oIzIB~~Db zdT|b_pqI4Vcfp0U1A%PpKXahcwB16QNtDhdUY6q(-M~;{ThIHE79EbwCpMnge5T%c zE~K63z2Gs}c`l34&Jzuhl3+9uV>p7-cb^r+huZEw@g8BS{inDXVbBJeUa(5 z1#hF+s(w!sv>B@%d?3~K^J1Le&|-tsTZv_=l*i=N8{v$1818DGjU2#qR%GO2Cj3T5cG1t1o-N#((31^JCHXF|`WkPzVJK~brP^R8PsECiXMXO{65fT}sny4C zEth6%2@XC1;)d)f0Yy%4ysCXMNQIXDJ8=2~rMX?`Bn#Hs5{QHzACm}BTBf<-2V%MZ z6)W_TECe_2-{VUxM=4+pW7R{5kT0iTw=m=`k}z!8GXNA$w8;A?TV)k8P4x*!vtDD> z6NqWK27i9Sb^E_d)wC71U?;3jC{rhEdP#cTBc)z)19++A3p1NAr=*vBR-bI>C2w?= zLope(RXqxn_e*&vy4WGQLs|y%gE^d8PT`km4mADb4P2gPtN7SN2tlty%z?&*jBKt< zrBpl>UC@+@KJfs~AJqwoO2yq^Yza!mDbO;Kkd{ha@)nps2Bfw5W6TL}1918C1&}+u zWU5XqV~uK8rlhzJy}Up6vs=M8g3zW;EH0OCR6?5?4ZkM^+>ZC8QlIhuZQ$op(+!UU zAnokf77wfA#ufhCA<6^ePr^=vR~BZoDn?b7$@fZziFaS)!MA*b>s;cjCR*hvn*+VB zTY6t-b*}cH$}sU~E&E1f51v#mp1DJ--B$SrM5z-U!E}fQ-+^3c%lT%CE-f1~!ycLx zJ-1vu0osIz95^*J%8RRl`+rB7Aq%WHL$lYxp{`wQcpFQ#Tttu`!4k29vpFCJ?}b$h z53XE{0!JsK3Ol@6I3XFhm3v}iT%wQhYo@!7mcyj>BkylwGvqmMFf%y9Clhab4j9 z0{lyS^dwUv)NiXi1azP~aIolj_AAgbXwa=hQuiXBcu+YYx9Fu9peZ*L(NzQnWXjbGZdIK?Uy!z7B=uzIAztO0q-aUSJm1tHd&t&(Vf65GQ2 zL>L+Y!xlbQ{O2jEg_US@`5QZ?9PqmP8)9_=mSQE;Z+IzzD%?mj#8Y^PIR>4UigyX6 zcqe*0l6^rlS@e<#vLCM#i^m?MCb;xyo#$4eBv)G0C|*WsY|p<6qQC>jXzL5te`d)c#6<+%WC8R%pBr^?ZlUV58{++ z*EU%enFM%XG2>_heJVjw^<1$MoKELw^vqFYv?<(2{1y9murYg08iD3(3?yiR_NbNg zW6WS_Y+k}FAeIwrTZ`UKxB$dj`df;kg?B#aQ9fi`(yzV#Uz&c+R{2Lm`Q0#e(#ls; z`4TZ&76&dh>eo>&0)=B87z4zME{_&opd3M4V$&qTCKB{Wd;>P+5H>l4gGr-Btip1W z*hDU(X{`XEqPzuQgla@=Tqouep1(+hB5M!i&J`P2AYp{sN&tKelZsOspD^*}1Yp5B z-=?XANdTj?|NeeX#6xpZiDUkc+wz==sa?@Kfs2#f;5Nr*%)zx`XAM9Iy4d}&mbx`;)=6V!HdZxo6y}N^C2U^d>py9y^ zbvnf#Ks)&3Akea0 z%!hmkOv(YNCD{=bcg&)G3^w}z>iCf$K&KqyqRnz?g(i!0Ad4|U4V^EE>BHCx0wsu; z)_jU=bb{syNJ9E4^Z~5lMje;&A_6T9W!9CZ!GXd8dvc;w5!sG14M#7 zQ~caUhT|6pIBd8tg|H=N6IN;o{R|zz-fskL-S-nJAHY%mCs=CAe!}N*!NsKiX68pH zdbpCZ)FN(Yxz${=G-e~Fcc;y7VtZ!2y%%|hr?>Ztp}i2=Ta(b9S&)!QFkNcy)QX7P zgOehvPkgaBRC&CE#R_ji)h_to)mVuMajf0EmXm#6+v zy>{p;F{iO|`a(kar29u6k;NP)wZl$1K2NM38IF%hhF$S=_K!bioYQb0tIc~qZQc)z zw=L^)$Lpi<BM1T8BPphYXCy2q!GBa zBW<~li&z4vhV~Qg3$4Twi!Z=m=nIgJqd_3PhZG%e+#>#j-qH%}{K}A_J(qF3jsJ+Z z=a!BpCBbFU7fn_tG>P*~$PV13ybpWa<4tj2EcHu665|a0O}H;M_>*{l2L{V@_^gre z+4F)CK4uXHb>Yr-dS%SeUc!B```$CQw?uERPPTVdLVH$WVzoHtqth#6hW6~n`S>I4 zlQah5mfH_)KFDGV^Ks~T;}Gp)%qDG!CvZ7B5&zuA_A9Y_i?{ED*sBluR@wftg!bK{ zlF1x~EZ?O%ZhWBiN%SsP+`ENF5GM_c04=M*{3I&yUxF9Cd9%z+wrm!!_$4$3iv=s=`t?c=N!2ReJ;V>xJ&*7QbT8QRBen>!_{`RY zZ~U^}J)my!6jEA+#|;&WE2t>evmvp}#}hWg#D&kv*^0J!u;qw`?V^z32eX-zLayKf znfE%*drB-XX%%pwSoZQW@i0O$RJ)9G0-`SQt>m2KR#e@OzYZuxa>Ny& z03=SrrueVG3E2X@13gU4vdFfT+2jBCUa{ zhLwflFjkIIAFFO2Rfnhdx}!pP1gM2>M=||rAbE^ij^RPDnhrA}ZpR_+yM-z;V@M8~ z9rPcEObuX=bS{PjoOs)9mG@ILy5q*pU_un$PV+i8m5Zw&Nh3<#Z5vdHd>EApbBYb^ zXeg!6{wsGB{cNG}xS`xGV5*1#t?aMR!jM3?n&;8abvate&^Z@F?%}V=71O2Yi2k{$ zf2}767s)c$aGAlfOkQ-3Py8AZlI9Ar0=q!&@_WC=Xx1~+l^d_;3G*JirYb+XEn-I<$4S$_`IuNkjc#Z%aWbz1+Q z9yq@GNm(Rz-az_bD4ntiKAwO&(VZ71$F1rqz;-vja`E4R|4#gO9cU^FLH83jSMIq! zJ@QK0fu^+3X&4w@U|j+Y8VS3j#RGsM7KR&;BpuVOK*s}3T|(!Si`#%?ST1yS>{cE} zyu{E&${pwnR=2#k=V+P5Ma-dL(J*SYinT0cVLgQgD(8@9g$dvSkicQ$0w}Z~v9x4! z8B3^tIW8$cnMbd8)FZFf3-V-npvfL8LT{aP4#5Ey^yv45B>33|$XV7C=O*ZQ`4V4%Wl}bW+Q{n-chU%Y&fg zhEBA+nv`Chl>TT^x{{P$my|v!DgElC^z5YcTh=D_=e(r+Y0Zi00ueI_o%i-wTp6}1(kkPAEE0NoMbB75cnO(m zJxN<|U^rG{!T#_sDQe*oL``R22HNsoe`~VSD67bEMgfn8ja4jvWb|UE5`B z*Kls+*wN77+VN>)OVh%qMUEW{^{pM>Wo&7e@JW$l(U7HfeEQhZ^l&%brlCoR#8CeV zyk0m=tYdb~`~3FgCj%&s>l5&@;6-pO%K>ecCs-EH|0Mr})I3 zAlTk$*er%$Pi@laqy^7iL%n)QQ-CQkBsItnzl6+2Y|aj?91c{AaHN>GCm`yX!_jf# zK-k-cA=;OFMfvj2;MrLc%5_a8sPf$@_0%W~^@{f&e7Et7LC}`D1LEpsn)@PukZ5F% zIQpV&g{)QY35bi34y=+F89HNdoy=;z6*w$%LVR9H=o(-?3x_yFp0*RXcZ4mFo=FrW z4y#>_(RrK6+Q<<0Oh?T(do>SbM2b`F?10IUi)N@G zP)(a}xGx=`W!+!#9+z#QO(%>U7INFOA&UpZi?Z-NxEH|_7WVnXKQVujt*$84T75%C z6>M8^mVW*h5I4)Rm+57f^+lt9(sJ&v_&~bu>YsN&IfjNM*SS1+1=j1Q8>t0y;%OHH zu$aol-yWqHTn3daBChA@WLkf;Bzyp z3+Fawv8aL}j>%t3FP5rvv1ufSqL=9LEAnwVcGAnWk==MW=~_+uZ*=N0rdhO2bHR;@d0XbN5}cf0 zv5@n6@c`pSm>;I&c15XA+{885$zcjm(Y`Iy6j^C8*(#S~?0o%eb3Wh{U3`Re=9$5)V`Mq3>Qi z|2E_=MZ_WX<;IpS-#nnc9Qio?n+?=_q{*`S`w$wes9S~Hrtrz%*j8ka^N9Oq;Lm*9 zyCk_{C#@U>5F{F36;uGRfsbMAvpm_D{(P@eh}o>#!(JzB6PvusbcBK7g^GfYV5SI@ zXq2u4-6uY0##`8;!oUaMCM>JRe-n)Oc8aPEF{uA&DE^aHRBy$ePVBtKxPB~%!uoO0 z4+cm->pX7!_&{9MyZIAt;lN8sDM1M}!@X>62ic(}PmCv~1LHej8=WtQ&V};jQ4k8R zxDscyUU9>n8cqW-u2SfnTF%Z2g}lgNf-0~x1)cGWrNB6FSUQC6`+H2o<$7LNI?uz> zL0UF4$~!%I4e~h=ts(!poh18|^t< zu<%QRn8#MLMj!o-BvN zngwzE!Jdjlmu%inf@tFSjf_i(xsk>HP51>297K1OtC<>vrv75LnFf!!C8c-gh<>Pso{*JtZk)kz2w1U#s0h_1d zk83J$>lEmzR3EN-+0`xu0FbsBa6)uk) zBB}nuw6Rmt!jmG0NUFcE%h)Mh!Ued3C#Cw5^zb?Ga)e&>#u)6loH7zCf~kGDp9CO4 zN!LiijLT(Blnpab1i6-Hz_w-p&=#3yU>dZQdV!X=Q<$De1_rgAPk><}OS114$dc3t zDf?azUI5wm5(p4UvhUowl4xW{bWYWt+3={?-u^_|$FI3EW>4or8!(XZ%T|EF`H=Pc z5XS+xErK8#69Ha<2ykJ!$U$6DNg#c<7|dmoL;yUW!k9rU8mfu#;5Gullt0seXp^p4 zkq2O~v0uEsn8!){9(T5H2mPfUJdf=s5jlv$ReNImeyPHF4gB6U0lxCN27AG7@Ov~q z7Yv{6H1|R!3L|j%tpV3AE z{SOEQfO$YP{)!<-0x;f+hZYh4NonN7Ht8Np!qy<9kTSX!nPI?BY{fy0COU1|-$t&6(E_b(vr($4GzS6@s}H3_@sA8XyN1JBgl&nKHY) zjnNd$;%bB0q7T5NyPbI==#bIll-p1UqQD|>T>u(v_qTjYqNzDENX=bkj)qNQ_p@^J zVoT8h=W6chbZlFOoST1h5oC^bC9HR2VbPU1Dm=x;k z6}_L=Dh3YWJ094PrU(rw%we^P3fix3nRMg&Ep7 zU!N}L1M$|H@s64clZ@M9`Nv7)fR?N$67+kt-r&y~*Ln*ERlo&=9TJNPhDS4-NVVdo z|Dq5xM|(i{pbYnzjXb>Fz|%Ig2i*r5iUe&CXnHPaMJdPL51t@p2in**gu46adJSm; zT`*BpA)rfcJp&=%W-S!TD=7d4t9TB5a^m)6H2+Sq1@wg+jX|9Q>7pj*PPhP^Gkb20 z^-kXOpqpBM#IGbJqBoMzd%6vZ6o>^TQCSBb3pZRfOPyi?=dx+A*v*%qv4V3e{@r>8(oa*(PZTs=XD)bYNgrjTp4BUEK}O^&LBfmN7g?hn&7$GzNxBNeMHZi~wY41|dtD*;dR7rH6 z(*$pGs<|4(v>PIEfNp|i9gFDiK#zu@AQq8`?W6%-FGM7)bm=4$AS^)Dk-~aYXsB9Z zRn7GY*nTn+ebM}SagA(-^mO;49W}EQ+klto)jPa0q*aiYqJlws1pzXc&?-BXDr{jdhe7sXn=6HS=I%9)hw9B$Ju&WttbAvY1W%JYy)>RQ`cvV4qu zZ?@e;Pok<9nd)wg*7>5)Rk-UMx78JHDrg4%a4j_#ytz17@DhICS$tVRGk*WR_~&&K z@P^;&f>#lGX>nP>>-cS6T&y+?0Zy;FK4Vg3`;j8`m7&`s+sp-DRUAe|?z(a5cSSx) zPk$q?q4B#ejh~pM+UjAXc^dz#i~906^yw=2X89mZAFEC@HJdlmSkqs8-JgS=O?t|}aBs2p>9!Ht@gyjYt)zY z!>cOBM!SdA;NjC8jf`%<76|iB;D3=uvt1SG;5~edoQ>|g;eKVZxo&A%-k#d5 z%3Yyh>QSc8Evvrk7S4cbeeR^ZJ=OMA-_5eEYbvTOPpi8qt#ViRBfu*gfN*a!nZTU# z!xyX?V+t3;MZ!}i>JJWX&0;BpUgBADz*PcD5|}*@c`p~|$pezsTvRZwh^korNR9cK zlGeL}S7}-LBMUIljqW#(vqK+G7;A@)^mlr-cv}h{hOph(bRA@v8@XI`u|@0!mBmE> zYAL$dD&C~vat`8>76K62^=+98HzjjXA#qYoTOPMcJh3(Ve^Tyxwl7%lbJFYV16koy ztN`%AA|8SMS+q z?Lr;H=HI53chy2i9Xe_LZH3*!ncFgFm0%H6XW}n((VuV2>^8mxmA$pCB-$NP!qEMG zT1>fObdajF7HkU-!Af%gZS6(rUr^V9syP_69qJgjZ4G&~K*%{~W^#7L0?W1KrX z(#|VyKYYep{*Ilpp?FK{6^Y@MknfG@6CY0uH$y#POn>Iu#PH>niQ(D5ObqV<2Qikn z{2?K%m~y*ji`7g#AfAR#R!rwW;7;P-l%-})gSTE6Zh4m2fvmuF>@y;JkK($8`eMzLwubC_e~rJvE`}Bd zB)1p?4S&e{p?E*KN;a zEW_t~d^_*G0bkX{p8oe$$W#|nS81v4`mrcS`Rq8}oTa{M^wm~(eI|%*pG2RPk>-r_ z!^-twP?6>?$}H3q5JQ1*`Zn)uo^dt^7uB#I_a{7Vr;Ww4F0N@9*$YqeSv;rU~S z{7L{!UXCzZ{C!Pq?4eR+hMt1Sw~I}hR0J70baGvZ$LkOF64QT8Lj;!&vAZ^9R;5`?T=dD-w#x>W7ST8H@9qY3s>uarVhJ> zVqm#Q3!O!O1}bA?S%aK)mN6?9#@;j(@nc0m+!7W%69NHX^~-cs5J`~=*xTBQ->mVs z)i$*4r`EsFC>>&XKriK5y#r81eoji2MS6t4ndAkNc$YVro=*2Kz#3e20*A^|X6mSl zM>-|r0R3|*v-N0z`*OhL+S>`-1eYcQ@ygIW08P$Hq9BQ5GPE~QuXw8XgD$M0y;40I z?afDfQI;H&+cWF!?ZG}RC1sWEV&*LD^XLFQ6OQZr4Y2W}-4LU!SDc2H_0`&SQ*R`+ zEEJ=;b54TSu+q;1C z@x46d_pv$_avi0yIyzm1hCavOm8X2A17@mNK3|8IOZi5R#`*}NuOG48$m;{+N?`32 z2D8;b_PCtxj;C6uHxmzFqaW&phwb!116E4WPN$v3;rAiz)|FU6!h4AOAsUgwwzeFb z4WEXu0X_!locz#zAai7NdWijd8{TcwK|<-q`%u(BYt@14UCI+NcwZ5`3UDCagE=E- zo~*wlt<$BKP#n(IDh4Xg#PZMQ{C}4D*`S(rl*gEgTU5&hk;7?AKZA3Yqjw!11C@r@_^t>p z1w5Kn9fu77xp;;SkW-Ycu>$kCz;9%Mxy+U4o-buP8bvp13l&(A4!I=!%izdzh;Um(sWV#bS@V^SZUQ)K$l*#d55!)nb<+b{u zH%}^LRig}5>s@7 zjcXhZDC6VW$dxCKYpG;NCq6EDzP`DVwn}QI^qY6GUpow1*>m$cBt%}1wtNUKcMv*w z(5O%^zq0Ukr(GQ6O^*c(2=*yeKBi>Qmk(DnBg-tNP(NHcFpJS(hiWgcA1|d-tx$@u z7=idwFd8a`-A?pN(*ulGv)Vra24-i3YDKYN< z-L&`5A$zVuVt=C}zqB7CmS05YFSfO-fisaqU2JO~MA68hbX(Q6h{16OrntMZ0owAG zVTgeKwYF;|)WcH|usz=ZEuK@HGL$TDX*pbMd%i28T%t!TYM^HPxhuANcVnMD7&d{l z0l_0e>J*3!wWitCX|Q|&C8Xr^Sh6!!@~1b*(vYCAm-i^Sc-uJG(<@%yKvI=6A?|6! zVS)>;aV(#v+=6jPYIS*JvOO)h4CwGSbYsdPU;~O}dlgF_e-(KZJHCP$Vl4M12i*r# z&^vgU)`Nm;oOm>FZLHdPt$&s<>#zJK7I`JNvjFWt)M|aY9M^*0JCpqYqxj0MD6q|( zZK}D(j?w3mT+FxiqEKjeut!VAsUI`U@@!;<*%Zza7h+G$zRbfJEaMRMN^XnuA1HCB zg-V0xGJJ`sRD#o&-M%Q6hvDmC(d~WFlYyg zFR|T}pv}T<4vG;2SD}X&-zu=WRFs}wMA6mu01cov!-8Ahk*x84=M~-y~WkimphX(<^18`HB zrHLzXFE?9+^iuS49So5;$s@)`d=$k%r<5o~^;|N4a+oGEc428j7r>l0xMh+*&;g^9o{&^8w21=E0^?3z*%{Bmka z<#cR^b*nxd?*_!-BV3B7qtjWC^w~8IHDJZVAcpIQ&^+JP2Xci9vX5UF>EJIpaB>5A zaKh-xASB|KMElk`Jky}IWwN`nD>_T0f?i7Ke0RTdXG<76O#?c#Ts=84JRF)7WBMLg zFB-!Zuuwy|J{zRcFlxr1fY=~!P(b}>t2_z(!cU68c@(UoH-MufE0zp_?)Pd4PU=V} zt|$QP-srm+lb$q!$t*?6m}uWz&Ss_SGaeke&_wM`^U_>9MHnUtWR5f!Zi&eF(5tLN zzhp_V5Q{Cb5=SsCAU^tSQYE>v5=cPL1jM1K&>X`I^B|itTWT$^t?ggABhXmW^R*o5$oR)E`vak zRD!yipV(E0r~-$5);b8qf_P+62R(1^%*WUp zpugEq4`G)=8dJ>s@L2rk0AO>*Dj*0D#}w`B229kEg>v@S6XHPJu;NJ)po^Kq%0fg2 z4@CREf7=k;VeUH}Kge^cVxjsDp}zH0ovaEtwtksji7EOn2T&9Dwqs^v?6~#~om*e( zn%a}qGdBLCOnAb=az2ejt~R?EU@(tMErhPC|p~$PK^76rDt?*^Y3~ zLSH8OGSkp@>R^kt)2&TW5M{IX`{(tkL)dih~lPmM<3l_B$bYEln% zSx?RCp&sk0X+30HPt9|a>#6zn+tpE_-ih-AQC1L_w<|pkM5&b?7k;2hmmP#vIi05= zIv3GecM;=i&ZtB~Ky*{hz-mLs{<}j^&!fcv*zrJ`GpKG_Ph;-(%n0>s zGqHci3L?Bv&n^NI%aYhfnX2IcR2H&T(!De~%}}-wwd=bV*@H0bfgJ7dDrDhet0aM` z4#P!J;7pWiorG*;U_#DBfT(utI|>eM$A6=Jy^qdS73RS`!&&$_t$Vopwqo<(?%b#I$YRG9 zS%dLZ&oX;j2SrB`89;s63S!12YxUGpfT`-d&QZJ`{?3c4)qn6YjlU^FGH|dy_J?@lelkIf{>yw*8dBB z<$dcdqvG_am<&2c(o!bb%t;7iC4>Q8C4@N>VGALQZ-9j}X$o<%z`x)2}vK5coXyw(vASK{c09Z$}f?Bhc$C;!a}i2XoT z61AOG2bPV%NwC`#q7xgt%;x57xK1+h0un=9biHp`kAT>E9!n+a{(vC&4`91EvOO@@ zHU%$c8F**sz2CXB-hve_7L1I;v(jTmlnOGBA0Mt$(k}>~=#X|?{VA$ozgSm0z5<5oh35ZK4B@FVT z!q^}m}ge7FHUZf zjfn-)Mg3Z!K>D3@}~p$qjG zMOpO`<#`6iWHOImx%2~ynK(g@DOOAhuYUz^i%@oWv3zTrFLoZo^9|-l2`4Rb_Q0Wa>3Z%TZo;&G|$3t8iY2 z@`1V=&xdhi8LYN1MV@(eYdlY)mb?a7QunS~=b`iTx{DeM-o(|J8H>-?l3`xm1D}*w z@XF#d(OwAodm&@pBc3NHwqN0bv zC~~N%;-l6fu`D*bwY$x3?q$pBHsAJTqb;l09EvnR94V^UQ(shwU1PCl7Rik?H0`_r z&)-1s!G)>;(6z8uMZv;ffE@J7se<34)}AU{&X{4S3aeyKF_&4gbqXsgN^$eH44y}wE zWhignF^ZrH{wwcrLIsAxE-IXN$hJXPY>&W>*pk=S_%D<|G4)lTfy1_8yKMPmOWw9T z=mjYP6x7*>h$cjMI0qTmLqA86Wu9z`LNH$jbG2Zh434HCeZHx_p0^!P`<9VgLM=~1 zEpNJ~2o({6OsZM;?Y971&6;ly;irUe9&hVZn?0RAZ1#&9L8rId z>`-cd`LZpmhs{12n(Z&Q}Yu2yd)KH3z0 z(nI&@;MB)_J4?pIFd-K5W4JZ(dcOYP!8D4Zc-(X~ zjU{>4nzyU93yQdti`*=W8omEVowRV?o)+%2r=jpIC)H9DFZrGm%4e8`u-31$5I6 zvXMQ8V;6hKRkEp0t;SzXevG80g}aT9G@1!eUxTb=lfODrVv-H)vliqW`X&gqL(5N^ zKGJ5YU)1@E8uz6aG|H>ZxDAJfoRO%-cJuG32jG%*lhSJ4i1kplyFSHVPiVK0Sw$%)}OsAKucqQN9wbMQBP2BJ8yFQ_otEUews$uZ7{ESXWsT*fjFMxE3 z#9I9B`WJ<;W$jb@65{N!&Zv#(&Vr#u=?7&O!mK`yaVXMbco;$p)9Rg zz0Uqcu{nbqJ}R!D-{PvEwos%@-ZWB3^5O~kW(u7pEy(fvT&HQYicpPOK|J4n>#5|=fK#iX<#Ad16>LwRG zM=)&^=|jIr-(GO^y0gH8JVTEnXSsi>=xa&GtC@8Ziqijs`Q*%yWmLm zICI@JGcJr&k4xvjUHETW|;1`aXR5J7B1Usfn?gVW(23sPG4A%t8#nq@x zE_;Vu_Q-Q6J+|i2j$ICk*8L))u3(!OY|hz$f_-%({RKR8A;h`~$~m^Gvp}|B*8^nj z)wnm^JGER4&fyipc=8imF+FbFO_dQHw{*dv%zkyDH9Qkv4ww*z$BS(csl1}Ln7U3E zLo?u}4CY>GSn&$ZXh;`~dkNvbP;e?ggYNhWTwdxS#^c~7BE7m01%(U20zT%k9`7>r z2j|NAtYX($m}spTSMV|!Jx;j`Fh@(fA()^gTHInU)UP$r$>M>FE0EecxVglPUz4~M z>OOuceK_(@M0 zKKMNgdc?v(fFM7SluR3KOj>_-asfe)NlRH>DS zoK{DF0fq`Xgbwk2^FN)&yJe#j9sa@;5xJ|x_Mxxu(z8Go9 zf`#A56ZLYHOHdBo3r{DfsPGLBQUw5`ks`4QwWE_o&?#HQk`gLHCka^`J~)@FP#2n8 zam>WeZM#v}9$jb=-JnVa`nPt2>pBu%+_%w9vTXc!2Zu$^RVsn`@IyeJ3A(|?rVfva z0CwaUE!y|nUy=Gg8|^S4v!KtWc-Y;E)&ZYITOHKdlMJzWmuMXerI)n+R`0@>h^4XA zL18%+H&Wp2T0DVKdlBi;!uc2i4V0p&+tc{4#L1KO0Dz{~wO-am_xZ6{!c;Aa6#_Jc z42qdlFTv=a*h)u8fgbkC`;ffEE#iC-h1lDttgfUgCYox(W;o=vh->(S1Jf9~40CMP zaEb8z1)qfEz%dKW%hPKXnkk(U{-h;v1X0$i9k!}E>NM0-WJgXYB5v_;@q$CPHG82; zX`u&6^ij=24T=J|GrpCJS0KtMSL$Kh8oL7I;x7TJz|#zzLs3)fBE9tKRQl4S+&6M= zvfq=rCx8*;s%aEPPE$=6xTvlfZx$Q+VMDI>bE4?LrMRRkDLsTd_*_iCl(dAnCp}@oy@!h;muLd6t?@yr$zfd$o3s{>TX9RCE}}7C06dQ*n+V5BoGWipAbtFI)!Mx z!Y}ydEVU^+GY+o-^bj{ossTTGGvTli4>c*ZdJeBxl8656N9SnGzIr(Vq?X{~esKvB zBXlD@R+0o-W8G(Q-Qd=G4*aq_BpbRnmVFU-<$Ekj$=s7Q+E*g{=&O#3j0BzZf<9cIQ;KrmeTzYcQOblO@RNtLR^&R|K zV*X``=_HTB$e1vXC^X5UWw=%b$;~T{vStf6;aME@VEvGciT7ndJ^=@H|G^Ij960zP zOQnU%lO&bW6E8i)Bam-^PIM!UT$(z$7=v)REULW*8IQlf!BuMU$%ndz@h5-Qf1XIc zFbFG|%iyh@?sLFwtu0^lfnhRRwxS0PaU=0$+%BMt*>0to76SLYi$c3WbpA``saQHN zbnq!8hLMp#K-d*BaNaGxge-``LFzhHc{NtNn_hgQUL1?Mshqsy7Yw8VAWAD{!2Zl9 z4uGt%dqeob0%hq-l7%z6&@M{YN{G1mMJ4J)6fmUi09sI|ao3{6S1LZ^PLONo#{m9Q z_>Bl22fuo(_r)XoqD(&1gggAV{DP}cw-HVFsTMi=Lf8ji2nWR55UstUKP;Zvi6Cv9 z_XA#-1t;7DEWlgOOutFdw)XEx02o|^E;lgH{Wx@ZAMz2AY{<}|^E7`)#n|K!u=)ix zfY>TYIPsT?hr#Y+^W_tN)C!X?)g9s=AfZF>76Hul7KD4kQ(ZR^=#ARHY#kr`g~Whv zt+QdN7cLIMxZ*d!Q)(GENWwjW3uT6$t%c~LZ2os_@j>&E4|h!a!soy(7U2;;>DuZD zE|W#(wf-;m&ICTn>Rk9q7+{2f88B$j5eFPKO4L|TmY^miOrk~{44J5GwxxEAs3~#Ty23%UJUFe6cSgoFPT(Aq^b(!z~JZH9qpxD}b zd%xe8-|u}h@7b5-go&qz?0mf6Yd(gcMNux)yY7{|`B;&ik2}xZ`+StD4H*OCZGF_v#|ypZLwsV% zklOh;4Yb&2KDvD*csp4a6@9NAv;C46$dsHWQ}PaoD>Eg-qjE^drWux;k)4(AO-lns z>?EC)Nl~#$&~$pwX!4-wk23+y3*jAw@y$%nVy5RWO4^y81897M88G@^JU!3!96}X& zNUi7PdA+|kpVjNzs=^ePzF~1@Z>X{S+Z)4Zz18LWqXw{SG7Crzx5D|VI~YjNcUaCQ z9LV84L2=RIQzX$;EwVVdzYQ$cz{53Aq)wk~tcVApgAgjCVU0bOg{6%u1w|sR*qmo; zToTlaBVAe>lnWI{>XLPZo6LH^Uae)D%v(aiwFyzp^HcIf%xb6>=}rNtY~*r{{FX-E zq?1(1+8c>$Vm?SYO2N;Q93H)1Q!du3-RVj`XTO@=(v1Jo4_7#Plcv0?Oe%B;6o29C z;C$OQ{OlQ`9=ey8cji;Gn#?$28z*cti-}bkvn8IEkFkF`0%{k1L(0OGYBxwFVm>!f z8}$L)NYqs+BfDM0Q!u?WHVT-gTeg|-#HPu%We=VzAG9o}dER7etg+g>cYuVJ;FkPr zLc!!|GT%R30yEd4if;VhgKujxzn8w7!=PnCX5~U|KWFBj6YgMe^g7lX%goV^3XR37 zFRT?5wiyT8i0K*QO)ujjDWx6h-$X&w@dX}BvFemdcrPZWZ|cvaYJvt*s6S(ICvt(xzly^pQqXdfN^}PJ1Xo!)%i~#m37!V`DhFNpsECT-vnFl8bW+jZoXl z8mU2+=Y9(2QtX;LNYGrE2G%;IR+{mebahBPYecYV9vgj83lHYfTu2(bdRQEudKoj}uHID}!4?hGjF~sF@$i zWj=-|!8z~iNQg|k?w1TS2lCExt=T*)x=E`(Sb+Vv9HK9bF2HYTj|$uFT}*&%CV1P{(|_Qi zRA^4Q@W^Fhb07uLtjY2;nOl`#k?VHbu9R_90lN|?Y#xj+yrJfBI6#(+Jtodt?Bcd6 z6cc{);E57}+a7PnJj8TaeG}e{h<;VoPZPHBOH4!yOtYJDM2zs8kf`EzM9i-!G;IE( zLW`EF#gS*;Y?z&%bZ5Qm#PvQSI1T7Yb1p5TXu8k*pjI0|!KFRlAb;m*UgMt>d;FF$ z4BNE|?Y~pR?}1Kx+YY6unH*>SV|0zi{S-DKy%JnRb(eyI-63bu=N-?_(@`>#saCK>720xUl+q8bu48w)0TlZ!+$&Q0h*PjTOYP)kr(S3!pG^jDUs9N0Qd~QF*8)Zo2KZNEa zHsOD-r9qZQ>$bQrdOJ%YVir;bODb9PnQh4Xjb^4UQn%or(&lj3h|PrJqKKJ7Qm*&; ztSt*Eyp#5fno{(#AJ4tEpj}7r=w25rUhw!N@9 ze$jojqx(^8gGM4furF%5{WP7p7Ab+;)B#0$DFA!NbJFSFu>Bv*837rADOg~)9QTOQ zoJDm|{hdw`b zf;4Yo7oSn9<(x-oPdYRY`u_cXssV?YR*=6QxUz!zE}yVXuW_0fEnrBR%y$N*=fJzJ z0;Jk#S_d-PJ8;(e2r9tnTLOK*0+|cBDl-Ft9hr&}R*d?xA50#m$%pjTL3(BrFOI*Z zUKlXK^}u5gC6Q8lp{cFnPjSLdjFKmogrl8z3jcMGwm})dpAs8Nd|15v57FJ3svFE> zFtE{}v=`3h4H^jIp{#Y{j-I<}W=e}?W$%oAsll8hwKr(x5i<%om>8P(e?ELof0_4= zl_0T%I)irD{m5R;akK*AHOjOpe@yeKOElcApdgAB;KC0;Y@HzG1-hc*hEvC?!3?Lt zL{El2uzUjP?oFrrpMaKL^2t&aYWtB-JPZ+AWF;!Ch~Zwqd202`tfm4u7ZFej(1Efc z;a>f&$Se=3kTT;*A=+)vbGsKH;^7i=oXrf2?f_;Y<|0M>It}0HLa$MfUP1o5xj((V;npOe)EiN6a@# z1^i0O4CUdo_~$a6R}AZ&VH#EXjhIMb zwrs=n=K4PobYwA*rE0eSB*|7&m#gN7gtu<@#^<&_!tVkR&BQ#r3 z=I3&Jjqdo9n6H%LNo3y= zTcpUoc=x8ri1`+BZE~mzxe-&Zl+d@Us{nBDwymbkVBIp|Y|0?pYbx&Nl}VlhdW%=qDQ1iRddf#&N$Wsk&d)U=5Cu8NYZ*_Hr_!4`2g zYnl*$GjeZCE-)^kqJnt~qQB#xqJ1de0fv2)X!i~~e|8h0`8JM!}mC3u_;&vV9@r~xt0grchHaJfX*fM@&{IE1$YBKj`*fR7h zjZJ2LxvpncMLkr}uk?@FeFJ_o;PHws%WBWyEML*AaCGZ~!q-iIUr-4MujL@Qjc0AP zTh3?b8U5IM4Wzi`C7XUSIDI>VyjlQS)A8JcHNAzq$o*groYTyOkZzP#MC znRe$5Gwq|Z@xm^#OMBUJ%K5dbI^7HcpQah@_@Q>o%T>}MBX3Y)xOd96#-OLzd zy?RS_zZnm;mBq?MHSzr-=BoFU(8vju{HpR=mHTPoebHZ;PXTWq$6^LKlh{QqGnYV; z^g`*e*3d>Ld9QB!3(kKGGc&^9j!$lJs%nJA)=Q%lR?ruK-M)rn{ z7_YU8WW%roxCM4I(oV^QH(`Y#bYhSbn{}2MM_)yMSSscQ6ZtU_L&k@Xp(v4$*vAh< zMN1Xc_6$>76WU{R5f%5SH^S4!dQ6UUW-2GBLXW@X79Wh|%##^>?RR}hy82$C&`vGkXeDndlD3KUQO zwuM!h7KUgc*5bfRgSq3sWQycwELX*(7;`pLc@7s_{XixID)ZWCs!vzUFkzcsBUN6S zUSzRb$T&E0NUT^1or>_Xn8vQR_up3f;%3WNOfTdbiugeAv|UMl=jpr^aT*tEdl@=i5&{?afhsU} zA8otE(f{~i{jG`h{bFnLVEFjBuLPui809<7R`xc6{5F9a!6gT0e3ydFZmxAnc24D`CR7%ZK$YIH05$M~BIt_r1vb36H zP|iPGLO|~EJNjEGe%jt-4u!}?%nGd^kL0!=`vwL3o=WZ2P;VSbA3O!I0Jy6T;!jt6 z=1~v^4m<~GOF>@F#*eqsZ?I?-ht4}?9W;mSBB)k76TqvgOsA#6%!6M-ai-_n}!4yh~$5ogZU!FN3x=qdhtrj@pjhP>lX9Qcm8IMh5l*;C=7@Gk6=3uC|Fp zZZ$oiSGBq5O@P|RYrFSYuMvU8kidf_Kg01gqqH1bahA3%eI}BsEawX>ck&6A^D|O_ z`1Q&@)Hf}!z9M#{Z6!Tbwh~vSl74rf)!(>J5!NKynL${`_J^=;V2x!F*6?vM0E)2m zB5j!*eJIP^HACv&YKHwy@f9m)ki93kM-U_|Yh%xz?ORCR_Xm{K_M}wL6XhhnIM=pv#1$5_UfHo91c~9M8UIjLE z9do;85b2>r_XpT+{5lk;5hQ=6%HS~dv0=j0{|UUEkZa*(P4Cs%tfzxw^IFlfqR;nx zee7FHsVzVKNcxuly%dQl|NHP!y}u9XQ~tO6l)o9=otK~eVf32=3gQDW5*6Yo=)V$1 z#xnj4Z?UN(1=38vb4(Vowfsw?q$G?LgDeH1>jz$vUKihJcu z^W(0>TdpN<%hH%y6!)*KDx5mKFkZa2s%Yx;qPS;m)qttf2gE^D^D!sJ!EJvlD6cd8 zRCsDiv_LBciLdL}{=)ktl|2_ZXN|BC!bm4pY7*?+V_w;k*_=tTY>_k5=WHBb>x+Mp z!_S|0A|7X{C(+}&j5F2+PEp06xKA4Xq>Hd6ZztY;KX$^}qN#)8M<(9=Aa?ZH>4j56 zh4F(D?|v9NWbO2#siC6yu*AEmm~ZX$0aHT*V(x=CaAgZ!(VJ`(UiU!`=-tcQm%VSV z{`J>VxBjZ(aNmSYfPS$ILO}ZcR}}(8Tsz6Umfo4bLG9&O!F$XbsBB=~a7GpPGDUK; z-5|901a+}15FFrrus&F{al3oyM)_5k{^bekk93A#A8a$^8uT&0?D^80UdwNFRLlpl9Q2?x8#6bC~{;*E_gAM)aZ~0WhM_ zMLF)W{UE8UxZm~gG2O?XAMj^x`V)o1Y_+?B$V05YLE=B#&FMApj&dl79$ZVp9#08G zjwomMqwvB7g3%j@a!uHl9F|&IW(s(%2umi#HknE-1^3BC zF;C}Y3YxI9=PCYWb1bwu7%J``?-3niqj?@KSSZVm74w_S{otKQBhVEvZQy@rcaupf z9l*w&Gj$?^<+tlwDnPlMty+Unwgw}1Ksrb9qZ>memMMMtZJMUZ%u;2L3u*E@wYf=00nOu^}`j-l?6a`1Z)~M?}@f8WLh-Z#|>A1>R0{;Dv(!V z7pE4NnFtaa7WAgF$h&2A*Z^6OnrZyMOfus9pI3GKzoxfrj%+)XF@L_B4 zIf9(l;EVF_>fm<%$*4Z8uDRe_=HSpsoH>`Vi8}&pZ;n=?3!Jo6UJJavhVZ8At|Z)X zx@(p>c9H-Cd`oVp3uS&06%rxi_|DKwr1=}p7P;Dfg$wiJS_ zw}6rk!cyd_KieVZ{O42{RPeH@ep&5Y(Khc>(frz>wfIBznPp~_mGtrnmBizEAsZy$ zToByr8%Pm-Xqid9qOHzGQbqsyOS4IY_3((M($f)gg*$3pVRMhwuu$r7?&nD@^_z$P zsNmNh+hh5I<$uVn2ZkukA8-an%$Kb@>u)l-jgg2z^WT-$cpSqOyi6}d{t_$5F|V=S zw*t>X-SMP~_`(4+H6N=ek=<{|sGM$I;%PEJ(0DPEuv|ki1+G>ThAh{xzpx1-g=_F- zxrPxru3=u2`G5)U!#3PGQjv0YsP}w@uiN zh`Ap1RoJ|xT%Vw*#bG1%P{(R>&4N@zE`CXk;^eZ!DpXh(j zi+$v`4p`m15_;+az2q-lFNosf&6)7yHWiT>1aDJw9eV=dt zjNb9v`^1;^iJ#UxK3Y&YXsDT$p)cGQUr7e@0&o~DujSVkg-C1bl-G{TkjVXM{+{pu zE;FezVY*oOpV;6Jo39TSQ1zrwUS_go|C3gHD99Q%S1EeUTfC&okDqav6xBOEzdk8r zj_%;)&d~Oa?@&~`_xRabM=oCLr`&rj|GvivP`)qSU!VMzHvITVX=nbdWxK2Q%@y~= zxAs3?#bI;472H04yoybeB^$lklpC*NGfGu}A2nXZX1wmq=K9-vyo${{MRS*%x{Dg0IQuPoScY zhOf!yJxg1&{pvkllg%-3Zh-q<=l*iIxMcutws5j zroXYE6H0el{tdEsS~edQePfo`Jk|Y0XSE-7n~b;Io17`te&^;U*PcQeBMj3=G)CIf zTA7n{?7Y*9mtTPA<=fi}2Gz2YO%uD9Hi&@Sq9<4?j~Mmi>Q9VhCNL(N`$81Xma#ZN zkH_`K+nKpa+dn*?l@A%;lBP(bxdg(O-W~F;xrN#paI9)ZiHggIl(=2Bn_^^xbcN9P zdl_M|WW`*6#S#)cMgr0IyPf@=r^XMDFBt#J*inf;7sf{NiXX~MiCd(Xip1_O{V@^- zh763yXSBBXuylkUej+J~u=vj5cfUyDJ{gktOM+7Z?Tt*E$AM^WWoZg20Am+{r~S<-|zNb--K*^m+ny? zeEYuDr|YdyI&z@A(!NAem%7XXTXUEEzDK#1J*kJ~pYq|o^9^b?WzXg0$=0ADNbY|S zA72=d-5(UoNoRmq_H&%g(kyO($k;5J^sZGQo`v8+bF zY`t894m2rdq55?ze5M7sQH`NmQzm64c~DH4Aj+pgH3b`(20UkAEhPxTyzip6-C6?M{vw~LnJ zBt?Vc7T|djN5DA9wR#oE|(J6k}ys z9_FX8ZAu~>W9hS-17-np4da&~-Q0u=lw_#4xCVq&{8=eUadJ>M?iAT6;Hd8BNhR*E zR`EYkQQ&Poiyx7u=As63#!wwaS%)V{pm7OnprCjQQMw)jf>Q9UHZ2bv(_lE8jo%Cy zUeNd$B@}clDKrCMs+mQ$GtP#7Y*3SC@t5_H>+vPzg&Y))V3}psV{OR%SU)LI*CWO| zz~Z3*h|j{J9H#*)N=%m(Tzc-IC9=Dv;D}ZWh;^AsG(^F%1u0sd%z)z|q3ViV z3XbVACs8Ifl+cmn!dz7Wf*Z|`Sy|Dda}*W>=5_dtWJQ0xNIU5)Wv~apdhsIl>=ZT{ zpEj8bh`up%VR(>APd;-o+ZFmH+xl--#tVA6`0$Y&ZaP08^MnzkxmO| z0|_L?tgaEJJ|t_MrAxy%1B-+@_x9EiMB7KIs~HhJ(Q-ow=7 zDuSHhf+2tRf?X+FS6uE4Y7ij(8ni>Xt;8vX@pX>9 zPB$DB25VP2U;&byHJXN9!p9oK^6R!>c-R5mY znrmX?)u)UXBKhmp94P1Z z(jw!b{jJr@EMXT(P%MFqE%}jO@|agOTE=4-hhM;<4CuZ{jk!+gBnp?}F;zN^vJMW8 zE@FM9NnrDGp?~PKnPNnq{)F;eanGpo)|U6qegYP@ym(%DD=6<<`&ypvs(CPD-RZ2r zZX)#y28`V_3pj_JFZ+cLDpqn=obC3uZU;=+5!^~Ll0evA%E{+ktWib+upYi{NL$ya*Sk6gM_F zZD$o#A0Hw&H&a*D6G*yD3fn2vUQ@ z>SpdninsVzU4jkn(=eZD95`JUY8JEst_`E#~D@-4Kf#Ny;^zD)lk(c3bE+gxv! zia?abPja>|!xw*t-vIsd@KXx(^Sh!u&Km7A4_O+A3svyQ;7$RdWRsQG&D1wvOM3$! z+$pSN@?=xX4sUx%aKzwFxza8^m# z7>0?3E@}+pLFN*7sV@FUH0J31 zrnP+F`6UFq#9sDu_3nnm21RUD7^=Nn=>6`7^nCZc{_D)r(*fzZfz8XjDew6#x`X`h zp+Ic@_Z@xmH}=VY1_&!Z{nLHyTX%!{^W&S?fz1D|?Nk2QoQ2Jge{*y1^go8v&5xhc zr@l3P>id45`Y!F0|KQ&FWxOK@oiK;a5T_hF(?Rj_@tb2u3%4~&xUF%bLA=amzMz(3 z7>=jEZ-A!+8uw-hoo=&~4uQbYHnf&TphW#y}9fXR96>98oK2$T0uv=(9F>8i}c=rZ?z&tzZvP>7e8eW z9a6FMbSxNCrFZ>mcv43Y{O3yz4mFs+=(lh=Kb-G8F>is~*(QBO5B32|ZS^J@3o(2W zZVqv8foRBH$;IS>$*12I7Aw8MPF19xTO}T?_kS{Q1*-PIy~-1cVVM2^uF5*(-g)e) z$$269% z7)n4{I)r{dlib@npFr`(6TUTs{wji>66r^CsX2{lNJDgNI<70Ud$LJ&oJu1ey&>f& zd~s>|s^~315G>R+J6}sRAC~U>HDLRMoC0E(aG!V*ZI9B&;$Czauu6+N;|e@!Yya%t&))0z^;>_Z7*U!x9?Yi)s|Z4_ygPU! zKk%xADouWgD?m{a3tiA}+*q)cd!i}Cgvi-S6i&Xqt>Pk6xyc6eiV7dHvYEenO?;QtlTsp`-5qCg!wcoD9bDVGA^t918ULE<$ z%Mswm$;G1)1UDwO59qFG6+a~fRTa-J@4@(eG8;g0`nr7Rlk|0(L=AmBK91j$rQiIY znEqcb|C51CD*kIhl0tIvf3p0auw}yY)Edy*63bi5IT^FoHG2BAz-6v!Etd~SJU&3` z-VQkVJ6uyS!{1mmU^9wEzj#HGZi+db>_RbL2jRE}l3w_djHb@8S~XX@lEW)dNqVml zke;GsIUO;IQpc1(OQnz4{N_)k0?)0KVv5G&l`^N2%J(lRTxfiolM##mNy5 zUCFQHz=uxbU*}hN&C;~t<`^jI4eSwM(o4-HYFAM*VMEf#>;<4LC7isXZs$qZjBCjz z$_coP6)53XZ<4jwcfn>5&;O(Ud*(gTn;$RfeTpw)Rw8|>+k?@~{^&v>9aYU&GRRj! zKGLFegStvcEiEx#N`r{Q5sxzou!hhOZ>wM@v5)+@Ww9lwji4sefhE1ORSxkgyNvW4 zyM(W$s%iy_W1mIecX_}p_;DJnW8n*6byv+Q zkqO&mm!E5bI3%{X*i5=#+Te0_$leMEgs*>q?+ryD=EUv+OIP6C*7v#5zKC+`=1o1O z<8tJnM_2QF9iQe{vW{yVWT={#Bu{EGLw=$qs@*mhI-GB#e93W5=51XA95wS3aj}iK zjB6V%vp?feL36Cv-?G+NuMx96zrqJTJKA>_&v$too<76^IM(B=%aITUSH*LpcXe?%l>z+Ap89$zj~F>0QEVRQ(ox7SPI#yPt_^2`%EEE^AR>tVRwNrVdf?op_v{Av%SjgggkaL zV~fB9e6e{6^aeG#@7Frs%)^f)#=NLlPiW0|i4A1w)mLf1P_Y9@C9X=psY`$mZ|f2y z;eC^l@<&?6qr1{AO@&K!^}NMSd$0J9v$vhi`XEi)^D&oG$VGIsY2Wj#6)vQc51;*T z>qoKu@bFRPNo?&JGUEN9mvl!U>(HrQtY&;Rr3%k7D}~)ekWT zCT0;Ip+yYGzN`xAuk2S`1Vfon`$t!g52ieSJn(IBj;`N5!CUDGhS6ELx8s-72c-28 z_KZG}oc(GER_u-)5;n`JQuI15um>l zHg(j7!&tjufFQ~15;Hx8uo&lb$&Xp>!_C#uh_Jc&xlFzz!X^N3w{bg+ic9RhS<;E! zm9ZfM&qFLj$G6l))*>yV&nWEsCxyB#jdLbd?1&v4HcyD66B6ZuM0pSg&s7@%SNkM@ zY-j&Kn^&D3--o|%{qK2%FBJ+}EF59l`XJo@;7&4Im|Y#*r9a3FZi}>!(Yl<$m3tK# z+%g9DM5QGXG0&-7%s(-1z${uE@5$iS+QH3@Z8@C?n<`bLNjAoI6M{kh*kZoi&)DuG z8=N-+y?+XDBsQ0bb1ab|aQWpkYk(!%*)J4p`t7gxN`Ll8q?h$1*WG|mA7!yn>`n-P zD?Vb^S5}d&Ab>b7#40|p6QxVR9!p15-M;k6W9N}gBoY0!ND0oF!o*e2!qjHVJvuQQ zu%4(2<5n;cH?iqvbRJldi2Jm}FH*_FIs}Xt zsY}UwRMY|(uh1xW$1*n_kK%Rqa>_shqDF~XhxC-$u+8 z#+ug7DRZtiZVwfUKc_ZP>12lFkm#xEtXdeyE;!STbRvi+ZJ`&A&$=>3@xG#Gp{It-GUWtHS$N5-u8lKpS}ir&Hyq-Wzq zC4}zofie=by+7R-V+lRKLJp54cDq*$?Qm6gxO8H>5*xDf`if%G-jb8;mgLVrzhvRp zbBnTK{*Ef>%HYe7OwUm4;C4^2!xO{-YQfVofK0`*!HEqR=#((iGNKzq#Ju( zVYA{cDTp)p{k1NAet}~?nMu(0<_&~x@7(WN`V#C2X@kdQ8hk7Z%!LZsMU&5fHMdPJ zktTCsS|BmdUT%2|X*?c7bU9tA7x%H8rlW~Sh@BeVS3kCI(#BiMSyK=vSNkyem2G#X z{kFY#e<$5u_5;Syh|3>1baU3mmrh9Zmx<}S5HLq7ow`6*CX}VVz z01u*=DxI)FiVPz$DIL_}1W3dUTq%Gev1wrGlPCu{cUn_z$+2f)nNYSIq_deOXZre& z{O`GFeJ}p7r<4Zd71VRp=HA~o_ST2?9MdQNJE&Fi^M4zyXa4tQ%;56A6}|*6+}z;c_On5I5L@b%`VzZO_g?i2ur!LN zU&TlJI;VkLyE-fN9n<$s`re@Lzv}~%Oa9yuD-9<_ucy5r0?zeb~j0ya(&n9JF4$ieLt-49r`XkTT;%}_cDF2 z*Y|V!cAq1$ll8q=-w*5iZ~C5iuEf^sd!fG9=zFui-_W;bj;7IfOy4)@`&ardm@BaX zeV6I`Y<;iM_j-N*O5d(|l5(QH>-8Pg_bPqguJ6tIena2>`I0`Y?`8Vlpzl}p?OGtQ zL48k?_xablVxxUScziqu@c4Pg@s#o$%QKN@Do-U(h^L&)qyf;pyaglxGvqQ#{Y{{D$X6okU&Z+fh%wN#UsrKy1==ZYOs>nFh{J>^wFtt!?%2s!5T?o*V_--)w z>IQ>bZOO+FLeqsl%eP%SPPqkjWOHE;>!-wqA;RjU7r@U>KnxVwz(%VyM3WHp1 z9)l9(yT3(RnU>e@V!5Ky_PZq4@875?jPzSZJ>#QVdexQd_EbB-`*1w8Pwz7N6nkpD zC4G7mUM-tW@PAq#qYO~?JL8{Obc0*;a4>!OS$YoLa<>f1y*|noN)^_Fj?1Yl_4?=T zsA6_^sA67v*-sO)ZIK@t`j+Qk*xalG>@)Ls_8#DU@-J*}2`)}M7@pJ|wUA%URqjL0 zD{8n<++eoC5XtgDGbW&idC_<8))32~XIu@}VX>gDhASeb#`35Ey}GC3*_DGaF|T{p z=>Zl>%%a?ai{0ADmgOC=4e%!n^@!-I+(PP!qt40mMf{1)zBA*6zuWr&L{Ur7k34_3 zpR=s@-pmy9@-OK(f2p4A+^dgy zHv|S24eH<3CDjQ|!oTam9D8_dD|Ym#K9}7oBPG`NZguNQe)ZTsF9)=}Ag1@n-u=m5 z&lG3s>*Y^2l&&}h=VmMZ1pZ|8ya6zVRYDydwEk-w#D8s2>XR|4`1^`uz^ZBgwK!9> zQluOOY8eW|4b|b9BkTR|p^CpRe-W5tt?io+-|Tu-<>tLCmEPmg^SM6!Yd6E2m!xO- zlfA#c@M!PvdA*D5DWUaw`K^9!?$?+*R1Ka;V#Rqlj(Qn|2#TQMM2mdAz*l0$+yZaw zdim8NzrMq-h}r&@E;5cP|0ey*lz&}jx{jlK(R(3O(bX)O<_lP{$E!M01R^}E zH=m0k5K>I|E4%W!*@vS;-V@(SMMEYFyGKj9Nh7?zCdII_kTy)%{laKN3mOdF^G?C z#s;(YhtfMuDd!?0wdO-@2q(*z|CV!;-=O{CgHw+{j{0_HD9&K9Kaaf@Mp}r!O-%l# z>(T|q4{b6%Dki&tXL0$~Ks`4*8x`7|dZ!BFDSBYgs_7?^?mp2P9T5gIvKHckOJ+V_rd@(Xn)ScWqccIbA-`+y;VS*zI1&$L=g==+$VP zDTB$P*JF*XC?aAG`wuC|*IUXJ{3vc(GIL!2ed6ue9qFcxuj~_F-{(80+Op|;*)x0e4gz&zS==#yd0KR6KN*E? zdLyhmR4?Gv9V)i}>g+#-vF^}$_DgmxNHW*{TWJ49?Y~9#-(veOX8$R~c88Yf*H>1_ zKmNMEB4}U!x*;ce5MZEFI}P8W8`?uP(D1=dEqNVZDzQQMw34zqC8*Q}f#>2{U77tZ)g(Kgo zwbTx+KH*m zjiAf0x$>u(LcC$qAUyR<;b7aV{0$MX!;%c&9Lu>6?mU3P!n*1?aw?v#!Qmw&jF^|$ z=(9NdpSS}dZ+U*|Kcj6Kgr!$inLlmFjL{*{HJK=Swa)fxy6zr9aIy=gPm5SD46tBD z7RZO*865YBMY)pw87UUcUW)5U=4H#hA}SNeX#E+kPxZa0YfIO+G+W=YOnqOdO2M+m0>^;%#s2iwV|(j$pF#MDPsJx3{9@*#pt}>XtQ{>7&PjlwJ zElsfW;0{=$T>CQR-Jm*~-sRcz_xkp!=@k??ze(g(?Y8`vHq|RjX6Hs~kh0-N3nbqB zSa)=L<SVzEGpPdqw}FqhrqxPD{Y{y10WpGt+`r z7?o3ZR(dMq)lQu&@$P}~g9YgA0^%10_GybXWmPBME!i{v*dN+%6W=%Mt4=oR6E)qo z_v$ZOJVg_S1F>OOyC=wT7#ExMxn5v7DDdhD0=Xh;%aTL2#ovQ-b!unRBi8pv{w!3r3JGUuW7T0EWs!& zFY;$9Wn|i%%@bSukBzs|2kyt=66;4-fhRB=7=Wm5nVSm`EQgwtAJd{?WM_~7%@8y@ z(Go6*4Y#g0L_yL#J^6yUi{XT<2r9aXGB|7*Hf0(1%Grmfm9|7cjm)Sf^NRA)ZaiTm%YE*|J@ zyPK3#s!O~bvI{}GK#u#NK$J$j*)k2S#02ey;$gXNc4u{nVktLM=jUa#Wbjo}wAen{1Jt;FRsrc|F8I}>Mx951R9Tl6gO4=p^ zlSG01_^IidR}K?3SbQ)gGeia4KZ$dR5`LyRSe@i^9?49y)NUvF6D{2Tro^3PG|wz* zfqvqukxQzZoO@Og#F)r&Epx1(@vR-Fr?}rNY?A1qnhTuWa{0)QZX@O~NehF%@$F%W z*9$r6=I2{Zyp^Z_OY!pU6XA;g0r^h6SLkh<1Xnp_e!aKtSbpG?Wx_TT?l*LS?uR-v ziq~^3)p}3-7*s)HSX~I&nYcKx-0dEjzOT3ip42qYpo0W@a4hE zYpJ(H>fPB@L?}vFnWihGIOB9Bwu{xE!jW^B*j!E0&N%OvSr7#G014}He#M_1OKZ^xt`^EKwcGl7vK|2q8B{Pm= zo!XL$Q2p}JsinT|T177A63v{`PPt55HvQ=c?2^8^yH-(9awY5G zktKUT0u8F_e7wBji< z^RL&Q3Ne!~^AjC>S8^6#rRH|N_~Ac;J8r`af;mkkGaqGmui46AH%2h{GfR$OFcC9Y zN=%~o2>HrH8FGf~2ofQ@QS;8O!uU*`_n0{24x9>SiZ<1IjnE)wrHo1Yke^Ic+fM#F zp?W#XfAydFU6**;rT#nv(g#ZNwmvI!!Gs+Jdu_KqXj9vRB4RG0tM@djVkdquF0jN>{Qx;6IwCzK7ZJsWoHRT1~nytwTQ5e_Uqhk<|%1*mi ziqs`?5H;zDjX||Q|NN;?y&d__e20Kgy&e3zFk>G9|CsNfFajINAW0`!yu;+b^l{xr zIwMQxL;lNH^=y1Um5Of30z^%-poUwauT)uPhN&Q5Li39sW1QvOZDktcQ>3rw85tXy z%~v7k(bu!ZRB18iQVdzkGFi`-R*@|YXg#71hTv-xeMe|k_WEzMLqJBQ<>kfc94jU36VwGg_a#SC^{dQ5P$fS~yj%)HFO zWcXWWvw#~&$(|=3%UmQ<1H5fQ=>$=}N2Z-Kie28;claffp$t!*a2Zrr$0STNKf?%` z)y(f2^U9ZPp&r1YK79y#i+A06a&v~sW!8~a{Zy@zJYR78P)<(o*r* zHq8`6rUcqHtQ-Z}DHm*5jy>=&IwdE^pBWCdb7r{#V-+*&<38>n z{p~mbJGTwQ<%hTR3>hu?At$&C8|?q6KtFIb*mRDGYKs`e;qTWp%N=4@M(+;)c}_4T9N5mvVqg5wUA1-G zgj5haAS*xx)6goC#mQhQIrJlUVLBjWh#;ZIfG)Uc!tai|7wG8%jv!^kuLE!0T**`KY8iH!9v%*%J_iTZfPH#Y}{7y&_yXQK@e} zb#+Qz%>~{wjc~AtlX9Es^I9zXGMqUkN2dB?wfeN+SCZ*VTJW1M5@SuS^4I}#zktts zbrJPtvCuhU;ICKA{fnVBeg0!H_82nNL3)n3Bs2v>hOjvoUePh#p{RsGg4!>p`HSVF zJEYrQ=_4wL3$dZ;8|-04x*8}aAjz7XP?^lv7)-#6XW0WzNMakjt(P(D&^uqxlTsSP zC&~HWSHctEFEB&lT{zU?%#5iSKoAO#L*$yg*F^cO;f(C>j+LgOJ&lK)P^BEIQX&iw zDJNIUuuhvQn$duy&%Mg!vy$xJBD*@_ZZcnh^-63Ks!T3#>joOaJQu^N1=>SVnez6~ zV*dHtLoph}5-y(=X`i_$Tcfx2Hu3<9M-wHUqSGz0Vu%x(h^dzi6gB1*iBwUnSVCou z=S7^yx6fS15Hy$$-cBgSTuj);XeskCg>i_DXy<*>%2lkTwySVopQO&T0!H~S)iY3f z-!K%~94(;y30nR!yeYgpw1}1$aK|a;5Hh3xVmSOqi|>>HFrJGu_$JVl85I5tl~?X) z^D@N`jQ+FYVB4Q)Sym7mNs0{61qYEbd7wza;bNz*WaFO)CH`0_K!%WC0>Gxc>y8B} zU)vz7qK5qxMQ7-9c2&jp@(9rH0_c%U*u~0Zk(JXo zi%9y}ESt#EWSXo63rzC|saP2-+aV|;PVA^Igg^B!-_H_{KvSvxuD8c{s&r zpP(y&*vUl2rVw$0uK9M3E6<+`)Zvdi>CtXoWM$ASmdrMxN|x$uGy~6j*Ih-S%A!`x zuU~!;qfjxcBtEj)mAcg5WZt8dBrJzOz@5hC%j^J z?Vi}TEibE-#;#XRjd)Q0sNBN`>#|smoTKh(`HWM$OpeGLJz!(Sb8NUMm{X{ogVDLH z_MEGpRNvMze5cM^AU<$9^XyB{qD;v|r*^rNcUZ;d7Jr}8kd!migENIQmsjDRil!t7 zKEgN^h<7@__v#loM#|b z1^*5O9nn!+$RXD;KT=HMY)u}|mya)Jt2oTJ9`nVTVYH}#PzRkiHaDW));AeN5v1P` zsAH`pR-Ok+yNM(_6RUU^#SYt3w!Fhs|NC`=;jjL7AcO<_eCF1!lMiIae!UNv@Yq@X=(xyIxTn zB=$^7{;K^7n=QJ((5qy@l<6UgAEqT=;b)YJ;yFeW@ja6yc0rm1G?-Jsu9?#S`!Uvv zD=S#K_)uwRGDqL9i<%aU1gY2=N!Jq#5`QYpg&p2Q$SmANuQYKkd=^$m7Kn<$`)0`c zKePgbN%ElTzZ?#?4sKxzdng12oZS)gxpdg!gu$J6uabTT#KTf0D*XN%+_{GFF!f)A ze&L$+T0Rn2LYsy#Ageb?Tvuv|8_vX?x?i30r6z<;G0G`03Q#jt?0(bfoNAArB6pU^ zUU7-}kc}s&TAU`7lHU{NRAHd~rl8h$IdCWY#N2e>B)YmQx&axqbIi+o^?QZE|*) zuj+S`nawwS-MLabhl6*97D%f&?~js$<<9|xOhfwb=st#uHLOQjQTz}>sUc7U z+UGA~P4%;&9%g)4Pz~mD-_S;c&GV`q7qA~En#*<)eAL3Cbgq$3b3}QIE7s6pLf_Xi z0qZlUVY2-So5k50hLL+b5H0H}x&IYM>RK90AR9#I$x7-U%t^r3j&fK30*FuBUn*In z=`$ex17oKEq2jS;5~tt8<|}M4=z5GD839a(Kv$v39Ky_sjxEDGLaFxJdR>ly?gAzF zdjsKKGi&!(moF4}p3BL6+EZQc{KqPW1I^u%FyQDnE?SU^i`K5x9CL}ZM!hhKG}tnw zi5T-o@R$PVs*^=m*P^<*7S+{VF1osai8iNuv9eeNsO8-0`1E>gN-ZffU%m*d zgbOFKhdG0W$R6oI(PyEZx-W@|iJ}Kj_Y4_qdr#64*K)yknJKJ`T zM$-?$9TDPsq;2Z{W&S+x^`D#yWcU$hzR$VX#g<~RQ(Y#U_P1z5*!*G<^UKb#%c;hA zv3Gk9OSiXrqP91Mq(A;z?dZZE(9zQ;&dO`@m~4|PYb!ojz7-C9Qa`)jN0XB|YtLg_ zPp0%NXYw6BR6Sme;bbg?@y3#A>`xS}k!)b}m2ytE+ZvsM#O#*(uNC+7D)f|Gp|LZZ zYIY)GXGmc+6!xl0a8lURehOQ&XJM7O!v5Qdv95=Hn)$M7bhOV9FS<9NH*c{wWMt_x zE33F-7q^hA;_3^n0Kr#Q(My((Ra~hR0_O@EnJB$>xgm*- z65P;IC7F_^=sgn_tl#I~sGNM^yZTD#iRnhRhV38uO&* zML41yK!1MMBa>)Aen9?0+{fR-0(eZvLOOs;HUa!oea@x9ww)^nrx!n9RW!d#b!Qdt zpgnuXyoXdaVGC(1Tn~u;%zN`jIV#t0jd`}!mH})oXu`H4DSInh1%$T=2qBlw!IQ^a z`oj)lS`#+>d%8CAOd#6lpQkHBJQem^8}@gCx=q=9OHWFmCo;Udqg2Pvii_P{R;{ALaD{3z~NTxgbC%v3v3yIm=ft0S7Kc-y;f2mJz@hs{a1YFW;z+X;~&lAI!y zz$;$)wR97_ZC@t@W~}W$`0rF>YEizPnYfv+^@Z^Zo0^&%Bg|o$`6*)|lPU*W!(4b| zM%rpwR7bK@e{qYJn3yd0!-mY?&zDaz@p~mH))9KYXOsEm0tykK5!irzEQpJn5>V{t24MTfk5MC`Ne)#v4pLPl~qRq~xdf3NYOn9aah`%Qht`e-X4m9Z_qfli72 z$2X;l;_hlS4T5)oo2OrUgYmt9{=Y?VhF@8R{5lt)NIWW%cR_42)CNu12ddB!e*4I1 z2)Qsb5OQRkfw=ER-8WMcC3}?t+Y%x+rWTiWgwCdfa7So%*o?kH2aMTI7tcijF!Sf= z6oWtpN5d4Jyj48qTtt-K32FWUmJ<-@wwv$jzgyM)x2u5U-nD9#wAC#dw)PA~MmG<8 zel+1(qP$R4{g;Pp&8_JIcQ5bEuu6&4hw-%jr#rMziX->3DtzW`hfMBoJ7nVCwnJ2x zohiz^&nWK0c1S6h_$j@zdvDtzc&ps|c1V!y4%yS&ArWP6WjlmiAHN+k@zdN6ncQbP zWTI(hkvEtY-g(<0%F0h+!2j9pkcoO8yTOdHs3a}0Lzx1Q^ir`5DMj;M=3V#u-UK0# z)$<1aSD<^lU;E5#MzID@iU62Pupwt_oW;U5+FLX5rmY*G6+2`LbcbqwWD69E8ZggT zY6lG%KsulU*SHlP9`d$0pK@EHwaaHZ?-3qWGH^50M-)d}Y3QZ?1~X4pRrsL9 zZsvTUWteTX7V<+Rx@Xfk8YJy)6ZS`uqTnN;-0^pE(Xl^GNJ;tHzA4lp%vbj_0A3~7O{DQ z$!D1y#2N`Wh3f+`Ut%*}#Tf+ug7$JDedLj=u1hFLIGY_S>QrU-!VU5**U7@kH<)il zG&*c1G-^0y`lzMB+@cUJg65YfDP*3TlP+g@Cx=Ca1wovOt>Erh*z5ajB$Kg-O5En+ z-j$q?spRJ@PAbns_&tv_)BSPuF_4w@nOhnVK?o z#Alo>H{rAwrhY0?oLO_OjwABTx}O7`=bw);pYp*44e1oeUk@~FZ6sH~Ohy0iobHBG z;x?@6i}fZRF`<;JpT}{^Or*4z z`9@Xr*R#?UdRq@9N6%9PI}2RN{Uz<$&Lx`n0STOstCm=(fu1Y}%K@7*7T+d@qg5y5 z`XTqUHJY1p#Xd|D4rJBZOwyBNlrtOP#EF@5TAE-CUFK~hM$9xFLmed=huR0pPfRyo zAdcqVrywZ^GD>L3ymPiTg;IY9iY%#?Vt$_v+q(QtnQpL;uUh6ZJx`Ak4P2DU>e}kP z`Xo?}Gb2oi0~3=ubD<*~KQIRu3iySxD3Ks z0_NuOV#;D#HSdi!ubgm)U=L?(OHgZnyCV&1@gdn^O!G<~$sqLUU4vXTRVvNDZMl)~$c4;^!lSTJSi^2dS-1XY^s+g|TO0Bx^^m_Cp_(D+}j`jtm z-DookxXwpeKBsHGmCg35rpmT5ZU1)qYbo!8H9=4Mk`pm_m?jd_q0zVYOH0Yt1MY=L ziXEJRL0ur%opi`?Inm%nFOx8K0p_CTNDj+}-*jcVrOQS*#TaD7h5(%)D(7)Lko+}M zHhxN^;EB0{vkm;1AD!a9z&w0f?!K^^V9dEXxQbu~c)4?ZPzDm@dZPrXxQj}zhI)U> zg(cqljnLG}w(8>8{=zg2RU+piY?`wvc4!;^(nLoB`zYc^^Ep2sXDwn#tK(PURL-bin)I(!vVbho+4NM^C5O zs?2Dlv6W_*YMVUd6Hcfg@m4{sl+&Z$wi%QHJM5QPD>pyUt!`%R2WS!;Q9t@Q0-23| z=?V?yTYzn+cg_CN2B?&-?IZrSv|?YCMK*7_9N?X;&FR z<=n{?hWosEnLw86@O~u4kr*qP(uylr5t9XS3 zF6&~%%0+O&X9P?~V#9}U`AdgLD8pKi93&j(isPKtpw8~f%{-l^9)#*|%OJZy_nlSDL;6_f2+G=rW1WlNgE&KOGr;w_SmT;9rcV~tn=KXWi$=Hr+v4*`=3wUwuSk$=T2`xI)AwMfmUYGG z_LhYLVBL1_qT3$slxiroAX92~mn@wuyTvhnt+pbw#XVs>%;y{9b`}K5iG!wk?0OgU8gmARAz_tLwc@@W2xs(?YFYkTswbchq zt6{G~TgwWz00(iWOQK8@(~7LYu=xu^4mQC62YeQ=Ix#Wj^@G|<^@qIlR{(X^!%&OF zUX6o=k2o=F99$wf^`)l9ezLD1ds-V-r(CaS!^jmY2P5EAUW|hAgp)A}<_l4%UF?qi znxIfO;Q(pfgO~sNJTm4xBS{_Rft(@li}t3A-<888AL< z=f?A5K_C+D=A5pb8!I``_r$K}+Cpc?hi5|d81jn|)671UfCA8i5rFa(z{O8P3hMyU zke#8~f!KW$;8Ud50MqlBymp23AVYk}>R%{N0KX<6w+Fz@2H+y*J4@1#H5^Q97jNCl z5Z6Me(n1z)8L95IUtwcDpH9WNmc+(gvH#A>#Te)%eo={D#+Mu+!I=*^OjQEO^T~3o9b=K0Mgm2%F^}X?5F=2j(W0BG}oc$l+8##9w+Bv zOLR`f-{YsC*Qp?d&%BJKn1D!w`EB|axz!?nV%Rk4N|DhFvL{zsuyQc}$_iQzpvlF~ zTTFzHw#ot#WlDfP)YnE#v2Aa~ZtvAi5O%7XMa*P8=&r?Dg2Xj6IDimP*FHv9SUq9E z_Tw7b3(a4tk;FA5o=0Muc((Ny*T8XrSTJnj%phn0-hTB58yF3YPi~eyx_`OSmNvVb zDqV&F*?`_Apj5R`Az$LwnW&liidIIIv3VWUqr;!gL zZ>bHFS`}TS7DZo-nERE75sRX`(!Y!gG@&aiUaJ;GFDaOdb;YENMbR_apkUgf=tpQa zG}B7Fc&T^Go3kNf8>`KtQ?*$lOW|7pL%=C5djEo$&yF=%Hs3tlvc{)D>-FH~Pu zsDQP0sV;57f6Jm&dpjR6Rovt(6%1MI)CRh1!_s>pw4U~*;e6vynw!2yr#Ng*+0A%? zmL!?nV2DSv>s2&OJ4)`yjaOq629J%+azdoUv^jJciH9BY_<#lCL>DID#abOwgx*QQ z0F=N0T(3yeJ*6ZzhMn{OvZoun$@$ zrwhd76rLnlGn*!-2{bwNN(|{#7#+6O3lfO-F6O3FtvbUG!PNA+@FhA1ysQ~nD`=!I zjvLN)sZBZ!u=$&1e@B7}qed8%NPN)R4oOX zK`Yedi3VRnWfRnF^L^)vd&~|T6oOwZ(UPaRhxw0*a8p|*dlBlFtJpC&#AR~_y_fMu z@dxeP-GI-1O%1lskPIAi8gInr*fukI8gI0d2sOr66}OolJ0C>)F<5|_BbB-k>Omi; z;NhT``i%ITdJcEkGt-PGut?HdRnPTf>A^6HNYm!$L$!>-1blF*CUHtE;+zk#%S!);kaG~px)ZR-e*q#13v{)b zcD{+D*hZ>U&+ANB4!dC7vtH({98#?cuwesgDH}&YwG50B05e)yzy5|5bCLb)zc3_= zKH!3MY=rpKxI~}ln*JL2_$gFqT-&U+jMeL`K+PJ%&hU)LiHIK9N5#K|9D5*koQAnv zd!_UgbD3AIz6BbHL7?tQ>h1CJjBvvDn=!Iw907GZ92Y{%u@)@G2a73ols#`72neTkIB1Q|ADK+!9*oW|RYZlQ=-?1nwN3L#fDXsI#ej z0mNWBnmK_w5ZQ9$ZzWqzKAHC4Ga;X|3tmVspKk<70he}i!{%HdVL(;3**=ZLCn2{( zLE=C$krSNAJSE)^iTac@uSL5A{>dPZPs5JLrD~p!zVj@rTzBWA2VSZdMtA3vI z>8=_+Ha?OtU5tJt#&0^NQe~NCROSTmi#w@ey`bV6YB{7f&L+;{BarE<*2@_Ib>?&d zGg9&cYAe(#o$Y8$DvpWPhjG4?ek}MZmtIOhMR-i$430=S}>HB|84{>|>^yj->`Z~y?L*DmsBh#~D@O1vj{94GkjKF~J z%$8}z>>r7%8kwkiQ-)TTMVR20T6ESYE-|wrHm$UOaJ7y7<{xe#pxxnAObF!hWEqQI z?|bpMa$gtq2|OT21VgKG9Pk7zQ1uX8Lw9|ETMU;Js%!CrD_`!q5Vu7xDNznEzuL>p z*HP-@DR`|lU(3{A`Fgp$2Gpx~i9TJ{l?~huL&^)$jvl=Da1-vbdlByTxcG3t1*P|{ z6=>MTgZ$>zI#LbG8)fP7-}4g%%=roT#d)03tP3|4_R!8-i9G53G0Ku=4_$u}m-Ks4 zqO5*T0vuaJPGh{wIN#h5H_v~aMth{_#(5;m=fG?VZkTHXy?p}Sy<5CI0w?Cly<6aT zaSD?sZN%`YI;q;_a|z^ug6kK2&+s==lYn1{lr}vntYS^pyqr zq_Q!;z(O!|zd#KZ3x*2?dNj=|h=tW@7nn=Y)8GaX1v10Pqw+?{l!4$)PUxrb{ZcQ( zsODE2WDACe5x`PC4X>b4XiEiZ8H8h@wVsA&@r`v&P_K@Zs^C8MLP8+=J= zTllEZ-9SB|Uw(CDl}td>^(C{B8_TnVh+sEeb=g{NK+4xvsmV|b^b8KJovA;Thwelj z(6b&4NuOcQYAcfsbRBgv-d2`RE6PCzA~b{)A71H8y~ft)wv47pUiG9_OPiJ=#8*mb z(tqR@9?@Jr#@}2H6kLj9p($J? z&E*wnp$yPVFK4yTLXx2toox4~;LbN4)8L-O9VYPMr=S&!2^Y%l0G`f73tsFkkB}AT zYmFXo!a?bPH}Xz(O^8u)1->Yo6Tofn6>2n;Mr)^Z0FR}a<@e4NxVMH5SY@!-)G1=1 zayMO72FjP&RE*4d!YWx3t!^?)S!4JrzrtTb^ktAP!T|pytW+Ua;sdQ{$G{MzZ@#0u z!L8k&HruhoAGZ7cnOOW@PCQ;0pPwU}x z!wCeMNk+h)SFP4S1I1nHz#!9?DLkqsjvCJpU_%%R9}6?$t-2KKCOcM76Z$D2k``)I z!|76|CRHg2cnDx&>Yac$?ibKJJNd48F!`x8WSr~kgM9lc@?G^XYc-wg3-?;Ry&fRI zW3t6$dCjAf@7^w?pB+0GpFgMlnTstY8-3yKm@9gYFSDuKg-3T&xy`wa`E%Q!A7kYY z-ChH+NsDxqS}<-gPb0oW!fCB1wR|l~{TCAo6T${yd|9v7BFy z3{`F@lTAF!961X2dr%?`sKr?4=p1<=Y=Ah8YRTD=lG;i$&lKe0q3c%!MIkvw`tvnj zDi~|_MGtMhj@Wf*ueQW=9`b>>Bsk?TdcpOm*SaIl@@r&n}S zX2VO7zk!kt-UNc)1==LqY472;P*1+Ke+l-NNZqlceKRCx`}^gyQr~X_>#?VQ8-`%} z`@1ln*xzrRmipeD*535A_Vz=NvzH&5mVV>Z)b!gR3EK0YnpXeE*QBQZFs=S=P}ywd zYjZW71v64@BJkT5FY?tMmrNfnCiK*FnJ+GTXc;^|yUu&o5$1gPGtQdM`P!6;DLdTx z`r7LQeR>?s!?DM+nIVT=9bqDk^t>$P{wL^1HvNPnD@zhgRzVsIiqxZddLFL~_MRu%FNZ8} zuxJsce6}=4O}UVpXt=XO&4RQ`w!Eos2=~u&ln?Z+X>nd9pT|m~o4b#CCMOl7iEy^w z>HEMQaD#46n!?n^7NK<)|{oIA5%=5H%2;D{vG z{NqxlV@LatQ&K*(e}-P!8g1VOWzY6bCn>i&@ybNkabL5C!q$^u7`S%%8c5%#YIzP^ zqm|wdxz-=c=BlS4Eiu^7FHhuXa}dbqU_0kjA6&e>G}|bp+nah0>KTqXa07aFs|4nTV9d5@Rt3Hq z*P#2{2P>$7usaFXB`-)Kddk2``*udoi$xmuF83NAHwQ8d0DE>$jdL^7L7=gnpG!`9G<1UM(u zUJG#kMVKy>f7~)%8txTT8J7v3%$p0z{p;kVoMz&y8xa-)N}ZtRn8DCb4SfMn={a5+ zisBjlszkp6q0ldKy|tO@cjvQDd6X1rlS`ZPXCYf~&VQRPkmR-5oiN*mWVXj7zUxa8 zlkZyk#gOt~0FWuC=dhLx>rUC%CR08%UMzx*KNx79;H>eh zyrA?_&$kHRm~fBr8Dz4=e19B3NCKzwEN50qZ^Lm`JbXNlZ|A^w#y1YYn{3W?^Q0`P z#hBo1T1F>QS*El9h0_RK-aj2WUObMx>Mz$@Y7S_jspfRTrke$s;r!qA3<&(7;K2SN zx2=~V$RZJe#w)D{^)Y~-Tl`YvrnU*(uyJ^9*}|xXJlSy+6ic@b z7|N8`a7euVSYGMJD+WX69Kg4rx&ovItHGG*nh-mpcvq}1MAHir-?YmK#Sf}Y^vyUW zP!nvL>r(q+B^97Y3PSIoc8!szBz8dv9cmRqOZ<23AhaDL@;@L9G&j8 zc(603Zh|})Fgnx?Alx8ASd2QRG8P$Zi}~VXo1^Y4(hKbG;>+ke`~^mu%yXWJKQx(( zCdrtf1V=J84YxIQmf6(xR#OM3-y|qrAt?Wa5wKbkl+(H*D4)SLl^`frq7x=TfzLHo zu=U6AFV_JBg2qmDMVyCB4%t##Ko?p?ODCTNrbk8T612EkHe2jW)lBM~1A36)3?{H$`p7xF_aI^X=c7|6VT zh59GxCxpPE=q84dLWxazC44+ADVZFGxQp?^_9Gg%FY7xUpEn9W>gL9Ez}=;K${q{l z$m|yEpvGGP6<7OhFavd3N9+%6E})=i8wE9L1JV2i;0Y37Ye5~js~kbQRvrgXBv33L$KiVy@8xh<*VNHxc&45a zh+pjrs)_&1pwVEfbDYB}(DiT+`8CNe!^4eXHL73ANRK7Fw}N9}ztRLR+BbPL|BaUG_>UmgUn;3C{#mSwvX^zpQnrvE<%SKN|zbh zGeM?oDeX^9N_`)KKHJlGf`QoH6YHg|j5bB61zPPH2tH-YmU!%(U#n5swTH;hg=4l6 zU+h-jf*Arh(rMIMJ_FFa0G=BXi6wx3Tzg`3WkzTx#Bjg5(VRs@`Hl>!JxRX8sIwXa z0FP`Iw*k#=h|1nz(wM&dl-fd_eTkKQ9E8m%kKX2}#Ydy5amG$*45!mEgSBTjy9XP4;@O(e=NR;2*Kd@=KTB!4ea z={A5rBywy0m}JVl$EJdtnUN!KZ@sRVJ9#^S2Y$80CoPQ~hP{MUCE;1oj_|5uabbE# zcDQ%6BR4X&d2vDPoTtt}=GgeBcy~&ybnIU>2aQo^`jhv zyz%_T0{lG15KJ-mQ#8}YdOYRFPf*=mC=H_16qu*ufP@>Di27B_5&X_?V=+9O44$y+ z=x`zX(IeV1AX2dU1s>-h9c)D(!~Jz9j=*R5M#*P9VoO&Zp%cw(z2xSf#0PqRjlZAc z?{WM&vi|LV85#d}6^C6xW@cBD|3B~z3&f2{&_3fuphVG4PR`LH@MXVx-NTtmTEDbC z0%crrGp}Ebmz;g7m@{c7vAT}DUh|K8W8>HOgQ}j{P)v68 zRPP)?&E!%<8@BzvlD2X&txU(RTp|8oEV!zI&HSogI-tz@&GN44&E;0%WZ7aVd!1~` zCryAB=;YTLkl90@deF=EZUPj$0{4zi`3GVV`qlh{-3&)g=o-Rhr5vY$zFEXR+#`a~kuc2nU| zL>{eCVOhA;Bc)x!f=!wDzyjFa^|zH*U}O&BQx*PjW@9bn6~FqHn>E-Mp4m^cJn_jY zoGd#<%APW!+ty8cLa@0K_cqs61yx)$Q4!gMkfK}O=1M=~Hpd~pBLWOi6o}R>S4RQT z4p->xYKJ$pX@KFoO?L=ORli$KlQNcPv-X?R{d{JNBwu4f&M?g z;Bfkr%>VG?#hQ| z4Z`@QU&;9wbN->`Cz=0Y;hBfXuX3Pg`PF@6yBlAJjPE|-qg{<}I`j>{y13i&XXx~9 z%RdT1z^_i|w)`zRy}R|sL%L0WG#}*~x-T#3^SdwqJ*-bv z(S3PIKeqewN0p(xv-|Rr{-%&h8~79BQ#-J)?N_grrw!1eAL=1VZ_Q3i*ZW81b4_~+ z^ix_G`PCcx?KpJl>Jzmk^D~i9;DL2e^WqL&-^Wma%&oNa`f@P@Zav(0c=cKR*PZzr zkSJyOCC0DQe@)ya@KMtw{T*j_=@*Nf`FOYGCrbK{x+@>L>1>WaKdt&C{3ZSD?)%>& z{X47M_HUE)iXsB1M zq10>c7a)4N#`1?z1g3$eY?T9Do|~ZI6LCJrsoovQ1WIj3p?z`GN92rI(lxQbNTt}) z12qe(|I#!~b{kS;v94ZFpHp|~WK39Ri20bT`^`tA`viMqB;uyCCh_2GVkGYWpn zX2o4-2Q38<&y^eP%1Of#*jpw_45PCkxBJ!K%2Q!IZ+1uVO~x6}&5i5JJ-n}3hbR(!E)rsr1> z9bnPA7DN}Yf{mx~r^rMfcLZpFXVBRr z(l4|pXAlgho4(x5VRZJRu=+wHgxFxKj1lnB_1yR~7@lDCnsVeIT*tTqThXYrpFVU& zQ`;;iFPWl__hI^R>}x3&HtxDqKZiCI$Neck#&76i#L0{=S*R{Pk3h`1yJXr42}st<^Qq@Iut%FFCNA zlYa-)SjN+pC=3XNGMxAWvu;zJM0unaHR8Ac8@6XOO`S1%lC!Q)bi4Kw#d~y>%Td>} zEV|t#FFyU^R#hdo8T zV!npfV+i;%M{U8E=+ihlumBU&H(ECbQ5RQFtwOa9{m#dEM$y`{sb(bK* z#Vc5=hN@9FV(t3XMWq@#-qrC7VDFUTbR4KrXP;x13Lm*To{2Ox5T6xxt&X#K+z!78 zK_Y9^<01qh{^N=t#n?7&mAJi2&MEM8H99sza0l&&dt0~@9?GnsDuM>QUpF1Qv{MnW z>lvtV#IAvTG_)TC}&&ljKA~aiC&DkCJhTMQaK4pQTh{VKlop)6@4>}Hx z?syBwVjF`V+dkoBUCApf|5zUAHHiJhZB2bix^2b9Uk`n9HLb1haz+#V<)&&jocWyb zw-TE<05uULUZnq5%SByF0U^fb3{l2WO|qMxl8Ew>hD$A^6~x=VrU z1H%fyuYHgrjwVD^D<$ZFI(9!jUopC?|xHbYsU?LpN_*$hHC91bIcAvZuq zEnhuZSV_XEizLFBun=|P%pC57+jtzk>K9TJc!!Po)md7O=9fR<3#QiBG${uydD#-Z zOyC7&wgd-#+_}h>BHyF*7Epfy0}nJ!%2nr_C@_olY?~y(5RFwnUPa8k5QnOQdCoVq6?Y1=LSKj{NF|5~*n{2N^j!j*bLXPkRL`eh0QSS=bg3 zAZH>Ji8$xSS*kqiUxb54%INiGKH+eXY5CfcEc%kv7hbC5aI8gW@q=XgBdO`cf1;`N zr@oV#PI`bet#6F^RB+ukGOK=_p4&OytY4>p@kx4luK(hfM4~jxXYjOH`Hay4%3IsC zA3ENnb(FWpN4J4h;Dz!QUcf14KwyHrwGAg}@LlAsZF%x6^47L8JR6l--s&fE6*fd> z$`^`S9-F9DJ`)mH`OFhU-YTCtUG1Jmc?-eMM6h=s4ln3xQrmxhOo~3%z8VN<%h*2b z8UyE{uaxWR`#qJlJzx=99MEyKa$_=kUOrfh1E(|ILAsiiF;!@{B zs0sC&1J!AS8U~5I_WIDLD2K z!)OQ)M3lsM=Hg%q+|1@9tu-_QP^?i;e8IVQ#Yzy6fN+I?$Q#ulAf9(BM~wsT;T-ny z$ZP<6k>sgS}hLuFR0--ZP&^a2X%O3n)LN=j7Xl1g&npKCU2Veo&}ug2%x4+Wpy zayoQWbd}TLX~+gj7Vm8Xmh(uldLFwvFp=aE7mh_zd?e$)b;vr!++LPAUq4bssBXij znH%Tc(Gy+aVd^H7X1pm+!+#xdfC)Okz0ibGZDK(l{Kb z!{q^Eq(unNLH!mX912m|MhJi5vJt|2@Dc?fWYjuGH`^*C2C(1ZhlJV-w;uA4Wk9du zm!h;RJ_h53!!z|+V{;eW2lr>bpUUrAo=km5bO=(Kq_*r-3t1+*^@~P87c1`vb$(-9-zY@~> zpiqakmT-FsGN7}9m2+FG1I>{P<1fMbai>Ryd!DMysLe#&97`7oAG@wHbIjQA5YJPa zn6NyNkX>JwH8$L%ew-sxY8)80AD5@lwf?uC3nWV0A`_8>JBY%0wV9!N>vIXrT=m~N znHN8ufb?)yWo%8z2h=gy#L>3C!6|=kYv^bzv9@Pz1f~*oB9J=A`dFiO_uy#cfDy8% zBF(??#s#cxDE&&eMS9`j-S&Anc-I>A>zODX04SS>7I2D3Ge@lAJ7k7jP2&W--MYi< z(Eh8hzYG0SA6AnMn`?xEI|B~{PR6Va7Lff+G&53_(-Zm>{J?RWRrBRI((QC(-O*T? zqi%_T8?jGKrM#dL_WHSSpGMvYqH5ymk&W54F5EaMREPCtPpd?<(lZl`q1zrMOSj$M<7Ar}kN ztQ$+)Fy*~!bqN3`teB(|e7Jy&8^_R|l{Exry{vzAR!60}Ru_iQ_pD~Bwr+I00@{~C zjI*U`K0*50q1FDhO4|FP6nEF(4LZ`OJ&!9{i@7R^NLr$x|9mdL#;ljNLqY^M48H0cS(g1qC6W~Q3Uu<4cbye#RP%}{j0UB&tpw<>sb ziwkui;GA|;67}R&lm^!U_V_e+od~dr(G5dDz$s5d963<;;}h5oXHyo%%C}i+iPN~= ziJs?hC;88S9+FMMbW~6s4e=d4VQz)o?eAhV%~!b~3JN_^E>JevuWULtx*tC0yeXG- zXjk1hiC$<)1yghqm`PVN4udgVsNBKlK|R6(4+k;?g7)zG0I)IvY8Kn z)-cs{GEW6#BWOcTV)TsQ3MqFKa^&%XuRH+r?e{TwXt1c1;mFHi3S+cK*B6Y@=qla? zsIA?Iav|RSD4fsVROwaiGV44ZUj!TBwV{xen#7j(pm_pYV%H{GRp%^ik|hV{ zCIg{aos;i3YdAAB1BvZdBAk9`8lM*82|>m{WQyR`IR|&1ps!G4WLp5W@k| zRjXg2x1!Dk6@A74xKUUGTBy%YqaL<}_SEmrVh`V*(Bm+t;;nr%%#76dV4-+e6bx#7=Rtu?z+R^inGg%xTTH;A=EV-ywf57rKbL1Kq|xRrL724{(A!xf?XiK4Mun zEkh_u>il^CKfWw*@8M6C+oj`N9YlvkAy7KRi!sUyK~y?*pYkuRYe3I(a6cSGeqdG( z&kK%+37bj`jwpuD461+j*KV43!d$QMomZ`RfgHZ3KFH#R>&MpDWmcO;%8^)vPdJPN z(A~Db1#+>i{XH;8(oOW(gS0<=*H>@fT>pR8`;RzQEOTyT0U&vl)&LGUKA8)Jo85u@ z=xJ!eLZ^`t%tp2-!SPEz5-R5XtK_p?rY`^I@#)t`W9d_>YI&Z-f8EQ11!DTXz@7HUj0e<&Lu#+E z`a|&#Y~pV)J^`E1A5F)-Mo*j&ehIyV-^D)J9>C;8dbeDK{LNKaF}RVze~tn0hvMQX zbEbN=z%=NZ1xH?8&F4z2ne~Nd*fCYKyN7s zrSN0ASMtJf7}<&tz3C_LD-h452w0QZkCa+MEDxLpGyN-Jl`c<7y1EqdI4D6UZnI+t ziaJBLp$@gY>-|%vN&UOt?yCM4rrYY(b?tkzyY?g<^(E~`=Zhw5)nSb%nLa!<-RzIX zkJn#jJ9hlA%IoweJEgbRV3;-twsc&U(ti{5{K0*|CTmfdo(A6U%`c62;GwxP><`pt zsf~bneB;wV33V-KO?>0cd|U&15Z_4TP;;<;;u~qwQ1!53#y3(eRxd!DiEq4%kDLF< z4EOT!ByiZ!#KG8~-FQFWZ-fOTzVRVGUMtE=37TzNvjZRaj~!@RL;Z&T+SaVq4|rOi zc&fLaDwk0SYpYx#KWo@c{%c#qUh-et8upC;+Sc49zkFi9_^)jZd&GZjYuF+FYg@x{ z^IzK<4v_!UEwDyWM%ffuKCjh+(#^me6H4b{Et*hTAg~iCjlhyNp)^YdEl|22dweF8 zeh4~XLMa1$&<>>w!AVRgohNc(Nmt9nHsjyyV~F@9>*c|}bhTb2UHwT*X0~->^-9)b zs21z79RuxRJu^+}pWJ2rroLc?O!Ozz1q4&9>MDLBsO=1 zt%MkR;%L+s&XGET>U|j!1*>d*HDO}c096@nP3Ix@_P=nmq=g?z`gT6L3OUw7~ z7q&(Qg}5y*Rl@8yOEi7A2BQQFX)TV+>`JE@{>|3meE3P^1>(P!{(d2h3##ODDmA_4 zHjQJ8S*;SJ%vv-5&aX8e8s6)lPlq?xLn4pAtxS%atA(BNS$)vERz9Mr(-_Bq?!;ni zoP{W7O-#x-eW~N*FcHEhiy`k09?7TuLg25}IDSLxF^xhWhP~Dm{b*sjef{!XP1?!4 zUC9UBy>zQbsciwF5PmU|DRJ(^av!cR1g#*94U~q}0Z6W58E7&SpfFWZ;dQImv0vQ8 z=WxGjzgn0{6v9^A?AB`aM`6=6G2qoUm@XcLKOEit=wDZ2&SbT*j@;X=hovjI-Ad?xxRWNXPJ{*ZkQgD+#EQL=R^l zM7i&(zcXOJxj}jZA=9X$>1HsA8oeZX8yQX6Iu)jzdHCz(a1D9T zLekEH8?{6?=wf{7pSWI(dUL!mP^AavNlDMb%_(;0ZB!=rmDu1#M%FdF++P`9RLlUc zcVS$x!Am%cInmw1>qH13o!~{L;a0`iH^KgZ4PK`}Vm(}V5fxm?N!8)=7dzED?4($o zM4T%pC$%fgOD#WGmoHM2Q;M^2m!-9Rv3S$y)toWz8lKk!>vE^Ab$V%7r*S#&B{NYDvuP5^)u7rFL!W{&g6GL8 zkD+QU3@*?uW7pv_Y*d)1-h^cd58^WR4mY+yh~Ts2Y2XeP_XcNWvxIZ(d)R%9xJRGv zX{4R1X=DuC4WV!Q{evALYm3PH`7rRwdw8;(_FHkA-1b_$H=ynjJ=+;_Oo4L1wilTj z93QMYI&}Fwd_;XS^*7V$Os;i@lR)j*v~g^bV-3F816YYUtJl_}OAe5X==1T|_i9gV z&xV3v1LxQNsPB*e;u*jT?Gw9rr?d+)1kSGjXdT#2mxub^gr0X^QZ(waFBX2D1Uch+ z=;0b1sOTK%VUQZ$osd##xpURgEF=M3fhAIXD&77xcdn65cz=5gTpb5@K9pbQx|$e+eHT z=bi_1rXY={f!Aacrtn4!sxy<{@5lFAHdW-Wy`Wls48yV20(KQ5N0}|GMmF@mxjaJ# zfSL-iW}aX-F+uP*X_~)ABh-_`R?F1{A;z$-rV9 z@r8dK9`XSKbvF%7)(YBzOhkqp0js%!`bN*joX{HLWjjJxH(@sDYSi z!I{VpVjH?#RR0HkH2ELV6p%b2zHu9#)N3%C1ymmV3d|1+XgwDb2ehJe8ektT@SeCc zc=a+T6&l&x^s66GZpXeGF<-kWc4CxfB`nWvRXM3B4{9l7Ex^0$67A1Fm%_i=zYPnD ztwZh0z<2ELe}Xb)e^+V#Jk!(4@5lbLJ%2%JeHQ&3%k?%@*spVMwlm@l?L@309vg6o zNynun3!D|$1Is||;E-?8P;Wr3!p11rUiT0sJoK4GNaGZ_d2b8=+#kE8u)$kOMGt%} z-W8}Vk$P*r>JG@|qz4d_BgfQ^(4S3@`k`vS@Jb%JL^*W>Kq3-Y{7kB)j>M1FluM1;sO0_P?ic!b3c=O*QWz>kB+J;+dd_s9nVKmy zvK#D@^N_lHb@8szGwF8gxs~wm<5$(&g_LLQG+MhKPqO_^@#MSOzLfKum}Ts5I;hUr zMy4NWpOW2cS!c`ol$JYJW^aDWnU#^Q37Gj4<1Z64Z=yeY%Y2zovGLV@orJA@-QV^r zfYY{Kv=@L8+1|ChxahYcFG3pe$^LK9_&F&GIEN2E4ppER27OoFzk&ie!4@)`m9Lil}A`DfSnpuV; z`j!`G_XEm+&|M*hX`6jtDgWy+nO(hCPN23n=0 z^Dpmm+9n|Fv3)YcV%xX&pKVQjzZzYzr_Thbx4-vEEgwJ-+7d2*w>lT#ut-X7ZH4|^ z9=accg$Q@Qc#p~{Zc2hau}={hd`+i;CV&hKCd?c4!67Zw7c#O#WF>a+kS~ur(T%7K z$2%}3y;_~F`M%nO&+ zafvUd*It~~;eF!o@2g!R-vjDX*f@Z6kU{6dMgdZm6`$b3Gw4<4WoU!gS9>IETsRes z%N*dJ>xJA5M1uTrHianq=kk_^r=gRHDRA^v&d8C52VzHV{A#DVLW?ZG@uQKgQvKm+ zjfZOyy1+j0R~v`bJa&Mtx${6ZxLZY<>&m`mf)71(t4D-U+jo?kjUUpovE&?d_Qr9v@%o1_=7^y1q`pB2nj^9Xqhz1V<7DS3?A@kdiB07}YPfEoX{0hO=re^iKK9qE!vekvEA!)po z8GXkIS5#PMcSQzaT3C%+{ZTJzUQak%)Xg>?RiijL49nUbR5Pg`6U#jteoWaJhct!+ z)t$N+eT5_VI$t99>k2SrFMLH`1g?NmQg0&Xs6{TINMW`5^Ja6|tm^#~n`-CYIi(x1Ua7duD(Klj zK8C_)urP*8Cz?D#I7>a4gq2$WoBIN8lW5KZHVE&8ey>YQ>^dbU<2*;uI0qLay4oDH znZ+Yc`X!Z#mBVW&V-j^4xBB#Ll09O59w^ufSVn|Lg=^8zw>HVZOZRxzhrn25@N*%8 z`7$Fk0D0BtJ0t@PPG^f`1|-UB8Wlb|s4fHD_p3Xl0<09z27?FbO$#knS!;BfFH;atp6aq=BvYr(iR{iHZlEb*jIqCpXIL?83dWyO z9J1pND1MFgA*i0jJN!!y)0Ykt0(V15a5;N3T;TzsIaWnDnifkIu@MnLw<&P zAdn^p8yM6Bs&FwN#YIGhS95kIB%JVRm?;_ybAtw$GUPfD9jV8tX0gR^wL)&Jg!}~J zCidz>dN0C%luKWYc|_!8_9G8;tNALoajda9YZH%K8R1smBQRcsSw!?`M+4>|@%<g35De#=y3O*smymz!=I4@^e7+IO>>f}lRWZ5xeXMUqpeQf31!mLdVGmr>y5U0 zLGji+$vP;B%;J?H<3S}w0s$0!h{qK^HXuGy9F zS@GgZRitYLUHz#Hbw<8-Jg;mj4LUvRuLcFfbmW(|N8|(=R)Pb@87L$;5KtP6c+roO zwU*EZa~xQNd2u#!K!(O!;?ScMvtijc_h{b#{%~^sg6iBSOxSrg`~%%Y_hBv%;yNxP zD{H&SjC?BJ$^~W=RBt142colm#FCbxvjv55*na=xQXQEQ4gje4^(#p>ojp+YK9&Va z7GWmy)%G?_m1(M)fVqdsDm=%|ix5#p-Jl)8z*MdVAFA$x54FIr2gDD2XoBWLi*|tz z@mcuLr{d#_i4GT`i!-e*de$$(EMh7w)0(R_I@y;gZ}^INad8h8qA8!gfoak9=l6du z@(qsA1ft8}|5d=yG`Yn6=9ONalJaag9q1K(#wiD`MyhEbB*q?`ih?`@i6)n^~ zAiR-AuuJ58Pc>6Yp!op1jh(194@o$OuWBSWWutkr2))d6trIbP&ieva{q}*x6~%tW zrttANqNC5S)*KKJQy};``o^P64hE#aQ6T(}1}as&g;|_jFg)0W?_OgfR%Q4|7USv5%2)@q({Y%beG_vS04{PAFs2aL^P0qgL&rT0Jc7ey z$XUjl;d70Zh{Us!H9(t#XFGi*mva{qb{L>IrZR zWa|aN86!rq<<9~Q3pMpzXQ?g^?B?bV)%>~Cq+EV>LDpj3KSN~M?RN+10qQR#Cy zpJHqSC{C9$C!-@^quJ;I5<)*olvcw~6pN~D0>n;C)%Mc4uzQC|Y+%ktk+VT`a-+}i zmh!@KLO9yS?Sfq53DKjp3dCtAPA%EcIwyg}^3-C|s!rDpd;=K%?^UVS$BqX{uOujn zx_u+TS$ zxwZPx_)?_z{JG8JGqJ#PS_943#ErjTQI$0AT88ipHA z^}=iH1%iBe8aVOMSjO^KfXyYw)+g}FEfcJrW!K*fXP6#)TQeh9li-!8pQ0jOk(ICJ zK}^Oh<2m;M8e2S#_o5s!<)~|Q;_$adRI#E%Gwkn-k9(oARFpS(J+*xd`dh27}e7QG_@Ir%mAs)yog9%TsDD{pxuq?>h()NjOqL)xDkREr^%0ZFRW=c0emTivW2JG5(wHbQ0E+!T#U)Bq^8 zjP|u1!eRjT$84P3Q}<#4H?M+N?Q2^4DajT6&azi~h*VgI-sn46l9B!}w`3!3 zPYEvHk)&8&RclWCfY0mJ3At8^RCJFp{<%0MNh+T*bXq2 z$XE@TyCx}c=L$9gH9ZaAXD%%ANdfipFJ(A5k+?oq5xpLKWSo&I&#zC`AD*48+l9K% zfgRY{G}Waxi9LW~PC2lHa3VAHsfbxSxadx3%ErVFvL0|@uC90yg0)%&BLlUBa{*2( zj*@_E;)zA`Zi0h+1D4*d@y?w8`!N3)j$15^pEh65=!0U}f#mW-2(M&?f(mu;+lK}1 z{P!r~IE|fHagwQ+!(rxCuY_xls`NrRg%>1ed z!3mie_XAt<^4^DRHBw%FSRbM5PF^0ND{M$sc-Zpt{-@a2F68BXf1W>hJCwNQ^31PI zb!&MAvWu;@mYge6-{+*g-<9_MV%j@}eD(favi`-MViExLC}duf+pVUs0j$IR3c^@i z0##OQ8xVi;=*Aizr2R?8JWr?5fbC^8N$!eSYXgd0%I~PYb;+@7I~{ zXNQ!$*P8FAhd!0}S?2q(A?IR_>Rj{PGd+|mANou0-PnQy^HvT|Ll$Lrp5}4kAygrm zd?4+ zDQeawpvaiz5*hg*T{Gn^H>O{z9&w}}8JuU|aKy8(ayjlZAc?6Zj8ezuXb;}7}h|R4y&R{4UpI(S6A$aHGuP~17^Ydh7 zlC$+d47Av+&?01DLWy3oAZF#Eu=y>|`WETi3~OQYOlm?ou@ge|W`?-NTdb$XqHNdz zXi`LuCqOP8P=7)3JoqotA3{=I2k35$2Zr+A$CFMS$Sl3_EguCOG3JwfM9G(r0WpXq zY}wbrxeorA)BW)y8@_Xb*VscDIyC0C<7i;dZz_ua2@om(9nGR@=UaV%>)3PSuL0C` z8Rh&m<}6R+j|pQInlAZsLmTlSYny<5Ix-cgA46sboiF6v@SOWj4_qI;AwaZU8o<80sILM43Jw!%_q;#;TNh4fuMjfyZ>FJw_k_qkxcSe zkkuxA#bmSZ$X?J~(h~`}&CYS=$E>X&j$cXv%G5ysWJE_@8CA%~D{|fW93I2ZQSc?Z>@XJC7s={9b1Lh!b)S~=~ z2_!RP)C2c0JMZ#Y|9!+?;#_cN;XCqK>;bo;7O@9F(yV-4dIByUJsTW&Ax91r_B2%S z$qyGgPs3O|Qj%F;!h#zCxpPHg+SYyoK7@XW$&~Jd?nU=4ir9!v2*d~qt=HrybesI* zP&|HXv&9+^eY&XqB-XGsQNw+<8YUdPhMz*;XASQHfT6!xH6%&06U8xjL?rgLGyYlV z%|xEC)ycPBIcO&*(WJ0~Hxm`L*RzV=0KKOC;dc58gecGk)dxQkAchVkT1YU4I<#Q* zVl1op9C_L!5GX>Q;3+ma^g*IfqFd)2yju@r&zoKPfIV=cOR-6zSFKt?VzV^alH=eN zU20Y&^Y(;QLlT6WUuHwT_69ce{Y3h;7WnQ)`a$N{rK^us7H^+UyTco@8NqF(5@Ka# zP__A)or)z#!X_|F@Hg|UhGZefuoA!p}`jXgK9Vu^wDOLC zPhq*7o#4P*xAU8yfmG)hZA<`L{-ycx|7yAB{}1l)AP}Pd37+Lr!=O`kHn2##3jVhs zsZEcyY6p*S(%;P?F{OOQXk#Cqu}9@Fa!U7udU~dP68+G54SfCTv4kKilx9Y}(albB ztPjDiq6fYRm#oV;D>HIZ^uU+llh>7JoHafpGAMfBKsa|@dFEN;GjXcrKs@YOSDtm& z_^fbG5lx_}Zo-Oi4%@E}2wVL*)NhFTwT!n^7GC-$t)#0<@91v4`hL1a-?q!k`2a$Z zzOD}DXUG|45K!FMk`5NfhqZ=Jp-k!b8xx&@coFzwo?rd_=mbmb1&Y`_<}<=3HViS4 z;}NIMvn82lHvudmZe{~H zY~Y=&VSk1M>w~0`&)GZTYo4}2dfMP@3V#TYq+^~TybG`cMPaT4wyW9r>N39`YEHk- zbYW+LAAX#QF}LF@1aDchL>-maMY z2`%6~0rHMO*uVch>}9w#=gsf$j#pt-LLAvJ8{9`6)>zuQpHQESih>JqXDUx(q!y}c zg=;~MM%Fw^{uB;iX@HrbSj-Hs@O_eVdbMEW0?5%vCNPp31k%K~Pz@_X3RJZB2Q zXOQ|pOkNH#dAt^rL4hD9FNd%_~LJ*UeLrflTiplG(mh#b3#W&f~P^+a9Yf&Ca z`_k9DFg;DQ>^E1(8(&&BP~_uvQ+)X_*{!Yc$N27E=|@Wa~Wo%iYmh#{+EvHPDiV&?`+2046{6R{$EYVG%AS;+-UkK@!A zl5zLbHT>!is54(g^_H&tJC0x+@RrAPfty?lq~eSHDzAT{gF--Uh}*1JykC8BgqbJ2 z5cs$da~F=r((y4ywQX;7zu@B;#K%3r$NhqjS8IIS1GR0x;N#UAANN3Q+b{TdwZ=!> zZc%rnw)%sHhCx5K!A2f^dReQnKA-(dI4L3NXJ*pZNjm?Zs4GN!J}}9p_5)kGke8UQ zPP(-_{y-|t^75xn8w{M%n1-DdT2$Z%Db|#1F`b+V5yuTnO1Q7-{wZHQs*(0G)1VT@ zy{OK4rZolYgdq|R<`J4YP3HOcwNNy;KCC~r@L2TB^qH7|uem^Z7k_aHCN>ah2-+mq z31T;Bn{231snHj^&cC45T7)k3ihu&aH}N&K=`PZh(5AAhHF@*i%^-u^!`UwNR^-Lqvf?KeGI5#-NC~Qz@YEzg9j;M8Gc75 z89RaJqXCEL;x1#RGg_Jv87_C=4if7@*xF|S?--x4`EA_03h5R4AWptKzfZzLqoajz zGyE80rRahyes1c4H?N@vxVer0H(=9O_+%to9|!rWXFmvV1M)grJ68HPQ11iv=RQzh z&&G0)nqKVR=C04yndMypo8+=L=6*^`E>BT%LG{FgQcxg9mMx~Qz9U=+n`psk(C-yP zc{gpi2X04;9L))ajZ~d8z#@^1&U*;#6<=mIcCtp$qO<8{YYI*>^$X0z67h%Q(Z%Q} zM|c3>`hA@ZThnhH%5S-421cRQH`?VlzsU9rxLF+tGCq8NXz?{@z8v*JIs-tt8C;K& zaN|&Wp7KzE8DYis{C!*F~j*v~P0OsTXlBryOzgM&5{oNr+N zRG(ne8mNi@+6za}%B!$lyKTNcQ&oO@Wr`{$8cp84M)6yhB(TR}p=gEv>9oKTA zq~cPgo0F+)Q4NL9dRAu>J{RLXDSS>rSngz*8O&?r;act$@e&z}lNpC0qh0u%<5x!} zQwb1}kcz%p4EY0c^q*VD~6NUbokRzW4aS%C@JGnMF5?H;nX9E3S)QJi7M<;;T zze#5J1v5~!NzA#HzrSwPX4N@%0A%Nw09Za8rgpEG*?nrfAw-I1_SrDACmdNo{QZzI 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%0A84$=1SK^Cx&8VCa+7VOb_ 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

|*50d=i9UiZIM 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

>2u&0+L3jJjS=6B&`GYm``c`~SkuEC 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 &=sb+KI{E{i$74=5Or1I4VfP8*bv@&cL>v9m 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

r{2UkUg#_wm^^y)CX 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

Q*@aM!$ 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≪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

Oi8eXnebN~7Z^*Z%>wN_oJ-k|E$ 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 literal 0 HcmV?d00001 diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/manifest.pycfg b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/manifest.pycfg new file mode 100644 index 0000000..7ee6c1a --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/manifest.pycfg @@ -0,0 +1,33 @@ +# Prebuilt directory manifest. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is executed by the distribution builder, as well as the dtls +# package startup code; the purpose of the latter is being able to run +# from a cloned source directory without executing any sort of installation +# procedure. This file provides the definitions required to create +# a distribution including this directory's prebuilts. + +from os import path +from glob import glob + +assert MANIFEST_DIR + +ARCHITECTURE = "win32" +FORMATS = "zip" +FILES = map(lambda x: path.basename(x), + glob(path.join(MANIFEST_DIR, "*.dll"))) diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/libcrypto-1_1-x64.dll b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/libcrypto-1_1-x64.dll new file mode 100644 index 0000000000000000000000000000000000000000..bff72ec8cdc9f42e72569e5f6e5cf33af2999319 GIT binary patch literal 3403776 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

M_(R3-n%Ko6tp;#sb2+apz_mYC7;0p~Ov(6*^Wd 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

6o==Tz!&#HGd%BwSoiJqD)!2?)1P8N` zR^Fyz2w0mhYA?0cr&!6DltJzes!SettD4=-z9GDL{au!W^#rxEBQvVZ*rmdYzH+RJsz+3y9nM~sS7S9V&UH`(Ob<1}uv=5diD_n`3E2QnPb?FS#Cd^_Q zlT)buQsBT)mYc>S^E1Z372hYKNPVswNQlQOJqLXl`FMCpeSm+K8R;u*k8N%<*kM?9 zLyA()OqaNbQP5ZacM(zvVHs-sH-Pgm)N0qGbh--s7wo4c14wp!$eV{qU)M5q!E4S~ zz^s1i7Ho-*w00rL?GJSblj%I5i^n}|U0s#R8xa~IBj2&K-=X^6;6x>z7cU%Y3-<;TiCHfC4ugm>! z>J{jWXNY9`c!)xy`&CAW&zW{7K|W13)>~Wpw;Ucs#(?$#XMGB{W**i*X3u!c+@fn{ zcmLaObb$f0Xhw||+t5voDmtqj&{dQr?5yL>#bkx`cCxJ#B^eZVWj`>AU2=U=dyn(x z$+q5A`*8ZPSGHUI06GWQ5A_H7041Qjr#de84Rm#xU5StOYAUW=U?34NLP&=K7;?P< z(|hE6kvKv<_C%FyV;alllpJ?8GJ_msxF$$1ROrsg`;@2vKusAoo1?k30VZL0#iv*3STD~`(w!jm zhcp9PZRWXx-MQ%GG->0(*LNeNoh&!fJLEbJ$CTVrZ%;=A9(Gqu7^7Ga3eDQwnmPq| z_4AxI;*G{JP~OAFpKVJ#cBNBBmt0P&THS~ibUdrKp$h0*gTiIJeE!aGk#L3v6JPmP zJ5Uuw;uMji=@+|9#`llFX35^yO=tfeV{bQ8uaHJ{y@y_ z;V7yVM7tYU1e|sa+N|m{-nKx(-tnL-=oaDGRgOb>$6n;i*HZVEoqTwaFRUr?D^q`o zEaEm=$hQa6^SGaMbq17?DBFJ((3MZoLr~>vDbP{=;eDt?kLfXq;{N0b#vFg;k}U^L zXFCQB7DEp0xtOVNM#^e1YjaBH$Cuhq^84`YlZ_WX%ViIwg+kvJ1U4lW;%ddvySmA+ z!_URf87l*GRv>i=!{wLcmfA}ev}*|xp-s1-86@N$x+XY4Lt?{^VCD(jX#kH5$y0K| zf$3+4MQXE>`>v)1M!UD1HcKp~djj_nb@P;i51kNSoX2}~3dW3Ec8b`_XrAy`Yo^73 ztbP*qQPflC^~Mfcp>#{cxUz{-r9E%iPmGvYepcf~Y0?9pJPuLn4}q;~rFu~RCykwnVFuuPHIA||vyl!Ov4 z)dvYstNxi#qxSF|6)_APkM{tw>{ko8{lAqKR#!ik<6h~p>BP?B`X-MR#B+IZ>&}C@XKGAatM%-TB5#=a47M!2imvSi4&^q9+f& zjH6!0R?8n`d`-e9#)kQ*gv7j5}tTb-!U?FDfu(j@7>Gi zeV^Y(s9h$@z=_BK(l$e{5Ut_-ehO;*_6-q@+$E%Km>WagzN$1BtFFY&{I@xTB(dIv zm{}@3Y4Mha4H-vyv`3Pg6mVG)rP;Yv?q(Hx4cDE1WM0k7_js+8GQiZE7pz$Wt|G#G zueuRC66kokgyN>AprOtrKLwJ5krZMxO3r)9>7F??G~2O$(HiLN?|45)4Lv>f;4jxf z{9>!GfVd#$gM$^5$!iIcY;TE=R}-Q^tNh%@`r=+22jE7P-p>kU2QIL&NwPyjTp0ax%uX~pmy7BEY?QJUfVgP)>WE_EHZDAb-C^%+R(PUU76TeldV z?cZ!+3wz=|zSS-fP2R^u8H4isgbn84h3tjac<#3yFn$AQZcRJ@&Anw?YMSDcIyUDs zY<18GVLDrxOB1gzT=*x~BX~0%_wB~|w75A0P^!6^b>Ot(1hFo1CP{olm2#L#Ex4&h zrmFgydS=fAAzpBl z*EKnv8-XhPd?pEPfC39vJLQ1)8PT5cX<~Z@349{(nf0z*GlhGWyzcL4Ly}t+=DL@} z^)zkGaI?;A)797=`FoA0dBy4~VtTf4eF0@%XFtUkPvppO2~DGhPzn6e2T2p>R-*ji z71RBIoT5+St(kP#1I_?Q5xkE_TkI9mq-;}1UR4CXyckRoHS&i z{s#|7rMhB;$xJWR^9?C0hMr2tBpOFUHL9W05k(oezDATIP1M|h0NujAEyyaw4}U$% zAVshJg|Iyz^4dv7fIe-zLR_sGik+7A5f%O0J+V@7V2~?l8x4sOEJZY|gE$?hv;{bL zTLn0zKC&f8cM>E+?YjX8l)<&i1#kSxm zW)9UIA-M9-RGls7JyT~&fOCJ^$i%$9@;1Z;iM%_5Ua%Q5|!?jupB|qOOc}h=G#=O#irAZN=?1 zR$$>Xc@30)Nz-Xs%WZ}O0nkru@6D4)SC6A0TcN}HkmvLJYmQHPF6!5AhRQm;PwQi* z_friBr5*+%KX^v<=HGxM9%Kqy+Qy61Wr^M{(L4$J&sDUnv`uA+?ycb_n!h|j)RZze zWH%ov{)2#IS8{m(LBot7(**S;L++r%UXEI0|CpF(%Im6dRz1dDNA>BR08O+1W_g&- z3Na>L8Eg3YsEjH&gj7SG8T=5_P%3m$u-Uo=!U%@LlKu z7AKvP{pv)`vt_bR;u;?1&3zs+may34ri?m{m`Pw#ux#Xwrz!_I*qRBExR$Du9QhX} z6E4bsYjMaABZo4TUT+YmB4sWn3&hFMl)$$_hW@&_A;3cwokMdNUH!gX-le+X|B4<` zUi@RDtmJ1=iW6!$F&T$D)fbFZ=+!D5__CFY9Ira%p&47Pa>ipt~=MS9SY4AeJjy zBTpO?zDBQaC++jUBO@1n(Q`zbMq-~cc!(ZCG8UvIdRV9rlWbd~Bte0|+qmml^{E&u zoU-k9S2Io~;!%9a&0Hj}6k;AwTLH5c&Vo1!V8t=J1RlZul)GVRpriNijaz~{CUf7h zI5)$-A4*`Ed~~g7nbbR)G|5!a2C2s!Z%{L=j}BIBl2f~LL^(btzWgX;G;}x|)hHOj z{}M|+;Y-jwd}fF(`Pr(&&me&$_}neUGL<+gE{R&0pkKH<8?va5~^ zY_L!JA5EMvZbF4;N3qV>$QPSsR8C znYWjx=jQ$xO-7c+4K|%z65hWjNb9=e)dn*1N89x)QUN9%AT(_+n)r`DQ+Bu)Sfr0P z48vDZHn~qN#l;tO+>BA?toI`CWO{_Z=7Y4}iTdD@SVai+)C62AI+6TorN_>nlcTXR&Kj}Ee#J!PKuJ>UXBCCRMA?GA(lePk}W^- zVl*LY8-Al_^jV+!4Cyi`r@46?~oFTg6Fs@T#zuq7LNAE zxjo&I#c33SR1=twB;O&0S)zzF>?3|yESaF!f%5Ik2Nae#RSJ^^A--F_>kTpqe}R!C zwWa^3U{MAM*|f;!snYiUk~vSTy}{`^SfQtSK;nGkm~F4%v^c9M*+N6*%9tDq2T-@@ z@>nC_uZGU=C=!zDR=Bt^{AU~r5*aE5S*@<)q@D(}vpk@G;?^=Uh<)7;TaJ_4UHr=| z&C{5?u7s6y}3?wIC*0CSQEMXnxXPud3KYEgi=!R|jq5&5)2v zf|H`zvDYH^-;J~cOt63ZLvP{hr_zdIKwu&uwZA{j#I^)cvFtQ#h`|HNPIbUAu*xGX z2x~L!ISxANDG5uUxAM2Mrh4vDhL8hpA$g- zWy3C&%fGu)t2UTxLKeYv8Ssr&o;8S-Tcl?T1 zoHFo!Ev}?dDnP`MB9c%Dyj#?-H65m8QK&UA{+94KC&1pJbc;sEy4=T4=Mc~6Q)8g? zK>s!BX5Dv-`VCAA8C-teQiYp$Y$d?K35K%zJ#^Ho?vC|jJIQQ&IQWiEJ-@L`CJklo zVc*_!37sEI?F_fS*U9&;9^OF~eo(+3c~Iod$6S8Rw_ z1=){73uD>G2E|2DbxnQrd#G1r=zy9HzeX0*zN+)iE8RLpok0ht2@#aZfXZvTVjidZ z^D)E+6O0u!dc-T4yuM0VpE-Ezk*5A_q{~7-HT$Ya@C1AZuVu~c8cBrcW}_(@zw#j* zy$;1vHGtEGnX0|^f2SLc8%8L z!GBuE``V_5iIRQqwX&U5S^<9OUsVax4P}(*tPB z$Y=?9&ecGF@ObImy@uYV9CeR2PkdA*o+|rg{e|YSoENzsZ!>AmLPSs!Cmda|YoHOi zTt?rw&45`kqXgKv_v<_QlEgb{1jSP#?yh(>qI4w9nB0)%Wk2RxgxgeKV;$7goQ za%0BpB1U4h0{R3zWp23)syV8TY!|14KVzZl9ab|SbKf&XhR~@+ypX^>9V}GfXTe#j z@0Q7eP5kE$=r#A_dD}I7lam*F{oBR(C7waH1bZ?c9(tWksQis7n{S_&YBuSO@!!SA z-7ClhjaYgc{hcxRG*tj-fR*dmwN;AVfy!Hr+J>lJO$#a|0SZ&-p`hwD{BD-n1RK-yd~VKO-|3p7Rr{$*)5n0zR5CLG#+&IXaL7?-#MtG11Xw6WW#FKG1r@XJhe( z-PkNfo;WvO=3si(1o{KP@OZTJL_@<@jma0`*zJ+dF2Nrouy+>#%e)7&@<*-_LZbK} zjn9MG*cvYK9b5l;v;M??&=LJ;;luH;kgii*0~iGC1$p~Wh)6&bcgf?0kBwri?fPd9 zEK_J^P*H|1u%>5fq^hhHLF`jNi^Rx0PE z#%v&%5BpQk(Vo6ntQQOKaYZ)?I2#z*US+a$4qcOps1EqZHkne9Lh0GS<=})KL11VZ zlT4$o(}dflGN*HJ8N1c_z}L@fR{qks`hx58HeYu|Sd0Jt%nY)O5W#eVU$$8mns^K| zBBZpqiy-K4>ciu8{&;RE`WMJM1bb z#6J~4Blx)YFFgjvO!)*eSPt=MMq_90bgu6)e7UJWZEV}RR$^vPV=QnKM}e4H&gKc$8=dB-E^Z`8rKa=R5*5;>_@rYf6|;qk}I4=VS(bN^^Wd! zs1E`!5M!_~E$lL}Fhy#-nONb&8bv><{!?GvtgP9>BY!+Pi{&S4lK8?dpLs&p=Wa%5 z6K0|x)J-y&W1Ygit8Lt(SJo2Os$XaM6^;ENzLYtEQ*}@MBDc$2AZw%Zi~luI^_^8| zuPDAmFuy^a?OV1$AmubSr1Fc|y4;%}FDgKX9 z_!iE3J6VMygqJJ6%w!*{wjm^|*}(GXTn694ba8!Q`gmnutF`X0s8l;9DHHz|T@g8)F>`ZGJFC4{37^Z&7LypjJD~hodT%iq<6a`@~c*eFfl$Wdr z_~c^7dCLa6Q1Hwi$mU>O!)7 z;74b;5DRV~{Qm6KU#=ZU{P@4Z*iQ%tmPIn^8lXUcHbCCgfN5k*!@pYAPOG?{#~xG@ zAGsXh5>RRV+nNH8>}hp8gM{Lmp@40so=jn-5&89XL_P{=!B4u*O|b_I!ErF5TPPff zJkFkZ>wLz_Cw|~TsSvp-BQfOi3ADXtzU;|*H?jBzkZ@Eu{rZ7fT|wi8pNp#*CmZ9( zl))(cLc6?gs^B|>Y=hgTL+!Ei1ze;7A+))%WKUL(KhxTQh=ZHTK9Q3k zPAfvO5`nyFk}o||$Fk?^z89cB#+UKRN_=**pRU`khyNwXR0@bTxel*=aSlzK_1%P$ z^^9*Zlh1(_QuM((hp6m6{_kr0>HHV8!;SmyMu3r}0F)pCtQgaC<&%bT4?qCu?I)Qt zOM!jp5Si*%J5A}6Y5PSYwl#|XGz}ns*HbtI;XGvzXdVwod1Ihiy#=g+DGZYX!cst? zmnXa?wfb^vacA+F77b?;(MUcXQUDhKc=oNg0pyRD)Lk6ZlKbpcrG2u;N8i3d$u5vc~p@U-3cdZ7N znVr?-P{uD*!skFLNqn`dRiE3a6OWXv!(~LUf|g8e6}@O=`eM-IevL6-tSJ|zmHf7z z_I=17QudJrs7BFasv(wG_qbuGj5IOMTJiN++NKRekuzqGqyLe$tazu-&gOk6t6iXtA$SnpHganLs*62D))02;rDHo5d8r)Eg+YGgGNHPFWs794 zWLpgm;yv~2%E8FfwER1b39|%U^G=ZsmDA@v6=d&K=uo&OZ)V}W8LLUuZpz`?aweMS zBC`UCQ+mtVeoJSu|05;!Zm*w?i+F{a+BKzX)FuYKt^*&^WZZM;M~t5de#wpjO;)JQ zjK-4QTzZW@#5^){p*7%meE)#>bz<^9s_i}}|2^)c_!v&F2Q`9NDD!d#waI>#hB2L0 z*%|Gfoa5YV-m@xR-zLHlO>V8L6fkm!LqB-=8CQrJ&!9<~zn$8US{e-etdbp9N$k3d ze^HV~{)L`aucS_)6tFXct;OXMFQ)P`g`3z0I?lqF2zvJ~szC?2qLudn{N6AnnZdJ! z3%cdjHp%1?QG||td*KVceV;+D$txxzjQoS8z2d)-$0~$tCuj{s>GaMs6#Iei-t=i- zlDkhM_V#S?SmEc{!#}QeGYfR-!uE+yq1IwaUC4W96%Em7e~N8v&9PSkByyTxVOiKp z7P*#iN~>4q_eYFhejP^l(AQVq;$F^xO$oMsM+F%){T&_)P|;oRLr*!1=0x9@O|qT} z8CPeAzXr;9BJBQeVl)eH``FDk>b#MiN4^E2JT`-@;}Dky!FIZV#)8Hu%Oo2;TUQc> zC{pq#m~Q6idB=yf{;^&_=>rUV=SZ~`FwpS*_iD2^;C8;KKnyWYO0e2Y?8<)hJ|c%= zAd@+ZRbY;1Mn)dMra%5+UK%C?YlecvN+d5_xJA$3LrZ8cQJSldW(p$5RK}L~kEO5m zesGW--r(C$mmNYZFT5_Vqs{chB>qmzJ%q2Iu@KFi!B@Cb1U^lX>(R;G>m7GuyU5;q zfvUxks&|rtu8|{y){XIc%7hxn7V|(CNsO=Vn&~KsDAy!zJ*Rr^Y!xf#!Us@H9g0Bh z#aGW?1a<|sIi7K!zl(;#0X#H)Ej|!_OC`!IqP{WlsW|KAv|{IrPhvi^6Euzm(d3>g z`UTi$X=9F-*T1-Zo0LeH7lb;}<<|&PrJdsQ9Xl_HA4l#zo)cWjiQre)O<6I9%#D%W zJSxcGOV9l#lv#T&EgQ=+Ndm=!2d-CkojPIa_sRY7Y+vibg65xxxajJ(f4m%7;Ql=& z=8zIT6Q>OT6;=r!>f(bCGmPdGcdaN2z8Hu-QUd&I%}`kLIuV7np^D!y3ty9ctmC&z ze4yQ}%K$G`nL@*oY~Kkf-qAk8W_Rd-T^Yh6z)Ihp*yfp@G)d=WPA+5#LGmB}qW=&k zS%DmN)JGjnW0N|sg-Ls9GkiyfM#>li^Y1~FWwi{c%OvnNNI1J#Xkh_1cOSlm@dtj~ zd{|j*0*@W9ao`&nT;-+ArJokRtWMDPJi25!+Xa1%e>Pr`U7l2?ZHJ1qqCgHq0% zyl8-OjO~>1$&h)ZMSxOtNk$r8*T+BgeLh)uo36#a*Jay5<4&wBo71BNB^WUpR_u7?F`6HR8x>(Ww4QL~U2&%^@T=cb7chT@S(4+pcp!~(Seadc&7gH$ z;X7a!re{d_99!dXJ+WoTEIzOqXhcB*Au%7U+CgCCe2M~Ztrw7db0|m(>21lY)Kq@A zvDRfm=ZJl!d5*(q<1Of%X4hfViR7nb#h!YB^7Y=J&2?7HPDx#uh35d8N>KwFKZ7R5 z4%W?sT9D_fgaH?!wJ%^$Dr6(D&4!q00Q=#lkkDw=e<4`{$08Y9=?;;rp@Y7d0z^ z;5c(ndC7f>+epw->CPS@xT9{!RzT`5G%I(yr)bOAK_yOKLDO#G+Z5~%ruH(MzL*v{ z_jcp%0!(Lv7pkY}Dgr}TmoI~551FJ}+Nkp>7BY_eI-I3>7P}d}E=}B=hAk$u(9g%l zM+=;m&=k(pC}HIjw^E}Y)3!aAxK%wkzqjS+P-ug3A6L8PmPK*L(k)vpAM>3hZNWT| zC5ew#T5qD^ZCkt;u9@`pz7wS1gs$+z>|KNSunMGQ*bC;iem6@QIb>O7X-Wu^C05wH z;O!$%xijQkLs8ahPw?pI_DbQ_Cm2x?$H0|>5DGax7P=l{y{6to$75wlN5pLY0s_16dXxxJQ2(hh(Fzch-Q}s?K0UZ=h?LNZHvRA*s6l*@Z8{wM^ln-@>9xXu7Ch= zf2TY|RuL1}g!xhILTN4OuIb-}_==#?%7!XUrP(FeAn@NgjhIs`K->&NS`&-&3l*ng z;EIt9n+`M#-q;`PeR(`pUE9AQQwhfqqNoljlw>MojB|7jilUM!QzcYLlp$27gQPhs zl2VBxQj}Tdd7d(N%%ltnzqR%`qHcHh^SsaVe%{|7$LF)BwfDNNYkk+c#=SARX*dNR z`#C&ro8>#xjZ@mj#F)aSChZl+^8CLtx;;}*`qZ!BWG%09ai$Xc-I~-TL8BRve@T9A z@0mT{M~b7D_|%quV014UPmOh+%v+(Mjy)t-pzmB==XGA7!62 zJK$ym)x9+)zCRj@yEkIy|8zFcpsu9ksxiW=pGaOx5(<#DxFa*&mHssDN9G#8 z-QOj|6#^fQ@-}*J^bF!Uv@Pm+bYS02hgFiad)D5JD-3U`v$p?c6|Zf0%IIOBy@1M4 z-v`4zZY8&m%lxp2uu3@@$X4B~a|Zj-RyKXiQ^LjC{0UFZI-#3uACB&Sncja&K{H6c zbNc)Nna*7wWlG=HeYvY28=>iCq%~-v)7OGZ3UCf>^-CWACLw@HKlVMw!p+7~wtG!r z8eh&=`QuL*;MX%)xU*&$vBH@X9I005 zBWb1=KNn4f9+Q^S*ccRD`#mP}pr9y2L*CGnzNUi@GB>3)iWJCmYOf!0+3@Am`X@kJ zmjvlLH=Sy9h*~l5M8V0EMnc|G){5q}xBkI$+laPZE$ggMubF&r=Q&HSu8$x1d|qsg z*b1fdo%Am6+v+Z%_vPyuo!8s=ebOAWUzzUTNBTbF`f-C<@pBsw zHR1k}oOtPVX6*$LZL=0PYq~Gch3@feF}?QIwS9vK<2`Xseb1NIwWXuZ4(ky0oSQxJ zRce?wM@Zc}Zt1#`-Fn+;zeu5RZ2=BF&Za7UGau#4i4O5MVvq1#h`Mh`&hd!$Vz^@Ogxg-sD1DKr&EGW^t}-l8zv4N{XC(d zEbQ(yblh%x6m!+iK>^Ivps1X;osw2ZNx~8I=4>7VB`r%Id7n-AI{pVTc)^^k+O;|R zl~-sQ-?WY`d7iv4fWNeQzrUrx)GNo1D``xO(`m$Ar)*`6!$h2tJ( zXq#tFV%uX09<64SH>&rJbn^!%_3d;r zCRrI?+f9@!IF|(4Hjcf#d#ve4yNGE0`KMyKMJBN!o+Xjma)KhgX;&G~NE58@#mc>5 zBrsSEI4IVnFWD?{;tEDNx7u;oL@mg;K1N0EmZ!)yam(>gp^^f3o9x;0r=_bImp+U5 zDpX!80Nf(+HEAG#FD|Pj`VZUcZp+;?z z%j+&jZgB>ob4!EI-wk9qDK2_7WNoW8{U^4RN%Z7Rbvxmiy#3!zgrCHjE^(qCyV+dJ zcmIf#*$wpb!t)nbD`0zw&03*0SBFT?M87dIzJ9dZ_Q$qlPO%}H=qYYX{uM4_D{%$Y zw>7Z7%GQH4tS8UX2-m&5e`GkEdc{>zWPx)V*j!S@8?@{Uy_^9E1%u?P-<>Rp(>`V&_1u@w;fdkHqeFx-oBmS_k#EauU+@Or6Nr8D zHfR4+9hRbFYh|r2z1>h%o4sOR(OH$qZrX;1$1j|8?=iWr$vt~8gM}}ezs4}nn{7h^ zWAEF~-=a!T7~ZeeXWclmKG_&PU$OPb`zN|i>_w{zbtE$C&#A6n87S3x>uKE8v&NC6 zs5hsdbJ}m{-LqWDwiEjz@c=WUQ+)iSYNkWlMb*(O(T{DUbYIb5xYd54GcohQWWr9l z?s!v`L?&tVJU{I&qYtIk+$UFLo~bR^ewVKJ$Qg@pUZ=~&cCO_l&(IHd%U_>%yLtnF_Pq!r47M(EPz;H(IaL_WP*nFB3mors6ZPi0}^$92ir1;7T>+LH!hFO+s8v=`1d`#=x1^HgvGt_u9MpXDneX}6xC=-WtDDLqC(FN zXf1tPs%Uu{{8OPO#q~oi4t}(Zp+e3&8+vltcHd9lTD;ba_+SfNAv$&!^K$*Rlxw2O zGPR_on^6-jXTK!Q#^}9O5nW2p`m9C$Yq-VoA&1Isuh#SOZJHEIty}u0rF*56+V1ow zh5OO#3Z^TplLvinOav7%5Aqz~-WN=}G~$YDXdBz5q2zBvY?lQKQwq|DCl&INi+fgY zO6S(OoLqO2q3ib8=9_L`#`akb5(I}726igS9-C>?{hC1E~Ftz!O;dp>&DhMP5M zVkGk7aIfvW8#L1rWbW;@*_3c#Qbs|HCfv>P)Zlmm*~G27I)F>3PL%d6_7$f9@}4qq2dI;(h@`;Eobinzgtn{)^2 z!Jme%SbpiDL=(@`ZK%gw3QEEL7uoHPzkafD)?6_7gjk6jXV~&Kv6sHNm0LH{KYVw} z#aJNHcI?&Di=45uUssmzm=1D|XTAHZ$#=yk$3uL>+s>-5sfBZyvTLoy|MdR`Bh>ZO6f@&+gk96DRH8Q%_JQgFPrK*w;7L=-hCPm z{%PjLq3|n8PfY?CTJ}wzU*mq?@mhtf@79Tkn~tuddowtq>(TIIjL#>nd|cGpw( zu<4^|?x2U7r_}c)j2RhC4ot-C+qvm{vH!#Fb_aLy;q9NEqQ2M|YbNiUXl`#-#hS9A zMCI*opI*y5zLC}LlxBPpE4$uXqbC6xlC~#j-%lUbDn*r)zl#+due&$Eb^CFH(9Opu zrN7FrIR2iWcJ_lCzns)nz0{f67PZYc+!N^D5EQ%bMrpF`o>bv(GLp9a`fg7x!`gKI zkeWByN2D^fV(56LvqBDU(qdjK@bHz9`Qd{CtXh*oT^c6#`*s)lJ7^8xlI72zEY+#M zj%DpJyml@`dyVw9$X4;bjO7{M*ZY0vAKjc;S9-~L&rMpt?y7dJC(mrpF>d;fdSJH0 zYh!&{(es(Nh;e*%)KcQ0(!xina z%L$lmRZI3fi^~o?v+j(jj^)eeUQ)-#j(7RrzWGI5iQq#= z?^;s(i2ujcD|_$0h-`{-Ii04D|DI5ydL%M5)!aNzAoG&jet8bh5GGvY1^zU-dhZ^c ztPGCQD(;$l{xSwTwh!i&@f|#<9ag=&m`P|k^I=g9V)Em%^nAw+GMQH|$Y_OyrN_chk&X*r*em!a~DnWSq!>$a4> zwV;QNGwr^l2 z!&QC_G+Z$@nu(Y)6ZN#Zp5PB}!xGs9GRF{?)7Sl#A#qnXG^q51 zWUdZPy?U;P*FMFyyDcWpte(ZC@$-F1*QjGLYtyC1{&y>1t zTb_82j!iy%>biXPC*tEj z<(*04m7tATm3PN^W%2ZF^Sy70yc$jy6a>9LMFgvIzO@aVrMc~rCoH;d3BAdVg25)I zT}toWHB?MAsq}p;aewYn*Dl&mE-bNPE9+d1(!+{b$^ zT;aTLQeB*1ovhQ(KkcWXTm$}_satM|Q-Dz&w>eEH@mPvm^qm*^j0#hzAeuY22?d(P zvfQLn%>=#8=XnMn_B~d?=*yf>#-5h2R+g5uJQ(rv!)+byx-uHULwFvg&KUO{Z1q0X zUK7nCBK*0U;@fV;CiiU(x9zO>azA561?ANnUhU~RCa@HDg5Gp|)@ZP29r)v;N)O|F z*$pL+nKeJr>fP&^nVHGVxzScj{aBy7-r?BsJF?{t5(%{?yRMdU^NsbYK8~_T5x6W& z{FD_Kb+;Xj?^s^;kIm~eXSVS$3ap##-g<+0U~Y_X=&0R^VcGy_g5v;8+$Tvb|Cb$5$(YRsvj zkFSP>!)ZNKFcF8Z#Y^|zyn=Q=@u8Q{Wwb@6a*JiyaYLm~T`rN=j?8R4s&UQXb5Nd5 z8_m^;>}$do^_EWup)Gn_ukLYynhpuHr!HNIdqlg8ac*CJ<@DT?J+ z{ID5eusM=wYQq*-iHl-7(n1T5a^)7!u5UJ z#p_1Ts#U((+Ri@JwEpeqj`eN4@8-11G1I_(?CS&ZcUeRCKH19Ol#`%SSYA1JPn)#m z!uHVQfeJ18HSR5YNr5Oc)|YSW8R9$rLnX?lVp}8$`sIOlN9p~~5Z7GLN|s+~2L2xD z@eOi8grGOCswA{~4h{G09{a@el-MJ2M1M&Yj!q#o_KA((P~zh*n$H|>mtT37(7R7C z_ObJzCsS1XJ7F!{u69(x@~`FExJ?d(>1*$=NEyWL>}zn$qT6(!Sj*q{(Ys(><)h|z zKHS^Xy|?-EfX~M8qpA^4bk|REH=EmC&{OlyeA!gGUSo1C$1=5953Ltig`%woMr2Pd zwI{wFGLJNP^~i9FyXXiyvEnKCB{Yn#%Z2QFra`N<<@df;I+7%>7+74pd5US4rF4a< z%;B4LQR|MqGIN$ zAnPtBY<#&&sBeE#*m(9Afxt+evaFPqY`g0dO%j$Xrni{yaEmeI-5Sc$Tf2F^0aIHv zlOFb^+4rkHN9EfHI-1owN@H)hB0LCh?OA#rUh-4XdEIfSnm0H%msfK%J|=kGIu1J@ zX6bRoQ}#lSNniH3N`@spbAFPVb7P=`$zY0O#0Lxh*F|`~@%2c-j%Na&EqeD2g;Q|pi17a`j4 z@!RH(851QB<)p-~D%n>s_Vo?1T8-QF;uY^Ceb)hO*4K9{OoL-$EXGuKs}00Ctt}k( zyQuh1LiKWtcc$XX5P24@6DrldtL?hFf|SyA?#GNVSa4b@tUPv2C5ryW`~J;tRqZ>K zZ^x2cw`E5iVSD*Ht#6ZJBEeqaB&W9RNN1{@`a_ulDJfnVH~YHPUYK@~#N-d(f)vv9 z-b@p+Q_d%ZUuE0)RH0N!W39@&)9z0h9z3kE^lkZZm(QYC>Zq3)gf6=#pU)%m!M&7#QnVjFY`X9&qpaZb5sXt~B;$EHA*;Ci zC1dYJCPyz!HZkMYCTgq^y~>*C^O_|^TrV#=hNadaBbwDe{@(I6k82DIS=`40k3ZsP z(FcDs=Bkkx7rPJFSTK>UdP2LUP~105m;By$)AvnkItQOW zFXWJE_G^$5l{~ojZl>U1&&m^JA*T&Dn+NPDl_lCXwN%&ACo|TSL~mZnH#aluWH|aGQ&e{f{#`it08u z_^xRV87k*3`x4QQx_U_cxNm9TT^hcu#I78HwOohd&9{#C?l@NQ%)=?-#&+kr`|E2T zc;CP260PriWoucN!TTiXRK-i;apR{~m-Qd!DB^4g*8aegW$baJU8dT<;eFJJ^}TGj zXN3Yj8+`Vs5B4h^d12|HkUz$)c&xCrE0#P=6=!ZcM=w({g?O7-p`V|?>_WF_N`iJW9i48eCfBI#%{EsH>w+> z>zp|dLbO~{i;sO3QMOAko8Bcs`+<7dRr=8L>%x~374@@r>YH_Oq~E?Is>C_S_)e}U zU00g5s6v@%OUZYko~@%FMy(QFN|VG+&0Zc7@MDl%@*;F~`q zM15XcY3y_Nr4Gj+dxU7mg+pWX@)MU%zu1aN)s`}J_1sPf6&E)iwD&mhjbs~95w@Ja zvp%=u!_d7=tX2Le2Df4oy(4q-aehl2LTrkIRHWZ!aQIy5prv8*I{Je%CN*$9b3d!` zD$^Rt0@n{}YQ+`XM?**^r8nFvww;h$zV7&qjBL)b9q(5@O_S@p=!^L-^ZLrJt8^!Z zbIkJJzBto09DKv)l2~E1!;D|Z_W^7DP!H?C0P%`tyX~i+kqU?Ul53e7UfsMMmoOk& zb;I)B=~*$47m+F-kLs~55e*_5mTC64>C+I8Kb8({x7QxF*?L{*^+&qL2K9z6EMj_l z9>>Uj=xx-U-oSY&>F`vL{uS0@(#N6Hqj`ym_w}Buy*3lF{I*v7=o_u{?lVV(+&d)B zJaRS{H>5AzQ&CvCEHk0n+RTkL_qm>iMQ`8+IolR3?)IH)B$b?2KN~mtXqR`}E3_xv zb%ho2_?WuxO*f{iO@3nL2QTGkmIl{^U)+6HacjftnZb)nJhyMSnY}yyYL9kv$eLFf zXxZ-cp_XfM3Y`v5Oto$Y3do^&RnHXito+gwGa1!)cW~&iMa3z5+SA<$n$bm!gS|#f z;+*!uH-~vXH=Tcz&;CP_GnA;G`=HKx*K?j=_LS%FEv$PQdQ?ugTVXIx%x$DIG`?$PEJ3PkSyU zS&nYM!Tix(TxPQgz9cLq)?G5uJ=i;~EY)I7apmYOyt5^X4D-SGhE?0`qk>-L@UF{e z?E9!YGovH;A~^Zto0Z-zY$11xn}iyS8ylK)?+$hsByY}0S*xLA;xmvsk^9lxYEt4U zgU{}J{_dlVS<8>{t4VdA?eEC9`ra`~WMxy{ab(K$^^dBG9?VJl`+8Zc z^Ci8#=^1^t%1WND8-2}cU|++ivLVAId-*_@2ZuOm3%}C+Lfo<0d?PXCT+`PPA-Q~O z-WH_zpXAo;Upf@IVfE*`d_OixUBeAC_7t+UJuBfp!%;TDACx4c^6d*5a1U8G8l(j2XtdmE_AJblG*kWJI}qvU6UnTe@itWb)Ov^2FN3T3lA7YZ+lnPT zmppwGyCm^WeEB2#M-Nutmr&c|qIyQ|a>b>NWa&{k+8iu@i(Pk-qrmoC;+!t2yD#zL zzWYz@k#Bp|6QHC}V#HY!iyyCWdaPWPa?CGH<>7%y-ydr<53O@HRn$J5)f)6^%s4_v zY2&wAnN1bBq0bML>3{Xtek}WWUjS3_1~ZSt`%-Q7J9c=d#2Yw;Ty0`Muuk9lS@xH@ zq?P=NbT@ss7;9&}W0)9@TBhpFK9O#EkP&?1Whzj#w#zANin#1TxsGJZr+x9Z!+5zD zw+LLVoAf!aEDiBJ-4w2(fAm6X`U`Fv(&Jr>m6~q|Dp8G5g~h9O(?5LmHt9MmuCd9@HaQ6XPPf<8KU-Qm8l(}@4m}b+rZaH@2@m7!7Y8N_f6Brw?#I4zFr@q=JJ#qCzB*k47h|M)_B^KHHqx--VwlNs`K#T+orNEnRWCbv5GOO z54MlB%G_u_xWkL>mRBcAf>k4CbijiNIIEDzgoHObzx zo2oLf+U|aD%ZB`Ik4C)1mlaOzTF>{RFUQT>Mn3W7P#NuNGr1=CbL>W|!z;t@D-VA^Zf zC8uI;-=U*~V1;V|7Eva<+lraboSa(O;jwhx0tV!TdGv7|SF7Hy*m`*y|7snEL z>cW05(bg?Ajaui7x^5p5J9ztEz|@)_%Xav^TtT}ef6LC7N1_v68A#RD`VV7QG?Ugl z7hWtKdOHpD((ITW{oR-AeaE>N00r$Hmf3$yZpW zu33oLop{pS;~%?b$C6%|JewNq!RHs%)wKC`bSi3Zyt`(9GW%DhPsT+(`>okt^VqNk z&8(Vny0>%Rbl!9B3-m1vLEl?F&YIBo*bN;Hy1F{S;5Drks_wO%W|jLpR-0*ej_}Fd zSF=0Wgvz30;sjm6AA2*Iak*lgK71_VmJH5&3H|#I?K|EE=VyYy=oQV464^E*jW4&^ z`mrEYBu&l)`=rrEVqZQ{=7pAk6kgg>)v1QnQtJWj_EQ}rRncs%=&YC0)i*1Pd9&!* zR<1cH?=fR0d=C8G;tFlvsU2R5{5g7wW_t=BuD*42Oqyk_gKE`<*PqNYnyqEpRJ8g_ zy>@4Gy6Sp1_$Ja-)Kn zHJmPxbou;j_L!DSpY*JKqoQN2^WBCY7Ve{}&O|zq#E_T-femK2BE~1CguPgNKY{=G zb(@BH*IxT8r^GgIE_ui+@6>c%%|}(~E!P>jn>O^Yqmw{7Frv-$A#%-!GShwYoSoevTsq9UC^@kDji)&8_+?<4LraqzG`55Q7oLHY9scURpqN!zb?ZvFNUJ!!odMj3YNbD^pAyHmGa zF2DFa_XFmTqZCXD<)i`GOawkZt2C60n+T!ixT}dG^}_rttp0h%=Z$_&$EZpYF%$fM9cq3 zIxO~v85dppK~LUQ6GJ|%$o{BfW{>M)o>r_aOXSc$U_E2t zEv9^DnuSYn+2JpD9N%=jj`Ogs+vTR3+_b8TyCj4rjn&0onq6A#+c7ESJd+bur+I95 zX3I)D9h>B*sl3p0q-Im$Sz)msjbm}>Jfn3RC+jvF^bJ>Bc^g{gwO8jZe|N@{68ETq zSJ#Qc3O1}ZA=@Xed{7VF`JR^cmZ0;;zOeU9DAvOPnGKj zkZ=ODL3)CxqnSw3E}cQ^+cB3vT5V+7sQ~`|`ZDK`ZtT@Bk8cac+ws}mba)#S9Q`Wu z#jwJ?^L;|&rE$Ugn3t{bjpVsQzznz0F-AwRR9l^$yvye8=y)f2nWTcxde0CagLiD$ z^R(Y|q!n|uN-SSg)|=tnVXNsLL^Q zQ*7sx9`>Vr>t`FC3b4fw(9GBvLCx-mBF;v$UHu0xg?}}E;dyS$8wrOc<+u52#_cBw zcO~@ho$ZcG{pKO4e}F*v-mhfTm3+3To8GKE))Q+ZtjVl$q$T|rF!C{Sp^DNOkvz0Z zw+)oC6SG)H;9jk9$cW)Qub9lJKbWKpFVTD}5a|sdqjic(^u8H0kxiNRz3hGsl%;0e> z>=7vM8y6UMC0&&<@(?RfjH*&OBzfVq=v5hg2mkA7S44A8lrQTLf2^NW$gi@EV|p^+ z^vsqUhxZ#?i$@*ZOXt8D5O?#LLW9=|bC%5RM(tu+e*L89De`WouS>L^=}F36wbI>X z_uU%SxCi%c=nrHKN13g`~YM0e$XatsE}S^G)TIC(}ObaXcS-bwywLUiQltpV1rk6a#-LA}J~N(s7SF=hRBH z-$5j=Z zT?yRlZaj+W)?cQ-EL@1@X2G}ObBBHeu$cHC)eP7qsr%ScN$A!{YR|izhq8XYqM5oH zS}%6l5A+6fd@U-{o|@=c9p54NNJZ~`h;(Bl$EydmM|0&88{{vyKkdc_d62@^cjT2! z2hJ2Z#alg6Z0)?Z=62-M7`B>i?9VwRCgP;eoEvZbe04y7wyyPw*mq-&o4wBc-G7+czt8>a;V=Q9r1UEQYh&R7Uh+MT{ zo4{j^m~7@V$FH0DR`Z^Ws$x(xZRr+KZm`h5iWTTzew}Metp8WJwi|0kS~smc74N10 z#DV|5Ly$Ye4LMq_O{o0LNFMNS5#>@o-4Y+9N$|-TElAFL_ci#?m(w+@e#dX?2{s0Z z$#kb%uF|%M$SX0O$P8-Nar~Zoc2v#(j?*sohu3Tm-i{tptn_f#vI@HPUgfAnhIZga ze$gfTX1C;uY%ln&m|A6a&u%1!?)o!Di>j4XmQGrB4? z$Kw`pL|Nd)CDz9KY==J7zwudGwBlnr-<~?G;1U1e*~G88M`cTn*-SrjcbDFEL+m4O z%jaVa{`$(3x|OtB*$ht1k}zNE)UE1q->;N!+EVEo;&j_@uj`B`TV0xgQR{(VTx}&5mW3`{|{WF2{e2^x-b?4P2|S z{k7WjJ1R9gQi8GQ4V+P0E6w%%m@?Jf~& zuUwxff4TWp$@$p@KQs$|Xu&zWMNesi=Tl&tfhXW+8f;6zJ^0+8Hac+sPaCBFrw!7c z`#~5}c2Ex3zFXw*kIaI%qki{!pS<|qojUgnya&(qQfUUC<&_r&|IH7Ea2CysAvmKs zFoaeNp%g=?Q8QHAiz71Pw+!Hjy3BambR2OX2OgD=BbxGI0%Z#TYaAgRkE+0<8gYaI ztZy|9PMi@(D8~utGUEimvxFv`fGHo=x0wdVuAUCI0vv$}9LjM7J!U++dJ~Rd#DOE2 z@!^OPXdKZBNDycRBp^arh(MOntT}Q2UOp%slnls1kxUAatN;p5kI2IXp5P+O!v*E3 zron6(15ppa2FI=m1k)@4TREOxvk4prafCcBna6@)?(m1V&J zL&(64i((1s7@((wa?H35GnO_3Twr`FXt0C|(7=JE&7?Ho2|0N7xQ|#uC1~fv(q@5y zlOL?Yj*H?5Iat~pN)v`qj^B`p^{u19A~f@n=OAek7FB^E^kRL>Xne`B zh#Jfo{%SmJ78ccySQWBlKZU^%s>#yzk}1u}L~aK{_L1AOD6--R`9M}+dto;qYd^dV zrVvY;4Q&#t8VZ0&U5ug408JPakhl=*TThX=;3pp-aU~*g`2vZvfy5k$#6?)t05Tei z#J@@g@cd1(pAsV?0eOB(49rPf?N5mZ$P(uxpq61#Wmw;Gvc%Ph#AS%YRnS0@F)sXx z0Fbx}k+@=k#5q7>K1AYbEQ$n)QD&cZ4l^=qAn|XK{gfCH3CQzP;(8$Qr=Jp&$P!mz zfqx^^VNvB+xQgJ)XhtNiLnLm-`ZiJ|E+R_|5C9TaBNA6GkT@NY77*%zgm8pzytpWy z(1#ar0G28PFQ5e+RWV*b7r3DY=oY(yIqn0;46qgB2Q1?)YcwYWA8 zHL$XeZ2@s`iUEo=-ijq;*kITlz&M71BEkmROdMgt2FvbHgTO|pL>hq-B8{KOjX)E- zu>_!opjQoMybICN3G%&KjfGW1|knXsV@QkiS)!=kcbV;!Xt%npt{PD>c}ryaneaxp|kEUFgB3ycI< z3J?cmEo5SVr9_4?XCQzqz(4?dhJi5u(!a0SgHN9e@Pe zu(Wgfi)ec#Fv?_t<$&P61q9QfX#C|^T0|qTP41$9E45l^U@^Fw&TC&UlIkcC}68_TYdfoIqF0=6m4Y$3aU zB#2-@e1#!sGuyBa<>LsaIlvKs?ZpRJ5Ao%~!-X-2sPe^^~#vZ1vHxifr}Du|_N{a?uHvMs{Z3*heg?7mEV+x(ck|f$YrD zG4QK&G&D9ic7?HwQMfNg8ZXT?<|B>ONMrC^V>!~;j5OW`jToWG36j*%0PJK zLyz<8nC75@lKzDkZ2Su^*hm=@A2Ozed@>O}Aov1&p!h$>v{3w?d=`rTlMjX5f-yDB zkBPFZkTDe?yegny`gKfmynY$e9Is!-G{4A0K?F(8gETRmZ{1}Zj)9Lk_S zD}f3QT*DV&wc&Lw76r`vPsJWW@K^vW2}k(8a781U3Fr-D2$`^d9q?%gX!*!0>_N^e z5CF%s+f&ARp|2`HS{sokijnh9IPHjYt3a*?kbC9G`53&{YYRIzAkB41^DJ^cH@&bs z(lCPTC!ncn?;8XDYfC!pziHvb!UhMju_23DjkJ(ebxt$jGvtL%9)1o&Asw0zgfK!C zVU~~FA=6++TFQ}@W-?Wzr5Ib^8{nwyx!PO%D=kq&WKw{pNk~2MLNf1SX^zJv8N;(D1N#EuLl*y;a!;AS}5*-7u zKmT!{jKr^PIHK%SAE!)xOBh-al_y{mk_N)?1qp0HBm`0>BzFLrC3d`XKCuLz z9ZRT!i6saCvE!dH83V$2+6=_?BPk=ubD;8rybdshne0!}AOR)ki*s=xg$21CH>58=Z5l=|Dt>V|2aOt^Djn#fr<27 z&Ick=Y)1P$|4Nu5qmXotk`;oY#2kN+%OOzyR(6y)b=`>~)2tZ}gXfm?&gH^?st zHCSJey8R((L-Gx%10Vy-2RR$K<{)Qhio?gIM2O(9t#D5fIo7fM5+0G2SQ z!2p1ej1g9Y;R1v8CdvaK=|`jn(!p{YwD!e#0!V>sKpu%FfZVDQS#x+0G{8020@mCl zmVXG=7C`RP3O3*!)`PuIc{AL`~#Z{EZ3Ycd{`7%Z%Eb+%-65NEadxBn1y_Q3e!X3 zH;+sMB1{GprVzOa@<8Hv9}=!Dh}wYx24)Nq21MMzS?PAcsRcn$FQwkJ1*~6?AT`bY zG-}23MvWY2BSsCZV#KI{O3fdP+JXf(AOC`KB&y(N@cTavdWko^kyZNVm={C5i9iW+ z*}q1;;4XqGy!%VsYYS(l7u<&HjvRX}jC>IVL7E3_kw<}{;o<6>B?rbM*_rv{dC2XF z?99S(;H9g{<_L!WVDLa~^2b4D2(#V7@eE|SX2E+v`SajCayAFGr+06c(75(|Z z-&xV04?rsl7cmOB9}plzHx1uymErl@kg_p&9l+m<=Lc^~c7lT4&JyI6;!Y5@|^r^Mg3eQ(>?m+5Jnwn_x_!AKDGPn8gH|x3X_= z0-M{gCi?~&uwgjBPG1=8jHSTNTovr>^uf-_9PHejfG7+BZL_mBYU~bquowmlVPNY7 z+W^?cz&2~6$?lkL!|s$%lgt5_+U#U^oB(Yz>0swN3GXh2yFDCme?S=SEv4Z8s4Co_ z)W-zwF$cXGOX02-@aijvXP@9%13dc-&pP1Q5IpOLXXEhf8{ExcX7gMKqwHQNupD~3aZ0gD-;!HC67&|t`7R%kG0F?+NV z*uO@5gOPxfR5a9kOyDDQG&mwykCLa35gc^@Gca{v2c`~8$KOoYuTw;1IGOr$ey;q7 z^8@f>===0{6QuTYVpM_j0JZsvSuiK$IhvaoFd3>4-+!14cD~E-YXD?AApQ>l&cEaT z-$Z?0k8&nX&#h=Ip#v`JoC&b1!Om?s$8Ccu4cK8gz)oKn?2M(r&RiAj?DWCT$sFw5 zoN}fe8LC=lXLAOIDssAp8grV5IzgLcIBYxT$de3?*%@aX^0M2H%jG9CgOP&U(qMBJ z2KUuf!R~-L*gf=yx6U~}3x~(OuK78Vt|K|sGo&2JnKDdZNIGcF&cHX0WM}MboXl2J zZp_G56KQP8R$JBBovp^xI0!_j0U~_L&NwvGnw@ccs4_dldZ-Crc0ph-=FE7Zz_wo) z>?~Bl=>J1lv4QJ;U6Aw8B4>aDg6q7|_@Ci>y?Ft?(~csV@cNu1g9-U4;tY-pFy?^( zPCIS{+ewJA4#ap5#Q2#vymihoJ{%sKT_x;IId&hQ);FrNS zFpGp-IPrBj0Wxs`KwJPJj^}U0`ewsa29E;yLK*m|qX3VZ#QQe_PX|bAl2cSo3Tf~a z%VMOF^@H2cisJ``iEJD{5VQbG7y!gEzUiRWk%vQl!+`oT9=!fWeMgAz1jKhD9v08O zvj8qkB%y(C*}(AeDDc$}@X`OZ!2<~wiBBl`b3@XCmy@VpJ`V8dN9;elBO`_|3hccu zd@Cc2BRYtI0hKX3V38_r zB_>b_O=5?BnW&`-GIR_m2kQfB#^7C#DkktOT9s4|s=^rJ67ri_OrQ)n0X_^z)C6y5 zVeePSeGMSTE!o%B|U~;DuLI!=I{lt9VXBO4SHw- z;fOf28{{TT@}2&NM^s7a;E}%(2=Xc=2?2>f@&u+1E+;(97QW#KBS<<9(tQ-jK*5Xk z1)oHLa8eUgR7Wf(vXpJA*8+Iy44}j_L5z$nFA6gjP#1USj#gJnvS_(P(p%uUpE+}&n6VO2$c305dsqGbzsSTVA z0Lo5HfHeqhk+9ko9GyDBTUHHK9Pt#;W)FQFQNtLrDkQ5kRMS+r;)rCXa3vwk!Fn-= z>jh}GGdweanr#I&I~Cmu7x;x9axaJ(>;;cd_k|Z%kW@4ccz~o&=@m}t1&=sl074Q+ zypART(r__B7C2%UdKfuALQf#=6xPcqL?D)sH9SHhkOL&bj1YfKxdu=p?T^rWNION6 zTv%@dogpU(sVYfk5||nmfT&{?o+TU4QUrjkhUT>iqT`Kp26~1N`vW@RfFQ{Zspk2i zTPeNZ@sH@F!(P%9nIH*7)gmSkk;vlgaB%_Bc*0kBp7nVDbQc@Ef4;LC)WVNozSBX# z%mg-Vpb1{u!f*#P!Alz~J}+(ko*8-?n%@F5$|WRFf>6QuWQPMmd#nKx^Uop%5E>v1 zTnnnuWq@U*i8dy$#B;riErh=@aH(i>z<`7Wb-lka`(%45Mj;B<;{2KnKzhR|wnK1z zO}P$3J0bW!Lc1aD6fPazWG4_}$3ON;hrPT&FLHo^D9XPX zWxyH+sMZ4$3Cr~0Qx;tgB+Mf2<0CP^ua&?8^#*L9)(L~5k%Gr4V8cdY3_1@v2B6Ep z5#j=kFnm}QKAZ&}{_V3YWF;fdkO}`gx<>G99BVh45=76o(hW(QtGNa(48e9Y*(+Vz{ZP**HBX$X#gdx@t z=w=z%>cQP?umN5DlnIVi;8+NbO-PS)zz*p_vFAt+pbkb*C(M9BUcgrvd6vRgihM!l z+d4;O7&-YD_F@rT1mpm&4}`dt&;u^+q8D?cWK@8&CL;p;ra3Ys z;5VJfCy(YFdJM&(BhH#0_&dZ|?}EGxgux1d&ibGf;+)C&%sFQYNMy&0_~!IE9%aZ0 zC>6od5ZOQ1&jr!KqyVs(DXzEU6^2NUCjjRQUGAI%24NJrVW<&L7zK|~+%OCp@q{0U zW!HukN{arF-B02dvSxV$=I{O;Qo4zrkK`xR4P%{sW^s zunTz99O;6@ABeOdGG5#F_B<&x*Lx82n`GC$P{F^&YSMn=ycHh zU$oxZBV@$F5pI;grGk=v{Uszn3&IZ`&|?v6O@^EdJsch+2!{vLM>st4H3(vn_DASs zq#dq~PEf{Mm~;HCk%rvgz=z5C3MD`i0m4#Zi+{mP@~kL=TG6;_HFEq9jO1d*xfKiw zm`$=%fH5JsAX~|BFzbkwq^R>hTS>T1kRJNT30cy`jWZ4M1cfaJ`GU+geU1plRQxkr z^SK^Q$jMK(3yd@0|G+qdFb&psxc;WT177vPS8D%Ji3idbSfs>*y!(aps}TAN%RE^2 zcO9UHqycI%8stKc2BpxW@z+8Rq|g6*iHA%ae5G9Wmog89xH=#XK6C(K6AYRDP=SQu z8S<6&@AaSm1~>r!Mf~IZ7eknTsnDaA2P-AYI^Y02(Z5va5k(3;`&54`^rVxW2{U*t zh9PpJq1|PMk*W7@m7dRt>o5>rq|yUO%vE|UfQcl5zfk;7m7X*IT|t!MuqYCZQT@(BN9tp^DBmtv0@cyR%zhf?er{vQ;37SRt- z-}yyF{*MbeziJT@E4Ct5dM;KR23H`C{I?1@;N?3xXZdp>#}urAf4z`n09NK-3pt3= z3zN9uasr{juSxU5Qbp?`g`9Iu^#nJsk73caNsyMB`LK6^kns2i%aDo$kt7Lz}GPrVAo&kH$lJE zZ-7<%8K$TIUcYHxbpC(VZ+^2{l+p?Yyps5P{{o}W3ly@yF2=uBa9ROMaC%`_1gH0x z3Qk)+nay7-IN=LTGQ|)lAWwtBE&}{w6`bFCrGp1a=RhwQBG7jJw<|d77OUXI{yP<% zw#6zqU=;(o=M$yhtEYdXbWpEI!FA*ntqLw61$g{5)k=rJ{#L#@0DD0dMS7)@t4m$f z@(sBR_A@cj2k`t%OrSaY|Dt@eSbX}s|NYkrHH)}OxPE}Y{Qs;_lTI!o(}QKZpism0 z?-Xjn=L$8Z)#SBL))<&7_ami`zxop4pKm^XF*DE@@HUj2EGVgq)79ra^5=s*NZgg7c0^%hmGJJ`JzP{ z{l$tj*C}xSU6BTq{ovUCTA`r>gF{e@Zw*)fI4RP2d}4N(^@R->c9#zk}+~ zxaur&q*iEte_H@`2C>*M6afWXq^1K3L;q=oW}lJ4-nl;t5r*;YSH%)l-LS;l@SP@> zc!C*E+^?{c$fX;|y#lwblKp%E((VoGlskzAOXk{r<3R%^P?ZBmG@rl`?ZB)4@H)_h zCq%b`L(es^Ltfhbd;CtCVu`==I;8^`GwTE{;lL2>CNMDgsmls0dIIpdvs;fQkSW z0V)Di1gHp55uhSKMSzL`6#*&&R0OC9P!XUaKt+Ix02Ki$0#pR32v8BAB0xoeiU1V> zDgsmls0dIIpdvs;fQkSW0V)Di1gHp55uhSKMSzL`6#*&&R0OC9P!XUaKt+Ix02Ki$ z0#pR32v8BAB0xoeiU1V>Dgsmls0dIIpdvs;fQkSW0V)Di1gHp55uhSKMSzL`6#*&& zR0OC9P!XUaKt+Ix02Ki$0#pR32v8BAB0xoeiU1V>Dgsmls0dIIpdvs;fQkSW0V)Di z1gHp55uhSKMSzL`6#*&&R0OC9P!XUaKt+Ix02Ki$0#pR32v8BAB0xoeiU1V>Dgsml zs0dIIpdvs;fQkSW0V)Di1gHp55uhSKMSzL`6#*&&R0OC9P!XUaKt+Ix02Ki$0#pR3 z2v8BAB0xoeiU1XXKOlf1oJBKZ2+n8@451Z6D8&$JaD>dg25N?Cd)4--8DRpg&~+Fh zBaXNdN3>+d5sz|Uh)4Nw#FN6XE7*O+j(6Y)f@lmO3qzQ|5X!ORoj8IOni)$-2Mv0G zN@G~q*o7n5qdBmIOwhP1P{|WZsKktSVF}rQI+jq49q+~xaxp|L(61IV{ux7?1ui%+ zsB&^g~qxf*2y?LJKKzU{Ujw_!d$EogrCr zXGjfnMx;R~fqrw8n8}m?=XpxP3n_t)fKt~yCD3`E66i;!B!MOq0*vPgF(ZV!$b$p@ zAR#O*6bJO1Cj$CFB7gwwOa!3!h|>@=Lu%bfjnclKp7g$cq^PPFm7bN z0P^WL!UT?>ghr+cT$7~$Qv~saQ(W!LC=o?JPn7? z4KaY{wU}drTo8XS$AL@a=I{h7V|cC;m~Tj-97C3NzUO#1>_r%!>y!>3f}r$qlM(rK zk9j|f{4mkr2o>0j-mTb-j^&s&y?B;NJZ%%!x0(h+sKaFRZN+4?fp!vxr5;P0hxIK7 z&v#%m`oZ&EpuG>z(uJoT#QN5P_6%$W3ADrKd+;n7INAa{;R`ln5VU<>j)ksSFtv_h+5h$d(ZT-N$f8I9p!nK9rIj?t{(;D4U~Wc5Z`Fe{>9!pWxuu+D6tb|RNx z!oelfOUfl!cs6`szA2Yr-fV<{W`gwt+c%J+|MSJt8=^^Oc{E zQMQ)#v0XTOQg9rLQYq*`-dR+PQmT{FopY^d)QWaGe#+@;Qhy)n^Oc`Vc{zQE(`vg z&n4_&lEmQ;|F%%TFbSSK6`uT5x@Aa$<%WlnEH^x=w%qXj^oT`d)?^v76ISHb_Q;Q!SK|4gV}hc?;=zQo@@fPW@L`QrRzm4CQ6@vCO{g$ISI zX7ieZH<;Iy-C$ML%9>LJrx%LfV2+3;i8VYLJJAN2e)qli>c?pE zmo8YeVA+D_o+;4GwJ+7I$i8pZeWOQd#y>hEcgp06M(bl5+e*#Y+4=cP?FM`PGm8rf z7JkdVbl&$AQ>Q)lxE$-fDXckeFgpz^%+5E?p~Epdmn2!8GqQ7>H%ux{yU8+Ux9XK& zaemPoIJNu;hc8xt=~HGW*UfQ0mKdU0@DMJN9x6^m?=%0L;GEuOappB!obv-12x>79 zc+HMCN{FrF?F~3t9UE%-^mc$xo0|EwwGAi1xdQ!IK1MUnL8@Xo3)=hMBuGCo^ z+dd!_y4!`{TO7LteFcpn5?le*sX9yXQ`wf{xfqP6z%Ly$;g^VIvjm4lo85Qg`|poZ zY(<}|5Y~7MzcJ1-K4pB;IP=LTWaD95_`lQw@%vv6F=+Pa=n>pa!-V3o zH#u_@&LxI(8Wydri)pjE>z#{f=?PYt<{>`oUgf zhTr6znrPJTHYsb}uHi<<4rQ%T?;ltEv+Jk+<&$P(Mj7rWp@vDh&s~~gboiB}Mt#}1 z;(tx`=WDdajNQ1Of*K}eo!gyibd)KpKy6&{n>U=?^|9NSu><$js9{q2-DSjGxg*5? zUDZoxB>rh}`QN$i)rk`S^tk+Qn2`0h#D8R5{>qW=AMG1se?Y+DGj{GaX4Df9Y+t|itW+>PC%GOFFew8@$4<9vD6l}KJOl^B z{}^17xTClfTD-~VsCSnp0})ipN1%A*`%is?g}#Xkfo-GF;c>gK11zY}PC~{XKY71J zl01z|0elA`k-IDn;0TNLr25-?B(Zc{D!y2crI`{WvBq+#_+qU-vawdOrTQXrm0#F> zRFX@35xI_3HfBh2WnV|`{KKdFAC`Ec*XYkP@8Q3mm3YGC`}55I=;RTs-`sdiVu_BjKg)GlL7&7Dy60KVgu+!Pl56=1zJ%%$n@u*Iq&(~AFj@O z{x1#S{~uS&<9@;Z7+m?-FtILpNPNM*vPfLgT`gvI?-Mh+o5T^_$Hdg`(;nlJ;HP3& z;T&yxYf9AeTgmD&tOg8Iz2Y09P!O#d0nyRD-DAuQ?)4Zmf(MXSnB+NVG_|Td#u34z z;yLvrC3M}cwCWF8$8>sZ`ugo+m))ez_83!xLAtj?;jv}bS9^@f!A_&+ZRXB;k8MPKlUNZv<2ks})T$821U(*GYW*?sRPeBQ&7nMTg?i-%y0=5^u_f1^ zrsQrVo=begGfrRsDaxC)>6GbDW$uD!TxR`Vti`r1p6(f!QGbAvb8|i8M${jrCwDr} zxYYU}FNTl|3)G%*$@QJYWfP=0MU*NP66uN{bE`oSkQCj)3v{c)`ZErK$huLg~_&4FgwVNmK$akkeY1_VS?L& z?Kj4>14_tkpaZim&%rUK-4IA_4UOmNF{ZKz8yGOeCXY#Si+~ulN}E_!(3>Qy`h>*E z<2DA*F%#T4%qC2akXL&S&QN)P))}UQSfpO4m8~~Nn=iG}Idw8?L7=T5F2Gi1eU(^I zus@05SqG_m37L(cA7J{(PpxmEE!?f~R7e%{w2{qjiPty`b8>ODj@)*s%j~PUBZv!`KK+Nnt z3<(upF{Aecq*T4G$5P>DZ55$i&Mepvm{8J3Kkl0V^%JS1bgcnwo~dc4FqfOil@3u zsWDdw#f5p0u%SUb-+j#DcuQLZqY1^&<#DeEX&a7+Rp{z$sSQD~qPq*iHnfZG?tSJp zrFqmtE*&SXNS(C7W2C-tMe5W|;!3+0iKkphG$1k8i^LHm76*_BBJo@s676D_9gj6z zyj^{LQolpJmGp=({`!%{QDJeK=$VK`n`PBk3v0UaRImIIfWh-(ZDhq-D1J*j9r2SY zbCZrwTeER8sWOWM>Aarq;<{wMbbe!nbk19k%=vHNkpDIgE4GT~o@eVJs4Fr$r>9to zzt%Whfu;E+MaI7usemry3wEWj=1>t88zK}t$a>adv%$F_-(wr2a9J~Y%^63nnVm~h z%&Yqp7!L}DSTehzdtt7n_98%NNo~9X! z08+PknjTR!^N^F}X)-I?ki=slPtyWmt*daEHrLZMTTzST6m1@kGqw56-#!o0)I>O- zb+~1?Rk%f{H9X_SD270lLZb^Bt8|jtbRo0pLT1y2%%%&OO&2noj?AVbv+2lex)ob# zd0twkr{(&*X2h>;Si(Oi6l3w+J0LoaaV@@prnA|kk7+E$iy*yvWr?MD_6BiDaHl-B z(zA&0pqv@>@xYuBtmnab1V(KP&|+%vIE}nwaw2V-PLHZa6zQ5zdFs7kSeRk{dR{10^5RH|!+Wt08aO zj|vR|a;jsJL#;@?j+#eJ&7;9#ZWa!U4LCf9>d)ncADy!jal4*@osLLmk^3BxY$JC$ zA`Hac4q1r19x_qOq0poJ&*^`wyEJeDqNl>>Z)HTG+{(*${fu8wXEjHiGUG3 z0C_z6!W83&E})Vfzj^B!_Gs)uVbSP-Fwni|Q>rlr4U0Y#%*)Y7OZ}!aV32$Ah(RFm z@@PRwAgn+d_Es7$Agx8K7Q5__gGd|O#EM0GC?rJG*AnCug2S>UrcV{<$t~z34w_8? zDH>JNIPY=m7qw7kTdK$XNU#O5LZ_!}foU&7QUttNpi%|;7IY~G^`;A4)2P8v-YuS5 z)aZFyZ`zRxI*p<}xXV-ah$*NLtAfW+gG_gDiK$KEWgxm@%OZp=OHAdd9Kg7j1S>pc zGfYP%R(T{24*~g}gA1SlE0pUoj#*R=nkOl9cPf^6g5@NXr_5;TQ-IJRiiARJ0v>nr zqFo+DLN2JYB)AJ~Pk6@bO`kH4G2#coE^%|PBV;RMu+nod*>n)B%m`E6sf%hnPbZt& zQFlSGM%;`bNwS^C_(;&rkqieh2UAVWl3r>^b{xU`qMe?nk;&riq*(o=XZ#pd8>+{e z(sOVGvqMv9B=cu1+TnQ`nJn)Png<*fUDL2#fW=n8uG>M%G+qr2vZrnH9L$h;8<@8{ zb5XVD=?qgX3tb&zi|Iu@iypEq8KXi>n${UhD^nJRLeax)YR}V|vY9Db+07adn5xLm zP#ec{sm!`eZ|YQ2*w_U#pN3@)I2Um&RPT z1ov{@mud)7U3YEDSFTA;8a`}j;*bP-v>WrUH0Da7P$u-x=yz?*dF-x9X3fO$R?Q;& zoaGCj`GU{mBO}-6OI>WeY2fqwrAy*Ge}9r5`42XbTOIsh-RcM}H-#R$^CS4s1NqSU zlu*pUgt>UD0!I2E4Thno?EmOT@^}2=>eAHz<7+rq)xTi2SwSFb4>&Cv{L}5*n z%IetuTA~UYe-kbDm~KrnyPL*Z?n$|IeVO&1l-;NVT2{SR=&M+9S5%cy`C)Q-;t;BU z(yhNODf!%m2NsM-0a`@WtSUFFDlCqP*rlivir>=;G~sqWh1uLU2Aj&ou^;XKb5snl zqS>*>jQ0!HoGcQG|E#4AUHWQ^b3zezp|4+GD!@)wM=42(or5K=rbIA)gUqx(O>0T^ zuHT1E+9U2=em8jN&>hw_2lsHuLKs8UI8UWS#p`zEu zp*kN_^ChZSqIhaWsOWWZs7l3h+CoKCpk{GcR|t+L&*2GSk;Mt`aH_1zPIL$lj!-CC zuZ{Bdk`n7Z*O{>?$y)rJ+M~33E^lIu3Vf(t5Az{eAFtU>R58j)StdBO5)9 zb|m@;tKP#3aWb#bvpN?3|sGo`%YH|%-K6KrD)mrSr07P(`X#;H7dq@y8cb8+WB#Hw4;8 zY6VEDeeJ43ND~a;Y*a%m)xjhrXKO&H{7Wdhz4=QjSbjs;rVTLQf-KpnhKNyfRl3ny zdT}N_yoe5*p{eY5`P2#pVGe&b*gjh!UR2L|1<3!eUxC#Z4A)!;Ba3x_-d4)b$y$j`%JMgClq%6?-#9$k?QLb|`8nQMgdrRFs) zYSr51L;2=XwR33mraSQ_%=s+UD;7Mt`np14Nc=7i3f0=NxJL0TVXGqG(iG(xHnfBA z(kwc@U}!h0?9;Q_b8wVM@0*(g2JmKkE8u;Qd;q@pfN38r`he*ed*)FE3gLbZ7s#O! zCdpyxbTXB%j;f8SX@2~jP^n+Y#rY6(QPYv?6B9a?M$>ERImIL6Q0=p9Wd7ercbD-#6F98?&IuFQ&rQ-*Z>+y29o|0HPX;q zHLVf}2txMW2Nlpz(>$yhnSw0C5|JH{K(E0)m;M~hKMw@{mp<9pg7D2c>Jyv{?r zH0V7%c}$B2u^bO#Hv%3gUN4?UNQgsIpV(MX{h-)_m=MpQPKZ@=k0a+olGxI^TdeZm zm6nV>0WEld*Rtrec-~%0Pp6C@;&HK}g_f4WENDwpYZ9K~E@%_aFRB$AgSEVcE0vC= zc+xc+aUvef25_fdtRoJM2=~PEK@W0w;G%5|vfI9ghi3=r>cLQ=8+)q?>ZxEE3hu;( zSBXS9*Q_h>4dujn$~}*VW9RM7|GWGVKf$hnNzk(a=E#H^^z_@P7K+VT<~7HZs&!tt z6AYp{9cXB3#Tletz!ofeUW_{{@U%J^eIq?z-sKsm zRAB31vS}CX6kO`{7?q1Y;rjd(d@o9k34X#QfaD9^mGj%sUh5j9M>u`FmT7$n#gYO&T^qreg;8EKY$X3356 zw9>Q(AfN^&`{Hsxw~335#LV#s`ly0hBoLNaZhq{Q!RI3)t*@C@kAgA+*httkgD2(L z(_}#4iG7odf)TWzQ;&J1WCFs2$2|gF0X<5meH2Tv%L<}-j6|5*pYRt25rUu8Dh!_S znW`pix@2TBdKe2KVBXCJU{Hjb4^l}@L%8vb&oo_+eVYtWHVAicGDtO&K}d)&e*+sN z%wIDY=cQQ#Si>PC*1Bmvb`@-3kc1zEe{M)VWBRm%aYT{uB#V+AB}v3F^9U{7>@aFd zc7+ke9J0YgVY%{fst<(XfQ=TB*FX@*fs5#aN*K{uO{G z`d=wVOa9mYT-vXoR7!&5FoN0bPizOcByjhkO>J%!?zFn*ThLf)z?+4es=BDl=S3%NpD{B)K@ek zo4)qeZF6tcef8Kae@A!gk6V&hux;u3=O215W!bLfC957SOnZ6HOK+_F{)&+=?0<2?Lfay({mA0C zpZ&oPM$JFDV3Td>)1zmf&D;9S3vqO3uHT zYK^xTW{&&!@qsC~KR##Le@<0*&SVHKI&X`)=>9Kt!Ljbzp($c13Me8tIys0lRZA)O*PZKzpAR=eQR}|_*>7R zZQuB0e(4{}j=R6*%HQ#Sws(}@SFvK}hn3x*es5RN>*Goif3oeT>;LVcU!?qe*Uw7+ z=zod$e=#4HpS zNl&`5ZyEcSX&>{K^YbJ3FTJ|g=~6d(T&;K20#_|?)dE*7aMc1=EfBUq{QY~h%aJ}# zIuRcyov0Sp^x`?-T7>Lai*xenYj0uxU~yi%`Db@xWp3zmSk)1Vul+V2vYJ&NHjcEY z#(D8qhg<$o-q$H_t3r{1tw${ybB5bI#;sbr@eWkniszfEZu|)!(2-Mj3={6aHz^$x z)A5N^yi0by#qs?l>C=^ zmB`1pZL5(M9Ia;hTCdlvdbh!>tg|@E_-l}=cUvvW9l|FrsMu#tJ!Z-9TJYs#_ptrhqjb{8C|%|?uA#7){7FKR$E?b6;jcm%k|5yAje=vC;6QW4n4S&w zPqaw9#|I@=$9{aMSJ0opn|JxJS@W~77XfO>hjg$y+DsX3R!5yFW1nN93wvH}aZK|7 zfwx4;m@-_ZjO~tz-r~d}$24DY+D{x415(LDR1zNuwm6yucOXS@H(?{9%aXkP>qLBY z6CE8B+qhx}RUB@1)HzLRLnk3-F z6fRGKah;LjI-~G?=7`In&ZSy=3|p756}wGZp~D&t=e~%JOz14V4dqm8 zC*gT*zwUw^1-*paapd4r$g2Y=U1W9aG&y#d@#SM&z?Q?XziPA<9_(&SL3>_rRUWaV z?l7ZMa0hgP`$Rgv|4g23{>07xj~f}Zo06U1sw}6fA?HVzM4btGC3H!#T~PCDs4T3j zSoVy?f!*fx-RaS0hu`d|M~`=lz%7k$T&D;UIw8z;*5v(y`*6B>^%24=K5DHKRv)CK zB^jTJ2&$LeDV|^K!XI86U%7Y>5`O$0Zn>WN_(<2&dIOB^7!G)w9M2~?W~HDzD*2-Q zk=$}$SKyEnxCMV)B4NnUf&q~2zp4w9UrZx#}Am9}P zfDVQ)EWrEwD+^!5g7?b8@mLyPS?DYRQ%d}lo@r9z*Be5K-)#yd{^SZJ{>K|i{560C z-cdCO#S;R8Y8>7mwLcGu3iB~UTkgVh(=PjB4Gzy`;pP051}t4S3==;n*gs5sWDy=r zEIjEk8W+0gAxD)CulLp5f)!GK5HA51coXp!rN7hT9#i1G6&ZfK6g$TMiKuTiV~@gI zL;>J63oqE(=LH~c{z;HpnZL(GqW>u$>WV81)3lV=eiDVIwNcW~w+o7JZzf)eM9y)# zc^jn;@x5nDaBmJ?uSL#jx;KxDRZA5&;N}#(8;hH*OqI(?RG;0%R1I{qgKo~^auV4B ze768(bLr+WCd=g{vd?;%Y#!ZgXEj1{;?oB7vOVQP&XxAjtWQYLHr#wmTZ9H^9m)_F zlRIkgx1XB!F%42j@w{OQshQl-Oovl+XNb7D0}s|I`7qwLT+mT2X8OEhvXAdRfj4wJ z@Z_aqmze6qV`TeGZI(E~hYzN#EPUF4{2H+l_cMI^a1S4hN4+-W?809IYP2xNT-3mW zs~H_`Is{Ofo_@^K=7EzBDM@N(LIlAYRk1ccn<~tPKqGtsU}hL9)`9Q=v96;JRd*wA zk{X!@JjNLvE;>};seSD*yyfZ(;4N9OZ!D+-v^p%>d{jD#$KZG*N~#%G+MNuFOka(7 zexX}zS?H&S>O=8*sTZJI31qbt>;~qR_&*iv{28FM8?am8-wToa5TSK8*~~WtC46i+ zA3l2Fp6z=g3rhN)CJ)dcbfY)64eSY%d;xWq9X zE-;}NiDAeLUjvizg4YfrgGULzD$07e4fW14)ekz3qkfRZVwe4(;{wQ(5-WCJs^kUH zYW(>nHzp8l?l>g5^DognulwpDK*!SoWV_kH9lk^2AAGJDN3R>ojxNEupSO#8e{x)p z7hf;fjb=hikhKqse~??ve7mW|N4Ehcu@YlHoDrzZa@wKLqqkB zI`)C{`8TV9?N3qj6wcdF2Y#AAOHH|lQRfgjbppX95pxIHZk zx58O-r^J-h&JhVwB9cNp3fdhfhz-NU%Sj{~(b%jhbh1Zb6+BL_vKwe5#z> ztPzzvm_JBqh|~p0wHLnG!tCIY)Im{86=o5w4sbd`Gz@grimDBuMPKvsRiXAd)apd- z4i@hN6d;{GfC!}H7<826k2G%LYe*O(@hs78#%3Z6p9TAHH1Z|^FpP97RB?pwD4LIk zS!1d{2p;V)F1ds;)4vy_Yk`J>J-AG!k#`CC96pkI0QDLf3v~q@36ucD$m5J7;Bnk_ z_ahR+LR`6O8b;Msvwnudyf<*Lzlp>18^GJnk%*lm5j#gBb~zHU%aMp(jzsKoBx08% z5xX3T*f|n07yphw;BxNyIMc^?zh8p)2iFv)AsU~0s;|#E+&MWBv3!!rIV;74`KsV< zyv0gC!4eoP;D=h=Eh+L(7`P8!XK~<_g=6VfC1SsT4pVZPb|ce*A0S%OGz2dhBG_*1 zriw~Td!-*~xU3oQ7npziASlm29OROr`Nyx3Mqv9OLI%~^AAR}f9~^BDYokSpfVuM% zl6!dr%}17Bk@<*qUEi|rAVPNGuitd!+pv^8Z3v&gWb+d;$Fe)(%ujqLV=WeKtbfd0 zrHMIqnVdJOSKp~H{uLjuaDCp1i!N+mRIe(!Q-RiqJB`5%o4)q2RGpKt&)8WC9@q=v z9!e{P;rUHD+KoouD?7g_zIL?+o`YG9*kfNw-!Pl(5>_8lV3h*tY4}E#{rLa|{51Xf zCjRW$>A05nqvC?T1yY6Bu99;feTB=8k89=JcUFA4Fa@80%DK;vRBj2S4v9U5 z`I{(p0(sNDl%jHZZItRmYQCE4(p~K3d%18$E^5pt$rczu`Fr3q0lTzvvR7%8o~OT? zJt0d;VX3&K;J`4C`z{KX-8qZeJjQz$0`xfx&rxO-Lfv}{suIPI3-;b3Ru=5hiTC;| zMbY0Qe%CLGclm3?%GM}Rvtqs7?{nsx=5s_YxeXLge*jFLGjL%GJ#CLp9 z4WIkT6Dtu4zHHCULJ3HlgHigs7|rhO=o0Vh*e5>T(IkG{(T2Y&@oN|uZ}c5Qx?ODO zz{X#3sIOQ2I-<>&?S?i{L@^P?zK-$iYrZC=+wtdP-LJ*?Jk*D;0{Eh*L@Ag<{^ z*ac&XKSvx~j5rwPtrmMa@^W#@Ej7+MSm5_CQ89*9@vt9X^?MSU{vMI6*y9&H?yvcF zi0d${iVc2zN9o>z>O}E3h-Sqz{z{K~5e>R8`#$lwhZb}qm_?GCh#TzZ7{bL9{u+<_ z83g+%TSanK-x@`HIf!$+#&Yl9!rZ#+?gp~wmT-Ia;&Xa{1sZe}8dS>-x(W?ChZ}U2 zJ$JO&YoEXD3pJ=I+@R5Ixyo+nh-uJJV}@H%Ztz}vZrXqbM2ktSMp%hzFuAGZR+1Zt z+Gfgtw(+>XOB=1XAchj(p}pCabSYxjzQ_1A%rPa?J_9H=py_(h98dQsb|-ia_9!~| zcrL*+zDMCnp#1GdEJMx1yh?8B8H{=XUDOhvV;g zH4f7>I22~#=JN(y>Ak{zWwz5j!i)t;e6iEkgsdQ^Yix}@4;3bAZJR}*z^mcBHd|xw zLxm}cw#^?41yxeselE9AtK<@0d_2nKrX(tDHqhI~C6;LuICo|u&Y=8~b_ie3NgQIE zjcb!O(YB<=&~d=FBuMilkh zj3`=bGoom~W<=3u+vazKg54Th>hA?#oh|h@g0IPz`g6h8YD+B^d>yvbb%L+QW)uw_ z6-=m#Ni;Hv7A9I}+gvFW`1+BEsw|N+RvJ3CbA=`*ag2#pGtqrA`{-IxqRfT{N1(K! zlunFw3$n`MKaf!E&|7B|MP*sdt2hy*3;dowdz=S+JWEwm$zndq=tLxAz+EbMdi) zkJWti^YIYiV+1DYxRZ}HeB95+qqfbD8#;B1QRku5^&cH1ey7$%{-J=fVz zq22fBY)iHX{z}^^k<5fjh!s{JbHU82A3lb_t@XBWq!&NI@I6iSeJ%!Zmu<<%f?u>P zLF?|bEqO=q*C5Bx(PVqQQfNKPpg%^U_$o*epPvNr9kRu1>Dj<|z&5*qkWTn1x6OtJ zcn8JIYn2z3=U%BhW4o}C2zBkczI2bq2=^Qz)Boa!Q|KEx%DGoE)ao_B}J z6L){e{RX0%ykIBS@^P(t+v{*(x9xR!&30f?@a^u`fK`XlSb!#D{uI3WpxmavM|Xub z?ID|2oe_LHTUfN`8*+ z2USi`16IKZ4PoF6WEsNJsbcg-sB@Uyu&P1u9g%C`Bl zjsa^IBSP%IvtP-lqtdBNI_;EBHA)cJ!BXt-krX?8B*hLNNwEV$TLr@ zSQ}L9vOBPX?Ot@D3L#(oFi`A%9`B`M9gdDK=F#iSu{9dbvR1!Ivgc<9gY&b z4o3-IhoeOFI$Xa+xcKW=vHO3p>xbU|zz2^OV>8bn+jY#eDFM58Pyicu%=D`!d}YpX z393JrkHSYobe4>A!QFx#JrnUbGO-9dd$O@kmmpw^1D5p?m9Hw7TYQ)Zw#DhOPLJfx`G9a`1GtE77bPUus>4T`uNQA;YU$`x`2nS!(={ zUX?KZm5O=XD}K@i?e{`$O7B3Zm$bOW=I+tiZ{ed&Y5QG|PI2N>zFJB#;k)+X2x#85 z|A!6~I5khnr+3pSgX+F81fiDBJ}$9UVQXBKWWR~F7ukOMjOIQ>4<|G^KHJ6<8vh#CPO+7D-u7K=@NM*9__G353`=%ah-Jo;Fj z{p&-~B3rS{q~OcD?!zVer*gEXYJCN=#jaJu@i!SSeXYWpac3#Dy=vXJaPl<0JYyD? zdS0F}4XO%?s=KsFR3ZsHJ9Bb(FMNzk=wmJWw`v}{v`zn%CywiVHwf)Ep| zhBBJ>s%;w&Yl41CZ`o(t*q{j>0w6T+HQ6@SXo4pwz2%r~W2Gi|*4A`b_a355Q4{Q= z^p-Bp@p8J>*3_VTFF?mF&9jH84BwH^3ye)ioO5Ns`QWhVSpW6VZdFpz|6G^BM#z zt3{2k7g2@|@eSWV%*+?3K+Fp(_vpyvXLP<@edWuZ63;9=G6Szi9b4^uHtrwI*gB)S4An4BZ2qm>l4< z;2!PhGZQDG5z5e#h=Ia@(htW$96h& zhdPQ<41?a#Fo^V)+R!jq3hxbshC!q;tVzRQDSCmn&@hNJ4ROfX!^7Hs9@dWVuy&G% zK@6K1277rJ+{?q@0WN=(%Llo9Czr>Rc)&2&@fpKl_eF-mR1AZnJPcOqe4XK8kT1xw z_h1;T)cHGXuZubima{+BEvykgUUUMB61s(DVkLr=*`jXYPElNR$X4)-rlIEG46t^HRF7;(Ojgr133u3pE=35_2Hj*bi*9T_+}GH`Tc;ONM}(UF0p zBLhcA29Aydj*bi*9SMkz;@PI@umG^(2$(fB2plm995D$TF$sWEw4P083_OGwwovpW zyhqWKu!W*0;XR6;ge??3VeN#XC#X!(6PaQ4VFRM4*RkLkw$jE11BX&rOrcN;E1nce z!7{**0ijep`)#Z=aCn6!7YeToQg~(H@X7$hypFY^YQ$Bvh{U%k$f64nlMRm{1gkj| za0wi63BFzEN0L_( zjr=hi`C~G26R2Tq1|1j{y;NQIDpkR6p*SW_9u( z6`(Zzqq9tqGor5LGE5Npm|gNenlIBojLH0urnU5s=+Iv@AG#;sM^ceO2mGTo4Ekr_ zBg@UpJdi(f24lX)VN|G`e7-YuE!Rg3!2jg)HKA)m=sbwo!`aLSi%yqymgT*KbFO{i zO8q@q_g?BJ3@n+YmP|K)?7Jm#**8ks$Eoaz3G~@J1(Jn{^fkRg{LY*~ZSJdB-iTdn zs%2Tm53t^$ux3{2UE^L$OfcT97`HxAXZ$Xm9J zI`Eqfsx==XUnn-WpsJ@EnJ4u4-}Ot2s)1)<0OmRd+f@qN*on$gD)gZv0@Madp|$zkJdRnyUBx zj;V<`%7Y5G(Os63FKEl&B59Az0-^f1r*oFUVah-r#(b*VpJqs`IpB&Ji7a+FQP zwKO%yfuCpaB)+Q5QSRfK^~}up|=u~yArr?ZhoS#Pu5d6K+mBA^&CY!v!LhS_Re(3dX5^P zCu;WBGmChRhMu>lmOLx#nKeMqVFUFXO+2%q=TDcs0ozKj9X&u#)aB)w;rwH+I!ExZj#Lj5ZIkjE)n$@Jn67a_OZ zDZ9|bL=wdW^=)6j^(-p^7c$7|50!i`)^RjB3#$reav|BG_m zdbzWhHj21fV1P%y|I{~F0dN&$16(J$UzFqe@jXkS04sKSi*4=kG_e6j=p*teYpveV2;&im%NH` z5Qf0$%R}#1;tsuf8hY7-Q(%hQXa8mqvunc8%j2#({&!wS}o zn|UO3%X|2*XJy^eV|5GrEXNeA8}}0$=$8M{-~T4-HZoSXu)}iT#JVw%wa{(bs$c75 z-88Yf!RMkzS<>w`0(i1ZW>XI zJNhiBHuP)j6J^y##i|ze(THl?7mbE$W4yjPS+%TK)xr)MX*O~Z9#{|m(tkwOZFH<| zVegFS#=Y%W=yvX<-`*zcmL02`?3&L=ZW+;#I|TzYd}ivODH?=BI;M`5G&FuhPg4gB zsrX5(U}JblWNDKtYkr8#6Osrot%w1wK`6GvH0$TPLeRqy0N%T9T z2GwiA^&cZ$j6l^j@DV1oa;<7aB3Hx@^W1&&XQm%H45T>u5iXsLav$bg2B3ohd(-Rh}aE$YGVE zy)~jU2aPkK^UEDU?)dc3dAi&wN9!E+*@(_O^~i(HRZp&6C+j>@?v0~$4ts7yXAY6) zKmA{kQd44>d z!_FMhnd8=c=)5sCX_Bn-!gxA|T{@yOPk)v{=T}xe)+y_pA5Z77V@Gu6D18NVzW4M4 zAImx~i>Gtgy(2pFWbY;D{MYKecgi}ih^Mpcb;AWU^cDzwS&+#2^Z@|gw*ig%-w*!3I)Xb-?Z8!bHoc8pvt#QfIyqPBERJm-{0iyrcH#FHj(7z8V7$dS48Kv2uZ&LBS&E;^ zwiM4zx46zAUfz8+)-oZnrh_ z@Hl7UFU*}~7(Y;E4|eJqRUZa!RlV{AY6)u&4mTGsRW37{GyMuTW}sMk8Rtm*g~E&a z&a_vlvhnUT?fCU+qpTP_ltSN*<$I_~|L-NqwVfmXVs@7&F_BW!Yl**5)DhW{KX#h_ zk|^Do@{2^pKE4&==$!eNB>7g7qJJ%X6PoBv`9;crT59>0dO)o-`BvJ1TIuqwbg7o1 zXd5P^VJW-i+asl##-dV|&b+2I$$C$scCsUpZIJ}4;=PYP)jHZlXH*mRFrEBi`2;G8klTy z4Zb1O7|S&h*Q9tFHSO`fOG7qHI*N z5!jYr+oA)ssO{;W?0*;aUNd=b%PDw%3FK0aQ@x*fvy9@kze09vl0>bfB*%#;6Hk$_ zz(ek?dOuBNP`!^;j*<6cW-p96HEw;{&C7=fjxspW-HA6F;iQT>OYu}LnxlHZnyIfJ zsj`fzTAy}56|IFs$wmM7sHQ;Hiq9u&sOaaB^$SzA7C&(=$B~1r$8+d;nKcp7exyrz z8LiK)JiIk0)V`|sE2tGv@H<_M#-G%-N9jqipFYdrWRjF;$YnN}@tcOK;Z0eVU(6sO%SbnX`%r}QqXz5)Xh z_B3PQ+4pUHEfHf&+S2jh=SX}tQCV=F3%H=c1i;^vka%~F!Yi!Vrkvm$`Kq8YotofC zdvyZ3A7hbQS>SIq%kV2%pEx|W{%dKf==!gX98QqhHDP_)@CiT}6WI63t2`K@*p zD@H9?yev}@NIU~HUh$HjF<;x+Db3fiBlESJ0V$fVeI4^M zwD|OMn5(s6&ej~7qs30nE;=1+{dTQi# znFQx#i{P9#1wY6%v-O%tPk|4&yosMG+KHbk!lKZuQNlfw|18`y?L7;Aq3WJlZ{gA> z+%wT*b{rS(v43RISK$iDY4sNU4&)p{0WGr9kd=rE9mq?;^h2m}3wpOjzfY)g33@N? zj1;O$1zg`_A1zds3Hnx{O0?)tm=VzEtIclrC^LTKLXV#gD*YF;zSiofG`q`gv?fDlBy+Ssh2L8TA&&q&Ka4pSiQz;+XqqYw|vebDqVjKW1@Eeao65LZ3rc z{TZl)UwK*lku`Z6w0zI1-wy>xS{%>4XVy2HQQhqtY1Z#BGlTDf-Cbt=K1h;gb%@qv z{6bOb5UUeyw?XaSM8tolZCrw{xOS(EF*#|JSWgU_0=8#0Ww>f0@jr*>MC z_kek#Reus3M}wostUqSfmx1I6vmP(el_i<=^KnV5lGVAfG%aU8=(u;~>*)w)OU zB@S!x-whbGBSUV~mZjptIrz1ga+DQ1r@e*0KjW_+f8F@g;%}^__`9QMnpM0^JEysc ze&=V9f{0(<6jpnPmT{KxDdUsInNL0uVH$CtLE;~LA8CMKw1#PR;Mpsf55^Y)I%&S` zn6yk7vwi6}e9dWyrFi0-W~b6xY)&%YLvNrZ(M!N)hvA&CrbW5@5HK)Da7g~eNomiBSC_nO{+;oGeF@M7QYSXP$m~_Oo~C@5{>4 z%(fNSm)bP57thfYELr}{GcP>z1%uA3qw4?fED+y+1?RQXZpZo`eoOMZs#hlci$c-q zRwxu|)wA2-JlTSjrrU5~@`H*m;%8;AK@ zamX*nVMQ%^VGVFdUyBC;!Le4Wrpw~He!PNCZ)(X~96!^h~lNslvq%l>h$yMtr z9AB}hCSd@dq;^h9m-8M@L?#BmsT$`bT{trdnKc&YQ(4YQ+2PC-WHzj;R;}&MR4Cd_ zD*Mg!X+ZJ~x|pS*<2(Z$?Rj+k3*DNlvMsFd1Ntw#9=w?PE(0Y}(Ft?n7HjdN zoVWwibHiMft&DC9*dA*0;e&vS&6Fyo)HtNF6(Ze!(SxI{oIV&xw|YCl()KCx>=)?J zj>FQnp*SAs!NK2wgRO!}Z=b6wG!&t9ZyWL!=a%ERwCbx^k?TYXwR2RhZX6byu^V=2 z=dD!oES1@vKz!Orf=aqSt{k^o4-kCaE6buW~MKn)SbxD|RB>g0ut%^$T zf-ph}v1mcw;^{l7WJH)QlnDugUA7@%PEwg_76y+vvlTTcw0PW35@s(Ed69_NYKgpv zL+{bBFuk~8XI&&=$Wne1&Q?dID#V-;OTY$)WaOgb{7KWO|euk<`!jPr>B%H00N<-hm>6=IxKk^nE zd{i=GnI4o034`sFAz}7YnHm-b8?mw#hf!$pI3EcUBqA>o5u1z1m*LR6H!KV_i=8kM z5{4}0C*f=a1^W&NljkB~h{9r_mP$s1=|-85FyI>!W)GF|u`n{p8w>egpDEz{SFN)u0l10KV5xHdi zs!13LzsF;RiNf#kurTrQdz^%k@JmE48NV75M(Tj+80Ab+_(flPQT$RTf(uK0MCMT+f-TPsP917Ltr%$~Q2NN&Sb zu9{>Zr?=C%D9Jzyb1ZQH7PlWG8QQ4=f(S{5YRCW^Lk1Es$~GYw%QD0QxV464*bA;0 zyzB-Z9z|SD*N_b4>vn1bNd_9UID|NG0fk#}g#ZFFAa;;s@IwaJ6fzKoqhtu7SC%0b zx2+8%LnpX4f~>%i6k@$#UKYtfLzJE31xbcRx}?GRKo<&AH-xwWGEj)bZGqms5X}i0 z2)a=+gg`6H5DVAV!z2T68UzUJxd9?shATv?kbykg&OX3+D5XpCbKp~Dr+yjX0xAGW zk_;}$fcAt8{c(z72o_`+V!PQ^kz}BV&Hl0iycw8nM3@<#L6(q9;t)K^8u;`MV}PSt z7qLU9$!I0DahGHSj+q?+H^dxvLfNwnTu=(fwy}UD1OSh$DnOY>oC%a6&fM}G!g`5X z3pmTlvRtT#7NRbk;2OjEBsfVZQI=1vBPjC{>$oU0Tyc~Y5$g!b0>nBV%Fr@b8fGQLIs&scVjUM|c~=}}8;Ern zW@_Rb7iIax`HG@!6LF59j9JG;SrM_mqJ0@5ItpbP(mF26N{ICp?aRExI)bt+VjUM{ z8;JE4?aT<#Q7AJI>$oV}M69o9XVyln!zjzmCDw6a<|5Wt5@rhG96?zgagK{JFLA!2 zC{q*bNMDvutmC3AK&-DQ$~43}g0doF9T#P7#QMshjG{9Q249ZOvPb~#(J<-!;WeQL8k0pg0BMP#HvM_+}zqwV@hEh6v73fo2=w$PM!gl(^{R!9apA z@(=_Bv5-g67zB~ghr7FoE=_6>x=J&MkRVhBfh496YT^?eCZYz1Q*tDSuni?HI)%t3 zNf?O;JcC%pqu>mZ$Y{jfN|J;ZN5Pfn5g|#aOb~HbVXlVwG@)LUO*p!e13Lf-C61s% zWCgNcC`V2p0Tbc}v4lsl8YGdyiMzB~>Zb_~MSDD(2uVU^f(XM3r)Lo#f2<@NZOL&S zf;yDA=yZa7Lc%G;4;s8Ul#(PeR&kf+n0{Us1y`O^ge0LdK|rT4&p>>rRikXe5xyMv zA;?3C_>+ndV970z@V$t3`G z14(k3fDMo&5x^QC$t3`G6G?KJfF(sDfW=YV3IH~XB#8jF1d@Dlz$RS*yc$T5a0iRUxl6*U znk2bAyz=r{6ks<&l1l)rh9tSn9yTu)ur5e)34qNaNiMaArAXEQz8uNshhfX>j9y%Z zqgW0P(S*FtXdsqtTrIr%bXn_+yv8Qs6oF}w*IvVME60Oi%?pjW#QI9kns|*(LMAUX zwvpCypvnOtSo1<-9k$^jql@;YNaalWFnrcJ~-GHX&3YdI1H z>khh2>x^g^u)Z?0rVz?pL_4wq#9GrlD73^GSjl|gy<-gWs%nM>?j6h8;JE4 zMOlDYM<(Y6VjUM{n~3!lMOhoM4x@}$TjRsbMXawR%m`LdD9ap)0>>lH;)5pj*6 zDL`ER@n|B@Mxm*Vxc&pt1ZJVVU|0d-^)>0KYG?>#{K!ELKmVnL7<#11YcRMSv(~~7 zZ&mgoA7C@4;yYhKp`gp6vWnk%I1x)acdX%-HFMDk9}zU``Yv-b4$Ii-E1*s-t1?_wG#dt(iDojRpmrz+hhfp)R# zw$f%+lS)@KUplW}A)RR-tB&@u>S!OUj`p$YXdkN%`&gax+xYiSiZz37Lmy;+?UmU< zTh%1*p~Y8TBKv5i4YaVu*ZyX`=tf#kyp6OwX?yI4_^qOL)vAdu9Oj`#R(%_P&ueff zL~w1xMh4qSG=&`z{z7`dzX;na@;{|5DEXfjBI)`n+6>3m+dg9lKE2bdX$NFSP-?!K zlC$|u=X?$AF+4~a3#i$%6@DtR0-vr@C=8fWErh6ky_o%YBA4RF{c#s|p zqI4(KJ3{rap#aRH+3bw4*?}s@sUEZrcAzxvcnh{tJz9RI{xrfbT1iwnL-mfMUJpvs zj?!QU)q{pn{FGs}l(<=5i7LHRk0w$V(~s&&`fBsea|7iUZ z!ulnUT|T#dR8P{6>wRwhsGg)B*ZbW1Q9TKkT<>$Y-=*p|B&^>MvdibzkLpSKalKgm z7@Iuu0Ck7HgU$a|bUjfhrP`8~TsxvAh9?x>i4NMag8Pw=*h$sP9ZoNGMi{QB%fX5I zBh{XJXaE|1Av`Wc^_5`Adp>DX%MU2^g9=obh%Oet3XRnLNbIM?!~jxtlzLu+!(@Z> zI{kARqJ3=_jb{JYK`p9JA7p%Uw`kUMB`nS8bZZbcsMfV+;G+;XMAeg%!j%~b<#90b zF3I=_k#|YNbI<>SUnhADYKkvJ-X=kMmIjA;1{~~pIB<+2b|F4#jsB5&aV6d=@wit? zFOg7!Un238t-L(}@AN%t@RTjM{RX6-FwjlGBc8XFdWo90?m<0lepO$1e(q;}R#F1_Oja<6c8N}C!7WYgkdm1oFh)rfH-$iK%A2};WP*!rsocZ2=t7xi0B>+5#o8gPQd*{ zx5N%7f(6Y;9*oynX@Vfgepa0gHRz>Ev>puJk^U~COm?Or&@Uc5nF;j@W|UVC@_i$7$}yjCMMF@ zLA9b_L4W|0>f6MY-s-5qLu_f~u|sSO)`E$_H7F)Z2ooqaT|-QyVS{Q#A%d^~CcAGF z8>!z9j~ilRD-RoDU9b^M1n;15*g$YVvAir|B8?bSD+&w*12FO3Cf4<$e+my7VqGhb z7~=V0DVXF22JIU*QTwBq0il6BSWqou66zVK<-x>zn|Qw06*XFj=UYV@EGYa?$c+z2 z=g2|oAyR`ho*P`Db{BGqN%dDfZga5X(pnw{=XgQt7e)kW6c@a5o97YzJxss-5*r<` z;V?n!4l;r?c;^P>_Rc2;HO%1jB{n!~fapN#1u}y)W>0UE+q#Gt_?W@jOKj|{ke^G# zK{70TzEC4Ka|zMk&Gfg424ZkoO`Mk-uZ=Zw8*d^8m)FL5@!B}+ z|6}i6prg3bb5S)CMu2e3v6zF$;Iv5FBQj#5&ftkW#&I{6#=@4c2!39_+F&zy43ZGW zgJ%XL#`Zbx)1w=bd(K_X$;`dViL>J5I_r{kGWXc`W+pA@;w_MPNO%OOd3lfpv?MGL z0yX#h{#{jF{j{LvSvX59x>Q}YYw!Q@?f;BnHL#>K~G)iJSIVb?IDvCU9D(tM@$ zLc|6P54EU3jhP%hWh6qNMJNKjGAa- z!x-tHj5MfXGY0TDX-(|oqqMr1C~b1r2;;IgfB|W~!og%l3kd0kQS(fY7$ddGNO1HF z&=|Pkq&2ZOLp9xnHzor_C^;b9)tMk-Q!+&Kr9gx?6GSv8LBxy{fT+s^5Sx<$B0mKn z8Z!YzAQ>PS$|VEDrc40QmJARjDFD%&2_Qnr0O3vnh|QS*qAv*`kYgt|5rGU4k(&$< z-V}gn%LEYl$pBH80uZ500O3dmh{hCv=*t8UCCLD>DLEi=Gr@y986KKb;2}Q)JRr@r zF7fCtV{-~XI5Gi*HyI!TDF9J&Lx4aa;Wh#ZL_CQRqnp}iMA1e!m04DRh$k^(tftl( zNrX5+W@*7D4iFzQdZ}y%5PcjVvyicw1H^}nb<{Cp1PA-iEMnZu{^KLYMrxMPLOut$ z!H7|h4FVh>KJ#O+C^{AnGShJz2Z+xK7$l00OETAShy%pyn6aVg*qynK`#3UdN38M8|a;ATu2UCW$&`*e5z}+0Ra zzB8M`KuDZ=b^o-C>bh)W-28!So(-Rta`+sqqx2QZKQSK0M_ksPuH`2NZT2?q|NE-)C#-0G01<=?&$h0Yz=Ay zZtDues-Zq$a@QryEz}51j;I=eA2s|3?MEne#D?VqsBy$bFCS?5wv-UC&|^!tSwvzj zZoD0bjp9bsJ$%`vASvoUSkl z!m~o%d?a)9S)mSjLi5~Z&aABV&s1dxUtogw8S%-X*sJv46ZgQ-)0O_3ga?M6tMtE- z@W4Rg%Gn=FI*YN=e?p-W{`N>*Xb$*$-l`!L7|*ipq_y6oGhIE0t!zax^MOZ{MqW6 z-4L{CcP~Y`$imrcu=~K$ZNZl3Bfr%&{2WFb?7pB4c73L;Z>VnM;S<4!?k+uS(|%Ro zcBghIe4eAc?A)QX*7x5PY$@*@l3{mfM;oMG6SdRbecI`+v!G)|DA=+#(i(hdT4^8Yz^z`Lj>nw`K?f>BOgau~gOOJ4 zG)jfAvoE+{T2{Yyy7!pS!77%T)!=8&>?eU9(t$suSs&R(?19){;jsY*8P7QngA7y&m}DH*Mk1Pa4s}G>*)9;(&h=sq4dTFU zU664&NSLxH24vt5$pGpMm||DRNCs0!YM34Y9afkgNdr?<)qsv80#laEfDZg29Y>-t z#jeni45seXFb#nYD@;RaU<&Pmj*!5V1vQ`pe@F+=Z@?70LPs)~dQ-#n6zH(R^i&#{ z;*K}yI3+M;xee&RAJTCu3RCO~9Z6vNT4QRMo&g+AAL~#zUS^<<@Y_a!k=Q?x)8CJ z=+PSmg4(R`vStivICNfP;*ZGVK)B80nH4z|_355jy~?M1riT%x)aDaMiO#xN;H6Cu zzZSp{hOg!&Cbozw4si%WOplz4x@*t$UgfSm({Mu#+I0{kiH}iL(nOsOGr)ob5$6#A4vZlH_)ImA(LKkosy(U3 z*EFJgWQ3yjb796!lpf+YYTJ$eMccSDd1KmM(v1F*9*WKf!;FV0ImBgDwHN)1s?R{9 zH>B!k1Lz-FzUX@-%s6L-*HCj^Bl;ILpM^GWNX^Sa=)WKRQ`bic;D$AghKg}BSJ!bb zRC!%GHq`q~bRVnTCC!F|Y-Djv> zFS?J_?l(>4I?#P)%Jnv)`#9w~Oy#=Kzf9m8R_=;s^dF~Px2at(`p-auQj9p%+xyr-N$NoMYEyaH34*=p?V$YKUTeU0Ykk@Lg+t3 z^}5l2ta^Dp=IJDR)UDx{sZ@UWch%H~P;^xz9GE|2XZs zP3?Noe`eZU7C`@T+VvXRt!_mBH>O>BUx#6yUlV=mOD(7IE1A2)PdCGnev0m)-P2*+ z@os2X(kPl&Ikb3Q?NAroCW9`zWn5%gaETARzD>pIao4%7e)wJ#)tp8-vuOETSogmh z8lG(y2$;j5*!fTwyeC60#$ve0vYrwjdo6KL!FupD*wqIgyds3KssrOd+k;`<4{vB# z77!?y;~e5R@S=>n7?9y2%X&$C0QRh#iq-?L5&U3kTDSy})m{t(rH_Plcf6s2^5xoy ztUO5Ag<)WC8G11$!$p?mkoXvEnV0(3W3Z#B%$gfUb<+sKFH!!{ux`0GG(f&w`!I$< zvHPJesE*+m<1$=hS?)06FarcD8mZb?5V(_rz>x_E>Y5W#;7yJKcP1#P4kV!9wZ`Nq z@MeO7r$Y%SSks&o29)rP!@!Y%f+c~ZD4>8dfC0vdgTb?*q%fd>xe{x@4L^Ih4qH&? zPJqE5Bt-$`%Y{~BnCr6z)!sxDIFh2kkqHQ%ZA=7#J2?n2&W(d$S#u%^yvb4E&IARo z1rkv3bYpT9cr!u4not4?yv<2rKndSC3~o zi0m~!09*#?snmQ01GLNnK%}qn0pM$1(JfbC7@%bq03v-&2ml*JsfYux?r@_4prPI( zbRQSpHyi53vO~t|b)f%P^*$Xi)Qe?@jMeK#|8ens$WZS$(S3&6^`iUO@V>}Yt^?g? zrd(vN3CeYt%5|gv8;kA@&#Mc zWU0xO=tkEWPS0j^olJ{fbbVt}6CGM~hi=;O!%4VM+~??(EJ{KHINN9{#no&n@i8h% z2g`s^=)p1|ly;yDC{l}s((pBHq;^niU4BVB zgx@TWbZx^ek-Zpj67Fz!AeeZ}fm`m!5WPHw^sMVsL`x|7I2utUACZPhTBHF@A`Lhl zX<#)-gK_GcW~~S-Ls$$ncvMyWusB9l{S@KxWq7Jtsd|c8BOh{xhnBsoKjmzb{*<%# z^rxJ$?xu)!H$|+wDO&eYrGH&prN8cQrGG`J((gSpnrEHG6sCMdzWmjjKmA|Qeg3BR zlbUp&Bma8mpQrnDv@G3~?vp*imNn^?i8)iJOwP66IxR2$JCE@k+wk0y3XjXNYVFF` zU!A*jW$m)p9Iw~ERI_GzZT%_-HjZ_r-+A#n^NJl0{n<0sk3aUXr)r5~>6?!EFV)r6 z*Sc%#Ua48LX63hP>%ae}S-O6nd@5R~_4A6i&bk-+-Rl~+$#SwrRDzH z7c~`pt@6GD^%V>Kjql-9xqnHHer(OW3V)-#z}f%w5Mi>=_ueL6?fak^e=3^fSwst) zK4NXw#|1ca^+LtpRruHCR`~1k7y6{Xg}%)n$Tc5r<{$r?MR?Qu$Nx=lTBDKXt}RwBRlerKAg{{z zK}h~=J|llV>cbz%jMFz>W@*RFl#-Y`B+@VErVB`_|5aS4n|U|a&@5*U}jxCF)} zFfM^{35-i%Tms_~7?;4f1jZ#WE`f0gj7wl#0^<@Gm%z9L#w9Q=fpH0pOW=zoko@^G zW&UaZ+kcvqRpy_#N&ZZ4!XMA2080>Fuk>8nf}89P`#b+NKP$_#@k9~o1Uwsu`N#ft z{~z&72o3T*8;|3UyL|Ev&qmfmRNLP^P?(j~^R3&njPi+1^8O5FPU!3O~D)YVW#3je=yIg%}T2^@G;ijM8rP}Ux4S8BLwA~5{@_l`%q{u7t zeL@n>NZU};zkZiZZTnn(o|fQUw(TIGpv-p$1mycJV|-G^UJp0@%e~SPN6vU!w%%pi zhVf^BD-uuuzIGXWJ>2x+x77GizA*kw^7XhAm-N_MqioGqO`~jq0*7x11mJ?>lz`6G z_nLk_OSQGKwb<$FBLxof1rl_=JWc=lr$VmD7$_(rV~;zXzRQ$=$=4rC!zf=MpqScP z=`8Z~iwI18%~b?g`C428zDNPC>46fIz8-FR{~pyg$`~k^2gV+E;)>%CL6onrtEN%D zKtd_hwbD5cYzYabz8(;2Eo?2G54K1^Dfof}biO=I|MDGa8fEO;#ZL0|xO2X*|Jy5p z8UZ5q98oX=hUfy2Y}WwCvTwgmARhMd${v2p69&2GjG_{FL|-1;9#8u=0Q1|AJ98B< zaqV$@%NJg;Q)GoFkO_R+cDHWL9O2Ph+qLQej za$$w$sw<#Coo$E;G!(0ug;~=>`RI(pgb>`qg@thn3b^L{!bCWZZ>7REcJjgX*QLFM zYef~{&3T3kKOtT7)ttayLDRoJpr|t0Rz#KRvuz$2FL3H0fmI>g)df+;Nz;6pais$I z99`KkEwsI=d6a9CG!Nzk1Yt@E+Vv?y+8i{&!bOy8MG`=c`8N+5A=cFd!H$!5ST3-# zO_~64&O%&Ar0bId)&|B{(PZ+hqy(EykC3ZiuFnk8R1Bl6t}vNaB;_lN=LyF3c>$72 zQewP7Fpl{|jAK&J;$d%IN{j>C#5iV!VBBG19Md8d#({0YIIbneorYDIp9|$psj6(xN6RtWb(t#0Bc+VloS;blA>H=dhY!Fl76$9@_K@3oSm_`(RbWu`; zLkjk}1UoNvU6Ct;?OO^j81wxIL{uyQEAm~TxC;9e_VWmNx$1&kwH1mi>^0|n7s)FT zq!s%v@JlHSqdp1p0ewYlFRrz12dQO+C-uqS4cahi(3Z2<*Nd<7VILtN7&guUb>&H3 zxvhOmSz(uM29ANeQiVHQf0C|%(!tRgoD1qQT{!KUMK+`k(}4iQ^+I`$2!|&ykKVsq z_|&CCxmv`7fCq9!2M`aOf)Wp`7N>?>W{3xrTEqjYav&XCeF3|9N;cSYVumP6m1rnc zQ!=CnGmEIi%TDY6=L{VFuDd&meLjPK6wpJIp9`nGPJt)f5hC!j$67!sRlv z;KpTU&2xV7#KMF+ObDP0POM^MV�%#G=Q7k&ua1VoWT!t1p~b@LQm@NKLGH(TSD! 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%0AB+Bt*XL+*gay;3{QL?M^JU)Eol0sdM?}Eoy zk?Y&%@m1%!Iz3obD2(*{%Y|##m`@lYj_X_m8YM?Y6%kn5cccrYpxb zSlEr18-{3_wfn+b>>mauVYWzK8f-x3PbCZj$Sm_77{W z=J74bDkXJk_V@Qsw9C{r#Pi ziPFp1KioOVbv}6VReMjaN0yOXY5yU zRWb>Z_bTQ<^06FhPiqNZp$0)RBxtH`9^P-pWRj{AbVxD@k~@Dtszs1|El+ES=y{}K z&>-2Z$O7-T%Jf6GKrz~K=`sZ?b~zl0#!X< z^lg`!)vt0zNOK4Ip-xtZcfdDGnka;RjMy0z0isA|$cybWtsj9F{ND`vKLU&q0)gD6h3U^g=$vUA5b(nqLS5)BPm1>R?4735P;;{5avt0jJf7Bb`bJR5eApN zWZ#lYgC=RA5+lA!N(d5QU6eRs*&t5I28BvkH60*K3KgN+CrAaE6EdU_y(1Gehc&_D0~{i3od)czBQKy3M3*KR1(B@Nd!T|9z&8aRgk3QfRrTdlnx0~rdm4^KoB~E z0f*=v`JW+17%NaWlN2$v(2|v?`G*yJO@PPFfrt z4YVaB)OTLn$%GFA01k#-VmLrHl0IDAi^VC5lxc>J*qDTmq%xt4h`}+F?*h=Kn;W`gr4=zZv8sVW zklTr|(HVe7bmSWFyPS)FdBX_;VDKp8BPveL9ndNN01L7Qor zOdF#tQza}@5;IN9^c*Zxf|*7xhk-~ouimJOV($umHCYwOnarR7<8&)eG4s(f4U#QLml!L6%G zpKxG*Ez-iRuarJtg8fy<4m}%~O`dp#IpTWeh-)Lgcy6fE$p`LUz#f7DJ`wf`c2T;3 z?N6NE;K{W}4z*t)8^k-KLfQl2EqER+cJRR1-`D<$`Q6b7-q(nCfFY-QzBd$XnJc;6 z^Wo!ccp~U|KC)Ze(C{;7BYq_jJh=*)8@fA^gJ-Swpu4TXmM8G8u3yQk&_H1bogxK0 z6gg*f`b00@!>Bra0&fpNbG+{23BA*1>GTz(Rp|6+j@B9;K&N>BY*n}qopz8$Bu1@~ zi$-@o$L-tix#eQ8oFSFrr&{M8=7eaTHO&>ZN@wfLj!I*UuJo)3kqZ5KBst1M(TU{f)cI!#(2bYyI zpBV`UJ=tp;gD2JvT*j9c+xg(KANPgsXwyd6AI zF>uAUg^fRz#@i;)N3QP;l-W+WBA)E3cY`NZ42%Rl>m$2u{cLwa+6_!7+#)K=g2yPc z9d`|TvY-E1@I>9fknI4Q9hYY96AJswY$sipJ=v?8gD1QL5nC(Io|Ln@Clnqivz>Nb z2`*dDG-xo~>d7vDU%17WUG%&^?mI1QA%vE-;T?soWwwxO1Y95<8AQGVwm%TIV_7al zPPn#rhp-GrKNLoTp0(g2j$7fvAzUbqF#*7FJhKLm!PGnkm#t+cbuQciZg&W`_;P@= z>aJL}F`dF~I`3cw@yj{P2=Kl~c#q|~5HE~b)!Qa~gWHgB8}zIKD=}IwJS?oZg%xNT zY@bT1UqiKLQZpJ{wu-sf>2NpHdRAD*mm{&Ng(V0d*7XZ65b2yo&IR7Hl4=^wdx1fy z8;s@%qe0Ir;37uLg{{JcSGa(t!EtU<{TiyxYbuyPm>BqVohN_Cg3DfECI^EA5IdUk zT-ZI$b_RZ3%1TkMpMgmyI6w+_6e(NN%-h+}K(I>?+#;q6%oQ~i&*5nU!eYH#g5wKd ztXRlz1o=%lGuY06uS<}72>iI4=6#E2jOpo;nYkml?0F`BuzAp28Mwl(C^*WQ7(!tW zOjUqlBz%XE;ekDN7-dbkLhIf$V68HaAu)|QS76916gQLNSk9tMy_}qv;Ib!}CBmSB zs}1r`__HpcNKM%cB*P}$o;cz}c_ z)fjktH#tkeWmQZeVY;EQpMg2-;#J}BEsj`~iB;#!qu!c9HXRJVx}2&cYooH8c4eNF>_(l8K;@-0yK6M#9z|Cj()> zkRBz3wTSJma5CNthpnFA`BSbfbOVMgX#dGibYg-Z-3`FW6w=9@arKjZG5!g<7yEB; zeiq_j%)v)4Qv?az8;obUTGR5Ila61jPpHa8_78_(a8D;dexlPB{OG;_7%!wt&2w!b z`{Dr-@E}gW;M@V_lOH$v(Gd`%e1mbPQh7>#i_-CHbqr4wyAIPU7}};gAU{!i1%7m| z0HYB+zXbB*VTXD2gFa3rKBDMx={w^?2` z6@6{E8;d)OXK_<8*Y_Kb@98|>AZ|3~`}U%oUe-_UnacGOW@MH3b^dUHHeCB~0Gr3! zuz4zk&5M1(*nCV+#Z(s7n{T!4+-GXQr{Txt0Sn*iX@pH~OdA=!oi_S=H+A#}yK}># zJh1$J(jeA|Uq47ovkKJXV|0c*bR9Pwnq%(mNE(#FNJu=IJn%MXRxx*cxNStBHymX{ z&MLN!jjN+6Q}RICpjpMx@!_*U^9@DKkTGNBSg&N5H)qQE86OizBgf*D^D|Wxtgk^} znL-bPV_k|F{iY-{KHRlLx+%>_ssLA3NRaz()dO785*M~x;#$+LaJFtk;hjNjd?0Iy zVN=R9Md%8jnB&w}Ymm+A@hj;{Of;HPn!xx7HA?CSDdn0bCIwMgq16|Tuu_EShFCOh z6ru;7$>3pxn`xu2jH1lmT*J<)g)CXq!fZsqnKsnWqswG4l4u$+<(C&qs?|2kP=t|2 z2nn@Eqemlh(qqPC$dVWtkz!iaWELRGY9J;BfAu!^c;o`|xM!=|243YPwJMjl6$r zOy0buJXpH-PgOQe|Mf4gUBi2~>_2@Ht4;cQr*QcH)YRd(9>n1azuR4rkHudO8bjsjL*1YZzU()VO~_CECbB)_|HdUSE`f0gj7wl#0^<@Gm%z9L z#w9Q=fpH0pOJH0A;}RH`z_ke0JJnP`P?Z#7C=%B*)Hh%8Q)zi7*AM(%7ovg8}X!^HC4C(tX&KdaT z+F$9v#lI*QPi=Ut{hgh@sg?FcTBUzczJJjSgpXrW!{=hv*tnU(&F#zWOMCFyg3lQosJ+T(u<`Lz zwFmJ$kIq}Tw}(&mz;nqORy5*kB#f`^Q}MKrVIIE~kK9_-h;Ns9_*S+L$~o*DVS|Y{ z)V&RxuA|Zl$9EVF+IS5f-^M<}TY2p#+zjzL(ic9Az0PSkxeJZX@bKj2_AWehFT9Uu z@C9e^d>RfuK$`e)tMFB9A{=d)J0sj&LvJ-J_@K8Mk8B^2?UI~$0bC&g2b2^H0pJ-q zx&SueYy=OTi5Eb#3~dNts|<~PK&UPN4HtHul2$kl;z1jB@R-J^0D?Rc;sy@^)CGX2 zJth$V1RE!SK3-u8fRCDt@Iihw#;SWc!G0m*AOku>!bE0`QnEO2H5S9y_QD zU^C7}&f;sF07Al=ApktFQ1N%17m5JLT-OO{h5i+7*nyU!0uTgz%JD~doe1DG_7Vk< zh=5Odg^7TZWLYx=aGKwKi+*cX5a|M-6-g98q7A@=w+P?}S%wrb z2zHKgLuA#g;NuZ%JhFX6wo3})1wdoLscA(3?Ts>mZ8#gj(+cAS;1JeuSd8FygFnt- zC4d%O*rjlS{$W&<0AOsQ0uTh8;`mp1ogjc#BvAl~2%sdR2sl8N2Mqzp(--;ZyP6d< zxb?{P5!o)85ibBtKOF&_p9%q(vbq3hq9X7b@d9wi2!Id05(IDtD*+HXUER_O48o|W z2DCG=2jsfV0(r7{Rq0RS>fQ;Bil!0y95x9H3e5qAS4L~ zig5i`IT~;Z|FV5VwoCBL*jWD4M+3BP#UcRN zJ|f#C#qk129}SdH0AJx~fb`dRWc!G0mz2Z{Abm98rU1Ue(Ey*wTjP=KBeGpGFJ1uY zqk(D);42&rl!^dk`-p6pl*S7neKg>u0KUS}!2GHvH&I;U!QMjIdjxxbCVNY;_blR? zSg(*iI;f*Sn5(9b4m|c56%QH_K(Y`44HiC#zOE-uz%`Gc(^SC%PCqKACu3kqoJSoo zV6kCEcxeSwrAo~zs>2~WP#s}+!TGuW>~ zM8;f@`Jxi4KGzbB=YZVRRu^5 z5UFkmGzC&OB?vVnnt`a5!u08XLr66OG##l(s#KN+RRgJ?<7*sJIkrq0K`IchkjflO zWociuQez@D-AawXF*#C`tkkGv86f?XNCo6;>)MFa>eNVWP7vxCB6Wy!5ET5oTKxf1 zq5DXkH#Jf>CkT~j86%r&1d_T?%@rl68b}?$&~Zq0#2|IMfmB+=(XA+%NIgY;rdUzx zV|U4sO0OEXq6E{gY${(O5a}dR(LrQIU23ET*vpq?QzPVzk;cELRVsTe67fh~V``wb zCCGFPfojCJx=hW5FK8M-C0^oy>Wu;F4J>?_Kux#s#eq`*HAxuDWj0y)lUKEv3tzx% zKJmIKHC`dfFAuM&qE)WHd{r0IU|1c*YjbM6itb`;0Hi!thBaldnopsAb%WIs0(Emr zpk{rUI87O=Iw;d~2{vF=AjSq@LPEPR&A%a$H_IJB0M$*Pg4R^AYJP%HLt_Wjl(A|F zh5FTvRjUcqwv<41I}(KYyNFe}g7Z~fn*>t5L@L0S3aKRtLLEb-ri@jqDb%lStXfB; z_N7LuJ3**ph}4v^s+U6T!O*dB)hbY9NmW&n2%CCS-BATn8zEGnF%?j&6J$DuKusB| z)={R=bev4rdShgIGsmjecG}_S*aJ=b}lV(q5(A|7Z>l zT*7Ya{g37@w=eI*?xFi1&4WTB*gc9>+lL#GsC8|_-eb+&+l#%Y_`=by4(wsI4SUD1 zi3ISKZ;rzaYJA(t3mp8)S5Zdx^C~yL%32ybuJ!V3glE}Fqz6@L4Ki9}fV(nWWQ4m? z$krf*Yz-`AYmh>=1}S7~Xn;l;1Ur(wpeAN@d();A^@(Fsf!Kb+s6kZ4hS5?)qXu#APau>uHHg7vHHdpyS(uMs zfJ4?BzTbEOn`Z9TLdP3wL)@Lj&0`L3pK^2iBGtL{6gTHkQn$2)8`P%M&icWVz#WS0 zKIdixo0TDM_zKfiecTLVQ+Mi4ZqLc~qHM3-**js=Ccg63aRFD#i(pseb633%mW$h^ zcuO)4EOubr(yXm)ysm_IWO(!$?BR}#y|%oOEqj6LAZB&=6b|jc=ZQwSimyeBv|$$y zSFSF%m+n3bO2V*~wOe@bC(b$^#DZ^K3zz(GIgY;Ubn_@~ywq*ERkg>_*PQ@kZPg}> zgsitT3sjWuMsiwDw%TVN~%U-(Yv}GW4-i*%o z#`N3Fe)mhi*zDn0h@We_c|<4M{$2Yq%=O4gG_7TM%y;d-$wAxmY@;)QzBi-EJ|4Qn zj!BV5*u9I%(R{+11J(^>|>Li%^@-8noI!KM`}_Y@NYCyaDhM(W~7?9nn( z08RGrkc>o}h=>9lNht)Kon-@KB-Cep*M1~A5;Ukr;yYla@bkO&!yM^q>_}}Gsnr|_ z?pTdPg+~sMr*@1qXdMau@w@h;(UDMJqehZM@i)TLCmbnGGa-zmG=ttm4Z>51BT?p& z{TPW-GI{FeNXMchA=^!~`qo~kp|(cQOcecXYa3B7Kf8bE~haRNN-f<@b7TaYr zj=_~lJtR%vS#Ac=VkvJZ{op+JSgrhnOYG_Ujo4jt1kqi`A1$}9Ihrd6oAI^Q!QI*b z&*QMY-h56Y2{uwKx#H+TZaP zqH^H&xY&wczqH?S{K5Br*T3cX23Xa8WceuvzC6~9%~JtvUJPOL4SZ@H*!<9qO@kK? zG`)AY5~7)rL^N^;Z)W3L9^R}e;s$S_SUmd=v3+xPwoPkadM0>caTeY;;Hf`&3tqKS z|M}OoVf;=5@5qaU@rsxFgHyE@yexrdrfco^r3Sv@1qwKEhk0UBu;q?!yi4kiuA}O# zt-fu7I=i~Q?=HM{3Ga_^g%4}C@MZKN{kBbGpMAIE`x)(%u9MnE)ot6?jBfj;Xs+;8 zym3c5OolE9L(Ph0 zoG=->0EX~RMQplGsk4*oJ57czks&-h?7r}CwaMXrgP~4g=#paSc-Illrx@xq8M-tD zFW|eN&50ahr^3+W$e=bkf@fuepOX+8yEJ21AF0p~0wz{z@@)$Yf}63N(6ByDy@d4BZhy zACcb*LwM5*AeS!;;XN*xLJ#G~GSnsv^$9}(GKBYguC8x08R`>;vJ8gWOosZv(0$!_ zaN>PkpQy8isII{trrzrM&vgym7CxdChCeYFIw%Z%9@Ws_D25K241GQY8a*QO#(*5> zQ(>fA7{bdo!BBxPRG=6th-Ii#7`h}3wUHsbRC9HGr^(PIVaQ=H)M+ww2@Kh~@yZ5U zm#)7#sKGKAx}a<54oG`WxZPmrfG~6+s-b^S3>`2Tx-bPAJ%Bkg$RXLC%%?dKJU<_h zn;{I%5Qb(bhGxVv)FBLA6ox`%2=A&{UEg6cbWs>`8w_=r3|$06le?kR$z9#*EZ&f6 zGBluTs4)B+SgoA~L;HoHfvAT5SuwQVWN2UtG`a_K=A6k8=aVb4PZ*jh49ygVW-5ke z#xitB7#b9Y`p6L8!?U{nkjc=XFyu8DI%G052!?L!-i}Szr|Rr|_z?m21akelhTuD3 zwOR~@+J&M1sD?gP47HmK^-qCDw_?tmHW}i4x(`1c*r@Smk;0cl_;LtecnuP#k|UNc zi#vkjy4k&vQ}}TTKTgGuGnOB#H-aI#*}ahj3!Wljs7NtX6w8p+8$q2nyEnqyb7AX> zg`r}_P;o3nR&P{7hHh?e1QNkei7hj9v(#4=>{Ms6~6b9*Daj~EQi6Ncs~hUUdG zWc5bXWa#GhMy0|~sW4Qk7%Gir$m)%}Wa#GhMpEi7Oi8J`FgD-DT6cjTcb|2~Bo$Wg zR7VC!oyZDpHnO`X(!MU zGSG6PX{U}BGwn3ck~;0gYq0gC=8njxOgo8|Hln3^RA_0=2rbrdAwaa;wBZ8L5+Yi> zqe9E(jL>2UI_lbpmI0zACItps90po$GzHetVy3_bT2iOL7frOxiF6Ia zELj<0#TvUfZu;28O|S$;1(y7bz+#PEN^bhtrJ7)A8x2_8j*QS^ja}R~eeB{TT0)~j zOG!p(vBoadH+}3oxbsVf|#UIwQ;?Gg-a_#-NbsNCnOWMxm09qshNB?l6c3;;v?BGtj zt7|WIaJPLR55>{eEp!e@tZ#Hj>sav;r!S!*P zMP2Qj6{xG7Q-`|RIqN>cvo!g_WTTE1&(d`8Yt!7nb@Gq#49y}ObnTaCYhtg`e^1=A zG*4IhYZ9KNd9Kp`M#8f+2P^#>5}u_wQtAJm+rOy7e86Tt9xGNgpvM6+yuiJz+eMwIyRuDV*_hCHn65+ z18X`WwfElhP1JPcP!D)-nX2C?mYYmjTz{>S?QL9u{gx~&Pr!EdY`aZ6w6rI<_0`$8 z+q99TTY@K_C_Q5fo_K;49_3Quv5FNQ5vlNag7@jyvcf~-JwKe8t{q~9hsNXtC+;v$ z;8x)*R(Q-pg@>G-$O;d+Y%ME1Mp)r-FDg7HV*TQf>X#KBt61T22o)YjQQ`59>J}9q z`go}DxEB>3cjJyUI^|JqB4)@$RpGJHnL}3HvaOQsVk!5iQRN;B_-FO($58d5Gra`6 ziCi^j;)P&Tinvezj1FlgTVsMS!M6MY=uc}9_2)GWAYb^b(<QO6V==#5^Zc!_ukB3^3 ziKrF17x%UeuBYRk_jFY&veGHVB5v7M$#$_6j?}2akp=t%u2DZ?aE;wWt{EM#sz*Ty zxDJtP+`?PSl|7vb6qJDL5J`{|Cf6(|xt9ec_oARg&dy>%iOw|&N<`PwP*5@pH^ggj&2EUkT&Ovq*uQFqvl2$pb7pc>qNxa`tW( zo#;%X=tNXK9R(zJqv%9unnfot6;W0Ho$3}vC;E6OI^i{($_CS%;&-c}la)@XI&sUk zO16uo0;NV(pe*1YFpWYKgK6w0GR@Ey6HQd@AlJATxR%RuIu)o)0oM+aASX<&S(!4C zl_?WZnIdQBvNA>I8kH%c>RC|RTvVp$Tyt3tMkK20e^T9|GDRN`l_|4OnZl`TaLp+` zS5>C0bV_N8TeelQT`XlPHL6Ty0snw&RI3gj&My{A>!nK=RP=TPGy5@PVswH`N~SC)UUW@TP54YQp-}KYFQTW54c82i@`N^6S-z&iisv%d&%_~ zGKU%$oeI>rfNQVf3nxsjc>{4eYh0!ybQ7*0V2z7hwwAXIMc44JFv%?j*IZSD5sB*h zSBh)Yxai}d#$_&QTsV~tt~tdYh^cW|iPtgHf62B=wu`0UrA8IJEZ`q7kGdCwdF&=K z&qXucYrob==5KPZ?G&zMTP54YQWjIA%3>Dq54c8kjKMW_6S=l{?bn*g^-b=zVR)fy z*;dJRvDD7g6oK70XD{F%a9tGT8oP;HTfFvb0djqgK)-ptcCm0R+bY>EmI9j^RbaD# zf50{BYz$pvH<4?L*M2QTu5WU$T_Rk|wo0~(r3$A;RpBh)A8?Ig9D{4@CUR}@+TSG8 zH@VlICrrz>O16uoQl~~$>MY_H@VkFbq{`xA__N4eyXsyN{%fyi+^fZ@$-fh|12=- zfFKU;Hn>fZ=w(5PVzIdO*Sr+VXxusqd@7x&`awKkaqc);^T^)(4$q zeGsq3x8K9gFpu1drVqKb(=3Eh=TIM{&Y?aCF`0H+u0SD-esr3&;}?n8zDg-RMHJ9l zBWnT>3+jUgSs!$c^+Aet)CVahQ6B`cc(tR+#nOtJnxiSjg0o!tutXDe&3KVL>x1If z09siegrVhx89-j&8i0(3Bt!y8tO2y5J}CVfK!>ToX{Zmv8UWD}B3eSDL5sUFBeYn; zjhlRt0AeYEIKMp_thk#q!ipstsS6P+pA!->YY4y!)T3twCRVO*4ME3>xrSh1CG{G@ zHWMqeP#uId1Y*TOtT;x6l|V*Vv4ks#Z*Sso1yWhz23FR%M+KHpMqshTGj$Gv1;s&e zs}KMS^rOIn;vg6``jP8fh0wubu0j}KNxcej(FDs}6bE4yf?)9yEZ)(8B`YJWSmK=( z?wdN^fmB{=1XxfUGzu?-o79mY9Gh zCYF)45h4>y7~3q%7?92yhAt@N9YyHk9<$KpCST_uXc)Sn;A1qP8MDx3P4mqjx~yU7 zg3`=Upyi=43tevVl@g%kn*dA6Xt46on1wEDLN|NpvWB4xs_aICl`#ulZtj&98Wx5w zsDdAbVR>lGLYFm;n>}<{!_Z}gcQjzh9;?vhCSR%nSWp^-IJa?BU>UQ}WzAP2bYX4K zPouR#cp922Dp`a-QX_O))d-zaH9~``Mrh>zM^X2|=i;H#gY`qG?O^>7syR?UG=ln} z5!4Tjpnj+o^+Ou!ha#*WdI0r95!Mggi~6AmD?8?*ekj8Fp}SE(^Z@FIBB&plg_}_K zqJ9WX9olr%56wmW5SqKSX{aCKJyKla)h42TXcp>+rlWpn1M7#Tp?+us>xXztbpz{% zHn4tZ1L}u1uzqL*>xVY5ekjs#zgb~|B9h~Hf*YUQ^6^nsFvP+dRWMY9huq=WcctoC zca3}$9v*G?uKp~%P5QI&-qW9j$C{lY*6b9qW~XS~N0t6{ZI%AI!@%I%XFW=>-uF+y3ey)p1d#Jr~kJ8XVQJ%|LKE& zneKDwg_-|Xy3c$6IO%Y@kL}sFelOkU*YCKT={{T6=RJ|`^ZN6@u21)g{PVHKbf3S! z<;Y*B`@G)&*DdKjEwBFdsdS(J`0%g8=|0yM=S z9QlXM&!zkP=H1J0r2G8Y;y-;a-DlM{*9Yl7JHC;BQg`hWU( z`f>Vd`eXWB`c?W)`bYXe`Z@YC`YZY*`W^Zf`VaaD`T^Q{+H=}$+GW~a+E?08+DY0% z+B@1b+AZ29+8^2&+7a3c+5^sc&TGzH&QH!k&NI#>&KJ%J;-1(heu+`ykXR$0h#BI7 zx~G1rL+XjTpu8zl%56!7=a29Gj$_r@m9M`#cj?O7Wv@A2uYaj#&GOp%RSs+%>q@`# z;&|syU635av9rItRtE;bd*VesKvu4f8Z`Ia+|4*|dSbOrRXyNzI zE8aTmUg&qPTj+mp2#@Jo=wF>%<$tEM+<*I`rh>0k-gltBVxhnBJ)A1{FUir5t(jNh zZ_Gy(v^+!^4^v*~dv6o3_I=QdKNU^#jKPIXA2DYCxB!Q)Ua0uH3jey?3V&VxLZ9@v z(6{*mx#pwI{NsPKsM5FXo@$R93}XLH92_FM3|^Jnwn#`Db5z;lfsHlzM&q z+kaYy*g8=L_P2`FaVd7^!=xbxhSDmeW@x>xEmkg7zUIRqugdp9Nd9a-BY!^X!ym|u zA$`2edbUTdUE4GMZ(IW75*U}jxCF)}FfM^{35-i%Tms_~7?;4f1jZ#WE`f0gj7wl# z0^<@Gm%z9L#w9Q=fpH0pOJH0A;}RH`z_uJFC?tf)YtNp*#< z%=xr`lK;gV|6{rSr}E1D59gQpA1f&HKQ*Jw|KiNvTdmb4n>P8n@$AFz--f3h?kn@( zQ|{9|zHJ`+9#2c_t>wObX6Xmv(6-}#h%54|Q{L6DZzC-1{WBN#C zzU>~@A&yiAGEgOxQ|>xei2ga!K^)^qY+<`v=IiE2IPW4Q7-);f9H`tiTxL67Zabj{ z8o)rg9$#y@uR|ZmKzPIF=9Rnl7E&}AC#Xcn^JHkB8fI@9 z54YY@?rXDhG*o5_mD@rXrl8!`hhg$PKCRqTlE>HWQA+YC(dCyz35B91kL@51DvmVa zNVJ3oM>>K+g`3S)J=v8Jg$y1#gWIhhY}(f z$dQs8Pb(Fb`C6h3mANiMKhTmIhH{%x?mOf$~FaC>71{xb~JSFnDZ7a8QkO zNMI+jLj#YJo$r8!pDWM~bfg9%=w_DtT0Iufb(SkEl(~eXnI6}%GKB??Er^3^pjNUZ zpu>43H{T8`Lqkvxltc}ciH02MrqRnz#~98dXZm^M9J6^;lztu+jo~~hPB)K=$7~*z zq@PD6V>gfHrJG0d#%vyyrk_WpV>XZGr=LgjGoMGjxrUc2^W({^W&WQy!QENdtf`n{ z<-UlgW#5Ey*J)47*=!t~RPMUuX*oXyM{g^4oyE7Z?3bO+vTUalQ&9}XHZ=Q^JQ_*N z1%(icPzVp_@kL?@EK>w3`tXJH6>&pH2m?c32m-$pM@ae*2h4{obovHHDZye=C?1p) zc96m^$%B%{e8@1zq_KZgGAtp5bi5+Ny`=C|#kpm=(QaiZfd!_$MhnUr9@NnZabK{%fTGDeVjEGqtTVg!)r z0ELi+ZfHg`jcH|GcFv?lr2O<*{3S#dpw3AFLK4?$2XjavW0I7HaI9hy&XXe02ytAO zCCnv-3}w<-!m$WtNMA992x(lWDa<2@5(K3-g<}&0k;oFz7&X_UGllu2kg-!rQ#cl} z6RDd=5f+&u9IYuVAb}Efr8b3Q6Lo>cQjl0|PL`B=#=43yXn~gP(Y^-@?W6dKQYaZDz&aAlqb!WA2lJCVF-($HxocH0p59fV2?|X5k z{ihf4+x-gv>&~a~vIhLr{0;mbrUpMXUyh%e*Ys8RUl^)rx`%_|Cqb_}^J9OZV??T> z`y2oJu=wxszZZ-D9{>BX`0w$*CyW0c|NFA|@A1Dki~k<~`?L7(@xMol{~rJQwD|Aw zzgLU@9{>Bb`0w$*XN&(H|NFN1@A1EPi~k<~`?vV-@xORgkU1W&_#_ zd+DAdMw0-VXl!!HY=SEzq-G>%6Ha!c^=YGZ2nV*=OIwdw8((4Lh&28!8lTr2+t79^ z+Mcwwy~?&%q%COLbHV7?fyO~JK5K0}!p2vnF{q2u>_%e^jR&obQ<3bw4w5%EVqN+= z$llnj+K-mqF-qzatVmo=vGZZx(+oeV7bR$ zJEU6GZuF`a+cud^Dz5F~mtANU+0PB06>|?J1W`}uUp6MeD!}!-_Ric3_6E$9hNtrR z02Ds|1P?*sgeu~MD#C>F1#jFJrI^17cNjLFZHCy>-DI%OdEM!?&-sb74%7T?XCs

`jh-oo~1yX(B%&?e;TQIQ~ah^YkiBOug9eXd7;O@lEAquNBYaiOa0%X?W zhs5~W35a@Rqen~3#VY9^FZUKqqt?hhlWnu*@ACXOb1r{W^q z=IF)>I`%%tMYmufdbz#+U^W+fZM`lGAK}XNgfiQ2aJ*xZ$94q?5A=Tp>+X^^;_x7r zs4kTIV6MWSk_O!KD0l544ZH^H>&I~Bw5Jg3`na@yaB5lh<5(UebKy^=rI2#TwH=G> z(v-JI3a`n9^_^47ZJ%KQ9i#*mDX^DoI&Les4RdwZ)`2Uqvc`2X1kB`XGS5%IEt7H= zY2wL=veFAF)m>}EiL&fUu8KezYr1hWh5dp|2;w+}K&BxGTvBd>V(Nyf)v`LIup5H9 zGpdtVrwo^#J zJs8|WavkJdX6wQ2E)YVRK?sZwpipMJ%vEF?Sy4VH zP{4`o=bSS4;&Qdz1jE3eKuVWvSCBSZNj`8unC+-V`URc)7gY>t4+8vrZ!=I8k^v?qQGhs zbPeb>0oKQ~2_=ocdJB;aRvvaR7bmYXf;P&xt4`ZZmrg>X?p%3t9MiqH#epaIZgY}GUAA)9 zx~DD|C#^`eIA~AQH%BL-a>*vGx+7M!a>l9Irf8+#RlQo~m-4o;epdNdb}FRmu9cm_ zt%^t0ovHGp>@(^^;RcoKR9;i}n95t~bn?cmI7!vzF4H%X&Zu8hE)mBNC!$c{VAK;P zI6)KartcHKr@K7m?G%|7PZxD^rh^mTW>TqpG({zmt5B<`WJIgFv_!K8L}UEb*kP33 zXUs85k23ZcE&ehvYO(uchEZ7YF~=w^^B7}v{`Fy0E#Uh4vM0$B?u{?t{<2?Rz_k}K zD=m_&w8+Rxi&R!xIR=ZnznlCSk4;UJLThi%H1#l|SRJ(!2mEL$}sm|M{&*rFj8uyVk;#MmbIGG81Mr)LkcfMlQrV zBFEVLR_znyM3O_Z(PVTr$jU*OBc7g!)5w!`e~N372k99!TFAAeK|HJ0vZnJkUZf|` zpgE{~EPI}y?F?T+w5%L0+oIC3gU#t~c@h~k8^V#d2DdEC>Vxs(X?k9Cg&Ms*hW7@rtQJmr9~}#VtaK>5gS!WY{DEX|xP&I%b~aH6Vg1GzacyOFfC^z&s5i_QFx) zD{^E#lUA0Hji)Mi&4!A1-Cy$@hKxFFKw+LUc=A=G!)%N}>AZs{-$YK##(In#)xOkJ z1pzeJ9ZmP< z?VFBYq-=VBQ}-$ah*3T+0m*w*Fu;yQ`AC^nM4)S6FNa`UAUg0J5eg6ti1J}(g#v^K z=*ki%fT7FP$`11arF)e&^8!=%Dsg7lSlu8ANStM0BZ4qBufm9^=2hA(qf!Qlb`eqF z>sp7NJB!Jd!6mMcVOig3q{{km(a1t9K|Uur5@5wN!wi~%A*FW}5J&Z{&-$JSTsUUE ziaV}z+9N3@-zs7j#b-M0^*F^;ee!AF6FE3m)4nHyVq;_47eV#IzoTi7fvG5C+SA7C z(_RmNDBA0p_V5Ip<|mc@jd}m7{j$=(%8jG?*XRB52#b~V%=mie{qPvidlgZc%1<%x zk4KJ-)x19*!OYXsgHkB}%I{>}b5zT`KaN-asClnKHBtQa%=>5_Y{R-dyVjjPd(z## zJ|I1P)XQ5Oyv4U0ew|nAbK5elEuP%s#7zfo@!h;$`E3rayKKw6HC?sEOItj&#W_c3 zDA%>s2|6aOX-nUxCq`ndlfBz?z-*yAU!K$bE|2PyF9Ub0_e~$G_stAh?;8OH7o?*p zGMmt!T3o5=MtRPfC9@Bxk;~0IrRT{!3V#uwD6<7@1=p3?g0ePKWuD{0k(n#A1)U-m zk$Db1a{-x0(X+)5T3n#T`$5#o@kO1U;qUa0xj?LU%$!;8m@e){y_?=KJ(}LJrl4yC zuND{fC1F~3OdNi!F|DW3()Czl`bK<-NL%?2k+$+2Jc{`e#dL{UO4Z38OH3;_ARUh} zruFn%x*k(ZU%%xZvpoA3MxHG%_V{vB?PyYK*&9v%z45FUzn!b#59KzV%DRe`0sdBQ zV=#bw^SnO(=W^-pWP{I&x6TLrlNS0u$B*hN?(4ws>MHGJr;_}(PFr6)asCnH-s@|p zJ&wP<6Esubk<+0(~?**8Uw=p3PF(K~@Ze3rR$A28x2`IE z-08rVwMfvnzEb*}v&4p^J@^Te_EpH&M2cWl&P^#}7B<6|!A$LES)zGQpYtTY_7vvif)mlpu;gx7#@62@sMu2X+@M4^{KX zfROSTNSQfR=v6{NXd#4&RPydaDz9jH+xau0aV{kUQ?$W2poI5Tuakb_0mY zCL}D%l8~Sb&A8%rMKA8Vq8GUl5i?mxLRdlwyFvh$31JB!Yk>Uksp^`U3<1Ku;eOwD z|KWManXaxrb?U8Cr%s)&uA;ufG}b>Y{7WXtdJ;q^Y!)djxJqbUt|UDpU4U?fJw~?( z5i%`IjsuV*AOf(TM7V$iK+nP7GKe8m1n<%r4gpw~ydElonIW@1WIXm)XH$|8Bp|`A zY*P+j-2U&{6a)+ZAJRa^jfS0%2lk)JL+ z2Ypcu8e8~S51_`l#`vn^dv=wf-8HO}7_JeWE<8tb;osmjl<06!sPV4Rn*?$X+*Jo{ z_jpZQUHytKJcnsrp~!>cgEQJ;0s<4n8f~5BIUr|O8H`=yFtHTvT(OVExo-Gyv$o=q*LvRCTadoG^VU+3uG*U?U?~orOBbx%$^wyf zc?@!`qQwjr=h6M^=dhuGaXGwzW>`QqN*u^$MB)F5L+l1MxauKxSHctG@6Bc6o8~`^ z4;yiPO98)5m2s`ur+g}^R9~-BDIGlbzk7=~pL9M1dHB2fMMOFIAHX0P)PGW`&S#Q6s;=&XaP=N~k@_iE=KTzcez zYeS-K{M3Wh;mHUIDz+7Db)I5d!ZsdKY-!3#kSUBT5oY|XkoQ&vPR`t92@IeqqeI!0l^Zr z*4i!?w*st&Uyz1dfyiMsoVVC=Rh&zJ43?<9$k51Q+pV^%M20q2!y!vlg=*MuWt-6( zt%eiWTF_{*ZME9==q0JPTuaneDoKSHbTEN6YLC(m1yE7NNTi}Di{ZR#+e<}JhA5H+ zMP*uCMOmZHsfK-4wvT_P)o>g|g;;DGthQ}>Q5M^FOVkD`${LkRMTJR^C>~V5Q^@ja1t@cdeADb7&|u7dG@astmvgtL;~d z3qZw|YZbNHOSOuyL~T=ftt^K13L!;mLA6pifRur^m1=XiYo$h=MMI&vP*kYO0F1QS zwpv^OT5Wr+TrDiTts*T^8?9U|EQVi@$cx&_G>`#^3>FTcV%y?bs|qv}nv2(pG%(s~ z+hFm~!0lG97HSl$RkS5)kIL1;V#r1!FKPp;71KauK&K%96Q-aIV`{NqDOc`SN-g%QxGVRoxLWL2gRk7L2G?T08g=Dh;Ddl)UCtA zNkry*5R7`SzCOMOL{tby0CH`%(MU3q({fqkSX)jDb(uJV5r|xybqpaH$=kVtb*vq4 zhh!W~I0BJtGmoJpBXg53ZysyMO(Gdb5sr#W#u}Q(Fp`n{t1Fnt+VQVQ#_@z>oJ+iG>_3FM}wh%?EByu5j=9TzfrP7vZVKeBTacr*pRqrM9(3uJ? z3+t{t*7(>E@9VewwfyG8`47Ajnlf?M))8&K@V@R@$NvL5b79ToDY1s3sZG4E7k~HL zLu(d9h3{WcxixD}aJ={RWZSdH=}d-iU%#RI#c%f{dtcw(B=^=&0)KgX$!$+;N<8+q z!~43Z@3D93%!UKE_KSY5&zhs&*WYhltgU^=VH&&i%Ngt63l8_bPO8&s2%YJW`rF5o zK2J;R>wR5ZA^-HtXUm6u-@kF{u7zi(dtZOCcia&=^C6_mirXiyjr!F4`hj*8Z|1f4 zU;bO8qib^K4f)yoI`hR(@1rvzZvCYGt9@SnWUKe}CB4$U&s#|C){(B_w`$4FYHxkDD6L0-ro29>g9KNUne}8pF?L_6fBYt^!yjBGKuPv6BY%@e$@g97UY z(3v5oMh$}-)URjszQ*|ZOG<@iB6Gh8LgPx~*-ntWs4MZO;*wo$kbM-9*}418O}?95qy zS+o^HWOnA$eg*h7E+#wk3BN7)H7PDTbF|-X{F**0J9CKN0sNXfB|GzBzp|WJ#j(k> z!P<$Or;Dx2vNL7Bikv5k<1=zb7Y{GVnKdsUJZDI8tRH@1Ux%EBi>+b!iCrCXEXDCL z_=!Coa^&LSqwv#=;HPaZ%Zam__p*GW5LIQl;Hl)4RXs03Ny#B#vlE^+Wf*xxq&z#} zVbd>0t8G0Zv~^!pW@hC@(@Tk{;%rB^X@x<^6@UP1wqsUtmbG0rZmIZA3@*gTj zqsVTy-0i3X?=BGg#siHIIeu~c%&fis6q+@Z(cMfQ*>02b*|hC}s$rMKP(eI1DBx~l0;V9iEO#u(Op(sHVK567S^csl(Lf~LHyOy4)OS{Odb^<4jS=7135?tARC{I zw;G6wlMsk0BfHgbkO#WksD{l}!#Rq&8nywTX5rZhPnaAG2CMCOc7nz9E5b!o9UTU| z=&(j*yJ0{?Hrw%Zae<}XdfZa+O<@RLaR?kpT#f_Yb|6~rfx`}eJZd$RG8}j;oNy=v z4h0s&Sz?+;AzH&mPaKXh94x$H=uHTk#Erv7OFKmHe`Nxx_%;yk6JD3F#(-YMFT;Bf z#L-dfuOf41jOb*i(!jmI7dtiDXMGO z<_1DV+mJ$7xI)mIk*Hr>Kp@C#ZHJd>00IIjXp;!MH9-y%f^{prI>9><9SDjE8%p_G zsx}tGI@NHNLc4~I3-!?FT6lg>=ECO;cAVwF*m(VLpmqt(++2G_awg5ziYB)o0S#*tvB%_e+ zwh&L7cGSQ^3@JV!ECjx;u9=D0UA>9e;xZ9Kic9`w69Iw9L?|96Vn}g3-qqI7L<|Uz zA+wOR5cGkc~JX~Dnwh?hPvk|{kZzGCbHsax8=f7+tAQ0Jz!5%i^;o|tPYp@Y< zWER%CZNzBPt{T_~OYsS}jlg`nCN|>N>TSeUmyNI#m;cK)0s@hZ81G>tXjHuh8-Yy+ zW|?x`He!frcMWWWTwLL{5mRbrBlc8pBeuD0gj|e1i<$nvVk00B*@&4QHbO3rC*Pr_ zHeyQhPtU`VLJ;6%Y^VU+hSw`-80JQB2Lfs2ItZJJTZko zP{kD>rY=^vXcvczC`9Q9Z;e7b!y^?c9)Q|zd zhilGNAE~_t>6-OD&b3@0jcJVn4X=a%mM3@)zWT6>B?;~kmyfl4^#^P`jj+7q5LXPV zRYp|e>KaVF#?hK}56%^_G&W%Jp^|gOq65$IQ6Cwr42pa>eknfXAT6 zx$5Jc*HBk84dz_0sIGhnt9^a-7>#OCUul%7eSP&9Fltd>X&|V5ef6;EwWzOTt7~6h zJ`f|H1#J`M(w0q|%t}3IHJCRNZVxhiQcd5dok{ zKeEv%JDjc4e2x&%Y>bz#{=o{q98uJv!DPyPVoSGMc<>JAI+qDd^t3Cky9* zO5Tk1EbHlU%+N8HUD2T#Ne5GtT- z5Dswz0*|{F0z4i*(9vUc)6wG&M>XhGjUq7dfSxOiz|o^H0?;cL=pEC;3#|HtULnvE z!32RGrm-A77E|Ep@t6Wf50M2bj-$ti0x$HavRGYcaI)wFLJds3%f%W97r^HZHc)OC zd_3BK`8;JKUq`@49uJ341RWTB7)4|BJXE2>=c#?%Evp8;su2q&AK-I^EI52L z9RYmD1$-y;PzF;U(!L_VCju7&KE3bi@e2+gk6&>35XGS4MBk+;iWhvSERSd)h#Fsl zum%(Fa&Zm9g7)3_WlWzd8p7aXwoea-c)%xYA0iG6zRTJ^a#(fvJhkr{ZJ#SV zQpNWDCE$bYV__4Q?JKUa?IYJ$bl$7kKB_FYeH2x>tnJfdC^~$weR>e33Vhu5Ax@#g zcNyD9KD6F>{|(#c3aV7GeJ4fdh3!MI#l!Y(t+DMRZ(P9V(Rr)2eH10)wvS>lm$iL* zqM4rL*K z&K0zwd+?Izg$|F}P!2t8L-*=(UyV}9P2&!l@Xdr*#vL-sLv`Y|s5&#G^QPd9&YND* z=)5lt-%uVsenWXkZdU+@?m=#P0mCCWlml{82#4-LZt{{S6>?u1#GyQa+!dlcTyAoJ zs_XxhgZv!?a_BkmrhZPTbD*M(&esQ{igAPD}L?kw;l{) za@SP9uU|Z<-cj0p?>`g|@;FMOMn7v3KT0{2To-Fc^z%D#$`o5hl9HVqfK=O+Km7bx zSCXV2Nycgt>_Nyr8|}7@z8s}J2&?^i)bqSeoa>Q{;L<}c;m^Qj2r=#2&O%HhmLp*t z9r-bdj{KNTM}EvL$I&3KQ8m1e{>Yr;m+3nCBQwcwojCd6Hn_?=3o^;OpcY-r_90nlu@*Ye{xPNEmEQUR-7xsa?=_W zmKS=?bHYIDi&E&k6ZTwoLK0S|s5lXUf`FKIW%sHmcXr55h{rk=`hBKJUz`%r59c0a zJ8s8}H@j#0@%>ghoS~w;yivAePH|SY<5t95D2p}fB;G`4JMKonDqFtwXl8aoMR`ek z-0B2_PYjBJvgP}ZIw-yTX8P-F(-&uq&?Cp#txV=N{DBu#IUgZb#d2Gtj##lTGiGKv zUm;ROg=VPOgx&~|;o=aRgB^0dN2rPl-IaZyqWoMF6e{(@2^Li9@s4z3XLe8L@qHFL z_@koyIAp&Yp(^$S<%8@riDUVCRg@oq)Xh3#DB)E^%88q)&Sj7{xG~x-KAAaBe~{-C zbp)C3g`WhRX{pOZEOk-+7vpdk5)fO4SCp3mQ!5s|(C@Plu349UIMhO5T7JBQFvY!N zkSnRUKwui&4sy}#%>z?Nu^-X}cferei7C>LGZ_y;#x@Nu!4xmjfN7xzror9de*x2S zV475%r{X+{2qrRiAV*aR(?U0<6%ac(svl0C@Wk}7lH>bzOixtBGytIl<-X!vkUiLqK_ESAkEX!RH-AvQ4QiW-Xr zH1zunqzj0IGO-qiqCR%!_&yy}Qq+mXTR5ncg@IZ{Q6Wr8d)z|o5>rvPq9PrNioeb_ z(7~qFfVv5wZgznR0|-UP2B^mX>crwg1$v-BWW=kO{t&2}bx^Y^$}7;nnstFrFiRORbS| z$PMaQh}}klQdQB^p37aJQZIA$e#*x{)q6kWIsmByB49Xq6_JA8kH2M*7n(Yj;$Aep zLGQ=D0{}H38pf4@%Iz^3(Cq5$Y7$oWkjPiFtHX;A2pdrQc6EyjS+`w10c3|4msDq0 zx43OUZQ50;B9N$JSBFzbOh@)oyK2Sqpla;uL@X4d*14KpjV~?}nAW~sE%v}v*ww#) zX?(G>I=foz#o%U#Zsec?CMx7IwDZ7W>>An2)&R4a6Rnm zRu`ylyLu9!T8r^&_ENjL)olW5)2>n#fkYL%O8%DswHmuR2g{bKv8%(efQj1YYIZfY z7|RklsI_lbw|Rgn?CL3i8e4p(I=i~f4Qg%LRjMLDtzuVWDW-OLyZRORsL6Al$1d3oU81tDlSzOKgz>-O7QfXaN*gtMQuEKHTC2YR*dkF zAeu)XuHqGuw*=5A=nc+p{|6VIPLI$u0!?AFn_}vHPF9TW)lh9b`dk$U#=~m?XSc(G z3(r0!G|j_Jp``@Zz0crdf$QF3RP00Go?Ts^-eSg{LD##hU_OtiG-(UXysu9`QSrI8 zn1NicPJ&(N8Z_)eD&W@0nytn1M|*l`$$YOzmjzYJ%gtgC!uYrEpN zwMzHzUqY`or7g#iGziZiFoT%|<_l%Po`E}A3A!BD=@BV0?n9RTCXe%MlKHlagU=}1cx?GL%BgDOOH^&j4pp03XtBLX#>!&*bQy@%wrmF@ zpO!GPoVh2qbo#0jWgPvPm(c=XFGG?z0wx&0{}ZR$q_R;6uQNfDZv50zL$M2>1~2A>c#6hky?O9|ArEdwRZX19-#%UyWZo;%6j&buZ8F{7g~bn5*&IjI68b5x?&nT^7GDUHp`*@k=3o zbZ)$J?B)3lbMcG28owyw*MRsnzdXNS7r((*|HGVSjYef8Ry*$4*E`C$4#_!m4@G}v=t-+VA?|Nk7^__V&e(w># z5aPGs^898Ce#?@P6W=FX!Tu3H90UgYcX@uDUHleajh}(|H6eb#H@Iwle@W8oyNq6t zT~WSok#$v5;`iF+`7L(wqxW%F~R#c$cw_%$Ye&57U1`j@Tmp+r&N zPp`($fvl@;AbuZRp5IazzvamJ-#>o;|6aqIeLqIJMaKlH_9fxT_-(P_^NFe0?(mTi zi>)X2sNaGQwZ6sJr9|Z`QAMg2gwp|yI2|wqrvrwn+T%DaaB_sI&4^UBdGL|~!pTW8 zs_>aYRNFAxsujo8s5Uz8#+De4&F*o4hbjfC(nCg78W^HVBSKYaT$n0NicqELk*YM; z!`C7KYk8)HmepnUPu0>+`oYL)0pZ%P@Bq`wJk!czhciU6 z-&GG6ioHh@B&d@4q#sc(M)-S-4=RrZg3$Nds7E-YlhIC`}quU1`z;lqMyz(wcddCe21^lDXQ_22g3k zSZSeNb(6+WX%ng~Es;u_%}Q(IU7DFnQ>!g)7?n1Ll@{h*+5{>svD(sRQ)y;aTDW&< zDwQ^%+S0~QX%kp!5#FUGQfafREzL}&sjRfl-lYwo(uP%A+5{>sk(CzdUD|9a&0KA1 zDwQ^Xm3EhRX~U?rG1ZoqNTtnYrA2#}W~S2AYD*hHr43`H_3$oj43##a+R|oIX=YYh zjCW}&l{TQ-(uPrKV_0cs@6skvX^9sMH8cR#bQJ2As3vtw%$}&ry850(-4gYzrdvX* zAr$YHs<%+oErIG)x@DYqX*KJX1H4PCS+^YQU0Th$Cx3ErjEtXod;F0E$WGSR!Vnsv*W-lf&7Th8__tp?rFyQCU)N$*lbcU0{B z6AeY17OJGKH7KbU>nC=lLDL=2xbhfV`Vkhd@%e?;9c*w=a4!tn=#N(zzoj5mf|DS@ zNs!NF%agq%jj)+?m+ zBW${hvf*mXEmXDXSSUBC1ZSl>6>U(_JAyA1{H+I2_QuZ~Q3ThNa`e*fbk0S{LST2wT-XwOeS1Pgld zYY*TFJ$x)boJ_Wt8LTA-Yw|cZx4L?%MG3G5%h%@>bwxJ*sw^@4?bA=!PCu$76}?0 z92q>SN9Y7WpMCb&vz3cZr3rfX-Nko*zwgOpL2tY<@kZwp_ns8Aq~w#5{(XM^hoIZH zKfC?Zm;Y!dXlCY}nIjIrwN%hM?nu95;_JhD3HsoJQ4c=$%-VBW;ka2edQ6Sp!eMK%RL{JO?pMp-+%wd@6Qh0{;r^Z z|9kJhFWz}eQ$fqi|196q;=(P0_U(Ij-zVoS2@`bJu711TeeE-|po0g`8GP>6|`r=K?dlrrt{9)b=Z{>1R2yH>p@Xh=v*NOD}*OM?FO z*Xh4b_@4|Cw4k6t!3`POuY%rx|C##_ubsU`(7JV-)s1Le|8_wie>~;!wtsA#Dd=y% z{qwhJ{kOg$X#4g{+u!=*Lv;j|<(6`n!SkmHs%bZ9o!@EjiJ+f+^6@7R8~UFTbk3a8 zIh&T8`%ci;UYqpVFBSJ#1l_Tt&yE-V{?$N1D=G>qw8*k4f*v{Y_anDF?bsmb?AeEB z=e~UAM?uGpTQIK0f%Q)d`s=U#fBpD{Po5RDMT=oA(jUCz8$tW@G4!d^`-NCR-+i~| zyJ^3)YbEHqb*6PU<=Bb^z5VtTwz=wLyA{(0@sFV9N2L(rh0HbK#?8$BTCS6{vG zmEo2?Ukdu@qe+h*ymQ5JK~qz^rcU_2^-)13X}z?da@tI3=F|Oq1kK9}%4@XAzE;qdEyuLXo6}{ppcYFv%hV~a zbQg5z&R#p8``b4o1wDTJ=<%!@&qfJ);J_CL9#nRJD(KLmPY>PSapez!E?n4Z;a#sZ zTrKF|{`Sb_U3K3uTdKa+NDcg7yG#}KMT5i`MKq#edi1jw6rv}wDpsl zjtgos^)StpZki$J)~(aF{_*iYB|(#u?@dlzb>m-xu33}0=B?SI+Y7p3L(>hn?RmaT z(7Wy`yz8gm#@hr{l>3xtl3wZ}Xl3Q@$}hqX3=s60XHGtI`o+KOg1+?9GcWyh@V+sE zjvP69Waj;A-Vrn`EII7PWij^&I)405;~(EzXcDwltMRQ0r{1+r(67Iq|8>jG@;?P# zvEty0{1HiW1RXHII^b_DKiw$ktFKOf_0aF#=LvfA%?odC___H`LC1`-j|nQxOAz$f zu>;3;H>vC>=(K5@r~Ul?AL|8e(nM+U(xc(?1&xWhG3MUk$>tB0>~Fv$8s6 z4RpT!nxKacy>Uo-HfOJ(yLUgh`}LQ;unPL#du!es-F9S#pmuvBdz;jtmj%7=zQ67} zng2|lpl`e}?v3@wq@jW$xKZWbrcJFkbuCUkD`=-qUv`Rpe&8fQr%%tB{>DEJ?GtqV z{4VpyEN}UTpxd_1+jjA@ZzBY4-Fian9W%OS3Htr_N4|FqoslT$H{U$>P2&!|)(HCK zlixo%V{h?|g2u%Sk6YDn*+D^LW9!8>Q5Ovo^u&p>69u6a(Sk-sW<XU*LuXnedS{*^YL=L-7%``^7kD`d!SK@T2$@u2_M{8B;3jg76aIqUdFSdo$G$qEub>$j?K2+w^PLw2-Lq%po;4}&jS{p?n<;Hd zCPbeX^umQL7k<3s&)$MstsSh7jQLwzL0@@g>MOsMbek?{Qc`}>i>nWw6ErX|G%)g} z;I4u$TGV7w_?z{<7WBLCj(>MHX4Zp({_)4peDp$lpl#dEZhI!- z{yznM?6KD#yXSOvyr9L!Gm4LV@cq4le){R(KJC!Onj+}yuRr#B_Fqwt2zvHx*4el3 zJ^GlSIXP0!xKnR@An2{PF1hvYZ~KM_I%LSKA;-hM`$EvPw2o;5zHVM9=+voOr~dPk z-*N=)*6p`$TYsH;NKjS1U43-)n>PqLV#L@H>$Gxe*Pbwe(xt}KtM=9STkd|pbtMh-Mb&^p0j=WMM1y#;`J}0?tAcWf^OW{cH{kig8v073ux&p-aT`Qd5f1T8Dm${LI+C=s+rkMbTD*3WbZ znv!yV%Iu%pUJ!K2k|&oGU%Wd;(9@?YPL~CrXd~zkKkWJ8y~ihq3fi=(+Vstlw`qcQ z?!3J7oTY;w7Ie#&r?zZM|FN#1fByO7pHB_jvRKeblRla>sNk1wf@WvmlRfFgYo811 z?;q?RY6@&E=)i%o0~a;@@)tqP=7#20mW3k(oi}gayuI&UTqfvepS}B8yL zPaUXtthu1Qd)Mt__&@aDS^ku!jwAbszt&y{vsQ=d~RbM`n-vwMCzI*WLbe&{vP9 zN>}W8~h@|n@UJ#!OPP5>fzh&?}6z$*r=1fV>G=l+p?lEQY!k4os?vHEkm|BQqDawX{?2Q z*K?$tos>9s0{?AFqp$x{D&(ZZ@hoR%%DJ(V5}#mqGgHnc$W}A|oisWfIg?HA--mUi z$y58wwoLkx9tm>2d^u&G0ZT6Alymr-Jhf{&5!3ssY2{{1CCle$df&8ae;ZTkm*9fc z3g+l7rstPY+50OiE031GhM#h3MS#2tmz7gnrPJ!oAMhU!qA1yMu#t?cSYyV%-#5KK z&NL~5o;~`roO;$TsY}W_=^HFwUV%wC* zjg^&q9!M<-Og$5r7?NK3XXR13og;lO65I8`X_ouK%(g<+mZjFqyY#gYR}TDD(E6$8 zOw(H6uU}&DLcBaXXi)4HL(*SNW0cbl5}DnxKZ2c?Erzl(#fB`8oKa3^;{(n00)4#nYo0Qbk4j%MJ0KK>Ws|8D&M4gU}Gg*(=$3~N+A3baP;R_s{% za)~?^+d9Rz*<#ybijr*^GE)}W^!2^vv4;AzU$H65mTB$)?Jq?{8W1@p6*a*t1KV5uxfI$3rPH7c6HIJK2(BNUBFY6kuvG$yv;xyuS) zGB0HKDwP=)$3de~xi0B2;BpsmDgSe|!vFueo%PA@L%@fC4*?$nJ_LLS_z>_R;6uQN zfDZv50zL$M2>1~2A>c#6hky?O9|ArEd_R;6uQNfDZv50zL$M2>1~2A>c#6hky?O9|ArEdjD#l&#oe3l%MCyj8;nJl5q_ z&8cX+RPC6uAV1NhEGSB>Z^f^=%7Rl#E#>rof`?*nesix@APu{NI1S5BR%&ymq*fZH zBLnS8g$xFs;StJE^3BOg`BaW65w~eiAl%;HlRckGw;j064&wp`(}JDY!b-8v4N zmedO&ruhf)gjKs>aU2a$?6OJezGFr`#oh})9MhUu?7dkE&MxX@vUFcNRkCVpRjokn z2s-5!3jT>ibn+7I+emzYq0}NdZ^1=Xcu&~{jlFG^&pD<0XcS1Hz4#ZwZc_&Qs z8d$Y#Mcbli1wRE5Ot?#?Qqc>`jqugD2YPDXF|H1g!n> z-y}H8rjS5-+CFEwlA0A_NnIOa>JYo!l6n+Xzme_tG%yU+kK}b7?_w#g*F zDWq4qqMcCeJxxk#89lj-;OMB>dsiymGpDv=f^Cu0afX?nsjVO6sn6xH;4~Cno(7Po zj*!P))5r(hHSNF|=1vW(Qd8pB?aK8tML;BN%Q(}#*yU77OX^`%%#4bamZM><{wGZx z62nJX{C`Cy!&G~3y`uXeFqCk)YELlfjScyMR_}7FeR?_7){=TC#A1&zNkxj?y37<( zZfZ=`&zsRjvB!R6>7FqaV#h91J7Ehey}c=;;=fL*%w#PO(NoWbOf4-lQEQ{wj>aw{ zNtdbGT9#GY;@sKG4a3c9_dloJ=m|k}^(Q=o1fF1*l!1z8s9F|p)M#o{bT3sKi`w-@ zHAt!VpuW6ep*}8oO(FhL5!KPFhB!Wq*RZ`_LtP(gT0d%Kf&%qBOD0e6Gysme(OLjU z3Vp(*&;mcSchn{AP4#v46jYN}8Op5KlQMXF--SAxBGAq_(hkO2vlc4SdS`|mmM0}U z8C91@$iwAfBS#D!eie)DVa$9#*IO54n9|1JRObHiuk`|GCrVxW{Y8(iI z6%cM}>{q%NH6mM&{}{>^oJ#D;p{c1YWt`c&*g&vTA>*+8GyK8iPtn&1G=8?2W+7 z!K5KiiU5yT{%WJq#B z){jH>GfDu{#t(&%HHuR;T@N*!0cwhX`jLGa#SdSHfof}GUfvXW#K;lDhyH(HncxD; z_qOtL)s*|kyC-vlVz1lg9w3aij;z+{ZubI%>k3YHdNkD0sd0YjM5C~y`OF@tU=Rkt zeFx((=W&fOiFV20Y3BL!4_0}8Vb?0pCz3mxnqB?#uyv-iZrEZoexQZi+T(C%O=-6w zfmzZU@q>%~fgsSwyIM+t6c;AegJ&j#+F4b4(4GT?w(xe>{LpR!hnE0f|l80t#NFiX~89F>XF zYh^9%jgZ~pezKJIV#r=lEzeO3**C-B=EZ6bi^Ca;dvAms(DVFBdGZ~lA$_zZ&(o8X zmlBMzaBh%0R$B4~apFAk`)G?Y_t-;= z*+W`EA8jdPhnnGWS;}M_Hfsg4b`H;`{ORXci87W75pdckyJAe1>Lstkqp{LX>8vbe z5u>9kj6O3oCN`$7b~Huvtof5m`JO|(@>SHSZ$|FNKq=D2GyT-KxJj=43wIy z-*%b97b|6=31n@D3}~DoDF>+GkV(t9l^TI~IZDtD8F(Ja(ymx(iz7D#BaT#ImbL?S zvot&y3SgGvkwKaRwagScUBrQ6`go~^qxp3WUni6pns=#|>k10R!~=L)*L8jVebI;BV=Lk zy6gvTAv1a!ZovRhms{Ao9{Yh?^{MONPB8023wt*tiu6Y2g#-P~3m*zHFYMi5;Xsp$ zb5J8j_@-x`?#O@L@0kASG^2Btj%6O7Mcd)*3x5o=%mo!Rn5B|eq&3j`2bi^9Mzi){ zu;M5QQ2evVtwHqWS0R}w!1k131W*TlVfMv>>lleFyDZjH3r!-dDNM`mhdPNa2!5-bnAS@;g zL3v~&OJPaLMrkK;B}>Po&DeLIxdBBo3X_gY2W9D$c2PQ~g+KspmlW#A3Xyhcaa{6U zW;k&&lFXFG1urs7aS)u(yI0s~j;QIiS~sP#zvbwGGGYd}mcnF$n4_@9D8+F}dU*4fZJQXc8AO$m^&ZGU#; zf8Pv{PV5fF+$&^|9G||Bdj8STtmoV3`=PU%wS#Xd+S%0oZT&MHXAtqn>?yFJK3TdD zOcSK|?be(b`2G5z&3hBa@Oy)wh-x&KanA-TU;j%q8`7N>nGfgY+Ox^zg zIR9>RoC#DC@-X|{-3jp~ScfTeg4B5U6ORXzi5P`nGXluS492f{(3wB*|4;n?&8Rr` z!2-dL?PHLf{9J&Qed>Uz84(`IiFALhrCECzCd(3xWR_IdER{osaNG@)ekFEDzy+pW z;%7ZGTvwV5BDsF7w2jI`60R`o5&PZ+)E9H_vNMbXuJ|_K&O~AAaGaZVB z!8cY~Pws=3A!{pMC6AdpsAe&pE99+p<5@`)1$c=+^7*<%RL9qM?vOol3(UZ6wE&@PRk zGJW7n4r)dZVh#;cj+7?)6@E5#|7E*9vt_6HaIb z8dzKXI<*}%Nm~7aKIbFVqO6nx{gYNdM{N#g(X1_Hxdj7KD_ujNM;?zOXBx|?ZDBXS z1ypBKMZu^Xvy|rXEQUE;vM18m6IzzGf-y5oGzW%WH$yc^ucZo?R$-bCMUYl3((3@- zq6-p##e6P4=5ghuDHp+qsR<2sx|$R)-O7m7cAx~zL-{!Bnsc%VET43$_PVdBCo7q% z;Qm^(v{#nS#!3ZHb97zlBABCXVlb^G+D31?O6er*Ko40f1qn54f5LR!WtNoAX6=+T z6A`5fB!oy)(5Az}qv=mGxCP;C){m+Z)o1y#w)7>cmn@tj#Iphq!J*V1(Mo6(5<9>i;4+(#!#6NU*DSqo<&oF#!R=zLxa_yUd;y!E5# z3ScK40$-^RZUn`c4jReV(K2=8Kw+9pv$llw4r=p4n(BbUIP5d>M%Uol<);tc5K&+^j9;oeT;uW#E<>!yvpSbRrmOy~iOs83r3IWhy9zURHnuW`p}0A_MhM zCSp^Rr#YmJ4rfzaeVT9YYlsx7fF^6UzL`|GtLEL%DOL0!K+hTVGTLb4!-A zR@y`i`UYzoI0J1hf>10M>W;Vo%ax1Tu(W2fwq8%mmZc3WEfkMZHZ35WoL1u2Mz1Af z$|TW6#7fB3i|njzWSOPla9iIpKim0X`-ehtWU7)^`*~kqVVIL03@QG5#4Y5IK)! z5D*|z{_!phamya?0F^d@5M-BK8HiI@lmrQoxiv>HkzyMxOKTT=Gf5?oM!F=ho6U6W zMu*d%r=zKFv@J@e@=48DD!Kz8^tUadwGT{}F+7p_@>FvFm3`q9rT=o!y^wm~nuk>j_5646EszAVuIi>O>A?-Ovtv~1e!v@KfAIKv3ERZ=oDaDj;MM(xXR zE=CSn2pEF4o1LtUs5MHUs<0Ayn@cGvQz`%(ZW;EWfu&8b3BsBAh6*)@-vzj$H<#i` z$$_6eHkZN>+IoiIRx?V>!w*F5VJX168FIuBZzLj04O4-{P$Y)%0s&SYmDol}%?zW? zK&1jh=w67ezK%ODjJm`!s*)PsjLE!7PnWf9q=hoE(Fm+|NNB@E&%hHjFPv$5fliOp zjRea>QP=fUI7}RHC&|$m8!4rkD?x``mwTfQlcS_55Qo^zJdnk8>}yIU!f_B*rJ=Nu z4gS9)2^}vCDQxh@?UJSl`oq?dchxh3yaZd%&NRs!EMa{HTb_Vf2hC=uC>o!xV0{AX z7{flIwrxb(dKuZsrxD&#}pe-;5%+eOP$tg7~~(TbIaW+lNO|6qVAS&%VT zvLqC$@X#`1k>xflcbiiWtGa?iPi-!%^KGS^7FfE)aXc&Jf=F1r z1F5KRe?&NcgK$g;l&>a~Gf}qek6n;q*3QYNx0_9|`;`T|lbRrw1*dd81+?UmSa&rn zD>WgcTuJb!piG=>pJbo_%wP)7j_RH>yPl#=GtyemM6fcYy@M(#+Jjg=(lGb-R(@(i zj-nOj!dLzXH=O5QK~1o*XhB9&IC6Z19CYs!+*?HpMeRyM;3qrVzJ0P%$|xJGBs9Qq z)5%epf%a^uCS)l78>J%WCaoo+K+}x2PMCJUeY-hVuAhxXnq9R93-agQs3P{v*Mlet zn|mhDu2bokWUS1$;pV)y!_+T1%5SnBQI;vLK4R9_0qV_Tpjv<6$n7-6jG~TNZ zv38D?PSJz|-d?$??MHk~*7nG$XAE-!fz9}`smg-NM5Cg$q}FQeJh*@$-2q5VY2>(B zw9erOLCo<}Ds!+{Drh>+_WT9uW%r^P&&XPCYM!xPuMll*;rf6ypG}So--l(Z;xWDQr{P_WKK0oL5w@^CdsjGBrOF|VmFTTc_ zZjcr0mb9%{hUIL%2(6VcfGV^p9q(`bib{HhnY&!H_#Y-sLFw;+xtyMYTQL+>Q7yJ1me7SkCTA%E09e7U zYR6SA9~K~~9*Y2n$s@9qgnHD#(bPY1$Mk}R2V`wlNX)5RW1=zj&!Ez0kz#?xQZxrw z&ia-4N@X6JaNbcR!C_iCAf)nCrXgv-smwZwK|hntDC>JFGXTHwlEts={!^Jo{3dkD zx}3@k!S4Y2ecP$bF#N7dzgwNkjKOcLMe!?Zl)OLr)UMW*D7&nVRB3<0fJJmQ!P`rgOwXG@kznZ4&_>sW)F(?@EiUEBQe&)PEFe zYN~Q-HX%@pcvtN&p}LWK3V{QluV@D)oGVLfPYB2Xb(K@?HBmAZ?z$@x4j6N21|u2!fE)_jph)mW3LV07gP;h}6557Z+DfIHXq zWddR;rA5Q~!sE_n2~<>`^KSZ5fpylTo6)IqB6z22jfd=U6S?&Za<+S&b)j*wP%5+$ zmZ1Ut0@)t-q*I~Zg+*)wXo*z;6&mU6{RJ^i439*0TPfN=1h(7+GrJ%2FE?WK?e(JtrBQn!R#M8F48}K)G7rWw zQ|AJ#2&J+I^Cm;Yj5k@BiVC2~!btSyEE8+EjT0&VztwQMje2pNP(BTS>sUfOdO()qzuls(cb;Rfr)M?b zD_e0JjzklBm*l@m>AuM{{{{BMSw$NTmqiVa)N)mOS~v?ybjnriOTsCXVe#L~)DRh9 z*Abp|J_a{TD^JUq)yi2i^ zdZE&k))n{V%AC?LnjI!AW(CA9IGh-TVdVC1i&@!8;c{vYRu!Jdl+uzy^T>Io=kCFS zGz9NMF{M3m!CL&71#))JGd=e%35q%L8owCaLZW}Ul0s9`d8X%&hse>fS59bD^w70nadcNfvC|G6`j zHx>UgN~ax3B^FlT0}mpmW-;}`cQtVJP9?pCNCCm9B;;HCH(9!GnmSL_&Z5qxtsov% zX%6nn=`Y}K*;KloPrn|cUo+VqnXU)ZHARHvbQ(d+6uMr^uGy2(biIaMv-}Zs{VnAT zRS=z^X}MyHfCp4|C*9e~a#EYEdJ2D_w_R^(NH?uT7 zSc^=={ubn5Wr^v5A7foMlw4g~BJO*`*l6^56lBow+U3{EsE-iO(=`>)NXt(?4GK(Q;+IHap;^VGe_t za8iBdBu9?KQh9unMV<{>qH=p`Mtw+mwk;YaAx};xXUJJj4mO{G+zw6n1vg0(Zp1x% z++?8#XBJZ~@NqLHq(%|%QH(cpg%Y8GNFFccS8kQl!@&dP*{D1Wu~@=19~PXkAxDth zzkhixzgQoYAH!--jiR796a?1?U(ysM_MkR}Ffb>1&K}IIP;mj<-*MH?$Vs)l4-}RD z^Cow9n(quoU3oQ!q8F*a4qW1-e0;lzN;*$1qdE{Pd-JdHpq$>5mAk|FA#-zC#jbgQ z3fgnvp!D(JG`)a>!s@NET; zmpsTtO`R1Gni#||i@>~bvy=~R5v6Hwct%jF>NtPCf&Tn6p3l>x^r3N;2hiT52%Mew z3vilj_XKCB8ytp=vnQzm4qD_nVOLn4MUJ#A9uK`N%JL`0y(`iAVN3!dux1d_B;&>+|baBOhX!V!8?$} z9+xN#&5H}!C^3xi;MT>WZ9;M6XX1r7xu6iedsgDj@P}IapXsziN!ZSuPQOmt{m$JeU`fQ3Hbo5Cip zFFuFdr>LzqX&221B8tW`z3?$L2MyDh!kX>9I|G^k6zM$F+ZBbv($R+HEj^lMV50pY z4B)+G=U-r4Hkuk{CUKbQY8VS|n1+~=p<&vy@58FLj>Vs~P8sGU-Lt55%-RuG>!hGK zie8OYl@3zzIAN znR4(*!Cj!c4;WbbOat*m4QEIdmq`}uc7l@L>1u{J;)S% zqciUn2I4Ki??zAU1~by&&)DsxeK6k$>8Z@alfha}rv};LBFs>3F1@4g{PI%-_#Cm(s#QX!K8zK=0)=fB&cjueOz{?Z6+Q)1Ra< zora{Nxa}A@f~z$m#hAa7f>DK;M*ozGyoX8guoFTqH41uffp)=cPck?kW5XYAajeO- zMCygQu(n&F!I&3!L7`>+b)-kU%#iNLaL@Y75w4jwzk+k1KGS+LZV#8JUb<^weDHz7zt80rs{`2zT@9sw{MwCtm ze_lC$zmqOsLXj(rWE5GdpuqE2bY=B%We#*@=J$7d-M_7#V21HH4E1sTz;kFr0<@k1 zSX^fC^ShP)bl;KKe8FxusQy6pHN`q0aoEr-=f{}hU<9sndIwLaB9`ldmp_zqXLbDp z;UL%T_56N!{k}(g0Dd{W4n!ljB+@wm@#ETP0bX9g-2$&&Or}$aRCAg5!1D#a&qC)# zN$t@$?rO$6JNwotQ**stWsZaA;`7sunryLf+K z`os{Vvgq#kw^vjh4s9EtzfMPcz=v|p- zsmxy(f;D+YGWthjJ7G#$=v+vXO&E>{acy3hMx zAYOfX-v7dNpV#jX_w&(^-Kp{Ws#c_GJ1wbajMJM?OH;sR)js?wU5Gz;-D^sF9Y3Ue zZ8N=RZGrtiXxEM=*u4X5zKu$QJWKb&sfXz8ZI*T&W%60aXoW2*&LIbBX)-}*Mdag_Rs-So6 z7recn?ZWU@wRNhtnc>l%;UGXk;U_{6x>B|x@N&_)e~zno+KA^b0niQOULt(zd*NfT z)pu9zDk3r!^VlB;>b0;LR@igpI(CVH=JNj-rOQtmou)4+L~ zTXr)#JYL6BkC(e=pvO}_dZ;%ZLY4i%gS~`gl|GEOhHOR>orNxpWxZrxRqDo=LSWAr zC{d(0XlqQK9KIk9yUC%a7hu#KXhp$cBo$~|NWpacs?)FjRrRaIk0{&^N(Qy@WazDC zsKvcP24^Rf@0q!(1~y|XFUOC6N73+bBX@-=ox3nw^<;RJwyllZyuAzV)Y}`cMtOS= zfMC>gm)Q5NkWp_omwo50O``xkqTBZ-9#yMwR$@mh{juup`+94YGU7c8ie}xaoZdC=CF(B&0>VUxDVI&FoW`=7 z-0}5G@0S}p<;B`$e4WDLMtIGculN@_b7}G>4EP(EDb>t?d)1K3-`XYrfVFP<%_x+O zA$(xLh{EKrb~x$gDNVhaOB16z?r+`B5QLYAP@(AY9asIP>%a-#;~NFL8})*-Vy3PG zm`13Guc4pEXpI%kVst*oUT;$HDd~3fv26{w_MHVA#*@4IPdL-Tf=x@9E`iVKJ^^Gm z5*xDBF%F^-)K9^g79(% zOArHSauGwzJsPBMf_P?ouY1cLrGWW??mUo2zwbfJ5fu(fc`%?3GEq`KUVf!!fJ*@_ zPqKHe!wkLSg8zcUCbN^i4asrN#pDr>bxr>T5Ak3yAp4+xE809SY}M~*0>0Ekn*pJ^ z@8PRF{(}F2`5|8YZ*s1oaGBQM2oqU%Z5_X|`jj{iW0vN$!OsVFBt5JuVxF}%@zeE~ z@Z{@h*!D&g!E#zVF-R>5PBsay17KBcgH$BtI}hVNU)Pd2MqfqKmSAn>T*dq$7Ow1@ z8>72WYZ0Y}3Jk-n5(xDHEQ{-LP3i`$R%L=4*Cg3FX*vq6vJR#Q0dS}ls*ND&1WX$# z7KMFcpsRsAj6|!X5Ky4`dMe&C_$5-)k@_iyJH)CNz*qL8SEs#(OWwb`cVviIH7tkQ z36~(L{i^*al&tID2oZb36-MDcoOgbZHX!4R7&bumuxRwyp;2wluAvS4>W6Ri&n!CJru zn{hey*n%Q07whvD6vdcc%LM?~8fax-L(0Bnh-&WcNV>y? zmS~6(E)Tm45_A?B(>k(O57`4+wBBv?$C@9w8L9iQFb7K&E%;EY({@XzqL)x!Or_Nx zQ>j$0ld}BtdM*rZh3{O*Q2fRW7h`Y1&`=}y{TejqU(3qg7l_pq+3lIyVOYHIvujw1 zwj^LtOu1DBWhBiJ&6zxu;cP4d>lSB(Xxq@#~xLIZ3=0_pASr||ZkaaK-c z<_9lR(3dcQ1s8<-oDIQ5$^d%6IF#+5wNh;s$^6|C%m*Aw_bpQoDYhW4su(?^Y5FsS z>=$1&@$c^9#OymWMKKWGnRy#SfGyO|d9T|Yix$XMBB+?Z z&7|co(ac;R^n|x85ZU<$BH&ngV3gCx;iV8%K}j%w`F2=$%rg0&!dSZ zl?EHog#wGw$@lstHN~pJ#74jz3)=9GAsQ7JMGH3(i)!*1Ed(U-3{)$=_OBa+kkkJG z5uE1+pkS>0Bc3*Yw+TdMiw(PAvQgMA!M~=W*Yp&h&sK!9X2kcx0hog+35{VBnVp8O z0mX?ZZ4Mx+<*SeP_~1_-7%LTZ*^SrdY@o-7(x>ncIMYeJ(8z4mOYcnnwl2S+F7p8_P!XO3r){GhZJT=pMoKUXQXy=z>30_ zj(cew%x6$l^T})^($yeu>&Xl&ZIhV~8;Kn{FKazzm`_;QX(2Dcd`823VviQGy2s?x zyISprX)|kEc5TbVSm`ep_TALv3m`vtSp%vWB^aX}5;76C?i*%G7As~CN5bEe?^7z+=Tob)UI_#gLD1VeY#3w$9o(F7Yv`%Yu3O|YVU^{URZ=$EvW5{+IX>@##==3+M zRyb%(7Y7aP5Oc5{Shz7mH2*L8oQV%rIAxDTb10vj;MryjXC=qbTOF4i!|xDBm(}lD zIL$6yyYSp(9oI~2aI((fY8TqMGAxxN8Ap@a36vL#x&!4q zIaxr@=oZ`dJ2`2RbC|Uv3qRs;**!#@AUtquTCW|H zu|sdC#V(;xx)d6r3+3xVlT({t$?eq^o=644P7vFv*;MvWIXzK1J%$*(>>k^BS}srX zxI4fVEYUuF!Fznz&fcuYhn-#NP8`O4*7%*fT2cc88<_}5zFDnA&%*)IgBXMb0Z-_) z{kvMy0{t3OpgEHLYm-gJC%am51HJ9tYipRWlzeYjOM0LehI_PhHQygwjj#2Z-CHdA zNI-4#K`+>;V`Ulj7zAtUpCb1$0hNs66noV{RE{p@pX6c*Gs3>PYF`4$(o{KGI0OGx z;HnHT+g^1rqqCzQ1zr0KOQc2%Og2af39DX66QX?8AslvJZF}+`3(m1}Jq-A#PK8@c8 zanO4F@s{`&AIKODNei|6G=dKz#~?Um#)9E3X#_8mVxjlaV$&$5QT#i)m_hLgT~K^% z62+xDINp+;teze_JV7rus9(YTLN8ACIc4`4nz2o*{J5s?M3O-a=vEILSA229}ilLs;+4st6lH zS%M}CM~8sMEECO@Z3!QuZ5AeODRT&M54t!LxA?1JQYgGOy(b{Xpm>w&0gSQMsK9Sm z@W_v!u^@+^@X2!EP%AFyb-L&tLjM{k7b4&M2e+ zewY6EvBK|x96_v`7v>1vnamk!K)`*%YYZp=hL+>$lPvbHZt(r4A1*xQ0)r71mqueg z@RLI{NS%Sw=c3X!5u5CE-nYhg$nl%6nE=>^2g@`MkR6J5vH%Bb7J$r~O=f}v=(W4` zD=`4UjL6tHfmg$WlJLMBDj?VR`9MYkC5cvF8Vn60{8YE*x!7D+Si?7qjoC7nAnmSI zAr~esN$^35M?kv0IZ`Q4l;EK?Q@^lNalos)mEx`)#!03p$1V_aTtHkrJbq_-;1s{Z z`yP3j9}0JH3CFCuQVidXfMUl7k}8@jbsnkCSVl7sp>ye-*#VlFkWbBxy3Bt_nNyNw z^275utLWp-)Igrya3#4E{XInm|8k^<&)ry`13@Fty{w;xm)&rdX>t-xM*KTe$5Etn z*c}a%aw=(Z%$-ezD1|pu(6_HD@MCPPqD-C~#lwx`=$){48xOr$Rpy<6gZ6EAJBrXQYXi~ zG-1_=+ewlGAE_@uR$UG);@c8DwKo3qTW%R(E&h{~e^kSoi4NP10kyGI<2juv+T8lUO5@>_DHUaU`K^kb1l%8ra)7`xNcfw|^( z%Da*(D*Ob&V8ox8oDLBI`VUHdg`=(O|0s3i8@LNB9OSQFU+!7wc}ff* zj*_Q*de@M(#Ad&2U-TBzkn}=|@azjO{ltz+>SHCYR(x&YqUQIqq({@=Td^CQt%Btm zWoq>ZSpxxk^!LbY1QX9C)ouv!Z)})+U!uPk%l9Dpo}<5SlZY1dc|XJW@C^F+`0A zdifno?LZhH9`&i;L8cx(lLis-X#41z(wbmTYEAc*%!@CmiZ_ArrEBLasoKZy3}!Ha z%w1y(QiFly>9h6i0VyksWIl7)$3}+}oCF3xM26s+djus^rDM+%#EL#ejm9=3FMneF>wzAlX7{rqH;`&K z45vk(9Keyh6KPep%B>JnyI}+wBn~7|u7lUY`5M|2oXQA1?wHFdrAL<-bz~}Y>!uX> zs|XL+AZ8qX_dZy`URllvI999V(ITEKY6(p!K+}tXKj9EIs@~-TQm7ZM&i8xX4bSd} z(7+!ai%u^Q=U|v9egB+<&3>6>B=7*FDFRo&1m3I-0+A{oU^91w`czlcv>O za+i^-wx~zsR1j7jPeDwmjn-=iLiH|MuN<^qQ!@i6*$n{$pYpd1w#E2t6vAqhS5z(n84=8&gL{8OTkA9o5+#3 zpL93Q(!v_PMSw9@lo*Dbi&m6zfx@=IN4y$6Gp%X4)i>bVn%EAsZhNGwU$*UXLV>lEQ_#!=yhdPrb2b_vN#fu{qjvfmMP3U2=A2;4dkE3 zsx2TxR_!RNHD1p@iFM0DLkj!Ox$Ac=OV|ZqE0!fV1=0v%*CBauBlgNn>PaK~Ng-y= ze^f!}s>yZE%GW%j3i$v!{8JBqHFVO$=}{g&-B}x3uA7!_Ivf8AF#MvTMxXk(EDcjZ zbz@?Y;mU_WySO#^1-UQ!eUUk@I4yA;hy%x2EB3gz>+<+o zH4-a(7|A~MO}6FRZLZh0RrVeRFho+J9f7A_HZM$gp9T?cGev|6yqeG;l5bq)Ga5u5 zo=ol>`Iq}8!$x$VOd4F2{uJA}}(hgswDs3WM!+BKYCA`2CjkFTj$sp(R201ry$yUEc z94*B(a=yL0MPxr^R@sMwoU+}yLGB7*vbN54ranW3@w69{*w)MtD265}P0iW2uwiQkf*PMV>s5>Vj6R}EcVz```@ zBhhc-jomXz=qmBXggMYh9U5)1{zLQt2~{U^E zKT(%+r>JmfoNGAR*bmwfnyr+Zy4YpC%QDz2-_!LnwvpvJ%|1&yQ1n*76IHVLOW6Eg zHou?EueByRs+ZsK=E}*KBO~}7jYJ=imYii<`6D*^S5`Q6h0Enb*p6Qm*6>r$QWl2Q zfowhLY{M=onB%X`(&uEfk`2Js|47XxO2cwiGpkb>ot~jDV((GEg2xYioN8Tn1jR!i zqnwkIoV$h@j9h}FZ=*vhcP`?^$&G3phriG9Xm$-Ro4$UHHb+YPk5M)Ox@TVQ1l?QF zCf&fe+=nwLvdf#DuJ<|S3KKae1_IDx73?AF!2=g)7h6ONbrs?i+50B@obK6M)LCQ; zP-4^jq)0LZ>C89*K_fUM!4gowGi zoK5`_uz!U_5g9efU)Ko7=@9X1QodHt&UvxiDtlQ33m1kKro)O%$PV^WzY^(w__FNS zQE+B~jL@izzyP(FMD++#0drq&3ItkHWY381>oC++&ZKlTL3kZ89t8iykZ#nBeufW^ zx1wC1f1S|5dr2RTZ`mWR26uxUe~$9k%T-EGMG#~HA58PO7YB$mVvV^?nrHzp$&=dD z7Wsp0#tlacdAmYa$k?;pC2t$l?l|y7I4K12Rg&$MS5ih;rkBV8U-3o88yCU{(YvfG z(YvT6IgT`_Qz~JiZ)H_?1sq{Q@;_0mr)DR zau2o=_15tp#aUGe6XTUCH`@(UbiS_YS2J`aC1+iUhUue{D?(wR!9rRpr4LRt0ir|z zaGn*AjMg%436unLQ35}5fj%LhlDv8tWm3w;p;})BMJ}>QojOJ*pImNCHST*scKF5g zqFy}?_=cwIEnZw6z5>q74o%8M8I>6gK&cYokPDlv+0|5)CHy|0!0e}yGb3u#I4+Rm zKcjHDTljmXaI+L%PGLQ8Ijz0XT&n#y-qhb{?Cv*q-j?$0f@D{lN0*mo*cz($p|Ff6=^&42F~YB7gOm^iap3YtE+I-Oq#K)~epw zq9zKA^*L#Bj?NHY+~M4*8lya<3K|N!^1oMBIHdse2puViGe^6Rm$3I`V=?^73tIF+ zYJPq>AQ}ByFEd)vT=k%KN%S<17SV1>^`#(4`o}VNN`uSq&5x9I`BVm4pj^ zq;Mlv_<{N!xB>g0LJ*fnLCs|fMw#sCB z#rSe~Xis{u%HFls6F9%|I+~EB7A-D&`{He=MI>;-nB6>0uLBB3pXI_v>D0jS$}dqeN62#iMF$qL^?7v&3+^ZjrRp=q1gz9(sOy=(SWkGN#>Mx;NN! zI_Fb{_0SHkuDdvpF2`y#FirkBjR9Nrr&H6l`2+cQ{KvJ3f_Lk*`ZGBmZH@qfwx-9b zTw_AFPBJGgHjcX*7il0;96ME0NiBNYqN)Z6vRg|wGD=|$eM5KntcQTA8MsI{M{@#r z^wU%&e)MwwU=JBR7tDut+Pj*v13_#?Bnl^SIwEIfs=7nr->P?TT^~8U7jM#Z)qip) zJ#6YkXCnbFME6=?J@l-)GfiRz%$ej-CS~bME?u{ zoo@GfKhcJ1QC(zOO82N(vtE~2nnbB^mxV@=QB>e9fRx}t!S~RbImuhW|ICLbllNDc zFfE@EWK*|(s5ceXs89wc3L}M&{uYkppFbm-i|6Cd3OBQwqE_P@J+3;!=OCtjPNH0k z24kv6*27wIJNfaZ=yLuzWAWSsIfg$e#8NsfQ7cCQLCwPtrd2tw8x93))lW5(f`+Fl zSit^DBU84UXk;Rl3lf6I>qN~sR?hpW!agFHr+M{5+VshdkyX=1z#CanBKlF6(LeOG z^s@OpL=%Ct8zd>3PW2_w?wUP|uFlee@0ewtXj}gTY>@R(x;_1< z)p!0NP=(RSP%-OIx7Vg1{imL8uplv}dg03qk&_#3qD0;yG8{ARKzkxp3-I(@f^*FV zTxd6BB6pu|)5R%Ra0)P9ezF@d7JV$h0O`8V#L-Ey(`xoiv+AqiLsweTQ2i5D-PwF4 zqK)L;BCRC=cosQ23`s1*H7jy`vL4qr+G$$2{k0+yeik--^|S-`tB#CWz|_^7J2W;; ztR@_$9Oy=!6PP+7G8)~+%Yn(f-`=yPz!;CUe}nlUG)A6wt`Uv* zGIJZgoj`tOY9h=XOni+5;^cXl8spSsEI>#6^8DAk+J(*JJYa}k8RGX55XRp8a$^&C z7Z7qV$#e);q2|4p*tri}>+amT!X|P6J+!JwW{+Hpx`;7RQ%UG%+bVybc!O#nd9qda zLz&=GirFyQhstT?;%Z@Gk#<@NzFdK;_>-0rIDqf8IdFh`CYzHf`@cXd%+hVn0w1SKx8AECVzD$!O94Nyj# zWPDE=hOwpKBf75hmK63muj)b8q*%W7^-a{NfT;eJ5~$K5IltkDpB$!91rH-Zx10^s z^Q-r<7IfP9Nmxw&Jsz3Nbz)3_3eK)=Pr>Fqj?KHT@OxA$?v9_%6Rb4tSiY_52{d02 zM@ZE+oNWTmmKKH9T`-c%G8#Eia3y)R@M5hXDs|7l6G7Qt3A3iTl}Mn}Qn_*tH+ z6svA6(B!8#C2%sLvSA{jbv8xj^i($jg4k%X3Q~f7oK24nprERRhlm{u)MspFsSY#m zgb0`?X2Q4X;BtD#chh3&%L|U(U8ix1 zue89xp&xh7^!G%TkeSw#<=G7saB?GzI|rz&gMck?c_e+Xph%Txhl`rXR{B_tZ|2X& zo3nNx>m%@v@8G=YYj1*7v7I*EN^@r2WcTrcYJ(LW1K9ytSx3xmDbWCm@l+? ziIaC}VG|7tPxS*W9KWrwh$hkW%2rFVSO1?p;yNRtXqXkC>)7<<)c3 z%dCgm8{{ZkTc^cEDbsyNwGYRUlV>?VUQsi^ReJ-IxS!3%r@=)WW50F)`oImjJ41+N zl9=ATLC8rW>o;V@TfB9qyy=Qwgs}qrUT(ArAPIb_f02a z5Z=ADzjbTvm*iueBrI04zuGP(0&|#qkE;$Xb1c8}%tRDvvOOkrfb1RhP0^K7&l#`z zV6Wzby`l>6tNXaiOStJsUVq)%7MBlRC``Cfbzsn%vv=KR^1(kv1gTHkgvz2A$sKA7 zqpp}ncn5#v7seq3(~X-Mgb*07DisL=@-Ej3XtTO~x9~(RryQX(PE$zUZtPL&<8y9{ zj8A2k--PAFFTdQ$`QM{d!k=K>n|l9x)wSU#7`!fa%=thTrLobl%_e=ntK?yXF`{o%eYS zo`>dr&Ys|5?7Hv5r>l%1;7&N>SxRVOXp2U&E$Sd47ux71&~ukJH`S{DqvSOFPOpF7 zW)N*5-6-50W7pV>x+^KjWXob!#xuQ(`mxPR?c0mGhk1L=PNq{qg6g z7|8n*gS=`j_6@PqMSMQU@r1Lu`JtfYW*8oy?Tqam9^cy;n;U9NjqyzT83yXD))0Qk4#&=1c^_l0Mt*S|BzgJ2$Y zu_rvcH^kb@HNN1{~$PJTIa-{Bfahf`Z@jSw&+(G0zwuRab55Gt&1)@2bp zOPwm%mZcQEs)oO(KOyioit%zy_=uoEL_k@E~+MSuxlY*2Xx zAZme=aUNwUc(nLwQ__^uB_8fAqOC>gpqs~!5*5w}6#7<$1mT<)zIz}%h`(PTP{?~t zuQJk?H^c@plK0%&0V(mk`c6+&cQkoUE;9`NatM4Reh=e%>o)|3xz7{^af) z&9U@9M(^Lx6Tc_opStyB$6o}lcMi(ce{{(|5>eHG`5(8N{-u6jl-394Z|{;{_^(g5 z|8ST5^U$?))~|3*ey_Kc#P=t;Slw(2T6JO4#X?bzoz`rV7S6D+k zvIMi#>7vVF33^MVD)k|zi6SVuo&v&->a{G6=WVM?wpC)KZ0R!*YYW8t3=(Y z+X@h>?=#lxyBrp**KzK89qO*v!K_y{>m_{0!Fn^qOu#xsCI>=$m8d0-n1iB13uN_kIvP*~@w|INNkGX%I841j096V7m?b}|)J9CS zG@vB19Qla0t{Pqth zlI-0*{}c6de2MzUz-}jNb^c&KR51Y+-IiFLBY&J+9p)|m0l|y)62$ZRFHWw@=fHD> zIEes0+a9eo0^I32cqf+aqbnld}vJ-4*(_L{}7gW908B9UWrma zOP1;!_m}diFHj zo#v(IYu{0vip65v5nBaL{;i_pi zgti@+`;Oi=v*d*(PEU@{t@=VLJTZ%-%bcAo^OdHo!h?M8W5khs)v4sLv>-xFFi~(q+oZ!0 z7Ngn{&akk*K>4>XYOZ>qE+(n@lA5l!j#a1qhQ({ChE%-k3iE^7Pp&|Um#B$Y3d%7Z zA{W%F2sLdEbyQzd#&1gG-M(@_N=T*I4WbH?A;g>T@>V~TOSNFDkhuvJY%&U;OHAH2 zm7c3_2YP72RTYRaLrf1bknu3Bq{!+N@k%EzXExkL0^O9-je0T-J&8@|au*;G-T8XAezd;`iYN*EZj+p=NsQ&39bOIdT6R90yj#BsG zFGKyesQJR~l9erm@C~JidjQti#=ua9h(~-Tc9q*`=ZN{6%x^|3=ScN{EqfveE+ul zjV`{s@1p~G5k{$bPiV+@(MRKfL?8)gOh4yigNI)0SFNGe{;^VW`Ug)W$E06=$J_SMKBT0yRi-Zj^#HDu8&QzcSoD|W;m-Q zNW#|3R$t;9+Qw-?EDkRhf8sw!GKQ?+T!6|!?N2#P|AsWs5~`D;MiClUSY>N9ozH|N z6l7?Lj)aQ+dtiQz5*pvT>fbf@|M8Xt=8u@+V!q+$<#~FB?$<{+)6&K)7^;z0B!nSd zw!Ro_9w7a|`s*aiJ{{)Oj0B7+8G;oI&7bYw2j3p6DnZ zNh)tZ0E-KlSQG3ocXkmuW01deM{t1HVw`dO(9tJe)5mYEYAUz>&}1$C0|KZq*r=Xi z*p9Ktc7-UuL-IIlRpA-f?3I^(f{SSPa)-L*9yiJ9XOywoSD{oJAL;iC>Wqu@tJL-W zNCEM!g7+8)#{)ABiX5{FiyJh@>7l*-5y-ABEB!@{`mm5-06~h&SMEoAT`z%>DdLM< zNT}5otL*VmN2Yact5D9HZD(oCjwotep9vHlX4B#@770*g|!pr;*gvvJ0=<4qVN;3#<*%L5b&9{n&LJEY-o z^L3DXq1&vxvxK?(FyFj>hK!Dc20&B0Rz_lbR?8G*LdEem{tbjaNt-{AbLImU9jaO! zU+0kx8PLHqV_EUdD=}d*}gtcn|)dHZJr&G^dXnE1IOpUFY*O`8?~KwPl_*?ZekQO$SeD-jfz;&a^%2 zY|r}LQO^_2d()C6B1=<(^O znxCfR{MSl92xONzk5fM=5zU>Yg*DPVcf0;tN_bmgyL=}@fDrwFhZnIVppp!N{UkS!QrQG6vmG>9oQ^tNsssN{@$H zxezRRonz&4#jvfum;v_n=h|U^ftF-O=TY+5F}+eo@_#k|&*J}$V|%4M#s4qx|JNY@ z|5xQ3LcquqHni#U5()T+ce(P2a}mRSM%!Ka`vNwPT4t*HtIx~jtp?}E@D@{W=+D_s zLw4BYA6~n4@A;`DjZV@Bu!`+M9=oL zb>?fj_Sxjj4ih5{Ch%Ke(G7erYjxq9@r%E?@ZE_O^JlPT=44K!JXu zjsWzqN~r;HQz?pGH5k@aer(*D6sJ3O_RulelA82}IY7qVb+rD_-0YNKwz%8Lw4Brf z<|I}VTtzjizVW!r6|UBX>rZIsuQql3vm_fk8m!bo7_;<(>4@s%!ix_UspE-i=MkFI zmh1dLx%-{2tpF(|hf8Ng>Tn6dZFd@65!b%ZyzG=fKdpsDsSYKbmMiTbq-@2skpoD6 z`opv6h#QFX+Sypwxqg}7(kc3KTqWH}?^xq59dN?>)Y+RH3C8y7Nv)KDtq5bp3(paX zrv?x4@7{hW!RptA{fr3v(F8oEcK!S^gQ1M37w%AgXoey&3Pj={herR<-qgTBT&_cV zGlR#fov$)jl8>#RjEEi7pmcJ*Nh5#V{czBe8f zJ%};E`Sg3^<;0i(4!k!mau6+o*`)F|!$Ed^Z+wa2v8}A2N zf8LCZe(P3KAMMK1=XbkbNsDs9DzfUuT+Y4h7pvxhhIjbABm#-4!eA7sH$*!jJViof zg@36VM9EcIO-A(sApVWYsz#cHt@Co>gq`cIvPxtg)io31$>=&6Lb<9YYbKN^iJDOn zx4X@L9$>y^y1~Z@&2%udo%hANtcTFWTR%DAlt{?)2bdbHz<1B3 zq-plb!AY6Sp)wmSluuxnmM%WDfn?Y>Ex6w;!gO8c-^`2gcmbsfH&H=iW9j$XHPR*} zegEeD_B)8;z!D$ue*2BCgT7?9 zAp7r8R<~|WUYXOZX%UZoEb@t9LvjgH(p=a+x+&-n?M<_mdbrYBE2gAcD^?dajjplk z{~p? z4DHRZmi|S+)fKYmeR@PAFrju&Vj;T1sB^5n%`%aYPWi-O z3mEJ=#-T+p-3PmW^ggTp2|d`%1-+8?M;d*mq;@XVA1DGG&y&?_F(VV1Rp+P|n=d`; z;2hF3q*U+@yml=3_n$dAA04C=2wKZb%*G*bOM^U&boe#ald-^YV-AK2K(qG zeRi(~jQ-j7g2-AHmYh=qmvT2lXKT&!7aWA$cG3Tw5B;R`I$}jn$&6(a_WQb@QmIyx&d2SNYk6tv`~;Tdz{{vvToAt$-34- zg;~9KxGnUS+VuNb&85+tNAEDx97}&za#KljXXsk_g`VCWZgU59ZO&~Q!>>;d6#&`k zdPB6p#mzronwzW(4k}FTz0YkfziV@D+on0UZI6heq8bb}ubfe{P6NVTQl4J6#Lm#T zY3>96wK(YV)8cBDbn)`C1nTCM?ty2#4kA+@L#8~@c}1KDFs~;<5l&qMOx&e|TR-|c zV_0@ERU04YpNM}GT4|4Z6B&Q>G6=FAnv?Dc4zn>*%?cc=%b@To_z`8fmM@@w!Y#bN zx?d`Nhf3nEFiUNfp%CnZQ(gjTPjUOYyt6M8Gishx6PjI4NY(Rj%O*B}4rhS_fEJry zr|nsTL!{%`)V3R1`B(Q#yL*01z#SR8I$0XJo`ztcq$2)W^b_wT$Msw8jvw zP7HxqMpgkaf*-IgAtC_iz1l|fzrznWA(V)T_UnO5zU;!P{Dtd2x{D8qghWsvBRP(eOW zSVN|{ZHh9|n{=c4uiahW{y8E0Ri1YEo_3V*GE4RDgUnlk1ntq=awird>NhI{7CSVs z6Z>YsZDt*hqEe$=ND{scV^cx*HK16z(?=bleHGPfV*Chx6~mxwosn)uSCi!(LUopR z?X3wNf4=$zJKX3(S_v&;qYvFRo_&6FvcfP^;U=kYW*y4Av(!eZFp&xpb%SNou6$4X zs}(5FS#gA^I4xQ6VRfTaw5X_~&!dArgG{?&Bqg0qKIa|fgIRZjqzS<&AX^4KiUQpx zex@|kOsZ^7?G}ae&=x!XwDFo0{&=(Uu+!M{11iM5lAs@()j4{91hOfyQR4^=#glufnA=H0^WP73XFvd%g6z zs4xk>3f+FrIkcp~MM?7pxR^UYS&1^7qn0XI+M{-!8MZK`Tj`Gb3N&Cy~roR8GkeBflY*P5G+8ngnX(^ zwX&|H-nAT*7r46 z$fp67a=!+Yvllomxn8Go7337JQG0GWG5Y7x%(xk(TF_yEZK{ejPf%m~16kyHUP_d^l5nOaon2J@x=IAe&( zVWS)zQ^nsZ{$j{x&*JYK{?4IpjVZ1FIE$BOaono6VR0<17|TBv1cw z7T;^W^dD#OR`aF*IE&lNm;U1{-e$h^A7^p9`I0~1HbKyw1Xd!lr_a7eqpBZM!&!Xu z@=)Z2`RrMFQbbh47);4_8d46@Xg&?2Ho zEiCh7aPlVIk;r4`8F5m;B7BOxaNDM4!F}?AdaJM^3|W&^Gx`S#=SWSn*}nx{ir}S} zvt68?i|9=CyITX0_XKb-(BZD%N4XFW9>FVeerL6}Pj>hWOBH%U;7Tu~- zkCN0nQtgI9!L3MMh;*TcR~lJ3MqFPwZdd;QH{Emny17|^L?ry7J^dCO?5}R-^qYIA z5b5-0ogSI;HcRoRGLl3dTY(Dc3)`>2w4s=LPf)Z`GFeoZY2j2+Ga#oKlO84ja#J9S zp9BNc7x}DmXH0%MMZh_X4%Y=FA=e*H)kozQ&nDqN+RMy`k?!*`QX0uoJ5iE^n)>Nk zBtmz8T9e_Pp9%XleM3tG9Dney8cOnh>R!9yMZxJv-oZb|zBo$6O*`Trtw!p*g*f#D z&I@fx3m1iFq=)D5vhl9KAiLpTrb_!V;rceKX4yaCv5=ak08uh`1&*^D-Zn)ZrHHVO zzcoeV7AdidH>kX76NMD|F>k^Ti*V%s4oF|HV*c>Y+ZjT#1-|9D==y!< z0pHmmUf2BGuHO~mT650)Tuzo`vE&GZesGZ1E+JQI+>$35)-e1w$Jvxw6`X?=;c(4o zHj->j_@AHe=vu}fp5mphzam^-DZRV>$w7^J&z!otCyw3llmdzpaJanq(*t$;pnY{f z`!l*0CcmS5{?EnsEm?$oIaE8p9XmJqK_A9na>AV&(=a-pPvOL=1!t+=7yZnZ!%ue0_G8hb$Y4C+_SqKgX9Z6G!oQ5K znCfbX8J~D9Rnl-JX}bRxvFwsKI+d_4()kf|p8wgM&rfz<{P~@m{rK=7H8ffgF&a4r zZ#;-vS3GIfMJj#jeqmidYg;D9JJ+<}a|SpsR5(xAp^xK%6S47|krFIwyq8u5I@`c- z#ybP&l8FB$gdQIC9I^v_Br`^T_0R95L}+in;Bm=1FPSQY$hh)ebL~ zivCFN8+6D0%fi!shMay(x*hiY?67jI-uN%p)0X5iNdkgZl2@p4r7T?WvjV2~1#tSp zMeC9&=WDLmCkxZ6SMvLR(6EJR6?2T8*awt3yMtdC0hnNXztec13)-FrOgY$RNBn&N zZC^DXRumzPoZ+Nu+bh+F?GY=r8ryTPx(E+s-ihSsjR(*0SFiPzj4ldvHkfA`eB?nI zoQ!y8Om8)m29tU&iHTTjIAQ(30?cu@q>OlcKmM~>*0paiL?mfao5k$5@lVT*urmqcsfyw#AeOI%ob~^g0@} zwU!0golh3CjORUGu46s)XdT;I#t~bZe1z!OyWJwUnC^}eE4%6R^Nk^k@G|DF85i2rZq z|4bIT%hvc`@$&%wxl>7`_xkML>AlWdolcZJE4RP`=M!Sp%iCdyp<9HH(57cr-DZI` z_wTECCBdp|k}rqo<6G2ZkzZ>ShE?|$o!sWUqz=Xzj2MSLVwb5_&!0RO=|5Xrh`YLIMBiIUxiSRctJAJm*6C1&s3F5l$DvC4ljD|Xxr>BcW8bC z%yQSW(`fMC-!Q15?P9U^erRtz*xwD8mUs_G!tD(p+#XC^(_e|BD3ZZzM;=2MO<9YS z(y7^ED+ufMEYYCidHu%8hDEAaO_{*4yvCCoC+b+n_xKFOPr7PLlAywLBuxq(FPEWj zW*F$g#GFSQN5)a<$=meoh#aM3P^#QLvIKIU3u20uv-uvw#!P^6G>UvTp%N*EDHLJC zH0{e)Nl4ENdCm*>q(~XY;Jfe)dT;@kKC5gOLEx-wuZE_u=GIbLzu8(*m$vPiUMLS2 zH}FT3er|(S-P`n1b9#zZ_gbPmp)Kd|!BECBl*BpUQTKu>(er2(gGIEZGBhcBp()$H zOW9tkPRgD@Sq97yz5d8q9CUIC5++Z66{|ZDuZv3WQW|Kugn#S?VN#+JPP2O#Gffu{ zKwcBW$y7&37wgGknR$u)MD0V!aVkw7LUO~8`NwW}K$;W}nC?x^A%z+Kpi7gz)#iWE zWjXO+F+|9{H&z>r9h;Fj;4S(zG^)y;bF7zFw z1F~U%?_7ob>ee@ypO0edchIZE{6xc*Njbrf2dLH!k7q~~dBHan_XNjDBs}5q1>kWR zpVKBgA-ADX4@3C)s4;64R>_~8h0%1WxW#k=LQ(qZ*#q71mIJ68o_}mzH%+T~tk{m<0KY2N{{O71rq@EYa4pp=MZvFF8w@S0_v%N(Bz{9+64x?99(=FR z8_#>Hvww1vD-B9XPVH>y2nGTK~4t0VSPJW`8rGIJzE|i z*4li3bT-{k|tM_`E~7uKl~5E|$)nHTs& zRHCvPe`}dMCKr#Kb9`&rQmXLe%DB|c+c2)b}6&gG^9+=D{BRR&FUwZa(axyl9jEYdzP? zsR4U;st+3YID1o(ns>fZh*~XSd&EgE%O$0mWN)Fc^f#% z`)$H4&tM**1vT+t?kK&CK{{!TZkGjM*ew1Qb(vQ0Z1M3{+}l_WNNH)(FN3HG%#;h{ z44)`y(>I59L1T^R(N$T2lju`kb;FIrYJ|V*4=` zFGc+VDYnxQ8wi|>?a-T?Z)G*G1q)0vhHm@()i(*c`6CzBfo3A8@J9}Fxbqi!=Jl30 z(>%^|+{M+IRIzcaorF2&{Av-#G&1 zIRNHl7DTQ=dOad}Q}2+8MP*{QAS|^T-r^?{_L45RT&ETJToEF&;l=LX|5qA7&j|Fj zB@>n$7yQqBZ{zFG$gfi8Q}>&#%K#lux*J*49yw0L>^yh^rrf~1;Ew|cDS6Q-EoK-{ z+beSjmw<7wHr;O{V)(N~J*LAk2a2>j=}5T8({I%Dq<;^Odlq!BrCX8HCVA2MDpS#A zJI}|2>dGs4v-f&ED}VJ)0#Hp9)Y3DPgL1vz+wr`=jxYx$+Pba>#}w^etY{KSs{ zXMvhMcHpYfV*?9ER|d`>eRg1+?q~^z37t%%leNCWDTa}g-mj&1#BJ&75om!Br%KJF zu{JX)t18&~`iZqxjuAL!N~c7BC~_H3Q}SEhvuO2rliy|fyC(U43g2HD^k1ri(Fa_1 zommc1p>E;e?yPF^Cv?gG!QVffKeS7J0Gv9oey=Y1dpCbNKNjiiKMZqwVEqkle(j!R zz~rigQkXma@%xX~nqPmsu&uLJ=dVxgry92@3CMcNyPq7LGJJcy-*8@z^E;JA6+_o! z`k#hldGDUSq0PZ8-X?~S#zb;&L29%8wIA0YS-WbZL9ODiyftM1p5p4)Ka%UV_Q^rB z&N;j;&}Y}!+*}UF>VwtT_4}c@8`OH#E1>P?>CN^0T>rg067B7g2r|_@oaUivBHZ|H zHs%O^0;sEZ+wzfa1T%?hIg}TDG?Ad~FR=Y^;n!XNY>YQGyL*!9QyX`%g`c$Q7P3O3 zNVn4+`o1cgH+u3!nS5#22M`7!ks;<*_NW01kx6C2g9QI$qH`=y`uMB2Vj(*b8)=M< z?UnLskoplUUO5h%l=86$i(LciLF6?y(p^I8aq4p)#wUi4HEUBTBC76t-^i(^wS))D zfb#v-n9n?mv|29Gt*!a=n6)|J`NxZC0WRiJQUWrxC;GuZAYAwnhH8{Cifv&6dJM5V|S8F8{&t0ua40JJ>GSE-0)>7D4R!a`1H&EnZEC(Mu z!zI>w=@`1a6)kS1dNQB2Zr0i_SU0%LZ)Md!f4OC?E^_zt(P6!UXwY?>F$V5*)V^5* z%Beu<_j?9f(f{I#`GM zcEz@YK7Za4J<=qO{Zp)y!LjU{NoSWG%et+1t&Woj#@UQbNsfB@sRV+xxQHd|)4iJ{ zz`SP(f@@Nd>2`|vQVTO7s&fk<8Nps`SW!-0^nG-SU0z8UC}Lai6??7#NE`Vvm^#0( zN@#}P$&_(U^gF|nG-Ch*Mu6?ojNu|V#SYA!%|6$L`BPY!X=W217CYJw4-$WJLl-dN z(sbdT7HmzOOrZ%Kv@b`hqG|YdoS^XkzjgYzBRw|(nzSMMsdOec26?^1}Um5IBAKqkeq6nN=K~6WK zlfa240&1C6yr3mKHC>?c`8w>b5aLQT5O=Nlhx(n=nv#^@!8LPIQ+QQ7)RdMqvBFX- zS>>yiKj8f8qUXJ)UIW%oz@b?anYS|_;T810yhbZ#iPj2_FETNX9@p6#`d5Kn`)U)Z zxQU7~G2RmD7#zHqfDKmNlcuokY^`gWTPgu?I^(mqQ4$BhR~9w5XE{&w+G_L2Bkvng zqjoQB9Z{BkVQSYk&3VFFp&M>r)ck5zFFaDCsXW*Ni3xeK}zV=McjhJA-*H{AV%zXY4iM3v25$(Eh!E5pi+E2ezQCAQ(JTtfUH7) zO=EFl618WOixVZ;@8CwqxE5V)H(o_cvfMx9FRR~xrE}!WTVZy4s4a*d8)`@wtm}Hz z%0|{th;ctD3um#PBuexHY|QfNCjZb4<(@wN(&mMQ$;K}*jbD&#e2BV=#;wM8vwH0> z4-bNb-u9O*f0!A0%MLyAq?MeIjZv2xusA4Uu{Aa?b|mDf7M%cOYym%j1QM;_H{BmOd9q$7c~=);(FE#;XKfs_<+h zb4*l(`<5X=lWDa0!UL@p+2U8dsBYcd?^4tT!lmoypTV`FPg%*p zV2|?n>UbL<+ZpOeUsQyE@I+TX2?%=N1LJx`gp?OJ)EVbRrcZki69Z582z z-Q$TK>G|tI3i~?Dfk5X)J?$mAX4+=3kP(ATMPgSh zj|~@LOTzC0b-UiRYy90I-KG8^ram&aS)Xb_s4Dg00Y9^q&hbQQ_ai&~3vW9SZr^Hr zKRdK%&^4AZgs#%2)#=T=5S8)bO`?ZFWd{$rt+4l0}@D+sMzB0srWOfBbGKDt;2!)IMrPC)|QCC*2Y>&_9g9+8|> z37otl@tmr@3)_KM7xA)4c6VYY!8*bO=Jljkc@S{ui@u?mLq9I_tPL-ykz0;hgt3%# z@n~@-q1?mSEba(}o?OS-(7M_zCwIiHo@OL@Gjj^|3hWraS4MB39Y6s^cHXn{i_Z zE`?p&t6nboEN5`8^PA=Tl$+aB z;`%uYmQ~QS-v|8WTZ4T7vi@H3llWieY%Ci}q|;WXUf@&ak@tOy%7(6?Yu2zpRk+VN zK8m}FZZ*`A7Rab5ee| ziT*cA|I#@Q#A=Ic9Ok7Vdei;eL)Un=>Heb@$(TsaNeO(%PuyWar76<@V5$#2mZrWm zq~7I1p~oa3+@Il2Bl;x2fMpe4=%J2**GyiSlKWzY^@rx!Wo2l$1oGH%T>u*1O?|2Q z3KcZ;^|pDP3%u3~Nf!=9c#6?$)jvX^Xd8Kqt12@X&=myQvt$V>L6{|F9)$Uq;D)|! zf!5Y=BMJ;P!==kQ>9W-A@(Nn>J7+JEK3?Zm5uGi)M)I=oDNvKJaFubDQwZ`ULf4e& zd2ZLK!AXq3n;IBQapj`kVQ!87U1~^tMz3{Uo}LbHqxd>g9Kl-gaaBdF<@0Kdis%Y~DzoU5TU*m!$HcP317n&nMw?$@wDMK=bybnSF?XqnwfT zLTxfjrMT&r=?QI68<1{BB^Ipns31P&IGBEbjR4-6d2e1195-`S5;%^c#_(Ok;^%|@ z=afb}Bp_jsb$_66ayUoJa9-SMIrK0rfWDdtS7&hnx zLKcYB%+hb(d*RlzC1j=2<)!+%ccWjf3Nok3!$Cxh=$C{+w}-Nim`XR>xYEcH_M#hL zAm+baVDPVqfx-Q5sW-?@!eCU_`=kqxzncmM9@nA)mA3EEwLTjjOWYnR-5x@em1*9A zxk7X)OD6E3jQY{9$3nA14H?R44k)MtUrTKN6hLxTnb$AfJ&ExVXIfWk$6NmjQ6A-1DN^pjG|W zb!#ck#)050JO&VWD0Q1DrRJe&lokaG4J0TvALEO>``r>hbsMRr1P5ZUe<%G7odW=d z=?N_Sp{D+t&R2Na1qlo@6U^c_N5CXa!L zA$Ez+X<`7kI0i!7;FuD*x)F-l?hi=^LsDYJBo=bVzz&;gc-tSOZrUVMTbM3ZjdZC->6PFAJ}J+K?r}t8*>L zyH+P}QmbH? zmzauK8uYgKm2x8#F|_)tsH>bd^#J75ja4FAgG-n`B&pz1hSw-!(S&a9w-4)kqPCL~ z-*jx(#8%|#+0x7otMQHAR%kxu#WHFZ_EXaKvDZkZm@EYgX*=6(dxEt6RF}5LNZZ?E zXY+!*YpQ-J21*I1k1cqN^ioxg$P@iJqQ+)wkOrWR z(iJ%2%sjm!meaz)QRm6DR3l9YEKLFIL-NJaW!#IMIsUuLaH9o;jSrFoxNUnJ!{B*c6A=mP%F3HBiT zKztpM0k+j|H(CZA03LSZ$b@`QN3mmMuemhj%Etjj=)Kh78^e*GAR2NfdoJnnIy#C* zVKAt1pIhTJat@eF@5EfLuR!o5@u*}jLYf2~i5;;??})621oE5I(`|CS+^8~Y<=d=` z;qjfNg|eo?aGW9ArFM-hzA*&5phXKME~cA!Yc$BeUvD4$XVvnWw@cg?d&XYI@Q1eJ z8#vKv7a7;h0!r;9C=moa72#9W`2!_M6Rly5f(k+oYd|L`*to@_SrhNPk5z;VwpN){ z0SW!8fy5PArq>23SmJ>*o*k)(obWgsQJy*zp$Vi*0!Mj2uohwKsQ$?w63bs4kpOPO z;xy!uf<%NC#Btp_meGiChsA#uD-f%GHHc77yb4awh-85(#@#~HtRg^cW!;-w}m4NFz{^aqQ9ic8l8mS2>Hh zSSk`Cm6X|{Zjderc<1a;`2(Z@?c_2d3BlhR2&UI2Ah=IWc#tZQyp{iunp;#G2V-;< zV-k=FfMh~mChP+0!I=h78`LrMWrEIGbw}ti&0t0-{Y;3VyzAYak@Y75RN!(t`JwLfN#9m{uxiJm1WY>ANVx$-^6qe3eSj9ChFoqD!;T{f zN>#L2THTCtT%CtVNID;)?+q={#R3d`zo@O|QdRsPt}tmHcT5+PoSgjM$V1P^7-ljC zBwTKeDiMz4oY7dY@<{4KK4r z&3fH%wVp^%w|mi$$9Rb`Z_hzyJX1+vb%gd^okJ2_N|}u8S$eP=HZZi<;S^u*79XOA zF9B$!kXQ`@gi{!X9-4vOT!1EaFy`Go34kW_K%U#v8$YxrNh|)d=-vOecJ=$GRk`pQ z*Zz>@)c1!btp2ptqpq-$98JHUSGQ{f*L3^X#%J7R%F`q+=eqfc9ky3JbFXG(wP`c< zhwOXdLzdAHbVnGeVnaxsEt>9>sMBU72=v{ecyS#D#JK<$P%-h$XJK0J-78-A~m zcyw|MgUuLvGKPfrP=}h#C@P#Xp)e;1>7h)DYM;%n5z#!UXX%-3QTIdZINjw7a#$&p zrvw3n)5Zm3=ydoY)V(@=vl{W}r;6sdMHf9c95tVHOH*uka$Ez=xUQPlIWE{WRB`nF zj3!rG)ChVPEu{gFA5K6+98Tbr`4p^OBTMt`z25rOu~+!#g0AnA-7A2+pgMsozxX(b zD>dYZEJcVJN9Os=Tf9jLi)vequk_@UK9(euFjM7^Sd_r`=xu(={-1DsTVs_tGZaf{ zq`?_^K4HhXQnmZ7u{*fCdab%19NPpSvg%U!5jnTO=)!J+bdY%64iZ1Gt~6rvz0#($ z)*A9a;c`aC!cMoYJmoG3iZ#SMddl~+{C`>lY5aZJy9CMAbZh&z>DDEv`>#X`zb|9E z$z0Fx!|&0*UvATV%%J28{NFL%ib>P0)Y>gI{-R6mJFS+#pg2+{ayL_Q|8(o+HcAWP zxh@D#Ss?55>-QuoFIXc> zTO@^LiK)BNZLNWd(LbtWqyIZn~ z)$g=um+uwQR3t}_&+_ky<(+mATPN6utcS^}h@7%f{)nU}?|uL%Sub2d#x z=a>M~?04hr`JlWZ-ro!~0iTA;i|(?e@Lz>&K9P&b~b7+0S!`C2r9OdNGShwrEozEK{XOn{KI} zQoKoJRvdd!f5dJo%X4mkmMG{%3i1fJ@y~xkc9Vl;^^crnq z0aPugLgK7`l$Iu{x@sjj>8y9dHJgN8R+*v1fwZ^0r1zREbqOP^COi7FfLo)*--^*b zzOEQ&&a_;Nv+O6rIH?7Cl+J~TcpDZ#e{^l%4|D|brEa#qUR zHPLPod&2gFy(cidSG`@e)!c|8En^D zQw$Z?24IyhJ@r~>f4gFYoAD?blWly(w(knGJ<7+Noj-(<%&v|cAKO_NIXt$rHnPv0 zo%fO!9ZKCVP&YM}kFY*Q^KphgPUT~~K8DWO`5%;x44AWX5LhpAq17l=y`4IW5|>g! zgK24#d(DR_DYXXsX;+cPq|?jH`0wCguq80>C0*yWiz819!AID+j>MLfJK&&tzF+-# zssjms_^0gir#0&Dzl{enSj&0u1+22zd?GjP;8`=bQ+r!&wN)t$foLw6>a^5xYRs2N zmeEpg|FPp&?QGF@%8{8%6pGfe$_(V#+b}}w&;X<2F(v3RTzHr$JWBBtzt!7zZ}rRO zKWH_hG;`YM^*>V0wdM!E)$dhiofM&NjX62ljz4YIpFMr|^O3ove$xcHtK5t-xE^r? z90p6;ZyG)mD2mOmF4kDAASA8(H?DDOHAb__)w(~)9N6!3tXY=@T5aCP5WV|6-_!|V zp1tK26yaFr32`>E5ige@1^{5$TSea^WhbK#?$8JL#y=^prSuO9^! zbRPF^Vt71Pvo2cPz&-2qB3?J$Coj4+RI>t4^fXU~jNs^PWla)i^a$20nmsA2Lp=#x zm@eIvJ_bDz@hO_&Ajb3%E1<}(D_ru4N(&dP#aF%u$EAm@@^kXGZ8)SuZolk1EN|9p z-~KJ3DVvfzq5tN$C=jLr`#y&3Id%adGY7d}v1b6JbI1s`$CY}eOC^Mz zp7BF|fJ~WvIQ7Byd2Wja*j8Z1Rg!J_v|(hY+XcU6E*Y0=&Uc<`=Zui<@m}dRLHNqZ zs9^h{uCGU~K$KifM;V>@Yg7WP@k49nxm-*(A;5jX3bP5NWCp$qT04MmxP}qI_M==o zU!fggzpvWaS=hZe+YAR}h53Hh27>JaUEPzY`$(Av+jNmuOy^8-!;AkwT0THf|y$wdM^H z1^gE0{9|MFW;f+8eEb`n2g@ehaJGdFscCd1xK4!`L?4LO16g~XixhNwV`To=dIg_w z0^L9(=c`<$2X(fWD7|=!e-^&ZhL~uJZAfQfG#SD6Zz%O*QiJW^bUSu}tw5-8AU;_~ z&Zn+m?f6{#>2I3+S@e9%49Ms-WozRN6`2`}XL3^jYnA!lH#))Gy22@hJ>}e-eLJde z1jMW*Yaf~e*`Q?bGap~>c;=Hhu;{toI9;~%7UqrCn~Su81#)&M(mKR&dViEji5|MA zJ#+*9|QeFCl9sg^i5t#_|(c~NaQudnl3@0_=& zZ(d`;(qMY?h~a^@C1gCxFc{y;Wxex^N4Mxy8xmvllMj$Qq9$7FXHB(PptaTJ8P>tk zl|bvEbZ6tKtT8LtV-YqfV>P<5`H4vdsY^!;j7|=DJ0C>_m6sYnVo=aq8>n05J(oIj z#HrEIkFp|psTMjq+I3{-YkJTd&!UDso=+kM26w1ji>cBPM@E0~C_b=+*cx$UyJ`nQ zV3{-W@#JJN+O{h?%1+E-lJIEK{0={B8xU}{>7N}qFEuF$%o?+w8+2QY(qRvsM(I0W z6j)irTVz$tX9?Jzv#A#GzBf}Kh^5a*yrZ%4o4esTvxxWS6xO&`sAQtFl&fj#D?%A~ zyio`u(E~KJ{tT^imfy$GO-~<`hikS+4)Nhf$4F|Y7#2| z60@oybIuE2%bs&*iho<`gMn3B2@v96!e72r#`MhjOfRji9(rtaTiuX91%H=s-W67} z-X6RyDbT-_)6ltb@S$tX-j~7+SWkv8k?DQnt-^ZUBymHOL*^{%Zav$M1sVmZ@|Mh|8mqV6cZm1Xn!)%{IxXYy< zr(#1oIp%eji^;yxW(Hd2I?GIgyOt!SNU3Z6Q=s)fbQJKc02mtzPsFq05Z|xD-jGb~ z=1TEeU28frs%{99`_{-l63ELXd^`>+g}&ZszA}{+g7MvWlYU!rezg;7>@|C3?T=15 zb#R^29S9e3Vw#)X0ea_060G0BH@3ugzhF(*m<=SWuYp!|>=xYIcZt8yx7`H_u*VPC zOVoLuI}#j);$~I+ZpGa?8grHi9C8(V+3|le){cL^71vXb|GR>|izl+Po=G8u#fUfZ z3zWy5qjmWkbMj9=XV3gcbMobg*0uh3a`JzL`K@dIO*#2X&e}77Vov@JHmPgujpockLKiG(9QV1las$-%%1(=nxjKv=6NG3})cPYer& z!Y45*9j;j(c~b=(tGryy*BWc^n;8cE5C?&{wKaJW(9gz5N>UA&x5HsxrdMF6cN5dwj(JGa>zzBjT~U8}y_w!x zN=$pnMw!Sqo;ZW)EoOQYzyt+`Qq23mE^T%0W9F-&8N=rV>BfTRxOdEb&sisOW3>R6 zwxZyC5OrQ^e=hz;`l4Xkyx+8pTU%c)h}Nf9>3Zl>p?DwO$KCxBWu|d{XV!CNorRd} zdXDT|&*QV}*~`s)zwXvEGsBjf@YpqV(lFln>YJOlr zZN8Xi$%Yw7-Y;Ue`q0dM%D{rN0HtLPzP2J-!-uB=3j!|THJcDQyz{2)BMh57MhR-|%MuzkEGi(~T5^$;fi_0wUf z6Jr#R`SN`76HPNRir3k#{d=p?Gw!;U$!|ngvOI{$d|&65{8PUcVU^7N4}aJ5U3{P3qa4ojo;OP@ zi?L4nmidU5{`;rL&X;St)~1}_EY*aIGhQdV%*D6b^-&-kMM*)chx_zeDfY0VayiA1 z;S;GCjGe=LE2fPP+&WgglC@-*As5oX+hK2dNrN}JC^7Chae9B6l?!Z3!rr#zD|Q__ zf$bcc$N<~@U$MYeo^yY9H+Z|*=I6j$)K&O2cpINBp94=pSEnKiZ!YxuZZLPKtN4n8 zxxp88g}DM~l7qQOGy^&-%%5gj&{kJ;{PjQkP*Z=qzS`z`LJkg$eFx z!YEflGKc&sV-C^XAjC1&U2qDpvK_jTqkHCdgXYVXJp<-!WrDU7XMP=5x25Ked3exe zw)$wd)mM|BaqjuVTuTY0u6uF;>EGnr;lr9*2g^cz$D&Z`9GuHPBq#q73^HBwccMUm z{{x?n5L=rl9cOz(aA&2QFX2=4*$ov%xlTK^tfIN!HCn}adN=sEXeFAA z+sVQB7ebREFH~=nG~E~k%|myHPu~oB+>dQG2ZF`ypH9my+4oy#W`PK+=n6E5<}_3@ zI#(Mw6b?S^Ll-zd%b?F+4f;9XbZd^x?f3@JiVmOkW;XGnMB8NzsYCrPW79aGy?tY&hJO;U?T)1t2* z&28^na9$?Fy8~G^-wi8JKNl*&uf4!{Ec~`bG-s>A4)8W?dTO{ zdB&H}@#BS7Nzo0K&ZOfXqm?;>`;jA-kFacAGM! z>>(K1R5@EQl+G!5kbcR>1;?RBfb%?4EpZ+b3szPsrvjj6iFk9#!*qo9Jy*iZ0sj9B zKOH{kJAxVd^4>=?a*wvlPt|nh=VRyToT$rRm6QLaaeL-Jn3I1R6t%NH@yFVyYt85S zMIgU*U($M8@>|o)C+d)}Z}1#6e-nju;P7SF<`r^z*KX{{)n+5ME6X%aw*i9E5Zz{$uOk zTj%Z4=e@K_1NGip4W4XYKB@$uG7JDN4-B{8ek%M{Ob zZw2hYEjPK&*MKGz36>|)r3Ui0SFP7Id3+HYxxH)kv2gGGO1KL@yrCo=y*f;&D4plA zRykg4@zuVYj$zXH!G*jnL@y7tUPi0dSlJa>t>8LshNyjPzSl;V3I^?v)ipv^edspq zUGI=p)k;!SC-=TH<0&LI)xzPSKI!NQ8GMyHp0B=?9nW@+r){c`i;ViV!JB9>hhN#P zJ>fr#uhiZhcHzx~r*-11*kd-0IrdkZT43AeiFS}J{Hd6++41d;ug({~DyC%B%AEdl z@mnsws?b}(JzpQk>~}*~Kk9<6z*djk;G8pt_{i#BhQXUuP$D5Ih4%jO@DI|UaH+$6 zWOXVH3##`(R=u%>T4Z$#C4l#<_$5FCSuJ(Q%9A9UDE%`!FG0>Fm1FYX$D+V1^fbOxZOX0`p2x{#uok*rN%0=6kZwJlRJJ+!<0NJy~IOuCfR4C|Y1Z zvGa0)XabBaoYAj45BUFMUq1iC{mhK1)0(q>b~3x;-}ieH{zq13P)8Z+gXmB*_%7|8 zl__;c^MP|6FdV=N&tJwr8OrsMwGAe}O_FcCok>v)rb+-PYRyl-C~;+Jc=;%-9VGHp zi&&+lV4-*cu-3Qz2$J!17|U(S*iC4Nhqn)KDgEjB1e%lb+4kzLJlss5a-i%Lj-8&T zxbUmYAE($&-bcxM+2n#Sp+9x=kTFOf1GyWoMo!7k_pC)ov6 zc+CdbN38fTY_*(zhF@!*zcag_M=`DQ&v6Tin@HaL<^1Co^vmX_@QY5j(w*(FyRl{U z1?(@Uu*-W@i-+1#ak<(0v?a~2!0en#HrMoyYil;!*Qss*CBqla{R+RW%i$FJ0ULHP zA4rH8@s6zcAbr{G;pPHvMPI)?=*??8??v7RDOjC;nFmk?%Gi7muel-ItZqn*YGSLy za%c;*256$HW7w#s!1p_N!k{4aXoEM3r>voDfDN>5wRslk5Ti8v-sD25mx|+Kf`PHSP%Z?r`45o~ivWmD7Klz2#7?&$N=!ueLe^!sf4H%4-hdbS=GF1@b$*`U z=V;bmKi$E^zcS6jpRZpXH^hgw4Fqg8KauB-j<{2Z#-t6+xPGnZOiMnGQuEVC?D)Bz zpR+C3JC$aAx&x1^9&)jg?6-p`OxXr<$FtPc3WCWP&w-zDJhkZJv(4@Ckv(s@PEr;F zobQj(+fm2U4)%_xu~+ykA`P@2uOkI0+$O%p+Y(HS{_s?J{snH?TRF`uV5=6CV<>-k zUeVmmJg0TX+$sDD7pzOa5+>wzoHM2?g7HyE9&5 zKQ9)7?>xbtGPKGNK%UA zj62w#V0>l`Qh2caRyUskc0Oy(BP8m9w^inc)tRp-`eu8uI*}fn39PS`4L^g8D0f=9 z<9+QoJ6=8Y@sX*ve9`Xc7FDJ^k#n)_-rPvfZLjNhENo$GIE-|5a*Rl1#&tCk(RBD)PtG)-ax z=4M(4Ub>G!?&^D=QvYJrAN`l=gT(I%=Y-bOe9Okk35%GT&h_gA*SL2@Q=Plbr-&QG zke+>D7D{BvD0d9NMS6%);!g98=Yati-OU}H$R?f-U= zgNo*lz;9>Bm5(=9&aetXlP~_=M`D#AvGYjp28k6#_rO;?>ja{TyBBeMRAmv<--MGK z{PlGs71Wlq1`TrAu?u2WqIdpO;LXfeqCknylywZWleUiQ9X1w^74}YSNSryLAu$p$ zdXeSRqlYG%ILK?v5uhuFk_THYjX?3@#j%^3T%h8>SwKO&#^Ex!)oPdjS$ALFlf0kJ zS!faxJ<^S?-kVxhk5iuBvOqmuDTXM9ttrr|3$fb6schLh?AHr*(lS#y5GPV0ALR}4X=Ua^j2C{cy;bFA$GY+ktBya^ZZq1*VEd(NCQ-V9 zi@+i=4db;_sg?8BkuG4BJPGW!lb_L}ePqUA*R+(zwf0#z>K|0jo|&+4KL_ z0nFVGdjju8thbt=Ezo!S%9Wx!(*Gkc(LfWRZh81o=rx+kSS|z~3&^9;IADUZ3&=;U zRyCMDM@OJh-(rLMn!Qe>dApxSGOb{{j-t6kiPV{{{nY)cUgT*%b(?MeT>i=I=-t<3 zc673)tOvQ8E`}hw9i6Tnou0F!-w_AvjbTSo2Ca`Q4aX1EpOyIqZs{h+d-+?OZR_vY z+wQ7rzgZaWE@#O7=|}ksteZo4ZwTfuv3t!VuVJCt@?Jq9&1t`KOufX*bA8R$hUyN!{Figi4u^iwK}z;&;oi`2dDcIe03&uH{QRNX5mGq z(`<0JYmT(@347dI;-2IVZR8Z4uPKDrB^u8+7+s?WDOn-1HATqzlx7=CFItqC^5x_r zrXuWhq)jZ0_Idnn(605(RB~UNTo7oxlXAKw>RL8efq+?(ybCn72zdR*gIU!eGcF&Gat!C8ayvY?^WhX zCIHmy-az>v#*26qY!@X;KF&X;6m0E8ttf`m;$o(vl82HvUwT$2P4zOfui_S{oC0(r z8@##gdZQC(da~X2n@n_C!I!o8jaJubR`XUi>u>{2$Ht@g@3modKuoz4~ybCgnM1w|BMa6YRaXkBA=$(m6HEJ7#SD zNp|x;k-~NPs4|u$&jG*{7(}`UfCXB&{ikXpHV88c0Q#N!DF84Fvt{zXsL>Sw(24rg z&If=x3jnd{wRw@TnM@$ykk6|l>#uAf;3}&c<~SJhxdyWER<;nZ6)q#}y=aMNy&g!) zo{ZE<-9W%Z?nFBXcmYOgcL;dUGAu$}O+s4kRB;~vVcPgTLB;70A3cmmoq^18~@jI-dgJl z3XSw_ALYtSPMyyk(t$Rt$g}1HxCepLwDwcUPMw^SH3_w}vL>{jq%7XPB@l^Tyf2^&7u6!&4irYBU%R|TD_ z!(HeVN!<}HGE=c|)fgg@jx|D>2kYI$aPx~MI#e~*p@NaY>z?z=@?yKv(en8>vueVb z*SOW}N9&0*Z-G|5VIE{pA#wK%6MMhbERdc+i>BZRcE#-4{bQG{b7}nd$ZtQGUMn}p zh85b7xNkz?qhst_bWoR(Iu5nwo+^!uc|tm;UdsF#pzJu__CUjyhUR7FXXE83sG*}Z z$nH2e|Ki2-Z}AsE@C-QhWwwbkZ)O2rH$P)7ZTWWWFMMgzwo*46`zcA*)$3d*TrNGx zPQ~8W(_Iva)78R_JYFKSjY!S?bgG1z zo`dLuFMJ`q#*HMqqGsqA%~V)EavYx zw^4vwIwM!WEJn8bpcseEW^zyo@EUkpB{1HT=qT~xMP`K zPvEZ4@wxsYBLfTiojAXQk(lkHwfd2M_Qiy7%_lQnP4$5DVF1et2j=O*-MmjIM>zMP z4Ew{#AzZy~nVB~jNE#a5o6r2g(cblmXpdN`N3imxt&8({_r#8~p`zMbd{5<~r?!Wi zH;haa)=*^xkD9?BJ|$crzBQN_aWRI*hGyK*icdHhoHJGgS_af-$;MKvbqaeaW-z+3 zd0Q1XwnD7rM*KexGe16?Rg<-Kt{hqF6a7xZ!-Dx29?`M6u+O5Aa|@3MGlLoH@!bx` zmi5r|mbA>M7<{1u=#L1r{hn$W_ciuJH;P(FGDDE4+;$|sBE#loBx=YK5*cXQNjkkh z;q(qRZ+$^JiUb)Tv8%{nI_7~o$A5pRI@zLrfA$juNMfZ3ciD|6MAxHDuql9C2Txa@t+0Th2(uli_Sh~CBxqP z#LL+jIk`P`@7`~2OBFo5@S~pIqJ`%0f^}Cuaa7H7(f--Xe#5Fn^H!Ue5o>5UdO-m% zIgKy!^S$C1;{)XCIV8N$6v~5&BOSJXgU!RS%>kuRpX7S;J-?08g6()2rI!V34hzhS zu)oQ3`Nxu$N}9>jh;NQr;!e*E-v zZa>a;E$^`(jdMTyeiZv{{Lk&jU!zao-N)_{^vT+|xEuQn1S!Pv7%Gx<0@`?=B<&+z zarY$Y)K4c#dfTMx?Zh-RVY`HsPjob?HgLz%wymE^kEWnku=Hq+E@ic)XZ!RBGFUjs z(W8}|zseUar8${CI*H8aL`7Ao=F*Cxvnqp$!i$2BTuMo}(L~SpPZVO-Gg?E<=~76F z!u*t6-73jY7YHR3PIS_zFQG9SOJME;6s%9|?^Bn4!#iFs?6YFz+W;QNdrW3s6Ay^C2t$ju$o~M^QqM>eXWkEOInmrcQM5(8bLzTmIng4@ z9g!{fyqRLlsiD+a*>cyqayw4Qw0E?rwdJO%+|k)`7rAmzWy>9F%51rd$pH7YlOf;# zKVS}rHQQgIs;#mzeu||z4e{9^q3$W&zdt2$BdqPDB{%Qwa2NIxVq;0S;lI4>h=|97 zyJDR=B~8$X!O#et-b%gWK}ZK95Q>Fs`xPtaSpuP=K11)<--M?4HLehSMiOxV4U0r9 zu#)edB;twKXGcjl`fcoqlFrVOh<^@8Q0wcE1{K@I#b2;Qp~pDq6C1pi4wlQhVoL0} zs6M?p9Gf1_i?GOcrBUUYfnk7;w?wzAPANChB{=-x5L)xrB92`*TwfG)G{4q1w0OQ0 zj5=C}^P3~H_i>#TqC!)JV$Mli>h-^;jM2!7e9>`SKYvcBg`YEDLvbMu#dK*X#ybtg zkoiCM2Xd4f$bGOA8B}g}%51d1yJVgv z-kHo8%AP%H9*NEHZ#|DrUvy<(nnjeK%A7UbeiO^7MDZp8Y+Vsb73yrv9?yyRGl~^IUl#<-0H;c76v! z#k=Xxm7ka`|8b`L8&~{=@|BtLJx659|9a$KD8F+rJAcSmc06DGOXXL&@;rYX#TbK^ zdm?-cYB}>?`_c}9EP$eNCrGn(*}dszdQZCf-Q?P1zq9LCjx63y1dFIoQ8BY@`itgt#XwUgv znWq1+){5$^%{>eC# zwVaEa;L*g}m8A`>rM*_S$_!`Hpv9hGzf>Qc|ESeNI;_#Di26HJj~3r`osWB=rk3%U zvwORp@LraMM`~g?hvL`a((An>`k#>ZQPoCMf2^h{6dwJG=2t;6iw2qVk?i1nJ$n>N z1c#ctu%-pu>uocoy|uZ_>fDp5o+!PYR;3oY^FXyGmDTEDPTKa}iKmF-P8B(jPqDit zb|DxwTr@wg$a*V}eb(H`L1G-m8ppBplbi+#+_p*($=wn;;4W%pFkZdK3L>6 zX0_O6w^_FK5OR|zQJXQ!d{Aufqd#N5JF+Y<6(D9xKGTMaytUGn30TF~)8=j1KxyJo zXvqm<{y?t4Y&4f0K;xNlou-yI!XuLZZSpniA5efmOYrBa2XRVItQA=sJ<-t#bD3Zl_uD|bO>KFIR)W4ry`PA`UnJHg7FH^qcQ~UeUp8Zv3 z>KA9nd-A9BSIyR&odtG&fT(=+F{lBnq;pG8pdb4bUp@_A@4ZBt4z41Yb5FMiX`N}Oc^#zFsA)D2QC~9T@g9I31IiK5u3jFOnp+-Z zVX5@im~|)Fdab=ECjE-i9)XZ?^I|{EtiWjVXX#Cxetg&?KV&H816T&`l|V}%-+yGCFB!l(Pf|H* z><{RlO@a`^%_8y;0Qr?VU~A_8gl}iIs%ccgHRd*O1@-sw>yNheSD9gq zRV$~^^HoopheVoUOQz)9we40T-p!%;A57{(uR4!{P`UY09&@VP9P(bq_{EwosL-3` z_~Cs7TK(}+S{~Ub0QQOB2iMHvH8zj zMO~g(sP4Wiwl@RyZ;}#N}d0auX3gkb|8vC7V0>=vOsrD{>=#v4iJ)B4H>p(0ongi@QaFpUD90 zH^h$Mu%Tn)t*c7}dzu6)7;}jwb5`CO_DCId%@j&DBy<;}w+vs2;+u~G7M}vxGq4yX zL6rKVfZAN1s<9BKBla8(0E+vw((`5UwN(CBXHqOtdb(e0iLFHxe%jkY0oz5;c2Rp(vW09j_6V^^ zjz)Ze1jTd#^Zfumqds0(VGJsOdWPr-(&>JS<1CpGL0V<%MbV%F({l;ZqH3QY9aHWO z>mb{iXqUZE9%%hQcmn2bI+NU;AlZw5r(^BYQGJ(U^^gKk#%gm6MvM$ay2Q@YQl#Ih zDY7?nD8t@6v0Vdnw``ReAj;5e>by0TgkD+H! zjkuo*$33RR+;3AO*NeEpe&YjnhfDclBW&@ZW{oI}ZnuDW&au|E73NS-F@KIS@AuPq ztzXW`kF18WnjJ7KIOcbBW4PV;>O5%_L|kJP>SZL>AYGhJ$McvJ&$R z-xk-@Icj&VUuA-DGN3TV((Y~bU}W4FYRy%47#8bQISPEO_@;Povjj~CSX&4XXA$$f z8ekm75(&oY?11l16RP_fT?X5OZV*4Bzxi+Q?P%}s>i{?^9A+!5HBV|(Y;M8n4&^yh zp6KofE!LbRI-37?k={b-ZJ z_SSs!NV`qZ0rMXsTidgl-#|y7!%>Tm?S-11cl|=xQuT78^ooh%6AuH<`q<;9^AjCF z@r|UZc9Pn*sYIB%wbz=rM^X2S{u#Yp(wlc~dAJ!sULV2#8kA@rfI9nPJNYT3;lKmP(X5!CdC2E_bPAN^LSteyX(1@>AnQ4LDzm zRXg;%R)`d&>JTY%ru73yn9$c`%OjgHmNkxiCF~Wzhe)d(20j(;qM?}~>5*aZW36r8 zHl{zjZz|69dKZ-%hjWTFH;0-^p?{x5jSD;Xq#Jj-H6Ap+8%O-XL7WzNILT0lBYKHI&1#@#pjjw^4mVQgU; z$g12N3Tpv!yXM{du3ajW(Dn(S=jz%zrgM2=ub{Wi+=z_?@GX;au?g209Vx|rz79bca=5R9 zM2JA=9UhL;1W~-brQHBBnNhLTW$Zo$M*r7i!)hjOKX-1ig9Kk4$79EKKW{m^TX^vI zKU2Qv#|Qf5&l%Kh`7Zd%Ulh;P=czI-7xNkh9sl;JGK}~gfMmUf;O(e=INzK)fQ^eS zE(*tpJ$t(R1-x^&i@3dsEW*ki$qVAq0Cm1ZZRuu)SwrQ1`aFTp+98B+SX`#+Q64cX z9pV?;*B0wsvcJL6ad$At{uPehScC}hM1%KKWIQqA>oLGK@}x~=MNz&`u^R|r^9yUiTpaZBbKx#KfG!_){ry!heaG&y zZ6?SKSnqk{%n0X4LwvfSavkIKyD!;&e8b%M7VDjuZpX*iGUNMY25Kz~2EBdS)=z&+ zKF#Rkf$&x4G6F0*s@Z$S+x_)x=g1scY+NeE)lpY2?6uW`4c04NOK zf!2Wtko8!!JqSuD^R98C_`^b3A;O76PgpBJN(I|nXeAt|!={2}bFEogVF!J*M4A7} zx8Fy|{E#TUzf|D5)_ffXm>zCh2Cr4skz8WA%{As2lz_1gaLk(6_VCP;OmLv+(;BbL zoWd7sR9(sYvf5x00~_?{BJR3tJsX$EjG)ofPaEPtuF!bQ^9{B!XRx^^_Db)9Mbr*c zd*SwibVvAZdPYV5%))d>s-Ql-1lGIW+oX0qdJ8`upX>hPG0Ws? zuGSu|5wkK&!`ogBFMP8geB~2?Fiv?V2p0BZ5cg3^{k5S}!&T#p!Nj;if89wv?OdFk53qpZBj)KSz1gjs9e zMDA$o2nELOlo~~=gQ<5P+XjK-f{#oj#T?7=Xz<)hD@1Qk=X8^>BxYiDy>}%X`>Rey z{HNY2*#pS`j)X4wH5*QE1P_MB90L_xdzNr{nN|NG8e9kAb%WqG&PbB1@{&*L)t z`OGS}pTC6@U_UQ*d+CnP&32C>W3-!9oxAxhRY{ai+&8nEPnt8W9%R-L{%+oueGKtB zzxl(E1JT~-AyT(PpJIJ2w>$NZ=GL^H#9PR@ulW038!myh(CTH6|07kP_n4KJrh0>%=AtLkqb7;c)#T`m47Kf{j@rxo z>|h~~Eu>0h)^jWC)Z@OCNF8W(%g>@)E;LhmSVUvK#nK~mO41C*s`DcIHQEr1rHlNg zpVKWP>6)&uw@7i2)z1 zEaUyk_F=caTD>i`@S`5SCvKwO`ht}ldtSBd4Mfpi+;4AO@PoAA;WUnQg327chS$~4|Nr*)Tz*_iL;pytoTntyxoadB^?qHQJxHc)3K!s~1 z$ICcre!9P69nFS`M02>xY#l0*C56?B{}JCMpihB4LO|C*2x0i&nU0SyY@USB|x(0ztLY*?3fqb^O&vLVMKP)917&J--2EE1v8MN~t zn=J0uzjyr^1{&LO*!6pdn-?P5o*g(S&nuimNS~Y;#y;vX;}9P8YN^hP?$uvTw=S9!Xln-S z3KrHZ3*34EU)kY}cf}gN-^xDX^zo#q4yhaMSsAv7Jt> zrgMX2)Q;4TJ_BI>i;CD=P`ZwxV`fIZ>ci*0!Iqcv>zm=}-xYX;2?M(@GgM=}F4+JN{YEvS_;Gojb++EBH&Ny^n*xgZs*j{6`%4!_AK4 z1b<1Hvg>1udSqbOHXmqPOmepN7A!g7iWmB1YM*2tb+vy^v33D>uw5Oe&dt`5)tovH zWa~^cx7a$Vo90WMr%@f~%Cy=ta~RccO|vU=e!K8fu>CR(V+#L>zoEGcY|;D72#RI9 z+Tn(GYPPF&roU^ZL@c=m))3#6FV;+29BdiZQ#e7#JlgyB z7FQP5d0#2yx%k=PxO+rB7~A!6pzRWl4CnReU}F4K_3<6o2Qac9{hUKTrrP$AybVAJ zRnNRWa?JdzXjg^xd^n|!qZH$!&c3FvD|`us!`_f^Ji19ZdQhmkCQ^Z>4qaEsn%g;(nM25;Qh z*7Y}x&6f0O_IcToyG#`&Ewu^Ft;x>+C;t3xIn95KQB*EY`z0Ke)|Kr9ss?M+ZHcAR z8Tmity@6mFsH?lCE)aUX-W#rkh|jdkbb^*?P`G)T7>;lOntUzSC@q&(DBid>#A1cw zbx$W_Y(ef?Ve%cvda+hlf1tHmtF@x6SBDMVZ9;?AYgy%T%O7aDM&&Hmi3JO_S_89d z;ICFwug=vv5oHI{Pxb?5Sbl%mK9{ZHFWaHnD!a_bcb2IVf&)PpUX^Wh=9T|jL~pmh`@;A@{i>oE8A zNuc#4zTm!E4|6Gf0=L{r>+P2bYy&^q5{TW*pV_T@-fi7idS*cPeAC2O*}5N4UvM5B zo850AM!~z!U5v1m1VOdt5GrLGeb_bHKilXUv-4=%sOYHWS(3GU)j{Y)xdsyuMZYn9 zHogHG;BL&gP3lM|);z;Qn4)U$(0jOf<{K)6o2uy8M9g`T92F?~KT&(B&RcYzXu2xn z{rpurPRc?U_Uhi@+;8G_BQ#jicT!h}n-O68pO(6mCHy36Wq+-E*jrZb4XNW{v7mw) z07_N8#QVwKIcbx(vvOBzNbCCOhU|1#xal6x0Nn8(Z2H;hrY@@76>fg6V27Sc^Xe|H z^VWpD#xtZllvH7jly&yI+~{U0eIQiSjSaeMrtz|tnalGc*sL1gL7ZG&prBBBoG3F3 znU>R&(eQ{%oNK4(a9vkC_%Fiu|F3r=`ODEeE`~Y%?O=Ard}%{g?9?tn>QGP-{t1nB zo%c@ALqF0~)`;qSaHDt5Kz$tJ!-o?)^+$OyO^mP$t#i7w-A&?ha%_6x$6|6PTP8Pp zTG$)L<;UnjCWK6y@u|H8@Dz_X1$WSz6N!3M)!`Ibk3Pq^l+oa&!ikHE5_Mcc+ADC2 z7)^AFla`ZxH^IwW46mry7pzFXoQW|dS^Ylo>pGV`)IK83UT~>VB*NK64t_1$1C;u9 z$F5^1#5W}LNftz>_E^VLCG$G(I4v>D&=@Cw#Aw3H3-~uLbvGw9Kc|*n>gMSGCa2c- z&F!jnHRS^j_lOrHzW@%&9rxnr-%lq;bxGX(Cx0@+p0W`K3kS&2pzqRnurPTTrDLBQ z7Of0!U6vmimmTzluKS^MpJ;psJlMzVr_Q%9*4QWgZTW)8fNc3MxblUz{JWIzYhFd8 zmF%E=*sD^2u3;F2YK-^u zNJvEV*dXb2u=zX+a&KL0$Mh=&1%t(4^z&9#t|(Xor#>}RD3u$!TBYv=s}>oWUY_cO zf~vltqjE#R`-B_4Z8|-HVLmThG{st%anW~53x5ha&A$U!g6;CVOr6%1E$a12na_9b zGOwZNb>&p+*VIbwt*?c)z8nRCz-K-#%bTxwU(OruS5|#Y@3PG~>$Y9Ge?IhRbGRaB zgR*9oYhmxL_Nffc#MawAZuGu{S2TBi2yMR^vn2X7+48?2Z$>Wpj4Nx1k2B!-Xzxbv z*@k%3cz3o?J-sMyf5$#LY}VNM(@D`f+{SQ34MFjAx4=l*nLliI4)ZB&dLw)AWx$q33^p)wC8;DJ$V0dF< zGbeL@Aygfm8a*5)WV$ZC<5ydc7p0h$&|JXD3?tBG>aOhg6w`u6tFq%8lAH*Ds5)9+ z)x*^Ebl~ASl*MbUPPJ}|8uRKV^Q+uE|1Oax>THckbXWz8Ys7613{_V}j*^^oZV8G; zcz@)i4Ngv479Up|j@OxZhYiR}6>t%t?fk;1--ceg%eJxBwc%yk*gyFj+7Lijvr*dH z!FApeMR7oz^t9x5UEZ(R!uH&+f}e3igXwYQ$tV@X#OQK>eSQ3o!9;x_{EL`{ez4N$ z&1t=H*eQY5RSe8LZ;fozPp&zmWaj6H`a@=T;j2Bv1s50L!)rc}8yHZCE$8=?q{;a} z47TvK=G2`sb6y<|dRrU3>v7?l1TCDjq2~IMndg!YNIk1!zqTy^8{;;3Z7p=!$Yn?n zAXAoN5Jr5}GBzjQe7*?6y*(X0JH4zvwyd0XW;~|$Vf@u-piP>jmYGxYBA2;Im=GK6 z1e#eqtzmxstRtS6GeGV4>y%ISWpl9?Jj`gsIDND~qEhCBe?kFv_uXw}C=p+BhkTJ) z&Hb5VLC>5W)MX{gt)!eQbYSw`oe*emxtn6uM{qThk?|V6{eq8_(5lXJouW}jrz3hd zPUfFUQ;?epRz7J1NN5W`*wVK~khV{jw0+V3A@91P`hw>}-sloL$d;txJBxj<#_^=E$vMNozTBn@t+70i{H(;;h$A@W!oU$v}VO;9|B$#>Yn&dW(aV zi$d}1;5$Z_#K-rGkKTV-ul%hG=R~W61;m|MGHY=5Ao())3tKyn-lNzVm-VtpQ4vCt zzhkvI)PD%EGp#Fc7?!Q;3*Yx<>+UkMWusKAYze}-ja*A3Jd`|r-%!mH(fYcY4FmvX zxCNBix)2&z=e-xIe7~;X12oWrtBU-Vm&@nY)e4ti5ZqcMoO@(PS8HCzWk8@}r?r_qom)4dbtTd7G9Yq-+xj&hX14y& z3_vNTKU=mM+6v&3D899Ny-%5yXFj!xF05JmYaSMw^`U zTXnR36Q^rzpKN1+*5ORRZ?IJEZ*eZla*s%x;846}+4JalQ9q z@*~EZZFr_D-jOZ7Ptu?u3DsO)GP7>C_5L9$xXeA4Cn&NtFFyj~XLs>loYpKlT4Aol zAzb^%jMd1DM}_dPF+dmC@4Cu55YxCk^Vmz)OKofhsP@YRs-6NWYz*;9F7SQwEA*Eg z^0(ZOiy3kj$K{(OXfSnTw$S~qka;h&3~!m`l)ykQwOJq93&Zt*0$-;DF|%=UFMWeg z`;eYEjdF7;a(42=t!bjY<+YvGFK^t)et8>f`{kX*_s{s<_jH%`&*v2Xe-HnE1@>G4 zYX$LNH?bl^(=uOVc-LMeeFaBL**O&IUM#$Q)J)EiVeFarf&@G>=d})gTSI1pH~axq z!1F=mLDUtpbKFgygn)Ma#=Kz>9m1q|?gsl-s(8`CGUsCkM7w?y zvD-Onc4+=71?Dkm#wQJF@@NZ2etk zt`M7$hq&L|hI-Qwru@=i4Qh@SGHc$9&d`wMh#c7ahod^^7ifvyLcsMy6YL{_ZsN6z$%-YtsBPZgoKjTm-AIhxqVP7d9bRE*l{>ftM zw7*7!!FEaQsQ?n?%k6)*33>y|_9G-sUp-x4$@Q?s{;)M%b?E>X0a1Q9S`tgQ|HMa9 z!727UQva(`oND1*U4fB}^tpDOXH&VzA_-pRYxLfYPa506T^Nu=2{7j0^t&i{;Y5s` zD3`z!bE(td9oyi)`x5fD2j{%ep~U#r?*!&=?l-L{9B)Lk_r1@o{e;I;3FK$Kn+ce~e*dA=_uvx4oHx>|N$r^oN#F*9nCQ zPo_0)%sqfHkW+`2dfcms8{5q7ka};@s~+upBzyE$w@E{~Bjl|_+Ni5s9lxk3&UoUZ z`-S3XN!EA(@1JXdQ-b^*q!D3on>8Ri9AD;`k{wQ!IfLOaiqs6OtH}}6#6Y}nux5L7 zSg7Ws=+F#vj~@pJLIIeH&RtJ5A=3>ojKnR42fOrvLmo8a(A(z`0@;NLXC zt}{2Sl@855ZVl|$2h(SlF#*kv`4A?@tD6m)s)hf(-YT{bJg$*SD+svT=?cA>4Jpkt zdacHC1%0Q^{LBqeWgz3U)39qc&$V^5+M1S~hXn}cvEU5>%Gb;fu?6DS)nC$#?+;d@ zO=FsjHoMkGv_|nVAj&y_?Si5 z^<3xGtrV;NJ+q(MT7Gpio8!=?VfOSCy;Rh;6iv7k+d(`y({j zMfO!oX{Ljo-o6WZZ?~?D?A0*rgg~oICDJ_ht@lQjvBdZvo7j$Cfm@y?BLwW<&TlN6 zv7cEb_}r-ze7hzJbx`;+onT#P_SWcrP1{#h+FNcy=q#=3iz2~CbTmorENd#rPvHsr zcElHo(Vn34?3I27j5pEu68_L4-Rad*a#o?_q?24FX8;X;&|Bl0m;RMl_Gmm6=H*W5 z+z(Lm0Vi|2=6k1~5u2IoDXJsQKIV9eGGeFs(QxUKN2dm+o(Kl!zuvbUjFJ}^xlT(d zWj&s^Eu;Wu>hyE@a-g67y;*y~mMJ}TM{H-hy@ani?{zK`1?m>Z@31++Ie$8xHKB)U ziV)A{b*Tp-k8ei~%N?*?k3j1gl!c3Lp(W{Tp`61mE(zSKYwb>bVf7Mg%qwW^GPiy9 zqmHN;=Em1?8N*IBv~#5zy(O$v)p~PPAFI6$w2FQ3yU=#cu6zRDn%mx`hk|v|>!ve9 zuerEn#@9l)=M4>Ukch0mjP)Fhj&==B=S*lQuDXHNyI?)Ru7TED`H}M}ZYyc_)ts&a+r*k0FaZ_(tj`l$FTx@UobHdk14T6y|f?+uKX_ z!3#(9j23yAN#aGRGG%=n-MilF;T1I`nu?T&d8a77GPVQ%jNb!(_S!({{QEcz^S*lC z#D2Ve{Y3g@YH**r%$u;k+AKo&ZIk&(SfboQf*nTHO7A)Q98%Eh0bEBm+y3yyitr#^ zq%VJ!=gnRPh1zS#O8ZA-!Ca!N`FGLyN<~>bv;ftOZ5zXZ&{OqZA7j98wjaNXuK6Pu zukG)!G^=HH=bT5nT_ZxrJ-mO-#C&>ew0bev}B2!qaM@y#!+iP`dh@bgF8Mo1ZT&G8m4wd{snmFM`L+CDB6r!SSS$CitYUv0!Lw3n$W@W(N@p zFlHgV{@iNb>J!(M;5;LB9b*t>U29sk|5QL=h4rzXFxrc1w@LER3VY)@_VU&F=3B7(B&h3rW=G}pQgaaXYRrsh z{A~lEIl%^!Ns zt@%LlBJFZMU~7M16IYx4NOU^BYb@8YmUy$@blr^h)tj01b)5|WY%@#AlKCJ{bQQbE zW`}g%%w@4@bmLg*q?yj0{fEH%zoHRK%5@XA41Z4-*LX7fX>RyWrdYW-=vCwAiPVD6 zS781d{igL10^{-!3UXaK`XyyCZ~6LV9hAFCsolD~ECL6xBOPc{;K-mirO?|p=k*Tq z#2M^JL!rG7{Dj_*Ln70J5p_Ix$V++4gGVhv&(}BbjgHn1tDp$nxfCvvhEeT(gC^^} zUV{IytaaT!9k5r?hV zJ5;EKN-7BJP?tM7LNmc2H zW#KR38}ZCCV)b5anHD+FdW%+PUFYI}K0-N)(;7=?wF1*=jg z34_Vz^niZHmP~dx)$vXKSDe6qZ6B6@^W%@)i3));?p;viE%t6Ks@W9w_Jx%XG%hFp zuzBV613~N2!pcpKFB;6s{PiFm=;JLl-~56Q`Ykn4tB^LPH#j?C7- zht2AmztQE($wOUq%+D6F{FUqE;m$&w&cWu}lv45Cb`=>T(0T;Sf!J9W%V?J0x%VF4 zk74f|$mWi4U_mb~Q}hYPhN0k1ayk_r1njdug1^NTC~Dr0ig(?PqDBEnP|&<`w-5&U zEqR~6HO1G`KpYVaxc9U^(#JeRgo~uG8BSb?zZ_Tome+;*TV(HD4oC7D^#{Z1ywUJ* zmN#0D^fTgYTF+6Yyt3AsakPiRp(|8?B`SKRaNNcN7 z!9N?NqMj~7%9A^fs^7Injv!X!a0%!&y}0J0k{NglQCFNSmfUV98a+gloJN>xScN-? zL}XHUzJ-_fW^@eCY9_VEfW7#&#kUW>ykA~VHu6jS-oWoy_&wfF>x9sMudmE|k3YSi zsa0;S!(K)#TcD4HxBA7%G2AAO>_a$;a=k=zkfuA4muN^pSnc3>TR)*?c+V(4-Zgnv zdl+cFpH)g7=bc%|s~eG0xgF(u`07ufLJem`L?E`~!!DZ;L{`dDmM>Uw`}iU!)(JF}d(S8WN~b>xm-b3;65x=>{{8owuh;5UMIS@uGSIMCB`foM0HE@~=eE`S1R>i+| zU;#e2y{>yoLbD~562#s4s?40EBZ1Y z{Bt02>8~RuLEW0BW~dughGmuraZi$4dR^);0=j0XUT-r@riQ-6UlvUo!K|h5)!k3u zBt!?oz8n_CdPJ=ucI@KCt^y6Fo=`A=L#$e!RsLhu%3%6gwb4o&ki^LE!p&pKiCho~ zG!Asky;o?ccntz^bVyAl`C zLUx{=E73Mu>2@~MbQhBF~MoRs+;+#nX}19q#FN2TN`EYxMUAN}QidHL7$ z!#9-^$IssUT;ZpECIJ68E9CHFR+%T+udYzCZK1t0P|6nW32s{#_KKWho)^0S z$h-u|#Fsjdd4n~d-^M=~pxFppw>!{CK3QevZueoto~OSLLL5HX_-!|H@nfemw9eIZ z`H#AMEG&Bg{N)T-2qf7Tt#M~j`$e47-3HV(iYZ>XyXOb{b@{twx4+w~1&KA;{@qwk_hPKz9qMjP}sVbiAL^^$peIlX}z7+>%7(KV6c<|S~)AcyN=-@lvHkh z;~2xXvuQi2>;ISwI;cw{&N-Zb8JWRy%Z8zA$YMd_AZm^Jc#X;%4f=vzCp^Jmxz!8@ z_MV^4?F*Hzvu_Yl$G_OOlQD{EYkNV1O_Y9hUO@~Z&MVlIQDnz{HGVRTy=|fzyNZ8Y z2#_YsM?#k{arupk=wOq3Z~3wax6tD&pvsYMwAEFk`@ z1)vh8m(zv$8aHdPDIS!Rp9zWIxSqtQO$^-nB5Aym62a#S@N+&kkBBG+(|=GOy4Yk{ z-tVij_vNBt7nRH!?;J?NP9eRXON<$Rm|JZrdz0r`OlftAx*m4$joy=%#H=-w@r7ZS zQwT%}FDj+ZuN56Y(5;b24rMR*W`rytWBpl&+mCxda;*Ch4HZ*x=THG=H`K@hmI9>o zlFAIfRx`DPyWOE_fNV^z_Y`DyT5UGStismypWZJoaznqo{rGttK)-^&kN49$Px=45 zU){W~biy4zUhR4LaiG+yC1HXrwy+s89+m)@*uHg?t=iGU*fh_+v4mE|p6VGo)sv8Y z-VHuR;XLzGE1>w7>~N((x*Sz4crLYgh4R3^bHGCOytZm3k(GN!`(ZKirWXe0Z!1b2 z>dh<+)_fe@7rr65wRchEIF8B*Z*aDbmR*Ur-_svQUni%eAu$!~b(mKj-HRf9Riq&7 zEsUjmMo$HLwk{CNtug1Izu^enHW`#7D4J*Ir^ugQevrv`^O}vui$VbYV2#<|)}E!{ z)uj>FV7^QGO>B8VEPY7emi0QniPBrYN^8x_xmnQTyR-Y{;WjgaU)F6V==GBQXdkbx zT)##4OBh{he}&O5JyZkbwCESkF6#CBARxwkYXfpu@^*_Ko`&mwOar$1)j;d#B)hsX zPPL_J>FLLiKD`%o*pWh|yas~=&U(@OCukyBIofcWd;^Wo`ly_6p;YA8doPB(*OIzJOa4RGg5B-{_ki;D^7B$N zLS6x4(yT+Jv#3inf*{wJ)K7LVbbi&_tN~4>8foYMu=g(TQ5IL@|0bJ2APF0lNbp9~ zh|zdKgBluacVPp&5G0BgEFx$Wu~4P5t56Vvo78L{S7~dj)?RGiUaZ=St;G;8O$ZVK z%FP=WFJP-aaYYf81W@<)Ju}bqWOE^+rElN=`+xly_IaL}IdkTmGiT16IWsc{IFhSe zf%4qBAL|z=67UHKgMFOq@tSZ_0K*8yIQZIl6N<%;C}SU}a+0h!Kf>X_iLBc_)laI3 z6?7MnkF+4~A&yjd^#Ex~ceUQjQwqF~-kRlF$KM@`vs}ge9mU_t{9QnoAJ$6`$^D3*pvS+_T*7Km zaSKHMg}x1gsdy~+X;RH=yQTC(Of_E-rGQs=OEYpc7D1y`POp_P>&X6ri_es6 z6*}TQ>IQ0IEnXo)w!!=pIw;aUFubT}OX-#+J5+do?$K7_4@fL+3eS*`iV9hQky}kO zJoRji@r?d>Z=;u{y~eJMaDR-MaR1ntlsMOaaTS1e)tTR0A#%1jUp995sZG+9l@`l= zoGQ!?DWqPSxak_gk=L!{dr|uUYDgn9NBT{@6ug-E@jY}@pOIF;xhVr=V@$a*t5DwKZi(@S zK64-RhSG|+$C)W!nd7N{Gix55--0j^OX9#4uPDV$%*fkikWpiZoYkn{-^i^(w zi2W>&D4J(d8MA-DBV)FNs=fa=333_(G$u_l3CtJnKKOUlDTm-zm7PcAd!2lOh)RyC z(DV>n!|ldom?H`4?-`0a;oI_;Ztx8o z{u`>u;?ILWH3@#OEBw~(^eD37?^f_n=zy>3k(mT~fsHoW(A`@6!ModoaluJD47cB5yh4R4!*cUD(;$6E04KhQXpr{PCV z8#-FgcpFQZSw@ILM>Woq#^(t;fA%6f|LWiLlHaiMEB<2wIN(J77Iepd zs?Hw^e57!>sDl@}{?a6Pr*(yQ-=Dh4Q>G2?eFg8cj<#9+U#i0d&{5cn(qLvEANB9ICij;A zekcD=ZGN5FTmIQjeg~hnS9@u1sgwW7-`n!vTmDQZzXSiC-tuo>WYgygTRyMrE&sz# z{!i@oUeH_qg?9e*XCJiiPwk|}9f8pDFy6_($mZAPPHMGF{$ri|BW?J<>MehKq1E2> zJ8XL1)LZ^1ocy2I@JIEQ-*EE()XsloZ~3P>`OAN2(dUi7_972OPJRdfzx0;>z<2ES zKKWY<{%yVGU+3h1*QU=Uz2(2($?xEQQg8WZJNZAc`MU?dy~EXx2Q3e!PX6O;d0W|A z{!AzTyLNkj&|CiP3vBv0{JOfg{0}?%AF=s$Zg2S)I{6)ZQhUok-pT*$Z!CUoCZ?@} zEY6dr&#_K^2mY^m%O9U_w|9;$pEvcE{|P65mYsjpLGt5DN$mDCrX^v*LJc$_6&vyGC+6)5t!an#aM5>mnr(%TxW3pt{$aK{$urP{D;`fh=|KdceIV zSUEo@;BGH{J~V@{hhrl9Q^GeI+u-zI@zr}1O)69xf5{W#z>5wxm5rprVZ&o+`-gON zA;?5h>s$FJ#u?VJ(O&c8Mlcf)vC*FiAE!16&`3Omi=TNQ~ zm=6>$SrAxUcJ0`32-oRS}KZ{>U$wLM@6ZDJEIjWJzJhl(4T zN6Y;;_)6VeJ0xzue2^^cknZltO8sYBRi0{Bd4j4kFHq^Fw#JaBxTV=^0nUg9GJ1ta zAJFITSevq3cB`b@NWk5Upux7A&7mn08YG=!qN*(zP;J{5@u3Sn=ydlozWnibE_ind6zG`0CW* zy0P3#M6=F|~^X%BXul-$jU$s^xfq_aFDK*4eb3lha z|Km@CbX+ONJt7)FaK#BmA-lC`BKk{}=`I*w@=Kud64YLbk!I;sk*^_^d=0YYYk=Z- zh9zH}?#z_!2<{{o>x9ZcTDiL)2kH5`hQ=ujWj@ak=!himD>}W4JX)d{zRBufvNWmp z2AcoCa)Z(*2RL#cT-gycVtht8Q^^pdQHyAkGRzyI6jgGjz8#!6x+`beHE~x__Ey^j zSk{9{qVsS6w#E#9h@H6#7eWu!2tE4*<@nJQ=|lwxpNtsczRjsJ@-=a;BA{WfT0kNmXw@8n-(=YM-cFZqk?{OOw_@p0a!@sTP{PupP7F`1Jn;UVaV)7`J$WC|zYe#n1^~_Sce6*vyOuoNx`JhwY zQeFjnN9Cu4jx^t-6Wd|s3J){($1Y!b#;il$a;#qS=C>48Rqbw2laVEF1zLH^C7edYCW|muD0Z0P~#{wDVe$nRePvZF>;hUIUM^jI|%uMX1+12=s6d`(! z=eOrO{hYQ@@cmd|3-#RlD_Q@QO0*cs5^y!&q@H}KCze#tm3BQ$NNgVP_<1sI^EY}u z)d+uO{TV|*;wST@{+ET!$$OGJLqzTa&7k43Oe(FvOKoV}m9<%RwHi5`Z=TO7yB@3% z>yQuVU)c0uDo26UY+leW(6&v_wK?ZnQd+;;(Bpv8x%#Ad9JP~SvJPH(VwTmKMFsPV zISqukqOB51F^s>8z=#ygHVlL+!XuF4De3^8fN`wscYIVxYOI$dw5G_}LKP|;(`=)w zlJ(5JWAjtP-Uw%PxTtYTWfK}fqf9&Ri>L2f+Tf4wuJY4#dae%llQy?}qHWRpW;qI@Ej8C{ia=EOTn5YM)$%xY<|g%kPl! zj3#2)k=Y!=<{j5)+h$LY*O3K{?Q9iq@GLitmF&_jGq!LdK4zhhT?W>9tAv{xYp~k= z?iL<4nLN6QHng6$+E1HIzk6#S@?J`Kz3?=jbD5bRjx2DaVyWEkUgKkz!Vaz$y1T&_ zY3%E3+~=0*B!B=|Hw{*YS!dE8ypiBKF z{ck;A!<QBxq&-BOOeVzN$^_||uAAn55ICe>v z6>s-(0!)%T6*ox3xQW5@qXxgbL3aPSHnpmkE%Lk4@7}C(sFzKxemCO*zGPf-07mML z@VT3~{ynJ+c15w%yG)v{w(RAslwC%)#MBze{KKRnHA~eeq!RfUnL!-p-$XhMj+N51onf^rs&1%e3*!b@2N$euIP7xBUvcJq~_v zE$Joy!*>4MCvE&HdWH{v_mRcvZn}N5?ef`+?D9AFR$r;k5B;9}u|@CZCwsv!)5(9F z4PW+U9lZThRr}etbA=86>Yn|t7XO|64nF7hmVcqnPfz`+jZglZ9`S)*3%b**)Gps4 zKVug6SYG3oY2%me$j?1b^pb!3H|+L*;$kAgZuFz4D z`5CTIb|Uvk!QtQ&5lc2S{nlNs`-3Q!w{A@)gvEcIUc7mQjQ?hsbtF{ku`D(`_+f#| zROIAC|3GAZjw|F&c=JR@IGrF#4mG@~o#CCL;OVx$)UowL~9 zEza$&1ozoz4(C4lBR#l$ix7AY{fTAg2~IcBzs#RpMfq5^TRxl*ES3E69>5!0%nEXV zG^ZK`;R3QcO9PUTGhgYrVE=_=D7W3MXT-AC zC021>EZ}BPy}-N@9ur-EdcfC z_dtct+OOU2tf7(R(Rq2wzdgXr5}lfOM*QRt#f$32pCW_(rTl{ENWjO&J%@?B-8>oW zxYSbof z5zJxk=iOHqCLd^SCX*!INz6OPx~FS(!KB42qG>I}5>KFlLcZYbF9#9Y!}!_OSJz)g z6UBnbzWIHK^82sziZm4lj47~kduT{xex55lxOsE|t){&hiQKF9!WZmH^BeQ5g*dG? zd&N#2#XRZj)(wj)qUOCzRn_IjI~>An$@T^36}D3)^;!?B>JG)A%eBa=Mh7bWLnL5>H>rdS#y3@*O1~1-`oTrRA~Qoy#=Ce-;sj(`N4U@lEqxaphF5 zJK)sjIR!K1T+jJJKQ37t!$DY2s@-IA`a+e!Aa#~C9}XB7DI0j0;VX!=yFJx+Kwlaabd*R3{NCP2{X%6DYxr>xRiSJ0O_gr z3Gl>NOnB+(?CSQ)2ZD@BeRcoPWU9YQHuG&TZxP#AS{Wt0LpJy66PG;IgD5gV#U_~J z?&7$(43iPe>h0$tHd4a; zJ}sdEFd&EmqX*8Cnf?qpH+6nzu0;pvZM>9f_7!l(0R!`x96=@CjGs?7x?jqUxmfm< z?;;basuc>a#X07ZNmAX!JP5VsPMMU56H|t-#Y;(u-bkBS`N}k>iN=rKkP|C&2cu`T z&QWW}>r8dmx>cFd9Icm-;`9-6^VMh zexd>z6z#AF!kV9`p^rf$yY3ytJ$^CC$n{a$-pNAZm}aIYTB02Gbr8eHqK>T(FV{5y zai&1L4~WV*nZlcJ_pi9I(JOtl%5zMVXYSCp&QffbL$Vv|)cR9=qH0ep&|;cDRe<{{ z%~I0jQwt=EwO&n)w8KSR$PXSWAZ~ZaQv=kf!Q;>kS_yoaKKqa9Cmic{)*N}1ORi?oa$r$(j| zwS_tjLgz{bzm);)_*GdI(xVum0pb>Fz)dn@bjG~1&C6wIQE8!KJtEzRZ$m7@_jk#N zMEqYvI9Ea){!Y`8S4&PMRx2U#f+{koS$On$t?%AIEwAItK~MAzsDJ$}P(St(B@!p7 zF9G?|PkkW{IF>zJ-Y>aJ^*GTswj$^>G3Z{E`XcfWu>5;3j#stnHCrz}J{e>xYgxoP zX`zq{mcRLK63YW5NoP-!R9gG_tHaLvx2}m90CS#P7jQ($>}N(Mw3S)OySH10KO~M- z%#^u;SjKC9Y_+naso7gVnsnga6hyMTd_2x--#&=ueiNddD|FU_=|U>(7);N>2n#fi zwafzHXnu@MI2szdIu`Qo9R(+f~@J}jxMj9>QY16SZ#^)s_p%L>jaEa@NjGjbp zRUAWW$H5@$?^-^JEXbm~UhU)<)9IN>=}$tk4wyPU9@LbO^2J!wUDMYkr4K|sbxnU* zr>}5%`xKi?X9EnjAuZX{zKgVcCboSndy_ox;kh??wDs%d8e+N8Pr97k;@Bcb-5nEI zP>>Qz4~#fMh4B?~0I_x0=v!)_K;|L~@>9Zi1AK+$r7ax97Ikw_MATd8t5ZrjmfHen z%^CB-DKS37#)X2IUi5U!-I72D9HIz!5Q8WBkB+dl&Y<~zKvPz$QsUtU%WUQ%F~Lh@ zW*S+@^}2&mIV$uRQ++(bu)bX4_Kc&r63$pCGm4{?FrK0WW1abHrH)^hnJ1(Hkgx7E zNs8sZ2Z?1SahuAahs*pgbm1oi59$SIA@`}vxZ6$c{}s2f4&lW7jI7KECM3)l@~;t2 zZ&Ot4QL|hAWm%UYw-0+7GktS7U7!cW4~?SrMdlZSH2BmB`-7Ab(pQBH`E%0MA!j={P3+qz-^2XYil zkmwW++!dTPUOhZUaT%@D3KwS8i2mA*{1mpH|MleFLj5vcCC7KFDd=onJ}7}C?`VB1 zD@Q{BB=>vnGvBA%l(Xasu_YR)4+&y#6~p#e#W!txtDc1oTmWYWqJ@FzT&iJ<0)(Ft zjE>4v&G9u8G^nH~GZ-CLD8o~ElyG)y(?vkmIxE2Y%b&ArwXY*SZ&{h{07eSFrs%gJ z?LK~TaEHM8iAvG5W?CY)2Qy zJtOAkhuqP*`7+2`lJIA1wEfaCc!Gc4Www7HA0*86e$|3RJt%=gsw0Z|7rq(H^;GQS=YHO+5UKP2r!(X{>-|u4#ND zPrjr!?#yZ2!MnV;^-nry(9Gq1Vjprg?#Rj9D2aVD*JQ2@8ZVLR8XCdLF{sryZQ(c_rE5ZfVxf#qiTA9Zb!JzkzRN^2m`a;_TL>RIkf zA%@qp{NgmE#)pLYGdJ=t-&2t>UU+irAGjZM#7mvF3;Rb~GPV&J8?HjsvQ=(MsR)SPJT1nfm+R-9xwc|zBhU*NYwW!sOR+2Vcl1KNg9dAuuyXn*~ z@`3fC!Bt7?Y0`gj^$<}}F6TFK>(FIB=Mv2{pQVec3#t&`fYIvrET5lJ9-Ea?=2;$g zPcSz58sEZW0_8HGLRkvLQc()~e2s7C_%qw(W2*Hlqzy)%N%v>&R);f5noCi4w<^nb zv_dTvxjfYy&=J1a{C2$3wJL@PSJ3>}Eg6Bv_uR(nwk@%d&7L8CaxTA&zX@r&@*H>u z<95QhGGUzXt<0_dO&7)%znr;7PDSP`n=oFK9BX)nd7S8);VWJb4e&}p2=jSDy8Ni5 ze4o)Q7a13zDQY(m2mN#(jRNg2&Gqdbt5t{zD0f~?Q9Z7GWttnx>Htt3DV9%AnyW(;dE z1k6*o@X)jTR*@s5E^~L|+YX<1lkCove}&JP?Q$R-FMWhQ?L73&Y!?n=MHZM>VPeqN z@?k-NuXuy6_$Afr{KjXsTx8c~G}exHYbMKg;jyt&vsiwG!}2Q(uAQWK8&q0e{D6wL zau1%;-peXK%|wwAVV(iqb)BZLS@CP*tWH%Mcg#~l4ggLQiCQDo8$mOPpS?#6fWYA4Y@_*3M7$ z*LvNxN7at!WgcGJE3^lAt{7PA3cP9Ddc_!8v?Tqb`z`MZw3 zGL64U{9VMKpT80OoyA`ve_upj{$JkSDd0E8`3XAo7jrWY(SVIyq(vWLm>_OYdXH~u zgDi1#5q1EXS=#7A-J$Ip#~S|I0_n0RyUgwXL7=SI|GfZDxA8Q}TUIRUR3Z<71*miZ z5om19^BI@k7LXM8rQZtx9k32aaska(ObW?lE52l;zj&>}sikK)jXl9>><*{IPxRek zz-M?*_*iWcVyPxd(~{fO13tqJ6Fz73gijKk50yTJhY6p8FNKd{xn`Qh_x!_z&v9Q0 zpYHOLcbM=Qc$o0X=?NdZZyihyd+1+ThY6pIp72SM!{q*s@@UX~zN|R4JE_w^sVwR) zi;nMn??&%h?Eg}23iu5aIqJOs;Zx^nhfAF!CWl3xBesV{ozsoFFKrrY zu;7E))g7NCIXsv;ALZ{(YcDwcpFX#>^B4bB{^iT$GHLDD2^jCzRWKVh3&pn}8%)!$ zYG%n;Zb2zyZLMrZnZfBw%e`rV7@i*ETeCR!1&3;SS#g5TEV#TuM$@yrug{o<_r`XX z`S2v*Y83~NxXxrkI~K>!#`5TdEj-Fp*Pq!?9{tu1-uh&&@kcM)=+A8Q*IxD^S6TU^ zWiQASyKFZvGRH2%TdXG8Wn8+#1eK(Z)B{N$tA~u*vc3M=vbHkWCAv*bx4*Teyyz|L zgN-<+)>`pezQlSQzm$l&@ z^(-$>-+k@?Uv1WK+c^qe;oG3!4!QXA!hkWsR4rmwJu$U*a-Ui}NUEjKQMGR@P7ofT z=Q|lpnKS&gW7GY$lT&KPrum}N@V!lFm-}1(D9Z!fIJJxxGcT^t^J2LV`hSL-)$%9G zePVU_B4-UaACNPaY790M2Z+*QmEyOvWtC!|nR-13+ysPqv56U_tV+D(tCw&t&qYmK z`Cjf`z09+`KYOc!r5m|0;w2?ksl3ZBs6+nZJ12+>=x~7fv0%X$;Ba@jfIc__4@bj33P#?+a~tA*8@V)_HDcq#r2S+P zEr@$^nE78U^W%jZ7EODcpW#ve<7t7&Y8HP#J95#sj{S^I{&{wO|1V_CkpjOc$5EUb zSbD&f=5j51N&H_WvcTw-wUjR&3G>CrivL6jiJwVl(L3K-HkS7^x#Y39L9LI=`yd^3 zadV;4B#GQv&k{z&VsTFk=r+8W)CqG}RH4>9XtU zsr@#*F8JlxWh{I%?00t4PHs^5%=)-zHm@6sp2w38$D%^rwEIt0O=CSg8|~=*4%(6S zwZ^+FhLhi3zq($R;A%$vzc|b|Ux!6$b3#A4qK*qp)C%?IT|N#x&*P~x<^G#2PmVrr z5!{>jGAMi;UwGN$$r+ck$8$t5hx@~6kq#ZOxYM0tIzDpUP`f?5Y0umRvcCUM^Bd>$ z6-JzjPx=bJ$%=dt^skq}Oh+i?n?rAMB9w|-W=3U`Z*TEQT@g|Cjy3d@X)GXl&}c6= z)~bf~dxGBOGMeH!s+FZrFe16sF6uyRpgNPQP=!=*5b?ityi8ZHP%_N9d<87sA@%#5aZ9`_d%&W!p}eVqU0ON)(4G)49g3J)V= zPWW`H;3Iz=rsx}JtV}MT!xTI@F3%BMWYLux-T4DCgbbm6()y5_0-ncxN#|$U^40GY z#X*rTZY-3TA|+qD=r3PLz6ccQ)Cao0&y0*ntvgPHK=|PCuk4h&Tl|N_0m-JE8DuuE zEwFI(8T+tW)wyM@zR12op4;Wv5YGLoa``>~wZ;?qm1OsSl})Kc>Z==`x!+Hu4i1P) z_e^d9@Ni=t=aH1~VdRINC3GwPEib6>MLtUj_w{AU;@~j34e(wHO1B%PE^2u$k^6e{ zP?yn8e8&0pcabJ?-GCW`n1^qcK=AMTW4<@c@*5c;HkgYRfVHtv-92Q!b}s9~r>Y|_ z8LIYf(9oM}EV4ZWtfWX2O$Cjip z@kMWnI=3p3&9`EJ65RA9 z^TfP=QFfEOKNe)v&P`8EfIO{>`Df%;JT-FBrWi)GcLFxq;|;x`C!sNXw;iapi8kqt4@7>PX06Z|D8 z>Mr?eH{fL5fD4=k>^BFf2E^ytTTp4j)f3pjGi?G-6+zKa=~Z7nJyhmfk*B$^@7uDW z2Rm}Tc|GxR!DyM(6h6bLWvH%Y6ZtfmR+tZdQ^*w04mOv8pdH+)0(uBrm@DR}-itF` z2lpKExYvCo`p^)l@kdD?lDESEC}&h52NGjdEgEzUK(}&Oa52+=-1SJd<&!wEnwf z5z)6cSbch<@bo8Y+<9RZ%I!HbjW}gz+?mO&obK=$rHXfXOXd`K!(PwheZ({f_whVF zCN(}l3r)p*A9_?krS+YLh*P#<5zKKI9{pkCo6_JKJ^OfAeL_FS|Iva#HSWKc~v zkCAN#67|1blH)r03M<)b9w?V&_I4*EPWfy$w~?_jFrp}YrF~vpD*npe>v%_INx@+J4!w;9s;uF7MpWeTT8!Htsd_)s@iAB`UVn z+MH()piL~L>MXET+E?B-K+E@uL}qw_i6m6;Qd949Rk zr?@AQNagNM^+D!S*6ycR?yXWY*YTJ$sj`?#Rot_L;#OMqIflPo- zqFmx8Kl2MkYDO%`4<9MBw?2v7K~KxRl+q-j=p5in7}yl%A?zFNQAaq;ISdbaK4=V9 zoK(ATrc|meU+HSbYyJqEOD22i_p|L+1`g{$)gC9q;G^M{Cc(?l@Un|rqS@pAs&@-D znHSn{M>}w%*+q8p!5hYW!3*Qft$7OL0HP$aNY_99Vs?o5jYd&m;?Y=hh(!yj|FQ z@N@EK9=yJXrM_98Q-0kbMGe1%RD!gF>{q35(LoDCX76RzR%H`(j5kmI$r*2| zNRfjG2-x$ASw|&YD^OjqTfQ4R{pf{Z#91%EW#16bs;h!p{?Z1&XJUi$b_Ak7EilQ- zH@OA$0Uimo2}GYPxR*!IL|pS47XIjRzRFeHMU4V=c%;GYQ-juhKI7TQ8*Vw8Hq~Fm z*}~_2_P7x@M~AwAeSH8zEIs43UOqR zsHi`Y3=AvRUe8{WA^A#YRBoY>*qV1P5Prvqv@dsmNR)M*}$T# zRg3!hn)~GE=X1>h(UU+b0+Je_)bUB&pd74h4!BoKlw&>8=NdyqGh0O%N4cE9d}Ih? zc9T08S?x|Vi%nSi@uIsW>~mP>3R7=B#`Adk%d_%axawv`{aF>B<>_mAt@USJB3_u9 zU5l?RuY8_JH;z#7)9BJyZoQNhf)yHR;!O9Y+*|@K`Yb0xIwiEaFm~~ds{L2`J!2XZ zO=D{P11mTXEx2^Q6!JvmjIU9v`RMW`nu3YO^3vxP{Vq`XyhVtW5F+K~yhM{^>Cg9o zE;w(ADpQVk(W{7@kozax?0@mQcZsn+5t73SmzhLC8*|rq5UWzd)yWoZQgtG>C z6yXRMT3%$36cGwA85U`j!87+vM1JWi5nF8vmgFj>(K#fFjn4ZKWsk2yFMRw;fs!VaobL@_S z2jrIzUibwIaTtk;nJZ8^Ef_^DHv9}Z$d)dN?va_m6lK?ITlq{v7@4D;5FkLA9JznE zMl85*^VNLK%T7RIj1%$Eav-Qz*e7wnfe=UU1J68#xtm=stjgU?IiubDH6`O&Qt4dN zMJ6UH9A}5!E6CDyzi}D`v3Ger?DM$}Qr69aB<7o7{v7a?1-P155Wonk6VuxI636CC+Pcc(8?+k$%a8 zoj5Eg^~glcIyHbe5+HlE6t2QwM+|7$4095yBAK(#U+r)`VE)sfU}hs(%P`GU}!XKX(pL{FKh3r>u&FO4NlwvlKn7Nw6t*_b^+{ z*NkHqCmllZvJ>~FH0f`VRZ~8nk77{jtt%avC51a^NAXCFqzq$gKtaw2d8}X~Ps)6n zgWO46K1ZRV3Y#&ld{0O}D=Yq|ALtcgtz3%b^2<;6B$kyMs|kbPtj*`Ew})=!2-7gz zJaMdypdZisR^-z@w@eRIK4aXmG?0<)VzbR03SnR*c2*rYCEWKZguoTYZ;YnkNr{l> z>81(BbF%XS$uZ~92_eyfa^voT2g%|`Cu3s_0WnZG0cep=`+91^;3t)zHi619JB;dr z266`zYsv)^&pnONjGMC*W{EYzgDIWrq(Y=KpAvX3Hk({%MWvpam&Gh^Xf^q%I*=dw zM%96nLu0BA6ot;MI&fy_gsKC>Lj9kj2fA8!VIY8&FY;^-n6p)`aMq{P*$0bA!&E1+N*ymy6sZx=tPNFT2)zWHUgb>OV9o3e;SY-~!^{9$L1Y=LlH({gSI6<%$G&~k*Kg)BwIF%R}1|C4J z?)5CMPF;kc8F$=vjmVpOZDJ>Yd{2v<0nQh@A-&Zn0G1D_Ixr_Z*t2}PaLD(xn7}$i zYHL0jd`}Bs^_R5P0lG-%Z2LaZJbzjUu5-Fu|7m}eHu3Q(@e8!Bm#L@kjHIt&HGygsR4A>&HA#08K22md}HlZ2W|}aUqMH8wcY{)s}6*VTO1f)O_2KNT#Zh_M8 z^PmXNdxLV3KxsnuIVX#X)!5dB#zNl5k%-oxk}aU~mDOxt;899+i72VGGD5{V(Lu3I zRSf485w4L5p)~(k{79`89k8H6Mi*Ao{=yugewNBMKu|+jOex+KO=%szpCgNzUn+m&_#4Duh`%cSTb->xc zb1i?lynn#oc|5P>cQJnhD02jVt^vLOmogx=YX*16hqUxQeKQ86%ZGmbGY1UF8qj}0 zcK^NDS8|?x-ev!EW!A~jMiYz= zf^uMhx;8Uij+B@eG&1B0v(h!UuqwzJ;oqbKm9FvBJR%PlCJrQ)+;S1SyBK7G+;e8A zOEhoh%qsK*W7n3+GILyGW{b_Oj{_=UcxdD^T|55*a@GGBNVaGAD9($KWkP1BGRI`1 z$#!!(toUQg4bG8o2;~HfqXjka#F^t|Zrdyakt=?@Z^bC!;Sjo7)w0z2BL5 z+)E0o|Ly`=C6&X>ccB|gF!J(w=TLKwHWw8-l28oegB8+J&*bf%;2KWDfZN+}DEMNR z<+BiQbAKp0{7j+5i4-rSICoyCkK%xjXFbS9yOo}@mB1e^8&`*sqn-ZJ7p;DB%1jR6AcWG*P;!Mw3&wrE^jPa8E)tE#+w?gj7Q=~H1-K3*&u{)JcTR2!ws5b zedFdMrJygdj+Y&`e5i=PW8=+(t$5!(o-0Xil5bWrv=CIsbI^=`Km7Ho*@dsD$=$x- zLJ=$79%mq6d=v7^#MpNXyji+R_+*a_gxMxGl&-{JEZj&X+oWUV-g230aqe_}Z+WDS z7%Z2k@Ric`Jps=d+dLDQl(CSNt^9$Yzg5}|q0V%XeUIl1kiDS+cjvUIKAGpdolb;T`p&mqu0<5geV&c=7#v9uq!8^nd1c!q`Uw84I3Ew0Q8*1zdZ z4Ep;9E?O^k^T^17qkNvSS8-*^I32*}IM5S$kFMpfy)><&Cb4*yxIgj7Phwsx1V587 zs&JvMt!;-?mGuL6i@&rnoRz?FvC?1q@~y+;&rzg~B03+pDCn#js|IYvG&VP{90g9Z z@O)Bc(XV`w{R4d~j)KT#p0c$QDt87WpMcBNf(wq2VYo^QW`P4Y%--xXn(i~!6t68> zi!+@2Etb_?NAM;B($=bMkjDLLbN3MUMn#CCHG#@TFd7O*Qnj&mRBd%y6;%Zj4KY&4GwlDrVnMBJD zqn?h;ALBid%5tMFw>g_k^Alc3KEahCM)?rqKp*-etm z5gZ`qNmkjxl$}uX&&9)3{~HDHg6o2bXS*S~r(Khvm0NnqzvRU-eEH0;d!SacmGx<| z`V!BPamma0G=>pd_FHghdzn&^w_(TX$c8e_4hGkJ^De90cPU5ykzm`wuv?E7wuq`W zuT%NL$Fxo(qkI)zZC-4Z4)9St$0bZmq2&eYz-G^oYVx3Wp7xJi!a2>|ns<~{y1$gJ zNi2B@8@+JmGOK<06zw40+ijW@uOUK_rj+^mysFksyqfnCIdE7<#Qy{K-$?=Qq-5{d zeAsTDAg%rgwv_uXLrK;3M5%2U1vTZ`B^~2%SIyAuGzr&fU+- z{T47h%lmMf(Rm&8@;u!@2{McRwZL!rP=gmU-?MxSrtxY51a1AO@X@1*UBmCJ0-};^ z{fN$4jJ^^5^fjfQp4H=oU54l+Ikfds(M>cN-K6w&kL_i}N%9toZhFm`(a#W*?M|6f zL{`nW?KJ{;rV zsc2(O%ltI1giJM_C)>a}dcqP+%Jy5+-23C{f0tJjKrQ)=J#DXvd9tTw@8S`ff0Rfo zef`!V@%7J8!`&)de%X^}-ui(StshZ3u|CmMvv^_waT6D^E};$Vpj20a*7** zX_irrlgSW*D_Okc{GIUnn!O8#&0J=c8+wrXvQ>Sd(}=HMoicy3B|FYRGu^`*5uMln zJ)G`!=>DGwW0!fib)hhX`y!R&8|`DFgk!i1m^%Rl`$6vN{0 zUcgHz<1C167Jh7N{i|Y*)~{fiFR}hH%xJS>FM_a}8^FWUj5THZ;EGmN0ZLzvxG%@wZWRY}|fx8mo@Z9Sv6GVk}_B5h^70; zS~ag9Iy%uB`_paH)jn>05KbYd0U!l7g3zRe0AUFT(*^fdh;5x*|LU}S7W-z`rG#DXcc z98Y;k3R)9nJa`@*eO|R7g=Pg)!h4A;`P~bHT!gwgS^TuaNiW{4&%;VPN^Zi_&_3|h zWeMS8xmh4aon{?zT)w(&{jqI~d|YS#mjvpwL_b3&xk6!?B(QKH3UgYfQkb(iTB&D= z{Lg+Bdqqc>-LgjsZ1G-yEYL`#E$VN+2JLZ0Tvru^M}k-!s~&g>L6mSzp)yQJc>wDMHY|0Vh#5R z5gL`*debnIP?aWCG6p*BV+kZrJ~Ni{ez*p7PUKPX0(s_%N~nsrW{L! znkl!*(z~?JuIb;#lq5#$c%yZ@6jSJz5@=NW{h>~vk+L|FI;xH@yWh$W;RAun773pq z0tPD9Cm4w!rx$9X3i{@#w?G$0KT zgOdnPNaW^&joQv9axepU+Az4D8e-%2n0`R2tI{R;Dw3&vf=c@#V$x<7BvmPX4)HY} zcXOdrM7A1HJxROKsDs$0WXd*+(n>FRu6*x$und7BB~@i9iChSo$%% zf(@LxmmeI5$MeBFw{Q?5h{ls~nGcEF#dis(LK73YX^^R29K_OiiRz;z6<=u4mO(;n&2k=G`B@D!lvDtYr%?-Ra=ewc)qCqbP>|-zzfWtzSGdtxDDQgFCI- zPD$kM<++F2zGl^S47K4f-Oi=Ky;Rw$gYHTAHmRG~yP;PU&S04C}dZi2h zEv7nH_b7WA0`n*9Fryb+b+0$l2i)eE)KdbLTUnVB)3pSi50l=5n}UOO+RfV;Xsk|z zSxYDSs#K$Fhwx-n;A3<$tc0>e?sFtKw)QE=nr6%S7iwGk19q;|M}aI-HPq@KD(xO5 zXS=zHbR{j~ZDk`-enrwP#ffyIE{D<^mSIB-S zRs_wXm0h)-y?`Et>_!zA*~GnDh7gl1^t?+Fx!5=4{pX0#>G9t#%ITZ!u-^;!Y4$sj0|{jV97P*MD&!i!Sj9JNdj1_7R~u^>a74gq zq8cv?e>kaAWaNz$b5*-YNaz+$Y{k=_=BZw)2*cP#AGZ%B`0*9 z!%IO!kC?`KiXKShPS!~H6S+U)$wA^;2Z_%{Xe3r3I}Q>vH4<--hO;gj1~2qdz@4(Mz1vQ27ZmTH1N9$c7y3l zuJ=Y=?>7}r()d?fKVwNqiB{_Yo;oSG|L{x4eC+LiS~AN~O8JpwD@nv2ImrhTP+#-$vZCctJ02fB$W%sWTH3zZ~tZ{*35 zlDENAi|UpOw3Pgi`GK#_R<9=MtxYs`Z&OZaYBwpI0j^pKH{Gtf0`NV_@OL}#=UecL z6@0rdd?4^EfiIGGfD_ndALiAY;;Jo?f27#WiB%kl{+j_Y*_=mnu6C+>fG4Lp;>pm> z>1S293TgJ$iE$yI#C~c0h!?4DEz?ph=uNUHAhT61=s)|eYD;M%ceDWSu0|g@U#rpU z0NzQBLW?(A?*qOpkfnwKa5b?+A$&P7rSqJZ;2su$5`?NS>JLU50rNVY z^LJ|Y0q73d{15lIf35m%^Y>vpRkx+j$|h0sZ1~Kclkj9XZM-aUbW)9G%Q*BXk18xz z1mzsaqv_c&TzLJNxsav6`aAe6$>;J5C4WsCEPp39?rw7`>DpM8Q8;Pr{sK)B{PV2q zMF^S6rxx>DJ5&>h&-NL+S#2seTFZ=2FtR+=fyYUkGRU~ zsXkpkGkR9UD&7}$GsXN1vqWQdnlU=R$TY?m6t$MV=ZXD^YApYCL+EnWfeYP@fyz%g zUR@@c?(v+x@se3~9P47^3(DPXMIZa!pQg;t2S}iD6GRHQCy`}>*Ze+n8Cj+E+$mdY zQ$`n%MQUm0>ZasYBwZ{6An z-8&}Jr`(O@mE6?9KHrxFLGyRV4sxZ;<9PTqu8X0;(qE>?NIHX|UT@5iJ5ZU_J!iHI zz|Ks}=jMXu_-4+l^LZvFcqHWy2tT!~eAj!zg7sGK~o z^h3XK0OjZRT=ZGMJvk#-*+#IxIsF*+E2bnq)|TlL*cy(=5Q(?#NBJGmfKSD<>Y*L zB0C%TM;KG+n>HOsUdMWzmIsvZi{?4v#Z~e!!0&D=eLZwS!2N#F>+x5lf<96~VnL>^ z;4`@QGdAwU?^WEhpdJQxbOWI|v;7sTM>Ygl<}67K9cRn4Z>g*zSIx@|BpQ6U+)G^H zcR31UH<9}6*E)gNjBX%1eX#V`99i^o_PZzTq`OMj955D?_++!3u?IfWx#4m-j>#AH z!DzWJZgK|8sME?T#||trUMP33EU#qC#TuXS0tXTWiyDHZjklhNuU_5WLxIG~suBG| zEFmzBz|}dcCmAQsK$l72iZr z#^!Ir?4D5B&XrASE8+){_645XGq9X+WBg_<8P#@!Uyo4R4d(Krwi~c(B03x9%+BW$ zUoW0VyzWF|l@ZH?4*LlUHF{H$C?53CToLy!M~Flga(vZY4^v7(*$^W)DNlX7IA z&iD-DQ^Js*3RI3B$Ud57u!T(;vSPx{3@!uYaK3On{&R|OyW2gj^U;LH!kqXXe`)FB z3w?ERLmlWZKTF?jeE*v?!#edw>`{SF@;xN`>P{uEIUm1&J^6nNZwbmxXan_ z&76HypmbhFD3=>fbpu}{mteOdgCY4QS|&sCJ)|)tuOKZp?vISh8_YGV;<3Z$Tg)n? z?NGRMedu^!o!lx9Vt10&3rnzXvRL%*D?n7Xn4mQQ!(4bv$iF2ROHZZ}V?9oga)7jMBS_7WpI=fmvM}G} zxpO(69UvL99yvOnjy0}BKL#o%6<7nVlsEG&Z$P{GQD4!Bo;#NSBV}QM0(08Jd_m`F zT+0g5l7`}4;ABg0>lpI-4L9pK=u}_q;;^~mG@vwuj`JI%)BMJU`Y^N=efvxG-BWWS zC5m?$pSI?y2ic}R%lK4T9S@0sjaZD;@c>V;%REx`XjOHNF@B(9Uz~%@P|8-Nw^@B> z@Y#X!kTIchmHFmTGV)Xo^q*GAu=7c1ny=`SVCAZE_ij-1m9{T|HIp(_tB*cTuv}b> zm>SPtpZol5*Ao|HyGE5{yV`ia zmEYTVZ{hiyq-F4T1$x7klA5LdbF&XYKRN&Qb)NpUhm6j}`}LRi%uYkg|GgXr3aIhL zvddq5!5U5WF)2MF#z`OH?I;`fh^ZIghuF_tD9Y-=}140^cC`KCSE5~lzTj5 zxqW48Ia+QoaENkTXTHHoJeCZjY$S$dY)ULX8CYPR<&jB;jsyLr*Gy|?5u zv%R^oiq~bfSEvkr^Y1K~+WIkA`CQq#?9g2wFg^>EelYJycJ~sW*6gcF^begJDBX9{ zd+ITd8)l0}XQ4L(%m9lxAf(ZAX9MV!SI*1Ap7Y%KS6)by;}}Rr(O$!iw8rcVU(tKz zm2ZxVWu42~a(U^FMW-`|EH7GZrVl{BGXt1qG#9N-8J%UUE^79>O?l%SIlp^HeDDNg zmHApW6S>)0LH9>0v%hGU-~A@TQIm}ZQ)=EikWDT{ z&v2YJ=bN5p{t;+=FM1|AI;-_0%ox==59Spk$%a7DdiL=wru(Qi(ug88Gb#FvF$DC3 zGQ>W`(5L4iH~HOf2bdQLPy9viOThHW0#BcD7pNT1Sy9YJGbdC&V-BKXKkX=UzsW8S zv2+WSJ*Xx^2ojrld@>=OicyTy4s#;QJjca|wQIo!c7rf$de?X+&Oo-_JVrhz7S!p_ zpD@??v+6$`AP$3K`!|7b`u~QG{=ZpnbX7eobe2*8=9qz)apK5=CwQF)$YL#1h!Y7j z(31vB4ccns;K=I!TdHf5(5^2uKfB>`=V#XZZ~ejb!`!#_B+c=h`G^(7&~e%1JvIz` zE)uLX*?ZEhc)3=7t2j=G8SgVckmCBh^_Ib0!H!DPhF_}#bZimXM}us z1<mZ7Eq3p;U=c5#b$seUOL?6|PYsh0@CTIryrFL|oJBhwRl zmLqg<1U;X1W99QGkFGeO=DK1XD4mrJuj3g6!7Q z)y)S1Cbo1v1&v8rrTev3dAYoDC0b>KIfVxBz364?j!w!_u+1qp;3W?gCqU8SP3tUx zpugDHW1G|K%}WURL2cDsPFiuI^%8z;ylYD2TSKdtpP9=(^<}#*7?tfh5}yB#zmYs| z<*$stwYK}1Q7de01Gt;WI;aG z(={)NoQeGwJ`-`z;hW4a#hj|YhktBw|K`!QndTLZE@s*&k^%GT5Ha)UQD&aN(#H>u z0@f-L1eU)mtP_Cct1HvRe@-s3*tX%$5gjI86TJ%DJUTKaF?zcPdnHAs@O6Nuiw)k zWq3WngXM6Q5Nr=cEd8kQVmtq@Sq z@`mXpeymivX1gS^qi^YM&qQ3R;Ui-lZ)R+WO-Y!03FbGN;uvESnciYAJ#P->*kcvN z!buSu%)JD`N5g64`LzNoQsV&bAlDauvc@kJf0s{_%pJ{2Fj!9!t5jm|ppQDFL;InZ zkqL^J<8bsOwSGFWBD6l&Ss$VGAtlwx4d3FKh$AtaAFJqRHT+W!TD47pjslttNaIUp zCh?<+3}Vlj?~Bib?=q`M&aS5h9bdjM9qaJO5~f1Ur~Uh;eK^4rjz z^WRm_h@$sgyXZd7+!eD)B$F;_g`TQrmeA=TV`P6;=<>+^YeR#~BblTKhb%f+8Eg~F z{KG$_$4>N?8|#oJ6}4`@E;~t-`Spgs)N#DJ6jOe@ZU|^2@NU7vQRcjXiR~=kl-evCEK|PsI4o=c% zw*7p)4M93wXe7#LaaSWcmzY$W!x+&mvhYclc;&+mZYriJ!X4_Q*si~|$!dYBziZXs z6*p{${eF>E^;KWIYD@pywe`@yX10&7N9oFg%iAJmlbz&ka9)zU34QWp%jDNt zeXhX1H{WlxV5Jv+!#S|QON#MG!Grvi$I_<*VeiT+pYcJt!Oj3q&e*4%FMi`=i39Z3 zOVEYi)1g{Y8!C?v1|K*u;f3bTrTH-zSOb&oPfu zzncD(T&$))8T_c}Pr2a<8f$} zB`LTBIn}qL1rL#`T96~DVsX#{zp)lCO&^BwGzR*B2)lFR6nYZbZT zGXUW?o&&^pNsI@b*SqBPE6(e^@>=A){!(6#b6y2M4$80G$Q|c^`YW&CWnL}zk}(lC zWDC=JVQdN3YREpHg4>;BIaF?@N;Js^r1w1a9}ZFZh`=zfV*VB%tm4-bx%XZt0gM}! z$Ru*V!<#yb^$r!|C()U|d|B#ZOOG&P=_(OV+4m@imVa_lw(FRQ*{(VKJ=K|CG`>D_q__`R2vq z=*A+*Lm7N47ZYK?xh7BDiRmH>n1qV#3Tf!D^nRlt9J2b3+lfbrZKJnsYJ_d}7y~e1*$PQ0G zU!gJTjHvI7rsS$+29_KuVQrrK$`eX}f(F5wraoy{>^dCztu2ann&tnH=g53nzum&4 z!)e9h@(~y3gaV4i*vJwBC=5O^0DVe$;c$Q!9yOEa6cozY()n=sNSniDxPXXT*6c-8 z`j%U6E3PPkyBOaVcgsgi&j}4v+@+!<-d>Pgm*THoPX}`y-=4$=3TrqdO8D&5{(St! z`s+Yf*eVMMu=VT{g{>=K>yhSfIr&PlHU8IRx*o1)-Jc&`q1XY%Vij-Tl{Ifl!x*|3 zbdz~4bz1#DDgT?2f7HS9TlSnS1ln#<-kl3HjABNN4~+NWzNcEE3e@Q#9qZ}k#`(-V zPQb%MYyni_5{I+RU#aD`bdTktGE6$^hgtAKa>=a2N7s zzA4sHbo5-MQA+p{G{(pgFrjSO@UT(zj09>1BZ~vBa9?|_VvNZUUp1?N%*Z%+F$cdQ zZ~TnO^TjH-=wWo8F$OP6zA>gCI=a9ZQ;0{!7&9z7dYCzsWj%r00NnD}m{|%rHo4C- zf4)&;_FGbj7YnBcj05rEvWxg^ZoWkT*?Zm)$|^VTD|lJyY6yP>8o}u0UOd3%(eVN5 z?qjZ2i)%OYEiU)@)U+sPr3#iqxj6F2d`k_+P4#oAPNZ$RLMXctpHOVvV@TUJvjkkr zW20x`z))zKPe?ewF@COD3C-iB;`F-aS)OJ&P{f&p%r11GylCzLqfSu5K=IXmQ<@pe zy_lQ==V$n1xMa-6rd-1*PJ;80w2Y&wPVSHgV>)fx5HOZ_%~!BhIrC@SP_Ex_Rc{XW^Vh5-xQnal{G}^HZQ@0UZ|V8&Jm$k*2xad3 zo9eLvd=8HPk2@=r`)0SN(O1DCU7ow{6N(#LH-hLeNcnO?W$4fxgovJgaf*yj$H@w( z42W_xnjBinUq0+XgT)6rs*+^=S-QyLaRKfvK{5vfjEV}G?;^PC5}P}*{5BOxgy5Wx zgTz-S(d6*{vg0)Gf5{5GZ5ABts*OWpfzlgt(~f0B?0ihqMcD&#{5A3~Az_3ws{QuH zEft)qiwwQKLU|<+B*-QAaC+_%k6}3>ES3Q=J4aSpMhmrs9-N3KZN?cnr-NSTdl^GI z4J**f8CJ>;fl^acGy;@z;p1>9RfKOvddV&SK*~Q$S<+7eN^33@Pm1o-mgz9#@DBGP zEy)8o@0~6UmEpEadZ*ofdVHArSIvb;qYQb4t&cN^X?7aiteVW&y}m*mga41cbAgYl zx)%O~2@rueQHZ2U>nK5^p>1@q27{Ud6F37Cg=!V66)HZk+LqQtKvckl;`BJJ?X_QR zuWjw?wzjrvpAoetpd^626+xS z?e*Gguf2C$dOpIlzxLQq-ZlPR>C3T4CI;jb^eS=k>1j@h zkG`p-fi(`)A^b5x=8n8XKVB^Tdzx}pOT=TKy`x&s5EG?FPEKGfHmt~k1$@@!5q&-wP0A?vP(l&mE{mHpGsTcOx4tA;E7Sf=2D zKbgg<^KJ4xdS+FqS&l$SkF7l#DR_a5Z9Lic{=2#BP!>+W zxC}L_!-ztkg!y?;aTxl_U6dYuLpap@Ppu}1N|;EkN^Dh7+F(Y`=!Jqw(}m<_3bpYS zcP{s*T0k@!08506J|oVEnP@N7V!-&$SBD0~5?|6x0|F0K^Ceck)r?XW1nioHnuHY40xD6Xi)0+9b`vjpY4Wx+mI;#UeTW z3)EUI*V0_qhvfG?`g@N2K3aaufLJYa`MpH8=)txlm`^7s{>r_7p9q-F8Nl70CjKW= zjJ&_$7fOHPxwB0f4j#ov!xYHGzFz#7B{lC}-C z3>kt`;rovZ$Ljj8Y|#ynCg>9jGCBE1e-YwFKrlkt-=dq~Sl_H^f-!NuX@ZytweGE} z#UgJw*L_0lD>F}Myo5IH8*JJTYd5QnBHG9?dz?RJS-RIz2!e2bEn)4lxRnczGeKt_ zSrXD`_{)&zY~#mUs4t*Y|%HjfnLr zw>D!J)XFf5bqmK@Q@y*N{om8Q-~M{e=$`|>tnFvnui?v==`8D3Ssa{O%Tx=atH1ji z%UwhkgR2b<*b0+s?ZiZScM%gJlVFozgWAM6UX8OQSPaFcAxVc^Ve;X5;AaYt_`V@u zk+Qb?S#6k~a>Mim%=={fGe8~;mwtVhnfko`VcT;nmXsAd$s7_QE0L*g_S`O(n4*0u zjME=5p|d-e^(=EG-62{wP^rBmayjf-JnJ4X9YZy6K8|j@5|oFUzk#svtsT__D*fk^ zYTPVr&+-i6>q{D-Q9NmQKUDl5iR{1qbmV^HIV#wE*$2HxOH{yNmke+pqpNAVWU8p- z>0;}u*W^kt7nJBdF-v;X6qAjnVCz!0rZ~&j6xWHSn4H!W&hmq3iOFJ1$yExTrnvc< zG}t1-0XrG;!QSf@3fN>$bw>G@ttL8+nxIio*Ih;2ldU2qcYPTvrw=ux+mcbCt&`Gi z4Rr5i#ZD39;VF%sgVE%F=R42-|A`Wvn9Y7{ZvBi_qZz&&)VnV()PqzG;9okyly|Qr zubw^dy`IhTDH&yAe&^0t5>s;LD~TyR%vXN?Ukb17&sYA7lPC<_?NKJ!ds zO78rH_Eqkg-10ux|CyMQJ0EeZis9LJ%4-M)GtepA+LBtz8POcZoGoAsJ_q{nF_(IE zi4U8!Y2J7J_`vu+2bP7N4~p-foAU0_3J<{lndp~o52lOxgM9?wX1r>Cq~qnTQNy5k+AW;Y(?={0 zr}*+(7IEAA+9b)J+a(SS&VM0U{(7>hvcWL*Z~dXxA5U5{nFA&5KTEOBH#TNdjwz-3 zf;#{_ZSezBQ14jSku^2`c$TsdFD*{_Fi!azkx;t)aw~H_ne5gPS}E%<@zHZRjX<=9 zsJc_+9h1Q|NdC-Zmyl~cl0t5q5f@5@Z6tQ%ZpPtZ{)M1dliZP`hOT9K_>tVthj7fhNO zA3d)~dNHp=fY+J&8qpX`GWBwtC3nbR$Yd6{+*8dJ#qwz7<_~&2EdvovYB^1aFIoC#p<&=potnWbr;ErmzrTmGo4;EW3P;@ktAZ zw2wU6{=j`SQn@|u93%2=e!QlY@KP%@zLnh&R_fyw#OfB??IOy=B#?3W9ugdX7i*6$Aj?{5e83~!R) zW%>iWjRfNS+u*(7TZ&)Ka+lQ6m$edK#%UXW*Em!OGIQI1=l5u~ zj=%}J-$$Y^+*b#5>ksnjId?t5+onH1WJRah*uP3BKe$&rq6bjSdO5WcL&tVH z7w#9$9a?-R{ASA*qv8>FvSpJUUq70sA&)omr6G^3%eTu(FM-Hz_mQK>?M$k0?~v87 z#7!0W02(VqKu#7q(M1L&x7F@$vdV^r>{s>Ty04@zX3>qeQc(%g`_&ZAa!jCFtK|W) z7I?Jd&b%^rayy*18y6u_SQim+b5n`z-cMdo;9iE}mAWYuubEdShcrA)Q7XHa$~e@) z9YR^LomiP_uM>+6u(KhF)-30M2eZ}pYRXtGzb2FWqAu+I#?%$py4JW)P#0>YO!^A< zWLINxAaCQ4rb$Ib!b^E?f{nv%$GMlb-NFX}czHbWYx&hJBd4qdiITCLg2cGO9xCb+ zTJ#DaHO(m6lmFJ4GzgFX_B~B2ioj_o+5wQVB{T;V>a8PeBMdHreE}evs4MT*m8RRm z0j{#(sGfeD+h8RMoSP_+Thkwq+w>zhQ478*BRWh`V@A>D9}9HAUm1G{Pe&_76s z756jb1FLtp<|lFjNw=iN^86%0Zuhit-3WxUOY*Ztb}ME{_Ic^QiC zk_HY+E3o$;4vGQn7mXg<_jMK2k=AyrN*E1CJLX)%sz9!_?q_RvvUVD=VwT(Z12V#I zvR~yVyb#-UI)uDLq>H~)WT7T8TAO4`N_%agOvl@6!&)yJN!sPNxv8~%oCz`0oCgKe zd+FO-F#~V@dPmb->>`ACl^hrqYFPVpLaR)JhgUJ^0#5Z}6F) zN0%}>fWfnyU%p%E0z5+japr`Ay@&P=4Y(gs(Dl5E@A2DeQM{Fz)C%`SnH4yb1aAxU zwc>5xlbR}c`;4Y4-d@pE$HUtceW7?;XRzl7b7;gVEVsLtnvgB|0Z z9u~D{y{$W+Fi?m=m@NL`ZpNTp(I5-A4I^3Lic2J42g_VHy9F=k!YdJFIuF&wI|=<> z<-R2@Np!TF9Rh+5VO}#o8%;~;xxwMaw`arujjt#c^&k)WFk@U;F3~szkkt{HD*fu2 zY>1dKDV!?p%M-z(A~D)0cmfHK4-IhI{x4%sk2y!KN*L_}rtRdcwRlvWKJ0Z(bzq4$ z?d=;{{2P2Q+@kdT+W_xgZY}>(#o}UaEnky*S^Q6p;)N;;HLHPM7gx-H9^;SrX zy+dq8vh-V;`XH(C#Js8Q(fYRa{YFU`y8?oOHWI*v!!)AZ%T`tmX10(U*{mCNIa z$28Nu?P8^2re#6LkyTB53mZPzw6|YF$+;p_LyIovKO0uMjNC5JKE%Yi^P9Ahu1Qo1MIKDo^bz+WYU=!AcBV}x)8cfd)7_6q zrg}1^O1FPB>)6#-$%+qxDlgOA|8fg6fL)sb>;@jQ09(Y2HVsp!X|62Y+_CPHY{Tz- z2?ToE6j!?s{B&0;nz(HwoqS`L2uUSPt7Rye+)p!H8G2mHd#gp=?FmDV7VqUu8VR#@ zCWSq!bc^qj60VL7|yD4jcH#lym9 zwpeg7T7cveaHl~e+<6J{$vw}j-EYfM4f~@|gP+#1&VjFZnKX|?-iQj;3FP8wqTteQG&-R-zOJ0AiFZwvRNneLrv zGV?2;69PL)qV*W16KePWL0~qN*)>Nw;RIX)a_|KJf+P1ALi3YY1PL`CE6pV5RY!>F zjk0cng)-BvX2y;77$A9XW4OA`G|kc7L7BG4O_lzyknu^3n{KFFo9+r)4x!B~>We8a zCof_|>9u%QaM#o4?kElnYAOyS`F!(B#evuO9?)EzzNaLuVBUXtR~Yc^(MK0EKpKC8 z;;%~DVopxofr_sCrlB`3qExpcc5omn;>Fv(ZITZNgXjPAYR9eSum6jp3ma8BSORxW zCj_x!gzbza_PjNoV4gh|Hm&6h+}g5uA{fP*kF&#qPZ`82X8ogY2$x?koOczTqx61) z?~^WuLcO|^XvH@-d-f0>5OjT!*2mfB0fnW_#a~j4)z83}@;W}m7zcE5V09l{luftG zV(4meu_1*1z`Fo_c{O^qirpNc=3ntjB#9nW5qEAPc5!#(X{cRvLWI`1 z54}noHS@x<%hnD};B-i8=$ebHFN@dgiHFARi8;GCku#DqGIk>kg{am@H@c}tN zwhsmu>{=_#YKwc*xUFPEXWG0vI5Ks4oK=->kj*~ziuS~e1Z^gkv_j#L>Qq-l{@1# zyJMm8yW`Goa~wBAa8Wq!w8gobmDsPEm2s{=WPm2gSkluqR3N8Z zz9G=W8HL|62nmjB`f@Mu*xM}G_|`*J6x}~>Qgq8CG_^9uN!HHJh5KctXLi*1JI}hw z_&b~LR)1#|U&i0rbbg>=bb9~VFAbJMe-$jt{eD` z|LbIk9-*))bsQtLs%vPVtBojq_Ev@)!pwL@2K<&(tL69Pbgv|opNbQ+VUlST5|kh4 zy4RT&?)ncKpk)1(bEv*h9a{7ZZ(>d7VQenti6Wz?RW!2S@4_$@Uab|*!p?Dz7Uj#a zJt*MD(*+*kcaK?}?Iq@fSsly#im*g0_+RhQ8CpmqYPg!jAl@*O=a3Y;lTddXI zBGvvL1s+R`*J=kywYQSm^^;iQ$`U?hiD@iXOsldJAGH!!7Lih=U5O<=rcJbuSA*B{ z%Xa>T5alHTl2mj96#?4!U-v=lAsKjtCYn`1pOA5#dHGhVurujwS zWI9h^t#VpP=cW2s)p;S`g4t;Xl+N?{-9Dj6Fl_4i2nn`1e)v`v9-Pgc!$^tE*lR04 zft2}n&0dp3NZh@IT#^#H5Nj!QJ7Rx)we4VdOsTd{yQPGULUVE>h|0vdmeodr>NQJD15D~<0tf8UJ?q|tm*KDRCCYf79Uuh=0HdZjTq;obu z%$aP-H59iAA&P(`?CdQYWUfPnyn_HZt&$ZPK)l|uI)BFn1IGjgL=kEL< z-!YC}(t@@#0g|-Bo&B|tnVejq&zv5r!4QjHC!(oo4)|~0&XZ|xGf#Kl{Z)DZcRj%O zBYd9ZGm{YK*ZF>)&$hdY1E1%21E2X{DGq#%&%=B=_-x_R#b+$}F6Pt1b6@fW3Wms^ zY^d+oUmyQR{;{8a;Gp88LnX%?JGi&v>i_$EBCmDJ8S%U-ssp#Hs21HU8<*lNwe@K@ zR!-pIQ2SV_m1FI7na$bZEwt@E(Y=ycp&nq}{AcpXCeh}t@*oRIwll9z*mgU;?-JRoJd|#8+~X2;hoWyj25ih>&ym$BdB+K~-n&)q@#fu!2!W>?dexPv zsGv#iFBdrnx{i~ie~I(ZA?SXO`=&l_lfm?2`&ly^#WN;pEODYSQkz&&#Wc0vc#o2kqqJL_v|abhG~qX0EiDU9ze?jYR^9o_ zr@DuGcH;uSzYomq*58=dXjuDOZ*FR{-O{W^z5a@&=$=n=nf^{cto~MJTG#%rdD-;0 zM4KM#_xIa0(XGE<^ZWY;{1@H&m!BZ#5pI7s{$Vj$n7)2E?)0gL%16YUTG>4vPZZT9 zY^JO>Q&yW9?0{$@#?OiToR|~Ql?zeN_V@{NbTq^s*HipH$ycXZZi`;j?$%(r$@S4{ z=CYeLK6*CBr)SgNO$V(OwQ0{0!``!L)%X;fw(G2TLN1$`UB=eUipCktH^gSWOpB?_ z8q{Lr)Mg#y+pKDL!Ut!N&o_mOz`6MG^QJ~7QW^t4P9OpwG}zfXD~~2-7tt{0ZY3uw z^LSI0XWDv(Zr88#&D*8h{O0<&-{RaXCPeq-;_$uvrSj5Szxc+@W)$=`Y&B|~ees%Q z%=0$8%`yyWL-Y1?K|N!1OdNkk?6j#R4dtS1-K_M%IS3UV7glHpLyo!l|xUjHA)G0}JwdXR7_J;@rg8x1g7JQP^pF+#El z&`!*)f|Nsx%8*fK|b*a*i4!C z#+)~L@^@ekWcfSZBGyHW3iWp^cgH*;{th-Jd;Sh$Nqm0?fR@EM?mW1=i`@nKY*+rh z@d4E>TsLt*xR|O?FczMeEbJ4n>A0z1y_wtN#2Nr1E0_)22~Ir+xiQDb_hF6Xdy_ev zexGB~EHvK{j6RK?{Ns#Pek)qe0nx@xy?a2XiTR7t0eT~Ifu{#@!#S{}) zlFs@W<7!K0KhVFiInP{j3-E}b`K-_&cF0y?`u}3O-N*gWALvGVt58r@CH2l^F2b?H z^)>4oPfXjIpA)}(x4g=PL7XmlVA^6eD{Oo*>Xq+^Rc?p{*T-ts!_+I|l`EK?HVlY4 zlNrx-c4DZu7t?)zhaJh<^LQ%0y!hQPeRiw9Xa>pI#eJzdA$GPjozu7BDEDO(17}CL zg!SCxPLY+yDmAs_>mE6}fUFTCG_9=8R;a;^Jq{B%B5Vge#?L@M>Z3w~GhyBq!=o~2!p ze&}(z8oGFF_pn9jrbI28v8e+%j2#`D?+?l99o)m%e2Jv2-o1ni$m`t~ScAuG@W*DY z`}o?d-W}f9NQd&q$JqB?FynKd_eO~m?9;)F`xfs1Hi!)^{vnotaqdl>W}JKX{!pEJ z)A{m(8P}$R8UMj;m|`cmzx^_mCT<7xMHNjoeM8NcNnIv#wz5b<89OJCqLF7~ zcn~)qadzWyZxwWYm;{M$lLS0q1)al4(0I0E#4GA9`la~G`gQg-$-&T~eaHlTb-{NO zdF2w8R?0P`8j+?4M1<;gc0NlsjYaER$rAx+olE&vARgrbN1?!RIW@^Xf#I8F)!+tJ zm*rY2w}b^XmE`@Yi?j`$1L(%E#KuoRcwD=s#xtl3s{|( zkS5(4Mg6icyNs7s%l%UKZ+LID$SqQ&|D1HWG~yl_L=)mWB3-+Gj@)Ol#%=u(&28cp z7x=Al7f8&8*k@#QM<@6}n1MjZcZGa+ia|(u@U&i@G%m8UgGT~3bHg1Ilgf0>Fp4_t zlryKqmEJH|=4t($2~`rtIJ>Gd$%{XTu+`Hmo!RAGw?`8b%Dd*oL0mm6mmy{ufQH-l zYIh<*)TFbXv7(_w*LS0GS>zh`3|>T?Nrjq50+RfD?mI|w797h!^b$FeS~3=C4dbfx zSUpL&l|Y=Xfe=R(#K9tj#`#srnxPsaDgcO86#;BU`-~zr7 zS9r1HTl`B-3P+RUEcXi52c2bI#U`B33-hBC^P}p}HgJ6iOx}>LUPsh`xV%W6uBkgDb&5$1yT`F`X0_~+c9Ny%lR-3Sgtu5=|Bj$MPT_1Y z=Rd^EzMFU=XTy_(I!yOs^Xjfo<0x7B!wmvkj1kms-F>fgOC@K5H&%;w>lTw5cF%9l z>efsxy2`ym-XaDL=2kKOlvOjeWkW3><`ilm+~Tr1Btte55I2~l9J0abpaF3si0aT8 z6ajG(vqWU528hyf&Q7nMNyek+unbb2fx^VY4PK8c?f2S0b*1IR!(Hv}WOWNWp@(?5 zSH%}7vqc5!P<9;tjtADg3x9`C--+uKgF@dP{SHS&S2tB>Xn5g3L&Kj2VLlBXVsyIM zFIne94mB;toiA1;r>>O{xLuhLxZmR#iYL$g?j>?_R&5}2KA;9vEw25A1jMaPz055& zMS;eFe90Enn;E`#Ib$=>c|MQM`0nv&-yt3K)IA>U5;*SmFqgoXlOWlFFc_T8 z8hv&t^$>d2-mg%qN$}6JD_6UB;-qn|WDb5oMboN6xBkcctjEBYOVZudm}!JEkzbAR z#&~3f`xdRpJb$~uCcyM(ekMS|1EJ{ADOKA4sI0&?v3pElTao7ems^! zxAqTrjnZu@W&_)$>YZ-tW9K7xR36nmuwX3hLa|+oMXOT@;AES9Eo5dQeyy=Km z@2t|h=RZ&!xR1}kpK(SwpJF(B8Q(oi+xJrsFdq~*i~IDap)iU0`T}$4eUTN++PSTs z_|Cqk1e$Ii7HBBVOwzfaHC6f~FSaWrHa>H@yu$rfd7-XJIfyDT*S=mv?Q?0J*h#yT?OtA3Z-I&(u zT?s8Swg1z-{`dXY-r)f|@|1u*RnSrA?0_e$WZSwftM0?aA8MA9DrEO6A&aTf#|ls3 zR;SRVtr%2N&JG7WMp-TWN&H?ume(2b?D|M7{=-@-H)#2P}Hl&uazqGq! zCoVxkICjKQwW$wx8u+Ymf$>+--RDxJzu`?+HS<&PuyDNHPjFJ`b ze7W;tX8}*r6UsuzuoN(%r1KdP1&B{zNU^M4 zSmWN_2MIX?hvoA!)-SrR+#pv76@748&4f^?C8JcUJk35%yn zf0Y^`Ogog?LR#DR)3v=$2D!{s0o~L27}(C10!sx%D{)+9$MtRIaeQIt@5p1d^hI8D zmf;lg9zVxpq;=u^aQsw8WRa{nbE&V|r;%3NN;fsm=XckwkygTAztIYWGef`cf31w> z8T=m_S~QFr1ZmQznM+Qi0v~G_g|DVchtmxL%j8VXbhgNbtUO1jN>l&RJdaBr2CfxW z>gu2Pe05i0q}ADMxMr3x+Z+TG2k#SSJ{zJv{@2BUlljc!`3}Bs;qxax<9>r5iO)Jd zTltLUGn4dLe0K4?iSLK_oX#gD?$a*u#747BAH|} zr}yL64-vQS*Y`lWzHa`ThT|j(q@l=(Rk@3h4czUh7bfDaKCNUHRM@4zX{$)vGL*@SgU)82s%CpGQOG&V#U3we$C zQTu*x4j4-B*_)aB@TQxlxzk}k66nQNhGacx@s4XPQO`xzA{3{4ah3CunSwO5;R`>N ztg;MZxc87d=Yi)%k6QX7AW$YIWw<)mvB#wwsCzer@GA5x{q?^n4T0xPqDI8}PoKSu<*PD36W z#j&R|qb{yjQJlD_$`{3naihe%v{R*%_KZMLT#RL7evgyij|LXBoqo~ zuReFrlU#EhC(oXji3*Cfbq=+zZ(Hzx?hRCSv|M-Xwbx2peLUk2Hx5VPh#qNk!%Re@ zl}l@uawuNaZAV!PTWiOL0;vwGrtLO-ny^{{+juJ5a8=^v>^pkWsD?o`9rKGCFO7~K zH|o|Y*LV0@&h*I_*VZ04%jRUM97|Iy&!vhfX*^YW=03%b7@OBUDfsDpo1Xbr=J?MF zVO*h-eENI1{~*JOy?gTI#oUWMTIufZ&6XFRU@6v@7d|f?R5BR;J;BuO-i0s5*g&OX z1IaAzQJJJ*10iB6mbK`NMV<}>z32bDNTDU#Ll)kl-~N0tJ-AqiSLn7jXP#i~nK>#$ zjMnwFIz2EWsMbrZXT97G@p%)3Uq2yZb{0eqWW8{VdH{pZS~}#OW+1ekzE^dnc@MtZ zB4l2XD!uq0hU~-M?>N8ZMy7Cv_T#~}4Ee|QY`5E9AA0>#q58z&Xl1JsK7^Q|{KQ3t zLSNM~i+N4__ZSHW^CkC$hB;~rt`But&zqq9F%`p}z5UrZg5Y0sxhTec1Nr^#!O zi|&&+WW43!;n(n(Ssq^Fen)QC6M@)Ed6*AlH~3noD15T9!oNSa;S)0V86ZGj2#~C_ zZA_I8%mB$I?al7-86a=J+5^&`f$qgo=EJAL8}%ofw~doJladcy55K#?TVmjq^?AZd zyqR1N>+{lu3A+@}cqFaRk{u^1vM0Tii|mhoE{*Kp&B#M`l{);69NF}z_L55t3s0SF z=z#fYE#X!Y&m<8B$leTHc-IGHv+CxnvPMac`T01m zJPEm)tU*rdbE&ir`i{sr}qKwIg#KIgbe0$4BHT8Cu z)EA(!na7T+O59YQm>2FkO|!~|Jvr+~C$MbE8~h~GmsHmvXI?ooj+@F^r&je;C1Exi z8SjO1Fkip>+BW7Ge75oVGoMaAU3~81vy{(FK9UwVs^BR8WS>UgLV5c4{PpYKbG=29 zTyS{9`L1)P4{%|_44Y=WD}p(Lk8$XU+I;=HFK*bYhxJxQSM2qpu-8+iSGOTi+;=AgH@loWPciRn#wf}4`Rg)B?yfuj%$FTpnR?ln;@ITZ-o1siZF~X+`saO_ zAMf%1blvHPoF+}`yK#;i;q{-B6uaL1;p|lDtA8C4Xli}uT*Zu>DNKo1yL;DD#9r|6V9o{oy+u&E?)1ZZjGv7B@`8kCO;l8Q^PMMt!+hsu zagpn}n~jH>@4Wt>lJ)TBJD1T$nl%o0zT@-HqcH{B>~C0#snSp@VeK`z0{jQDOKvTRays`NDSbP=2@ z&p##;cGV|Rr6;^TBCv$*i0W>-mv6J?Hw>gEC+>02W}#Ehc9yA&M31nP70`Fz#V@tM z9m;!^@MafOlWgknWxoDUEc9llNAE zfaeGf%?Nr(c|oYp&!$6k~9hO;g`f@>Sf zQl;Z)TKqF*zQL%rVyg7bzhmGtM8{X3*l=9NOW)3^k>`8266ciY9s2zE$Oc&?$l71N zWW0iky)kvYOzy#yF*|&lV;3G|Yz}VtIp<@9o*?J~|9FNFGv}TK9efjMo|20)olSct zsIF=#PLj_EH`PEAOB5jxt1T| z;k=*_2QK04v#WE;x#)OKFA^FrEgL~ev}9p!10Rt-B`RtRH{@m z8MC+PwGtnylVt0{5;?|e(e9S3_zKl64gGFis`QZ!!vmdv#lwN~8^41<(t#CJvLsL| z755>bb2YEE;#BD_R7B?!;t-*YALB`OmILI3^4i_YiyF%K-v?jK-ySCFCt3O@`3gnX zb)D%fZ`$5(OuxpXNPU*nt}rb{`$9m74F&HPh42&>Z?(?j_ zc1ql%o#W^!IlZ)E^qRO2q|~*dW2cOROz!_DB``T z#!FMBQ(i@q9qUym@hnD?ef%MjWINo?epTQv{SSWPSnKNnXKAz2K^<~>bh74u&^?#@ zGRtCuU>L17dlmMND~bb!D~kiC@`=Cz)qKw7^Dv)@q>bSFO}>B0H+Odr;4fG>pl?5U z__zJ3FTGps0ZHl8OZo8sdN#4IRX|~16PcGbv3&!=*DnuPP)qmwm?VC&VT{_-9~B`f z;e4m~dKR+$&Kg6O*K{Ji+s9QyS4^g~L2piVaC5sG`aS!~-*1p3wEOxnz8>e`m2VRg zxa2C@vpMX9aH=bBW7p53Ry}Lz(PKytMR&5DBeduqo>@=&(}=)hLifUaHs6L@${!T= zNM2gto+~T%$-W;YA&cMTJzO+GzL>*@lNa`NA7!$Nzw$350;9_sN>Zg2JT+DKYv`Mt zFr{n2V+*Lfs{5;1G;GYpVk8B2 zl{P(N3;`+dkRxUxtraoI;n zCs4Y&Um&wpz~&5?#uuGWRpACI_M&H2yJPMX-(^?(5%tc*ujbRor*IiSAEHcOSL0I5 z^|EUZV6RL3k=X1WSy;OFqTwWNo4whm4Tu8r*!NSenoN$L@BRntXBQ;BJg$3%W6@98YptWnrKus#ZB+#8XK2dRXna7Rbdfz{?bzjoX9NswLYBn-h@AF!upA-b9iZ63 z`0eS@9#SRF#~f~N;(QkFUkK_#i{%(d<7llr%{W>&KaX?go`pZ`ij-8D9|~>LTV`Qqssh^-Cig+@~6k3q&@=5}e}DU$!sg%9^gZ>@+QkY{2V1x+HYZ zvaahBpR4M+S`wWyVerf3dQjQ2F9P2uc(4wwmDAt81Kd&S;LjoRg~QrS#erk#<*9rc`P{;1J|BnA zVm>u|rtvx1`;Cn|kUu$6r(b`0`gi{o9hKX{fCCV~e{-_&7v6BTk$4-#@7Gz)p!r~! z_mj-~^5n)&|Zc_kx=S*r~Qu z>w?jN#y6bJq71r%_KX)W5dZdl%&D&_wCiNniiDyHuQtaFedv0>8hN{rhnJlZGBnILlx+j~)nJ5i%Gd zYC)`=3@H$vlT8Zep%wj!$AQ@1TY^|5=4eE)gql*bo48@|5j)}jVPZ%^1eO!or$L@F`zke zz0(C^O(aWq+>f1T?{L38PN-OS51ot%TtAhZ^H7xY6IWvnO7rb^z{RcoACXqxbk7SDL zg(N&l)I7ZA?^s{s4)~go9Ru#`(!&|aZtztZ_;ke=6)lnX4O3+Se4+yB9U5R_5>Cwk zDTf9uz90?c@jj4#NWwLE?|k?&6tCnc6rARPEY$bvu^aresuaJyicgsRtiiiTc+79v zZ!Ym5Q%TOk{lkP=d$=DrEf4o!!AW<-xR(xgga6@=D*R1LIcjB}S$A*R|LM@;)x!FE z*U{(`jV<__m(>=$k1rGUjbuDom(z#o8hH`R>F(9+PRv?OrxK^XSxhGt-=n!kBVQ6} z`47qA8~z&mI0RL$wDOLf%T08~VH@Tqu^Mkge_nd`v7bgyzR2eoK3lgJ2TtMp%X|U_ zef#w3`|tY;7WRtN-jhrOpQRmt+>T;at+Q%Q`~N%h~a4i&)w`ta!F= z^j2#c^43dNe;fPe5!rvxyYtwa0X)ld6wdOT;iZpgoc4J2tdaI*& zMEb#7ulsGKH|BS27iO1OyNT27_OX*CwuR+8t7UFF@w`(iYWXPzW#`tJ@^7p= zls?%ejIe0M<8NN~+EJgeBW?Dhm3!~`*B9UX+!#C2-)^b-$;#$Mzr5}-JAwxyiU~yy zJ+^m3iT&u#k9~03Id5I~V|ha)A!XO!@!BuufBfhv><}rF)LKnF@y7Ei@0!{7KS>Qs zs;#w5sIXgZc*1)7=UbXik{kImH^7ky{W>dzz17B3T!Ulp*^t5tVb8<(;4+OZMJiLQCv=Iol;UC8cR>+hGT#5 z2_^1)x+3;un*N;2Sf(jNui^VjF!phl5GKnOLfhu&R9S4S#hKVl<)Di2*g5siU+v2E zvMf2r(%Vn6_QN}a`q&A3FM+Oon35F9H~vcIFg!4Ew3#@@`-FLbR^xptcz`_PeE`9( zkcVm>h;Og0jWxLijStrsOsch$L(Z;u#?{iVSkpx|65`_WS@Foy`pEKFTSu59&Y3Xn zi$_wiwzXxkT`yI<99zFE9{E$O z?HR^k$PnCd^^vFJ&V%Kx+PHxGPpLNewjHFoJ@LqP0Uc6kF7MDRYr+&Rxga^@WGXl# z4$YOtiI9dyR@#wO^^tY9GpyWhTTw;@vGq&sAavDSzD~*pS&P~e<0MMwH|5(&uo+%E zxCtP1_p9w(%nk{}V{lz;JxD{0#v|-ZSixw*9UVIKQrTvyEV3%D)|+XGGLw>pXOmy5 zs-_S%Z>|q+5%%B^xK$i(!1Oe>zRixX6v!$aAToG3i=T@{o{u|^m%9qlN(iR#9Lm}W zx)Ir8x2-ZK2(PxO8wiW+^=)K1Lr#B-?2^`s=tnpnd@081R^T6GkfQaRt`e!)P*!B8 z@McjAg3*5OrAS@r;~2d*D{+HBKznHHFw!Cj#S@vf(ChU2DTgcJS$RUTU5tNHOvO zAQu3npdKZ=##GEXjPxTGStr{As9+_;x-?E1ax0ZPJ47;2V5fFEPN#Lu)(CG?6*z#b zkY@(n8G;oeQNq$}bkk6+hZc`fn#+aN;Sa^UA#+8$kSz@=%__CWDGy8{Y!pp{%7ux7 z9YV*X->E$t1f8xDv&p0BEd=MqA{*n%hhEn<`(2Ae*73-e7-Ss_vIwFsEG>=Ak|uBu zwk-O#gB-w}Pv6G^ii}I#xthj30ymgKQ6oup+J!Y1j}Vb28*J?c?*WTwh7ZvoK4t*1M*4zTyXM~(4I&Zkw6YO6)d+3ktO)iwi?#?CuRsE8JD+)cTS2c z0xFU0agz;F{3q-1I$9zKys=WEmET7CARt8gK;Xz3dDpf|2t3k_zx0^lQ(2E;-rHrz1*_y#-hHB5)e7&)Ajl`wECIgOG;vFKPB}vu1dqD4wQ#-jLOg-qh0u_x9*LuJlm)e{vR!v7-!DKiBx|8Y zwsCLBmg2S#6z*1$p+48ahR6Xa1ff1JYLGVbi5#BTCCb4LV&?i_h)mR;iH7tffR_;@ z5k)2~srQNbuJ$^z1=zYGZcjcvDKOT?Si9q3F^Sk8o!kPRkbXkQeftMn%WNs!%Y` zxpIEAQhZdBcN#%2WVl?^GmTU*tSY^!qz8$Qs1s7ollZ_qC!6q9Lr6a8U5R{oS~+-J znkDsVGlwaRGBHvTQubv*h82AKM!Mka^^6-VLg9gSPaJ5$`xy+M4Uh+-0!7WJfv6sre<hlWy5)ivWaio`=>3Vd8rBEXB0hm+bI5@VtBf)++6-eG&1|YCcF~bMB6hi7 zY2fgtB0BayyU=yv7?sH45bALrVodn7fA)~`5QZFqQzHtw_tW-f&u~b)tg=}|c?KVz z=hnpxs_2z2$>tA0I@2>sbLQ&CIO;ZGHwyP)JOX>*#9w*K zV-H~qxI?+5`!wAM;zP4Xwl;YfOmx>Ks-9U)V`AdDad+C0m(;#W7FPP}e27#0vKY8G ziqY%wjjX3+d2?4Ym3vF=4$8wxcyq;l9NekizfIYE;p+|GpzZF=-6IE6QAveVa!_aP z4o?jFYZmaFn)QH&qda?_X>!t)Go|J#UAP#<1aOC9Wes}5TZz`iw|H<1tH7!jgm}&D z0>g${C}i}gx9rQ3mabsq@LTCEDeE1HOhsamw{sUGOg*Q^aU)mjRK*kD_%$)=i{il@ zQpB?;J)f=GY5*Z@6!w>xW06hX5_3GbfukNW3lY>I6|^wkk_k$dID(){J{d4>I%07} zYN9%<<}IA}otvEV6o?F8WfR2r_D^_g38K&}vcL*v@h8 zY>Oz&kfgmUhlMH)MLJY2D(&lmdR!1u}ZQSW%`aT4dkvXs@`%$K#v&k_YYmaZ>QNSu!TS= zHe8sU7VhuQB3!>E9oo+B)XZ)Q*=yR8*=WHn;w^q$H2YA?>=r*ho?qciEI7Y%HchZo zENj!mxN5dW9Q1_k+-}IiNs-KAOLT_n?Tq=)y_aAj2xRaZi>c$Q~F>#FlWX8$POVIK1M(M5H zKFzg{th7hT$tiQB!0yPA?p-qHuTSzJ?c4m7^iCzlXnlKOd6<1ldKV4TwW7$1Yxx=! zI%aJ6&S?43XUVoBepKI7YY1N@j#VZxUbCW(HH_~;GIgFQVdZn8{0uv{6gp$e zqE4Tv^Tk0?=aWUwCrg~q42e2dg?U}=jFm#=&e(FkY-el*U(=njBl(ItW2^Y8aK?_} zYos$)t~ZN2W5+}bVqs@WI99Wsgxaym!t!77fMoWCf+oVODTsG~z}P!)>ekb7P@Ut%ZN5E_sKrNL{3ZCI;07cSeJ|@Gle! zeB7wEE6R^SR*+(AipCL7;_L;pR%Bn>+Wyq&tc*rtG!3M}C06hs0%%=wp>R`OWF0L~ z`1RoHz&^-|bP90cw$(*-$zfdQ#)#Af+bm~mGzea;$P0DOy0%sQqmd~iflzo%AheQS z7e)jr0%4S43P-t`qnzU5x?qPSl(18-PTzC4Xq$z*X?$|X04uVzt)u^hw^1bAS2s? zODRTMtqJl)o)#JkN69f=A;sTJJzB*}(Z~yAEfR>JyZWQj~3oLWEgpYtKO&zDq@% zpdE}4qI6_G<%E7C8>7MJ1#^X39DEL;TkGnaWs3Zj{rzsy#WFJrBO#=)&Uq@z*0Hb$nBq|Td4&~RDtu59bnCdjOa|Ey zne$v-@MSBqUO+a$!LZZ|8Pz#$brHtl83SFgHRw|CVv%29JMucj9}TXqb9QE^tS+(x z?&>dybEp!ciEN@wRT^n^k!6AgxH^mAqE_&!ZZx;cqdA69h%DGjy*!meVv*;Ca zKN|T90773+844U@1>2RrqCt?cjslaF#x@FtMK)T&{m!s*3Q1w;<{3zgqZcv*xtS7C z!DdOCuC_WG6T`{@8Y&Umg5xCDC@ra^Wd-*})1+kuxsr|w$PK#`1>Y8mB0oYRWd)%| zrH(Axy0{10s*5ZaCMwE7)0p7$?gU27Ra(JD=u9aLN>g;_5ZQA2?+>G)uUg9t9Q-R% zRuI?`*+q3Q@%qSfu!Et+m$WiQ!>2I1@RSf%1~$0qbb$=wf~p+2c?EJ~r%zRNDwL>3 z@g0P|=o!RCTX6_X$;7KnDQq&-B}8^03KQxw#854uZd3q8ka@&4ECU%$pf$P^S6ZN@ ziEEV**A+f-$(l$s_(EOel?-*sv5F$HM0%<6SxF})i=bwhQZ+;;eWa$Tma`GkA0^@< zh~@!lSwxmgU~f8`5~Do9CBn3fkr#?7hh;@*wR;lG5^=W1lVCtInzv*FUC;%^EC?*J z7cx-&36gsdWb_c(pDW0EB({-hX|^*h&4fBCJYqvyia3h~HyKp*EYW=C zF&2CZ-VhlUMC(xxwIaSCJHh)dAusMlRYL0+By>p2@w9@fQeh^#GO|)BrrOXP1SeWw zBw3o~q8WOVq3Ma-v27$!p_ zk=HV`M#kD~S_?uvE75VgqM8cmFPXm4Vi^-NsI=AMWvhaIa#K4V^p^bswIS>|C*%5D@bd_cMqBxH#lB`DRl z$P>n3q)IZ+&?yoW%8)%54Q>+(GL)snri!w=7^4IK|Y?Uj? za4oE!;8rB%7a^>$)Rxg&}>qb5Zx+oED%ZO`bhJq*be_a)aA9_I4Y z!yqQSRw z41KZk4OxiD=#_qq=gBdl4BvV>OgTmmeZ2vpnOUz?=S?1ZCBZT(=;C}TD?ccesZw;> zj7=uizLI~?H3pd4X21uRF6-Ic_o$3K$`jp;v0O{v?up6{V2okb9!x*Nn>iw^5P8L9 z(ir+G!EVKpHzG_YG5%z-U2QDwC=uYINhz8`U}h#smqz*)e{bwC49(u#VX#o|=&a%Z zc9=2b4z4em)@SQWGe$kyVI7!ayoMb`VS)^qOd6q$-+@) zN{O{;2u-A#IGqk?_xB*o1SG?on{#O_-xf0-i(zVT)n+7FuFdZmWLPP^Gst8-dyr;1 zc#0lCkO?~DA4i$Q8H?ihO}q z^{#BPk-ereF=umavhGxNMQ;U~Pi0ekqB1DtqrKLdW7I^$KBFcaAZnQ9>C9YG%eTyM z!<{a^v_WcsRb>pcZfa8uH25gXK#K-bIhs?&|5`CJ4n$`-TFp?Gdy*`QU~qOuc3F-% z8JIw3*=lN$sml^q6^$F7T#3NQc2c^$5yV^0i+IP>AOk755sg7covXl2owFtQx=1RK zWh@zZZVU064M}sr4!mKWO>G4`ou^TIYSUSfRvfNO^~KYQU|Op4%xKS&%&W~;nnqDt zqExjjadDk`)k&vNf*7IOc|r?3>ltU#N9L4i!0^8PQM_E98>%k2%BwI_m$(Ec2LTa` z@>l^vbv_wZ!iel?TQdlar0*d5_Bw~D&<-RRd6|1|!W)JtbB3X}#OWH@6MR8jcj^zz zs7SF7cQ^$Rii4=V;ELjf(Zpyg^0bJ5E4T%n8~J18vO<();0 zvGAC0Ojv^=Q%b;T5ml7n(oRe%iI`=Kgf)aTGPN;9jaX&kW5MM-UE&1i5P*s&ac07j zNWsV#hDosCEM}nL#21DkRagV#ZD>)+Us4(rD2plY2L+}V;og6E9|P}W;C&3dkAeSo z3~>4^!5qg}VLn7{SVV1DL~U4TENfPKfh>HtTZM~Qvu?MFWSP0$8l-_5R*42@SVLrK zyWP^Tjdn{zHF6_ja=WCFBh>$1w&(OYOW;ftIAaSn0wrM)QsBjySVhiQ6I+6AcgC8S zl7v;_jP+tkQ0rsM&eEt8tBh!avozYo8g{T=SLbD?zGq(A!+RIdA;IJtMJY#ebX%&D9riJ&IXOs zaBr6g7S1+k;+B)gUfG$-hSWS~$0vQllp@B3pd@#!DJwB39N&gBu8L~H(>hOPV?+0P z{1iXF?kH0ix5c~rBxr|%a% z=6rZBzUmvMln4JErffI(_wDQn4}OI{{4rCPz&|<<{(qL_!oR}GfvGqt2~fP2PCyHbg1C0+GI`LOZDz54}0*4jm_Tm<#064^uMJmSc_op(e zKd$XaG?etGp}+g-Fn4{`0km9lv}}vtnUW0L|M=U^RM+sirE)d!^3k6vAusnTxi3oc zpvYEw(}&(9$3gx+{2(YpfQQ$QeE9V&hT6&boV2=5!XF3QP0P5AYD4Ir*7)$&roR_7Zi^>^8r4-- zFuurYADg9DH~QhOn;dga6hwVQDIENyQdG9yFXW1kJVB>Ub&`@^JHsYiigIoidL>`sAL;s{fwnCr=t#(YqIw;hnRVY?iQoE)37+d9KBLQC8J6t-*j$GHe19vTPB?XR!th=s;2wP3Uc zZqRi(HQ)@p?bWbJXY@K~tiFWC#X=^nS z;H$ZSWkMOblrbd>XRUXyi?_m??co@_30wMHc`^8hDbI^lzO6m*<>N%B@I*RLpS(P~ z0}7wtfpL4K&OClhwDL(im zDOjHzGEmc3n)H(R;5E_+*8*_F`DRJP{-8jaJ$O@n4a@N_AtqZH8#_5>Kz#6WVIclf zgrlEjKX=Vua#PJFss6$w7aBE|3gUSt;2uFKN zpf4)2c_ja3mEjGkjAC{WK_s|G-CRRCVH;iv+nHBJuy_pR!JhpF2)#1W-|RQ^%6lU? z%6(Zbr>|&Z_ao#Eb__-9jMTI zV}>R32Kw;c7!t_5(PIbG=r9eL+A7jz1c%vgs{A(w?U~A|y%&iKs{ua0ShXBfTC^BUU zys1xITI&r<_S?)rn;L~x49RDR!$2W#(zKgJHgT2x=1qnOO@A|Gz=V`h*t}Fl*q&=Z zpGP(}DQx;!lbs19lEG6b87lQ?hFZN>0;5l+qLhKedy`G3@+QrBIV37?=&_WkPfVCB zm@+h)U1^4>GU)e4Izv_YW0s~WPZ(uJ(<3W^T=XczqKXd>jo20xyb_V5iFW@BzP{f-OeZy(|ylk}xbLteAgqZb;HQJOURpl+MtIGzI&K=b5J027=%H8d(U-X5mYk(Kd8OlFEj!g)AHgY1AqD>TAYV90}6ykZ!sX&5Rf z0vycg1&N*x(+MJ=h!%(^##ecg?RevmOCVyQgpy_NZrqQwbRNCLC1ym4W*0;w-e`|# z`q_J~JuuN=y$I2ylxW(OXtw1M4c5=|hG=;25zV#PM6=c>nr*&mAw(m^e4^O~4A}!f ziF~3FGm89%Xxhn73u&V9^2Z0an*pnby=}FxvWujNM(fG}8KMyY(nOO6nF@M&-*JE^972gz%CDP^)_JjzU#1_*iV-assIWo?=OvPxyievc=F`wbtbJ7J`# z5I7i5>xyh15?$dT!{>RA9}bwkK5>an5w_4guRTvU0=eqcz~I&pH9#?`mFBzi3s^H# z31I+%`Tw%_F7QznSL1&|0;~|&sNo{sqKS$|TQF9^rJ4se_$=%~yr6i6HkDHA1trm- zAwV~=ZXXwEYg=t?^=-Aat*y3I3wUWRKms8N5JeCbuvRCoNbo{}RQC5hGtVZQ7{OlO zzW;yyNcNfg%$YN1&YW}Rj9y-XAK=rcW@nicNsmDo-Mm?4ZmDFvm3fjG>{=D0drwqP zJa^~SWCzb@(u$a&_AJF9U6`5bWb^`g)9Zr)ZMM|c6Jp5&6}^a_exVL<@ZA)1E(O6@ zk~_f& zN%}STTPv*6g`!);3l-rEvJ8WvlEqX{C;od^ewCQnC=BWjF|dmy*w(%;cm z$>?7EvmFgc6`GBXT-ZsOcA88redF)JpXD9~qV_*-G;W*{cq|V!%v$f#qtUYIGlU(P zklUpWmDiVh_GeVU0pDBg;s$v$IPawZN_5}$5_$67x1F^Xz-36lDf9HqbFrs)CO}h` z*(+0K?@SyF%0~9eG_q$Vpcx2PYyFEAZbL6>6uDIwaV}wCCB9HzZ7A>ME`8ppsm_ER z?M{tk^%_CA)L#+CO zTqZZAZ8d*{&n+m--y5&CB9fk6dTMC=yv&juYj45mex=7q`sl)Zo^f|M;zspiKW$kM zyH2vCmR=ijc<0&KsJj&c?x*rdq)NwV36!2@JA;GHR2=A_G&fmP8)iJHc+ZH9r6ecA z8Z~IQ&WwqMWR|F|%)2qJp`HQO^9&&+d`E6}>A?C)y8ne$yctaIOJ;>QfAX~P#h_n{ z$`glT6bVSX*tDR+7e}@NH0j+y)mxXjyy6!4t94 zmLfZ4-m6|^K-h+SU`K}?BZIO>s-HYVe#8OHz-Y$WpJ*BSN#u-PHaR-|&xKKcuBGO7 z?1`fxf46M`a98R{3PmOrNU8HE)pCp}l_#Z&=0xo@TCr3^|7A+p*GW{Bh|&v z?TLc{Eb@2^eef%`6SJ$fc`F;G#Q6Nu!`0{-sWy#Ty_HYt*!iB)Bh=wK8sBje|6}4q zSi20P;!JgMY)>4A&g$9sbcZtS@$)lF7K#t$#Zn$@N@w^`KjTz65dnH#43QeF?p>*S zIW=j25X%l5P{Lmvi6d&7VUqyR>if^r1_QovJXSCzBc1vhma^XC#&xN%}*v`zlrf#M5Q|9qlK+FVv?r1^bFeto7{g|Z~ zdqO*JqamDLT|5TEr;=m3RvQlVxD_#)H774CIL& zgPlgnSQ(cd-bQr>OGVNrl1_Q?zD+KwP)E8cVv&i@t+sRc?LzGTqaUoVlFKeO{>dJB zlqt>If$fUz9Hs`e5dtjgW2HYb)d%Kdq>7o30yS-gtduJRxuV0C9A#F@>X*nL{)|ot z9cy$C0%Z1+qfJkG>?h-Y)7O4daPbV|Vov=+cV2HDF9#vgRhvq2qBheLs@hh13b^$8 zNVC`h(!ePvxBTs9`fTe%hN6U%{_f#N$n;}>Lj?|xYD zk(e13&GBBpsS2a3Yo$Oq?(u$OjC?tV&+*=L(zj{Qj!x;cqZ#`G zzD>6shVhj*?A3K+gomL}5IdsTcKA)+tSrf$yW)I$HplkfxYZy1+G>CF=AGW-Q@oe0 z4Mal+B)S?ywgOtDv(=tC-WOKQ@$Rpk<87_ux8Z%udy{|oLvy^_8YFLx<} zF3jO|Pv)d!Lr%Rln4N#3N8G^iMh#D*P91}76Mp+u@fty2Cexi`oki@P*tN;2E_q9% zt4#lY;2KWY4R(Rp=j+^zf?O``}pny!H+iIbCCXXv0 zOxVY`?ANbNes*1vF5{-eY_^XNGn?&L**DE*D~6l*5#6=fc5j=1h5f;B^LemXRj^nS zJMB3Xxz;VxYo}dHk*=NAB9H78RjpD_Y=L?XuaV;Vl>IGJ%zyvF_GUK3l2dHME48Q% zI#O?k!aOypKbZ)<8)ry;$G)lSJGE;w{)J)ck~ZTtPjzj^+L_{2Vyl;AEj#9)U@6-@ zN6BA_`91Y#?u>0!Yv`T~yH66zIjjeon)#0X)Gh7Kcj40qpYLZ({`_6oT06Y_zSSN- z-dibWCEG!1AiTDCnsg=!tL^o#j#o$IGaJ*u@pojFoV0fmO!e_v*K&RCXc&Gmh=jcs za4!TsVGLq($dp?8Wm$>B2@B)OCMvw^$?7-kEo{5joBg>5Tm2oKRC-!$x@K*k=9U3D zCwA@f-8}V=OwuWTf}*CxvR8hUA3@76^9#zocZBc`55PO5t1rQj3DfP=m!?I&u8&0j zrU^FlGxdM;`~Zh&(M&r!WG{Ww3-d;BJsBO=yZc{K{&D1=lgNKw-}z$?CF);U&FCqS zb)?%uM$Zza{%5-L%X<>!e>jo<6)svN^~mIRy7MnK`Af3g`XB2n|CQbOQ^eP7bjT`M z+(L@V`^uk}ls`@9zjuV2KiF6P{SS7HHw6}(`oA*D&3{bK{El@NPzQqs#2X^kS#|Ni zXeTf_;1pyRa^Aza;a!Ekjq|_Asd8jt-nwW}+{&v7zwYG}IZ_VaxmJ8FjKpvXcvgA^ zw9Z`ufk4Fe#QMe8k*D;P!bs5=PNU;UPU*2Yli-;6)NAWWGP*ut$C{C7b)LRs5y*y> zIy-wrinV+n+0<>#47Gl8Hs3UU9A|`*%Y`=?XYAYmB=-Gw*q2h`(OmW$0kUmF;ogm|g+Lx$ne<_{nl+%Z7M~4wF>;8*mTlrl|B+hYPhm^E!k^w=U z3as#(47McKeolkT>F^%!CrS1ds6VIhk|8TaxFj)8v!~kOHGpv!_X9X{u>Vlfjy$g6 z$JS6_N4}|-0-!I=vLim5S7P|*2~RpEGd^i#Y^E+1S#AK{Gi?H5GHk=0RAgbE*f48v zwYSu31E>z~@WKlfP>~1I&<$3!AXWT=SH(-muPmXb^^?XiWTABfjx!+G zNiaI)9%$Y^b%SQofvMQfyQlpt>!8&L7uq?@hQFG{vr6@GIok;2lk!wE+hhKB*dfEr zAN_#F7TA!BnE289%&?}xfakT;eS$JMG20mOJ~oVV&7J{;?Gjq}de#%Pq+YiqnU+FK>}1E7Wj zqZ0}??vC~UQ?}%80myY6wK4YZXDuH9QmNFRQ$GgNfO{sxdxTV3V*_Q4-AkDSzy~rw z(yQF`=SW()MAD}rOPPFwdh0KH;_*pzQkIWiH-c;iznn><>B^Cu(Q+L;5psE&Yki&yL)2x-)e&OCmdR`d#r!qhpW2n*fA=6~bzHl}oFu z?$~^?(qWrBC!Et1W8Quc#ti--E0QHbj1#L{{(YBCGxFNPZ0A zkqCC4Fm!P~gqUaW<|oLki&x#<`3#g*=qOvfkW!(ndMwcl2u-@2sANoCtN)+>0r1eO z2;E25!q3QFjhVUQGRH=`8TaSavI+RfhNMgDRfHd@7Jg)kXSv}=zHRuCi-aF((ELcR zDtinfao$+*eT^!@`bUS=3$x`M=5ieU*N{{X8^&G;9v*xjkn!kk%xyFt@Hps^oKz=$ znMCz{-t?K)@$2rqO&7*n9&goMwAUwPI#qhtO_{zbys^R8T<~=;seMwW8+PhrUmKzZ4x3gn6Wnw@1ijotEoAXAGsq#q?}F~6h`0tiyJH{_*WbWKLMX2z@U!) zo}L}cX?NZZBCTB}b0K26dvgU$b~Gq2+wD^ga4y2V4N?1LY3U<+NuJ1SIa9TWpcX|s z!d4>ymP@paMtYlXPFd!B{GtXF>IH1rt`eEd^&*=s(X!d1zwzYjjt6z}W%>mQRdlZ& z3hWFwY;brpkGs8cSCb*2P28jUGlZZ7xV?*34!Uv0`!bZPx|I|K2kAp{dm3~*zMI^h zEBWqf5~%G_-$9(==V?6yN)0P$3cH(W|7^_}Yd|2TVhJ1!2A`NeO^B~FonWY1o3&O8 zR$f*oBclWtb60+yNfe}giC=K|veV?KdLL}tU)9sE>Djo+E}jMsa`ULXp);j4r>n?L zQzVxney5gFs?wDBq78Is^M2=_s{c&V*IF zN(YNZ<5dYDonpV8O;~PI&QuLVO2Fw<&zVmdw+(SnzabpdM8aG77dcT1EE?42SEWvb z`x}W1IOhe5*?pbh_D*#lSIezu3+RO3Y4GRm@JAlbeTa~?WG7+RxJJHBqJQ}PZ1>GahM zEk&S(fe^A8=maMX6q}d8OoTs9E$v^J|3<9;bmuGYw2QNbCN(Y5_z(2kG3GH3Ej>j&weTbb()AM)5(rxF4cJ0=nRsBFDxrjwM8hRgZ4s^U> z=V2_?ddrEW{cYdsTaUKm*i~`w=(Sri1HM)B(||`HZxuP_Ya?FB(Qv3{!0{G3tJRTP z1i8G0(GbADMC5^20iv>4U&2_pbi0IUdMO*1XO@2k$?LYG9+N1`4Zod6$Yj z6quqLkYhh3Ebat{oZ%QCa2a-8D0&QbO4YX=7Q*~4{B6nNtC!GrZ#b+&Yi{cw^1U{H zf#ozU**Q>MVj)liRC-xzrSqV}8o{dU;lT915kS1?+D(qJ2kf^F+W z4)0HS$m9{pf31mm!o+NlP%uRV{B8mw`;Fy~1$-|qRmd<3EGY8Pn?{3Arf-7 zoed42tMBJuea(NSl)ndy_8%2U-e0o?-iz-B_@X2nYoiE*cpm6fSH1x8Tv2@n{-$^fP6O929CJpf10`Z`LV!5dNf z1zOvtM6zBB<}?Q4n5=Ivhx0)OWy!_<5_38_2usidenx)T=;%(bNNFFKrv68C6EM*u z5gUGd;)oP=oE)2?Lk>Tg?<4ru=mgI6l(d^_(KlkYz!qjsT(_8t<@T_%CtCDEfX+K@ z_`4{~-=)rHw~?dy*&JY_MNq0nb-EjsLzJA%XG2?MQ=6+^4S2&H5bnP<|^85Bz6o}oFaSkKf&!8Hej;jKyQ`?b! zkb78AT$I3JvPrZk8(xmc6XSIjleQ}8>k!4wOR|AqWg5c*<~gZf^mcH9P3j7PJ%PE< zGMQXis~o{Bo-<~tuDpZ1!YGZEGSMMRPZvh%E*PbH71eBOkL*2W{@)Y$6#g&5sAQ4A z&%gr0|Fwf6>VAw?WCQ;eqY2;PNiDr5d`D(V=}cEM)vShWlMJO7xY%+4F&)8iI@qF3 zPzavN(L}g=;4+$ufFqi^@7$#5-_3CqXxxniG)ok~1xXRplqy(L3bKCh9QvO(OCtuGS zdm1kP1Vw?oM;OZjXR~1XY?hwr(`_Iw!zhC1W`Xf!%?|Z6Ly?8pOmVK(vJM}1W1e+m zTGV&km|qbixV&qHhW*K4-AH(CLBjLsLG;qEMW@BZxM+?V+$!5l+jn(|CbbJu7!8kM zit_a$q8gUjX{f1K0j!Im`J2?OoG~#SnWf9Ls;^3LOIr~^G5Xr_J;~dm-Zj56j&W+I z3Bb;(&3sDaydb==VA5-_f---$Y?A$h&W_kAYUuBT8`+`L*9bESS0(GSL0MR4NU0}? z)tJYYNkXH#ZkZ&M9ixq1$juUOQd?d$xo;oZ_9{E?FyXXDi7a7jo8zoYmS; z2ew#Mp?;7KcTg{2cUI&4q*w)gyDFygq@B_ zXx&*sZk7e3>D*k#EPKOkL+q&Kffw|KzfazfZ?mw0YrJ7Orv`m5-PS+otDj%7VJOa38^m8($xdO^p^GOV%n_;e_k4Qr47{nRJF?D*%cmxq| zPg3lTMpI1A3c^-b*7IS-n__=*UeyZV{9V3|TfZRtN`VzEjaTh0%aM{T>I=HwlEF?( zUX!!C_0QqP)OdZo*;k%>R{2)RKRb*NHzit{T6$(ano|JqwUhg|l z4m#G6C9tv6^i%hGn&V$JPpvii&X4xs0Ji1cC=8u(QF1X@l>6ymuIuOv`V0U zx2B?Y{(9x%fMPFMCFIud!PZr?{>uvgdpLxitrWQIBZkjAzzH>W2{Qwy5Y;NhA8#Pe zjy$zR|2!u-Y^Q(GQL-%})TvrVgh*UNYydkWg`;6DB89f!QNc)Pu3rqw3Pr+YM`je@ zd`_ME2fd_3NqR|+*~8{QuA)WdsBa3RljkUVz23s!%@DcAA|rFCd24__vM^YPo-to9 zEx12Cv3>a!%}BcY*l#iabn4rlq9A*SdQgI$Z9gX%bRKD^n}V;0-6LpN)hWZE92(C~ zJ5o30aV4Kdjq9yEi-LN0VRvp&_q!&V$LZP&3;g5fW{QGTCq55v#a=aD8d$Sb zSCg$N@tcFCFEa{c$qGxVu45u3Jbzz9@wSO7;n`*bS}KWlj*Ih zl5Rq3PC{1kMKLQVy-_Xf|A8#_YlF_hdCz2$LAJ&gRUjflp<=h_nlC7lx`Jri*_%hp z_0*ulMGCJ3v!ywhGxc-&ji0v2ES7SM6}c=^T?zYDUpSgYnbLNut|&Cd?VP9db~@!L zEs+l0i-7NoVnoz^s|=4e@RqD|LCP@v0OrD(&a@Xt#JJE z3O2qS&W6}f0>cZP9e(fhH*xTeaUMdj)*aD|qkRpL!jax-4R{$#YY2bT-ycms%I}@X zBZ529{2(uCjsip&){@Q?W_#hu?15g}S99ASdwIJMOgx#{;T`SDQ^8>Kb_8Xi#7?x9 zx5~a79afH|gt`RKCOD$4hY2bd+9Bt|P;qr2{3bU4f$$pEL$f+{KYfR!3$ao+!)?VuW2>I1=$uU1Y}W$c#+*fLDbNh+KVD|CEFFr>C<~B?mnjx!QFN*>4w)(5==DI#Cb9#kPpSG`>325xws@BwPeOPf zY{77yqY&)JwaJEHbSAS{SQZeDV9RYo0&oPjZ-X~1Y*dICj7SOXB-Be51RpTu9xNf8 zy1be|eVxG0z7K)f4*7^wEtc}4Y$*f+I>Z8jO$p|0p~qz_1h?%xeDf5IMvP@gp3_W+ zqkn8?VOe6opUNA2Rv6)u_oj4wPNg3u_M-UaL&mhd299B(W+pPRJxw2Iojyfz6 zRV^$=rZ5+}5WIydf7tIV49W~R3&+VU1&TL`TKx$e2YeDRS{_)o_lag;J~)f}3tNM^fNV3`S}o4vwKl3o^x8UJ8BJEkyQUF+$K6 z)y|3!;%V`4Ji8n|`hXoNor^uPv>BbOtEUnu3pUpBFpJxCG-R5FC2`yR5w%1ooE<)p zx$qoazR%W&eWLZ^i}Ihz`CJ z?A^hS7MhqoV6y1!4?w0+0Yed}2~gHc?r>p2y4xZ8P=0s#K=#5*?BX@i3INmI17DNo zNB{-**A1-MU43bGfpsIb%P6Tl96!FimIbl=t4PCbr*w>^#Wd0B@s75QA33!(>iDz` z(e&@+HRM;L*o@cY?X!@e)m!m(_lI9UpJ&&c2A%}Z;~l7d1MnnSwYKbB=Tgoj>lU7A zJ7=TB5YZcZTQ#;ZuP==+l#SpY^GwUikE+6*_5Xcz1sol~?ZQ zszQ(#i&U8p24z-3(-ijmxc8ny&?}a&@zpK(vx67RxMr| zf@gEup;T%XvV*F<-pV&*8*@VQ?C6XXbxxh$&_$+~;`O&jO5r6Ij%(}3=IguK<1~7w zhj^rTTd;U-)!K?Pg6vehi-V|eS~N>5baa0qj6xvec)Rrm(mvv}R{nO32eAuB`mUa- z>zHXOI4!{b7_EREY2^^AE10uP6Sk!Cow|I`xnUfHG1YsgCalhL@)L{}LVmRPGMny0 z4?`)c6&a)8!Boc8)o0UQzI2!dGxIkkI;`94qrbLk!F1d2TT@YjjwcM`Q6M9 zx~vQ0_gfu*jozN>&x>1N(t9J|A6n$$=xjrpK_aVlCojXq@#Gjv@v}r;JJPI*FVHb2 z029eb(=SATWcxeRrTQv?O5UHG}6lgV1NB76utnkv#Y;!)2hzwfgC?mCQ`)At*4z^l<$%(!&y;4E7$mFXf&JbOvx5 z9vxOTo6T2{pG{v~spSGCxh^Bh(bl+?U;#~0Pcfy*gmVMUiwgPq@`Vj{zL%+bp+ zM=!@5y`qmiM4rTn`D(Fe`R{-Kg}{Fy@b7~_FZ&T2;jBli5JUwxlhvtq-=q(QMg^C@ zD_ZmzngGAEOKV=*)k>|l@>Z3xiAHB|_-|3aaHFmzO2YlP4TI$I48$a7aM40U{YxPp z(HW)D5LT>(bJPitGvqGeRWn63rA24qMd)vk<|395qv{T>kfN5jH}^jJ#Gzfb{UY@; zassivDYdL;4fo!{=~#_GmTw|t5T?nD#d6+ZqrdBGj4R1rL+r~tI6rrYon}Xe z?W-@j$tW4Bc9oo_ZsVG_um?4i3OTU7DE+j>K#|+rqa?Qa{pqcgD;hn;m|y+s`_i+L@qPEo!8D81qXBE|DaQP}M0J9wPawXFJ;n90 z@Ut43w0ycrJ^V|9epR;%R)LVt>b5gKj>lJ)(hXTwce1QlW%WZvjk^>zZj)N`$4@M+ ze;;-E&uwT>y2ecw|07}LFu~P|ZC8)=Gu0Q!=2u=@7Uvvb2NBEuC3c#9z*tD%;k(j}9 z0}2jX^To8^td4R;Q*ukniiS9F!;u$b4!6Hzx||b43SNDihQ%wIp_M^?iOkpvzoKP$ zx>EOiD?;&l=1nr`TUGHooY9o8bk)_ZoQ1in(d6v6;@BJh++;kG+PcmsT1(4In(8%V zuFU8N%xrc`bftf9sp!=G1syyaijx@-6x)2sC160gOedrTO{WFeW-9Y5(b~ zD=!+$mTB=i#`;>Yc#Y14&dzjILwuT3xk})t9yOUy=vLWTdG&0ryo}r)orT8kBj_-2 zLeH{ZJx~hdH3HS&0DglBLbnBH+l~~vw~!iz?mf;gbg$(wp?h~i_g1Us-wWMq`vCW& z|M^ttU)Mup4LBbW zI`#yeVqYGk{(KAQTHkglCv%sZb7|JVLKG|qCt8KhJKaR9M4qcfT+h{kR#yYvtS^sA zk7OYT(So7XR15Z{ViLY?WRQc+$X+SZWcf~Vx;4RoUh;~~z;t!$DMOhnzaqdh3YUxxV*i4SdHM?(tAdQJ zQUiah$=I=E`_~bP|KOVS)dq}%xYCt;GMx*=)Qm5&^zwFa;HoAVblNzP6u)O!1ZQ*W zpLr4-$&_$n?T?caY#hRoZ*JESQ;sj_F<%RRoVMtC!NH}YSmInWu#027p_C^3KYF74 zYHlBJ%e`L0nA$`OJo%gB)zK;Oj!iXhA79gUd=4f{c4x=S;n!2`j+gD6jlsd|e0AlB zjuE1!eLM}b9quJya8&+8+aFm#IGmw>A6deYrC6;z-lnLHijWe0mr1;$Z% zP^?v7<(;W?u5Sd>P=eu%ihUy(&DE0OP#Tvy(%VAOfXB|MDRg)tD8T#QYoG)zSez|H>#kf#$ zP5xTc#5i@X4@R?u)pW(_UHodmIWp*^ud}(f6G;up0|kxcXqa(_Y9Phz|4pvC9iHpj z`e`EeFBWofu+Uet@T`z?bf|d4R41zynth_Y+=hJJB4v2DiVZYsH3IfpcF)?n{OZpj zV9sA>7Ld~Gx~5}GuQG>9KX~c&>p|v@txio>tp&zh_fi9U ztT~U$+>x+RA*^!2M-QL+AaB!LO$zq+$D$5npIZxL2OfhsT+nbUqI6g+~ znhRtmOHWyOD?h?N=khCZg9F3ac%p1P?P@7UFZk!T*^*%S?8pPT4@v*#BB|q?hT!WO zzMM{^W?2x{%xDp&FiooCeq2KQmNGux$$TvLK8nDMc&FD11yPq@XBe=X%&+v+R$uN8 z%vLiJfy&&)^oc8JY9dj>rOGwjgeWbC7Gy!sg{YSd)C4Vdsw#!$(BGQnnL!>*b2TH= zpnmQOlzhZ$yV$6W?M+#lYg2oeu&AvV{EVFSb@*_MYrWGO z0PHwtt2P=~5|{JnqEnW{-Gk_#q;PCRI6l3?V`-~^*r0Hnf4^p9&{#cXB6gCL5#X0c@QR*t3EoqoxLMtWoJpE} zMCXLmV|b&t*W^>ORQZ#+dX8RMEbU4Ov1xW|)cj_dSi@jTnXS%p>zpZd8h-P+SbwpB zA7gfI4IMAg5mD^eV(CFyw&=5@IPz1h1b|3zk?GnXs=*XV@{Z<+2s$7$HW2yx4_t_} z?PYfK$(H>oBxbT0uEK%-%2ZKLD&|$Gx8By>%4%8>DBroj~7Bl>3^S3c)w#=Ec-+ABfypOuBmTN!) zKz7#+GN9S?TRFcpy*XL$P}k=cS@=)!-W8`Pipj$0wedW}QqNFzij}urmK-y$&vLQB zuDvoyF^=_mnyjALvTcviZ-FrPx*_*aaF0IXcq?T_5J1eAjPqO(R-qn2$t*vHw``~_ z{&1#4#_pTOZ!QB`7-Yg%rOxydV#noY2C@Fsq5$=QD2u2OUsJ?fD&M0Y*FR-H^e;OwQ&`=$mfzFyC1 zBX##y>o$d6-OVWEd&-8?r%yr$~& zOoRH;cMS=i2-y+-NGP-%E)98?Q?%Y}I7OcYf#DP_yOP$>ibZo>{pO z{}&+O@_z~cgu)qQyMBVR1+h~vVb(DNb#`hm!RBWDdw70U4}95R&hO2y=)QizXTri* z97*d9rcrHpkZ4&OBH7eu>=yNGA}&rV{)qS)5oM1*a$BZqUnUs60P|%Kal5{a2gTT{ zE`zR8O_eb8A}L=f`@6nh_j&bOEnX-&PD^sN_US~uqP>+r(YYlmHbOVj67#A>x~N35 z+H&+QL_TgcolrNK4zT&U9f*xmyV*U+a;o%wj)6JAu%> zcvKI4C!JV5hEWyWYeaP4T3-l>J`3Bo;>CE+}Gg#vr*VXyk)KK6jaLhHoa(U}0 z^C@CIHx+hKrMa<;VVv4KOPAvImWr8AuubjYQ{$`XcjRXDLo`Z6X@8Zjw{|Ue#t8v4 zz6+GrhJwODi2c1t0b5iY)u(t5ku#(44NVWYRm#OJmVodlD~Lm-C$;>NSSxa4=J*@2 zyO;OYNuGR#;8Yn`qu6%T@8GDc<-&97Fe^*d0an=vqTH6(dgNyHy$1~*Uanhdehzeu zt8C3tdaGn1EHkwg_pFT;RjHmNku5UC1>)91NC9k@+Jn$tI{C343!Vo84)^yPgU()B zN281iSUvA@abh9Wy#xzgjYgf0eTz|HSJd&Um>)Tsk@HqwFDqu(0+t$LGA+zc)Bq4# z)YT=X?K_FZ6@Uo)`4Z^8X!zYgcoEDqo2|}%#O0EG!7#Trp(yI2A8F8wb84EwAW$7`bb7;!btX4`o8Bz$jrKBCj@wYE;vh zbiurot0l(+S~7qL1Z|1#N(S+$24rf3YbUcgz$PVgt> zZXifiE$IUB6Bi34ctM8u) z?%KXaQ?;H}HTMTyuzsX3w*QhdHW!QV%K~ki`KW>Ksoe7vGBX_#Ch$Iru#hLc9|Gil z5##G#7EZoKZEw{UPM%z6mSWj%)M}zcuxHF2^g1t6%}^VmI&YX?p`TmLr<}RnRN3R} z5*a$x^2Fy3^&96Rvu z9O9bPO+VD|tGrs*)$}J#C2v@0BFiD20ACxkJ;{|=&#AAEs~_YW}Q{qYo@)&%XLB1p58w0a|d~=Zcj&@?zUH~+Y=B* zdbVfoRH~@ko{xNbGip*RE|&VsPSAu?GdAPg`iGOL3$(H0wPs4UHp0#Dp^#JACG@@0 zrwtERxt&-UaTuy8V|$YGP1~t%>L?RtBO0UPECRaB_WU7VzWUP9?zYQS@^6|t zoTQf4s}tEE+CI=krb*S9($TV1^|UVTt$Ic$A&ho8()@3jS_*Ze+KEElMH023yQR@? zODFVbX*q(1Ht{jQ&ZmdDUOl2^58ld7P)7?g6Rx(ynX^L8X zC(6x&l|VH`t>9aL^LtZ^mVvBS0qTw&C+o>bq!D2lM*=#%MOk`h^i~>7K0xh=-2=`$ z>Zcm^;)SZU?Jnxq4NAZI8Rl8MR0!=jbqNj3(oPa2^^HndhCw~3vZ;PB1u>tc zt@2Kgh!_V+y%ziH!NKbyUphiPbu;-837a)&49e89TgeEdngD9Hp+SuRm4w`_<$xu}ba;J?bzG|4!w}JJB0nB` zHqjgw9rdx_!ldbr3L>#lb=fYq{qX})Hg<0e15D3)SyXhY636`$j(QbHcbeO0Y} zbB4#OLyrIO&QQMpuujZuLlJ&`lRf3HqGLgklFu!?U^Kl63{SQ{)#VH5%7}P%zzKS| zSQpIf)J4kw9GOl+i~2KUPxnL%sghW&e*$Pf0iU8t8a`SVvJdKjDEt=anKKa2`X$HY zZxit;yu-bEIiKM8A52H=Di2(}>Gn>zzGQBYFbk(U=HQ*y=hw@Y7Xc-;dZOEv{s&%m zbvK9u@yw_neZUc@wux=P1F+=&KCCYkW<f@^M-#TDVSk(n8KqQ2~m?zd-v}POG(? zTRkM)q#aYoqMn}U1jFfpcs2g% zA9Duxst?j`=dBL#&Wfqb9@~$K4)0b?23lVaj=cyg!;ii9oC`b3-A2JU8#j91s)rea z9X(yHFr_T}MBQlicsXrDp!IErU2@0D0&?YTr20Y5I=M~6&YTv2FNvg|YJdYgvdOA(nJgsae!E@5KWzt^iw&0%Yqptlgh)uxc>Z?~hvp5Ko2b6%=l+$@fiRjw^V5Togm%e4rCJ*Y`Nf&$FhLR^k- z&7$K)BLhasHEu0mbjawwiw)-0n zcN%AmEThPvj>e=gKHvU6{OJuajm1VvYy9+KxS$tGgHHnrIT02LNHaoCXV7^YFMZ#h zuIK((?PV4A`l?*nr%Zpu3td|sa53h3oIb?*bEc8CkI)d#l@?0xGg2 zusEK~T`U1uVfLbV>f?ZnzF8ePMSeBx7XVvbM}t!I4#;seKeKd&JE>)*H z+M>4VUlDVcERX>#ydVGa`Dh%y0M4saG+3`HS?N!fMEK%L#C0Br)^pI;zTC@X*UCu!h6D4pvQKG`6W8aUpC z55Qqh6wMb;WJ2VBrn}RqR_I?%-A>d*ut^Own96xqM9g}qE$YS!@C%l@QN23Z3`G3y zLdH z)ct5w9p+c)jji794vcYo+9)ZrMHbj3iCUfqD<~p~Gm)L)uVw&Sm+-M=>LpiFzYih= z3+uh7T@xxltJ`>_ETx-LvXgj0iwKmBaFxLJegz+JdX?bRxixZ?o}pW<|Qsegg2&KVdO@SdQX&p z(v=%1B>HsQ#g+!8LS_(~)FK?E zRSXUQVva&hx>Zy&g^{@^;70{TF+XRSxKew8x#>QC`3m;dr+TY?t|x3j&_Q66k&Aa@ z3xiDD8}qoKm~;m)A}!u%=ht$-{;NS}TPQl&6Uuwdu3sbWasqkiVm=anZmN@!HYJ)h zfPLqDu}rl$?zRWF*c;VUr#!85Y9yG}F7-3&K_2Q1m)a}mRQ9FB6fQq{B~;T z)A@U)>_!}EPuM>-l72u;OR7RN76f8NLDnM=2Z@;{!2%72@W%y;M*!+o9538;OvL5*7-YJ9_WKM|jO*t-^tX*c+|lGUvX=z& zP!iPPS%-nv_v2Ul`pcZ--EE$SOlikKkIkQ(g8kavSo}S=2Yma=*W!6*p)UmJqZ#`{ z&SrT-F|U;xgL!b2FY-<{CYAn_lqpVnsy#N9pTnk%O+TEsy*;)}&s%yHx?vB1y;Tn) z8Uq@Jr|0k@7ng+8WbEe|tkRMCyamzqG7)v1cDw=q+#D3%QG?4x29=oA?n?1Y7Cn7n zn@zQP0jZIs2f`_*)!OvduMVZRp{H$nX56RDdi0(@BvsEspyhaP~vv<=%f}j11Isx^Gwfqg! z_{}5<8}KSc|Ky?3I4y>2 zc|O{_)ZpCaNqY}5^f_k5;ew9lt;nq&zf*?`%}c`v`rUFN4=jkQD9BSZy#+~((6cr3 zzu>GniOfRHa1nkWUhOpJvAIJ}$%4+#L_n9w4%KvyBU* z5sCduoUKNVX~4dl=<+?zbdOWV&NhZ8ESCyT*L`E)F|C)h^b1ACQE>DI6^dTFL< zy|@+}PIH2%;Y*QAjlP;v%y%(^i4DRd(rF?L5|fzAJkI-7o4iZKPpfuKf`q})NAda> zP50tkDH{)ZzS`3MC?l{GAkKEOu;LG5XNd&`%ukcr{nq zdQQwca0cY@)p+kdTm(CEh8H`#C#WuQk3hJgKcU)|O?0*v6bNxG{Jii-E*}gVU=Tsj z<@=mL^}Sx|kh5Jk9bG1D+LBd*z1A$b)!UO8{2vS*ihh0R482CReVQCIHJAPoO7oVx zg@jxHn+pB<+=d?X>t5lQ4gH$leI6giDNfU`qd1Qbd0IF#>uWC6x)aFC{x+3* zp<0_;x~bM1>g%j=Yx#wy$!ACss&xvd#(yEz5`rTRI^^(eq|Vb%*AT%WzNY9~kc0yr zf8;T(eJUJ3x|GY*>KX^xMQ{d2brrqEX@@ zENfBs-A2n&dOL|un#mb&;4+{lS(a~=_wEBibL5s0>p-8`H@mvUU-wRU&Q16Z2_j@M z*6(YJLGp{ZKpx8;h^vSCIQ!8 zyT@4HM!6~t$bCbTpruC5YNMMVhYHP_b08JMA|~@<+;G$7rl6p6w-{sX*Cn>3B;=aJ zx2Qjwm@ga10F=d$A#=2BiLYUAl5h1V*a6B74IemD1HpUuUbOjoI3{Jx(IxZ#jez=@a|EcMo%YEh5^!#@Xq-&KQ+Ybmb&3L(cR@rN+%ivh<#)yh zyTp88WHS%Chw*m!9r}7=SC39JJ<3Sx(XO_yX;O{tGu;+Z&QN*?CKe*fDRlnQb`i<{ z2Wjcw)8#|a-<_ub8P`6z2mPJ?O(BNjtrhzF+jTwYZ&to6D9t}6(ckZ3r}!!KH}xi$ z{t7Ax9*q<#6CH9X^(MH+*Hw%aZY@8~H0dKrh{!K4mA2S*|20xjfzL?IWr&rk2-n02 zK&?C31q;F>S4<2YMtC&m3(}n?Rjz-HEJf_Pa1{y1m$pgGFsO#Hw8T#HAZO#rDOUMi zuZR_u+&fIkWJSRbq1a-4AGF!hqK-6N*nz;!Aa{6JrLD>Nb7P&!ckl;)=Nbe;$ z+YM%$;9NIYV1o19;9?UjbAxojmb+gmM4LV_1c}i!!i`DSF+!RWc|1DizN8p@l~C%T zq?l|S^Jr2`fsT11DQ2;bk!BJ#r|`B?Wo~tH3{F!ka~qOlfOuu@hU6H$A6MpXNsi${ zb7k(1>i=HJwo$(gvyda?o1~JVduCLogAU( zIXOa4vx(p@P@&XA0!9rK3F%2rE~4i+IYLiyazwRMkQ|{WI5|SkZ*qj5-sFfKz3b4E zo18=D*3M~4G?b=4Hu85$3or|P2++8Py(tCvZ z^$4Z9A@VylRJ+&(9iIhUm+r15yGMv6m6XPkOA4_hlR_-nq!3HGJG2-8bcYIhgtB{t zGJAwPJwif&x@!^Y)EyEc))k_;?ofC~L03w6M|RS80+N(-YOt4S5AX1}=#;i8ydyp7 zyC2_jl^!vuw6ZY}R;hu=C8?R-ND;ngpy(n!klTH73=}#o$3UUv(R6OSG)5+Q)bEkU zX%-c6V~d(_g0L*zD$yqOz0b)e@<46@rI7C}StXQN1ZV$;4bA@^=H&kaWe&w(Eji9* zBw((DztU>d*E!$r)*`aiqQQs`n^psVH5Ywzlh!vsd8*)t@K@f-AA#Y15xQf@(xS#< zM5%eKYkjL1eJOw2_~T0tH%<;ioKy9M+t~G@?0a5Lk^H`(f3>E6w1ZJ+Lwuc@05=8? zJraeUR)lKv27NtuKe@$Ls9C+oDF>}}gw};$TPv!P=#ZaRck^pIl~0a;%k{P6=rD&Q z;nzBX((u2GUpo}a$b7IBU!Obj(r#7hUysp~ASY_H*`g-pL2`?ebOtBsEI`UZIwqD6 zB3><0-(c*b&bm+kYBu*&OI{)p;Kgq+z|&e}x$7i0!<<|JIoWw;ih@s_CKXVX%o}u7~>)LjmW=3R*!sR^%148Z7@}`o3T=HK8#q{$~x)qLbo%k9; zf#NzIqO)pVNBYOjY%BbBKU;H$%8X9CXHkAAurysyGZjUPBf`p1Vz z|LY;r_a7qtqeGSYJ2_i(tzPhcoao03w7O6$$^SOYKh#S9-$~c>Uf*v_ zzSUU35>ekDsi_QFgkUkomOG;}Yvi8om!PRo+p)YKbUYL$I&8r+!&B7891)a=%AhnO zIyKuLom9{^8!liCYb|nooA~z<7wV#k|K=AZz%BUwt#2WKik^PH`VFUcSPB8Pw-6(N z2lQp2(D<`*y_Ftv7mhE<^_UxfBC-l2h~phN+WvUu@DywL1iBj#;UULPPI6<^q~zY% z6aCQwt8J5Ha?*37Gvdz3oR{*N@ih~pS?8%IknnmRuiv;oZ3|C2kEIW>D%*$H=+=QNzCp)gT*Y_qBSppp zoHenTD{hwPQ7f*MpCeb4%TLCNbL6N0iadU-<$nH(K7ozw!xipEwsI+v>gF%YP9~cj zFxpC*$U4tJ7%x`259BVE1a7}3={EmUB=Z^{FEa+e!-i`F6vxUqCK*4`Q!zyPF9y+J zxdzAkKJW_oe^Nyk4TGc~X=8Dk87!UzOWUI3&(!f0&;}Kz)WO%8V`cOJg=2Yogl#9a zXSmq%_^gbDrv=?76cD${zLU^TVJ;zN4;ZtH9XdJ7cl9XWyEo>&+M|c*k%t3W9NrK2 zM=THjGU_eP7<%SLw;hi}wRc$wYxoHllgeNSxdMguScreJf(DZ2G3s%wQNqbna{Ud*MI*&H*kZ2A$W{11E~qx=H=s z{K9XlU+|gGWA25Fxc7-R%+lkxy!X~?mUJ6QTHf8Po-#?)+LbB-CRsjT6Um~!cI1uD zNK+S!2vD^>NUEwS6!KM?DcPRV-`>hf5;Q04yrzDCmQFts{~kO$$)&MY^`MR_X-8kG zCBe<=7o#MT+p=)VergCtPdfMM;VD9rvd7=%Db28*cf)@fXgQ}p7R%CaFOE(G`)f&< zHU8GjlEG+VGsoYSS<DIMC~Ay*YKymyEG zKym5GQ8eNa#paqA_ErOU--<_IEbrr|S7IhF&5i&VtifhpXo`)}z4XYZmuN~g$0#z8 zhcZ7=&51S6s~yCmfvL#VHZBhzC5mg$Z(%eS?liFy$Ia$ak9>Sl&GrQR5gIAqBc$m zj;1zd@kA)cdw(#LV{>A!_iO7Jb!9-mnpoxAjw`xLPMj{z^3Ci!i8fJ1+#A(<2Oo)=l zsmDMk(I6U*2K_tMo(4x|T~mj<#mvR$r@%CbH=wF*i{68S7+F6VnUByIRC&p{<$zM4 zT{-%r^KrBJt`&ZBV9l45mUs!fY_MK(__md+Dzx`8~$*ufcAEx_LwOG=M=b39_~g;B6Vx zo=G2U(wWr-gS1CejnX>nwj&Z*4L*0Bm$fJ$ad&9l5Z!>qd^&GqVyp@{_J*qmVwyHE zt+u8!jd$vO?=KvvE00}k%=@Af@+uB@*5q%>6B>!uL#e>96db3V*Ddd65~7RZn1t4a zcLMVT1EP}#a-g8bXokL)Q{uvu>?;mOc5l;ZFh)Y$+kw!{QGSW3vRWI6YvEIR1kL9v7 zeXZ2KhsjX9l}i|*JI+0WdyUh#cT!fr(l2z4))&|xCkhLkVFiziNM~@#5UwT&j4o!0 z#Rgl;#fnFHeS0KdDyrC#ghlL;LQ!k^!xD-POVfoL)mvg_a44vC_Z|2N^vi@b&2X$t z-$Y0h8`$+4)9Tzqk0n%0-yiooThZ#ij2oVfzKNbi6UxuV$tZ8Poo+k*LO{BBV=xbU zstgwMVmc-#8f-~ZZ#(i9e8;wFTg(M9JV{zk9sh7FNxSU^yxpBT$XCH-f#Q83c{TB6 zd1E<=MrWchAQ%>wG(B57$1eeA{Ekn{%4S^6eC5T4Qa`9NT;2 zn^ykCnK_&1c&~rM8hbI(BsoP{mNPZW&fge*J>AaP?9}GhSdmFtBxXUkoLb(g^DY&T zhvV4Oim))?jN;%J;vG|MNY)sLW~{Z1nQYDcg#q8oVpdi=e}2HXO}^^pe=X!&CtvI4 z&kFe(<*RZ245z-A(OF9#cTNjZFow+_#yWQE9PhSQ_~wP} z-4d=L$0GkAv7x8zFHvgQ2zsh-0?1J^VUx?$fCD1~ddp&gy&Et&{p3 zb^-K3pOqR_%>~i~$0E%T5&5^kqOZI{Os;Wh--iRJ9RJc5CxNYx_A5PENPTqJ<-Z!9 z@~Eea%H6;$c2T)g&vvQYFl)IyNt?E61r79=pC;WIuHnTf)UTQpU~6<})_2`S8qT z*PC`+8TXI3JQWQ6us|`|<@Ajfq_$PzZMwU3@7C6@=f=5}>HS`!`t`J*QUQOLiu>7; zJ~&3{U+FOERd`%#X-0Sf-kti$*40qo{OEAuM-LZTJm3&DNdG+Ed%ODAi(l>6Ur)Jq zef_f7_u`X>s*h9qIjm`hRmA(Hy%<(I?R|LGVM6Z~SHv~r+N>GZJ(_X7z-3&q%>Rsx z>rHx*)WT?_F~K=?hJrhC-j^SvViy3@C)@iJdHt zpg~ySfC5gcjLJoRxg6cGA#g%UA&`#o{@d)zA$;JA8X5tM;N!n5Q_ex>hgR zCG`q^{T0tY$+`L%Y(cDrWx`SO!f zAIyihmPD-Dw~&Za_dreTeEG?#>q_LdQ-e<28h~fv3R575P1!A(&Mu}Cw*+bwiCY3W z)g+3+~M8;&6@*o z2@w8sX4)DHb$nVqm#5fJ7?hP(XQfq#ccN{oNvrvX*?nHRuK@+|c~AGV1I`AwTk=!2 z)~U6;->VJg*93EFF(B}#Z3yLKkqrO#Hf#q2+t_1ZPEB}6ChQ9NOeVVAXC_Z_ru5#K z#KL7GqfTU63+RAjO@OO%$-zv7Ct+(246uvU_?tb8{LzY3-vXE48;D)$0B%A7 zpOVRT@l>4bza&>Jj=_33iT*fg!AN@7eG5dtVp+nya;TIMvO9xp@2}hJ$e>t1BR?J^ zXIu`tqC4Z@^pEjChh3P}gNI;C9V&ZxiHD=4P@6jUNa^OXRvu5JQ&Skd;&qNuM_JA~ zpgIksZ64B!^e-(vF(oo2e*cLn07?>x_igk}TPKOLb>iD3LSD;`HmNZF7Ud;L{N_-9WJ<>1 z1OCWFilCGT?;Hw)Hn<_e8IUe{BDeEPJD(XqW_|D9uOWl$8g1$-1U_8tQDtI9&~vxZ z{FqanH_^lUp3Y(NfC+BEI7%|3qwHwz_)mR6H&#v; zd^$z4qy5+ZWO(-ke=1-u%7S$czkWc5zrn&LYhAxcFI<+HK`>(h$S$Q*g8#czJ7-b- z%)l)%YI#rOSY=meIaa=0vNV#c@cww|na&o=nVcbNn&O6FF^^Cfw=J@%Asjzt+Rhoe=6B{@khOlBj}f^XTV z^{0XHWu2}#5s)rwNBugQJY@FzTTo7pk;_imY9j{z$>KKO7H{+lIDR9{0~5MCLdQLU zwD;(H5NFE>PiNcFuf^r=$J}5s?>UrQl&oJ1V8;SGiMpV``!q^Fl@>swl2^ZY=RnzM z0`a=NTdH@aO>;781FdcT6B~p>%vGw2hYZ2DztfroP6*Bi{8a^{MUGmq>g)Ob;xx}#`VB%y%c2~W*V$d z4r>lhma0jQUT~m$@iYeWcrPa_O%~5@yBCixF0@}N{0BiK%ncjTL_{s{gEgH&-zBx3 zB}033AM2fhq+TU2X~fA%OS+G~*si1!JuPNKwaWtG1tK30huF_qR zkxzfUL(`)rA90LbRDsD7_%pgLI`RCjtIueD7XiW=odBCAQttb>vD00X`Ev20TYsBr zsx;zjIL0%s1S2B3{%O>WYm=B>f=ldx?FUM8xIW*A@6XAVn2I3&`FZc*$L`CX#er|$15ifsTCT|$aVlrMH9KXM1! zPJQKubAdFfr7FDv_78>&Ts8SGKXf^!eM>&lpgoz9d+DXHhyvs&kK5a=fO?`j5-v$k zF~c`dAFbgfSq1u(eDOXFGcKC%QE#AvOJui;xnMu+kEF_!$Yd8J%}YP@v|aP^Y`VLgS%8l*sg;=!A9qkljqIszhv2oDerkRheY`G9-hkP6X+%*roH{| z>-I!e*|n&2rG*HwdQ_^{lPm9BZA#%@hNYy&As6e#KFNz3MQkP7PM}f1I)+-aXSFqf z`aP=c3MAKmt*IYl6?Fy{liIG8$8THIEhric@J)ROA&OF;YZ}Q5=Lz39gH6qNE@J1G zUd}@Tq%E@d6*{}s=ja|AZsk^RqY$6%B_l-z{xq73#DzeSg!&_8nY{WfTWXO=^Jj)o zk0Tet=}@y8bCKXt3!Y6*<||K?-RO&nw^O;p=$-Z6cYR8~^Jwr7?QIt1zT zXjC(=$qU~6h^Yl{;AaIr7zLhGUWS6W+?RoZtilJV!HL{l&VTUEYkrzd;Q zbTq2jnA-zp1SFrt)Mv$VNP1=tW4dR3w`^kF@phjdwdDOr?e3u4G^e>olmkQ6eG{l# z@a$0l1L9nVJWklj4?u73kYRB1HeY%5WncHzIs3Iw+h*^yPIwP+r(VrM+zu|>n719= znSn_-fiIyyHOg=23_YkLFy1yL)t)S!1@pW`_q#_+z4hoT?#59O1h_Zi%>QmT;#`ML z4^d?O!2ocGv|jKrM$4Dzm8sF==G`vk12wUcWO{f40-J1eyUru$aAxc{{j06k&)1`C z2>AAvq8_SKe_*qVVN@j!T+~3MxIpD-9b|IxE52S8;Q}xDzD1)tNe+fgvi92~FLJPk zY(d2$K}BuH^0#~9>wyp5{Kv9)T~ugi34~x^K+Slr^vEoM0<%Z!y*bHw7VCgluqHEw zx}!;%4i50-^)M{aw5HduBYXU;^!ivRM&Iou*ZeVe`CK`FZqPB=gn z8FqMcN&wqmbV_**RDKQ=4gc6X?-i1|$&3JpD2Q13+dcL= zhT=6Q6k<{41;IEZSm!@NK#tP!mpYcvDPS1p^zC*_!j<6&;;n-PnOY_u%R3T6wQVsg zFr*feSZ>MXdlff2M9(zHTgXkVga>8;-?^R8mqk>1cJcMn(B&}BL$ zof%T*?LwH9h;V+qr}7n;_M;>qHiGj0&%_bTXOC+Y!;J6L#RrdTMfxAFTE`B1JG*j| zP)3u=B1;6mXsaj@*)rw6QC=UcHnE*uV=nj zoK6kaZ}t>Lh)hSeeHdg~g%nBM#h~vhPu(?HNBSY-#GXvgs+4WpS!67>DWspi%cKg; zYPr+_knO!m7An4(Qj5RKKN&vtCG2r^LW_v&;=8%^h%#!}&o)l7zjuewU47^wNc#c% zp~>pO3RqxO`EcP}sXa*eTo{-!`YXJ7xH{B@ZgE)EP$2G)DZqP9VjnuJIW?d#f*?+5J71vm&O#C2}0g!z;&iFLG!k%xVDsvY3Lg;xzrD9eXVA? z(tu=r73F(X?NIuWYei4RxC7TZscvTf>F=Z$m)bt&njYFjxcUP6C79F8g=COJH;~wt z3i2F;K9g^{X0MzadNo}S91MR=OD!iRIP>UZY23dw*W^h7nT*%@fTN;nOMIBGL6t~` z*g^_i_ivRME+fh5%Ujo87)iU1r$!mbm;0qC5zE|gK6zyv*(dL z_^w>cMm+X$ZVUf3#ZzO-!NrU_o|-+$glP#EM|mfP#YqIWV8Rk*%(XTt(JH}+()Ax{ zqsgMOiV*|A;ra2Bm9skB*BM6|?WTr^!i$`lJ7TF3`O3%2sVqw_-qNzx0v5h*1`8`f zG#(5}ognic^F{HA%u!(Q2N)D!M5{Pp{wu7VT(u=ZMA$o)%`quqfxJ_N6I?4&wE0`Z z^Pgfy+Hghdd~n6+spgkL?^;|@2(EaU%8HROm5raPhwT0K+x`QN<-yAI95Dwdo##i!Xj6*x-iD7woi z?0Hbyfwhw3Rq)SWZ=~B{=?|K3i>V5jJ#+la^dd9cke%vjy%Pt#U+a9YHLLVjkWdn& zqv70l%gMrfzQ!AZ`foB@Q>45qY`k)hSgH%psN7?4&VS6~E_41Ue0*hF z&|?Xv0=XvgW3$A%%|N={$agcds*FpAX=r;q{Csq`twRUe_|OV{+i?{BQ82D2^jG5_ z7r;6rel)Kmp$g`MvEokBWJWW$z#vaADLBG8XFAo9fj-iCkWjH~l|*5#jd`Y$0aw;< z;!8RkjyG+9GDfe^bL?D>FMgB2fGL=#s%37*<%IkAWZ+=;HGaPf6!+pI^c?#@v1%(4niT8J=Zd5oZ^P^}|a_#xb&Y+VPZIN_ULnG&A%D#DrYz2~v;Rqy47?>J}&Ew?zlflvs>R1>LQ3;NY;8w9?m*y4-44~HhE%Qnz{lD@WVw?p9LbgdI1Bysbp!abhWYqOhPLV=mD zIxHDy&e(%Ed@QmT53w@FrhE7sav4;{YuD1n{> zKu=d5+M{@u5y>-A(t1aaL`i+PN~k=dd3FM($PR$)PgBA#osm5Uzf*r?KBG)jG{z&i zYAp{>e3Nx6-2vSmSD=|QbqDMT(De0<{Hcsw zW$nIRT1&=MdBaA#MN_CeC!qycXsJkXNt?=R$M(Knx=kCEQhYf0sH9EZmMN(Pwv`EZ zxPXUS!K$yAb)H1d^ZV4tBWZYfI5nIQ1%veOb)nn8i`*UK=HZ2$xUkhteXX@D@^0me z4!3UwwlJJ0&{YR*{Wj$H&xa4pfS|>Wq(}#)$nIwY1bf*4)rN?az+MYf^ z*-q-}+-BWnV{`qSlR{&1OIFUtTCwHpa)?fYfDm%2-1L)u1T}G<{;I6^BGsR)bW09c zS#KlS5S8^J3o<7{P4Tr8G~S_JAo=)SZj!Tsb@2Z{+|gi4goP9Vfoe*w6j86-xAM2s zfmr*Rhqma{9&BH;Tf8Vc+t&o=waSYNKnG^Bt*TDj?+{>aGt3U5gFCodVX4r4cU|{V zJURVdgCG|xkMSL>2s^7&ytVrS0+=;d?aP@ZdfD-J zSt|Bi2Je@bS{?C)=4}`d;U=wbY0=e7!wVg3TZuFuUO2e!joiFe61rp_Li*s3%qdxt zJa6$dni9fToc;O2J9mmk<6lvQwVDTLR#xBb)V9(NF2qGovrFW)1hj@HdQqGX%S9A) z;rw(ZuJgEOSl^76NHn~#GRO7HFn?X@)V(FPe}58h3y9@2r6G>B29pE07T1a^V7bzZ z`r!^kl55gqXad3Os-o)gIDitLR%x|TCfPg|v{Z3tuEeRWmw~VF3)#t*7b1qiP zm7spkm)z7Z3g=Rj5p(3rS#0{fx+7gy@|R@KvXVW?uG~p24%#rhBwMB@T)k8%v9}{{ zRSv95=T@HMPso=NyZY5N(;ko%VSmt%dwx-bB(y}Ane61YE>HP3LLd;bi z;GuvALS9t0b_st&%&h0XIUQ9Yw)ohvjMy?d0fFg1S? z7D_NxxOxChlZg1fCGesG&4{r!Vb$srEbV={tmx>pEvoJ%4^L#0@c!sAGW8<%siV(m&;6# z;45ilN9|u}IVX6gDQ}ax$2OL9=p5=ux>4pqg!L(;lLh9I7j_^p5b~&4{g@IZyOOL9 z0nZxE0zrGA>h3lkRhMF{y@M%yNSHJ}GnWnmbz)4{c3;31h%L;&ApgjyVmsx1S{veT z+)jYsGxC67=iRkIcb+2`(V({iTWlwg0nRAqd9)AWS~B}qiA(NQt;kP!V_RK+Osf{y zWS)O!hb=HJ;e~CQfa&r~@`aQ~5i&+1$-~8&Ut}`4>vwEE8L&T^@vmg?jRL+=pw2Ac z3yzRa&)Ar`J$U?`@y2cIbsj_(bRQ6aFtMznxy;)J#V0ldMGtaNM3z8hOF;WpFfi7U zQLjlE<0-J834h>z>Ji+kDIR)jte^8)8+)rNT`+UdD8JuZ^7#T&0BWWPRD<%hYt6eD z3XLYNjAoNl<}H&+Z0!zxagI5MmgDe|WB~e7j(l?hsht{moJP3Owqu&i;55PT(`86= z=~*X`Zk|PzPZ!`mFu!fFK8|0fa~la-Au4!euWe!e#qn+%L)b6bqs^{AWI3HCgyLST2703jF{6oM)chmT65dhfOT1ulkit_te6K{x$pEQawLD+> zy@WRgXf12C?U^q;Ezs(wMq+q6dooP~-vMbI6J*iW>=9H0Xv?@vQo3RGYz(U9lF4<5 zV*uQ;{2=o=GHFpZ2f!-;yxanCRdC84tjs@BS;Ud7OdzVT=ikIdQ<($v%SN4PB3sw; z8utuqzmg~JM=Pt?JJK&4>Tq7oDs=puFCzAh`8h*5^rS4%F%^VouSaRlhx8+^lSs>d zN+t{+dtmaI0LGD#lt#T(N|b=~6mZ_|UqleYxRph{+J#nd={W?^7HA_uRc~L>LfQoqky+lCpzV zTDsbgS1jYJ#Ue5cViw6+J!y>Jhd6rBB{#*r)#g2s^?DB%E}-W!Dv8&=$-JhiiNbQ0 z&JDqi$k~bA?3v6cg4G7o`x%+kWrR$qXHqL_0hu8ZGF_Spt#BVudqlriy?G?}*sSK& zOwMpWr!dB}RIJ43%J0|6#|0-8(WL>p9K4j8!NOEYve3P@S5%TRe3kx5N$KFxlw(m@ zjtA+YIMPHTE6H@Dky_!?8E44>$6JRB$9gxk1<%2&^?D&I2u*PTZWOo!N3V+N$7vi; zWOQFJ3t`1%azz(m2jurcDz_|B$2f=iIj1p%V0caDkKGww3xl40xoy8=(=G;l<=Q@t zw@uwJ1%e}(x(aDq91gL!wTh8(q(B|c>JMc&>l9fNSszQcGk{)p0Jng!S}v9$*eeC< zXC6Q0zLFI)k1nZC&MsMzyfa9i)uJ5~9W*y-MUZ32w0J9N7ZqR5nL^NI*4qcHGSdxI2{X>PVe=t{mTfF*dV6H;4>joa*D;jBLfe?5nXvpW7=%)9&@3GV%8 znU6mM8k$gr>>cA1PG*_R?s`48qQSY95Z{(3^>lz|gC>_F3|cH+@s77bL7R|)#OXW- zv1Vm|qCZvkpZ{j%skp=dudKPMdoxGb`>+GDnabJyW!{<*uzVK9NMZ9i$UZRJIj}6L zt8wAmv!!^0MRs?DmMac;Anx~Cad4wQ4;>4_*d*uU0{RwNKg!Qh9!T&nIDK_Iv?O)E z?vf~mb|jW-nB3Lo*F8BUx5T{V#kFu(9KJHPXlOZ4wLGaPnI_^P$UuaTY;iM}DxFTr zDIi;WN={v_lW^Y~!Y=a&8Z*mPu68-X<|}azmS-Zs}}f2^)?t z%eLtvTdM>glEjFy(N z^!jW>+JZ-HbHR=k+E{06qxR-C)dYQ1pZ`dy4vn{zA3@7rrsYHT_eQcMa)j-ccfYO< zR|Do$-Y((B=D$P7xp8s62+k9J;k*1GBF`DZIdrUypcL$xKx75yeJ&xmT0Rq=SQ|bX zEBI18vPuj1D-x0Z<5UZH)1DO5Wf|@F8>ovX$M9dm5WQzJWqdoga<%~b41m4zBfi;f zE8Rc+gWu%4rTmriH<`a_2lb)pIguj(8lC#^1gXtGqo+P$FctagaLfEYnB#ziIcFy^ z?$7DCIo5eEV(cv70G-^;vXnxKT!iAUrKiNwTWzk-I+l@hic1j+6aghd63Ds_U~5Vp z(@QjkQYbUr4|Gne(gbiez=HV}v~{X5k#Kw>NBPbPSW-NxpvMxO1r-B;ZlOprtn|=k z^V=ppyn3sB=q7V573^WeR&I=kP`*Gmtv7K^PoB#;1eNdwrlO2%Au)7BbeTDa`n26Y zcF=jWKvudlZd!WZqwz48HiomhjIzJ-EPodV@4w*p<73)?);N+GIGA6x{?<3t=}{D~ z5FLcdzr;k%{Oei)Dn+PUV%!&3y;AYva#tKPft66J8^VKcLpXI0>#Qi#^^W3HI6*w* z#T+Mu#o}|(5GB5;RmbFZ%pIQFmVJZG&hM0+-RE$1qF)j0-woKp5Cx`bv=$l$jup7y5+h49XtW}FRty1QT`0MIxw!Fc@3?r-t4M;?d4YN-GiwuTOuj5lo z3T%d~*PJJCG8JB`NMt30gitMIm;M!Mh9d#JPgKVByPi9xqkppBweFzzRR_JVKj{70 zj_>xq=Svq&W(`d84+{1ehyU`@z&M;!*!vnM;5!NLJTaG*_;dTrS~h|#l-kPq$Nqp8 z1;_4!f)g4_E{_ydncsWc2qr?U==(dY1hx4&ip@UDE!k7qOHHu6`sVte zCgt{F$W<(IS}tO*C+ioIg(&{q#aK_!e53hhuRzRf73ApuP61S`h1YPgra;j+JimpM z7ps=*c9YX_$lpj zA7j%OCH(WpTzg!WAbTy?S}%JDSNyDK;9!EGi&t5YFQqVo~LBec{F zt&E3OrYKj}jH)uHhc}x0K3b`9RvZ)JP^|=AsC!$o23b=y%z{H3Q_{e8Nj=&d--kw0 zRI@E`)yldQX=D>GfnE;deB8(>Tq;Kq1Vhvx+r_-LQjSg2(#3n*(Mznvv~ZBJS{uzu z$pk5JqXR||+^{{!?T@j-O1J+~2?*DJwP?L1dP_(mipuuqH=kLTDXkxoD^q2@hWO2^ zZ-LZ-EA9GVg+KuwO^xQJJmD7Miska%E8tW0%#-UwK9;fPC)y!@U8T|qqD(@S8DXVd1YXfy4Kvp_F?l>epYRvBMmb$(l z5!Vx`ul$sAnOcBs>~RSbz?*C!g>2ralqy?<4H@~h=2l2-l$@Dr+8M3ah-WWfTiT&B zc`GRfg)6m@jx?F`scbu%NLBvS)}8gZ2(VLE*)LSqnhDF9^Hk;Ik{ygHb=6NP6it2Q zH?%^Vi=mV!MSo&L|ajF{oHf;L@`s4r|30p!GI+@)`sgaOU@KB6OA8AJ3&$ zX0#>bc+Uqt_RChPtY>q2v!V~J0Ss8|5$|8$5ql^!5f@9L$qAsz!I^cf0F+Z7%aye? z{qZUThMF(tz6M;*KqYToBdA27{d9C7%`tNRx_$k>rFP~#o+cCXDvZ6FjCMVFY}uP`xM$|1T*z=Q__W9K~COH2dySl0TQ=B3I>EOWsaiYOU63=RI+72jdhZ5ZhRm zHi2ym*ygfE#WvIQ0;wy&HQf>!TtjRSg^m#cbc|@jH3uAj_WRs`T~R+j4dv!vTF`!I ze|{&2_DfaicBH~#4NWU2H|_|*p)IPWfpRhr0tF6{1N$tY@#FwUsm(!6eu3kS%yGOh zH!k;zM;hEnLp*|Q+e$ao7?=BF!fxGOZt4QZfe|B3ZTa!gD$O6usjdY$r%$4Qt*yBY zP72MZ|3ti#&n&U=R5dR~N=sdIz+P&CGP2`l{Cq;(Qh5UE~+MN7a@<;nQ-N@(A zm;65DkAw`6%S`KDC!cC^I6E$cJ51?M^t zbX%Fz5LmetSOXq=ygY%14~bj4c)K#F1pyD#39SUUMQcEslztrK0z?aP$>5fHFtfL` z`KyXrgi3krihxR4Yw7{(=86BmK`n{`Gx%kJ;Fs$E&fu4)Mg{oA8oyW+!%=Wv97Ka8 zhXW7}c(r*N##jjd*DaoT>vBu@?}J7X!e8&&Qo{dAa?GDJ+2WZ7Ga8!_OWP=(dFfz0 zgGoR39-Z+_;Bi4P*BcjeUnz$|gZbAii)Wy0f^6>|wL}qA^VTYcG;f&ErEr=c=z$tj*W^k*l2)x z_)UwAxA{*lo(G(s>+x@gE>>(bWv<<)4px(_0Sc&Lu{+)x9U(G8&ymJWfMw9vH_>I-^f6j}&r8RZ%&0d@jMf;#E1 zVjoH(mL?QFOA^9K@@f1e>3%kGbG*%Q>DQ4bUO2;`=mR zXq#p#&aRjMsQNh|`m(cV9Nf1Y=tOI68y{d>d4^692-z;SwuBDM0s&*E#j~VEP~#bj z8aHv`fEv4^iHZwVQ%97<&~kHwuneHb_>7pKM}>(B&?8D>oUB0KLAe8RVk(OKi#&ZO zinM@*j1@5?Eggg+CuLCN^3L>^0KE`k0`x+F3D84;DbPCs&^rT!Rg;$i^-ivQ$9!A# z6HA*P;zCIb1i+g9r2*D#FyF?TtvN-`{}z(!f3%+>PwX39)hsOJT$#>(zs%s-39$?d ziCFPY76n#ESV$tl2n$*Hq(!g))oVaS!?{9Q+sF zqsx4=x=`!E6^d*{cta}-fGJV9=2;Q^O1R#rAqQ05!6R-y(ho@ry)lWGTO; zV#fNCT4?LA=J$SZqXIGwuY9`J?a!0Sv{1Sj}Y z*CPV0q>xEjuM9>{_(xuCIjQ2o@uXslK2Ua@@RA=WyM@%1A1J$x)ODFYq{_Al`<3a+ zM)J0}^D0WyFns12PCPpbw&M120u%s2*#gt$cdQap9Om$&WjDHBPOx51uwHC_T`wnC zFDFR?arW4T(AethR*+Yf+cHc|^FYKI)aH!YFphYM>3kmfC$ z%a8iq;u7^gx)J|}K9;$kWAM)yws`kJG(ns9zNcL1ALuI>qTUE?*9(G2q%7QzY~Wt3 zqFLIW|Aer59rAM||3~fl%fvFVL;m~HepP#ZL~PkRM7EN z$oiP4_t#**LF7>zDMO+jydA?;>of6+C9%kA!XU=|A;il?04uiYZ9y~-+q*SuY&@~Y zexj?vH=+`PQLpS6?De37$&(3dFPD%q3j>xC|Hkn)IBY!L;^}Mi<09n5G`JVx_i(S0 ztn@ksdoBMV9}cqA9$jpK`i`HVazraxKhbtbt|fYw87TteMZN7}*YNhdeLp&isKNQD zt{-5tKv-ur`?721?RUD8(L2ZxQAx`kq&^QyXiKF;R?D@d-q$fz4)K=Jq+Zc66~C^m zmh(v+*fBL%Qcp#({KZA5^SJmV{t1^Y!4yHdgLZlWKV)~ARU}RfrMxYc{^7|m5do%3 zmhok1Hf!*f)_I+*Qbk0)l}9m^J*8HZ3N4^aYgY?I_M>WoC?1oXMJ{FaxFV$SFGl^Uw+{Ch=<)kSc z;~iUFe#U=K$Z%}LW)==qKj&{SGj9FzL5tIBU5CSoqakB4mWZJ?1J`vSber_*9TqC# z&+cL+_Wt?DjFnp%UveYV>F~Yq@A`0SPNO+roSUjz@jMVM!xWCvG@dL=ySVexS**a! z&CGD=oMMVr9&OH)s43Qmp13zH$CLsVd>E9A3J1>;Q7^w$ZCnx3MxAj5t55n{V7iPchMeQxaNfxwY?o~)xPZ8 z7inuK-I3olnyJ~=$-fRdLf*eGp#@-a2010m!}A~E`Zm$ImC8C`?_d|Fc@)clvVH-p zmz5llfWH%Y5@sxFZi6w2w6wmrh(g2FH_~!*Us*W7UYat&L{i1rIAb73OqD4(7&KjBma{;!v5Yo|~AlUowUb$svo?3FT~TV@Ro|Lz$1L{CNKt?A_108IAn z-DFu>P^;Lvp=IVl%8k_aD}UM2+rnT_Kt})c<9r%e8^^2=SaANU$-$1-t5ERY34;G} z(Au0OI;}FyEmk>ce4bknn&U1Z$gg)9PsCG@0s;0*W11nu7PFnq!c?A*L5$!C%(7uh zWH;-&tau(20IYhFrNX6dM^d>lS=GWv9p?_QE8Eu z8LZ=v%QiRJhLf|URxyogQyspprA7v&CP}H@l#<~O%?w}Af8|9C+wnF(0pMim;2HFp z>7^}qw@pDpJtY!h$?V@QZmoPw+G7bbTfueF{ zL5BaA1&*0XQ*ls}ONdyK|QUYfaCk4KON)W`vIH97~Gy+^h1o1ChT z{aB{ZQV;h^Dw2)VqbfNx=lsI%&zSw?<{fjPeH5Ca6g`;veEYjg=rMDmzS|S#cx_JO zQ4Hq**3OA-M9`xqbG72?aP?byOsPTIiJ5|81`*gaph*l0E+Cl z5OnWIp2g^p0XLXS8K4xnki^9dhMWfTV9-CgeNAlg28qIsUm$~%6A7nqv2uc%k=S}l z_4k6B)d+Wo^2&XJdz;(lciJcqdBe487D1LsQLw4NN%f@_aWL&WuKsp z{@O-zq%aRlWY1A7tNCA~a0M!t-gm_Wg8JYk*l6w)UuJ}RbIA>mwcS1-m#yxNxVcvKl8Wpb28Os{j&%VH(3h3|O+ zsz$oP9Pu3sVsaFmBuBxZpV0}Yo+;)$ddO@RHBSI@C@~%ME5m~X zum8ue?%!@W2if-tJ6eFxUt1*Rc@Hx65$d+0c>_l%~mq@tF`PUOaXoou4?I)iPpTv{7&&8UQ0o=W~h}9#31;j;buQ_ArD%QcE9Zc zCdlmizODv0fJeQ3Vh3Vbu@3x{rQ;#Y0(R^+;=jq{4-$OjpYJ=z1!yz!Zrs1B%Y7OC z>$eSdqQcb@3FVOdSGtY=`WYu|C;saJ67YmmbnYI^e{DsVa6I1)pBm+etD>1pggn2F zI&flfzpRVp#%zATk6qWsk6r$e{Mcj7-M|K1qYbzu&_VlMe~fhJ!&tE8omjBflx4L+ z#QOqe4zXV5{~8>nNF@`>q+E#G02Zp26}0(1A0*KE$QAzU3beroa!R!G?eHOZ-E91> z!iOmS=JJje1R51E?NnYkRETH1E$Zzw*K!7uVeYWM*m;{9GmkqoSFY9*uo(g{sJF#R zb2Iksk|ZAaj(;fvR~|SSYvtsKK(|Y#xI~n(3Ub2fc9y8fMfPN?ew*^Te>vX~6sXM2 z265;9p32_-#B52QxRZT;np##gn7zRXD0gADSG|ezK)xnNrJmM!2wRIwJt{32GQM6k z1sb^%_5J;D>6Sk~+4~Zb2yny|iX+yV#jvcFh7vO77rePu>2xxxc;icSEy=wOwMolo zu7EXi1+T3&ZyOM?6`|EK z&h!7XK-2*Q+u#X(g6_&*c><6jk;gp8S1=B*bGv#ANOaorJ6GTX(uwV(`z05VDqRyPN_^Uq?ShT_*BBz>HGvy04l!|%*S4IQQ?I>=k7hb zGAF!n6w=*`v)yoP8CGoDP`4?E^?av9QYS6nI`j&o_3wCu$o(P zwgeUr>!n_D?TUbn+FEBwnvZ2>eb9OygdcwWZ5;tIKRC-B?_F~y`!+4!h4L#?N?y{%3ZePe- zl3qf2H0}Pe53$h_729wUevN@-nV&ukny+||y772beOF4%pac{;tcLLZmvPp-3~+u3n0 ze3P_)*9w&rU5FEt%IRPK&5V3g3>PCfD71#+CCkG8Qk{H&xzRKNY@*UrQO#qTj@#VQ zTvJYC?F|8{#3viwn((_mS;nbTCBm)EF8X$faqx`U00G721vj}5i2Fmy3(1mB@~)t& zf2gY8w^h%}RIS`#huTuGSP(imreHL`2%0}jb$`@DFm}(HuWaQ6TXeg0*gv+nvc_lb zgn$-RC5jtQ z3*JKQV+V~-Fx2F zV@(dVbvGMpMO%!#J% z$`?O|{CL%uR3y@1Caun3>hgaGz9a)xVhov!yjKX1HX*{0lm#28j(4Koy9zl%-2{;H zOCF=%{)GSK>>+1k;rGH*`K*KhPa+(9MU;=z+w?;Y0MyUhWJWL2NmJ-q>dt#lq34yj z$IXA?>aiUFXgJS~-*0FT(wty#;p$UJOL!NEaJYsq0b7+Pnx8#FC(-Ikils?_tItqU zJaHS%FA)`XhO1@@c7!XZWCVaE2P=RjRes7EDPYNMRKgMsh@V4 zTd~Khe?o@L?YEFOTvqEw-i;OSmV80opEb*u$Y{CvG202dD{$|~fhOScaGW~kzd489 zXb0k?msh==4}8it=@w7*>ki44J3l?=K|RPthd3QA*j&wTjHPbQHT4=3jjkn97dTb3 z1=?b%+4)dKnN$Bt^-6LFf1wh>rqR5FvziLtEc0S>Gpqmi|INhjU-d5va#a@%>wh|8 z#KEqAvcpq-*K_!nMFm?oh}xl`lxM>K=0m^rjY370Z!@RfM_oGun4|bzbEzWJaU{N3 zDxB>Q)!MQBz`4%b2|FJ3(u42JPkxSj^nt5>0nc{0Vdp z3>P045n+If+WOCkT3+~6fdcz%!m}4J2=jWiF0oE5*N6SPEZRzQiI_*I7NfEUi&o%> z)LNtzpb_*E4WdawinA7F^w-Q?O#m}4%V}ru?EGZEDB(}ZVvgrO#KK67JQgNA|A%}| zVE@tw6zQDOwiJ*2$S%d_GbN`}(k=(&pDagaB`#(qrXmFUMDyQ(sEm$O9@A<5t;A9Z z&0y}=nnmFc!S{d`lyR~nw*R}P;HY5HlawOn%4Zp-G7fVQ# zGdO<|&29%bJemAgnDW~0&Z3uC(Fcpu+&1hF>{53i1aM@P9+y?MA{T&Y@H4;`t`>!o zLs&HKxvbhd`F6Q-ee+PuB1mAQ#p3erj|~B1m8K4oqj@|P*nS%N>c4LPaEsdu*wE zwpKpd{XeXVM#@RT6WXm8BU`H8D-DliguOThmzr-pg;PMZuqd=t`_1eZm8=NgJ%ocC zftpZ8Yfb5|X&g$aP&H}9r4dmZB@5uNkt=Tb@s6|#AqiO>Wex2lI*WSnRhH>8WezE)aAzMEY?6c>nGNAeYp&CDUd#(xe|A+b>E209~PM@>wmvn3&i%V(#;zKnb zw4ZmF_P->nc!>75&erkU{s-;vWGz3?ed1eU%flH0b!rU~7TBty;T0S6C2E9Oc`hEg z#IJdU>5C42v~U664bOj?H(@eXngvg?W&9Cc=*uLzS&SnlT=QG9F~rWU>_L#=@V9=< zs~Ggnr7jmXcYjb$pmOktP=ffP$w5?#+{y8$As{pJUdL2G?kH%iYHUrf@W*5?N>g*= zHX!+1B_f_#D|^IQ^f^+^#X?c5jJ~ozh~{Pr2r+Xed4Bnvj4VP-w3=U*@2e&bwyC?B z#rACJYOeW#9A)8qMc}3KvyvAouR$_2`|`N=dY z1+?Lhsz#c&#~i;hAa^r`KL}F)P$@zO_J#WjL|{wq(v9I%P0!Errwt@}Eu!nFw>Lch zdcZRdDBt}pUDf#$6b0qC%0t$+Rm;Phwa$S+3>+hiJ$-U1Nw zAC;sD`z2{nko0Ym;9#cQ=R6^de3S+YqJ(e8W&u5+xgE1JYF58Uu|U&vmojwIOWJOq zG%s5at$w1t(51G}aqWek3<}*(A*j;i(Lr-(X~`LLTZYQQK1goM<#ec1f5g;7yYmL@ zBgf2mY2d7&ffif`=GV?>3aDHrM)V(0jv^ba64lK$S1*;ee@xqpC15lA@bxdY8~nau zwnE>ao>-!}QdI8zrBrQ)QqAVK;=(SiX1FTa{riCwv22nlY*BW&k0{s3p%wu!w(^W1tJKH4GVmqW-er^rM_ zO~Kc+wdMH9e>WK|fC7~R9UqiC2!wIrv-{YSQ zqC9t5hN*?&V<=071C-$|wya=1BbLFk?jOMfs{Aa_7sYI!)HU>3Gm39WNAhH`i)3hA zCeJsZ=*Ee21KZ*uEL-skQjn`3K9Damv2BG9I|m&zGWK?ATc5!KPu@ZM;(U<_+sr}N zsJ99k(193ojD<}VF$kS&G?RHz3CLFaUM@T=vq)ACQ=5xk;t+V=DJ@mr6U0G6~0-aq}}%ZV4`xGMExl?T*teX2WlAy3%f&En4L>5@qW8 zfyC_{Srn?pOnR(wO8%T=Z#Ftl3c4|KX-2yHJ^7`t)A?rdEIAfdnga+hcsoU`vZlNK z*uGq3t-5rHUasg%4iu^VD;UgZ>UxBC?;})_3k$_qX)dc0<;$uTPcRKH&Qdg3{U=?n zO9&?7kEBs1c&+)YJu|CC1{wJ}bvALCk+70K3o8AJ49bek#ag9EtsSbu;$j;ziQ=nY zYB2Aij-}&sKu{tO1Wq09LjM7$9QvhUf&Rc-jtr=^%bdH~QRGrvZ=|Zl<=gLW(1tDu zFx^$!!VHjMgGoYHusmI3oSXS7c2pxfjTBFyc)25_&0vd`?txDesYU0-T6{PCmVNeJ z8JyqSoG0h$JkHZUnR-0qTb}F)vH#f52g$=fU>|jD5(ty6MIiCE`>^B4AH01WH)fA8 zpUi7-VQ5u%lpH0NZlBk==cbhXl}Eu-nScMeF3A^K`iO|aTW;QbL=#RHkH9@xR7gJx zG_uIk(Ak5bKch59%RTf9YORFQsm(%#^_$wZEPsWy(m~_5lC2`v)m(p@jvVgUpH5uN zRoI0&oi6+zgLCFZmAR-#f1t1WaP$F4( z7*POYA5*EqeY+F&en#`VTeZKAkU)sy#Mf$u91349vWLQD49YJb=etWeM80TF`9px8 zN{RooRQS#t3nyD0f_G_dU28WH@0-vjQ&>m4?3wPx>E=DOp;zh(f1dD%yvcjk{8i!V zpYtmsxv5oe3{80bk!A=NFs&@~M#B3-RIYp$kB>cDovJ?#jd{DUu3y_4-}55EWO2T# zGr5Vp9o$aiZZz@!E8?k~rxE$3S0*af;V|~av3{?o%OZbP`zf!9+jCpv8@I(J5*@HYB|6gU9F29unZsxYt9jA9m79 z$_SE-_NE6@#&X3|rL8DShek`r=gxdi{DOMM>$8L?NC!6vcQ~o-2lMO)E5EBbO5J@d zQx2UvgtfAP^v|m zLUpnsqJCi)r|$i3*j$vFAE8lDxSLWqM{matv zs#?MrZcPsC$iJ@D;v2Wab`biw%;P#}q#Ur-vJb(R`-AN(+6)BXuiX^D`?HGCR-1)Nc_mM@ z?Am*eWoRjIsTepET&-|WF^SIOvbg(3dx>tX zECEQj&P_MD>y6z{QZ=5+YOhU=IUBg7hSd(GZpgPpAkZ>M;HF3*s8se*FpdvS%qL9I zcrR8+^}QQ|>D-C;>4WSuV4HJ-5Qd%b{XjOBfVOBC23XygT%yi|7Ieh;KLv7B3DXJV zPwIs6Cx1eWe|{Ur&-$47ezL{)lRDu0@)n5xP`o||`4ii{BgSw3iDG=&v;{1@u1N=s zpBeurocbfGRysbTNiLvwX6M&*jgZjWHQJ(Uv^TUQ27t(^oTzAxl^mFP0$cSx_)!Z+ zfW9&rS6&lfLQYvB`6tC9d%RtHUaWe3sP~q;=LHvu0B5b6XW}LACy#b;!W$usui(I0 zd|s}ROn9eq89-}-II3I|DW~{k%<1e&d?5j+%7`ur8RXxYtN4oh?8AboJi<;2+VL9V z8;#q4c|5N+;mKLt8n4=(UAZM*v0Mm0S|gvM7*9^T4GAx(BZzXDrQ%P~_E?>cSdPkg zj%XH-ypZZo>`h@VSl`V1YOpD~F3xV#o`~ayTX8FX>$@>U=N2H&+xffa934&FlI!?4 zXY-rie6AEi>;3@0Fg3!yl}d}R!i*+L(#fubx@gH$c1PG#u1$dRc}+6CZHN@Z)ti`c zo%%IANKkFAwOR)=S783%?}A17Z*o+6S#=<%vUl7&9%99uFD%A%iF%(&S&RmtWs1bN z?=IJD7qujWz56by-X`BoZ^Uk3P07y6GyFcnv1FNXw*BO#WjKX$AxqHks)uOiRKL#! zrYIl8W|_B5wX@tPH|Hp>07d|q`f7gjASnQ3)bPv9OYA6tD6_g!AY->c z#)|?OYs@pGC}gZL4U#DmZy@6Z7D*vPuB=IPJ}FJnIV_HWKw?cD^jkP%S0433R=o!3GO6S+2sH_no9On7G|KM*tVX$nETY! zuS}c%1sqJvDf0DWg8cLJzBTlFa4~u2c7TU-Ey@umM=R_*-dp%0V&N=s(N)1%4EJR$ zGWH@kONq23QzPJD=S#psCm66eeKvLHEt8c{WbpS;SRKS4sz+$M(QyIuZ@%;ux+yM1 zdmVqO#M`LYKhhsah^{w6dXd$(xPJ}g_;!excq&of8c&7ML!6xOdb++ua^&75hTmXr z`6cb7a!$nvmy1fHg$Lk$AU-S8VkeWa=jF;O*Pqi$aPQpoTCMmDb)7UfvPl%L=_eNVpxl%#I$eP3_D08sLg;34q zR6oL9bmYZ^{%<#=+Et-bNw5%@9O?Hv4>GNC?N6d&hX6XBpd0Cx+U7uFH=hZyBH(^- z`cI(^1?VkwcB-=W0WN6vbKYg?*rgmuu76fv<|1;qJ}IqUKMh)ZlNmswUhjtC45G+J^AuVD*Y9`0 zCemLuxie<_&32^=$RJjmIv=c$7~@l3`C(;*Lnaw0*b+|3>(fN^Gs+XhC_i4Z z?zW@E-%Re);YMe@Pe&%Jee(Y<7+Urtt{gXtD+f9kHOFh;kL66Y`PH=wM4Z_OtWW_F zTPrRCtLIF!N(#f(Kf#%~&57pnr&KgE|2rhgijM;kgInic61=bBaFX{Z?;>5yVb{p- ziS}0@rO5Pde}RnOVfF(F6yi2cv(r9`@68**BUJ{O=XlAuAq|#q;S)NS{%7H;ykAdG z$OLfw$=VN2b@44!_%la6u3f6W2VZS#JngOZHky-=M@dgNVE~cA7inNq^3s-d+QC)I z;p*Q|pz6IY;rUshR^{F^fZb$1`wiQ65p6rFx&*l8MBmI=DE&E}j0E;Y^)clhk>3iR zRiFaDMzaN(xs-Z`Ph>i*e$k(~(`IZTgX&sG%pC-fo6HaX!{&&Rti$f@%vh(V{doV4a_&Cua_nT#)z zmKoe1(6Mtp>v1!$6T$!UoUYuFP`nCrE7_xa0te}?nO1OsnJQhLerDCId{jrhAC;rh z@45%+sLXIUrZG~zrZjS`aP?X&1f-ppklb-Z9^+Q%O!bG#lI&lRjm#WOErq(=FV-&U z@IBurS+bv}Z=$^HmtIkI;NtK-;xQ~jX^Falgl}5&m2&;7{O*g=xK zcM-89F6D#|NlwZ@M3WiKVJ8i*3W{yLMc#$AmsKiw*zU1ju&i++L*K^d#; ziJFCEF&aWN`Y0*l-W7x5-VLR3?>0B?{i)4-?C)~?A30(9&yt_Y3>?J&1e%D^lK?K^ zi;;{x+vX@^jssn@>)`!BHx1@;$kpbruw#nf9DGB*b#EaZF46q?1}%9;P_l@UUaM@h z#lWIJ0Xv!olYl|g8{(*(>_rrW}9WUreV z6clOU@IFRZrNI~8Hjr~1tkrHWE|vUvHZjoRXAa4>(PzA4xMOoIjc1J-Jf!EX&ugI3 zUdtuHJ#z>aI3aiNS=p7nQM9C2MoVfD=nl^fsy3LTb!e46>2+!N{^fKUBk?bL@A6sb zX|Q?8ZMm};I8w>wxTy6Yg1cL?Z&a#JkMQ?v(Mvj6+r~l9^CcC%hXHWf3M<{ax-aQqo-QvkK^wIsH-vl;Yjo zC@xAX@u?EI*Wg=nEAvs`(A{#&WnvX1G}bl_5Jwf7EO<0hTULQSqsf(9?wib;mcXt4 zS6aZ!P%yen9AhPgQw?)tJg_UA;+)dj>!s2W+wj`o&0Sa7drWkW}0#kl|Aq|PB)7= z8j^B#HqaGd^G+|*6|fFt8U90cUSbi#dcf3gG)siemi1VtS-L6!e`_MblHeWINVCC` zVBT++q%TD?OEQ=xkrf$ZS7c4xu1E+26TGK|uXW`d?+dvsi&`!hzsOcVcodbGo;WQ` z=U$5*Y-)Bn^tk9lTRAi3_870uTcy{nUfo^V7wV?IyBi~wdD)# z>-0bMr1&|2h~HkU5Fx{~`v2q=07m7pX8nB`O#!f;G0pON5bFRXm)Q$7U~O%a5s(dN z25s^eV4WZ)J6m&Ah#EX37)NlAv}G~R$3Qh5``gv_x5Fv&I$&kiAy4BJi3DduA385* zL$Y^3{5P2M5N`@=T#Oz(B==VQ+lUijkBd>9DN>_~I3tz4 z^t>opr^m%7o4t>QD$(~5i_Vpa{LgZQ2VemzuZR?O`aOjU_{9%IqFi{}%`=KpD|@m> zTpIyPRHxGX-IkWri#R~ZIfP<#%`TVGc`@|o@T!pYf$bv24EjI`^NxJex zjb><&D}W(_O?VZ)2v*)?{6O6B13`F_ir`KprKU(kW76dOOb+EW(Aeccl^Z)`DC@mn z^w3h};!IA4J^ox!WiS~k%+Q8%AzY+*e^Lc9mCHzC)SLN|&nYeix@dbG;piXXOoD!# z8;BKNWzySIFAM54lB!HHbUM*S=g2f`pw-o~jipsl3b+W`7w~(Z?ipbZo>vR3OyE+P z5Mpo&!*|>_b-T@w=WUFXoyMhG^o$k1PHeK+XbnWxJlDKgBe~PZR$kCPlqHX9jgs~* zy*9h8OJ5ASH2-!^#Fpb#Y_bN&w2-N#kN~BA2As{hH+;l(WLZc zB4A-Cmt=-nuv=t>Z@9t=P3G|%*y3GQyp>WyjjxUQPPU2J;=^?HNo_P&TDeP+JipJ; z?2mo8q;5{n^wN3n8H6q~Yo+D+m+wg1_gP`Zl`TCb`DT)ZhEuYuEiR1`KHRR`Fr!UCrBvSDMJc>jHbO+B2u~4l$ zQ7uwbSS&*tpwesewn|HNvxQ^LvIkDQ4E!i;x9ra=3gX@k17N(1;@({_;gTXJeJqHf1+@BqjEuFcLvgE zG|%0s`U)K@Am>knM{6)YR>9o#(#i>KQ6-1l>Sw31!wc#@LS2i7GSw{_I?+tg`ZH<| z1$xA!pnlM9qq&SmEWkef!J&Vm6nlTtC2~U~*5~eVfZ&(nI$0=*?|8XQNsQ4Y^Yn1x zN)=)|GM+`X5~x0Uul;AFOpb> zayUHft%U#MS~g}hJZxtiE6dRCOkrf=;bA*W$*P@zIKtaTQ>2d^4r|OA`uJqwBDw=3 zT1JpBIdrhRmfY)~+{vNL1>7Vh{H~J_1O+)|opTNxr#Z5=#T{~JP7jktb26#ojBJJN z;f9^?6N2M4dyP4;T0YbyLMKVJhmf@PmH(2GR=THh`lmloQqOOq;%Z5-BGaoSe}1@< zM{CL4_L6fCSMrTt$=8|jOq-W#Njo8Gwyx@g(%(z5quYy}LowTHHDTu>j6>GT)p{&i zrujXrM(1e=26we~1cgtge?l5Ow!PR7 zDCX!DTtS!LI%K`+TCaP1z3UIxk;Y$0gIv#$8QevOEBUCFl(0$xSRQl8Pw!tO4W8Uy z?9cU*AV3;)x#W=bmTNt^h$U$LH`F^M2%V%I`AmDsZ)(Yd=Ps!Y9@Ac|LOUX>6LjRi z4_R-B*2`P+}UTh`BV&R1&=g<$Q&cM7jnm&Bw_q)Lo;o7kLvSWHL!ACv;< zdifco6rwe5Mn>MxW4?Vv?#QiLn%bAg0{d9N;{f{z1<&paLbJ{O?P?FzG*|8dxJkM2U%j%@pFgUz60Zgp?W* z-9_m?3hiO_%aC{jg5tEHR^3YAK*Dd^i2(sdQ!}?Z;f1q?!ow&j-T=zUG1s+!RJOg= zQDMhpjUa_L!gO4k4ul{w1nJFD&+bhBL4q3a@oIjuB;M1IFYsj=kO4?rf!O2AH00IO z4xghL*e3*a@(G;~vy`a_M%}pspZ~B9_|(#9KHm`ekSO+T&&|M>!7sJ6$q5fmoIN-0tChv%%rBY0CA+=O^bt64&dt_fa^-Qv- z@bIl{x0d5v(NOtaD`?hv;#!%B$H&rtR3EIxd3-QVj(op@Jiq)p-$m_I>X`Y+1XP1p zytQ-3Q9@zJmxy35=!U{>>W+Lf;0{Kp+CGZPNZW?_VL0=z$?Lctu?vIs(8chMc1t$i ziqswyiAd^bE}BIQ-j@YoDdyaMh^0t)KIYU-YhsN5irt~-|eej6d7w)UU!p-HE zdCcx53-<^Mw*^u3p%-rShb^4=078E>8My|th5PAb;d=i(k3hc`ONr0}=PL8>2t7J1 zU=1Z?0aIjagjBnL%aBhT=>lF~`9HmY2Q9vhcXQVuT}j}tg-!Z+PbSngrl;TufEAfQ z?}tf~v7Vu0{fa;;0Bg2j*~Uic6tbq%7c%h+POx9_o~7n4-`&Ay;xuV~JkoySvwUOF zp}&#Le1q5osYwzv;X{HH#Qjo^39kRGL-mzBgL}D>BRkqp6V_UyS=Y#ogE-U~{Ogv+ z*PG<&fqx`NpC|;g!wd6m8K-8PE%(|Xuw$p8>Wj+L0)5;WU({I@#~2C0MXbzYC-jgT zqvS+1RiYaACZb0}?zkbz{d2X+bMV%rpc!?WfW`5kk3uR4KsrwhlS-e*JV|y&{YYUM z$!Sx8eXuKsOtG3F?*iL1zw2bt!HH%DG38uZ4mrvD4Z9bx}xf1Li`(AIuwrhSp)$^=;v?xAL=#hz&&K123= z&a>?kgo$WMjCw%x7SYwo!;ANHva+}3w+__tT7|9S)rjefmVRN(uFf|3!TEo<@+Xfz zsC*_~$qZW~?rlNSDMx#W$iXl0ht2)jP?R^7r_I|ZLdYc65$hdqm3eyHu5_wO`k~vO zgM5)hxTRC_$W!omNFeNxXyN6(;>>!klVv(A5IO=)9||P0|G0md_*ZRl6d$pTi)p}g z6rVWpb8Zvh*YWxlV$2b)-pnX6#@8YPEmyM!@d}(4uHMR;Cn7s>%bEY5Jc1v>{EZ|~ z>B5OQbuZ`Ut*m>cucUOTdpRFoI&Y=CgzQgOIE-n$kL^aux^4M+>m;*#-pV|j*Ef-w z6!LSLxebjW^yxGSOW)TaJ`}(CPR+<{ICUDR85yc;&UI?WcA-!*C^RxBQ>v?!N@|Ib z-6_?LkKj&L(J<`1oWV8S2tS-&I;SG`HNN4b`)c?*NV9_ep8u7`-B@ac~0 zxH!R-E0#*w*g?Z;`V2a6ea&$?z|7zx0(vZ6xvbwLC)JPOdN!mHaUvZYS^h3&8XX|M z^J8ZJ?Jd$B5DhOpntO7?3mu_x!wbjd05uK}7R_77zod5o#(3c=WPe!wcpG>{3IA5a z@(&MSY#lH*P+$ztlU2#?rsu6t=3UKa(C-L8q@Yw2Qb4L1uFzIgGhBeJX0*WCKwDtA z1+|*t3TXo+dwBPn(cBnv1QdMd@gDr9^rUcjxZorT3$Prsnw28J)4Y0)MGG0!>oqe! z(_uKKW+M-)3~_0PQ};U7LCjPxqmE@WIqbA(-d?Bfjr?d{t0Z;_eq}1-Rm;0Z^N1dM zW8nbFBM3gFcW7OH!2&gWI4fK&*4d6fy%pd68uh}%(rdbpmulTxA#cT=EnGaV_8{Sf z69^Vp#x)u8@qE^Zg?gEVipfIBI>kczZ&z8!;>~$0S)l?_yMX|t#7dr7EVpWTu2bB| z9AMJMM3~9bM0bWlhWIm4Nc=)DLrsVmFat}a4=h6$$6Fye!*le3tgiY%7E$W%3Po#f z>n^uC2ooHhKZeaA_U{7{SawgZO1FlqmqPRUBXg_jyCWHNL(Qm**H@*x-|;mFvhXR) ze?`6HRnNn8R)OT@t6!X1+bC|g0>!$<#CBqlpB0#1SO&~(U;sSEve`d6kT?3~wX$VMQh;q`WITto z*E$xtUr@#(808&>O$JwJnqrKCB@<7@+$i)AW7!M{#fn$Bp=X_{W!?F7_dAHt+>4sAA8S1c;-*ak%Q2C2A8?I?fEfoK&Gjfx7i^%#!bm2VhifjwSYESV>MUpi`L{v zxoIL=Grdc+rZiM@OwDLsZtEh)##Axz6zh}VCL@egIWtGU63!aRUon4Y@mIiKAO3Rr z%jWMJuZFW`@pl`4H}iKLf0yw$hCi3TgK^+L-#_^bD8D5Z0@%~cEbLpgcrKvMjy-?; z34%RNNO&8(%W~kq!t>7n2SmML)4fqMqoJkF##%s6!Xbng3dvSW9Oigkb6@ZNduv}g zBdhrSf2n=_3<8Tvk$frQ4VxAs@2xYkhDG?@E}5f}(ce4I$j-`) ztoXSjgT%rQ9aq7TuUNlKLzxk_k4#sr@5fuQ{LbU*lNr~MuUM~4hYq=7xkt2Oz)DW0 znGcRj`r>^?7p&v{f@ubM)5jG4Nb0gfd_+OAP)8;|E6o zY(=ds%m`U0uzytGM^P>}NGbaFhCwJc_l*V!#T(UYL~sg3i;s7TmmCID3eK^p+Z)~I z@KEIq2MCVnAn``GZMqM_c(izfH@>eE+Hhp}A8g_cFX*u8ywQan_grVR9ULLv=(92> zSKIlh$Jz(?iZ^@!J#r$4n;fZ;nVf-{$vM=hp7X|kEF9?#AJl17L)u5R;mFo1n(5A= zMpb%*Yn5r^P(xy&To%e3o%k4QRo-b-m$Z-Sh>loq^p%+&9comQj&Q9qZ5(P;&sF_i z>96p7xq`?WeVsS{$jK1}$Elr$cw_qzk8t6VneH8Gh|`X6;k?l^GtC@ol&szKBi>{( z9!7Q$kwr%oC6^<*kgSCajC*)kSM2>~g#%(Nyed;G!nBgBK2%ni$e( zW$I%RoKLghY*D0cx$e8O_Ei3O}jqwjbA_elx%+Z*b=Zwo1fj<+|O z>c!7H(Nx)4-06X`1o_6^RIDiGy)5PX#k^)p4#Z&p+{ zT`y1G>Ul3?yC@fPtg2eIBVETbx&DNLNS#~16hjH*f35Mz*0?`BN9CPU!Z*9`6V;yI zd{n6UQ^J$cYxWB9yAH)_>6GxbE1jxZ(yv{&D?De^0;%v9DJ40iT>LmG<*iaaQ>Ipu z*WtK5^FN~AO+%vIR|ZABSp&UTQPitE%bQi`RSxj3Du^PqWgORW(*mr1KN)v4HL#H9 z+&JNjDmD-jca|$=jVGj{KA|`3NSOKdCu|bbu#I`@>4YC)8f#>8AUp)hg|lC#j|b$jULSX!ku|co`rEbp&dBnX*8M3r51G^} zv~vjyC$&f?mCeBsl%Mw^Th%`2f{sk4Xn_>TdolAm$XmhI-sYfVhDddaAD&ms7l#)= z{2#&Z&$Khe52tGRJo&-r)dEhw1N|#nw^FRFXdveOM*D%HS$1Z4;TGh3bm=!KX^!{z zhI>ywC{4zS@1G}4MycFuWbyq^=u7c4XyJ0A$O94T5}$%lDc2RcF>eXLCFi02;uiV( zWYJjkb-eqvIr9HM_Rc)M%If(4VGjb{xS-&gD%4oiM&knFk}C<^K!8|9v2IwZVzn*m z4MJ615?Zeh*QG`4*1FW)+NxC>;uZuY7)WqOv2|%}PmETy6#zp}r=FH5QGp8~={F;6&7;m}=dK1EIzK0s#1uGK+23u3M4*w1b|AM>) zwE~HFJ4e$1BK#%7<=B!17YgdJ!422wZ+XeHhXu74dN_%W3}76y52wbMlG;0!%-=fI zEP+jLja73osC_O~J|Fw#CF-CIpyDNqN}h%u-s_A1!&0zum)ee(Je?}P#!naCR$I@5 zXqfk`38^;a=b`pmb-lyn01(=64PY#MVFwMW~zi@bj07l|D_AzLe(t265dhL#CG%`jK`Rb$Gbg{ zElb6-%yt8|(;>lwfqI&h-a~iL1my@pz&qGBO1c=TCK>fr%IDj#$SZ5RKZ}E(;zUqPBxa{otUjf=TINz8^8Ou>tT?KyXb;A7qtS zJ3IUNt=++ojpg=qSxMP2PDUD-!p5jO?K*r;N)NP(LR%sOHhr<4A<-xoxOva(h8hjN zyyONOWSRaNGW-454pBBpXe0DvbWiU$jq;#oFM(FFI91L=t9Wxj z48_(&P`i&11ENY)g7m#TqDZHy@?gOMevk|HRe6YlXN4d-7GSXo>SJ1Y2_KZ!2Xxj_ zUea7vf^<_kY&Hup%&OD@69?B(3V(f9sVSGxO^G$HG#X-^?&HBZ5(`MlN{k_J0nj0F z$e5qR25?0x^P(jg%?;CH%~pmh4fOA$rlBKGgTbT4YFeDFV0^lFNT@d$loSs8hgBKx zD=&GS$ua_sy4Gx2*Zdel3_oiY*cOis9yBypria;lR6-{3k&qmi$NZ?j%G8uQg9mK} z@VR`fCozG7`2+n#%->8HQ0G;C@#-OjfyNKKUpyOI49}FBSqz<`)Qj+S|hZ2DMu%+nOxD(>P*k;sTaT zm|VM+)zXsLQ=meSR{ZI>D43Q?SP8Tn59ZrSNR{8fma&&rT3PZe6RLn_gwU98lxs`E z&;|{FHd|$yJ$AsxvXW)pBX3efkD#wmj4Wb7wr2+OFB439?WPz?G8&^`Z~8P+vTlGh zT8{j+MO;=wKwpWLVCAMS`{M}q* zVattq5^NY3++f5HR6K0s83!%wuAxKKA-6>)9@GPH7=$g6c*)Xu$r5A(s!bDp7Kl-c zX4HdGQ-*Q(S5xq;Uua*%{KU5Z@aONt25N_>B>Z@&V1XYGRSVj(3WEi|Q?2rnC+VQH zj7nMqFaak?i?}OXAXk0gqty$W(S}!opruICkbub^Y(30S1AD_gsu9XoYLa`%k{m#h z$qWJ%PBv9r(hO2;m*uZ@B)|qDI+!Ryb4Z&YUh-n>;dSaMH4*WXtvqW?SSmr2U6txB0-}pmh7nGwH7#~u zfZ0q7Jm|*$LX@9;`o6hP6qG!Pghopc%at})!2%PHX&LHZdEe{jo4A!MVyJ~bLxSGJ z2z=uix3G>(8Ht4Vhh%eQ*kOJI#Z+h}Rem2^Q+mhq>78l3US%qEK+WWsXkwFP)Z@o! zLt(P6S1WAgb#M+Vh+cCc+#|$#ISh*5DwB~Bjd8WGqQ%+SPV9ixB(`TmAuwW9n(R zBf$_t`KsbzqlCJmTyd}}+JBS-EoQ{Qi5Et)UnQyuaaqb3QYas0rNJW=ditM>gY2Ih z7YEr1zPvnGz9zE)m2?#c2Z}QlXT`xHTLUO6i~Yj29~%`13nDQ~VtrxMAL8K3tT_0a zb=n6yi-YCHi10%FzGKmZ!r(wiVb8ggwQEGRHN6EIP5#mCTWRunAG5d|M0t2)} zgDwqPO%I~_{Iu^9DX`pY*Itj3geEmYJtjh-4{Y?NOO$A1+~a}=(;66qG4+O)zbQnO z2e(?I=pwl?V4-K+-N=9*c<~>sk~i4iscy#v3+4$)Fe5`GhA_iVB>Ayn8Dhrl3#~nG8Hw^!GosTmX*9ynLc zl8TQcV@8y8P*8qFDqc!bew2i5b3rPu0ZxsQDuePHQt>L1s-mPLgYr95@u?&gMM=l; zF*6mP7EhP^p?sHwX{q>(^jQCW2H&Nr_{@0vFrSblA(4vDj;Bj~!g=7iREGcIJ17v& z4Z~zY_aSJ{E+q<1B8q~8C-?tGyuE+N8P$K=_%8uzON4P(9H|9%9LYoj~FfK&YJ7I!QS0gpk5`D`Su z^QI@!DC*~Osa=6*T6R$jfE|t{Q)uIJsS?qA^pC)LwLXs>Ucn-E!WN5Y|2;0G=<~PS z7~c4VczitaNk@EKyj_G3`YUE;-L+APV~A1lEKkxU52<;lPjdU34SkaXqN|JCE4J(F z2;hZnWAs?&WG)EK9zStx~N5bIsXb@+eu2%CyJY!5mUH*?GN= z!#AsFUv$`kW&PW$$5hm;?^oT%qd4v=U2IjgTNshu}L>}j z|4#VtguC2-&+y+f+y#6GKT(gyHBvv;&(6F;#s7stK#%f<29eeEm6vGZRcj$hSRFd&O&HyH*QuRBshNDm(H_rab$i5{SUjYcr( zNDzsjN6v0${Lt3&`n3i-(GhT19(?ryZRx9{%oK~R~VoB)>(7jK=n)Rr^T6s_aT*g*yNuKg3PT$x); z&q-dCHNSynntTpFqFvN2D)1dWs(B~azys=Sxuhs{f4fqH7&CA>WUCatIUvrI-tWZs z2Xp#CU?|{azXGgkYb%*l6E#IzG?Y6!!E3opBbNZ(Ap8gc=7{oOsWKQ%zN?a?k!H)Y zw0u{}w<7m4Quji!xIiq7_^XcSCFXnCTv4tJ9j)&Y^q{1nbv=LneRKRpErs*nP5kKJ zf9ScpUx&7dDat|e``|WK`?w*mzdL0&t+{m-(~S?a#!f>7tPDQUn+7b85%|ols*H`{ zw(6o86}|L$q)ITgX?73m)&SP&D_V`tb4gCDzl_Yr=pcg`&O zuJNF)(&*P`71Dq`SZIOTlU6agG%@1!M6Zt%qhCLV_cABh_QBl8={uhKWoFN4yZMWd z^A+;K_%|Qq;;yk5`wx#fmukeO) zyn)vqc?pWu`3krvj{2g+h-ZU!+4pFW*D8DR_IqiM@}A2u);73)@y!;|%(D0;3RnZX zdyR*F>wFw$E)Ol-o_UFtHF8heE)K=lu8!~0MCJHC%Ptv+kdH4)haKmOk%af$G4Bn| z%hcezKRaIT_Z(JV?)SByb^NaQwBvUQ){(_VuZbhuj zZGZQ59lu8*4(H}y`*FwbyE^5sXRpjH|4OIvUrSiZ4*7rUSiXO!@t&6Z?e&!b?|XV% z-;38MbUc&@P`@UWy3@2gg9#(uk4#aUkZ>!vRSHESm7%*RZP__xzLtH^5uQ{h`1}8( zWB$irj}1rh^H^?je{*1kkXm?Hfuu8Jk5#x}rcYtgJ-Z zAb)Ef*HZCq&>a*PQxi(i`!8$Ph{nWthPs^j;QN?P-*^4BWB#btj^D53e%DO}-R}A} z8+m-uo7_5BB>i^eqIK}bXD?BtE8=Z%Y=+-GhZBp|ac+cNCBWn(+i`M1y>qm+i z8pg!pSD`srm0eOf>|BX63P{P{Vc}m1W4uhTD+sAsGBv|0q9K0aU&eb^2l%xB-0F7> zem3PHu4pV${4&W%p%U7GSp0r=k1sF5OLc7D+LQV0P;X>+koo$jcT<1c!cDI~qMQ0# z5(jb<`nU16wfgY}6$oo3e=Iaw@;|=Gl*yihe`rHJ3jTC)6(oOwS;jo)p z|J-isf9K#$s&Df(+SiAV$*@T@vrhA|edl~aa^>RllfOpu?dyLN-jbWYx>J7D*E-gJ zq*MO$o#ylU&iVM?`S!)s3qnn{Ix6*nJ3!`11?CH1z%oN9`YU>Cv3qTxIDVs>zM1~6 zQz3TmCU+nqCnST(q6-5I>nv1EeK#2Hj#|EOFdvE3x%o>Kex6XQ5(I+DFw~b4DMCJ> zl{8!Hv?9ek+l&YIOaHI&p|2B0#C0ywUhHX^CJ~K94-G6I2Fc^IZc00Hw3tLslgW zlfkqk(p&*>cY#@QF1wPPdG96JUXL0A-4~DUncStmnnW?w$yRO9%l#DDb0LvEd%GXd zQ`?CcTX6~@VsB;8;EH!auW!hyHpN%FDN}8CcYvxD*(2F)UsISGT)O}x5lkB5-u^qz zDNXYlEH_r(q`0;%?nLB!VSfIw3u#VA(br+;*HN^P$5AvfoKGLAW9SnI2lJ&ToOjord5c5O^kO(>M;J{nVQrh)5x1O8zg{}!X!jEhk6C(c_ItKC`J zKD1KGtm~1+2NC*`)JM2ljK4allwA1N1U6U05Ob-yC%YQb=Ch^ z5K{rT81v6U1>tv_Nw`Y&O9bQcQ|!Ssn;zVUmg04y7K|*3V0L~Y^}`{qQmZ31u23uN zaQb__k0(OCa8>_1{)@=)aC#0Fx7Et-R}8FgU%&UNb7>*_pk+H`P$t=lVb~Obhqv#w4Dpu)<$UCOM+IaS4L)Jq2(9? zI6e5XxG?^#@p+@;(b4_Nx@k;;Tl}UUXVB-?gU2U3GqwBe)4E@H&#p!<3%OhZSI!I5Mou0T?dAdCA{4Z613awkOvT^OTB)HFx}vSc z9xwQA{cPG0w&VPz@6UURL?4Dk-*e}y!=K7T6>RQ~jYHs#Zb#d38r>>ImSs^~j}=(e z52QMkcA$wz)I?%0cZjMVVb$f~O&q)0>nP<;51T0a6U?vCoi9lx)z`e8-5DIYA<_@U zN+%xc_U1y7c=bSquEkJs} zIR(IWAz82X|wnElW?HEgcVy;JX0eF43~ewCwJrDRvZQxuKF$I_U!-XbTr! z&GKOo7K0g=8BC2I%#h4r-g7@^z^ylto+*^{h5n9}!IQ%0?kozm&DBWGb~`BudTMOG z&^=agx6h{i_sQ{1W|Lk%c;q!uulKn1 z@@oS5V9t;{qMFyt=2_IfOY>O1vY`~UKFk%Cj!oPC5$+n*Q7h}+0Wyf1{F^+KX7cl5 z^R$b)?R3}!+n7N^k+y#XRCw~|{$%Rw6iQdkji_*;nX{T)Au?6{dE|*w#q+4L4^`6R zAAoH=<*t-&3L)qq0a4+^#qiC1zY)JC$tBRkvxl<?%1N0EPg7;Q4@gOIJ-w zC)ToOPj_bsmIm-Maxol7e_I#7PCNSXB6s-h$7?+4^hMy=dnt8OS<&vtuNn}%Tld6S z88tvbo)tem*br&AxU~*w1m@$O8s~&Er(rPkEDX_4mnv`UD z*amkdO}2jDYIE|8K2e%j^j0r-;~kpd?00xDpS86rmx`ddVBX8ewFFxqvi`+3H~apqi?uX8{${4-P4_AbtW~_f0z8_UH!+xK`yg*xQN>+pjm$mQ z??dUqhfq0QH<6ayOym#$5ODP_G&_tO^&)p)>oQ^?v*@Gkzz!Yw{lxe1P2a;%Q|-uL zgJqQ8%b&LVpvjj^i{ODq@j!a?)lm1 zmyDIvjOt0eP2*ulVr18Rj%IbFs}6^Oje(E3J2BjZJk0k|l`l6dl)=)1id0W2D9EBc zD^k)*l}y+**+mExA)ZGXOY5)#gr6h;qJ8ve>s9Fs%uw!*QSKJBh+fpAj_ESaxR_$u zkFSh-Z%>}^Vl-YJcSwGt{?c%b+$ZWu4?3C?5V6Aa_*>n>m`x-9vZ)Xh#&szW;AY>@ zTwF?JMuH$&#(xO3m^gwhT}*v!)PC&o|Hum=Jb6jh;h>i}(Gl{u0GxEy5g?yudDglS zIiPU3DMNxcCX8%7iIeSaapK!QHru{7sB^;h{|4acs-vRzn>e@0OfHT4iRBe6Ke5ba zyf8bnaVGrB&xnVo>G{lf1_eIb?Q%3FP+USP}{P)w5}7|ZryU)$MOPD&>} zr@Kn`L*G|cLJFs^$n8HsOrC=m%S+A9^uI~kb|-hF<(=#a+8heex%Jqs8Ilfv&M;35 z#fqR6bIZiwjo{UiwXU9%I7w66XdWa;m8Hj?Zjx2&@3km`3s~srpfafF&4EMto2dD$ zAXYoV{~8i*Q8k|xCbv){ZKShF&uTr3ACGK;N(b3yH}3ss(*J=ScHfMqR9 zZd;k!+kaQlcCCMdI(1k+V#7zTSk(e^H(1GI-R*7-N@*f^x>aiXBZC=#>nFIGJhHWZ zeZa4v>7rJBB#U%-w}a%e0wL}r1|6@vmRfBGn)`Iyz`7y&>f@id9qnSZ_fubGH(1$O z%H3SwBfwF-F3z_-j|DvjoQ5yliv;&BQjyzA^N)Ri&GYj&y!LjSqF0=Okq^h8X|jLM zfNlAJ+5gt3s{h%N{g=|S_9g7&Idpzn3*(eVwI0(Ea~lu-SRU~Cj$4VXu>+--XDIy# zMs6Xci)`JWR|;K?Zls)ZO#F=1?nj2ClGjZ+GP{!z{9=&IT0gUO7b{R|yWH0+gDdVM zL%Za5GBmJxfaI2Vx+*{3{+u>OBvno|o1w40L<3uC9(BkZuREXrTVLS6@?d#nwrg^$ zA^O&zlj^oZ{1J55N!!!eEsiOo_1}|OmMVz{%P)E}55X`IycmyN_4LQD5LA8phMG5e z%3y>q=o{WXRO_8lR=CroQ6P+-g1DYY;c%DwL18>r|K}~w{+gFkSIBuhHMUs$_Z0Pf zq&JZiw2e~%-Uc_7Ejh}szUSQmta!@yL49l6Ujuss&_V(|nV%5oJGi5G0-eD5i#tmb zCjxD9$Nw=R&>0=$xF7dFV7T8zs*L>8*DnWb(VO|Mo`K*l8Y}9on|_9`Nu-+|HzeGC z@ZGcx0E>`ZDeM#Kv0oblBV-{%#X>e0`Se1_Gp@1Nn8sq`8CkKM-3cQz&Fl>8#cQS21?_YyIVjs zJN8CU&)ST%nTrtK-W&LKaq!mOgMy$kNWxxw+?ICoZnnG7{~bOp6fQ z9J)x4zaIhbW4CG>v=fdUs8E5wXr|7_(7ws}LKN>A9bG3i#6AB^NF{v+PS2?9#l|ka zoiE(S^Mm&j!A#81=N4A)R2H) z`ZBkxWMyx{A5gcHoU-(|c-x*>WvPI)4f7)qanNHTBA_XzWyWXF@K+in3e94JP$r1F znl2cCTaTSSUiTVh==UNLMcEJGM&A}oT=STbU1}_BZf;>lxi5Ct_C>kpCFc3VY-Ug$ zkCA0GxMA2#A^V~wno3m2?4sIaYWVjT>_}f{H%9dJ>uH_nONI&AZe+E=Vk659R-4^A zTWyZRb0)IdJi-kMrhbLZu(#U~Y6iQ_U#|*hw;Q|7_B4=Vx2Z6ZZDtiRGP24mm1e1@idbt$sd}AWk5u1Ok5upDND@*1N6QgPEpF z$tihGSA%v#&sRGuZi}Y0$;uCUN++R>G z9V2BbkC3^8<8{BH1|!*pCd6KjLOc_V9jBc!+KB!qTn#pfxs)O%hpOP%B}R57gl7Qn zAC5XOxjSe-_Aego;(h6=<0Jj`x7g`Qq${HsAboj#Sz9qDWz!PD_Xuf~9ER67TUT&A z^5JT4*lczS5b7k84xqiZ-DKRwusILWClaTnD2qt@V|W#Hyai%IT1;jPn`p5tax-ks zLRU0$>}1%y#2yh4T*LQl~G#B-Z5G z=*gQk6HCjDpnzocpFJ7H&^QP=>q!V=vU?e!X7;}*{)&Gg{YCx?-o6m}UwyR52r3@5 zRfhe-?1Q{+vNr-z#Px7`Zgh2hj0uc@Y{6f#Fga2( zWT|xA1f;ARP2!l%?tSXg&x>aIX-&++08t-n)_q>RCyVy> zw>*)KBd1o6Bn~`_OueB=?Q8-J!l5C)8lJkIuV7B9Ke=2X@Ux$J!hbiopCOvy%4UefM+RGf^P+@1@6x`q3W->tn9R*Eh~=yKibekOuRLyJtr^8L0k6ck9zq{* zOh3p(Fb8XEd$O7vOW5M>Y!}tf-ADIxNx_I!4!AbnSnV@F=f^yNF%KZW{b@Ig8l%^o zhYCJ9p9W6#4Se13H94OKdi(LlY6BbGzR>$KlCwAt2J}-8SE?7 z)viTnCa{?u(K)^GrBPC@?s;zex?hf+PSa0kf%`r5*u^CcsUGRXVE5QlYNf;R_}O?^ z`s4ANl$nARV%lN#3SIqO0XOVqTWIZUTe$g;TE_m|J1?iJe+dbh@jnVE8T-b34k5d> zZ|s-@!+HI(!_5w1BhYw%hru}kF-HMZTLw15E7tgA|! zLF5OUoz8#xSF}GYtYd7pzTANsNQ}d^9tAj=eky}!buQPJd*~d?Nr0#snO8m34qoBN zaeweSyke=4lGnC_GWx}rTD$=r$+BTc1W9l#%egSVjgme3Rp?AM)$wL~rppdL{Q4N+ zp>>y|dNSQwbghG#(+)-LBcvZLI)*~g%HP{SrE6aCFVQ{wh8G3b)QK;Cof` z71HB%r&Gv1fbX>lHWIM?rSGpO+Jy36p<4au;cyOLWhwa6Q`xfe@)inu_uLam^a~64 zX>``YA<=to+)_be+6Gf2F_j(g#KmquJ3Lw`fp2U^kSW0TvR*BSTzkqXU ze~fPHJ8?s-0~(H2`In8*&~Z*pev^2n`w0HLxPkqy(B)xgv?4_SWtdvZHS)ZB%irwt^q-H7UUeocLHiV5ccg ztkTY4Wb2OC$x*6x4{L{*y05zd00m+DzR%zGpqVg3-My3a9f%~rb4VaWT;%H5)J@AKcnefZC< zWE$cN`|{I>vzg(1?8-F*(OCM?UA*pIRjadNQ(#ueSV+%N%bQyfDLJiI@t`vU&2zlr}~MW=98H$DC`cP4VN`;Ld8B3SNffXtor zq`+fZSGcqJHf4YQunVavccr&ED#*FCQp%;3Ozgz}L+4)|xx>HztCRe3w0{gNxF6JM zxBQ@_-3S0B@oY&W!Ag9-3)nF}(?;=*3=-k8uK};_b@R2*N^(mwp;?lrrzh`&o1NP` z(?Z{rE)V09x6l67sn zh;Bw@=&1f?YC|W7a7dpXpLP|WLKg={K(27dS~WbXExf}SoXoRZ&18$H`tmn1x}Tda z_%+FH#C?Eo(uhSOseqv%-t~72*jHGNN13xjqx+Qt_Snvp+|8ZH?uX499@tnJ;6I4@ zSfbw3AeFV&&$geoo{=#kD_7OXEYUr1-K2*54~-w~FT-8<+Yr<~3&(ZXcw|yg43+W5 z4PV6~2;?`I2-)ab;o>3W)7)+BU4p!OPADmt0gMtCv=a5=&SH}BI?tJ#-5JQ>P>FE? z@kVSf@wUN!$ip<{5>Y&>s25LU$|n$2AU%G*lbujV$*%l1MDm=KUsw^GUBq>@FM*_P z@D?S7^KJ52A&|Jz-x&VlEEAcjK5&m9ZV*eXOb*vrExFBQ#K7RM8dQ?>v}WKiiW36}D%q=X>PNY1RE z-9ennVGl+N)+-!RuluH4P{27L{c!P%l zy`>!23-=?jBsEUxKcWasr+f4H;bO6711aH^_b93fRr)kl_co6%o) z^qof-$Ieup{q8iLT>rvS&C~~X*~lt`^&A5bdc)$0ZE(kKCh4msSmVZP(&5Avq?843 z)O@~8^=6g0?o|X+^VuF&yR(px4ey)}sJG&Y!P9y*un2a+k5d&!%f)UrT?V8 ze#pa}TgiTR!hc9l=lYgA(9<_)U!)}J2?+@rDvvbZO{5{uKevw{?{EI)gF?UKqSg)A zZgi9Z}mpEU&JgJR%Nh7Jm?88tf&N~MHA6zxmtD@ZDFApptNG8 z4e)6)TMU4^VF5wnGI#S{p-1vDkvFpWydSP2QA3H;W-^21w1F=xrjW2r{FuEdL=7)f z!{)HgA*TPZZ5-3$Mn;mIqugIMUwY3r<8jq;yHP=f{M&;MOIU;JgV(jsLhCaV)8W4} z^V3K7aK|DG0yT$3V^Oh=BosXNJS0%gH1UdAWnsAE`g083ki`3GTt4U6`*9{8U$~Z) zmA`=uW9%k(`HQN; z62n9SlhLaBclf-7{@=G54r)g6V(&6@(LRrEwXN*{6zQsS{GF-u-AFtenHFt2no;z0 zvtDtNESggLW(xBo`^Fb;Ps`~tb0MO*n@NrA-MeQX}m5({rzQSyPxkLW+j>lvGOs;&oVMTN~JQUh!;Ma{98 zBCOF|F4=zN-{JoMt7v|0a&HXJ(D$^-O(Gqiulo$g;$j$0uFY#{q0RIcb?(~_vh8!c zKj_e~{qI=&v!eDtK~jvaZs@**H+**BU?W>c!fu7gh^LKgUCwky?CXn(2|#oC`$xCf zsO>aQn#bfKmoM!k^itGJ?Bd;I=b7SUvLMT`(XI44#@30{iJ|+y7$3;MSSMw5k%V+5 z8M?lV1&;s{)4+;5#=}-~AS>>}(6iA-(^cO}Cz`?A6!$bjhV?Sd;(dZM z<4ailrb?%)N(5(-!~weAge}!Y>5+-x2kbCED6S+KkFME6CI+W+ziqjb8d(vnz|Qwj z6Ri@Nq9KV9V}-k&PmO!e=J(r&_sfgr9o!Que)1d%cr>V2T}%BbJ^C(Pns+)}s*+7! zXc27c;Luw&d3#j%a|fZ4w(VHcUT{G__dFUv+ZHwLL-;*`ovW>n`vwe9uKA2b5hqd2 z1%?cWfRsuEbMtEsZLcm|+7Clto{K{P0`D-HrhMFtSnV_xlQ+>VLUNLAY?782&8&M< z-*e~ayjI%H3U?Lri5(8+yAD23;3i_PkZ;Ql)R+ie7(bsFj6=xwdLI*^P#$(BT&()Yx)94LN5}U+fu)hwen~$IQsy9Zsg|Moeyjp z)_!9m=$A-w1Dij(hz)anfRLhY)`!;9wr#hj2p@y^7n6^p1tS2U%URWaZLDy2TYI*& zA>2my4S$+mmO0y)ieXO%WUcE~N#r`?z$fa=Y{hL=@zIB^+<=PI2}S0R)&^8f?%}A4 z`|KqO5O;U;kPPpW*yV}nhXt)0{%5Gc_@4&P`HoHWnqSyNbFa>pVQD{mt8?aLG7@0YR}u}C&-Q{_0ZE=p}Vz3MU$tg zeRH}-EG(0D1jg3-yorhF*dGz*Rt>V4fU64OztjqoZ` zhgPI|57VV7z+_I`tXI}UdUAf0&nF!`_J_eRrxtgNF?I%g@SoHTu^#)vfN_Uh8b*T5 zv2*p3`h3w64K-Sz|M_dVx%65re3V`f6f$+rDMlQZp{CmCsD(Ix6kda90Zb;*`+-b)6riw6pOY&S`!A=$!moICZwoQ2)!xUx*zm zn;)HDufNv!z#A3!zb&v~#!V7nj%~dfj+Lq4+iUWr-H)xPKRraqX~7TdO;ew)6&qws^ec6p@Xcc>BmEwu$<#^%t_T;USbWw$#pROQs@Yxtq=0 zVV@Iu?CVyUWv0<>!5Bgox3l&LCUL)CYMEB==pM8(LW`?~!u%eUk?|1oG7?{A@SE@> z7vAb?Y!2KIVn}p>xACSNcn^_w11+BN#@|64@3T%_cA@C+v6*F

n~)@OX~d&8zn;U@K+ZbGBigIRH&px_m7@wq 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∋1he+E4hu?_1P1$+Ov)$ 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⁣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�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�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 bn68288knwu=^B`>f$18Uu7Up@HSm7`Z*Z$d literal 0 HcmV?d00001 diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/manifest.pycfg b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/manifest.pycfg new file mode 100644 index 0000000..cedd6cf --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86_64/manifest.pycfg @@ -0,0 +1,33 @@ +# Prebuilt directory manifest. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file is executed by the distribution builder, as well as the dtls +# package startup code; the purpose of the latter is being able to run +# from a cloned source directory without executing any sort of installation +# procedure. This file provides the definitions required to create +# a distribution including this directory's prebuilts. + +from os import path +from glob import glob + +assert MANIFEST_DIR + +ARCHITECTURE = "win-amd64" +FORMATS = "zip" +FILES = map(lambda x: path.basename(x), + glob(path.join(MANIFEST_DIR, "*.dll"))) diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/sslconnection.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/sslconnection.py new file mode 100644 index 0000000..2e330e3 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/sslconnection.py @@ -0,0 +1,1014 @@ +# SSL connection: state and behavior associated with the connection between +# the OpenSSL library and an individual peer. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""SSL Connection + +This module encapsulates the state and behavior associated with the connection +between the OpenSSL library and an individual peer when using the DTLS +protocol. It defines the application side of the interface of a client with a +DTLS server, and of a server with a DTLS client. + +Classes: + + SSLConnection -- DTLS peer association + +Integer constants: + + PROTOCOL_DTLSv1 + +The cert group must coincide in meaning and value with the one of the standard +library's ssl module, since its values can be passed to this module. + + CERT_NONE + CERT_OPTIONAL + CERT_REQUIRED +""" + +import sys +import errno +import socket +import hmac +import datetime +import hashlib +from logging import getLogger +from os import urandom, fsencode +from select import select +from weakref import proxy + +from .err import openssl_error, InvalidSocketError +from .err import raise_ssl_error +from .err import SSL_ERROR_WANT_READ, SSL_ERROR_SYSCALL +from .err import ERR_WRONG_VERSION_NUMBER, ERR_COOKIE_MISMATCH, ERR_NO_SHARED_CIPHER +from .err import ERR_NO_CIPHER, ERR_HANDSHAKE_TIMEOUT, ERR_PORT_UNREACHABLE +from .err import ERR_READ_TIMEOUT, ERR_WRITE_TIMEOUT +from .err import ERR_BOTH_KEY_CERT_FILES, ERR_BOTH_KEY_CERT_FILES_SVR, ERR_NO_CERTS +from .x509 import _X509, decode_cert +from .openssl import * +from .util import _Rsrc, _BIO + +_logger = getLogger(__name__) + +PROTOCOL_DTLSv1 = 256 +PROTOCOL_DTLSv1_2 = 258 +PROTOCOL_DTLS = 259 +CERT_NONE = 0 +CERT_OPTIONAL = 1 +CERT_REQUIRED = 2 + +# +# One-time global OpenSSL library initialization +# +OPENSSL_init_ssl(0, None) +#SSL_load_error_strings() + +DTLS_OPENSSL_VERSION_NUMBER = OpenSSL_version_num() +DTLS_OPENSSL_VERSION = OpenSSL_version(OPENSSL_VERSION).decode() +DTLS_OPENSSL_VERSION_INFO = ( + DTLS_OPENSSL_VERSION_NUMBER >> 28 & 0xFF, # major + DTLS_OPENSSL_VERSION_NUMBER >> 20 & 0xFF, # minor + DTLS_OPENSSL_VERSION_NUMBER >> 12 & 0xFF, # fix + DTLS_OPENSSL_VERSION_NUMBER >> 4 & 0xFF, # patch + DTLS_OPENSSL_VERSION_NUMBER & 0xF) # status + + +def _ssl_logging_cb(conn, where, return_code): + _state = where & ~SSL_ST_MASK + state = "SSL" + if _state & SSL_ST_INIT == SSL_ST_INIT: + if _state & SSL_ST_RENEGOTIATE == SSL_ST_RENEGOTIATE: + state += "_renew" + else: + state += "_init" + elif _state & SSL_ST_CONNECT: + state += "_connect" + elif _state & SSL_ST_ACCEPT: + state += "_accept" + elif _state == 0: + if where & SSL_CB_HANDSHAKE_START: + state += "_handshake_start" + elif where & SSL_CB_HANDSHAKE_DONE: + state += "_handshake_done" + + if where & SSL_CB_LOOP: + state += '_loop' + _logger.debug("%s:%s:%d" % (state, + SSL_state_string_long(conn), + return_code)) + + elif where & SSL_CB_ALERT: + state += '_alert' + state += "_read" if where & SSL_CB_READ else "_write" + _logger.debug("%s:%s:%s" % (state, + SSL_alert_type_string_long(return_code), + SSL_alert_desc_string_long(return_code))) + + elif where & SSL_CB_EXIT: + state += '_exit' + if return_code == 0: + _logger.debug("%s:%s:%d(failed)" % (state, + SSL_state_string_long(conn), + return_code)) + elif return_code < 0: + _logger.debug("%s:%s:%d(error)" % (state, + SSL_state_string_long(conn), + return_code)) + else: + _logger.debug("%s:%s:%d" % (state, + SSL_state_string_long(conn), + return_code)) + + else: + _logger.debug("%s:%s:%d" % (state, + SSL_state_string_long(conn), + return_code)) + + +class _CTX(_Rsrc): + """SSL_CTX wrapper""" + def __init__(self, value): + _logger.debug("Allocating SSL CTX: %d", value.raw) + super(_CTX, self).__init__(value) + + def __del__(self): + _logger.debug("Freeing SSL CTX: %d", self.raw) + SSL_CTX_free(self._value) + self._value = None + + +class _SSL(_Rsrc): + """SSL structure wrapper""" + def __init__(self, value): + _logger.debug("Allocating SSL: %d", value.raw) + super(_SSL, self).__init__(value) + + def __del__(self): + _logger.debug("Freeing SSL: %d", self.raw) + SSL_free(self._value) + self._value = None + + +class _CallbackProxy(object): + """Callback gateway to an SSLConnection object + + This class forms a weak connection between a callback method and + an SSLConnection object. It can be passed as a callback callable + without creating a strong reference through bound methods of + the SSLConnection. + """ + + def __init__(self, cbm): + self.ssl_connection = proxy(cbm.__self__) + self.ssl_func = cbm.__func__ + + def __call__(self, *args, **kwargs): + return self.ssl_func(self.ssl_connection, *args, **kwargs) + + +class SSLContext(object): + + def __init__(self, ctx): + self._ctx = ctx + + def set_ciphers(self, ciphers): + u''' + s.a. https://www.openssl.org/docs/man1.1.0/apps/ciphers.html + + :param str ciphers: Example "AES256-SHA:ECDHE-ECDSA-AES256-SHA", ... + :return: 1 for success and 0 for failure + ''' + retVal = SSL_CTX_set_cipher_list(self._ctx, ciphers.encode('ascii')) + return retVal + + def set_sigalgs(self, sigalgs): + u''' + s.a. https://www.openssl.org/docs/man1.1.0/ssl/SSL_CTX_set1_sigalgs_list.html + + :param str sigalgs: Example "RSA+SHA256", "ECDSA+SHA256", ... + :return: 1 for success and 0 for failure + ''' + retVal = SSL_CTX_set1_sigalgs_list(self._ctx, sigalgs.encode('ascii')) + return retVal + + def set_curves(self, curves): + u''' Set supported curves by name, nid or nist. + + :param str | tuple(int) curves: Example "secp384r1:secp256k1", (715, 714), "P-384", "K-409:B-409:K-571", ... + :return: 1 for success and 0 for failure + ''' + retVal = None + if isinstance(curves, str): + retVal = SSL_CTX_set1_curves_list(self._ctx, curves.encode('ascii')) + elif isinstance(curves, tuple): + retVal = SSL_CTX_set1_curves(self._ctx, curves.encode('ascii'), len(curves.encode('ascii'))) + return retVal + + @staticmethod + def get_ec_nist2nid(nist): + if not isinstance(nist, tuple): + nist = nist.split(":") + nid = tuple(EC_curve_nist2nid(x) for x in nist) + return nid + + @staticmethod + def get_ec_nid2nist(nid): + if not isinstance(nid, tuple): + nid = (nid, ) + nist = ":".join([EC_curve_nid2nist(x) for x in nid]) + return nist + + @staticmethod + def get_ec_available(bAsName=True): + curves = get_elliptic_curves() + return sorted([x.name for x in curves] if bAsName else [x.nid for x in curves]) + + def set_ecdh_curve(self, curve_name=None): + u''' Select a curve to use for ECDH(E) key exchange or set it to auto mode + + Used for server only! + + s.a. openssl.exe ecparam -list_curves + + :param None | str curve_name: None = Auto-mode, "secp256k1", "secp384r1", ... + :return: 1 for success and 0 for failure + ''' + if curve_name: + retVal = SSL_CTX_set_ecdh_auto(self._ctx, 0) + avail_curves = get_elliptic_curves() + key = [curve for curve in avail_curves if curve.name == curve_name][0].to_EC_KEY() + retVal &= SSL_CTX_set_tmp_ecdh(self._ctx, key) + else: + retVal = SSL_CTX_set_ecdh_auto(self._ctx, 1) + return retVal + + def build_cert_chain(self, flags=SSL_BUILD_CHAIN_FLAG_NONE): + u''' + Used for server side only! + + :param flags: + :return: 1 for success and 0 for failure + ''' + retVal = SSL_CTX_build_cert_chain(self._ctx, flags) + return retVal + + def set_ssl_logging(self, enable=False, func=_ssl_logging_cb): + u''' Enable or disable SSL logging + + :param True | False enable: Enable or disable SSL logging + :param func: Callback function for logging + ''' + if enable: + SSL_CTX_set_info_callback(self._ctx, func) + else: + SSL_CTX_set_info_callback(self._ctx, 0) + + +class SSL(object): + + def __init__(self, ssl): + self._ssl = ssl + + def set_mtu(self, mtu=None): + if mtu: + _logger.debug("set mtu to: %d for ssl: %d", mtu, self._ssl.raw) + SSL_set_options(self._ssl, SSL_OP_NO_QUERY_MTU) + SSL_set_mtu(self._ssl, mtu) + else: + _logger.debug("set mtu to query mode for ssl: %d", self._ssl.raw) + SSL_clear_options(self._ssl, SSL_OP_NO_QUERY_MTU) + + def set_link_mtu(self, mtu=None): + if mtu: + _logger.debug("set DTLS mtu to: %d for ssl: %d", mtu, self._ssl.raw) + SSL_set_options(self._ssl, SSL_OP_NO_QUERY_MTU) + DTLS_set_link_mtu(self._ssl, mtu) + else: + _logger.debug("set mtu to query mode for ssl: %d", self._ssl.raw) + SSL_clear_options(self._ssl, SSL_OP_NO_QUERY_MTU) + + def DTLS_set_timer_cb(self, cb): + _logger.debug("set timer callback to: %s for ssl: %d", repr(cb), self._ssl.raw) + DTLS_set_timer_cb(self._ssl, _CallbackProxy(cb)) + + +class SSLConnection(object): + """DTLS peer association + + This class associates two DTLS peer instances, wrapping OpenSSL library + state including SSL (struct ssl_st), SSL_CTX, and BIO instances. + """ + + _rnd_key = urandom(16) + + def _config_ssl_ctx(self, verify_mode): + SSL_CTX_set_verify(self._ctx.value, verify_mode) + SSL_CTX_set_read_ahead(self._ctx.value, 1) + # Compression occurs at the stream layer now, leading to datagram + # corruption when packet loss occurs + SSL_CTX_set_options(self._ctx.value, SSL_OP_NO_COMPRESSION) + if self._certfile: + SSL_CTX_use_certificate_chain_file(self._ctx.value, fsencode(self._certfile)) + if self._keyfile: + SSL_CTX_use_PrivateKey_file(self._ctx.value, fsencode(self._keyfile), SSL_FILE_TYPE_PEM) + if self._ca_certs: + SSL_CTX_load_verify_locations(self._ctx.value, fsencode(self._ca_certs), None) + # if self._server_side: + # cert_names = SSL_load_client_CA_file(fsencode(self._ca_certs)) + # if cert_names: + # SSL_CTX_set_client_CA_list(self._ctx.value, cert_names) + if self._ciphers: + try: + SSL_CTX_set_cipher_list(self._ctx.value, self._ciphers.encode('ascii')) + except openssl_error() as err: + raise_ssl_error(ERR_NO_CIPHER, err) + if self._user_config_ssl_ctx: + self._user_config_ssl_ctx(self._intf_ssl_ctx) + + def _init_server(self, peer_address): + if (self._sock.type & socket.SOCK_DGRAM) != socket.SOCK_DGRAM: + raise InvalidSocketError("sock must be of type SOCK_DGRAM") + + self._wbio = _BIO(BIO_new_dgram(self._sock.fileno(), BIO_NOCLOSE)) + if peer_address: + # We are connected directly to a client peer, bypassing the demux + rsock = self._sock + BIO_dgram_set_connected(self._wbio.value, peer_address) + else: + # We are starting an UDP listening socket + from .demux import UDPDemux + self._udp_demux = UDPDemux(self._sock) + rsock = self._udp_demux.get_connection(None) + if rsock is self._sock: + self._rbio = self._wbio + else: + _logger.debug("!!! _init_server where rsock != self._sock !!!") + self._rsock = rsock + self._rbio = _BIO(BIO_new_dgram(self._rsock.fileno(), BIO_NOCLOSE)) + server_method = DTLS_server_method + if self._ssl_version == PROTOCOL_DTLSv1_2: + server_method = DTLSv1_2_server_method + elif self._ssl_version == PROTOCOL_DTLSv1: + server_method = DTLSv1_server_method + self._ctx = _CTX(SSL_CTX_new(server_method())) + self._intf_ssl_ctx = SSLContext(self._ctx.value) + SSL_CTX_set_session_cache_mode(self._ctx.value, SSL_SESS_CACHE_OFF) + if self._cert_reqs == CERT_NONE: + verify_mode = SSL_VERIFY_NONE + elif self._cert_reqs == CERT_OPTIONAL: + verify_mode = SSL_VERIFY_PEER | SSL_VERIFY_CLIENT_ONCE + else: + verify_mode = SSL_VERIFY_PEER | SSL_VERIFY_CLIENT_ONCE | \ + SSL_VERIFY_FAIL_IF_NO_PEER_CERT + self._config_ssl_ctx(verify_mode) + if not peer_address: + # Configure UDP listening socket + self._listening = False + self._listening_peer_address = None + self._pending_peer_address = None + self._cb_keepalive = SSL_CTX_set_cookie_cb( + self._ctx.value, + _CallbackProxy(self._generate_cookie_cb), + _CallbackProxy(self._verify_cookie_cb)) + self._ssl = _SSL(SSL_new(self._ctx.value)) + self._intf_ssl = SSL(self._ssl.value) + SSL_set_accept_state(self._ssl.value) + if self._user_config_ssl: + self._user_config_ssl(self._intf_ssl) + if peer_address and self._do_handshake_on_connect: + return lambda: self.do_handshake() + + def _init_client(self, peer_address): + if (self._sock.type & socket.SOCK_DGRAM) != socket.SOCK_DGRAM: + raise InvalidSocketError("sock must be of type SOCK_DGRAM") + + self._wbio = _BIO(BIO_new_dgram(self._sock.fileno(), BIO_NOCLOSE)) + self._rbio = self._wbio + client_method = DTLSv1_2_client_method # no "any" exists, therefore use v1_2 (highest possible) + if self._ssl_version == PROTOCOL_DTLSv1_2: + client_method = DTLSv1_2_client_method + elif self._ssl_version == PROTOCOL_DTLSv1: + client_method = DTLSv1_client_method + self._ctx = _CTX(SSL_CTX_new(client_method())) + self._intf_ssl_ctx = SSLContext(self._ctx.value) + if self._cert_reqs == CERT_NONE: + verify_mode = SSL_VERIFY_NONE + else: + verify_mode = SSL_VERIFY_PEER + self._config_ssl_ctx(verify_mode) + self._ssl = _SSL(SSL_new(self._ctx.value)) + self._intf_ssl = SSL(self._ssl.value) + SSL_set_connect_state(self._ssl.value) + if self._user_config_ssl: + self._user_config_ssl(self._intf_ssl) + if peer_address: + return lambda: self.connect(peer_address) + + def _copy_server(self): + source = self._sock + self._udp_demux = source._udp_demux + rsock = self._udp_demux.get_connection(source._pending_peer_address) + self._ctx = source._ctx + self._ssl = source._ssl + self._intf_ssl = source._intf_ssl + new_source_wbio = _BIO(BIO_new_dgram(source._sock.fileno(), BIO_NOCLOSE)) + if hasattr(source, "_rsock"): + _logger.debug("copy_server with rsock!") + self._sock = source._sock + self._rsock = rsock + self._wbio = _BIO(BIO_new_dgram(self._sock.fileno(), BIO_NOCLOSE)) + self._rbio = _BIO(BIO_new_dgram(self._rsock.fileno(), BIO_NOCLOSE)) + new_source_rbio = _BIO(BIO_new_dgram(source._rsock.fileno(), BIO_NOCLOSE)) + BIO_dgram_set_peer(self._wbio.value, source._pending_peer_address) + else: + _logger.debug("copy_server for client fork") + self._sock = rsock + self._wbio = _BIO(BIO_new_dgram(self._sock.fileno(), BIO_NOCLOSE)) + self._rbio = self._wbio + new_source_rbio = new_source_wbio + BIO_dgram_set_connected(self._wbio.value, source._pending_peer_address) + source._ssl = _SSL(SSL_new(self._ctx.value)) + source._intf_ssl = SSL(source._ssl.value) + SSL_set_accept_state(source._ssl.value) + if self._user_config_ssl: + self._user_config_ssl(self._intf_ssl) # Why is this not source._intf_ssl? If it is, then the mtu size is not set correctly!? + source._rbio = new_source_rbio + source._wbio = new_source_wbio + SSL_set_bio(source._ssl.value, new_source_rbio.value, new_source_wbio.value) + new_source_rbio.disown() + new_source_wbio.disown() + + def _reconnect_unwrapped(self): + _logger.debug("reconnect unwrapped socket") + source = self._sock + self._sock = source._wsock + self._udp_demux = source._demux + self._rsock = source._rsock + self._ctx = source._ctx + self._wbio = _BIO(BIO_new_dgram(self._sock.fileno(), BIO_NOCLOSE)) + self._rbio = _BIO(BIO_new_dgram(self._rsock.fileno(), BIO_NOCLOSE)) + BIO_dgram_set_peer(self._wbio.value, source._peer_address) + self._ssl = _SSL(SSL_new(self._ctx.value)) + self._intf_ssl = SSL(self._ssl.value) + SSL_set_accept_state(self._ssl.value) + if self._user_config_ssl: + self._user_config_ssl(self._intf_ssl) + if self._do_handshake_on_connect: + return lambda: self.do_handshake() + + def _check_nbio(self): + timeout = self._sock.gettimeout() + if self._wbio_nb != timeout is not None: + BIO_set_nbio(self._wbio.value, timeout is not None) + self._wbio_nb = timeout is not None + if self._wbio is not self._rbio: + timeout = self._rsock.gettimeout() + if self._rbio_nb != timeout is not None: + BIO_set_nbio(self._rbio.value, timeout is not None) + self._rbio_nb = timeout is not None + return timeout # read channel timeout + + def _wrap_socket_library_call(self, call, timeout_error): + timeout_sec_start = timeout_sec = self._check_nbio() + # Pass the call if the socket is blocking or non-blocking + if not timeout_sec: # None (blocking) or zero (non-blocking) + return call() + start_time = datetime.datetime.now() + read_sock = self.get_socket(True) + need_select = False + while timeout_sec > 0: + if need_select: + if not select([read_sock], [], [], timeout_sec)[0]: + break + timeout_sec = timeout_sec_start - \ + (datetime.datetime.now() - start_time).total_seconds() + try: + return call() + except openssl_error() as err: + if err.ssl_error == SSL_ERROR_WANT_READ: + need_select = True + continue + raise + raise_ssl_error(timeout_error) + + def _get_cookie(self, ssl): + _logger.debug("Get cookie for ssl: %d", ssl.raw) + rbio = SSL_get_rbio(ssl) + peer_address = BIO_dgram_get_peer(rbio) + cookie_hmac = hmac.new(self._rnd_key, str(peer_address).encode(), hashlib.md5) + return cookie_hmac.digest() + + def _generate_cookie_cb(self, ssl): + return self._get_cookie(ssl) + + def _verify_cookie_cb(self, ssl, cookie): + if self._get_cookie(ssl) != cookie: + raise Exception("DTLS cookie mismatch") + + def __init__(self, sock, keyfile=None, certfile=None, + server_side=False, cert_reqs=CERT_NONE, + ssl_version=PROTOCOL_DTLS, ca_certs=None, + do_handshake_on_connect=True, + suppress_ragged_eofs=True, ciphers=None, + cb_user_config_ssl_ctx=None, + cb_user_config_ssl=None): + """Constructor + + Arguments: + these arguments match the ones of the SSLSocket class in the + standard library's ssl module + """ + + if keyfile and not certfile or certfile and not keyfile: + raise_ssl_error(ERR_BOTH_KEY_CERT_FILES) + if server_side and not keyfile: + raise_ssl_error(ERR_BOTH_KEY_CERT_FILES_SVR) + # if cert_reqs != CERT_NONE and not ca_certs: + # raise_ssl_error(ERR_NO_CERTS) + + if not ciphers: + ciphers = "DEFAULT" + + self._sock = sock + self._keyfile = keyfile + self._certfile = certfile + self._cert_reqs = cert_reqs + self._ssl_version = ssl_version + self._ca_certs = ca_certs + self._do_handshake_on_connect = do_handshake_on_connect + self._suppress_ragged_eofs = suppress_ragged_eofs + self._ciphers = ciphers + self._handshake_done = False + self._wbio_nb = self._rbio_nb = False + self._server_side = server_side + + self._user_config_ssl_ctx = cb_user_config_ssl_ctx + self._intf_ssl_ctx = None + self._user_config_ssl = cb_user_config_ssl + self._intf_ssl = None + + if isinstance(sock, SSLConnection): + post_init = self._copy_server() + elif isinstance(sock, _UnwrappedSocket): + post_init = self._reconnect_unwrapped() + else: + # Standard OS socket? Is it connected to a peer? + try: + peer_address = sock.getpeername() + except socket.error: + peer_address = None + if self._server_side: + post_init = self._init_server(peer_address) + else: + post_init = self._init_client(peer_address) + + if False: # sys.platform.startswith('win') and not (SSL_get_options(self._ssl.value) & SSL_OP_NO_QUERY_MTU): + SSL_set_options(self._ssl.value, SSL_OP_NO_QUERY_MTU) + DTLS_set_link_mtu(self._ssl.value, 1350) + + SSL_set_bio(self._ssl.value, self._rbio.value, self._wbio.value) + self._rbio.disown() + self._wbio.disown() + if post_init: + post_init() + + def __del__(self): + self._sock.detach() + del self._sock + remove_from_timer_callbacks(self._ssl.value) + remove_from_info_callback(self._ctx.value) + if hasattr(self, '_ssl'): + del self._intf_ssl + del self._ssl + if hasattr(self, '_ctx'): + del self._intf_ssl_ctx + del self._ctx + + def get_socket(self, inbound): + """Retrieve a socket used by this connection + + When inbound is True, then the socket from which this connection reads + data is retrieved. Otherwise the socket to which this connection writes + data is retrieved. + + Read and write sockets differ depending on whether this is a server- or + a client-side connection, and on whether a routing demux is in use. + """ + + if inbound and hasattr(self, "_rsock"): + return self._rsock + return self._sock + + def listen(self): + """Server-side cookie exchange + + This method reads datagrams from the socket and initiates cookie + exchange, upon whose successful conclusion one can then proceed to + the accept method. Alternatively, accept can be called directly, in + which case it will call this method. In order to prevent denial-of- + service attacks, only a small, constant set of computing resources + are used during the listen phase. + + On some platforms, listen must be called so that packets will be + forwarded to accepted connections. Doing so is therefore recommened + in all cases for portable code. + + Return value: a peer address if a datagram from a new peer was + encountered, None if a datagram for a known peer was forwarded + """ + + if not hasattr(self, "_listening"): + raise InvalidSocketError("listen called on non-listening socket") + + self._pending_peer_address = None + try: + peer_address = self._udp_demux.service() + except socket.timeout: + peer_address = None + except socket.error as sock_err: + if sock_err.errno != errno.EWOULDBLOCK: + _logger.exception("Unexpected socket error in listen") + raise + peer_address = None + + if not peer_address: + _logger.debug("Listen returning without peer") + return + + # The demux advises that a datagram from a new peer may have arrived + if type(peer_address) is tuple: + # For this type of demux, the write BIO must be pointed at the peer + BIO_dgram_set_peer(self._wbio.value, peer_address) + self._udp_demux.forward() + self._listening_peer_address = peer_address + + timeout = self._check_nbio() + self._listening = True + try: + _logger.debug("Invoking DTLSv1_listen for ssl: %d", self._ssl.raw) + start_time = datetime.datetime.now() + while True: + dtls_peer_address = DTLSv1_listen(self._ssl.value) + if timeout: + if (datetime.datetime.now() - start_time).total_seconds() > timeout: + break + if type(dtls_peer_address) is tuple: + break + except openssl_error() as err: + if err.ssl_error == SSL_ERROR_WANT_READ: + # This method must be called again to forward the next datagram + _logger.debug("DTLSv1_listen must be resumed") + return + elif err.errqueue and err.errqueue[0][0] == ERR_WRONG_VERSION_NUMBER: + _logger.debug("Wrong version number; aborting handshake") + raise + elif err.errqueue and err.errqueue[0][0] == ERR_COOKIE_MISMATCH: + _logger.debug("Mismatching cookie received; aborting handshake") + raise + elif err.errqueue and err.errqueue[0][0] == ERR_NO_SHARED_CIPHER: + _logger.debug("No shared cipher; aborting handshake") + raise + _logger.exception("Unexpected error in DTLSv1_listen") + raise + self._listening = False + self._listening_peer_address = None + _logger.debug( + "peer_address from demux: %s and dtls_peer_address from DTLSv1_listen: %s", + repr(peer_address), repr(dtls_peer_address) + ) + if type(peer_address) is tuple: + self._pending_peer_address = peer_address + else: + self._pending_peer_address = dtls_peer_address + _logger.debug("New peer: %s", self._pending_peer_address) + return self._pending_peer_address + + def accept(self): + """Server-side UDP connection establishment + + This method returns a server-side SSLConnection object, connected to + that peer most recently returned from the listen method and not yet + connected. If there is no such peer, then the listen method is invoked. + + Return value: SSLConnection connected to a new peer, None if packet + forwarding only to an existing peer occurred. + """ + + if not self._pending_peer_address: + if not self.listen(): + _logger.debug("Accept returning without connection") + return + new_conn = SSLConnection(self, self._keyfile, self._certfile, True, + self._cert_reqs, self._ssl_version, + self._ca_certs, self._do_handshake_on_connect, + self._suppress_ragged_eofs, self._ciphers, + cb_user_config_ssl_ctx=self._user_config_ssl_ctx, + cb_user_config_ssl=self._user_config_ssl) + new_peer = self._pending_peer_address + self._pending_peer_address = None + if self._do_handshake_on_connect: + # Note that since that connection's socket was just created in its + # constructor, the following operation must be blocking; hence + # handshake-on-connect can only be used with a routing demux if + # listen is serviced by a separate application thread, or else we + # will hang in this call + new_conn.do_handshake() + _logger.debug("Accept returning new connection for new peer") + return new_conn, new_peer + + def connect(self, peer_address): + """Client-side UDP connection establishment + + This method connects this object's underlying socket. It subsequently + performs a handshake if do_handshake_on_connect was set during + initialization. + + Arguments: + peer_address - address tuple of server peer + """ + + self._sock.connect(peer_address) + peer_address = self._sock.getpeername() # substituted host addrinfo + BIO_dgram_set_connected(self._wbio.value, peer_address) + assert self._wbio is self._rbio + if self._do_handshake_on_connect: + self.do_handshake() + + def do_handshake(self): + """Perform a handshake with the peer + + This method forces an explicit handshake to be performed with either + the client or server peer. + """ + + _logger.debug("Initiating handshake...") + try: + self._wrap_socket_library_call( + lambda: SSL_do_handshake(self._ssl.value), + ERR_HANDSHAKE_TIMEOUT) + except openssl_error() as err: + if err.ssl_error == SSL_ERROR_SYSCALL and err.result == -1: + raise_ssl_error(ERR_PORT_UNREACHABLE, err) + raise + self._handshake_done = True + _logger.debug("...completed handshake") + + def read(self, len=1024, buffer=None): + """Read data from connection + + Read up to len bytes and return them. + Arguments: + len -- maximum number of bytes to read + + Return value: + string containing read bytes + """ + + try: + return self._wrap_socket_library_call( + lambda: SSL_read(self._ssl.value, len, buffer), ERR_READ_TIMEOUT) + except openssl_error() as err: + if err.ssl_error == SSL_ERROR_SYSCALL and err.result == -1: + raise_ssl_error(ERR_PORT_UNREACHABLE, err) + raise + + def write(self, data): + """Write data to connection + + Write data as string of bytes. + + Arguments: + data -- buffer containing data to be written + + Return value: + number of bytes actually transmitted + """ + + try: + ret = self._wrap_socket_library_call( + lambda: SSL_write(self._ssl.value, data), ERR_WRITE_TIMEOUT) + except openssl_error() as err: + if err.ssl_error == SSL_ERROR_SYSCALL and err.result == -1: + raise_ssl_error(ERR_PORT_UNREACHABLE, err) + raise + except: + raise + if ret: + self._handshake_done = True + return ret + + def shutdown(self): + """Shut down the DTLS connection + + This method attempts to complete a bidirectional shutdown between + peers. For non-blocking sockets, it should be called repeatedly until + it no longer raises continuation request exceptions. + """ + + if hasattr(self, "_listening"): + # Listening server-side sockets cannot be shut down + return + + try: + _logger.debug("Starting shutdown %s", "server-side" if self._server_side else "client-side") + self._wrap_socket_library_call( + lambda: SSL_shutdown(self._ssl.value), ERR_READ_TIMEOUT) + _logger.debug("...completed shutdown %s", "server-side" if self._server_side else "client-side") + except openssl_error() as err: + if err.result == 0: + # close-notify alert was just sent; wait for same from peer + # Note: while it might seem wise to suppress further read-aheads + # with SSL_set_read_ahead here, doing so causes a shutdown + # failure (ret: -1, SSL_ERROR_SYSCALL) on the DTLS shutdown + # initiator side. And test_starttls does pass. + _logger.debug("Initiated shutdown %s...", "server-side" if self._server_side else "client-side") + self._wrap_socket_library_call( + lambda: SSL_shutdown(self._ssl.value), ERR_READ_TIMEOUT) + _logger.debug("...completed shutdown %s", "server-side" if self._server_side else "client-side") + else: + raise + if self._server_side: + SSL_set_accept_state(self._ssl.value) + else: + SSL_set_connect_state(self._ssl.value) + if hasattr(self, "_rsock"): + # Return wrapped connected server socket (non-listening) + return _UnwrappedSocket(self._sock, self._rsock, self._udp_demux, + self._ctx, + BIO_dgram_get_peer(self._wbio.value)) + # Return unwrapped client-side socket or unwrapped server-side socket + # for single-socket servers + return self._sock + + def getpeercert(self, binary_form=False): + """Retrieve the peer's certificate + + When binary form is requested, the peer's DER-encoded certficate is + returned if it was transmitted during the handshake. + + When binary form is not requested, and the peer's certificate has been + validated, then a certificate dictionary is returned. If the certificate + was not validated, an empty dictionary is returned. + + In all cases, None is returned if no certificate was received from the + peer. + """ + + try: + peer_cert = _X509(SSL_get_peer_certificate(self._ssl.value)) + except openssl_error(): + return + + if binary_form: + return i2d_X509(peer_cert.value) + if self._cert_reqs == CERT_NONE: + return {} + return decode_cert(peer_cert) + + peer_certificate = getpeercert # compatibility with _ssl call interface + + def getpeercertchain(self, binary_form=False): + try: + stack, num, certs = SSL_get_peer_cert_chain(self._ssl.value) + except openssl_error(): + return + + peer_cert_chain = [_Rsrc(cert) for cert in certs] + ret = [] + if binary_form: + ret = [i2d_X509(x.value) for x in peer_cert_chain] + elif len(peer_cert_chain): + ret = [decode_cert(x) for x in peer_cert_chain] + + return ret + + def cipher(self): + """Retrieve information about the current cipher + + Return a triple consisting of cipher name, SSL protocol version defining + its use, and the number of secret bits. Return None if handshaking + has not been completed. + """ + + if not self._handshake_done: + return + + current_cipher = SSL_get_current_cipher(self._ssl.value) + cipher_name = SSL_CIPHER_get_name(current_cipher) + cipher_version = SSL_CIPHER_get_version(current_cipher) + cipher_bits = SSL_CIPHER_get_bits(current_cipher) + return cipher_name, cipher_version, cipher_bits + + def pending(self): + """Retrieve number of buffered bytes + + Return the number of bytes that have been read from the socket and + buffered by this connection. Return 0 if no bytes have been buffered. + """ + + return SSL_pending(self._ssl.value) + + def get_timeout(self): + """Retrieve the retransmission timedelta + + Since datagrams are subject to packet loss, DTLS will perform + packet retransmission if a response is not received after a certain + time interval during the handshaking phase. When using non-blocking + sockets, the application must call back after that time interval to + allow for the retransmission to occur. This method returns the + timedelta after which to perform the call to handle_timeout, or None + if no such callback is needed given the current handshake state. + """ + + return DTLSv1_get_timeout(self._ssl.value) + + def handle_timeout(self): + """Perform datagram retransmission, if required + + This method should be called after the timedelta retrieved from + get_timeout has expired, and no datagrams were received in the + meantime. If datagrams were received, a new timeout needs to be + requested. + + Return value: + True -- retransmissions were performed successfully + False -- a timeout was not in effect or had not yet expired + + Exceptions: + Raised when retransmissions fail or too many timeouts occur. + """ + + return DTLSv1_handle_timeout(self._ssl.value) + + def unwrap(self): + try: + s = self.shutdown() + except Exception as e: + _logger.exception(e) + s = self._sock + return s + + +class _UnwrappedSocket(socket.socket): + """Unwrapped server-side socket + + Depending on UDP demux implementation, there may not be single socket + that can be used for both reading and writing to the client socket with + which it is associated. An object of this type is therefore returned from + the SSLSocket's unwrap method to allow for unencrypted communication over + the established channels, including the demux. + """ + + def __init__(self, wsock, rsock, demux, ctx, peer_address): + socket.socket.__init__(self, _sock=rsock._sock) + for attr in "send", "sendto", "sendall": + try: + delattr(self, attr) + except AttributeError: + pass + self._wsock = wsock + self._rsock = rsock # continue to reference to hold in demux map + self._demux = demux + self._ctx = ctx + self._peer_address = peer_address + + def send(self, data, flags=0): + __doc__ = self._wsock.send.__doc__ + return self._wsock.sendto(data, flags, self._peer_address) + + def sendto(self, data, flags_or_addr, addr=None): + __doc__ = self._wsock.sendto.__doc__ + return self._wsock.sendto(data, flags_or_addr, addr) + + def sendall(self, data, flags=0): + __doc__ = self._wsock.sendall.__doc__ + amount = len(data) + count = 0 + while (count < amount): + v = self.send(data[count:], flags) + count += v + return amount + + def getpeername(self): + __doc__ = self._wsock.getpeername.__doc__ + return self._peer_address + + def connect(self, addr): + __doc__ = self._wsock.connect.__doc__ + raise ValueError("Cannot connect already connected unwrapped socket") + + connect_ex = connect diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/__init__.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/__init__.py new file mode 100644 index 0000000..6c0d677 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/__init__.py @@ -0,0 +1,22 @@ +# Test: unit tests for PyDTLS. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""PyDTLs unit tests + +This package contains unit tests and other test scripts and resources. +""" diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badcert.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badcert.pem new file mode 100644 index 0000000..c419146 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badcert.pem @@ -0,0 +1,36 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXwIBAAKBgQC8ddrhm+LutBvjYcQlnH21PPIseJ1JVG2HMmN2CmZk2YukO+9L +opdJhTvbGfEj0DQs1IE8M+kTUyOmuKfVrFMKwtVeCJphrAnhoz7TYOuLBSqt7lVH +fhi/VwovESJlaBOp+WMnfhcduPEYHYx/6cnVapIkZnLt30zu2um+DzA9jQIDAQAB +AoGBAK0FZpaKj6WnJZN0RqhhK+ggtBWwBnc0U/ozgKz2j1s3fsShYeiGtW6CK5nU +D1dZ5wzhbGThI7LiOXDvRucc9n7vUgi0alqPQ/PFodPxAN/eEYkmXQ7W2k7zwsDA +IUK0KUhktQbLu8qF/m8qM86ba9y9/9YkXuQbZ3COl5ahTZrhAkEA301P08RKv3KM +oXnGU2UHTuJ1MAD2hOrPxjD4/wxA/39EWG9bZczbJyggB4RHu0I3NOSFjAm3HQm0 +ANOu5QK9owJBANgOeLfNNcF4pp+UikRFqxk5hULqRAWzVxVrWe85FlPm0VVmHbb/ +loif7mqjU8o1jTd/LM7RD9f2usZyE2psaw8CQQCNLhkpX3KO5kKJmS9N7JMZSc4j +oog58yeYO8BBqKKzpug0LXuQultYv2K4veaIO04iL9VLe5z9S/Q1jaCHBBuXAkEA +z8gjGoi1AOp6PBBLZNsncCvcV/0aC+1se4HxTNo2+duKSDnbq+ljqOM+E7odU+Nq +ewvIWOG//e8fssd0mq3HywJBAJ8l/c8GVmrpFTx8r/nZ2Pyyjt3dH1widooDXYSV +q6Gbf41Llo5sYAtmxdndTLASuHKecacTgZVhy0FryZpLKrU= +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +Just bad cert data +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +MIICXwIBAAKBgQC8ddrhm+LutBvjYcQlnH21PPIseJ1JVG2HMmN2CmZk2YukO+9L +opdJhTvbGfEj0DQs1IE8M+kTUyOmuKfVrFMKwtVeCJphrAnhoz7TYOuLBSqt7lVH +fhi/VwovESJlaBOp+WMnfhcduPEYHYx/6cnVapIkZnLt30zu2um+DzA9jQIDAQAB +AoGBAK0FZpaKj6WnJZN0RqhhK+ggtBWwBnc0U/ozgKz2j1s3fsShYeiGtW6CK5nU +D1dZ5wzhbGThI7LiOXDvRucc9n7vUgi0alqPQ/PFodPxAN/eEYkmXQ7W2k7zwsDA +IUK0KUhktQbLu8qF/m8qM86ba9y9/9YkXuQbZ3COl5ahTZrhAkEA301P08RKv3KM +oXnGU2UHTuJ1MAD2hOrPxjD4/wxA/39EWG9bZczbJyggB4RHu0I3NOSFjAm3HQm0 +ANOu5QK9owJBANgOeLfNNcF4pp+UikRFqxk5hULqRAWzVxVrWe85FlPm0VVmHbb/ +loif7mqjU8o1jTd/LM7RD9f2usZyE2psaw8CQQCNLhkpX3KO5kKJmS9N7JMZSc4j +oog58yeYO8BBqKKzpug0LXuQultYv2K4veaIO04iL9VLe5z9S/Q1jaCHBBuXAkEA +z8gjGoi1AOp6PBBLZNsncCvcV/0aC+1se4HxTNo2+duKSDnbq+ljqOM+E7odU+Nq +ewvIWOG//e8fssd0mq3HywJBAJ8l/c8GVmrpFTx8r/nZ2Pyyjt3dH1widooDXYSV +q6Gbf41Llo5sYAtmxdndTLASuHKecacTgZVhy0FryZpLKrU= +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +Just bad cert data +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badkey.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badkey.pem new file mode 100644 index 0000000..1c8a955 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/badkey.pem @@ -0,0 +1,40 @@ +-----BEGIN RSA PRIVATE KEY----- +Bad Key, though the cert should be OK +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIICpzCCAhCgAwIBAgIJAP+qStv1cIGNMA0GCSqGSIb3DQEBBQUAMIGJMQswCQYD +VQQGEwJVUzERMA8GA1UECBMIRGVsYXdhcmUxEzARBgNVBAcTCldpbG1pbmd0b24x +IzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9uMQwwCgYDVQQLEwNT +U0wxHzAdBgNVBAMTFnNvbWVtYWNoaW5lLnB5dGhvbi5vcmcwHhcNMDcwODI3MTY1 +NDUwWhcNMTMwMjE2MTY1NDUwWjCBiTELMAkGA1UEBhMCVVMxETAPBgNVBAgTCERl +bGF3YXJlMRMwEQYDVQQHEwpXaWxtaW5ndG9uMSMwIQYDVQQKExpQeXRob24gU29m +dHdhcmUgRm91bmRhdGlvbjEMMAoGA1UECxMDU1NMMR8wHQYDVQQDExZzb21lbWFj +aGluZS5weXRob24ub3JnMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8ddrh +m+LutBvjYcQlnH21PPIseJ1JVG2HMmN2CmZk2YukO+9LopdJhTvbGfEj0DQs1IE8 +M+kTUyOmuKfVrFMKwtVeCJphrAnhoz7TYOuLBSqt7lVHfhi/VwovESJlaBOp+WMn +fhcduPEYHYx/6cnVapIkZnLt30zu2um+DzA9jQIDAQABoxUwEzARBglghkgBhvhC +AQEEBAMCBkAwDQYJKoZIhvcNAQEFBQADgYEAF4Q5BVqmCOLv1n8je/Jw9K669VXb +08hyGzQhkemEBYQd6fzQ9A/1ZzHkJKb1P6yreOLSEh4KcxYPyrLRC1ll8nr5OlCx +CMhKkTnR6qBsdNV0XtdU2+N25hqW+Ma4ZeqsN/iiJVCGNOZGnvQuvCAGWF8+J/f/ +iHkC6gGdBJhogs4= +-----END CERTIFICATE----- +-----BEGIN RSA PRIVATE KEY----- +Bad Key, though the cert should be OK +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIICpzCCAhCgAwIBAgIJAP+qStv1cIGNMA0GCSqGSIb3DQEBBQUAMIGJMQswCQYD +VQQGEwJVUzERMA8GA1UECBMIRGVsYXdhcmUxEzARBgNVBAcTCldpbG1pbmd0b24x +IzAhBgNVBAoTGlB5dGhvbiBTb2Z0d2FyZSBGb3VuZGF0aW9uMQwwCgYDVQQLEwNT +U0wxHzAdBgNVBAMTFnNvbWVtYWNoaW5lLnB5dGhvbi5vcmcwHhcNMDcwODI3MTY1 +NDUwWhcNMTMwMjE2MTY1NDUwWjCBiTELMAkGA1UEBhMCVVMxETAPBgNVBAgTCERl +bGF3YXJlMRMwEQYDVQQHEwpXaWxtaW5ndG9uMSMwIQYDVQQKExpQeXRob24gU29m +dHdhcmUgRm91bmRhdGlvbjEMMAoGA1UECxMDU1NMMR8wHQYDVQQDExZzb21lbWFj +aGluZS5weXRob24ub3JnMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC8ddrh +m+LutBvjYcQlnH21PPIseJ1JVG2HMmN2CmZk2YukO+9LopdJhTvbGfEj0DQs1IE8 +M+kTUyOmuKfVrFMKwtVeCJphrAnhoz7TYOuLBSqt7lVHfhi/VwovESJlaBOp+WMn +fhcduPEYHYx/6cnVapIkZnLt30zu2um+DzA9jQIDAQABoxUwEzARBglghkgBhvhC +AQEEBAMCBkAwDQYJKoZIhvcNAQEFBQADgYEAF4Q5BVqmCOLv1n8je/Jw9K669VXb +08hyGzQhkemEBYQd6fzQ9A/1ZzHkJKb1P6yreOLSEh4KcxYPyrLRC1ll8nr5OlCx +CMhKkTnR6qBsdNV0XtdU2+N25hqW+Ma4ZeqsN/iiJVCGNOZGnvQuvCAGWF8+J/f/ +iHkC6gGdBJhogs4= +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert.pem new file mode 100644 index 0000000..e125a65 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICCzCCAXQCCQCwvSKaN4J3cTANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQGEwJV +UzETMBEGA1UECBMKV2FzaGluZ3RvbjETMBEGA1UEChMKUmF5IENBIEluYzERMA8G +A1UEAxMIUmF5Q0FJbmMwHhcNMTQwMTE4MjEwMjUwWhcNMjQwMTE2MjEwMjUwWjBK +MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjETMBEGA1UEChMKUmF5 +IENBIEluYzERMA8GA1UEAxMIUmF5Q0FJbmMwgZ8wDQYJKoZIhvcNAQEBBQADgY0A +MIGJAoGBAN/UYXt4uq+YdTDnm7WPCu+0B50kJXWU3sSS+WAAhr3BHh7qa7UTiRXy +yGYysgvtwriETAZRckzd+hdblNRUWXGJdRvtyx94nLpPpI8p4djBrJ5IMPqK5SgW +ZP4XTWs694VtUBAvHCX+Ly+t0O5Rw3NmqxY1MakooqU9t+wL0H0TAgMBAAEwDQYJ +KoZIhvcNAQEFBQADgYEANemjvYCJrTc/6im0DmDC6AW8KrLG0xj31HWpq1dO9LG7 +mlVFgbVtbcuCZgA78kxgw1vN6kBBLEsAJC8gkg++AO/w3a4oP+U9txAr9KRg6IGA +FiUohuWbjKBnQEpceoECgrymooF3ayzke/vf3wcMYy153uC+H4t96Yc5T066c4o= +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert_ec.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert_ec.pem new file mode 100644 index 0000000..b215e54 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/ca-cert_ec.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBgzCCASoCCQDdMwvUA/R3lzAKBggqhkjOPQQDAzBKMQswCQYDVQQGEwJVUzET +MBEGA1UECAwKV2FzaGluZ3RvbjETMBEGA1UECgwKUmF5IENBIEluYzERMA8GA1UE +AwwIUmF5Q0FJbmMwHhcNMTcwMzA3MDgzNjU3WhcNMjcwMzA1MDgzNjU3WjBKMQsw +CQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjETMBEGA1UECgwKUmF5IENB +IEluYzERMA8GA1UEAwwIUmF5Q0FJbmMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC +AASD4xiQkPryjEwUl/GYeGu1CSA3UC6BUY3TiGED3zrC5Bn/POaVVn9GGOQMZUFi +rCkuTgfg/qeIzTrTFndiR5C/MAoGCCqGSM49BAMDA0cAMEQCIHpd9qMvZZV6iaB5 +HrmlyfmhIuLBxDQra20Uxl2Y8N64AiAmPKqwPPp7z6IT2AzAXyHCPoVxwWA0NfGx +nmXoYpDFlw== +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert.pem new file mode 100644 index 0000000..696cb73 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert.pem @@ -0,0 +1,30 @@ +-----BEGIN PRIVATE KEY----- +MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBANjL+g7MpTEB40Vo +2pxWbx33YwgXQ6QbnLg1QyKlrH6DEEotyDRWI/ZftvWbjGUh0zUGhQaLzF3ZNgdM +VkF5j0wCgRdwPon1ct5wJUg6GCWvfi4B/HlQrWg8JDaWoGuDcTqLh6KYfDdWTlWC +Bq3pOW14gVe3d12R8Bxu9PCK8jrvAgMBAAECgYAQFjqs5HSRiWFS4i/uj99Y6uV3 +UTqcr8vWQ2WC6aY+EP2hc3o6n/W1L28FFJC7ZGImuiAe1zrH7/k5W2m/HAUM7M9p +oBcp7ZVMFU6R00cQWVKCpQRCpNHnn+tVJdRGiHRj9836/u2z3shBxDYgXJIR787V +SlBXkCcsi0Clem5ocQJBAPp/0tF4CpoaOCAnNN+rDjPNGcH57lmpSZBMXZVAVCRq +vJDdH9SIcb19gKToCF1MUd7CJWbSHKxh49Hr+prBW8cCQQDdjrH8EZ4CDYvoJbVX +iWFfbh6lPwv8uaj43HoHq4+51mhHvLxO8a1AKMSgD2cg7yJYYIpTTAf21gqU3Yt9 +wJeZAkEAl75e4u0o3vkLDs8xRFzGmbKg69SPAll+ap8YAZWaYwUVfVu2MHUHEZa5 +GyxEBOB6p8pMBeE55WLXMw8UHDMNeQJADEWRGjMnm1mAvFUKXFThrdV9oQ2C7nai +I1ai87XO+i4kDIUpsP216O3ZJjx0K+DS+C4wuzhk4IkugNxck5SNUQJASxf8E4z5 +W5rP2XXIohGpDyzI+criUYQ6340vKB9bPsCQ2QooQq1BH0wGA2fY82Kr95E8KhUo +zGoP1DtpzgwOQg== +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIICDTCCAXYCCQCxc2uXBLZhDjANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQGEwJV +UzETMBEGA1UECBMKV2FzaGluZ3RvbjETMBEGA1UEChMKUmF5IENBIEluYzERMA8G +A1UEAxMIUmF5Q0FJbmMwHhcNMTQwMTE4MjEwMjUwWhcNMjQwMTE2MjEwMjUwWjBM +MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEUMBIGA1UEChMLUmF5 +IFNydiBJbmMxEjAQBgNVBAMTCVJheVNydkluYzCBnzANBgkqhkiG9w0BAQEFAAOB +jQAwgYkCgYEA2Mv6DsylMQHjRWjanFZvHfdjCBdDpBucuDVDIqWsfoMQSi3INFYj +9l+29ZuMZSHTNQaFBovMXdk2B0xWQXmPTAKBF3A+ifVy3nAlSDoYJa9+LgH8eVCt +aDwkNpaga4NxOouHoph8N1ZOVYIGrek5bXiBV7d3XZHwHG708IryOu8CAwEAATAN +BgkqhkiG9w0BAQUFAAOBgQBw0XUTYzfiI0Fi9g4GuyWD2hjET3NtrT4Ccu+Jiivy +EvwhzHtVGAPhrV+VCL8sS9uSOZlmfK/ZVraDiFGpJLDMvPP5y5fwq5VGrFuZispG +X6bTBq2AIKzGGXxhwPqD8F7su7bmZDnZFRMRk2Bh16rv0mtzx9yHtqC5YJZ2a3JK +2g== +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert_ec.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert_ec.pem new file mode 100644 index 0000000..e76947d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/keycert_ec.pem @@ -0,0 +1,19 @@ +-----BEGIN EC PARAMETERS----- +BggqhkjOPQMBBw== +-----END EC PARAMETERS----- +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIEMWCku4TqKwrQdeECm5LQPCBnr7+cqE4InlRYeObLOxoAoGCCqGSM49 +AwEHoUQDQgAEgroFe2fym1V7E3zr/zjuJixpyAjwfig+UTsxxm/04IvXzk2jQCQC +TgbDVohJ8dgh4iEENZv2axWye7XCBzbftQ== +-----END EC PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBhjCCASwCCQCZ3L2TA/e93zAKBggqhkjOPQQDAzBKMQswCQYDVQQGEwJVUzET +MBEGA1UECAwKV2FzaGluZ3RvbjETMBEGA1UECgwKUmF5IENBIEluYzERMA8GA1UE +AwwIUmF5Q0FJbmMwHhcNMTcwMzA3MDgzNjU4WhcNMjcwMzA1MDgzNjU4WjBMMQsw +CQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjEUMBIGA1UECgwLUmF5IFNy +diBJbmMxEjAQBgNVBAMMCVJheVNydkluYzBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABIK6BXtn8ptVexN86/847iYsacgI8H4oPlE7McZv9OCL185No0AkAk4Gw1aI +SfHYIeIhBDWb9msVsnu1wgc237UwCgYIKoZIzj0EAwMDSAAwRQIhAK4caAt0QSTz +A1WYlrEAA2AH181P7USiXkqQ5qRyoWQNAiBm3vKaoB+0p4B98HeI+h5V/7loomQg +sW3uB0zEuJyqIQ== +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/nullcert.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/nullcert.pem new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert.pem new file mode 100644 index 0000000..c407cb0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICDTCCAXYCCQCxc2uXBLZhDjANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQGEwJV +UzETMBEGA1UECBMKV2FzaGluZ3RvbjETMBEGA1UEChMKUmF5IENBIEluYzERMA8G +A1UEAxMIUmF5Q0FJbmMwHhcNMTQwMTE4MjEwMjUwWhcNMjQwMTE2MjEwMjUwWjBM +MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEUMBIGA1UEChMLUmF5 +IFNydiBJbmMxEjAQBgNVBAMTCVJheVNydkluYzCBnzANBgkqhkiG9w0BAQEFAAOB +jQAwgYkCgYEA2Mv6DsylMQHjRWjanFZvHfdjCBdDpBucuDVDIqWsfoMQSi3INFYj +9l+29ZuMZSHTNQaFBovMXdk2B0xWQXmPTAKBF3A+ifVy3nAlSDoYJa9+LgH8eVCt +aDwkNpaga4NxOouHoph8N1ZOVYIGrek5bXiBV7d3XZHwHG708IryOu8CAwEAATAN +BgkqhkiG9w0BAQUFAAOBgQBw0XUTYzfiI0Fi9g4GuyWD2hjET3NtrT4Ccu+Jiivy +EvwhzHtVGAPhrV+VCL8sS9uSOZlmfK/ZVraDiFGpJLDMvPP5y5fwq5VGrFuZispG +X6bTBq2AIKzGGXxhwPqD8F7su7bmZDnZFRMRk2Bh16rv0mtzx9yHtqC5YJZ2a3JK +2g== +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert_ec.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert_ec.pem new file mode 100644 index 0000000..a9a76e5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/server-cert_ec.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBhjCCASwCCQCZ3L2TA/e93zAKBggqhkjOPQQDAzBKMQswCQYDVQQGEwJVUzET +MBEGA1UECAwKV2FzaGluZ3RvbjETMBEGA1UECgwKUmF5IENBIEluYzERMA8GA1UE +AwwIUmF5Q0FJbmMwHhcNMTcwMzA3MDgzNjU4WhcNMjcwMzA1MDgzNjU4WjBMMQsw +CQYDVQQGEwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjEUMBIGA1UECgwLUmF5IFNy +diBJbmMxEjAQBgNVBAMMCVJheVNydkluYzBZMBMGByqGSM49AgEGCCqGSM49AwEH +A0IABIK6BXtn8ptVexN86/847iYsacgI8H4oPlE7McZv9OCL185No0AkAk4Gw1aI +SfHYIeIhBDWb9msVsnu1wgc237UwCgYIKoZIzj0EAwMDSAAwRQIhAK4caAt0QSTz +A1WYlrEAA2AH181P7USiXkqQ5qRyoWQNAiBm3vKaoB+0p4B98HeI+h5V/7loomQg +sW3uB0zEuJyqIQ== +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/wrongcert.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/wrongcert.pem new file mode 100644 index 0000000..5f92f9b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/wrongcert.pem @@ -0,0 +1,32 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQC89ZNxjTgWgq7Z1g0tJ65w+k7lNAj5IgjLb155UkUrz0XsHDnH +FlbsVUg2Xtk6+bo2UEYIzN7cIm5ImpmyW/2z0J1IDVDlvR2xJ659xrE0v5c2cB6T +f9lnNTwpSoeK24Nd7Jwq4j9vk95fLrdqsBq0/KVlsCXeixS/CaqqduXfvwIDAQAB +AoGAQFko4uyCgzfxr4Ezb4Mp5pN3Npqny5+Jey3r8EjSAX9Ogn+CNYgoBcdtFgbq +1yif/0sK7ohGBJU9FUCAwrqNBI9ZHB6rcy7dx+gULOmRBGckln1o5S1+smVdmOsW +7zUVLBVByKuNWqTYFlzfVd6s4iiXtAE2iHn3GCyYdlICwrECQQDhMQVxHd3EFbzg +SFmJBTARlZ2GKA3c1g/h9/XbkEPQ9/RwI3vnjJ2RaSnjlfoLl8TOcf0uOGbOEyFe +19RvCLXjAkEA1s+UE5ziF+YVkW3WolDCQ2kQ5WG9+ccfNebfh6b67B7Ln5iG0Sbg +ky9cjsO3jbMJQtlzAQnH1850oRD5Gi51dQJAIbHCDLDZU9Ok1TI+I2BhVuA6F666 +lEZ7TeZaJSYq34OaUYUdrwG9OdqwZ9sy9LUav4ESzu2lhEQchCJrKMn23QJAReqs +ZLHUeTjfXkVk7dHhWPWSlUZ6AhmIlA/AQ7Payg2/8wM/JkZEJEPvGVykms9iPUrv +frADRr+hAGe43IewnQJBAJWKZllPgKuEBPwoEldHNS8nRu61D7HzxEzQ2xnfj+Nk +2fgf1MAzzTRsikfGENhVsVWeqOcijWb6g5gsyCmlRpc= +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIICsDCCAhmgAwIBAgIJAOqYOYFJfEEoMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMDgwNjI2MTgxNTUyWhcNMDkwNjI2MTgxNTUyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB +gQC89ZNxjTgWgq7Z1g0tJ65w+k7lNAj5IgjLb155UkUrz0XsHDnHFlbsVUg2Xtk6 ++bo2UEYIzN7cIm5ImpmyW/2z0J1IDVDlvR2xJ659xrE0v5c2cB6Tf9lnNTwpSoeK +24Nd7Jwq4j9vk95fLrdqsBq0/KVlsCXeixS/CaqqduXfvwIDAQABo4GnMIGkMB0G +A1UdDgQWBBTctMtI3EO9OjLI0x9Zo2ifkwIiNjB1BgNVHSMEbjBsgBTctMtI3EO9 +OjLI0x9Zo2ifkwIiNqFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt +U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAOqYOYFJ +fEEoMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAQwa7jya/DfhaDn7E +usPkpgIX8WCL2B1SqnRTXEZfBPPVq/cUmFGyEVRVATySRuMwi8PXbVcOhXXuocA+ +43W+iIsD9pXapCZhhOerCq18TC1dWK98vLUsoK8PMjB6e5H/O8bqojv0EeC+fyCw +eSHj5jpC8iZKjCHBn+mAi4cQ514= +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/yahoo-cert.pem b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/yahoo-cert.pem new file mode 100644 index 0000000..d2cd76d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/certs/yahoo-cert.pem @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIE6jCCBFOgAwIBAgIDEIGKMA0GCSqGSIb3DQEBBQUAME4xCzAJBgNVBAYTAlVT +MRAwDgYDVQQKEwdFcXVpZmF4MS0wKwYDVQQLEyRFcXVpZmF4IFNlY3VyZSBDZXJ0 +aWZpY2F0ZSBBdXRob3JpdHkwHhcNMTAwNDAxMjMwMDE0WhcNMTUwNzAzMDQ1MDAw +WjCBjzEpMCcGA1UEBRMgMmc4YU81d0kxYktKMlpENTg4VXNMdkRlM2dUYmc4RFUx +CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRIwEAYDVQQHEwlTdW5u +eXZhbGUxFDASBgNVBAoTC1lhaG9vICBJbmMuMRYwFAYDVQQDEw13d3cueWFob28u +Y29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6ZM1jHCkL8rlEKse +1riTTxyC3WvYQ5m34TlFK7dK4QFI/HPttKGqQm3aVB1Fqi0aiTxe4YQMbd++jnKt +djxcpi7sJlFxjMZs4umr1eGo2KgTgSBAJyhxo23k+VpK1SprdPyM3yEfQVdV7JWC +4Y71CE2nE6+GbsIuhk/to+jJMO7jXx/430jvo8vhNPL6GvWe/D6ObbnxS72ynLSd +mLtaltykOvZEZiXbbFKgIaYYmCgh89FGVvBkUbGM/Wb5Voiz7ttQLLxKOYRj8Mdk +TZtzPkM9scIFG1naECPvCxw0NyMyxY3nFOdjUKJ79twanmfCclX2ZO/rk1CpiOuw +lrrr/QIDAQABo4ICDjCCAgowDgYDVR0PAQH/BAQDAgTwMB0GA1UdDgQWBBSmrfKs +68m+dDUSf+S7xJrQ/FXAlzA6BgNVHR8EMzAxMC+gLaArhilodHRwOi8vY3JsLmdl +b3RydXN0LmNvbS9jcmxzL3NlY3VyZWNhLmNybDCCAVsGA1UdEQSCAVIwggFOgg13 +d3cueWFob28uY29tggl5YWhvby5jb22CDHVzLnlhaG9vLmNvbYIMa3IueWFob28u +Y29tggx1ay55YWhvby5jb22CDGllLnlhaG9vLmNvbYIMZnIueWFob28uY29tggxp +bi55YWhvby5jb22CDGNhLnlhaG9vLmNvbYIMYnIueWFob28uY29tggxkZS55YWhv +by5jb22CDGVzLnlhaG9vLmNvbYIMbXgueWFob28uY29tggxpdC55YWhvby5jb22C +DHNnLnlhaG9vLmNvbYIMaWQueWFob28uY29tggxwaC55YWhvby5jb22CDHFjLnlh +aG9vLmNvbYIMdHcueWFob28uY29tggxoay55YWhvby5jb22CDGNuLnlhaG9vLmNv +bYIMYXUueWFob28uY29tggxhci55YWhvby5jb22CDHZuLnlhaG9vLmNvbTAfBgNV +HSMEGDAWgBRI5mj5K9KylddH2CMgEE8zmJCf1DAdBgNVHSUEFjAUBggrBgEFBQcD +AQYIKwYBBQUHAwIwDQYJKoZIhvcNAQEFBQADgYEAp9WOMtcDMM5T0yfPecGv5QhH +RJZRzgeMPZitLksr1JxxicJrdgv82NWq1bw8aMuRj47ijrtaTEWXaCQCy00yXodD +zoRJVNoYIvY1arYZf5zv9VZjN5I0HqUc39mNMe9XdZtbkWE+K6yVh6OimKLbizna +inu9YTrN/4P/w6KzHho= +-----END CERTIFICATE----- diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/echo_seq.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/echo_seq.py new file mode 100644 index 0000000..c57c128 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/echo_seq.py @@ -0,0 +1,128 @@ +# PyDTLS sequential echo. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""PyDTLS sequential echo + +This script runs a sequential echo server. It is sequential in that it will +respond without error only to a single client that invokes the following steps +in order: + * DTLS cookie exchange on port 28000 of localhost + * DTLS handshake (application-default ciphers) + * Write and receive echo back for an arbitrary number of datagrams + * Issue shutdown notification and receive the shutdown notification response + +Note that this script's operation is slow and inefficient on purpose: it +invokes the demux without socket select, but with 5-second timeouts after +the cookie exchange; this is done so that one can follow the debug logs when +operating this server from a client shell interactively. +""" + +import socket +from os import path +from logging import basicConfig, DEBUG +basicConfig(level=DEBUG) # set now for dtls import code +from dtls.sslconnection import SSLConnection +from dtls.err import SSLError, SSL_ERROR_WANT_READ, SSL_ERROR_ZERO_RETURN + + +def main(): + cert_path = path.join(path.abspath(path.dirname(__file__)), "certs") + + sck = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sck.bind(("127.0.0.1", 28000)) + sck.settimeout(30) + + scn = SSLConnection( + sck, + keyfile=path.join(cert_path, "keycert.pem"), + certfile=path.join(cert_path, "keycert.pem"), + server_side=True, + ca_certs=path.join(cert_path, "ca-cert.pem"), + do_handshake_on_connect=False) + + cnt = 0 + while True: + cnt += 1 + print("Listen invocation: %d" % cnt) + peer_address = scn.listen() + if peer_address: + print("Completed listening for peer: %s" % str(peer_address)) + break + + print("Accepting...") + conn = scn.accept()[0] + sck.settimeout(5) + conn.get_socket(True).settimeout(5) + + cnt = 0 + while True: + cnt += 1 + # print("Listen invocation: %d" % cnt) + # peer_address = scn.listen() + # assert not peer_address + print("Handshake invocation: %d" % cnt) + try: + conn.do_handshake() + except SSLError as err: + if err.errno == 504: + continue + raise + print("Completed handshaking with peer") + break + + cnt = 0 + while True: + cnt += 1 + # print("Listen invocation: %d" % cnt) + # peer_address = scn.listen() + # assert not peer_address + print("Read invocation: %d" % cnt) + try: + message = conn.read() + except SSLError as err: + if err.errno == 502: + continue + if err.args[0] == SSL_ERROR_ZERO_RETURN: + break + raise + print(message.decode()) + conn.write(str("Back to you: " + message.decode()).encode()) + + cnt = 0 + while True: + cnt += 1 + # print("Listen invocation: %d" % cnt) + # peer_address = scn.listen() + # assert not peer_address + print("Shutdown invocation: %d" % cnt) + try: + s = conn.unwrap() + s.close() + except SSLError as err: + if err.errno == 502: + continue + raise + break + + sck.close() + + pass + + +if __name__ == "__main__": + main() diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts new file mode 100755 index 0000000..251b520 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts @@ -0,0 +1,36 @@ +#!/bin/bash -eu + +############################################################################## +# +# Generate Certificates for PyDTLS Unit Testing +# +# This script is invoked manually (as opposed to by the unit test suite), in +# order to generate certain certificates that are required to be valid by +# the unit test suite. +# +# This script is not portable: it has been tested on Ubuntu 13.04 only. New +# certificates are written into the current directory. +# +# Copyright 2014 Ray Brown +# +############################################################################## + +DIR=`dirname "$0"` + +# Generate self-signed certificate for the certificate authority +echo Generating CA...; echo +openssl req -config "$DIR/openssl_ca.cnf" -x509 -newkey rsa -nodes -keyout tmp_ca.key -out ca-cert.pem -days 3650 + +# Generate a certificate request +echo Generating certificate request...; echo +openssl req -config "$DIR/openssl_server.cnf" -newkey rsa -nodes -keyout tmp_server.key -out tmp_server.req + +# Sign the request with the certificate authority's certificate created above +echo Signing certificate request...; echo +openssl x509 -req -in tmp_server.req -CA ca-cert.pem -CAkey tmp_ca.key -CAcreateserial -days 3650 -out server-cert.pem + +# Build pem file with private and public keys, ready for unprompted server use +cat tmp_server.key server-cert.pem > keycert.pem + +# Clean up +rm tmp_ca.key tmp_server.key tmp_server.req ca-cert.srl diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts_ec.bat b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts_ec.bat new file mode 100644 index 0000000..5d5eb8b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/makecerts_ec.bat @@ -0,0 +1,24 @@ +@echo off +set RANDFILE=.rnd + + +rem # Generate self-signed certificate for the certificate authority +echo Generating CA... +openssl ecparam -name prime256v1 -genkey -out tmp_ca_ec.key +openssl req -config "openssl_ca.cnf" -x509 -new -SHA384 -nodes -key tmp_ca_ec.key -days 3650 -out ca-cert_ec.pem + +rem # Generate a certificate request +echo Generating certificate request... +openssl ecparam -name prime256v1 -genkey -out tmp_server_ec.key +openssl req -config "openssl_server.cnf" -new -SHA384 -nodes -key tmp_server_ec.key -out tmp_server_ec.req + +rem # Sign the request with the certificate authority's certificate created above +echo Signing certificate request... +openssl req -in tmp_server_ec.req -noout -text +openssl x509 -req -SHA384 -days 3650 -in tmp_server_ec.req -CA ca-cert_ec.pem -CAkey tmp_ca_ec.key -CAcreateserial -out server-cert_ec.pem + +rem # Build pem file with private and public keys, ready for unprompted server use +cat tmp_server_ec.key server-cert_ec.pem > keycert_ec.pem + +rem # Clean up +rm tmp_ca_ec.key tmp_server_ec.key tmp_server_ec.req ca-cert_ec.srl diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_ca.cnf b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_ca.cnf new file mode 100644 index 0000000..3ded7e5 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_ca.cnf @@ -0,0 +1,12 @@ +HOME = . +RANDFILE = $ENV::HOME/.rnd + +[ req ] +distinguished_name = req_distinguished_name +prompt = no + +[ req_distinguished_name ] +C = US +ST = Washington +O = Ray CA Inc +CN = RayCAInc diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_server.cnf b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_server.cnf new file mode 100644 index 0000000..33aac27 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/openssl_server.cnf @@ -0,0 +1,12 @@ +HOME = . +RANDFILE = $ENV::HOME/.rnd + +[ req ] +distinguished_name = req_distinguished_name +prompt = no + +[ req_distinguished_name ] +C = US +ST = Washington +O = Ray Srv Inc +CN = RaySrvInc diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/rl.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/rl.py new file mode 100644 index 0000000..ceffe6d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/rl.py @@ -0,0 +1,48 @@ +# PyDTLS reloader. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""PyDTLS package reloader + +This script reloads all modules of the DTLS package. This can be useful in +runtime environments that usually persist across package file edits, such as +the IPython shell. +""" + +import dtls +import dtls.err +import dtls.util +import dtls.sslconnection +import dtls.x509 +import dtls.openssl +import dtls.demux +import dtls.demux.router + +def main(): + reload(dtls) + reload(dtls.err) + reload(dtls.util) + reload(dtls.sslconnection) + reload(dtls.x509) + reload(dtls.openssl) + reload(dtls.demux) + reload(dtls.demux.router) + reload(dtls.sslconnection) + reload(dtls.x509) + +if __name__ == "__main__": + main() diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/simple_client.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/simple_client.py new file mode 100755 index 0000000..fcf3739 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/simple_client.py @@ -0,0 +1,18 @@ +from os import path +import ssl +from socket import socket, AF_INET, SOCK_DGRAM +from logging import basicConfig, DEBUG +basicConfig(level=DEBUG) # set now for dtls import code +from dtls import do_patch +do_patch() + +cert_path = path.join(path.abspath(path.dirname(__file__)), "certs") +s = ssl.wrap_socket(socket(AF_INET, SOCK_DGRAM), cert_reqs=ssl.CERT_NONE, ca_certs=path.join(cert_path, "ca-cert.pem")) +s.connect(('127.0.0.1', 28000)) +s.send('Hi there'.encode()) +print(s.recv().decode()) +s = s.unwrap() +s.close() + +pass + diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/test_perf.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/test_perf.py new file mode 100644 index 0000000..90e10dd --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/test_perf.py @@ -0,0 +1,439 @@ +# Performance tests for PyDTLS. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""PyDTLS performance tests + +This module implements relative performance testing of throughput for the +PyDTLS package. Throughput for the following transports can be compared: + + * Python standard library stream transport (ssl module) + * PyDTLS datagram transport + * PyDTLS datagram transport with thread locking callbacks disabled + * PyDTLS datagram transport with demux type forced to routing demux +""" + +import socket +import errno +import ssl +import sys +import time +from argparse import ArgumentParser, ArgumentTypeError +from os import path, urandom +from timeit import timeit +from select import select +from multiprocessing import Process +from multiprocessing.managers import BaseManager +from dtls import do_patch + +AF_INET4_6 = socket.AF_INET +CERTFILE = path.join(path.dirname(__file__), "certs", "keycert.pem") +CHUNK_SIZE = 1459 +CHUNKS = 150000 +CHUNKS_PER_DOT = 500 +COMM_KEY = "tronje%T577&kkjLp" + +# +# Traffic handler: required for servicing the root socket if the routing demux +# is used; only waits for traffic on the data socket with +# the osnet demux, as well as streaming sockets +# + +def handle_traffic(data_sock, listen_sock, err): + assert data_sock + assert err in (ssl.SSL_ERROR_WANT_READ, ssl.SSL_ERROR_WANT_WRITE) + readers = [] + writers = [] + if listen_sock: + readers.append(listen_sock) + if err == ssl.SSL_ERROR_WANT_READ: + readers.append(data_sock) + else: + writers.append(data_sock) + while True: + read_ready, write_ready, exc_ready = select(readers, writers, [], 5) + if not read_ready and not write_ready: + raise ssl.SSLError("timed out") + if data_sock in read_ready or data_sock in write_ready: + break + assert listen_sock in read_ready + acc_ret = listen_sock.accept() + assert acc_ret is None # test does not attempt multiple connections + +# +# Transfer functions: transfer data on non-blocking sockets; written to work +# properly for stream as well as message-based protocols +# + +fill = urandom(CHUNK_SIZE) + +def transfer_out(sock, listen_sock=None, marker=False): + max_i_len = 10 + start_char = "t" if marker else "s" + for i in xrange(CHUNKS): + prefix = start_char + str(i) + ":" + pad_prefix = prefix + "b" * (max_i_len - len(prefix)) + message = pad_prefix + fill[:CHUNK_SIZE - max_i_len - 1] + "e" + count = 0 + while count < CHUNK_SIZE: + try: + count += sock.send(message[count:]) + except ssl.SSLError as err: + if err.args[0] in (ssl.SSL_ERROR_WANT_READ, + ssl.SSL_ERROR_WANT_WRITE): + handle_traffic(sock, listen_sock, err.args[0]) + else: + raise + except socket.error as err: + if err.errno == errno.EWOULDBLOCK: + handle_traffic(sock, None, ssl.SSL_ERROR_WANT_WRITE) + else: + raise + if not i % CHUNKS_PER_DOT: + sys.stdout.write('.') + sys.stdout.flush() + print + +def transfer_in(sock, listen_sock=None): + drops = 0 + pack_seq = -1 + i = 0 + try: + sock.getpeername() + except: + peer_set = False + else: + peer_set = True + while pack_seq + 1 < CHUNKS: + pack = "" + while len(pack) < CHUNK_SIZE: + try: + if isinstance(sock, ssl.SSLSocket): + segment = sock.recv(CHUNK_SIZE - len(pack)) + else: + segment, addr = sock.recvfrom(CHUNK_SIZE - len(pack)) + except ssl.SSLError as err: + if err.args[0] in (ssl.SSL_ERROR_WANT_READ, + ssl.SSL_ERROR_WANT_WRITE): + try: + handle_traffic(sock, listen_sock, err.args[0]) + except ssl.SSLError as err: + if err.message == "timed out": + break + raise + else: + raise + except socket.error as err: + if err.errno == errno.EWOULDBLOCK: + try: + handle_traffic(sock, None, ssl.SSL_ERROR_WANT_READ) + except ssl.SSLError as err: + if err.message == "timed out": + break + raise + else: + raise + else: + pack += segment + if not peer_set: + sock.connect(addr) + peer_set = True + # Do not try to assembly packets from datagrams + if sock.type == socket.SOCK_DGRAM: + break + if len(pack) < CHUNK_SIZE or pack[0] == "t": + break + if pack[0] != "s" or pack[-1] != "e": + raise Exception("Corrupt message received") + next_seq = int(pack[1:pack.index(':')]) + if next_seq > pack_seq: + drops += next_seq - pack_seq - 1 + pack_seq = next_seq + if not i % CHUNKS_PER_DOT: + sys.stdout.write('.') + sys.stdout.flush() + i += 1 + drops += CHUNKS - 1 - pack_seq + print + return drops + +# +# Single-threaded server +# + +def server(sock_type, do_wrap, listen_addr): + sock = socket.socket(AF_INET4_6, sock_type) + sock.bind(listen_addr) + if do_wrap: + wrap = ssl.wrap_socket(sock, server_side=True, certfile=CERTFILE, + do_handshake_on_connect=False, + ciphers="NULL") + wrap.listen(0) + else: + wrap = sock + if sock_type == socket.SOCK_STREAM: + wrap.listen(0) + yield wrap.getsockname() + if do_wrap or sock_type == socket.SOCK_STREAM: + while True: + acc_res = wrap.accept() + if acc_res: + break + conn = acc_res[0] + else: + conn = wrap + wrap.setblocking(False) + conn.setblocking(False) + class InResult(object): pass + def _transfer_in(): + InResult.drops = transfer_in(conn, wrap) + in_time = timeit(_transfer_in, number=1) + yield in_time, InResult.drops + out_time = timeit(lambda: transfer_out(conn, wrap), number=1) + # Inform the client that we are done, in case it has missed the final chunk + if sock_type == socket.SOCK_DGRAM: + global CHUNKS, CHUNK_SIZE + CHUNKS_sav = CHUNKS + CHUNK_SIZE_sav = CHUNK_SIZE + try: + CHUNKS = 5 + CHUNK_SIZE = 10 + for _ in range(10): + try: + transfer_out(conn, wrap, True) + except ssl.SSLError as err: + if err.args[0] == ssl.SSL_ERROR_SYSCALL: + break + else: + raise + except socket.error as err: + if err.errno == errno.ECONNREFUSED: + break + else: + raise + time.sleep(0.2) + finally: + CHUNKS = CHUNKS_sav + CHUNK_SIZE = CHUNK_SIZE_sav + conn.shutdown(socket.SHUT_RDWR) + conn.close() + wrap.close() + yield out_time + +# +# Client, launched into a separate process +# + +def client(sock_type, do_wrap, listen_addr): + do_patch() # we might be in a new process + sock = socket.socket(AF_INET4_6, sock_type) + if do_wrap: + wrap = ssl.wrap_socket(sock, ciphers="NULL") + else: + wrap = sock + wrap.connect(listen_addr) + transfer_out(wrap) + drops = transfer_in(wrap) + wrap.shutdown(socket.SHUT_RDWR) + wrap.close() + return drops + +# +# Client manager - remote clients, run in a separate process +# + +def make_client_manager(): + # Create the global client manager class in servers configured as client + # managers + class ClientManager(object): + from Queue import Queue + + queue = Queue() + clients = -1 # creator does not count + + @classmethod + def get_queue(cls): + cls.clients += 1 + return cls.queue + + @classmethod + def release_clients(cls): + def wait_queue_empty(fail_return): + waitcount = 5 + while not cls.queue.empty() and waitcount: + time.sleep(1) + waitcount -= 1 + if not cls.queue.empty(): + # Clients are already dead or stuck + return fail_return + # Wait a moment for the queue to empty + wait_queue_empty("No live clients detected") + for _ in range(cls.clients): + cls.queue.put("STOP") + # Wait for all stop messages to be retrieved + wait_queue_empty("Not all clients responded to stop signal") + return "Client release succeeded" + globals()["ClientManager"] = ClientManager + +def get_queue(): + return ClientManager.get_queue() + +def release_clients(): + return ClientManager.release_clients() + +MANAGER = None +QUEUE = None +class Manager(BaseManager): pass + +def start_client_manager(port): + global MANAGER, QUEUE + make_client_manager() + Manager.register("get_queue", get_queue) + Manager.register("release_clients", release_clients) + if sys.platform.startswith('win'): + addr = socket.gethostname(), port + else: + addr = '', port + MANAGER = Manager(addr, COMM_KEY) + MANAGER.start(make_client_manager) + QUEUE = MANAGER.get_queue() + +def stop_client_manager(): + global MANAGER, QUEUE + QUEUE = None + MANAGER.release_clients() + MANAGER.shutdown() + MANAGER = None + +def remote_client(manager_address): + Manager.register("get_queue") + manager = Manager(manager_address, COMM_KEY) + manager.connect() + queue = manager.get_queue() + print("Client connected; waiting for job...") + while True: + command = queue.get() + if command == "STOP": + break + command = command[:-1] + [(manager_address[0], command[-1][1])] + print("Starting job: " + str(command)) + drops = client(*command) + print("%d drops" % drops) + print("Job completed; waiting for next job...") + +# +# Test runner +# + +def run_test(server_args, client_args, port): + if port is None: + port = 0 + if QUEUE: + # bind to all interfaces, for remote clients + listen_addr = '', port + else: + # bind to loopback only, for local clients + listen_addr = 'localhost', port + svr = iter(server(*server_args, listen_addr=listen_addr)) + listen_addr = svr.next() + listen_addr = 'localhost', listen_addr[1] + client_args = list(client_args) + client_args.append(listen_addr) + if QUEUE: + QUEUE.put(client_args) + else: + proc = Process(target=client, args=client_args) + proc.start() + in_size = CHUNK_SIZE * CHUNKS / 2**20 + out_size = CHUNK_SIZE * CHUNKS / 2**20 + print("Starting inbound: %dMiB" % in_size) + svr_in_time, drops = svr.next() + print("Inbound: %.3f seconds, %dMiB/s, %d drops" % ( + svr_in_time, in_size / svr_in_time, drops)) + print("Starting outbound: %dMiB" % out_size) + svr_out_time = svr.next() + print("Outbound: %.3f seconds, %dMiB/s" % ( + svr_out_time, out_size / svr_out_time)) + if not QUEUE: + proc.join() + print("Combined: %.3f seconds, %dMiB/s" % ( + svr_out_time + svr_in_time, + (in_size + out_size) / (svr_in_time + svr_out_time))) + +# +# Main entry point +# + +if __name__ == "__main__": + def port(string): + val = int(string) + if val < 1 or val > 2**16: + raise ArgumentTypeError("%d is an invalid port number" % val) + return val + def endpoint(string): + addr = string.split(':') + if len(addr) != 2: + raise ArgumentTypeError("%s is not a valid host endpoint" % string) + addr[1] = port(addr[1]) + socket.getaddrinfo(addr[0], addr[1], socket.AF_INET) + return tuple(addr) + parser = ArgumentParser() + parser.add_argument("-s", "--server", type=port, metavar="PORT", + help="local server port for remote clients") + parser.add_argument("-p", "--port", type=port, metavar="SUITEPORT", + help="fixed suite port instead of dynamic assignment") + parser.add_argument("-c", "--client", type=endpoint, metavar="ENDPOINT", + help="remote server endpoint for this client") + args = parser.parse_args() + if args.client: + remote_client(args.client) + sys.exit() + if args.server: + start_client_manager(args.server) + suites = { + "Raw TCP": (socket.SOCK_STREAM, False), + "Raw UDP": (socket.SOCK_DGRAM, False), + "SSL (TCP)": (socket.SOCK_STREAM, True), + "DTLS (UDP)": (socket.SOCK_DGRAM, True), + } + selector = { + 0: "Exit", + 1: "Raw TCP", + 2: "Raw UDP", + 3: "SSL (TCP)", + 4: "DTLS (UDP)", + } + do_patch() + while True: + print("\nSelect protocol:\n") + for key in sorted(selector): + print("\t" + str(key) + ": " + selector[key]) + try: + choice = raw_input("\nProtocol: ") + choice = int(choice) + if choice < 0 or choice >= len(selector): + raise ValueError("Invalid selection input") + except (ValueError, OverflowError): + print("Invalid selection input") + continue + except EOFError: + break + if not choice: + break + run_test(suites[selector[choice]], suites[selector[choice]], args.port) + if args.server: + stop_client_manager() diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit.py new file mode 100644 index 0000000..3f489a4 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit.py @@ -0,0 +1,1424 @@ +# Test the support for DTLS through the SSL module. Adapted from the Python +# standard library's test_ssl.py regression test module by Ray Brown. + +import sys +import unittest +import asyncore +import socket +import select +import gc +import os +import errno +import pprint +from urllib import parse as urlparse +import traceback +import weakref +import platform +import threading +import time +import datetime +import socketserver +from http.server import SimpleHTTPRequestHandler +from collections import OrderedDict + +import ssl +from dtls import do_patch, force_routing_demux, reset_default_demux, err + +# from logging import basicConfig, DEBUG +# basicConfig(level=DEBUG) # set now for dtls import code + +HOST = "localhost" +CONNECTION_TIMEOUT = datetime.timedelta(seconds=30) + +class TestSupport(object): + verbose = True + + class Ctx(object): + def __enter__(self): + self.server = AsyncoreEchoServer(CERTFILE) + flag = threading.Event() + self.server.start(flag) + flag.wait() + return self.server.sockname + + def __exit__(self, exc_type, exc_value, traceback): + self.server.stop() + self.server = None + + def transient_internet(self): + return self.Ctx() + +test_support = TestSupport() + +def handle_error(prefix): + exc_format = ' '.join(traceback.format_exception(*sys.exc_info())) + if test_support.verbose: + sys.stdout.write(prefix + exc_format) + + +class BasicSocketTests(unittest.TestCase): + + def test_constants(self): + ssl.PROTOCOL_SSLv23 + ssl.PROTOCOL_TLSv1 + ssl.PROTOCOL_DTLSv1 # added + ssl.PROTOCOL_DTLSv1_2 # added + ssl.PROTOCOL_DTLS # added + ssl.CERT_NONE + ssl.CERT_OPTIONAL + ssl.CERT_REQUIRED + + def test_dtls_openssl_version(self): + n = ssl.DTLS_OPENSSL_VERSION_NUMBER + t = ssl.DTLS_OPENSSL_VERSION_INFO + s = ssl.DTLS_OPENSSL_VERSION + self.assertIsInstance(n, int) + self.assertIsInstance(t, tuple) + self.assertIsInstance(s, str) + # Some sanity checks follow + # >= 1.0.2 + self.assertGreaterEqual(n, 0x10002000) + # < 2.0 + self.assertLess(n, 0x20000000) + major, minor, fix, patch, status = t + self.assertGreaterEqual(major, 1) + self.assertLess(major, 2) + self.assertGreaterEqual(minor, 0) + self.assertLess(minor, 256) + self.assertGreaterEqual(fix, 0) + self.assertLess(fix, 256) + self.assertGreaterEqual(patch, 0) + self.assertLessEqual(patch, 26) + self.assertGreaterEqual(status, 0) + self.assertLessEqual(status, 15) + # Version string as returned by OpenSSL, the format might change + self.assertTrue( + s.startswith("OpenSSL {:d}.{:d}.{:d}".format(major, minor, fix)), + (s, t)) + + def test_ciphers(self): + server = AsyncoreEchoServer(CERTFILE) + flag = threading.Event() + server.start(flag) + flag.wait() + remote = (HOST, server.port) + try: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_NONE, ciphers="ALL") + s.connect(remote) + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_NONE, ciphers="DEFAULT") + s.connect(remote) + # Error checking occurs when connecting, because the SSL context + # isn't created before. + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_NONE, + ciphers="^$:,;?*'dorothyx") + with self.assertRaisesRegex(ssl.SSLError, + "No cipher can be selected"): + s.connect(remote) + finally: + server.stop() + + @unittest.skipIf(platform.python_implementation() != "CPython", + "Reference cycle test feasible under CPython only") + def test_refcycle(self): + # Issue #7943: an SSL object doesn't create reference cycles with + # itself. + s = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + ss = ssl.wrap_socket(s) + wr = weakref.ref(ss) + # ss = ss.unwrap() + ss.close() + del ss + self.assertEqual(wr(), None) + + def test_wrapped_unconnected(self): + # The _delegate_methods in socket.py are correctly delegated to by an + # unconnected SSLSocket, so they will raise a socket.error rather than + # something unexpected like TypeError. + s = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + ss = ssl.wrap_socket(s) + if os.name != "posix": + # On Linux, unconnected, unbound datagram sockets can receive and + # the following calls will therefore block + self.assertRaises(socket.error, ss.recv, 1) + self.assertRaises(socket.error, ss.recv_into, bytearray(b'x')) + self.assertRaises(socket.error, ss.recvfrom, 1) + self.assertRaises(socket.error, ss.recvfrom_into, bytearray(b'x'), + 1) + self.assertRaises(socket.error, ss.send, b'x') + self.assertRaises(socket.error, ss.sendto, b'x', + ('0.0.0.0', 0) if AF_INET4_6 == socket.AF_INET else + ('::', 0)) + + +class NetworkedTests(unittest.TestCase): + + def test_connect(self): + with test_support.transient_internet() as remote: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_NONE) + s.connect(remote) + c = s.getpeercert() + if c: + self.fail("Peer cert %s shouldn't be here!") + s = s.unwrap() + s.close() + + # this should fail because we have no verification certs + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_REQUIRED) + try: + s.connect(remote) + except ssl.SSLError: + pass + finally: + # s = s.unwrap() + s.close() + + # this should succeed because we specify the root cert + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_REQUIRED, + ca_certs=ISSUER_CERTFILE) + try: + s.connect(remote) + finally: + s = s.unwrap() + s.close() + + def test_connect_ex(self): + # Issue #11326: check connect_ex() implementation + with test_support.transient_internet() as remote: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_REQUIRED, + ca_certs=ISSUER_CERTFILE) + try: + self.assertEqual(0, s.connect_ex(remote)) + self.assertTrue(s.getpeercert()) + finally: + s = s.unwrap() + s.close() + + def test_non_blocking_connect_ex(self): + # Issue #11326: non-blocking connect_ex() should allow handshake + # to proceed after the socket gets ready. + with test_support.transient_internet() as remote: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + cert_reqs=ssl.CERT_REQUIRED, + ca_certs=ISSUER_CERTFILE, + do_handshake_on_connect=False) + try: + s.setblocking(False) + rc = s.connect_ex(remote) + # EWOULDBLOCK under Windows, EINPROGRESS elsewhere + self.assertIn(rc, (0, errno.EINPROGRESS, errno.EWOULDBLOCK)) + # Non-blocking handshake + while True: + try: + s.do_handshake() + break + except ssl.SSLError as err: + if err.args[0] == ssl.SSL_ERROR_WANT_READ: + while True: + to = s.get_timeout() + to = to.total_seconds() if to else 5.0 + sel = select.select([s], [], [], to) + if sel[0]: + break + s.handle_timeout() + else: + raise + # SSL established + self.assertTrue(s.getpeercert()) + finally: + s.close() + + @unittest.skipIf(os.name == "nt", + "Can't use a socket as a file under Windows") + def test_makefile_close(self): + # Issue #5238: creating a file-like object with makefile() shouldn't + # delay closing the underlying "real socket" (here tested with its + # file descriptor, hence skipping the test under Windows). + with test_support.transient_internet() as remote: + ss = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM)) + ss.connect(remote) + fd = ss.fileno() + f = ss.makefile() + f.close() + # The fd is still open + os.read(fd, 0) + # Closing the SSL socket should close the fd too + ss.close() + gc.collect() + with self.assertRaises(OSError) as e: + os.read(fd, 0) + self.assertEqual(e.exception.errno, errno.EBADF) + + # def test_non_blocking_handshake(self): + # with test_support.transient_internet() as remote: + # s = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + # s.connect(remote) + # s.setblocking(False) + # s = ssl.wrap_socket(s, + # cert_reqs=ssl.CERT_NONE, + # do_handshake_on_connect=False) + # count = 0 + # while True: + # try: + # count += 1 + # s.do_handshake() + # break + # except ssl.SSLError as err: + # if err.args[0] == ssl.SSL_ERROR_WANT_READ: + # while True: + # to = s.get_timeout() + # if to: + # sel = select.select([s], [], [], + # to.total_seconds()) + # if sel[0]: + # break + # s.handle_timeout() + # continue + # select.select([s], [], []) + # break + # else: + # raise + # except: + # pass + # s.close() + # if test_support.verbose: + # sys.stdout.write(("\nNeeded %d calls to do_handshake() " + + # "to establish session.\n") % count) + + def test_get_server_certificate(self): + for prot in (ssl.PROTOCOL_DTLSv1, ssl.PROTOCOL_DTLSv1_2, ssl.PROTOCOL_DTLS): + with test_support.transient_internet() as remote: + pem = ssl.get_server_certificate(remote, prot) + if not pem: + self.fail("No server certificate!") + + try: + pem = ssl.get_server_certificate(remote, + prot, + ca_certs=OTHER_CERTFILE) + except ssl.SSLError: + # should fail + pass + else: + self.fail("Got server certificate %s!" % pem) + + pem = ssl.get_server_certificate(remote, + prot, + ca_certs=ISSUER_CERTFILE) + if not pem: + self.fail("No server certificate!") + if test_support.verbose: + sys.stdout.write("\nVerified certificate is\n%s\n" % pem) + +class ThreadedEchoServer(threading.Thread): + + class ConnectionHandler(threading.Thread): + + """A mildly complicated class, because we want it to work both + with and without the SSL wrapper around the socket connection, so + that we can test the STARTTLS functionality.""" + + def __init__(self, server, connsock): + self.server = server + self.running = False + self.sock = connsock + self.sock.settimeout(CONNECTION_TIMEOUT.total_seconds()) + self.sslconn = None + if not self.server.starttls_server: + self.sslconn = self.sock + threading.Thread.__init__(self) + server.register_handler(True) + self.daemon = True + + def show_conn_details(self): + if self.server.certreqs == ssl.CERT_REQUIRED: + cert = self.sslconn.getpeercert() + if test_support.verbose and self.server.chatty: + sys.stdout.write(" client cert is " + + pprint.pformat(cert) + "\n") + cert_binary = self.sslconn.getpeercert(True) + if test_support.verbose and self.server.chatty: + sys.stdout.write(" cert binary is " + + str(len(cert_binary)) + " bytes\n") + cipher = self.sslconn.cipher() + if test_support.verbose and self.server.chatty: + sys.stdout.write(" server: connection cipher is now " + + str(cipher) + "\n") + + def wrap_conn(self): + try: + self.sslconn = ssl.wrap_socket( + self.sock, server_side=True, + certfile=self.server.certificate, + ssl_version=self.server.protocol, + ca_certs=self.server.cacerts, + cert_reqs=self.server.certreqs, + ciphers=self.server.ciphers) + except ssl.SSLError: + # XXX Various errors can have happened here, for example + # a mismatching protocol version, an invalid certificate, + # or a low-level bug. This should be made more + # discriminating. + if self.server.chatty: + handle_error("\n server: bad connection attempt " + + "from " + + str(self.sock.getpeername()) + ":\n") + self.close() + self.running = False + self.server.stop() + return False + else: + return True + + def read(self): + if self.sslconn: + return self.sslconn.read() + else: + return self.sock.recv(1024) + + def write(self, bytes): + if self.sslconn: + return self.sslconn.write(bytes) + else: + return self.sock.send(bytes) + + def close(self): + self.server.register_handler(False) + if self.sslconn: + self.sslconn.close() + else: + self.sock._sock.close() + + def run(self): + self.running = True + # Complete the handshake + try: + self.sock.do_handshake() + except ssl.SSLError: + if self.server.chatty: + handle_error("\n server: failed to handshake with " + + str(self.sock.getpeername()) + ":\n") + self.close() + self.running = False + self.server.stop() + return + if self.server.starttls_server: + self.sock = self.sock.unwrap() + self.sslconn = None + else: + self.show_conn_details() + while self.running: + try: + msg = self.read().decode() + if not msg: + # eof, so quit this handler + if test_support.verbose and \ + self.server.connectionchatty: + sys.stdout.write(" server: eof\n") + self.running = False + self.close() + elif msg.strip() == 'over': + if test_support.verbose and \ + self.server.connectionchatty: + sys.stdout.write(" server: client closed " + + "connection\n") + self.close() + return + elif self.server.starttls_server and not self.sslconn \ + and msg.strip() == 'STARTTLS': + if test_support.verbose and \ + self.server.connectionchatty: + sys.stdout.write(" server: read STARTTLS " + + "from client, sending OK...\n") + self.write("OK\n".encode()) + if not self.wrap_conn(): + return + elif self.server.starttls_server and self.sslconn and \ + msg.strip() == 'ENDTLS': + if test_support.verbose and \ + self.server.connectionchatty: + sys.stdout.write(" server: read ENDTLS from " + + "client, sending OK...\n") + self.write("OK\n".encode()) + self.sslconn.unwrap() + self.sslconn = None + if test_support.verbose and \ + self.server.connectionchatty: + sys.stdout.write(" server: connection is now " + + "unencrypted...\n") + else: + if test_support.verbose and \ + self.server.connectionchatty: + ctype = (self.sslconn and "encrypted") or \ + "unencrypted" + sys.stdout.write((" server: read %s (%s), " + + "sending back %s (%s)...\n") + % (repr(msg), ctype, + repr(msg.lower()), ctype)) + self.write(msg.lower().encode()) + except ssl.SSLError: + if self.server.chatty: + handle_error("Test server failure:\n") + self.close() + self.running = False + # normally, we'd just stop here, but for the test + # harness, we want to stop the server + self.server.stop() + except socket.timeout: + pass + + def __init__(self, certificate, ssl_version=None, + certreqs=None, cacerts=None, + chatty=True, connectionchatty=False, starttls_server=False, + ciphers=None): + + if ssl_version is None: + ssl_version = ssl.PROTOCOL_DTLSv1 + if certreqs is None: + certreqs = ssl.CERT_NONE + self.certificate = certificate + self.protocol = ssl_version + self.certreqs = certreqs + self.cacerts = cacerts + self.ciphers = ciphers + self.chatty = chatty + self.connectionchatty = connectionchatty + self.starttls_server = starttls_server + self.sock = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + self.flag = None + self.num_handlers = 0 + self.num_handlers_lock = threading.Lock() + self.sock = ssl.wrap_socket(self.sock, server_side=True, + certfile=self.certificate, + cert_reqs=self.certreqs, + ca_certs=self.cacerts, + ssl_version=self.protocol, + do_handshake_on_connect=False, + ciphers=self.ciphers) + if test_support.verbose and self.chatty: + sys.stdout.write(' server: wrapped server ' + + 'socket as %s\n' % str(self.sock)) + self.sock.bind((HOST, 0)) + self.port = self.sock.getsockname()[1] + self.active = False + threading.Thread.__init__(self) + self.daemon = True + self._handlers = list() + + def start(self, flag=None): + self.flag = flag + self.starter = threading.current_thread().ident + threading.Thread.start(self) + + def run(self): + self.sock.settimeout(0.05) + self.sock.listen(5) + self.active = True + if self.flag: + # signal an event + self.flag.set() + while self.active: + try: + acc_ret = self.sock.accept() + if acc_ret: + newconn, connaddr = acc_ret + if test_support.verbose and self.chatty: + sys.stdout.write(' server: new connection from ' + + str(connaddr) + '\n') + handler = self.ConnectionHandler(self, newconn) + self._handlers.append(handler) + handler.start() + except socket.timeout: + pass + except ssl.SSLError: + pass + except KeyboardInterrupt: + self.stop() + self.sock.close() + + def register_handler(self, add): + with self.num_handlers_lock: + if add: + self.num_handlers += 1 + else: + self.num_handlers -= 1 + assert self.num_handlers >= 0 + + def stop(self): + self.active = False + if self.starter != threading.current_thread().ident: + return + self.join() # don't allow spawning new handlers after we've checked + last_msg = datetime.datetime.now() + while self.num_handlers: + time.sleep(0.05) + now = datetime.datetime.now() + if now > last_msg + datetime.timedelta(seconds=1): + sys.stdout.write(' server: waiting for connections to close\n') + last_msg = now + +class AsyncoreEchoServer(threading.Thread): + + class EchoServer(asyncore.dispatcher): + + class ConnectionHandler(asyncore.dispatcher): + + def __init__(self, conn, timeout_tracker, server): + asyncore.dispatcher.__init__(self, conn) + self._timeout_tracker = timeout_tracker + self._server = server + self._ssl_accepting = True + # Complete the handshake + self.handle_read_event() + + def __hash__(self): + return hash(self.socket) + + def readable(self): + while self.socket.pending() > 0: + self.handle_read_event() + if self in self._timeout_tracker and \ + datetime.datetime.now() >= self._timeout_tracker[self]: + self._timeout_tracker.pop(self) + try: + self.socket.handle_timeout() + except: + self.handle_close() + return False + return True + + def writable(self): + return False + + def _do_ssl_handshake(self): + try: + self.socket.do_handshake() + except ssl.SSLError as err: + if err.args[0] in (ssl.SSL_ERROR_WANT_READ, + ssl.SSL_ERROR_WANT_WRITE, + ssl.SSL_ERROR_SSL): + return + elif err.args[0] == ssl.SSL_ERROR_EOF: + return self.handle_close() + raise + except socket.error as err: + if err.args[0] == errno.ECONNABORTED: + return self.handle_close() + else: + self._ssl_accepting = False + + def handle_read(self): + if self._ssl_accepting: + self._do_ssl_handshake() + else: + data = self.recv(1024) + if data and data.strip() != 'over': + self.send(data.lower()) + if self.connected: + self._server.reset_timeout(self) + self._server.check_timeout() + if not self.connected: # above called handle_close + return + delta = self.socket.get_timeout() + if delta: + self._timeout_tracker[self] = \ + datetime.datetime.now() + delta + + def handle_close(self): + if self in self._timeout_tracker: + self._timeout_tracker.pop(self) + self._server._handlers.pop(self) + self.socket = self.socket.unwrap() + self.close() + if test_support.verbose: + sys.stdout.write(" server: closed connection %s\n" % self.socket) + + def handle_error(self): + try: + raise + except err.openssl_error() as e: + if e.ssl_error != ssl.SSL_ERROR_ZERO_RETURN: + raise + else: + self.handle_close() + + def __init__(self, certfile, timeout_tracker): + asyncore.dispatcher.__init__(self) + self._timeout_tracker = timeout_tracker + self._handlers = OrderedDict() + sock = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind((HOST, 0)) + self.sockname = sock.getsockname() + self.port = self.sockname[1] + self.set_socket(ssl.wrap_socket(sock, server_side=True, + certfile=certfile, + do_handshake_on_connect=False)) + self.listen(5) + + def writable(self): + return False + + def handle_accept(self): + self.check_timeout() + acc_ret = self.accept() + if acc_ret: + sock_obj, addr = acc_ret + if test_support.verbose: + sys.stdout.write(" server: new connection from " + + "%s:%s\n" % (addr[0], str(addr[1:]))) + self._handlers[self.ConnectionHandler(sock_obj, + self._timeout_tracker, + self)] = \ + datetime.datetime.now() + + def handle_error(self): + raise + + def reset_timeout(self, handler): + if handler in self._handlers: + self._handlers.pop(handler) + self._handlers[handler] = datetime.datetime.now() + + def check_timeout(self): + now = datetime.datetime.now() + while True: + try: + handler = self._handlers.__iter__().__next__() # oldest handler + except StopIteration: + break # there are no more handlers + if now > self._handlers[handler] + CONNECTION_TIMEOUT: + handler.handle_close() + else: + break # the oldest handlers has not yet timed out + + def close(self): + # map(lambda x: x.handle_close(), self._handlers.keys()) + for x in list(self._handlers.keys()): + x.handle_close() + assert not self._handlers + asyncore.dispatcher.close(self) + + def __init__(self, certfile): + self.flag = None + self.active = False + self.timeout_tracker = {} + self.server = self.EchoServer(certfile, self.timeout_tracker) + self.sockname = self.server.sockname + self.port = self.server.port + threading.Thread.__init__(self) + self.daemon = True + + def __str__(self): + return "<%s %s>" % (self.__class__.__name__, self.server) + + def start(self, flag=None): + self.flag = flag + threading.Thread.start(self) + + def run(self): + self.active = True + if self.flag: + self.flag.set() + while self.active: + now = datetime.datetime.now() + future_timeouts = list( + filter(lambda x: x > now, + self.timeout_tracker.values()) + ) + future_timeouts.append(now + datetime.timedelta(seconds=0.05)) + first_timeout = min(future_timeouts) - now + asyncore.loop(first_timeout.total_seconds(), count=1) + + def stop(self): + self.active = False + self.join() + self.server.close() + +# Note that this HTTP-over-UDP server does not implement packet recovery and +# reordering, but it's good enough for testing on a loopback interface +class SocketServerHTTPSServer(threading.Thread): + + class HTTPSServerUDP(socketserver.ThreadingTCPServer): + + def __init__(self, server_address, RequestHandlerClass, certfile): + socketserver.ThreadingTCPServer.__init__(self, server_address, + RequestHandlerClass, False) + # account for dealing with a datagram socket + self.socket = ssl.wrap_socket(socket.socket(AF_INET4_6, + socket.SOCK_DGRAM), + server_side=True, + certfile=certfile, + do_handshake_on_connect=False) + self.server_bind() + self.server_activate() + + def __str__(self): + return ('<%s %s:%s>' % + (self.__class__.__name__, + self.server_name, + self.server_port)) + + def server_bind(self): + """Override server_bind to store the server name.""" + socketserver.ThreadingTCPServer.server_bind(self) + host, port = self.socket.getsockname()[:2] + self.server_name = socket.getfqdn(host) + self.server_port = port + + def get_request(self): + # account for the fact that accept can return nothing, and + # according to BaseServer documentation, we should not block here + acc_ret = self.socket.accept() + if not acc_ret: + raise socket.error("No new connection") + return acc_ret + + def shutdown_request(self, request): + # Notify client of termination + request.unwrap() + + class RootedHTTPRequestHandler(SimpleHTTPRequestHandler): + # need to override translate_path to get a known root, + # instead of using os.curdir, since the test could be + # run from anywhere + + server_version = "TestHTTPS-UDP/1.0" + + root = None + + def translate_path(self, path): + """Translate a /-separated PATH to the local filename syntax. + + Components that mean special things to the local file system + (e.g. drive or directory names) are ignored. (XXX They should + probably be diagnosed.) + + """ + # abandon query parameters + path = urlparse.urlparse(path)[2] + path = os.path.normpath(urlparse.unquote(path)) + words = path.split('/') + words = filter(None, words) + path = self.root + for word in words: + drive, word = os.path.splitdrive(word) + head, word = os.path.split(word) + if word in self.root: continue + path = os.path.join(path, word) + return path + + def log_message(self, format, *args): + # we override this to suppress logging unless "verbose" + if test_support.verbose: + sys.stdout.write(" server (%s:%d %s):\n [%s] %s\n" % + (self.server.server_address, + self.server.server_port, + self.request.cipher(), + self.log_date_time_string(), + format%args)) + + + def __init__(self, certfile): + self.flag = None + self.RootedHTTPRequestHandler.root = os.path.split(CERTFILE)[0] + self.server = self.HTTPSServerUDP( + (HOST, 0), self.RootedHTTPRequestHandler, certfile) + self.port = self.server.server_port + threading.Thread.__init__(self) + self.daemon = True + + def __str__(self): + return "<%s %s>" % (self.__class__.__name__, self.server) + + def start(self, flag=None): + self.flag = flag + threading.Thread.start(self) + + def run(self): + if self.flag: + self.flag.set() + self.server.serve_forever(0.05) + + def stop(self): + self.server.shutdown() + + +def bad_cert_test(certfile): + """ + Launch a server with CERT_REQUIRED, and check that trying to + connect to it with the given client certificate fails. + """ + server = ThreadedEchoServer(CERTFILE, + certreqs=ssl.CERT_REQUIRED, + cacerts=ISSUER_CERTFILE, chatty=False) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + # try to connect + try: + try: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + certfile=certfile, + ssl_version=ssl.PROTOCOL_DTLSv1) + s.connect((HOST, server.port)) + except ssl.SSLError as x: + if test_support.verbose: + sys.stdout.write("\nSSLError is %s\n" % x) + except socket.error as x: + if test_support.verbose: + sys.stdout.write("\nsocket.error is %s\n" % x) + else: + raise AssertionError("Use of invalid cert should have failed!") + finally: + server.stop() + +def server_params_test(certfile, protocol, certreqs, cacertsfile, + client_certfile, client_protocol=None, + indata="FOO\n", ciphers=None, chatty=True, + connectionchatty=False): + """ + Launch a server, connect a client to it and try various reads + and writes. + """ + server = ThreadedEchoServer(certfile, + certreqs=certreqs, + ssl_version=protocol, + cacerts=cacertsfile, + ciphers=ciphers, + chatty=chatty, + connectionchatty=connectionchatty) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + # try to connect + if client_protocol is None: + client_protocol = protocol + try: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + certfile=client_certfile, + ca_certs=cacertsfile, + ciphers=ciphers, + cert_reqs=certreqs, + ssl_version=client_protocol) + s.connect((HOST, server.port)) + for arg in [indata, bytearray(indata.encode()), memoryview(indata.encode())]: + if connectionchatty: + if test_support.verbose: + sys.stdout.write( + " client: sending %s...\n" % (repr(arg))) + s.write(arg) + outdata = s.read().decode() + if connectionchatty: + if test_support.verbose: + sys.stdout.write(" client: read %s\n" % repr(outdata)) + if outdata != indata.lower(): + raise AssertionError( + "bad data <<%s>> (%d) received; expected <<%s>> (%d)\n" + % (outdata[:min(len(outdata),20)], len(outdata), + indata[:min(len(indata),20)].lower(), len(indata))) + s.write("over\n") + if connectionchatty: + if test_support.verbose: + sys.stdout.write(" client: closing connection.\n") + s.close() + finally: + server.stop() + +def try_protocol_combo(server_protocol, + client_protocol, + expect_success, + certsreqs=None): + if certsreqs is None: + certsreqs = ssl.CERT_NONE + certtype = { + ssl.CERT_NONE: "CERT_NONE", + ssl.CERT_OPTIONAL: "CERT_OPTIONAL", + ssl.CERT_REQUIRED: "CERT_REQUIRED", + }[certsreqs] + if test_support.verbose: + formatstr = (expect_success and " %s->%s %s\n") or " {%s->%s} %s\n" + sys.stdout.write(formatstr % + (ssl.get_protocol_name(client_protocol), + ssl.get_protocol_name(server_protocol), + certtype)) + try: + # NOTE: we must enable "ALL" ciphers, otherwise an SSLv23 client + # will send an SSLv3 hello (rather than SSLv2) starting from + # OpenSSL 1.0.0 (see issue #8322). + server_params_test(CERTFILE, server_protocol, certsreqs, + ISSUER_CERTFILE, CERTFILE, client_protocol, + ciphers="ALL", chatty=False) + # Protocol mismatch can result in either an SSLError, or a + # "Connection reset by peer" error. + except ssl.SSLError: + if expect_success: + raise + except socket.error as e: + if expect_success or e.errno != errno.ECONNRESET: + raise + else: + if not expect_success: + raise AssertionError( + "Client protocol %s succeeded with server protocol %s!" + % (ssl.get_protocol_name(client_protocol), + ssl.get_protocol_name(server_protocol))) + + +class ThreadedTests(unittest.TestCase): + + def test_unreachable(self): + server = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + server.bind((HOST, 0)) + port = server.getsockname()[1] + server.close() + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM)) + self.assertRaisesRegex(ssl.SSLError, + "The peer address is not reachable", + s.connect, (HOST, port)) + + def test_echo(self): + """Basic test of an SSL client connecting to a server""" + if test_support.verbose: + sys.stdout.write("\n") + server_params_test(CERTFILE, ssl.PROTOCOL_DTLSv1, ssl.CERT_NONE, + ISSUER_CERTFILE, CERTFILE, ssl.PROTOCOL_DTLSv1, + chatty=True, connectionchatty=True) + + def test_getpeercert(self): + if test_support.verbose: + sys.stdout.write("\n") + server = ThreadedEchoServer(CERTFILE, + certreqs=ssl.CERT_NONE, + ssl_version=ssl.PROTOCOL_DTLSv1, + cacerts=ISSUER_CERTFILE, + chatty=False) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + # try to connect + try: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + certfile=CERTFILE, + ca_certs=ISSUER_CERTFILE, + cert_reqs=ssl.CERT_REQUIRED, + ssl_version=ssl.PROTOCOL_DTLSv1) + s.connect((HOST, server.port)) + cert = s.getpeercert() + self.assertTrue(cert, "Can't get peer certificate.") + cipher = s.cipher() + if test_support.verbose: + sys.stdout.write(pprint.pformat(cert) + '\n') + sys.stdout.write("Connection cipher is " + str(cipher) + '.\n') + if 'subject' not in cert: + self.fail("No subject field in certificate: %s." % + pprint.pformat(cert)) + if ((('organizationName', 'Ray Srv Inc'),) + not in cert['subject']): + self.fail( + "Missing or invalid 'organizationName' field in " + "certificate subject; should be 'Ray Srv Inc'.") + s.write("over\n") + s.close() + finally: + server.stop() + + def test_empty_cert(self): + """Connecting with an empty cert file""" + bad_cert_test(os.path.join(os.path.dirname(__file__) or os.curdir, + "certs", "nullcert.pem")) + def test_malformed_cert(self): + """Connecting with a badly formatted certificate (syntax error)""" + bad_cert_test(os.path.join(os.path.dirname(__file__) or os.curdir, + "certs", "badcert.pem")) + def test_nonexisting_cert(self): + """Connecting with a non-existing cert file""" + bad_cert_test(os.path.join(os.path.dirname(__file__) or os.curdir, + "certs", "wrongcert.pem")) + def test_malformed_key(self): + """Connecting with a badly formatted key (syntax error)""" + bad_cert_test(os.path.join(os.path.dirname(__file__) or os.curdir, + "certs", "badkey.pem")) + + def test_protocol_dtlsv1(self): + """Connecting to a DTLSv1 server with various client options""" + if test_support.verbose: + sys.stdout.write("\n") + # server: 1.0 - client: 1.0 -> ok + try_protocol_combo(ssl.PROTOCOL_DTLSv1, ssl.PROTOCOL_DTLSv1, True) + try_protocol_combo(ssl.PROTOCOL_DTLSv1, ssl.PROTOCOL_DTLSv1, True, + ssl.CERT_OPTIONAL) + try_protocol_combo(ssl.PROTOCOL_DTLSv1, ssl.PROTOCOL_DTLSv1, True, + ssl.CERT_REQUIRED) + # server: any - client: 1.0 and 1.2(any) -> ok + try_protocol_combo(ssl.PROTOCOL_DTLS, ssl.PROTOCOL_DTLSv1, True) + try_protocol_combo(ssl.PROTOCOL_DTLS, ssl.PROTOCOL_DTLSv1, True, + ssl.CERT_REQUIRED) + try_protocol_combo(ssl.PROTOCOL_DTLS, ssl.PROTOCOL_DTLSv1_2, True) + try_protocol_combo(ssl.PROTOCOL_DTLS, ssl.PROTOCOL_DTLSv1_2, True, + ssl.CERT_REQUIRED) + try_protocol_combo(ssl.PROTOCOL_DTLS, ssl.PROTOCOL_DTLS, True) + try_protocol_combo(ssl.PROTOCOL_DTLS, ssl.PROTOCOL_DTLS, True, + ssl.CERT_REQUIRED) + # server: 1.0 - client: 1.2 -> fail + try_protocol_combo(ssl.PROTOCOL_DTLSv1, ssl.PROTOCOL_DTLSv1_2, False) + try_protocol_combo(ssl.PROTOCOL_DTLSv1, ssl.PROTOCOL_DTLSv1_2, False, + ssl.CERT_REQUIRED) + # server: 1.2 - client: 1.0 -> fail + # try_protocol_combo(ssl.PROTOCOL_DTLSv1_2, ssl.PROTOCOL_DTLSv1, False) + # try_protocol_combo(ssl.PROTOCOL_DTLSv1_2, ssl.PROTOCOL_DTLSv1, False, + # ssl.CERT_REQUIRED) + # server: 1.2 - client: 1.2 -> ok + try_protocol_combo(ssl.PROTOCOL_DTLSv1_2, ssl.PROTOCOL_DTLSv1_2, True) + try_protocol_combo(ssl.PROTOCOL_DTLSv1_2, ssl.PROTOCOL_DTLSv1_2, True, + ssl.CERT_REQUIRED) + + # def test_starttls(self): + # """Switching from clear text to encrypted and back again.""" + # msgs = ("msg 1", "MSG 2", "STARTTLS", "MSG 3", "msg 4", "ENDTLS", + # "msg 5", "msg 6") + # + # server = ThreadedEchoServer(CERTFILE, + # ssl_version=ssl.PROTOCOL_DTLSv1, + # starttls_server=True, + # chatty=True, + # connectionchatty=True) + # flag = threading.Event() + # server.start(flag) + # # wait for it to start + # flag.wait() + # # try to connect + # wrapped = False + # try: + # s = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + # ss = ssl.wrap_socket(s, ssl_version=ssl.PROTOCOL_DTLSv1) + # ss.connect((HOST, server.port)) + # s = ss.unwrap() + # if test_support.verbose: + # sys.stdout.write("\n") + # for indata in msgs: + # if test_support.verbose: + # sys.stdout.write( + # " client: sending %s...\n" % repr(indata)) + # if wrapped: + # conn.write(indata) + # outdata = conn.read() + # else: + # s.send(indata.encode()) + # outdata = s.recv(1024).decode() + # if (indata == "STARTTLS" and + # outdata.strip().lower().startswith("ok")): + # # STARTTLS ok, switch to secure mode + # if test_support.verbose: + # sys.stdout.write( + # " client: read %s from server, starting TLS...\n" + # % repr(outdata)) + # conn = ssl.wrap_socket(s, ssl_version=ssl.PROTOCOL_DTLSv1) + # wrapped = True + # elif (indata == "ENDTLS" and + # outdata.strip().lower().startswith("ok")): + # # ENDTLS ok, switch back to clear text + # if test_support.verbose: + # sys.stdout.write( + # " client: read %s from server, ending TLS...\n" + # % repr(outdata)) + # s = conn.unwrap() + # wrapped = False + # else: + # if test_support.verbose: + # sys.stdout.write( + # " client: read %s from server\n" % repr(outdata)) + # if test_support.verbose: + # sys.stdout.write(" client: closing connection.\n") + # if wrapped: + # conn.write("over\n") + # else: + # s.send("over\n".encode()) + # s.close() + # except Exception as e: + # raise + # finally: + # server.stop() + + def test_socketserver(self): + """Using a SocketServer to create and manage SSL connections.""" + server = SocketServerHTTPSServer(CERTFILE) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + # try to connect + try: + if test_support.verbose: + sys.stdout.write('\n') + with open(CERTFILE, 'rb') as f: + d1 = f.read().decode() + d2 = [] + # now fetch the same data from the HTTPS-UDP server + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM)) + s.connect((HOST, server.port)) + fl = "/" + os.path.split(CERTFILE)[1] + s.write("GET " + fl + " HTTP/1.1\r\n" + + "Host: " + HOST + "\r\n\r\n") + content = False + last_buf = "" + while True: + try: + buf = last_buf + s.read().decode() + except ssl.SSLError as err: + if err.args[0] == ssl.SSL_ERROR_ZERO_RETURN: + s = s.unwrap() # complete shutdown protocol with server + break + raise + if test_support.verbose: + sys.stdout.write( + " client: read %d bytes from remote server '%s'\n" + % (len(buf), server)) + if content: + d2.append(buf) + continue + ind = buf.find("\r\n\r\n") + if ind < 0: + last_buf = buf[-3:] # find double-newline across buffers + continue + d2.append(buf[ind + 4:]) + content = True + last_buf = "" + s.close() + self.assertEqual(d1, ''.join(d2)) + finally: + server.stop() + + def test_asyncore_server(self): + """Check the example asyncore integration.""" + indata = "TEST MESSAGE of mixed case\n" + + if test_support.verbose: + sys.stdout.write("\n") + server = AsyncoreEchoServer(CERTFILE) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + # try to connect + try: + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM)) + s.connect((HOST, server.port)) + if test_support.verbose: + sys.stdout.write( + " client: sending %s...\n" % (repr(indata))) + s.write(indata) + outdata = s.read().decode() + if test_support.verbose: + sys.stdout.write(" client: read %s\n" % repr(outdata)) + if outdata != indata.lower(): + self.fail( + "bad data <<%s>> (%d) received; expected <<%s>> (%d)\n" + % (outdata[:min(len(outdata),20)], len(outdata), + indata[:min(len(indata),20)].lower(), len(indata))) + s.write("over\n") + if test_support.verbose: + sys.stdout.write(" client: closing connection.\n") + s.close() + finally: + server.stop() + + def test_recv_send(self): + """Test recv(), send() and friends.""" + if test_support.verbose: + sys.stdout.write("\n") + + server = ThreadedEchoServer(CERTFILE, + certreqs=ssl.CERT_NONE, + ssl_version=ssl.PROTOCOL_TLSv1, + cacerts=CERTFILE, + chatty=True, + connectionchatty=False) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + # try to connect + s = ssl.wrap_socket(socket.socket(AF_INET4_6, socket.SOCK_DGRAM), + server_side=False, + certfile=CERTFILE, + ca_certs=CERTFILE, + cert_reqs=ssl.CERT_NONE, + ssl_version=ssl.PROTOCOL_DTLSv1) + s.connect((HOST, server.port)) + try: + # helper methods for standardising recv* method signatures + def _recv_into(): + b = bytearray(100) + count = s.recv_into(b) + return b[:count] + + def _recvfrom_into(): + b = bytearray(100) + count, addr = s.recvfrom_into(b) + return b[:count] + + # (name, method, whether to expect success, *args) + send_methods = [ + ('send', s.send, True, []), + # ('sendto', s.sendto, False, ["some.address"]), + ('sendall', s.sendall, True, []), + ] + recv_methods = [ + ('recv', s.recv, True, []), + # ('recvfrom', s.recvfrom, False, ["some.address"]), + ('recv_into', _recv_into, True, []), + # ('recvfrom_into', _recvfrom_into, False, []), + ] + data_prefix = u"PREFIX_" + + for meth_name, send_meth, expect_success, args in send_methods: + indata = data_prefix + meth_name + try: + send_meth(indata.encode('ASCII', 'strict'), *args) + outdata = s.read() + outdata = outdata.decode('ASCII', 'strict') + if outdata != indata.lower(): + self.fail( + "While sending with <<%s>> bad data " + "<<%r>> (%d) received; " + "expected <<%r>> (%d)\n" % ( + meth_name, outdata[:20], len(outdata), + indata[:20], len(indata) + ) + ) + except ValueError as e: + if expect_success: + self.fail( + "Failed to send with method <<%s>>; " + "expected to succeed.\n" % (meth_name,) + ) + if not str(e).startswith(meth_name): + self.fail( + "Method <<%s>> failed with unexpected " + "exception message: %s\n" % ( + meth_name, e + ) + ) + + for meth_name, recv_meth, expect_success, args in recv_methods: + indata = data_prefix + meth_name + try: + s.send(indata.encode('ASCII', 'strict')) + outdata = recv_meth(*args) + outdata = outdata.decode('ASCII', 'strict') + if outdata != indata.lower(): + self.fail( + "While receiving with <<%s>> bad data " + "<<%r>> (%d) received; " + "expected <<%r>> (%d)\n" % ( + meth_name, outdata[:20], len(outdata), + indata[:20], len(indata) + ) + ) + except ValueError as e: + if expect_success: + self.fail( + "Failed to receive with method <<%s>>; " + "expected to succeed.\n" % (meth_name,) + ) + if not str(e).startswith(meth_name): + self.fail( + "Method <<%s>> failed with unexpected " + "exception message: %s\n" % ( + meth_name, e + ) + ) + # consume data + s.read() + + s.write("over\n".encode("ASCII", "strict")) + s.close() + finally: + server.stop() + + def test_handshake_timeout(self): + # Issue #5103: SSL handshake must respect the socket timeout + server = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + server.bind((HOST, 0)) + port = server.getsockname()[1] + + try: + # try: + # c = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + # c.settimeout(0.2) + # c.connect((HOST, port)) + # # Will attempt handshake and time out + # self.assertRaisesRegex(ssl.SSLError, "timed out", + # ssl.wrap_socket, c) + # finally: + # c.close() + try: + c = socket.socket(AF_INET4_6, socket.SOCK_DGRAM) + c.settimeout(0.2) + c = ssl.wrap_socket(c) + # Will attempt handshake and time out + self.assertRaisesRegex(ssl.SSLError, "timed out", + c.connect, (HOST, port)) + finally: + c.close() + finally: + server.close() + + +def hostname_for_protocol(protocol): + global HOST + # We can't quite predict the content of the hosts file, but we prefer names + # to numbers in order to test name resolution; if we can't find a name, + # then fall back to a number for the given protocol + for name in HOST, "localhost", "ip6-localhost", "127.0.0.1", "::1": + try: + socket.getaddrinfo(name, 0, protocol) + except socket.error: + pass + else: + HOST = name + return + # Is the loopback interface enabled along with ipv6 for that interface? + raise Exception("Failed to select hostname for protocol %d" % protocol) + +def test_main(verbose=True): + global CERTFILE, ISSUER_CERTFILE, OTHER_CERTFILE, AF_INET4_6 + CERTFILE = os.path.join(os.path.dirname(__file__) or os.curdir, + "certs", "keycert.pem") + ISSUER_CERTFILE = os.path.join(os.path.dirname(__file__) or os.curdir, + "certs", "ca-cert.pem") + OTHER_CERTFILE = os.path.join(os.path.dirname(__file__) or os.curdir, + "certs", "yahoo-cert.pem") + + for fl in CERTFILE, ISSUER_CERTFILE, OTHER_CERTFILE: + if not os.path.exists(fl): + raise Exception("Can't read certificate files!") + + TestSupport.verbose = verbose + reset_default_demux() + do_patch() + for demux in "platform-native", "routing": + for AF_INET4_6 in socket.AF_INET, socket.AF_INET6: + print("Suite run: demux: %s, protocol: %d" % (demux, AF_INET4_6)) + hostname_for_protocol(AF_INET4_6) + res = unittest.main(exit=False).result.wasSuccessful() + if not res: + print("Suite run failed: demux: %s, protocol: %d" % (demux, AF_INET4_6)) + sys.exit(True) + break + if not force_routing_demux(): + break + +if __name__ == "__main__": + verbose = True if len(sys.argv) > 1 and sys.argv[1] == "-v" else False + test_main(verbose) diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit_wrapper.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit_wrapper.py new file mode 100644 index 0000000..9bcc11b --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/test/unit_wrapper.py @@ -0,0 +1,654 @@ +# -*- coding: utf-8 -*- + +# Test the support for DTLS through the SSL module. Adapted from the Python +# standard library's test_ssl.py regression test module by Björn Freise. + +import unittest +import threading +import sys +import socket +import os +import pprint + +from logging import basicConfig, DEBUG, getLogger +# basicConfig(level=DEBUG, format="%(asctime)s - %(threadName)-10s - %(name)s - %(levelname)s - %(message)s") +_logger = getLogger(__name__) + +import ssl +from dtls.wrapper import DtlsSocket +from dtls import err + + +HOST = "localhost" +CHATTY = True +CHATTY_CLIENT = True + + +class ThreadedEchoServer(threading.Thread): + + def __init__(self, certificate, ssl_version=None, certreqs=None, cacerts=None, + ciphers=None, curves=None, sigalgs=None, + mtu=None, server_key_exchange_curve=None, server_cert_options=None, + chatty=True): + + if ssl_version is None: + ssl_version = ssl.PROTOCOL_DTLSv1 + if certreqs is None: + certreqs = ssl.CERT_NONE + + self.certificate = certificate + self.protocol = ssl_version + self.certreqs = certreqs + self.cacerts = cacerts + self.ciphers = ciphers + self.curves = curves + self.sigalgs = sigalgs + self.mtu = mtu + self.server_key_exchange_curve = server_key_exchange_curve + self.server_cert_options = server_cert_options + self.chatty = chatty + + self.flag = None + + self.sock = DtlsSocket(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), + keyfile=self.certificate, + certfile=self.certificate, + server_side=True, + cert_reqs=self.certreqs, + ssl_version=self.protocol, + ca_certs=self.cacerts, + ciphers=self.ciphers, + curves=self.curves, + sigalgs=self.sigalgs, + user_mtu=self.mtu, + server_key_exchange_curve=self.server_key_exchange_curve, + server_cert_options=self.server_cert_options) + + if self.chatty: + sys.stdout.write(' server: wrapped server socket as %s\n' % str(self.sock)) + self.sock.bind((HOST, 0)) + self.port = self.sock.getsockname()[1] + self.active = False + threading.Thread.__init__(self) + self.daemon = True + + def start(self, flag=None): + self.flag = flag + self.starter = threading.current_thread().ident + threading.Thread.start(self) + + def run(self): + self.sock.settimeout(0.05) + self.sock.listen(0) + self.active = True + if self.flag: + # signal an event + self.flag.set() + while self.active: + try: + acc_ret = self.sock.recvfrom(4096) + if acc_ret: + newdata, connaddr = acc_ret + if self.chatty: + sys.stdout.write(' server: new data from ' + str(connaddr) + '\n') + self.sock.sendto(newdata.lower(), connaddr) + except socket.timeout: + pass + except KeyboardInterrupt: + self.stop() + except Exception as e: + if self.chatty: + sys.stdout.write(' server: error ' + str(e) + '\n') + pass + if self.chatty: + sys.stdout.write(' server: closing socket as %s\n' % str(self.sock)) + self.sock.close() + + def stop(self): + self.active = False + if self.starter != threading.current_thread().ident: + return + self.join() # don't allow spawning new handlers after we've checked + + +CERTFILE = os.path.join(os.path.dirname(__file__) or os.curdir, "certs", "keycert.pem") +CERTFILE_EC = os.path.join(os.path.dirname(__file__) or os.curdir, "certs", "keycert_ec.pem") +ISSUER_CERTFILE = os.path.join(os.path.dirname(__file__) or os.curdir, "certs", "ca-cert.pem") +ISSUER_CERTFILE_EC = os.path.join(os.path.dirname(__file__) or os.curdir, "certs", "ca-cert_ec.pem") + +# certfile, protocol, certreqs, cacertsfile, +# ciphers=None, curves=None, sigalgs=None, +tests = [ + {'testcase': + {'name': 'standard dtls v1', + 'desc': 'Standard DTLS v1 test with out-of-the box configuration and RSA certificate', + 'start_server': True}, + 'input': + {'certfile': CERTFILE, + 'protocol': ssl.PROTOCOL_DTLSv1, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE, + 'client_ciphers': None, + 'client_curves': None, + 'client_sigalgs': None}, + 'result': + {'ret_success': True, + 'error_code': None, + 'exception': None}}, + {'testcase': + {'name': 'standard dtls v1_2', + 'desc': 'Standard DTLS v1_2 test with out-of-the box configuration and ECDSA certificate', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': None, + 'client_curves': None, + 'client_sigalgs': None}, + 'result': + {'ret_success': True, + 'error_code': None, + 'exception': None}}, + {'testcase': + {'name': 'protocol version mismatch', + 'desc': 'Client and server have different protocol versions', + 'start_server': True}, + 'input': + {'certfile': CERTFILE, + 'protocol': ssl.PROTOCOL_DTLSv1, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE, + 'client_ciphers': None, + 'client_curves': None, + 'client_sigalgs': None}, + 'result': + {'ret_success': False, + 'error_code': 'wrong ssl version', + 'exception': None}}, + {'testcase': + {'name': 'certificate verify fails', + 'desc': 'Server certificate cannot be verified by client', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE, + 'client_ciphers': None, + 'client_curves': None, + 'client_sigalgs': None}, + 'result': + {'ret_success': False, + 'error_code': 'certificate verify failed', + 'exception': None}}, + {'testcase': + {'name': 'no matching curve', + 'desc': 'Client doesn\'t support curve used by server ECDSA certificate', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': None, + 'client_curves': 'secp384r1', + 'client_sigalgs': None}, + 'result': + {'ret_success': False, + 'error_code': 'handshake failure', + 'exception': None}}, + {'testcase': + {'name': 'matching curve', + 'desc': '', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': None, + 'client_curves': 'prime256v1', + 'client_sigalgs': None}, + 'result': + {'ret_success': True, + 'error_code': None, + 'exception': None}}, + {'testcase': + {'name': 'no host', + 'desc': 'No server port is listening', + 'start_server': False}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': None, + 'client_curves': None, + 'client_sigalgs': None}, + 'result': + {'ret_success': False, + 'error_code': err.ERR_WRITE_TIMEOUT, + 'exception': None}}, + {'testcase': + {'name': 'no matching sigalgs', + 'desc': '', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': None, + 'client_curves': None, + 'client_sigalgs': "RSA+SHA256"}, + 'result': + {'ret_success': False, + 'error_code': 'handshake failure', + 'exception': None}}, + {'testcase': + {'name': 'matching sigalgs', + 'desc': '', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': None, + 'client_curves': None, + 'client_sigalgs': "ECDSA+SHA256"}, + 'result': + {'ret_success': True, + 'error_code': None, + 'exception': None}}, + {'testcase': + {'name': 'no matching cipher', + 'desc': 'Server using a ECDSA certificate while client is only able to use RSA encryption', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': "AES256-SHA", + 'client_curves': None, + 'client_sigalgs': None}, + 'result': + {'ret_success': False, + 'error_code': 'handshake failure', + 'exception': None}}, + {'testcase': + {'name': 'matching cipher', + 'desc': '', + 'start_server': True}, + 'input': + {'certfile': CERTFILE_EC, + 'protocol': ssl.PROTOCOL_DTLSv1_2, + 'certreqs': None, + 'cacertsfile': ISSUER_CERTFILE_EC, + 'ciphers': None, + 'curves': None, + 'sigalgs': None, + 'client_certfile': None, + 'client_protocol': ssl.PROTOCOL_DTLSv1_2, + 'client_certreqs': ssl.CERT_REQUIRED, + 'client_cacertsfile': ISSUER_CERTFILE_EC, + 'client_ciphers': "ECDHE-ECDSA-AES256-SHA", + 'client_curves': None, + 'client_sigalgs': None}, + 'result': + {'ret_success': True, + 'error_code': None, + 'exception': None}}, +] + + +def params_test(start_server, certfile, protocol, certreqs, cacertsfile, + client_certfile=None, client_protocol=None, client_certreqs=None, client_cacertsfile=None, + ciphers=None, curves=None, sigalgs=None, + client_ciphers=None, client_curves=None, client_sigalgs=None, + mtu=None, server_key_exchange_curve=None, server_cert_options=None, + indata="FOO\n", chatty=False, connectionchatty=False): + """ + Launch a server, connect a client to it and try various reads + and writes. + """ + server = ThreadedEchoServer(certfile, + ssl_version=protocol, + certreqs=certreqs, + cacerts=cacertsfile, + ciphers=ciphers, + curves=curves, + sigalgs=sigalgs, + mtu=mtu, + server_key_exchange_curve=server_key_exchange_curve, + server_cert_options=server_cert_options, + chatty=chatty) + # should we really run the server? + if start_server: + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + else: + server.sock.close() + # try to connect + if client_protocol is None: + client_protocol = protocol + if client_ciphers is None: + client_ciphers = ciphers + if client_curves is None: + client_curves = curves + if client_sigalgs is None: + client_sigalgs = sigalgs + try: + s = DtlsSocket(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), + keyfile=client_certfile, + certfile=client_certfile, + cert_reqs=client_certreqs, + ssl_version=client_protocol, + ca_certs=client_cacertsfile, + ciphers=client_ciphers, + curves=client_curves, + sigalgs=client_sigalgs, + user_mtu=mtu) + s.settimeout(3.0) + s.connect((HOST, server.port)) + if connectionchatty: + sys.stdout.write(" client: sending %s...\n" % (repr(indata))) + s.write(indata) + outdata = s.read().decode() + if connectionchatty: + sys.stdout.write(" client: read %s\n" % repr(outdata)) + if outdata != indata.lower(): + raise AssertionError("bad data <<%s>> (%d) received; expected <<%s>> (%d)\n" + % (outdata[:min(len(outdata), 20)], len(outdata), + indata[:min(len(indata), 20)].lower(), len(indata))) + cert = s.getpeercert() + cipher = s.cipher() + if connectionchatty: + sys.stdout.write("cert:\n" + pprint.pformat(cert) + "\n") + sys.stdout.write("cipher:\n" + pprint.pformat(cipher) + "\n") + if connectionchatty: + sys.stdout.write(" client: closing connection.\n") + try: + s.close() + except Exception as e: + if connectionchatty: + sys.stdout.write(" client: error closing connection %s...\n" % (repr(e))) + pass + except Exception as e: + if connectionchatty: + sys.stdout.write(" client: aborting with exception %s...\n" % (repr(e))) + return False, e + finally: + if start_server: + server.stop() + return True, None + + +class TestSequenceMeta(type): + + @classmethod + def gen_test(mcs, _case, _input, _result): + def test(self): + try: + if CHATTY or CHATTY_CLIENT: + sys.stdout.write("\nTestcase: %s\n" % _case['name']) + ret, e = params_test(_case['start_server'], chatty=CHATTY, connectionchatty=CHATTY_CLIENT, **_input) + if _result['ret_success']: + self.assertEqual(ret, _result['ret_success']) + else: + try: + last_error = e.errqueue[-1][1].decode() + except: + try: + last_error = e.errno + except: + last_error = None + if isinstance(last_error, str): + self.assertRegex(last_error, _result['error_code']) + else: + self.assertEqual(last_error, _result['error_code']) + except Exception as e: + raise + + return test + + def __new__(mcs, name, bases, attrs): + for testcase in tests: + _case, _input, _result = testcase.values() + test_name = "test_%s" % _case['name'].lower().replace(' ', '_') + attrs[test_name] = mcs.gen_test(_case, _input, _result) + + return super(TestSequenceMeta, mcs).__new__(mcs, name, bases, attrs) + + +class WrapperTests(unittest.TestCase, metaclass=TestSequenceMeta): + + def test_build_cert_chain(self): + steps = [ssl.SSL_BUILD_CHAIN_FLAG_NONE, ssl.SSL_BUILD_CHAIN_FLAG_NO_ROOT] + chatty, connectionchatty = CHATTY, CHATTY_CLIENT + indata = 'FOO' + certs = dict() + + if chatty or connectionchatty: + sys.stdout.write("\nTestcase: test_build_cert_chain\n") + for step in steps: + server = ThreadedEchoServer(certificate=CERTFILE, + ssl_version=ssl.PROTOCOL_DTLSv1_2, + certreqs=ssl.CERT_NONE, + cacerts=ISSUER_CERTFILE, + ciphers=None, + curves=None, + sigalgs=None, + mtu=None, + server_key_exchange_curve=None, + server_cert_options=step, + chatty=chatty) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + try: + s = DtlsSocket(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), + keyfile=None, + certfile=None, + cert_reqs=ssl.CERT_REQUIRED, + ssl_version=ssl.PROTOCOL_DTLSv1_2, + ca_certs=ISSUER_CERTFILE, + ciphers=None, + curves=None, + sigalgs=None, + user_mtu=None) + s.connect((HOST, server.port)) + if connectionchatty: + sys.stdout.write(" client: sending %s...\n" % (repr(indata))) + s.write(indata) + outdata = s.read().decode() + if connectionchatty: + sys.stdout.write(" client: read %s\n" % repr(outdata)) + if outdata != indata.lower(): + raise AssertionError("bad data <<%s>> (%d) received; expected <<%s>> (%d)\n" + % (outdata[:min(len(outdata), 20)], len(outdata), + indata[:min(len(indata), 20)].lower(), len(indata))) + # cert = s.getpeercert() + # cipher = s.cipher() + # if connectionchatty: + # sys.stdout.write("cert:\n" + pprint.pformat(cert) + "\n") + # sys.stdout.write("cipher:\n" + pprint.pformat(cipher) + "\n") + certs[step] = s.getpeercertchain() + if connectionchatty: + sys.stdout.write(" client: closing connection.\n") + try: + s.close() + except Exception as e: + if connectionchatty: + sys.stdout.write(" client: error closing connection %s...\n" % (repr(e))) + pass + except Exception as e: + if connectionchatty: + sys.stdout.write(" client: aborting with exception %s...\n" % (repr(e))) + raise + finally: + server.stop() + + if chatty: + sys.stdout.write("certs:\n") + for step in steps: + sys.stdout.write("SSL_CTX_build_cert_chain: %s\n%s\n" % (step, pprint.pformat(certs[step]))) + self.assertNotEqual(certs[steps[0]], certs[steps[1]]) + self.assertEqual(len(certs[steps[0]]) - len(certs[steps[1]]), 1) + + def test_set_ecdh_curve(self): + steps = { + # server, client, result + 'all auto': (None, None, True), # Auto + 'client restricted': (None, "secp256k1:prime256v1", True), # client can handle key curve + 'client too restricted': (None, "secp256k1", False), # client _cannot_ handle key curve + 'client minimum': (None, "prime256v1", True), # client can only handle key curve + 'server restricted': ("secp384r1", None, True), # client can handle key curve + 'server one, client two': ("secp384r1", "prime256v1:secp384r1", True), # client can handle key curve + 'server one, client one': ("secp384r1", "secp384r1", False), # client _cannot_ handle key curve + } + + chatty, connectionchatty = CHATTY, CHATTY_CLIENT + indata = 'FOO' + certs = dict() + + if chatty or connectionchatty: + sys.stdout.write("\nTestcase: test_ecdh_curve\n") + for step, tmp in steps.items(): + if chatty or connectionchatty: + sys.stdout.write("\n Subcase: %s -> should %s\n" % (step, 'succeed' if tmp[2] else 'FAIL')) + server_curve, client_curve, result = tmp + server = ThreadedEchoServer(certificate=CERTFILE_EC, + ssl_version=ssl.PROTOCOL_DTLSv1_2, + certreqs=ssl.CERT_NONE, + cacerts=ISSUER_CERTFILE_EC, + ciphers=None, + curves=None, + sigalgs=None, + mtu=None, + server_key_exchange_curve=server_curve, + server_cert_options=None, + chatty=chatty) + flag = threading.Event() + server.start(flag) + # wait for it to start + flag.wait() + try: + s = DtlsSocket(socket.socket(socket.AF_INET, socket.SOCK_DGRAM), + keyfile=None, + certfile=None, + cert_reqs=ssl.CERT_REQUIRED, + ssl_version=ssl.PROTOCOL_DTLSv1_2, + ca_certs=ISSUER_CERTFILE_EC, + ciphers=None, + curves=client_curve, + sigalgs=None, + user_mtu=None) + s.connect((HOST, server.port)) + if connectionchatty: + sys.stdout.write(" client: sending %s...\n" % (repr(indata))) + s.write(indata) + outdata = s.read().decode() + if connectionchatty: + sys.stdout.write(" client: read %s\n" % repr(outdata)) + if outdata != indata.lower(): + raise AssertionError("bad data <<%s>> (%d) received; expected <<%s>> (%d)\n" + % (outdata[:min(len(outdata), 20)], len(outdata), + indata[:min(len(indata), 20)].lower(), len(indata))) + if connectionchatty: + sys.stdout.write(" client: closing connection.\n") + try: + s.close() + except Exception as e: + if connectionchatty: + sys.stdout.write(" client: error closing connection %s...\n" % (repr(e))) + pass + except Exception as e: + if connectionchatty: + sys.stdout.write(" client: aborting with exception %s...\n" % (repr(e))) + if result: + raise + finally: + server.stop() + + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/tlock.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/tlock.py new file mode 100644 index 0000000..682c0e0 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/tlock.py @@ -0,0 +1,57 @@ +# TLock: OpenSSL lock support on thread-enabled systems. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""TLock + +This module provides the callbacks required by the OpenSSL library in situations +where it is being entered concurrently by multiple threads. This module is +enagaged automatically by the PyDTLS package on systems that have Python +threading support. It does not have client-visible components. +""" + +from logging import getLogger +from openssl import * + +try: + import threading +except ImportError: + pass + +_logger = getLogger(__name__) +DO_DEBUG_LOG = False + +def tlock_init(): + if not globals().has_key("threading"): + return # nothing to configure + # The standard library ssl module's lock implementation is more efficient; + # do not override it if it has been established + if CRYPTO_get_id_callback(): + return + global _locks + num_locks = CRYPTO_num_locks() + _locks = tuple(threading.Lock() for _ in range(num_locks)) + CRYPTO_set_locking_callback(_locking_function) + +def _locking_function(mode, n, file, line): + if DO_DEBUG_LOG: + _logger.debug("Thread lock: mode: %d, n: %d, file: %s, line: %d", + mode, n, file, line) + if mode & CRYPTO_LOCK: + _locks[n].acquire() + else: + _locks[n].release() diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/util.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/util.py new file mode 100644 index 0000000..8011f81 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/util.py @@ -0,0 +1,73 @@ +# Shared implementation internals. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utilities + +This module contains private implementation details shared among modules of +the PyDTLS package. +""" + +from logging import getLogger + +_logger = getLogger(__name__) + + +class _Rsrc(object): + """Wrapper base for library-owned resources""" + def __init__(self, value): + self._value = value + + @property + def value(self): + return self._value + + @property + def raw(self): + return self._value.raw + + +class _BIO(_Rsrc): + """BIO wrapper""" + def __init__(self, value): + _logger.debug("Allocating BIO: %d", value.raw) + super(_BIO, self).__init__(value) + self.owned = True + + def disown(self): + self.owned = False + + def __del__(self): + if self.owned: + _logger.debug("Freeing BIO: %d", self.raw) + from .openssl import BIO_free + BIO_free(self._value) + self.owned = False + self._value = None + + +class _EC_KEY(_Rsrc): + """EC KEY wrapper""" + def __init__(self, value): + _logger.debug("Allocating EC_KEY: %d", value.raw) + super(_EC_KEY, self).__init__(value) + + def __del__(self): + _logger.debug("Freeing EC_KEY: %d", self.raw) + from .openssl import EC_KEY_free + EC_KEY_free(self._value) + self._value = None diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/wrapper.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/wrapper.py new file mode 100644 index 0000000..0162792 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/wrapper.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- + +# DTLS Socket: A wrapper for a server and client using a DTLS connection. + +# Copyright 2017 Björn Freise +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""DTLS Socket + +This wrapper encapsulates the state and behavior associated with the connection +between the OpenSSL library and an individual peer when using the DTLS +protocol. + +Classes: + + DtlsSocket -- DTLS Socket wrapper for use as a client or server +""" + +import select +import time +import collections +from logging import getLogger + +import ssl +import socket +from .patch import do_patch +do_patch() +from .sslconnection import SSLContext, SSL +from .err import * + +_logger = getLogger(__name__) + + +def wrap_client(sock, keyfile=None, certfile=None, + cert_reqs=ssl.CERT_NONE, ssl_version=ssl.PROTOCOL_DTLSv1_2, ca_certs=None, + do_handshake_on_connect=True, suppress_ragged_eofs=True, + ciphers=None, curves=None, sigalgs=None, user_mtu=None, + client_cert_options=ssl.SSL_BUILD_CHAIN_FLAG_NONE, + ssl_logging=False, handshake_timeout=None): + + return DtlsSocket(sock=sock, keyfile=keyfile, certfile=certfile, server_side=False, + cert_reqs=cert_reqs, ssl_version=ssl_version, ca_certs=ca_certs, + do_handshake_on_connect=do_handshake_on_connect, suppress_ragged_eofs=suppress_ragged_eofs, + ciphers=ciphers, curves=curves, sigalgs=sigalgs, user_mtu=user_mtu, + server_key_exchange_curve=None, server_cert_options=client_cert_options, + ssl_logging=ssl_logging, handshake_timeout=handshake_timeout) + + +def wrap_server(sock, keyfile=None, certfile=None, + cert_reqs=ssl.CERT_NONE, ssl_version=ssl.PROTOCOL_DTLS, ca_certs=None, + do_handshake_on_connect=False, suppress_ragged_eofs=True, + ciphers=None, curves=None, sigalgs=None, user_mtu=None, + server_key_exchange_curve=None, server_cert_options=ssl.SSL_BUILD_CHAIN_FLAG_NONE, + ssl_logging=False, client_timeout=None, handshake_timeout=None, + cb_ignore_ssl_exception_in_handshake=None, cb_ignore_ssl_exception_read=None, + cb_ignore_ssl_exception_write=None): + + return DtlsSocket(sock=sock, keyfile=keyfile, certfile=certfile, server_side=True, + cert_reqs=cert_reqs, ssl_version=ssl_version, ca_certs=ca_certs, + do_handshake_on_connect=do_handshake_on_connect, suppress_ragged_eofs=suppress_ragged_eofs, + ciphers=ciphers, curves=curves, sigalgs=sigalgs, user_mtu=user_mtu, + server_key_exchange_curve=server_key_exchange_curve, server_cert_options=server_cert_options, + ssl_logging=ssl_logging, client_timeout=client_timeout, handshake_timeout=handshake_timeout, + cb_ignore_ssl_exception_in_handshake=cb_ignore_ssl_exception_in_handshake, + cb_ignore_ssl_exception_read=cb_ignore_ssl_exception_read, + cb_ignore_ssl_exception_write=cb_ignore_ssl_exception_write) + + +class DtlsSocket(object): + + class _ClientSession(object): + + def __init__(self, host, port, handshake_done=False, timeout=None): + self.host = host + self.port = int(port) + self.handshake_done = handshake_done + self.timeout = timeout + self.updateTimestamp() + + def getAddr(self): + return self.host, self.port + + def updateTimestamp(self): + if self.timeout is not None: + self.last_update = time.time() + + def expired(self): + if self.timeout is None: + return False + else: + return (time.time() - self.last_update) > self.timeout + + def __init__( + self, + sock=None, + keyfile=None, + certfile=None, + server_side=False, + cert_reqs=ssl.CERT_NONE, + ssl_version=ssl.PROTOCOL_DTLSv1_2, + ca_certs=None, + do_handshake_on_connect=False, + suppress_ragged_eofs=True, + ciphers=None, + curves=None, + sigalgs=None, + user_mtu=None, + server_key_exchange_curve=None, + server_cert_options=ssl.SSL_BUILD_CHAIN_FLAG_NONE, + ssl_logging=False, + client_timeout=None, + handshake_timeout=None, + cb_ignore_ssl_exception_in_handshake=None, + cb_ignore_ssl_exception_read=None, + cb_ignore_ssl_exception_write=None, + ): + + if server_cert_options is None: + server_cert_options = ssl.SSL_BUILD_CHAIN_FLAG_NONE + + self._ssl_logging = ssl_logging + self._server_side = server_side + self._ciphers = ciphers + self._curves = curves + self._sigalgs = sigalgs + self._user_mtu = user_mtu + self._server_key_exchange_curve = server_key_exchange_curve + self._server_cert_options = server_cert_options + self._client_timeout = client_timeout + self._handshake_timeout = handshake_timeout + self._cb_ignore_ssl_exception_in_handshake = cb_ignore_ssl_exception_in_handshake + self._cb_ignore_ssl_exception_read = cb_ignore_ssl_exception_read + self._cb_ignore_ssl_exception_write = cb_ignore_ssl_exception_write + + # Default socket creation + if isinstance(sock, socket.socket): + _sock = sock + else: + _sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + self._sock = ssl.wrap_socket(_sock, + keyfile=keyfile, + certfile=certfile, + server_side=self._server_side, + cert_reqs=cert_reqs, + ssl_version=ssl_version, + ca_certs=ca_certs, + do_handshake_on_connect=do_handshake_on_connect, + suppress_ragged_eofs=suppress_ragged_eofs, + ciphers=self._ciphers, + cb_user_config_ssl_ctx=self.user_config_ssl_ctx, + cb_user_config_ssl=self.user_config_ssl) + + if self._server_side: + self._clients = {} + self._timeout = None + + def __getattr__(self, item): + if hasattr(self, "_sock") and hasattr(self._sock, item): + return getattr(self._sock, item) + raise AttributeError + + def user_config_ssl_ctx(self, _ctx): + """ + + :param SSLContext _ctx: + """ + _ctx.set_ssl_logging(self._ssl_logging) + if self._ciphers: + _ctx.set_ciphers(self._ciphers) + if self._curves: + _ctx.set_curves(self._curves) + if self._sigalgs: + _ctx.set_sigalgs(self._sigalgs) + _ctx.build_cert_chain(flags=self._server_cert_options) + if self._server_side: + _ctx.set_ecdh_curve(curve_name=self._server_key_exchange_curve) + + def _dtls_timer_cb(self, ssl, timer_us): + timer_us = 1000000 # Standard value from OpenSSL 1.1.1b + if self._handshake_timeout: + timer_us = int(self._handshake_timeout*1000*1000) + _logger.debug("DTLS timer callback ... %d [us]", timer_us) + return timer_us + + def user_config_ssl(self, _ssl): + """ + + :param SSL _ssl: + """ + if self._user_mtu: + _ssl.set_link_mtu(self._user_mtu) + + _ssl.DTLS_set_timer_cb(self._dtls_timer_cb) + + def settimeout(self, t): + if self._server_side: + self._timeout = t + else: + self._sock.settimeout(t) + + def close(self): + if self._server_side: + for cli in self._clients.keys(): + cli.close() + else: + try: + conn = self._sock.unwrap() + except: + self._sock.close() + else: + conn.close() + + def recvfrom(self, bufsize, flags=0): + if self._server_side: + return self._recvfrom_on_server_side(bufsize, flags=flags) + else: + return self._recvfrom_on_client_side(bufsize, flags=flags) + + def _recvfrom_on_server_side(self, bufsize, flags): + try: + r, _, _ = select.select(self._getAllReadingSockets(), [], [], self._timeout) + + except OSError as ose: + import errno + if ose.errno == errno.EBADF: + # Connection closed? Do nothing ... + pass + else: + raise + except socket.timeout: + # __Nothing__ received from any client + pass + + else: + for conn in r: + try: + _last_peer = conn.getpeername() if conn._connected else None + if self._sockIsServerSock(conn): + # Connect + self._clientAccept(conn) + else: + # Handshake + if not self._clientHandshakeDone(conn): + self._clientDoHandshake(conn) + # Normal read + else: + buf = self._clientRead(conn, bufsize) + if buf: + self._clients[conn].updateTimestamp() + if conn in self._clients: + return buf, self._clients[conn].getAddr() + else: + _logger.debug('Received data from an already disconnected client!') + + except Exception as e: + _logger.warning('Exception for connection %s raised' % repr(_last_peer)) + setattr(e, 'peer', _last_peer) + if self._cb_ignore_ssl_exception_read is not None \ + and isinstance(self._cb_ignore_ssl_exception_read, collections.Callable) \ + and self._cb_ignore_ssl_exception_read(e): + self._clientDrop(conn, e) + continue + raise e + + try: + for conn in self._getClientReadingSockets(): + timeleft = conn.get_timeout() + if timeleft is not None and timeleft == 0: + ret = conn.handle_timeout() + _logger.debug('Retransmission triggered for %s: %d' % (str(self._clients[conn].getAddr()), ret)) + + if self._clients[conn].expired(): + _logger.debug('Found expired session') + self._clientDrop(conn) + + except Exception as e: + raise e + + # __No_data__ received from any client + raise socket.timeout + + def _recvfrom_on_client_side(self, bufsize, flags): + try: + buf = self._sock.recv(bufsize, flags) + + except ssl.SSLError as e: + if e.errno == ssl.ERR_READ_TIMEOUT or e.args[0] == ssl.SSL_ERROR_WANT_READ: + pass + else: + raise e + + else: + if buf: + return buf, self._sock.getpeername() + + # __No_data__ received from any client + raise socket.timeout + + def sendto(self, buf, address): + if self._server_side: + return self._sendto_from_server_side(buf, address) + else: + return self._sendto_from_client_side(buf, address) + + def _sendto_from_server_side(self, buf, address): + for conn, client in self._clients.items(): + if client.getAddr() == address: + try: + return self._clientWrite(conn, buf) + except Exception as e: + if self._cb_ignore_ssl_exception_write is not None \ + and isinstance(self._cb_ignore_ssl_exception_write, collections.Callable) \ + and self._cb_ignore_ssl_exception_write(e): + self._clientDrop(conn, e) + continue + raise e + return 0 + + def _sendto_from_client_side(self, buf, address): + try: + if not self._sock._connected: + self._sock.connect(address) + bytes_sent = self._sock.send(buf) + + except ssl.SSLError as e: + raise e + + return bytes_sent + + def _getClientReadingSockets(self): + return [x for x in self._clients.keys()] + + def _getAllReadingSockets(self): + return [self._sock] + self._getClientReadingSockets() + + def _sockIsServerSock(self, conn): + return conn is self._sock + + def _clientHandshakeDone(self, conn): + return conn in self._clients and self._clients[conn].handshake_done is True + + def _clientAccept(self, conn): + _logger.debug('+' * 60) + ret = None + + try: + ret = conn.accept() + _logger.debug('Accept returned with ... %s' % (str(ret))) + + except Exception as e: + raise e + + else: + if ret: + client, addr = ret + host, port = addr + if client in self._clients: + _logger.debug('Client already connected %s' % str(client)) + raise ValueError + self._clients[client] = self._ClientSession(host=host, port=port, timeout=self._client_timeout) + + self._clientDoHandshake(client) + + def _clientDoHandshake(self, conn): + _logger.debug('-' * 60) + conn.setblocking(False) + + try: + conn.do_handshake() + _logger.debug('Connection from %s successful' % (str(self._clients[conn].getAddr()))) + + self._clients[conn].handshake_done = True + + except ssl.SSLError as e: + if e.errno == ERR_HANDSHAKE_TIMEOUT or e.args[0] == ssl.SSL_ERROR_WANT_READ: + pass + else: + self._clientDrop(conn, error=e) + if self._cb_ignore_ssl_exception_in_handshake is not None \ + and isinstance(self._cb_ignore_ssl_exception_in_handshake, collections.Callable) \ + and self._cb_ignore_ssl_exception_in_handshake(e): + return + raise e + + def _clientRead(self, conn, bufsize=4096): + _logger.debug('*' * 60) + ret = None + + try: + ret = conn.recv(bufsize) + _logger.debug('From client %s ... bytes received %s' % (str(self._clients[conn].getAddr()), str(len(ret)))) + + except ssl.SSLError as e: + if e.args[0] == ssl.SSL_ERROR_WANT_READ: + pass + else: + self._clientDrop(conn, error=e) + + return ret + + def _clientWrite(self, conn, data): + _logger.debug('#' * 60) + ret = None + + try: + _data = data + ret = conn.send(_data) + _logger.debug('To client %s ... bytes sent %s' % (str(self._clients[conn].getAddr()), str(ret))) + + except Exception as e: + raise e + + return ret + + def _clientDrop(self, conn, error=None): + _logger.debug('$' * 60) + + try: + if conn not in self._clients: + _logger.debug('Drop client %s not yet connected?!' % repr(conn)) + elif error: + _logger.debug('Drop client %s ... with error: %s' % (self._clients[conn].getAddr(), error)) + else: + _logger.debug('Drop client %s' % str(self._clients[conn].getAddr())) + handshake_done = False + if conn in self._clients: + handshake_done = self._clients[conn] + del self._clients[conn] + try: + _conn = conn + if handshake_done: + _conn = conn.unwrap() + except Exception as e: + _logger.warning('Error in unwrap: %s', e) + conn.close() + else: + _conn.close() + + except Exception as e: + _logger.warning('Error in clientDrop: %s', e) + pass diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/x509.py b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/x509.py new file mode 100644 index 0000000..96b6a01 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/x509.py @@ -0,0 +1,142 @@ +# X509: certificate support. + +# Copyright 2012 Ray Brown +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# The License is also distributed with this work in the file named "LICENSE." +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""X509 Certificate + +This module provides support for X509 certificates through the OpenSSL library. +This support includes mapping certificate data to Python dictionaries in the +manner established by the Python standard library's ssl module. This module is +required because the standard library's ssl module does not provide its support +for certificates from arbitrary sources, but instead only for certificates +retrieved from servers during handshaking or get_server_certificate by its +CPython _ssl implementation module. This author is aware of the latter module's +_test_decode_certificate function, but has decided not to use this function +because it is undocumented, and because its use would tie PyDTLS to the CPython +interpreter. +""" + +from logging import getLogger +from .openssl import * +from .util import _Rsrc, _BIO + +_logger = getLogger(__name__) + + +class _X509(_Rsrc): + """Wrapper for the cryptographic library's X509 resource""" + def __init__(self, value): + _logger.debug("Allocating X509: %d", value.raw) + super(_X509, self).__init__(value) + + def __del__(self): + _logger.debug("Freeing X509: %d", self.raw) + X509_free(self._value) + self._value = None + + +class _STACK(_Rsrc): + """Wrapper for the cryptographic library's stacks""" + def __init__(self, value): + _logger.debug("Allocating STACK: %d", value.raw) + super(_STACK, self).__init__(value) + + def __del__(self): + _logger.debug("Freeing stack: %d", self.raw) + sk_pop_free(self._value) + self._value = None + +def decode_cert(cert): + """Convert an X509 certificate into a Python dictionary + + This function converts the given X509 certificate into a Python dictionary + in the manner established by the Python standard library's ssl module. + """ + + ret_dict = {} + subject_xname = X509_get_subject_name(cert.value) + ret_dict["subject"] = _create_tuple_for_X509_NAME(subject_xname) + + notAfter = X509_get_notAfter(cert.value) + ret_dict["notAfter"] = ASN1_TIME_print(notAfter) + + peer_alt_names = _get_peer_alt_names(cert) + if peer_alt_names is not None: + ret_dict["subjectAltName"] = peer_alt_names + + return ret_dict + +def _test_decode_cert(cert_filename): + """format_cert testing + + Test the certificate conversion functionality with a PEM-encoded X509 + certificate. + """ + + cert_file = _BIO(BIO_new_file(cert_filename, "rb")) + cert = _X509(PEM_read_bio_X509_AUX(cert_file.value)) + return decode_cert(cert) + +def _create_tuple_for_attribute(name, value): + name_str = OBJ_obj2txt(name, False) + value_str = decode_ASN1_STRING(value) + return name_str, value_str + +def _create_tuple_for_X509_NAME(xname): + distinguished_name = [] + relative_distinguished_name = [] + level = -1 + for ind in range(X509_NAME_entry_count(xname)): + name_entry_ptr = X509_NAME_get_entry(xname, ind) + name_entry = name_entry_ptr.contents + if level >= 0 and level != name_entry.set: + distinguished_name.append(tuple(relative_distinguished_name)) + relative_distinguished_name = [] + level = name_entry.set + asn1_object = X509_NAME_ENTRY_get_object(name_entry_ptr) + asn1_string = X509_NAME_ENTRY_get_data(name_entry_ptr) + attribute_tuple = _create_tuple_for_attribute(asn1_object, asn1_string) + relative_distinguished_name.append(attribute_tuple) + if relative_distinguished_name: + distinguished_name.append(tuple(relative_distinguished_name)) + return tuple(distinguished_name) + +def _get_peer_alt_names(cert): + ret_list = None + ext_index = -1 + while True: + ext_index = X509_get_ext_by_NID(cert.value, NID_subject_alt_name, + ext_index) + if ext_index < 0: + break + if ret_list is None: + ret_list = [] + ext_ptr = X509_get_ext(cert.value, ext_index) + method_ptr = X509V3_EXT_get(ext_ptr) + general_names = _STACK(ASN1_item_d2i(method_ptr.contents, + ext_ptr.contents.value.contents)) + for name_index in range(sk_num(general_names.value)): + name_ptr = sk_value(general_names.value, name_index) + if name_ptr.contents.type == GEN_DIRNAME: + name_tuple = "DirName", \ + _create_tuple_for_X509_NAME(name_ptr.contents.d.directoryName) + else: + name_str = GENERAL_NAME_print(name_ptr) + name_tuple = tuple(name_str.split(':', 1)) + ret_list.append(name_tuple) + + return tuple(ret_list) if ret_list is not None else None diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/udp/__init__.py b/hyperscale/core_rewrite/engines/client/udp/protocols/udp/__init__.py new file mode 100644 index 0000000..38f16fb --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/udp/__init__.py @@ -0,0 +1 @@ +from .connection import UDPConnection \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/udp/connection.py b/hyperscale/core_rewrite/engines/client/udp/protocols/udp/connection.py new file mode 100644 index 0000000..ebb4e07 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/udp/connection.py @@ -0,0 +1,61 @@ +import asyncio +import socket +import ssl +from typing import Callable + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + _DEFAULT_LIMIT, + Reader, + Writer, +) + +from .protocol import UDPProtocol + +QuicStreamHandler = Callable[[asyncio.StreamReader, asyncio.StreamWriter], None] + + +class UDPConnection: + + def __init__(self) -> None: + self.loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + self.transport: asyncio.DatagramTransport = None + self._connection = None + self.socket: socket.socket = None + self._writer = None + + async def create_udp(self, socket_config=None, *, limit=_DEFAULT_LIMIT, tls: ssl.SSLContext=None): + + self.loop = asyncio.get_event_loop() + + family, type_, _, _, address = socket_config + + self.socket = socket.socket(family=family, type=type_) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + await self.loop.run_in_executor(None, self.socket.connect, address) + + self.socket.setblocking(False) + + if tls: + self.socket = tls.wrap_socket(self.socket) + + reader = Reader(limit=limit, loop=self.loop) + reader_protocol = UDPProtocol(reader, loop=self.loop) + + self.transport, _ = await self.loop.create_datagram_endpoint( + lambda: reader_protocol, + sock=self.socket + ) + + self._writer = Writer(self.transport, reader_protocol, reader, self.loop) + + return reader, self._writer + + async def close(self): + + try: + self.transport.close() + + except Exception: + pass + diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/udp/protocol.py b/hyperscale/core_rewrite/engines/client/udp/protocols/udp/protocol.py new file mode 100644 index 0000000..4e8f428 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/udp/protocols/udp/protocol.py @@ -0,0 +1,118 @@ +from asyncio import Protocol, Transport +from asyncio.coroutines import iscoroutine +from weakref import ref + +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + FlowControlMixin, + Reader, + Writer, +) + + +class UDPProtocol(FlowControlMixin, Protocol): + + _source_traceback = None + + def __init__(self, stream_reader, client_connected_cb=None, loop=None): + super().__init__(loop=loop) + + if stream_reader is not None: + self._stream_reader_wr: Reader = ref(stream_reader) + self._source_traceback = stream_reader._source_traceback + else: + self._stream_reader_wr = None + if client_connected_cb is not None: + # This is a stream created by the `create_server()` function. + # Keep a strong reference to the reader until a connection + # is established. + self._strong_reader = stream_reader + self._reject_connection = False + self._stream_writer: Writer = None + self._transport: Transport = None + self._client_connected_cb = client_connected_cb + + self._closed = self._loop.create_future() + + @property + def _stream_reader(self) -> Reader: + if self._stream_reader_wr is None: + return None + return self._stream_reader_wr() + + def _replace_writer(self, writer: Writer): + transport = writer.transport + self._stream_writer = writer + self._transport = transport + + def connection_made(self, transport: Transport): + if self._reject_connection: + context = { + 'message': ('An open stream was garbage collected prior to ' + 'establishing network connection; ' + 'call "stream.close()" explicitly.') + } + if self._source_traceback: + context['source_traceback'] = self._source_traceback + self._loop.call_exception_handler(context) + transport.abort() + return + self._transport = transport + reader: Reader = self._stream_reader + if reader is not None: + reader.set_transport(transport) + + if self._client_connected_cb is not None: + self._stream_writer = Reader(transport, self, + reader, + self._loop) + res = self._client_connected_cb(reader, + self._stream_writer) + if iscoroutine(res): + self._loop.create_task(res) + self._strong_reader = None + + def connection_lost(self, exc): + reader: Reader = self._stream_reader + if reader is not None: + if exc is None: + reader.feed_eof() + else: + reader.set_exception(exc) + if not self._closed.done(): + if exc is None: + self._closed.set_result(None) + else: + self._closed.set_exception(exc) + super().connection_lost(exc) + self._stream_reader_wr = None + self._stream_writer = None + self._transport = None + + def error_received(self, exc): + raise exc + + def datagram_received(self, data): + reader = self._stream_reader + if reader is not None: + reader.feed_data(data) + + def eof_received(self): + reader: Reader = self._stream_reader + if reader is not None: + reader.feed_eof() + + return True + + def _get_close_waiter(self, stream): + return self._closed + + def __del__(self): + # Prevent reports about unhandled exceptions. + # Better than self._closed._log_traceback = False hack + try: + closed = self._closed + except AttributeError: + pass # failed constructor + else: + if closed.done() and not closed.cancelled(): + closed.exception() \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/websocket/__init__.py b/hyperscale/core_rewrite/engines/client/websocket/__init__.py new file mode 100644 index 0000000..7f9bf02 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/__init__.py @@ -0,0 +1,8 @@ +from .mercury_sync_websocket_connection import ( + MercurySyncWebsocketConnection as MercurySyncWebsocketConnection, +) +from .models.websocket import ( + OptimizedWebsocketRequest, + WebsocketRequest, + WebsocketResponse, +) diff --git a/hyperscale/core_rewrite/engines/client/websocket/connection.py b/hyperscale/core_rewrite/engines/client/websocket/connection.py new file mode 100644 index 0000000..f9ae61c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/connection.py @@ -0,0 +1,6 @@ +from hyperscale.core_rewrite.engines.client.http.protocols import HTTPConnection + + +class WebsocketConnection(HTTPConnection): + def __init__(self, reset_connections: bool = False) -> None: + super().__init__(reset_connections=reset_connections) diff --git a/hyperscale/core_rewrite/engines/client/websocket/mercury_sync_websocket_connection.py b/hyperscale/core_rewrite/engines/client/websocket/mercury_sync_websocket_connection.py new file mode 100644 index 0000000..9dc8b66 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/mercury_sync_websocket_connection.py @@ -0,0 +1,453 @@ +import asyncio +import ssl +import time +from collections import defaultdict +from typing import ( + Dict, + List, + Literal, + Optional, + Tuple, + Union, +) +from urllib.parse import urlparse + +from pydantic import BaseModel + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + Cookies, + HTTPCookie, + HTTPEncodableValue, + URLMetadata, +) +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .connection import WebsocketConnection +from .models.websocket import ( + WebsocketRequest, + WebsocketResponse, + get_header_bits, + get_message_buffer_size, +) + + +class MercurySyncWebsocketConnection: + def __init__( + self, + pool_size: Optional[int] = None, + timeouts: Timeouts = Timeouts(), + reset_connections: bool = False, + ) -> None: + if pool_size is None: + pool_size = 100 + + self.timeouts = timeouts + self.reset_connections = reset_connections + + self._client_ssl_context = self._create_general_client_ssl_context() + + self._dns_lock: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) + self._dns_waiters: Dict[str, asyncio.Future] = defaultdict(asyncio.Future) + self._pending_queue: List[asyncio.Future] = [] + + self._client_waiters: Dict[asyncio.Transport, asyncio.Future] = {} + self._connections: List[WebsocketConnection] = [ + WebsocketConnection( + reset_connections=reset_connections, + ) + for _ in range(pool_size) + ] + + self._hosts: Dict[str, Tuple[str, int]] = {} + + self._connections_count: Dict[str, List[asyncio.Transport]] = defaultdict(list) + self._locks: Dict[asyncio.Transport, asyncio.Lock] = {} + + self._max_concurrency = pool_size + + self._semaphore = asyncio.Semaphore(self._max_concurrency) + self._connection_waiters: List[asyncio.Future] = [] + + self._url_cache: Dict[str, URL] = {} + + def _create_general_client_ssl_context(self): + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + + async def send( + self, + url: str, + auth: Optional[Tuple[str, str]] = None, + cookies: Optional[List[HTTPCookie]] = None, + headers: Dict[str, str] = {}, + params: Optional[Dict[str, HTTPEncodableValue]] = None, + timeout: Union[Optional[int], Optional[float]] = None, + data: Union[ + Optional[str], Optional[Dict[str, str]], Optional[BaseModel] + ] = None, + redirects: int = 3, + ): + async with self._semaphore: + try: + method = "GET" + if data: + method = "POST" + + return await asyncio.wait_for( + self._request( + WebsocketRequest( + url=url, + method=method, + cookies=cookies, + auth=auth, + headers=headers, + params=params, + data=data, + redirects=redirects, + ), + ), + timeout=timeout, + ) + + except asyncio.TimeoutError: + url_data = urlparse(url) + + return WebsocketResponse( + url=URLMetadata( + host=url_data.hostname, + path=url_data.path, + params=url_data.params, + query=url_data.query, + ), + headers=headers, + method="PUT", + status=408, + status_message="Request timed out.", + ) + + async def _request( + self, + request: WebsocketRequest, + cert_path: Optional[str] = None, + key_path: Optional[str] = None, + ): + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = { + "request_start": None, + "connect_start": None, + "connect_end": None, + "write_start": None, + "write_end": None, + "read_start": None, + "read_end": None, + "request_end": None, + } + timings["request_start"] = time.monotonic() + + result, redirect, timings = await self._execute(request, timings=timings) + + if redirect: + location = result.headers.get(b"location").decode() + + upgrade_ssl = False + if "https" in location and "https" not in request.url: + upgrade_ssl = True + + for _ in range(request.redirects): + result, redirect, timings = await self._execute( + request, + upgrade_ssl=upgrade_ssl, + redirect_url=location, + timings=timings, + ) + + if redirect is False: + break + + location = result.headers.get(b"location").decode() + + upgrade_ssl = False + if "https" in location and "https" not in request.url: + upgrade_ssl = True + + timings["request_end"] = time.monotonic() + result.timings.update(timings) + + return result + + async def _execute( + self, + request: WebsocketRequest, + upgrade_ssl: bool = False, + redirect_url: Optional[str] = None, + timings: Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ] = {}, + ) -> Tuple[ + WebsocketResponse, + bool, + Dict[ + Literal[ + "request_start", + "connect_start", + "connect_end", + "write_start", + "write_end", + "read_start", + "read_end", + "request_end", + ], + float | None, + ], + ]: + if redirect_url: + request_url = redirect_url + + else: + request_url = request.url + + try: + if timings["connect_start"] is None: + timings["connect_start"] = time.monotonic() + + (connection, url, upgrade_ssl) = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=request_url if upgrade_ssl else None + ), + timeout=self.timeouts.connect_timeout, + ) + + if upgrade_ssl: + ssl_redirect_url = request_url.replace("http://", "https://") + + connection, url, _ = await asyncio.wait_for( + self._connect_to_url_location( + request_url, ssl_redirect_url=ssl_redirect_url + ), + timeout=self.timeouts.connect_timeout, + ) + + request_url = ssl_redirect_url + + headers, data = request.prepare(url) + + if connection.reader is None: + timings["connect_end"] = time.monotonic() + + return ( + WebsocketResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method=request.method, + status=400, + headers=headers, + ), + False, + timings, + ) + + timings["connect_end"] = time.monotonic() + + if timings["write_start"] is None: + timings["write_start"] = time.monotonic() + + connection.writer.write(headers) + + if data: + connection.writer.write(data) + + timings["write_end"] = time.monotonic() + + if timings["read_start"] is None: + timings["read_start"] = time.monotonic() + + response_code = await asyncio.wait_for( + connection.reader.readline(), timeout=self.timeouts.read_timeout + ) + + status_string: List[bytes] = response_code.split() + status = int(status_string[1]) + + if status >= 300 and status < 400: + timings["read_end"] = time.monotonic() + + return ( + WebsocketResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method=request.method, + status=status, + headers=headers, + ), + True, + timings, + ) + + headers: Dict[bytes, bytes] = {} + + raw_headers = b"" + async for key, value, header_line in connection.reader.iter_headers( + connection + ): + headers[key] = value + raw_headers += header_line + + cookies: Union[Cookies, None] = None + cookies_data: Union[bytes, None] = headers.get(b"set-cookie") + if cookies_data: + cookies = Cookies() + cookies.update(cookies_data) + + header_content_length = 0 + if data: + header_bits = get_header_bits(raw_headers) + header_content_length = get_message_buffer_size(header_bits) + + body_size = min(16384, header_content_length) + + if body_size > 0: + body = await asyncio.wait_for( + connection.readexactly(body_size), self.timeouts.request_timeout + ) + + self._connections.append(connection) + + timings["read_end"] = time.monotonic() + + return ( + WebsocketResponse( + url=URLMetadata(host=url.hostname, path=url.path), + method=request.method, + status=status, + headers=headers, + content=body, + ), + False, + timings, + ) + + except Exception as request_exception: + self._connections.append( + WebsocketConnection(reset_connection=self.reset_connections) + ) + + if isinstance(request_url, str): + request_url = urlparse(request_url) + + timings["read_end"] = time.monotonic() + + return ( + WebsocketResponse( + url=URLMetadata(host=request_url.hostname, path=request_url.path), + method=request.method, + status=400, + status_message=str(request_exception), + ), + False, + timings, + ) + + async def _connect_to_url_location( + self, request_url: str, ssl_redirect_url: Optional[str] = None + ) -> Tuple[WebsocketConnection, URL, bool]: + if ssl_redirect_url: + parsed_url = URL(ssl_redirect_url) + + else: + parsed_url = URL(request_url) + + url = self._url_cache.get(parsed_url.hostname) + dns_lock = self._dns_lock[parsed_url.hostname] + dns_waiter = self._dns_waiters[parsed_url.hostname] + + do_dns_lookup = url is None or ssl_redirect_url + + if do_dns_lookup and dns_lock.locked() is False: + await dns_lock.acquire() + url = parsed_url + await url.lookup() + + self._dns_lock[parsed_url.hostname] = dns_lock + self._url_cache[parsed_url.hostname] = url + + dns_waiter = self._dns_waiters[parsed_url.hostname] + + if dns_waiter.done() is False: + dns_waiter.set_result(None) + + dns_lock.release() + + elif do_dns_lookup: + await dns_waiter + url = self._url_cache.get(parsed_url.hostname) + + connection = self._connections.pop() + + if url.address is None or ssl_redirect_url: + for address, ip_info in url: + try: + await connection.make_connection( + url.hostname, + address, + url.port, + ip_info, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + url.address = address + url.socket_config = ip_info + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return None, parsed_url, True + + else: + try: + await connection.make_connection( + url.hostname, + url.address, + url.port, + url.socket_config, + ssl=self._client_ssl_context + if url.is_ssl or ssl_redirect_url + else None, + ssl_upgrade=ssl_redirect_url is not None, + ) + + except Exception as connection_error: + if "server_hostname is only meaningful with ssl" in str( + connection_error + ): + return None, parsed_url, True + + raise connection_error + + return connection, parsed_url, False diff --git a/hyperscale/core_rewrite/engines/client/websocket/models/__init__.py b/hyperscale/core_rewrite/engines/client/websocket/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/engines/client/websocket/models/websocket/__init__.py b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/__init__.py new file mode 100644 index 0000000..435a40c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/__init__.py @@ -0,0 +1,11 @@ +from .optimized_websocket_request import ( + OptimizedWebsocketRequest as OptimizedWebsocketRequest, +) +from .utils import ( + get_header_bits as get_header_bits, +) +from .utils import ( + get_message_buffer_size as get_message_buffer_size, +) +from .websocket_request import WebsocketRequest as WebsocketRequest +from .websocket_response import WebsocketResponse as WebsocketResponse diff --git a/hyperscale/core_rewrite/engines/client/websocket/models/websocket/constants.py b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/constants.py new file mode 100644 index 0000000..99baa44 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/constants.py @@ -0,0 +1,2 @@ +WEBSOCKETS_VERSION = 13 +HEADER_LENGTH_INDEX = 6 \ No newline at end of file diff --git a/hyperscale/core_rewrite/engines/client/websocket/models/websocket/optimized_websocket_request.py b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/optimized_websocket_request.py new file mode 100644 index 0000000..383592c --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/optimized_websocket_request.py @@ -0,0 +1,38 @@ +from typing import Literal, Optional, Tuple + +from pydantic import ( + BaseModel, + StrictBytes, + StrictInt, + StrictStr, +) + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class OptimizedWebsocketRequest(BaseModel): + call_id: StrictInt + url: Optional[URL] = None + method: Literal[ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "PATCH", + "DELETE", + ] + encoded_params: Optional[StrictStr | StrictBytes] = None + encoded_auth: Optional[StrictStr | StrictBytes] = None + encoded_cookies: Optional[ + StrictStr + | StrictBytes + | Tuple[StrictStr, StrictStr] + | Tuple[StrictBytes, StrictBytes] + ] = None + encoded_headers: Optional[StrictBytes] = None + encoded_data: Optional[StrictBytes] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/engines/client/websocket/models/websocket/utils.py b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/utils.py new file mode 100644 index 0000000..77d5a28 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/utils.py @@ -0,0 +1,51 @@ +import os +import struct +from base64 import encodebytes as base64encode +from typing import Any, Tuple + +from .constants import HEADER_LENGTH_INDEX + + +def create_sec_websocket_key(): + randomness = os.urandom(16) + return base64encode(randomness).decode("utf-8").strip() + + +def pack_hostname(hostname): + # IPv6 address + if ":" in hostname: + return "[" + hostname + "]" + + return hostname + + +def get_header_bits(raw_headers: bytes): + b1 = raw_headers[0] + fin = b1 >> 7 & 1 + rsv1 = b1 >> 6 & 1 + rsv2 = b1 >> 5 & 1 + rsv3 = b1 >> 4 & 1 + opcode = b1 & 0xF + b2 = raw_headers[1] + has_mask = b2 >> 7 & 1 + length_bits = b2 & 0x7F + + header_bits = (fin, rsv1, rsv2, rsv3, opcode, has_mask, length_bits) + + return header_bits + + +async def get_message_buffer_size(header_bits: Tuple[int], connection: Any): + bits = header_bits[HEADER_LENGTH_INDEX] + length_bits = bits & 0x7F + length = 0 + if length_bits == 0x7E: + v = await connection.readexactly(2) + length = struct.unpack("!H", v)[0] + elif length_bits == 0x7F: + v = await connection.readexactly(8) + length = struct.unpack("!Q", v)[0] + else: + length = length_bits + + return length diff --git a/hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_request.py b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_request.py new file mode 100644 index 0000000..de12e34 --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_request.py @@ -0,0 +1,130 @@ +from typing import Dict, List, Literal, Optional, Tuple, Union +from urllib.parse import urlencode + +import orjson +from pydantic import BaseModel, StrictBytes, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.models import ( + URL, + HTTPCookie, + HTTPEncodableValue, +) + +from .constants import WEBSOCKETS_VERSION +from .utils import create_sec_websocket_key, pack_hostname + +NEW_LINE = "\r\n" + + +class WebsocketRequest(BaseModel): + url: StrictStr + method: Literal["GET", "POST", "HEAD", "OPTIONS", "PUT", "PATCH", "DELETE"] + cookies: Optional[List[HTTPCookie]] = None + auth: Optional[Tuple[str, str]] = None + params: Optional[Dict[str, HTTPEncodableValue]] = None + headers: Dict[str, str] = {} + data: Union[Optional[StrictStr], Optional[StrictBytes], Optional[BaseModel]] = None + redirects: StrictInt = 3 + + class Config: + arbitrary_types_allowed = True + + def prepare(self, url: URL): + url_path = url.path + + if self.params and len(self.params) > 0: + url_params = urlencode(self.params) + url_path += f"?{url_params}" + + get_base: str = f"{self.method} {url.path} HTTP/1.1{NEW_LINE}" + + port = url.port or (443 if url.scheme == "https" else 80) + + hostname = url.hostname.encode("idna").decode() + + if port not in [80, 443]: + hostname = f"{hostname}:{port}" + + if isinstance(self.data, BaseModel): + data = orjson.dumps( + { + name: value + for name, value in self.data.__dict__.items() + if value is not None + } + ) + self.headers["content-type"] = "application/json" + + size = len(data) + + elif isinstance(self.data, str): + data = self.data.encode() + size = len(data) + + elif self.data: + data = self.data + size = len(self.data) + + else: + data = self.data + size = 0 + + headers = ["Upgrade: websocket", f"Content-Length: {size}"] + + if url.port == 80 or url.port == 443: + hostport = pack_hostname(url.hostname) + else: + hostport = "%s:%d" % (pack_hostname(url.hostname), url.port) + + host = self.headers.get("host") + if host: + headers.append(f"Host: {host}") + else: + headers.append(f"Host: {hostport}") + + # scheme, url = url.split(":", 1) + if not self.headers.get("suppress_origin"): + origin = self.headers.get("origin") + + if origin: + headers.append(f"Origin: {origin}") + + elif url.scheme == "wss": + headers.append(f"Origin: https://{hostport}") + + else: + headers.append(f"Origin: http://{hostport}") + + key = create_sec_websocket_key() + + header = self.headers.get("header") + if not header or "Sec-WebSocket-Key" not in header: + headers.append(f"Sec-WebSocket-Key: {key}") + else: + key = self.headers.get("header", {}).get("Sec-WebSocket-Key") + + if not header or "Sec-WebSocket-Version" not in header: + headers.append(f"Sec-WebSocket-Version: {WEBSOCKETS_VERSION}") + + connection = self.headers.get("connection") + if not connection: + headers.append("Connection: Upgrade") + else: + headers.append(connection) + + subprotocols = self.headers.get("subprotocols") + if subprotocols: + headers.append("Sec-WebSocket-Protocol: %s" % ",".join(subprotocols)) + + if header: + if isinstance(header, dict): + header = [": ".join([k, v]) for k, v in header.items() if v is not None] + + headers.extend(header) + + headers.append("") + headers.append("") + + get_base += "\r\n".join(headers).encode() + + return (get_base + NEW_LINE).encode(), data diff --git a/hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_response.py b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_response.py new file mode 100644 index 0000000..a01230d --- /dev/null +++ b/hyperscale/core_rewrite/engines/client/websocket/models/websocket/websocket_response.py @@ -0,0 +1,9 @@ +from hyperscale.core_rewrite.engines.client.http.models.http import HTTPResponse + + +class WebsocketResponse(HTTPResponse): + + class Config: + arbitrary_types_allowed=True + + \ No newline at end of file diff --git a/hyperscale/core_rewrite/graph.py b/hyperscale/core_rewrite/graph.py new file mode 100644 index 0000000..b7a4fa7 --- /dev/null +++ b/hyperscale/core_rewrite/graph.py @@ -0,0 +1,156 @@ +import asyncio +import math +import time +from typing import Any, Callable, Dict, List + +import psutil + +from .hooks import CallResolver, Hook +from .workflow import Workflow + + +async def cancel_pending(pend: asyncio.Task): + try: + if pend.done(): + pend.exception() + + return pend + + pend.cancel() + await asyncio.sleep(0) + if not pend.cancelled(): + await pend + + return pend + + except asyncio.CancelledError as cancelled_error: + return cancelled_error + + except asyncio.TimeoutError as timeout_error: + return timeout_error + + except asyncio.InvalidStateError as invalid_state: + return invalid_state + + +class Graph: + def __init__( + self, + workflows: List[Workflow], + context: Dict[str, Callable[..., Any] | object] = {}, + ) -> None: + self.graph = __file__ + self.workflows = workflows + self.max_active = 0 + self.active = 0 + self._call_resolver = CallResolver() + + self.context: Dict[str, Callable[..., Any] | object] = context + + self._active_waiter: asyncio.Future | None = None + self._workflows_by_name: Dict[str, Workflow] = {} + + async def run(self): + for workflow in self.workflows: + await self._run(workflow) + + async def setup(self): + call_ids: List[str] = [] + hooks_by_call_id: Dict[str, Hook] = {} + + for workflow in self.workflows: + self._workflows_by_name[workflow.name] = workflow + + for hook in workflow.hooks.values(): + self._call_resolver.add_args(hook.static_args) + + hooks_by_call_id.update({hook.call_id: hook}) + + call_ids.append(hook.call_id) + + # await self._call_resolver.resolve_arg_types() + + # for call_id, optimized in self._call_resolver: + # print(call_id, optimized) + + async def _run(self, workflow: Workflow): + loop = asyncio.get_event_loop() + + completed, pending = await asyncio.wait( + [ + loop.create_task(self._spawn_vu(workflow)) + async for _ in self._generate(workflow) + ], + timeout=1, + ) + + results: List[List[Any]] = await asyncio.gather(*completed) + + await asyncio.gather( + *[asyncio.create_task(cancel_pending(pend)) for pend in pending] + ) + + all_completed: List[Any] = [] + for results_set in results: + all_completed.extend(results_set) + + return all_completed + + async def _generate(self, workflow: Workflow): + self._active_waiter = asyncio.Future() + + duration = workflow.config.get("duration") + vus = workflow.config.get("vus") + threads = workflow.config.get("threads") + + elapsed = 0 + + self.max_active = math.ceil( + vus * (psutil.cpu_count(logical=False) ** 2) / threads + ) + + start = time.monotonic() + while elapsed < duration: + remaining = duration - elapsed + + yield remaining + + await asyncio.sleep(0) + elapsed = time.monotonic() - start + + if self.active > self.max_active: + remaining = duration - elapsed + + try: + await asyncio.wait_for(self._active_waiter, timeout=remaining) + except asyncio.TimeoutError: + pass + + async def _spawn_vu( + self, + workflow: Workflow, + ): + try: + results: List[Any] = [] + + for hook_set in workflow.traversal_order: + set_count = len(hook_set) + self.active += set_count + + results.extend( + await asyncio.gather( + *[hook.call() for hook in hook_set], + return_exceptions=True, + ) + ) + + self.active -= set_count + + if self.active <= self.max_active and self._active_waiter: + self._active_waiter.set_result(None) + self._active_waiter = asyncio.Future() + + except Exception: + pass + + return results diff --git a/hyperscale/core_rewrite/hooks/__init__.py b/hyperscale/core_rewrite/hooks/__init__.py new file mode 100644 index 0000000..edcea76 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/__init__.py @@ -0,0 +1,4 @@ +from .call_arg import CallArg +from .call_resolver import CallResolver +from .hook import Hook +from .step import step \ No newline at end of file diff --git a/hyperscale/core_rewrite/hooks/call_arg.py b/hyperscale/core_rewrite/hooks/call_arg.py new file mode 100644 index 0000000..c8e891e --- /dev/null +++ b/hyperscale/core_rewrite/hooks/call_arg.py @@ -0,0 +1,22 @@ +from typing import Any, Literal, Optional + +from pydantic import BaseModel, StrictInt, StrictStr + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + + +class CallArg(BaseModel): + call_name: StrictStr + call_id: StrictInt + arg_name: Optional[StrictStr] = None + arg_type: Literal["arg", "kwarg"] + position: Optional[StrictInt] = None + workflow: StrictStr + engine: StrictStr + method: StrictStr + value: Any + data_type: Literal["static", "dynamic"] + timeouts: Optional[Timeouts] = None + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/hooks/call_resolver.py b/hyperscale/core_rewrite/hooks/call_resolver.py new file mode 100644 index 0000000..d69ccba --- /dev/null +++ b/hyperscale/core_rewrite/hooks/call_resolver.py @@ -0,0 +1,1235 @@ +import asyncio +import binascii +from collections import defaultdict +from ssl import SSLContext +from typing import ( + Any, + Callable, + Coroutine, + Dict, + Iterator, + List, + Literal, + Optional, + Tuple, + Union, +) +from urllib.parse import urlencode + +import orjson +from google.protobuf.message import Message +from graphql import Source, parse, print_ast + +from hyperscale.core_rewrite.engines.client.graphql import ( + MercurySyncGraphQLConnection, + OptimizedGraphQLRequest, +) +from hyperscale.core_rewrite.engines.client.graphql_http2 import ( + MercurySyncGraphQLHTTP2Connection, + OptimizedGraphQLHTTP2Request, +) +from hyperscale.core_rewrite.engines.client.grpc import ( + MercurySyncGRPCConnection, + OptimizedGRPCRequest, +) +from hyperscale.core_rewrite.engines.client.http import ( + MercurySyncHTTPConnection, + OptimizedHTTPRequest, +) +from hyperscale.core_rewrite.engines.client.http2 import ( + MercurySyncHTTP2Connection, + OptimizedHTTP2Request, +) +from hyperscale.core_rewrite.engines.client.http2.fast_hpack import Encoder +from hyperscale.core_rewrite.engines.client.http2.settings import Settings +from hyperscale.core_rewrite.engines.client.http3 import ( + MercurySyncHTTP3Connection, + OptimizedHTTP3Request, +) +from hyperscale.core_rewrite.engines.client.http3.protocols.quic_protocol import ( + FrameType, + encode_frame, +) +from hyperscale.core_rewrite.engines.client.shared.models import URL, RequestType +from hyperscale.core_rewrite.engines.client.shared.protocols import ( + NEW_LINE, + WEBSOCKETS_VERSION, +) +from hyperscale.core_rewrite.engines.client.shared.request_types_map import ( + RequestTypesMap, +) +from hyperscale.core_rewrite.engines.client.udp import ( + MercurySyncUDPConnection, + OptimizedUDPRequest, +) +from hyperscale.core_rewrite.engines.client.websocket import ( + MercurySyncWebsocketConnection, + OptimizedWebsocketRequest, +) +from hyperscale.core_rewrite.engines.client.websocket.models.websocket.utils import ( + create_sec_websocket_key, + pack_hostname, +) + +from .call_arg import CallArg +from .resolved_arg import ResolvedArg +from .resolved_arg_type import ResolvedArgType +from .resolved_auth import ResolvedAuth +from .resolved_cookies import ResolvedCookies +from .resolved_data import ResolvedData +from .resolved_headers import ResolvedHeaders +from .resolved_method import ResolvedMethod +from .resolved_params import ResolvedParams +from .resolved_redirects import ResolvedRedirects +from .resolved_url import ResolvedURL + + +class CallResolver: + def __init__(self) -> None: + self._args: Dict[int, List[CallArg]] = defaultdict(list) + self._kwargs: Dict[int, List[CallArg]] = defaultdict(list) + + self._position_map: Dict[str, Dict[int, str]] = { + "graphql": {0: "url", 1: "query"}, + "graphqlh2": {0: "url", 1: "query"}, + "grpc": {0: "url"}, + "http": {0: "url"}, + "http2": {0: "url"}, + "http3": {0: "url"}, + "udp": {0: "url"}, + "websocket": {0: "url"}, + } + + self._call_optimization_map: Dict[ + str, Callable[[CallArg], Coroutine[Any, Any, None]] + ] = { + "url": self._set_url, + "method": self._set_method, + "auth": self._set_auth, + "cookies": self._set_cookies, + "params": self._set_params, + "headers": self._set_headers, + "data": self._set_data, + "redirects": self._set_redirects, + } + + self._request_types_map = RequestTypesMap() + self._resolved: Dict[ + int, + Dict[ + Literal[ + "url", + "method", + "auth", + "cookies", + "params", + "headers", + "data", + "redirects", + ], + ResolvedArg, + ], + ] = defaultdict(dict) + self._optimized_actions: Dict[ + RequestType, + OptimizedGraphQLRequest + | OptimizedGraphQLHTTP2Request + | OptimizedGRPCRequest + | OptimizedHTTPRequest + | OptimizedHTTP2Request + | OptimizedHTTP3Request + | OptimizedUDPRequest + | OptimizedWebsocketRequest, + ] = {} + + self._connections: Dict[ + RequestType, + Callable[ + [], + Union[ + MercurySyncGraphQLConnection, + MercurySyncGraphQLHTTP2Connection, + MercurySyncGRPCConnection, + MercurySyncHTTPConnection, + MercurySyncHTTP2Connection, + MercurySyncHTTP3Connection, + MercurySyncUDPConnection, + MercurySyncWebsocketConnection, + ], + ], + ] = { + RequestType.GRAPHQL: lambda: MercurySyncGraphQLConnection(pool_size=1), + RequestType.GRAPHQL_HTTP2: lambda: MercurySyncGraphQLHTTP2Connection( + pool_size=1 + ), + RequestType.GRPC: lambda: MercurySyncGRPCConnection(pool_size=1), + RequestType.HTTP: lambda: MercurySyncHTTPConnection(pool_size=1), + RequestType.HTTP2: lambda: MercurySyncHTTP2Connection(pool_size=1), + RequestType.HTTP3: lambda: MercurySyncHTTP3Connection(pool_size=1), + RequestType.UDP: lambda: MercurySyncUDPConnection(pool_size=1), + RequestType.WEBSOCKET: lambda: MercurySyncWebsocketConnection(pool_size=1), + } + + self._call_engine_types: Dict[str, str] = {} + self._call_workflows: Dict[str, str] = {} + self._call_args: Dict[ + Literal[ + "method", + "url", + "data", + "params", + "headers", + "auth", + "cookies", + "redirects", + ], + List[Tuple[CallArg, Callable[..., None]]], + ] = defaultdict(list) + + self._available_optimizations: Dict[ + Literal[ + "method", + "url", + "data", + "params", + "headers", + "auth", + "cookies", + "redirects", + ], + List[str], + ] = defaultdict(list) + + def __iter__(self): + for call_id, action in self._optimized_actions.items(): + yield call_id, action + + def __getitem__(self, call_id: str): + return self._optimized_actions.get(call_id) + + def get_engine(self, call_id: str): + return self._call_engine_types.get(call_id) + + def get_workflow(self, call_id: str): + return self._call_workflows.get(call_id) + + def add_args(self, optimizer_args: Dict[str, List[CallArg]]): + for call_id in optimizer_args: + self._args[call_id].extend( + [arg for arg in optimizer_args[call_id] if arg.arg_type == "arg"] + ) + + self._kwargs[call_id].extend( + [arg for arg in optimizer_args[call_id] if arg.arg_type == "kwarg"] + ) + + async def resolve_arg_types(self): + await asyncio.gather( + *[ + self._optimize_engine_type(engine_type) + for engine_type in self._position_map + ] + ) + + for call_id in self._args: + # call_engine_type = self.get_engine(call_id) + call_workflow = self.get_workflow(call_id) + + if call_workflow: + method: ResolvedArg[ResolvedMethod] = self._resolved[call_id]["method"] + has_headers = call_id in self._available_optimizations["headers"] + has_optimized_headers = self._resolved[call_id].get("headers") + headers_request_type = self._request_types_map[method.arg.engine] + + optimize_default_headers = ( + has_headers is False + and has_optimized_headers is None + and headers_request_type != RequestType.UDP + ) + + if optimize_default_headers: + self._set_headers( + CallArg( + call_name=method.arg.call_name, + call_id=method.arg.call_id, + method=method.arg.method, + arg_name="headers", + arg_type="kwarg", + workflow=method.arg.workflow, + engine=method.arg.engine, + value={}, + data_type="static", + timeouts=method.arg.timeouts, + ) + ) + + self._available_optimizations["headers"].append(call_id) + + request_type = self._request_types_map[method.arg.engine] + + resolved_data = self._resolved[call_id] + + method: ResolvedArg[ResolvedMethod] = resolved_data.get("method") + url: ResolvedArg[ResolvedURL] = resolved_data.get("url") + params: ResolvedArg[ResolvedParams] = resolved_data.get("params") + auth: ResolvedArg[ResolvedAuth] = resolved_data.get("auth") + headers: ResolvedArg[ResolvedHeaders] = resolved_data.get("headers") + data: ResolvedArg[ResolvedData] = resolved_data.get("data") + redirects: ResolvedArg[ResolvedRedirects] = resolved_data.get( + "redirects" + ) + + match request_type: + case RequestType.GRAPHQL: + self._optimized_actions[call_id] = OptimizedGraphQLRequest( + call_id=call_id, + method=method.value.method, + url=url.value.url if url else None, + encoded_params=params.value.params if params else None, + encoded_auth=auth.value.auth if auth else None, + encoded_headers=headers.value.headers if headers else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case RequestType.GRAPHQL_HTTP2: + self._optimized_actions[call_id] = OptimizedGraphQLHTTP2Request( + call_id=call_id, + method=method.value.method, + url=url.value.url if url else None, + encoded_params=params.value.params if params else None, + encoded_auth=auth.value.auth if auth else None, + encoded_headers=headers.value.headers if headers else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case RequestType.GRPC: + self._optimized_actions[call_id] = OptimizedGRPCRequest( + call_id=call_id, + method=method.value.method, + url=url.value.url if url else None, + encoded_headers=headers.value.headers if headers else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case RequestType.HTTP: + self._optimized_actions[call_id] = OptimizedHTTPRequest( + call_id=call_id, + method=method.value.method, + url=url.value.url if url else None, + encoded_params=params.value.params if params else None, + encoded_auth=auth.value.auth if auth else None, + encoded_headers=headers.value.headers if headers else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case RequestType.HTTP2: + self._optimized_actions[call_id] = OptimizedHTTP2Request( + call_id=call_id, + method=method.value.method, + url=url.value.url if url else None, + encoded_params=params.value.params if params else None, + encoded_auth=auth.value.auth if auth else None, + encoded_headers=headers.value.headers if headers else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case RequestType.HTTP3: + self._optimized_actions[call_id] = OptimizedHTTP3Request( + call_id=call_id, + method=method.value.method, + url=url.value.url if url else None, + encoded_params=params.value.params if params else None, + encoded_auth=auth.value.auth if auth else None, + encoded_headers=headers.value.headers if headers else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case RequestType.UDP: + self._optimized_actions[call_id] = OptimizedUDPRequest( + call_id=call_id, + url=url.value.url if url else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case RequestType.WEBSOCKET: + self._optimized_actions[call_id] = OptimizedWebsocketRequest( + call_id=call_id, + method=method.value.method, + url=url.value.url if url else None, + encoded_params=params.value.params if params else None, + encoded_auth=auth.value.auth if auth else None, + encoded_headers=headers.value.headers if headers else None, + encoded_data=data.value.data if data else None, + redirects=redirects.value.redirects if redirects else 3, + ) + + case _: + raise Exception("Err. - Invalid request type.") + + async def _optimize_engine_type(self, engine_type: str): + engine_type_args: List[Tuple[CallArg, Callable[..., None]]] = [] + engine_arg_positions = self._position_map.get(engine_type) + + for call_id in self._args: + for arg in self._args[call_id]: + self._call_engine_types[call_id] = self._request_types_map[arg.engine] + self._call_workflows[call_id] = arg.workflow + + method = arg.method + + if method == "query": + method = "GET" + + elif method == "mutate": + method = "POST" + + self._resolved[call_id]["method"] = ResolvedArg( + ResolvedArgType.METHOD, + arg, + ResolvedMethod(method=method.upper()), + ) + + for call_id in self._args: + engine_type_args.extend( + [ + ( + arg, + self._call_optimization_map.get( + engine_arg_positions.get(arg.position) + ), + ) + for arg in self._args[call_id] + if arg.engine == engine_type + ] + ) + + for call_id in self._kwargs: + engine_type_args.extend( + [ + ( + arg, + self._call_optimization_map.get(arg.arg_name), + ) + for arg in self._kwargs[call_id] + if arg.engine == engine_type + ] + ) + + for arg, optimizer in engine_type_args: + arg_type = arg.arg_name + if arg_type is None: + arg_type = engine_arg_positions.get(arg.position) + + if arg_type == "query" and arg.method == "query": + arg_type = "params" + optimizer = self._set_params + + elif arg_type == "query" and arg.method == "mutate": + arg_type = "data" + optimizer = self._set_data + + self._available_optimizations[arg_type].append(arg.call_id) + self._call_args[arg_type].append((arg, optimizer)) + + await asyncio.gather( + *[call_optimizer(arg) for arg, call_optimizer in self._call_args["url"]] + ) + + for arg, call_optimizer in self._call_args["method"]: + call_optimizer(arg) + + for arg, call_optimizer in self._call_args["data"]: + call_optimizer(arg) + + for arg, call_optimizer in self._call_args["params"]: + call_optimizer(arg) + + for arg, call_optimizer in self._call_args["auth"]: + call_optimizer(arg) + + for arg, call_optimizer in self._call_args["cookies"]: + call_optimizer(arg) + + for arg, call_optimizer in self._call_args["headers"]: + call_optimizer(arg) + + for arg, call_optimizer in self._call_args["redirects"]: + call_optimizer(arg) + + async def _set_url(self, arg: CallArg): + request_type = self._request_types_map[arg.engine] + + url_string: str = arg.value + connection = self._connections.get(request_type)() + connection = self._connections.get(request_type)() + + ssl_context: Union[SSLContext, None] = None + upgrade_ssl = False + + url, upgrade_ssl = await self._connect_to_url( + connection, url_string, upgrade_ssl + ) + + if upgrade_ssl: + url, _ = await self._connect_to_url(connection, url_string, upgrade_ssl) + + self._resolved[arg.call_id]["url"] = ResolvedArg( + arg_type=ResolvedArgType.URL, + call_arg=arg, + value=ResolvedURL(ssl_context=ssl_context, url=url), + ) + + async def _connect_to_url( + self, + connection: MercurySyncGraphQLConnection + | MercurySyncGRPCConnection + | MercurySyncGraphQLHTTP2Connection + | MercurySyncGraphQLHTTP2Connection + | MercurySyncHTTPConnection + | MercurySyncHTTP3Connection + | MercurySyncUDPConnection + | MercurySyncWebsocketConnection, + url_string: str, + upgrade_ssl: bool = False, + ) -> Tuple[URL, bool]: + if isinstance( + connection, + ( + MercurySyncGraphQLConnection, + MercurySyncHTTPConnection, + MercurySyncHTTP3Connection, + MercurySyncWebsocketConnection, + ), + ): + (active_connection, url, upgrade_ssl) = await asyncio.wait_for( + connection._connect_to_url_location( + url_string, + ssl_redirect_url=url_string if upgrade_ssl else None, + ), + timeout=connection.timeouts.connect_timeout, + ) + + connection._connections.append(active_connection) + + return ( + url, + upgrade_ssl, + ) + + elif isinstance(connection, MercurySyncUDPConnection): + (_, active_connection, url) = await asyncio.wait_for( + connection._connect_to_url_location( + url_string, + ssl_redirect_url=url_string if upgrade_ssl else None, + ), + timeout=connection.timeouts.connect_timeout, + ) + + connection._connections.append(active_connection) + + return ( + url, + upgrade_ssl, + ) + + else: + (_, active_connection, pipe, url, upgrade_ssl) = await asyncio.wait_for( + connection._connect_to_url_location( + url_string, ssl_redirect_url=url_string if upgrade_ssl else None + ), + timeout=connection.timeouts.connect_timeout, + ) + + connection._connections.append(active_connection) + connection._pipes.append(pipe) + + return ( + url, + upgrade_ssl, + ) + + def _set_redirects(self, arg: CallArg): + self._resolved[arg.call_id]["redirects"] = ResolvedArg( + arg_type=ResolvedArgType.REDIRECTS, + call_arg=arg, + value=ResolvedRedirects(redirects=arg.value), + ) + + def _set_headers(self, arg: CallArg): + request_type = self._request_types_map[arg.engine] + + match request_type: + case RequestType.GRAPHQL | RequestType.HTTP: + self._parse_to_http_headers(arg) + + case RequestType.GRAPHQL_HTTP2 | RequestType.GRPC | RequestType.HTTP2: + self._parse_to_http2_headers(arg) + + case RequestType.HTTP3: + self._parse_to_http3_headers(arg) + + case RequestType.PLAYWRIGHT | RequestType.UDP: + pass + + case RequestType.WEBSOCKET: + self._parse_to_websocket_headers(arg) + + case _: + raise Exception("Err. - Invalid request type.") + + def _set_cookies(self, arg: CallArg): + request_type = self._request_types_map[arg.engine] + + match request_type: + case RequestType.GRAPHQL | RequestType.HTTP | RequestType.WEBSOCKET: + self._parse_to_http_cookies(arg) + + case ( + RequestType.GRAPHQL_HTTP2 + | RequestType.GRPC + | RequestType.HTTP2 + | RequestType.HTTP3 + ): + self._parse_to_http2_or_http3_cookies(arg) + + case RequestType.PLAYWRIGHT | RequestType.UDP: + pass + + case _: + raise Exception("Err. - Invalid request type.") + + def _set_method(self, arg: CallArg): + method: str = arg.value + + self._resolved[arg.call_id]["method"] = ResolvedArg( + arg_type=ResolvedArgType.METHOD, + call_arg=arg, + value=ResolvedMethod(method=method.upper()), + ) + + def _set_auth(self, arg: CallArg): + auth_params: Tuple[str] | Tuple[str, str] = arg.value + params_count = len(auth_params) + + if params_count == 1: + username = auth_params[0] + self._resolved[arg.call_id]["auth"] = ResolvedArg( + arg_type=ResolvedArgType.AUTH, + call_arg=arg, + value=ResolvedAuth( + username=username, + auth=f"{username}:", + ), + ) + + elif params_count == 2: + username, password = auth_params + self._resolved[arg.call_id]["auth"] = ResolvedArg( + arg_type=ResolvedArgType.AUTH, + call_arg=arg, + value=ResolvedAuth( + username=username, + password=password, + auth=f"{username}:{password}", + ), + ) + + else: + raise Exception( + "Err. - can only except username tuple of length one or username/password tuple of length two." + ) + + def _set_params(self, arg: CallArg): + request_type = self._request_types_map[arg.engine] + + match request_type: + case RequestType.GRAPHQL | RequestType.GRAPHQL_HTTP2: + self._parse_to_graphql_params(arg) + + case _: + self._parse_to_http_params(arg) + + def _set_data(self, arg: CallArg): + request_type = self._request_types_map[arg.engine] + + match request_type: + case RequestType.GRAPHQL | RequestType.GRAPHQL_HTTP2: + self._parse_to_graphql_data(arg) + + case RequestType.GRPC: + self._parse_to_grpc_data(arg) + + case ( + RequestType.HTTP + | RequestType.HTTP2 + | RequestType.UDP + | RequestType.WEBSOCKET + ): + self._parse_to_http_or_udp_data(arg) + + case RequestType.HTTP3: + self._parse_to_http3_data(arg) + + case RequestType.PLAYWRIGHT: + pass + + case _: + raise Exception("Err. - Invalid request type.") + + def _parse_to_graphql_params(self, arg: CallArg): + query_string: str = arg.value + query_string = "".join(query_string.split()).replace("query", "", 1) + + query_url = f"?query={{{query_string}}}" + + self._resolved[arg.call_id]["params"] = ResolvedArg( + arg_type=ResolvedArgType.PARAMS, + call_arg=arg, + value=ResolvedParams(params=query_url), + ) + + has_headers = arg.call_id in self._available_optimizations["headers"] + + if has_headers is False: + self._call_args["headers"].append( + ( + CallArg( + call_name=arg.call_name, + call_id=arg.call_id, + arg_name="headers", + arg_type="kwarg", + workflow=arg.workflow, + engine=arg.engine, + method=arg.method, + value={}, + data_type="static", + timeouts=arg.timeouts, + ), + self._set_headers, + ) + ) + + self._available_optimizations["headers"].append(arg.call_id) + + def _parse_to_http_params(self, arg: CallArg): + params_dict: Dict[str, Any] = arg.value + params = urlencode(params_dict) + + self._resolved[arg.call_id]["params"] = ResolvedArg( + arg_type=ResolvedArgType.PARAMS, + call_arg=arg, + value=ResolvedParams(params=f"?{params}"), + ) + + def _parse_to_http_headers(self, arg: CallArg): + resolved_url: ResolvedURL = self._resolved[arg.call_id]["url"].value + url_path = resolved_url.url.path + + optimized_params: Optional[ResolvedArg[ResolvedParams]] = self._resolved[ + arg.call_id + ].get("params") + + if optimized_params: + url_path += optimized_params.value.params + + method = arg.method + + if method == "query": + method = "GET" + + elif method == "mutate": + method = "POST" + + get_base = f"{method.upper()} {url_path} HTTP/1.1{NEW_LINE}" + + port = resolved_url.url.port or ( + 443 if resolved_url.url.scheme == "https" else 80 + ) + + hostname = resolved_url.url.parsed.hostname.encode("idna").decode() + + if port not in [80, 443]: + hostname = f"{hostname}:{port}" + + header_items = [] + + optimized_data: Optional[ResolvedArg[ResolvedData]] = self._resolved[ + arg.call_id + ].get("data") + + if optimized_data: + header_items.append( + ("Content-Length", optimized_data.value.size), + ) + + header_data: Dict[str, Any] = arg.value + header_items.extend(list(header_data.items())) + + for key, value in header_items: + get_base += f"{key}: {value}{NEW_LINE}" + + optimized_cookies: Optional[ResolvedArg[ResolvedCookies]] = self._resolved[ + arg.call_id + ].get("cookies") + + if optimized_cookies: + get_base += optimized_cookies.value.cookies + + self._resolved[arg.call_id]["headers"] = ResolvedArg( + arg_type=ResolvedArgType.HEADERS, + call_arg=arg, + value=ResolvedHeaders(headers=get_base.encode()), + ) + + def _parse_to_websocket_headers(self, arg: CallArg): + resolved_url: ResolvedURL = self._resolved[arg.call_id]["url"].value + header_data: Dict[str, str] = arg.value + lowered_headers: Dict[str, Any] = {} + + for header_name, header_value in header_data.items(): + header_name_lowered = header_name.lower() + lowered_headers[header_name_lowered] = header_value + + url_path = resolved_url.url.path + + optimized_params: Optional[ResolvedArg[ResolvedParams]] = self._resolved[ + arg.call_id + ].get("params") + + if optimized_params: + url_path += optimized_params.value.params + + headers = [f"GET {url_path} HTTP/1.1", "Upgrade: websocket"] + + optimized_data: Optional[ResolvedArg[ResolvedData]] = self._resolved[ + arg.call_id + ].get("data") + + if optimized_data: + headers.append(f"Content-Length: {optimized_data.value.size}") + + if resolved_url.url.port == 80 or resolved_url.url.port == 443: + hostport = pack_hostname(resolved_url.url.hostname) + else: + hostport = "%s:%d" % ( + pack_hostname(resolved_url.url.hostname), + resolved_url.url.port, + ) + + host = lowered_headers.get("host") + if host: + headers.append(f"Host: {host}") + else: + headers.append(f"Host: {hostport}") + + if not lowered_headers.get("suppress_origin"): + origin = lowered_headers.get("origin") + + if origin: + headers.append(f"Origin: {origin}") + + elif resolved_url.url.scheme == "wss": + headers.append(f"Origin: https://{hostport}") + + else: + headers.append(f"Origin: http://{hostport}") + + key = create_sec_websocket_key() + + header = lowered_headers.get("header") + if not header or "Sec-WebSocket-Key" not in header: + headers.append(f"Sec-WebSocket-Key: {key}") + else: + key = header + + if not header or "Sec-WebSocket-Version" not in header: + headers.append(f"Sec-WebSocket-Version: {WEBSOCKETS_VERSION}") + + connection = lowered_headers.get("connection") + if not connection: + headers.append("Connection: Upgrade") + else: + headers.append(connection) + + subprotocols = lowered_headers.get("subprotocols") + if subprotocols: + headers.append("Sec-WebSocket-Protocol: %s" % ",".join(subprotocols)) + + if header: + if isinstance(header, dict): + header = [": ".join([k, v]) for k, v in header.items() if v is not None] + headers.extend(header) + + headers.extend(["", ""]) + + encoded_headers = f"{NEW_LINE}".join(headers) + + optimized_cookies: Optional[ResolvedArg[ResolvedCookies]] = self._resolved[ + arg.call_id + ].get("cookies") + + if optimized_cookies: + encoded_headers += optimized_cookies.value.cookies + + self._resolved[arg.call_id]["headers"] = ResolvedArg( + arg_type=ResolvedArgType.HEADERS, + call_arg=arg, + value=ResolvedHeaders(headers=encoded_headers.encode()), + ) + + def _parse_to_http2_headers(self, arg: CallArg): + resolved_url: ResolvedURL = self._resolved[arg.call_id]["url"].value + request_type = self._request_types_map[arg.engine] + + hpack_encoder = Encoder() + remote_settings = Settings(client=False) + + url_path = resolved_url.url.path + + optimized_params: Optional[ResolvedArg[ResolvedParams]] = self._resolved[ + arg.call_id + ].get("params") + + if optimized_params: + url_path += optimized_params.value.params + + method = arg.method + + if method == "query": + method = "GET" + + elif method == "mutate": + method = "POST" + + request_headers: List[Tuple[str, str]] = [ + (":method", method), + (":authority", resolved_url.url.authority), + (":scheme", resolved_url.url.scheme), + (":path", url_path), + ] + + headers_data: Dict[str, str] = arg.value + if request_type == RequestType.GRPC: + grpc_headers = { + "Content-Type": "application/grpc", + "Grpc-Timeout": f"{arg.timeouts.request_timeout}S", + "TE": "trailers", + } + + headers_data.update(grpc_headers) + + header_items = list(headers_data.items()) + + request_headers.extend( + [ + (k.lower(), v) + for k, v in header_items + if k.lower() + not in ( + "host", + "transfer-encoding", + ) + ] + ) + + optimized_cookies: Optional[ResolvedArg[ResolvedCookies]] = self._resolved[ + arg.call_id + ].get("cookies") + + if optimized_cookies: + request_headers.append(optimized_cookies.value.cookies) + + encoded_headers: List[Tuple[bytes, bytes]] = [ + (name.encode(), value.encode()) for name, value in request_headers + ] + + hpack_encoded_headers = hpack_encoder.encode(encoded_headers) + hpack_encoded_headers = [ + hpack_encoded_headers[i : i + remote_settings.max_frame_size] + for i in range( + 0, len(hpack_encoded_headers), remote_settings.max_frame_size + ) + ] + + self._resolved[arg.call_id]["headers"] = ResolvedArg( + arg_type=ResolvedArgType.HEADERS, + call_arg=arg, + value=ResolvedHeaders(headers=hpack_encoded_headers[0]), + ) + + def _parse_to_http3_headers(self, arg: CallArg) -> Union[bytes, Dict[str, str]]: + resolved_url: ResolvedURL = self._resolved[arg.call_id]["url"].value + + url_path = resolved_url.url.path + + optimized_params: Optional[ResolvedArg[ResolvedParams]] = self._resolved[ + arg.call_id + ].get("params") + + if optimized_params: + url_path += optimized_params.value.params + + encoded_headers: List[Tuple[str, str]] = [ + (":method", arg.method), + (":scheme", resolved_url.url.scheme), + (":authority", resolved_url.url.authority), + (":path", url_path), + ("user-agent", "hyperscale/client"), + ] + + headers_data: Dict[str, str] = arg.value + encoded_headers.extend( + [(k.lower(), v.lower()) for (k, v) in headers_data.items()] + ) + + optimized_cookies: Optional[ResolvedArg[ResolvedCookies]] = self._resolved[ + arg.call_id + ].get("cookies") + + if optimized_cookies: + encoded_headers.append(optimized_cookies.value.cookies) + + self._resolved[arg.call_id]["headers"] = ResolvedArg( + arg_type=ResolvedArgType.HEADERS, + call_arg=arg, + value=ResolvedHeaders( + headers=[ + ( + name.encode(), + value.encode(), + ) + for name, value in encoded_headers + ] + ), + ) + + def _parse_to_http_cookies(self, arg: CallArg): + cookies = [] + + get_base = "" + + for cookie_data in arg.value: + if len(cookie_data) == 1: + cookies.append(cookie_data[0]) + + elif len(cookie_data) == 2: + cookie_name, cookie_value = cookie_data + cookies.append(f"{cookie_name}={cookie_value}") + + cookies = "; ".join(cookies) + get_base += f"cookie: {cookies}{NEW_LINE}" + + self._resolved[arg.call_id]["cookies"] = ResolvedArg( + arg_type=ResolvedArgType.COOKIES, + call_arg=arg, + value=ResolvedCookies( + cookies=get_base, + ), + ) + + has_headers = arg.call_id in self._available_optimizations["headers"] + + if has_headers is False: + self._call_args["headers"].append( + ( + CallArg( + call_name=arg.call_name, + call_id=arg.call_id, + arg_name="headers", + arg_type="kwarg", + workflow=arg.workflow, + engine=arg.engine, + method=arg.method, + value={}, + data_type="static", + timeouts=arg.timeouts, + ), + self._set_headers, + ) + ) + + self._available_optimizations["headers"].append(arg.call_id) + + def _parse_to_http2_or_http3_cookies(self, arg: CallArg): + cookies: List[str] = [] + + for cookie_data in arg.value: + if len(cookie_data) == 1: + cookies.append(cookie_data[0]) + + elif len(cookie_data) == 2: + cookie_name, cookie_value = cookie_data + cookies.append(f"{cookie_name}={cookie_value}") + + self._resolved[arg.call_id]["cookies"] = ResolvedArg( + arg_type=ResolvedArgType.COOKIES, + call_arg=arg, + value=ResolvedCookies( + cookies=( + "cookie", + "; ".join(cookies), + ), + ), + ) + + has_headers = arg.call_id in self._available_optimizations["headers"] + + if has_headers is False: + self._call_args["headers"].append( + ( + CallArg( + call_name=arg.call_name, + call_id=arg.call_id, + arg_name="headers", + arg_type="kwarg", + workflow=arg.workflow, + engine=arg.engine, + method=arg.method, + value={}, + data_type="static", + timeouts=arg.timeouts, + ), + self._set_headers, + ) + ) + + self._available_optimizations["headers"].append(arg.call_id) + + def _parse_to_http_or_udp_data( + self, + arg: CallArg, + ): + data: str | dict | Iterator | bytes | None = arg.value + encoded_data: bytes | None = None + is_stream = False + size = 0 + + if isinstance(data, Iterator): + chunks = [] + for chunk in data: + chunk_size = hex(len(chunk)).replace("0x", "") + NEW_LINE + encoded_chunk = chunk_size.encode() + chunk + NEW_LINE.encode() + size += len(encoded_chunk) + chunks.append(encoded_chunk) + + is_stream = True + encoded_data = chunks + + elif isinstance(data, dict): + encoded_data = orjson.dumps(data) + size = len(encoded_data) + + elif isinstance(data, tuple): + encoded_data = urlencode(data).encode() + size = len(encoded_data) + + elif isinstance(data, str): + encoded_data = data.encode() + size = len(encoded_data) + + self._resolved[arg.call_id]["data"] = ResolvedArg( + arg_type=ResolvedArgType.DATA, + call_arg=arg, + value=ResolvedData(data=encoded_data, size=size, is_stream=is_stream), + ) + + def _parse_to_http3_data( + self, + arg: CallArg, + ): + data: str | dict | Iterator | bytes | None = arg.value + encoded_data: bytes | None = None + is_stream = False + size = 0 + + if isinstance(data, Iterator): + chunks = [] + for chunk in data: + chunk_size = hex(len(chunk)).replace("0x", "") + NEW_LINE + encoded_chunk = chunk_size.encode() + chunk + NEW_LINE.encode() + size += len(encoded_chunk) + chunks.append(encoded_chunk) + + is_stream = True + encoded_data = chunks + + elif isinstance(data, dict): + encoded_data = orjson.dumps(data) + size = len(encoded_data) + + elif isinstance(data, tuple): + encoded_data = urlencode(data).encode() + size = len(encoded_data) + + elif isinstance(data, str): + encoded_data = data.encode() + size = len(encoded_data) + + encoded_data = encode_frame(FrameType.DATA, encoded_data) + + self._resolved[arg.call_id]["data"] = ResolvedArg( + arg_type=ResolvedArgType.DATA, + call_arg=arg, + value=ResolvedData( + data=encoded_data, + size=size, + is_stream=is_stream, + ), + ) + + async def _parse_to_graphql_data(self, arg: CallArg): + source = Source(arg.value) + document_node = parse(source) + query_string = print_ast(document_node) + + query = {"query": query_string} + + call_kwargs = self._kwargs.get(arg.call_id) + + operation_name = next( + arg for arg in call_kwargs if arg.arg_name == "operation_name" + ) + variables = next(arg for arg in call_kwargs if arg.arg_name == "variables") + + if operation_name: + query["operationName"] = operation_name + + if variables: + query["variables"] = variables + + encoded_data = orjson.dumps(query) + + self._resolved[arg.call_id]["data"] = ResolvedArg( + arg_type=ResolvedArgType.DATA, + call_arg=arg, + value=ResolvedData( + data=encoded_data, + size=len(encoded_data), + ), + ) + + def _parse_to_grpc_data(self, arg: CallArg): + data: Message | None = arg.value + + encoded_protobuf = str( + binascii.b2a_hex(data.SerializeToString()), encoding="raw_unicode_escape" + ) + + encoded_message_length = ( + hex(int(len(encoded_protobuf) / 2)).lstrip("0x").zfill(8) + ) + + encoded_protobuf = f"00{encoded_message_length}{encoded_protobuf}" + + self._resolved[arg.call_id]["data"] = ResolvedArg( + arg_type=ResolvedArgType.DATA, + call_arg=arg, + value=ResolvedData(data=binascii.a2b_hex(encoded_protobuf)), + ) diff --git a/hyperscale/core_rewrite/hooks/hook.py b/hyperscale/core_rewrite/hooks/hook.py new file mode 100644 index 0000000..067b794 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/hook.py @@ -0,0 +1,208 @@ +import ast +import inspect +import textwrap +from collections import defaultdict +from inspect import signature +from typing import ( + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Optional, + Type, + Union, + get_args, +) + +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts +from hyperscale.core_rewrite.parser import Parser + +from .call_arg import CallArg + + +class Hook: + def __init__( + self, + call: Callable[ + ..., Awaitable[Any] | Awaitable[BaseAction] | Awaitable[BaseResult] + ], + dependencies: List[str], + timeouts: Optional[Timeouts] = None, + ) -> None: + if timeouts is None: + timeouts = Timeouts() + + call_signature = signature(call) + + self.call = call + self.full_name = call.__qualname__ + self.name = call.__name__ + self.workflow = self.full_name.split(".").pop(0) + self.dependencies = dependencies + self._timeouts = timeouts + self.call_id: int = 0 + + self.params = call_signature.parameters + self.args: Dict[ + int, + Dict[ + Union[Literal["annotation"], Literal["default"]], Union[Type[Any], Any] + ], + ] = { + arg.name: {"annotation": arg.annotation, "default": arg.default} + for arg in self.params.values() + if arg.KEYWORD_ONLY + } + + # self.static = len([param for param in self.params if param != "self"]) == 0 + + self.static = True + self.return_type = call_signature.return_annotation + self.is_test = False + + annotation_subtypes = list(get_args(self.return_type)) + + if len(annotation_subtypes) > 0: + self.return_type = [return_type for return_type in annotation_subtypes] + + else: + self.is_test = self.return_type in BaseResult.__subclasses__() + + self.cache: Dict[ + str, Dict[int, Dict[str, Union[List[Dict[str, Any]], Dict[str, Any]]]] + ] = defaultdict(dict) + self.parser = Parser(self.args) + self._tree = ast.parse(textwrap.dedent(inspect.getsource(call))) + + def setup(self, context: Dict[str, Any]): + self.parser.attributes.update(context) + + for cls in inspect.getmro(self.call.__self__.__class__): + if self.call.__name__ in cls.__dict__: + self.parser.parser_class = cls + self.parser.parser_class_name = self.workflow + + break + + for node in ast.walk(self._tree): + if isinstance(node, ast.Assign): + result = self.parser.parse_assign(node) + + if isinstance(node, ast.Attribute): + result = self.parser.parse_attribute(node) + + for node in ast.walk(self._tree): + if isinstance(node, ast.Call): + result = self.parser.parse_call(node) + engine = result.get("engine") + call_source = result.get("source") + call_id: int = result.get("call_id") + method = result.get("method") + + if engine: + self.call_id = call_id + + parser_class = self.parser.parser_class_name + self.static = result.get("static") + + source_fullname = f"{parser_class}.client.{engine}.{method}" + + elif isinstance(call_source, ast.Attribute): + source_fullname = call_source.attr + + elif inspect.isfunction(call_source) or inspect.ismethod(call_source): + source_fullname = call_source.__qualname__ + + else: + source_fullname = call_source + + is_cacheable_call = source_fullname != "step" and ( + engine is not None or self.is_test is False + ) + + if is_cacheable_call: + result["source"] = source_fullname + self.cache[source_fullname][call_id] = result + + self.cache = { + key: value for key, value in self.cache.items() if isinstance(key, str) + } + + @property + def args_map(self): + call_args: Dict[str, List[CallArg]] = defaultdict(list) + + for call_name, calls in self.cache.items(): + for call_id, call_data in calls.items(): + call_id = call_data.get("call_id") + args = call_data.get("args") + timeouts = call_data.get("kwargs", {}).get("timeouts", self._timeouts) + + call_args[call_id].extend( + [ + CallArg( + call_name=call_name, + call_id=call_id, + arg_type="arg", + position=arg_postition, + workflow=self.workflow, + engine=call_data.get("engine"), + method=call_data.get("method"), + value=arg.get("value"), + data_type=arg.get("type", "static"), + timeouts=timeouts, + ) + for arg_postition, arg in enumerate(args) + ] + ) + + return call_args + + @property + def kwargs_map(self): + call_args: Dict[str, List[CallArg]] = defaultdict(list) + for call_name, calls in self.cache.items(): + for call_id, call_data in calls.items(): + call_id = call_data.get("call_id") + kwargs = call_data.get("kwargs", {}) + + call_timeouts = kwargs.get("timeouts", self._timeouts) + + for arg_name, arg in kwargs.items(): + call_args[call_id].extend( + [ + CallArg( + call_name=call_name, + call_id=call_id, + arg_name=arg_name, + arg_type="kwarg", + workflow=self.workflow, + engine=call_data.get("engine"), + method=call_data.get("method"), + value=arg.get("value"), + data_type=arg.get("type", "static"), + timeouts=call_timeouts, + ) + ] + ) + + return call_args + + @property + def static_args(self): + static_args: Dict[str, List[str]] = defaultdict(list) + + call_args = self.args_map + for call_id in self.args_map: + call_args[call_id].extend(self.kwargs_map[call_id]) + + for call_id, args in call_args.items(): + static_call_args = [arg for arg in args if arg.data_type == "static"] + if len(static_call_args) > 0: + static_args[call_id].extend(static_call_args) + + return static_args diff --git a/hyperscale/core_rewrite/hooks/optimized/__init__.py b/hyperscale/core_rewrite/hooks/optimized/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/hooks/optimized/models/__init__.py b/hyperscale/core_rewrite/hooks/optimized/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/hooks/optimized/models/base/__init__.py b/hyperscale/core_rewrite/hooks/optimized/models/base/__init__.py new file mode 100644 index 0000000..9dc6867 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/base/__init__.py @@ -0,0 +1,2 @@ +from .frozen_dict import FrozenDict as FrozenDict +from .optimized_arg import OptimizedArg as OptimizedArg diff --git a/hyperscale/core_rewrite/hooks/optimized/models/base/frozen_dict.py b/hyperscale/core_rewrite/hooks/optimized/models/base/frozen_dict.py new file mode 100644 index 0000000..b53fb05 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/base/frozen_dict.py @@ -0,0 +1,245 @@ +from copy import deepcopy + + +def immutable(self, *_args, **_kwargs): + r""" + Function for not implemented method since the object is immutable + """ + + raise AttributeError(f"'{self.__class__.__name__}' object is read-only") + + +_empty_frozendict = None + + +# noinspection PyPep8Naming +class FrozenDict(dict): + r""" + A simple immutable dictionary. + + The API is the same as `dict`, without methods that can change the + immutability. In addition, it supports __hash__(). + """ + + __slots__ = ("_hash",) + + @classmethod + def fromkeys(cls, *args, **kwargs): + r""" + Identical to dict.fromkeys(). + """ + + return cls(dict.fromkeys(*args, **kwargs)) + + # noinspection PyMethodParameters + def __new__(e4b37cdf_d78a_4632_bade_6f0579d8efac, *args, **kwargs): + cls = e4b37cdf_d78a_4632_bade_6f0579d8efac + + has_kwargs = bool(kwargs) + continue_creation = True + self = None + + # check if there's only an argument and it's of the same class + if len(args) == 1 and not has_kwargs: + it = args[0] + + # no isinstance, to avoid subclassing problems + if it.__class__ == FrozenDict and cls == FrozenDict: + self = it + continue_creation = False + + if continue_creation: + self = dict.__new__(cls, *args, **kwargs) + + dict.__init__(self, *args, **kwargs) + + # empty singleton - start + + if self.__class__ == FrozenDict and not len(self): + global _empty_frozendict + + if _empty_frozendict is None: + _empty_frozendict = self + else: + self = _empty_frozendict + continue_creation = False + + # empty singleton - end + + if continue_creation: + object.__setattr__(self, "_hash", -1) + + return self + + # noinspection PyMissingConstructor + def __init__(self, *args, **kwargs): + pass + + def __hash__(self, *args, **kwargs): + r""" + Calculates the hash if all values are hashable, otherwise + raises a TypeError. + """ + + if self._hash != -1: + _hash = self._hash + else: + fs = frozenset(self.items()) + _hash = hash(fs) + + object.__setattr__(self, "_hash", _hash) + + return _hash + + def __repr__(self, *args, **kwargs): + r""" + Identical to dict.__repr__(). + """ + + body = super().__repr__(*args, **kwargs) + klass = self.__class__ + + if klass == FrozenDict: + name = f"frozendict.{klass.__name__}" + else: + name = klass.__name__ + + return f"{name}({body})" + + def copy(self): + r""" + Return the object itself, as it's an immutable. + """ + + klass = self.__class__ + + if klass == FrozenDict: + return self + + return klass(self) + + def __copy__(self, *args, **kwargs): + r""" + See copy(). + """ + + return self.copy() + + def __deepcopy__(self, memo, *args, **kwargs): + r""" + As for tuples, if hashable, see copy(); otherwise, it returns a + deepcopy. + """ + + klass = self.__class__ + return_copy = klass == FrozenDict + + if return_copy: + try: + hash(self) + except TypeError: + return_copy = False + + if return_copy: + return self.copy() + + tmp = deepcopy(dict(self)) + + return klass(tmp) + + def __reduce__(self, *args, **kwargs): + r""" + Support for `pickle`. + """ + + return (self.__class__, (dict(self),)) + + def set(self, key, val): + new_self = deepcopy(dict(self)) + new_self[key] = val + + return self.__class__(new_self) + + def setdefault(self, key, default=None): + if key in self: + return self + + new_self = deepcopy(dict(self)) + + new_self[key] = default + + return self.__class__(new_self) + + def delete(self, key): + new_self = deepcopy(dict(self)) + del new_self[key] + + if new_self: + return self.__class__(new_self) + + return self.__class__() + + def _get_by_index(self, collection, index): + try: + return collection[index] + except IndexError: + maxindex = len(collection) - 1 + name = self.__class__.__name__ + raise IndexError(f"{name} index {index} out of range {maxindex}") from None + + def key(self, index=0): + collection = tuple(self.keys()) + + return self._get_by_index(collection, index) + + def value(self, index=0): + collection = tuple(self.values()) + + return self._get_by_index(collection, index) + + def item(self, index=0): + collection = tuple(self.items()) + + return self._get_by_index(collection, index) + + def __setitem__(self, key, val, *args, **kwargs): + raise TypeError( + f"'{self.__class__.__name__}' object doesn't support item " "assignment" + ) + + def __delitem__(self, key, *args, **kwargs): + raise TypeError( + f"'{self.__class__.__name__}' object doesn't support item " "deletion" + ) + + +def frozendict_or(self, other, *_args, **_kwargs): + res = {} + res.update(self) + res.update(other) + + return self.__class__(res) + + +FrozenDict.__or__ = frozendict_or +FrozenDict.__ior__ = frozendict_or + +try: + # noinspection PyStatementEffect + FrozenDict.__reversed__ +except AttributeError: # pragma: no cover + + def frozendict_reversed(self, *_args, **_kwargs): + return reversed(tuple(self)) + + FrozenDict.__reversed__ = frozendict_reversed + +FrozenDict.clear = immutable +FrozenDict.pop = immutable +FrozenDict.popitem = immutable +FrozenDict.update = immutable +FrozenDict.__delattr__ = immutable +FrozenDict.__setattr__ = immutable +FrozenDict.__module__ = "frozendict" + +__all__ = (FrozenDict.__name__,) diff --git a/hyperscale/core_rewrite/hooks/optimized/models/base/optimized_arg.py b/hyperscale/core_rewrite/hooks/optimized/models/base/optimized_arg.py new file mode 100644 index 0000000..53b23d5 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/base/optimized_arg.py @@ -0,0 +1,29 @@ +import threading +import uuid +from typing import Optional + +from hyperscale.core_rewrite.snowflake.snowflake_generator import SnowflakeGenerator + + +class OptimizedArg: + def __init__(self) -> None: + self._snowflake = SnowflakeGenerator( + (uuid.uuid1().int + threading.get_native_id()) >> 64 + ) + + self.arg_id = self._snowflake.generate() + self.call_id: Optional[int] = None + + self.optimized: bool = False + + def __hash__(self): + return self.arg_id + + def __eq__(self, value: object) -> bool: + return ( + isinstance( + value, + OptimizedArg, + ) + and value.arg_id == self.arg_id + ) diff --git a/hyperscale/core_rewrite/hooks/optimized/models/headers/__init__.py b/hyperscale/core_rewrite/hooks/optimized/models/headers/__init__.py new file mode 100644 index 0000000..0fb2a79 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/headers/__init__.py @@ -0,0 +1 @@ +from .headers import Headers as Headers diff --git a/hyperscale/core_rewrite/hooks/optimized/models/headers/headers.py b/hyperscale/core_rewrite/hooks/optimized/models/headers/headers.py new file mode 100644 index 0000000..b0f0b73 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/headers/headers.py @@ -0,0 +1,51 @@ +from typing import ( + Any, + Dict, + Generator, + Optional, +) + +from _collections_abc import ( + dict_items, + dict_keys, + dict_values, +) + +from hyperscale.core_rewrite.hooks.optimized.models.base import FrozenDict, OptimizedArg + +from .headers_validator import HeaderValidator + + +class Headers(OptimizedArg): + def __init__(self, headers: Dict[str, str | int | float | bool]) -> None: + super( + OptimizedArg, + self, + ).__init__() + + validated_headers = HeaderValidator(headers=headers) + self.data: FrozenDict = FrozenDict(validated_headers.value) + self.optimized: Optional[str] = None + + def __getitem__(self, key: str) -> str | int | float | bool: + return self.data[key] + + def __iter__(self) -> Generator[str, Any, None]: + for key in self.data: + yield key + + def items(self) -> dict_items[str, str | int | float | bool]: + return self.data.items() + + def keys(self) -> dict_keys[str]: + return self.data.keys() + + def values(self) -> dict_values[str | int | float | bool]: + return self.data.values() + + def get( + self, + key: str, + default: Optional[str | int | float | bool] = None, + ) -> Optional[str | int | float | bool]: + return self.data.get(key, default) diff --git a/hyperscale/core_rewrite/hooks/optimized/models/headers/headers_validator.py b/hyperscale/core_rewrite/hooks/optimized/models/headers/headers_validator.py new file mode 100644 index 0000000..42ef706 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/headers/headers_validator.py @@ -0,0 +1,9 @@ +from typing import Any, Dict + +from pydantic import ( + BaseModel, +) + + +class HeaderValidator(BaseModel): + value: Dict[str, Any] diff --git a/hyperscale/core_rewrite/hooks/optimized/models/mutation/__init__.py b/hyperscale/core_rewrite/hooks/optimized/models/mutation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/core_rewrite/hooks/optimized/models/query/__init__.py b/hyperscale/core_rewrite/hooks/optimized/models/query/__init__.py new file mode 100644 index 0000000..f43015a --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/query/__init__.py @@ -0,0 +1 @@ +from .query import Query as Query diff --git a/hyperscale/core_rewrite/hooks/optimized/models/query/query.py b/hyperscale/core_rewrite/hooks/optimized/models/query/query.py new file mode 100644 index 0000000..481e922 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/query/query.py @@ -0,0 +1,23 @@ +from typing import Optional + +from hyperscale.core_rewrite.hooks.optimized.models.base import OptimizedArg + +from .query_validator import QueryValidator + + +class Query(OptimizedArg): + def __init__(self, query: str) -> None: + super( + OptimizedArg, + self, + ).__init__() + + validated_query = QueryValidator(value=query) + self.data = validated_query.value + self.optimized: Optional[str] = None + + def __str__(self) -> str: + return self.data + + def __repr__(self) -> str: + return self.data diff --git a/hyperscale/core_rewrite/hooks/optimized/models/query/query_validator.py b/hyperscale/core_rewrite/hooks/optimized/models/query/query_validator.py new file mode 100644 index 0000000..3c8b732 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/query/query_validator.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, StrictStr + + +class QueryValidator(BaseModel): + value: StrictStr diff --git a/hyperscale/core_rewrite/hooks/optimized/models/url/__init__.py b/hyperscale/core_rewrite/hooks/optimized/models/url/__init__.py new file mode 100644 index 0000000..34581b1 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/url/__init__.py @@ -0,0 +1 @@ +from .url import URL as URL diff --git a/hyperscale/core_rewrite/hooks/optimized/models/url/url.py b/hyperscale/core_rewrite/hooks/optimized/models/url/url.py new file mode 100644 index 0000000..be74dda --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/url/url.py @@ -0,0 +1,26 @@ +from typing import Optional +from urllib.parse import urlparse + +from hyperscale.core_rewrite.engines.client.shared.models import URL as OptimizedUrl +from hyperscale.core_rewrite.hooks.optimized.models.base import OptimizedArg + +from .url_validator import URLValidator + + +class URL(OptimizedArg): + def __init__(self, url: str) -> None: + super( + URL, + self, + ).__init__() + + URLValidator(value=url) + self.data = url + self.parsed = urlparse(url) + self.optimized: Optional[OptimizedUrl] = None + + def __str__(self) -> str: + return self.data + + def __repr__(self) -> str: + return self.data diff --git a/hyperscale/core_rewrite/hooks/optimized/models/url/url_validator.py b/hyperscale/core_rewrite/hooks/optimized/models/url/url_validator.py new file mode 100644 index 0000000..2199702 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/optimized/models/url/url_validator.py @@ -0,0 +1,9 @@ +from pydantic import ( + AnyUrl, + BaseModel, + IPvAnyAddress, +) + + +class URLValidator(BaseModel): + value: AnyUrl | IPvAnyAddress diff --git a/hyperscale/core_rewrite/hooks/resolved_arg.py b/hyperscale/core_rewrite/hooks/resolved_arg.py new file mode 100644 index 0000000..cffda8d --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_arg.py @@ -0,0 +1,34 @@ +from typing import Generic, TypeVar + +from .call_arg import CallArg +from .resolved_arg_type import ResolvedArgType +from .resolved_auth import ResolvedAuth +from .resolved_cookies import ResolvedCookies +from .resolved_data import ResolvedData +from .resolved_headers import ResolvedHeaders +from .resolved_method import ResolvedMethod +from .resolved_params import ResolvedParams +from .resolved_url import ResolvedURL + +T = TypeVar("T") + + +class ResolvedArg(Generic[T]): + def __init__(self, arg_type: ResolvedArgType, call_arg: CallArg, value: T) -> None: + self.arg = call_arg + self.arg_type = arg_type + self.value = value + + @property + def data( + self, + ) -> ( + ResolvedAuth + | ResolvedData + | ResolvedCookies + | ResolvedHeaders + | ResolvedMethod + | ResolvedParams + | ResolvedURL + ): + return self.value diff --git a/hyperscale/core_rewrite/hooks/resolved_arg_type.py b/hyperscale/core_rewrite/hooks/resolved_arg_type.py new file mode 100644 index 0000000..2677cef --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_arg_type.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class ResolvedArgType(Enum): + URL = "URL" + QUERY = "QUERY" + HEADERS = "HEADERS" + METHOD = "METHOD" + PARAMS = "PARAMS" + AUTH = "AUTH" + DATA = "DATA" + OPTIONS = "OPTIONS" + COOKIES = "COOKIES" + REDIRECTS = "REDIRECTS" diff --git a/hyperscale/core_rewrite/hooks/resolved_auth.py b/hyperscale/core_rewrite/hooks/resolved_auth.py new file mode 100644 index 0000000..59e83c2 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_auth.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, StrictBytes, StrictStr + + +class ResolvedAuth(BaseModel): + username: StrictStr + password: StrictStr + auth: StrictBytes | StrictStr diff --git a/hyperscale/core_rewrite/hooks/resolved_cookies.py b/hyperscale/core_rewrite/hooks/resolved_cookies.py new file mode 100644 index 0000000..45c2c88 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_cookies.py @@ -0,0 +1,12 @@ +from typing import Tuple + +from pydantic import BaseModel, StrictBytes, StrictStr + + +class ResolvedCookies(BaseModel): + cookies: ( + StrictBytes + | Tuple[StrictBytes, StrictBytes] + | StrictStr + | Tuple[StrictStr, StrictStr] + ) diff --git a/hyperscale/core_rewrite/hooks/resolved_data.py b/hyperscale/core_rewrite/hooks/resolved_data.py new file mode 100644 index 0000000..483e78a --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_data.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, StrictBytes, StrictInt, StrictBool +from typing import Optional + + +class ResolvedData(BaseModel): + data: StrictBytes + size: Optional[StrictInt] + is_stream: Optional[StrictBool]=False \ No newline at end of file diff --git a/hyperscale/core_rewrite/hooks/resolved_headers.py b/hyperscale/core_rewrite/hooks/resolved_headers.py new file mode 100644 index 0000000..f56e7d3 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_headers.py @@ -0,0 +1,7 @@ +from typing import List, Tuple + +from pydantic import BaseModel, StrictBytes + + +class ResolvedHeaders(BaseModel): + headers: StrictBytes | List[StrictBytes] | List[Tuple[StrictBytes, StrictBytes]] diff --git a/hyperscale/core_rewrite/hooks/resolved_method.py b/hyperscale/core_rewrite/hooks/resolved_method.py new file mode 100644 index 0000000..d968c53 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_method.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel, StrictStr + +class ResolvedMethod(BaseModel): + method: StrictStr \ No newline at end of file diff --git a/hyperscale/core_rewrite/hooks/resolved_params.py b/hyperscale/core_rewrite/hooks/resolved_params.py new file mode 100644 index 0000000..879b78c --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_params.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, StrictBytes, StrictStr + + +class ResolvedParams(BaseModel): + params: StrictStr | StrictBytes diff --git a/hyperscale/core_rewrite/hooks/resolved_redirects.py b/hyperscale/core_rewrite/hooks/resolved_redirects.py new file mode 100644 index 0000000..57af9fd --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_redirects.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, StrictInt + + +class ResolvedRedirects(BaseModel): + redirects: StrictInt diff --git a/hyperscale/core_rewrite/hooks/resolved_url.py b/hyperscale/core_rewrite/hooks/resolved_url.py new file mode 100644 index 0000000..9e26d5d --- /dev/null +++ b/hyperscale/core_rewrite/hooks/resolved_url.py @@ -0,0 +1,14 @@ +from ssl import SSLContext +from typing import Optional + +from pydantic import BaseModel + +from hyperscale.core_rewrite.engines.client.shared.models import URL + + +class ResolvedURL(BaseModel): + ssl_context: Optional[SSLContext] = None + url: URL + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/core_rewrite/hooks/step.py b/hyperscale/core_rewrite/hooks/step.py new file mode 100644 index 0000000..7b30ae5 --- /dev/null +++ b/hyperscale/core_rewrite/hooks/step.py @@ -0,0 +1,12 @@ +from typing import Any, Awaitable, Callable, Optional + +from hyperscale.core_rewrite.engines.client.shared.timeouts import Timeouts + +from .hook import Hook + + +def step(*args: str, timeouts: Optional[Timeouts] = None): + def wrapper(func: Callable[..., Awaitable[Any]]): + return Hook(func, args) + + return wrapper diff --git a/hyperscale/core_rewrite/hooks/step_type.py b/hyperscale/core_rewrite/hooks/step_type.py new file mode 100644 index 0000000..702c63d --- /dev/null +++ b/hyperscale/core_rewrite/hooks/step_type.py @@ -0,0 +1,25 @@ +from enum import Enum +from typing import TypeVar + +from hyperscale.core.engines.types.common.base_result import BaseResult + + +class StepType(Enum): + WORK='WORK' + TEST='TEST' + CHECK='CHECK' + + +T = TypeVar('T') + + +def map_step_type(step_return_type: T) -> StepType: + + if issubclass(step_return_type, BaseResult): + return StepType.TEST + + elif issubclass(step_return_type, Exception): + return StepType.CHECK + + else: + return StepType.WORK \ No newline at end of file diff --git a/hyperscale/core_rewrite/parser/__init__.py b/hyperscale/core_rewrite/parser/__init__.py new file mode 100644 index 0000000..b8d5ea3 --- /dev/null +++ b/hyperscale/core_rewrite/parser/__init__.py @@ -0,0 +1 @@ +from .parser import Parser \ No newline at end of file diff --git a/hyperscale/core_rewrite/parser/dynamic_placeholder.py b/hyperscale/core_rewrite/parser/dynamic_placeholder.py new file mode 100644 index 0000000..b488b8c --- /dev/null +++ b/hyperscale/core_rewrite/parser/dynamic_placeholder.py @@ -0,0 +1,8 @@ +from typing import Any + + +class DynamicPlaceholder: + + def __init__(self, name: str) -> None: + self.name = name + self.value: Any = None \ No newline at end of file diff --git a/hyperscale/core_rewrite/parser/dynamic_template_string.py b/hyperscale/core_rewrite/parser/dynamic_template_string.py new file mode 100644 index 0000000..c50d625 --- /dev/null +++ b/hyperscale/core_rewrite/parser/dynamic_template_string.py @@ -0,0 +1,49 @@ +import inspect +from typing import List, Any +from .placeholder_call import PlaceholderCall + + +class DynamicTemplateString: + + def __init__( + self, + values: List[Any] + ) -> None: + self.values = values + + is_awaitable = len([ + inspect.isawaitable(value) for value in values + ]) > 0 and len([ + value for value in values if isinstance( + value, + PlaceholderCall + ) and value.is_async + ]) > 0 + + self.is_async = is_awaitable + + async def to_value(self, *values, **kwargs) -> str: + + joined_string = '' + + for value in values: + if inspect.iscoroutinefunction(value): + value = await value() + joined_string = f'{joined_string}{value}' + + elif inspect.isawaitable(value): + value = await value + joined_string = f'{joined_string}{value}' + + elif inspect.isfunction(value): + value = value() + joined_string = f'{joined_string}{value}' + + elif isinstance(value, PlaceholderCall): + value = await value.call() + joined_string = f'{joined_string}{value}' + + else: + joined_string = f'{joined_string}{value}' + + return joined_string \ No newline at end of file diff --git a/hyperscale/core_rewrite/parser/parser.py b/hyperscale/core_rewrite/parser/parser.py new file mode 100644 index 0000000..54d8e99 --- /dev/null +++ b/hyperscale/core_rewrite/parser/parser.py @@ -0,0 +1,352 @@ +import ast +import inspect +import json +import threading +import uuid +from collections import defaultdict +from typing import ( + Any, + Callable, + Dict, + List, + Literal, + Tuple, + Type, + Union, + get_args, + get_origin, +) + +from hyperscale.core_rewrite.snowflake.snowflake_generator import SnowflakeGenerator + +from .dynamic_placeholder import DynamicPlaceholder +from .dynamic_template_string import DynamicTemplateString +from .placeholder_call import PlaceholderCall + + +class Parser: + def __init__( + self, + step_args: Dict[ + int, + Dict[ + Union[Literal["annotation"], Literal["default"]], Union[Type[Any], Any] + ], + ], + ) -> None: + self._id_generator = SnowflakeGenerator( + (uuid.uuid1().int + threading.get_native_id()) >> 64 + ) + self.parser_class: Any = None + self.parser_class_name: Union[str, None] = None + self.attributes = {} + self._constants = [] + self._calls: Dict[int, Callable[..., Any]] = {} + self._active_trace: bool = False + self.step_args = step_args + + node_types = { + ast.Constant: self.parse_constant, + ast.Dict: self.parse_dict, + ast.List: self.parse_list, + ast.Tuple: self.parse_tuple, + ast.Name: self.parse_name, + ast.Attribute: self.parse_attribute, + ast.Assign: self.parse_assign, + ast.Call: self.parse_call, + ast.keyword: self.parse_keyword, + ast.Await: self.parse_await, + ast.JoinedStr: self.parse_joined_string, + ast.FormattedValue: self.parse_formatted_value, + } + + self._types = defaultdict( + lambda node: node, zip(node_types.keys(), node_types.values()) + ) + + def parse_node(self, node: ast.AST): + return self._types.get(type(node))(node) + + def parse_constant(self, node: ast.Constant) -> Any: + return node.value + + def parse_list(self, node: ast.List) -> List[Any]: + return [self._types.get(type(node_val))(node_val) for node_val in node.elts] + + def parse_dict(self, node: ast.Dict) -> Dict[Any, Any]: + keys = [self._types.get(type(key_val))(key_val) for key_val in node.keys] + + values = [ + self._types.get(type(value_val))(value_val) for value_val in node.values + ] + + return dict(zip(keys, values)) + + def parse_tuple(self, node: ast.Tuple) -> Tuple[Any, ...]: + return [ + self._types.get(type(node_val))(node_val) + for node_val in node.elts + if node_val is not None + ] + + def parse_name(self, node: ast.Name) -> Any: + attribute_value = self.attributes.get(node.id) + + arg_default = self.step_args.get(node.id) + + if attribute_value: + return attribute_value + + elif ( + isinstance( + node.ctx, + ast.Load, + ) + and arg_default + and arg_default["default"] + ): + return arg_default["default"] + + return DynamicPlaceholder(node.id) + + def parse_attribute(self, node: ast.Attribute) -> Any: + if isinstance(node.value, ast.Name): + source = node.value.id + + else: + source = self._types.get(type(node.value))(node.value) + + attribute_name = node.attr + attribute_value: Any = node.value + + source_instance = self.attributes.get(source, self.parser_class) + + if hasattr(source_instance, attribute_name): + attribute_value = getattr(source_instance, attribute_name) + + self.attributes[attribute_name] = attribute_value + + return attribute_value + + def parse_assign(self, node: ast.Assign) -> Any: + assignments: Dict[str, Any] = {} + + for target in node.targets: + target_node = self._types.get(type(target))(target) + + target_value = self._types.get(type(node.value))(node.value) + + if isinstance(target_value, ast.Name) and isinstance( + node.value, ast.Attribute + ): + instance = self.attributes.get(target_value.id, self.parser_class) + + if hasattr(instance, node.value.attr): + target_value = getattr(instance, node.value.attr) + self.attributes[node.value.attr] = target_value + + if isinstance(node.value, ast.Call): + call_data: Dict[str, Any] = target_value + is_static = call_data.get("static") + call_args: List[Any] = call_data.get("args") + call_kwargs: Dict[str, Any] = call_data.get("kwargs") + call_is_static = call_data.get("static") + + compiled_call = compile(ast.unparse(node.value), "", "eval") + + call_name = compiled_call.co_names[0] + call_item = self.attributes.get(call_name) + + if call_name == "self" and isinstance(node.value.func, ast.Attribute): + call_item = getattr(self.parser_class, node.value.func.attr) + + elif call_item is None: + target_value = PlaceholderCall( + { + **target_value, + "call": self.attributes.get(call_name), + "call_name": call_name, + "awaitable": False, + } + ) + + else: + call_return_annotation = inspect.signature( + call_item + ).return_annotation + return_is_static = get_origin(call_return_annotation) == Literal + + no_arguments = len(inspect.signature(call_item).parameters) == 0 + + is_static = call_is_static and return_is_static and no_arguments + + is_async = inspect.isawaitable( + call_item + ) or inspect.iscoroutinefunction(call_item) + + if is_static and call_item and is_async is False: + return_annotation_value = get_args(call_return_annotation)[0] + + target_node = ( + target.id if isinstance(target, ast.Name) else target_node + ) + + args = [arg.get("value") for arg in call_args] + + kwargs = { + name: arg.get("value") for name, arg in call_kwargs.items() + } + + target_value = call_item(*args, **kwargs) + + assert ( + return_annotation_value == target_value + ), "Err. - Literal annotation does not match return value of static function." + + else: + call_name = compiled_call.co_names[0] + + target_value = PlaceholderCall( + { + **target_value, + "call": self.attributes.get(call_name), + "call_name": call_name, + "awaitable": is_async, + } + ) + + elif isinstance(node.value, ast.Await): + compiled_call = compile(ast.unparse(target_value), "", "eval") + + call_data = self._types.get(type(target_value))(target_value) + + call_name = compiled_call.co_names[0] + + if inspect.iscoroutine(call_data): + call_name = call_data.__qualname__ + call = self.attributes.get(call_name) + + call_signature = inspect.signature(call) + call_args = call_signature.parameters.values() + + call_data = { + "args": [ + DynamicPlaceholder(arg.name) + for arg in call_args + if arg.default is None + ], + "kwargs": { + arg.name: DynamicPlaceholder(arg.name) + for arg in call_args + if arg.default + }, + } + + target_value = PlaceholderCall( + { + **call_data, + "call": self.attributes.get(call_name), + "call_name": call_name, + "awaitable": True, + } + ) + + elif ( + isinstance(target, ast.Name) + and isinstance(target_value, ast.expr) is False + ): + target_node = target.id + + assignments[target_node] = target_value + self.attributes.update(assignments) + + return assignments + + def parse_call(self, node: ast.Call): + call_id = self._id_generator.generate() + self._calls[call_id] = node + + call_source = self._types.get(type(node.func))(node.func) + + source = call_source + if isinstance(call_source, ast.Attribute): + source = call_source.attr + + elif isinstance(call_source, ast.Name): + source = call_source.id + + call = { + "call_id": call_id, + "source": source, + "args": [ + {"value": self._types.get(type(arg))(arg)} for arg in node.args if arg + ], + "kwargs": [self._types.get(type(arg))(arg) for arg in node.keywords if arg], + } + + matched_constants = [] + all_args: List[Dict[str, Any]] = [*call["args"], *call["kwargs"]] + + for arg in all_args: + try: + arg_value = arg.get("value") + json.dumps(arg_value) + + arg["type"] = "static" + matched_constants.append(arg) + + except Exception: + arg["type"] = "dynamic" + + call_kwargs = {} + for arg in call["kwargs"]: + kwarg_name = arg["name"] + call_kwargs[kwarg_name] = {"type": arg["type"], "value": arg["value"]} + + call["kwargs"] = call_kwargs + call_string = ast.unparse(node) + + all_args_count = len(call["args"]) + len(call["kwargs"]) + call["static"] = len(matched_constants) == all_args_count + + if "self.client." in call_string: + call_string = call_string.removeprefix("self.client.") + engine, method_string = call_string.split(".", maxsplit=1) + + method, _ = method_string.split("(", maxsplit=1) + + call.update( + {"source": self.parser_class_name, "engine": engine, "method": method} + ) + + return call + + def parse_keyword(self, node: ast.keyword) -> Any: + return { + "name": node.arg, + "value": self._types.get(type(node.value))(node.value), + } + + def parse_await(self, node: ast.Await) -> Any: + return node.value + + def parse_joined_string(self, node: ast.JoinedStr) -> Any: + values = [self._types.get(type(arg))(arg) for arg in node.values] + + joined_values = "" + + for value in values: + try: + json.dumps(value) + joined_values = f"{joined_values}{value}" + + except Exception: + joined_values = DynamicTemplateString(values) + break + + return joined_values + + def parse_formatted_value(self, node: ast.FormattedValue) -> Any: + result = self._types.get(type(node.value))(node.value) + + return result diff --git a/hyperscale/core_rewrite/parser/placeholder_call.py b/hyperscale/core_rewrite/parser/placeholder_call.py new file mode 100644 index 0000000..0449670 --- /dev/null +++ b/hyperscale/core_rewrite/parser/placeholder_call.py @@ -0,0 +1,20 @@ +from typing import Tuple, Dict, Any +from typing import Dict, Any + + +class PlaceholderCall: + + def __init__(self, call_node: Dict[str, Any]) -> None: + self.node = call_node + self.call = call_node.get('call') + self.is_async = call_node.get('awaitable') + + async def to_value( + self, + *args, + **kwargs + ): + if self.is_async: + return await self.call(*args, **kwargs) + + return self.call(*args, **kwargs) \ No newline at end of file diff --git a/hyperscale/core_rewrite/snowflake/__init__.py b/hyperscale/core_rewrite/snowflake/__init__.py new file mode 100644 index 0000000..beace0d --- /dev/null +++ b/hyperscale/core_rewrite/snowflake/__init__.py @@ -0,0 +1 @@ +from .snowflake import Snowflake \ No newline at end of file diff --git a/hyperscale/core_rewrite/snowflake/constants.py b/hyperscale/core_rewrite/snowflake/constants.py new file mode 100644 index 0000000..d1e35e3 --- /dev/null +++ b/hyperscale/core_rewrite/snowflake/constants.py @@ -0,0 +1,3 @@ +MAX_TS = 0b11111111111111111111111111111111111111111 +MAX_INSTANCE = 0b1111111111 +MAX_SEQ = 0b111111111111 diff --git a/hyperscale/core_rewrite/snowflake/snowflake.py b/hyperscale/core_rewrite/snowflake/snowflake.py new file mode 100644 index 0000000..3a9fc3a --- /dev/null +++ b/hyperscale/core_rewrite/snowflake/snowflake.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from datetime import ( + datetime, + tzinfo, + timedelta +) +from typing import Optional +from .constants import ( + MAX_INSTANCE, + MAX_SEQ +) + + +@dataclass(frozen=True) +class Snowflake: + timestamp: int + instance: int + epoch: int = 0 + seq: int = 0 + + + @classmethod + def parse(cls, snowflake: int, epoch: int = 0) -> 'Snowflake': + return cls( + epoch=epoch, + timestamp=snowflake >> 22, + instance=snowflake >> 12 & MAX_INSTANCE, + seq=snowflake & MAX_SEQ + ) + + @property + def milliseconds(self) -> int: + return self.timestamp + self.epoch + + @property + def seconds(self) -> float: + return self.milliseconds / 1000 + + @property + def datetime(self) -> datetime: + return datetime.utcfromtimestamp(self.seconds) + + def datetime_tz(self, tz: Optional[tzinfo] = None) -> datetime: + return datetime.fromtimestamp(self.seconds, tz=tz) + + @property + def timedelta(self) -> timedelta: + return timedelta(milliseconds=self.epoch) + + @property + def value(self) -> int: + return self.timestamp << 22 | self.instance << 12 | self.seq + + def __int__(self) -> int: + return self.value \ No newline at end of file diff --git a/hyperscale/core_rewrite/snowflake/snowflake_generator.py b/hyperscale/core_rewrite/snowflake/snowflake_generator.py new file mode 100644 index 0000000..9ee46db --- /dev/null +++ b/hyperscale/core_rewrite/snowflake/snowflake_generator.py @@ -0,0 +1,49 @@ +from time import time +from typing import Optional + +from .constants import MAX_SEQ +from .snowflake import Snowflake + + +class SnowflakeGenerator: + def __init__( + self, + instance: int, + *, + seq: int = 0, + timestamp: Optional[int] = None, + ): + current = int(time() * 1000) + + timestamp = timestamp or current + + self._ts = timestamp + + self._inf = instance << 12 + self._seq = seq + + @classmethod + def from_snowflake(cls, sf: Snowflake) -> "SnowflakeGenerator": + return cls(sf.instance, seq=sf.seq, epoch=sf.epoch, timestamp=sf.timestamp) + + def __iter__(self): + return self + + def generate(self) -> Optional[int]: + current = int(time() * 1000) + + if self._ts == current: + if self._seq == MAX_SEQ: + return None + + self._seq += 1 + + elif self._ts > current: + return None + + else: + self._seq = 0 + + self._ts = current + + return self._ts << 22 | self._inf | self._seq diff --git a/hyperscale/core_rewrite/workflow.py b/hyperscale/core_rewrite/workflow.py new file mode 100644 index 0000000..5836310 --- /dev/null +++ b/hyperscale/core_rewrite/workflow.py @@ -0,0 +1,78 @@ +import inspect +import os +import uuid +from typing import Any, Dict, List + +import networkx + +from .engines.client import Client, TimeParser +from .engines.client.config import Config +from .hooks import Hook + + +class Workflow: + def __init__(self): + self.graph = __file__ + self.name = self.__class__.__name__ + self.id = str(uuid.uuid4()) + + self.context: Dict[str, Any] = {} + self.hooks: Dict[str, Hook] = { + name: hook + for name, hook in inspect.getmembers( + self, predicate=lambda member: isinstance(member, Hook) + ) + } + + for hook in self.hooks.values(): + hook.call = hook.call.__get__(self, self.__class__) + setattr(self, hook.name, hook.call) + + self.config = { + "vus": 1000, + "duration": "1m", + "threads": os.cpu_count(), + "connect_retries": 3, + } + + self.config.update( + { + name: value + for name, value in inspect.getmembers(self) + if self.config.get(name) + } + ) + + self.config["duration"] = TimeParser(self.config["duration"]).time + + self.client = Client( + self.graph, self.id, self.name, self.id, Config(**self.config) + ) + + # self.client.set_mutations() + + self.workflow_graph = networkx.DiGraph() + + self.traversal_order: List[List[Hook]] = [] + + self.is_test = len([hook for hook in self.hooks.values() if hook.is_test]) > 0 + + for hook in self.hooks.values(): + hook.setup(self.context) + + sources = [] + + for hook_name, hook in self.hooks.items(): + self.workflow_graph.add_node(hook_name, hook=hook) + + for hook in self.hooks.values(): + if len(hook.dependencies) == 0: + sources.append(hook.name) + + for dependency in hook.dependencies: + self.workflow_graph.add_edge(dependency, hook.name) + + for traversal_layer in networkx.bfs_layers(self.workflow_graph, sources): + self.traversal_order.append( + [self.hooks.get(hook_name) for hook_name in traversal_layer] + ) diff --git a/hyperscale/data/__init__.py b/hyperscale/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/__init__.py b/hyperscale/data/connectors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/aws_lambda/__init__.py b/hyperscale/data/connectors/aws_lambda/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/aws_lambda/aws_lambda_connector.py b/hyperscale/data/connectors/aws_lambda/aws_lambda_connector.py new file mode 100644 index 0000000..f9f3916 --- /dev/null +++ b/hyperscale/data/connectors/aws_lambda/aws_lambda_connector.py @@ -0,0 +1,155 @@ +import asyncio +import functools +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .aws_lambda_connector_config import AWSLambdaConnectorConfig + +try: + import boto3 + has_connector=True +except Exception: + boto3 = None + has_connector=False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class AWSLambdaConnector: + connector_type = ConnectorType.AWSLambda + + def __init__( + self, + config: AWSLambdaConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.aws_access_key_id = config.aws_access_key_id + self.aws_secret_access_key = config.aws_secret_access_key + self.region_name = config.region_name + self.lambda_name = config.lambda_name + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._client = None + self._loop = asyncio.get_event_loop() + self.session_uuid = str(uuid.uuid4()) + + self.connector_type_name = self.connector_type.name.capitalize() + self.metadata_string: str = None + self.stage = stage + self.parser_config = parser_config + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.parser = Parser() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to AWS - Region: {self.region_name}') + + self._client = await self._loop.run_in_executor( + self._executor, + functools.partial( + boto3.client, + 'lambda', + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.region_name + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Successfully opened connection to AWS - Region: {self.region_name}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data() + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data() + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, Any]: + return await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.lambda_name + ) + ) + + async def close(self): + self._executor.shutdown(cancel_futures=True) diff --git a/hyperscale/data/connectors/aws_lambda/aws_lambda_connector_config.py b/hyperscale/data/connectors/aws_lambda/aws_lambda_connector_config.py new file mode 100644 index 0000000..d72e1e5 --- /dev/null +++ b/hyperscale/data/connectors/aws_lambda/aws_lambda_connector_config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class AWSLambdaConnectorConfig(BaseModel): + aws_access_key_id: StrictStr + aws_secret_access_key: StrictStr + region_name: StrictStr + lambda_name: StrictStr='actions' + connector_type: ConnectorType=ConnectorType.AWSLambda \ No newline at end of file diff --git a/hyperscale/data/connectors/bigtable/__init__.py b/hyperscale/data/connectors/bigtable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/bigtable/bigtable_connector.py b/hyperscale/data/connectors/bigtable/bigtable_connector.py new file mode 100644 index 0000000..031b1a9 --- /dev/null +++ b/hyperscale/data/connectors/bigtable/bigtable_connector.py @@ -0,0 +1,167 @@ +import asyncio +import functools +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .bigtable_connector_config import BigTableConnectorConfig + +try: + from google.auth import load_credentials_from_file + from google.cloud import bigtable + from google.cloud.bigtable.row_data import PartialRowsData + has_connector = True + +except Exception: + bigtable = None + Credentials = None + has_connector = False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class BigTableConnector: + connector_type = ConnectorType.BigTable + + def __init__( + self, + config: BigTableConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + + self.service_account_json_path = config.service_account_json_path + self.instance_id = config.instance_id + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + self.instance = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.stage = stage + self.parser_config = parser_config + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._table_name = config.table_name + self._column_family_name = None + self._table = None + self._columns = None + + self.credentials = None + self.client = None + self._loop = asyncio.get_event_loop() + + self.parser = Parser() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to Google Cloud - Loading account config from - {self.service_account_json_path}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + + credentials, project_id = load_credentials_from_file(self.service_account_json_path) + self.client = bigtable.Client( + project=project_id, + credentials=credentials, + admin=True + ) + self.instance = self.client.instance(self.instance_id) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opened connection to Google Cloud - Created Client Instance - ID:{self.instance_id}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opened connection to Google Cloud - Loaded account config from - {self.service_account_json_path}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data() + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data() + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + self._table = self.instance.table(self._table_name) + + data_rows: PartialRowsData = await self._loop.run_in_executor( + self._executor, + functools.partial( + self._table.read_rows + ) + ) + + return [ + row.to_dict() for row in data_rows + ] + + async def close(self): + self._executor.shutdown(cancel_futures=True) diff --git a/hyperscale/data/connectors/bigtable/bigtable_connector_config.py b/hyperscale/data/connectors/bigtable/bigtable_connector_config.py new file mode 100644 index 0000000..a56d7c7 --- /dev/null +++ b/hyperscale/data/connectors/bigtable/bigtable_connector_config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class BigTableConnectorConfig(BaseModel): + service_account_json_path: StrictStr + instance_id: StrictStr + table_name: StrictStr + connector_type: ConnectorType=ConnectorType.BigTable + diff --git a/hyperscale/data/connectors/cassandra/__init__.py b/hyperscale/data/connectors/cassandra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/cassandra/cassandra_connector.py b/hyperscale/data/connectors/cassandra/cassandra_connector.py new file mode 100644 index 0000000..eb91a64 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/cassandra_connector.py @@ -0,0 +1,467 @@ +import asyncio +import functools +import os +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Coroutine, Dict, List, Type, Union + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.engines.types.playwright.command import PlaywrightCommand +from hyperscale.core.engines.types.task.task import Task +from hyperscale.core.engines.types.udp.action import UDPAction +from hyperscale.core.engines.types.websocket.action import WebsocketAction +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .cassandra_connector_config import CassandraConnectorConfig +from .cassandra_load_validator import CassandraLoadValidator +from .schema_set import CassandraSchemaSet + + +def noop(): + pass + + +Action = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action, + PlaywrightCommand, + Task, + UDPAction, + WebsocketAction +] + + +try: + from cassandra.auth import PlainTextAuthProvider + from cassandra.cluster import Cluster + from cassandra.cqlengine import columns, connection + from cassandra.cqlengine.management import sync_table + from cassandra.cqlengine.models import Model + from cassandra.cqlengine.query import ModelQuerySet + from cassandra.query import dict_factory + has_connector = True + +except ImportError: + columns = object + connection = object + sync_table = noop + dict_factory = noop + ModelQuerySet = object + Model = object + Cluster = object + PlainTextAuthProvider = object + has_connector = False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class CassandraConnector: + connector_type = ConnectorType.Cassandra + + def __init__( + self, + config: CassandraConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.cluster = None + self.session = None + + self.hosts = config.hosts + self.port = config.port or 9042 + + self.username = config.username + self.password = config.password + self.keyspace = config.keyspace + + self.table_name = config.table_name + + self.replication_strategy = config.replication_strategy + self.replication = config.replication + self.ssl = config.ssl + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.stage = stage + self.parser_config = parser_config + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop = asyncio.get_event_loop() + + self.parser = Parser() + + self._primary_column_types = { + 'ascii': columns.Ascii, + 'bigint': columns.BigInt, + 'blob': columns.Blob, + 'boolean': columns.Boolean, + 'date': columns.Date, + 'datetime': columns.DateTime, + 'decimal': columns.Decimal, + 'duration': columns.Duration, + 'uuid': columns.UUID, + 'integer': columns.Integer, + 'ipaddr': columns.Inet, + 'float': columns.Float, + 'map': columns.Map, + 'smallint': columns.SmallInt, + 'text': columns.Text, + 'time': columns.Time, + 'time_uuid': columns.TimeUUID, + 'tinyint': columns.TinyInt, + 'varint': columns.VarInt, + } + + self._fields: Dict[str, columns.Column] = {} + self._columns_factory: Dict[ + str, + Callable[ + [Dict[str, Any]], + columns.Column + ] + ] = { + 'ascii': lambda column_config: columns.Ascii( + min_length=column_config.get('min_length', 1), + **column_config, + ), + 'bigint': lambda column_config: columns.BigInt(**column_config), + 'blob': lambda column_config: columns.Blob(**column_config), + 'boolean': lambda column_config: columns.Boolean(**column_config), + 'date': lambda column_config: columns.Date(**column_config), + 'datetime': lambda column_config: columns.DateTime(**column_config), + 'decimal': lambda column_config: columns.Decimal(**column_config), + 'duration': lambda column_config: columns.Duration(**column_config), + 'uuid': lambda column_config: columns.UUID( + primary_key=column_config.get('primary_key', True), + default=uuid.uuid4, + **column_config + ), + 'integer': lambda column_config: columns.Integer(**column_config), + 'ipaddr': lambda column_config: columns.Inet(**column_config), + 'list': lambda column_config: columns.List( + value_type=self._primary_column_types.get( + column_config.get('value_type') + ), + **column_config + ), + 'map': lambda column_config: columns.Map( + key_type=self._primary_column_types.get( + column_config.get('value_type') + ), + value_type=self._primary_column_types.get( + column_config.get('value_type') + ), + **column_config + ), + 'float': lambda column_config: columns.Float(**column_config), + 'set': lambda column_config: columns.Set( + value_type=self._primary_column_types.get( + column_config.get('value_type') + ), + **column_config + ), + 'smallint': lambda column_config: columns.SmallInt(**column_config), + 'text': lambda column_config: columns.Text( + min_length=column_config.get('min_length', 1), + **column_config, + ), + 'time': lambda column_config: columns.Time(**column_config), + 'time_uuid': lambda column_config: columns.TimeUUID( + primary_key=column_config.get('primary_key', True), + default=uuid.uuid1, + **column_config + ), + 'tinyint': lambda column_config: columns.TinyInt(**column_config), + 'tuple': lambda column_config: columns.Tuple( + **column_config + ), + 'varint': lambda column_config: columns.VarInt(**column_config), + } + + self.schemas = CassandraSchemaSet() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + host_port_combinations = ', '.join([ + f'{host}:{self.port}' for host in self.hosts + ]) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Opening amd authorizing connection to Cassandra Cluster at - {host_port_combinations}') + await self.logger.filesystem.aio['hyperscale.core'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + + auth = None + if self.username and self.password: + auth = PlainTextAuthProvider(self.username, self.password) + + + self.cluster = Cluster( + self.hosts, + port=self.port, + auth_provider=auth, + ssl_context=self.ssl + ) + + self.session = await self._loop.run_in_executor( + None, + self.cluster.connect + ) + + self.session.row_factory = dict_factory + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Connected to Cassandra Cluster at - {host_port_combinations}') + + if self.keyspace is None: + self.keyspace = 'hyperscale' + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Creating Keyspace - {self.keyspace}') + + keyspace_options = f"'class' : '{self.replication_strategy}', 'replication_factor' : {self.replication}" + keyspace_query = f"CREATE KEYSPACE IF NOT EXISTS {self.keyspace} WITH REPLICATION = " + "{" + keyspace_options + "};" + + await self._loop.run_in_executor( + None, + self.session.execute, + keyspace_query + ) + + await self._loop.run_in_executor( + None, + self.session.set_keyspace, + self.keyspace + ) + if os.getenv('CQLENG_ALLOW_SCHEMA_MANAGEMENT') is None: + os.environ['CQLENG_ALLOW_SCHEMA_MANAGEMENT'] = '1' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + connection.setup, + self.hosts, + self.keyspace, + protocol_version=3 + ) + ) + + await self.logger.filesystem.aio['hyperscale.core'].info(f'{self.metadata_string} - Created Keyspace - {self.keyspace}') + + async def create_schemas(self): + for schema in self.schemas.action_schemas(self.table_name): + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + schema, + keyspaces=[self.keyspace] + ) + ) + + for schema in self.schemas.results_schemas(self.table_name): + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + schema, + keyspaces=[self.keyspace] + ) + ) + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + cassandra_load_request = CassandraLoadValidator(**options) + + table: Type[Model] = options.get('table') + table_name = options.get('table_name') + + if table_name is None: + table_name = self.table_name + + if table is None: + + fields = { + field_name: self._columns_factory.get( + field_config.field_type + )( + field_config.options + ) for field_name, field_config in cassandra_load_request.fields.items() + } + + table = type( + table_name, + (Model, ), + fields + ) + + if cassandra_load_request.filters: + + data_rows: ModelQuerySet = await self._loop.run_in_executor( + self._executor, + functools.partial( + table.filter, + **cassandra_load_request.filters + ) + ) + + else: + data_rows: ModelQuerySet = await self._loop.run_in_executor( + self._executor, + table.all + ) + + if cassandra_load_request.limit: + data_rows = await self._loop.run_in_executor( + self._executor, + functools.partial( + data_rows.limit, + cassandra_load_request.limit + ) + ) + + return [ + row for row in data_rows + ] + + async def store_actions( + self, + actions: List[Action], + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + pass + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + tables = [ + table for table in self.schemas.action_schemas(self.table_name) + ] + + actions: List[Dict[str, Any]] = [] + + table_results: List[List[Dict[str, Any]]] = await asyncio.gather(*[ + asyncio.create_task( + self.load_data( + options={ + 'table': table, + 'table_name': table_name, + **options + } + ) + ) for table_name, table in tables + ]) + + for table_result in table_results: + actions.extend(table_result) + + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + + tables = [ + table for table in self.schemas.action_schemas(self.table_name) + ] + + results: List[Dict[str, Any]] = [] + + table_results: List[List[Dict[str, Any]]] = await asyncio.gather(*[ + asyncio.create_task( + self.load_data( + options={ + 'table': table, + 'table_name': table_name + **options + } + ) + ) for table_name, table in tables + ]) + + for table_result in table_results: + results.extend(table_result) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def close(self): + + await self._loop.run_in_executor( + self._executor, + self.cluster.shutdown + ) + + self._executor.shutdown(cancel_futures=True) + \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/cassandra_connector_config.py b/hyperscale/data/connectors/cassandra/cassandra_connector_config.py new file mode 100644 index 0000000..d5be762 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/cassandra_connector_config.py @@ -0,0 +1,22 @@ +from ssl import SSLContext +from typing import List, Optional + +from pydantic import BaseModel, StrictInt, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class CassandraConnectorConfig(BaseModel): + hosts: List[StrictStr] = ['127.0.0.1'] + port: StrictInt=9042 + username: Optional[StrictStr]=None + password: Optional[StrictStr]=None + keyspace: StrictStr='hyperscale' + table_name: StrictStr + replication_strategy: StrictStr='SimpleStrategy' + replication: StrictInt=3 + ssl: Optional[SSLContext]=None + connector_type: ConnectorType=ConnectorType.Cassandra + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/cassandra_load_validator.py b/hyperscale/data/connectors/cassandra/cassandra_load_validator.py new file mode 100644 index 0000000..25b890e --- /dev/null +++ b/hyperscale/data/connectors/cassandra/cassandra_load_validator.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, StrictStr, StrictInt +from typing import Dict, Any, Optional, Type + +class FieldOption: + field_type: StrictStr + options: Dict[StrictStr, Any] + + +class CassandraLoadValidator(BaseModel): + fields: Optional[Dict[StrictStr, FieldOption]] + filters: Optional[Dict[StrictStr, Any]] + table: Type[Any] + limit: Optional[StrictInt] + + class Config: + arbitrary_types_allowed=True \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/__init__.py b/hyperscale/data/connectors/cassandra/schema_set/__init__.py new file mode 100644 index 0000000..385081a --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/__init__.py @@ -0,0 +1 @@ +from .cassandra_schema_set import CassandraSchemaSet \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/__init__.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/__init__.py new file mode 100644 index 0000000..82d6e5f --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/__init__.py @@ -0,0 +1,10 @@ +from .cassandra_graphql_action_schema import CassandraGraphQLActionSchema +from .cassandra_graphql_http2_action_schema import CassandraGraphQLHTTP2ActionSchema +from .cassandra_grpc_action_schema import CassandraGRPCActionSchema +from .cassandra_http_action_schema import CassandraHTTPActionSchema +from .cassandra_http2_action_schema import CassandraHTTP2ActionSchema +from .cassandra_http3_action_schema import CassandraHTTP3ActionSchema +from .cassandra_playwright_action_schema import CassandraPlaywrightActionSchema +from .cassandra_task_schema import CassandraTaskSchema +from .cassandra_udp_action_schema import CassandraUDPActionSchema +from .cassandra_websocket_action_schema import CassandraWebsocketActionSchema \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_action_schema.py new file mode 100644 index 0000000..e5d0813 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_action_schema.py @@ -0,0 +1,113 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql.action import GraphQLAction + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraGraphQLActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'query': columns.Text(min_length=1), + 'operation_name': columns.Text(min_length=1), + 'variables': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_graphql', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.GRAPHQL + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + action: GraphQLAction + ) -> Dict[str, Any]: + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'headers': json.dumps(action.headers), + 'query': action.data.get('query'), + 'operation_name': action.data.get('operation_name'), + 'variables': json.dumps(action.data.get('variables')), + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'headers': json.loads(action.get('headers')), + 'query': action.get('query'), + 'operation_name': action.get('operation_name'), + 'variables': json.loads(action.get('variables')), + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_http2_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_http2_action_schema.py new file mode 100644 index 0000000..b27a742 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_graphql_http2_action_schema.py @@ -0,0 +1,113 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraGraphQLHTTP2ActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'query': columns.Text(min_length=1), + 'operation_name': columns.Text(min_length=1), + 'variables': columns.Text(min_length=1), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_graphql_http2', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.GRAPHQL_HTTP2 + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + action: GraphQLHTTP2Action + ) -> Dict[str, Any]: + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'headers': json.dumps(action.headers), + 'query': action.data.get('query'), + 'operation_name': action.data.get('operation_name'), + 'variables': json.dumps(action.data.get('variables')), + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'headers': json.loads(action.get('headers')), + 'query': action.get('query'), + 'operation_name': action.get('operation_name'), + 'variables': json.loads(action.get('variables')), + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_grpc_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_grpc_action_schema.py new file mode 100644 index 0000000..20ee4cb --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_grpc_action_schema.py @@ -0,0 +1,139 @@ +import binascii +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.grpc.protobuf_registry import protobuf_registry + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraGRPCActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'protobuf': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_grpc', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.GRPC + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + action: GRPCAction + ) -> Dict[str, Any]: + + + + encoded_protobuf = str( + binascii.b2a_hex( + action.data.SerializeToString() + ), + encoding='raw_unicode_escape' + )\ + + encoded_message_length = hex( + int(len(encoded_protobuf)/2) + ).lstrip("0x").zfill(8) + + encoded_protobuf = f'00{encoded_message_length}{encoded_protobuf}' + encoded_data = binascii.a2b_hex(encoded_protobuf) + + if protobuf_registry.get(action.name) is None: + protobuf_registry[action.name] = action.data + + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'headers': json.dumps(action.headers), + 'protobuf': encoded_data, + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + + + wire_msg = binascii.b2a_hex( + action.get('protobuf') + ) + + message_length = wire_msg[4:10] + msg = wire_msg[10:10+int(message_length, 16)*2] + + protobuf = binascii.a2b_hex(msg) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'headers': json.loads(action.get('headers')), + 'data': protobuf, + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http2_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http2_action_schema.py new file mode 100644 index 0000000..3cfef9a --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http2_action_schema.py @@ -0,0 +1,147 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.action import HTTP2Action + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraHTTP2ActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_http2', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.HTTP2 + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + action: HTTP2Action + ) -> Dict[str, Any]: + + action_data = action.data + + if isinstance(action_data, (bytes,)): + action_data = { + 'data': str(action_data), + 'datatype': 'bytes' + } + + elif isinstance(action_data, (int, )): + action_data = { + 'data': str(action_data), + 'datatype': 'integer' + } + + elif isinstance(action_data, (float, )): + action_data = { + 'data': str(action_data), + 'datatype': 'float' + } + + else: + action_data = { + 'data': action_data, + 'datatype': 'string' + } + + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'headers': json.dumps(action.headers), + 'data': json.dumps(action_data), + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + action_data: Dict[str, str] = json.load(action.get('data')) + + data = action_data.get('data') + datatype = action_data.get('datatype') + + if datatype == 'bytes': + data = data.encode() + + elif datatype == 'integer': + data = int(data) + + elif datatype == 'float': + data = float(data) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'headers': json.loads(action.get('headers')), + 'data': data, + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http3_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http3_action_schema.py new file mode 100644 index 0000000..7101553 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http3_action_schema.py @@ -0,0 +1,147 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http3.action import HTTP3Action + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraHTTP3ActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_http3', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.HTTP3 + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + action: HTTP3Action + ) -> Dict[str, Any]: + + action_data = action.data + + if isinstance(action_data, (bytes,)): + action_data = { + 'data': str(action_data), + 'datatype': 'bytes' + } + + elif isinstance(action_data, (int, )): + action_data = { + 'data': str(action_data), + 'datatype': 'integer' + } + + elif isinstance(action_data, (float, )): + action_data = { + 'data': str(action_data), + 'datatype': 'float' + } + + else: + action_data = { + 'data': action_data, + 'datatype': 'string' + } + + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'headers': json.dumps(action.headers), + 'data': json.dumps(action_data), + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + action_data: Dict[str, str] = json.load(action.get('data')) + + data = action_data.get('data') + datatype = action_data.get('datatype') + + if datatype == 'bytes': + data = data.encode() + + elif datatype == 'integer': + data = int(data) + + elif datatype == 'float': + data = float(data) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'headers': json.loads(action.get('headers')), + 'data': data, + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http_action_schema.py new file mode 100644 index 0000000..7b214a5 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_http_action_schema.py @@ -0,0 +1,147 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http.action import HTTPAction + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraHTTPActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_http', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.HTTP + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + action: HTTPAction + ) -> Dict[str, Any]: + + action_data = action.data + + if isinstance(action_data, (bytes,)): + action_data = { + 'data': str(action_data), + 'datatype': 'bytes' + } + + elif isinstance(action_data, (int, )): + action_data = { + 'data': str(action_data), + 'datatype': 'integer' + } + + elif isinstance(action_data, (float, )): + action_data = { + 'data': str(action_data), + 'datatype': 'float' + } + + else: + action_data = { + 'data': action_data, + 'datatype': 'string' + } + + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'headers': json.dumps(action.headers), + 'data': json.dumps(action_data), + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + action_data: Dict[str, str] = json.load(action.get('data')) + + data = action_data.get('data') + datatype = action_data.get('datatype') + + if datatype == 'bytes': + data = data.encode() + + elif datatype == 'integer': + data = int(data) + + elif datatype == 'float': + data = float(data) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'headers': json.loads(action.get('headers')), + 'data': data, + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_playwright_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_playwright_action_schema.py new file mode 100644 index 0000000..6fbd24b --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_playwright_action_schema.py @@ -0,0 +1,265 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Callable, Dict, List, Type, Union + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.playwright.command import PlaywrightCommand + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraPlaywrightActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'command': columns.Text(min_length=1), + 'selector': columns.Text(min_length=1), + 'attribute': columns.Text(min_length=1), + 'x_coordinate': columns.Text(min_length=1), + 'y_coordinate': columns.Text(min_length=1), + 'frame': columns.Integer(default=0), + 'location': columns.Text(), + 'headers': columns.Text(), + 'key': columns.Text(), + 'text': columns.Text(), + 'expression': columns.Text(), + 'args': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'filepath': columns.Text(), + 'file': columns.Text(), + 'path': columns.Text(), + 'option': columns.Map( + columns.Text(), + columns.Text() + ), + 'by_label': columns.Boolean(default=False), + 'by_value': columns.Boolean(default=False), + 'event': columns.Text(), + 'is_checked': columns.Boolean(default=False), + 'timeout': columns.Float(), + 'extra': columns.Text(), + 'switch_by': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_playwright', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.PLAYWRIGHT + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bool: 'boolean', + bytes: 'bytes', + dict: 'dictionary', + list: 'list' + } + + self._serialize_map: Dict[ + str, + Callable[ + [Type[ + Union[ + str, + bytes, + int, + float, + list, + dict + ] + ]], + str + ] + ] = { + str: str, + bytes: str, + int: str, + float: str, + bool: str, + dict: json.dumps, + list: json.dumps + } + + + self._deserialize_map: Dict[ + str, + Callable[ + [str], + Type[ + Union[ + str, + bytes, + int, + float, + list, + dict + ] + ] + ] + ] = { + 'string': str, + 'integer': int, + 'float': float, + 'bool': lambda boolean: boolean == "True", + 'bytes': bytes, + 'dictionary': json.loads, + 'list': json.loads + } + + def to_schema_record( + self, + action: PlaywrightCommand + ) -> Dict[str, Any]: + + action_option: Union[Dict[str, str], None] = None + if action.input.option: + action_option = { + 'data': self._serialize_map.get(type( + action.input.option + ))(action.input.option), + 'datatype': self._types_map.get(type( + action.input.option + )) + } + + + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'selector': action.page.selector, + 'attribute': action.page.attribute, + 'x_coordinate': action.page.x_coordinate, + 'y_coordinate': action.page.y_coordinate, + 'frame': action.page.frame, + 'location': action.url.location, + 'headers': json.dumps(action.url.headers), + 'key': action.input.key, + 'text': action.input.text, + 'expression': action.input.expression, + 'args': [ + { + 'data': self._serialize_map.get(type( + arg + ))(arg), + 'datatype': self._types_map.get(type( + arg + )) + } for arg in action.input.args + ], + 'filepath': action.input.filepath, + 'file': action.input.file, + 'path': action.input.path, + 'option': action_option, + 'by_label': action.input.by_label, + 'by_value': action.input.by_value, + 'event': action.options.event, + 'is_checked': action.options.is_checked, + 'timeout': action.options.timeout, + 'extra': json.dumps(action.options.extra), + 'switch_by': action.options.switch_by, + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + action_args: List[Dict[str, Any]] = action.get('args', []) + action_option: Dict[str, Any] = action.get('option') + + option: Union[ + str, + bytes, + int, + float, + bool, + list, + dict, + None + ] = None + + if action_option: + option = self._deserialize_map.get( + action_option.get('datatype') + )( + action_option.get('data') + ) + + return { + 'id': uuid.UUID(action.get('action_id')), + 'name': action.get('name'), + 'url': action.get('url'), + 'selector': action.get('selector'), + 'attribute': action.get('attribute'), + 'x_coordinate': action.get('x_coordinate'), + 'y_coordinate': action.get('y_coordinate'), + 'frame': action.get('frame'), + 'location': action.get('location'), + 'headers': json.loads( + action.get('headers', {}) + ), + 'key': action.get('key'), + 'text': action.get('text'), + 'expression': action.get('expression'), + 'args': [ + self._deserialize_map.get( + arg_config.get('datatype') + )( + arg_config.get('data') + ) for arg_config in action_args + ], + 'filepath': action.get('filepath'), + 'file': action.get('file'), + 'path': action.get('path'), + 'option': option, + 'by_label': action.get('by_label'), + 'by_value': action.get('by_Value'), + 'event': action.get('event'), + 'is_checked': action.get('is_checked'), + 'timeout': action.get('timeout'), + 'extra': json.loads( + action.get('extra', {}) + ), + 'switch_by': action.get('switch_by'), + 'user': action.get('user'), + 'tags': action_tags + } diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_task_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_task_schema.py new file mode 100644 index 0000000..22a40b2 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_task_schema.py @@ -0,0 +1,104 @@ +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.task.task import Task + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraTaskSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'task_name': columns.Text(min_length=1), + 'env': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_task', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.TASK + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + task: Task + ) -> Dict[str, Any]: + + + return { + 'id': uuid.UUID(task.action_id), + 'name': task.name, + 'task_name': task.execute.__name__, + 'env': task.event, + 'user': task.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in task.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'task_name': action.get('task_name'), + 'env': action.get('env'), + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_udp_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_udp_action_schema.py new file mode 100644 index 0000000..af43d87 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_udp_action_schema.py @@ -0,0 +1,147 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.udp.action import UDPAction + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraUDPActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'wait_for_response': columns.Boolean(default=False), + 'data': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_udp', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.UDP + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def to_schema_record( + self, + action: UDPAction + ) -> Dict[str, Any]: + + action_data = action.data + + if isinstance(action_data, (bytes,)): + action_data = { + 'data': str(action_data), + 'datatype': 'bytes' + } + + elif isinstance(action_data, (int, )): + action_data = { + 'data': str(action_data), + 'datatype': 'integer' + } + + elif isinstance(action_data, (float, )): + action_data = { + 'data': str(action_data), + 'datatype': 'float' + } + + else: + action_data = { + 'data': action_data, + 'datatype': 'string' + } + + return { + 'id': uuid.UUID(action.action_id), + 'name': action.name, + 'url': action.url, + 'wait_for_response': action.wait_for_response, + 'data': json.dumps(action_data), + 'user': action.metadata.user, + 'tags': [ + { + 'name': tag.get('name'), + 'value': str(tag.get('value')), + 'datatype': self._types_map.get(type( + tag.get('value') + )) + } for tag in action.metadata.tags + ] + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + action_data: Dict[str, str] = json.load(action.get('data')) + + data = action_data.get('data') + datatype = action_data.get('datatype') + + if datatype == 'bytes': + data = data.encode() + + elif datatype == 'integer': + data = int(data) + + elif datatype == 'float': + data = float(data) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'wait_for_response': action.get('wait_for_response'), + 'data': data, + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_websocket_action_schema.py b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_websocket_action_schema.py new file mode 100644 index 0000000..05fe50e --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/action_schemas/cassandra_websocket_action_schema.py @@ -0,0 +1,97 @@ +import json +import uuid +from datetime import datetime +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraWebsocketActionSchema: + + def __init__(self, table_name: str) -> None: + self.action_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.actions_table = type( + f'{table_name}_websocket', + (Model, ), + self.action_columns + ) + + self.type = RequestTypes.WEBSOCKET + + self._types_map: Dict[type, str] = { + str: 'string', + int: 'integer', + float: 'float', + bytes: 'bytes', + bool: 'bool' + } + + self._reverse_types_map: Dict[str, type] = { + mapped_name: mapped_type for mapped_type, mapped_name in self._types_map.items() + } + + def from_schema_record( + self, + action: Dict[str, Any] + ) -> Dict[str, Any]: + + action_tags: List[Dict[str, Any]] = action.get('tags', []) + action_data: Dict[str, str] = json.load(action.get('data')) + + data = action_data.get('data') + datatype = action_data.get('datatype') + + if datatype == 'bytes': + data = data.encode() + + elif datatype == 'integer': + data = int(data) + + elif datatype == 'float': + data = float(data) + + return { + 'engine': RequestTypes.GRAPHQL, + 'name': action.get('name'), + 'url': action.get('url'), + 'method': action.get('method'), + 'headers': json.loads(action.get('headers')), + 'data': data, + 'user': action.get('user'), + 'tags': [ + { + 'name': tag.get('name'), + 'value': self._reverse_types_map.get( + tag.get('datatype') + )( + tag.get('value') + ) + } for tag in action_tags + ] + } diff --git a/hyperscale/data/connectors/cassandra/schema_set/cassandra_schema_set.py b/hyperscale/data/connectors/cassandra/schema_set/cassandra_schema_set.py new file mode 100644 index 0000000..1c37c01 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/cassandra_schema_set.py @@ -0,0 +1,144 @@ +from typing import Callable, Dict, Union + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + Model = None + has_connector = False + +from .action_schemas import ( + CassandraGraphQLActionSchema, + CassandraGraphQLHTTP2ActionSchema, + CassandraGRPCActionSchema, + CassandraHTTP2ActionSchema, + CassandraHTTP3ActionSchema, + CassandraHTTPActionSchema, + CassandraPlaywrightActionSchema, + CassandraTaskSchema, + CassandraUDPActionSchema, + CassandraWebsocketActionSchema, +) +from .result_schemas import ( + CassandraGraphQLHTTP2ResultSchema, + CassandraGraphQLResultSchema, + CassandraGRPCResultSchema, + CassandraHTTP2ResultSchema, + CassandraHTTP3ResultSchema, + CassandraHTTPResultSchema, + CassandraPlaywrightResultSchema, + CassandraTaskResultSchema, + CassandraUDPResultSchema, + CassandraWebsocketResultSchema, +) + + +class CassandraSchemaSet: + + def __init__(self) -> None: + self._action_schemas: Dict[ + RequestTypes, + Callable[ + [str], + Union[ + CassandraGraphQLActionSchema, + CassandraGraphQLHTTP2ActionSchema, + CassandraGRPCActionSchema, + CassandraHTTPActionSchema, + CassandraHTTP2ActionSchema, + CassandraHTTP3ActionSchema, + CassandraPlaywrightActionSchema, + CassandraTaskSchema, + CassandraUDPActionSchema, + CassandraWebsocketActionSchema + ] + ] + ] = { + RequestTypes.GRAPHQL: lambda table_name: CassandraGraphQLActionSchema(table_name), + RequestTypes.GRAPHQL_HTTP2: lambda table_name: CassandraGraphQLHTTP2ActionSchema(table_name), + RequestTypes.GRPC: lambda table_name: CassandraGRPCActionSchema(table_name), + RequestTypes.HTTP: lambda table_name: CassandraHTTPActionSchema(table_name), + RequestTypes.HTTP2: lambda table_name: CassandraHTTP2ActionSchema(table_name), + RequestTypes.HTTP3: lambda table_name: CassandraHTTP3ActionSchema(table_name), + RequestTypes.PLAYWRIGHT: lambda table_name: CassandraPlaywrightActionSchema(table_name), + RequestTypes.TASK: lambda table_name: CassandraTaskSchema(table_name), + RequestTypes.UDP: lambda table_name: CassandraUDPActionSchema(table_name), + RequestTypes.WEBSOCKET: lambda table_name: CassandraWebsocketActionSchema(table_name) + } + + self._results_schemas: Dict[ + RequestTypes, + Callable[ + [str], + Union[ + CassandraGraphQLResultSchema, + CassandraGraphQLHTTP2ResultSchema, + CassandraGRPCResultSchema, + CassandraHTTPResultSchema, + CassandraHTTP2ResultSchema, + CassandraHTTP3ResultSchema, + CassandraPlaywrightResultSchema, + CassandraTaskResultSchema, + CassandraUDPResultSchema, + CassandraWebsocketResultSchema + ] + ] + ] = { + RequestTypes.GRAPHQL: lambda table_name: CassandraGraphQLResultSchema(table_name), + RequestTypes.GRAPHQL_HTTP2: lambda table_name: CassandraGraphQLHTTP2ResultSchema(table_name), + RequestTypes.GRPC: lambda table_name: CassandraGRPCResultSchema(table_name), + RequestTypes.HTTP: lambda table_name: CassandraHTTPResultSchema(table_name), + RequestTypes.HTTP2: lambda table_name: CassandraHTTP2ResultSchema(table_name), + RequestTypes.HTTP3: lambda table_name: CassandraHTTP3ResultSchema(table_name), + RequestTypes.PLAYWRIGHT: lambda table_name: CassandraPlaywrightResultSchema(table_name), + RequestTypes.TASK: lambda table_name: CassandraTaskResultSchema(table_name), + RequestTypes.UDP: lambda table_name: CassandraUDPResultSchema(table_name), + RequestTypes.WEBSOCKET: lambda table_name: CassandraWebsocketResultSchema(table_name) + } + + def get_action_schema( + self, + request_type: RequestTypes, + table_name: str + ): + return self._action_schemas.get( + request_type, + CassandraHTTPActionSchema + )(table_name) + + def get_result_schema( + self, + request_type: RequestTypes, + table_name: str + ): + return self._results_schemas.get( + request_type, + CassandraHTTPResultSchema + )(table_name) + + def action_schemas( + self, + table_name: str + ): + for schema in self._action_schemas.values(): + cassandra_schema = schema(table_name) + + request_type = cassandra_schema.type.lower() + assembled_table_name = f'{table_name}_{request_type}' + + yield assembled_table_name, cassandra_schema.actions_table + + def results_schemas( + self, + table_name: str + ): + for schema in self._results_schemas.values(): + cassandra_schema = schema(table_name) + + request_type = cassandra_schema.type.lower() + assembled_table_name = f'{table_name}_{request_type}' + + yield assembled_table_name, cassandra_schema.results_table diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/__init__.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/__init__.py new file mode 100644 index 0000000..9e0f138 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/__init__.py @@ -0,0 +1,10 @@ +from .cassandra_graphql_http2_result_schema import CassandraGraphQLHTTP2ResultSchema +from .cassandra_graphql_result_schema import CassandraGraphQLResultSchema +from .cassandra_grpc_result_schema import CassandraGRPCResultSchema +from .cassandra_http_result_schema import CassandraHTTPResultSchema +from .cassandra_http2_result_schema import CassandraHTTP2ResultSchema +from .cassandra_http3_result_schema import CassandraHTTP3ResultSchema +from .cassandra_playwright_result_schema import CassandraPlaywrightResultSchema +from .cassandra_task_result_schema import CassandraTaskResultSchema +from .cassandra_udp_result_schema import CassandraUDPResultSchema +from .cassandra_websocket_result_schema import CassandraWebsocketResultSchema \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_http2_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_http2_result_schema.py new file mode 100644 index 0000000..8bf540b --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_http2_result_schema.py @@ -0,0 +1,60 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraGraphQLHTTP2ResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'query': columns.Text(min_length=1), + 'operation_name': columns.Text(min_length=1), + 'variables': columns.Text(min_length=1), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + table_name, + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.GRAPHQL_HTTP2 diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_result_schema.py new file mode 100644 index 0000000..bd06175 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_graphql_result_schema.py @@ -0,0 +1,60 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraGraphQLResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'query': columns.Text(min_length=1), + 'operation_name': columns.Text(min_length=1), + 'variables': columns.Text(min_length=1), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_graphql', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.GRAPHQL diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_grpc_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_grpc_result_schema.py new file mode 100644 index 0000000..f769f26 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_grpc_result_schema.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraGRPCResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'protobuf': columns.Text(), + 'user': columns.Text(), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_grpc', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.GRPC diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http2_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http2_result_schema.py new file mode 100644 index 0000000..481f172 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http2_result_schema.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraHTTP2ResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_http2', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.HTTP2 diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http3_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http3_result_schema.py new file mode 100644 index 0000000..f130d43 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http3_result_schema.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraHTTP3ResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_http3', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.HTTP3 diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http_result_schema.py new file mode 100644 index 0000000..fea4209 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_http_result_schema.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraHTTPResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_http', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.HTTP diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_playwright_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_playwright_result_schema.py new file mode 100644 index 0000000..4f46880 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_playwright_result_schema.py @@ -0,0 +1,85 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraPlaywrightResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'command': columns.Text(min_length=1), + 'selector': columns.Text(min_length=1), + 'attribute': columns.Text(min_length=1), + 'x_coordinate': columns.Text(min_length=1), + 'y_coordinate': columns.Text(min_length=1), + 'frame': columns.Integer(default=0), + 'location': columns.Text(), + 'headers': columns.Text(), + 'key': columns.Text(), + 'text': columns.Text(), + 'expression': columns.Text(), + 'args': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'filepath': columns.Text(), + 'file': columns.Text(), + 'path': columns.Text(), + 'option': columns.Map( + columns.Text(), + columns.Text() + ), + 'by_label': columns.Boolean(default=False), + 'by_value': columns.Boolean(default=False), + 'event': columns.Text(), + 'is_checked': columns.Boolean(default=False), + 'timeout': columns.Float(), + 'extra': columns.Text(), + 'switch_by': columns.Text(), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_playwright', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.PLAYWRIGHT diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_task_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_task_result_schema.py new file mode 100644 index 0000000..b4484ea --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_task_result_schema.py @@ -0,0 +1,45 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraTaskResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'source': columns.Text(), + 'data': columns.Text(), + 'error': columns.Text(), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'complete': columns.Float(), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_task', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.TASK \ No newline at end of file diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_udp_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_udp_result_schema.py new file mode 100644 index 0000000..15e7484 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_udp_result_schema.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraUDPResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'wait_for_response': columns.Boolean(default=False), + 'data': columns.Text(), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_udp', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.UDP diff --git a/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_websocket_result_schema.py b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_websocket_result_schema.py new file mode 100644 index 0000000..36636c6 --- /dev/null +++ b/hyperscale/data/connectors/cassandra/schema_set/result_schemas/cassandra_websocket_result_schema.py @@ -0,0 +1,58 @@ +import uuid +from datetime import datetime + +from hyperscale.core.engines.types.common.types import RequestTypes + +try: + from cassandra.cqlengine import columns + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + Model = None + has_connector = False + + +class CassandraWebsocketResultSchema: + + def __init__(self, table_name: str) -> None: + self.results_columns = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'method': columns.Text(min_length=1), + 'url': columns.Text(min_length=1), + 'headers': columns.Text(), + 'data': columns.Text(), + 'error': columns.Text(), + 'status': columns.Integer(), + 'reason': columns.Text(), + 'params': columns.Map( + columns.Text(), + columns.Text() + ), + 'wait_start': columns.Float(), + 'start': columns.Float(), + 'connect_end': columns.Float(), + 'write_end': columns.Float(), + 'complete': columns.Float(), + 'checks': columns.List( + columns.Text() + ), + 'user': columns.Text(), + 'tags': columns.List( + columns.Map( + columns.Text(), + columns.Text() + ) + ), + 'created_at': columns.DateTime(default=datetime.now) + } + + self.results_table = type( + f'{table_name}_websocket', + (Model, ), + self.results_columns + ) + + self.type = RequestTypes.WEBSOCKET diff --git a/hyperscale/data/connectors/common/__init__.py b/hyperscale/data/connectors/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/common/connector_type.py b/hyperscale/data/connectors/common/connector_type.py new file mode 100644 index 0000000..3aa0da1 --- /dev/null +++ b/hyperscale/data/connectors/common/connector_type.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class ConnectorType(Enum): + AWSLambda='aws_lambda' + BigTable='bigtable' + Cassandra='cassandra' + CosmosDB='cosmosdb' + CSV='csv' + GCS='gcs' + HAR='har' + JSON='json' + Kafka='kafka' + MongoDB='mongodb' + MySQL='mysql' + Postgres='postgres' + Redis='redis' + S3='s3' + Snowflake='snowflake' + SQLite='sqlite' + TimescaleDB='timescaledb' + XML='xml' \ No newline at end of file diff --git a/hyperscale/data/connectors/common/execute_stage_summary_validator.py b/hyperscale/data/connectors/common/execute_stage_summary_validator.py new file mode 100644 index 0000000..7752657 --- /dev/null +++ b/hyperscale/data/connectors/common/execute_stage_summary_validator.py @@ -0,0 +1,19 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictBool, + StrictInt, + StrictFloat +) + +from typing import Union + + +class ExecuteStageSummaryValidator(BaseModel): + stage: StrictStr + total_elapsed: Union[StrictInt, StrictFloat] + total_results: Union[StrictInt, StrictFloat] + stage_batch_size: StrictInt + stage_optimized: StrictBool + stage_persona_type: StrictStr + stage_workers: StrictInt \ No newline at end of file diff --git a/hyperscale/data/connectors/connector.py b/hyperscale/data/connectors/connector.py new file mode 100644 index 0000000..b09a0be --- /dev/null +++ b/hyperscale/data/connectors/connector.py @@ -0,0 +1,248 @@ +from typing import Any, Callable, Coroutine, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) + +from .aws_lambda.aws_lambda_connector import AWSLambdaConnector +from .aws_lambda.aws_lambda_connector_config import AWSLambdaConnectorConfig +from .bigtable.bigtable_connector import BigTableConnector +from .bigtable.bigtable_connector_config import BigTableConnectorConfig +from .cassandra.cassandra_connector import CassandraConnector +from .cassandra.cassandra_connector_config import CassandraConnectorConfig +from .common.connector_type import ConnectorType +from .cosmosdb.cosmos_connector import CosmosDBConnector +from .cosmosdb.cosmos_connector_config import CosmosDBConnectorConfig +from .csv.csv_connector import CSVConnector +from .csv.csv_connector_config import CSVConnectorConfig +from .google_cloud_storage.google_cloud_storage_connector import ( + GoogleCloudStorageConnector, +) +from .google_cloud_storage.google_cloud_storage_connector_config import ( + GoogleCloudStorageConnectorConfig, +) +from .har.har_connector import HARConnector +from .har.har_connector_config import HARConnectorConfig +from .json.json_connector import JSONConnector +from .json.json_connector_config import JSONConnectorConfig +from .kafka.kafka_connector import KafkaConnector +from .kafka.kafka_connector_config import KafkaConnectorConfig +from .mongodb.mongodb_connector import MongoDBConnector +from .mongodb.mongodb_connector_config import MongoDBConnectorConfig +from .mysql.mysql_connector import MySQLConnector +from .mysql.mysql_connector_config import MySQLConnectorConfig +from .postgres.postgres_connector import PostgresConnection +from .postgres.postgres_connector_config import PostgresConnectorConfig +from .redis.redis_connector import RedisConnector +from .redis.redis_connector_config import RedisConnectorConfig +from .s3.s3_connector import S3Connector +from .s3.s3_connector_config import S3ConnectorConfig +from .snowflake.snowflake_connector import SnowflakeConnector +from .snowflake.snowflake_connector_config import SnowflakeConnectorConfig +from .sqlite.sqlite_connector import SQLiteConnector +from .sqlite.sqlite_connector_config import SQLiteConnectorConfig +from .xml.xml_connector import XMLConnector +from .xml.xml_connector_config import XMLConnectorConfig + +ConnectorConfig = Union[ + AWSLambdaConnectorConfig, + BigTableConnectorConfig, + CassandraConnectorConfig, + CosmosDBConnectorConfig, + CSVConnectorConfig, + GoogleCloudStorageConnectorConfig, + HARConnectorConfig, + JSONConnectorConfig, + KafkaConnectorConfig, + MongoDBConnectorConfig, + MySQLConnectorConfig, + PostgresConnectorConfig, + RedisConnectorConfig, + S3ConnectorConfig, + SnowflakeConnectorConfig, + SQLiteConnectorConfig, + XMLConnectorConfig +] + + +class Connector: + + def __init__( + self, + stage: str, + connector_config: ConnectorConfig, + parser_config: Config + ) -> None: + self._connectors: Dict[ + ConnectorType, + Callable[ + [ + Union[ + AWSLambdaConnectorConfig, + BigTableConnectorConfig, + CassandraConnectorConfig, + CosmosDBConnectorConfig, + CSVConnectorConfig, + GoogleCloudStorageConnectorConfig, + HARConnectorConfig, + JSONConnectorConfig, + KafkaConnectorConfig, + MongoDBConnectorConfig, + MySQLConnectorConfig, + PostgresConnectorConfig, + RedisConnectorConfig, + S3ConnectorConfig, + SnowflakeConnectorConfig, + SQLiteConnectorConfig, + XMLConnectorConfig + ], + str, + Config + ], + Union[ + AWSLambdaConnector, + BigTableConnector + ] + ] + ] = { + ConnectorType.AWSLambda: lambda config, stage, parser_config: AWSLambdaConnector( + config, + stage, + parser_config + ), + ConnectorType.BigTable: lambda config, stage, parser_config: BigTableConnector( + config, + stage, + parser_config + ), + ConnectorType.Cassandra: lambda config, stage, parser_config: CassandraConnector( + config, + stage, + parser_config + ), + ConnectorType.CosmosDB: lambda config, stage, parser_config: CosmosDBConnector( + config, + stage, + parser_config + ), + ConnectorType.CSV: lambda config, stage, parser_config: CSVConnector( + config, + stage, + parser_config + ), + ConnectorType.GCS: lambda config, stage, parser_config: GoogleCloudStorageConnector( + config, + stage, + parser_config + ), + ConnectorType.HAR: lambda config, stage, parser_config: HARConnector( + config, + stage, + parser_config + ), + ConnectorType.JSON: lambda config, stage, parser_config: JSONConnector( + config, + stage, + parser_config + ), + ConnectorType.Kafka: lambda config, stage, parser_config: KafkaConnector( + config, + stage, + parser_config + ), + ConnectorType.MongoDB: lambda config, stage, parser_config: MongoDBConnector( + config, + stage, + parser_config + ), + ConnectorType.MySQL: lambda config, stage, parser_config: MySQLConnector( + config, + stage, + parser_config + ), + ConnectorType.Postgres: lambda config, stage, parser_config: PostgresConnection( + config, + stage, + parser_config + ), + ConnectorType.Redis: lambda config, stage, parser_config: RedisConnector( + config, + stage, + parser_config + ), + ConnectorType.S3: lambda config, stage, parser_config: S3Connector( + config, + stage, + parser_config + ), + ConnectorType.Snowflake: lambda config, stage, parser_config: SnowflakeConnector( + config, + stage, + parser_config + ), + ConnectorType.SQLite: lambda config, stage, parser_config: SQLiteConnector( + config, + stage, + parser_config + ), + ConnectorType.XML: lambda config, stage, parser_config: XMLConnector( + config, + stage, + parser_config + ) + } + + self.selected = self._connectors.get( + connector_config.connector_type + )( + connector_config, + stage, + parser_config + ) + + self.stage = stage + self.parser_config = parser_config + self.connected = False + + async def connect(self): + await self.selected.connect() + self.connected = True + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + return await self.selected.load_execute_stage_summary( + options=options + ) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + return await self.selected.load_actions( + options=options + ) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + return await self.selected.load_results( + options=options + ) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, Any]: + return await self.load_data( + options=options + ) + + async def close(self): + self.connected = False + return await self.selected.close() \ No newline at end of file diff --git a/hyperscale/data/connectors/cosmosdb/__init__.py b/hyperscale/data/connectors/cosmosdb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/cosmosdb/cosmos_connector.py b/hyperscale/data/connectors/cosmosdb/cosmos_connector.py new file mode 100644 index 0000000..b91c2bd --- /dev/null +++ b/hyperscale/data/connectors/cosmosdb/cosmos_connector.py @@ -0,0 +1,130 @@ +import asyncio +import uuid +from typing import Any, Coroutine, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .cosmos_connector_config import CosmosDBConnectorConfig + +try: + from azure.cosmos import DatabaseProxy + from azure.cosmos.aio import CosmosClient + has_connector = True +except Exception: + CosmosClient = None + DatabaseProxy = None + has_connector = False + + +class CosmosDBConnector: + connector_type=ConnectorType.CosmosDB + + def __init__( + self, config: CosmosDBConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.account_uri = config.account_uri + self.account_key = config.account_key + + self.database_name = config.database + self.container_name = config.container_name + self.partition_key = config.partition_key + + self.analytics_ttl = config.analytics_ttl + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.stage = stage + self.parser_config = parser_config + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.container = None + + self.client = None + self.database: Union[DatabaseProxy, None] = None + self.parser = Parser() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to CosmosDB') + + self.client = CosmosClient( + self.account_uri, + credential=self.account_key + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to CosmosDB') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Database - {self.database_name} - if not exists') + self.database = self.client.get_database_client(self.database_name) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Database - {self.database_name}') + + self.container = self.database.get_container_client(self.container_name) + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data() + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data() + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + return [ + record async for record in self.container.read_all_items( + max_item_count=options.get('max_item_count'), + ) + ] + + + async def close(self): + await self.client.close() diff --git a/hyperscale/data/connectors/cosmosdb/cosmos_connector_config.py b/hyperscale/data/connectors/cosmosdb/cosmos_connector_config.py new file mode 100644 index 0000000..0857540 --- /dev/null +++ b/hyperscale/data/connectors/cosmosdb/cosmos_connector_config.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class CosmosDBConnectorConfig(BaseModel): + account_uri: str + account_key: str + database: str + container_name: str + partition_key: str + analytics_ttl: int=0 + connector_type: ConnectorType=ConnectorType.CosmosDB \ No newline at end of file diff --git a/hyperscale/data/connectors/csv/__init__.py b/hyperscale/data/connectors/csv/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/csv/csv_connector.py b/hyperscale/data/connectors/csv/csv_connector.py new file mode 100644 index 0000000..6aa7b4d --- /dev/null +++ b/hyperscale/data/connectors/csv/csv_connector.py @@ -0,0 +1,190 @@ +import asyncio +import csv +import functools +import os +import pathlib +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List, TextIO + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .csv_connector_config import CSVConnectorConfig +from .csv_load_validator import CSVLoadValidator + +has_connector = True + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop, + csv_file: TextIO +): + try: + csv_file.close() + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class CSVConnector: + connector_type=ConnectorType.CSV + + def __init__( + self, + config: CSVConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.filepath = config.filepath + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.stage = stage + self.parser_config = parser_config + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._csv_reader: csv.DictReader = None + self.csv_file: TextIO = None + + self._loop: asyncio.AbstractEventLoop = None + self.file_mode = config.file_mode + self.headers = config.headers + + self.parser = Parser() + + async def connect(self): + self._loop = asyncio._get_running_loop() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping connect') + + if self.filepath[:2] == '~/': + user_directory = pathlib.Path.home() + self.filepath = os.path.join( + user_directory, + self.filepath[2:] + ) + + self.filepath = await self._loop.run_in_executor( + self._executor, + functools.partial( + os.path.abspath, + self.filepath + ) + ) + + if self.csv_file is None: + self.csv_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.filepath, + self.file_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.csv_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening from file - {self.filepath}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + actions = await self.load_data() + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data() + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + csv_options = CSVLoadValidator(**options) + headers = csv_options.headers + + if headers is None and self.headers: + headers = self.headers + + if self._csv_reader is None: + self._csv_reader = csv.DictReader( + self.filepath, + fieldnames=headers + ) + + return await self._loop.run_in_executor( + self._executor, + self._load_data + ) + + def _load_data(self): + return [ row for row in self._csv_reader ] + + async def close(self): + + await self._loop.run_in_executor( + self._executor, + self.csv_file.close + ) + + self._executor.shutdown(cancel_futures=True) + diff --git a/hyperscale/data/connectors/csv/csv_connector_config.py b/hyperscale/data/connectors/csv/csv_connector_config.py new file mode 100644 index 0000000..d31c34e --- /dev/null +++ b/hyperscale/data/connectors/csv/csv_connector_config.py @@ -0,0 +1,12 @@ +from typing import List, Optional + +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class CSVConnectorConfig(BaseModel): + filepath: StrictStr + file_mode: StrictStr='r' + reporter_type: ConnectorType=ConnectorType.CSV + headers: Optional[List[StrictStr]] \ No newline at end of file diff --git a/hyperscale/data/connectors/csv/csv_load_validator.py b/hyperscale/data/connectors/csv/csv_load_validator.py new file mode 100644 index 0000000..005550a --- /dev/null +++ b/hyperscale/data/connectors/csv/csv_load_validator.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, StrictStr +from typing import List, Optional + + +class CSVLoadValidator(BaseModel): + headers: Optional[List[StrictStr]] \ No newline at end of file diff --git a/hyperscale/data/connectors/empty/__init__.py b/hyperscale/data/connectors/empty/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/google_cloud_storage/__init__.py b/hyperscale/data/connectors/google_cloud_storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector.py b/hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector.py new file mode 100644 index 0000000..1d47917 --- /dev/null +++ b/hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector.py @@ -0,0 +1,170 @@ +import asyncio +import functools +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .google_cloud_storage_connector_config import GoogleCloudStorageConnectorConfig + +try: + + from google.cloud import storage + has_connector = True + +except Exception: + storage = None + has_connector = False + + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + + except Exception: + pass + + +class GoogleCloudStorageConnector: + connector_type=ConnectorType.GCS + + def __init__( + self, + config: GoogleCloudStorageConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + + self.service_account_json_path = config.service_account_json_path + self.stage = stage + self.parser_config = parser_config + + self.bucket_namespace = config.bucket_namespace + self.bucket_name = config.bucket_name + + + self.credentials = None + self.client = None + + self._bucket = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop = asyncio.get_event_loop() + + self.parser = Parser() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to Google Cloud - Loading account config from - {self.service_account_json_path}') + self.client = storage.Client.from_service_account_json(self.service_account_json_path) + + self._bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + self.bucket_name + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opened connection to Google Cloud - Loaded account config from - {self.service_account_json_path}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data() + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data() + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + blobs: List[storage.Blob] = await self._loop.run_in_executor( + self._executor, + self._bucket.list_blobs + ) + + return await asyncio.gather(*[ + self._loop.run_in_executor( + self._executor, + functools.partial( + self._bucket.get_blob, + blob.name + ) + ) for blob in blobs + ]) + + async def close(self): + + await self._loop.run_in_executor( + self._executor, + self.client.close + ) + + self._executor.shutdown(wait=False, cancel_futures=True) diff --git a/hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector_config.py b/hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector_config.py new file mode 100644 index 0000000..fad399b --- /dev/null +++ b/hyperscale/data/connectors/google_cloud_storage/google_cloud_storage_connector_config.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class GoogleCloudStorageConnectorConfig(BaseModel): + service_account_json_path: str + bucket_namespace: str + bucket_name: str + connector_type: ConnectorType=ConnectorType.GCS \ No newline at end of file diff --git a/hyperscale/data/connectors/har/__init__.py b/hyperscale/data/connectors/har/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/har/har_connector.py b/hyperscale/data/connectors/har/har_connector.py new file mode 100644 index 0000000..471bcd0 --- /dev/null +++ b/hyperscale/data/connectors/har/har_connector.py @@ -0,0 +1,213 @@ +import asyncio +import functools +import json +import os +import pathlib +import re +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List, TextIO, Union + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .har_connector_config import HARConnectorConfig + +try: + + from haralyzer import HarEntry, HarParser + from haralyzer.http import Request + + has_connector=True + +except ImportError: + has_connector=False + + HarParser=object + HarEntry=object + Request=object + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop, + harfile: TextIO +): + try: + harfile.close() + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class HARConnector: + connector_type=ConnectorType.HAR + + def __init__( + self, + config: HARConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.filepath = config.filepath + self._name_pattern = re.compile('[^0-9a-zA-Z]+') + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.stage = stage + self.parser_config = parser_config + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._parser: Union[HarParser, None] = None + self.har_file: TextIO = None + + self._loop: asyncio.AbstractEventLoop = None + + self.parser = Parser() + self._protocol_types_map: Dict[str, str] = { + "http/3.0": "http3", + "http/2.0": "http2", + "http/1.1": "http" + } + + async def connect(self): + self._loop = asyncio._get_running_loop() + + if self.filepath[:2] == '~/': + user_directory = pathlib.Path.home() + self.filepath = os.path.join( + user_directory, + self.filepath[2:] + ) + + self.filepath = await self._loop.run_in_executor( + None, + functools.partial( + os.path.abspath, + self.filepath + ) + ) + + self.har_file = await self._loop.run_in_executor( + None, + functools.partial( + open, + self.filepath + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.har_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening from file - {self.filepath}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, NotImplementedError]: + raise NotImplementedError('Execute stage summary loading is not available for the HAR Connector.') + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + self._parser = HarParser( + har_data=json.loads(self.har_file) + ) + + actions: List[Dict[str, Any]] = [] + + action_order = 0 + + for page in self._parser.pages: + page_entries: List[HarEntry] = page.entries + + for entry in page_entries: + + page_request: Request = entry.request + + action_data = page_request.text + + content_type: str = page_request.mimeType + if content_type.lower() == 'application/json' and page_request.text: + action_data = json.loads(action_data) + + action_url: str = page_request.url + action_method_stub: str= page_request.method + action_method = action_method_stub.upper() + action_headers: List[Dict[str, str]] = page_request.headers + + action_basename = '_'.join([ + segment.capitalize() for segment in self._name_pattern.sub( + '_', + action_url + ).split('_') + ]) + + action_fullname = f'{action_method.lower()}_{action_basename}' + + http_type: str = self._protocol_types_map.get( + page_request.httpVersion, + "http" + ) + + actions.append({ + 'engine': http_type, + 'name': action_fullname, + 'url': action_url, + 'headers': action_headers, + 'method': action_method, + 'data': action_data, + 'order': action_order + }) + + action_order += 1 + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, NotImplementedError]: + raise NotImplementedError('Results loading is not available for the HAR Connector.') + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, NotImplementedError]: + raise NotImplementedError('Data loading is not available for the HAR Connector.') + + async def close(self): + await self._loop.run_in_executor( + self._executor, + self.har_file.close + ) + + self._executor.shutdown(cancel_futures=True) diff --git a/hyperscale/data/connectors/har/har_connector_config.py b/hyperscale/data/connectors/har/har_connector_config.py new file mode 100644 index 0000000..8aa3ca9 --- /dev/null +++ b/hyperscale/data/connectors/har/har_connector_config.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class HARConnectorConfig(BaseModel): + filepath: StrictStr + connector_type: ConnectorType=ConnectorType.CSV \ No newline at end of file diff --git a/hyperscale/data/connectors/json/__init__.py b/hyperscale/data/connectors/json/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/json/json_connector.py b/hyperscale/data/connectors/json/json_connector.py new file mode 100644 index 0000000..b7139f0 --- /dev/null +++ b/hyperscale/data/connectors/json/json_connector.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import asyncio +import functools +import json +import os +import pathlib +import re +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List, TextIO + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .json_connector_config import JSONConnectorConfig + +has_connector = True + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop, + json_file: TextIO +): + try: + json_file.close() + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class JSONConnector: + connector_type=ConnectorType.JSON + + def __init__( + self, + config: JSONConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.filepath = config.filepath + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop: asyncio.AbstractEventLoop = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.stage = stage + self.parser_config = parser_config + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.json_file: TextIO = None + + self.file_mode = config.file_mode + self.pattern = re.compile("_copy[0-9]+") + + self.parser = Parser() + + async def connect(self): + self._loop = asyncio._get_running_loop() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Setting filepaths') + + if self.filepath[:2] == '~/': + user_directory = pathlib.Path.home() + self.filepath = os.path.join( + user_directory, + self.filepath[2:] + ) + + self.filepath = await self._loop.run_in_executor( + self._executor, + functools.partial( + os.path.abspath, + self.filepath + ) + ) + + if self.json_file is None: + self.json_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.filepath, + self.file_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.json_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening from file - {self.filepath}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + actions: List[Dict[str, Any]] = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, Any]: + return await self._loop.run_in_executor( + self._executor, + functools.partial( + json.load, + self.json_file + ) + ) + + async def close(self): + + await self._loop.run_in_executor( + self._executor, + self.json_file.close + ) + + self._executor.shutdown(cancel_futures=True) \ No newline at end of file diff --git a/hyperscale/data/connectors/json/json_connector_config.py b/hyperscale/data/connectors/json/json_connector_config.py new file mode 100644 index 0000000..2031fde --- /dev/null +++ b/hyperscale/data/connectors/json/json_connector_config.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class JSONConnectorConfig(BaseModel): + filepath: StrictStr + file_mode: StrictStr='r' + connector_type: ConnectorType=ConnectorType.JSON \ No newline at end of file diff --git a/hyperscale/data/connectors/kafka/__init__.py b/hyperscale/data/connectors/kafka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/kafka/kafka_connector.py b/hyperscale/data/connectors/kafka/kafka_connector.py new file mode 100644 index 0000000..50c9845 --- /dev/null +++ b/hyperscale/data/connectors/kafka/kafka_connector.py @@ -0,0 +1,159 @@ +import asyncio +import json +import uuid +from typing import Any, Coroutine, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .kafka_connector_config import KafkaConnectorConfig + +try: + + from aiokafka import AIOKafkaConsumer + from aiokafka.structs import ConsumerRecord + has_connector = True + +except Exception: + AIOKafkaConsumer = object + ConsumerRecord = object + has_connector = False + + +class KafkaConnector: + connector_type=ConnectorType.Kafka + + def __init__( + self, + config: KafkaConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + + self.host = config.host + self.client_id = config.client_id + self.stage = stage + self.parser_config = parser_config + + self.topic = config.topic + self.partition = config.partition + + self.compression_type = config.compression_type + self.timeout = config.timeout + self.enable_idempotence = config.idempotent or True + self.options: Dict[str, Any] = config.options or {} + self._consumer = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.loop: Union[asyncio.AbstractEventLoop, None] = None + + self.logger = HyperscaleLogger() + self.logger.initialize() + self.parser = Parser() + + async def connect(self): + + self.loop = asyncio.get_event_loop() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Kafka at - {self.host}') + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - Compression Type: {self.compression_type}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - Connection Timeout: {self.timeout}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - Idempotent: {self.enable_idempotence}') + + for option_name, option in self.options.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - {option_name.capitalize()}: {option}') + + + self._consumer = AIOKafkaConsumer( + self.topic, + loop=self.loop, + bootstrap_servers=self.host, + client_id=self.client_id, + compression_type=self.compression_type, + request_timeout_ms=self.timeout, + enable_idempotence=self.enable_idempotence, + **self.options + ) + + await self._consumer.start() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Kafka at - {self.host}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> ExecuteStageSummaryValidator: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + actions = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + data: Dict[str, List[ConsumerRecord]] = await self._consumer.getmany( + timeout_ms=self.timeout, + max_records=options.get('max_records') + ) + + records: List[Dict[str, Any]] = [] + + for messages in data.values(): + for message in messages: + records.append( + json.loads(message.value) + ) + + return records + + + async def close(self): + await self._consumer.stop() diff --git a/hyperscale/data/connectors/kafka/kafka_connector_config.py b/hyperscale/data/connectors/kafka/kafka_connector_config.py new file mode 100644 index 0000000..17a8568 --- /dev/null +++ b/hyperscale/data/connectors/kafka/kafka_connector_config.py @@ -0,0 +1,17 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel, StrictBool, StrictInt, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class KafkaConnectorConfig(BaseModel): + host: StrictStr='localhost:9092' + client_id: StrictStr='hyperscale' + topic: StrictStr + partition: StrictInt=0 + compression_type: Optional[StrictStr] + timeout: StrictInt=1000 + idempotent: StrictBool=True + options: Dict[StrictInt, Any]={} + reporter_type: ConnectorType=ConnectorType.Kafka \ No newline at end of file diff --git a/hyperscale/data/connectors/mongodb/__init__.py b/hyperscale/data/connectors/mongodb/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/mongodb/mongodb_connector.py b/hyperscale/data/connectors/mongodb/mongodb_connector.py new file mode 100644 index 0000000..dc03878 --- /dev/null +++ b/hyperscale/data/connectors/mongodb/mongodb_connector.py @@ -0,0 +1,124 @@ +import asyncio +import uuid +from typing import Any, Coroutine, Dict, List + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .mongodb_connector_config import MongoDBConnectorConfig + +try: + from motor.motor_asyncio import AsyncIOMotorClient + has_connector = True + +except Exception: + AsyncIOMotorClient = None + has_connector = False + + +class MongoDBConnector: + connector_type=ConnectorType.MongoDB + + def __init__( + self, + config: MongoDBConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.host = config.host + self.username = config.username + self.password = config.password + self.database_name = config.database + self.stage = stage + self.parser_config = parser_config + + self.collection = config.collection + self.connection: AsyncIOMotorClient = None + self.database = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + self.parser = Parser() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to MongoDB instance at - {self.host} - Database: {self.database_name}') + + if self.username and self.password: + connection_string = f'mongodb://{self.username}:{self.password}@{self.host}/{self.database_name}' + + else: + connection_string = f'mongodb://{self.host}/{self.database_name}' + + self.connection = AsyncIOMotorClient(connection_string) + self.database = self.connection[self.database_name] + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to MongoDB instance at - {self.host} - Database: {self.database_name}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + actions: List[Dict[str, Any]] = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + return await self.database[self.collection].find( + limit=options.get('limit') + ) + + async def close(self): + await self.connection.close() + diff --git a/hyperscale/data/connectors/mongodb/mongodb_connector_config.py b/hyperscale/data/connectors/mongodb/mongodb_connector_config.py new file mode 100644 index 0000000..b6dc209 --- /dev/null +++ b/hyperscale/data/connectors/mongodb/mongodb_connector_config.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class MongoDBConnectorConfig(BaseModel): + host: StrictStr='localhost:27017' + username: Optional[StrictStr] + password: Optional[StrictStr] + database: StrictStr + collection: StrictStr + connector_type: ConnectorType=ConnectorType.MongoDB \ No newline at end of file diff --git a/hyperscale/data/connectors/mysql/__init__.py b/hyperscale/data/connectors/mysql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/mysql/mysql_connector.py b/hyperscale/data/connectors/mysql/mysql_connector.py new file mode 100644 index 0000000..a0ba2c8 --- /dev/null +++ b/hyperscale/data/connectors/mysql/mysql_connector.py @@ -0,0 +1,160 @@ +import asyncio +import uuid +import warnings +from typing import Any, Coroutine, Dict, List + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .mysql_connector_config import MySQLConnectorConfig + +try: + # Aiomysql will raise warnings if a table exists despite us + # explicitly passing "IF NOT EXISTS", so we're going to + # ignore them. + import aiomysql + import sqlalchemy as sa + warnings.filterwarnings('ignore', category=aiomysql.Warning) + + from aiomysql.sa import SAConnection, create_engine + from aiomysql.sa.result import RowProxy + + has_connector = True + +except Exception: + SAConnection = object + RowProxy = object + sqlalchemy = object + sa = object + create_engine = object + CreateTable = object + OperationalError = object + has_connector = object + + + +class MySQLConnector: + connection_type=ConnectorType.MySQL + + def __init__( + self, + config: MySQLConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.host = config.host + self.database = config.database + self.username = config.username + self.password = config.password + self.stage = stage + self.parser_config = parser_config + + self.table_name = config.table_name + self._table = None + + self.metadata = sa.MetaData() + self._engine = None + self._connection = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.parser = Parser() + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to MySQL instance at - {self.host} - Database: {self.database}') + self._engine = await create_engine( + db=self.database, + host=self.host, + user=self.username, + password=self.password + ) + + self._connection: SAConnection = await self._engine.acquire() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to MySQL instance at - {self.host} - Database: {self.database}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + if self._table is None: + + self._table = sa.Table( + self.table_name, + self.metadata, + autoload_with=self._engine + ) + + rows: List[RowProxy] = [ + row async for row in self._connection.execute( + self._table.select(**options) + ) + ] + + return [ + { + column: value for column, value in result.items() + } for result in rows + ] + + async def close(self): + await self._connection.close() + + \ No newline at end of file diff --git a/hyperscale/data/connectors/mysql/mysql_connector_config.py b/hyperscale/data/connectors/mysql/mysql_connector_config.py new file mode 100644 index 0000000..4dc2f7c --- /dev/null +++ b/hyperscale/data/connectors/mysql/mysql_connector_config.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class MySQLConnectorConfig(BaseModel): + host: StrictStr='127.0.0.1' + database: StrictStr + username: StrictStr + password: StrictStr + table_name: StrictStr + connector_type: ConnectorType=ConnectorType.MySQL + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/data/connectors/postgres/__init__.py b/hyperscale/data/connectors/postgres/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/postgres/postgres_connector.py b/hyperscale/data/connectors/postgres/postgres_connector.py new file mode 100644 index 0000000..300af20 --- /dev/null +++ b/hyperscale/data/connectors/postgres/postgres_connector.py @@ -0,0 +1,161 @@ +# # This is an ugly patch for: https://github.com/aio-libs/aiopg/issues/837 +# import selectors # isort:skip # noqa: F401 + +# selectors._PollLikeSelector.modify = ( # type: ignore +# selectors._BaseSelectorImpl.modify # type: ignore +# ) +import asyncio +import uuid +from typing import Any, Coroutine, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .postgres_connector_config import PostgresConnectorConfig + +try: + import sqlalchemy + from sqlalchemy.engine.result import Result as SQLResult + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.ext.asyncio.engine import AsyncConnection, AsyncEngine + + has_connector = True + +except Exception: + SQLResult = object + sqlalchemy = object + AsyncEngine = object + AsyncConnection = object + AsyncTransaction = object + create_async_engine: lambda *args, **kwargs: None + has_connector = False + + +class PostgresConnection: + connection_type=ConnectorType.Postgres + + def __init__( + self, + config: PostgresConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + + self.host = config.host + self.database = config.database + self.username = config.username + self.password = config.password + self.stage = stage + self.parser_config = parser_config + + self.table_name = config.table_name + + self._engine: Union[AsyncEngine, None] = None + self.metadata = sqlalchemy.MetaData() + + self._table = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.parser = Parser() + self.sql_type = 'Postgresql' + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to {self.sql_type} instance at - {self.host} - Database: {self.database}') + + connection_uri = 'postgresql+asyncpg://' + + if self.username and self.password: + connection_uri = f'{connection_uri}{self.username}:{self.password}@' + + self._engine: AsyncEngine = await create_async_engine( + f'{connection_uri}{self.host}/{self.database}', + echo=False + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to {self.sql_type} instance at - {self.host} - Database: {self.database}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> ExecuteStageSummaryValidator: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + if self._table is None: + + self._table = sqlalchemy.Table( + self.table_name, + self.metadata, + autoload_with=self._engine + ) + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + results: SQLResult = await connection.execute( + self._table.select(**options) + ) + + return [ + { + column: value for column, value in row._mapping.items() + } for row in results.fetchall() + ] + + async def close(self): + pass \ No newline at end of file diff --git a/hyperscale/data/connectors/postgres/postgres_connector_config.py b/hyperscale/data/connectors/postgres/postgres_connector_config.py new file mode 100644 index 0000000..9136ae2 --- /dev/null +++ b/hyperscale/data/connectors/postgres/postgres_connector_config.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class PostgresConnectorConfig(BaseModel): + host: StrictStr='localhost' + database: StrictStr + username: StrictStr + password: StrictStr + table_name: StrictStr + connector_type: ConnectorType=ConnectorType.Postgres + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/data/connectors/redis/__init__.py b/hyperscale/data/connectors/redis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/redis/redis_connector.py b/hyperscale/data/connectors/redis/redis_connector.py new file mode 100644 index 0000000..41350e0 --- /dev/null +++ b/hyperscale/data/connectors/redis/redis_connector.py @@ -0,0 +1,170 @@ +import asyncio +import json +import time +import uuid +from typing import Any, Coroutine, Dict, List + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .redis_connector_config import RedisConnectorConfig + +try: + + import aioredis + + has_connector = True + +except Exception: + aioredis = None + has_connector = True + + +class RedisConnector: + connector_type=ConnectorType.Redis + + def __init__( + self, + config: RedisConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.host = config.host + self.base = 'rediss' if config.secure else 'redis' + self.username = config.username + self.password = config.password + self.database = config.database + self.channel = config.channel + self.stage = stage + self.parser_config = parser_config + + self.channel_type = config.channel_type + self.connection = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.parser = Parser() + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Redis instance at - {self.base}://{self.host} - Database: {self.database}') + + self.connection = await aioredis.from_url( + f'{self.base}://{self.host}', + username=self.username, + password=self.password, + db=self.database + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Redis instance at - {self.base}://{self.host} - Database: {self.database}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + if self.channel: + async with self.connection.client() as connection: + keys = await connection.keys() + return await asyncio.gather(*[ + asyncio.create_task( + connection.get(key) for key in keys + ) + ]) + + else: + subscriber = self.connection.pubsub() + await subscriber.subscribe(self.channel) + + limit = options.get('limit') + timeout = options.get('timeout', 1) + + if limit is None and timeout is None: + raise Exception('A limit or timeout must be provided.') + + if limit: + + async with subscriber as subscription: + results: List[bytes] = await asyncio.gather(*[ + asyncio.create_task(subscription.get_message( + ignore_subscribe_messages=True + )) for _ in range(limit) + ]) + + else: + async with subscriber as subscription: + elapsed = 0 + start = time.time() + + results: List[bytes] = [] + + while elapsed < timeout: + results.append( + await subscription.get_message( + ignore_subscribe_messages=True + ) + ) + + elapsed = time.time() - start + + return [ + json.loads(result) for result in results + ] + + async def close(self): + await self.connection.close() diff --git a/hyperscale/data/connectors/redis/redis_connector_config.py b/hyperscale/data/connectors/redis/redis_connector_config.py new file mode 100644 index 0000000..64ed763 --- /dev/null +++ b/hyperscale/data/connectors/redis/redis_connector_config.py @@ -0,0 +1,16 @@ +from typing import Optional + +from pydantic import BaseModel, StrictBool, StrictInt, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class RedisConnectorConfig(BaseModel): + host: StrictStr='localhost:6379' + username: Optional[StrictStr] + password: Optional[StrictStr] + database: StrictInt=0 + channel: StrictStr + channel_type: StrictStr='pipeline' + secure: StrictBool=False + connector_type: ConnectorType=ConnectorType.Redis \ No newline at end of file diff --git a/hyperscale/data/connectors/s3/__init__.py b/hyperscale/data/connectors/s3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/s3/s3_connector.py b/hyperscale/data/connectors/s3/s3_connector.py new file mode 100644 index 0000000..abeada5 --- /dev/null +++ b/hyperscale/data/connectors/s3/s3_connector.py @@ -0,0 +1,177 @@ +import asyncio +import functools +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .s3_connector_config import S3ConnectorConfig + +try: + import boto3 + has_connector = True + +except Exception: + boto3 = None + has_connector = False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class S3Connector: + connector_type=ConnectorType.S3 + + def __init__( + self, + config: S3ConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + + self.aws_access_key_id = config.aws_access_key_id + self.aws_secret_access_key = config.aws_secret_access_key + self.region_name = config.region_name + self.bucket_name = config.bucket_name + self.stage = stage + self.parser_config = parser_config + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self.client = None + self._loop = asyncio.get_event_loop() + + self._bucket = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.parser = Parser() + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to AWS S3 - Region: {self.region_name}') + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + self.client = await self._loop.run_in_executor( + self._executor, + functools.partial( + boto3.client, + 's3', + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.region_name + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to AWS S3 - Region: {self.region_name}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + keys_data: Dict[str, List[Dict[str, Any]] ] = await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.list_objects_v2, + Bucket=self.bucket_name + ) + ) + + keys: List[str] = [ + item.get('Key') for item in keys_data.get('Contents') + ] + + return await asyncio.gather(*[ + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.get_object, + Bucket=self.bucket_name, + Key=key + + ) + ) for key in keys + ]) + + async def close(self): + self._executor.shutdown( + wait=False, + cancel_futures=True + ) diff --git a/hyperscale/data/connectors/s3/s3_connector_config.py b/hyperscale/data/connectors/s3/s3_connector_config.py new file mode 100644 index 0000000..5d0ac4d --- /dev/null +++ b/hyperscale/data/connectors/s3/s3_connector_config.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class S3ConnectorConfig(BaseModel): + aws_access_key_id: StrictStr + aws_secret_access_key: StrictStr + region_name: StrictStr + bucket_name: StrictStr + connector_type: ConnectorType=ConnectorType.S3 \ No newline at end of file diff --git a/hyperscale/data/connectors/snowflake/__init__.py b/hyperscale/data/connectors/snowflake/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/snowflake/snowflake_connector.py b/hyperscale/data/connectors/snowflake/snowflake_connector.py new file mode 100644 index 0000000..f697369 --- /dev/null +++ b/hyperscale/data/connectors/snowflake/snowflake_connector.py @@ -0,0 +1,224 @@ +import asyncio +import functools +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List, Union + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .snowflake_connector_config import SnowflakeConnectorConfig + + +def noop_create_engine(): + pass + + +try: + import sqlalchemy + from snowflake.sqlalchemy import URL + from sqlalchemy import create_engine + from sqlalchemy.engine import Connection, Engine + + has_connector = True + +except Exception: + sqlalchemy = object + URL = object + create_engine=noop_create_engine + Engine = object + Connection = object + has_connector = False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class SnowflakeConnector: + connector_type=ConnectorType.Snowflake + + def __init__( + self, + config: SnowflakeConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.username = config.username + self.password = config.password + self.organization_id = config.organization_id + self.account_id = config.account_id + self.private_key = config.private_key + self.warehouse = config.warehouse + self.database = config.database + self.schema = config.database_schema + self.stage = stage + self.parser_config = parser_config + + self.table_name = config.table_name + + self.connect_timeout = config.connect_timeout + + self.metadata = sqlalchemy.MetaData() + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + self._engine: Union[Engine, None] = None + self._connection: Union[Connection, None] = None + + self._table: Union[sqlalchemy.Table, None] = None + + self._loop = asyncio.get_event_loop() + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.parser = Parser() + + async def connect(self): + + try: + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Snowflake instance at - Warehouse: {self.warehouse} - Database: {self.database} - Schema: {self.schema}') + self._engine = await self._loop.run_in_executor( + self._executor, + create_engine, + URL( + user=self.username, + password=self.password, + account=self.account_id, + warehouse=self.warehouse, + database=self.database, + schema=self.schema + ) + + ) + + self._connection = await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + self._engine.connect + ), + timeout=self.connect_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Snowflake instance at - Warehouse: {self.warehouse} - Database: {self.database} - Schema: {self.schema}') + + except asyncio.TimeoutError: + raise Exception('Err. - Connection to Snowflake timed out - check your account id, username, and password.') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + actions: List[Dict[str, Any]] = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + if self._table is None: + + self._table = sqlalchemy.Table( + self.table_name, + self.metadata, + autoload_with=self._engine + ) + + results = await self._loop.run_in_executor( + self._executor, + functools.partial( + self._connection.execute, + self._table.select(**options) + ) + ) + + all_results = await self._loop.run_in_executor( + self._executor, + results.fetchall + ) + + return [ + { + column: value for column, value in result._mapping.items() + } for result in all_results + ] + + async def close(self): + await self._loop.run_in_executor( + self._executor, + self._connection.close + ) + + self._executor.shutdown() \ No newline at end of file diff --git a/hyperscale/data/connectors/snowflake/snowflake_connector_config.py b/hyperscale/data/connectors/snowflake/snowflake_connector_config.py new file mode 100644 index 0000000..fb2ed90 --- /dev/null +++ b/hyperscale/data/connectors/snowflake/snowflake_connector_config.py @@ -0,0 +1,22 @@ +from typing import Optional + +from pydantic import BaseModel, StrictInt, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class SnowflakeConnectorConfig(BaseModel): + username: StrictStr + password: StrictStr + organization_id: StrictStr + account_id: StrictStr + private_key: Optional[StrictStr] + warehouse: StrictStr + database: StrictStr + database_schema: StrictStr='PUBLIC' + table_name: StrictStr + connect_timeout: StrictInt=30 + connector_type: ConnectorType=ConnectorType.Snowflake + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/data/connectors/sqlite/__init__.py b/hyperscale/data/connectors/sqlite/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/sqlite/sqlite_connector.py b/hyperscale/data/connectors/sqlite/sqlite_connector.py new file mode 100644 index 0000000..b1a074a --- /dev/null +++ b/hyperscale/data/connectors/sqlite/sqlite_connector.py @@ -0,0 +1,149 @@ +import asyncio +import uuid +from typing import Any, Coroutine, Dict, List, Union + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .sqlite_connector_config import SQLiteConnectorConfig + + +def noop_create_async_engine(): + pass + + +try: + import sqlalchemy + from sqlalchemy.engine.result import Result as SQLResult + from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncEngine, + create_async_engine, + ) + + has_connector = True + +except Exception: + sqlalchemy = object + SQLResult = None + create_async_engine = noop_create_async_engine + AsyncEngine = object + AsyncConnection = object + has_connector = False + + + +class SQLiteConnector: + connector_type=ConnectorType.SQLite + + def __init__( + self, + config: SQLiteConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self.path = f'sqlite+aiosqlite:///{config.path}' + self.table_name = config.table_name + self.stage = stage + self.parser_config = parser_config + + self.metadata = sqlalchemy.MetaData() + + self.database = None + self._engine: Union[AsyncEngine, None] = None + self._connection: Union[AsyncConnection, None] = None + + self._table: Union[sqlalchemy.Table, None] = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.parser = Parser() + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to SQLite at - {self.path} - Database: {self.database}') + self._engine = create_async_engine(self.path) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to SQLite at - {self.path} - Database: {self.database}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + actions = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + if self._table is None: + self._table = sqlalchemy.Table( + self.table_name, + self.metadata, + autoload_with=self._engine + ) + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + results: SQLResult = await self._connection.execute( + self._table.select(**options) + ) + + return [ + { + column: value for column, value in result._mapping.items() + } for result in results.fetchall() + ] + + async def close(self): + await self._connection.close() \ No newline at end of file diff --git a/hyperscale/data/connectors/sqlite/sqlite_connector_config.py b/hyperscale/data/connectors/sqlite/sqlite_connector_config.py new file mode 100644 index 0000000..5e9420d --- /dev/null +++ b/hyperscale/data/connectors/sqlite/sqlite_connector_config.py @@ -0,0 +1,14 @@ +import os + +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class SQLiteConnectorConfig(BaseModel): + path: StrictStr=f'{os.getcwd()}/results.db' + table_name:StrictStr + reporter_type: ConnectorType=ConnectorType.SQLite + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/data/connectors/xml/__init__.py b/hyperscale/data/connectors/xml/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/connectors/xml/xml_connector.py b/hyperscale/data/connectors/xml/xml_connector.py new file mode 100644 index 0000000..223b75b --- /dev/null +++ b/hyperscale/data/connectors/xml/xml_connector.py @@ -0,0 +1,189 @@ +import asyncio +import collections +import collections.abc +import functools +import os +import pathlib +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Coroutine, Dict, List, TextIO, Union + +import psutil + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.results_set import ResultsSet +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.data.connectors.common.connector_type import ConnectorType +from hyperscale.data.connectors.common.execute_stage_summary_validator import ( + ExecuteStageSummaryValidator, +) +from hyperscale.data.parsers.parser import Parser +from hyperscale.logging import HyperscaleLogger + +from .xml_connector_config import XMLConnectorConfig + +try: + import xmltodict +except Exception: + xmltodict = object + + +collections.Iterable = collections.abc.Iterable + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop, + events_file: TextIO +): + try: + events_file.close() + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class XMLConnector: + connector_type=ConnectorType.XML + + def __init__( + self, + config: XMLConnectorConfig, + stage: str, + parser_config: Config, + ) -> None: + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop: asyncio.AbstractEventLoop = None + self.stage = stage + self.parser_config = parser_config + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.filepath: str = config.filepath + + self.xml_file: Union[TextIO, None] = None + + self.file_mode = config.file_mode + self.parser = Parser() + + async def connect(self): + self._loop = asyncio._get_running_loop() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Setting filepaths') + + if self.filepath[:2] == '~/': + user_directory = pathlib.Path.home() + self.filepath = os.path.join( + user_directory, + self.filepath[2:] + ) + + self.filepath = await self._loop.run_in_executor( + self._executor, + functools.partial( + os.path.abspath, + self.filepath + ) + ) + + if self.xml_file is None: + self.xml_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.filepath, + self.file_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.xml_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening from file - {self.filepath}') + + async def load_execute_stage_summary( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ExecuteStageSummaryValidator]: + execute_stage_summary = await self.load_data( + options=options + ) + + return ExecuteStageSummaryValidator(**execute_stage_summary) + + async def load_actions( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[ActionHook]]: + + actions: List[Dict[str, Any]] = await self.load_data( + options=options + ) + + return await asyncio.gather(*[ + self.parser.parse_action( + action_data, + self.stage, + self.parser_config, + options + ) for action_data in actions + ]) + + async def load_results( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, ResultsSet]: + results = await self.load_data( + options=options + ) + + return ResultsSet({ + 'stage_results': await asyncio.gather(*[ + self.parser.parse_result( + results_data, + self.stage, + self.parser_config, + options + ) for results_data in results + ]) + }) + + async def load_data( + self, + options: Dict[str, Any]={} + ) -> Coroutine[Any, Any, List[Dict[str, Any]]]: + + file_data = await self._loop.run_in_executor( + self._executor, + self.xml_file.read + ) + + return await self._loop.run_in_executor( + self._executor, + functools.partial( + xmltodict.parse, + file_data + ) + ) + + async def close(self): + + await self._loop.run_in_executor( + self._executor, + self.xml_file.close + ) + + self._executor.shutdown(cancel_futures=True) \ No newline at end of file diff --git a/hyperscale/data/connectors/xml/xml_connector_config.py b/hyperscale/data/connectors/xml/xml_connector_config.py new file mode 100644 index 0000000..e33c1ec --- /dev/null +++ b/hyperscale/data/connectors/xml/xml_connector_config.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, StrictStr + +from hyperscale.data.connectors.common.connector_type import ConnectorType + + +class XMLConnectorConfig(BaseModel): + filepath: StrictStr + file_mode: StrictStr='r' + reporter_type: ConnectorType=ConnectorType.XML diff --git a/hyperscale/data/parsers/__init__.py b/hyperscale/data/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser.py b/hyperscale/data/parsers/parser.py new file mode 100644 index 0000000..af6fe90 --- /dev/null +++ b/hyperscale/data/parsers/parser.py @@ -0,0 +1,211 @@ +from typing import Any, Callable, Dict, Union + +from hyperscale.core.engines.client.config import Config + +from .parser_types import ( + GraphQLActionParser, + GraphQLHTTP2ActionParser, + GraphQLHTTP2ResultParser, + GraphQLResultParser, + GRPCActionParser, + GRPCResultParser, + HTTP2ActionParser, + HTTP2ResultParser, + HTTP3ActionParser, + HTTP3ResultParser, + HTTPActionParser, + HTTPResultParser, + PlaywrightActionParser, + PlaywrightResultParser, + UDPActionParser, + UDPResultParser, + WebsocketActionParser, + WebsocketResultParser, +) + + +class Parser: + + def __init__(self) -> None: + + self._action_parsers: Dict[ + str, + Callable[ + [ + Dict[str, Any] + ], + Union[ + GraphQLActionParser, + GraphQLHTTP2ActionParser, + GRPCActionParser, + HTTPActionParser, + HTTP2ActionParser, + HTTP3ActionParser, + PlaywrightActionParser, + UDPActionParser, + WebsocketActionParser + ] + ] + ] = { + 'graphql': lambda config, options: GraphQLActionParser( + config, + options + ), + 'graphqlh2': lambda config, options: GraphQLHTTP2ActionParser( + config, + options + ), + 'grpc': lambda config, options: GRPCActionParser( + config, + options + ), + 'http': lambda config, options: HTTPActionParser( + config, + options + ), + 'http2': lambda config, options: HTTP2ActionParser( + config, + options + ), + 'http3': lambda config, options: HTTP3ActionParser( + config, + options + ), + 'playwright': lambda config, options: PlaywrightActionParser( + config, + options + ), + 'udp': lambda config, options: UDPActionParser( + config, + options + ), + 'websocket': lambda config, options: WebsocketActionParser( + config, + options + ) + } + + self._result_parsers: Dict[ + str, + Callable[ + [ + Dict[str, Any] + ], + Union[ + GraphQLResultParser, + GraphQLHTTP2ResultParser, + GRPCResultParser, + HTTPResultParser, + HTTP2ResultParser, + HTTP3ResultParser, + PlaywrightResultParser, + UDPResultParser, + WebsocketResultParser + ] + ] + ] = { + 'graphql': lambda config, options: GraphQLResultParser( + config, + options + ), + 'graphqlh2': lambda config, options: GraphQLHTTP2ResultParser( + config, + options + ), + 'grpc': lambda config, options: GRPCResultParser( + config, + options + ), + 'http': lambda config, options: HTTPResultParser( + config, + options + ), + 'http2': lambda config, options: HTTP2ResultParser( + config, + options + ), + 'http3': lambda config, options: HTTP3ResultParser( + config, + options + ), + 'playwright': lambda config, options: PlaywrightResultParser( + config, + options + ), + 'udp': lambda config, options: UDPResultParser( + config, + options + ), + 'websocket': lambda config, options: WebsocketResultParser( + config, + options + ) + } + + self._active_action_parsers: Dict[ + str, + Union[ + GraphQLActionParser, + GraphQLHTTP2ActionParser, + GRPCActionParser, + HTTPActionParser, + HTTP2ActionParser, + HTTP3ActionParser, + PlaywrightActionParser, + UDPActionParser, + WebsocketActionParser + ] + ] = {} + + self._active_result_parser: Dict[ + str, + Union[ + GraphQLResultParser, + GraphQLHTTP2ResultParser, + GRPCResultParser, + HTTPResultParser, + HTTP2ResultParser, + HTTP3ResultParser, + PlaywrightResultParser, + UDPResultParser, + WebsocketResultParser + ] + ] = {} + + async def parse_action( + self, + action_data: Dict[str, Any], + stage: str, + config: Config, + options: Dict[str, Any]={} + ): + engine_type = action_data.get('engine') + parser = self._active_action_parsers.get(engine_type) + + if parser is None: + parser = self._action_parsers.get(engine_type)( + config, + options + ) + + return await parser.parse( + action_data, + stage + ) + + async def parse_result( + self, + result_data: Dict[str, Any], + config: Config, + options: Dict[str, Any]={} + ): + + engine_type = result_data.get('engine') + parser = self._active_result_parser.get(engine_type) + if parser is None: + parser = self._result_parsers.get(engine_type)( + config, + options + ) + + return await parser.parse(result_data) \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/__init__.py b/hyperscale/data/parsers/parser_types/__init__.py new file mode 100644 index 0000000..d83774a --- /dev/null +++ b/hyperscale/data/parsers/parser_types/__init__.py @@ -0,0 +1,18 @@ +from .graphql.graphql_action_parser import GraphQLActionParser +from .graphql.graphql_result_parser import GraphQLResultParser +from .graphql_http2.graphql_http2_action_parser import GraphQLHTTP2ActionParser +from .graphql_http2.graphql_http2_result_parser import GraphQLHTTP2ResultParser +from .grpc.grpc_action_parser import GRPCActionParser +from .grpc.grpc_result_parser import GRPCResultParser +from .http.http_action_parser import HTTPActionParser +from .http.http_result_parser import HTTPResultParser +from .http2.http2_action_parser import HTTP2ActionParser +from .http2.http2_result_parser import HTTP2ResultParser +from .http3.http3_action_parser import HTTP3ActionParser +from .http3.http3_result_parser import HTTP3ResultParser +from .playwright.playwright_action_parser import PlaywrightActionParser +from .playwright.playwright_result_parser import PlaywrightResultParser +from .udp.udp_action_parser import UDPActionParser +from .udp.udp_result_parser import UDPResultParser +from .websocket.websocket_action_parser import WebsocketActionParser +from .websocket.websocket_result_parser import WebsocketResultParser diff --git a/hyperscale/data/parsers/parser_types/common/__init__.py b/hyperscale/data/parsers/parser_types/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/common/base_parser.py b/hyperscale/data/parsers/parser_types/common/base_parser.py new file mode 100644 index 0000000..e4b7636 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/common/base_parser.py @@ -0,0 +1,35 @@ +import asyncio +import re +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.hooks.types.action.hook import ActionHook + + +class BaseParser: + + def __init__( + self, + name: str, + config: Config, + parser_type: RequestTypes, + options: Dict[str, Any]={} + ) -> None: + + self._loop: asyncio.AbstractEventLoop = None + self._name_pattern = re.compile('[^0-9a-zA-Z]+') + self.name = name + self.config = config + self.timeouts = Timeouts( + connect_timeout=config.connect_timeout, + total_timeout=config.request_timeout + ) + + self.parser_type = parser_type + self.options = options + + async def parse(self, action_data: Dict[str, Any]) -> Coroutine[Any, Any, ActionHook]: + raise NotImplementedError('Parse method is not implemented for base Parser class.') + diff --git a/hyperscale/data/parsers/parser_types/common/parsing.py b/hyperscale/data/parsers/parser_types/common/parsing.py new file mode 100644 index 0000000..bf3ab38 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/common/parsing.py @@ -0,0 +1,42 @@ +import json +from typing import Dict, Any, Union + + +def normalize_headers(action_data: Dict[str, Any]) -> Dict[str, str]: + + normalized_headers = {} + action_item_headers = action_data.get('headers', {}) + + if isinstance(action_item_headers, (str, bytes, bytearray, )): + action_item_headers = json.loads(action_item_headers) + + for header_name, header in action_item_headers.items(): + normalized_headers[header_name] = header + + return normalized_headers + + +def parse_data( + action_data: Dict[str, Any], + content_type: str +) -> Union[str, Dict[str, Any]]: + + action_item_data = action_data.get('data') + if isinstance(action_item_data, (str, bytes, bytearray, )) and content_type == 'application/json': + action_item_data = json.loads(action_item_data) + + return action_item_data + + +def parse_tags( + action_data: Dict[str, Any] +): + action_item_tags = action_data.get('tags', []) + + if isinstance(action_item_tags, (bytes, bytearray, )): + action_item_tags = action_item_tags.decode() + + if isinstance(action_item_tags, str): + action_item_tags = action_item_tags.split(',') + + return action_item_tags \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/common/result_validator.py b/hyperscale/data/parsers/parser_types/common/result_validator.py new file mode 100644 index 0000000..77d3c15 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/common/result_validator.py @@ -0,0 +1,24 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBytes +) + +from typing import List, Optional + + +class ResultValidator(BaseModel): + error: Optional[StrictStr] + body: Optional[StrictBytes] + status: StrictInt + reason: Optional[StrictStr] + params: Optional[StrictStr] + query: StrictStr + wait_start: StrictFloat + start: StrictFloat + connect_end: StrictFloat + write_end: StrictFloat + complete: StrictFloat + checks: Optional[List[StrictStr]] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/graphql/__init__.py b/hyperscale/data/parsers/parser_types/graphql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/graphql/graphql_action_parser.py b/hyperscale/data/parsers/parser_types/graphql/graphql_action_parser.py new file mode 100644 index 0000000..0cfe784 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/graphql/graphql_action_parser.py @@ -0,0 +1,99 @@ +import json +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql import GraphQLAction, MercuryGraphQLClient +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_tags, +) + +from .graphql_action_validator import GraphQLActionValidator + + +class GraphQLActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + GraphQLActionParser.__name__, + config, + RequestTypes.GRAPHQL, + options + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + graphql_variables_data = action_data.get('variables') + if isinstance(graphql_variables_data, (str, bytes, bytearray,)): + graphql_variables_data = json.loads(graphql_variables_data) + + normalized_headers = normalize_headers(action_data) + tags_data = parse_tags(action_data) + + generator_action = GraphQLActionValidator(**{ + **action_data, + 'headers': normalized_headers, + 'variables': graphql_variables_data, + 'tags': tags_data + }) + + action = GraphQLAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data={ + 'query': generator_action.query, + 'operation_name': generator_action.operation_name, + 'variables': generator_action.variables + }, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryGraphQLClient( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + sourcefile=generator_action.sourcefile + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/graphql/graphql_action_validator.py b/hyperscale/data/parsers/parser_types/graphql/graphql_action_validator.py new file mode 100644 index 0000000..ed2fb78 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/graphql/graphql_action_validator.py @@ -0,0 +1,31 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBool, + AnyHttpUrl +) + + +from typing import List, Dict, Optional, Union + + +class GraphQLActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class GraphQLActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + method: StrictStr='GET' + headers: Dict[StrictStr, StrictStr]={} + query: StrictStr + operation_name: StrictStr + variables: Dict[str, Union[StrictStr, StrictInt, StrictFloat, StrictBool, None]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[GraphQLActionTag]=[] diff --git a/hyperscale/data/parsers/parser_types/graphql/graphql_result_parser.py b/hyperscale/data/parsers/parser_types/graphql/graphql_result_parser.py new file mode 100644 index 0000000..f556f14 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/graphql/graphql_result_parser.py @@ -0,0 +1,107 @@ +import json +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql import GraphQLAction, GraphQLResult +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .graphql_action_validator import GraphQLActionValidator + + +class GraphQLResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + GraphQLResultParser.__name__, + config, + RequestTypes.GRAPHQL, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, GraphQLResult]]: + + graphql_variables_data = result_data.get('variables') + if isinstance(graphql_variables_data, (str, bytes, bytearray,)): + graphql_variables_data = json.loads(graphql_variables_data) + + normalized_headers = normalize_headers(result_data) + tags_data = parse_tags(result_data) + + generator_action = GraphQLActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + method=result_data.get('method'), + headers=normalized_headers, + query=result_data.get('query'), + operation_name=result_data.get('operation_name'), + variables=graphql_variables_data, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tag=tags_data + ) + + action = GraphQLAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data={ + 'query': generator_action.query, + 'operation_name': generator_action.operation_name, + 'variables': generator_action.variables + }, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = GraphQLResult( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.query = result_validator.query + result.status = result_validator.status + result.reason = result_validator.reason + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/graphql_http2/__init__.py b/hyperscale/data/parsers/parser_types/graphql_http2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_parser.py b/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_parser.py new file mode 100644 index 0000000..9418dd2 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_parser.py @@ -0,0 +1,103 @@ +import json +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql_http2 import ( + GraphQLHTTP2Action, + MercuryGraphQLHTTP2Client, +) +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_tags, +) + +from .graphql_http2_action_validator import GraphQLHTTP2ActionValidator + + +class GraphQLHTTP2ActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + GraphQLHTTP2ActionParser.__name__, + config, + RequestTypes.GRAPHQL_HTTP2, + options + + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + normalized_headers = normalize_headers(action_data) + tags_data = parse_tags(action_data) + + graphql_variables_data = action_data.get('variables') + if isinstance(graphql_variables_data, (str, bytes, bytearray,)): + graphql_variables_data = json.loads(graphql_variables_data) + + generator_action = GraphQLHTTP2ActionValidator(**{ + **action_data, + 'headers': normalized_headers, + 'variables': graphql_variables_data, + 'tags': tags_data + }) + + action = GraphQLHTTP2Action( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data={ + 'query': generator_action.query, + 'operation_name': generator_action.operation_name, + 'variables': generator_action.variables + }, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryGraphQLHTTP2Client( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + sourcefile=generator_action.sourcefile, + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_validator.py b/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_validator.py new file mode 100644 index 0000000..4a49f47 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_action_validator.py @@ -0,0 +1,31 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBool, + AnyHttpUrl +) + + +from typing import List, Dict, Optional, Union + + +class GraphQLHTTP2ActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class GraphQLHTTP2ActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + method: StrictStr='GET' + headers: Dict[StrictStr, StrictStr]={} + query: StrictStr + operation_name: StrictStr + variables: Dict[str, Union[StrictStr, StrictInt, StrictFloat, StrictBool, None]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[GraphQLHTTP2ActionTag]=[] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_result_parser.py b/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_result_parser.py new file mode 100644 index 0000000..10f293c --- /dev/null +++ b/hyperscale/data/parsers/parser_types/graphql_http2/graphql_http2_result_parser.py @@ -0,0 +1,110 @@ +import json +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql_http2 import ( + GraphQLHTTP2Action, + GraphQLHTTP2Result, +) +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .graphql_http2_action_validator import GraphQLHTTP2ActionValidator + + +class GraphQLHTTP2ResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + GraphQLHTTP2ResultParser.__name__, + config, + RequestTypes.GRAPHQL_HTTP2, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, GraphQLHTTP2Result]]: + + graphql_variables_data = result_data.get('variables') + if isinstance(graphql_variables_data, (str, bytes, bytearray,)): + graphql_variables_data = json.loads(graphql_variables_data) + + normalized_headers = normalize_headers(result_data) + tags_data = parse_tags(result_data) + + generator_action = GraphQLHTTP2ActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + method=result_data.get('method'), + headers=normalized_headers, + query=result_data.get('query'), + operation_name=result_data.get('operation_name'), + variables=graphql_variables_data, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tag=tags_data + ) + + action = GraphQLHTTP2Action( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data={ + 'query': generator_action.query, + 'operation_name': generator_action.operation_name, + 'variables': generator_action.variables + }, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = GraphQLHTTP2Result( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.query = result_validator.query + result.status = result_validator.status + result.reason = result_validator.reason + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/grpc/__init__.py b/hyperscale/data/parsers/parser_types/grpc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/grpc/grpc_action_parser.py b/hyperscale/data/parsers/parser_types/grpc/grpc_action_parser.py new file mode 100644 index 0000000..3fdf6c6 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/grpc/grpc_action_parser.py @@ -0,0 +1,109 @@ +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.grpc import GRPCAction, MercuryGRPCClient +from hyperscale.core.engines.types.grpc.protobuf_registry import protobuf_registry +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_tags, +) + +from .grpc_action_validator import GRPCActionValidator +from .grpc_options_validator import GRPCOptionsValidator + + +class GRPCActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + GRPCActionParser.__name__, + config, + RequestTypes.GRPC, + options + ) + + self.grpc_options = GRPCOptionsValidator( + protobuf_map=protobuf_registry + ) + + self.protobuf_map = self.grpc_options.protobuf_map + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + action_name = action_data.get('name') + normalized_headers = normalize_headers(action_data) + tags_data = parse_tags(action_data) + + protobuf = self.grpc_options.protobuf_map[action_name].ParseFromString( + action_data.get('data') + ) + + generator_action = GRPCActionValidator(**{ + **action_data, + 'headers': normalized_headers, + 'data': protobuf, + 'tags': tags_data + }) + + action = GRPCAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryGRPCClient( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + order=generator_action.order, + weight=generator_action.weight, + metadata={ + 'user': generator_action.user, + 'tags': generator_action.tags + } + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/grpc/grpc_action_validator.py b/hyperscale/data/parsers/parser_types/grpc/grpc_action_validator.py new file mode 100644 index 0000000..e620499 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/grpc/grpc_action_validator.py @@ -0,0 +1,30 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + AnyHttpUrl, + Json +) + + +from typing import List, Dict, Optional, Union + + +class GRPCActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class GRPCActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + method: StrictStr='GET' + headers: Dict[StrictStr, StrictStr]={} + params: Optional[Dict[StrictStr, Union[StrictInt, StrictStr, StrictFloat]]] + data: Optional[Union[StrictStr, Json]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[GRPCActionTag]=[] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/grpc/grpc_options_validator.py b/hyperscale/data/parsers/parser_types/grpc/grpc_options_validator.py new file mode 100644 index 0000000..f7f143f --- /dev/null +++ b/hyperscale/data/parsers/parser_types/grpc/grpc_options_validator.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, StrictStr +from typing import Dict, Any, Callable + + +class GRPCOptionsValidator(BaseModel): + protobuf_map: Dict[StrictStr, Callable[[Dict[str, Any]], object]] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/grpc/grpc_result_parser.py b/hyperscale/data/parsers/parser_types/grpc/grpc_result_parser.py new file mode 100644 index 0000000..542778c --- /dev/null +++ b/hyperscale/data/parsers/parser_types/grpc/grpc_result_parser.py @@ -0,0 +1,108 @@ +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.grpc import GRPCAction, GRPCResult +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .grpc_action_validator import GRPCActionValidator +from .grpc_options_validator import GRPCOptionsValidator + + +class GRPCResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + GRPCResultParser.__name__, + config, + RequestTypes.GRPC, + options + ) + + self.grpc_options = GRPCOptionsValidator( + protobuf_map=options.get('protobuf_map') + ) + + self.protobuf_map = self.grpc_options.protobuf_map + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, GRPCResult]]: + + action_name = result_data.get('name') + normalized_headers = normalize_headers(result_data) + parsed_data = parse_data(result_data) + tags_data = parse_tags(result_data) + + protobuf = self.protobuf_map[action_name](**parsed_data) + + generator_action = GRPCActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + method=result_data.get('method'), + headers=normalized_headers, + params=result_data.get('params'), + data=protobuf, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tag=tags_data + ) + + action = GRPCAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = GRPCResult( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.query = result_validator.query + result.status = result_validator.status + result.reason = result_validator.reason + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/http/__init__.py b/hyperscale/data/parsers/parser_types/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/http/http_action_parser.py b/hyperscale/data/parsers/parser_types/http/http_action_parser.py new file mode 100644 index 0000000..20e374f --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http/http_action_parser.py @@ -0,0 +1,99 @@ +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http import HTTPAction, MercuryHTTPClient +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) + +from .http_action_validator import HTTPActionValidator + + +class HTTPActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + HTTPActionParser.__name__, + config, + RequestTypes.HTTP, + options + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + normalized_headers = normalize_headers(action_data) + content_type = normalized_headers.get('content-type') + + parsed_data = parse_data( + action_data, + content_type + ) + + tags_data = parse_tags(action_data) + + generator_action = HTTPActionValidator(**{ + **action_data, + 'headers': normalized_headers, + 'data': parsed_data, + 'tags': tags_data + }) + + action = HTTPAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryHTTPClient( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + order=generator_action.order, + weight=generator_action.weight, + metadata={ + 'user': generator_action.user, + 'tags': generator_action.tags + } + ) + + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/http/http_action_validator.py b/hyperscale/data/parsers/parser_types/http/http_action_validator.py new file mode 100644 index 0000000..b832571 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http/http_action_validator.py @@ -0,0 +1,30 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + AnyHttpUrl, + Json +) + + +from typing import List, Dict, Optional, Union + + +class HTTPActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class HTTPActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + method: StrictStr='GET' + headers: Dict[StrictStr, StrictStr]={} + params: Optional[Dict[StrictStr, Union[StrictInt, StrictStr, StrictFloat]]] + data: Optional[Union[StrictStr, Json]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[HTTPActionTag]=[] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/http/http_result_parser.py b/hyperscale/data/parsers/parser_types/http/http_result_parser.py new file mode 100644 index 0000000..0e813aa --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http/http_result_parser.py @@ -0,0 +1,106 @@ +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http import HTTPAction, HTTPResult +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .http_action_validator import HTTPActionValidator + + +class HTTPResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + HTTPResultParser.__name__, + config, + RequestTypes.HTTP, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, HTTPResult]]: + + normalized_headers = normalize_headers(result_data) + content_type = normalized_headers.get('content-type') + + parsed_data = parse_data( + result_data, + content_type + ) + + tags_data = parse_tags(result_data) + + generator_action = HTTPActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + method=result_data.get('method'), + headers=normalized_headers, + params=result_data.get('params'), + data=parsed_data, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tag=tags_data + ) + + + action = HTTPAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = HTTPResult( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.query = result_validator.query + result.status = result_validator.status + result.reason = result_validator.reason + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/http2/__init__.py b/hyperscale/data/parsers/parser_types/http2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/http2/http2_action_parser.py b/hyperscale/data/parsers/parser_types/http2/http2_action_parser.py new file mode 100644 index 0000000..7de44fa --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http2/http2_action_parser.py @@ -0,0 +1,92 @@ +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2 import HTTP2Action, MercuryHTTP2Client +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, +) + +from .http2_action_validator import HTTP2ActionValidator + + +class HTTP2ActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + HTTP2ActionParser.__name__, + config, + RequestTypes.HTTP2, + options + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + normalized_headers = normalize_headers(action_data) + parsed_data = parse_data(action_data) + tags_data = parse_data(action_data) + + generator_action = HTTP2ActionValidator(**{ + **action_data, + 'headers': normalized_headers, + 'data': parsed_data, + 'tags': tags_data + }) + + action = HTTP2Action( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryHTTP2Client( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + sourcefile=generator_action.sourcefile, + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/http2/http2_action_validator.py b/hyperscale/data/parsers/parser_types/http2/http2_action_validator.py new file mode 100644 index 0000000..7d2aa45 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http2/http2_action_validator.py @@ -0,0 +1,28 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + AnyHttpUrl, + Json +) +from typing import List, Dict, Optional, Union + + +class HTTP2ActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class HTTP2ActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + method: StrictStr='GET' + headers: Dict[StrictStr, StrictStr]={} + params: Optional[Dict[StrictStr, Union[StrictInt, StrictStr, StrictFloat]]] + data: Optional[Union[StrictStr, Json]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[HTTP2ActionTag]=[] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/http2/http2_result_parser.py b/hyperscale/data/parsers/parser_types/http2/http2_result_parser.py new file mode 100644 index 0000000..f5fda25 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http2/http2_result_parser.py @@ -0,0 +1,106 @@ +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2 import HTTP2Action, HTTP2Result +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .http2_action_validator import HTTP2ActionValidator + + +class HTTP2ResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + HTTP2ResultParser.__name__, + config, + RequestTypes.HTTP2, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, HTTP2Result]]: + + normalized_headers = normalize_headers(result_data) + content_type = normalized_headers.get('content-type') + + parsed_data = parse_data( + result_data, + content_type + ) + + tags_data = parse_tags(result_data) + + generator_action = HTTP2ActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + method=result_data.get('method'), + headers=normalized_headers, + params=result_data.get('params'), + data=parsed_data, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tag=tags_data + ) + + + action = HTTP2Action( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = HTTP2Result( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.query = result_validator.query + result.status = result_validator.status + result.reason = result_validator.reason + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/http3/__init__.py b/hyperscale/data/parsers/parser_types/http3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/http3/http3_action_parser.py b/hyperscale/data/parsers/parser_types/http3/http3_action_parser.py new file mode 100644 index 0000000..c3fb06e --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http3/http3_action_parser.py @@ -0,0 +1,92 @@ +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http3 import HTTP3Action, MercuryHTTP3Client +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) + +from .http3_action_validator import HTTP3ActionValidator + + +class HTTP3ActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + HTTP3ActionParser.__name__, + config, + RequestTypes.HTTP3, + options + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + normalized_headers = normalize_headers(action_data) + parsed_data = parse_data(action_data) + tags_data = parse_tags(action_data) + + generator_action = HTTP3ActionValidator(**{ + **action_data, + 'headers': normalized_headers, + 'data': parsed_data, + 'tags': tags_data + }) + + action = HTTP3Action( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryHTTP3Client( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + sourcefile=generator_action.sourcefile, + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/http3/http3_action_validator.py b/hyperscale/data/parsers/parser_types/http3/http3_action_validator.py new file mode 100644 index 0000000..491d113 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http3/http3_action_validator.py @@ -0,0 +1,30 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + AnyHttpUrl, + Json +) + + +from typing import List, Dict, Optional, Union + + +class HTTP3ActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class HTTP3ActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + method: StrictStr='GET' + headers: Dict[StrictStr, StrictStr]={} + params: Optional[Dict[StrictStr, Union[StrictInt, StrictStr, StrictFloat]]] + data: Optional[Union[StrictStr, Json]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[HTTP3ActionTag]=[] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/http3/http3_result_parser.py b/hyperscale/data/parsers/parser_types/http3/http3_result_parser.py new file mode 100644 index 0000000..16caae3 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/http3/http3_result_parser.py @@ -0,0 +1,106 @@ +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http3 import HTTP3Action, HTTP3Result +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .http3_action_validator import HTTP3ActionValidator + + +class HTTP3ResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + HTTP3ResultParser.__name__, + config, + RequestTypes.HTTP3, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, HTTP3Action]]: + + normalized_headers = normalize_headers(result_data) + content_type = normalized_headers.get('content-type') + + parsed_data = parse_data( + result_data, + content_type + ) + + tags_data = parse_tags(result_data) + + generator_action = HTTP3ActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + method=result_data.get('method'), + headers=normalized_headers, + params=result_data.get('params'), + data=parsed_data, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tag=tags_data + ) + + + action = HTTP3Action( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = HTTP3Result( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.query = result_validator.query + result.status = result_validator.status + result.reason = result_validator.reason + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/playwright/__init__.py b/hyperscale/data/parsers/parser_types/playwright/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/playwright/playwright_action_parser.py b/hyperscale/data/parsers/parser_types/playwright/playwright_action_parser.py new file mode 100644 index 0000000..d78bb4b --- /dev/null +++ b/hyperscale/data/parsers/parser_types/playwright/playwright_action_parser.py @@ -0,0 +1,166 @@ +import json +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.playwright import ( + URL, + ContextConfig, + Input, + MercuryPlaywrightClient, + Options, + Page, + PlaywrightCommand, +) +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_tags, +) + +from .playwright_action_validator import ( + PlaywrightActionValidator, + PlaywrightInputValidator, + PlaywrightOptionsValidator, + PlaywrightPageValidator, + PlaywrightURLValidator, +) + + +class PlaywrightActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + PlaywrightActionParser.__name__, + config, + RequestTypes.PLAYWRIGHT, + options + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + normalized_headers = normalize_headers(action_data) + tags_data = parse_tags(action_data) + + playwright_input_args = action_data.get('args') + if isinstance(playwright_input_args, (str, bytes, bytearray,)): + playwright_input_args = playwright_input_args.split(',') + + playwright_options_extra = action_data.get('extra') + if isinstance(playwright_options_extra, (str, bytes, bytearray,)): + playwright_options_extra = json.loads(playwright_options_extra) + + generator_action = PlaywrightActionValidator( + name=action_data.get('name'), + command=action_data.get('command'), + page=PlaywrightPageValidator( + selector=action_data.get('selector'), + attribute=action_data.get('attribute'), + x_coordinate=action_data.get('x_coordinate'), + y_coordinate=action_data.get('y_coordinate'), + frame=action_data.get('frame') + ), + url=PlaywrightURLValidator( + location=action_data.get('location'), + headers=normalized_headers + ), + input=PlaywrightInputValidator( + key=action_data.get('key'), + text=action_data.get('text'), + expression=action_data.get('expression'), + args=playwright_input_args, + filepath=action_data.get('filepath'), + file=action_data.get('file'), + path=action_data.get('path'), + option=action_data.get('option'), + by_label=action_data.get('by_label', False), + by_value=action_data.get('by_value', False) + ), + options=PlaywrightOptionsValidator( + event=action_data.get('event'), + option=action_data.get('option'), + is_checked=action_data.get('is_checked', False), + timeout=action_data.get('timeout'), + extra=playwright_options_extra, + switch_by=action_data.get('switch_by') + ), + weight=action_data.get('weight'), + order=action_data.get('order'), + user=action_data.get('user'), + tags=tags_data + ) + + action = PlaywrightCommand( + generator_action.name, + generator_action.command, + url=URL(**generator_action.url.dict( + exclude_none=True + )), + page=Page(**generator_action.page.dict( + exclude_none=True + )), + input=Input(**generator_action.input.dict( + exclude_none=True + )), + options=Options(**generator_action.options.dict( + exclude_none=True + )), + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryPlaywrightClient( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + group_size=self.config.group_size + ) + + await session.setup( + config=ContextConfig( + browser_type=self.config.browser_type, + device_type=self.config.device_type, + locale=self.config.locale, + geolocation=self.config.geolocation, + permissions=self.config.permissions, + color_scheme=self.config.color_scheme, + options=self.config.playwright_options + ) + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + sourcefile=generator_action.sourcefile, + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/playwright/playwright_action_validator.py b/hyperscale/data/parsers/parser_types/playwright/playwright_action_validator.py new file mode 100644 index 0000000..4967c9f --- /dev/null +++ b/hyperscale/data/parsers/parser_types/playwright/playwright_action_validator.py @@ -0,0 +1,64 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBool, + AnyHttpUrl +) +from typing import List, Dict, Optional, Union, Any + + +class PlaywrightOptionsValidator(BaseModel): + event: Optional[StrictStr] + option: Any + is_checked: Optional[StrictBool] + timeout: Optional[Union[StrictInt, StrictFloat]] + extra: Optional[Dict[str, Any]] + switch_by: Optional[StrictStr] + + +class PlaywrightInputValidator(BaseModel): + key: Optional[StrictStr] + text: Optional[StrictStr] + expression: Optional[StrictStr] + args: Optional[List[Any]] + filepath: Optional[StrictStr] + file: Optional[StrictStr] + path: Optional[StrictStr] + option: Optional[Union[StrictStr, StrictBool, StrictInt, StrictFloat]] + by_label: StrictBool=False + by_value: StrictBool=False + + +class PlaywrightURLValidator(BaseModel): + location: Optional[AnyHttpUrl] + headers: Dict[str, str] + + +class PlaywrightPageValidator(BaseModel): + selector: Optional[StrictStr] + attribute: Optional[StrictStr] + x_coordinate: Optional[Union[StrictInt, StrictFloat]] + y_coordinate: Optional[Union[StrictInt, StrictFloat]] + frame: Optional[StrictInt] + + +class PlaywrightActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class PlaywrightActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + command: StrictStr + page: Optional[PlaywrightPageValidator] + url: Optional[PlaywrightURLValidator] + input: Optional[PlaywrightInputValidator] + options: Optional[PlaywrightOptionsValidator] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[PlaywrightActionTag]=[] + diff --git a/hyperscale/data/parsers/parser_types/playwright/playwright_result_parser.py b/hyperscale/data/parsers/parser_types/playwright/playwright_result_parser.py new file mode 100644 index 0000000..a72d61d --- /dev/null +++ b/hyperscale/data/parsers/parser_types/playwright/playwright_result_parser.py @@ -0,0 +1,155 @@ +import json +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.playwright import ( + URL, + Input, + Options, + Page, + PlaywrightCommand, + PlaywrightResult, +) +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .playwright_action_validator import ( + PlaywrightActionValidator, + PlaywrightInputValidator, + PlaywrightOptionsValidator, + PlaywrightPageValidator, + PlaywrightURLValidator, +) + + +class PlaywrightResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + PlaywrightResultParser.__name__, + config, + RequestTypes.UDP, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, PlaywrightResult]]: + + normalized_headers = normalize_headers(result_data) + tags_data = parse_tags(result_data) + + playwright_input_args = result_data.get('args') + if isinstance(playwright_input_args, (str, bytes, bytearray,)): + playwright_input_args = playwright_input_args.split(',') + + playwright_options_extra = result_data.get('extra') + if isinstance(playwright_options_extra, (str, bytes, bytearray,)): + playwright_options_extra = json.loads(playwright_options_extra) + + generator_action = PlaywrightActionValidator( + name=result_data.get('name'), + command=result_data.get('command'), + page=PlaywrightPageValidator( + selector=result_data.get('selector'), + attribute=result_data.get('attribute'), + x_coordinate=result_data.get('x_coordinate'), + y_coordinate=result_data.get('y_coordinate'), + frame=result_data.get('frame') + ), + url=PlaywrightURLValidator( + location=result_data.get('location'), + headers=normalized_headers + ), + input=PlaywrightInputValidator( + key=result_data.get('key'), + text=result_data.get('text'), + expression=result_data.get('expression'), + args=playwright_input_args, + filepath=result_data.get('filepath'), + file=result_data.get('file'), + path=result_data.get('path'), + option=result_data.get('option'), + by_label=result_data.get('by_label', False), + by_value=result_data.get('by_value', False) + ), + options=PlaywrightOptionsValidator( + event=result_data.get('event'), + option=result_data.get('option'), + is_checked=result_data.get('is_checked', False), + timeout=result_data.get('timeout'), + extra=playwright_options_extra, + switch_by=result_data.get('switch_by') + ), + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tags=tags_data + ) + + action = PlaywrightCommand( + generator_action.name, + generator_action.command, + url=URL(**generator_action.url.dict( + exclude_none=True + )), + page=Page(**generator_action.page.dict( + exclude_none=True + )), + input=Input(**generator_action.input.dict( + exclude_none=True + )), + options=Options(**generator_action.options.dict( + exclude_none=True + )), + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + body = result_data.get('body') + if isinstance(body, str): + body = body.encode() + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = PlaywrightResult( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.checks = result_validator.checks + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + + result.type= RequestTypes.PLAYWRIGHT + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/udp/__init__.py b/hyperscale/data/parsers/parser_types/udp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/udp/udp_action_parser.py b/hyperscale/data/parsers/parser_types/udp/udp_action_parser.py new file mode 100644 index 0000000..2611d6e --- /dev/null +++ b/hyperscale/data/parsers/parser_types/udp/udp_action_parser.py @@ -0,0 +1,86 @@ +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.udp import MercuryUDPClient, UDPAction +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import parse_data, parse_tags + +from .udp_action_validator import UDPActionValidator + + +class UDPActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + UDPActionParser.__name__, + config, + RequestTypes.UDP, + options + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + parsed_data = parse_data(action_data) + tags_data = parse_tags(action_data) + + generator_action = UDPActionValidator(**{ + **action_data, + 'data': parsed_data, + 'tags': tags_data + }) + + action = UDPAction( + generator_action.name, + generator_action.url, + wait_for_response=generator_action.wait_for_response, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryUDPClient( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None, + sourcefile=generator_action.sourcefile + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/udp/udp_action_validator.py b/hyperscale/data/parsers/parser_types/udp/udp_action_validator.py new file mode 100644 index 0000000..f7b661c --- /dev/null +++ b/hyperscale/data/parsers/parser_types/udp/udp_action_validator.py @@ -0,0 +1,29 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBool, + AnyHttpUrl, + Json +) + + +from typing import List, Optional, Union + + +class UDPActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class UDPActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + wait_for_response: StrictBool=False + data: Optional[Union[StrictStr, Json]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[UDPActionTag]=[] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/udp/udp_result_parser.py b/hyperscale/data/parsers/parser_types/udp/udp_result_parser.py new file mode 100644 index 0000000..af4031d --- /dev/null +++ b/hyperscale/data/parsers/parser_types/udp/udp_result_parser.py @@ -0,0 +1,94 @@ +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.udp import UDPAction, UDPResult +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import parse_data, parse_tags +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .udp_action_validator import UDPActionValidator + + +class UDPResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + UDPResultParser.__name__, + config, + RequestTypes.UDP, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, UDPResult]]: + + parsed_data = parse_data(result_data) + tags_data = parse_tags(result_data) + + generator_action = UDPActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + wait_for_response=result_data.get('wait_for_response'), + data=parsed_data, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tags=tags_data + ) + + action = UDPAction( + generator_action.name, + generator_action.url, + wait_for_response=generator_action.wait_for_response, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + body = result_data.get('body') + if isinstance(body, str): + body = body.encode() + + result_validator = ResultValidator( + error=result_data.get('error'), + body=body, + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = UDPResult( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.body = result_validator.body + result.status = result_validator.status + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/parsers/parser_types/websocket/__init__.py b/hyperscale/data/parsers/parser_types/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/parsers/parser_types/websocket/websocket_action_parser.py b/hyperscale/data/parsers/parser_types/websocket/websocket_action_parser.py new file mode 100644 index 0000000..1a10814 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/websocket/websocket_action_parser.py @@ -0,0 +1,95 @@ +import uuid +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.websocket import ( + MercuryWebsocketClient, + WebsocketAction, +) +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.simple_context import SimpleContext +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) + +from .websocket_action_validator import WebsocketActionValidator + + +class WebsocketActionParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + WebsocketActionParser.__name__, + config, + RequestTypes.WEBSOCKET, + options + ) + + async def parse( + self, + action_data: Dict[str, Any], + stage: str + ) -> Coroutine[Any, Any, Coroutine[Any, Any, ActionHook]]: + + normalized_headers = normalize_headers(action_data) + parsed_data = parse_data(action_data) + tags_data = parse_tags(action_data) + + generator_action = WebsocketActionValidator(**{ + **action_data, + 'headers': normalized_headers, + 'data': parsed_data, + 'tags': tags_data + }) + + action = WebsocketAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + session = MercuryWebsocketClient( + concurrency=self.config.batch_size, + timeouts=self.timeouts, + reset_connections=self.config.reset_connections, + tracing_session=self.config.tracing + ) + + await session.prepare(action) + + hook = ActionHook( + f'{stage}.{generator_action.name}', + generator_action.name, + None + ) + + hook.session = session + hook.action = action + hook.stage = stage + hook.context = SimpleContext() + hook.hook_id = uuid.uuid4() + + hook.metadata.order = generator_action.order + hook.metadata.weight = generator_action.weight + hook.metadata.tags = generator_action.tags + hook.metadata.user = generator_action.user + + + return hook + + + diff --git a/hyperscale/data/parsers/parser_types/websocket/websocket_action_validator.py b/hyperscale/data/parsers/parser_types/websocket/websocket_action_validator.py new file mode 100644 index 0000000..82c282c --- /dev/null +++ b/hyperscale/data/parsers/parser_types/websocket/websocket_action_validator.py @@ -0,0 +1,30 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + AnyHttpUrl, + Json +) + + +from typing import List, Dict, Optional, Union + + +class WebsocketActionTag(BaseModel): + name: StrictStr + value: StrictStr + + +class WebsocketActionValidator(BaseModel): + engine: StrictStr + name: StrictStr + url: AnyHttpUrl + method: StrictStr='GET' + headers: Dict[StrictStr, StrictStr]={} + params: Optional[Dict[StrictStr, Union[StrictInt, StrictStr, StrictFloat]]] + data: Optional[Union[StrictStr, Json]] + weight: Optional[Union[StrictInt, StrictFloat]] + order: Optional[StrictInt] + user: Optional[StrictStr] + tags: List[WebsocketActionTag]=[] \ No newline at end of file diff --git a/hyperscale/data/parsers/parser_types/websocket/websocket_result_parser.py b/hyperscale/data/parsers/parser_types/websocket/websocket_result_parser.py new file mode 100644 index 0000000..a7f3312 --- /dev/null +++ b/hyperscale/data/parsers/parser_types/websocket/websocket_result_parser.py @@ -0,0 +1,106 @@ +from typing import Any, Coroutine, Dict + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.websocket import WebsocketAction, WebsocketResult +from hyperscale.data.parsers.parser_types.common.base_parser import BaseParser +from hyperscale.data.parsers.parser_types.common.parsing import ( + normalize_headers, + parse_data, + parse_tags, +) +from hyperscale.data.parsers.parser_types.common.result_validator import ResultValidator + +from .websocket_action_validator import WebsocketActionValidator + + +class WebsocketResultParser(BaseParser): + + def __init__( + self, + config: Config, + options: Dict[str, Any]={} + ) -> None: + super().__init__( + WebsocketResultParser.__name__, + config, + RequestTypes.GRPC, + options + ) + + async def parse( + self, + result_data: Dict[str, Any] + ) -> Coroutine[Any, Any, Coroutine[Any, Any, WebsocketAction]]: + + normalized_headers = normalize_headers(result_data) + content_type = normalized_headers.get('content-type') + + parsed_data = parse_data( + result_data, + content_type + ) + + tags_data = parse_tags(result_data) + + generator_action = WebsocketActionValidator( + engine=result_data.get('engine'), + name=result_data.get('name'), + url=result_data.get('url'), + method=result_data.get('method'), + headers=normalized_headers, + params=result_data.get('params'), + data=parsed_data, + weight=result_data.get('weight'), + order=result_data.get('order'), + user=result_data.get('user'), + tag=tags_data + ) + + + action = WebsocketAction( + generator_action.name, + generator_action.url, + method=generator_action.method, + headers=generator_action.headers, + data=generator_action.data, + user=generator_action.user, + tags=[ + tag.dict() for tag in generator_action.tags + ] + ) + + + result_validator = ResultValidator( + error=result_data.get('error'), + status=result_data.get('status'), + reason=result_data.get('reason'), + params=result_data.get('params'), + wait_start=result_data.get('wait_start'), + start=result_data.get('start'), + connect_end=result_data.get('connect_end'), + write_end=result_data.get('write_end'), + complete=result_data.get('complete'), + checks=result_data.get('checks') + ) + + result = WebsocketResult( + action, + error=Exception(result_validator.error) if result_validator.error else None + ) + + result.query = result_validator.query + result.status = result_validator.status + result.reason = result_validator.reason + result.params = result_validator.params + result.wait_start = result_validator.wait_start + result.start = result_validator.start + result.connect_end = result_validator.connect_end + result.write_end = result_validator.write_end + result.complete = result_validator.complete + result.checks = result_validator.checks + + return result + + + diff --git a/hyperscale/data/serializers/__init__.py b/hyperscale/data/serializers/__init__.py new file mode 100644 index 0000000..0bfb262 --- /dev/null +++ b/hyperscale/data/serializers/__init__.py @@ -0,0 +1 @@ +from .serializer import Serializer \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer.py b/hyperscale/data/serializers/serializer.py new file mode 100644 index 0000000..44aeae4 --- /dev/null +++ b/hyperscale/data/serializers/serializer.py @@ -0,0 +1,229 @@ +from typing import Any, Callable, Dict, Union + +import dill + +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql.result import GraphQLResult +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.graphql_http2.result import GraphQLHTTP2Result +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.grpc.result import GRPCResult +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http.result import HTTPResult +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http2.result import HTTP2Result +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.engines.types.http3.result import HTTP3Result +from hyperscale.core.engines.types.playwright.command import PlaywrightCommand +from hyperscale.core.engines.types.playwright.result import PlaywrightResult +from hyperscale.core.engines.types.task.result import TaskResult +from hyperscale.core.engines.types.task.task import Task +from hyperscale.core.engines.types.udp.action import UDPAction +from hyperscale.core.engines.types.udp.result import UDPResult +from hyperscale.core.engines.types.websocket.action import WebsocketAction +from hyperscale.core.engines.types.websocket.result import WebsocketResult +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.task.hook import TaskHook + +from .serializer_types import ( + GraphQLHTTP2Serializer, + GraphQLSerializer, + GRPCSerializer, + HTTP2Serializer, + HTTP3Serializer, + HTTPSerializer, + PlaywrightSerializer, + TaskSerializer, + UDPSerializer, + WebsocketSerializer, +) + +Action = Union[ + GraphQLAction, + GraphQLHTTP2Action, + GRPCAction, + HTTPAction, + HTTP2Action, + HTTP3Action, + PlaywrightCommand, + Task, + UDPAction, + WebsocketAction +] + + +Result = Union[ + GraphQLResult, + GraphQLHTTP2Result, + GRPCResult, + HTTPResult, + HTTP2Result, + HTTP3Result, + PlaywrightResult, + TaskResult, + UDPResult, + WebsocketResult +] + + +class Serializer: + + def __init__(self) -> None: + self._serializers: Dict[ + str, + Callable[ + ..., + Union[ + GraphQLSerializer, + GraphQLHTTP2Serializer, + GRPCSerializer, + HTTPSerializer, + HTTP2Serializer, + HTTP3Serializer, + PlaywrightSerializer, + TaskSerializer, + UDPSerializer, + WebsocketSerializer + ] + ] + ] = { + RequestTypes.GRAPHQL: lambda: GraphQLSerializer(), + RequestTypes.GRAPHQL_HTTP2: lambda: GraphQLHTTP2Serializer(), + RequestTypes.GRPC: lambda: GRPCSerializer(), + RequestTypes.HTTP: lambda: HTTPSerializer(), + RequestTypes.HTTP2: lambda: HTTP2Serializer(), + RequestTypes.HTTP3: lambda: HTTP3Serializer(), + RequestTypes.PLAYWRIGHT: lambda: PlaywrightSerializer(), + RequestTypes.TASK: lambda: TaskSerializer(), + RequestTypes.UDP: lambda: UDPSerializer(), + RequestTypes.WEBSOCKET: lambda: WebsocketSerializer() + } + + self._active_serializers: Dict[ + str, + Union[ + GraphQLSerializer, + GraphQLHTTP2Serializer, + GRPCSerializer, + HTTPSerializer, + HTTP2Serializer, + HTTP3Serializer, + PlaywrightSerializer, + TaskSerializer, + UDPSerializer, + WebsocketSerializer + ] + ] = {} + + def serialize_action( + self, + hook: Union[ActionHook, TaskHook] + ): + action: Action = hook.action + serializer = self._active_serializers.get(action.type) + + if serializer is None and action.type in self._serializers: + serializer = self._serializers.get(action.type)() + self._active_serializers[action.type] = serializer + + serializable_action = serializer.action_to_serializable(action) + serializable_hook = hook.to_dict() + serializable_client_config = hook.session.config_to_dict() + + return dill.dumps({ + 'hook': serializable_hook, + 'action': serializable_action, + 'client_config': serializable_client_config + }) + + def deserialize_action( + self, + serialized_hook: Union[str, bytes] + ): + deserialized_hook: Dict[str, Any] = dill.loads(serialized_hook) + + deserialized_hook_config = deserialized_hook.get('hook', {}) + deserialized_action: Dict[str, Any] = deserialized_hook.get('action', {}) + deserialized_client_config = deserialized_hook.get('client_config', {}) + + action_type = deserialized_action.get('type', RequestTypes.HTTP) + + serializer = self._active_serializers.get(action_type) + + if serializer is None and action_type in self._serializers: + serializer = self._serializers.get(action_type)() + self._active_serializers[action_type] = serializer + + if action_type == RequestTypes.TASK: + + action_hook = TaskHook( + deserialized_hook_config.get('name'), + deserialized_hook_config.get('shortname'), + None, + *deserialized_hook_config.get('names', []), + weight=deserialized_hook_config.get('weight'), + order=deserialized_hook_config.get('order'), + skip=deserialized_hook_config.get('skip'), + metadata={ + 'user': deserialized_hook_config.get('user'), + 'tags': deserialized_hook_config.get('tags') + } + ) + + action = serializer.deserialize_task(deserialized_action) + + else: + + action_hook = ActionHook( + deserialized_hook_config.get('name'), + deserialized_hook_config.get('shortname'), + None, + *deserialized_hook_config.get('names', []), + weight=deserialized_hook_config.get('weight'), + order=deserialized_hook_config.get('order'), + skip=deserialized_hook_config.get('skip'), + metadata={ + 'user': deserialized_hook_config.get('user'), + 'tags': deserialized_hook_config.get('tags') + } + ) + + action = serializer.deserialize_action(deserialized_action) + + action_hook.action = action + + session = serializer.deserialize_client_config(deserialized_client_config) + action_hook.session = session + + return action_hook + + def serialize_result( + self, + result: Result + ): + serializer = self._active_serializers.get(result.type) + + if serializer is None and result.type in self._serializers: + serializer = self._serializers.get(result.type)() + self._active_serializers[result.type] = serializer + + serializable = serializer.result_to_serializable(result) + + return dill.dumps(serializable) + + def deserialize_result( + self, + serialized_result: Union[str, bytes] + ) -> Result: + deserialized_result: Dict[str, Any] = dill.loads(serialized_result) + + result_type = deserialized_result.get('type', RequestTypes.HTTP) + + serializer = self._active_serializers.get(result_type) + + if serializer is None and result_type in self._serializers: + serializer = self._serializers.get(result_type)() + self._active_serializers[result_type] = serializer + + return serializer.deserialize_result(deserialized_result) \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/__init__.py b/hyperscale/data/serializers/serializer_types/__init__.py new file mode 100644 index 0000000..8c81ffd --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/__init__.py @@ -0,0 +1,10 @@ +from .graphql.graphql_serializer import GraphQLSerializer +from .graphql_http2.graphql_http2_serializer import GraphQLHTTP2Serializer +from .grpc.grpc_serializer import GRPCSerializer +from .http.http_serializer import HTTPSerializer +from .http2.http2_serializer import HTTP2Serializer +from .http3.http3_serializer import HTTP3Serializer +from .playwright.playwright_serializer import PlaywrightSerializer +from .task.task_serializer import TaskSerializer +from .udp.udp_serializer import UDPSerializer +from .websocket.websocket_serializer import WebsocketSerializer \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/common/__init__.py b/hyperscale/data/serializers/serializer_types/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/common/base_serializer.py b/hyperscale/data/serializers/serializer_types/common/base_serializer.py new file mode 100644 index 0000000..e3038f3 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/common/base_serializer.py @@ -0,0 +1,44 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.base_action import BaseAction +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.data.serializers.serializer_types.common.metadata_serializer import ( + MetadataSerializer, +) + + +class BaseSerializer: + + def __init__(self) -> None: + self.metadata_serializer = MetadataSerializer() + + def action_to_serializable( + self, + action: BaseAction + ) -> Dict[str, Union[str, List[str]]]: + return { + 'action_id': action.action_id, + 'name': action.name, + 'metadata': self.metadata_serializer.serialize_metadata( + action.metadata + ), + } + + def result_to_serializable( + self, + result: BaseResult + ) -> Dict[str, Any]: + return { + 'name': result.name, + 'error': str(result.error), + 'source': result.source, + 'user': result.user, + 'tags': result.tags, + 'type': result.type, + 'wait_start': float(result.wait_start), + 'start': float(result.start), + 'connect_end': float(result.connect_end), + 'write_end': float(result.write_end), + 'complete': float(result.complete), + 'checks': result.checks + } \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/common/metadata_serializer.py b/hyperscale/data/serializers/serializer_types/common/metadata_serializer.py new file mode 100644 index 0000000..2ee2cc6 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/common/metadata_serializer.py @@ -0,0 +1,19 @@ +from typing import Dict, List, Union + +from hyperscale.core.engines.types.common.metadata import Metadata + + +class MetadataSerializer: + + def __init__(self) -> None: + self.user: Union[str, None] = None + self.tags: List[Dict[str, str]] = [] + + def serialize_metadata( + self, + metadata: Metadata + ) -> Dict[str, Union[str, List[str]]]: + return { + 'user': metadata.user, + 'tags': metadata.tags + } \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/graphql/__init__.py b/hyperscale/data/serializers/serializer_types/graphql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/graphql/graphql_serializer.py b/hyperscale/data/serializers/serializer_types/graphql/graphql_serializer.py new file mode 100644 index 0000000..9dd08da --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/graphql/graphql_serializer.py @@ -0,0 +1,144 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql.action import GraphQLAction +from hyperscale.core.engines.types.graphql.client import MercuryGraphQLClient +from hyperscale.core.engines.types.graphql.result import GraphQLResult +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class GraphQLSerializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: GraphQLAction + ) -> Dict[str, Union[str, List[str]]]: + serialized_action = super().action_to_serializable(action) + + return { + **serialized_action, + 'type': RequestTypes.GRAPHQL, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'method': action.method, + 'headers': action._headers, + 'data': action.data, + 'is_stream': action.is_stream, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> GraphQLAction: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + graphql_action = GraphQLAction( + name=action.get('name'), + url=url_config.get('full'), + method=action.get('method'), + headers=action.get('headers'), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []), + redirects=action.get('redirects', 3) + ) + + graphql_action.url.ip_addr = url_config.get('ip_addr') + graphql_action.url.socket_config = url_config.get('socket_config') + graphql_action.url.has_ip_addr = url_config.get('has_ip_addr') + + graphql_action.setup() + + return graphql_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryGraphQLClient: + return MercuryGraphQLClient( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: GraphQLResult + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = { + str(k.decode()): str(v.decode()) for k, v in result.headers.items() + } + + body: Union[str, None] = None + if result.body: + body = str(result.body.decode()) + + return { + **serialized_result, + 'url': result.url, + 'method': result.method, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'headers': encoded_headers, + 'body': body, + 'tags': result.tags, + 'user': result.user, + 'error': str(result.error), + 'status': result.status, + 'reason': result.reason, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> GraphQLResult: + deserialized_result = GraphQLResult( + GraphQLAction( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + body = result.get('body') + if isinstance(body, str): + body = body.encode() + + deserialized_result.body = body + deserialized_result.status = result.get('status') + deserialized_result.reason = result.get('reason') + deserialized_result.params = result.get('params') + deserialized_result.query = result.get('query') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + deserialized_result.checks = result.get('checks') + + deserialized_result.type = RequestTypes.GRAPHQL + + return deserialized_result diff --git a/hyperscale/data/serializers/serializer_types/graphql_http2/__init__.py b/hyperscale/data/serializers/serializer_types/graphql_http2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/graphql_http2/graphql_http2_serializer.py b/hyperscale/data/serializers/serializer_types/graphql_http2/graphql_http2_serializer.py new file mode 100644 index 0000000..c49c3c1 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/graphql_http2/graphql_http2_serializer.py @@ -0,0 +1,143 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.graphql_http2.action import GraphQLHTTP2Action +from hyperscale.core.engines.types.graphql_http2.client import MercuryGraphQLHTTP2Client +from hyperscale.core.engines.types.graphql_http2.result import GraphQLHTTP2Result +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class GraphQLHTTP2Serializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: GraphQLHTTP2Action + ) -> Dict[str, Union[str, List[str]]]: + serialized_action = super().action_to_serializable(action) + + return { + **serialized_action, + 'type': RequestTypes.GRAPHQL_HTTP2, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'method': action.method, + 'headers': action._headers, + 'data': action.data, + 'is_stream': action.is_stream, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> GraphQLHTTP2Action: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + graphql_http2_action = GraphQLHTTP2Action( + name=action.get('name'), + url=url_config.get('full'), + method=action.get('method'), + headers=action.get('headers'), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []) + ) + + graphql_http2_action.url.ip_addr = url_config.get('ip_addr') + graphql_http2_action.url.socket_config = url_config.get('socket_config') + graphql_http2_action.url.has_ip_addr = url_config.get('has_ip_addr') + + graphql_http2_action.setup() + + return graphql_http2_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryGraphQLHTTP2Client: + return MercuryGraphQLHTTP2Client( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: GraphQLHTTP2Result + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = { + str(k.decode()): str(v.decode()) for k, v in result.headers.items() + } + + body: Union[str, None] = None + if result.body: + body = str(result.body.decode()) + + return { + **serialized_result, + 'url': result.url, + 'method': result.method, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'headers': encoded_headers, + 'body': body, + 'tags': result.tags, + 'user': result.user, + 'error': str(result.error), + 'status': result.status, + 'reason': result.reason, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> GraphQLHTTP2Result: + deserialized_result = GraphQLHTTP2Result( + GraphQLHTTP2Action( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + body = result.get('body') + if isinstance(body, str): + body = body.encode() + + deserialized_result.body = body + deserialized_result.status = result.get('status') + deserialized_result.reason = result.get('reason') + deserialized_result.params = result.get('params') + deserialized_result.query = result.get('query') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + deserialized_result.checks = result.get('checks') + + deserialized_result.type = RequestTypes.GRAPHQL_HTTP2 + + return deserialized_result \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/grpc/__init__.py b/hyperscale/data/serializers/serializer_types/grpc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/grpc/grpc_serializer.py b/hyperscale/data/serializers/serializer_types/grpc/grpc_serializer.py new file mode 100644 index 0000000..c260cd8 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/grpc/grpc_serializer.py @@ -0,0 +1,138 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.grpc.action import GRPCAction +from hyperscale.core.engines.types.grpc.client import MercuryGRPCClient +from hyperscale.core.engines.types.grpc.result import GRPCResult +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class GRPCSerializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: GRPCAction + ) -> Dict[str, Union[str, List[str]]]: + serialized_action = super().action_to_serializable(action) + + return { + **serialized_action, + 'type': RequestTypes.GRPC, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'method': action.method, + 'headers': action._headers, + 'data': action.data, + 'is_stream': action.is_stream, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> GRPCAction: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + grpc_action = GRPCAction( + name=action.get('name'), + url=url_config.get('full'), + method=action.get('method'), + headers=action.get('headers'), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []) + ) + + grpc_action.url.ip_addr = url_config.get('ip_addr') + grpc_action.url.socket_config = url_config.get('socket_config') + grpc_action.url.has_ip_addr = url_config.get('has_ip_addr') + + grpc_action.setup() + + return grpc_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryGRPCClient: + return MercuryGRPCClient( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: GRPCResult + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = { + str(k.decode()): str(v.decode()) for k, v in result.headers.items() + } + + data = result.data + if isinstance(data, bytes) or isinstance(data, bytearray): + data = str(data.decode()) + + return { + **serialized_result, + 'url': result.url, + 'method': result.method, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'headers': encoded_headers, + 'data': data, + 'tags': result.tags, + 'user': result.user, + 'error': str(result.error), + 'status': result.status, + 'reason': result.reason, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> GRPCResult: + deserialized_result = GRPCResult( + GRPCAction( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + deserialized_result.status = result.get('status') + deserialized_result.reason = result.get('reason') + deserialized_result.params = result.get('params') + deserialized_result.query = result.get('query') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + deserialized_result.checks = result.get('checks') + + deserialized_result.type = RequestTypes.HTTP2 + + return deserialized_result \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/http/__init__.py b/hyperscale/data/serializers/serializer_types/http/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/http/http_serializer.py b/hyperscale/data/serializers/serializer_types/http/http_serializer.py new file mode 100644 index 0000000..27e41af --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/http/http_serializer.py @@ -0,0 +1,147 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http.action import HTTPAction +from hyperscale.core.engines.types.http.client import MercuryHTTPClient +from hyperscale.core.engines.types.http.result import HTTPResult +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class HTTPSerializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: HTTPAction + ) -> Dict[str, Union[str, List[str]]]: + + serialized_action = super().action_to_serializable(action) + return { + **serialized_action, + 'type': RequestTypes.HTTP, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'method': action.method, + 'headers': action._headers, + 'header_items': action._header_items, + 'encoded_headers': action.encoded_headers, + 'data': action.data, + 'encoded_data': action.encoded_data, + 'is_stream': action.is_stream, + 'redirects': action.redirects, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> HTTPAction: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + http_action = HTTPAction( + name=action.get('name'), + url=url_config.get('full'), + headers=action.get('headers'), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []), + redirects=action.get('redirects', 3) + ) + + http_action.url.ip_addr = url_config.get('ip_addr') + http_action.url.socket_config = url_config.get('socket_config') + http_action.url.has_ip_addr = url_config.get('has_ip_addr') + + http_action.setup() + + return http_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryHTTPClient: + return MercuryHTTPClient( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: HTTPResult + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = { + str(k.decode()): str(v.decode()) for k, v in result.headers.items() + } + + body: Union[str, None] = None + if result.body: + body = str(result.body.decode()) + + return { + **serialized_result, + 'url': result.url, + 'method': result.method, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'headers': encoded_headers, + 'body': body, + 'tags': result.tags, + 'user': result.user, + 'error': str(result.error), + 'status': result.status, + 'reason': result.reason, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> HTTPResult: + deserialized_result = HTTPResult( + HTTPAction( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + body = result.get('body') + if isinstance(body, str): + body = body.encode() + + deserialized_result.body = body + deserialized_result.status = result.get('status') + deserialized_result.reason = result.get('reason') + deserialized_result.params = result.get('params') + deserialized_result.query = result.get('query') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + deserialized_result.checks = result.get('checks') + + deserialized_result.type = RequestTypes.HTTP + + return deserialized_result diff --git a/hyperscale/data/serializers/serializer_types/http2/__init__.py b/hyperscale/data/serializers/serializer_types/http2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/http2/http2_serializer.py b/hyperscale/data/serializers/serializer_types/http2/http2_serializer.py new file mode 100644 index 0000000..fbccde6 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/http2/http2_serializer.py @@ -0,0 +1,138 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http2.action import HTTP2Action +from hyperscale.core.engines.types.http2.client import MercuryHTTP2Client +from hyperscale.core.engines.types.http2.result import HTTP2Result +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class HTTP2Serializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: HTTP2Action + ) -> Dict[str, Union[str, List[str]]]: + serialized_action = super().action_to_serializable(action) + + return { + **serialized_action, + 'type': RequestTypes.HTTP2, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'method': action.method, + 'headers': action._headers, + 'data': action.data, + 'is_stream': action.is_stream, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> HTTP2Action: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + http2_action = HTTP2Action( + name=action.get('name'), + url=url_config.get('full'), + method=action.get('method'), + headers=action.get('headers'), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []) + ) + + http2_action.url.ip_addr = url_config.get('ip_addr') + http2_action.url.socket_config = url_config.get('socket_config') + http2_action.url.has_ip_addr = url_config.get('has_ip_addr') + + http2_action.setup() + + return http2_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryHTTP2Client: + return MercuryHTTP2Client( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: HTTP2Result + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = { + str(k.decode()): str(v.decode()) for k, v in result.headers.items() + } + + data = result.data + if isinstance(data, bytes) or isinstance(data, bytearray): + data = str(data.decode()) + + return { + **serialized_result, + 'url': result.url, + 'method': result.method, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'headers': encoded_headers, + 'data': data, + 'tags': result.tags, + 'user': result.user, + 'error': str(result.error), + 'status': result.status, + 'reason': result.reason, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> HTTP2Result: + deserialized_result = HTTP2Result( + HTTP2Action( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + deserialized_result.status = result.get('status') + deserialized_result.reason = result.get('reason') + deserialized_result.params = result.get('params') + deserialized_result.query = result.get('query') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + deserialized_result.checks = result.get('checks') + + deserialized_result.type = RequestTypes.HTTP2 + + return deserialized_result \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/http3/__init__.py b/hyperscale/data/serializers/serializer_types/http3/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/http3/http3_serializer.py b/hyperscale/data/serializers/serializer_types/http3/http3_serializer.py new file mode 100644 index 0000000..d808d11 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/http3/http3_serializer.py @@ -0,0 +1,149 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.http3.action import HTTP3Action +from hyperscale.core.engines.types.http3.client import MercuryHTTP3Client +from hyperscale.core.engines.types.http3.result import HTTP3Result +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class HTTP3Serializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: HTTP3Action + ) -> Dict[str, Union[str, List[str]]]: + + serialized_action = super().action_to_serializable(action) + return { + **serialized_action, + 'type': RequestTypes.HTTP3, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'method': action.method, + 'headers': action._headers, + 'header_items': action._header_items, + 'encoded_headers': action.encoded_headers, + 'data': action.data, + 'encoded_data': action.encoded_data, + 'is_stream': action.is_stream, + 'redirects': action.redirects, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> HTTP3Action: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + http3_action = HTTP3Action( + name=action.get('name'), + url=url_config.get('full'), + headers=action.get('headers'), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []), + redirects=action.get('redirects', 3) + ) + + http3_action.url.ip_addr = url_config.get('ip_addr') + http3_action.url.socket_config = url_config.get('socket_config') + http3_action.url.has_ip_addr = url_config.get('has_ip_addr') + + http3_action.setup() + + return http3_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryHTTP3Client: + return MercuryHTTP3Client( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: HTTP3Result + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = { + str(k.decode()): str(v.decode()) for k, v in result.headers.items() + } + + body: Union[str, None] = None + if result.body: + body = str(result.body.decode()) + + + return { + **serialized_result, + 'url': result.url, + 'method': result.method, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'headers': encoded_headers, + 'body': body, + 'tags': result.tags, + 'user': result.user, + 'error': str(result.error), + 'status': result.status, + 'reason': result.reason, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> HTTP3Result: + deserialized_result = HTTP3Result( + HTTP3Action( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + body = result.get('body') + if isinstance(body, str): + body = body.encode() + + deserialized_result.body = body + deserialized_result.status = result.get('status') + deserialized_result.reason = result.get('reason') + deserialized_result.params = result.get('params') + deserialized_result.query = result.get('query') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + deserialized_result.checks = result.get('checks') + + deserialized_result.type = RequestTypes.HTTP3 + + return deserialized_result + \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/playwright/__init__.py b/hyperscale/data/serializers/serializer_types/playwright/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/playwright/playwright_serializer.py b/hyperscale/data/serializers/serializer_types/playwright/playwright_serializer.py new file mode 100644 index 0000000..0e516e4 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/playwright/playwright_serializer.py @@ -0,0 +1,228 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.playwright.client import ( + ContextConfig, + MercuryPlaywrightClient, +) +from hyperscale.core.engines.types.playwright.command import ( + URL, + Input, + Options, + Page, + PlaywrightCommand, +) +from hyperscale.core.engines.types.playwright.result import PlaywrightResult +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class PlaywrightSerializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: PlaywrightCommand + ) -> Dict[str, Union[str, List[str]]]: + + serialized_action = super().action_to_serializable(action) + return { + **serialized_action, + 'type': RequestTypes.PLAYWRIGHT, + 'command': action.command, + 'command_args': action.command_args, + 'url': { + 'location': action.url.location, + 'headers': action.url.headers, + }, + 'page': { + 'selector': action.page.selector, + 'attribute': action.page.attribute, + 'x_coordinate': action.page.x_coordinate, + 'y_coordinate': action.page.y_coordinate, + 'frame': action.page.frame + }, + 'input': { + 'key': action.input.key, + 'text': action.input.text, + 'expression': action.input.expression, + 'args': action.input.args, + 'filepath': action.input.filepath, + 'file': action.input.file, + 'path': action.input.path, + 'option': action.input.option, + 'by_label': action.input.by_label, + 'by_value': action.input.by_value + }, + 'options': { + 'event': action.options.event, + 'option': action.options.option, + 'is_checked': action.options.is_checked, + 'timeout': action.options.timeout, + 'extra': action.options.extra, + 'switch_by': action.options.switch_by + } + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> PlaywrightCommand: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + page_config = action.get('page', {}) + input_config = action.get('input', {}) + url_config = action.get('url', {}) + options_config=action.get('options', {}) + + playwright_command = PlaywrightCommand( + name=action.get('name'), + command=action.get('command'), + page=Page( + selector=page_config.get('selector'), + attribute=page_config.get('attribute'), + x_coordinate=page_config.get('x_coordinate'), + y_coordinate=page_config.get('y_coordinate'), + frame=page_config.get('frame', 0) + ), + url=URL( + location=url_config.get('locations'), + headers=url_config.get('headers', {}) + ), + input=Input( + key=input_config.get('key'), + text=input_config.get('text'), + expression=input_config.get('expression'), + args=input_config.get('args'), + filepath=input_config.get('filepath'), + file=input_config.get('file'), + path=input_config.get('path'), + option=input_config.get('option'), + by_label=input_config.get('by_label', False), + by_value=input_config.get('by_value', False) + ), + options=Options( + event=options_config.get('event'), + option=options_config.get('option'), + is_checked=options_config.get('is_checked', False), + timeout=options_config.get('timeout', 10), + extra=options_config.get('extra', {}), + switch_by=options_config.get('switch_by', 'url') + ), + user=metadata.get('user'), + tags=metadata.get('tags', []) + ) + + return playwright_command + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryPlaywrightClient: + playwright_client = MercuryPlaywrightClient( + concurrency=client_config.get('concurrency'), + group_size=client_config.get('group_size'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ) + ) + + playwright_client.config = ContextConfig( + **client_config.get('context_config') + ) + + return playwright_client + + def result_to_serializable( + self, + result: PlaywrightResult + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = dict(result.headers) + + return { + **serialized_result, + 'url': result.url, + 'command': result.command, + 'selector': result.selector, + 'attribute': result.attribute, + 'x_coord': result.x_coord, + 'y_coord': result.y_coord, + 'frame': result.frame, + 'type': result.type, + 'headers': encoded_headers, + 'tags': result.tags, + 'user': result.user, + 'key': result.key, + 'text': result.text, + 'expression': result.expression, + 'args': result.args, + 'filepath': result.filepath, + 'file': result.file, + 'option': result.option, + 'event': result.event, + 'timeout': result.option, + 'is_checked': result.is_checked, + 'error': str(result.error) + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> PlaywrightResult: + + playwright_command = PlaywrightCommand( + result.get('name'), + result.get('command'), + page=Page( + selector=result.get('selector'), + x_coordinate=result.get('x_coord'), + y_coordinate=result.get('y_coord'), + frame=result.get('frame') + ), + url=URL( + location=result.get('url'), + headers=result.get('headers') + ), + input=Input( + key=result.get('key'), + text=result.get('text'), + expression=result.get('expression'), + args=result.get('args'), + filepath=result.get('filepath'), + file=result.get('file'), + path=result.get('path'), + option=result.get('option'), + by_label=result.get('by_label'), + by_value=result.get('by_value') + ), + options=Options( + event=result.get('event'), + is_checked=result.get('is_checked'), + timeout=result.get('timeout') + ), + user=result.get('user'), + tags=result.get('tags') + ) + + + playwright_result = PlaywrightResult( + playwright_command, + error=result.get('error') + ) + + playwright_result.checks = result.get('checks') + playwright_result.wait_start = result.get('wait_start') + playwright_result.start = result.get('start') + playwright_result.connect_end = result.get('connect_end') + playwright_result.write_end = result.get('write_end') + playwright_result.complete = result.get('complete') + + playwright_result.type= RequestTypes.PLAYWRIGHT + + return playwright_result + \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/task/__init__.py b/hyperscale/data/serializers/serializer_types/task/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/task/task_serializer.py b/hyperscale/data/serializers/serializer_types/task/task_serializer.py new file mode 100644 index 0000000..01ccf93 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/task/task_serializer.py @@ -0,0 +1,95 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.task.result import TaskResult +from hyperscale.core.engines.types.task.runner import MercuryTaskRunner +from hyperscale.core.engines.types.task.task import Task +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class TaskSerializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + task: Task + ) -> Dict[str, Union[str, List[str]]]: + + serialized_action = super().action_to_serializable(task) + return { + **serialized_action, + 'type': RequestTypes.TASK, + 'source': task.source, + 'task_action': task.execute.__name__, + 'user': task.metadata.user, + 'tags': task.metadata.tags + } + + def deserialize_task( + self, + task: Dict[str, Any] + ) -> Task: + + deserialized_task = Task( + task.get('name'), + None, + source=task.get('source'), + user=task.get('user'), + tags=task.get('tags', []) + ) + + return deserialized_task + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryTaskRunner: + return MercuryTaskRunner( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ) + ) + + def result_to_serializable( + self, + result: TaskResult + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + return { + **serialized_result, + 'data': result.data, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> TaskResult: + + task_action = Task( + result.get('name'), + None, + source=result.get('source'), + user=result.get('user'), + tags=result.get('tags') + ) + + task_result = TaskResult( + task_action, + error=result.get('error') + ) + + task_result.data = result.get('data') + + + task_result.checks = result.get('checks') + task_result.wait_start = result.get('wait_start') + task_result.start = result.get('start') + task_result.connect_end = result.get('connect_end') + task_result.write_end = result.get('write_end') + task_result.complete = result.get('complete') + + return task_result diff --git a/hyperscale/data/serializers/serializer_types/udp/__init__.py b/hyperscale/data/serializers/serializer_types/udp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/udp/udp_serializer.py b/hyperscale/data/serializers/serializer_types/udp/udp_serializer.py new file mode 100644 index 0000000..682e5fc --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/udp/udp_serializer.py @@ -0,0 +1,133 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.udp.action import UDPAction +from hyperscale.core.engines.types.udp.client import MercuryUDPClient +from hyperscale.core.engines.types.udp.result import UDPResult +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class UDPSerializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: UDPAction + ) -> Dict[str, Union[str, List[str]]]: + + serialized_action = super().action_to_serializable(action) + return { + **serialized_action, + 'type': RequestTypes.UDP, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'wait_for_response': action.wait_for_response, + 'data': action.data, + 'encoded_data': action.encoded_data, + 'is_stream': action.is_stream, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> UDPAction: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + udp_action = UDPAction( + name=action.get('name'), + url=url_config.get('full'), + wait_for_response=action.get('wait_for_response', False), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []) + ) + + udp_action.url.ip_addr = url_config.get('ip_addr') + udp_action.url.socket_config = url_config.get('socket_config') + udp_action.url.has_ip_addr = url_config.get('has_ip_addr') + + udp_action.setup() + + return udp_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryUDPClient: + return MercuryUDPClient( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: UDPResult + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + body: Union[str, None] = None + if result.body: + body = str(result.body.decode()) + + return { + **serialized_result, + 'url': result.url, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'body': body, + 'tags': result.tags, + 'user': result.user, + 'status': result.status, + 'error': str(result.error) + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> UDPResult: + + deserialized_result = UDPResult( + UDPAction( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + body = result.get('body') + if isinstance(body, str): + body = body.encode() + + deserialized_result.body = body + deserialized_result.status = result.get('status') + deserialized_result.checks = result.get('checks') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + + deserialized_result.type = RequestTypes.UDP + + return deserialized_result \ No newline at end of file diff --git a/hyperscale/data/serializers/serializer_types/websocket/__init__.py b/hyperscale/data/serializers/serializer_types/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/data/serializers/serializer_types/websocket/websocket_serializer.py b/hyperscale/data/serializers/serializer_types/websocket/websocket_serializer.py new file mode 100644 index 0000000..697f4c3 --- /dev/null +++ b/hyperscale/data/serializers/serializer_types/websocket/websocket_serializer.py @@ -0,0 +1,146 @@ +from typing import Any, Dict, List, Union + +from hyperscale.core.engines.types.common.timeouts import Timeouts +from hyperscale.core.engines.types.common.types import RequestTypes +from hyperscale.core.engines.types.websocket.action import WebsocketAction +from hyperscale.core.engines.types.websocket.client import MercuryWebsocketClient +from hyperscale.core.engines.types.websocket.result import WebsocketResult +from hyperscale.data.serializers.serializer_types.common.base_serializer import ( + BaseSerializer, +) + + +class WebsocketSerializer(BaseSerializer): + + def __init__(self) -> None: + super().__init__() + + def action_to_serializable( + self, + action: WebsocketAction + ) -> Dict[str, Union[str, List[str]]]: + + serialized_action = super().action_to_serializable(action) + return { + **serialized_action, + 'type': RequestTypes.HTTP, + 'url': { + 'full': action.url.full, + 'ip_addr': action.url.ip_addr, + 'socket_config': action.url.socket_config, + 'has_ip_addr': action.url.has_ip_addr + }, + 'method': action.method, + 'headers': action._headers, + 'header_items': action._header_items, + 'encoded_headers': action.encoded_headers, + 'data': action.data, + 'encoded_data': action.encoded_data, + 'is_stream': action.is_stream, + 'is_setup': action.is_setup, + 'action_args': action.action_args, + } + + def deserialize_action( + self, + action: Dict[str, Any] + ) -> WebsocketAction: + + url_config = action.get('url', {}) + metadata = action.get('metadata', {}) + + websocket_action = WebsocketAction( + name=action.get('name'), + url=url_config.get('full'), + headers=action.get('headers'), + data=action.get('data'), + user=metadata.get('user'), + tags=metadata.get('tags', []) + ) + + websocket_action.url.ip_addr = url_config.get('ip_addr') + websocket_action.url.socket_config = url_config.get('socket_config') + websocket_action.url.has_ip_addr = url_config.get('has_ip_addr') + + websocket_action.setup() + + return websocket_action + + def deserialize_client_config(self, client_config: Dict[str, Any]) -> MercuryWebsocketClient: + return MercuryWebsocketClient( + concurrency=client_config.get('concurrency'), + timeouts=Timeouts( + **client_config.get('timeouts', {}) + ), + reset_connections=client_config.get('reset_sessions') + ) + + def result_to_serializable( + self, + result: WebsocketResult + ) -> Dict[str, Any]: + + serialized_result = super().result_to_serializable(result) + + encoded_headers = { + str(k.decode()): str(v.decode()) for k, v in result.headers.items() + } + + body: Union[str, None] = None + if result.body: + body = str(result.body.decode()) + + return { + **serialized_result, + 'url': result.url, + 'method': result.method, + 'path': result.path, + 'params': result.params, + 'query': result.query, + 'type': result.type, + 'headers': encoded_headers, + 'body': body, + 'tags': result.tags, + 'user': result.user, + 'error': str(result.error), + 'status': result.status, + 'reason': result.reason, + } + + def deserialize_result( + self, + result: Dict[str, Any] + ) -> WebsocketResult: + deserialized_result = WebsocketResult( + WebsocketResult( + name=result.get('name'), + url=result.get('url'), + method=result.get('method'), + headers=result.get('headers'), + data=result.get('data'), + user=result.get('user'), + tags=result.get('tags', []) + ), + error=Exception(result.get('error')) + ) + + body = result.get('body') + if isinstance(body, str): + body = body.encode() + + deserialized_result.body = body + deserialized_result.status = result.get('status') + deserialized_result.reason = result.get('reason') + deserialized_result.params = result.get('params') + deserialized_result.query = result.get('query') + deserialized_result.wait_start = result.get('wait_start') + deserialized_result.start = result.get('start') + deserialized_result.connect_end = result.get('connect_end') + deserialized_result.write_end = result.get('write_end') + deserialized_result.complete = result.get('complete') + deserialized_result.checks = result.get('checks') + + deserialized_result.type = RequestTypes.WEBSOCKET + + return deserialized_result + \ No newline at end of file diff --git a/hyperscale/distributed/__init__.py b/hyperscale/distributed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/connection/__init__.py b/hyperscale/distributed/connection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/connection/addresses/__init__.py b/hyperscale/distributed/connection/addresses/__init__.py new file mode 100644 index 0000000..35c9549 --- /dev/null +++ b/hyperscale/distributed/connection/addresses/__init__.py @@ -0,0 +1 @@ +from .subnet_range import SubnetRange \ No newline at end of file diff --git a/hyperscale/distributed/connection/addresses/subnet_range.py b/hyperscale/distributed/connection/addresses/subnet_range.py new file mode 100644 index 0000000..bad99bc --- /dev/null +++ b/hyperscale/distributed/connection/addresses/subnet_range.py @@ -0,0 +1,27 @@ +import ipaddress +from typing import List + + +class SubnetRange: + + def __init__( + self, + base_address: str, + subnet_range: int=24 + ) -> None: + self.subnet = f'{base_address}/{subnet_range}' + self._network = ipaddress.ip_network(self.subnet, strict=False) + self._addresses = [ + str(ip) for ip in self._network.hosts() + ] + + self.reserved: List[str] = [] + + def __iter__(self): + + available_addresses = [ + address for address in self._addresses if address not in self.reserved + ] + + for address in available_addresses: + yield address \ No newline at end of file diff --git a/hyperscale/distributed/connection/base/__init__.py b/hyperscale/distributed/connection/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/connection/base/connection_type.py b/hyperscale/distributed/connection/base/connection_type.py new file mode 100644 index 0000000..84d0d35 --- /dev/null +++ b/hyperscale/distributed/connection/base/connection_type.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ConnectionType(Enum): + UDP='udp' + TCP='tcp' + HTTP='http' \ No newline at end of file diff --git a/hyperscale/distributed/connection/tcp/__init__.py b/hyperscale/distributed/connection/tcp/__init__.py new file mode 100644 index 0000000..5753331 --- /dev/null +++ b/hyperscale/distributed/connection/tcp/__init__.py @@ -0,0 +1,2 @@ +from .mercury_sync_tcp_connection import MercurySyncTCPConnection +from .mercury_sync_http_connection import MercurySyncHTTPConnection \ No newline at end of file diff --git a/hyperscale/distributed/connection/tcp/mercury_sync_http_connection.py b/hyperscale/distributed/connection/tcp/mercury_sync_http_connection.py new file mode 100644 index 0000000..2558764 --- /dev/null +++ b/hyperscale/distributed/connection/tcp/mercury_sync_http_connection.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import asyncio +import ipaddress +import socket +import ssl +from collections import defaultdict, deque +from typing import Callable, Deque, Dict, List, Optional, Tuple, Union + +import psutil +import zstandard +from pydantic import BaseModel + +from hyperscale.distributed.connection.base.connection_type import ConnectionType +from hyperscale.distributed.env import Env +from hyperscale.distributed.models.http import ( + HTTPMessage, + HTTPRequest, + Request, + Response, +) +from hyperscale.distributed.rate_limiting import Limiter + +from .mercury_sync_tcp_connection import MercurySyncTCPConnection +from .protocols import MercurySyncTCPClientProtocol + + +class MercurySyncHTTPConnection(MercurySyncTCPConnection): + + def __init__( + self, + host: str, + port: int, + instance_id: int, + env: Env, + ) -> None: + super().__init__( + host, + port, + instance_id, + env + ) + + self._waiters: Deque[asyncio.Future] = deque() + self._connections: Dict[str, List[asyncio.Transport]] = defaultdict(list) + self._http_socket: Union[socket.socket, None] = None + self._hostnames: Dict[Tuple[str, int], str] = {} + self._max_concurrency = env.MERCURY_SYNC_MAX_CONCURRENCY + + self.connection_type = ConnectionType.HTTP + self._is_server = env.MERCURY_SYNC_USE_HTTP_SERVER + self._use_encryption = env.MERCURY_SYNC_USE_HTTP_MSYNC_ENCRYPTION + + self._supported_handlers: Dict[str, Dict[str, str]] = defaultdict(dict) + self._response_parsers: Dict[ + Tuple[str, int], + Callable[ + [BaseModel], + str + ] + ] = {} + + self._middleware_enabled: Dict[str, bool] = {} + + self._limiter = Limiter(env) + + self._backoff_sem: Union[asyncio.Semaphore, None] = None + + rate_limit_strategy = env.MERCURY_SYNC_HTTP_RATE_LIMIT_STRATEGY + self._rate_limiting_enabled = rate_limit_strategy != "none" + self._rate_limiting_backoff_rate = env.MERCURY_SYNC_HTTP_RATE_LIMIT_BACKOFF_RATE + + self._initial_cpu = psutil.cpu_percent() + + async def connect_async( + self, + cert_path: Optional[str] = None, + key_path: Optional[str] = None, + worker_socket: Optional[socket.socket] = None, + worker_server: Optional[asyncio.Server]=None + ): + self._backoff_sem = asyncio.Semaphore( + self._rate_limiting_backoff_rate + ) + + return await super().connect_async( + cert_path, + key_path, + worker_socket + ) + + async def connect_client( + self, + address: Tuple[str, int], + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None, + is_ssl: bool=False, + hostname: str=None, + ) -> None: + + self._hostnames[address] = hostname + + if self._semaphore is None: + self._semaphore = asyncio.Semaphore(self._max_concurrency) + + if self._compressor is None and self._decompressor is None: + self._compressor = zstandard.ZstdCompressor() + self._decompressor = zstandard.ZstdDecompressor() + + + if cert_path and key_path: + self._client_ssl_context = self._create_client_ssl_context( + cert_path=cert_path, + key_path=key_path + ) + + elif is_ssl: + self._client_ssl_context = self._create_general_client_ssl_context( + cert_path=cert_path, + key_path=key_path + ) + + last_error: Union[Exception, None] = None + + for _ in range(self._tcp_connect_retries): + + try: + + self._connections[address] = await asyncio.gather(*[ + self._connect_client( + address, + hostname=hostname, + worker_socket=worker_socket + ) for _ in range(self._max_concurrency) + ]) + + return + + except ConnectionRefusedError as connection_error: + last_error = connection_error + + await asyncio.sleep(1) + + if last_error: + raise last_error + + def _create_general_client_ssl_context( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + ): + ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + return ctx + + + async def _connect_client( + self, + address: Tuple[str, int], + hostname: str=None, + worker_socket: Optional[socket.socket]=None, + ) -> asyncio.Transport: + + self._loop = asyncio.get_event_loop() + + if worker_socket is None: + + http_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + http_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + await self._loop.run_in_executor(None, http_socket.connect, address) + + http_socket.setblocking(False) + + else: + http_socket = worker_socket + + transport, _ = await self._loop.create_connection( + lambda: MercurySyncTCPClientProtocol( + self.read + ), + sock=http_socket, + server_hostname=hostname, + ssl=self._client_ssl_context + ) + + return transport + + async def send( + self, + event_name: str, + data: HTTPRequest, + address: Tuple[str, int] + ): + async with self._semaphore: + + connections = self._connections.get(address) + if connections is None: + + connections = await self.connect_client( + address, + cert_path=self._client_cert_path, + key_path=self._client_key_path, + is_ssl='https' in data.url + ) + + self._connections[address] = connections + + client_transport = connections.pop() + + result: Union[bytes, None] = None + + try: + + encoded_request = data.prepare_request() + encrypted_request = self._encryptor.encrypt(encoded_request) + compressed_request = self._compressor.compress(encrypted_request) + + client_transport.write(compressed_request) + + waiter = self._loop.create_future() + self._waiters.append(waiter) + + result = await waiter + + except Exception: + self._connections[address].append( + await self._connect_client( + ( + self.host, + self.port + ), + hostname=self._hostnames.get(address) + ) + ) + + self._connections[address].append(client_transport) + + return result + + async def send_request( + self, + data: HTTPRequest, + address: Tuple[str, int] + ): + async with self._semaphore: + + encoded_request = data.prepare_request() + + connections = self._connections.get(address) + client_transport = connections.pop() + + result: Union[bytes, None] = None + + try: + + client_transport.write(encoded_request) + + waiter = self._loop.create_future() + self._waiters.append(waiter) + + result = await waiter + + except Exception: + self._connections[address].append( + await self._connect_client( + ( + self.host, + self.port + ), + hostname=self._hostnames.get(address) + ) + ) + + self._connections[address].append(client_transport) + + return result + + def read( + self, + data: bytes, + transport: asyncio.Transport + ) -> None: + + if self._is_server: + self._pending_responses.append( + asyncio.create_task( + self._route_request( + data, + transport + ) + ) + ) + + elif bool(self._waiters): + + waiter = self._waiters.pop() + waiter.set_result( + HTTPRequest.parse(data) + ) + + async def _route_request( + self, + data: bytes, + transport: asyncio.Transport + ): + if self._use_encryption: + encrypted_data = self._encryptor.encrypt(data) + data = self._compressor.compress(encrypted_data) + + request_data = data.split(b'\r\n') + method, path, request_type = request_data[0].decode().split(' ') + + try: + + handler_key = f'{method}_{path}' + + handler = self.events[handler_key] + + query: Union[str, None] = None + if '?' in path: + path, query = path.split('?') + + + request = Request( + path, + method, + query, + request_data, + model=self.parsers.get(handler_key) + ) + + if self._rate_limiting_enabled: + + ip_address, _ = transport.get_extra_info('peername') + + rejected = await self._limiter.limit( + ipaddress.ip_address(ip_address), + request, + limit=handler.limit, + ) + + if rejected and transport.is_closing() is False: + + async with self._backoff_sem: + too_many_requests_response = HTTPMessage( + path=request.path, + status=429, + error='Too Many Requests', + protocol=request_type, + method=request.method + ) + + transport.write(too_many_requests_response.prepare_response()) + + return + + elif rejected: + + async with self._backoff_sem: + transport.close() + + return + + response_info: Tuple[ + Union[ + Response, + BaseModel, + str, + None + ], + int + ] = await handler(request) + + ( + response_data, + status_code + ) = response_info + + response_key = f'{handler_key}_{status_code}' + + encoded_data: str = '' + + response_parser = self._response_parsers.get(response_key) + middleware_enabled = self._middleware_enabled.get(path) + response_headers: Dict[str, str] = handler.response_headers + + if middleware_enabled and response_parser: + + encoded_data = response_parser(response_data.data) + response_headers.update( + response_data.headers + ) + + content_length = len(encoded_data) + headers = f'content-length: {content_length}' + + elif middleware_enabled: + + encoded_data = response_data.data or '' + + response_headers.update( + response_data.headers + ) + + content_length = len(encoded_data) + headers = f'content-length: {content_length}' + + elif response_parser: + + encoded_data = response_parser(response_data) + + content_length = len(encoded_data) + headers = f'content-length: {content_length}' + + + elif response_data: + encoded_data = response_data + + content_length = len(response_data) + headers = f'content-length: {content_length}' + + else: + headers = 'content-length: 0' + + for key in response_headers: + headers = f'{headers}\r\n{key}: {response_headers[key]}' + + response_data = f'HTTP/1.1 {status_code} OK\r\n{headers}\r\n\r\n{encoded_data}'.encode() + + if self._use_encryption: + encrypted_data = self._encryptor.encrypt(response_data) + response_data = self._compressor.compress(encrypted_data) + + transport.write(response_data) + + except KeyError: + + if self._supported_handlers.get(path) is None: + + not_found_response = HTTPMessage( + path=path, + status=404, + error='Not Found', + protocol=request_type, + method=method + ) + + transport.write(not_found_response.prepare_response()) + + elif self._supported_handlers[path].get(method) is None: + + method_not_allowed_response = HTTPMessage( + path=path, + status=405, + error='Method Not Allowed', + protocol=request_type, + method=method + ) + + transport.write(method_not_allowed_response.prepare_response()) + + except Exception: + + async with self._backoff_sem: + if transport.is_closing() is False: + + server_error_respnse = HTTPMessage( + path=path, + status=500, + error='Internal Error', + protocol=request_type, + method=method + ) + + transport.write(server_error_respnse.prepare_response()) + + async def close(self): + await self._limiter.close() + return await super().close() + diff --git a/hyperscale/distributed/connection/tcp/mercury_sync_tcp_connection.py b/hyperscale/distributed/connection/tcp/mercury_sync_tcp_connection.py new file mode 100644 index 0000000..c9ad7c9 --- /dev/null +++ b/hyperscale/distributed/connection/tcp/mercury_sync_tcp_connection.py @@ -0,0 +1,909 @@ + +import asyncio +import pickle +import socket +import ssl +from collections import defaultdict, deque +from typing import Any, AsyncIterable, Coroutine, Deque, Dict, Optional, Tuple, Union + +import zstandard + +from hyperscale.distributed.connection.base.connection_type import ConnectionType +from hyperscale.distributed.connection.tcp.protocols import ( + MercurySyncTCPClientProtocol, + MercurySyncTCPServerProtocol, +) +from hyperscale.distributed.encryption import AESGCMFernet +from hyperscale.distributed.env import Env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.models.base.message import Message +from hyperscale.distributed.snowflake.snowflake_generator import SnowflakeGenerator + + +class MercurySyncTCPConnection: + + def __init__( + self, + host: str, + port: int, + instance_id: int, + env: Env + ) -> None: + + self.id_generator = SnowflakeGenerator(instance_id) + self.env = env + + self.host = host + self.port = port + + self.events: Dict[ + str, + Coroutine + ] = {} + + self.queue: Dict[str, Deque[Tuple[str, int, float, Any]] ] = defaultdict(deque) + self.parsers: Dict[str, Message] = {} + self.connected = False + self._running = False + + self._client_transports: Dict[str, asyncio.Transport] = {} + self._server: asyncio.Server = None + self._loop: Union[asyncio.AbstractEventLoop, None] = None + self._waiters: Dict[str, Deque[asyncio.Future]] = defaultdict(deque) + self._pending_responses: Deque[asyncio.Task]= deque() + self._last_call: Deque[str] = deque() + + self._sent_values = deque() + self.server_socket = None + self._stream = False + + self._client_key_path: Union[str, None] = None + self._client_cert_path: Union[str, None] = None + + self._server_key_path: Union[str, None] = None + self._server_cert_path: Union[str, None] = None + + self._client_ssl_context: Union[ssl.SSLContext, None] = None + self._server_ssl_context: Union[ssl.SSLContext, None] = None + + self._encryptor = AESGCMFernet(env) + self._semaphore: Union[asyncio.Semaphore, None] = None + self._compressor: Union[zstandard.ZstdCompressor, None] = None + self._decompressor: Union[zstandard.ZstdDecompressor, None] = None + self._cleanup_task: Union[asyncio.Task, None] = None + self._sleep_task: Union[asyncio.Task, None] = None + self._cleanup_interval = TimeParser(env.MERCURY_SYNC_CLEANUP_INTERVAL).time + + self._request_timeout = TimeParser( + env.MERCURY_SYNC_REQUEST_TIMEOUT + ).time + + self._max_concurrency = env.MERCURY_SYNC_MAX_CONCURRENCY + self._tcp_connect_retries = env.MERCURY_SYNC_TCP_CONNECT_RETRIES + + self.connection_type = ConnectionType.TCP + + def connect( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None + ): + + try: + + self._loop = asyncio.get_event_loop() + + except Exception: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + self._running = True + self._semaphore = asyncio.Semaphore(self._max_concurrency) + + self._compressor = zstandard.ZstdCompressor() + self._decompressor = zstandard.ZstdDecompressor() + + if cert_path and key_path: + self._server_ssl_context = self._create_server_ssl_context( + cert_path=cert_path, + key_path=key_path + ) + + if self.connected is False and worker_socket is None: + self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server_socket.bind((self.host, self.port)) + + self.server_socket.setblocking(False) + + elif self.connected is False: + self.server_socket = worker_socket + host, port = worker_socket.getsockname() + + self.host = host + self.port = port + + if self.connected is False: + + server = self._loop.create_server( + lambda: MercurySyncTCPServerProtocol( + self.read + ), + sock=self.server_socket, + ssl=self._server_ssl_context + ) + + self._server = self._loop.run_until_complete(server) + + self.connected = True + + self._cleanup_task = self._loop.create_task(self._cleanup()) + + async def connect_async( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None, + worker_server: Optional[asyncio.Server]=None + ): + + try: + + self._loop = asyncio.get_event_loop() + + except Exception: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + self._running = True + self._semaphore = asyncio.Semaphore(self._max_concurrency) + + self._compressor = zstandard.ZstdCompressor() + self._decompressor = zstandard.ZstdDecompressor() + + if cert_path and key_path: + self._server_ssl_context = self._create_server_ssl_context( + cert_path=cert_path, + key_path=key_path + ) + + if self.connected is False and worker_socket is None: + self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + self.server_socket.bind((self.host, self.port)) + + except Exception: + pass + + self.server_socket.setblocking(False) + + elif self.connected is False and worker_socket: + self.server_socket = worker_socket + host, port = worker_socket.getsockname() + + self.host = host + self.port = port + + elif self.connected is False and worker_server: + self._server = worker_server + + server_socket, _ = worker_server.sockets + host, port = server_socket.getsockname() + self.host = host + self.port = port + + self.connected = True + self._cleanup_task = self._loop.create_task(self._cleanup()) + + if self.connected is False: + + server = await self._loop.create_server( + lambda: MercurySyncTCPServerProtocol( + self.read + ), + sock=self.server_socket, + ssl=self._server_ssl_context + ) + + self._server = server + self.connected = True + + self._cleanup_task = self._loop.create_task(self._cleanup()) + + def _create_server_ssl_context( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None + ) -> ssl.SSLContext: + + if self._server_cert_path is None: + self._server_cert_path = cert_path + + if self._server_key_path is None: + self._server_key_path = key_path + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.options |= ssl.OP_NO_TLSv1 + ssl_ctx.options |= ssl.OP_NO_TLSv1_1 + ssl_ctx.options |= ssl.OP_SINGLE_DH_USE + ssl_ctx.options |= ssl.OP_SINGLE_ECDH_USE + ssl_ctx.load_cert_chain(cert_path, keyfile=key_path) + ssl_ctx.load_verify_locations(cafile=cert_path) + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.VerifyMode.CERT_REQUIRED + ssl_ctx.set_ciphers('ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384') + + return ssl_ctx + + async def connect_client( + self, + address: Tuple[str, int], + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None, + ) -> None: + + if self._semaphore is None: + self._semaphore = asyncio.Semaphore(self._max_concurrency) + + self._loop = asyncio.get_event_loop() + if cert_path and key_path: + self._client_ssl_context = self._create_client_ssl_context( + cert_path=cert_path, + key_path=key_path + ) + + if worker_socket is None: + + tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + tcp_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + await self._loop.run_in_executor(None, tcp_socket.connect, address) + + tcp_socket.setblocking(False) + + else: + + tcp_socket = worker_socket + + last_error: Union[Exception, None] = None + + for _ in range(self._tcp_connect_retries): + + try: + + client_transport, _ = await self._loop.create_connection( + lambda: MercurySyncTCPClientProtocol( + self.read + ), + sock=tcp_socket, + ssl=self._client_ssl_context + ) + + self._client_transports[address] = client_transport + + return client_transport + + except ConnectionRefusedError as connection_error: + last_error = connection_error + + await asyncio.sleep(1) + + if last_error: + raise last_error + + def _create_client_ssl_context( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None + ) -> ssl.SSLContext: + + if self._client_cert_path is None: + self._client_cert_path = cert_path + + if self._client_key_path is None: + self._client_key_path = key_path + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_ctx.options |= ssl.OP_NO_TLSv1 + ssl_ctx.options |= ssl.OP_NO_TLSv1_1 + ssl_ctx.load_cert_chain(cert_path, keyfile=key_path) + ssl_ctx.load_verify_locations(cafile=cert_path) + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.VerifyMode.CERT_REQUIRED + ssl_ctx.set_ciphers('ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384') + + return ssl_ctx + + async def _cleanup(self): + while self._running: + self._sleep_task = asyncio.create_task( + asyncio.sleep(self._cleanup_interval) + ) + + await self._sleep_task + + for pending in list(self._pending_responses): + if pending.done() or pending.cancelled(): + + try: + await pending + + except (Exception, socket.error): + pass + # await self.close() + # await self.connect_async( + # cert_path=self._client_cert_path, + # key_path=self._client_key_path + # ) + + self._pending_responses.pop() + + async def send( + self, + event_name: bytes, + data: bytes, + address: Tuple[str, int] + ) -> Tuple[int, Dict[str, Any]]: + + async with self._semaphore: + + try: + self._last_call.append(event_name) + + client_transport = self._client_transports.get(address) + if client_transport is None: + await self.connect_client( + address, + cert_path=self._client_cert_path, + key_path=self._client_key_path + ) + + client_transport = self._client_transports.get(address) + + item = pickle.dumps( + ( + 'request', + self.id_generator.generate(), + event_name, + data, + self.host, + self.port + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + if client_transport.is_closing(): + return ( + self.id_generator.generate(), + Message( + host=self.host, + port=self.port, + error='Transport closed.' + ) + ) + + client_transport.write(compressed) + + waiter = self._loop.create_future() + self._waiters[event_name].append(waiter) + + ( + _, + shard_id, + _, + response_data, + _, + _ + ) = await asyncio.wait_for( + waiter, + timeout=self._request_timeout + ) + + return ( + shard_id, + response_data + ) + + except (Exception, socket.error): + + return ( + self.id_generator.generate(), + Message( + host=self.host, + port=self.port, + error='Request timed out.' + ) + ) + + async def send_bytes( + self, + event_name: str, + data: bytes, + address: Tuple[str, int] + ) -> bytes: + async with self._semaphore: + + try: + self._last_call.append(event_name) + + client_transport = self._client_transports.get(address) + if client_transport is None: + await self.connect_client( + address, + cert_path=self._client_cert_path, + key_path=self._client_key_path + ) + + client_transport = self._client_transports.get(address) + + if client_transport.is_closing(): + return ( + self.id_generator.generate(), + Message( + host=self.host, + port=self.port, + error='Transport closed.' + ) + ) + + client_transport.write(data) + + waiter = self._loop.create_future() + self._waiters[event_name].append(waiter) + + return await asyncio.wait_for( + waiter, + timeout=self._request_timeout + ) + + except (Exception, socket.error): + return b'Request timed out.' + + + async def stream( + self, + event_name: str, + data: Any, + address: Tuple[str, int] + ) -> AsyncIterable[Tuple[int, Dict[str, Any]]]: + + async with self._semaphore: + + try: + self._last_call.append(event_name) + + client_transport = self._client_transports.get(address) + + + if self._stream is False: + item = pickle.dumps( + ( + 'stream_connect', + self.id_generator.generate(), + event_name, + data, + self.host, + self.port + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + + else: + item = pickle.dumps( + ( + 'stream', + self.id_generator.generate(), + event_name, + data, + self.host, + self.port + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + if client_transport.is_closing(): + yield ( + self.id_generator.generate(), + Message( + host=self.host, + port=self.port, + error='Transport closed.' + ) + ) + + client_transport.write(compressed) + + waiter = self._loop.create_future() + self._waiters[event_name].append(waiter) + + await asyncio.wait_for( + waiter, + timeout=self._request_timeout + ) + + if self._stream is False: + + self.queue[event_name].pop() + + self._stream = True + + item = pickle.dumps( + ( + 'stream', + self.id_generator.generate(), + event_name, + data, + self.host, + self.port + ), + pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + client_transport.write(compressed) + + waiter = self._loop.create_future() + self._waiters[event_name].append(waiter) + + await waiter + + + while bool(self.queue[event_name]) and self._stream: + + ( + _, + shard_id, + _, + response_data, + _, + _ + ) = self.queue[event_name].pop() + + yield( + shard_id, + response_data + ) + + except (Exception, socket.error): + + yield ( + self.id_generator.generate(), + Message( + host=self.host, + port=self.port, + error='Request timed out.' + ) + ) + + self.queue.clear() + + def read( + self, + data: bytes, + transport: asyncio.Transport + ) -> None: + decompressed = b'' + + try: + decompressed = self._decompressor.decompress(data) + + except Exception as decompression_error: + self._pending_responses.append( + asyncio.create_task( + self._send_error( + error_message=str(decompression_error), + transport=transport + ) + ) + ) + + if bool(self._last_call): + event_name = self._last_call.pop() + event_waiter = self._waiters[event_name] + + if bool(event_waiter): + waiter = event_waiter.pop() + + try: + + waiter.set_result(None) + + except asyncio.InvalidStateError: + pass + + return + + decrypted = self._encryptor.decrypt(decompressed) + + result: Tuple[ + str, + int, + float, + Any, + str, + int + ] = pickle.loads(decrypted) + + ( + message_type, + shard_id, + event_name, + payload, + incoming_host, + incoming_port + ) = result + + if message_type == 'request': + self._pending_responses.append( + asyncio.create_task( + self._read( + event_name, + self.events.get(event_name)( + shard_id, + self.parsers[event_name](**payload) + ), + transport + ) + ) + ) + + elif message_type == "stream_connect": + + self.queue[event_name].append(( + message_type, + shard_id, + event_name, + payload, + incoming_host, + incoming_port + )) + + self._pending_responses.append( + asyncio.create_task( + self._initialize_stream( + event_name, + transport + ) + ) + ) + + event_waiter = self._waiters[event_name] + + if bool(event_waiter): + waiter = event_waiter.pop() + + try: + + waiter.set_result(None) + + except asyncio.InvalidStateError: + pass + + elif message_type == 'stream' or message_type == "stream_connect": + + self.queue[event_name].append(( + message_type, + shard_id, + event_name, + payload, + incoming_host, + incoming_port + )) + + self._pending_responses.append( + asyncio.create_task( + self._read_iterator( + event_name, + self.events.get(event_name)( + shard_id, + self.parsers[event_name](**payload) + ), + transport + ) + ) + ) + + event_waiter = self._waiters[event_name] + + if bool(event_waiter): + waiter = event_waiter.pop() + + try: + + waiter.set_result(None) + + except asyncio.InvalidStateError: + pass + + else: + + if event_name is None and bool(self._last_call): + event_name = self._last_call.pop() + + + event_waiter = self._waiters[event_name] + + if bool(event_waiter): + waiter = event_waiter.pop() + + try: + + waiter.set_result(( + message_type, + shard_id, + event_name, + payload, + incoming_host, + incoming_port + )) + + except asyncio.InvalidStateError: + pass + + async def _read( + self, + event_name: str, + coroutine: Coroutine, + transport: asyncio.Transport + ) -> Coroutine[Any, Any, None]: + response: Message = await coroutine + + try: + if transport.is_closing() is False: + + item = pickle.dumps( + ( + 'response', + self.id_generator.generate(), + event_name, + response.to_data(), + self.host, + self.port + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + transport.write(compressed) + + except (Exception, socket.error): + pass + + async def _read_iterator( + self, + event_name: str, + coroutine: AsyncIterable[Message], + transport: asyncio.Transport + ) -> Coroutine[Any, Any, None]: + + if transport.is_closing() is False: + + async for response in coroutine: + + try: + + item = pickle.dumps( + ( + 'response', + self.id_generator.generate(), + event_name, + response.to_data(), + self.host, + self.port + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + transport.write(compressed) + + except (Exception, socket.error): + pass + + async def _initialize_stream( + self, + event_name: str, + transport: asyncio.Transport + ) -> Coroutine[Any, Any, None]: + + if transport.is_closing() is False: + + try: + + message = Message() + item = pickle.dumps( + ( + 'response', + self.id_generator.generate(), + event_name, + message.to_data(), + self.host, + self.port + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + transport.write(compressed) + + except (Exception, socket.error): + pass + + async def _send_error( + self, + error_message: str, + transport: asyncio.Transport + ) -> Coroutine[Any, Any, None]: + + if transport.is_closing(): + + try: + + error = Message( + error=error_message + ) + + item = pickle.dumps( + ( + 'response', + self.id_generator.generate(), + None, + error.to_data(), + self.host, + self.port + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + transport.write(compressed) + + except (Exception, socket.error): + pass + + async def close(self) -> None: + self._stream = False + self._running = False + + for client in self._client_transports.values(): + client.abort() + + if self._cleanup_task: + self._cleanup_task.cancel() + if self._cleanup_task.cancelled() is False: + try: + self._sleep_task.cancel() + if not self._sleep_task.cancelled(): + await self._sleep_task + + except (Exception, socket.error): + pass + + try: + + await self._cleanup_task + + except Exception: + pass + + \ No newline at end of file diff --git a/hyperscale/distributed/connection/tcp/protocols/__init__.py b/hyperscale/distributed/connection/tcp/protocols/__init__.py new file mode 100644 index 0000000..c819ac3 --- /dev/null +++ b/hyperscale/distributed/connection/tcp/protocols/__init__.py @@ -0,0 +1,2 @@ +from .mercury_sync_tcp_client_protocol import MercurySyncTCPClientProtocol +from .mercury_sync_tcp_server_protocol import MercurySyncTCPServerProtocol \ No newline at end of file diff --git a/hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_client_protocol.py b/hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_client_protocol.py new file mode 100644 index 0000000..0f7d806 --- /dev/null +++ b/hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_client_protocol.py @@ -0,0 +1,30 @@ +import asyncio +from typing import Callable, Any + + +class MercurySyncTCPClientProtocol(asyncio.Protocol): + def __init__( + self, + callback: Callable[ + [Any], + bytes + ] + ): + super().__init__() + self.transport: asyncio.Transport = None + self.loop = asyncio.get_event_loop() + self.callback = callback + + self.on_con_lost = self.loop.create_future() + + def connection_made(self, transport: asyncio.Transport) -> str: + self.transport = transport + + def data_received(self, data: bytes): + self.callback( + data, + self.transport + ) + + def connection_lost(self, exc): + self.on_con_lost.set_result(True) diff --git a/hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_server_protocol.py b/hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_server_protocol.py new file mode 100644 index 0000000..f6fd694 --- /dev/null +++ b/hyperscale/distributed/connection/tcp/protocols/mercury_sync_tcp_server_protocol.py @@ -0,0 +1,33 @@ +import asyncio +from typing import Callable, Tuple + + +class MercurySyncTCPServerProtocol(asyncio.Protocol): + def __init__( + self, + callback: Callable[ + [ + bytes, + Tuple[str, int] + ], + bytes + ] + ): + super().__init__() + self.callback = callback + self.transport: asyncio.Transport = None + self.loop = asyncio.get_event_loop() + self.on_con_lost = self.loop.create_future() + + + def connection_made(self, transport) -> str: + self.transport = transport + + def data_received(self, data: bytes): + self.callback( + data, + self.transport + ) + + def connection_lost(self, exc: Exception | None) -> None: + self.on_con_lost.set_result(True) \ No newline at end of file diff --git a/hyperscale/distributed/connection/udp/__init__.py b/hyperscale/distributed/connection/udp/__init__.py new file mode 100644 index 0000000..486003e --- /dev/null +++ b/hyperscale/distributed/connection/udp/__init__.py @@ -0,0 +1,2 @@ +from .mercury_sync_udp_connection import MercurySyncUDPConnection +from .mercury_sync_udp_multicast_connection import MercurySyncUDPMulticastConnection \ No newline at end of file diff --git a/hyperscale/distributed/connection/udp/mercury_sync_udp_connection.py b/hyperscale/distributed/connection/udp/mercury_sync_udp_connection.py new file mode 100644 index 0000000..4def37b --- /dev/null +++ b/hyperscale/distributed/connection/udp/mercury_sync_udp_connection.py @@ -0,0 +1,573 @@ + +from __future__ import annotations + +import asyncio +import pickle +import socket +import ssl +from collections import defaultdict, deque +from typing import Any, AsyncIterable, Coroutine, Deque, Dict, Optional, Tuple, Union + +import zstandard +from dtls import do_patch + +from hyperscale.distributed.connection.base.connection_type import ConnectionType +from hyperscale.distributed.connection.udp.protocols import MercurySyncUDPProtocol +from hyperscale.distributed.encryption import AESGCMFernet +from hyperscale.distributed.env import Env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.models.base.message import Message +from hyperscale.distributed.snowflake.snowflake_generator import SnowflakeGenerator + +do_patch() + + +class MercurySyncUDPConnection: + + def __init__( + self, + host: str, + port: int, + instance_id: int, + env: Env + ) -> None: + + self.id_generator = SnowflakeGenerator(instance_id) + self.env = env + + self.host = host + self.port = port + + self.events: Dict[ + str, + Coroutine + ] = {} + + self._transport: asyncio.DatagramTransport = None + self._loop: Union[asyncio.AbstractEventLoop, None] = None + self.queue: Dict[str, Deque[Tuple[str, int, float, Any]] ] = defaultdict(deque) + self.parsers: Dict[str, Message] = {} + self._waiters: Dict[str, asyncio.Queue] = defaultdict(asyncio.Queue) + self._pending_responses: Deque[asyncio.Task] = deque() + + self._udp_cert_path: Union[str, None] = None + self._udp_key_path: Union[str, None] = None + self._udp_ssl_context: Union[ssl.SSLContext, None] = None + self._request_timeout = TimeParser( + env.MERCURY_SYNC_REQUEST_TIMEOUT + ).time + + self._encryptor = AESGCMFernet(env) + self._semaphore: Union[asyncio.Semaphore, None] = None + self._compressor: Union[zstandard.ZstdCompressor, None] = None + self._decompressor: Union[zstandard.ZstdDecompressor, None] = None + + self._running = False + self._cleanup_task: Union[asyncio.Task, None] = None + self._sleep_task: Union[asyncio.Task, None] = None + self._cleanup_interval = TimeParser(env.MERCURY_SYNC_CLEANUP_INTERVAL).time + self._max_concurrency = env.MERCURY_SYNC_MAX_CONCURRENCY + self.udp_socket: Union[socket.socket, None] = None + + self.connection_type = ConnectionType.UDP + self.connected = False + + def connect( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None + ) -> None: + + try: + + self._loop = asyncio.get_event_loop() + + except Exception: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + self._running = True + + self._semaphore = asyncio.Semaphore(self._max_concurrency) + + self._compressor = zstandard.ZstdCompressor() + self._decompressor = zstandard.ZstdDecompressor() + + if self.connected is False and worker_socket is None: + self.udp_socket = socket.socket( + socket.AF_INET, + socket.SOCK_DGRAM, + socket.IPPROTO_UDP + ) + + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.udp_socket.setblocking(False) + self.udp_socket.set_inheritable(True) + + self.udp_socket.bind(( + self.host, + self.port + )) + + elif self.connected is False and worker_socket: + self.udp_socket = worker_socket + host, port = self.udp_socket.getsockname() + + self.host = host + self.port = port + + if cert_path and key_path: + self._udp_ssl_context = self._create_udp_ssl_context( + cert_path=cert_path, + key_path=key_path, + ) + + self.udp_socket = self._udp_ssl_context.wrap_socket(self.udp_socket) + + server = self._loop.create_datagram_endpoint( + lambda: MercurySyncUDPProtocol( + self.read + ), + sock=self.udp_socket + ) + + transport, _ = self._loop.run_until_complete(server) + self._transport = transport + self._cleanup_task = self._loop.create_task(self._cleanup()) + + async def connect_async( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None, + worker_transport: Optional[asyncio.DatagramTransport]=None + ) -> None: + + self._loop = asyncio.get_event_loop() + self._running = True + + self._semaphore = asyncio.Semaphore(self._max_concurrency) + + self._compressor = zstandard.ZstdCompressor() + self._decompressor = zstandard.ZstdDecompressor() + + if self.connected is False and worker_socket is None: + self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.udp_socket.bind(( + self.host, + self.port + )) + + self.udp_socket.setblocking(False) + + elif self.connected is False and worker_socket: + self.udp_socket = worker_socket + host, port = worker_socket.getsockname() + self.host = host + self.port = port + + elif self.connected is False: + self._transport = worker_transport + + address_info: Tuple[str, int] = self._transport.get_extra_info('sockname') + self.udp_socket: socket.socket = self._transport.get_extra_info('socket') + + host, port = address_info + self.host = host + self.port = port + + self.connected = True + self._cleanup_task = self._loop.create_task(self._cleanup()) + + if self.connected is False and cert_path and key_path: + self._udp_ssl_context = self._create_udp_ssl_context( + cert_path=cert_path, + key_path=key_path, + ) + + self.udp_socket = self._udp_ssl_context.wrap_socket(self.udp_socket) + + if self.connected is False: + server = self._loop.create_datagram_endpoint( + lambda: MercurySyncUDPProtocol( + self.read + ), + sock=self.udp_socket + ) + + transport, _ = await server + + self._transport = transport + + self._cleanup_task = self._loop.create_task(self._cleanup()) + + def _create_udp_ssl_context( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None + ) -> ssl.SSLContext: + + if self._udp_cert_path is None: + self._udp_cert_path = cert_path + + if self._udp_key_path is None: + self._udp_key_path = key_path + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS) + ssl_ctx.options |= ssl.OP_NO_TLSv1 + ssl_ctx.options |= ssl.OP_NO_TLSv1_1 + ssl_ctx.options |= ssl.OP_SINGLE_DH_USE + ssl_ctx.options |= ssl.OP_SINGLE_ECDH_USE + ssl_ctx.load_cert_chain(cert_path, keyfile=key_path) + ssl_ctx.load_verify_locations(cafile=cert_path) + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.VerifyMode.CERT_REQUIRED + ssl_ctx.set_ciphers('ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384') + + return ssl_ctx + + async def _cleanup(self): + while self._running: + self._sleep_task = asyncio.create_task( + asyncio.sleep(self._cleanup_interval) + ) + + await self._sleep_task + + for pending in list(self._pending_responses): + if pending.done() or pending.cancelled(): + try: + await pending + + except (Exception, socket.error): + # await self._reset_connection() + pass + + if len(self._pending_responses) > 0: + self._pending_responses.pop() + + async def send( + self, + event_name: str, + data: Any, + addr: Tuple[str, int] + ) -> Tuple[int, Dict[str, Any]]: + + item = pickle.dumps(( + 'request', + self.id_generator.generate(), + event_name, + data + ), protocol=pickle.HIGHEST_PROTOCOL) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + try: + self._transport.sendto(compressed, addr) + + waiter = self._loop.create_future() + self._waiters[event_name].put_nowait(waiter) + + ( + _, + shard_id, + _, + response_data, + _, + _ + ) = await asyncio.wait_for( + waiter, + timeout=self._request_timeout + ) + + return ( + shard_id, + response_data + ) + + except (Exception, socket.error): + + return ( + self.id_generator.generate(), + Message( + host=self.host, + port=self.port, + error='Request timed out.' + ) + ) + + async def send_bytes( + self, + event_name: str, + data: bytes, + addr: Tuple[str, int] + ) -> bytes: + + try: + self._transport.sendto(data, addr) + + waiter = self._loop.create_future() + self._waiters[event_name].put_nowait(waiter) + + return await asyncio.wait_for( + waiter, + timeout=self._request_timeout + ) + + except (Exception, socket.error): + return b'Request timed out.' + + async def stream( + self, + event_name: str, + data: Any, + addr: Tuple[str, int] + ) -> AsyncIterable[Tuple[int, Dict[str, Any]]]: + + item = pickle.dumps(( + 'stream', + self.id_generator.generate(), + event_name, + data + ), protocol=pickle.HIGHEST_PROTOCOL) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + + try: + self._transport.sendto(compressed, addr) + + waiter = self._loop.create_future() + self._waiters[event_name].put_nowait(waiter) + + await asyncio.wait_for( + waiter, + timeout=self._request_timeout + ) + + for item in self.queue[event_name]: + ( + _, + shard_id, + _, + response_data, + _, + _ + ) = item + + yield ( + shard_id, + response_data + ) + + self.queue.clear() + + except (Exception, socket.error): + + yield ( + self.id_generator.generate(), + Message( + host=self.host, + port=self.port, + error='Request timed out.' + ) + ) + + def read( + self, + data: bytes, + addr: Tuple[str, int] + ) -> None: + + decrypted = self._encryptor.decrypt( + self._decompressor.decompress(data) + ) + + result: Tuple[ + str, + int, + float, + Any + ] = pickle.loads(decrypted) + + ( + message_type, + shard_id, + event_name, + payload + ) = result + + incoming_host, incoming_port = addr + + if message_type == 'request': + + self._pending_responses.append( + asyncio.create_task( + self._read( + event_name, + self.events.get(event_name)( + shard_id, + self.parsers[event_name](**payload) + ), + addr + ) + ) + ) + + elif message_type == 'stream': + self._pending_responses.append( + asyncio.create_task( + self._read_iterator( + event_name, + self.events.get(event_name)( + shard_id, + self.parsers[event_name](**payload) + ), + addr + ) + ) + ) + + else: + self._pending_responses.append( + asyncio.create_task( + self._receive_response( + event_name, + message_type, + shard_id, + payload, + incoming_host, + incoming_port + ) + ) + ) + + + async def _receive_response( + self, + event_name: str, + message_type: str, + shard_id: int, + payload: bytes, + incoming_host: str, + incoming_port: int + ): + event_waiter = self._waiters[event_name] + + if bool(event_waiter): + waiter: asyncio.Future = await event_waiter.get() + + try: + + waiter.set_result(( + message_type, + shard_id, + event_name, + payload, + incoming_host, + incoming_port + )) + + except asyncio.InvalidStateError: + pass + + async def _reset_connection(self): + + try: + + await self.close() + await self.connect_async( + cert_path=self._udp_cert_path, + key_path=self._udp_key_path + ) + + except Exception: + pass + + async def _read( + self, + event_name: str, + coroutine: Coroutine, + addr: Tuple[str, int] + ) -> Coroutine[Any, Any, None]: + + try: + + response: Message = await coroutine + + item = pickle.dumps( + ( + 'response', + self.id_generator.generate(), + event_name, + response.to_data() + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + + self._transport.sendto(compressed, addr) + + except (Exception, socket.error): + pass + # await self._reset_connection() + + + async def _read_iterator( + self, + event_name: str, + coroutine: AsyncIterable[Message], + addr: Tuple[str, int] + ) -> Coroutine[Any, Any, None]: + async for response in coroutine: + + try: + + item = pickle.dumps( + ( + 'response', + self.id_generator.generate(), + event_name, + response.to_data() + ), + protocol=pickle.HIGHEST_PROTOCOL + ) + + encrypted_message = self._encryptor.encrypt(item) + compressed = self._compressor.compress(encrypted_message) + self._transport.sendto(compressed, addr) + + except Exception: + pass + # await self._reset_connection() + + async def close(self) -> None: + self._running = False + self._transport.abort() + + if self._cleanup_task: + self._cleanup_task.cancel() + if self._cleanup_task.cancelled() is False: + try: + self._sleep_task.cancel() + if not self._sleep_task.cancelled(): + await self._sleep_task + + except asyncio.CancelledError: + pass + + except Exception: + pass + + try: + + await self._cleanup_task + + except Exception: + pass \ No newline at end of file diff --git a/hyperscale/distributed/connection/udp/mercury_sync_udp_multicast_connection.py b/hyperscale/distributed/connection/udp/mercury_sync_udp_multicast_connection.py new file mode 100644 index 0000000..e2f7cb2 --- /dev/null +++ b/hyperscale/distributed/connection/udp/mercury_sync_udp_multicast_connection.py @@ -0,0 +1,120 @@ + +from __future__ import annotations + +import asyncio +import socket +from typing import ( + Optional, +) + +import zstandard +from dtls import do_patch + +from hyperscale.distributed.connection.udp.protocols import MercurySyncUDPProtocol +from hyperscale.distributed.env import Env + +from .mercury_sync_udp_connection import MercurySyncUDPConnection + +do_patch() + + +class MercurySyncUDPMulticastConnection(MercurySyncUDPConnection): + """Implementation of Zeroconf Multicast DNS Service Discovery + Supports registration, unregistration, queries and browsing. + """ + def __init__( + self, + host: str, + port: int, + instance_id: int, + env: Env, + ): + super().__init__( + host, + port, + instance_id, + env + ) + + self._mcast_group = env.MERCURY_SYNC_MULTICAST_GROUP + + if self._mcast_group is None: + self.group = ('', self.port) + + else: + self.group = (self._mcast_group, self.port) + + async def connect_async( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None + ) -> None: + + self._loop = asyncio.get_event_loop() + self._running = True + + self._semaphore = asyncio.Semaphore(self._max_concurrency) + + self._compressor = zstandard.ZstdCompressor() + self._decompressor = zstandard.ZstdDecompressor() + + if worker_socket is None: + self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except Exception: + pass + + self.udp_socket.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_TTL, 255) + self.udp_socket.setsockopt(socket.SOL_IP, socket.IP_MULTICAST_LOOP, 1) + + try: + self.udp_socket.bind(self.group) + except ConnectionRefusedError: + pass + + except OSError: + pass + + self.udp_socket.setsockopt( + socket.SOL_IP, + socket.IP_MULTICAST_IF, + socket.inet_aton(self.host) + socket.inet_aton('0.0.0.0') + ) + + if self._mcast_group is not None: + self.udp_socket.setsockopt( + socket.SOL_IP, + socket.IP_ADD_MEMBERSHIP, + socket.inet_aton(self.udp_socket) + socket.inet_aton('0.0.0.0') + ) + + self.udp_socket.setblocking(False) + + else: + self.udp_socket = worker_socket + + + if cert_path and key_path: + self._udp_ssl_context = self._create_udp_ssl_context( + cert_path=cert_path, + key_path=key_path, + ) + + self.udp_socket = self._udp_ssl_context.wrap_socket(self.udp_socket) + + server = self._loop.create_datagram_endpoint( + lambda: MercurySyncUDPProtocol( + self.read + ), + sock=self.udp_socket + ) + + transport, _ = await server + self._transport = transport + + self._cleanup_task = self._loop.create_task(self._cleanup()) diff --git a/hyperscale/distributed/connection/udp/protocols/__init__.py b/hyperscale/distributed/connection/udp/protocols/__init__.py new file mode 100644 index 0000000..62026f6 --- /dev/null +++ b/hyperscale/distributed/connection/udp/protocols/__init__.py @@ -0,0 +1 @@ +from .mercury_sync_udp_protocol import MercurySyncUDPProtocol \ No newline at end of file diff --git a/hyperscale/distributed/connection/udp/protocols/mercury_sync_udp_protocol.py b/hyperscale/distributed/connection/udp/protocols/mercury_sync_udp_protocol.py new file mode 100644 index 0000000..2847ce3 --- /dev/null +++ b/hyperscale/distributed/connection/udp/protocols/mercury_sync_udp_protocol.py @@ -0,0 +1,32 @@ +import asyncio +from collections import deque +from typing import Callable, Tuple, Deque + + +class MercurySyncUDPProtocol(asyncio.DatagramProtocol): + def __init__( + self, + callback: Callable[ + [ + bytes, + Tuple[str, int] + ], + bytes + ] + ): + super().__init__() + self.callback = callback + + def connection_made(self, transport) -> str: + self.transport = transport + + def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None: + # Here is where you would push message to whatever methods/classes you want. + # data: Message = pickle.loads(lzma.decompress(unpacked)) + self.callback( + data, + addr + ) + + def connection_lost(self, exc: Exception | None) -> None: + pass diff --git a/hyperscale/distributed/discovery/__init__.py b/hyperscale/distributed/discovery/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/discovery/dns/__init__.py b/hyperscale/distributed/discovery/dns/__init__.py new file mode 100644 index 0000000..f0d4444 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/__init__.py @@ -0,0 +1 @@ +from .registrar import Registrar diff --git a/hyperscale/distributed/discovery/dns/core/__init__.py b/hyperscale/distributed/discovery/dns/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/discovery/dns/core/cache/__init__.py b/hyperscale/distributed/discovery/dns/core/cache/__init__.py new file mode 100644 index 0000000..e582450 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/cache/__init__.py @@ -0,0 +1 @@ +from .cache_node import CacheNode \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/cache/cache_node.py b/hyperscale/distributed/discovery/dns/core/cache/cache_node.py new file mode 100644 index 0000000..12783c8 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/cache/cache_node.py @@ -0,0 +1,82 @@ +from typing import Dict, Iterable, Union + +from hyperscale.distributed.discovery.dns.core.record import Record, RecordType +from hyperscale.distributed.discovery.dns.core.record.record_data_types import ( + RecordData, +) + +from .cache_value import CacheValue + + +class CacheNode: + def __init__(self): + self.children: Dict[str, CacheNode] = {} + self.data = CacheValue() + + def get(self, fqdn: str, touch: bool = False): + current = self + keys = reversed(fqdn.split('.')) + for key in keys: + child = current.children.get(key) + + if child is None: + child = current.children.get('*') + + if child is None and touch is False: + return None + + elif child is None and touch: + child = CacheNode() + current.children[key] = child + + current = child + return current.data + + def query( + self, + fqdn: str, + record_type: Union[RecordType, Iterable[RecordType]] + ): + if isinstance(record_type, RecordType): + value = self.get(fqdn) + if value is not None: + yield from value.get(record_type) + else: + for rtype in record_type: + yield from self.query(fqdn, rtype) + + def add( + self, + fqdn: str = None, + record_type: RecordType = None, + data: Union[RecordData, bytes, Iterable] = None, + ttl=-1, + record: Record = None + ): + if record is None: + + if isinstance(data, bytes): + _, rdata = Record.load_rdata(record_type, data, 0, len(data)) + + elif isinstance(data, RecordData): + rdata = data + + else: + rdata = Record.create_rdata(record_type, *data) + + record = Record( + name=fqdn, + data=rdata, + record_type=record_type, + ttl=ttl + ) + + value = self.get(record.name, True) + value.add(record) + + def iter_values(self) -> Iterable[Record]: + + yield from self.data.get(RecordType.ANY) + + for child in self.children.values(): + yield from child.iter_values() diff --git a/hyperscale/distributed/discovery/dns/core/cache/cache_value.py b/hyperscale/distributed/discovery/dns/core/cache/cache_value.py new file mode 100644 index 0000000..4bab359 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/cache/cache_value.py @@ -0,0 +1,43 @@ +import time +from typing import Dict, Iterable, Tuple + +from hyperscale.distributed.discovery.dns.core.record import Record, RecordType +from hyperscale.distributed.discovery.dns.core.record.record_data_types import ( + RecordData, +) + + +class CacheValue: + def __init__(self): + self.data: Dict[RecordData, Dict[Tuple[int, RecordData], Record]] = {} + + def check_ttl(self, record: Record): + return record.ttl < 0 or record.timestamp + record.ttl >= time.time() + + def get( + self, + record_type: RecordType + ) -> Iterable[Record]: + + if record_type == RecordType.ANY: + for qt in self.data.keys(): + yield from self.get(qt) + + results = self.data.get(record_type) + if results is not None: + + keys = list(results.keys()) + for key in keys: + record = results[key] + + if self.check_ttl(record): + yield record + + else: + results.pop(key, None) + + def add(self, record: Record): + if self.check_ttl(record): + results = self.data.setdefault(record.record_type, {}) + results[record.data] = record + diff --git a/hyperscale/distributed/discovery/dns/core/config/__init__.py b/hyperscale/distributed/discovery/dns/core/config/__init__.py new file mode 100644 index 0000000..cb5f887 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/config/__init__.py @@ -0,0 +1,8 @@ +import os + +from .root import * + +if os.name == 'nt': + from .nt import get_nameservers +elif os.name == 'posix': + from .posix import get_nameservers diff --git a/hyperscale/distributed/discovery/dns/core/config/nt.py b/hyperscale/distributed/discovery/dns/core/config/nt.py new file mode 100644 index 0000000..a69aba0 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/config/nt.py @@ -0,0 +1,68 @@ +''' +This module load nameservers from Windows Registry. +''' + +import winreg + + +def _nt_read_key(hlm, key): + + regkey = winreg.OpenKey(hlm, key) + + try: + value, _rtype = winreg.QueryValueEx(regkey, 'NameServer') + if not value: + value, _rtype = winreg.QueryValueEx(regkey, 'DhcpNameServer') + except Exception: + value = None + regkey.Close() + if value: + sep = ',' if ',' in value else ' ' + return value.split(sep) + + +def _nt_is_enabled(hlm, guid): + connection_key = winreg.OpenKey( + hlm, + r'SYSTEM\CurrentControlSet\Control\Network\{4D36E972-E325-11CE-BFC1-08002BE10318}\%s\Connection' + % guid) + pnp_id, _ttype = winreg.QueryValueEx(connection_key, 'PnpInstanceID') + device_key = winreg.OpenKey(hlm, + r'SYSTEM\CurrentControlSet\Enum\%s' % pnp_id) + try: + flags, _ttype = winreg.QueryValueEx(device_key, 'ConfigFlags') + return not flags & 0x1 + except Exception: + return False + finally: + device_key.Close() + connection_key.Close() + + +def get_nameservers(): + ''' + Get nameservers from Windows Registry. + ''' + nameservers = [] + hlm = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE) + servers = _nt_read_key( + hlm, r'SYSTEM\CurrentControlSet\Services\Tcpip\Parameters') + if servers is not None: + nameservers.extend(servers) + interfaces = winreg.OpenKey( + hlm, r'SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces') + i = 0 + while True: + try: + guid = winreg.EnumKey(interfaces, i) + i += 1 + if not _nt_is_enabled(hlm, guid): + continue + servers = _nt_read_key(interfaces, guid) + if servers is not None: + nameservers.extend(servers) + except EnvironmentError: + break + interfaces.Close() + hlm.Close() + return nameservers diff --git a/hyperscale/distributed/discovery/dns/core/config/posix.py b/hyperscale/distributed/discovery/dns/core/config/posix.py new file mode 100644 index 0000000..852d306 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/config/posix.py @@ -0,0 +1,20 @@ +from pathlib import Path + + +def get_nameservers(filename='/etc/resolv.conf'): + + nameservers = [] + + for line in Path(filename).read_text().splitlines(): + + if line.startswith('#'): + continue + + parts = line.split() + if len(parts) < 2: + continue + + if parts[0] == 'nameserver': + nameservers.append(parts[1]) + + return nameservers diff --git a/hyperscale/distributed/discovery/dns/core/config/root.py b/hyperscale/distributed/discovery/dns/core/config/root.py new file mode 100644 index 0000000..cc7fb8e --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/config/root.py @@ -0,0 +1,93 @@ +''' +Cache module. +''' + +import json +import os +from pathlib import Path +from urllib import request + +from hyperscale.distributed.discovery.dns.core.record import ( + Record, + RecordType, + RecordTypesMap, +) + +__all__ = [ + 'core_config', + 'get_name_cache', + 'get_root_servers', +] + +CONFIG_DIR = os.environ.get('MERCURY_SYNC_DNS_CONFIG_DIR', + os.path.expanduser('~/.config/mercury_dns')) +os.makedirs(CONFIG_DIR, exist_ok=True) +CACHE_FILE = os.path.join(CONFIG_DIR, 'named.cache.txt') + +try: + with open(os.path.join(CONFIG_DIR, 'config.json')) as f: + user_config = json.load(f) +except Exception: + user_config = None + +core_config = { + 'default_nameservers': [ + '8.8.8.8', + '8.8.4.4', + ], +} +if user_config is not None: + core_config.update(user_config) + del user_config + + +def get_nameservers(): + return [] + + +def get_name_cache( + url='ftp://rs.internic.net/domain/named.cache', + filename=CACHE_FILE, + timeout=10 +): + + try: + res = request.urlopen(url, timeout=timeout) + + except Exception: + pass + + else: + with open(filename, 'wb') as f: + f.write(res.read()) + + +def get_root_servers(filename=CACHE_FILE): + + if not os.path.isfile(filename): + get_name_cache(filename=filename) + + if not os.path.isfile(filename): + return + for line in Path(filename).read_text().splitlines(): + + if line.startswith(';'): + continue + + parts = line.lower().split() + if len(parts) < 4: + continue + + name = parts[0].rstrip('.') + + types_map = RecordTypesMap() + record_type = types_map.types_by_code.get(parts[2], RecordType.NONE) + + data_str = parts[3].rstrip('.') + data = Record.create_rdata(record_type, data_str) + yield Record( + name=name, + record_type=record_type, + data=data, + ttl=-1, + ) diff --git a/hyperscale/distributed/discovery/dns/core/exceptions/__init__.py b/hyperscale/distributed/discovery/dns/core/exceptions/__init__.py new file mode 100644 index 0000000..bf6f43d --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/exceptions/__init__.py @@ -0,0 +1,2 @@ +from .dns_error import DNSError +from .invalid_service_url_error import InvalidServiceURLError \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/exceptions/dns_error.py b/hyperscale/distributed/discovery/dns/core/exceptions/dns_error.py new file mode 100644 index 0000000..c43045c --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/exceptions/dns_error.py @@ -0,0 +1,14 @@ +class DNSError(Exception): + errors = { + 1: 'Format error: bad request', + 2: 'Server failure: error occurred', + 3: 'Name error: not exist', + 4: 'Not implemented: query type not supported', + 5: 'Refused: policy reasons' + } + + def __init__(self, code: int, message: str = None): + message = self.errors.get(code, + message) or 'Unknown reply code: %d' % code + super().__init__(message) + self.code = code diff --git a/hyperscale/distributed/discovery/dns/core/exceptions/invalid_service_url_error.py b/hyperscale/distributed/discovery/dns/core/exceptions/invalid_service_url_error.py new file mode 100644 index 0000000..f1d26be --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/exceptions/invalid_service_url_error.py @@ -0,0 +1,6 @@ +class InvalidServiceURLError(Exception): + + def __init__(self, url: str) -> None: + super().__init__( + f'Err. - {url} does not match required patter (instance_name)._(service_name)._(udp|tcp).(domain_name)' + ) \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/exceptions/utils/__init__.py b/hyperscale/distributed/discovery/dns/core/exceptions/utils/__init__.py new file mode 100644 index 0000000..360e03f --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/exceptions/utils/__init__.py @@ -0,0 +1 @@ +from .get_bits import get_bits \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/exceptions/utils/get_bits.py b/hyperscale/distributed/discovery/dns/core/exceptions/utils/get_bits.py new file mode 100644 index 0000000..8c858c9 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/exceptions/utils/get_bits.py @@ -0,0 +1,9 @@ +def get_bits( + num: int, + bit_len: int +): + + high = num >> bit_len + low = num - (high << bit_len) + + return low, high \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/nameservers/__init__.py b/hyperscale/distributed/discovery/dns/core/nameservers/__init__.py new file mode 100644 index 0000000..389e787 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/nameservers/__init__.py @@ -0,0 +1 @@ +from .nameserver import NameServer \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/nameservers/exceptions.py b/hyperscale/distributed/discovery/dns/core/nameservers/exceptions.py new file mode 100644 index 0000000..9b8e0f8 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/nameservers/exceptions.py @@ -0,0 +1,2 @@ +class NoNameServer(Exception): + pass diff --git a/hyperscale/distributed/discovery/dns/core/nameservers/nameserver.py b/hyperscale/distributed/discovery/dns/core/nameservers/nameserver.py new file mode 100644 index 0000000..68bd44a --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/nameservers/nameserver.py @@ -0,0 +1,56 @@ +import time +from typing import Iterable, List, Union + +from hyperscale.distributed.discovery.dns.core.url import URL + +from .exceptions import NoNameServer + + +class NameServer: + + def __init__( + self, + urls: List[Union[str, URL]] + ): + self.data = [ + URL(url) if isinstance(url, str) else url for url in urls + ] + + + self._failures = [0] * len(self.data) + self.timestamp = 0 + self._update() + + def __bool__(self): + return len(self.data) > 0 + + def __iter__(self): + return iter(self.data) + + def iter(self) -> Iterable[URL]: + if not self.data: + raise NoNameServer() + + return iter(self.data) + + def _update(self): + + if time.time() > self.timestamp + 60: + self.timestamp = time.time() + + self._sorted = list( + self.data[i] for i in sorted( + range(len(self.data)), + key=lambda i: self._failures[i] + ) + ) + + self._failures = [0] * len(self.data) + + def success(self, item): + self._update() + + def fail(self, item): + self._update() + index = self.data.index(item) + self._failures[index] += 1 diff --git a/hyperscale/distributed/discovery/dns/core/random/__init__.py b/hyperscale/distributed/discovery/dns/core/random/__init__.py new file mode 100644 index 0000000..b4a6bc9 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/random/__init__.py @@ -0,0 +1 @@ +from .random_id_generator import RandomIDGenerator \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/random/random_id_generator.py b/hyperscale/distributed/discovery/dns/core/random/random_id_generator.py new file mode 100644 index 0000000..f407f3b --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/random/random_id_generator.py @@ -0,0 +1,87 @@ +import random +from typing import Union, Tuple + + +class RandomIDGenerator: + def __init__( + self, + start: int=0, + stop: int=65535 + ): + self.data = [ + (start, stop) + ] + + def generate(self): + + index = random.randrange( + len(self.data) + ) + + rng = self.data[index] + id = random.randrange( + rng[0], + rng[1] + 1 + ) + + rngs = [] + if id > rng[0]: + rngs.append((rng[0], id - 1)) + + if id < rng[1]: + rngs.append((id + 1, rng[1])) + + self.data[index:index + 1] = rngs + + return id + + def put(self, value: int) -> None: + + size = len(self.data) + + for index, rng in enumerate(self.data): + if value < rng[0]: + break + + else: + index = size + + last_rng: Union[Tuple[int, int], None] = None + next_rng: Union[Tuple[int, int], None] = None + + if index > 0: + last_rng = self.data[index - 1] + + if index < size: + next_rng = self.data[index] + + if last_rng is not None and last_rng[1] == value - 1: + last_rng = last_rng[0], value + + if next_rng is not None and next_rng[0] == value + 1: + next_rng = value, next_rng[1] + + has_last_range = last_rng is not None + has_next_range = next_rng is not None + + if has_last_range and has_next_range and last_rng[1] == next_rng[0]: + last_rng = last_rng[0], next_rng[1] + next_rng = None + + rngs = [] + if last_rng is not None: + rngs.append(last_rng) + + not_last_range = last_rng is None or last_rng[1] < value + not_next_range = next_rng is None or value < next_rng[0] + + if not_last_range and not_next_range: + rngs.append((value, value)) + + if next_rng is not None: + rngs.append(next_rng) + + start = max(0, index - 1) + end = min(index + 1, size) + + self.data[start:end] = rngs diff --git a/hyperscale/distributed/discovery/dns/core/record/__init__.py b/hyperscale/distributed/discovery/dns/core/record/__init__.py new file mode 100644 index 0000000..0e5cff6 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/__init__.py @@ -0,0 +1,6 @@ +from .query_type import QueryType +from .record import Record +from .record_data_types import ( + RecordType, + RecordTypesMap +) \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/query_type.py b/hyperscale/distributed/discovery/dns/core/record/query_type.py new file mode 100644 index 0000000..f946673 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/query_type.py @@ -0,0 +1,17 @@ +from enum import Enum + +class QueryType(Enum): + REQUEST=0 + RESPONSE=1 + + @classmethod + def by_value(cls, value: int): + value_map = { + 0: QueryType.REQUEST, + 1: QueryType.RESPONSE + } + + return value_map.get( + value, + QueryType.REQUEST + ) diff --git a/hyperscale/distributed/discovery/dns/core/record/record.py b/hyperscale/distributed/discovery/dns/core/record/record.py new file mode 100644 index 0000000..27af1d3 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record.py @@ -0,0 +1,227 @@ +import io +import struct +import time +from typing import Dict, Tuple + +from hyperscale.distributed.discovery.dns.core.record.record_data_types.utils import ( + load_domain_name, + pack_domain_name, + pack_string, +) + +from .query_type import QueryType +from .record_data_types import ( + AAAARecordData, + ARecordData, + CNAMERecordData, + MXRecordData, + NAPTRRecordData, + NSRecordData, + PTRRecordData, + RecordData, + RecordType, + RecordTypesMap, + SOARecordData, + SRVRecordData, + TXTRecordData, + UnsupportedRecordData, +) + +MAXAGE = 3600000 + + + +class Record: + + record_types: Dict[ + RecordType, + RecordData + ] = { + RecordType.A: ARecordData, + RecordType.AAAA: AAAARecordData, + RecordType.CNAME: CNAMERecordData, + RecordType.MX: MXRecordData, + RecordType.NAPTR: NAPTRRecordData, + RecordType.NS: NSRecordData, + RecordType.PTR: PTRRecordData, + RecordType.SOA: SOARecordData, + RecordType.SRV: SRVRecordData, + RecordType.TXT: TXTRecordData + } + + def __init__( + self, + query_type: QueryType = QueryType.REQUEST, + name: str = '', + record_type: RecordType = RecordType.ANY, + qclass: int = 1, + ttl: int = 0, + data: Tuple[int, RecordData] = None + ): + self.query_type = query_type + self.name = name + self.record_type = record_type + self.qclass = qclass + + self.ttl = ttl # 0 means item should not be cached + self.data = data + self.timestamp = int(time.time()) + + self.types_map = RecordTypesMap() + + @classmethod + def create_rdata( + cls, + record_type: RecordType, + *args + ) -> RecordData: + + record_data = cls.record_types.get(record_type) + + if record_data is None: + return UnsupportedRecordData( + record_type, + *args + ) + + return record_data( + *args + ) + + @classmethod + def load_rdata( + cls, + record_type: RecordType, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, RecordData]: + '''Load RData from a byte sequence.''' + record_data = cls.record_types.get(record_type) + if record_data is None: + return UnsupportedRecordData.load( + data, + cursor_position, + size, + record_type + ) + + return record_data.load( + data, + cursor_position, + size + ) + + def copy( + self, + **kwargs + ): + return Record( + query_type=kwargs.get('query_type', self.query_type), + name=kwargs.get('name', self.name), + record_type=kwargs.get('record_type', self.record_type), + qclass=kwargs.get('qclass', self.qclass), + ttl=kwargs.get('ttl', self.ttl), + data=kwargs.get('data', self.data) + ) + + def parse( + self, + data: bytes, + cursor_position: int + ): + cursor_position, self.name = load_domain_name( + data, + cursor_position + ) + + record_type, self.qclass = struct.unpack( + '!HH', + data[cursor_position:cursor_position + 4] + ) + + self.record_type = self.types_map.types_by_code.get( + record_type + ) + + cursor_position += 4 + if self.query_type == QueryType.RESPONSE: + self.timestamp = int(time.time()) + self.ttl, size = struct.unpack( + '!LH', + data[cursor_position:cursor_position + 6] + ) + + cursor_position += 6 + + _, self.data = Record.load_rdata( + self.record_type, + data, + cursor_position, + size + ) + + cursor_position += size + + return cursor_position + + def pack( + self, + names, + offset=0 + ): + buf = io.BytesIO() + + buf.write( + pack_domain_name( + self.name, + names, + offset + ) + ) + + buf.write( + struct.pack( + '!HH', + self.record_type.value, + self.qclass + ) + ) + + if self.query_type == QueryType.RESPONSE: + + if self.ttl < 0: + ttl = MAXAGE + + else: + now = int(time.time()) + self.ttl -= now - self.timestamp + + if self.ttl < 0: + self.ttl = 0 + + self.timestamp = now + ttl = self.ttl + + buf.write( + struct.pack( + '!L', + ttl + ) + ) + + data_str = b''.join( + self.data.dump( + names, + offset + buf.tell() + ) + ) + + buf.write( + pack_string( + data_str, + '!H' + ) + ) + + return buf.getvalue() diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/__init__.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/__init__.py new file mode 100644 index 0000000..9191676 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/__init__.py @@ -0,0 +1,17 @@ +from .a_record_data import ARecordData +from .aaaa_record_data import AAAARecordData +from .cname_record_data import CNAMERecordData +from .domain_record_data import DomainRecordData +from .mx_record_data import MXRecordData +from .naptr_record_data import NAPTRRecordData +from .ns_record_data import NSRecordData +from .ptr_record_data import PTRRecordData +from .record_data import RecordData +from .record_types import ( + RecordType, + RecordTypesMap +) +from .soa_record_data import SOARecordData +from .srv_record_data import SRVRecordData +from .txt_record_data import TXTRecordData +from .unsupported_record_data import UnsupportedRecordData \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/a_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/a_record_data.py new file mode 100644 index 0000000..b65dfab --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/a_record_data.py @@ -0,0 +1,36 @@ +from __future__ import annotations +import socket +from typing import Dict, Iterable, Tuple +from .record_data import RecordData +from .record_types import RecordType + + +class ARecordData(RecordData): + '''A record''' + + def __init__(self, data: str): + super().__init__( + RecordType.A, + data=data + ) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, ARecordData]: + + ip = socket.inet_ntoa( + data[cursor_position:cursor_position + size] + ) + + return cursor_position + size, ARecordData(ip) + + def dump( + self, + names: Dict[str, int], + offset: int + ) -> Iterable[bytes]: + yield socket.inet_aton(self.data) diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/aaaa_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/aaaa_record_data.py new file mode 100644 index 0000000..b2dfa26 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/aaaa_record_data.py @@ -0,0 +1,36 @@ +from __future__ import annotations +import socket +from typing import Dict, Iterable, Tuple +from .record_data import RecordData +from .record_types import RecordType + + +class AAAARecordData(RecordData): + + def __init__(self, data: str): + super().__init__( + RecordType.AAAA, + data=data + ) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, AAAARecordData]: + + ip = socket.inet_ntop( + socket.AF_INET6, + data[cursor_position:cursor_position + size] + ) + + return cursor_position + size, AAAARecordData(ip) + + def dump( + self, + names: Dict[str, int], + offset: int + ) -> Iterable[bytes]: + yield socket.inet_pton(socket.AF_INET6, self.data) diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/cname_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/cname_record_data.py new file mode 100644 index 0000000..ed5cdb2 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/cname_record_data.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from typing import Tuple +from .domain_record_data import DomainRecordData +from .record_types import RecordType +from .utils import load_domain_name + + +class CNAMERecordData(DomainRecordData): + + def __init__(self, data: str): + super().__init__( + RecordType.CNAME, + data=data + ) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, CNAMERecordData]: + + cursor_position, domain = load_domain_name( + data, + cursor_position + ) + + return cursor_position, CNAMERecordData(domain) \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/domain_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/domain_record_data.py new file mode 100644 index 0000000..75aaa60 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/domain_record_data.py @@ -0,0 +1,39 @@ +from __future__ import annotations +from typing import Dict, Iterable, Tuple, Optional +from .record_data import RecordData +from .record_types import RecordType +from .utils import ( + pack_domain_name +) + + +class DomainRecordData(RecordData): + '''A record''' + + def __init__( + self, + record_type: RecordType, + data: Optional[str]=None + ): + super().__init__( + record_type, + data=data + ) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, DomainRecordData]: + raise NotImplementedError('Err. - Not implemented for DomainRecordData type') + + def dump( + self, + names: Dict[str, int], + offset: int + ) -> Iterable[bytes]: + yield pack_domain_name(self.data, names, offset + 2) + + diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/mx_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/mx_record_data.py new file mode 100644 index 0000000..8d02f82 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/mx_record_data.py @@ -0,0 +1,69 @@ +from __future__ import annotations +import struct +from typing import Dict, Iterable, Tuple +from .record_data import RecordData +from .record_types import RecordType +from .utils import ( + load_domain_name, + pack_domain_name +) + + +class MXRecordData(RecordData): + '''A record''' + + def __init__(self, *args): + super().__init__( + RecordType.MX, + data=args + ) + + ( + preference, + exchange + ) = args + + self.preference = preference + self.exchange = exchange + + def __repr__(self): + return '<%s-%s: %s>' % (self.type_name, self.preference, self.exchange) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, MXRecordData]: + + preference, = struct.unpack( + '!H', + data[cursor_position:cursor_position + 2] + ) + + cursor_position, exchange = load_domain_name(data, cursor_position + 2) + + return cursor_position, MXRecordData(preference, exchange) + + def dump(self, names: Dict[str, int], offset: int) -> Iterable[bytes]: + + preference = struct.pack( + '!H', + self.preference + ) + + domain_name = pack_domain_name( + self.exchange, + names, + offset + 4 + ) + + record_data = [ + preference, + domain_name + ] + + for data in record_data: + yield data + diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/naptr_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/naptr_record_data.py new file mode 100644 index 0000000..31f005e --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/naptr_record_data.py @@ -0,0 +1,92 @@ +from __future__ import annotations +import struct +from typing import Dict, Iterable, Tuple +from .record_data import RecordData +from .record_types import RecordType +from .utils import ( + load_domain_name, + pack_domain_name +) + + +class NAPTRRecordData(RecordData): + '''A record''' + + def __init__(self, *args): + super().__init__( + RecordType.SRV, + data=args + ) + + ( + order, + preference, + flags, + service, + regexp, + replacement + ) = args + + self.order = order + self.preference = preference + self.flags = flags + self.service = service + self.regexp = regexp + self.replacement = replacement + + def __repr__(self): + return '<%s-%s-%s: %s %s %s %s>' % ( + self.type_name, + self.order, + self.preference, + self.flags, + self.service, + self.regexp, + self.replacement + ) + + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, NAPTRRecordData]: + pos = cursor_position + + order, preference = struct.unpack('!HH', data[pos:pos + 4]) + pos += 4 + + length = data[pos] + pos += 1 + + flags = data[pos:pos + length].decode() + pos += length + + length = data[pos] + pos += 1 + + service = data[pos:pos + length].decode() + pos += length + + length = data[pos] + pos += 1 + + regexp = data[pos:pos + length].decode() + pos += length + + cursor_position, replacement = load_domain_name(data, pos) + return cursor_position, NAPTRRecordData( + order, + preference, + flags, + service, + regexp, + replacement + ) + + def dump(self, names: Dict[str, int], offset: int) -> Iterable[bytes]: + raise NotImplementedError + + diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/ns_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/ns_record_data.py new file mode 100644 index 0000000..bcf1c5d --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/ns_record_data.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from typing import Tuple +from .domain_record_data import DomainRecordData +from .record_types import RecordType +from .utils import load_domain_name + + +class NSRecordData(DomainRecordData): + + def __init__(self, data: str): + super().__init__( + RecordType.NS, + data=data + ) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, NSRecordData]: + + cursor_position, domain = load_domain_name( + data, + cursor_position + ) + + return cursor_position, NSRecordData(domain) \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/ptr_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/ptr_record_data.py new file mode 100644 index 0000000..4cd8d45 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/ptr_record_data.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from typing import Tuple +from .domain_record_data import DomainRecordData +from .record_types import RecordType +from .utils import load_domain_name + + +class PTRRecordData(DomainRecordData): + + def __init__(self, data: str): + super().__init__( + RecordType.PTR, + data=data + ) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, PTRRecordData]: + + cursor_position, domain = load_domain_name( + data, + cursor_position + ) + + return cursor_position, PTRRecordData(domain) + \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/record_data.py new file mode 100644 index 0000000..914aca7 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/record_data.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import Dict, Iterable, Optional, Tuple +from .record_types import ( + RecordType, + RecordTypesMap +) + + + + +class RecordData: + '''Base class of RData''' + + def __init__( + self, + rtype: RecordType, + data: Optional[str]=None + ) -> None: + self.types_map = RecordTypesMap() + self.rtype = rtype + self.data = data + + def __hash__(self): + return hash(self.data) + + def __eq__(self, other: RecordData): + return self.__class__ == other.__class__ and self.data == other.data + + def __repr__(self): + return '<%s: %s>' % (self.type_name, self.data) + + @property + def type_name(self): + return self.types_map.names_mapping.get( + self.rtype + ).lower() + + @classmethod + def load(cls, data: bytes, ip_length: int, size: int) -> Tuple[int, RecordData]: + raise NotImplementedError + + def dump(self, names: Dict[str, int], offset: int) -> Iterable[bytes]: + raise NotImplementedError diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/record_types.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/record_types.py new file mode 100644 index 0000000..fa1110b --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/record_types.py @@ -0,0 +1,66 @@ +from __future__ import annotations +from enum import Enum +from typing import Dict, Optional +''' +Constants of DNS types. +''' + +class RecordType(Enum): + NONE = 0 + A = 1 + NS = 2 + CNAME = 5 + SOA = 6 + PTR = 12 + MX = 15 + TXT = 16 + AAAA = 28 + SRV = 33 + NAPTR = 35 + ANY = 255 + + +class RecordTypesMap: + + def __init__(self) -> None: + self.names_mapping: Dict[RecordType, str] = {} + self.codes_mapping: Dict[RecordType, int] = {} + self.types_by_code: Dict[int, RecordType] = {} + self.types_by_name: Dict[str, RecordType] = {} + + for record_type in RecordType: + self.names_mapping[record_type] = record_type.name + self.codes_mapping[record_type] = record_type.value + self.types_by_code[record_type.value] = record_type + self.types_by_name[record_type.name] = record_type + + + def get_name_by_code( + self, + code: int, + default: Optional[RecordType]=None + ) -> str: + record_type = self.types_by_code.get( + code, + default + ) + + if record_type is None: + return str(code) + + return record_type.name + + def get_code_by_name( + self, + name: str, + default: Optional[RecordType]=None + ): + record_type = self.types_by_name.get( + name, + default + ) + + if record_type is None: + raise KeyError(f'No record type matches code - {name}') + + return record_type.value \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/soa_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/soa_record_data.py new file mode 100644 index 0000000..7d539d9 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/soa_record_data.py @@ -0,0 +1,107 @@ +from __future__ import annotations +import socket +import struct +from typing import Dict, Iterable, Tuple +from .record_data import RecordData +from .record_types import RecordType +from .utils import ( + load_domain_name, + pack_domain_name +) + + +class SOARecordData(RecordData): + + def __init__(self, *args): + super().__init__( + RecordType.SOA, + data=args + ) + + ( + mname, + rname, + serial, + refresh, + retry, + expire, + minimum, + ) = args + + self.mname = mname + self.rname = rname + self.serial = serial + self.refresh = refresh + self.retry = retry + self.expire = expire + self.minimum = minimum + + def __repr__(self): + return '<%s: %s>' % (self.type_name, self.rname) + + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, SOARecordData]: + + cursor_position, mname = load_domain_name(data, cursor_position) + cursor_position, rname = load_domain_name(data, cursor_position) + + ( + serial, + refresh, + retry, + expire, + minimum, + ) = struct.unpack( + '!LLLLL', + data[cursor_position:cursor_position + 20] + ) + + return cursor_position + 20, SOARecordData( + mname, + rname, + serial, + refresh, + retry, + expire, + minimum + ) + + def dump(self, names: Dict[str, int], offset: int) -> Iterable[bytes]: + + mname = pack_domain_name( + self.mname, + names, + offset + 2 + ) + + mname_length = len(mname) + + domain_name = pack_domain_name( + self.rname, + names, + offset + 2 + mname_length + ) + + record_bytes = struct.pack( + '!LLLLL', + self.serial, + self.refresh, + self.retry, + self.expire, + self.minimum + ) + + record_data = [ + mname, + domain_name, + record_bytes + ] + + for data in record_data: + yield data diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/srv_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/srv_record_data.py new file mode 100644 index 0000000..64afa0e --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/srv_record_data.py @@ -0,0 +1,93 @@ +from __future__ import annotations +import struct +from typing import Dict, Iterable, Tuple +from .record_data import RecordData +from .record_types import RecordType +from .utils import ( + load_domain_name, + pack_domain_name +) + + +class SRVRecordData(RecordData): + '''A record''' + + def __init__(self, *args): + super().__init__( + RecordType.SRV, + data=args + ) + + ( + priority, + weight, + port, + hostname + ) = args + + self.priority = priority + self.weight = weight + self.port = port + self.hostname = hostname + + def __repr__(self): + return '<%s-%s: %s:%s>' % ( + self.type_name, + self.priority, + self.hostname, + self.port + ) + + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, SRVRecordData]: + + priority, weight, port = struct.unpack( + '!HHH', + data[cursor_position:cursor_position + 6] + ) + + cursor_position, hostname = load_domain_name( + data, + cursor_position + 6 + ) + + return cursor_position, SRVRecordData( + priority, + weight, + port, + hostname + ) + + def dump( + self, + names: Dict[str, int], + offset: int + ) -> Iterable[bytes]: + + record_bytes = struct.pack( + '!HHH', + self.priority, + self.weight, + self.port + ) + + domain_name = pack_domain_name( + self.hostname, + names, + offset + 8 + ) + + record_data = [ + record_bytes, + domain_name + ] + + for data in record_data: + yield data + diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/txt_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/txt_record_data.py new file mode 100644 index 0000000..5eafb78 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/txt_record_data.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from typing import Dict, Iterable, Tuple +from .record_data import RecordData +from .record_types import RecordType +from .utils import ( + load_string, + pack_string +) + + +class TXTRecordData(RecordData): + '''A record''' + + def __init__(self, data: str): + super().__init__( + RecordType.TXT, + data=data + ) + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int + ) -> Tuple[int, TXTRecordData]: + + _, text = load_string( + data, + cursor_position + ) + + return cursor_position + size, TXTRecordData( + text.decode() + ) + + def dump( + self, + names: Dict[str, int], + offset: int + ) -> Iterable[bytes]: + yield pack_string(self.data) diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/unsupported_record_data.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/unsupported_record_data.py new file mode 100644 index 0000000..fb44660 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/unsupported_record_data.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from typing import Tuple, Iterable, Dict +from .record_data import RecordData +from .record_types import RecordType + + +class UnsupportedRecordData(RecordData): + '''Unsupported RData''' + def __init__(self, rtype: RecordType, raw: str): + + super().__init__( + rtype, + data=raw.encode() + ) + + self.raw = raw + + @classmethod + def load( + cls, + data: bytes, + cursor_position: int, + size: int, + record_type: RecordType + ) -> Tuple[int, UnsupportedRecordData]: + + return cursor_position + size, UnsupportedRecordData( + record_type, + data[cursor_position:cursor_position + size] + ) + + def dump(self, names: Dict[str, int], offset: int) -> Iterable[bytes]: + yield self.raw \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/__init__.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/__init__.py new file mode 100644 index 0000000..0216ea1 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/__init__.py @@ -0,0 +1,4 @@ +from .load_domain_name import load_domain_name +from .load_string import load_string +from .pack_domain_name import pack_domain_name +from .pack_string import pack_string \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_domain_name.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_domain_name.py new file mode 100644 index 0000000..f253a0a --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_domain_name.py @@ -0,0 +1,43 @@ +def load_domain_name( + buffer: bytes, + offset: int +): + parts = [] + cursor = None + data_len = len(buffer) + visited = set() + + while offset < data_len: + + if offset in visited: + raise Exception(buffer, offset, 'Pointer loop detected') + + visited.add(offset) + length = buffer[offset] + offset += 1 + + if length == 0: + + if cursor is None: + cursor = offset + + break + + if length >= 0xc0: + + if cursor is None: + cursor = offset + 1 + + offset = (length - 0xc0) * 256 + buffer[offset] + + continue + + parts.append(buffer[offset:offset + length]) + offset += length + + if cursor is None: + raise Exception(buffer, offset, 'Bad data') + + data = b'.'.join(parts).decode() + + return cursor, data \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_string.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_string.py new file mode 100644 index 0000000..ca06e78 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/load_string.py @@ -0,0 +1,9 @@ +def load_string( + buffer: bytes, + offset: int +): + '''Load a character string from packed data.''' + length = buffer[offset] + offset += 1 + data = buffer[offset:offset + length] + return offset + length, data diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_domain_name.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_domain_name.py new file mode 100644 index 0000000..1b27fc9 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_domain_name.py @@ -0,0 +1,36 @@ +import io +import struct +from typing import Dict +from .pack_string import pack_string + +def pack_domain_name( + name: bytes, + names: Dict[bytes, bytes], + offset: int=0 +): + + parts = name.split('.') + buf = io.BytesIO() + + while parts: + + subname = '.'.join(parts) + u = names.get(subname) + + if u: + buf.write(struct.pack('!H', 0xc000 + u)) + break + + else: + names[subname] = buf.tell() + offset + + buf.write( + pack_string( + parts.pop(0) + ) + ) + + else: + buf.write(b'\0') + + return buf.getvalue() diff --git a/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_string.py b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_string.py new file mode 100644 index 0000000..6619d09 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/record/record_data_types/utils/pack_string.py @@ -0,0 +1,10 @@ +import struct +from typing import Union + + +def pack_string(string: Union[str, bytes], btype='B') -> bytes: + '''Pack string into `{length}{data}` format.''' + if not isinstance(string, bytes): + string = string.encode() + length = len(string) + return struct.pack('%s%ds' % (btype, length), length, string) diff --git a/hyperscale/distributed/discovery/dns/core/url/__init__.py b/hyperscale/distributed/discovery/dns/core/url/__init__.py new file mode 100644 index 0000000..a3709e4 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/url/__init__.py @@ -0,0 +1,5 @@ +from .url import URL +from .exceptions import ( + InvalidHost, + InvalidIP +) \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/core/url/exceptions.py b/hyperscale/distributed/discovery/dns/core/url/exceptions.py new file mode 100644 index 0000000..9bab9a7 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/url/exceptions.py @@ -0,0 +1,6 @@ +class InvalidHost(Exception): + pass + + +class InvalidIP(Exception): + pass diff --git a/hyperscale/distributed/discovery/dns/core/url/host.py b/hyperscale/distributed/discovery/dns/core/url/host.py new file mode 100644 index 0000000..51088a7 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/url/host.py @@ -0,0 +1,47 @@ +from typing import Union + + +class Host: + hostname: str + port: Union[int, None] + username: Union[str, None] + password: Union[str, None] + + def __init__( + self, + netloc: str + ): + userinfo, _, host = netloc.rpartition('@') + if host.count(':') == 1 or '[' in host: + hostname, _, port = host.rpartition(':') + port = int(port) + else: + hostname, port = host, None + if hostname.startswith('[') and hostname.endswith(']'): + hostname = hostname[1:-1] + if userinfo: + username, _, password = userinfo.partition(':') + else: + username = password = None + + self.netloc = netloc + self.hostname = hostname + self.port = port + self.username = username + self.password = password + + @property + def host(self): + host = f'[{self.hostname}]' if ':' in self.hostname else self.hostname + if self.port: + host = f'{host}:{self.port}' + return host + + def __str__(self): + userinfo = '' + if self.username: + userinfo += self.username + if self.password: + userinfo += ':' + self.password + userinfo += '@' + return userinfo + self.host diff --git a/hyperscale/distributed/discovery/dns/core/url/url.py b/hyperscale/distributed/discovery/dns/core/url/url.py new file mode 100644 index 0000000..62c88cc --- /dev/null +++ b/hyperscale/distributed/discovery/dns/core/url/url.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import re +import socket +from typing import Optional, Union +from urllib.parse import urlparse + +from hyperscale.distributed.discovery.dns.core.record import RecordType + +from .exceptions import InvalidHost, InvalidIP + +ip_pattern = '(?P[^:/ ]+).?(?P[0-9]*).*' +match_pattern = re.compile(ip_pattern) + + + +class URL: + + + + def __init__( + self, + url: str, + port: Optional[int]=None + ): + + self._default_ports = { + 'tcp': 53, + 'udp': 53, + 'tcps': 853, + 'http': 80, + 'https': 443, + } + + + self.url = url + self.parsed = urlparse(url) + + self.host = self.parsed.hostname + + if port is None: + port = self.parsed.port + + self.port = port + + if self.host is None: + ( + _, + host, + _ + ) = self.parse_netloc() + + self.host = host + + self.is_ssl = False + if self.parsed.scheme in ['tcps', 'https', 'msyncs']: + self.is_ssl = True + + self.ip_type = self.get_ip_type( + self.host + ) + + if self.ip_type is None: + + matches = re.search( + ip_pattern, + self.url + ) + self.host = matches.group('host') + self.port = matches.group('port') + + if self.port: + self.port = int(self.port) + + + if self.port is None or self.port == '': + self.port = self._default_ports.get( + self.parsed.scheme, + 80 + ) + + self.domain_protocol_map = { + "tcp": "tcp", + "udp": "udp", + "tcps": "tcp", + "http": "tcp", + "https": "tcp" + } + + self.address = ( + self.host, + self.port + ) + + self.is_msync = self.parsed.scheme in ['msync', 'msyncs'] + + def __str__(self): + return self.url + + def __eq__(self, other): + return str(self) == str(other) + + def __repr__(self): + return str(self) + + def __hash__(self): + return hash(str(self)) + + def copy(self): + return URL(self.url) + + def parse_netloc(self): + + authentication: Union[str, None] = None + port: Union[str, None] = None + + host = self.parsed.netloc + + if '@' in host: + authentication, host = host.split('@') + + if ':' in host: + host, port = host.split(':') + + if port: + port = int(port) + + return ( + authentication, + host, + port + ) + + + def to_ptr(self): + if self.ip_type is RecordType.A: + reversed_hostname = '.'.join( + self.parsed.hostname.split('.')[::-1] + ) + + return f'{reversed_hostname}.in-addr.arpa' + + raise InvalidIP(self.parsed.hostname) + + def get_ip_type( + self, + hostname: str + ): + + if ':' in hostname: + # ipv6 + try: + socket.inet_pton(socket.AF_INET6, hostname) + except OSError: + raise InvalidHost(hostname) + + return RecordType.AAAA + + try: + socket.inet_pton(socket.AF_INET, hostname) + except OSError: + # domain name + pass + else: + return RecordType.A + + + @property + def domain_protocol(self): + return self.domain_protocol_map.get(self.parsed.scheme, "udp") diff --git a/hyperscale/distributed/discovery/dns/registrar.py b/hyperscale/distributed/discovery/dns/registrar.py new file mode 100644 index 0000000..8dcb0c2 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/registrar.py @@ -0,0 +1,451 @@ +import asyncio +import socket +from typing import Dict, List, Optional, Tuple, Union + +from hyperscale.distributed.discovery.dns.core.random import RandomIDGenerator +from hyperscale.distributed.discovery.dns.core.record import Record +from hyperscale.distributed.discovery.dns.core.url import URL +from hyperscale.distributed.discovery.dns.resolver import DNSResolver +from hyperscale.distributed.env import Env, load_env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.hooks import client, server +from hyperscale.distributed.models.dns import ( + DNSEntry, + DNSMessage, + DNSMessageGroup, + Service, +) +from hyperscale.distributed.service.controller import Controller +from hyperscale.distributed.types import Call + + +class Registrar(Controller): + + def __init__( + self, + host: str, + port: int, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + workers: int=0, + env: Env=None + ) -> None: + + if env is None: + env = load_env(Env) + + super().__init__( + host, + port, + cert_path=cert_path, + key_path=key_path, + env=env, + workers=workers, + engine='async' + ) + + self.resolver = DNSResolver( + host, + port, + self._instance_id, + self._env + ) + + self.random_id_generator = RandomIDGenerator() + + self._nameservers: List[URL] = [] + self._next_nameserver_idx = 0 + self._connected_namservers: Dict[Tuple[str, int], bool] = {} + self._connected_domains: Dict[str, bool] = {} + + def add_entries( + self, + entries: List[DNSEntry] + ): + + for entry in entries: + for domain, record in entry.to_record_data(): + + self.resolver.add_to_cache( + domain, + record.rtype, + record + ) + + async def add_nameservers( + self, + urls: List[str] + ): + urls = self.resolver.add_nameservers(urls) + + await self.resolver.connect_nameservers( + urls, + cert_path=self.cert_path, + key_path=self.key_path + ) + + self._nameservers.extend(urls) + + def _next_nameserver_url(self) -> Union[URL, None]: + + if len(self._nameservers) > 0: + + namserver_url = self._nameservers[self._next_nameserver_idx] + + self._next_nameserver_idx = ( + self._next_nameserver_idx + 1 + )%len(self._nameservers) + + return namserver_url + + @server() + async def update_registered( + self, + shard_id: int, + registration: DNSMessage + ): + for record in registration.query_domains: + self.resolver.add_to_cache( + record.name, + record.record_type, + record.data + ) + + return registration + + + @server() + async def resolve_query( + self, + shard_id: int, + query: DNSMessage + ) -> Call[DNSMessage]: + + messages: List[DNSMessage] = [] + + for record in query.query_domains: + + dns_message, has_result = await self.resolver.query( + record.name, + record_type=record.record_type + ) + + if has_result is False: + # TODO: Query using client. + pass + + dns_data = dns_message.to_data() + dns_data.update({ + 'query_id': query.query_id, + 'has_result': has_result + }) + + response = DNSMessage(**dns_data) + + messages.append(response) + + return DNSMessageGroup( + messages=messages + ) + + @client('resolve_query') + async def submit_query( + self, + host: str, + port: int, + entry: DNSEntry + ) -> Call[DNSMessageGroup]: + + return DNSMessage( + host=host, + port=port, + query_domains=[ + Record( + name=domain, + record_type=record.rtype, + data=record, + ttl=entry.time_to_live + + ) for domain, record in entry.to_record_data() + ] + ) + + @client('update_registered') + async def submt_registration( + self, + host: str, + port: int, + entry: DNSEntry + ) -> Call[DNSMessage]: + return DNSMessage( + host=host, + port=port, + query_domains=[ + Record( + name=domain, + record_type=record.rtype, + data=record, + ttl=entry.time_to_live + + ) for domain, record in entry.to_record_data() + ] + ) + + async def query( + self, + entry: DNSEntry + ) -> List[DNSEntry]: + + nameserver_url = self._next_nameserver_url() + + host = nameserver_url.host + port = nameserver_url.port + + if nameserver_url.ip_type is not None: + host = socket.gethostbyname(nameserver_url.host) + + if not self._connected_namservers.get((host, port)): + + await self.start_client( + DNSMessage( + host=host, + port=port + ) + ) + + self._connected_namservers[(host, port)] = True + + _, results = await self.submit_query( + host, + port, + entry + ) + + entries: List[DNSEntry]=[] + + for message in results.messages: + for answer in message.query_answers: + + entries.append( + DNSEntry.from_record_data( + answer.name, + answer.data + ) + ) + + return entries + + async def register( + self, + entry: DNSEntry + ) -> List[DNSEntry]: + + nameserver_url = self._next_nameserver_url() + + host = nameserver_url.host + port = nameserver_url.port + + if nameserver_url.ip_type is not None: + host = socket.gethostbyname(nameserver_url.host) + + if not self._connected_namservers.get((host, port)): + + await self.start_client( + DNSMessage( + host=host, + port=port + ) + ) + + self._connected_namservers[(host, port)] = True + + _, results = await self.submt_registration( + host, + port, + entry + ) + + entries: List[DNSEntry]=[] + + for answer in results.query_domains: + + entries.append( + DNSEntry.from_record_data( + answer.name, + answer.data + ) + ) + + return entries\ + + async def discover( + self, + url: str, + expected: Optional[int]=None, + timeout: Optional[str]=None + ): + + services_data: Dict[str, Dict[str, Union[str, int, Dict[str, str]]]] = {} + services: Dict[str, Service] = {} + + + + if expected and timeout: + + poll_timeout = TimeParser(timeout).time + + return await asyncio.wait_for( + self.poll_for_services( + url, + expected + ), + timeout=poll_timeout + ) + + else: + return await self.get_services(url) + + async def poll_for_services( + self, + url: str, + expected: int + ): + services_data: Dict[str, Dict[str, Union[str, int, Dict[str, str]]]] = {} + services: Dict[str, Service] = {} + + discovered = 0 + + while discovered < expected: + + ptr_records = await self.get_ptr_records(url) + + srv_records = await self.get_srv_records(ptr_records) + txt_records = await self.get_txt_records(ptr_records) + + + for record in srv_records: + service_url = record.to_domain(record.record_type.name) + + services_data[service_url] = { + "service_instance": record.instance_name, + "service_name": record.service_name, + "service_protocol": record.domain_protocol, + 'service_url': service_url, + 'service_ip': record.domain_targets[0], + 'service_port': record.domain_port, + 'service_context': {} + } + + for record in txt_records: + service_url = record.domain_name + + services_data[service_url]['service_context'].update(record.domain_values) + + for service_url, data in services_data.items(): + services[service_url] = Service(**data) + + discovered = len(services) + + return list(services.values()) + + async def get_services(self, url: str): + + services_data: Dict[str, Dict[str, Union[str, int, Dict[str, str]]]] = {} + services: Dict[str, Service] = {} + + ptr_records = await self.get_ptr_records(url) + + srv_records = await self.get_srv_records(ptr_records) + txt_records = await self.get_txt_records(ptr_records) + + + for record in srv_records: + service_url = record.to_domain(record.record_type.name) + + services_data[service_url] = { + "service_instance": record.instance_name, + "service_name": record.service_name, + "service_protocol": record.domain_protocol, + 'service_url': service_url, + 'service_ip': record.domain_targets[0], + 'service_port': record.domain_port, + 'service_context': {} + } + + + for record in txt_records: + service_url = record.domain_name + + services_data[service_url]['service_context'].update(record.domain_values) + + for service_url, data in services_data.items(): + services[service_url] = Service(**data) + + return list(services.values()) + + async def get_ptr_records( + self, + url: str + ): + + ( + service_name, + domain_protocol, + domain_name + ) = DNSEntry.to_ptr_segments(url) + + + return await self.query( + DNSEntry( + service_name=service_name, + domain_protocol=domain_protocol, + domain_name=domain_name, + record_types=["PTR"] + ) + ) + + async def get_srv_records( + self, + ptr_records: List[DNSEntry] + ): + + srv_records: List[List[DNSEntry]] = await asyncio.gather(*[ + self.query( + DNSEntry( + instance_name=entry.instance_name, + service_name=entry.service_name, + domain_protocol=entry.domain_protocol, + domain_name=entry.domain_name, + record_types=["SRV"] + ) + ) for entry in ptr_records + ], return_exceptions=True) + + service_records: List[DNSEntry] = [] + + for results in srv_records: + service_records.extend(results) + + return service_records + + async def get_txt_records( + self, + ptr_records: List[DNSEntry] + ): + txt_records = await asyncio.gather(*[ + self.query( + DNSEntry( + instance_name=entry.instance_name, + service_name=entry.service_name, + domain_protocol=entry.domain_protocol, + domain_name=entry.domain_name, + record_types=["TXT"] + ) + ) for entry in ptr_records + ], return_exceptions=True) + + text_records: List[DNSEntry] = [] + for results in txt_records: + text_records.extend(results) + + return text_records diff --git a/hyperscale/distributed/discovery/dns/request/__init__.py b/hyperscale/distributed/discovery/dns/request/__init__.py new file mode 100644 index 0000000..e23ab6c --- /dev/null +++ b/hyperscale/distributed/discovery/dns/request/__init__.py @@ -0,0 +1 @@ +from .dns_client import DNSClient \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/request/dns_client.py b/hyperscale/distributed/discovery/dns/request/dns_client.py new file mode 100644 index 0000000..52c1aa5 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/request/dns_client.py @@ -0,0 +1,219 @@ +import asyncio +import socket +from typing import Dict, Optional, Tuple, Union + +from hyperscale.distributed.connection.base.connection_type import ConnectionType +from hyperscale.distributed.connection.tcp import ( + MercurySyncHTTPConnection, + MercurySyncTCPConnection, +) +from hyperscale.distributed.connection.udp import MercurySyncUDPConnection +from hyperscale.distributed.discovery.dns.core.url import URL +from hyperscale.distributed.env import Env, RegistrarEnv, load_env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.models.dns import DNSMessage +from hyperscale.distributed.models.http import HTTPMessage + + +class DNSClient: + + def __init__( + self, + host: str, + port: int, + instance_id: str, + env: Env + ) -> None: + + registrar_env: RegistrarEnv = load_env(RegistrarEnv) + + self.host = host + self.port = port + self.instance_id = instance_id + self.env = env + + self._client_config = ( + host, + port + 2, + instance_id, + env + ) + + self._connection_types: Dict[ + ConnectionType, + Union[ + MercurySyncUDPConnection, + MercurySyncTCPConnection, + MercurySyncHTTPConnection + ] + ] = { + ConnectionType.UDP: lambda config: MercurySyncUDPConnection(*config), + ConnectionType.TCP: lambda config: MercurySyncTCPConnection(*config), + ConnectionType.HTTP: lambda config: MercurySyncHTTPConnection(*config), + } + + self._client: Union[ + MercurySyncUDPConnection, + MercurySyncTCPConnection, + MercurySyncHTTPConnection, + None + ] = None + + self._client_types = { + "udp": ConnectionType.UDP, + "tcp": ConnectionType.TCP, + "http": ConnectionType.HTTP + } + + self.client_type = self._client_types.get( + registrar_env.MERCURY_SYNC_RESOLVER_CONNECTION_TYPE + ) + + self._request_timeout = TimeParser( + registrar_env.MERCURY_SYNC_RESOLVER_REQUEST_TIMEOUT + ).time + + self._connections: Dict[Tuple[str, int], bool] = {} + self.cert_paths: Dict[str, str] = {} + self.key_paths: Dict[str, str] = {} + + + async def connect_client( + self, + url: URL, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + worker_socket: Optional[socket.socket]=None + ): + + self.cert_paths[url.address] = cert_path + self.key_paths[url.address] = key_path + + self._client: Union[ + MercurySyncUDPConnection, + MercurySyncTCPConnection, + MercurySyncHTTPConnection + ] = self._connection_types.get( + self.client_type + )(self._client_config) + + if self._client.connection_type == ConnectionType.TCP: + await self._client.connect_client( + url.address, + cert_path=cert_path, + key_path=key_path, + worker_socket=worker_socket + ) + + elif self._client.connection_type == ConnectionType.HTTP: + await self._client.connect_client( + url.address, + is_ssl=url.is_ssl, + hostname=url.host, + worker_socket=worker_socket + ) + + else: + await self._client.connect_async( + cert_path=cert_path, + key_path=key_path, + worker_socket=worker_socket + ) + + async def send( + self, + event_name: str, + data: DNSMessage, + url: URL + ): + if url.is_msync: + + return await asyncio.wait_for( + self._send_msync( + event_name, + data, + url + ), + timeout=self._request_timeout + ) + + else: + + return await asyncio.wait_for( + self._send( + event_name, + data, + url + ), + timeout=self._request_timeout + ) + async def _send( + self, + event_name: str, + data: DNSMessage, + url: URL + ): + + if self._client is None: + await self.connect_client( + url + ) + + if self._client.connection_type == ConnectionType.TCP: + response = await self._client.send_bytes( + event_name, + data.to_tcp_bytes(), + url.address + ) + + return DNSMessage.parse(response) + + elif self._client.connection_type == ConnectionType.HTTP: + response: HTTPMessage = await self._client.send_request( + event_name, + data.to_http_bytes(url.url), + url.address + ) + + return DNSMessage.parse(response.data) + + else: + + response = await self._client.send_bytes( + event_name, + data.to_udp_bytes(), + url.address + ) + + return DNSMessage.parse(response) + + async def _send_msync( + self, + event_name: str, + data: DNSMessage, + url: URL + ): + if self._client is None: + await self.connect_client( + url + ) + + if self._client.connection_type == ConnectionType.TCP: + response = await self._client.send( + event_name, + data, + url.address + ) + + return DNSMessage.parse(response) + + else: + + response = await self._client.send( + event_name, + data, + url.address + ) + + return DNSMessage.parse(response) + \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/resolver/__init__.py b/hyperscale/distributed/discovery/dns/resolver/__init__.py new file mode 100644 index 0000000..6835f6d --- /dev/null +++ b/hyperscale/distributed/discovery/dns/resolver/__init__.py @@ -0,0 +1 @@ +from .resolver import DNSResolver \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/resolver/base_resolver.py b/hyperscale/distributed/discovery/dns/resolver/base_resolver.py new file mode 100644 index 0000000..da5f3cb --- /dev/null +++ b/hyperscale/distributed/discovery/dns/resolver/base_resolver.py @@ -0,0 +1,238 @@ +import asyncio +from typing import List, Union + +from hyperscale.distributed.discovery.dns.core.cache import CacheNode +from hyperscale.distributed.discovery.dns.core.exceptions import DNSError +from hyperscale.distributed.discovery.dns.core.record import RecordType +from hyperscale.distributed.discovery.dns.core.record.record_data_types import ( + CNAMERecordData, + NSRecordData, +) +from hyperscale.distributed.discovery.dns.core.url import URL, InvalidHost, InvalidIP +from hyperscale.distributed.discovery.dns.request.dns_client import DNSClient +from hyperscale.distributed.env import Env, RegistrarEnv, load_env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.models.dns import DNSEntry, DNSMessage + +from .memoizer import Memoizer + + +class BaseResolver: + zone_domains = [] + nameserver_types = [ + RecordType.A + ] + memoizer=Memoizer() + + def __init__( + self, + host: str, + port: int, + instance_id: str, + env: Env, + cache: CacheNode = None + ): + self.host = host + self.port = port + self._queries = {} + self.cache = cache or CacheNode() + self.client = DNSClient( + host, + port, + instance_id, + env + ) + + registrar_env: RegistrarEnv = load_env(RegistrarEnv) + + self._request_timeout = TimeParser( + registrar_env.MERCURY_SYNC_RESOLVER_REQUEST_TIMEOUT + ).time + + def cache_message(self, query: DNSEntry): + for _, record in query.to_record_data(): + if query.time_to_live > 0 and record.rtype != RecordType.SOA: + self.cache.add(record=record) + + def set_zone_domains(self, domains: List[str]): + self.zone_domains = [ + domain.lstrip('.') for domain in domains + ] + + async def _query(self, _fqdn: str, _record_type: RecordType) -> DNSMessage: + raise NotImplementedError + + async def query( + self, + fqdn: str, + record_type: RecordType=RecordType.ANY, + skip_cache: bool=False + ): + + if fqdn.endswith('.'): + fqdn = fqdn[:-1] + + if record_type == RecordType.ANY: + try: + addr = URL( + fqdn, + port=self.port + ) + + ptr_name = addr.to_ptr() + + except (InvalidHost, InvalidIP): + pass + + else: + fqdn = ptr_name + record_type = RecordType.PTR + + try: + + return await asyncio.wait_for( + self._query( + fqdn, + record_type, + skip_cache + ), + timeout=self._request_timeout + ) + + except asyncio.TimeoutError: + return DNSMessage() + + async def request( + self, + fqdn: str, + message: DNSMessage, + url: URL + ) -> DNSMessage: + + result = await self.client.send(fqdn, message, url) + + if len(result.query_domains) < 1: + return False, fqdn, [] + + if result.query_domains[0].name != fqdn: + raise DNSError(-1, 'Question section mismatch') + + assert result.query_result_code != 2, 'Remote server fail' + + self.cache_message(result) + + return result + + def _add_cache_cname(self, msg: DNSMessage, fqdn: str) -> Union[str, None]: + for cname in self.cache.query(fqdn, RecordType.CNAME): + msg.query_answers.append(cname.copy(name=fqdn)) + if isinstance(cname.data, CNAMERecordData): + return cname.data.data + + def _add_cache_cname( + self, + msg: DNSMessage, + fqdn: str + ) -> Union[str, None]: + + for cname in self.cache.query(fqdn, RecordType.CNAME): + msg.query_answers.append(cname.copy(name=fqdn)) + if isinstance(cname.data, CNAMERecordData): + return cname.data.data + + def _add_cache_qtype( + self, + msg: DNSMessage, + fqdn: str, + record_type: RecordType + ) -> bool: + if record_type == RecordType.CNAME: + return False + + has_result = False + for rec in self.cache.query(fqdn, record_type): + + if isinstance(rec.data, NSRecordData): + a_res = list(self.cache.query( + rec.data.data, ( + RecordType.A, + RecordType.AAAA + ) + )) + + if a_res: + msg.query_additional_records.extend(a_res) + msg.query_namservers.append(rec) + has_result = True + else: + msg.query_answers.append(rec.copy(name=fqdn)) + has_result = True + + return has_result + + def _add_cache_record_type( + self, + msg: DNSMessage, + fqdn: str, + record_type: RecordType + ) -> bool: + + if record_type == RecordType.CNAME: + return False + + has_result = False + for rec in self.cache.query(fqdn, record_type): + + if isinstance(rec.data, NSRecordData): + records = list(self.cache.query( + rec.data.data, ( + RecordType.A, + RecordType.AAAA + ) + )) + + if records: + msg.query_additional_records.extend(records) + msg.query_namservers.append(rec) + has_result = True + else: + msg.query_answers.append(rec.copy(name=fqdn)) + has_result = True + + return has_result + + def query_cache( + self, + msg: DNSMessage, + fqdn: str, + record_type: RecordType + ): + + cnames = set() + + while True: + cname = self._add_cache_cname(msg, fqdn) + if not cname: + break + + if cname in cnames: + # CNAME cycle detected + break + + cnames.add(cname) + # RFC1034: If a CNAME RR is present at a node, no other data should be present + fqdn = cname + + has_result = bool(cname) and record_type in (RecordType.CNAME, RecordType.ANY) + + if record_type != RecordType.CNAME: + has_result = self._add_cache_qtype(msg, fqdn, record_type) or has_result + + if any(('.' + fqdn).endswith(root) for root in self.zone_domains): + if not has_result: + msg.query_result_code = 3 + has_result = True + + msg.query_authoritative_answer = 1 + # fqdn may change due to CNAME + return has_result, fqdn \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/resolver/cache_resolver.py b/hyperscale/distributed/discovery/dns/resolver/cache_resolver.py new file mode 100644 index 0000000..3c51659 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/resolver/cache_resolver.py @@ -0,0 +1,201 @@ +from typing import List, Tuple, Union + +from hyperscale.distributed.discovery.dns.core.cache import CacheNode +from hyperscale.distributed.discovery.dns.core.record import Record, RecordType +from hyperscale.distributed.discovery.dns.core.record.record_data_types import ( + CNAMERecordData, + NSRecordData, +) +from hyperscale.distributed.discovery.dns.core.url import URL, InvalidHost, InvalidIP +from hyperscale.distributed.models.dns import DNSEntry, DNSMessage, QueryType + +from .memoizer import Memoizer + + +class CacheResolver: + zone_domains = [] + nameserver_types = [ + RecordType.A + ] + memoizer=Memoizer() + + def __init__( + self, + port: int, + cache: CacheNode = None, + query_timeout: float = 3.0, + request_timeout: float = 5.0 + ): + self.port = port + self._queries = {} + self.cache = cache or CacheNode() + self.request_timeout = request_timeout + self.query_timeout = query_timeout + + def cache_message(self, entry: DNSEntry): + + for _, record in entry.to_record_data(): + if entry.time_to_live > 0 and record.rtype != RecordType.SOA: + self.cache.add(record=record) + + def set_zone_domains(self, domains: List[str]): + self.zone_domains = [ + domain.lstrip('.') for domain in domains + ] + + async def _query(self, _fqdn: str, _record_type: RecordType) -> Tuple[DNSMessage, bool]: + raise NotImplementedError + + @memoizer.memoize_async( + lambda _, fqdn, record_type, skip_cache: (fqdn, record_type) + ) + async def query_local( + self, + fqdn: str, + record_type: RecordType=RecordType.ANY + ) -> Tuple[DNSMessage, bool]: + + if fqdn.endswith('.'): + fqdn = fqdn[:-1] + + if record_type == RecordType.ANY: + try: + + url = URL( + fqdn, + port=self.port + ) + + ptr_name = url.to_ptr() + + except (InvalidHost, InvalidIP): + pass + + else: + fqdn = ptr_name + record_type = RecordType.PTR + + msg = DNSMessage() + msg.query_domains.append( + Record( + QueryType.REQUEST, + name=fqdn, + record_type=record_type + ) + ) + + has_result = False + has_result, fqdn = self.query_cache(msg, fqdn, record_type) + + return msg, has_result + + def _add_cache_cname(self, msg: DNSMessage, fqdn: str) -> Union[str, None]: + + for cname in self.cache.query(fqdn, RecordType.CNAME): + + msg.query_answers.append(cname.copy(name=fqdn)) + if isinstance(cname.data, CNAMERecordData): + return cname.data.data + + def _add_cache_record_type( + self, + msg: DNSMessage, + fqdn: str, + record_type: RecordType + ) -> bool: + '''Query cache for records other than CNAME and add to result msg. + ''' + if record_type == RecordType.CNAME: + return False + + has_result = False + for rec in self.cache.query(fqdn, record_type): + + if isinstance(rec.data, NSRecordData): + records = list(self.cache.query( + rec.data.data, ( + RecordType.A, + RecordType.AAAA + ) + )) + + if records: + msg.query_additional_records.extend(records) + msg.query_namservers.append(rec) + has_result = True + else: + msg.query_answers.append(rec.copy(name=fqdn)) + has_result = True + + return has_result + + def query_cache( + self, + fqdn: str, + record_type: RecordType + ) -> Tuple[DNSMessage, bool]: + + if fqdn.endswith('.'): + fqdn = fqdn[:-1] + + if record_type == RecordType.ANY: + try: + + url = URL( + fqdn, + port=self.port + ) + + ptr_name = url.to_ptr() + + except (InvalidHost, InvalidIP): + pass + + else: + fqdn = ptr_name + record_type = RecordType.PTR + + msg = DNSMessage() + msg.query_domains.append( + Record( + QueryType.REQUEST, + name=fqdn, + record_type=record_type + ) + ) + + cnames = set() + + while True: + cname = self._add_cache_cname(msg, fqdn) + if not cname: + break + + if cname in cnames: + # CNAME cycle detected + break + + cnames.add(cname) + # RFC1034: If a CNAME RR is present at a node, no other data should be present + fqdn = cname + + has_result = bool(cname) and record_type in (RecordType.CNAME, RecordType.ANY) + + if record_type != RecordType.CNAME: + has_result = self._add_cache_record_type( + msg, + fqdn, + record_type + ) or has_result + + if any(('.' + fqdn).endswith(root) for root in self.zone_domains): + if not has_result: + msg.r = 3 + has_result = True + + msg = DNSMessage( + **msg.dict(), + query_authoritative_answer=1 + ) + # fqdn may change due to CNAME + return msg, has_result diff --git a/hyperscale/distributed/discovery/dns/resolver/memoizer.py b/hyperscale/distributed/discovery/dns/resolver/memoizer.py new file mode 100644 index 0000000..a9044ac --- /dev/null +++ b/hyperscale/distributed/discovery/dns/resolver/memoizer.py @@ -0,0 +1,63 @@ +import asyncio +import functools +from typing import Callable, Dict, Optional, Tuple + +from hyperscale.distributed.discovery.dns.core.record import RecordType +from hyperscale.distributed.models.dns import DNSMessage + + +class Memoizer: + def __init__(self): + self.data: Dict[str, asyncio.Task] = {} + + def memoize_async( + self, + key: Callable[ + [ + Tuple[ + Optional[DNSMessage], + str, + RecordType + ] + ], + Tuple[ + str, + RecordType + ] + + ]=None + ): + data = self.data + + def wrapper(func): + + @functools.wraps(func) + async def wrapped(*args, **kwargs): + + cache_key = () + if key: + cache_key = key + + + task = data.get(cache_key) + + if task is None: + + task = asyncio.create_task( + func(*args, **kwargs) + ) + + data[cache_key] = task + + task.add_done_callback( + lambda _: self.clear(cache_key) + ) + + return await task + + return wrapped + + return wrapper + + def clear(self, key: str): + self.data.pop(key, None) diff --git a/hyperscale/distributed/discovery/dns/resolver/proxy_resolver.py b/hyperscale/distributed/discovery/dns/resolver/proxy_resolver.py new file mode 100644 index 0000000..b45979a --- /dev/null +++ b/hyperscale/distributed/discovery/dns/resolver/proxy_resolver.py @@ -0,0 +1,190 @@ + +from typing import Callable, List, Optional, Tuple, Union + +from hyperscale.distributed.discovery.dns.core.cache import CacheNode +from hyperscale.distributed.discovery.dns.core.config import core_config +from hyperscale.distributed.discovery.dns.core.nameservers import NameServer +from hyperscale.distributed.discovery.dns.core.record import ( + Record, + RecordType, + RecordTypesMap, +) +from hyperscale.distributed.env import Env +from hyperscale.distributed.models.dns import DNSMessage, QueryType + +from .base_resolver import BaseResolver +from .memoizer import Memoizer + +Proxy = Tuple[ + Union[ + Callable[ + [str], + bool + ], + str, + None + ], + str +] + +NameServerPair = Tuple[ + Union[ + Callable[ + [str], + bool + ], + None + ], + NameServer +] + + +class ProxyResolver(BaseResolver): + + default_nameservers = core_config['default_nameservers'] + memoizer = Memoizer() + + def __init__( + self, + host: str, + port: int, + instance_id: str, + env: Env, + cache: CacheNode = None, + proxies: Optional[List[Proxy]]=None + ): + super().__init__( + host, + port, + instance_id, + env, + cache=cache + ) + + if proxies is None: + proxies = self.default_nameservers + + self.types_map = RecordTypesMap() + self._nameserver_pairs = self.set_proxies(proxies) + + def _get_matching_nameserver(self, fqdn): + + for nameserver_test, nameserver in self._nameserver_pairs: + if nameserver_test is None or nameserver_test(fqdn): + return nameserver + + return NameServer([]) + + def add_nameserver( + self, + urls: List[str] + ): + namserver = NameServer(urls) + + self._nameserver_pairs.append(( + None, + namserver + )) + + return namserver.data + + @staticmethod + def build_tester(rule) -> Callable[ + [str], + bool + ]: + + if rule is None or callable(rule): + return rule + + assert isinstance(rule, str) + + if rule.startswith('*.'): + suffix = rule[1:] + + return lambda d: d.endswith(suffix) + + return lambda d: d == rule + + def set_proxies( + self, + proxies: List[Proxy] + ): + + nameserver_pairs: List[NameServerPair] = [] + fallback: List[str] = [] + + if proxies: + for item in proxies: + + if isinstance(item, str): + fallback.append(item) + continue + + test, nameserver = item + if test is None: + fallback.extend(nameserver) + continue + + nameserver_pairs.append( + ( + self.build_tester(test), + NameServer([nameserver]) + ) + ) + + if fallback: + nameserver_pairs.append( + ( + None, + NameServer(fallback) + ) + ) + + return nameserver_pairs + + @memoizer.memoize_async( + lambda _, fqdn, record_type, skip_cache: (fqdn, record_type) + ) + async def _query( + self, + fqdn: str, + record_type: RecordType, + skip_cache: bool + ): + + msg = DNSMessage() + msg.query_domains.append( + Record( + QueryType.REQUEST, + name=fqdn, + record_type=record_type + ) + ) + + has_result = False + + if skip_cache is False: + has_result, fqdn = self.query_cache(msg, fqdn, record_type) + + while not has_result: + nameserver = self._get_matching_nameserver(fqdn) + + for addr in nameserver.iter(): + try: + res = await self.request(fqdn, msg, addr) + + except: + nameserver.fail(addr) + raise + + else: + nameserver.success(addr) + self.cache_message(res) + msg.query_answers.extend(res.query_answers) + has_result = True + + break + + return msg + diff --git a/hyperscale/distributed/discovery/dns/resolver/recursive_resolver.py b/hyperscale/distributed/discovery/dns/resolver/recursive_resolver.py new file mode 100644 index 0000000..84d8d82 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/resolver/recursive_resolver.py @@ -0,0 +1,370 @@ +import asyncio +import os +import pathlib +from typing import List, Optional, Tuple +from urllib import request + +from hyperscale.distributed.discovery.dns.core.cache import CacheNode +from hyperscale.distributed.discovery.dns.core.exceptions import DNSError +from hyperscale.distributed.discovery.dns.core.nameservers import NameServer +from hyperscale.distributed.discovery.dns.core.record import ( + Record, + RecordType, + RecordTypesMap, +) +from hyperscale.distributed.discovery.dns.core.record.record_data_types import ( + CNAMERecordData, + NSRecordData, + SOARecordData, +) +from hyperscale.distributed.discovery.dns.core.url import URL +from hyperscale.distributed.env import Env, RegistrarEnv, load_env +from hyperscale.distributed.models.dns_message import DNSMessage, QueryType + +from .base_resolver import BaseResolver +from .memoizer import Memoizer + + +class RecursiveResolver(BaseResolver): + + memoizer = Memoizer() + + def __init__( + self, + host: str, + port: int, + instance_id: str, + env: Env, + cache: CacheNode = None + ): + super().__init__( + host, + port, + instance_id, + env, + cache=cache + ) + + self.types_map = RecordTypesMap() + self._nameserver_urls: List[str] = [] + + registrar_env: RegistrarEnv = load_env(RegistrarEnv) + + self._maximum_tries = registrar_env.MERCURY_SYNC_RESOLVER_MAXIMUM_TRIES + + def add_nameserver( + self, + urls: List[str] + ): + self._nameserver_urls.extend(urls) + + for url in urls: + + self.cache.add( + fqdn=url, + record_type=RecordType.NS, + data=NSRecordData(url) + ) + + nameserver = NameServer(urls) + + return nameserver.data + + def load_nameserver_cache( + self, + url: str='ftp://rs.internic.net/domain/named.cache', + cache_file: str=os.path.join( + os.getcwd(), + 'named.cache.txt' + ), + timeout: Optional[int]=None + ): + + if not os.path.isfile(cache_file): + try: + res = request.urlopen( + url, + timeout=timeout + ) + + with open(cache_file, 'wb') as f: + f.write(res.read()) + + except Exception: + return + + cache_data = pathlib.Path( + cache_file + ).read_text().splitlines() + + for line in cache_data: + if line.startswith(';'): + continue + parts = line.lower().split() + if len(parts) < 4: + continue + + name = parts[0].rstrip('.') + # parts[1] (expires) is ignored + record_type = self.types_map.types_by_name.get( + parts[2], + RecordType.NONE + ) + + data_str = parts[3].rstrip('.') + + + data = Record.create_rdata( + record_type, + data_str + ) + + record = Record( + name=name, + record_type=record_type, + data=data, + ttl=-1, + ) + + self.cache.add(record=record) + + async def _query( + self, + fqdn: str, + record_type: int, + skip_cache: bool=False + ) -> DNSMessage: + + current_try_count = 0 + + return await self._query_tick( + fqdn, + record_type, + skip_cache, + current_try_count + ) + + def _get_matching_nameserver(self, fqdn: str): + '''Return a generator of parent domains''' + + hosts: List[URL] = self._nameserver_urls + empty = True + + while fqdn and empty: + if fqdn in ('in-addr.arpa', ): + break + _, _, fqdn = fqdn.partition('.') + + for rec in self.cache.query(fqdn, RecordType.NS): + record_data: NSRecordData = rec.data + host = record_data.data + + url = URL( + host, + port=self.client.port + ) + + if url.ip_type is None: + # host is a hostname instead of IP address + + for res in self.cache.query( + host, + self.nameserver_types + ): + hosts.append( + URL( + res.data.data, + port=self.client.port + ) + ) + + empty = False + + else: + hosts.append(url) + empty = False + + return NameServer(hosts) + + @memoizer.memoize_async( + lambda _, fqdn, record_type, skip_cache: (fqdn, record_type) + ) + async def _query_tick( + self, + fqdn: str, + record_type: int, + skip_cache: bool, + current_try_count: int + ): + + msg = DNSMessage() + msg.query_domains.append( + Record( + query_type=QueryType.REQUEST, + name=fqdn, + record_type=record_type + ) + ) + + has_result = False + + if skip_cache is False: + has_result, fqdn = self.query_cache(msg, fqdn, record_type) + + last_err = None + nameserver = self._get_matching_nameserver(fqdn) + + while not has_result and current_try_count < self._maximum_tries: + + current_try_count += 1 + + for url in nameserver.iter(): + try: + has_result, fqdn, nsips = await self._query_remote( + msg, + fqdn, + record_type, + url, + current_try_count + ) + + nameserver = NameServer( + self.client.port, + nameservers=nsips + ) + + except Exception as err: + last_err = err + + else: + break + else: + raise last_err or Exception('Unknown error') + + + assert has_result, 'Maximum nested query times exceeded' + + return msg + + async def _query_remote( + self, + msg: DNSMessage, + fqdn: str, + record_type: RecordType, + url: URL, + current_try_count: int + ): + + result: DNSMessage = await self.request( + fqdn, + msg, + url + ) + + if result.query_domains[0].name != fqdn: + raise DNSError(-1, 'Question section mismatch') + + assert result.query_result_code != 2, 'Remote server fail' + + self.cache_message(result) + + has_cname = False + has_result = False + has_ns = False + + for rec in result.query_answers: + msg.query_answers.append(rec) + + if isinstance(rec.data, CNAMERecordData): + fqdn = rec.data.data + has_cname = True + + if rec.record_type != RecordType.CNAME or record_type in ( + RecordType.ANY, + RecordType.CNAME + ): + has_result = True + + for rec in result.query_namservers: + if rec.record_type in ( + RecordType.NS, + RecordType.SOA + ): + has_result = True + + else: + has_ns = True + + if not has_cname and not has_ns: + # Not found, return server fail since we are not authorative + msg = DNSMessage( + **msg.dict(), + query_result_code=2 + ) + + has_result = True + if has_result: + return has_result, fqdn, [] + + # Load name server IPs from res.ar + namespace_ip_address_map = {} + + for record in result.query_additional_records: + if record.record_type in self.nameserver_types: + namespace_ip_address_map[(rec.name, record.record_type)] = rec.data.data + + hosts = [] + for record in result.query_namservers: + + if isinstance(record.data, SOARecordData): + hosts.append(record.data.mname) + + elif isinstance(record.data, NSRecordData): + hosts.append(record.data.data) + + namespace_ips = [] + + for host in hosts: + for record_type in self.nameserver_types: + ip = namespace_ip_address_map.get((host, record_type)) + + if ip is not None: + namespace_ips.append(ip) + + # Usually name server IPs will be included in res.ar. + # In case they are not, query from remote. + if len(namespace_ips) < 1 and len(hosts) > 0: + + current_try_count += 1 + + for record_type in self.nameserver_types: + for host in hosts: + try: + query_tick_result: Tuple[DNSMessage, bool] = await asyncio.shield( + self._query_tick( + host, + record_type, + False, + current_try_count + ) + ) + + ( + ns_res, + _ + ) = query_tick_result + + except Exception: + pass + + else: + for rec in ns_res.query_answers: + if rec.record_type == record_type: + namespace_ips.append(rec.data.data) + break + + if len(namespace_ips) > 0: + break + + return has_result, fqdn, namespace_ips + diff --git a/hyperscale/distributed/discovery/dns/resolver/resolver.py b/hyperscale/distributed/discovery/dns/resolver/resolver.py new file mode 100644 index 0000000..4ac86c7 --- /dev/null +++ b/hyperscale/distributed/discovery/dns/resolver/resolver.py @@ -0,0 +1,126 @@ +import asyncio +from typing import Callable, List, Literal, Optional, Tuple, Union + +from hyperscale.distributed.discovery.dns.core.record import RecordType, RecordTypesMap +from hyperscale.distributed.discovery.dns.core.record.record_data_types import ( + RecordData, +) +from hyperscale.distributed.discovery.dns.core.url import URL +from hyperscale.distributed.env import Env +from hyperscale.distributed.models.dns import DNSMessage + +from .proxy_resolver import ProxyResolver +from .recursive_resolver import RecursiveResolver + +Proxy = List[ + Tuple[ + Union[ + Callable[ + [str], + bool + ], + str, + None + ], + str + ] +] + + +class DNSResolver: + + def __init__( + self, + host: str, + port: int, + instance_id: str, + env: Env, + resolver: Literal["proxy", "recursive"]="proxy", + proxies: Optional[ + List[Proxy] + ]=None + ) -> None: + + if resolver == "proxy": + self.resolver = ProxyResolver( + host, + port, + instance_id, + env, + proxies=proxies + ) + + else: + self.resolver = RecursiveResolver( + host, + port, + instance_id, + env + ) + + self.types_map = RecordTypesMap() + + def add_to_cache( + self, + domain: str, + record_type: RecordType, + data: RecordData, + ttl: Union[int, float]=-1 + ): + self.resolver.cache.add( + fqdn=domain, + record_type=record_type, + data=data, + ttl=ttl + ) + + def add_nameservers( + self, + urls: List[str] + ): + return self.resolver.add_nameserver(urls) + + def set_proxies( + self, + proxies: List[Proxy] + ): + if isinstance(self.resolver, ProxyResolver): + self.resolver.set_proxies(proxies) + + def download_common(self): + if isinstance(self.resolver, RecursiveResolver): + self.resolver.load_nameserver_cache() + + async def connect_nameservers( + self, + urls: List[URL], + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + ): + + await asyncio.gather(*[ + self.resolver.client.connect_client( + url, + cert_path=cert_path, + key_path=key_path + ) for url in urls + ]) + + async def query( + self, + domain_name: str, + record_type: RecordType=RecordType.SRV, + skip_cache: bool=False + ) -> Tuple[DNSMessage, bool]: + + try: + result = await self.resolver.query( + domain_name, + record_type=record_type, + skip_cache=skip_cache + ) + + return result, True + + except asyncio.TimeoutError: + return DNSMessage(), False \ No newline at end of file diff --git a/hyperscale/distributed/discovery/dns/resolver/types.py b/hyperscale/distributed/discovery/dns/resolver/types.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/discovery/volume/__init__.py b/hyperscale/distributed/discovery/volume/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/discovery/volume/backup_volume.py b/hyperscale/distributed/discovery/volume/backup_volume.py new file mode 100644 index 0000000..a27b360 --- /dev/null +++ b/hyperscale/distributed/discovery/volume/backup_volume.py @@ -0,0 +1,18 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor + + +class BackupVolume: + + def __init__( + self, + path: str, + service_name: str, + instance_id: str + ) -> None: + self.path = path + self.service_name = service_name + self.instance_id = instance_id + + + diff --git a/hyperscale/distributed/encryption/__init__.py b/hyperscale/distributed/encryption/__init__.py new file mode 100644 index 0000000..da7ec6c --- /dev/null +++ b/hyperscale/distributed/encryption/__init__.py @@ -0,0 +1 @@ +from .aes_gcm import AESGCMFernet \ No newline at end of file diff --git a/hyperscale/distributed/encryption/aes_gcm.py b/hyperscale/distributed/encryption/aes_gcm.py new file mode 100644 index 0000000..d2f7ed8 --- /dev/null +++ b/hyperscale/distributed/encryption/aes_gcm.py @@ -0,0 +1,22 @@ +import secrets + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from hyperscale.distributed.env import Env + + +class AESGCMFernet: + + def __init__(self, env: Env) -> None: + self.secret = env.MERCURY_SYNC_AUTH_SECRET + + def encrypt(self, data: bytes) -> bytes: + key = secrets.token_bytes(32) + nonce = secrets.token_bytes(12) + return key + nonce + AESGCM(key).encrypt(nonce, data, b"") + + def decrypt(self, data: bytes) -> bytes: + key = data[:32] + nonce = data[32:44] + return AESGCM(key).decrypt(nonce, data[44:], b"") + diff --git a/hyperscale/distributed/env/__init__.py b/hyperscale/distributed/env/__init__.py new file mode 100644 index 0000000..963098a --- /dev/null +++ b/hyperscale/distributed/env/__init__.py @@ -0,0 +1,5 @@ +from .env import Env +from .monitor_env import MonitorEnv +from .replication_env import ReplicationEnv +from .registrar_env import RegistrarEnv +from .load_env import load_env \ No newline at end of file diff --git a/hyperscale/distributed/env/env.py b/hyperscale/distributed/env/env.py new file mode 100644 index 0000000..8b41fca --- /dev/null +++ b/hyperscale/distributed/env/env.py @@ -0,0 +1,91 @@ +import os +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictBool, + StrictFloat, + IPvAnyAddress +) +from typing import ( + Dict, + Union, + Callable, + Literal +) + + +PrimaryType = Union[str, int, float, bytes, bool] + + +class Env(BaseModel): + MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_REJECTION_SENSITIVITY: StrictFloat=2 + MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_FAILURE_WINDOW: StrictStr='1m' + MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_FAILURE_THRESHOLD: Union[StrictInt, StrictFloat]=0.2 + MERCURY_SYNC_HTTP_HANDLER_TIMEOUT: StrictStr='1m' + MERCURY_SYNC_HTTP_RATE_LIMIT_STRATEGY: Literal[ + "none", + "global", + "endpoint", + "ip", + "ip-endpoint", + "custom" + ]="none" + MERCURY_SYNC_HTTP_RATE_LIMITER_TYPE: Literal[ + "adaptive", + "cpu-adaptive", + "leaky-bucket", + "rate-adaptive", + "sliding-window", + "token-bucket", + ]="sliding-window" + MERCURY_SYNC_HTTP_CORS_ENABLED: StrictBool=False + MERCURY_SYNC_HTTP_MEMORY_LIMIT: StrictStr='512mb' + MERCURY_SYNC_HTTP_CPU_LIMIT: Union[StrictFloat, StrictInt]=50 + MERCURY_SYNC_HTTP_RATE_LIMIT_BACKOFF_RATE: StrictInt=10 + MERCURY_SYNC_HTTP_RATE_LIMIT_BACKOFF: StrictStr='1s' + MERCURY_SYNC_HTTP_RATE_LIMIT_PERIOD: StrictStr='1s' + MERCURY_SYNC_HTTP_RATE_LIMIT_REQUESTS: StrictInt=100 + MERCURY_SYNC_HTTP_RATE_LIMIT_DEFAULT_REJECT: StrictBool=True + MERCURY_SYNC_USE_HTTP_MSYNC_ENCRYPTION: StrictBool=False + MERCURY_SYNC_USE_HTTP_SERVER: StrictBool=False + MERCURY_SYNC_USE_HTTP_AND_TCP_SERVERS: StrictBool=False + MERCURY_SYNC_USE_UDP_MULTICAST: StrictBool=False + MERCURY_SYNC_TCP_CONNECT_RETRIES: StrictInt=3 + MERCURY_SYNC_CLEANUP_INTERVAL: StrictStr='0.5s' + MERCURY_SYNC_MAX_CONCURRENCY: StrictInt=2048 + MERCURY_SYNC_AUTH_SECRET: StrictStr + MERCURY_SYNC_MULTICAST_GROUP: IPvAnyAddress='224.1.1.1' + MERCURY_SYNC_LOGS_DIRECTORY: StrictStr=os.getcwd() + MERCURY_SYNC_REQUEST_TIMEOUT: StrictStr='30s' + MERCURY_SYNC_LOG_LEVEL: StrictStr='info' + + @classmethod + def types_map(self) -> Dict[str, Callable[[str], PrimaryType]]: + return { + 'MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_REJECTION_SENSITIVITY': float, + 'MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_FAILURE_WINDOW': str, + 'MERCURY_SYNC_HTTP_HANDLER_TIMEOUT': str, + 'MERCURY_SYNC_USE_UDP_MULTICAST': lambda value: True if value.lower() == 'true' else False, + 'MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_FAILURE_THRESHOLD': float, + 'MERCURY_SYNC_HTTP_CORS_ENABLED': lambda value: True if value.lower() == 'true' else False, + 'MERCURY_SYNC_HTTP_MEMORY_LIMIT': str, + 'MERCURY_SYNC_HTTP_RATE_LIMIT_BACKOFF_RATE': int, + 'MERCURY_SYNC_HTTP_RATE_LIMIT_BACKOFF': str, + 'MERCURY_SYNC_HTTP_CPU_LIMIT': float, + 'MERCURY_SYNC_HTTP_RATE_LIMIT_STRATEGY': str, + 'MERCURY_SYNC_HTTP_RATE_LIMIT_PERIOD': str, + 'MERCURY_SYNC_USE_TCP_SERVER': lambda value: True if value.lower() == 'true' else False, + 'MERCURY_SYNC_HTTP_RATE_LIMIT_REQUESTS': int, + 'MERCURY_SYNC_HTTP_RATE_LIMIT_DEFAULT_REJECT': lambda value: True if value.lower() == 'true' else False, + 'MERCURY_SYNC_USE_HTTP_MSYNC_ENCRYPTION': lambda value: True if value.lower() == 'true' else False, + 'MERCURY_SYNC_USE_HTTP_SERVER': lambda value: True if value.lower() == 'true' else False, + 'MERCURY_SYNC_TCP_CONNECT_RETRIES': int, + 'MERCURY_SYNC_CLEANUP_INTERVAL': str, + 'MERCURY_SYNC_MAX_CONCURRENCY': int, + 'MERCURY_SYNC_AUTH_SECRET': str, + 'MERCURY_SYNC_MULTICAST_GROUP': str, + 'MERCURY_SYNC_LOGS_DIRECTORY': str, + 'MERCURY_SYNC_REQUEST_TIMEOUT': str, + 'MERCURY_SYNC_LOG_LEVEL': str + } \ No newline at end of file diff --git a/hyperscale/distributed/env/load_env.py b/hyperscale/distributed/env/load_env.py new file mode 100644 index 0000000..b0f6c91 --- /dev/null +++ b/hyperscale/distributed/env/load_env.py @@ -0,0 +1,50 @@ +import os +from dotenv import dotenv_values +from typing import ( + Dict, + Union, + Type, + TypeVar +) +from .env import Env +from .monitor_env import MonitorEnv +from .replication_env import ReplicationEnv +from .registrar_env import RegistrarEnv + +T = TypeVar('T') + + +PrimaryType=Union[str, int, bool, float, bytes] + + +def load_env( + env: Type[T], + env_file: str=None +) -> T: + + env_type: Union[Env, MonitorEnv, ReplicationEnv, RegistrarEnv] = env + envars = env_type.types_map() + + if env_file is None: + env_file = '.env' + + values: Dict[str, PrimaryType] = {} + + for envar_name, envar_type in envars.items(): + envar_value = os.getenv(envar_name) + if envar_value: + values[envar_name] = envar_type(envar_value) + + if env_file and os.path.exists(env_file): + env_file_values = dotenv_values(dotenv_path=env_file) + + for envar_name, envar_value in env_file_values.items(): + envar_type = envars.get(envar_name) + if envar_type: + env_file_values[envar_name] = envar_type(envar_value) + + values.update(env_file_values) + + return env(**{ + name: value for name, value in values.items() if value is not None + }) diff --git a/hyperscale/distributed/env/memory_parser.py b/hyperscale/distributed/env/memory_parser.py new file mode 100644 index 0000000..2bdaa0d --- /dev/null +++ b/hyperscale/distributed/env/memory_parser.py @@ -0,0 +1,89 @@ +import re + +class MemoryParser: + + def __init__(self, time_amount: str) -> None: + self.UNITS = { + 'kb':'kilobytes', + 'mb':'megabytes', + 'gb':'gigabytes' + } + + self._conversion_table = { + 'kilobytes': { + 'kilobytes': 1, + 'megabytes': 1/1024, + 'gigabytes': 1/(1024**2) + }, + 'megabytes': { + 'kilobytes': 1024, + 'megabytes': 1, + 'gigabytes': 1/1024 + }, + 'gigabytes': { + 'kilobytes': 1024**2, + 'megabytes': 1024, + 'gigabytes': 1 + } + } + + + parsed_size = { + self.UNITS.get( + m.group( + 'unit' + ).lower(), + 'megabytes' + ): float(m.group('val')) + for m in re.finditer( + r'(?P\d+(\.\d+)?)(?P[smhdw]?)', + time_amount, + flags=re.I + ) + } + + self.unit = list(parsed_size.keys()).pop() + self.size = parsed_size.pop(self.unit) + + def kilobytes(self, accuracy: int = 2): + conversion_amount = self._conversion_table.get( + self.unit, + {} + ).get( + 'kilobytes', + 1 + ) + + return round( + self.size * conversion_amount, + accuracy + ) + + def megabytes(self, accuracy: int = 2): + conversion_amount = self._conversion_table.get( + self.unit, + {} + ).get( + 'megabytes', + 1 + ) + + return round( + self.size * conversion_amount, + accuracy + ) + + def gigabytes(self, accuracy: int = 2): + conversion_amount = self._conversion_table.get( + self.unit, + {} + ).get( + 'gigabytes', + 1 + ) + + + return round( + self.size * conversion_amount, + accuracy + ) \ No newline at end of file diff --git a/hyperscale/distributed/env/monitor_env.py b/hyperscale/distributed/env/monitor_env.py new file mode 100644 index 0000000..f81b946 --- /dev/null +++ b/hyperscale/distributed/env/monitor_env.py @@ -0,0 +1,57 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat +) +from typing import ( + Dict, + Union, + Callable +) + + +PrimaryType = Union[str, int, float, bytes, bool] + + +class MonitorEnv(BaseModel): + MERCURY_SYNC_UDP_SYNC_INTERVAL: StrictStr='5s' + MERCURY_SYNC_BOOT_WAIT: StrictStr='1s' + MERCURY_SYNC_MAX_TIME_IDLE: StrictStr='10s' + MERCURY_SYNC_IDLE_REBOOT_TIMEOUT: StrictStr='10s' + MERCURY_SYNC_POLL_RETRIES: StrictInt=3 + MERCURY_SYNC_MIN_SUSPECT_NODES_THRESHOLD=3 + MERCURY_SYNC_MAX_POLL_MULTIPLIER: StrictInt=5 + MERCURY_SYNC_MIN_SUSPECT_TIMEOUT_MULTIPLIER: StrictInt=4 + MERCURY_SYNC_MAX_SUSPECT_TIMEOUT_MULTIPLIER: StrictInt=7 + MERCURY_SYNC_INITIAL_NODES_COUNT: StrictInt=3 + MERCURY_SYNC_HEALTH_CHECK_TIMEOUT: StrictStr='1s' + MERCURY_SYNC_REGISTRATION_TIMEOUT: StrictStr='1m' + MERCURY_SYNC_HEALTH_POLL_INTERVAL: StrictFloat='1s' + MERCURY_SYNC_INDIRECT_CHECK_NODES: StrictInt=3 + MERCURY_SYNC_FAILED_NODES_MAX_AGE: StrictStr='1m' + MERCURY_SYNC_REMOVED_NODES_MAX_AGE: StrictStr='2m' + MERCURY_SYNC_EXPECTED_NODES: StrictInt=3 + MERCURY_SYNC_SUSPECT_MAX_AGE: StrictStr='1m' + + @classmethod + def types_map(self) -> Dict[str, Callable[[str], PrimaryType]]: + return { + 'MERCURY_SYNC_UDP_SYNC_INTERVAL': str, + 'MERCURY_SYNC_POLL_RETRIES': int, + 'MERCURY_SYNC_MAX_POLL_MULTIPLIER': int, + 'MERCURY_SYNC_MAX_TIME_IDLE': str, + 'MERCURY_SYNC_IDLE_REBOOT_TIMEOUT': str, + 'MERCURY_SYNC_MIN_SUSPECT_NODES_THRESHOLD': int, + 'MERCURY_SYNC_MIN_SUSPECT_TIMEOUT_MULTIPLIER': int, + 'MERCURY_SYNC_MAX_SUSPECT_TIMEOUT_MULTIPLIER': int, + 'MERCURY_SYNC_INITIAL_NODES_COUNT': int, + 'MERCURY_SYNC_BOOT_WAIT': str, + 'MERCURY_SYNC_REGISTRATION_TIMEOUT': str, + 'MERCURY_SYNC_HEALTH_POLL_INTERVAL': str, + 'MERCURY_SYNC_INDIRECT_CHECK_NODES': int, + 'MERCURY_SYNC_FAILED_NODES_MAX_AGE': str, + 'MERCURY_SYNC_REMOVED_NODES_MAX_AGE': str, + 'MERCURY_SYNC_EXPECTED_NODES': int, + 'MERCURY_SYNC_SUSPECT_MAX_AGE': str + } \ No newline at end of file diff --git a/hyperscale/distributed/env/registrar_env.py b/hyperscale/distributed/env/registrar_env.py new file mode 100644 index 0000000..288957c --- /dev/null +++ b/hyperscale/distributed/env/registrar_env.py @@ -0,0 +1,34 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt +) +from typing import ( + Dict, + Union, + Callable, + Literal +) + + +PrimaryType = Union[str, int, float, bytes, bool] + + +class RegistrarEnv(BaseModel): + MERCURY_SYNC_REGISTRAR_CLIENT_POLL_RATE: StrictStr='1s' + MERCURY_SYNC_REGISTRAR_EXPECTED_NODES: StrictInt + MERCURY_SYNC_REGISTRATION_TIMEOUT: StrictStr='1m' + MERCURY_SYNC_RESOLVER_CONNECTION_TYPE: Literal["udp", "tcp", "http"]="udp" + MERCURY_SYNC_RESOLVER_REQUEST_TIMEOUT: StrictStr='5s' + MERCURY_SYNC_RESOLVER_MAXIMUM_TRIES: StrictInt=5 + + @classmethod + def types_map(self) -> Dict[str, Callable[[str], PrimaryType]]: + return { + 'MERCURY_SYNC_REGISTRAR_CLIENT_POLL_RATE': str, + 'MERCURY_SYNC_REGISTRAR_EXPECTED_NODES': int, + 'MERCURY_SYNC_REGISTRATION_TIMEOUT': str, + 'MERCURY_SYNC_RESOLVER_CONNECTION_TYPE': str, + 'MERCURY_SYNC_RESOLVER_REQUEST_TIMEOUT': str, + 'MERCURY_SYNC_RESOLVER_MAXIMUM_TRIES': int + } \ No newline at end of file diff --git a/hyperscale/distributed/env/replication_env.py b/hyperscale/distributed/env/replication_env.py new file mode 100644 index 0000000..ab5c005 --- /dev/null +++ b/hyperscale/distributed/env/replication_env.py @@ -0,0 +1,35 @@ +from pydantic import ( + BaseModel, + StrictInt, + StrictStr +) +from typing import ( + Dict, + Union, + Callable +) + + +PrimaryType = Union[str, int, float, bytes, bool] + + +class ReplicationEnv(BaseModel): + MERCURY_SYNC_RAFT_ELECTION_MAX_TIMEOUT: StrictStr='30s' + MERCURY_SYNC_RAFT_ELECTION_POLL_INTERVAL: StrictStr='1s' + MERCURY_SYNC_RAFT_LOGS_UPDATE_POLL_INTERVAL: StrictStr='1s' + MERCURY_SYNC_RAFT_REGISTRATION_TIMEOUT: StrictStr='15s' + MERCURY_SYNC_RAFT_EXPECTED_NODES: StrictInt=3 + MERCURY_SYNC_RAFT_LOGS_PRUNE_MAX_AGE: StrictStr='1h' + MERCURY_SYNC_RAFT_LOGS_PRUNE_MAX_COUNT: StrictInt=1000 + + @classmethod + def types_map(self) -> Dict[str, Callable[[str], PrimaryType]]: + return { + 'MERCURY_SYNC_RAFT_ELECTION_MAX_TIMEOUT': str, + 'MERCURY_SYNC_RAFT_ELECTION_POLL_INTERVAL': str, + 'MERCURY_SYNC_RAFT_LOGS_UPDATE_POLL_INTERVAL': str, + 'MERCURY_SYNC_RAFT_REGISTRATION_TIMEOUT': str, + 'MERCURY_SYNC_RAFT_EXPECTED_NODES': int, + 'MERCURY_SYNC_RAFT_LOGS_PRUNE_MAX_AGE': str, + 'MERCURY_SYNC_RAFT_LOGS_PRUNE_MAX_COUNT': int + } \ No newline at end of file diff --git a/hyperscale/distributed/env/time_parser.py b/hyperscale/distributed/env/time_parser.py new file mode 100644 index 0000000..306ac09 --- /dev/null +++ b/hyperscale/distributed/env/time_parser.py @@ -0,0 +1,31 @@ +import re +from datetime import timedelta + +class TimeParser: + + def __init__(self, time_amount: str) -> None: + self.UNITS = { + 's':'seconds', + 'm':'minutes', + 'h':'hours', + 'd':'days', + 'w':'weeks' + } + self.time = float( + timedelta( + **{ + self.UNITS.get( + m.group( + 'unit' + ).lower(), + 'seconds' + ): float(m.group('val') + ) + for m in re.finditer( + r'(?P\d+(\.\d+)?)(?P[smhdw]?)', + time_amount, + flags=re.I + ) + } + ).total_seconds() + ) diff --git a/hyperscale/distributed/hooks/__init__.py b/hyperscale/distributed/hooks/__init__.py new file mode 100644 index 0000000..fb9f05a --- /dev/null +++ b/hyperscale/distributed/hooks/__init__.py @@ -0,0 +1,5 @@ +from .client_hook import client +from .endpoint_hook import endpoint +from .middleware_hook import middleware +from .server_hook import server +from .stream_hook import stream \ No newline at end of file diff --git a/hyperscale/distributed/hooks/client_hook.py b/hyperscale/distributed/hooks/client_hook.py new file mode 100644 index 0000000..e86aaf5 --- /dev/null +++ b/hyperscale/distributed/hooks/client_hook.py @@ -0,0 +1,39 @@ +import functools +from typing import Union + +from hyperscale.distributed.service import Service +from hyperscale.distributed.service.controller import Controller + + +def client( + call_name: str, + as_tcp: bool=False +): + + def wraps(func): + + func.client_only = True + func.target = call_name + + @functools.wraps(func) + async def decorator( + *args, + **kwargs + ): + connection: Union[Service, Controller] = args[0] + + if as_tcp: + return await connection.send_tcp( + call_name, + await func(*args, **kwargs) + ) + + else: + return await connection.send( + call_name, + await func(*args, **kwargs) + ) + + return decorator + + return wraps diff --git a/hyperscale/distributed/hooks/endpoint_hook.py b/hyperscale/distributed/hooks/endpoint_hook.py new file mode 100644 index 0000000..3b92586 --- /dev/null +++ b/hyperscale/distributed/hooks/endpoint_hook.py @@ -0,0 +1,103 @@ +import functools +from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypeVar + +from pydantic import BaseModel + +from hyperscale.distributed.models.http import Limit, Request + +T = TypeVar('T') + + +def endpoint( + path: Optional[str]="/", + methods: List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ]=["GET"], + responses: Optional[ + Dict[ + int, + BaseModel + ] + ]=None, + serializers: Optional[ + Dict[ + int, + Callable[ + ..., + str + ] + ] + ]=None, + middleware: Optional[ + List[ + Callable[ + [ + Request + ], + Tuple[ + Any, + int, + bool + ] + ] + ] + ]=None, + response_headers: Optional[Dict[str, str]]=None, + limit: Optional[Limit]=None +): + + def wraps(func): + + func.server_only = True + func.path = path + func.methods = methods + func.as_http = True + + func.response_headers = response_headers or {} + func.responses = responses + func.serializers = serializers + func.limit = limit + + if middleware: + @functools.wraps(func) + async def middleware_decorator( + *args, + **kwargs + ): + + run_next = True + + _, request = args + + for middleware_func in middleware: + response, run_next = await middleware_func(request) + + if run_next is False: + return response + + + return await func(*args, **kwargs) + + return middleware_decorator + + + else: + @functools.wraps(func) + def decorator( + *args, + **kwargs + ): + return func(*args, **kwargs) + + return decorator + + return wraps diff --git a/hyperscale/distributed/hooks/middleware_hook.py b/hyperscale/distributed/hooks/middleware_hook.py new file mode 100644 index 0000000..09de637 --- /dev/null +++ b/hyperscale/distributed/hooks/middleware_hook.py @@ -0,0 +1,19 @@ +import functools + + +def middleware(): + + def wraps(func): + + func.is_middleware = True + + @functools.wraps(func) + def decorator( + *args, + **kwargs + ): + return func(*args, **kwargs) + + return decorator + + return wraps diff --git a/hyperscale/distributed/hooks/server_hook.py b/hyperscale/distributed/hooks/server_hook.py new file mode 100644 index 0000000..3b6be28 --- /dev/null +++ b/hyperscale/distributed/hooks/server_hook.py @@ -0,0 +1,20 @@ +import functools + + +def server(): + + def wraps(func): + + func.server_only = True + func.as_http = False + + @functools.wraps(func) + def decorator( + *args, + **kwargs + ): + return func(*args, **kwargs) + + return decorator + + return wraps diff --git a/hyperscale/distributed/hooks/stream_hook.py b/hyperscale/distributed/hooks/stream_hook.py new file mode 100644 index 0000000..ae38b63 --- /dev/null +++ b/hyperscale/distributed/hooks/stream_hook.py @@ -0,0 +1,49 @@ + + + +import functools +from typing import Union + +from hyperscale.distributed.service import Service +from hyperscale.distributed.service.controller import Controller + + +def stream( + call_name: str, + as_tcp: bool=False +): + + def wraps(func): + + func.client_only = True + func.target = call_name + + @functools.wraps(func) + async def decorator( + *args, + **kwargs + ): + connection: Union[Service, Controller] = args[0] + + if as_tcp: + + async for data in func(*args, **kwargs): + async for response in connection.stream_tcp( + call_name, + data + ): + yield response + + + else: + async for data in func(*args, **kwargs): + async for response in connection.stream( + call_name, + data + ): + + yield response + + return decorator + + return wraps diff --git a/hyperscale/distributed/middleware/__init__.py b/hyperscale/distributed/middleware/__init__.py new file mode 100644 index 0000000..82edb97 --- /dev/null +++ b/hyperscale/distributed/middleware/__init__.py @@ -0,0 +1,18 @@ +from .cors import Cors +from .crsf import CRSF + +from .circuit_breaker import CircuitBreaker + +from .compressor import ( + BidirectionalGZipCompressor, + BidirectionalZStandardCompressor, + GZipCompressor, + ZStandardCompressor +) + +from .decompressor import ( + BidirectionalGZipDecompressor, + BidirectionalZStandardDecompressor, + GZipDecompressor, + ZStandardDecompressor +) \ No newline at end of file diff --git a/hyperscale/distributed/middleware/base/__init__.py b/hyperscale/distributed/middleware/base/__init__.py new file mode 100644 index 0000000..5824fb9 --- /dev/null +++ b/hyperscale/distributed/middleware/base/__init__.py @@ -0,0 +1,4 @@ +from .bidirectional_wrapper import BidirectionalWrapper +from .unidirectional_wrapper import UnidirectionalWrapper +from .middleware import Middleware +from .types import MiddlewareType \ No newline at end of file diff --git a/hyperscale/distributed/middleware/base/base_wrapper.py b/hyperscale/distributed/middleware/base/base_wrapper.py new file mode 100644 index 0000000..bf4ee54 --- /dev/null +++ b/hyperscale/distributed/middleware/base/base_wrapper.py @@ -0,0 +1,14 @@ +from typing import Callable, Coroutine, Any + + +class BaseWrapper: + + def __init__(self) -> None: + self.setup: Callable[ + [], + Coroutine[ + Any, + Any, + None + ] + ] = None \ No newline at end of file diff --git a/hyperscale/distributed/middleware/base/bidirectional_wrapper.py b/hyperscale/distributed/middleware/base/bidirectional_wrapper.py new file mode 100644 index 0000000..089271c --- /dev/null +++ b/hyperscale/distributed/middleware/base/bidirectional_wrapper.py @@ -0,0 +1,131 @@ +from typing import Callable, Dict, List, Literal, Optional, TypeVar, Union + +from pydantic import BaseModel + +from hyperscale.distributed.models.http import Request + +from .base_wrapper import BaseWrapper +from .types import BidirectionalMiddlewareHandler, Handler, MiddlewareType + +T = TypeVar('T') + + +class BidirectionalWrapper(BaseWrapper): + + def __init__( + self, + name: str, + handler: Handler, + middleware_type: MiddlewareType=MiddlewareType.BIDIRECTIONAL, + methods: Optional[ + List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] + ]=None, + responses: Optional[ + Dict[ + int, + BaseModel + ] + ]=None, + serializers: Optional[ + Dict[ + int, + Callable[ + ..., + str + ] + ] + ]=None, + response_headers: Optional[ + Dict[str, str] + ]=None + ) -> None: + + super().__init__() + + self.name = name + self.path = handler.path + self.methods: List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] = handler.methods + + if methods: + self.methods.extend(methods) + + self.response_headers: Union[ + Dict[str, str], + None + ] = handler.response_headers + + if self.response_headers and response_headers: + self.response_headers.update(response_headers) + + elif response_headers: + self.response_headers = response_headers + + self.responses = responses + self.serializers = serializers + self.limit = handler.limit + + self.handler = handler + self.wraps = isinstance(handler, BaseWrapper) + + if self.handler.response_headers and self.response_headers: + self.handler.response_headers = {} + + self.pre: Optional[BidirectionalMiddlewareHandler] = None + self.post: Optional[BidirectionalMiddlewareHandler] = None + + self.middleware_type = middleware_type + + async def __call__( + self, + request: Request + ): + + (request, response, middleware_status), run_next = await self.pre( + request, + None, + None + ) + + if run_next is False: + return response, middleware_status + + if self.wraps: + result, status = await self.handler(request) + result.headers.update(response.headers) + + else: + result, status = await self.handler(request) + + + (request, response, middleware_status), run_next = await self.post( + request, + result, + status + ) + + if run_next is False: + return response, middleware_status + + return response, status \ No newline at end of file diff --git a/hyperscale/distributed/middleware/base/call_wrapper.py b/hyperscale/distributed/middleware/base/call_wrapper.py new file mode 100644 index 0000000..c890488 --- /dev/null +++ b/hyperscale/distributed/middleware/base/call_wrapper.py @@ -0,0 +1,110 @@ + +from typing import Callable, Dict, List, Literal, Optional, TypeVar, Union + +from pydantic import BaseModel + +from hyperscale.distributed.models.http import Request + +from .base_wrapper import BaseWrapper +from .types import CallHandler, Handler, MiddlewareType + +T = TypeVar('T') + + +class CallWrapper(BaseWrapper): + + def __init__( + self, + name: str, + handler: Handler, + middleware_type: MiddlewareType=MiddlewareType.CALL, + methods: Optional[ + List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] + ]=None, + responses: Optional[ + Dict[ + int, + BaseModel + ] + ]=None, + serializers: Optional[ + Dict[ + int, + Callable[ + ..., + str + ] + ] + ]=None, + response_headers: Optional[ + Dict[str, str] + ]=None + ) -> None: + + super().__init__() + + self.name = name + self.path = handler.path + self.methods: List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] = handler.methods + + if methods: + self.methods.extend(methods) + + self.response_headers: Union[ + Dict[str, str], + None + ] = handler.response_headers + + if self.response_headers and response_headers: + self.response_headers.update(response_headers) + + elif response_headers: + self.response_headers = response_headers + + self.responses = responses + self.serializers = serializers + self.limit = handler.limit + + self.handler = handler + self.wraps = isinstance(handler, BaseWrapper) + + if self.handler.response_headers and self.response_headers: + self.handler.response_headers = {} + + self.run: Optional[CallHandler] = None + + self.middleware_type = middleware_type + + async def __call__( + self, + request: Request + ): + + (request, response, status) = await self.run( + request, + self.handler + ) + + return response, status diff --git a/hyperscale/distributed/middleware/base/middleware.py b/hyperscale/distributed/middleware/base/middleware.py new file mode 100644 index 0000000..66459a2 --- /dev/null +++ b/hyperscale/distributed/middleware/base/middleware.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from typing import Callable, Dict, List, Literal, Optional, Tuple, Union + +from pydantic import BaseModel + +from hyperscale.distributed.models.http import Request, Response + +from .bidirectional_wrapper import BidirectionalWrapper +from .call_wrapper import CallWrapper +from .types import MiddlewareType +from .unidirectional_wrapper import UnidirectionalWrapper + + +class Middleware: + + def __init__( + self, + name: str, + middleware_type: MiddlewareType=MiddlewareType.UNIDIRECTIONAL_BEFORE, + methods: Optional[ + List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] + ]=None, + response_headers: Dict[str, str]={} + ) -> None: + + self.name = name + self.methods = methods + self.response_headers = response_headers + self.middleware_type = middleware_type + self.wraps = False + + self._wrapper_types = { + MiddlewareType.BIDIRECTIONAL: BidirectionalWrapper, + MiddlewareType.CALL: CallWrapper, + MiddlewareType.UNIDIRECTIONAL_BEFORE: UnidirectionalWrapper, + MiddlewareType.UNIDIRECTIONAL_AFTER: UnidirectionalWrapper, + } + + def __call__(self, request: Request) -> Tuple[ + Tuple[Response, int], + bool + ]: + raise NotImplementedError('Err. __call__() should not be called on base Middleware class.') + + def wrap( + self, + handler: Callable[ + [Request], + Union[ + BaseModel, + str, + None + ] + ] + ): + + wrapper = self._wrapper_types.get( + self.middleware_type, + BidirectionalWrapper( + self.name, + handler, + methods=self.methods, + response_headers=self.response_headers, + middleware_type=self.middleware_type + ) + )( + self.name, + handler, + methods=self.methods, + response_headers=self.response_headers, + middleware_type=self.middleware_type + ) + + if isinstance(wrapper, BidirectionalWrapper): + wrapper.pre = self.__pre__ + wrapper.post = self.__post__ + + elif isinstance(wrapper, (CallWrapper, UnidirectionalWrapper)): + + wrapper.run = self.__run__ + + self.response_headers.update(wrapper.response_headers) + + wrapper.setup = self.__setup__ + self.wraps = wrapper.wraps + + return wrapper + + async def __setup__(self): + pass + + async def __pre__( + self, + request: Request, + response: Response, + status: int + ) -> Tuple[ + Tuple[ + Request, + Response, + int + ], + bool + ]: + raise NotImplementedError('Err. - __pre__() is not implemented for base Middleware class.') + + async def __post__( + self, + request: Request, + response: Response, + status: int + ) -> Tuple[ + Tuple[ + Request, + Response, + int + ], + bool + ]: + raise NotImplementedError('Err. - __post__() is not implemented for base Middleware class.') + + async def __run__( + self, + request: Request, + response: Response, + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + raise NotImplementedError('Err. - __post__() is not implemented for base Middleware class.') + + async def run( + self, + request: Request + ): + raise NotImplementedError('Err. - middleware() is not implemented for base Middleware class.') + \ No newline at end of file diff --git a/hyperscale/distributed/middleware/base/types.py b/hyperscale/distributed/middleware/base/types.py new file mode 100644 index 0000000..4d7dd23 --- /dev/null +++ b/hyperscale/distributed/middleware/base/types.py @@ -0,0 +1,105 @@ +from enum import Enum +from typing import Any, Callable, Coroutine, Tuple, Union + +from pydantic import BaseModel + +from hyperscale.distributed.models.http import Request, Response + + +class MiddlewareType(Enum): + BIDIRECTIONAL='BIDIRECTIONAL' + CALL='CALL' + UNIDIRECTIONAL_BEFORE='UNIDIRECTIONAL_BEFORE' + UNIDIRECTIONAL_AFTER='UNIDIRECTIONAL_AFTER' + + +RequestHandler = Callable[ + [Request], + Coroutine[ + Any, + Any, + Tuple[ + Union[ + Response, + BaseModel, + str, + None + ], + int + ] + ] +] + +WrappedHandler = Callable[ + [ + Request, + Response, + int + ], + Coroutine[ + Any, + Any, + Tuple[ + Response, + int + ] + ] +] + +CallHandler = Callable[ + [ + Request, + RequestHandler + ], + Coroutine[ + Any, + Any, + Tuple[ + Request, + Response, + int + ] + ] +] + +MiddlewareHandler = Callable[ + [ + Request, + Response, + int + ], + Coroutine[ + Any, + Any, + Tuple[ + Tuple[Response, int], + bool + ] + ] +] + + + +BidirectionalMiddlewareHandler = Callable[ + [ + Request, + Response, + int + ], + Coroutine[ + Any, + Any, + Tuple[ + Tuple[ + Request, + Response, + int + ], + bool + ] + ] +] + + + +Handler = Union[RequestHandler, WrappedHandler] \ No newline at end of file diff --git a/hyperscale/distributed/middleware/base/unidirectional_wrapper.py b/hyperscale/distributed/middleware/base/unidirectional_wrapper.py new file mode 100644 index 0000000..dd10a06 --- /dev/null +++ b/hyperscale/distributed/middleware/base/unidirectional_wrapper.py @@ -0,0 +1,163 @@ +from typing import ( + Callable, + Dict, + List, + Literal, + Optional, + TypeVar, + Union, +) + +from pydantic import BaseModel + +from hyperscale.distributed.models.http import Request + +from .base_wrapper import BaseWrapper +from .types import Handler, MiddlewareHandler, MiddlewareType + +T = TypeVar('T') + + +class UnidirectionalWrapper(BaseWrapper): + + def __init__( + self, + name: str, + handler: Handler, + middleware_type: MiddlewareType=MiddlewareType.UNIDIRECTIONAL_BEFORE, + methods: Optional[ + List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] + ]=None, + responses: Optional[ + Dict[ + int, + BaseModel + ] + ]=None, + serializers: Optional[ + Dict[ + int, + Callable[ + ..., + str + ] + ] + ]=None, + response_headers: Optional[ + Dict[str, str] + ]=None + ) -> None: + + super().__init__() + + self.name = name + self.path = handler.path + self.methods: List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] = handler.methods + + if methods: + self.methods.extend(methods) + + self.response_headers: Union[ + Dict[str, str], + None + ] = handler.response_headers + + if self.response_headers and response_headers: + self.response_headers.update(response_headers) + + elif response_headers: + self.response_headers = response_headers + + self.responses = responses + self.serializers = serializers + self.limit = handler.limit + + self.handler = handler + self.wraps = isinstance(handler, BaseWrapper) + + if self.handler.response_headers and self.response_headers: + self.handler.response_headers = {} + + self.run: Optional[MiddlewareHandler] = None + self.middleware_type = middleware_type + + async def __call__( + self, + request: Request + ): + + if self.wraps: + + result, status = await self.handler(request) + + (response, middleware_status), run_next = await self.run( + request, + result, + status + ) + + + result.headers.update(response.headers) + + if response.data: + result.data = response.data + + if run_next is False: + return response, middleware_status + + return result, status + + elif self.middleware_type == MiddlewareType.UNIDIRECTIONAL_BEFORE: + + (response, middleware_status), run_next = await self.run( + request, + None, + None + ) + + if run_next is False: + return response, middleware_status + + result, status = await self.handler(request) + + response.data = result + + return response, status + + else: + + result, status = await self.handler(request) + + (response, middleware_status), run_next = await self.run( + request, + result, + status + ) + + if run_next is False: + return response, middleware_status + + return response, status + diff --git a/hyperscale/distributed/middleware/circuit_breaker/__init__.py b/hyperscale/distributed/middleware/circuit_breaker/__init__.py new file mode 100644 index 0000000..65299b4 --- /dev/null +++ b/hyperscale/distributed/middleware/circuit_breaker/__init__.py @@ -0,0 +1 @@ +from .circuit_breaker import CircuitBreaker \ No newline at end of file diff --git a/hyperscale/distributed/middleware/circuit_breaker/circuit_breaker.py b/hyperscale/distributed/middleware/circuit_breaker/circuit_breaker.py new file mode 100644 index 0000000..434b857 --- /dev/null +++ b/hyperscale/distributed/middleware/circuit_breaker/circuit_breaker.py @@ -0,0 +1,230 @@ +import asyncio +import math +import random +from typing import Optional, Union + +from hyperscale.distributed.env import Env, load_env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.middleware.base.types import RequestHandler +from hyperscale.distributed.models.http import Request, Response +from hyperscale.distributed.rate_limiting.limiters import SlidingWindowLimiter + +from .circuit_breaker_state import CircuitBreakerState + + +class CircuitBreaker(Middleware): + + def __init__( + self, + failure_threshold: Optional[float]=None, + failure_window: Optional[str]=None, + handler_timeout: Optional[str]=None, + rejection_sensitivity: Optional[float]=None + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.CALL + ) + + env = load_env(Env) + + if failure_threshold is None: + failure_threshold = env.MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_FAILURE_THRESHOLD + + if failure_window is None: + failure_window = env.MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_FAILURE_WINDOW + + if handler_timeout is None: + handler_timeout = env.MERCURY_SYNC_HTTP_HANDLER_TIMEOUT + + if rejection_sensitivity is None: + rejection_sensitivity = env.MERCURY_SYNC_HTTP_CIRCUIT_BREAKER_REJECTION_SENSITIVITY + + self.failure_threshold = failure_threshold + self.rejection_sensitivity = rejection_sensitivity + + self.failure_window = TimeParser(failure_window).time + self.handler_timeout = TimeParser(handler_timeout).time + self._limiter_failure_window = failure_window + + self.overload = 0 + self.failed = 0 + self.succeeded = 0 + self.total_completed = 0 + + self._rate_per_sec = 0 + self._rate_per_sec_succeeded = 0 + self._rate_per_sec_failed = 0 + + self._previous_count = 0 + self._previous_count_succeeded = 0 + self._previous_count_failed = 0 + + self.wraps: bool = False + + self._loop: Union[asyncio.AbstractEventLoop, None] = None + self._current_time: Union[float, None]=None + self._breaker_state = CircuitBreakerState.CLOSED + + self._limiter: Union[SlidingWindowLimiter, None]=None + + self._closed_window_start: Union[float, None] = None + self._closed_elapsed = 0 + + self._half_open_window_start: Union[float, None] = None + self._half_open_elapsed = 0 + + def trip_breaker(self) -> bool: + + failed_rate_threshold = max( + self._rate_per_sec * self.failure_threshold, + 1 + ) + + return int(self._rate_per_sec_failed) > int(failed_rate_threshold) + + def reject_request(self) -> bool: + + if (self._loop.time() - self._current_time) > self.failure_window: + self._current_time = math.floor( + self._loop.time()/self.failure_window + ) * self.failure_window + + self._previous_count = self.total_completed + self._previous_count_succeeded = self.succeeded + self._previous_count_failed = self.failed + + self.failed = 0 + self.succeeded = 0 + self.total_completed = 0 + + self._rate_per_sec = ( + self._previous_count * ( + self.failure_window - (self._loop.time() - self._current_time) + )/self.failure_window + ) + self.total_completed + + + self._rate_per_sec_succeeded = ( + self._previous_count_succeeded * ( + self.failure_window - (self._loop.time() - self._current_time) + )/self.failure_window + ) + self.succeeded + + self._rate_per_sec_failed = ( + self._previous_count_failed * ( + self.failure_window - (self._loop.time() - self._current_time) + )/self.failure_window + ) + self.failed + + success_rate = self._rate_per_sec_succeeded/(1 - self.failure_threshold) + + rejection_probability = max( + (self._rate_per_sec - success_rate)/(self._rate_per_sec + 1), + 0 + )**(1/self.rejection_sensitivity) + + return random.random() < rejection_probability + + async def __setup__(self): + self._loop = asyncio.get_event_loop() + self._current_time = self._loop.time() + + async def __run__( + self, + request: Request, + handler: RequestHandler + ): + + reject = self.reject_request() + + if self._breaker_state == CircuitBreakerState.OPEN and self._closed_elapsed < self.failure_window: + self._closed_elapsed = self._loop.time() - self._closed_window_start + reject = True + + elif self._breaker_state == CircuitBreakerState.OPEN: + + self._breaker_state = CircuitBreakerState.HALF_OPEN + + self._half_open_window_start = self._loop.time() + self._closed_elapsed = 0 + + if self._breaker_state == CircuitBreakerState.HALF_OPEN and self._half_open_elapsed < self.failure_window: + self._half_open_elapsed = self._loop.time() - self._half_open_window_start + + elif self._breaker_state == CircuitBreakerState.HALF_OPEN: + self._breaker_state = CircuitBreakerState.CLOSED + self._half_open_elapsed = 0 + + if reject: + response = Response( + request.path, + request.method, + headers={ + 'x-mercury-sync-overload': True + } + ) + + status = 503 + + else: + + try: + + response, status = await asyncio.wait_for( + handler(request), + timeout=self.handler_timeout + ) + + + if self.wraps is False: + response = Response( + request.path, + request.method, + headers=handler.response_headers, + data=response + ) + + + except Exception: + + response = Response( + request.path, + request.method + ) + + status = 504 + + # Don't count rejections toward failure stats. + if status >= 400: + self.failed += 1 + + elif status < 400: + self.succeeded += 1 + + self.total_completed += 1 + + breaker_open = self._breaker_state == CircuitBreakerState.CLOSED or self._breaker_state == CircuitBreakerState.HALF_OPEN + + if self.trip_breaker() and breaker_open: + + self._breaker_state = CircuitBreakerState.OPEN + reject = True + + self._closed_window_start = self._loop.time() + self._half_open_elapsed = 0 + + return ( + request, + response, + status + ) + + + + + + + + diff --git a/hyperscale/distributed/middleware/circuit_breaker/circuit_breaker_state.py b/hyperscale/distributed/middleware/circuit_breaker/circuit_breaker_state.py new file mode 100644 index 0000000..b482ad3 --- /dev/null +++ b/hyperscale/distributed/middleware/circuit_breaker/circuit_breaker_state.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class CircuitBreakerState(Enum): + CLOSED='CLOSED' + HALF_OPEN='HALF_OPEN' + OPEN='OPEN' \ No newline at end of file diff --git a/hyperscale/distributed/middleware/compressor/__init__.py b/hyperscale/distributed/middleware/compressor/__init__.py new file mode 100644 index 0000000..55df9b3 --- /dev/null +++ b/hyperscale/distributed/middleware/compressor/__init__.py @@ -0,0 +1,4 @@ +from .bidirectional_gzip_compressor import BidirectionalGZipCompressor +from .bidirectional_zstandard_compressor import BidirectionalZStandardCompressor +from .gzip_compressor import GZipCompressor +from .zstandard_compressor import ZStandardCompressor \ No newline at end of file diff --git a/hyperscale/distributed/middleware/compressor/bidirectional_gzip_compressor.py b/hyperscale/distributed/middleware/compressor/bidirectional_gzip_compressor.py new file mode 100644 index 0000000..8667aa6 --- /dev/null +++ b/hyperscale/distributed/middleware/compressor/bidirectional_gzip_compressor.py @@ -0,0 +1,175 @@ +from base64 import b64encode +from gzip import compress +from typing import Callable, Dict, Tuple, Union + +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class BidirectionalGZipCompressor(Middleware): + + def __init__( + self, + compression_level: int=9, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.BIDIRECTIONAL + ) + + self.compression_level = compression_level + self.serializers = serializers + + async def __pre__( + self, + request: Request, + response: Union[ + BaseModel, + str, + None + ], + status: int + ): + try: + + if request.raw != b'': + request.content = compress( + request.content, + compresslevel=self.compression_level + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'x-compression-encoding': 'zstd' + } + ), + 200 + ), True + + except Exception as e: + return ( + None, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False + + async def __post__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + request, + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + compressed_data = compress( + response.encode(), + compresslevel=self.compression_level + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }, + data=b64encode(compressed_data).decode() + ), + status + ), True + + else: + serialized = self.serializers[request.path](response) + + compressed_data = compress( + serialized, + compresslevel=self.compression_level + ) + + response.headers.update({ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }) + + return ( + request, + Response( + request.path, + request.method, + headers=response.headers, + data=b64encode(compressed_data).decode() + ), + status + ), True + + except KeyError: + return ( + request, + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + return ( + request, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/compressor/bidirectional_zstandard_compressor.py b/hyperscale/distributed/middleware/compressor/bidirectional_zstandard_compressor.py new file mode 100644 index 0000000..0567858 --- /dev/null +++ b/hyperscale/distributed/middleware/compressor/bidirectional_zstandard_compressor.py @@ -0,0 +1,174 @@ +from base64 import b64encode +from typing import Callable, Dict, Tuple, Union + +import zstandard +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class BidirectionalZStandardCompressor(Middleware): + + def __init__( + self, + compression_level: int=9, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.BIDIRECTIONAL + ) + + self.compression_level = compression_level + self.serializers = serializers + self._compressor = zstandard.ZstdCompressor() + + async def __pre__( + self, + request: Request, + response: Union[ + BaseModel, + str, + None + ], + status: int + ): + try: + + if request.raw != b'': + + request.content = self._compressor.compress( + request.content + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'x-compression-encoding': 'zstd' + } + ), + 200 + ), True + + except Exception as e: + return ( + None, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False + + async def __post__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + request, + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + compressed_data = self._compressor.compress( + response.encode() + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }, + data=b64encode(compressed_data).decode() + ), + status + ), True + + else: + serialized = self.serializers[request.path](response) + + compressed_data = self._compressor.compress( + serialized + ) + + response.headers.update({ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }) + + return ( + request, + Response( + request.path, + request.method, + headers=response.headers, + data=b64encode(compressed_data).decode() + ), + status + ), True + + except KeyError: + return ( + request, + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + return ( + request, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/compressor/gzip_compressor.py b/hyperscale/distributed/middleware/compressor/gzip_compressor.py new file mode 100644 index 0000000..9921d2e --- /dev/null +++ b/hyperscale/distributed/middleware/compressor/gzip_compressor.py @@ -0,0 +1,129 @@ +from base64 import b64encode +from gzip import compress +from typing import Callable, Dict, Tuple, Union + +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class GZipCompressor(Middleware): + + def __init__( + self, + compression_level: int=9, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.UNIDIRECTIONAL_AFTER + ) + + self.compression_level = compression_level + self.serializers = serializers + + async def __run__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + compressed_data = compress( + response.encode(), + compresslevel=self.compression_level + ) + + + return ( + Response( + request.path, + request.method, + headers={ + 'content-encoding': 'gzip' + }, + data=b64encode(compressed_data).decode() + ), + status + ), True + + else: + serialized = self.serializers[request.path](response) + + compressed_data = compress( + serialized, + compresslevel=self.compression_level + ) + + response.headers.update({ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }) + + return ( + Response( + request.path, + request.method, + headers=response.headers, + data=b64encode(compressed_data).decode() + ), + status + ), True + + except KeyError: + return ( + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + return ( + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/compressor/zstandard_compressor.py b/hyperscale/distributed/middleware/compressor/zstandard_compressor.py new file mode 100644 index 0000000..2a1508b --- /dev/null +++ b/hyperscale/distributed/middleware/compressor/zstandard_compressor.py @@ -0,0 +1,127 @@ +from base64 import b64encode +from typing import Callable, Dict, Tuple, Union + +import zstandard +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class ZStandardCompressor(Middleware): + + def __init__( + self, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.UNIDIRECTIONAL_AFTER + ) + + self.serializers = serializers + self._compressor = zstandard.ZstdCompressor() + + async def __run__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + compressed_data: bytes = self._compressor.compress( + response.encode() + ) + + return ( + Response( + request.path, + request.method, + headers={ + 'x-compression-encoding': 'zstd', + 'content-type': 'text/plain' + }, + data=b64encode(compressed_data).decode() + ), + status + ), True + + else: + + serialized = self.serializers[request.path](response) + compressed_data: bytes = self._compressor.compress( + serialized + ) + + response.headers.update({ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }) + + return ( + Response( + request.path, + request.method, + headers=response.headers, + data=b64encode(compressed_data).decode() + ), + status + ), True + + except KeyError: + return ( + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + + return ( + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/cors/__init__.py b/hyperscale/distributed/middleware/cors/__init__.py new file mode 100644 index 0000000..d4b6fd6 --- /dev/null +++ b/hyperscale/distributed/middleware/cors/__init__.py @@ -0,0 +1 @@ +from .cors import Cors \ No newline at end of file diff --git a/hyperscale/distributed/middleware/cors/cors.py b/hyperscale/distributed/middleware/cors/cors.py new file mode 100644 index 0000000..c5a356c --- /dev/null +++ b/hyperscale/distributed/middleware/cors/cors.py @@ -0,0 +1,166 @@ +from typing import List, Literal, Optional, Tuple, Union + +from hyperscale.distributed.middleware.base import Middleware +from hyperscale.distributed.models.http import Request, Response + +from .cors_headers import CorsHeaders + + +class Cors(Middleware): + + def __init__( + self, + access_control_allow_origin: List[str]=None, + access_control_allow_methods: List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ]=None, + access_control_expose_headers: Optional[List[str]]=None, + access_control_max_age: Optional[Union[int, float]]=None, + access_control_allow_credentials: Optional[bool]=None, + access_control_allow_headers: Optional[List[str]]=None + ) -> None: + + self._cors_config = CorsHeaders( + access_control_allow_origin=access_control_allow_origin, + access_control_expose_headers=access_control_expose_headers, + access_control_max_age=access_control_max_age, + access_control_allow_credentials=access_control_allow_credentials, + access_control_allow_methods=access_control_allow_methods, + access_control_allow_headers=access_control_allow_headers, + ) + + self.origins = self._cors_config.access_control_allow_origin + self.cors_methods = self._cors_config.access_control_allow_methods + self.cors_headers = self._cors_config.access_control_allow_headers + self.allow_credentials = self._cors_config.access_control_allow_credentials + + self.allow_all_origins = '*' in self._cors_config.access_control_allow_origin + + allowed_headers = self._cors_config.access_control_allow_headers + self.allow_all_headers = False + + if allowed_headers: + self.allow_all_headers = '*' in allowed_headers + + self.simple_headers = self._cors_config.to_simple_headers() + self.preflight_headers = self._cors_config.to_preflight_headers() + self.preflight_explicit_allow_origin = not self.allow_all_origins or self.allow_credentials + + super().__init__( + self.__class__.__name__, + methods=['OPTIONS'], + response_headers=self._cors_config.to_headers() + ) + + async def __run__( + self, + request: Request, + response: Optional[Response], + status: Optional[int] + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + + headers = request.headers + method = request.method + + origin = headers.get('origin') + access_control_request_method = headers.get('access-control-request-method') + access_control_request_headers = headers.get('access-control-request-headers') + access_control_request_headers = headers.get("access-control-request-headers") + + if method == "OPTIONS" and access_control_request_method: + + response_headers = dict(self.preflight_headers) + + failures: List[str] = [] + + if self.allow_all_origins is False and origin not in self.origins: + failures.append("origin") + + elif self.preflight_explicit_allow_origin: + response['Access-Control-Allow-Origin'] = origin + + if access_control_request_method not in self.cors_methods: + failures.append("method") + + if self.allow_all_headers and access_control_request_headers is not None: + response_headers["Access-Control-Allow-Headers"] = access_control_request_headers + + elif access_control_request_headers: + + for header in access_control_request_headers.split( + ',' + ): + if header.lower().strip() not in self.cors_headers: + failures.append("headers") + break + + if len(failures) > 0: + + failures_message = ', '.join(failures) + + return ( + Response( + request.path, + request.method, + headers=response_headers, + data=f"Disallowed CORS {failures_message}" + ), + 401 + ), False + + if response and status: + response.headers.update(response_headers) + + return ( + response, + status + ), False + + return ( + Response( + request.path, + request.method, + headers=response_headers, + data=None + ), + 204 + ), False + + response_headers = dict(self.simple_headers) + + has_cookie = headers.get('cookie') + if self.allow_all_origins and has_cookie: + response_headers['access-control-allow-origin'] = origin + + elif origin in self.origins: + response_headers['access-control-allow-origin'] = origin + + if response and status: + response.headers.update(response_headers) + + return ( + response, + status + ), True + + return ( + Response( + request.path, + request.method, + headers=response_headers, + data=None + ), + 200 + ), True \ No newline at end of file diff --git a/hyperscale/distributed/middleware/cors/cors_headers.py b/hyperscale/distributed/middleware/cors/cors_headers.py new file mode 100644 index 0000000..d15de52 --- /dev/null +++ b/hyperscale/distributed/middleware/cors/cors_headers.py @@ -0,0 +1,120 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + StrictFloat, + StrictBool, + conlist +) +from typing import ( + Union, + List, + Literal, + Optional, + Dict +) + + +class CorsHeaders(BaseModel): + access_control_allow_origin: conlist( + StrictStr, + min_items=1 + ) + access_control_expose_headers: Optional[List[StrictStr]] + access_control_max_age: Optional[Union[StrictInt, StrictFloat]] + access_control_allow_credentials: Optional[StrictBool] + access_control_allow_methods: conlist( + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ], + min_items=1 + ) + access_control_allow_headers: Optional[List[StrictStr]] + + def to_headers(self): + + cors_headers: Dict[str, str] = {} + + headers = self.dict(exclude_none=True) + + for key, value in headers.items(): + + header_key = '-'.join([ + segment.capitalize() for segment in key.split('_') + ]) + + if key == 'access_control_allow_origin': + header_value = ' | '.join(value) + + elif key == 'access_control_max_age': + header_value = "true" if value else "false" + + else: + header_value = ', '.join(value) + + cors_headers[header_key] = header_value + + return cors_headers + + def to_simple_headers(self): + + + allow_all_origins = False + allow_all_origins = "*" in self.access_control_allow_origin + simple_headers: Dict[str, str] = {} + + if allow_all_origins: + simple_headers["Access-Control-Allow-Origin"] = "*" + + if self.access_control_allow_credentials: + simple_headers["Access-Control-Allow-Credentials"] = "true" + + if self.access_control_expose_headers: + simple_headers["Access-Control-Expose-Headers"] = ", ".join(self.access_control_expose_headers) + + return simple_headers + + def to_preflight_headers(self): + + allow_all_origins = "*" in self.access_control_allow_origin + + access_control_allow_headers = self.access_control_allow_headers or [] + allow_all_headers = "*" in access_control_allow_headers + + safe_headers = {"Accept", "Accept-Language", "Content-Language", "Content-Type"} + + + preflight_explicit_allow_origin = not allow_all_origins or self.access_control_allow_credentials + + + preflight_headers: Dict[str, str] = {} + if preflight_explicit_allow_origin: + # The origin value will be set in preflight_response() if it is allowed. + preflight_headers["Vary"] = "Origin" + + else: + preflight_headers["Access-Control-Allow-Origin"] = "*" + + preflight_headers.update( + { + "Access-Control-Allow-Methods": ", ".join(self.access_control_allow_methods), + "Access-Control-Max-Age": str(self.access_control_max_age), + } + ) + + allow_headers = sorted(safe_headers | set(access_control_allow_headers)) + + if allow_headers and not allow_all_headers: + preflight_headers["Access-Control-Allow-Headers"] = ", ".join(allow_headers) + + if self.access_control_allow_credentials: + preflight_headers["Access-Control-Allow-Credentials"] = "true" + + return preflight_headers \ No newline at end of file diff --git a/hyperscale/distributed/middleware/crsf/__init__.py b/hyperscale/distributed/middleware/crsf/__init__.py new file mode 100644 index 0000000..bcf5e7d --- /dev/null +++ b/hyperscale/distributed/middleware/crsf/__init__.py @@ -0,0 +1 @@ +from .crsf import CRSF \ No newline at end of file diff --git a/hyperscale/distributed/middleware/crsf/crsf.py b/hyperscale/distributed/middleware/crsf/crsf.py new file mode 100644 index 0000000..e514d56 --- /dev/null +++ b/hyperscale/distributed/middleware/crsf/crsf.py @@ -0,0 +1,214 @@ +from base64 import b64decode, b64encode +from http.cookies import BaseCookie, SimpleCookie +from secrets import compare_digest, token_urlsafe +from typing import Dict, List, Literal, Optional, Set, Tuple + +import zstandard + +from hyperscale.distributed.encryption import AESGCMFernet +from hyperscale.distributed.env import Env, load_env +from hyperscale.distributed.middleware.base import Middleware +from hyperscale.distributed.models.http import Request, Response + + +class CRSF(Middleware): + + def __init__( + self, + secret_bytes_size: Optional[int]=16, + required_paths: Optional[List[str]] = None, + exempt_paths: Optional[List[str]] = None, + sensitive_cookies: Optional[Set[str]] = None, + safe_methods: List[ + Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ] + ] = [ + "GET", + "HEAD", + "OPTIONS", + "TRACE" + ], + cookie_name: str = "csrftoken", + cookie_path: str = "/", + cookie_domain: Optional[str] = None, + cookie_secure: bool = False, + cookie_httponly: bool = False, + cookie_samesite: str = "lax", + header_name: str = "x-csrftoken", + ) -> None: + + env = load_env(Env) + + self.encryptor = AESGCMFernet(env) + self.secret_bytes_size = secret_bytes_size + + self.required_paths = required_paths + self.exempt_paths = exempt_paths + self.sensitive_cookies = sensitive_cookies + self.safe_methods = safe_methods + self.cookie_name = cookie_name + self.cookie_path = cookie_path + self.cookie_domain = cookie_domain + self.cookie_secure = cookie_secure + self.cookie_httponly = cookie_httponly + self.cookie_samesite = cookie_samesite + self.header_name = header_name + + self._compressor = zstandard.ZstdCompressor() + self._decompressor = zstandard.ZstdDecompressor() + + super().__init__( + self.__class__.__name__, + response_headers={} + ) + + async def __run__( + self, + request: Request, + response: Response, + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + + crsf_cookie = request.cookies.get(self.cookie_name) + + request_path = request.path + + is_unsafe_method = request.method not in self.safe_methods + + path_is_required = False + if self.required_paths: + path_is_required = self._path_is_required(request_path) + + path_is_exempt = False + if self.exempt_paths: + path_is_exempt = self._path_is_exempt(request_path) + + has_sensitive_cookies = False + if self.sensitive_cookies: + has_sensitive_cookies = self._has_sensitive_cookies(request.cookies) + + is_sensitive = is_unsafe_method and not path_is_exempt and has_sensitive_cookies + + if path_is_required or is_sensitive: + submitted_csrf_token = request.headers.get(self.header_name) + + csrf_tokens_match = False + + try: + + decoded_crsf_cookie: str = self.encryptor.decrypt( + self._decompressor.decompress( + b64decode(crsf_cookie.encode()) + ) + ) + decoded_crsf_token: str = self.encryptor.decrypt( + self._decompressor.decompress( + b64decode(submitted_csrf_token.encode()) + ) + ) + + csrf_tokens_match = compare_digest( + decoded_crsf_cookie, + decoded_crsf_token + ) + + except Exception: + csrf_tokens_match = False + + + crsf_match_failed = crsf_cookie is None or submitted_csrf_token is None or csrf_tokens_match is False + + if crsf_match_failed: + return ( + Response( + request.path, + request.method, + data="CSRF token verification failed" + ), + 403 + ), False + + crsf_cookie = request.cookies.get(self.cookie_name) + + response_headers = {} + + if crsf_cookie is None: + + cookie: BaseCookie = SimpleCookie() + cookie_name = self.cookie_name + + crsf_token = self.encryptor.encrypt( + token_urlsafe( + nbytes=self.secret_bytes_size + ).encode() + ) + + cookie[cookie_name] = b64encode( + self._compressor.compress( + crsf_token + ) + ).decode() + + cookie[cookie_name]["path"] = self.cookie_path + cookie[cookie_name]["secure"] = self.cookie_secure + cookie[cookie_name]["httponly"] = self.cookie_httponly + cookie[cookie_name]["samesite"] = self.cookie_samesite + + if self.cookie_domain is not None: + cookie[cookie_name]["domain"] = self.cookie_domain # pragma: no cover + + response_headers["set-cookie"] = cookie.output(header="").strip() + + if response and status: + response.headers.update(response_headers) + + return ( + response, + status + ), True + + return ( + Response( + request.path, + request.method, + headers=response_headers, + data=None + ), + 200 + ), True + + + def _has_sensitive_cookies(self, cookies: Dict[str, str]) -> bool: + + for sensitive_cookie in self.sensitive_cookies: + if cookies.get(sensitive_cookie) is not None: + return True + + return False + + def _path_is_required(self, path: str) -> bool: + + for required_url in self.required_paths: + if required_url in path: + return True + + return False + + def _path_is_exempt(self, path: str) -> bool: + + for exempt_path in self.exempt_paths: + if exempt_path in path: + return True + + return False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/decompressor/__init__.py b/hyperscale/distributed/middleware/decompressor/__init__.py new file mode 100644 index 0000000..bded39f --- /dev/null +++ b/hyperscale/distributed/middleware/decompressor/__init__.py @@ -0,0 +1,4 @@ +from .bidirectional_gzip_decompressor import BidirectionalGZipDecompressor +from .bidirectional_zstandard_decompressor import BidirectionalZStandardDecompressor +from .gzip_decompressor import GZipDecompressor +from .zstandard_decompressor import ZStandardDecompressor \ No newline at end of file diff --git a/hyperscale/distributed/middleware/decompressor/bidirectional_gzip_decompressor.py b/hyperscale/distributed/middleware/decompressor/bidirectional_gzip_decompressor.py new file mode 100644 index 0000000..e05364f --- /dev/null +++ b/hyperscale/distributed/middleware/decompressor/bidirectional_gzip_decompressor.py @@ -0,0 +1,195 @@ +from gzip import decompress +from typing import Callable, Dict, Tuple, Union + +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class BidirectionalGZipDecompressor(Middleware): + + def __init__( + self, + compression_level: int=9, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.BIDIRECTIONAL + ) + + self.compression_level = compression_level + self.serializers = serializers + + async def __pre__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ): + try: + + headers = request.headers + content_encoding = headers.get( + 'content-encoding', + headers.get('x-compression-encoding') + ) + + if request.raw != b'' and content_encoding == 'gzip': + request.content = decompress( + request.content + ) + + request_headers = { + key: value for key, value in headers.items() if key != 'content-encoding' and key != 'x-compression-encoding' + } + + return ( + request, + Response( + request.path, + request.method, + headers=request_headers + ), + 200 + ), True + + except Exception as e: + return ( + None, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False + + async def __post__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + request, + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + decompressed_data = decompress( + response.encode() + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'content-type': 'text/plain' + }, + data=decompressed_data.decode() + ), + status + ), True + + else: + + headers = response.headers + content_encoding = headers.get( + 'content-encoding', + headers.get('x-compression-encoding') + ) + + if content_encoding == 'gzip': + + serialized = self.serializers[request.path](response) + decompressed_data = decompress( + serialized + ) + + headers.pop( + 'content-encoding', + headers.pop( + 'x-compression-encoding', + None + ) + ) + + return ( + request, + Response( + request.path, + request.method, + headers=headers, + data=decompressed_data.decode() + ), + status + ), True + + return ( + response, + status + ), True + + except KeyError: + return ( + request, + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + return ( + request, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/decompressor/bidirectional_zstandard_decompressor.py b/hyperscale/distributed/middleware/decompressor/bidirectional_zstandard_decompressor.py new file mode 100644 index 0000000..45b5d6d --- /dev/null +++ b/hyperscale/distributed/middleware/decompressor/bidirectional_zstandard_decompressor.py @@ -0,0 +1,198 @@ +from typing import Callable, Dict, Tuple, Union + +import zstandard +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class BidirectionalZStandardDecompressor(Middleware): + + def __init__( + self, + compression_level: int=9, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.BIDIRECTIONAL + ) + + self.compression_level = compression_level + self.serializers = serializers + self._decompressor = zstandard.ZstdDecompressor() + + async def __pre__( + self, + request: Request, + response: Union[ + BaseModel, + str, + None + ], + status: int + ): + try: + + headers = request.headers + content_encoding = headers.get( + 'content-encoding', + headers.get('x-compression-encoding') + ) + + if request.raw != b'' and content_encoding == 'gzip': + + request.content = self._decompressor.decompress( + request.content + ) + + request_headers = { + key: value for key, value in headers.items() if key != 'content-encoding' and key != 'x-compression-encoding' + } + + + return ( + request, + Response( + request.path, + request.method, + headers=request_headers + ), + 200 + ), True + + except Exception as e: + return ( + None, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False + + async def __post__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + request, + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + decompressed_data = self._decompressor.decompress( + response.encode() + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }, + data=decompressed_data.decode() + ), + status + ), True + + else: + + headers = response.headers + content_encoding = headers.get( + 'content-encoding', + headers.get('x-compression-encoding') + ) + + if content_encoding == 'gzip': + + headers.pop( + 'content-encoding', + headers.pop( + 'x-compression-encoding', + None + ) + ) + + serialized = self.serializers[request.path](response) + decompressed_data = self._decompressor.decompress( + serialized + ) + + return ( + request, + Response( + request.path, + request.method, + headers=headers, + data=decompressed_data.decode() + ), + status + ), True + + return ( + response, + status + ), True + + except KeyError: + return ( + request, + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + return ( + request, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/decompressor/gzip_decompressor.py b/hyperscale/distributed/middleware/decompressor/gzip_decompressor.py new file mode 100644 index 0000000..651e2c4 --- /dev/null +++ b/hyperscale/distributed/middleware/decompressor/gzip_decompressor.py @@ -0,0 +1,146 @@ +from gzip import decompress +from typing import Callable, Dict, Tuple, Union + +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class GZipDecompressor(Middleware): + + def __init__( + self, + compression_level: int=9, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.UNIDIRECTIONAL_AFTER + ) + + self.compression_level = compression_level + self.serializers = serializers + + async def __run__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + request, + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + decompressed_data = decompress( + response.encode() + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'content-type': 'text/plain' + }, + data=decompressed_data.decode() + ), + status + ), True + + else: + + headers = response.headers + content_encoding = headers.get( + 'content-encoding', + headers.get('x-compression-encoding') + ) + + if content_encoding == 'gzip': + + serialized = self.serializers[request.path](response) + decompressed_data = decompress( + serialized + ) + + headers.pop( + 'content-encoding', + headers.pop( + 'x-compression-encoding', + None + ) + ) + + return ( + request, + Response( + request.path, + request.method, + headers=headers, + data=decompressed_data.decode() + ), + status + ), True + + return ( + response, + status + ), True + + except KeyError: + return ( + request, + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + return ( + request, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/middleware/decompressor/zstandard_decompressor.py b/hyperscale/distributed/middleware/decompressor/zstandard_decompressor.py new file mode 100644 index 0000000..5e8f4a0 --- /dev/null +++ b/hyperscale/distributed/middleware/decompressor/zstandard_decompressor.py @@ -0,0 +1,146 @@ +from typing import Callable, Dict, Tuple, Union + +import zstandard +from pydantic import BaseModel + +from hyperscale.distributed.middleware.base import Middleware, MiddlewareType +from hyperscale.distributed.models.http import Request, Response + + +class ZStandardDecompressor(Middleware): + + def __init__( + self, + serializers: Dict[ + str, + Callable[ + [ + Union[ + Response, + BaseModel, + str, + None + ] + ], + Union[ + str, + None + ] + ] + ]={} + ) -> None: + super().__init__( + self.__class__.__name__, + middleware_type=MiddlewareType.UNIDIRECTIONAL_AFTER + ) + + self.serializers = serializers + self._decompressor = zstandard.ZstdDecompressor() + + async def __run__( + self, + request: Request, + response: Union[ + Response, + BaseModel, + str, + None + ], + status: int + ) -> Tuple[ + Tuple[Response, int], + bool + ]: + try: + + if response is None: + return ( + request, + Response( + request.path, + request.method, + data=response + ), + status + ), True + + elif isinstance(response, str): + + decompressed_data = self._decompressor.decompress( + response.encode() + ) + + return ( + request, + Response( + request.path, + request.method, + headers={ + 'x-compression-encoding': 'gzip', + 'content-type': 'text/plain' + }, + data=decompressed_data.decode() + ), + status + ), True + + else: + + headers = response.headers + content_encoding = headers.get( + 'content-encoding', + headers.get('x-compression-encoding') + ) + + if content_encoding == 'gzip': + + headers.pop( + 'content-encoding', + headers.pop( + 'x-compression-encoding', + None + ) + ) + + serialized = self.serializers[request.path](response) + decompressed_data = self._decompressor.decompress( + serialized + ) + + return ( + request, + Response( + request.path, + request.method, + headers=headers, + data=decompressed_data.decode() + ), + status + ), True + + return ( + response, + status + ), True + + except KeyError: + return ( + request, + Response( + request.path, + request.method, + data=f'No serializer for {request.path} found.' + ), + 500 + ), False + + except Exception as e: + return ( + request, + Response( + request.path, + request.method, + data=str(e) + ), + 500 + ), False \ No newline at end of file diff --git a/hyperscale/distributed/models/__init__.py b/hyperscale/distributed/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/models/base/__init__.py b/hyperscale/distributed/models/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/models/base/error.py b/hyperscale/distributed/models/base/error.py new file mode 100644 index 0000000..34b2565 --- /dev/null +++ b/hyperscale/distributed/models/base/error.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, StrictStr, StrictInt + + +class Error(BaseModel): + host: StrictStr + port: StrictInt + error: StrictStr diff --git a/hyperscale/distributed/models/base/message.py b/hyperscale/distributed/models/base/message.py new file mode 100644 index 0000000..48edc91 --- /dev/null +++ b/hyperscale/distributed/models/base/message.py @@ -0,0 +1,13 @@ +from __future__ import annotations +from pydantic import BaseModel, StrictStr, StrictInt +from typing import Optional + +class Message(BaseModel): + host: Optional[StrictStr] + port: Optional[StrictInt] + error: Optional[StrictStr] + + def to_data(self): + return { + name: value for name, value in self.__dict__.items() if value is not None + } diff --git a/hyperscale/distributed/models/dns/__init__.py b/hyperscale/distributed/models/dns/__init__.py new file mode 100644 index 0000000..cfd2632 --- /dev/null +++ b/hyperscale/distributed/models/dns/__init__.py @@ -0,0 +1,7 @@ +from .dns_entry import DNSEntry +from .dns_message import ( + DNSMessage, + QueryType +) +from .dns_message_group import DNSMessageGroup +from .service import Service \ No newline at end of file diff --git a/hyperscale/distributed/models/dns/dns_entry.py b/hyperscale/distributed/models/dns/dns_entry.py new file mode 100644 index 0000000..e7da4d2 --- /dev/null +++ b/hyperscale/distributed/models/dns/dns_entry.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import re +from typing import Dict, List, Literal, Optional, Tuple, Union + +from pydantic import BaseModel, IPvAnyAddress, StrictFloat, StrictInt, StrictStr + +from hyperscale.distributed.discovery.dns.core.exceptions import InvalidServiceURLError +from hyperscale.distributed.discovery.dns.core.record.record_data_types import ( + AAAARecordData, + ARecordData, + CNAMERecordData, + PTRRecordData, + RecordType, + SRVRecordData, + TXTRecordData, +) + +DomainProtocol = Literal["tcp", "udp"] +RecordTypeName = Literal["A", "AAAA", "CNAME", "PTR", "SRV", "TXT"] + + +service_pattern = re.compile(r'([a-zA-Z0-9\-]{1,256})?(\.?\_)([a-zA-Z0-9\-]{1,256})(\._)([udp|tcp]*)(\.)([a-zA-Z0-9\-]{1,256})(\.)([a-zA-Z0-9]{2,5})') +ptr_service_pattern = re.compile(r'([a-zA-Z0-9\-]{1,256})(\._)([udp|tcp]*)(\.)([a-zA-Z0-9\-]{1,256})(\.)([a-zA-Z0-9]{2,5})') + + +class DNSEntry(BaseModel): + instance_name: Optional[StrictStr] + service_name: StrictStr + domain_protocol: DomainProtocol + domain_name: StrictStr + domain_priority: StrictInt=10 + domain_weight: StrictInt=0 + domain_port: Optional[StrictInt] + domain_values: Dict[StrictStr, StrictStr]={} + domain_targets: Optional[ + Tuple[ + Union[IPvAnyAddress, StrictStr] + ] + ] + record_type: Optional[RecordType] + record_types: List[RecordTypeName]=["PTR", "SRV", "TXT"] + time_to_live: Union[StrictInt, StrictFloat]=-1 + + + @classmethod + def to_segments(cls, url: str): + + if service_pattern.match(url) is None: + raise InvalidServiceURLError(url) + + segments = [ + segment for segment in service_pattern.split(url) if segment.isalnum() + ] + + instance_name, service_name, domain_protocol = segments[:3] + domain_name = '.'.join(segments[3:]) + + return ( + instance_name, + service_name, + domain_protocol, + domain_name + ) + + @classmethod + def to_ptr_segments(cls, url: str): + + if ptr_service_pattern.match(url) is None: + raise InvalidServiceURLError(url) + + segments = [ + segment for segment in ptr_service_pattern.split(url) if segment.isalnum() + ] + + service_name, domain_protocol = segments[:2] + domain_name = '.'.join(segments[2:]) + + return ( + service_name, + domain_protocol, + domain_name + ) + + def to_domain( + self, + record_type: RecordTypeName + ): + + if record_type == "PTR": + domain = f'{self.service_name}._{self.domain_protocol}.in-addr.arpa' + + else: + domain = f'{self.instance_name}._{self.service_name}._{self.domain_protocol}.{self.domain_name}' + + return domain + + + def to_data( + self, + record_type: RecordTypeName + ): + + domain_target: Union[str, None] = None + + if self.domain_targets: + domain_target = str(self.domain_targets[0]) + + if record_type == "A": + return ARecordData(domain_target) + + elif record_type == "AAAA": + return AAAARecordData(domain_target) + + elif record_type == "CNAME": + return CNAMERecordData(domain_target) + + elif record_type == "SRV": + return SRVRecordData( + self.domain_priority, + self.domain_weight, + self.domain_port, + domain_target + ) + + elif record_type == "PTR" and self.instance_name: + domain_target = f'{self.instance_name}._{self.service_name}._{self.domain_protocol}.{self.domain_name}' + return PTRRecordData(domain_target) + + elif record_type == "PTR": + domain_target = f'{self.instance_name}._{self.service_name}._{self.domain_protocol}.{self.domain_name}' + return PTRRecordData(domain_target) + + else: + domain_target_value = f'service={domain_target}' + txt_values = [ + f'{key}={value}' for key, value in self.domain_values.items() + ] + + txt_values.append(domain_target_value) + + txt_record_data = '\n'.join(txt_values) + + return TXTRecordData(txt_record_data) + + def to_record_data(self) -> List[ + Tuple[ + str, + Union[ + ARecordData, + AAAARecordData, + CNAMERecordData, + PTRRecordData, + SRVRecordData, + TXTRecordData + ] + ] + ]: + return [ + ( + self.to_domain(record_type), + self.to_data(record_type) + ) for record_type in self.record_types + ] + + @classmethod + def from_record_data( + self, + record_name: str, + record_data: Union[ + ARecordData, + AAAARecordData, + CNAMERecordData, + SRVRecordData, + TXTRecordData + ] + ): + + if record_data.rtype == RecordType.PTR: + ( + instance_name, + service_name, + domain_protocol, + domain_name + ) = DNSEntry.to_segments(record_data.data) + + else: + + ( + instance_name, + service_name, + domain_protocol, + domain_name + ) = DNSEntry.to_segments(record_name) + + if isinstance( + record_data, + ( + ARecordData, + AAAARecordData, + CNAMERecordData + ) + ): + return DNSEntry( + instance_name=instance_name, + service_name=service_name, + domain_protocol=domain_protocol, + domain_name=record_name, + domain_targets=( + record_data.data, + ), + record_type=record_data.rtype + ) + + elif isinstance(record_data, PTRRecordData): + + return DNSEntry( + instance_name=instance_name, + service_name=service_name, + domain_protocol=domain_protocol, + domain_name=domain_name, + domain_targets=( + record_data.data, + ), + record_type=record_data.rtype + ) + + elif isinstance(record_data, SRVRecordData): + + return DNSEntry( + instance_name=instance_name, + service_name=service_name, + domain_protocol=domain_protocol, + domain_name=domain_name, + domain_port=record_data.port, + domain_priority=record_data.priority, + domain_weight=record_data.weight, + domain_targets=( + record_data.hostname, + ), + record_type=record_data.rtype + ) + + else: + + txt_data = record_data.data.split("\n") + + record_values: Dict[str, str] = {} + + for txt_item in txt_data: + key, value = txt_item.split("=") + record_values[key] = value + + domain_target = record_values.get("service") + + return DNSEntry( + instance_name=instance_name, + service_name=service_name, + domain_protocol=domain_protocol, + domain_name=record_name, + domain_targets=( + domain_target, + ), + domain_values=record_values, + record_type=record_data.rtype + ) + + diff --git a/hyperscale/distributed/models/dns/dns_message.py b/hyperscale/distributed/models/dns/dns_message.py new file mode 100644 index 0000000..d8230f5 --- /dev/null +++ b/hyperscale/distributed/models/dns/dns_message.py @@ -0,0 +1,274 @@ + +import base64 +import io +import struct +from typing import Dict, Iterable, List, Optional, Tuple, Union + +from pydantic import StrictBool, StrictInt + +from hyperscale.distributed.discovery.dns.core.exceptions import DNSError +from hyperscale.distributed.discovery.dns.core.record import ( + QueryType, + Record, + RecordType, +) +from hyperscale.distributed.models.base.message import Message +from hyperscale.distributed.models.http import HTTPRequest, HTTPRequestMethod + + +class DNSMessage(Message): + query_type: QueryType=QueryType.REQUEST + query_id: StrictInt=0 + query_opcode: StrictInt=0 + query_authoritative_answer: StrictInt=0 + query_truncation: StrictInt=0 + query_desired_recursion: StrictInt=0 + query_available_recursion: StrictInt=0 + query_result_code: StrictInt=0 + record_types: List[RecordType]=[] + query_domains: List[Record]=[] + query_answers: List[Record]=[] + query_namservers: List[Record]=[] + query_additional_records: List[Record]=[] + query_has_result: StrictBool=False + + class Config: + arbitrary_types_allowed=True + + + def __iter__(self): + return iter(self.query_answers) + + def is_request(self): + return self.query_type + + @classmethod + def get_bits( + cls, + num: int, + bit_len: int + ): + + high = num >> bit_len + low = num - (high << bit_len) + + return low, high + + @staticmethod + def parse_entry( + query_type: QueryType, + data: bytes, + cursor_posiition: int, + length: int + ) -> Tuple[int, List[Record]]: + + results: List[Record] = [] + + for _ in range(length): + record = Record(query_type.value) + cursor_posiition = record.parse(data, cursor_posiition) + + results.append(record) + + return cursor_posiition, results + + @classmethod + def parse( + cls, + data: bytes, + query_id: Optional[bytes] = None + ): + + ( + request_id, + raw_data, + domains, + answers, + nameservers, + additional_records + ) = struct.unpack('!HHHHHH', data[:12]) + + if query_id is not None and query_id != request_id: + raise DNSError(-1, 'Transaction ID mismatch') + + result_code, raw_data = cls.get_bits(raw_data, 4) # rcode: 0 for no error + + _, raw_data = cls.get_bits(raw_data, 3) # reserved + + available_recursion, raw_data = cls.get_bits(raw_data, 1) # recursion available + + desired_recursion, raw_data = cls.get_bits(raw_data, 1) # recursion desired + + truncation, raw_data = cls.get_bits(raw_data, 1) # truncation + + authoritative_answer, raw_data = cls.get_bits(raw_data, 1) # authoritative answer + + opcode, raw_data = cls.get_bits(raw_data, 4) # opcode + + query_type, raw_data = cls.get_bits(raw_data, 1) # qr: 0 for query and 1 for response + + cursor_position, query_domains = cls.parse_entry( + QueryType.REQUEST.value, + data, + 12, + domains + ) + + cursor_position, query_answers = cls.parse_entry( + QueryType.RESPONSE.value, + data, + cursor_position, + answers + ) + + cursor_position, query_nameservers = cls.parse_entry( + QueryType.RESPONSE.value, + data, + cursor_position, + nameservers + ) + + _, query_additional_records = cls.parse_entry( + QueryType.RESPONSE.value, + data, + cursor_position, + additional_records + ) + + return DNSMessage( + query_type=QueryType.by_value(query_type), + query_opcode=opcode, + query_authoritative_answer=authoritative_answer, + query_truncation=truncation, + query_desired_recursion=desired_recursion, + query_available_recursion=available_recursion, + query_result_code=result_code, + query_domains=query_domains, + query_answers=query_answers, + query_namservers=query_nameservers, + query_additional_records=query_additional_records + ) + + def get_record( + self, + record_types: Union[RecordType, Iterable[RecordType]] + ): + '''Get the first record of qtype defined in `qtypes` in answer list. + ''' + if isinstance(record_types, RecordType): + record_types = record_types + + for item in self.query_answers: + if item.record_types in record_types: + return item.data + + def pack( + self, + size_limit: int = None + ) -> bytes: + + + names: Dict[str, int] = {} + buffer = io.BytesIO() + buffer.seek(12) + truncation = 0 + + query_groups = [ + self.query_domains, + self.query_answers, + self.query_namservers, + self.query_additional_records + ] + + for group in query_groups: + if truncation: + break + + for record in group: + offset = buffer.tell() + packed_record = record.pack(names, offset) + + if size_limit is not None and offset + len(packed_record) > size_limit: + truncation = 1 + break + + buffer.write(packed_record) + + self.query_truncation = truncation + buffer.seek(0) + + query_type = self.query_type.value << 15 + query_opcode = self.query_opcode << 11 + query_authoritative_answer = self.query_authoritative_answer << 10 + query_truncation = truncation << 9 + query_desired_recursion = self.query_desired_recursion << 8 + query_available_recursion = self.query_available_recursion << 7 + query_buffer_extra = 0 << 4 + query_result_code = self.query_result_code + + query_data = sum([ + query_type, + query_opcode, + query_authoritative_answer, + query_truncation, + query_desired_recursion, + query_available_recursion, + query_buffer_extra, + query_result_code + ]) + + + + buffer.write( + struct.pack( + '!HHHHHH', + self.query_id, + query_data, + len(self.query_domains), + len(self.query_answers), + len(self.query_namservers), + len(self.query_additional_records) + ) + ) + + return buffer.getvalue() + + def to_http_bytes( + self, + url: str, + method: HTTPRequestMethod=HTTPRequestMethod.GET + ) -> bytes: + + message = self.pack() + params: Dict[str, str] = {} + data: Union[str, None] = None + + if method == HTTPRequestMethod.GET: + params['dns'] = base64.urlsafe_b64encode(message).decode().rstrip('=') + + else: + data = message.decode() + + http_request = HTTPRequest( + host=self.host, + port=self.port, + error=self.error, + url=url, + method=method, + headers={ + 'accept': 'application/dns-message', + 'content-type': 'application/dns-message', + }, + data=data + ) + + return http_request.prepare_request() + + def to_tcp_bytes(self) -> Tuple[bytes, bytes]: + message = self.pack() + message_size = len(message) + + return struct.pack('!H', message_size), + message + + def to_udp_bytes(self) -> bytes: + return self.pack() diff --git a/hyperscale/distributed/models/dns/dns_message_group.py b/hyperscale/distributed/models/dns/dns_message_group.py new file mode 100644 index 0000000..0240ee0 --- /dev/null +++ b/hyperscale/distributed/models/dns/dns_message_group.py @@ -0,0 +1,9 @@ +from typing import List + +from hyperscale.distributed.models.base.message import Message + +from .dns_message import DNSMessage + + +class DNSMessageGroup(Message): + messages: List[DNSMessage] \ No newline at end of file diff --git a/hyperscale/distributed/models/dns/service.py b/hyperscale/distributed/models/dns/service.py new file mode 100644 index 0000000..4305430 --- /dev/null +++ b/hyperscale/distributed/models/dns/service.py @@ -0,0 +1,28 @@ +from pydantic import ( + BaseModel, + StrictStr, + StrictInt, + IPvAnyAddress +) + +from typing import ( + Dict, + Tuple, + Literal +) + + +class Service(BaseModel): + service_instance: StrictStr + service_name: StrictStr + service_protocol: Literal["udp", "tcp"] + service_url: StrictStr + service_ip: IPvAnyAddress + service_port: StrictInt + service_context: Dict[StrictStr, StrictStr]={} + + def to_address(self) -> Tuple[str, int]: + return ( + str(self.service_ip), + self.service_port + ) \ No newline at end of file diff --git a/hyperscale/distributed/models/http/__init__.py b/hyperscale/distributed/models/http/__init__.py new file mode 100644 index 0000000..9bba168 --- /dev/null +++ b/hyperscale/distributed/models/http/__init__.py @@ -0,0 +1,8 @@ +from .http_message import HTTPMessage +from .http_request import ( + HTTPRequest, + HTTPRequestMethod +) +from .limit import Limit +from .request import Request +from .response import Response \ No newline at end of file diff --git a/hyperscale/distributed/models/http/http_message.py b/hyperscale/distributed/models/http/http_message.py new file mode 100644 index 0000000..d93a892 --- /dev/null +++ b/hyperscale/distributed/models/http/http_message.py @@ -0,0 +1,59 @@ +import json +from typing import Dict, Literal, Optional, Union + +from pydantic import Json, StrictInt, StrictStr + +from hyperscale.distributed.models.base.message import Message + + +class HTTPMessage(Message): + protocol: StrictStr="HTTP/1.1" + path: Optional[StrictStr] + method: Optional[ + Literal[ + "GET", + "POST", + "HEAD", + "OPTIONS", + "PUT", + "PATCH", + "DELETE" + ] + ] + status: Optional[StrictInt] + status_message: Optional[StrictStr] + params: Dict[StrictStr, StrictStr]={} + headers: Dict[StrictStr, StrictStr]={} + data: Optional[Union[Json, StrictStr]] + + def prepare_response(self): + + message = 'OK' + if self.error: + message = self.error + + head_line = f'HTTP/1.1 {self.status} {message}' + + encoded_data: str = '' + + if isinstance(self.data, Message): + encoded_data = json.dumps(self.data.to_data()) + + content_length = len(encoded_data) + headers = f'content-length: {content_length}' + + elif self.data: + encoded_data = self.data + + content_length = len(encoded_data) + headers = f'content-length: {content_length}' + + else: + headers = 'content-length: 0' + + response_headers = self.headers + if response_headers: + for key in response_headers: + headers = f'{headers}\r\n{key}: {response_headers[key]}' + + return f'{head_line}\r\n{headers}\r\n\r\n{encoded_data}'.encode() \ No newline at end of file diff --git a/hyperscale/distributed/models/http/http_request.py b/hyperscale/distributed/models/http/http_request.py new file mode 100644 index 0000000..9bc28f9 --- /dev/null +++ b/hyperscale/distributed/models/http/http_request.py @@ -0,0 +1,158 @@ +import json +from enum import Enum +from typing import Dict, List, Optional, Union +from urllib.parse import urlparse + +from pydantic import AnyHttpUrl + +from hyperscale.distributed.models.base.message import Message + +from .http_message import HTTPMessage + + +class HTTPRequestMethod(Enum): + GET='GET' + POST='POST' + + +class HTTPRequest(Message): + url: AnyHttpUrl + method: HTTPRequestMethod + params: Optional[Dict[str, str]] + headers: Dict[str, str]={} + data: Optional[Union[str, Message]] + + class Config: + arbitrary_types_allowed=True + + def prepare_request(self): + parsed = urlparse(self.url) + + path = parsed.path + if path is None: + path = "/" + + + if self.params: + + params_string = '&'.join([ + f'{name}={value}' for name, value in self.params + ]) + + path = f'{path}?{params_string}' + + request: List[str] = [ + f'{self.method.value} {path} HTTP/1.1' + ] + + request.append( + f'host: {parsed.hostname}' + ) + + request.extend([ + f'{key}: {value}' for key, value in self.headers.items() + ]) + + encoded_data = None + if isinstance(self.data, Message): + encoded_data = json.dumps(self.data.to_data()) + + request.append( + 'content-type: application/msync' + ) + + elif self.data: + encoded_data = self.data + content_length = len(encoded_data) + + request.append( + f'content-length: {content_length}' + ) + + request.append('\r\n') + + if encoded_data: + request.append(encoded_data) + + encoded_request = '\r\n'.join(request) + + + return encoded_request.encode() + + @classmethod + def parse(cls, data: bytes): + response = data.split(b'\r\n') + + response_line = response[0] + + headers: Dict[bytes, bytes] = {} + + header_lines = response[1:] + data_line_idx = 0 + + for header_line in header_lines: + + if header_line == b'': + data_line_idx += 1 + break + + key, value = header_line.decode().split( + ':', + maxsplit=1 + ) + headers[key.lower()] = value.strip() + + data_line_idx += 1 + + data = b''.join(response[data_line_idx + 1:]).strip() + + + request_type, status, message = response_line.decode().split(' ') + + return HTTPMessage( + protocol=request_type, + status=int(status), + status_message=message, + headers=headers, + data=data.decode() + ) + + @classmethod + def parse_request(cls, data: bytes): + response = data.split(b'\r\n') + + response_line = response[0] + + headers: Dict[bytes, bytes] = {} + + header_lines = response[1:] + data_line_idx = 0 + + for header_line in header_lines: + + if header_line == b'': + data_line_idx += 1 + break + + key, value = header_line.decode().split( + ':', + maxsplit=1 + ) + headers[key.lower()] = value.strip() + + data_line_idx += 1 + + data = b''.join(response[data_line_idx + 1:]).strip() + + method, path, request_type = response_line.decode().split(' ') + + if path is None or path == '': + path = "/" + + return HTTPMessage( + method=method, + path=path, + protocol=request_type, + headers=headers, + data=data.decode() + ) diff --git a/hyperscale/distributed/models/http/limit.py b/hyperscale/distributed/models/http/limit.py new file mode 100644 index 0000000..bcee9cb --- /dev/null +++ b/hyperscale/distributed/models/http/limit.py @@ -0,0 +1,113 @@ +from typing import Callable, List, Literal, Optional, Union + +from pydantic import ( + BaseModel, + IPvAnyAddress, + StrictBool, + StrictFloat, + StrictInt, + StrictStr, +) + +from hyperscale.distributed.env.memory_parser import MemoryParser +from hyperscale.distributed.env.time_parser import TimeParser + +from .request import Request + +HTTPMethod = Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" +] + + +class Limit(BaseModel): + max_requests: StrictInt + min_requests: Optional[StrictInt] + request_period: StrictStr='1s' + reject_requests: StrictBool=True + request_backoff: StrictStr='1s' + cpu_limit: Optional[Union[StrictFloat, StrictInt]] + memory_limit: Optional[StrictStr] + limiter_type: Optional[ + Literal[ + "adaptive", + "cpu-adaptive", + "leaky-bucket", + "rate-adaptive", + "sliding-window", + "token-bucket", + ] + ] + limit_key: Optional[ + Callable[ + [ + Request, + IPvAnyAddress, + ], + str + ] + ] + rules: Optional[ + List[ + Callable[ + [ + Request, + IPvAnyAddress, + + ], + bool + ] + ] + ] + + @property + def backoff(self): + return TimeParser(self.request_backoff).time + + @property + def period(self): + return TimeParser(self.request_period).time + + @property + def memory(self): + return MemoryParser(self.memory_limit).megabytes(accuracy=4) + + def get_key( + self, + request: Request, + ip_address: IPvAnyAddress, + default: str='default' + ): + + if self.limit_key is None: + return default + + return self.limit_key( + request, + ip_address + ) + + def matches( + self, + request: Request, + ip_address: IPvAnyAddress + ): + + if self.rules is None: + return True + + matches_rules = False + + for rule in self.rules: + matches_rules = rule( + request, + ip_address + ) + + return matches_rules diff --git a/hyperscale/distributed/models/http/request.py b/hyperscale/distributed/models/http/request.py new file mode 100644 index 0000000..fb70062 --- /dev/null +++ b/hyperscale/distributed/models/http/request.py @@ -0,0 +1,147 @@ +import json +from http.cookies import SimpleCookie +from pydantic import ( + BaseModel, + Json +) +from typing import ( + Dict, + Union, + List, + TypeVar, + Generic, + Optional, + Literal +) + + +T = TypeVar('T', bound=BaseModel) + + +class Request(Generic[T]): + + def __init__( + self, + path: str, + method: Literal[ + "GET", + "HEAD", + "OPTIONS", + "POST", + "PUT", + "PATCH", + "DELETE", + "TRACE" + ], + query: str, + raw: List[bytes], + model: Optional[BaseModel] = None, + ) -> None: + + self.path = path + self.method = method + self._query = query + + self._headers: Dict[str, str] = {} + self._params: Dict[str, str] = {} + self._content: Union[bytes, None] = None + self._data: Union[str, Json, None] = None + + self.raw = raw + self._data_line_idx = -1 + self._model = model + self._cookies: Union[ + Dict[str, str], + None + ] = None + + @property + def headers(self): + + if self._data_line_idx == -1: + header_lines = self.raw[1:] + data_line_idx = 0 + + for header_line in header_lines: + + if header_line == b'': + data_line_idx += 1 + break + + key, value = header_line.decode().split( + ':', + maxsplit=1 + ) + + self._headers[key.lower()] = value.strip() + + data_line_idx += 1 + + self._data_line_idx = data_line_idx + 1 + + return self._headers + + @property + def cookies(self): + headers = self.headers + + if self._cookies is None: + + cookies = headers.get('cookie') + self._cookies = {} + + if cookies: + + parsed_cookies = SimpleCookie() + parsed_cookies.load(cookies) + + self._cookies = { + name: morsel.value for name, morsel in parsed_cookies.items() + } + + return self._cookies + + @property + def params(self) -> Dict[str, str]: + + if len(self._params) < 1: + params = self._query.split('&') + + for param in params: + key, value = param.split('=') + + self._params[key] = value + + return self._params + + @property + def content(self): + if self._content is None: + self._content = b''.join(self.raw[self._data_line_idx:]).strip() + + return self._content + + @content.setter + def content(self, updated: bytes): + self._content = updated + + @property + def body(self): + + headers = self.headers + + if self._data is None: + self._data = self.content + + if headers.get('content-type') == 'application/json': + self._data = json.loads(self._data) + + return self._data + + def data(self) -> Union[bytes, str, Dict[str, str], T]: + data = self.body + + if isinstance(data, dict) and self._model: + return self._model(**data) + + return data \ No newline at end of file diff --git a/hyperscale/distributed/models/http/response.py b/hyperscale/distributed/models/http/response.py new file mode 100644 index 0000000..03b84fe --- /dev/null +++ b/hyperscale/distributed/models/http/response.py @@ -0,0 +1,45 @@ +from http.cookies import SimpleCookie +from pydantic import ( + BaseModel +) +from typing import ( + Dict, + Union +) + + +class Response: + def __init__( + self, + path: str, + method: str, + headers: Dict[str, str]={}, + data: Union[BaseModel, str, None]=None + ): + self.path = path + self.method = method + self.headers = headers + self.data = data + self._cookies: Union[ + Dict[str, str], + None + ] = None + + @property + def cookies(self): + + if self._cookies is None: + + cookies = self.headers.get('cookie') + self._cookies = {} + + if cookies: + + parsed_cookies = SimpleCookie() + parsed_cookies.load(cookies) + + self._cookies = { + name: morsel.value for name, morsel in parsed_cookies.items() + } + + return self._cookies diff --git a/hyperscale/distributed/models/raft/__init__.py b/hyperscale/distributed/models/raft/__init__.py new file mode 100644 index 0000000..7aeeab1 --- /dev/null +++ b/hyperscale/distributed/models/raft/__init__.py @@ -0,0 +1,4 @@ +from .election_state import ElectionState +from .healthcheck import HealthCheck, HealthStatus +from .raft_message import RaftMessage +from .vote_result import VoteResult \ No newline at end of file diff --git a/hyperscale/distributed/models/raft/election_state.py b/hyperscale/distributed/models/raft/election_state.py new file mode 100644 index 0000000..1f84be9 --- /dev/null +++ b/hyperscale/distributed/models/raft/election_state.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class ElectionState(Enum): + ACTIVE='ACTIVE' + CONFIRMED='CONFIRMED' + PENDING='PENDING' + READY='READY' \ No newline at end of file diff --git a/hyperscale/distributed/models/raft/healthcheck.py b/hyperscale/distributed/models/raft/healthcheck.py new file mode 100644 index 0000000..f107551 --- /dev/null +++ b/hyperscale/distributed/models/raft/healthcheck.py @@ -0,0 +1,26 @@ +from typing import List, Literal, Optional, Tuple, Union + +from pydantic import StrictInt, StrictStr + +from hyperscale.distributed.models.base.message import Message + +HealthStatus = Literal[ + "initializing", + "waiting", + "healthy", + "suspect", + "failed" +] + +class HealthCheck(Message): + target_host: Optional[StrictStr] + target_port: Optional[StrictInt] + target_status: Optional[HealthStatus] + target_last_updated: Optional[StrictInt] + target_instance_id: Optional[Union[StrictInt, None]] + registered_nodes: Optional[List[Tuple[StrictStr, StrictInt, StrictInt]]] + registered_count: Optional[StrictInt] + source_host: StrictStr + source_port: StrictInt + source_status: Optional[HealthStatus] + status: HealthStatus \ No newline at end of file diff --git a/hyperscale/distributed/models/raft/logs/__init__.py b/hyperscale/distributed/models/raft/logs/__init__.py new file mode 100644 index 0000000..87a676e --- /dev/null +++ b/hyperscale/distributed/models/raft/logs/__init__.py @@ -0,0 +1,2 @@ +from .entry import Entry +from .node_state import NodeState \ No newline at end of file diff --git a/hyperscale/distributed/models/raft/logs/entry.py b/hyperscale/distributed/models/raft/logs/entry.py new file mode 100644 index 0000000..f4577cc --- /dev/null +++ b/hyperscale/distributed/models/raft/logs/entry.py @@ -0,0 +1,51 @@ +from typing import Any, Dict, Union + +from pydantic import BaseModel, StrictInt, StrictStr + +from hyperscale.distributed.snowflake import Snowflake + + +class Entry(BaseModel): + entry_id: StrictInt + key: StrictStr + value: Any + term: StrictInt + leader_host: StrictStr + leader_port: StrictInt + timestamp: StrictInt + + def __init__(self, *args, **kwargs): + + entry_id: Union[int, None] = kwargs.get('entry_id') + if entry_id: + kwargs['timestamp'] = Snowflake.parse(entry_id).timestamp + + super().__init__( + *args, + **kwargs + ) + + def to_data(self): + return { + 'key': self.key, + 'value': self.value, + 'timestamp': self.timestamp + } + + @classmethod + def from_data( + cls, + entry_id: int, + leader_host: str, + leader_port: int, + term: int, + data: Dict[str, Any] + ): + + return Entry( + entry_id=entry_id, + leader_host=leader_host, + leader_port=leader_port, + term=term, + **data + ) \ No newline at end of file diff --git a/hyperscale/distributed/models/raft/logs/node_state.py b/hyperscale/distributed/models/raft/logs/node_state.py new file mode 100644 index 0000000..7d68527 --- /dev/null +++ b/hyperscale/distributed/models/raft/logs/node_state.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class NodeState(Enum): + FOLLOWER='FOLLOWER' + CANDIDATE='CANDIDATE' + LEADER='LEADER' \ No newline at end of file diff --git a/hyperscale/distributed/models/raft/raft_message.py b/hyperscale/distributed/models/raft/raft_message.py new file mode 100644 index 0000000..414b01e --- /dev/null +++ b/hyperscale/distributed/models/raft/raft_message.py @@ -0,0 +1,22 @@ +from typing import List, Optional, Tuple + +from pydantic import StrictInt, StrictStr + +from hyperscale.distributed.models.base.message import Message + +from .healthcheck import HealthStatus +from .logs import Entry, NodeState +from .vote_result import VoteResult + + +class RaftMessage(Message): + source_host: StrictStr + source_port: StrictInt + elected_leader: Optional[Tuple[StrictStr, StrictInt]] + failed_node: Optional[Tuple[StrictStr, StrictInt]] + vote_result: Optional[VoteResult] + raft_node_status: NodeState + status: HealthStatus + entries: Optional[List[Entry]] + term_number: StrictInt + received_timestamp: Optional[StrictInt] diff --git a/hyperscale/distributed/models/raft/vote_result.py b/hyperscale/distributed/models/raft/vote_result.py new file mode 100644 index 0000000..a11da4e --- /dev/null +++ b/hyperscale/distributed/models/raft/vote_result.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class VoteResult(Enum): + ACCEPTED='ACCEPTED' + REJECTED='REJECTED' \ No newline at end of file diff --git a/hyperscale/distributed/monitoring/__init__.py b/hyperscale/distributed/monitoring/__init__.py new file mode 100644 index 0000000..138586c --- /dev/null +++ b/hyperscale/distributed/monitoring/__init__.py @@ -0,0 +1 @@ +from .monitor_service import Monitor \ No newline at end of file diff --git a/hyperscale/distributed/monitoring/monitor_service.py b/hyperscale/distributed/monitoring/monitor_service.py new file mode 100644 index 0000000..7c3c4b0 --- /dev/null +++ b/hyperscale/distributed/monitoring/monitor_service.py @@ -0,0 +1,1998 @@ +import asyncio +import math +import random +import time +from collections import defaultdict, deque +from typing import Deque, Dict, List, Optional, Tuple, Union + +from hyperscale.distributed.env import Env, MonitorEnv, load_env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.hooks.client_hook import client +from hyperscale.distributed.hooks.server_hook import server +from hyperscale.distributed.models.raft import HealthCheck, HealthStatus +from hyperscale.distributed.service.controller import Controller +from hyperscale.distributed.snowflake import Snowflake +from hyperscale.distributed.types import Call +from hyperscale.logging import HyperscaleLogger, logging_manager +from hyperscale.tools.helpers import cancel + + +class Monitor(Controller): + + def __init__( + self, + host: str, + port: int, + env: Optional[Env]=None, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + logs_directory: Optional[str]=None, + workers: int=0, + ) -> None: + + if workers <= 1: + engine = 'async' + + else: + engine = 'process' + + if env is None: + env: Env = load_env(Env) + + if logs_directory is None: + logs_directory = env.MERCURY_SYNC_LOGS_DIRECTORY + + monitor_env: MonitorEnv = load_env(MonitorEnv) + + super().__init__( + host, + port, + cert_path=cert_path, + key_path=key_path, + workers=workers, + env=env, + engine=engine + ) + + self.status: HealthStatus = 'initializing' + + + self.error_context: Optional[str] = None + + self.registration_timeout = TimeParser( + monitor_env.MERCURY_SYNC_REGISTRATION_TIMEOUT + ).time + + self.boot_wait = TimeParser( + monitor_env.MERCURY_SYNC_BOOT_WAIT + ).time + + self._healthcheck_task: Union[asyncio.Task, None] = None + self._registered: Dict[int, Tuple[str, int]] = {} + self._running = False + + self._cleanup_interval = TimeParser( + env.MERCURY_SYNC_CLEANUP_INTERVAL + ).time + + self._poll_interval = TimeParser( + monitor_env.MERCURY_SYNC_HEALTH_POLL_INTERVAL + ).time + + self._poll_timeout = TimeParser( + monitor_env.MERCURY_SYNC_HEALTH_CHECK_TIMEOUT + ).time + + self._local_health_multipliers: Dict[ + Tuple[str, int], + float + ] = defaultdict(lambda: 0) + + self._reboot_timeout = TimeParser( + monitor_env.MERCURY_SYNC_IDLE_REBOOT_TIMEOUT + ).time + + self._max_time_idle = TimeParser( + monitor_env.MERCURY_SYNC_MAX_TIME_IDLE + ).time + + self._poll_retries = monitor_env.MERCURY_SYNC_MAX_POLL_MULTIPLIER + + self._sync_interval = TimeParser( + monitor_env.MERCURY_SYNC_UDP_SYNC_INTERVAL + ).time + + self._suspect_max_age= TimeParser( + monitor_env.MERCURY_SYNC_SUSPECT_MAX_AGE + ).time + + self._check_nodes_count = monitor_env.MERCURY_SYNC_INDIRECT_CHECK_NODES + + self.min_suspect_multiplier = monitor_env.MERCURY_SYNC_MIN_SUSPECT_TIMEOUT_MULTIPLIER + self.max_suspect_multiplier = monitor_env.MERCURY_SYNC_MAX_SUSPECT_TIMEOUT_MULTIPLIER + self._min_suspect_node_count = monitor_env.MERCURY_SYNC_MIN_SUSPECT_NODES_THRESHOLD + self._max_poll_multiplier = monitor_env.MERCURY_SYNC_MAX_POLL_MULTIPLIER + self._initial_expected_nodes = monitor_env.MERCURY_SYNC_EXPECTED_NODES + + self._confirmed_suspicions: Dict[Tuple[str, int], int] = defaultdict(lambda: 0) + self._registered_counts: Dict[Tuple[str, int], int] = defaultdict(lambda: 0) + self._waiter: Union[asyncio.Future, None] = None + + self._tasks_queue: Deque[asyncio.Task] = deque() + self._degraded_nodes: Deque[Tuple[str, int]] = deque() + self._suspect_nodes: Deque[Tuple[str, int]] = deque() + self._suspect_history: List[Tuple[str, int, int]] = [] + + self._degraded_tasks: Dict[Tuple[str, int], asyncio.Task] = {} + self._suspect_tasks: Dict[Tuple[str, int], asyncio.Task] = {} + self._latest_update: Dict[Tuple[str,int], int] = {} + + self._local_health_monitor: Union[asyncio.Task, None] = None + self._udp_sync_task: Union[asyncio.Task, None] = None + self._tcp_sync_task: Union[asyncio.Task, None] = None + + self._cleanup_task: Union[asyncio.Task, None] = None + self._investigating_nodes: Dict[Tuple[str, int], Dict[Tuple[str, int]]] = defaultdict(dict) + self._node_statuses: Dict[Tuple[str, int], HealthStatus] = {} + self._instance_ids: Dict[Tuple[str, int], int] = {} + + self._models = [ + HealthCheck + ] + + self.bootstrap_host: Union[str, None] = None + self.bootstrap_port: Union[int, None] = None + + logging_manager.logfiles_directory = logs_directory + logging_manager.update_log_level( + env.MERCURY_SYNC_LOG_LEVEL + ) + + self._logger = HyperscaleLogger() + self._logger.initialize() + + self._healthy_statuses = [ + 'initializing', + 'waiting', + 'healthy' + ] + + self._unhealthy_statuses = [ + 'suspect', + 'failed' + ] + + self.failed_nodes: List[Tuple[str, int, float]] = [] + self.removed_nodes: List[Tuple[str, int, float]] = [] + + self._failed_max_age = TimeParser( + monitor_env.MERCURY_SYNC_FAILED_NODES_MAX_AGE + ).time + + self._removed_max_age = TimeParser( + monitor_env.MERCURY_SYNC_REMOVED_NODES_MAX_AGE + ).time + + @server() + async def register_node( + self, + shard_id: int, + healthcheck: HealthCheck + ) -> Call[HealthCheck]: + + try: + source_host = healthcheck.source_host + source_port = healthcheck.source_port + + not_self = self._check_is_not_self( + source_host, + source_port + ) + + not_registered = self._check_is_not_registered( + source_host, + source_port + ) + + if not_self and not_registered: + self._node_statuses[(source_host, source_port)] = 'healthy' + + snowflake = Snowflake.parse(shard_id) + self._instance_ids[(source_host, source_port)] = snowflake.instance + + if healthcheck.registered_nodes: + + for host, port, instance_id in healthcheck.registered_nodes: + + not_self = self._check_is_not_self( + host, + port + ) + + not_registered = self._check_is_not_registered( + host, + port + ) + + if not_self and not_registered: + self._node_statuses[(host, port)] = 'healthy' + + self._tasks_queue.append( + asyncio.create_task( + self._cancel_suspicion_probe( + host, + port + ) + ) + ) + + self._instance_ids[(host, port)] = instance_id + + node_address = (source_host, source_port) + + self._tasks_queue.append( + asyncio.create_task( + self._cancel_suspicion_probe( + source_host, + source_port + ) + ) + ) + + if node_address in self.failed_nodes: + self.failed_nodes.remove(node_address) + + self._registered_counts[(source_host, source_port)] = max( + healthcheck.registered_count, + self._registered_counts[(source_host, source_port)] + ) + + return HealthCheck( + host=source_host, + port=source_port, + source_host=self.host, + source_port=self.port, + registered_nodes=[ + ( + host, + port, + self._instance_ids.get(( + host, port + )) + ) for host, port in self._instance_ids + ], + status=self.status, + registered_count=len(self._instance_ids) + ) + + except Exception: + pass + + @server() + async def deregister_node( + self, + shard_id: int, + healthcheck: HealthCheck + ) -> Call[HealthCheck]: + + source_host = healthcheck.source_host + source_port = healthcheck.source_port + + node = self._node_statuses.get(( + source_host, + source_port + )) + + await self._logger.distributed.aio.info(f'Node - {source_host}:{source_port} - submitted request to leave to source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {source_host}:{source_port} - submitted request to leave to source - {self.host}:{self.port}') + + if self._suspect_tasks.get(( + source_host, + source_port + )): + + self._tasks_queue.append( + asyncio.create_task( + self._cancel_suspicion_probe( + source_host, + source_port + ) + ) + ) + + await self._logger.distributed.aio.debug(f'Source - {self.host}:{self.port} - has cancelled suspicion of node - {source_host}:{source_port} - due to leave request') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - has cancelled suspicion of node - {source_host}:{source_port} - due to leave request') + + if node is not None: + + node_status = "inactive" + self._node_statuses[(source_host, source_port)] = node_status + + await self._logger.distributed.aio.debug(f'Source - {self.host}:{self.port} - has accepted request to remove node - {source_host}:{source_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - has accepted request to remove node - {source_host}:{source_port}') + + + return HealthCheck( + host=healthcheck.source_host, + port=healthcheck.source_port, + source_host=self.host, + source_port=self.port, + status=self.status + ) + + @server() + async def update_node_status( + self, + shard_id: int, + healthcheck: HealthCheck + ) -> Call[HealthCheck]: + + update_node_host = healthcheck.source_host + update_node_port = healthcheck.source_port + update_status = healthcheck.status + + await self._logger.distributed.aio.debug(f'Node - {update_node_host}:{update_node_port} - updating status to - {update_status} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {update_node_host}:{update_node_port} - updating status to - {update_status} - for source - {self.host}:{self.port}') + + if healthcheck.target_host and healthcheck.target_port: + update_node_host = healthcheck.target_host + update_node_port = healthcheck.target_port + + if healthcheck.target_status: + update_status = healthcheck.target_status + + target_last_updated: Union[int, None] = healthcheck.target_last_updated + local_last_updated: Union[int, None] = self._latest_update.get(( + update_node_host, + update_node_port + ), 0) + + snowflake = Snowflake.parse(shard_id) + + source_host = healthcheck.source_host + source_port = healthcheck.source_port + self._instance_ids[(source_host, source_port)] = snowflake.instance + + if target_last_updated > local_last_updated: + self._node_statuses[(update_node_host, update_node_port)] = update_status + + self._local_health_multipliers[(update_node_host, update_node_port)] = self._reduce_health_multiplier( + update_node_host, + update_node_port + ) + + await self._logger.distributed.aio.debug(f'Node - {update_node_host}:{update_node_port} - updated status to - {update_status} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {update_node_host}:{update_node_port} - updated status to - {update_status} - for source - {self.host}:{self.port}') + + return HealthCheck( + host=healthcheck.source_host, + port=healthcheck.source_port, + source_host=self.host, + source_port=self.port, + status=self.status + ) + + @server() + async def update_as_suspect( + self, + shard_id: int, + healthcheck: HealthCheck + ) -> Call[HealthCheck]: + + source_host = healthcheck.source_host + source_port = healthcheck.source_port + + await self._logger.distributed.aio.debug(f'Node - {source_host}:{source_port} - requested a check for suspect source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {source_host}:{source_port} - requested a check for suspect source - {self.host}:{self.port}') + + if self.status == 'healthy': + + await self._logger.distributed.aio.debug(f'Source - {self.host}:{self.port} - received notification it is suspect despite being healthy from node - {source_host}:{source_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Source - {self.host}:{self.port} - received notification it is suspect despite being healthy from node - {source_host}:{source_port}') + + self._local_health_multipliers[(source_host, source_port)] = self._increase_health_multiplier( + source_host, + source_port + ) + + self._tasks_queue.append( + asyncio.create_task( + self._run_healthcheck( + source_host, + source_port + ) + ) + ) + + return HealthCheck( + host=source_host, + port=source_port, + source_host=self.host, + source_port=self.port, + status=self.status + ) + + @server() + async def send_indirect_check( + self, + shard_id: int, + healthcheck: HealthCheck + ) -> Call[HealthCheck]: + + source_host = healthcheck.source_host + source_port = healthcheck.source_port + + target_host = healthcheck.target_host + target_port = healthcheck.target_port + + await self._logger.distributed.aio.debug(f'Node - {source_host}:{source_port} - requested an indirect check for node - {target_host}:{target_port} - from source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {source_host}:{source_port} - requested an indirect check for node - {target_host}:{target_port} - from source - {self.host}:{self.port}') + + try: + + investigation_update = self._acknowledge_indirect_probe( + source_host, + source_port, + target_host, + target_port + ) + + indirect_probe = self._run_healthcheck( + target_host, + target_port + ) + + for task in asyncio.as_completed([ + investigation_update, + indirect_probe + ]): + await task + + self._local_health_multipliers[(target_host, target_port)] = self._reduce_health_multiplier( + target_host, + target_port + ) + + await self._logger.distributed.aio.debug(f'Suspect node - {target_host}:{target_port} - responded to an indirect check from source - {self.host}:{self.port} - for node - {source_host}:{source_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Suspect node - {target_host}:{target_port} - responded to an indirect check from source - {self.host}:{self.port} - for node - {source_host}:{source_port}') + + except Exception: + + if self._node_statuses[(target_host, target_port)] != 'failed': + + await self._logger.distributed.aio.debug(f'Suspect node - {target_host}:{target_port} - failed to respond to an indirect check from source - {self.host}:{self.port} - for node - {source_host}:{source_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Suspect node - {target_host}:{target_port} - failed to respond to an indirect check from source - {self.host}:{self.port} - for node - {source_host}:{source_port}') + + self._local_health_multipliers[(target_host, target_port)] = self._increase_health_multiplier( + target_host, + target_port + ) + + # Our suspicion is correct! + return HealthCheck( + host=healthcheck.source_host, + port=healthcheck.source_port, + source_host=target_host, + source_port=target_port, + target_status='suspect', + status=self.status + ) + + return HealthCheck( + host=healthcheck.source_host, + port=healthcheck.source_port, + target_status=self._node_statuses.get((target_host, target_port)), + source_host=target_host, + source_port=target_port, + status=self.status, + error=self.error_context + ) + + @server() + async def update_acknowledged( + self, + shard_id: int, + healthcheck: HealthCheck + ) -> Call[HealthCheck]: + source_host = healthcheck.source_host + source_port = healthcheck.source_port + target_host = healthcheck.target_host + target_port = healthcheck.target_port + + + await self._logger.distributed.aio.debug(f'Node - {source_host}:{source_port} - acknowledged the indirect check request for node - {target_host}:{target_port} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {source_host}:{source_port} - acknowledged the indirect check request for node - {target_host}:{target_port} - for source - {self.host}:{self.port}') + + if self._investigating_nodes.get((target_host, target_port)) is None: + self._investigating_nodes[(target_host, target_port)] = {} + + self._investigating_nodes[(target_host, target_port)].update({ + (source_host, source_port): healthcheck.status + }) + + return HealthCheck( + host=source_host, + port=source_port, + source_host=self.host, + source_port=self.port, + status=self.status + ) + + @server() + async def update_node_health( + self, + shard_id: int, + healthcheck: HealthCheck + ) -> Call[HealthCheck]: + + try: + update_node_host = healthcheck.source_host + update_node_port = healthcheck.source_port + + local_node_status = self._node_statuses.get((update_node_host, update_node_port)) + + if self._suspect_tasks.get(( + update_node_host, + update_node_port + )): + + await self._logger.distributed.aio.debug(f'Node - {update_node_host}:{update_node_port} - submitted healthy status to source - {self.host}:{self.port} - and is no longer suspect') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {update_node_host}:{update_node_port} - submitted healthy status to source - {self.host}:{self.port} - and is no longer suspect') + + self._tasks_queue.append( + asyncio.create_task( + self._cancel_suspicion_probe( + update_node_host, + update_node_port + ) + ) + ) + + snowflake = Snowflake.parse(shard_id) + + self._node_statuses[(update_node_host, update_node_port)] = healthcheck.status + self._latest_update[(update_node_host, update_node_port)] = snowflake.timestamp + + return HealthCheck( + host=healthcheck.source_host, + port=healthcheck.source_port, + source_host=self.host, + source_port=self.port, + source_status=local_node_status, + error=self.error_context, + status=self.status + ) + + except Exception: + return HealthCheck( + host=healthcheck.source_host, + port=healthcheck.source_port, + source_host=self.host, + source_port=self.port, + source_status=local_node_status, + error=self.error_context, + status=self.status + ) + + @client('register_node') + async def submit_registration( + self, + host: str, + port: int + ) -> Call[HealthCheck]: + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + registered_nodes=[ + ( + host, + port, + self._instance_ids.get(( + host, + port + )) + ) for host, port in self._instance_ids + ], + registered_count=len(self._instance_ids), + error=self.error_context, + status=self.status + ) + + @client('update_node_health') + async def push_health_update( + self, + host: str, + port: int, + health_status: HealthStatus, + target_host: Optional[str]=None, + target_port: Optional[str]=None, + error_context: Optional[str]=None + ) -> Call[HealthCheck]: + + target_status: Union[HealthCheck, None] = None + if target_host and target_port: + target_status = self._node_statuses.get((target_host, target_port)) + + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + target_host=target_host, + target_port=target_port, + target_status=target_status, + error=error_context, + status=health_status + ) + + @client('update_node_health', as_tcp=True) + async def push_tcp_health_update( + self, + host: str, + port: int, + health_status: HealthStatus, + target_host: Optional[str]=None, + target_port: Optional[str]=None, + error_context: Optional[str]=None + ) -> Call[HealthCheck]: + + target_status: Union[HealthCheck, None] = None + if target_host and target_port: + target_status = self._node_statuses.get((target_host, target_port)) + + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + target_host=target_host, + target_port=target_port, + target_status=target_status, + error=error_context, + status=health_status + ) + + + + async def _cancel_suspicion_probe( + self, + suspect_host: str, + suspect_port: int + ): + + suspect_node = (suspect_host, suspect_port) + + suspect_tasks = dict(self._suspect_tasks) + suspect_task = suspect_tasks.get(suspect_node) + + if suspect_task is not None: + await cancel(suspect_task) + del suspect_tasks[suspect_node] + + self._suspect_tasks = suspect_tasks + + async def _run_tcp_healthcheck( + self, + host: str, + port: int, + target_host: Optional[str]=None, + target_port: Optional[str]=None + ) -> Union[Tuple[int, HealthCheck], None]: + + shard_id: Union[int, None] = None + healthcheck: Union[HealthCheck, None] = None + + await self._logger.distributed.aio.debug(f'Running TCP healthcheck for node - {host}:{port} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Running TCP healthcheck for node - {host}:{port} - for source - {self.host}:{self.port}') + + for idx in range(self._poll_retries): + + try: + + response: Tuple[int, HealthCheck] = await asyncio.wait_for( + self.push_tcp_health_update( + host, + port, + self.status, + target_host=target_host, + target_port=target_port, + error_context=self.error_context + ), + timeout=self._calculate_current_timeout( + host, + port + ) + ) + + shard_id, healthcheck = response + source_host, source_port = healthcheck.source_host, healthcheck.source_port + + self._node_statuses[(source_host, source_port)] = healthcheck.status + + self._local_health_multipliers[(host, port)] = self._reduce_health_multiplier( + host, + port + ) + + return shard_id, healthcheck + + except Exception: + self._local_health_multipliers[(host, port)] = self._increase_health_multiplier( + host, + port + ) + + check_host = host + check_port = port + + if target_host and target_port: + check_host = target_host + check_port = target_port + + node_status = self._node_statuses.get(( + check_host, + check_port + )) + + not_self = self._check_is_not_self( + check_host, + check_port + ) + + if not_self and healthcheck is None and node_status == 'healthy': + + await self._logger.distributed.aio.debug(f'Node - {check_host}:{check_port} - failed to respond over - {self._poll_retries} - retries and is now suspect for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {check_host}:{check_port} - failed to respond over - {self._poll_retries} - retries and is now suspect for source - {self.host}:{self.port}') + + self._node_statuses[(check_host, check_port)] = 'suspect' + + self._suspect_nodes.append(( + check_host, + check_port + )) + + self._suspect_tasks[(host, port)] = asyncio.create_task( + self._start_suspect_monitor() + ) + + else: + await self._logger.distributed.aio.debug(f'Node - {check_host}:{check_port} - responded on try - {idx}/{self._poll_retries} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {check_host}:{check_port} - responded on try - {idx}/{self._poll_retries} - for source - {self.host}:{self.port}') + + return shard_id, healthcheck + + @client('update_acknowledged') + async def push_acknowledge_check( + self, + host: str, + port: int, + target_host: str, + target_port: int, + health_status: HealthStatus, + error_context: Optional[str]=None + ) -> Call[HealthCheck]: + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + target_host=target_host, + target_port=target_port, + status=health_status, + error=error_context + ) + + @client('send_indirect_check') + async def request_indirect_check( + self, + host: str, + port: int, + target_host: str, + target_port: int, + health_status: HealthStatus, + error_context: Optional[str]=None + + ) -> Call[HealthCheck]: + return HealthCheck( + host=host, + port=port, + target_host=target_host, + target_port=target_port, + target_status=self._node_statuses[(target_host, target_port)], + source_host=self.host, + source_port=self.port, + error=error_context, + status=health_status + ) + + @client('update_node_status') + async def push_status_update( + self, + host: str, + port: int, + health_status: HealthStatus, + target_host: Optional[str]=None, + target_port: Optional[int]=None, + error_context: Optional[str]=None + ) -> Call[HealthCheck]: + + target_status: Union[HealthStatus, None] = None + target_last_updated: Union[int, None] = self._latest_update.get( + (host, port), 0 + ) + + if target_host and target_port: + target_status = self._node_statuses.get((target_host, target_port)) + target_last_updated = self._latest_update.get( + (target_host, target_port), 0 + ) + + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + target_host=target_host, + target_port=target_port, + target_last_updated=target_last_updated, + target_status=target_status, + status=health_status, + error=error_context + ) + + @client('update_node_status', as_tcp=True) + async def push_tcp_status_update( + self, + host: str, + port: int, + health_status: HealthStatus, + target_host: Optional[str]=None, + target_port: Optional[int]=None, + error_context: Optional[str]=None + ) -> Call[HealthCheck]: + + target_status: Union[HealthStatus, None] = None + target_last_updated: Union[int, None] = self._latest_update.get( + (host, port), 0 + ) + + if target_host and target_port: + target_status = self._node_statuses.get((target_host, target_port)) + target_last_updated = self._latest_update.get( + (target_host, target_port), 0 + ) + + + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + target_host=target_host, + target_port=target_port, + target_status=target_status, + target_last_updated=target_last_updated, + status=health_status, + error=error_context + ) + + @client('update_as_suspect') + async def push_suspect_update( + self, + host: str, + port: int, + health_status: HealthStatus, + error_context: Optional[str]=None + ) -> Call[HealthCheck]: + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + status=health_status, + error=error_context + ) + + @client('deregister_node') + async def request_deregistration( + self, + host: str, + port: int, + health_status: HealthStatus, + error_context: Optional[str]=None + ) -> Call[HealthCheck]: + return HealthCheck( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + status=health_status, + error=error_context + ) + + async def start(self): + + await self._logger.filesystem.aio.create_logfile(f'hyperscale.distributed.{self._instance_id}.log') + self._logger.filesystem.create_filelogger(f'hyperscale.distributed.{self._instance_id}.log') + + await self.start_server() + + boot_wait = random.uniform(0.1, self.boot_wait * self._initial_expected_nodes) + await asyncio.sleep(boot_wait) + + async def register( + self, + host: str, + port: int + ): + + await self._logger.distributed.aio.info(f'Initializing node - {self.host}:{self.port} - with id - {self._instance_id}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Initializing node - {self.host}:{self.port} - with id - {self._instance_id}') + + self.bootstrap_host = host + self.bootstrap_port = port + self.status = 'healthy' + + await self._logger.distributed.aio.info(f'Connecting to node node - {self.bootstrap_host}:{self.bootstrap_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Connecting to node node - {self.bootstrap_host}:{self.bootstrap_port}') + + await self._register_initial_node() + + self._running = True + + + self._healthcheck_task = asyncio.create_task( + self.start_health_monitor() + ) + + self._cleanup_task = asyncio.create_task( + self.cleanup_pending_checks() + ) + + self._udp_sync_task = asyncio.create_task( + self._run_udp_state_sync() + ) + + self._tcp_sync_task = asyncio.create_task( + self._run_tcp_state_sync() + ) + + await self._logger.distributed.aio.info(f'Initialized node - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Initialized node - {self.host}:{self.port}') + + + self.status = 'healthy' + + async def _register_initial_node(self): + await self._logger.distributed.aio.info(f'Connecting to initial node - {self.bootstrap_host}:{self.bootstrap_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Connecting to initial node - {self.bootstrap_host}:{self.bootstrap_port}') + + poll_timeout = self._poll_timeout * self._initial_expected_nodes + + try: + + self._node_statuses[(self.bootstrap_host, self.bootstrap_port)] = 'healthy' + + await asyncio.wait_for( + self.start_client( + { + (self.bootstrap_host, self.bootstrap_port): self._models + }, + cert_path=self.cert_path, + key_path=self.key_path + ), + timeout=poll_timeout + ) + + while len(self._node_statuses) < 1: + + try: + + shard_id, response = await asyncio.wait_for( + self.submit_registration( + self.bootstrap_host, + self.bootstrap_port + ), + timeout=poll_timeout + ) + + source_host = response.source_host + source_port = response.source_port + + self._instance_ids[(source_host, source_port)] = Snowflake.parse(shard_id).instance + + except Exception: + pass + + await asyncio.sleep(self._poll_interval) + + except Exception: + pass + + def _calculate_min_suspect_timeout( + self, + suspect_node_address: Tuple[str, int] + ): + nodes_count = len(self._node_statuses) + 1 + + suspect_host, suspect_port = suspect_node_address + + poll_timeout = self._calculate_current_timeout( + suspect_host, + suspect_port + ) + + return round( + self.min_suspect_multiplier * math.log10(nodes_count) * poll_timeout, + 2 + ) + + def _reduce_health_multiplier( + self, + host: str, + port: int + ) -> int: + + modifier = len([ + address for address, status in self._node_statuses.items() if status == 'healthy' + ]) + + return max( + self._local_health_multipliers[(host, port)] - (1 * modifier), + 0 + ) + + def _increase_health_multiplier( + self, + host: str, + port: int + ) -> int: + + return min( + self._local_health_multipliers[(host, port)] + 1, + self.max_suspect_multiplier + ) + + def _calculate_current_timeout( + self, + host: str, + port: int + ): + modifier = max( + len([ + address for address, status in self._node_statuses.items() if status == 'healthy' + ]), + self._initial_expected_nodes + ) + + return self._poll_timeout + (self._local_health_multipliers[(host, port)] + 1) * modifier + + def _calculate_current_poll_interval( + self, + host: str, + port: int + ) -> float: + return self._poll_interval * (self._local_health_multipliers[(host, port)] + 1) + + def _calculate_max_suspect_timeout(self, min_suspect_timeout: float): + + return round( + self.max_suspect_multiplier * min_suspect_timeout, + 2 + ) + + def _calculate_suspicion_timeout( + self, + suspect_node_address: Tuple[str, int] + ): + + min_suspect_timeout = self._calculate_min_suspect_timeout(suspect_node_address) + + max_suspect_timeout = self._calculate_max_suspect_timeout(min_suspect_timeout) + + confirmed_suspect_count = max( + 0, + self._confirmed_suspicions[suspect_node_address] + ) + + timeout_modifier = math.log( + confirmed_suspect_count + 1 + )/math.log(self._min_suspect_node_count + 1) + + timeout_difference = max_suspect_timeout - min_suspect_timeout + + return max( + min_suspect_timeout, + max_suspect_timeout - (timeout_difference * timeout_modifier) + ) + + def _check_is_not_self( + self, + host: str, + port: int + ): + return host != self.host and port != self.port + + def _check_is_not_registered( + self, + host: str, + port: int + ): + return self._node_statuses.get(( + host, + port + )) is None + + async def _acknowledge_indirect_probe( + self, + host: str, + port: int, + target_host: str, + target_port: int + ): + shard_id: Union[int, None] = None + healthcheck: Union[HealthCheck, None] = None + + await self._logger.distributed.aio.debug(f'Running UDP healthcheck for node - {host}:{port} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Running UDP healthcheck for node - {host}:{port} - for source - {self.host}:{self.port}') + + for idx in range(self._poll_retries): + + try: + + await self._logger.distributed.aio.debug(f'Sending indirect check request to - {target_host}:{target_port} -for node - {host}:{port} - from source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Sending indirect check request to - {target_host}:{target_port} -for node - {host}:{port} - from source - {self.host}:{self.port}') + + response: Tuple[int, HealthCheck] = await asyncio.wait_for( + self.push_acknowledge_check( + host, + port, + target_host, + target_port, + self.status, + error_context=self.error_context + ), + timeout=self._calculate_current_timeout( + host, + port + ) + ) + + shard_id, healthcheck = response + + source_host, source_port = healthcheck.source_host, healthcheck.source_port + + not_self = self._check_is_not_self( + source_host, + source_port + ) + + if not_self: + self._node_statuses[(source_host, source_port)] = healthcheck.status + + await self._logger.distributed.aio.debug(f'Completed indirect check request to - {target_host}:{target_port} -for node - {host}:{port} - from source - {self.host}:{self.port} - on try - {idx}/{self._poll_retries}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Completed indirect check request to - {target_host}:{target_port} -for node - {host}:{port} - from source - {self.host}:{self.port} - on try - {idx}/{self._poll_retries}') + + return shard_id, healthcheck + + except Exception: + pass + + async def _run_healthcheck( + self, + host: str, + port: int, + target_host: Optional[str]=None, + target_port: Optional[str]=None + ) -> Union[Tuple[int, HealthCheck], None]: + + shard_id: Union[int, None] = None + healthcheck: Union[HealthCheck, None] = None + + await self._logger.distributed.aio.debug(f'Running UDP healthcheck for node - {host}:{port} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Running UDP healthcheck for node - {host}:{port} - for source - {self.host}:{self.port}') + + for idx in range(self._poll_retries): + + timeout = self._calculate_current_timeout( + host, + port + ) + + try: + + response: Tuple[int, HealthCheck] = await asyncio.wait_for( + self.push_health_update( + host, + port, + self.status, + target_host=target_host, + target_port=target_port, + error_context=self.error_context + ), + timeout=timeout + ) + + shard_id, healthcheck = response + source_host, source_port = healthcheck.source_host, healthcheck.source_port + + not_self = self._check_is_not_self( + source_host, + source_port + ) + + if not_self: + self._node_statuses[(source_host, source_port)] = healthcheck.status + + self._local_health_multipliers[(host, port)] = self._reduce_health_multiplier( + host, + port + ) + + await self._logger.distributed.aio.debug(f'Node - {host}:{port} - responded on try - {idx}/{self._poll_retries} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {host}:{port} - responded on try - {idx}/{self._poll_retries} - for source - {self.host}:{self.port}') + + return shard_id, healthcheck + + except Exception: + + await self._logger.distributed.aio.debug(f'Node - {host}:{port} - failed for source node - {self.host}:{self.port} - on attempt - {idx}/{self._poll_retries}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {host}:{port} - failed for source node - {self.host}:{self.port} - on attempt - {idx}/{self._poll_retries}') + + self._local_health_multipliers[(host, port)] = self._increase_health_multiplier( + host, + port + ) + + check_host = host + check_port = port + + if target_host and target_port: + check_host = target_host + check_port = target_port + + node_status = self._node_statuses.get(( + check_host, + check_port + )) + + not_self = self._check_is_not_self( + check_host, + check_port + ) + + if not_self and healthcheck is None and node_status== 'healthy': + + await self._logger.distributed.aio.debug(f'Node - {check_host}:{check_port} - failed to respond over - {self._poll_retries} - retries and is now suspect for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {check_host}:{check_port} - failed to respond over - {self._poll_retries} - retries and is now suspect for source - {self.host}:{self.port}') + + self._node_statuses[(check_host, check_port)] = 'suspect' + + self._suspect_nodes.append(( + check_host, + check_port + )) + + self._suspect_tasks[(host, port)] = asyncio.create_task( + self._start_suspect_monitor() + ) + + return shard_id, healthcheck + + async def _start_suspect_monitor(self) -> Tuple[str, int]: + + if len(self._suspect_nodes) < 1: + return + + address = self._suspect_nodes.pop() + suspect_host, suspect_port = address + + + not_self = self._check_is_not_self( + suspect_host, + suspect_port + ) + + if not_self and address not in self._suspect_history: + self._suspect_history.append(( + suspect_host, + suspect_port, + time.monotonic() + )) + + else: + return + + + status = self._node_statuses[(suspect_host, suspect_port)] + + if status == 'suspect': + + await self._logger.distributed.aio.debug(f'Node - {suspect_host}:{suspect_port} - marked suspect for source {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {suspect_host}:{suspect_port} - marked suspect for source {self.host}:{self.port}') + + suspicion_timeout = self._calculate_suspicion_timeout(address) + + elapsed = 0 + start = time.monotonic() + + while elapsed < suspicion_timeout and status == 'suspect': + + self._tasks_queue.append( + asyncio.create_task( + self._push_suspect_update( + host=suspect_host, + port=suspect_port, + health_status=self.status, + error_context=self.error_context + ) + ) + ) + + confirmation_members = self._get_confirmation_members(( + suspect_host, + suspect_port + )) + + suspect_count = await self._request_indirect_probe( + suspect_host, + suspect_port, + confirmation_members + ) + + self._confirmed_suspicions[(suspect_host, suspect_port)] += max( + 0, + suspect_count - 1 + ) + + indirect_ack_count = len(self._investigating_nodes[(suspect_host, suspect_port)]) + + missing_ack_count = len(confirmation_members) - indirect_ack_count + + await self._logger.distributed.aio.debug(f'Source - {self.host}:{self.port} - acknowledged - {indirect_ack_count} - indirect probes and failed to acknowledge - {missing_ack_count} - indirect probes.') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - acknowledged - {indirect_ack_count} - indirect probes and failed to acknowledge - {missing_ack_count} - indirect probes.') + + next_health_multiplier = self._local_health_multipliers[(suspect_host, suspect_port)] + missing_ack_count - indirect_ack_count + if next_health_multiplier < 0: + self._local_health_multipliers[(suspect_host, suspect_port)] = 0 + + else: + self._local_health_multipliers[(suspect_host, suspect_port)] = self._increase_health_multiplier( + suspect_host, + suspect_port + ) + + confirmation_members_count = len(confirmation_members) + + if suspect_count < confirmation_members_count: + # We had a majority confirmation the node was healthy. + self._investigating_nodes[(suspect_host, suspect_port)] = {} + self._confirmed_suspicions[(suspect_host, suspect_port)] = 0 + + self._node_statuses[(suspect_host, suspect_port)] = 'healthy' + + self._reduce_health_multiplier( + suspect_host, + suspect_port + ) + + await self._logger.distributed.aio.info(f'Node - {suspect_host}:{suspect_port} - successfully responded to one or more probes for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {suspect_host}:{suspect_port} - failed to respond for source - {self.host}:{self.port}. Setting next timeout as - {suspicion_timeout}') + + break + + await asyncio.sleep( + self._calculate_current_poll_interval( + suspect_host, + suspect_port + ) + ) + + + status = self._node_statuses[(suspect_host, suspect_port)] + + elapsed = time.monotonic() - start + suspicion_timeout = self._calculate_suspicion_timeout(address) + + + await self._logger.distributed.aio.debug(f'Node - {suspect_host}:{suspect_port} - failed to respond for source - {self.host}:{self.port}. Setting next timeout as - {suspicion_timeout}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {suspect_host}:{suspect_port} - failed to respond for source - {self.host}:{self.port}. Setting next timeout as - {suspicion_timeout}') + + if self._node_statuses[(suspect_host, suspect_port)] == 'suspect': + self._node_statuses[(suspect_host, suspect_port)] = 'failed' + + monitors = [ + address for address, status in self._node_statuses.items() if status in self._healthy_statuses + ] + + active_nodes_count = len(monitors) + + if active_nodes_count > 0: + + self._tasks_queue.extend([ + asyncio.create_task( + self._push_state_to_node( + host=host, + port=port + ) + ) for host, port in monitors + ]) + + await self._logger.distributed.aio.info(f'Node - {suspect_host}:{suspect_port} - marked failed for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {suspect_host}:{suspect_port} - marked failed for source - {self.host}:{self.port}') + + self._investigating_nodes[(suspect_host, suspect_port)] = {} + self._confirmed_suspicions[(suspect_host, suspect_port)] = 0 + + return ( + suspect_host, + suspect_port + ) + + def _get_confirmation_members(self, suspect_address: Tuple[str, int]) -> List[Tuple[str, int]]: + + confirmation_members = [ + address for address in self._node_statuses.keys() if address != suspect_address + ] + + confirmation_members_count = len(confirmation_members) + + if self._check_nodes_count > confirmation_members_count: + self._check_nodes_count = confirmation_members_count + + confirmation_members = random.sample( + confirmation_members, + self._check_nodes_count + ) + + return confirmation_members + + async def _request_indirect_probe( + self, + host: str, + port: int, + confirmation_members: List[Tuple[str, int]] + ) -> Tuple[List[Call[HealthCheck]], int]: + + await self._logger.distributed.aio.debug(f'Requesting indirect check for node - {host}:{port} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Requesting indirect check for node - {host}:{port} - for source - {self.host}:{self.port}') + + if len(confirmation_members) < 1: + requested_checks = [ + asyncio.create_task( + self._run_tcp_healthcheck( + host, + port + ) + ) + ] + + else: + requested_checks = [ + asyncio.create_task( + self.request_indirect_check( + node_host, + node_port, + host, + port, + self.status, + error_context=self.error_context + ) + ) for node_host, node_port in confirmation_members + ] + + requested_checks.append( + asyncio.create_task( + self._run_tcp_healthcheck( + host, + port + ) + ) + ) + + check_tasks: Tuple[List[asyncio.Task], List[asyncio.Task]] = await asyncio.wait( + requested_checks, + timeout=self._calculate_current_timeout( + host, + port + ) + ) + + completed, pending = check_tasks + + results: List[Call[HealthCheck]] = await asyncio.gather( + *completed, + return_exceptions=True + ) + + healthchecks = [ + result for result in results if isinstance( + result, + tuple + ) and isinstance( + result[0], + int + ) and isinstance( + result[1], + HealthCheck + ) + ] + + errors = [ + result for result in results if result not in healthchecks + ] + + sorted_checks: List[Call[HealthCheck]] = list(sorted( + healthchecks, + key=lambda check: Snowflake.parse(check[0]).timestamp + )) + + suspect = [ + ( + shard_id, + check + ) for shard_id, check in sorted_checks if check.target_status == 'suspect' + ] + + healthy = [ + ( + shard_id, + check + ) for shard_id, check in sorted_checks if check.target_status == 'healthy' + ] + + if len(healthy) < 1: + suspect_count = len(suspect) + len(pending) + len(errors) + + else: + suspect_checks: List[Call[HealthCheck]] = [] + for suspect_shard_id, suspect_check in suspect: + + newer_count = 0 + for healthy_shard_id, _ in healthy: + if suspect_shard_id > healthy_shard_id: + newer_count += 1 + + if newer_count >= len(healthy): + suspect_checks.append(( + suspect_shard_id, + suspect_check + )) + + suspect_count = len(suspect_checks) + len(pending) + len(errors) + + await asyncio.gather(*[ + cancel(pending_check) for pending_check in pending + ], return_exceptions=True) + + + await self._logger.distributed.aio.debug(f'Total of {suspect_count} nodes confirmed node - {host}:{port} - is suspect for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Total of {suspect_count} nodes confirmed node - {host}:{port} - is suspect for source - {self.host}:{self.port}') + + return suspect_count + + async def _propagate_state_update( + self, + target_host: str, + target_port: int + ): + monitoring = [ + address for address, status in self._node_statuses.items() if status in self._healthy_statuses + ] + + for host, port in monitoring: + await self.push_health_update( + host, + port, + self.status, + target_host=target_host, + target_port=target_port + ) + + async def run_forever(self): + self._waiter = asyncio.Future() + await self._waiter + + async def start_health_monitor(self): + + while self._running: + + monitors = list(self._node_statuses.keys()) + + host: Union[str, None] = None + port: Union[int, None] = None + + monitors_count = len(monitors) + + if monitors_count > 0: + host, port = random.choice(monitors) + + node_status = self._node_statuses.get((host, port)) + if node_status in self._healthy_statuses: + + self._tasks_queue.append( + asyncio.create_task( + self._run_healthcheck( + host, + port + ) + ) + ) + + await asyncio.sleep( + self._calculate_current_poll_interval( + host, + port + ) + ) + + async def leave(self): + + await self._submit_leave_requests() + await self._shutdown() + + async def _submit_leave_requests(self): + monitors = [ + address for address, status in self._node_statuses.items() if status in self._healthy_statuses + ] + + if len(monitors) > 0: + await asyncio.gather(*[ + asyncio.create_task( + self.request_deregistration( + host, + port, + self.status, + error_context=self.error_context + ) + ) for host, port in monitors + ]) + + async def _run_udp_state_sync(self): + while self._running: + + monitors = [ + address for address, status in self._node_statuses.items() if status in self._healthy_statuses + ] + + active_nodes_count = len(monitors) + + if active_nodes_count > 0: + + self._tasks_queue.extend([ + asyncio.create_task( + self._push_state_to_node( + host=host, + port=port + ) + ) for host, port in monitors + ]) + + await asyncio.sleep( + self._sync_interval + ) + + async def _run_tcp_state_sync(self): + + await asyncio.sleep( + self._sync_interval/2 + ) + + while self._running: + + monitors = [ + address for address, status in self._node_statuses.items() if status in self._healthy_statuses + ] + + active_nodes_count = len(monitors) + + if active_nodes_count > 0: + + self._tasks_queue.extend([ + asyncio.create_task( + self._push_state_to_node_tcp( + host=host, + port=port + ) + ) for host, port in monitors + ]) + + await asyncio.sleep( + self._sync_interval + ) + + async def _push_state_to_node( + self, + host: str, + port: int + ): + + updates = [ + self._push_status_update( + host=host, + port=port, + target_host=node_host, + target_port=node_port + ) for node_host, node_port in self._node_statuses if self._node_statuses.get(( + node_host, + node_port + )) == 'healthy' and host != node_host and port != node_port + ] + + if len(updates) > 0: + await asyncio.gather(*updates) + + async def _push_state_to_node_tcp( + self, + host: str, + port: int + ): + updates = [ + asyncio.create_task( + self._push_tcp_status_update( + host=host, + port=port, + target_host=node_host, + target_port=node_port + ) + ) for node_host, node_port in self._node_statuses if self._node_statuses.get(( + node_host, + node_port + )) == 'healthy' and host != node_host and port != node_port + ] + + if len(updates) > 0: + await asyncio.gather(*updates) + + async def _push_status_update( + self, + host: str, + port: int, + target_host: Optional[str]=None, + target_port: Optional[int]=None + ) -> Tuple[ + Union[int, None], + Union[HealthCheck, None] + ]: + + shard_id: Union[int, None] = None + healthcheck: Union[HealthCheck, None] = None + + await self._logger.distributed.aio.debug(f'Pushing UDP health update for source - {host}:{port} - to node - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Pushing UDP health update for source - {host}:{port} - to node - {self.host}:{self.port}') + + for _ in range(self._poll_retries): + + try: + + timeout = self._calculate_current_timeout( + host, + port + ) + + response: Tuple[int, HealthCheck] = await asyncio.wait_for( + self.push_status_update( + host, + port, + self.status, + target_host=target_host, + target_port=target_port, + error_context=self.error_context + ), + timeout=timeout + ) + + shard_id, healthcheck = response + source_host, source_port = healthcheck.source_host, healthcheck.source_port + + not_self = self._check_is_not_self( + source_host, + source_port + ) + + if not_self: + self._node_statuses[(source_host, source_port)] = healthcheck.status + + return shard_id, healthcheck + + except Exception: + + self._local_health_multipliers[(host, port)] = self._increase_health_multiplier( + host, + port + ) + + return shard_id, healthcheck + + async def _push_tcp_status_update( + self, + host: str, + port: int, + target_host: Optional[str]=None, + target_port: Optional[int]=None + ): + shard_id: Union[int, None] = None + healthcheck: Union[HealthCheck, None] = None + + await self._logger.distributed.aio.debug(f'Pushing TCP health update for source - {host}:{port} - to node - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Pushing TCP health update for source - {host}:{port} - to node - {self.host}:{self.port}') + + for _ in range(self._poll_retries): + + try: + + response: Tuple[int, HealthCheck] = await asyncio.wait_for( + self.push_tcp_status_update( + host, + port, + self.status, + target_host=target_host, + target_port=target_port, + error_context=self.error_context + ), + timeout=self._calculate_current_timeout( + host, + port + ) + ) + + self._local_health_multipliers[(host, port)] = self._reduce_health_multiplier( + host, + port + ) + shard_id, healthcheck = response + source_host, source_port = healthcheck.source_host, healthcheck.source_port + + not_self = self._check_is_not_self( + source_host, + source_port + ) + + if not_self: + self._node_statuses[(source_host, source_port)] = healthcheck.status + + return shard_id, healthcheck + + except Exception: + + self._local_health_multipliers[(host, port)] = self._increase_health_multiplier( + host, + port + ) + + return shard_id, healthcheck + + + async def _push_suspect_update( + self, + host: str, + port: int, + health_status: HealthStatus, + error_context: Optional[str]=None + ): + + await self._logger.distributed.aio.debug(f'Pushing TCP health update for source - {host}:{port} - to suspect node - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Pushing TCP health update for source - {host}:{port} - to suspect node - {self.host}:{self.port}') + + try: + response: Tuple[int, HealthCheck] = await asyncio.wait_for( + self.push_suspect_update( + host=host, + port=port, + health_status=health_status, + error_context=error_context + ), + timeout=self._calculate_current_timeout( + host, + port + ) + ) + + _, healthcheck = response + + not_self = self._check_is_not_self( + host, + port + ) + + if not_self: + self._node_statuses[(host, port)] = healthcheck.status + + except Exception: + pass + + async def cleanup_pending_checks(self): + + await self._logger.distributed.aio.debug(f'Running cleanup for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Running cleanup for source - {self.host}:{self.port}') + + while self._running: + + pending_checks_count = 0 + + for pending_check in list(self._tasks_queue): + if pending_check.done() or pending_check.cancelled(): + try: + await pending_check + + except Exception: + pass + + self._tasks_queue.remove(pending_check) + pending_checks_count += 1 + + for node in list(self._suspect_history): + _, _, age = node + + failed_elapsed = time.monotonic() - age + + if failed_elapsed >= self._suspect_max_age: + self._suspect_history.remove(node) + + + for node in list(self.failed_nodes): + + _, _, age = node + failed_elapsed = time.monotonic() - age + removed_elapsed = time.monotonic() - age + + if node not in self.removed_nodes: + self.removed_nodes.append(node) + + if failed_elapsed >= self._failed_max_age: + self.failed_nodes.remove(node) + + elif removed_elapsed >= self._removed_max_age: + self.removed_nodes.remove(node) + + await self._logger.distributed.aio.debug(f'Cleaned up - {pending_checks_count} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Cleaned up - {pending_checks_count} - for source - {self.host}:{self.port}') + + await asyncio.sleep(self._cleanup_interval) + + async def _shutdown(self): + + await self._logger.distributed.aio.debug(f'Shutdown requested for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Shutdown requested for source - {self.host}:{self.port}') + + self._running = False + + await asyncio.gather(*[ + cancel(check) for check in self._tasks_queue + ], return_exceptions=True) + + if self._healthcheck_task: + await cancel(self._healthcheck_task) + + if self._local_health_monitor: + await cancel(self._local_health_monitor) + + if self._cleanup_task: + await cancel(self._cleanup_task) + + if self._udp_sync_task: + await cancel(self._udp_sync_task) + + if self._tcp_sync_task: + await cancel(self._tcp_sync_task) + + await self.close() + + await self._logger.distributed.aio.debug(f'Shutdown complete for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Shutdown complete for source - {self.host}:{self.port}') + + async def soft_shutdown(self): + await asyncio.gather(*[ + cancel(check) for check in self._tasks_queue + ], return_exceptions=True) + + + + + + + + + diff --git a/hyperscale/distributed/rate_limiting/__init__.py b/hyperscale/distributed/rate_limiting/__init__.py new file mode 100644 index 0000000..9067cf3 --- /dev/null +++ b/hyperscale/distributed/rate_limiting/__init__.py @@ -0,0 +1 @@ +from .limiter import Limiter \ No newline at end of file diff --git a/hyperscale/distributed/rate_limiting/limiter.py b/hyperscale/distributed/rate_limiting/limiter.py new file mode 100644 index 0000000..c12d750 --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiter.py @@ -0,0 +1,173 @@ +from typing import Callable, Dict, Optional, Union + +from pydantic import IPvAnyAddress + +from hyperscale.distributed.env import Env +from hyperscale.distributed.models.http import Limit, Request + +from .limiters import ( + AdaptiveRateLimiter, + CPUAdaptiveLimiter, + LeakyBucketLimiter, + ResourceAdaptiveLimiter, + SlidingWindowLimiter, + TokenBucketLimiter, +) + + +class Limiter: + + def __init__( + self, + env: Env + ) -> None: + + self._limiter: Union[ + Union[ + AdaptiveRateLimiter, + CPUAdaptiveLimiter, + LeakyBucketLimiter, + ResourceAdaptiveLimiter, + SlidingWindowLimiter, + TokenBucketLimiter + ], + None + ] = None + + self._default_limit = Limit( + max_requests=env.MERCURY_SYNC_HTTP_RATE_LIMIT_REQUESTS, + request_period=env.MERCURY_SYNC_HTTP_RATE_LIMIT_PERIOD, + reject_requests=env.MERCURY_SYNC_HTTP_RATE_LIMIT_DEFAULT_REJECT, + cpu_limit=env.MERCURY_SYNC_HTTP_CPU_LIMIT, + memory_limit=env.MERCURY_SYNC_HTTP_MEMORY_LIMIT + ) + + self._rate_limit_strategy = env.MERCURY_SYNC_HTTP_RATE_LIMIT_STRATEGY + self._default_limiter_type = env.MERCURY_SYNC_HTTP_RATE_LIMITER_TYPE + + self._rate_limiter_types: Dict[ + str, + Callable[ + [Limit], + Union[ + AdaptiveRateLimiter, + CPUAdaptiveLimiter, + LeakyBucketLimiter, + ResourceAdaptiveLimiter, + SlidingWindowLimiter, + TokenBucketLimiter + ] + ] + ] = { + "adaptive": AdaptiveRateLimiter, + "cpu-adaptive": CPUAdaptiveLimiter, + "leaky-bucket": LeakyBucketLimiter, + "rate-adaptive": ResourceAdaptiveLimiter, + "sliding-window": SlidingWindowLimiter, + "token-bucket": TokenBucketLimiter, + } + + self._rate_limit_period = env.MERCURY_SYNC_HTTP_RATE_LIMIT_PERIOD + + self._rate_limiters: Dict[ + str, + Union[ + AdaptiveRateLimiter, + CPUAdaptiveLimiter, + LeakyBucketLimiter, + SlidingWindowLimiter, + TokenBucketLimiter + ] + ] = {} + + async def limit( + self, + ip_address: IPvAnyAddress, + request: Request, + limit: Optional[Limit]=None + ): + limit_key: Union[str, None] = None + + if self._rate_limit_strategy == 'ip': + + if limit is None: + limit = self._default_limit + + limit_key = limit.get_key( + request, + ip_address, + default=ip_address + ) + + elif self._rate_limit_strategy == 'endpoint' and limit: + + if limit is None: + limit = self._default_limit + + limit_key = limit.get_key( + request, + ip_address, + default=request.path + ) + + elif self._rate_limit_strategy == 'global': + + limit_key = self._default_limit.get_key( + request, + ip_address, + default='default' + ) + + limit = self._default_limit + + elif self._rate_limit_strategy == "ip-endpoint" and limit: + + if limit is None: + limit = self._default_limit + + limit_key = limit.get_key( + request, + ip_address, + default=f'{request.path}_{ip_address}' + ) + + elif limit: + + limit_key = limit.get_key( + request, + ip_address + ) + + if limit_key and limit.matches(request, ip_address): + return await self._check_limiter( + limit_key, + limit + ) + + return False + + async def _check_limiter( + self, + limiter_key: str, + limit: Limit + ): + + limiter = self._rate_limiters.get(limiter_key) + + rate_limiter_type = limit.limiter_type + if rate_limiter_type is None: + rate_limiter_type = self._default_limiter_type + + if limiter is None: + + limiter = self._rate_limiter_types.get(rate_limiter_type)(limit) + + self._rate_limiters[limiter_key] = limiter + + + return await limiter.acquire() + + async def close(self): + for limiter in self._rate_limiters.values(): + if isinstance(limiter, CPUAdaptiveLimiter): + await limiter.close() \ No newline at end of file diff --git a/hyperscale/distributed/rate_limiting/limiters/__init__.py b/hyperscale/distributed/rate_limiting/limiters/__init__.py new file mode 100644 index 0000000..c08ad89 --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/__init__.py @@ -0,0 +1,6 @@ +from .adaptive_limiter import AdaptiveRateLimiter +from .cpu_adaptive import CPUAdaptiveLimiter +from .leaky_bucket_limiter import LeakyBucketLimiter +from .resource_adaptive_limiter import ResourceAdaptiveLimiter +from .sliding_window_limiter import SlidingWindowLimiter +from .token_bucket_limiter import TokenBucketLimiter \ No newline at end of file diff --git a/hyperscale/distributed/rate_limiting/limiters/adaptive_limiter.py b/hyperscale/distributed/rate_limiting/limiters/adaptive_limiter.py new file mode 100644 index 0000000..54f8526 --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/adaptive_limiter.py @@ -0,0 +1,111 @@ +import asyncio +import math +import statistics + +from hyperscale.distributed.models.http import Limit + +from .base_limiter import BaseLimiter + + +class AdaptiveRateLimiter(BaseLimiter): + + __slots__ = ( + "max_rate", + "min_rate", + "time_period", + "history", + "rate_history", + "moments", + "waiting", + "last_request_time", + "current_rate", + "_rate_per_sec", + "_level", + "_waiters", + "_loop", + "_current_time", + "_previous_count", + "_last_slope", + "_current_slope" + ) + + def __init__( + self, + limit: Limit + ): + super().__init__( + limit.max_requests, + limit.period + ) + + min_requests = limit.min_requests + if min_requests is None: + min_requests = math.ceil(self.max_rate * 0.1) + + self.initial_rate = math.ceil( + (self.max_rate - min_requests)/2 + ) + + self.min_rate = min_requests + + self.history = [] + self.rate_history = [] + self.moments = [] + self.waiting = [] + + + self._loop = asyncio.get_event_loop() + + self._current_time = self._loop.time() + self._previous_count = limit.max_requests + + self.last_request_time = self._loop.time() + self.current_rate = self.initial_rate + + def get_next_rate(self): + + current_time = self._loop.time() + + elapsed_time = current_time - self.last_request_time + self.history.append(elapsed_time) + + if len(self.history) > self.time_period: + self.history.pop(0) + + average_time = statistics.mean(self.history) + + if average_time > 1 / self.current_rate: + self.current_rate = max(self.min_rate, self.current_rate / 2) + else: + self.current_rate = min(self.max_rate, self.current_rate * 2) + + self.last_request_time = current_time + + return self.current_rate + + def has_capacity(self, amount: float = 1) -> bool: + + expected_rate = self.get_next_rate() + + if (self._loop.time() - self._current_time) > self.time_period: + self._current_time = math.floor( + self._loop.time()/self.time_period + ) * self.time_period + + self._previous_count = self._level + self._level = 0 + + self._rate_per_sec = ( + self._previous_count * ( + self.time_period - (self._loop.time() - self._current_time) + )/self.time_period + ) + (self._level + amount) + + if self._rate_per_sec < expected_rate: + + for fut in self._waiters.values(): + if not fut.done(): + fut.set_result(True) + break + + return self._rate_per_sec <= expected_rate diff --git a/hyperscale/distributed/rate_limiting/limiters/base_limiter.py b/hyperscale/distributed/rate_limiting/limiters/base_limiter.py new file mode 100644 index 0000000..8bd1506 --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/base_limiter.py @@ -0,0 +1,96 @@ +import asyncio +from contextlib import AbstractAsyncContextManager +from types import TracebackType +from typing import ( + Dict, + Optional, + Type +) + + + +class BaseLimiter(AbstractAsyncContextManager): + + __slots__ = ( + "max_rate", + "time_period", + "_rate_per_sec", + "_level", + "_waiters", + "_loop" + ) + + def __init__( + self, + max_rate: float, + time_period: float = 60, + reject_requests: bool=True + ) -> None: + self.max_rate = max_rate + self.time_period = time_period + self._rate_per_sec = max_rate / time_period + self._level = 0.0 + + self._waiters: Dict[asyncio.Task, asyncio.Future] = {} + self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + + self._reject_requests = reject_requests + + def has_capacity(self, amount: float = 1) -> bool: + raise NotImplementedError('Err. - has_capacity() is not implemented on BaseLimiter') + + async def acquire( + self, + amount: float = 1, + ): + + if amount > self.max_rate: + raise ValueError("Can't acquire more than the maximum capacity") + + + task = asyncio.current_task( + loop=self._loop + ) + + assert task is not None + + rejected = False + + if not self.has_capacity(amount) and self._reject_requests: + return True + + while not self.has_capacity(amount): + + fut = self._loop.create_future() + try: + + self._waiters[task] = fut + + await asyncio.wait_for( + asyncio.shield(fut), + timeout=(1 / self._rate_per_sec * amount) + ) + + except asyncio.TimeoutError: + pass + + fut.cancel() + + if self._reject_requests: + rejected = True + + self._waiters.pop(task, None) + self._level += amount + + return rejected + + async def __aenter__(self) -> None: + await self.acquire() + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + tb: Optional[TracebackType], + ) -> None: + return None \ No newline at end of file diff --git a/hyperscale/distributed/rate_limiting/limiters/cpu_adaptive.py b/hyperscale/distributed/rate_limiting/limiters/cpu_adaptive.py new file mode 100644 index 0000000..46aeb43 --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/cpu_adaptive.py @@ -0,0 +1,196 @@ +import asyncio +import math +import os +import statistics +from typing import List, Union + +import psutil + +from hyperscale.distributed.models.http import Limit + +from .base_limiter import BaseLimiter + + +class CPUAdaptiveLimiter(BaseLimiter): + + __slots__ = ( + "max_rate", + "time_period", + "_rate_per_sec", + "_level", + "_waiters", + "_loop", + "_last_check", + "_cpu_limit", + "_current_time", + "_previous_count", + "_current_cpu", + "_max_queue", + "_sample_task", + "_running", + "_process", + "_max_fast_backoff", + "_min_backoff", + "_history" + ) + + def __init__( + self, + limit: Limit + ) -> None: + super().__init__( + limit.max_requests, + limit.period, + reject_requests=limit.reject_requests + ) + + cpu_limit = limit.cpu_limit + if cpu_limit is None: + cpu_limit = 50 + + self._cpu_limit = cpu_limit + self._backoff = limit.backoff + self._min_backoff = self._backoff + self._max_fast_backoff = math.ceil(self._backoff * 10) + self._max_backoff = math.ceil(self._max_fast_backoff * 10) + self._last_check = self._loop.time() + self._current_time = self._loop.time() + self._previous_count = limit.max_requests + + self._history: List[float] = [] + + self._max_queue = limit.max_requests + self._sample_task: Union[asyncio.Task, None] = None + self._running = False + self._activate_limit = False + self._process = psutil.Process(os.getpid()) + + self._current_cpu = self._process.cpu_percent() + self._history.append(self._current_cpu) + + def has_capacity(self, amount: float = 1) -> bool: + + elapsed = self._loop.time() - self._last_check + + self._backoff = max( + self._backoff - (1 / self._rate_per_sec * elapsed), + self._min_backoff + ) + + if (self._loop.time() - self._current_time) > self.time_period: + self._current_time = math.floor( + self._loop.time()/self.time_period + ) * self.time_period + + self._previous_count = self._level + self._level = 0 + + self._rate_per_sec = ( + self._previous_count * ( + self.time_period - (self._loop.time() - self._current_time) + )/self.time_period + ) + (self._level + amount) + + if self._rate_per_sec < self.max_rate: + + for fut in self._waiters.values(): + if not fut.done(): + fut.set_result(True) + break + + self._last_check = self._loop.time() + + return self._rate_per_sec <= self.max_rate + + async def acquire( + self, + amount: float = 1, + ): + + if not self._running: + self._running = True + self._sample_task = asyncio.create_task( + self._sample_cpu() + ) + + if amount > self.max_rate: + raise ValueError("Can't acquire more than the maximum capacity") + + + task = asyncio.current_task( + loop=self._loop + ) + + assert task is not None + + rejected = False + + while not self.has_capacity(amount) or self._activate_limit: + + fut = self._loop.create_future() + try: + + self._waiters[task] = fut + + await asyncio.wait_for( + asyncio.shield(fut), + timeout=self._backoff + ) + + if self._activate_limit: + await asyncio.sleep(self._backoff) + self._max_fast_backoff = min( + self._max_fast_backoff + (1/math.sqrt(self._rate_per_sec)), + self._max_backoff + ) + + except asyncio.TimeoutError: + pass + + fut.cancel() + + rejected = True + + self._backoff = min(self._backoff * 2, self._max_fast_backoff) + self._waiters.pop(task, None) + self._level += amount + + return rejected + + async def _sample_cpu(self): + while self._running: + + self._current_cpu = self._process.cpu_percent() + self._history.append(self._current_cpu) + + elapsed = self._loop.time() - self._last_check + + if elapsed > self.time_period: + self._history.pop(0) + + if self._current_cpu >= self._cpu_limit: + self._activate_limit = True + + elif statistics.median(self._history) < self._cpu_limit: + self._activate_limit = False + self._max_fast_backoff = max( + self._max_fast_backoff - (1/self._rate_per_sec), + self._min_backoff + ) + + await asyncio.sleep(0.1) + + async def close(self): + self._running = False + + self._sample_task.cancel() + if not self._sample_task.cancelled(): + try: + + await self._sample_task + + except ( + asyncio.CancelledError, + asyncio.InvalidStateError + ): + pass \ No newline at end of file diff --git a/hyperscale/distributed/rate_limiting/limiters/leaky_bucket_limiter.py b/hyperscale/distributed/rate_limiting/limiters/leaky_bucket_limiter.py new file mode 100644 index 0000000..892c15c --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/leaky_bucket_limiter.py @@ -0,0 +1,52 @@ +from hyperscale.distributed.models.http import Limit + +from .base_limiter import BaseLimiter + + +class LeakyBucketLimiter(BaseLimiter): + + __slots__ = ( + "max_rate", + "time_period", + "_rate_per_sec", + "_level", + "_waiters", + "_loop", + "_last_check" + ) + + def __init__( + self, + limit: Limit + ) -> None: + super().__init__( + limit.max_requests, + limit.period, + reject_requests=limit.reject_requests + ) + + self._level = 0.0 + self._last_check = 0.0 + + def _leak(self) -> None: + + if self._level: + elapsed = self._loop.time() - self._last_check + decrement = elapsed * self._rate_per_sec + self._level = max(self._level - decrement, 0) + + self._last_check = self._loop.time() + + def has_capacity(self, amount: float = 1) -> bool: + + self._leak() + requested = self._level + amount + + if requested < self.max_rate: + + for fut in self._waiters.values(): + if not fut.done(): + fut.set_result(True) + break + + return requested <= self.max_rate diff --git a/hyperscale/distributed/rate_limiting/limiters/resource_adaptive_limiter.py b/hyperscale/distributed/rate_limiting/limiters/resource_adaptive_limiter.py new file mode 100644 index 0000000..5cb19fa --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/resource_adaptive_limiter.py @@ -0,0 +1,181 @@ +import asyncio +import math +import os +import statistics +from typing import List, Union + +import psutil + +from hyperscale.distributed.models.http import Limit + +from .base_limiter import BaseLimiter + + +class ResourceAdaptiveLimiter(BaseLimiter): + + __slots__ = ( + "max_rate", + "time_period", + "_rate_per_sec", + "_level", + "_waiters", + "_loop", + "_last_check", + "_cpu_limit", + "_current_time", + "_previous_count", + "_current_cpu", + "_max_queue", + "_sample_task", + "_running", + "_process", + "_max_fast_backoff", + "_min_backoff", + "_cpu_history", + "_memory_history", + "_memory_limit", + "_current_memory" + ) + + def __init__( + self, + limit: Limit + ) -> None: + super().__init__( + limit.max_requests, + limit.period, + reject_requests=limit.reject_requests + ) + + cpu_limit = limit.cpu_limit + if cpu_limit is None: + cpu_limit = 50 + + self._cpu_limit = cpu_limit + self._backoff = limit.backoff + self._min_backoff = self._backoff + self._max_fast_backoff = math.ceil(self._backoff * 10) + self._max_backoff = math.ceil(self._max_fast_backoff * 10) + self._last_check = self._loop.time() + self._current_time = self._loop.time() + self._previous_count = limit.max_requests + + self._memory_limit = limit.memory + + self._cpu_history: List[float] = [] + self._memory_history: List[float] = [] + + self._max_queue = limit.max_requests + self._sample_task: Union[asyncio.Task, None] = None + self._running = False + self._activate_limit = False + self._process = psutil.Process(os.getpid()) + + self._current_cpu = self._process.cpu_percent() + self._current_memory = self._get_memory() + + self._cpu_history.append(self._current_cpu) + + async def acquire( + self, + amount: float = 1, + ): + + if not self._running: + self._running = True + self._sample_task = asyncio.create_task( + self._sample_cpu() + ) + + if amount > self.max_rate: + raise ValueError("Can't acquire more than the maximum capacity") + + + task = asyncio.current_task( + loop=self._loop + ) + + assert task is not None + + rejected = False + + while self._activate_limit: + + fut = self._loop.create_future() + try: + + self._waiters[task] = fut + + await asyncio.wait_for( + asyncio.shield(fut), + timeout=self._backoff + ) + + self._max_fast_backoff = min( + self._max_fast_backoff + (1/math.sqrt(self._rate_per_sec)), + self._max_backoff + ) + + except asyncio.TimeoutError: + pass + + fut.cancel() + + rejected = True + + self._backoff = min(self._backoff * 2, self._max_fast_backoff) + self._waiters.pop(task, None) + self._level += amount + + return rejected + + async def _sample_cpu(self): + while self._running: + + + self._current_cpu = self._process.cpu_percent() + self._current_memory = self._get_memory() + + self._cpu_history.append(self._current_cpu) + self._memory_history.append(self._current_memory) + + elapsed = self._loop.time() - self._last_check + + if elapsed > self.time_period: + self._cpu_history.pop(0) + + + median_cpu_usage = statistics.median(self._cpu_history) + median_memory_usage = statistics.median(self._memory_history) + + + if self._current_cpu >= self._cpu_limit or self._current_memory >= self._memory_limit: + self._activate_limit = True + + elif median_cpu_usage < self._cpu_limit and median_memory_usage < self._memory_limit: + self._activate_limit = False + self._max_fast_backoff = max( + self._max_fast_backoff - (1/self._rate_per_sec), + self._min_backoff + ) + + + await asyncio.sleep(0.1) + + def _get_memory(self): + return self._process.memory_info().rss / 1024 ** 2 + + async def close(self): + self._running = False + + self._sample_task.cancel() + if not self._sample_task.cancelled(): + try: + + await self._sample_task + + except ( + asyncio.CancelledError, + asyncio.InvalidStateError + ): + pass \ No newline at end of file diff --git a/hyperscale/distributed/rate_limiting/limiters/sliding_window_limiter.py b/hyperscale/distributed/rate_limiting/limiters/sliding_window_limiter.py new file mode 100644 index 0000000..94ad0da --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/sliding_window_limiter.py @@ -0,0 +1,58 @@ +import math + +from hyperscale.distributed.models.http import Limit + +from .base_limiter import BaseLimiter + + +class SlidingWindowLimiter(BaseLimiter): + + __slots__ = ( + "max_rate", + "time_period", + "_rate_per_sec", + "_level", + "_waiters", + "_loop", + "_current_time", + "_previous_count" + ) + + def __init__( + self, + limit: Limit + ) -> None: + + super().__init__( + limit.max_requests, + limit.period, + reject_requests=limit.reject_requests + ) + + self._current_time = self._loop.time() + self._previous_count = limit.max_requests + + def has_capacity(self, amount: float = 1) -> bool: + + if (self._loop.time() - self._current_time) > self.time_period: + self._current_time = math.floor( + self._loop.time()/self.time_period + ) * self.time_period + + self._previous_count = self._level + self._level = 0 + + self._rate_per_sec = ( + self._previous_count * ( + self.time_period - (self._loop.time() - self._current_time) + )/self.time_period + ) + (self._level + amount) + + if self._rate_per_sec < self.max_rate: + + for fut in self._waiters.values(): + if not fut.done(): + fut.set_result(True) + break + + return self._rate_per_sec <= self.max_rate \ No newline at end of file diff --git a/hyperscale/distributed/rate_limiting/limiters/token_bucket_limiter.py b/hyperscale/distributed/rate_limiting/limiters/token_bucket_limiter.py new file mode 100644 index 0000000..a0e8f7b --- /dev/null +++ b/hyperscale/distributed/rate_limiting/limiters/token_bucket_limiter.py @@ -0,0 +1,123 @@ +import asyncio +from types import TracebackType +from typing import Optional, Type + +from hyperscale.distributed.models.http import HTTPMessage, Limit, Request + +from .base_limiter import BaseLimiter + + +class TokenBucketLimiter(BaseLimiter): + + __slots__ = ( + "max_rate", + "time_period", + "_rate_per_sec", + "_level", + "_waiters", + "_loop", + "_last_check" + ) + + def __init__( + self, + limit: Limit + ) -> None: + super().__init__( + limit.max_requests, + limit.period, + reject_requests=limit.reject_requests + ) + + self._level = limit.max_requests + self._last_check = self._loop.time() + + def has_capacity(self, amount: float = 1) -> bool: + + if self._level < self.max_rate: + current_time = self._loop.time() + delta = self._rate_per_sec * (current_time - self._last_check) + self._level = min(self.max_rate, self._level + delta) + self._last_check = current_time + + requested_amount = self._level - amount + if requested_amount > 0 or self._level >= self.max_rate: + for fut in self._waiters.values(): + if not fut.done(): + fut.set_result(True) + break + + + return amount < self._level + + async def acquire( + self, + amount: float = 1 + ): + + if amount > self.max_rate: + raise ValueError("Can't acquire more than the maximum capacity") + + + task = asyncio.current_task( + loop=self._loop + ) + + assert task is not None + + rejected = False + + if not self.has_capacity(amount) and self._reject_requests: + return True + + while not self.has_capacity(amount): + + fut = self._loop.create_future() + + try: + + self._waiters[task] = fut + await asyncio.wait_for( + asyncio.shield(fut), + timeout=(1 / self._rate_per_sec * amount) + ) + + except asyncio.TimeoutError: + pass + + fut.cancel() + if self._reject_requests: + rejected = True + + self._waiters.pop(task, None) + self._level -= amount + + return rejected + + async def reject( + self, + request: Request, + transport: asyncio.Transport + ): + if transport.is_closing() is False: + + server_error_respnse = HTTPMessage( + path=request.path, + status=429, + error='Too Many Requests', + method=request.method + ) + + transport.write(server_error_respnse.prepare_response()) + + + async def __aenter__(self) -> None: + await self.acquire() + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + tb: Optional[TracebackType], + ) -> None: + return None \ No newline at end of file diff --git a/hyperscale/distributed/replication/__init__.py b/hyperscale/distributed/replication/__init__.py new file mode 100644 index 0000000..fbb62da --- /dev/null +++ b/hyperscale/distributed/replication/__init__.py @@ -0,0 +1 @@ +from .replication_controller import ReplicationController \ No newline at end of file diff --git a/hyperscale/distributed/replication/constants.py b/hyperscale/distributed/replication/constants.py new file mode 100644 index 0000000..0214c83 --- /dev/null +++ b/hyperscale/distributed/replication/constants.py @@ -0,0 +1 @@ +FLEXIBLE_PAXOS_QUORUM = 1 / 2 \ No newline at end of file diff --git a/hyperscale/distributed/replication/errors/__init__.py b/hyperscale/distributed/replication/errors/__init__.py new file mode 100644 index 0000000..329acf7 --- /dev/null +++ b/hyperscale/distributed/replication/errors/__init__.py @@ -0,0 +1 @@ +from .invalid_term_error import InvalidTermError \ No newline at end of file diff --git a/hyperscale/distributed/replication/errors/invalid_term_error.py b/hyperscale/distributed/replication/errors/invalid_term_error.py new file mode 100644 index 0000000..7e4befa --- /dev/null +++ b/hyperscale/distributed/replication/errors/invalid_term_error.py @@ -0,0 +1,11 @@ +class InvalidTermError(Exception): + + def __init__( + self, + entry_id: int, + entry_term: int, + expected_term: int + ) -> None: + super().__init__( + f'Log entry - {entry_id} - provided invalid term - {entry_term} - Expected term - {expected_term}' + ) \ No newline at end of file diff --git a/hyperscale/distributed/replication/log_queue.py b/hyperscale/distributed/replication/log_queue.py new file mode 100644 index 0000000..5423f70 --- /dev/null +++ b/hyperscale/distributed/replication/log_queue.py @@ -0,0 +1,236 @@ +import time +from typing import Dict, List, Union + +from hyperscale.distributed.env import ReplicationEnv, load_env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.models.raft.logs import Entry +from hyperscale.distributed.snowflake.snowflake_generator import Snowflake + +from .errors import InvalidTermError + + +class LogQueue: + + def __init__(self) -> None: + + env = load_env(ReplicationEnv) + + self.logs: List[Entry] = [] + self._timestamps: List[float] = [] + self._commits: List[float] = [] + self.timestamp_index_map: Dict[float, int] = {} + self._term = 0 + self.size = 0 + self.commit_index = 0 + self._last_timestamp = 0 + self._last_commit_timestamp = 0 + self._prune_max_age = TimeParser( + env.MERCURY_SYNC_RAFT_LOGS_PRUNE_MAX_AGE + ).time + self._prune_max_count = env.MERCURY_SYNC_RAFT_LOGS_PRUNE_MAX_COUNT + + @property + def last_timestamp(self): + + if len(self._timestamps) > 0: + return self._timestamps[-1] + + else: + return 0 + + def latest(self): + + if len(self._commits) > 0: + latest_commit_timestamp = self._commits[-1] + latest_index = self.timestamp_index_map[latest_commit_timestamp] + + else: + latest_index = 0 + + return self.logs[latest_index:] + + def commit(self): + + if len(self._timestamps) > 0: + self._last_commit_timestamp = self._timestamps[-1] + self._commits.append(self._last_commit_timestamp) + + def get(self, shard_id: int): + flake = Snowflake.parse(shard_id) + + index = self.timestamp_index_map.get(flake.timestamp, -1) + + if self.size < 1: + return None + + return self.logs[index] + + def filter(self, key: str): + return [ + entry for entry in self.logs if entry.key == key + ] + + def update( + self, + entries: List[Entry] + ) -> Union[Exception, None]: + + last_entry = entries[-1] + + last_entry_id = Snowflake.parse(last_entry.entry_id) + last_entry_term = last_entry.term + + if last_entry_term < self._term: + return InvalidTermError( + last_entry_id, + last_entry_term, + self._term + ) + + # Did we miss an election or havent caught on to a leader change? let's update! + elif last_entry_term > self._term: + self._term = last_entry_term + + if self.size < 1: + + for idx, entry in enumerate(entries): + + entry_id = Snowflake.parse(entry.entry_id) + entry_timestamp = entry_id.timestamp + + self.timestamp_index_map[entry_timestamp] = idx + self._timestamps.append(entry_timestamp) + self.logs.append(entry) + + self.size += 1 + + else: + + for entry in entries: + + if len(self._timestamps) > 0: + last_queue_timestamp = self._timestamps[-1] + + else: + last_queue_timestamp = 0 + + next_index = self.size + + entry_id = Snowflake.parse(entry.entry_id) + entry_timestamp = entry_id.timestamp + + # We've received a missing entry so insert it in order.. + if entry_timestamp < last_queue_timestamp: + + # The insert index is at the index of last timestamp less + # than the entry timestamp + 1. + # + # I.e. if the last idx < timestamp is 4 we insert at 5. + # + + previous_timestamps = [ + idx for idx, timestamp in enumerate(self._timestamps) if timestamp < entry_timestamp + ] + + if len(previous_timestamps) > 0: + + last_previous_timestamp_idx = previous_timestamps[-1] + + insert_index: int = last_previous_timestamp_idx + 1 + + next_logs = self.logs[insert_index:] + next_timestamps = self._timestamps[insert_index:] + + previous_logs = self.logs[:insert_index] + previous_timestamps = self._timestamps[:insert_index] + + else: + + insert_index = 0 + + next_logs = self.logs + next_timestamps = self._timestamps + + previous_logs = [] + previous_timestamps = [] + + previous_logs.append(entry) + previous_timestamps.append(entry_timestamp) + + previous_logs.extend(next_logs) + previous_timestamps.extend(next_timestamps) + + self.timestamp_index_map[entry_timestamp] = insert_index + + for timestamp in next_timestamps: + self.timestamp_index_map[timestamp] += 1 + + self.logs = previous_logs + self._timestamps = previous_timestamps + + self.size += 1 + + # We've received entries to append + elif entry_timestamp > last_queue_timestamp: + + self.logs.append(entry) + self._timestamps.append(entry_timestamp) + + self.timestamp_index_map[entry_timestamp] = next_index + self.size += 1 + + # We've receive an entry to replace. + else: + + next_index = self.timestamp_index_map[entry_timestamp] + + self.logs[next_index] = entry + self._timestamps[next_index] = entry_timestamp + + + def prune(self): + + current_time = int(time.time() * 1000) + + # Get the number of timestamps older than our max prune age + count = len([ + timestamp for timestamp in self._timestamps if current_time - timestamp > self._prune_max_age + ]) + + # If greater than our max prune count, set prune count as max prune count. + if count > self._prune_max_count: + count = self._prune_max_count + + if count >= self.size: + + self.logs = [] + self._timestamps = [] + self.timestamp_index_map = {} + self._commits = [] + + self.size = 0 + self.commit_index = 0 + self._last_timestamp = 0 + self._last_commit_timestamp = 0 + self.size = 0 + + else: + + pruned_timestamps = self._timestamps[:count] + + for timestamp in pruned_timestamps: + if self.timestamp_index_map.get(timestamp): + del self.timestamp_index_map[timestamp] + + self.logs = self.logs[count:] + self._timestamps = self._timestamps[count:] + + self._commits = [ + commit for commit in self._commits if commit > self._timestamps[0] + ] + + self.size -= count + + + + diff --git a/hyperscale/distributed/replication/replication_controller.py b/hyperscale/distributed/replication/replication_controller.py new file mode 100644 index 0000000..ea72487 --- /dev/null +++ b/hyperscale/distributed/replication/replication_controller.py @@ -0,0 +1,1187 @@ +import asyncio +import random +import time +from collections import defaultdict, deque +from typing import Any, Deque, Dict, List, Optional, Tuple, Union + +from hyperscale.distributed.env import Env, ReplicationEnv, load_env +from hyperscale.distributed.env.time_parser import TimeParser +from hyperscale.distributed.hooks.client_hook import client +from hyperscale.distributed.hooks.server_hook import server +from hyperscale.distributed.models.raft import ( + ElectionState, + HealthCheck, + RaftMessage, + VoteResult, +) +from hyperscale.distributed.models.raft.logs import Entry, NodeState +from hyperscale.distributed.monitoring import Monitor +from hyperscale.distributed.snowflake.snowflake_generator import ( + Snowflake, + SnowflakeGenerator, +) +from hyperscale.distributed.types import Call +from hyperscale.logging import HyperscaleLogger, logging_manager +from hyperscale.tools.helpers import cancel + +from .log_queue import LogQueue + + +class ReplicationController(Monitor): + + def __init__( + self, + host: str, + port: int, + env: Optional[Env]=None, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + logs_directory: Optional[str]=None, + workers: int=0, + ) -> None: + + if env is None: + env = load_env(Env) + + if logs_directory is None: + logs_directory = env.MERCURY_SYNC_LOGS_DIRECTORY + + replication_env = load_env(ReplicationEnv) + + super().__init__( + host, + port, + env=env, + cert_path=cert_path, + key_path=key_path, + workers=workers, + logs_directory=logs_directory + ) + + self._models = [ + HealthCheck, + RaftMessage + ] + + self._term_number = 0 + self._term_votes = defaultdict( + lambda: defaultdict( + lambda: 0 + ) + ) + + self._max_election_timeout = TimeParser( + replication_env.MERCURY_SYNC_RAFT_ELECTION_MAX_TIMEOUT + ).time + + self._min_election_timeout = max( + self._max_election_timeout * 0.5, + 1 + ) + + self._election_poll_interval = TimeParser( + replication_env.MERCURY_SYNC_RAFT_ELECTION_POLL_INTERVAL + ).time + + self._logs_update_poll_interval = TimeParser( + replication_env.MERCURY_SYNC_RAFT_LOGS_UPDATE_POLL_INTERVAL + ).time + + self._election_status = ElectionState.READY + self._raft_node_status = NodeState.FOLLOWER + self._active_election_waiter: Union[asyncio.Future, None] = None + self._latest_election: Dict[int, int] = {} + self._term_leaders: List[Tuple[str, int]] = [] + + self._running = False + + self._logs = LogQueue() + self._previous_entry_index = 0 + self._term_number = 0 + + self._raft_monitor_task: Union[asyncio.Task, None] = None + self._tasks_queue: Deque[asyncio.Task] = deque() + self._entry_id_generator = SnowflakeGenerator(self._instance_id) + + logging_manager.logfiles_directory = logs_directory + logging_manager.update_log_level( + env.MERCURY_SYNC_LOG_LEVEL + ) + + self._logger = HyperscaleLogger() + self._logger.initialize() + + + self._election_poll_interval = TimeParser( + replication_env.MERCURY_SYNC_RAFT_ELECTION_POLL_INTERVAL + ).time + + self._cleanup_interval = TimeParser( + env.MERCURY_SYNC_CLEANUP_INTERVAL + ).time + + self.registration_timeout = TimeParser( + replication_env.MERCURY_SYNC_RAFT_REGISTRATION_TIMEOUT + ).time + + self._pending_election_waiter: Union[asyncio.Future, None] = None + + self._election_timeout = random.uniform( + self._min_election_timeout, + self._max_election_timeout + ) + + self._raft_cleanup_task: Union[asyncio.Future, None] = None + self._election_task: Union[asyncio.Task, None] = None + self._active_election = False + + async def start(self): + + await self._logger.filesystem.aio.create_logfile(f'hyperscale.distributed.{self._instance_id}.log') + self._logger.filesystem.create_filelogger(f'hyperscale.distributed.{self._instance_id}.log') + + await self._logger.distributed.aio.info(f'Starting server for node - {self.host}:{self.port} - with id - {self._instance_id}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Starting server for node - {self.host}:{self.port} - with id - {self._instance_id}') + + await self.start_server() + + self._instance_ids[(self.host, self.port)] = Snowflake.parse( + self._entry_id_generator.generate() + ).instance + + boot_wait = random.uniform(0.1, self.boot_wait * self._initial_expected_nodes) + await asyncio.sleep(boot_wait) + + async def register( + self, + host: str, + port: int + ): + + await self._logger.distributed.aio.info(f'Initializing node - {self.host}:{self.port} - with id - {self._instance_id}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Initializing node - {self.host}:{self.port} - with id - {self._instance_id}') + + self.bootstrap_host = host + self.bootstrap_port = port + self.status = 'healthy' + + await self._register_initial_node() + await self._run_registration() + + self._running = True + + self._healthcheck_task = asyncio.create_task( + self.start_health_monitor() + ) + + self._cleanup_task = asyncio.create_task( + self.cleanup_pending_checks() + ) + + self._udp_sync_task = asyncio.create_task( + self._run_udp_state_sync() + ) + + self._tcp_sync_task = asyncio.create_task( + self._run_tcp_state_sync() + ) + + boot_wait = random.uniform(0.1, self.boot_wait * self._initial_expected_nodes) + await asyncio.sleep(boot_wait) + + if self._term_number == 0: + self._election_status = ElectionState.ACTIVE + await self.run_election() + + self._raft_cleanup_task = asyncio.create_task( + self._cleanup_pending_raft_tasks() + ) + + self._raft_monitor_task = asyncio.create_task( + self._run_raft_monitor() + ) + + self.status = 'healthy' + + async def _run_registration(self): + + last_registered_count = -1 + poll_timeout = self.registration_timeout * self._initial_expected_nodes + + while self._check_all_nodes_registered() is False: + + monitors = [ + address for address in self._node_statuses.keys() + ] + + active_nodes_count = len(monitors) + registered_count = self._calculate_all_registered_nodes() + + if registered_count > last_registered_count: + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - reporting - {registered_count}/{self._initial_expected_nodes} - as fully registered') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - reporting - {registered_count}/{self._initial_expected_nodes} - as fully registered') + + last_registered_count = registered_count + + if active_nodes_count > 0: + + for host, port in monitors: + self._tasks_queue.append( + asyncio.create_task( + asyncio.wait_for( + self._submit_registration( + host, + port + ), + timeout=poll_timeout + ) + ) + ) + + await asyncio.sleep(self._poll_interval) + + await asyncio.sleep(self._poll_interval) + + registered_count = self._calculate_all_registered_nodes() + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - reporting - {registered_count}/{self._initial_expected_nodes} - as fully registered') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - reporting - {registered_count}/{self._initial_expected_nodes} - as fully registered') + + def _calculate_all_registered_nodes(self) -> int: + self._registered_counts[(self.host, self.port)] = len(self._instance_ids) + return len([ + count for count in self._registered_counts.values() if count == self._initial_expected_nodes + ]) + + def _check_all_nodes_registered(self) -> bool: + return self._calculate_all_registered_nodes() == self._initial_expected_nodes + + async def _submit_registration( + self, + host: str, + port: int + ): + shard_id, response = await self.submit_registration( + host, + port + ) + + if isinstance(response, HealthCheck): + source_host = response.source_host + source_port = response.source_port + + not_self = self._check_is_not_self( + source_host, + source_port + ) + + self._instance_ids[(source_host, source_port)] = Snowflake.parse(shard_id).instance + + if not_self: + self._node_statuses[(source_host, source_port)] = 'healthy' + + self._registered_counts[(source_host, source_port)] = max( + response.registered_count, + self._registered_counts[(source_host, source_port)] + ) + + @server() + async def receive_vote_request( + self, + shard_id: int, + raft_message: RaftMessage + ) -> Call[RaftMessage]: + + source_host = raft_message.source_host + source_port = raft_message.source_port + + term_number = raft_message.term_number + + elected_host: Union[str, None] = None + elected_port: Union[int, None] = None + + if term_number > self._term_number: + # The requesting node is ahead. They're elected the leader by default. + elected_host = source_host + elected_port = source_port + + elif term_number == self._term_number and self._raft_node_status != NodeState.LEADER: + # The term numbers match, we can choose a candidate. + + elected_host, elected_port = self._get_max_instance_id() + + else: + + leader_host, leader_port = self._term_leaders[-1] + + return RaftMessage( + host=source_host, + port=source_port, + source_host=self.host, + source_port=self.port, + elected_leader=( + leader_host, + leader_port + ), + status=self.status, + error='Election request term cannot be less than current term.', + vote_result=VoteResult.REJECTED, + raft_node_status=self._raft_node_status, + term_number=self._term_number + ) + + vote_result = VoteResult.REJECTED + + if elected_host == source_host and elected_port == source_port: + vote_result = VoteResult.ACCEPTED + + return RaftMessage( + host=source_host, + port=source_port, + source_host=self.host, + source_port=self.port, + elected_leader=( + elected_host, + elected_port + ), + status=self.status, + vote_result=vote_result, + raft_node_status=self._raft_node_status, + term_number=term_number + ) + + @server() + async def receive_log_update( + self, + shard_id: int, + message: RaftMessage + ) -> Call[RaftMessage]: + + entries_count = len(message.entries) + + if entries_count < 1: + return RaftMessage( + host=message.host, + port=message.port, + source_host=self.host, + source_port=self.port, + status=self.status, + term_number=self._term_number, + election_status=self._election_status, + raft_node_status=self._raft_node_status + ) + + # We can use the Snowflake ID to sort since all records come from the + # leader. + entries: List[Entry] = list( + sorted( + message.entries, + key=lambda entry: Snowflake.parse( + entry.entry_id + ).timestamp + ) + ) + + last_entry = entries[-1] + + leader_host = last_entry.leader_host + leader_port = last_entry.leader_port + + try: + + + if message.term_number > self._term_number: + + self._tasks_queue.append( + asyncio.create_task( + self._cancel_election(message) + ) + ) + + amount_behind = max( + message.term_number - self._term_number - 1, + 0 + ) + + last_entry = entries[-1] + + leader_host = last_entry.leader_host + leader_port = last_entry.leader_port + + self._term_number = message.term_number + + for _ in range(amount_behind): + self._term_leaders.append(( + None, + None + )) + + await self._logger.distributed.aio.info(f'Term number for source - {self.host}:{self.port} - was updated to - {self._term_number} - and leader was updated to - {leader_host}:{leader_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Term number for source - {self.host}:{self.port} - was updated to - {self._term_number} - and leader was updated to - {leader_host}:{leader_port}') + + + self._term_leaders.append(( + leader_host, + leader_port + )) + + self._election_status = ElectionState.READY + self._raft_node_status = NodeState.FOLLOWER + + return RaftMessage( + host=message.source_host, + port=message.source_port, + source_host=self.host, + source_port=self.port, + elected_leader=( + leader_host, + leader_port + ), + status=self.status, + error='Election request term cannot be less than current term.', + vote_result=VoteResult.REJECTED, + raft_node_status=self._raft_node_status, + term_number=self._term_number + ) + + + source_host = message.source_host + source_port = message.source_port + + if message.failed_node and self._suspect_tasks.get( + message.failed_node + ): + + node_host, node_port = message.failed_node + + self._tasks_queue.append( + asyncio.create_task( + self._cancel_suspicion_probe( + node_host, + node_port + ) + ) + ) + + await self._logger.distributed.aio.debug(f'Node - {node_host}:{node_port} - submitted healthy status to source - {self.host}:{self.port} - and is no longer suspect') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {node_host}:{node_port} - submitted healthy status to source - {self.host}:{self.port} - and is no longer suspect') + + + if self._suspect_tasks.get(( + source_host, + source_port + )): + self._tasks_queue.append( + asyncio.create_task( + self._cancel_suspicion_probe( + source_host, + source_port + ) + ) + ) + + await self._logger.distributed.aio.debug(f'Node - {source_host}:{source_port} - submitted healthy status to source - {self.host}:{self.port} - and is no longer suspect') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {source_host}:{source_port} - submitted healthy status to source - {self.host}:{self.port} - and is no longer suspect') + + error = self._logs.update(entries) + + self._local_health_multipliers[(source_host, source_port)] = self._reduce_health_multiplier( + source_host, + source_port + ) + + if isinstance(error, Exception): + + return RaftMessage( + host=message.source_host, + port=message.source_port, + source_host=self.host, + source_port=self.port, + status=self.status, + raft_node_status=self._raft_node_status, + error=str(error), + elected_leader=( + leader_host, + leader_port + ), + term_number=self._term_number + ) + + return RaftMessage( + host=message.source_host, + port=message.source_port, + source_host=self.host, + source_port=self.port, + status=self.status, + elected_leader=( + leader_host, + leader_port + ), + term_number=self._term_number, + raft_node_status=self._raft_node_status, + received_timestamp=self._logs.last_timestamp + ) + + except Exception as rpc_error: + return RaftMessage( + host=message.source_host, + port=message.source_port, + source_host=self.host, + source_port=self.port, + status=self.status, + raft_node_status=self._raft_node_status, + error=str(rpc_error), + elected_leader=( + leader_host, + leader_port + ), + term_number=self._term_number + ) + + @server() + async def receive_forwarded_entries( + self, + shard_id: int, + message: RaftMessage + ) -> Call[RaftMessage]: + + if self._raft_node_status == NodeState.LEADER and message.entries: + + entries = message.entries + + entries.append( + Entry.from_data( + entry_id=self._entry_id_generator.generate(), + leader_host=self.host, + leader_port=self.port, + term=self._term_number, + data={ + 'key': 'logs_update', + 'value': f'Node - {self.host}:{self.port} - submitted log update', + } + ) + ) + + self._tasks_queue.append( + asyncio.create_task( + self._submit_logs_to_members(entries) + ) + ) + + return RaftMessage( + host=message.host, + port=message.port, + source_host=self.host, + source_port=self.port, + status=self.status, + term_number=self._term_number, + raft_node_status=self._raft_node_status, + received_timestamp=self._logs.last_timestamp + ) + + @server() + async def receive_failure_notification( + self, + shard_id: int, + message: RaftMessage + ) -> Call[RaftMessage]: + + try: + failed_node = message.failed_node + host, port = failed_node + + not_self = self._check_is_not_self( + host, + port + ) + + + if not_self and self._election_status == ElectionState.READY and failed_node not in self.failed_nodes: + + + self.failed_nodes.append(( + host, + port, + time.monotonic() + )) + + self._node_statuses[failed_node] = 'failed' + + self._election_status = ElectionState.ACTIVE + + self._tasks_queue.append( + asyncio.create_task( + self.run_election( + failed_node=failed_node + ) + ) + ) + + return RaftMessage( + host=message.host, + port=message.port, + source_host=self.host, + source_port=self.port, + status=self.status, + term_number=self._term_number, + raft_node_status=self._raft_node_status, + received_timestamp=self._logs.last_timestamp + ) + + except Exception: + pass + + @client('receive_vote_request') + async def request_vote( + self, + host: str, + port: int + ) -> Call[RaftMessage]: + return RaftMessage( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + status=self.status, + term_number=self._term_number, + raft_node_status=self._raft_node_status + ) + + @client('receive_log_update') + async def submit_log_update( + self, + host: str, + port: int, + entries: List[Entry], + failed_node: Optional[Tuple[str, int]]=None + ) -> Call[RaftMessage]: + return RaftMessage( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + status=self.status, + term_number=self._term_number, + raft_node_status=self._raft_node_status, + failed_node=failed_node, + entries=entries + ) + + @client('receive_forwarded_entries') + async def forward_entries_to_leader( + self, + host: str, + port: int, + entries: List[Entry] + ) -> Call[RaftMessage]: + return RaftMessage( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + status=self.status, + term_number=self._term_number, + raft_node_status=self._raft_node_status, + entries=entries + ) + + @client('receive_failure_notification') + async def submit_failure_notification( + self, + host: str, + port: int, + failed_node: Tuple[str, int] + ) -> Call[RaftMessage]: + return RaftMessage( + host=host, + port=port, + source_host=self.host, + source_port=self.port, + status=self.status, + term_number=self._term_number, + raft_node_status=self._raft_node_status, + failed_node=failed_node + ) + + async def _start_suspect_monitor(self): + suspect_host, suspect_port = await super()._start_suspect_monitor() + + node_status = self._node_statuses.get((suspect_host, suspect_port)) + + failed_node = (suspect_host, suspect_port) + + if self._election_status == ElectionState.READY and node_status == 'failed' and failed_node not in self.failed_nodes: + + self.failed_nodes.append(( + suspect_host, + suspect_port, + time.monotonic() + )) + + self._election_status = ElectionState.ACTIVE + + await self.notify_of_failed_node(failed_node=failed_node) + await self.run_election(failed_node=failed_node) + + async def push_entries( + self, + entries: List[Dict[str, Any]] + ) -> List[RaftMessage]: + + entries.append({ + 'key': 'logs_update', + 'value': f'Node - {self.host}:{self.port} - submitted log update', + }) + + entries = self._convert_data_to_entries(entries) + entries_count = len(entries) + + if self._raft_node_status == NodeState.LEADER: + results = await self._submit_logs_to_members(entries) + + results_count = len(results) + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - pushed - {entries_count} - entries to - {results_count} - members') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - pushed - {entries_count} - entries to - {results_count} - members') + + return results + + else: + + try: + + current_leader_host, current_leader_port = self._term_leaders[-1] + + result = await asyncio.wait_for( + self.forward_entries_to_leader( + current_leader_host, + current_leader_port, + entries + ), + timeout=self._calculate_current_timeout( + current_leader_host, + current_leader_port + ) + ) + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - forwarded - {entries_count} - entries to leader at - {current_leader_host}:{current_leader_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - forwarded - {entries_count} - entries to leader at - {current_leader_host}:{current_leader_port}') + + return [ + result + ] + + except Exception as forward_error: + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - encountered error - {str(forward_error)} - out forwarding - {entries_count} - entries to leader at - {current_leader_host}:{current_leader_port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - encountered error - {str(forward_error)} - out forwarding - {entries_count} - entries to leader at - {current_leader_host}:{current_leader_port}') + + return [ + RaftMessage( + host=current_leader_host, + port=current_leader_port, + source_host=self.host, + source_port=self.port, + elected_leader=( + current_leader_host, + current_leader_port + ), + error=str(forward_error), + raft_node_status=self._raft_node_status, + status=self.status, + term_number=self._term_number + ) + ] + + def submit_entries( + self, + entries: List[Dict[str, Any]] + ): + self._tasks_queue.append( + asyncio.create_task( + self.push_entries(entries) + ) + ) + + def _convert_data_to_entries( + self, + entries: List[Dict[str, Any]] + ) -> List[Entry]: + + current_leader_host, current_leader_port = self._term_leaders[-1] + + entries = [ + Entry.from_data( + self._entry_id_generator.generate(), + current_leader_host, + current_leader_port, + self._term_number, + entry + ) for entry in entries + ] + + return entries + + def _get_max_instance_id(self): + + nodes = [ + address for address, status in self._node_statuses.items() if status == 'healthy' + ] + + nodes.append(( + self.host, + self.port + )) + + instance_address_id_pairs = list( + sorted( + nodes, + key=lambda instance: self._instance_ids.get( + instance, + self._instance_id + ) + ) + ) + + if len(instance_address_id_pairs) > 0: + + max_instance = instance_address_id_pairs[-1] + elected_host, elected_port = max_instance + + else: + + elected_host = self.host + elected_port = self.port + + return elected_host, elected_port + + async def _cancel_election( + self, + message: RaftMessage + ): + self._election_status = ElectionState.READY + self._term_number = message.term_number + + if self._election_task: + await cancel(self._election_task) + self._election_task = None + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - election for term - {self._term_number} - was cancelled due to leader reporting for term') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - election for term - {self._term_number} - was cancelled due to leader reporting for term') + + async def _update_logs( + self, + host: str, + port: int, + entries: List[Entry], + failed_node: Optional[Tuple[str, int]]=None, + ) -> Union[ + Tuple[int, RaftMessage], + None + ]: + shard_id: Union[int, None] = None + update_response: Union[RaftMessage, None] = None + + await self._logger.distributed.aio.debug(f'Running UDP logs update for node - {host}:{port} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Running UDP logs update for node - {host}:{port} - for source - {self.host}:{self.port}') + + for idx in range(self._poll_retries): + + try: + + response = await asyncio.wait_for( + self.submit_log_update( + host, + port, + entries, + failed_node=failed_node + ), + timeout=self._calculate_current_timeout( + host, + port + ) + ) + + shard_id, update_response = response + source_host, source_port = update_response.source_host, update_response.source_port + + not_self = self._check_is_not_self( + source_host, + source_port + ) + + if not_self: + self._node_statuses[(source_host, source_port)] = update_response.status + + self._local_health_multipliers[(host, port)] = self._reduce_health_multiplier( + host, + port + ) + + return shard_id, update_response + + except Exception: + + self._local_health_multipliers[(host, port)] = self._increase_health_multiplier( + host, + port + ) + + check_host = host + check_port = port + + node_status = self._node_statuses.get(( + check_host, + check_port + )) + + not_self = self._check_is_not_self( + check_host, + check_port + ) + + if not_self and update_response is None and node_status == 'healthy': + + await self._logger.distributed.aio.debug(f'Node - {check_host}:{check_port} - failed to respond over - {self._poll_retries} - retries and is now suspect for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Node - {check_host}:{check_port} - failed to respond over - {self._poll_retries} - retries and is now suspect for source - {self.host}:{self.port}') + + self._node_statuses[(check_host, check_port)] = 'suspect' + + self._suspect_nodes.append(( + check_host, + check_port + )) + + self._suspect_tasks[(host, port)] = asyncio.create_task( + self._start_suspect_monitor() + ) + + else: + + await self._logger.distributed.aio.debug(f'Node - {check_host}:{check_port} - responded on try - {idx}/{self._poll_retries} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Node - {check_host}:{check_port} - responded on try - {idx}/{self._poll_retries} - for source - {self.host}:{self.port}') + + def _calculate_current_timeout( + self, + host: str, + port: int + ): + + modifier = max( + len([ + address for address, status in self._node_statuses.items() if status == 'healthy' + ]), + self._initial_expected_nodes + ) + + return self._poll_timeout * (self._local_health_multipliers[(host, port)] + 1) * modifier + + async def notify_of_failed_node( + self, + failed_node: Tuple[str, int] + ): + monitors = [ + address for address, status in self._node_statuses.items() if status == 'healthy' and address != failed_node + ] + + responses: List[ + Union[ + Tuple[int, RaftMessage], + Exception + ] + ] = await asyncio.gather(*[ + asyncio.wait_for( + self.submit_failure_notification( + host, + port, + failed_node + ), + timeout=self._calculate_current_timeout( + host, + port + ) + ) for host , port in monitors + ], return_exceptions=True) + + for response in responses: + if isinstance(response, Exception): + raise response + + + async def run_election( + self, + failed_node: Optional[Tuple[str, int]]=None + ): + + # Trigger new election + next_term = self._term_number + 1 + self._raft_node_status = NodeState.CANDIDATE + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - Running election for term - {next_term}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - Running election for term - {next_term}') + + elected_host, elected_port = self._get_max_instance_id() + self._term_leaders.append((elected_host, elected_port)) + + if elected_host == self.host and elected_port == self.port: + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - was elected as leader for term - {next_term}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - was elected as leader for term - {next_term}') + + + self._raft_node_status = NodeState.LEADER + self._term_number += 1 + + members: List[Tuple[str, int]] = [ + address for address, status in self._node_statuses.items() if status == 'healthy' + ] + + members = list(set(members)) + + self._logs.update([ + Entry.from_data( + entry_id=self._entry_id_generator.generate(), + leader_host=self.host, + leader_port=self.port, + term=self._term_number, + data={ + 'key': 'election_update', + 'value': f'Election complete! Elected - {self.host}:{self.port}' + } + ) + ]) + + members: List[Tuple[str, int]] = [ + address for address, status in self._node_statuses.items() if status == 'healthy' + ] + + latest_logs = self._logs.latest() + + await asyncio.gather(*[ + asyncio.wait_for( + self._update_logs( + host, + port, + latest_logs, + failed_node=failed_node + ), + timeout=self._calculate_current_timeout( + host, + port + ) + ) for host, port in members + ], return_exceptions=True) + + else: + + self._raft_node_status = NodeState.FOLLOWER + + await self._logger.distributed.aio.info(f'Source - {self.host}:{self.port} - failed to receive majority votes and is reverting to a follower for term - {self._term_number}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].info(f'Source - {self.host}:{self.port} - failed to receive majority votes and is reverting to a follower for term - {self._term_number}') + + if self._term_number > next_term: + self._term_number = next_term + + self._election_status = ElectionState.READY + + return + + async def _run_raft_monitor(self): + + while self._running: + + if self._raft_node_status == NodeState.LEADER: + + self._tasks_queue.append( + asyncio.create_task( + self._submit_logs_to_members([ + Entry.from_data( + entry_id=self._entry_id_generator.generate(), + leader_host=self.host, + leader_port=self.port, + term=self._term_number, + data={ + 'key': 'logs_update', + 'value': f'Node - {self.host}:{self.port} - submitted log update', + } + ) + ]) + ) + ) + + await asyncio.sleep( + self._logs_update_poll_interval * self._initial_expected_nodes + ) + + async def _submit_logs_to_members( + self, + entries: List[Entry] + ) -> List[RaftMessage]: + + members: List[Tuple[str, int]] = [ + address for address, status in self._node_statuses.items() if status == 'healthy' + ] + + self._logs.update(entries) + + latest_logs = self._logs.latest() + + results: List[Tuple[ + int, + RaftMessage + ]] = await asyncio.gather(*[ + asyncio.create_task( + self._update_logs( + host, + port, + latest_logs + ) + ) for host, port in members + ]) + + self._logs.commit() + + return results + + async def _cleanup_pending_raft_tasks(self): + + await self._logger.distributed.aio.debug(f'Running cleanup for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Running cleanup for source - {self.host}:{self.port}') + + while self._running: + + pending_count = 0 + + for pending_task in list(self._tasks_queue): + if pending_task.done() or pending_task.cancelled(): + try: + await pending_task + + except Exception: + pass + + self._tasks_queue.remove(pending_task) + pending_count += 1 + + await self._logger.distributed.aio.debug(f'Cleaned up - {pending_count} - for source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Cleaned up - {pending_count} - for source - {self.host}:{self.port}') + + await asyncio.sleep(self._logs_update_poll_interval) + self._logs.prune() + + async def leave(self): + await self._logger.distributed.aio.debug(f'Shutdown requested for RAFT source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Shutdown requested for RAFT source - {self.host}:{self.port}') + + await cancel(self._raft_monitor_task) + await cancel(self._raft_cleanup_task) + + if self._election_task: + await cancel(self._election_task) + self._election_task = None + + await self._submit_leave_requests() + await self._shutdown() + + await self._logger.distributed.aio.debug(f'Shutdown complete for RAFT source - {self.host}:{self.port}') + await self._logger.filesystem.aio[f'hyperscale.distributed.{self._instance_id}'].debug(f'Shutdown complete for RAFT source - {self.host}:{self.port}') diff --git a/hyperscale/distributed/service/__init__.py b/hyperscale/distributed/service/__init__.py new file mode 100644 index 0000000..768af88 --- /dev/null +++ b/hyperscale/distributed/service/__init__.py @@ -0,0 +1,2 @@ +from .service import Service +from .controller import Controller \ No newline at end of file diff --git a/hyperscale/distributed/service/controller.py b/hyperscale/distributed/service/controller.py new file mode 100644 index 0000000..7cc05c7 --- /dev/null +++ b/hyperscale/distributed/service/controller.py @@ -0,0 +1,656 @@ +from __future__ import annotations + +import asyncio +import functools +import inspect +import multiprocessing as mp +import os +import random +import signal +import socket +import sys +from collections import defaultdict +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor +from inspect import signature +from types import MethodType +from typing import ( + Any, + AsyncIterable, + Callable, + Dict, + Generic, + List, + Literal, + Optional, + Tuple, + Type, + TypeVarTuple, + Union, + get_args, +) + +from pydantic import BaseModel + +from hyperscale.distributed.connection.tcp.mercury_sync_http_connection import ( + MercurySyncHTTPConnection, +) +from hyperscale.distributed.connection.tcp.mercury_sync_tcp_connection import ( + MercurySyncTCPConnection, +) +from hyperscale.distributed.connection.udp.mercury_sync_udp_connection import ( + MercurySyncUDPConnection, +) +from hyperscale.distributed.connection.udp.mercury_sync_udp_multicast_connection import ( + MercurySyncUDPMulticastConnection, +) +from hyperscale.distributed.env import Env, load_env +from hyperscale.distributed.middleware.base import Middleware +from hyperscale.distributed.models.base.error import Error +from hyperscale.distributed.models.base.message import Message + +from .socket import bind_tcp_socket, bind_udp_socket + +P = TypeVarTuple('P') + + +mp.allow_connection_pickling() +spawn = mp.get_context("spawn") + + +def handle_worker_loop_stop( + signame, + loop: asyncio.AbstractEventLoop, + waiter: Optional[asyncio.Future] +): + if waiter: + waiter.set_result(None) + + loop.stop() + + +def handle_loop_stop( + signame, + executor: Union[ProcessPoolExecutor, ThreadPoolExecutor], +): + try: + executor.shutdown(cancel_futures=True) + + except BrokenPipeError: + pass + + except RuntimeError: + pass + + +async def run( + udp_connecton: MercurySyncUDPConnection, + tcp_connection: MercurySyncTCPConnection, + config: Dict[str, Union[int, socket.socket, str]]={} +): + loop = asyncio.get_event_loop() + + waiter = loop.create_future() + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_worker_loop_stop( + signame, + loop, + waiter + ) + ) + + await udp_connecton.connect_async( + cert_path=config.get('cert_path'), + key_path=config.get('key_path'), + worker_socket=config.get('udp_socket') + ) + await tcp_connection.connect_async( + cert_path=config.get('cert_path'), + key_path=config.get('key_path'), + worker_socket=config.get('tcp_socket') + ) + + + await waiter + + +def start_pool( + udp_connection: MercurySyncUDPConnection, + tcp_connection: MercurySyncTCPConnection, + config: Dict[str, Union[int, socket.socket, str]]={}, +): + import asyncio + + try: + import uvloop + uvloop.install() + + except ImportError: + pass + + try: + + loop = asyncio.get_event_loop() + + except Exception: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + stdin_fileno = config.get('stdin_fileno') + + if stdin_fileno is not None: + sys.stdin = os.fdopen(stdin_fileno) + + loop = asyncio.get_event_loop() + + loop.run_until_complete( + run( + udp_connection, + tcp_connection, + config + ) + ) + + +class Controller(Generic[*P]): + services: Dict[str, Type[Controller]] = {} + + def __init__( + self, + host: str, + port: int, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + workers: int=0, + env: Optional[Env]=None, + engine: Literal["process", "async"]="async", + middleware: List[Middleware]= [] + ) -> None: + + if env is None: + env = load_env(Env) + + self.name = self.__class__.__name__ + self._instance_id = random.randint(0, 2**16) + self._response_parsers: Dict[str, Message] = {} + self._host_map: Dict[ + str, + Dict[ + Union[MercurySyncUDPConnection, MercurySyncTCPConnection], + Tuple[str, int] + ] + ] = defaultdict(dict) + + if workers < 1: + workers = 1 + + self._workers = workers + + self.host = host + self.port = port + self.cert_path = cert_path + self.key_path = key_path + self.middleware = middleware + + self._env = env + self._engine: Union[ProcessPoolExecutor, None] = None + self._udp_queue: Dict[Tuple[str, int], asyncio.Queue] = defaultdict(asyncio.Queue) + self._tcp_queue: Dict[Tuple[str, int], asyncio.Queue] = defaultdict(asyncio.Queue) + self._cleanup_task: Union[asyncio.Task, None] = None + self._waiter: Union[asyncio.Future, None] = None + + self.engine_type = engine + self._response_parsers: Dict[str, Message] = {} + + self.instance_ids = [ + self._instance_id + idx for idx in range(0, workers) + ] + + if env.MERCURY_SYNC_USE_UDP_MULTICAST: + self._udp = MercurySyncUDPMulticastConnection( + self.host, + self.port, + self._instance_id, + env=env + ) + else: + self._udp = MercurySyncUDPConnection( + self.host, + self.port, + self._instance_id, + env=env + ) + + if env.MERCURY_SYNC_USE_HTTP_SERVER: + + self._tcp = MercurySyncHTTPConnection( + self.host, + self.port + 1, + self._instance_id, + env=env + ) + + else: + self._tcp =MercurySyncTCPConnection( + self.host, + self.port + 1, + self._instance_id, + env=env + ) + + self.setup() + + def setup(self): + self.reserved_methods = [ + 'connect', + 'send', + 'send_tcp', + 'stream', + 'stream_tcp', + 'close' + ] + + middleware_enabled: Dict[str, bool] = {} + + response_parsers: Dict[ + str, + Callable[ + [Dict[str, Any]], + BaseModel + ] + ] = {} + controller_models: Dict[str, Message] = {} + controller_methods: Dict[str, Callable[ + [Message], + Message + ]] = {} + + supported_http_handlers: Dict[str, Dict[str, str]] = defaultdict(dict) + + for _, method in inspect.getmembers(self, predicate=inspect.ismethod): + ( + controller_models, + controller_methods, + middleware_enabled, + response_parsers + ) = self.apply_method( + method, + controller_models, + controller_methods, + middleware_enabled, + response_parsers + ) + + + self._parsers: Dict[str, Message] = {} + self._events: Dict[str, Message] = {} + + for method_name, model in controller_models.items(): + + self._udp.parsers[method_name] = model + self._tcp.parsers[method_name] = model + + if isinstance(self._tcp, MercurySyncHTTPConnection): + self._tcp._supported_handlers = supported_http_handlers + self._tcp._middleware_enabled = middleware_enabled + + self._parsers[method_name] = model + + for method_name, method in controller_methods.items(): + + self._udp.events[method_name] = method + self._tcp.events[method_name] = method + + self._events[method_name] = method + + for key, parser in response_parsers.items(): + self._tcp._response_parsers[key] = parser + + def apply_method( + self, + method: MethodType, + controller_models: Dict[str, Message], + controller_methods: Dict[str, Callable[ + [Message], + Message + ]], + middleware_enabled: Dict[str, bool], + response_parsers: Dict[ + str, + Callable[ + [Dict[str, Any]], + BaseModel + ] + ] + ) -> Tuple[ + Dict[str, Message], + Dict[str, Callable[ + [Message], + Message + ]], + Dict[str, bool], + Dict[ + str, + Callable[ + [Dict[str, Any]], + BaseModel + ] + ] + ]: + + method_name = method.__name__ + + not_internal = method_name.startswith('__') is False + not_reserved = method_name not in self.reserved_methods + is_server = hasattr(method, 'server_only') + is_client = hasattr(method, 'client_only') + is_http = hasattr(method, 'as_http') and method.as_http is True + + + rpc_signature = signature(method) + + if not_internal and not_reserved and is_server: + + for param_type in rpc_signature.parameters.values(): + + if issubclass(param_type.annotation, (BaseModel,)): + + model = param_type.annotation + controller_models[method_name] = model + + controller_methods[method_name] = method + + elif not_internal and not_reserved and is_client: + + is_stream = inspect.isasyncgenfunction(method) + + if is_stream: + + response_type = rpc_signature.return_annotation + args = get_args(response_type) + + response_call_type: Tuple[int, Message] = args[0] + self._response_parsers[method.target] = get_args(response_call_type)[1] + + else: + + response_type = rpc_signature.return_annotation + args = get_args(response_type) + response_model: Tuple[int, Message] = args[1] + + self._response_parsers[method.target] = response_model + + if not_internal and not_reserved and is_http: + + path: str = method.path + + for middleware_operator in self.middleware: + method = middleware_operator.wrap(method) + middleware_enabled[path] = True + + response_type = rpc_signature.return_annotation + args = get_args(response_type) + + response_model: Tuple[ + Union[BaseModel, str, None], + int + ] = args[0] + + event_http_methods: List[str] = method.methods + path: str = method.path + + for event_http_method in event_http_methods: + event_key = f'{event_http_method}_{path}' + + for param_type in rpc_signature.parameters.values(): + args = get_args(param_type.annotation) + + if len(args) > 0 and issubclass(args[0], (BaseModel,)): + + path: str = method.path + + model = args[0] + + controller_models[event_key] = model + + controller_methods[event_key] = method + + if isinstance(method.responses, dict): + + responses = method.responses + + for status, status_response_model in responses.items(): + status_key = f'{event_http_method}_{path}_{status}' + + if issubclass(status_response_model, BaseModel): + response_parsers[status_key] = lambda response: status_response_model( + **response + ).json() + + if isinstance(method.serializers, dict): + + serializers = method.serializers + + for status, serializer in serializers.items(): + status_key = f'{event_http_method}_{path}_{status}' + + response_parsers[status_key] = serializer + + return ( + controller_models, + controller_methods, + middleware_enabled, + response_parsers + ) + + async def run_forever(self): + loop = asyncio.get_event_loop() + self._waiter = loop.create_future() + + await self._waiter + + async def start_server( + self, + cert_path: Optional[str]=None, + key_path: Optional[str]=None + ): + + for middleware in self.middleware: + await middleware.__setup__() + + pool: List[asyncio.Future] = [] + + loop = asyncio.get_event_loop() + + if self.engine_type == "process": + engine = ProcessPoolExecutor( + max_workers=self._workers, + mp_context=mp.get_context(method='spawn') + ) + + if self.engine_type == 'process': + + udp_socket = bind_udp_socket(self.host, self.port) + tcp_socket = bind_tcp_socket(self.host, self.port + 1) + + stdin_fileno: Optional[int] + try: + stdin_fileno = sys.stdin.fileno() + except OSError: + stdin_fileno = None + + config = { + "udp_socket": udp_socket, + "tcp_socket": tcp_socket, + "stdin_fileno": stdin_fileno, + "cert_path": cert_path, + "key_path": key_path + } + + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + engine + ) + ) + + for _ in range(self._workers): + + service_worker = loop.run_in_executor( + engine, + functools.partial( + start_pool, + MercurySyncUDPConnection( + self.host, + self.port, + self._instance_id, + self._env + ), + MercurySyncTCPConnection( + self.host, + self.port + 1, + self._instance_id, + self._env + ), + config=config + ) + ) + + pool.append(service_worker) + + await asyncio.gather(*pool) + + else: + + await self._udp.connect_async( + cert_path=cert_path, + key_path=key_path + ) + + await self._tcp.connect_async( + cert_path=cert_path, + key_path=key_path, + ) + + async def start_client( + self, + remotes: Dict[ + Tuple[str, int]: List[Type[Message]] + ], + cert_path: Optional[str]=None, + key_path: Optional[str]=None + ): + + for address, message_types in remotes.items(): + + host, port = address + + await self._tcp.connect_client( + (host, port + 1), + cert_path=cert_path, + key_path=key_path + ) + + async def send( + self, + event_name: str, + message: Message + ): + shard_id, data = await self._udp.send( + event_name, + message.to_data(), + (message.host, message.port) + ) + + if isinstance(data, Message): + return shard_id, data + + response_data = self._response_parsers.get(event_name)( + **data + ) + + return shard_id, response_data + + async def send_tcp( + self, + event_name: str, + message: Message + ): + + shard_id, data = await self._tcp.send( + event_name, + message.to_data(), + (message.host, message.port + 1) + ) + + response_data = self._response_parsers.get(event_name)( + **data + ) + + return shard_id, response_data + + async def stream( + self, + event_name: str, + message: Message + ) -> AsyncIterable[Tuple[int, Union[Message, Error]]]: + + address = ( + message.host, + message.port + ) + + async for response in self._udp.stream( + event_name, + message.to_data(), + address + ): + shard_id, data = response + response_data = self._response_parsers.get(event_name)( + **data + ) + + yield shard_id, response_data + + async def stream_tcp( + self, + event_name: str, + message: Message + ) -> AsyncIterable[Tuple[int, Union[Message, Error]]]: + + address = ( + message.host, + message.port + ) + + async for response in self._tcp.stream( + event_name, + message.to_data(), + address + ): + shard_id, data = response + + if data.get('error'): + yield shard_id, Error(**data) + + response_data = self._response_parsers.get(event_name)( + **data + ) + + yield shard_id, response_data + + async def close(self) -> None: + + if self._engine: + self._engine.shutdown(cancel_futures=True) + + await self._udp.close() + await self._tcp.close() + + if self._waiter: + self._waiter.set_result(None) diff --git a/hyperscale/distributed/service/plugin_group.py b/hyperscale/distributed/service/plugin_group.py new file mode 100644 index 0000000..953b08f --- /dev/null +++ b/hyperscale/distributed/service/plugin_group.py @@ -0,0 +1,30 @@ +from typing import List, Iterable, Generic, TypeVarTuple, Union +from .service import Service + + +P = TypeVarTuple('P') + +class PluginGroup(Generic[*P]): + + def __init__( + self, + service_pool: List[Union[*P]] + ) -> None: + self._services = service_pool + self._services_count = len(service_pool) + self._current_idx = 0 + + @property + def one(self) -> Union[*P]: + service: Service = self._services[self._current_idx] + self._current_idx = (self._current_idx + 1)%self._services_count + + return service + + def each(self) -> Iterable[Union[*P]]: + for service in self._services: + yield service + + def at(self, idx: int) -> Union[*P]: + return self._services[idx] + \ No newline at end of file diff --git a/hyperscale/distributed/service/plugin_wrapper.py b/hyperscale/distributed/service/plugin_wrapper.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/distributed/service/service.py b/hyperscale/distributed/service/service.py new file mode 100644 index 0000000..d9c104e --- /dev/null +++ b/hyperscale/distributed/service/service.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import asyncio +import inspect +import random +import socket +from inspect import signature +from typing import AsyncIterable, Dict, List, Optional, Tuple, Union, get_args + +from hyperscale.distributed.connection.tcp.mercury_sync_tcp_connection import ( + MercurySyncTCPConnection, +) +from hyperscale.distributed.connection.udp.mercury_sync_udp_connection import ( + MercurySyncUDPConnection, +) +from hyperscale.distributed.env import Env, load_env +from hyperscale.distributed.models.base.error import Error +from hyperscale.distributed.models.base.message import Message + + +class Service: + + def __init__( + self, + host: str, + port: int, + cert_path: Optional[str]=None, + key_path: Optional[str]=None, + env: Optional[Env]=None + ) -> None: + self.name = self.__class__.__name__ + self._instance_id = random.randint(0, 2**16) + self._response_parsers: Dict[str, Message] = {} + + self.host = host + self.port = port + self.cert_path = cert_path + self.key_path = key_path + + if env is None: + env = load_env(Env) + + self._env = env + + self._udp_connection = MercurySyncUDPConnection( + host, + port, + self._instance_id, + env + ) + + self._tcp_connection = MercurySyncTCPConnection( + host, + port + 1, + self._instance_id, + env + ) + + self._host_map: Dict[str, Tuple[str, int]] = {} + + methods = inspect.getmembers(self, predicate=inspect.ismethod) + + reserved_methods = [ + 'start', + 'connect', + 'send', + 'send_tcp', + 'stream', + 'stream_tcp', + 'close' + ] + + for _, method in methods: + method_name = method.__name__ + + not_internal = method_name.startswith('__') is False + not_reserved = method_name not in reserved_methods + is_server = hasattr(method, 'server_only') + is_client = hasattr(method, 'client_only') + + rpc_signature = signature(method) + + if not_internal and not_reserved and is_server: + + for param_type in rpc_signature.parameters.values(): + if param_type.annotation in Message.__subclasses__(): + + model = param_type.annotation + + self._tcp_connection.parsers[method_name] = model + self._udp_connection.parsers[method_name] = model + + self._tcp_connection.events[method_name] = method + self._udp_connection.events[method_name] = method + + elif not_internal and not_reserved and is_client: + + is_stream = inspect.isasyncgenfunction(method) + + if is_stream: + + response_type = rpc_signature.return_annotation + args = get_args(response_type) + + response_call_type: Tuple[int, Message] = args[0] + self._response_parsers[method.target] = get_args(response_call_type)[1] + + else: + + response_type = rpc_signature.return_annotation + args = get_args(response_type) + response_model: Tuple[int, Message] = args[1] + + self._response_parsers[method.target] = response_model + + self._loop: Union[ asyncio.AbstractEventLoop, None] = None + + def update_parsers( + self, + parsers: Dict[str, Message] + ): + self._udp_connection.parsers.update(parsers) + self._tcp_connection.parsers.update(parsers) + + + def start( + self, + tcp_worker_socket: Optional[socket.socket]=None, + udp_worker_socket: Optional[socket.socket]=None + ) -> None: + + self._loop = asyncio.get_event_loop() + + self._tcp_connection.connect( + cert_path=self.cert_path, + key_path=self.key_path, + worker_socket=tcp_worker_socket + ) + self._udp_connection.connect( + cert_path=self.cert_path, + key_path=self.key_path, + worker_socket=udp_worker_socket + ) + + + def create_pool(self, size: int) -> List[Service]: + + port_pool_size = size * 2 + + ports = [ + self.port + idx for idx in range(0, port_pool_size, 2) + ] + + return [ + self._copy( + port=port + ) for port in ports + ] + + def _copy( + self, + host: str=None, + port: int= None + ): + + if host is None: + host = self.host + + if port is None: + port = self.port + + return type(self)( + host, + port + ) + + async def use_server_socket( + self, + udp_worker_socket: socket.socket, + tcp_worker_socket: socket.socket, + cert_path: Optional[str]=None, + key_path: Optional[str]=None + ): + await self._udp_connection.connect_async( + cert_path=cert_path, + key_path=key_path, + worker_socket=udp_worker_socket + ) + + await self._tcp_connection.connect_async( + cert_path=cert_path, + key_path=key_path, + worker_socket=tcp_worker_socket + ) + + + async def connect( + self, + remote: Message, + cert_path: Optional[str]=None, + key_path: Optional[str]=None + ) -> None: + address = (remote.host, remote.port) + self._host_map[remote.__class__.__name__] = address + + if cert_path is None: + cert_path = self.cert_path + + if key_path is None: + key_path = self.key_path + + await self._tcp_connection.connect_client( + (remote.host, remote.port + 1), + cert_path=cert_path, + key_path=key_path + ) + + async def send( + self, + event_name: str, + message: Message + ) -> Tuple[int, Union[Message, Error]]: + (host, port) = self._host_map.get(message.__class__.__name__) + address = ( + host, + port + ) + + shard_id, data = await self._udp_connection.send( + event_name, + message.to_data(), + address + ) + + response_data = self._response_parsers.get(event_name)( + **data + ) + return shard_id, response_data + + async def send_tcp( + self, + event_name: str, + message: Message + ) -> Tuple[int, Union[Message, Error]]: + (host, port) = self._host_map.get(message.__class__.__name__) + address = ( + host, + port + 1 + ) + + shard_id, data = await self._tcp_connection.send( + event_name, + message.to_data(), + address + ) + + + if data.get('error'): + return shard_id, Error(**data) + + response_data = self._response_parsers.get(event_name)( + **data + ) + return shard_id, response_data + + async def stream( + self, + event_name: str, + message: Message + ) -> AsyncIterable[Tuple[int, Union[Message, Error]]]: + (host, port) = self._host_map.get(message.__class__.__name__) + address = ( + host, + port + ) + + async for response in self._udp_connection.stream( + event_name, + message.to_data(), + address + ): + shard_id, data = response + response_data = self._response_parsers.get(event_name)( + **data + ) + + yield shard_id, response_data + + async def stream_tcp( + self, + event_name: str, + message: Message + ) -> AsyncIterable[Tuple[int, Union[Message, Error]]]: + (host, port) = self._host_map.get(message.__class__.__name__) + address = ( + host, + port + 1 + ) + + async for response in self._tcp_connection.stream( + event_name, + message.to_data(), + address + ): + shard_id, data = response + + if data.get('error'): + yield shard_id, Error(**data) + + response_data = self._response_parsers.get(event_name)( + **data + ) + + yield shard_id, response_data + + async def close(self) -> None: + await self._tcp_connection.close() + await self._udp_connection.close() diff --git a/hyperscale/distributed/service/socket/__init__.py b/hyperscale/distributed/service/socket/__init__.py new file mode 100644 index 0000000..fca4a8a --- /dev/null +++ b/hyperscale/distributed/service/socket/__init__.py @@ -0,0 +1,4 @@ +from .socket import ( + bind_tcp_socket, + bind_udp_socket +) \ No newline at end of file diff --git a/hyperscale/distributed/service/socket/socket.py b/hyperscale/distributed/service/socket/socket.py new file mode 100644 index 0000000..c09b812 --- /dev/null +++ b/hyperscale/distributed/service/socket/socket.py @@ -0,0 +1,52 @@ +import socket +import sys + +def bind_tcp_socket( + host: str, + port: int +) -> socket.socket: + + family = socket.AF_INET + + if host and ":" in host: + family = socket.AF_INET6 + + sock = socket.socket(family, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + + sock.bind((host, port)) + + except OSError : + sys.exit(1) + + sock.setblocking(False) + sock.set_inheritable(True) + + return sock + + +def bind_udp_socket( + host: str, + port: int +) -> socket.socket: + + sock = socket.socket( + socket.AF_INET, + socket.SOCK_DGRAM, + socket.IPPROTO_UDP + ) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + + sock.bind((host, port)) + + except OSError : + sys.exit(1) + + sock.setblocking(False) + sock.set_inheritable(True) + + return sock \ No newline at end of file diff --git a/hyperscale/distributed/snowflake/__init__.py b/hyperscale/distributed/snowflake/__init__.py new file mode 100644 index 0000000..beace0d --- /dev/null +++ b/hyperscale/distributed/snowflake/__init__.py @@ -0,0 +1 @@ +from .snowflake import Snowflake \ No newline at end of file diff --git a/hyperscale/distributed/snowflake/constants.py b/hyperscale/distributed/snowflake/constants.py new file mode 100644 index 0000000..d1e35e3 --- /dev/null +++ b/hyperscale/distributed/snowflake/constants.py @@ -0,0 +1,3 @@ +MAX_TS = 0b11111111111111111111111111111111111111111 +MAX_INSTANCE = 0b1111111111 +MAX_SEQ = 0b111111111111 diff --git a/hyperscale/distributed/snowflake/snowflake.py b/hyperscale/distributed/snowflake/snowflake.py new file mode 100644 index 0000000..3a9fc3a --- /dev/null +++ b/hyperscale/distributed/snowflake/snowflake.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from datetime import ( + datetime, + tzinfo, + timedelta +) +from typing import Optional +from .constants import ( + MAX_INSTANCE, + MAX_SEQ +) + + +@dataclass(frozen=True) +class Snowflake: + timestamp: int + instance: int + epoch: int = 0 + seq: int = 0 + + + @classmethod + def parse(cls, snowflake: int, epoch: int = 0) -> 'Snowflake': + return cls( + epoch=epoch, + timestamp=snowflake >> 22, + instance=snowflake >> 12 & MAX_INSTANCE, + seq=snowflake & MAX_SEQ + ) + + @property + def milliseconds(self) -> int: + return self.timestamp + self.epoch + + @property + def seconds(self) -> float: + return self.milliseconds / 1000 + + @property + def datetime(self) -> datetime: + return datetime.utcfromtimestamp(self.seconds) + + def datetime_tz(self, tz: Optional[tzinfo] = None) -> datetime: + return datetime.fromtimestamp(self.seconds, tz=tz) + + @property + def timedelta(self) -> timedelta: + return timedelta(milliseconds=self.epoch) + + @property + def value(self) -> int: + return self.timestamp << 22 | self.instance << 12 | self.seq + + def __int__(self) -> int: + return self.value \ No newline at end of file diff --git a/hyperscale/distributed/snowflake/snowflake_generator.py b/hyperscale/distributed/snowflake/snowflake_generator.py new file mode 100644 index 0000000..28415ee --- /dev/null +++ b/hyperscale/distributed/snowflake/snowflake_generator.py @@ -0,0 +1,54 @@ +from time import time +from typing import Optional +from .constants import ( + MAX_SEQ +) +from .snowflake import Snowflake + + +class SnowflakeGenerator: + def __init__( + self, + instance: int, + *, + seq: int = 0, + timestamp: Optional[int] = None + ): + + current = int(time() * 1000) + + + timestamp = timestamp or current + + self._ts = timestamp + + self._inf = instance << 12 + self._seq = seq + + @classmethod + def from_snowflake(cls, sf: Snowflake) -> 'SnowflakeGenerator': + return cls(sf.instance, seq=sf.seq, epoch=sf.epoch, timestamp=sf.timestamp) + + def __iter__(self): + return self + + def generate(self) -> Optional[int]: + + current = int(time() * 1000) + + if self._ts == current: + + if self._seq == MAX_SEQ: + return None + + self._seq += 1 + + elif self._ts > current: + return None + + else: + self._seq = 0 + + self._ts = current + + return self._ts << 22 | self._inf | self._seq \ No newline at end of file diff --git a/hyperscale/distributed/types/__init__.py b/hyperscale/distributed/types/__init__.py new file mode 100644 index 0000000..b2356ca --- /dev/null +++ b/hyperscale/distributed/types/__init__.py @@ -0,0 +1,3 @@ +from .call import Call +from .response import Response +from .stream import Stream \ No newline at end of file diff --git a/hyperscale/distributed/types/call.py b/hyperscale/distributed/types/call.py new file mode 100644 index 0000000..f4c48a9 --- /dev/null +++ b/hyperscale/distributed/types/call.py @@ -0,0 +1,7 @@ +from typing import TypeVar, Tuple + + +T = TypeVar('T') + + +Call = Tuple[int, T] \ No newline at end of file diff --git a/hyperscale/distributed/types/response.py b/hyperscale/distributed/types/response.py new file mode 100644 index 0000000..84252fb --- /dev/null +++ b/hyperscale/distributed/types/response.py @@ -0,0 +1,7 @@ +from typing import TypeVar, Tuple + + +T = TypeVar('T') + + +Response = Tuple[T, int] \ No newline at end of file diff --git a/hyperscale/distributed/types/stream.py b/hyperscale/distributed/types/stream.py new file mode 100644 index 0000000..1e6822a --- /dev/null +++ b/hyperscale/distributed/types/stream.py @@ -0,0 +1,10 @@ +from typing import AsyncIterable, TypeVar + +from hyperscale.distributed.models.base.message import Message + +from .call import Call + +T = TypeVar('T', bound=Message) + + +Stream = AsyncIterable[Call[T]] diff --git a/hyperscale/logging/__init__.py b/hyperscale/logging/__init__.py new file mode 100644 index 0000000..e5500af --- /dev/null +++ b/hyperscale/logging/__init__.py @@ -0,0 +1,2 @@ +from .hyperscale_logger import HyperscaleLogger as HyperscaleLogger +# from .table import SummaryTable \ No newline at end of file diff --git a/hyperscale/logging/config/__init__.py b/hyperscale/logging/config/__init__.py new file mode 100644 index 0000000..4afb3ba --- /dev/null +++ b/hyperscale/logging/config/__init__.py @@ -0,0 +1 @@ +from .logging_config import LoggingConfig \ No newline at end of file diff --git a/hyperscale/logging/config/logging_config.py b/hyperscale/logging/config/logging_config.py new file mode 100644 index 0000000..e4353d2 --- /dev/null +++ b/hyperscale/logging/config/logging_config.py @@ -0,0 +1,80 @@ +import datetime +import os +import signal +from typing import Any, Coroutine, Dict, List + +from aiologger.levels import LogLevel +from yaspin.spinners import Spinners + +from hyperscale.logging.logger_types.handers.async_file_handler import RolloverInterval +from hyperscale.logging.logger_types.logger_types import LoggerTypes +from hyperscale.logging.spinner import ProgressText + + +class LoggingConfig: + logger_name: str=None + logger_type: LoggerTypes=LoggerTypes.CONSOLE + logfiles_directory: str=f'{os.getcwd()}/logs' + log_level: LogLevel=LogLevel.INFO + logger_enabled: bool=None + filesystem_rotation_interval_type: RolloverInterval=RolloverInterval.DAYS + filesystem_rotation_interval: int=1 + filesystem_backup_count: int=1 + filesystem_rotation_time: datetime.time=None + spinner_type: Spinners=Spinners.bouncingBar + spinner_color: str='cyan' + spinner_on_color: str=None + spinner_attrs: List[str]=["bold"] + spinner_reversal: bool=False + spinner_side: str="left" + spinner_sigmap: Dict[signal.Signals, Coroutine]=None + spinner_has_timer: bool=False + spinner_enabled: bool=True + spinner_display: ProgressText=None + + def from_dict(self, config: Dict[str, Any]): + for config_value_name, config_value in config.items(): + if hasattr(self, config_value_name): + setattr(self, config_value_name, config_value) + + @property + def filesystem_logger(self): + return { + 'logger_name': self.logger_name, + 'logger_type': self.logger_type, + 'logfiles_directory': self.logfiles_directory, + 'log_level': self.log_level, + 'logger_enabled': self.logger_enabled, + 'rotation_interval_type': self.filesystem_rotation_interval_type, + 'rotation_interval': self.filesystem_rotation_interval, + 'backup_count': self.filesystem_backup_count, + 'rotation_time': self.filesystem_rotation_time + } + + @property + def cli_logger(self): + return { + 'logger_name': self.logger_name, + 'logger_type': self.logger_type, + 'log_level': self.log_level, + 'logger_enabled': self.logger_enabled, + } + + @property + def spinner(self): + return { + 'logger_name': self.logger_name, + 'logger_type': self.logger_type, + 'log_level': self.log_level, + 'logger_enabled': self.logger_enabled, + 'spinner': self.spinner_type, + 'color': self.spinner_color, + 'on_color': self.spinner_on_color, + 'attrs': self.spinner_attrs, + 'reversal': self.spinner_reversal, + 'side': self.spinner_side, + 'sigmap': self.spinner_sigmap, + 'timer': self.spinner_has_timer, + 'enabled': self.spinner_enabled, + 'text': self.spinner_display + } \ No newline at end of file diff --git a/hyperscale/logging/hyperscale_logger.py b/hyperscale/logging/hyperscale_logger.py new file mode 100644 index 0000000..fc8cffd --- /dev/null +++ b/hyperscale/logging/hyperscale_logger.py @@ -0,0 +1,150 @@ +import datetime +from typing import Dict, Type, Union + +from aiologger.levels import LogLevel + +from .config import LoggingConfig +from .logger_types import ( + AsyncFilesystemLogger, + AsyncLogger, + AsyncSpinner, + Logger, + LoggerTypes, + LoggerTypesMap, + SyncFilesystemLogger, + SyncLogger, +) +from .logger_types.handers.async_file_handler import RolloverInterval +from .logging_manager import logging_manager + + +class HyperscaleLogger: + + def __init__(self) -> None: + self.loggers: Dict[str, Logger[Union[AsyncLogger, AsyncFilesystemLogger, AsyncSpinner], Union[SyncLogger, SyncFilesystemLogger]]] = {} + + self.logger_names = logging_manager.logger_types.names + self.logger_types = logging_manager.logger_types.types + self.log_level: LogLevel = None + self.logger_types_map = LoggerTypesMap() + self.logger_directory: str = None + + def initialize(self, config: LoggingConfig=LoggingConfig()): + + for logger_type in self.logger_types: + + logger_name = logging_manager.logger_types.get_name(logger_type) + logger_enabled = logging_manager.get_logger_enabled_state(logger_type) + self.log_level = logging_manager.log_level + + config.from_dict({ + 'logger_name': logger_name, + 'logger_enabled': logger_enabled, + 'logger_type': logger_type, + 'log_level': logging_manager.log_level, + 'logfiles_directory': logging_manager.logfiles_directory, + 'spinner_display': logging_manager.progress_display + }) + + ASYNC_TYPE = Type[AsyncLogger] + SYNC_TYPE = Type[SyncLogger] + + if logger_type == LoggerTypes.SPINNER: + ASYNC_TYPE = Type[AsyncSpinner] + SYNC_TYPE = None + + elif logger_type == LoggerTypes.FILESYSTEM: + ASYNC_TYPE = Type[AsyncFilesystemLogger] + SYNC_TYPE = Type[SyncFilesystemLogger] + + logger: Logger[ASYNC_TYPE, SYNC_TYPE] = Logger(config) + + if logger_type == LoggerTypes.CONSOLE: + logger.set_patterns('%(message)s') + + elif logger_type == LoggerTypes.DISTRIBUTED or logger_type == LoggerTypes.HYPERSCALE: + + logger.set_patterns( + '%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s', + datefmt_pattern='%Y-%m-%dT%H:%M:%S.%Z' + ) + + self.loggers[logger_name] = logger + + def __getitem__(self, logger_name: str) -> Logger[Union[AsyncLogger, AsyncFilesystemLogger, AsyncSpinner], Union[SyncLogger, SyncFilesystemLogger]]: + logger = self.loggers.get(logger_name) + if logger is None: + logger = Logger( + logger_name, + LoggerTypes.CONSOLE, + log_level=logging_manager.log_level + ) + + logger.set_patterns('%(message)s') + + return logger + + @property + def console(self) -> Logger[AsyncLogger, SyncLogger]: + return self.loggers['console'] + + @property + def distributed(self) -> Logger[AsyncLogger, SyncLogger]: + return self.loggers['distributed'] + + @property + def hyperscale(self) -> Logger[AsyncLogger, SyncLogger]: + return self.loggers['hyperscale'] + + @property + def filesystem(self) -> Logger[AsyncFilesystemLogger, SyncFilesystemLogger]: + return self.loggers['filesystem'] + + + @property + def distributed_filesystem(self) -> Logger[AsyncFilesystemLogger, SyncFilesystemLogger]: + return self.loggers['distributed_filesystem'] + + @property + def spinner(self) -> AsyncSpinner: + return self.loggers['spinner'].aio + + def create_logger( + self, + logger_name: str, + logger_type: LoggerTypes, + rotation_interval_type: RolloverInterval=RolloverInterval.DAYS, + rotation_interval: int=1, + backups: int=1, + rotation_time: datetime.time=None + ): + + logger_enabled = logging_manager.get_logger_enabled_state(logger_type) + + ASYNC_TYPE = Type[AsyncLogger] + SYNC_TYPE = Type[SyncLogger] + + if logger_type == LoggerTypes.FILESYSTEM: + ASYNC_TYPE = Type[AsyncFilesystemLogger] + SYNC_TYPE = Type[SyncFilesystemLogger] + + self.loggers[logger_name]: Logger[ASYNC_TYPE, SYNC_TYPE] = Logger( + logger_name, + logger_type, + log_level=logging_manager.log_level, + logger_enabled=logger_enabled, + rotation_interval_type=rotation_interval_type, + rotation_interval=rotation_interval, + backup_count=backups, + rotation_time=rotation_time + ) + + def disable_logger(self, logger_name): + logger = self.loggers.get(logger_name) + if logger: + logger.logger_enabled = False + + def enable_logger(self, logger_name): + logger = self.loggers.get(logger_name) + if logger: + logger.logger_enabled = True \ No newline at end of file diff --git a/hyperscale/logging/logger_types/__init__.py b/hyperscale/logging/logger_types/__init__.py new file mode 100644 index 0000000..441de52 --- /dev/null +++ b/hyperscale/logging/logger_types/__init__.py @@ -0,0 +1,8 @@ +from .async_filesystem_logger import AsyncFilesystemLogger +from .async_logger import AsyncLogger +from .async_spinner import AsyncSpinner +from .logger import Logger +from .logger_types import LoggerTypes +from .logger_types_map import LoggerTypesMap +from .sync_filesystem_logger import SyncFilesystemLogger +from .sync_logger import SyncLogger \ No newline at end of file diff --git a/hyperscale/logging/logger_types/async_filesystem_logger.py b/hyperscale/logging/logger_types/async_filesystem_logger.py new file mode 100644 index 0000000..e03ada2 --- /dev/null +++ b/hyperscale/logging/logger_types/async_filesystem_logger.py @@ -0,0 +1,113 @@ +import asyncio +import datetime +import os +from pathlib import Path +from typing import Dict + +from aiologger.formatters.base import Formatter +from aiologger.levels import LogLevel + +from hyperscale.tools.filesystem import open + +from .async_logger import AsyncLogger +from .handers.async_file_handler import AsyncTimedRotatingFileHandler, RolloverInterval +from .logger_types import LoggerTypes + + +class AsyncFilesystemLogger: + + def __init__( + self, + logger_name: str=None, + logger_type: LoggerTypes=LoggerTypes.FILESYSTEM, + logfiles_directory: str=None, + log_level: LogLevel=LogLevel.NOTSET, + logger_enabled: bool = True, + rotation_interval_type: RolloverInterval=RolloverInterval.DAYS, + rotation_interval: int=1, + backup_count: int=1, + rotation_time: datetime.time=None + ) -> None: + self.logger_name = logger_name + self.logger_type = logger_type + self.log_level = log_level + self.logger_enabled = logger_enabled + self.rotation_interval_type = rotation_interval_type + self.rotation_interval = rotation_interval + self.backups = backup_count + self.rotation_time = rotation_time + self.logfiles_directory: str = logfiles_directory + + self.files: Dict[str, AsyncLogger] = {} + self.filepaths: Dict[str, str] = {} + + def __getitem__(self, logger_name: str): + + file_logger = self.files.get(logger_name) + + if file_logger is None: + file_logger = self._create_file_logger( + logger_name, + os.path.join( + self.logfiles_directory, + f'{logger_name}.log' + ) + ) + + self.files[logger_name] = file_logger + + return file_logger + + def _create_file_logger(self, logger_name: str, filepath: str) -> AsyncLogger: + async_logger = AsyncLogger( + logger_name=logger_name, + logger_type=self.logger_type, + log_level=self.log_level, + logger_enabled=self.logger_enabled + ) + + async_file_handler = AsyncTimedRotatingFileHandler( + filepath, + when=self.rotation_interval_type, + interval=self.rotation_interval, + backup_count=self.backups, + at_time=self.rotation_time + ) + + async_file_handler.formatter = Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s', + '%Y-%m-%dT%H:%M:%S.%Z' + ) + + async_logger.add_handler(async_file_handler) + + return async_logger + + async def create_logfile(self, log_filename: str): + + filepath = os.path.join(self.logfiles_directory, log_filename) + + loop = asyncio.get_event_loop() + path_exists = await loop.run_in_executor( + None, + os.path.exists, + filepath + ) + + if path_exists is False: + logfile = await open(filepath, 'w') + await logfile.close() + + self.update_files(filepath) + + else: + self.update_files(filepath) + + + def update_files(self, filepath: str): + logger_name = Path(filepath).stem + + if self.files.get(logger_name) is None: + self.files[logger_name] = self._create_file_logger(logger_name, filepath) + self.filepaths[logger_name] = filepath + diff --git a/hyperscale/logging/logger_types/async_logger.py b/hyperscale/logging/logger_types/async_logger.py new file mode 100644 index 0000000..965b3fd --- /dev/null +++ b/hyperscale/logging/logger_types/async_logger.py @@ -0,0 +1,57 @@ +from __future__ import annotations +import os +import sys +import asyncio +import aiologger +from asyncio import Task +from aiologger.levels import LogLevel +from aiologger.formatters.base import Formatter +from aiologger.handlers.streams import AsyncStreamHandler +from .logger_types import LoggerTypes + + +class AsyncLogger(aiologger.Logger): + + def __init__( + self, + logger_name: str=None, + logger_type: LoggerTypes=LoggerTypes.CONSOLE, + log_level: LogLevel = LogLevel.INFO, + logger_enabled: bool=True + ) -> None: + super().__init__(name=logger_name, level=log_level) + + self.logger_name = logger_name + self.logger_type = logger_type + self.log_level = log_level + self.logger_enabled = logger_enabled + self.pattern = None + self.datefmt_pattern = None + + def initialize(self, pattern: str, datefmt_pattern: str=None): + + self.pattern = pattern + self.datefmt_pattern = datefmt_pattern + + self.add_handler( + AsyncStreamHandler( + stream=os.fdopen( + os.dup(sys.__stdout__.fileno()) + ), + level=self.level, + formatter=Formatter(pattern, datefmt=datefmt_pattern), + ) + ) + + def _make_log_task(self, level, msg, *args, **kwargs) -> Task: + + if self.logger_enabled: + return super()._make_log_task(level, msg, *args, **kwargs) + + else: + return asyncio.create_task(self._skip_task()) + + async def _skip_task(self): + return + + diff --git a/hyperscale/logging/logger_types/async_spinner.py b/hyperscale/logging/logger_types/async_spinner.py new file mode 100644 index 0000000..62e4e1a --- /dev/null +++ b/hyperscale/logging/logger_types/async_spinner.py @@ -0,0 +1,541 @@ +from __future__ import annotations + +import asyncio +import functools +import inspect +import signal +import sys +import time +from asyncio import Task +from enum import Enum +from os import get_terminal_size +from typing import Any, Coroutine, Dict, List, Mapping + +from aiologger.formatters.base import Formatter +from aiologger.levels import LogLevel +from yaspin.core import Yaspin, to_unicode +from yaspin.spinners import Spinners + +from hyperscale.logging.spinner import ProgressText + +from .async_logger import AsyncLogger +from .logger_types import LoggerTypes + + +class LoggerMode(Enum): + CONSOLE='console' + SYSTEM='system' + + +async def default_handler(signame: str,spinner: AsyncSpinner): # pylint: disable=unused-argument + """Signal handler, used to gracefully shut down the ``spinner`` instance + when specified signal is received by the process running the ``spinner``. + + ``signum`` and ``frame`` are mandatory arguments. Check ``signal.signal`` + function for more details. + """ + await spinner.fail() + await spinner.stop() + + +class AsyncSpinner(Yaspin): + + def __init__( + self, + logger_name: str=None, + logger_type: LoggerTypes=LoggerTypes.SPINNER, + log_level: LogLevel=LogLevel.NOTSET, + logger_enabled: bool=True, + spinner: Spinners=None, + text: ProgressText=None, + color: str=None, + on_color: str=None, + attrs: List[str]=None, + reversal: bool=False, + side: str="left", + sigmap: Dict[signal.Signals, Coroutine]=None, + timer: bool=False, + enabled: bool=True + ): + super().__init__( + spinner, + text, + color, + on_color, + attrs, + reversal, + side, + sigmap, + timer + ) + + self.logger: AsyncLogger = AsyncLogger( + logger_name=logger_name, + logger_type=logger_type, + log_level=log_level, + logger_enabled=logger_enabled + ) + + self.logger.initialize('%(message)s') + self.display = text + + self.enabled = enabled + self.logger_enabled = True + self.logger_mode = LoggerMode.CONSOLE + + self._stdout_lock = asyncio.Lock() + self._loop = asyncio.get_event_loop() + + @property + def console(self): + if self.logger_mode == LoggerMode.SYSTEM: + for handler in self.logger.handlers: + handler.formatter = Formatter('%(message)s') + + self.logger_mode = LoggerMode.CONSOLE + + return self + + @property + def system(self): + if self.logger_mode == LoggerMode.CONSOLE: + for handler in self.logger.handlers: + handler.formatter = Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s', + datefmt='%Y-%m-%dT%H:%M:%S.%Z' + ) + + self.logger_mode = LoggerMode.SYSTEM + + return self + + def append_message(self, message: str) -> Coroutine[None]: + return self.display.append_cli_message(message) + + def set_default_message(self, message: str) -> Coroutine[None]: + return self.display.clear_and_replace(message) + + def set_message_at(self, message_index: int, message:str) -> None: + if message_index < len(self.display.cli_messages): + self.display.cli_messages[message_index] = message + + def finalize(self): + self.display.finalized = True + + def group_finalize(self): + self.display.group_finalized = True + + async def debug(self, message: str, *args: List[Any], **kwargs: Mapping[str, Any]) -> Task: + + if self.logger_enabled: + await self._stdout_lock.acquire() + await self._clear_line() + + # Ensure output is Unicode + + log_result = await self.logger.debug( + message, + *args, + **kwargs + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + + return log_result + + async def info(self, message: str, *args: List[Any], **kwargs: Mapping[str, Any]) -> Task: + + if self.logger_enabled: + await self._stdout_lock.acquire() + await self._clear_line() + + # Ensure output is Unicode + + log_result = await self.logger.info( + message, + *args, + **kwargs + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + + return log_result + + async def warning(self, message: str, *args: List[Any], **kwargs: Mapping[str, Any]) -> Task: + + if self.logger_enabled: + await self._stdout_lock.acquire() + await self._clear_line() + + # Ensure output is Unicode + + log_result = await self.logger.warning( + message, + *args, + **kwargs + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + return log_result + + async def warn(self, message: str, *args: List[Any], **kwargs: Mapping[str, Any]) -> Task: + if self.logger_enabled: + await self._stdout_lock.acquire() + await self._clear_line() + + # Ensure output is Unicode + + log_result = await self.logger.warn( + message, + *args, + **kwargs + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + return log_result + + async def error(self, message: str, *args: List[Any], **kwargs: Mapping[str, Any]) -> Task: + + if self.logger_enabled: + await self._stdout_lock.acquire() + await self._clear_line() + + # Ensure output is Unicode + + log_result = await self.logger.error( + message, + *args, + **kwargs + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + return log_result + + async def critical(self, message: str, *args: List[Any], **kwargs: Mapping[str, Any]) -> Task: + + if self.logger_enabled: + await self._stdout_lock.acquire() + await self._clear_line() + + # Ensure output is Unicode + + log_result = await self.logger.critical( + message, + *args, + **kwargs + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + return log_result + + async def fatal(self, message: str, *args: List[Any], **kwargs: Mapping[str, Any]) -> Task: + + if self.logger_enabled: + await self._stdout_lock.acquire() + await self._clear_line() + + # Ensure output is Unicode + + log_result = await self.logger.fatal( + message, + *args, + **kwargs + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + return log_result + + async def __aenter__(self): + + if self.logger_enabled: + self.display.group_timer.reset() + await self.start() + + return self + + async def __aexit__(self, exc_type, exc_val, traceback): + # Avoid stop() execution for the 2nd time + + enabled = self.enabled and self.logger_enabled + + if enabled and self._spin_thread.done() is False and self._spin_thread.cancelled() is False: + await self.stop() + + return False # nothing is handled + + def __call__(self, fn): + @functools.wraps(fn) + async def inner(*args, **kwargs): + async with self: + + if inspect.iscoroutinefunction(fn): + return await fn(*args, **kwargs) + + else: + return fn(*args, **kwargs) + + return inner + + async def start(self): + if self.enabled: + + if self._sigmap: + self._register_signal_handlers() + + await self._hide_cursor() + self._start_time = time.time() + self._stop_time = None # Reset value to properly calculate subsequent spinner starts (if any) # pylint: disable=line-too-long + self._stop_spin = asyncio.Event() + self._hide_spin = asyncio.Event() + try: + self._spin_thread = asyncio.create_task(self._spin()) + finally: + # Ensure cursor is not hidden if any failure occurs that prevents + # getting it back + await self._show_cursor() + + self.display.start_cli_tasks() + + async def stop(self): + if self.enabled: + self._stop_time = time.time() + + if self._dfl_sigmap: + # Reset registered signal handlers to default ones + self._reset_signal_handlers() + + if self._spin_thread: + self._stop_spin.set() + await self._spin_thread + + await self._clear_line() + await self.display.stop_cli_tasks() + await self._show_cursor() + + async def hide(self): + """Hide the spinner to allow for custom writing to the terminal.""" + thr_is_alive = self._spin_thread and (self._spin_thread.done() is False and self._spin_thread.cancelled() is False) + + if thr_is_alive and not self._hide_spin.is_set(): + + # set the hidden spinner flag + self._hide_spin.set() + await self._clear_line() + + # flush the stdout buffer so the current line + # can be rewritten to + await self._loop.run_in_executor( + None, + sys.stdout.flush + ) + + async def show(self): + """Show the hidden spinner.""" + thr_is_alive = self._spin_thread and (self._spin_thread.done() is False and self._spin_thread.cancelled() is False) + + if thr_is_alive and self._hide_spin.is_set(): + + # clear the hidden spinner flag + self._hide_spin.clear() + + # clear the current line so the spinner is not appended to it + await self._clear_line() + + async def write(self, text): + if self.logger_enabled: + """Write text in the terminal without breaking the spinner.""" + # similar to tqdm.write() + # https://pypi.python.org/pypi/tqdm#writing-messages + await self._stdout_lock.acquire() + await self._clear_line() + + if isinstance(text, (str, bytes)): + _text = to_unicode(text) + else: + _text = str(text) + + # Ensure output is Unicode + assert isinstance(_text, str) + + await self._loop.run_in_executor( + None, + sys.stdout.write, + + ) + + self._cur_line_len = 0 + self._stdout_lock.release() + + async def ok(self, text="OK"): + if self.enabled: + await self.display.stop_cli_tasks() + """Set Ok (success) finalizer to a spinner.""" + _text = text if text else "OK" + await self._freeze(_text) + + async def fail(self, text="FAIL"): + if self.enabled: + await self.display.stop_cli_tasks() + """Set fail finalizer to a spinner.""" + _text = text if text else "FAIL" + await self._freeze(_text) + + async def _freeze(self, final_text): + """Stop spinner, compose last frame and 'freeze' it.""" + text = to_unicode(final_text) + self._last_frame = self._compose_out(text, mode="last") + + # Should be stopped here, otherwise prints after + # self._freeze call will mess up the spinner + await self.stop() + + + await self._loop.run_in_executor( + None, + sys.stdout.write, + self._last_frame + ) + + self._cur_line_len = 0 + + async def _spin(self): + while not self._stop_spin.is_set(): + + if self._hide_spin.is_set(): + # Wait a bit to avoid wasting cycles + await asyncio.sleep(self._interval) + continue + + await self._stdout_lock.acquire() + terminal_size = await self._loop.run_in_executor( + None, + get_terminal_size + ) + + terminal_width = terminal_size[0] + + # Compose output + spin_phase = next(self._cycle) + out = self._compose_out(spin_phase) + + if len(out) > terminal_width: + out = f'{out[:terminal_width-1]}...' + + # Write + + await self._clear_line() + + await self._loop.run_in_executor( + None, + sys.stdout.write, + out + ) + + await self._loop.run_in_executor( + None, + sys.stdout.flush + ) + + self._cur_line_len = max(self._cur_line_len, len(out)) + + # Wait + try: + await asyncio.wait_for(self._stop_spin.wait(), timeout=self._interval) + + except asyncio.TimeoutError: + pass + + self._stdout_lock.release() + + async def _clear_line(self): + if sys.stdout.isatty(): + # ANSI Control Sequence EL does not work in Jupyter + await self._loop.run_in_executor( + None, + sys.stdout.write, + "\r\033[K" + ) + + else: + fill = " " * self._cur_line_len + await self._loop.run_in_executor( + None, + sys.stdout.write, + sys.stdout.write, + f"\r{fill}\r" + ) + + @staticmethod + async def _show_cursor(): + loop = asyncio.get_event_loop() + if sys.stdout.isatty(): + # ANSI Control Sequence DECTCEM 1 does not work in Jupyter + await loop.run_in_executor( + None, + sys.stdout.write, + "\033[?25h" + ) + + await loop.run_in_executor( + None, + sys.stdout.flush + ) + + @staticmethod + async def _hide_cursor(): + loop = asyncio.get_event_loop() + if sys.stdout.isatty(): + # ANSI Control Sequence DECTCEM 1 does not work in Jupyter + await loop.run_in_executor( + None, + sys.stdout.write, + "\033[?25l" + ) + + await loop.run_in_executor( + None, + sys.stdout.flush + ) + + def _register_signal_handlers(self): + # SIGKILL cannot be caught or ignored, and the receiving + # process cannot perform any clean-up upon receiving this + # signal. + if signal.SIGKILL in self._sigmap: + raise ValueError( + "Trying to set handler for SIGKILL signal. " + "SIGKILL cannot be caught or ignored in POSIX systems." + ) + + for sig, sig_handler in self._sigmap.items(): + # A handler for a particular signal, once set, remains + # installed until it is explicitly reset. Store default + # signal handlers for subsequent reset at cleanup phase. + dfl_handler = signal.getsignal(sig) + self._dfl_sigmap[sig] = dfl_handler + + # ``signal.SIG_DFL`` and ``signal.SIG_IGN`` are also valid + # signal handlers and are not callables. + if callable(sig_handler): + # ``signal.signal`` accepts handler function which is + # called with two arguments: signal number and the + # interrupted stack frame. ``functools.partial`` solves + # the problem of passing spinner instance into the handler + # function. + sig_handler = functools.partial(sig_handler, spinner=self) + + self._loop.add_signal_handler(getattr(signal, sig.name), + lambda signame=sig.name: asyncio.create_task(sig_handler(self))) + + def _reset_signal_handlers(self): + for sig, sig_handler in self._dfl_sigmap.items(): + self._loop.add_signal_handler(getattr(signal, sig.name), + lambda signame=sig.name: asyncio.create_task(sig_handler(signame, self))) + diff --git a/hyperscale/logging/logger_types/handers/__init__.py b/hyperscale/logging/logger_types/handers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/logging/logger_types/handers/async_file_handler.py b/hyperscale/logging/logger_types/handers/async_file_handler.py new file mode 100644 index 0000000..950216c --- /dev/null +++ b/hyperscale/logging/logger_types/handers/async_file_handler.py @@ -0,0 +1,477 @@ +# The following code and documentation was inspired, and in some cases +# copied and modified, from the work of Vinay Sajip and contributors +# on cpython's logging package + +import abc +import asyncio +import datetime +import enum +import os +import re +import time +from typing import Callable, List, Optional + +from aiologger.handlers.base import Handler +from aiologger.records import LogRecord +from aiologger.utils import classproperty, get_running_loop + +from hyperscale.tools.filesystem import open +from hyperscale.tools.filesystem.text import AsyncTextIOWrapper + + +class AsyncFileHandler(Handler): + terminator = "\n" + + def __init__( + self, filename: str, mode: str = "a", encoding: str = None + ) -> None: + super().__init__() + filename = os.fspath(filename) + self.absolute_file_path = os.path.abspath(filename) + self.mode = mode + self.encoding = encoding + self.stream: AsyncTextIOWrapper = None + self._initialization_lock = None + + @property + def initialized(self): + return self.stream is not None + + async def _init_writer(self): + """ + Open the current base file with the (original) mode and encoding. + """ + if not self._initialization_lock: + self._initialization_lock = asyncio.Lock() + + async with self._initialization_lock: + if not self.initialized: + self.stream = await open( + file=self.absolute_file_path, + mode=self.mode, + encoding=self.encoding, + ) + + async def flush(self): + await self.stream.flush() + + async def close(self): + if not self.initialized: + return + await self.stream.flush() + await self.stream.close() + self.stream = None + self._initialization_lock = None + + async def emit(self, record: LogRecord): + if not self.initialized: + await self._init_writer() + + try: + msg = self.formatter.format(record) + + # Write order is not guaranteed. String concatenation required + await self.stream.write(msg + self.terminator) + + await self.stream.flush() + except Exception as exc: + await self.handle_error(record, exc) + + +Namer = Callable[[str], str] +Rotator = Callable[[str, str], None] + + +class BaseAsyncRotatingFileHandler(AsyncFileHandler, metaclass=abc.ABCMeta): + def __init__( + self, + filename: str, + mode: str = "a", + encoding: str = None, + namer: Namer = None, + rotator: Rotator = None, + ) -> None: + super().__init__(filename, mode, encoding) + self.mode = mode + self.encoding = encoding + self.namer = namer + self.rotator = rotator + self._rollover_lock: Optional[asyncio.Lock] = None + + def should_rollover(self, record: LogRecord) -> bool: + raise NotImplementedError + + async def do_rollover(self): + raise NotImplementedError + + async def emit(self, record: LogRecord): # type: ignore + """ + Emit a record. + + Output the record to the file, catering for rollover as described + in `do_rollover`. + """ + try: + if self.should_rollover(record): + if not self._rollover_lock: + self._rollover_lock = asyncio.Lock() + + async with self._rollover_lock: + if self.should_rollover(record): + await self.do_rollover() + await super().emit(record) + except Exception as exc: + await self.handle_error(record, exc) + + def rotation_filename(self, default_name: str) -> str: + """ + Modify the filename of a log file when rotating. + + This is provided so that a custom filename can be provided. + + :param default_name: The default name for the log file. + """ + if self.namer is None: + return default_name + + return self.namer(default_name) + + async def rotate(self, source: str, dest: str): + """ + When rotating, rotate the current log. + + The default implementation calls the 'rotator' attribute of the + handler, if it's callable, passing the source and dest arguments to + it. If the attribute isn't callable (the default is None), the source + is simply renamed to the destination. + + :param source: The source filename. This is normally the base + filename, e.g. 'test.log' + :param dest: The destination filename. This is normally + what the source is rotated to, e.g. 'test.log.1'. + """ + if self.rotator is None: + # logging issue 18940: A file may not have been created if delay is True. + loop = get_running_loop() + if await loop.run_in_executor(None, lambda: os.path.exists(source)): + await loop.run_in_executor( # type: ignore + None, lambda: os.rename(source, dest) + ) + else: + self.rotator(source, dest) + + +class RolloverInterval(str, enum.Enum): + SECONDS = "S" + MINUTES = "M" + HOURS = "H" + DAYS = "D" + MONDAYS = "W0" + TUESDAYS = "W1" + WEDNESDAYS = "W2" + THURSDAYS = "W3" + FRIDAYS = "W4" + SATURDAYS = "W5" + SUNDAYS = "W6" + MIDNIGHT = "MIDNIGHT" + + @classproperty + def WEEK_DAYS(cls): + return ( + cls.MONDAYS, + cls.TUESDAYS, + cls.WEDNESDAYS, + cls.THURSDAYS, + cls.FRIDAYS, + cls.SATURDAYS, + cls.SUNDAYS, + ) + + +ONE_MINUTE_IN_SECONDS = 60 +ONE_HOUR_IN_SECONDS = 60 * 60 +ONE_DAY_IN_SECONDS = ONE_HOUR_IN_SECONDS * 24 +ONE_WEEK_IN_SECONDS = 7 * ONE_DAY_IN_SECONDS + + +class AsyncTimedRotatingFileHandler(BaseAsyncRotatingFileHandler): + """ + Handler for logging to a file, rotating the log file at certain timed + intervals. + + If `backup_count` is > 0, when rollover is done, no more than `backup_count` + files are kept - the oldest ones are deleted. + + | when | at_time behavior | + |------------|--------------------------------------------------------| + | SECONDS | at_time will be ignored | + | MINUTES | -- // -- | + | HOURS | -- // -- | + | DAYS | at_time will be IGNORED. See also MIDNIGHT | + | MONDAYS | rotation happens every WEEK on MONDAY at ${at_time} | + | TUESDAYS | rotation happens every WEEK on TUESDAY at ${at_time} | + | WEDNESDAYS | rotation happens every WEEK on WEDNESDAY at ${at_time} | + | THURSDAYS | rotation happens every WEEK on THURSDAY at ${at_time} | + | FRIDAYS | rotation happens every WEEK on FRIDAY at ${at_time} | + | SATURDAYS | rotation happens every WEEK on SATURDAY at ${at_time} | + | SUNDAYS | rotation happens every WEEK on SUNDAY at ${at_time} | + | MIDNIGHT | rotation happens every DAY at ${at_time} | + """ + + def __init__( + self, + filename: str, + when: RolloverInterval = RolloverInterval.HOURS, + interval: int = 1, + backup_count: int = 0, + encoding: str = None, + utc: bool = False, + at_time: datetime.time = None, + ) -> None: + super().__init__(filename=filename, mode="a", encoding=encoding) + self.when = when.upper() + self.backup_count = backup_count + self.utc = utc + self.at_time = at_time + # Calculate the real rollover interval, which is just the number of + # seconds between rollovers. Also set the filename suffix used when + # a rollover occurs. Current 'when' events supported: + # S - Seconds + # M - Minutes + # H - Hours + # D - Days + # midnight - roll over at midnight + # W{0-6} - roll over on a certain day; 0 - Monday + # + # Case of the 'when' specifier is not important; lower or upper case + # will work. + if self.when == RolloverInterval.SECONDS: + self.interval = 1 # one second + self.suffix = "%Y-%m-%d_%H-%M-%S" + ext_match = r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(\.\w+)?$" + elif self.when == RolloverInterval.MINUTES: + self.interval = ONE_MINUTE_IN_SECONDS # one minute + self.suffix = "%Y-%m-%d_%H-%M" + ext_match = r"^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}(\.\w+)?$" + elif self.when == RolloverInterval.HOURS: + self.interval = ONE_HOUR_IN_SECONDS # one hour + self.suffix = "%Y-%m-%d_%H" + ext_match = r"^\d{4}-\d{2}-\d{2}_\d{2}(\.\w+)?$" + elif ( + self.when == RolloverInterval.DAYS + or self.when == RolloverInterval.MIDNIGHT + ): + self.interval = ONE_DAY_IN_SECONDS # one day + self.suffix = "%Y-%m-%d" + ext_match = r"^\d{4}-\d{2}-\d{2}(\.\w+)?$" + elif self.when.startswith("W"): + if self.when not in RolloverInterval.WEEK_DAYS: + raise ValueError( + f"Invalid day specified for weekly rollover: {self.when}" + ) + self.interval = ONE_DAY_IN_SECONDS * 7 # one week + self.day_of_week = int(self.when[1]) + self.suffix = "%Y-%m-%d" + ext_match = r"^\d{4}-\d{2}-\d{2}(\.\w+)?$" + else: + raise ValueError(f"Invalid RolloverInterval specified: {self.when}") + + self.ext_match = re.compile(ext_match, re.ASCII) + self.interval = self.interval * interval # multiply by units requested + # The following line added because the filename passed in could be a + # path object (see Issue #27493), but self.baseFilename will be a string + filename = self.absolute_file_path + if os.path.exists(filename): # todo: IO. Remove or postpone + t = int(os.stat(filename).st_mtime) + else: + t = int(time.time()) + self.rollover_at = self.compute_rollover(t) + + def compute_rollover(self, current_time: int) -> int: + """ + Work out the rollover time based on the specified time. + + If we are rolling over at midnight or weekly, then the interval is + already known. need to figure out is WHEN the next interval is. + In other words, if you are rolling over at midnight, then your base + interval is 1 day, but you want to start that one day clock at midnight, + not now. So, we have to fudge the `rollover_at` value in order to trigger + the first rollover at the right time. After that, the regular interval + will take care of the rest. Note that this code doesn't care about + leap seconds. :) + """ + result = current_time + self.interval + + if ( + self.when == RolloverInterval.MIDNIGHT + or self.when in RolloverInterval.WEEK_DAYS + ): + if self.utc: + t = time.gmtime(current_time) + else: + t = time.localtime(current_time) + current_hour = t[3] + current_minute = t[4] + current_second = t[5] + current_day = t[6] + # r is the number of seconds left between now and the next rotation + if self.at_time is None: + rotate_ts = ONE_DAY_IN_SECONDS + else: + rotate_ts = ( + self.at_time.hour * 60 + self.at_time.minute + ) * 60 + self.at_time.second + + r = rotate_ts - ( + (current_hour * 60 + current_minute) * 60 + current_second + ) + if r < 0: + # Rotate time is before the current time (for example when + # self.rotateAt is 13:45 and it now 14:15), rotation is + # tomorrow. + r += ONE_DAY_IN_SECONDS + current_day = (current_day + 1) % 7 + result = current_time + r + # If we are rolling over on a certain day, add in the number of days until + # the next rollover, but offset by 1 since we just calculated the time + # until the next day starts. There are three cases: + # Case 1) The day to rollover is today; in this case, do nothing + # Case 2) The day to rollover is further in the interval (i.e., today is + # day 2 (Wednesday) and rollover is on day 6 (Sunday). Days to + # next rollover is simply 6 - 2 - 1, or 3. + # Case 3) The day to rollover is behind us in the interval (i.e., today + # is day 5 (Saturday) and rollover is on day 3 (Thursday). + # Days to rollover is 6 - 5 + 3, or 4. In this case, it's the + # number of days left in the current week (1) plus the number + # of days in the next week until the rollover day (3). + # The calculations described in 2) and 3) above need to have a day added. + # This is because the above time calculation takes us to midnight on this + # day, i.e. the start of the next day. + if self.when in RolloverInterval.WEEK_DAYS: + day = current_day # 0 is Monday + if day != self.day_of_week: + if day < self.day_of_week: + days_to_wait = self.day_of_week - day + else: + days_to_wait = 6 - day + self.day_of_week + 1 + new_rollover_at = result + ( + days_to_wait * ONE_DAY_IN_SECONDS + ) + if not self.utc: + dst_now = t[-1] + dst_at_rollover = time.localtime(new_rollover_at)[-1] + if dst_now != dst_at_rollover: + if not dst_now: + # DST kicks in before next rollover, so we need to deduct an hour + new_rollover_at -= ONE_HOUR_IN_SECONDS + else: + # DST bows out before next rollover, so we need to add an hour + new_rollover_at += ONE_HOUR_IN_SECONDS + result = new_rollover_at + return result + + def should_rollover(self, record: LogRecord) -> bool: + """ + Determine if rollover should occur. + + record is not used, as we are just comparing times, but it is needed so + the method signatures are the same + """ + t = int(time.time()) + if t >= self.rollover_at: + return True + return False + + async def get_files_to_delete(self) -> List[str]: + """ + Determine the files to delete when rolling over. + """ + dir_name, base_name = os.path.split(self.absolute_file_path) + loop = get_running_loop() + file_names = await loop.run_in_executor( + None, lambda: os.listdir(dir_name) + ) + result = [] + prefix = base_name + "." + plen = len(prefix) + for file_name in file_names: + if file_name[:plen] == prefix: + suffix = file_name[plen:] + if self.ext_match.match(suffix): + result.append(os.path.join(dir_name, file_name)) + if len(result) < self.backup_count: + return [] + else: + result.sort(reverse=True) # os.listdir order is not defined + return result[: len(result) - self.backup_count] + + async def _delete_files(self, file_paths: List[str]): + loop = get_running_loop() + for file_path in file_paths: + await loop.run_in_executor( # type: ignore + None, lambda: os.unlink(file_path) + ) + + async def do_rollover(self): + """ + do a rollover; in this case, a date/time stamp is appended to the filename + when the rollover happens. However, you want the file to be named for the + start of the interval, not the current time. If there is a backup count, + then we have to get a list of matching filenames, sort them and remove + the one with the oldest suffix. + """ + if self.stream: + await self.stream.close() + self.stream = None + # get the time that this sequence started at and make it a TimeTuple + current_time = int(time.time()) + dst_now = time.localtime(current_time)[-1] + t = self.rollover_at - self.interval + if self.utc: + time_tuple = time.gmtime(t) + else: + time_tuple = time.localtime(t) + dst_then = time_tuple[-1] + if dst_now != dst_then: + if dst_now: + addend = ONE_HOUR_IN_SECONDS + else: + addend = -ONE_HOUR_IN_SECONDS + time_tuple = time.localtime(t + addend) + destination_file_path = self.rotation_filename( + self.absolute_file_path + + "." + + time.strftime(self.suffix, time_tuple) + ) + loop = get_running_loop() + if await loop.run_in_executor( + None, lambda: os.path.exists(destination_file_path) + ): + await loop.run_in_executor( + None, lambda: os.unlink(destination_file_path) + ) + await self.rotate(self.absolute_file_path, destination_file_path) + if self.backup_count > 0: + files_to_delete = await self.get_files_to_delete() + if files_to_delete: + await self._delete_files(files_to_delete) + + await self._init_writer() + new_rollover_at = self.compute_rollover(current_time) + while new_rollover_at <= current_time: + new_rollover_at = new_rollover_at + self.interval + # If DST changes and midnight or weekly rollover, adjust for this. + if ( + self.when == RolloverInterval.MIDNIGHT + or self.when in RolloverInterval.WEEK_DAYS + ) and not self.utc: + dst_at_rollover = time.localtime(new_rollover_at)[-1] + if dst_now != dst_at_rollover: + if not dst_now: + # DST kicks in before next rollover, so we need to deduct an hour + addend = -ONE_HOUR_IN_SECONDS + else: + # DST bows out before next rollover, so we need to add an hour + addend = ONE_HOUR_IN_SECONDS + new_rollover_at += addend + self.rollover_at = new_rollover_at diff --git a/hyperscale/logging/logger_types/logger.py b/hyperscale/logging/logger_types/logger.py new file mode 100644 index 0000000..326474e --- /dev/null +++ b/hyperscale/logging/logger_types/logger.py @@ -0,0 +1,63 @@ +from typing import Dict, Generic, TypeVar + +from hyperscale.logging.config import LoggingConfig + +from .async_filesystem_logger import AsyncFilesystemLogger +from .async_logger import AsyncLogger +from .async_spinner import AsyncSpinner +from .logger_types import LoggerTypes +from .logger_types_map import LoggerTypesMap +from .sync_filesystem_logger import SyncFilesystemLogger +from .sync_logger import SyncLogger + +A = TypeVar('A') +S = TypeVar('S') + + +class Logger(Generic[A, S]): + + def __init__(self, config: LoggingConfig) -> None: + self.logger_types = LoggerTypesMap() + self.log_level = config.log_level + self.logger_enabled = config.logger_enabled + + if config.logger_type == LoggerTypes.SPINNER: + self.aio: A = self.logger_types.async_loggers.get(config.logger_type, AsyncSpinner)(**config.spinner) + self.sync: S = None + + elif config.logger_type == LoggerTypes.FILESYSTEM or config.logger_type == LoggerTypes.DISTRIBUTED_FILESYSTEM: + self.aio: A = self.logger_types.async_loggers.get(config.logger_type, AsyncFilesystemLogger)(**config.filesystem_logger) + self.sync: S = self.logger_types.sync_loggers.get(config.logger_type, SyncFilesystemLogger)(**config.filesystem_logger) + + else: + self.aio: A = self.logger_types.async_loggers.get(config.logger_type, AsyncLogger)(**config.cli_logger) + self.sync: S = self.logger_types.sync_loggers.get(config.logger_type, SyncLogger)(**config.cli_logger) + + def set_patterns(self, pattern: str, datefmt_pattern: str=None): + + if isinstance(self.aio, AsyncLogger) and isinstance(self.sync, SyncLogger): + self.aio.initialize( + pattern, + datefmt_pattern=datefmt_pattern + ) + + self.sync.initialize( + pattern, + datefmt_pattern=datefmt_pattern + ) + + def create_filelogger(self, filepath: str): + if isinstance(self.aio, AsyncFilesystemLogger) and isinstance(self.sync, SyncFilesystemLogger): + self.aio.update_files(filepath) + self.sync.update_files(filepath) + + @property + def files(self) -> Dict[str, str]: + + logfiles = {} + if isinstance(self.aio, AsyncFilesystemLogger) and isinstance(self.sync, SyncFilesystemLogger): + logfiles.update(self.aio.filepaths) + logfiles.update(self.sync.filepaths) + + return logfiles + diff --git a/hyperscale/logging/logger_types/logger_types.py b/hyperscale/logging/logger_types/logger_types.py new file mode 100644 index 0000000..ad64961 --- /dev/null +++ b/hyperscale/logging/logger_types/logger_types.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class LoggerTypes(Enum): + CONSOLE='console' + DISTRIBUTED='distributed' + FILESYSTEM='filesystem' + DISTRIBUTED_FILESYSTEM='distributed_filesysem' + HEDRA='hyperscale' + SPINNER='spinner' + + diff --git a/hyperscale/logging/logger_types/logger_types_map.py b/hyperscale/logging/logger_types/logger_types_map.py new file mode 100644 index 0000000..d392ca9 --- /dev/null +++ b/hyperscale/logging/logger_types/logger_types_map.py @@ -0,0 +1,53 @@ +from typing import Dict, Union + +from .async_filesystem_logger import AsyncFilesystemLogger +from .async_logger import AsyncLogger +from .async_spinner import AsyncSpinner +from .logger_types import LoggerTypes +from .sync_filesystem_logger import SyncFilesystemLogger +from .sync_logger import SyncLogger + + +class LoggerTypesMap: + + def __init__(self) -> None: + self.logger_types={} + + for logger_type in LoggerTypes: + logger_name = logger_type.name.lower() + self.logger_types[logger_name] = logger_type + + self.async_loggers: Dict[LoggerTypes, Union[AsyncLogger, AsyncFilesystemLogger]] = { + LoggerTypes.CONSOLE: AsyncLogger, + LoggerTypes.DISTRIBUTED: AsyncLogger, + LoggerTypes.HYPERSCALE: AsyncLogger, + LoggerTypes.FILESYSTEM: AsyncFilesystemLogger, + LoggerTypes.DISTRIBUTED_FILESYSTEM: AsyncFilesystemLogger, + LoggerTypes.SPINNER: AsyncSpinner + } + + self.sync_loggers: Dict[LoggerTypes, Union[SyncLogger, SyncFilesystemLogger]] = { + LoggerTypes.CONSOLE: SyncLogger, + LoggerTypes.DISTRIBUTED: SyncLogger, + LoggerTypes.HYPERSCALE: SyncLogger, + LoggerTypes.FILESYSTEM: SyncFilesystemLogger, + LoggerTypes.DISTRIBUTED_FILESYSTEM: SyncFilesystemLogger + } + + self.logger_names = { + logger_type: logger_name for logger_name, logger_type in self.logger_types.items() + } + + @property + def names(self): + return list(self.logger_types.keys()) + + @property + def types(self): + return list(self.logger_types.values()) + + def get_name(self, logger_type: LoggerTypes): + return self.logger_names.get(logger_type) + + def get_type(self, logger_name: str): + return self.logger_types.get(logger_name) \ No newline at end of file diff --git a/hyperscale/logging/logger_types/sync_filesystem_logger.py b/hyperscale/logging/logger_types/sync_filesystem_logger.py new file mode 100644 index 0000000..df617ee --- /dev/null +++ b/hyperscale/logging/logger_types/sync_filesystem_logger.py @@ -0,0 +1,104 @@ +import datetime +import os +from logging import Formatter +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path +from typing import Dict + +from aiologger.levels import LogLevel + +from hyperscale.logging.logger_types.handers.async_file_handler import RolloverInterval + +from .logger_types import LoggerTypes +from .sync_logger import SyncLogger + + +class SyncFilesystemLogger: + + def __init__( + self, + logger_name: str=None, + logger_type: LoggerTypes=LoggerTypes.FILESYSTEM, + log_level: LogLevel=LogLevel.NOTSET, + logfiles_directory: str=None, + logger_enabled: bool = True, + rotation_interval_type: RolloverInterval=RolloverInterval.DAYS, + rotation_interval: int=1, + backup_count: int=1, + rotation_time: datetime.time=None + ) -> None: + self.logger_name = logger_name + self.logger_type = logger_type + self.log_level = log_level + self.logger_enabled = logger_enabled + self.rotation_interval_type = rotation_interval_type + self.rotation_interval = rotation_interval + self.backups = backup_count + self.rotation_time = rotation_time + self.logfiles_directory: str = logfiles_directory + + self.files: Dict[str, SyncLogger] = {} + self.filepaths: Dict[str, str] = {} + + def __getitem__(self, logger_name: str=None): + + file_logger = self.files.get(logger_name) + + if file_logger is None: + file_logger = self._create_file_logger( + logger_name, + os.path.join( + self.logfiles_directory, + f'{logger_name}.log' + ) + ) + + self.files[logger_name] = file_logger + + return file_logger + + def _create_file_logger(self, logger_name: str, filepath: str) -> SyncLogger: + sync_logger = SyncLogger( + logger_name=logger_name, + logger_type=self.logger_type, + log_level=self.log_level, + logger_enabled=self.logger_enabled + ) + + sync_file_handler = TimedRotatingFileHandler( + filepath, + when=self.rotation_interval_type, + interval=self.rotation_interval, + backupCount=self.backups, + atTime=self.rotation_time + ) + + sync_file_handler.formatter = Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(funcName)s:%(lineno)d - %(message)s', + '%Y-%m-%dT%H:%M:%S.%Z' + ) + + sync_logger.addHandler(sync_file_handler) + + return sync_logger + + def create_logfile(self, log_filename: str): + + filepath = os.path.join(self.logfiles_directory, log_filename) + + if os.path.exists(filepath) is False: + log_file = open(filepath, 'w') + log_file.close() + + self.update_files(filepath) + + else: + self.update_files(filepath) + + def update_files(self, filepath: str): + logger_name = Path(filepath).stem + + if self.files.get(logger_name) is None: + self.files[logger_name] = self._create_file_logger(logger_name, filepath) + self.filepaths[logger_name] = filepath + diff --git a/hyperscale/logging/logger_types/sync_logger.py b/hyperscale/logging/logger_types/sync_logger.py new file mode 100644 index 0000000..6485e9d --- /dev/null +++ b/hyperscale/logging/logger_types/sync_logger.py @@ -0,0 +1,36 @@ +import sys +import logging +from typing import Mapping, Any, List +from aiologger.levels import LogLevel +from .logger_types import LoggerTypes + + +class SyncLogger(logging.Logger): + + def __init__( + self, + logger_name: str=None, + logger_type: LoggerTypes=LoggerTypes.CONSOLE, + log_level: LogLevel = LogLevel.INFO, + logger_enabled: bool=True + ) -> None: + super().__init__(logger_name, level=log_level) + self.logger_name = logger_name + self.logger_type = logger_type + self.log_level = log_level + self.logger_enabled = logger_enabled + self.handler = None + + def initialize(self, pattern: str, datefmt_pattern: str=None): + stream_handler = logging.StreamHandler(stream=sys.stdout) + self.setLevel(self.level) + stream_handler.setFormatter( + logging.Formatter(pattern, datefmt=datefmt_pattern) + ) + + self.addHandler(stream_handler) + + + def _log(self, level: int, msg: object, *args: List[Any], **kwargs: Mapping[str, Any]) -> None: + if self.logger_enabled: + return super()._log(level, msg, *args, **kwargs) \ No newline at end of file diff --git a/hyperscale/logging/logging_manager.py b/hyperscale/logging/logging_manager.py new file mode 100644 index 0000000..f57f7c1 --- /dev/null +++ b/hyperscale/logging/logging_manager.py @@ -0,0 +1,74 @@ +import asyncio +from typing import List +from aiologger.levels import LogLevel +from .spinner import ProgressText +from .logger_types import ( + LoggerTypes, + LoggerTypesMap +) + + +class LoggingManager: + + def __init__(self) -> None: + + self.logger_types = LoggerTypesMap() + self.enabled_loggers = {} + + for logger_type in self.logger_types.async_loggers.keys(): + self.enabled_loggers[logger_type] = True + + self.log_levels = { + 'info': LogLevel.INFO, + 'debug': LogLevel.DEBUG, + 'error': LogLevel.ERROR, + 'warning': LogLevel.WARNING, + 'warn': LogLevel.WARN, + 'critical': LogLevel.CRITICAL, + 'fatal': LogLevel.FATAL + } + + self.log_level = LogLevel.INFO + self.log_level_name = 'info' + self.logfiles_directory = None + self.progress_display = ProgressText() + + def update_log_level(self, log_level_name: str): + self.log_level = self.log_levels.get(log_level_name, LogLevel.INFO) + self.log_level_name = log_level_name + + def get_logger_enabled_state(self, logger_type: LoggerTypes): + return self.enabled_loggers.get(logger_type) + + def enable(self, *logger_types: List[LoggerTypes]): + for logger_type in logger_types: + self.enabled_loggers[logger_type] = True + + def disable(self, *logger_types: List[LoggerTypes]): + for logger_type in logger_types: + self.enabled_loggers[logger_type] = False + + def list_enabled(self): + return [ + logger_type for logger_type, logger_enabled in self.enabled_loggers.items() if logger_enabled + ] + + def list_disabled(self): + return [ + logger_type for logger_type, logger_enabled in self.enabled_loggers.items() if not logger_enabled + ] + + def list_enabled_type_names(self): + enabled_loggers = self.list_enabled() + return [ + self.logger_types.get_name(logger_type) for logger_type in enabled_loggers + ] + + def list_disabled_type_names(self): + disabled_loggers = self.list_disabled() + return [ + self.logger_types.get_name(logger_type) for logger_type in disabled_loggers + ] + + +logging_manager = LoggingManager() \ No newline at end of file diff --git a/hyperscale/logging/spinner/__init__.py b/hyperscale/logging/spinner/__init__.py new file mode 100644 index 0000000..4283d95 --- /dev/null +++ b/hyperscale/logging/spinner/__init__.py @@ -0,0 +1 @@ +from .progress_text import ProgressText \ No newline at end of file diff --git a/hyperscale/logging/spinner/progress_text.py b/hyperscale/logging/spinner/progress_text.py new file mode 100644 index 0000000..20fbb36 --- /dev/null +++ b/hyperscale/logging/spinner/progress_text.py @@ -0,0 +1,101 @@ +import asyncio +import datetime +from .timer import Timer + + +class ProgressText: + def __init__(self): + self.total_timer = Timer('Total') + self.group_timer = Timer('Group') + + self.selected_timer_name = self.group_timer.name + self.selected_timer = self.group_timer + + self.run_cli_task = False + self.run_timer_task = False + self.cli_message = '' + self.cli_messages = [] + self.next_cli_message = 0 + self._cli_task = None + self._timer_task = None + self.enabled = True + self.finalized = False + self.group_finalized = False + + def __str__(self): + + self.total_timer.update() + self.group_timer.update() + + if self.finalized: + return f'{self.cli_message} - {self.total_timer.elapsed_message}' + + elif self.group_finalized: + return f'{self.cli_message} - {self.group_timer.elapsed_message}' + + return f'> {self.cli_message} - {self.selected_timer.elapsed_message}' + + async def append_cli_message(self, text: str): + self.cli_message = text + self.cli_messages.append(text) + await asyncio.sleep(1) + + def start_cli_tasks(self): + if self.enabled: + self.finalized = False + self.group_finalized = False + self._timer_task = asyncio.create_task(self._start_timer_tasks()) + self._cli_task = asyncio.create_task(self._start_cli_tasks()) + + async def _start_cli_tasks(self): + self.run_cli_task = True + + while self.run_cli_task: + for cli_task in self.cli_messages: + + if self.run_cli_task is False: + return + + self.cli_message = cli_task + await asyncio.sleep(2.5) + + async def _start_timer_tasks(self): + self.run_timer_task = True + self.total_timer.update() + self.group_timer.update() + + while self.run_timer_task: + if self.selected_timer_name == self.total_timer.name: + self.selected_timer_name = self.group_timer.name + self.selected_timer = self.group_timer + + else: + self.selected_timer_name = self.total_timer.name + self.selected_timer = self.total_timer + + await asyncio.sleep(2) + if self.run_timer_task is False: + return + + + async def stop_cli_tasks(self): + self.run_cli_task = False + self.run_timer_task = False + + if self._cli_task and self._timer_task: + await self._cli_task + await self._timer_task + + self.cli_messages = [] + + async def pause_cli_tasks(self): + self.run_cli_task = False + if self._cli_task: + await self._cli_task + + async def clear_and_replace(self, message: str): + + await self.pause_cli_tasks() + self.cli_message = message + self.cli_messages = [message] + self.start_cli_tasks() diff --git a/hyperscale/logging/spinner/timer.py b/hyperscale/logging/spinner/timer.py new file mode 100644 index 0000000..7a09ae1 --- /dev/null +++ b/hyperscale/logging/spinner/timer.py @@ -0,0 +1,42 @@ +import datetime + +class Timer: + + def __init__(self, name: str) -> None: + self.name = name + self.elapsed = 0 + self.seconds = 0 + self.minutes = 0 + self.hours = 0 + self.start = datetime.datetime.now() + self.default_message = 'Pending...' + self.elapsed_message = self.default_message + + def __str__(self) -> str: + return self.elapsed_message + + def update(self): + current = datetime.datetime.now() + self.elapsed = round((current - self.start).total_seconds()) + + self.seconds = self.elapsed%60 + time_elapsed_string = f'{self.seconds}s' + + self.minutes = int(self.elapsed/60)%60 + if self.minutes > 0: + time_elapsed_string = f'{self.minutes}m.{self.seconds}s' + + self.hours = int(self.elapsed/3600) + if self.hours > 0: + time_elapsed_string = f'{self.hours}h.{self.minutes}m.{self.seconds}s' + + + self.elapsed_message = f'{self.name} Time Elapsed: {time_elapsed_string}' + + def finalize(self, message: str): + self.update() + return f'{message} - {self.elapsed_message}' + + def reset(self): + self.start = datetime.datetime.now() + self.elapsed_message = self.default_message \ No newline at end of file diff --git a/hyperscale/logging/table/__init__.py b/hyperscale/logging/table/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/logging/table/execution_summary_table.py b/hyperscale/logging/table/execution_summary_table.py new file mode 100644 index 0000000..f170829 --- /dev/null +++ b/hyperscale/logging/table/execution_summary_table.py @@ -0,0 +1,281 @@ +from collections import OrderedDict, defaultdict +from typing import Dict, List, Tuple, Union + +import plotille +from tabulate import tabulate + +from hyperscale.logging import HyperscaleLogger + +from .table_types import ExecutionResults + +CompletionRateSet = Tuple[str, List[Union[int, float]]] + + +class ExecutionSummaryTable: + + def __init__( + self, + execution_results: ExecutionResults + ) -> None: + + self.session_table: Union[str, None] = None + self.stages_table: Union[str, None] = None + self.stage_timings_table: Union[str, None] = None + + self.execution_results = execution_results + self.session_table_rows: List[OrderedDict] = [] + + self.stage_summary_tables = defaultdict(list) + self.stage_streamed_data = defaultdict(dict) + self._has_streamed = False + self._graph_time_steps: List[int] = [] + self.actions_and_tasks_common_table_rows: List[OrderedDict] = [] + self.actions_and_tasks_common_table: Union[str, None] = None + + self.session_metrics: List[str] = [ + 'total', + 'succeeded', + 'failed' + ] + + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.enabled_tables = { + 'session': False, + 'stages': True, + 'actions': True, + 'timings': False + } + + self.actions_and_tasks_table_rows: Dict[str, List[OrderedDict]] = defaultdict(list) + self.actions_and_tasks_tables: Dict[str, str] = {} + + def generate_tables(self): + self._generate_stage_and_session_tables() + self._generate_actions_and_tasks_table() + + self.session_table = tabulate( + self.session_table_rows, + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt=".2f" + ) + + self.stages_table = tabulate( + list(sorted( + self.stage_summary_tables.get('stage_results'), + key=lambda row: row['name'] + )), + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt=( + '.2f', + '.2f', + '.2f', + '.2f', + '.2f', + '.2f', + '.2f', + '.2f', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E' + ) + ) + + for table_name, table_rows in self.actions_and_tasks_table_rows.items(): + self.actions_and_tasks_table_rows[table_name] = list(sorted( + table_rows, + key=lambda row: row['name'] + )) + + self.actions_and_tasks_tables[table_name] = tabulate( + self.actions_and_tasks_table_rows[table_name], + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt=( + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E', + '.2E' + ) + ) + + self.actions_and_tasks_common_table_rows = list(sorted( + self.actions_and_tasks_common_table_rows, + key=lambda row: row['name'] + )) + + self.actions_and_tasks_common_table = tabulate( + self.actions_and_tasks_common_table_rows, + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt=( + '.2f', + '.2f', + '.2f', + '.2f', + '.2f', + '.2f' + ) + ) + + def show_tables(self): + + self.logger.console.sync.info('') + + if self._has_streamed and self.enabled_tables.get('stages'): + completion_rates: List[CompletionRateSet] = list(sorted( + self.stage_streamed_data.get( + 'completion_rates' + ).items(), + key=lambda completion_rate: completion_rate[0] + )) + + for stage_name, stage_completion_rates in completion_rates: + + stage_summary = self.execution_results.get(stage_name) + + graph_time_steps_count = len(self._graph_time_steps) + stage_completion_rates_count = len(stage_completion_rates) + + graph_size = min(graph_time_steps_count, stage_completion_rates_count) + + scatter_plot = plotille.scatter( + self._graph_time_steps[:graph_size], + stage_completion_rates[:graph_size], + width=120, + height=10, + y_min=0, + x_min=0, + x_max=int(round( + stage_summary.stage_metrics.time, + 0 + )), + linesep='\n', + X_label='time (sec)', + Y_label='completion rate', + lc='cyan', + marker='⨯' + ) + + self.logger.console.sync.info(f'''\n{stage_name} Completion Rates\n''') + self.logger.console.sync.info(f'''{scatter_plot}\n''') + + if self.enabled_tables.get('session'): + self.logger.console.sync.info('\n-- Session --\n') + self.logger.console.sync.info(f'''{self.session_table}\n''') + + + if self.enabled_tables.get('stages'): + self.logger.console.sync.info('\n-- Stages --\n') + self.logger.console.sync.info(f'''{self.stages_table}\n''') + + if self.enabled_tables.get('actions'): + self.logger.console.sync.info('\n-- Actions and Tasks --\n') + + self.logger.console.sync.info('Overall:\n') + self.logger.console.sync.info(f'''{self.actions_and_tasks_common_table}\n''') + + if self.enabled_tables.get('actions') and self.enabled_tables.get('timings'): + for table_name, table in self.actions_and_tasks_tables.items(): + self.logger.console.sync.info(f'{table_name.capitalize()}:\n') + self.logger.console.sync.info(f'''{table}\n''') + + def _generate_stage_and_session_tables(self): + + session_row = OrderedDict() + + for stage_summary in self.execution_results.values(): + + stage_summary_dict = stage_summary.stage_metrics.dict() + + for field_name in self.session_metrics: + if session_row.get(field_name) is None: + session_row[field_name] = stage_summary_dict.get(field_name) + + else: + session_row[field_name] += stage_summary_dict.get(field_name) + + table_row = OrderedDict() + for field_name in stage_summary.stage_table_header_keys: + header_name = stage_summary.stage_table_headers.get(field_name) + table_row[header_name] = stage_summary_dict.get(field_name) + + self.stage_summary_tables['stage_results'].append(table_row) + + if stage_summary.stage_streamed_analytics: + self._has_streamed = True + + time_steps = [] + current_batch_time = 0 + + for time_step in stage_summary.stage_metrics.streamed_batch_timings: + current_batch_time += time_step + time_steps.append(current_batch_time) + + self._graph_time_steps = time_steps + + self.stage_streamed_data['completed'][stage_summary.stage_metrics.name] = stage_summary.stage_metrics.streamed_completed + self.stage_streamed_data['succeeded'][stage_summary.stage_metrics.name] = stage_summary.stage_metrics.streamed_succeeded + self.stage_streamed_data['failed'][stage_summary.stage_metrics.name] = stage_summary.stage_metrics.streamed_failed + self.stage_streamed_data['completion_rates'][stage_summary.stage_metrics.name] = stage_summary.stage_metrics.streamed_completion_rates + + self.session_table_rows.append(session_row) + + def _generate_actions_and_tasks_table(self): + + for stage_name, stage_metrics in self.execution_results.items(): + for action_or_task_name, group_metrics_set in stage_metrics.action_and_task_metrics.items(): + + table_row = OrderedDict() + table_row['name'] = action_or_task_name + table_row['stage'] = stage_name + + common_metrics = stage_metrics.common_metrics.get(action_or_task_name) + common_metrics_data = common_metrics.dict() + for field_name, field_value in common_metrics_data.items(): + table_row[field_name] = field_value + + self.actions_and_tasks_common_table_rows.append(table_row) + + for group in stage_metrics.groups: + table_row = OrderedDict() + table_row['name'] = action_or_task_name + table_row['stage'] = stage_name + + metrics = group_metrics_set.get_group(group) + + for field_name, field_value in metrics.items(): + + header_name = field_name.replace( + f'{group}_', '' + ).replace( + '_', ' ' + ) + + table_row[header_name] = field_value + + self.actions_and_tasks_table_rows[group].append(table_row) diff --git a/hyperscale/logging/table/experiments_summary_table.py b/hyperscale/logging/table/experiments_summary_table.py new file mode 100644 index 0000000..51fa30f --- /dev/null +++ b/hyperscale/logging/table/experiments_summary_table.py @@ -0,0 +1,189 @@ +from collections import OrderedDict, defaultdict +from typing import Dict, List, Union + +from tabulate import tabulate + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiment_metrics_set_types import ( + ExperimentSummary, +) + + +class ExperimentsSummaryTable: + + def __init__( + self, + experiments_summaries: Dict[str, ExperimentSummary], + experiment_headers: Dict[str, Dict[str, str]], + experiment_headers_keys: Dict[str, List[str]] + + ) -> None: + self.experiments_summaries = experiments_summaries + self.experiment_headers = experiment_headers + self.experiment_header_keys = experiment_headers_keys + + self.experiments_table: Union[str, None] = None + self.variants_tables: Dict[str, Union[str, None]] = None + self.variants_table_rows: Dict[str, List[OrderedDict]] = defaultdict(list) + self.mutations_table: Union[str, None] = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.enabled_tables = { + 'experiments': False, + 'variants': False, + 'mutations': False + } + + + def generate_tables(self): + self.experiments_table = self._generate_experiments_table() + self.variants_tables = self._generate_variants_tables() + self.mutations_table = self._generate_mutations_table() + + def show_tables(self): + + if any(self.enabled_tables.values()): + self.logger.console.sync.info('\n-- Experiments --') + + if self.enabled_tables.get('experiments'): + self.logger.console.sync.info('\nExperiments:\n') + self.logger.console.sync.info(f'''{self.experiments_table}\n''') + + if self.enabled_tables.get('variants'): + self.logger.console.sync.info('\nVariants:\n') + + variants_table = self.variants_tables.get('variants_table') + self.logger.console.sync.info(f'''{variants_table}\n''') + + if self.mutations_table and self.enabled_tables.get('mutations'): + self.logger.console.sync.info('\nMutations:\n') + self.logger.console.sync.info(f'''{self.mutations_table}\n''') + + def _generate_experiments_table(self) -> str: + + experiment_table_rows: List[str] = [] + + header_keys = self.experiment_header_keys.get('experiments_table_headers_keys') + + for experiment_summary in self.experiments_summaries.values(): + + table_row = OrderedDict() + + for field_name in header_keys: + + experiment_summary_dict = experiment_summary.dict() + + headers = self.experiment_headers.get('experiment_table_headers') + header_name = headers.get(field_name) + table_row[header_name] = experiment_summary_dict.get(field_name) + + experiment_table_rows.append(table_row) + + + return tabulate( + list(sorted( + experiment_table_rows, + key=lambda row: row['name'] + )), + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt='.2f' + ) + + def _generate_variants_tables(self) -> Dict[str, Union[str, None]]: + + variants_table_header_keys = self.experiment_header_keys.get('variant_table_headers_keys') + variants_stats_table_header_keys = self.experiment_header_keys.get('variants_stats_table_header_keys') + + for experiment_summary in self.experiments_summaries.values(): + + for variant_summary in experiment_summary.experiment_variant_summaries.values(): + table_row = OrderedDict() + + for field_name in variants_table_header_keys: + + variant_summary_dict = variant_summary.dict() + + headers = self.experiment_headers.get('variants_table_headers') + header_name = headers.get(field_name) + table_row[header_name] = variant_summary_dict.get(field_name) + + self.variants_table_rows['variants_table_rows'].append(table_row) + + table_row = OrderedDict() + + for field_name in variants_stats_table_header_keys: + + variant_summary_dict = variant_summary.dict() + + headers = self.experiment_headers.get('variants_table_headers') + header_name = headers.get(field_name) + table_row[header_name] = variant_summary_dict.get(field_name) + + self.variants_table_rows['variant_stats_table_rows'].append(table_row) + + variant_stats_table_rows = list(sorted( + self.variants_table_rows.get('variant_stats_table_rows'), + key=lambda row: row['name'] + )) + + for row in variant_stats_table_rows: + del row['name'] + + return { + 'variants_table': tabulate( + list(sorted( + self.variants_table_rows.get('variants_table_rows'), + key=lambda row: row['name'] + )), + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt=".2f" + ), + 'variant_stats_table': tabulate( + variant_stats_table_rows, + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt=".2f" + ) + } + + def _generate_mutations_table(self) -> str: + + mutation_table_rows: List[OrderedDict] = [] + + mutations_table_headers_keys = self.experiment_header_keys.get('mutations_table_header_keys') + + for experiment_summary in self.experiments_summaries.values(): + + for variant_summary in experiment_summary.experiment_variant_summaries.values(): + + for mutation_summary in variant_summary.variant_mutation_summaries.values(): + + mutation_summary_dict = mutation_summary.dict() + + table_row = OrderedDict() + + for field_name in mutations_table_headers_keys: + + headers = self.experiment_headers.get('mutations_table_headers') + header_name = headers.get(field_name) + + table_row[header_name] = mutation_summary_dict.get(field_name) + + mutation_table_rows.append(table_row) + + return tabulate( + list(sorted( + mutation_table_rows, + key=lambda row: row['name'] + )), + headers='keys', + missingval='None', + tablefmt="simple" + ) + \ No newline at end of file diff --git a/hyperscale/logging/table/summary_table.py b/hyperscale/logging/table/summary_table.py new file mode 100644 index 0000000..f4099ae --- /dev/null +++ b/hyperscale/logging/table/summary_table.py @@ -0,0 +1,121 @@ +from collections import defaultdict +from typing import Dict, List, Union + +from hyperscale.reporting.experiment.experiment_metrics_set import ExperimentSummary +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet + +from .execution_summary_table import ExecutionSummaryTable +from .experiments_summary_table import ExperimentsSummaryTable +from .system_summary_table import SystemSummaryTable +from .table_types import ( + ExecutionResults, + GraphExecutionResults, + GraphResults, + SystemMetricsCollection, +) + + +class SummaryTable: + + def __init__( + self, + graph_results: GraphResults, + summaries_visibility_config: Dict[str, bool]={} + ) -> None: + self.experiment_summary_table: Union[ExperimentsSummaryTable, None] = None + self.execution_summary_table: Union[ExecutionSummaryTable, None] = None + self.system_summary_table = SystemSummaryTable() + self.system_summary_table.enabled_tables.update({ + table_name: enabled for table_name, enabled in summaries_visibility_config.items() if table_name in self.system_summary_table.enabled_tables + }) + + graph_execution_results: GraphExecutionResults = graph_results.get('metrics') + submit_stage_system_metrics: SystemMetricsCollection = graph_results.get('submit_stage_system_metrics', {}) + graph_system_metrics: SystemMetricsSet = graph_results.get('graph_system_metrics') + + self.system_summary_table.graph_metrics_summary = graph_system_metrics + + for system_metrics in submit_stage_system_metrics.values(): + self.system_summary_table.system_metrics_summaries.append(system_metrics) + + experiment_summaries: Dict[str, ExperimentSummary] = {} + execution_results: ExecutionResults = {} + + experiment_headers: Dict[str, Dict[str]] = defaultdict(dict) + experiment_headers_keys: Dict[str, List[str]] = defaultdict(list) + + for results_set in graph_execution_results.values(): + stage_execution_results: ExecutionResults = results_set.get('stages') + execution_results.update(stage_execution_results) + + system_metrics: SystemMetricsSet = results_set.get('system_metrics') + self.system_summary_table.system_metrics_summaries.append(system_metrics) + + experiment_metrics_sets = results_set.get('experiment_metrics_sets', {}) + + for experiment_name, experiment in experiment_metrics_sets.items(): + experiment_summaries[experiment_name] = experiment.experiments_summary + + experiment_headers['experiment_table_headers'].update( + experiment.experiments_table_headers + ) + + experiment_headers['variants_table_headers'].update( + experiment.variants_table_headers + ) + + experiment_headers['mutations_table_headers'].update( + experiment.mutations_table_headers + ) + + if len(experiment_headers_keys['experiments_table_headers_keys']) == 0: + experiment_headers_keys['experiments_table_headers_keys'].extend( + experiment.experiments_table_header_keys + ) + + if len(experiment_headers_keys['variant_table_headers_keys']) == 0: + experiment_headers_keys['variant_table_headers_keys'].extend( + experiment.variants_table_header_keys + ) + + if len(experiment_headers_keys['variants_stats_table_header_keys']) == 0: + experiment_headers_keys['variants_stats_table_header_keys'].extend( + experiment.variants_stats_table_header_keys + ) + + if len(experiment_headers_keys['mutations_table_header_keys']) == 0: + experiment_headers_keys['mutations_table_header_keys'].extend( + experiment.mutations_table_headers_keys + ) + + self.execution_summary_table = ExecutionSummaryTable(execution_results) + self.execution_summary_table.enabled_tables.update({ + table_name: enabled for table_name, enabled in summaries_visibility_config.items() if table_name in self.execution_summary_table.enabled_tables + }) + + if len(experiment_summaries) > 0: + self.experiment_summary_table = ExperimentsSummaryTable( + experiment_summaries, + experiment_headers, + experiment_headers_keys + ) + + self.experiment_summary_table.enabled_tables.update({ + table_name: enabled for table_name, enabled in summaries_visibility_config.items() if table_name in self.experiment_summary_table.enabled_tables + }) + + def generate_tables(self): + self.execution_summary_table.generate_tables() + self.system_summary_table.generate_tables() + + if self.experiment_summary_table: + self.experiment_summary_table.generate_tables() + + def show_tables(self): + + self.execution_summary_table.show_tables() + + if self.experiment_summary_table: + self.experiment_summary_table.show_tables() + + self.system_summary_table.show_tables() \ No newline at end of file diff --git a/hyperscale/logging/table/system_summary_table.py b/hyperscale/logging/table/system_summary_table.py new file mode 100644 index 0000000..5c82fd0 --- /dev/null +++ b/hyperscale/logging/table/system_summary_table.py @@ -0,0 +1,308 @@ +from collections import OrderedDict, defaultdict +from typing import Dict, List, Union + +import plotille +from tabulate import tabulate + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet + + +class SystemSummaryTable: + + def __init__(self) -> None: + self.system_metrics_summaries: List[SystemMetricsSet] = [] + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.cpu_table: Union[str, None] = None + self.memory_table: Union[str, None] = None + self.mb_per_vu_table: Union[str, None] = None + self.graph_cpu_table: Union[str, None] = None + self.graph_memory_table: Union[str, None] = None + + self.cpu_plot_rows: Dict[str, List[Union[int, float]]] = defaultdict(list) + self.memory_plot_rows: Dict[str, List[Union[int, float]]] = defaultdict(list) + self.graph_metrics_summary: Union[SystemMetricsSet, None] = None + + self.enabled_tables = { + 'system': False + } + + def generate_tables(self): + self.graph_cpu_table = self._to_graph_cpu_table() + self.graph_memory_table = self._to_graph_memory_table() + + self.cpu_table = self._to_cpu_table() + self.memory_table = self._to_memory_table() + self.mb_per_vu_table = self._to_mb_per_vu_table() + + def show_tables(self): + + if any(self.enabled_tables.values()): + self.logger.console.sync.info('\n-- System Metrics --') + + if self.enabled_tables.get('system'): + for stage_name, monitor in self.graph_metrics_summary.cpu.stage_metrics.items(): + for monitor_name, metrics in monitor.items(): + + show_plot = self.graph_metrics_summary.cpu.visibility_filters[stage_name][monitor_name] + + if show_plot: + scatter_plot = plotille.scatter( + [idx for idx in range( + 1, + len(metrics) + 1 + )], + metrics, + width=120, + height=10, + y_min=0, + x_min=0, + x_max=len(metrics) + 1, + linesep='\n', + X_label='time (sec)', + Y_label='pct. utilization per thread', + lc='cyan', + marker='⨯' + ) + + self.logger.console.sync.info(f'''\n{monitor_name} % CPU Usage (Per Worker)\n''') + self.logger.console.sync.info(f'''{scatter_plot}\n''') + + for stage_name, monitor in self.graph_metrics_summary.memory.stage_metrics.items(): + for monitor_name, metrics in monitor.items(): + + show_plot = self.graph_metrics_summary.memory.visibility_filters[stage_name][monitor_name] + + if show_plot: + scatter_plot = plotille.scatter( + [idx for idx in range( + 1, + len(metrics) + 1 + )], + [ + round( + metric_value/(1024**3), + 2 + ) for metric_value in metrics + ], + width=120, + height=10, + y_min=0, + x_min=0, + x_max=len(metrics) + 1, + linesep='\n', + X_label='time (sec)', + Y_label='memory used (gb)', + lc='cyan', + marker='⨯' + ) + + self.logger.console.sync.info(f'''\n{monitor_name} % Memory Usage (gb)\n''') + self.logger.console.sync.info(f'''{scatter_plot}\n''') + + self.logger.console.sync.info('\nCPU (% per worker):\n') + self.logger.console.sync.info(f'''{self.graph_cpu_table}\n''') + + self.logger.console.sync.info('\nMemory (gb):\n') + self.logger.console.sync.info(f'''{self.graph_memory_table}\n''') + + if self.enabled_tables.get('system') and self.enabled_tables.get('stages'): + + seen_plots: List[str] = [] + + for metrics_set in self.system_metrics_summaries: + for stage_name, monitor in metrics_set.cpu.stage_metrics.items(): + for monitor_name, metrics in monitor.items(): + + show_plot = metrics_set.cpu.visibility_filters[stage_name][monitor_name] + + if show_plot and monitor_name not in seen_plots: + scatter_plot = plotille.scatter( + [idx for idx in range( + 1, + len(metrics) + 1 + )], + metrics, + width=120, + height=10, + y_min=0, + x_min=0, + x_max=len(metrics) + 1, + linesep='\n', + X_label='time (sec)', + Y_label='pct. utilization per thread', + lc='cyan', + marker='⨯' + ) + + self.logger.console.sync.info(f'''\n{monitor_name} % CPU Usage (Per Worker)\n''') + self.logger.console.sync.info(f'''{scatter_plot}\n''') + + seen_plots.append(monitor_name) + + seen_plots: List[str] = [] + + for metrics_set in self.system_metrics_summaries: + for stage_name, monitor in metrics_set.memory.stage_metrics.items(): + for monitor_name, metrics in monitor.items(): + + show_plot = metrics_set.cpu.visibility_filters[stage_name][monitor_name] + + if show_plot and monitor_name not in seen_plots: + scatter_plot = plotille.scatter( + [idx for idx in range( + 1, + len(metrics) + 1 + )], + [ + round( + metric_value/(1024**3), + 2 + ) for metric_value in metrics + ], + width=120, + height=10, + y_min=0, + x_min=0, + x_max=len(metrics) + 1, + linesep='\n', + X_label='time (sec)', + Y_label='memory used (gb)', + lc='cyan', + marker='⨯' + ) + + self.logger.console.sync.info(f'''\n{monitor_name} % Memory Usage (gb)\n''') + self.logger.console.sync.info(f'''{scatter_plot}\n''') + + seen_plots.append(monitor_name) + + self.logger.console.sync.info('\nCPU (% per worker):\n') + self.logger.console.sync.info(f'''{self.cpu_table}\n''') + + self.logger.console.sync.info('\nMemory (gb):\n') + self.logger.console.sync.info(f'''{self.memory_table}\n''') + + self.logger.console.sync.info('\nMemory per VU (mb):\n') + self.logger.console.sync.info(f'''{self.mb_per_vu_table}\n''') + + def _to_graph_cpu_table(self): + + table_row = OrderedDict() + for metric_group in self.graph_metrics_summary.cpu: + for row_name in SystemMetricsSet.metrics_table_keys: + table_row[row_name] = metric_group.record.get(row_name) + + return tabulate( + [table_row], + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt='.2f' + ) + + def _to_graph_memory_table(self): + + table_row = OrderedDict() + for metric_group in self.graph_metrics_summary.memory: + for row_name in SystemMetricsSet.metrics_table_keys: + table_row[row_name] = metric_group.record.get(row_name) + + return tabulate( + [table_row], + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt='.2f' + ) + + def _to_cpu_table(self): + + seen_monitors: List[str] = [] + table_rows: List[OrderedDict] = [] + + for metrics_set in self.system_metrics_summaries: + for metric_group in metrics_set.cpu: + + if metric_group.name not in seen_monitors: + + table_row = OrderedDict() + + for row_name in SystemMetricsSet.metrics_table_keys: + table_row[row_name] = metric_group.record.get(row_name) + + table_rows.append(table_row) + seen_monitors.append(metric_group.name) + + return tabulate( + list(sorted( + table_rows, + key=lambda row: row['name'] + )), + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt='.2f' + ) + + def _to_memory_table(self): + + seen_monitors: List[str] = [] + table_rows: List[OrderedDict] = [] + + for metrics_set in self.system_metrics_summaries: + for stage_metrics in metrics_set.memory.metrics.values(): + for metric_group in stage_metrics.values(): + + if metric_group.name not in seen_monitors: + + table_row = OrderedDict() + + for row_name in SystemMetricsSet.metrics_table_keys: + table_row[row_name] = metric_group.record.get(row_name) + + table_rows.append(table_row) + seen_monitors.append(metric_group.name) + + return tabulate( + list(sorted( + table_rows, + key=lambda row: row['name'] + )), + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt='.2f' + ) + + def _to_mb_per_vu_table(self): + + seen_monitors: List[str] = [] + table_rows: List[OrderedDict] = [] + + for metrics_set in self.system_metrics_summaries: + for monitor_name, stage_metrics in metrics_set.mb_per_vu.items(): + + if monitor_name not in seen_monitors: + + table_row = OrderedDict() + + for row_name in SystemMetricsSet.metrics_table_keys: + table_row[row_name] = stage_metrics.record.get(row_name) + + table_rows.append(table_row) + seen_monitors.append(monitor_name) + + return tabulate( + list(sorted( + table_rows, + key=lambda row: row['name'] + )), + headers='keys', + missingval='None', + tablefmt="simple", + floatfmt='.2f' + ) + diff --git a/hyperscale/logging/table/table_types.py b/hyperscale/logging/table/table_types.py new file mode 100644 index 0000000..9ba8856 --- /dev/null +++ b/hyperscale/logging/table/table_types.py @@ -0,0 +1,13 @@ +from typing import Dict, Union + +from hyperscale.reporting.experiment.experiment_metrics_set import ExperimentMetricsSet +from hyperscale.reporting.metric.stage_metrics_summary import StageMetricsSummary +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet + +ExecutionResults = Dict[str, StageMetricsSummary] + +GraphExecutionResults = Dict[str, Dict[str, Union[Dict[str, ExperimentMetricsSet], ExecutionResults, SystemMetricsSet]]] + +SystemMetricsCollection = Dict[str, SystemMetricsSet] + +GraphResults = Dict[str, Union[GraphExecutionResults, SystemMetricsCollection]] \ No newline at end of file diff --git a/hyperscale/monitoring/__init__.py b/hyperscale/monitoring/__init__.py new file mode 100644 index 0000000..9de2204 --- /dev/null +++ b/hyperscale/monitoring/__init__.py @@ -0,0 +1,2 @@ +from .cpu import CPUMonitor +from .memory import MemoryMonitor \ No newline at end of file diff --git a/hyperscale/monitoring/base/__init__.py b/hyperscale/monitoring/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/monitoring/base/exceptions.py b/hyperscale/monitoring/base/exceptions.py new file mode 100644 index 0000000..d243b9c --- /dev/null +++ b/hyperscale/monitoring/base/exceptions.py @@ -0,0 +1,6 @@ +class MonitorKilledError(Exception): + + def __init__(self, *args: object) -> None: + super().__init__( + 'Process killed or aborted.' + ) \ No newline at end of file diff --git a/hyperscale/monitoring/base/monitor.py b/hyperscale/monitoring/base/monitor.py new file mode 100644 index 0000000..ab4d97b --- /dev/null +++ b/hyperscale/monitoring/base/monitor.py @@ -0,0 +1,254 @@ +import asyncio +import functools +import psutil +import signal +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor +from .exceptions import MonitorKilledError +from typing import ( + Dict, + List, + Union, + Any +) + + +WorkerMetrics = Dict[int, Dict[str, List[Union[int, float]]]] + + +def handle_thread_loop_stop( + loop: asyncio.AbstractEventLoop, + monitor_name: str, + running_monitors: Dict[str, bool]=None, + +): + running_monitors[monitor_name] = False + loop.stop() + loop.close() + + +def handle_monitor_stop( + loop: asyncio.AbstractEventLoop=None, + running_monitors: Dict[str, bool]=None, + executor: ThreadPoolExecutor=None +): + try: + + for monitor_name in running_monitors: + running_monitors[monitor_name] = False + + executor.shutdown(wait=False, cancel_futures=True) + + if loop and loop.is_running(): + loop.stop() + + raise MonitorKilledError() + + except MonitorKilledError: + executor.shutdown(wait=False, cancel_futures=True) + pass + + except BrokenPipeError: + executor.shutdown(wait=False, cancel_futures=True) + raise MonitorKilledError() + + except RuntimeError: + executor.shutdown(wait=False, cancel_futures=True) + raise MonitorKilledError() + + finally: + pass + + +class BaseMonitor: + + def __init__(self) -> None: + self.active: Dict[str, List[int]] = defaultdict(list) + self.collected: Dict[str, List[int]] = defaultdict(list) + self.cpu_count = psutil.cpu_count() + self.stage_metrics: Dict[str, List[Union[int, float]]] = {} + self.visibility_filters: Dict[str, bool] = defaultdict(lambda: False) + self.stage_type: Union[Any, None] = None + self.worker_metrics: WorkerMetrics = defaultdict(dict) + self.is_execute_stage = False + + self._background_monitors: Dict[str, asyncio.Task] = {} + self._sync_background_monitors: Dict[str, asyncio.Future] = {} + self._running_monitors: Dict[str, bool] = {} + + self._loop: Union[asyncio.AbstractEventLoop, None] = None + self._executor: Union[ThreadPoolExecutor, None] = None + + def start_background_monitor_sync( + self, + monitor_name: str, + interval_sec: Union[int, float]=1 + ): + if self._executor is None: + self._executor = ThreadPoolExecutor( + max_workers=psutil.cpu_count(logical=False) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + signal_type: signal = getattr(signal, signame) + + signal.signal( + signal_type, + lambda sig, frame: handle_monitor_stop( + running_monitors=self._running_monitors, + executor=self._executor + ) + ) + + self._sync_background_monitors[monitor_name] = self._executor.submit( + functools.partial( + self._monitor_at_interval, + monitor_name, + interval_sec=interval_sec + ) + ) + + def aggregate_worker_stats(self): + raise NotImplementedError('Aggregate worker stats method method must be implemented in a non-base Monitor class.') + + def _collect_worker_stats(self): + + monitor_stats: Dict[str, List[Union[int, float]]] = defaultdict(list) + + for worker_id in self.worker_metrics: + for monitor_name, metrics in self.worker_metrics[worker_id].items(): + monitor_stats[monitor_name].append(metrics) + + return monitor_stats + + async def start_background_monitor( + self, + monitor_name: str, + interval_sec: Union[int, float]=1 + ): + if self._loop is None: + self._loop = asyncio.get_event_loop() + + if self._executor is None: + self._executor = ThreadPoolExecutor( + max_workers=psutil.cpu_count(logical=False) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + signal_type: signal = getattr(signal, signame) + + signal.signal( + signal_type, + lambda sig, frame: handle_monitor_stop( + loop=self._loop, + running_monitors=self._running_monitors, + executor=self._executor + ) + ) + + self._background_monitors[monitor_name] = self._loop.run_in_executor( + self._executor, + functools.partial( + self._monitor_at_interval, + monitor_name, + interval_sec=interval_sec + ) + ) + + def update_monitor(str, monitor_name: str) -> Union[int, float]: + raise NotImplementedError('Monitor background update method must be implemented in non-base Monitor class.') + + def store_monitor(self, monitor_name: str): + self.collected[monitor_name] = list(self.active[monitor_name]) + del self.active[monitor_name] + + def trim_monitor_samples( + self, + monitor_name: str, + trim_length: int + ): + if self.collected.get(monitor_name): + self.collected[monitor_name][:trim_length] + + async def _update_background_monitor( + self, + monitor_name: str, + interval_sec: Union[int, float]=1 + ): + while self._running_monitors.get(monitor_name): + await asyncio.sleep(interval_sec) + self.update_monitor(monitor_name) + + def _monitor_at_interval( + self, + monitor_name: str, + interval_sec: Union[int, float]=1 +): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + self._running_monitors[monitor_name] = True + + try: + loop.run_until_complete( + self._update_background_monitor( + monitor_name, + interval_sec=interval_sec + ) + ) + + except Exception: + self._running_monitors[monitor_name] = False + raise RuntimeError() + + def stop_background_monitor_sync( + self, + monitor_name: str + ): + + self._running_monitors[monitor_name] = False + self._sync_background_monitors[monitor_name].result() + + if self.active.get(monitor_name): + self.collected[monitor_name].extend( + list(self.active[monitor_name]) + ) + + async def stop_background_monitor( + self, + monitor_name: str + ): + self._running_monitors[monitor_name] = False + + if not self._background_monitors[monitor_name].cancelled(): + await self._background_monitors[monitor_name] + + if self.active.get(monitor_name): + self.collected[monitor_name].extend( + list(self.active[monitor_name]) + ) + + def stop_all_background_monitors_sync(self): + + for monitor_name in self._running_monitors.keys(): + self._running_monitors[monitor_name] = False + + for monitor in self._sync_background_monitors.values(): + monitor.result() + + for monitor_name in self._running_monitors.keys(): + self.collected[monitor_name] = list(self.active[monitor_name]) + + async def stop_all_background_monitors(self): + + for monitor_name in self._running_monitors.keys(): + self._running_monitors[monitor_name] = False + + await asyncio.gather(list(self._background_monitors.values())) + + for monitor_name in self._running_monitors.keys(): + self.collected[monitor_name] = list(self.active[monitor_name]) + + def close(self): + if self._executor: + self._executor.shutdown(wait=False, cancel_futures=True) \ No newline at end of file diff --git a/hyperscale/monitoring/cpu/__init__.py b/hyperscale/monitoring/cpu/__init__.py new file mode 100644 index 0000000..7e61da4 --- /dev/null +++ b/hyperscale/monitoring/cpu/__init__.py @@ -0,0 +1 @@ +from .monitor import CPUMonitor \ No newline at end of file diff --git a/hyperscale/monitoring/cpu/monitor.py b/hyperscale/monitoring/cpu/monitor.py new file mode 100644 index 0000000..a4c835a --- /dev/null +++ b/hyperscale/monitoring/cpu/monitor.py @@ -0,0 +1,31 @@ +import itertools +import statistics + +import psutil + +from hyperscale.monitoring.base.monitor import BaseMonitor + + +class CPUMonitor(BaseMonitor): + + def __init__(self) -> None: + super().__init__() + + def update_monitor(self, monitor_name: str): + self.active[monitor_name].append( + psutil.cpu_percent() + ) + + def aggregate_worker_stats(self): + + monitor_stats = self._collect_worker_stats() + + for monitor_name, metrics in monitor_stats.items(): + self.stage_metrics[monitor_name] = [ + statistics.median(cpu_usage) for cpu_usage in itertools.zip_longest( + *metrics, + fillvalue=0 + ) + ] + + \ No newline at end of file diff --git a/hyperscale/monitoring/memory/__init__.py b/hyperscale/monitoring/memory/__init__.py new file mode 100644 index 0000000..9e763cc --- /dev/null +++ b/hyperscale/monitoring/memory/__init__.py @@ -0,0 +1 @@ +from .monitor import MemoryMonitor \ No newline at end of file diff --git a/hyperscale/monitoring/memory/monitor.py b/hyperscale/monitoring/memory/monitor.py new file mode 100644 index 0000000..db43381 --- /dev/null +++ b/hyperscale/monitoring/memory/monitor.py @@ -0,0 +1,33 @@ +import itertools +import os + +import psutil + +from hyperscale.monitoring.base.monitor import BaseMonitor + + +class MemoryMonitor(BaseMonitor): + + def __init__(self) -> None: + super().__init__() + self.total_memory = psutil.virtual_memory().total + + def update_monitor(self, monitor_name: str): + process = psutil.Process(os.getpid()) + mem_info = process.memory_info() + + self.active[monitor_name].append(mem_info.rss) + + def aggregate_worker_stats(self): + monitor_stats = self._collect_worker_stats() + + for monitor_name, metrics in monitor_stats.items(): + + self.collected[monitor_name] = [ + sum(cpu_usage) for cpu_usage in itertools.zip_longest( + *metrics, + fillvalue=0 + ) + ] + + self.stage_metrics[monitor_name] = self.collected[monitor_name] diff --git a/hyperscale/plugins/__init__.py b/hyperscale/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/__init__.py b/hyperscale/plugins/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/common/__init__.py b/hyperscale/plugins/types/common/__init__.py new file mode 100644 index 0000000..6d7b112 --- /dev/null +++ b/hyperscale/plugins/types/common/__init__.py @@ -0,0 +1,2 @@ + +from .event import Event \ No newline at end of file diff --git a/hyperscale/plugins/types/common/event.py b/hyperscale/plugins/types/common/event.py new file mode 100644 index 0000000..360f40d --- /dev/null +++ b/hyperscale/plugins/types/common/event.py @@ -0,0 +1,34 @@ +import json +from typing import Generic, TypeVar + +from hyperscale.reporting.processed_result.types.base_processed_result import BaseEvent + +T = TypeVar('T') + +class Event(BaseEvent, Generic[T]): + + __slots__ = ( + 'fields', + 'type' + ) + + def __init__(self, result: T) -> None: + super().__init__(result) + + self.timings = result.as_timings() + self.time = result.times['complete'] - result.times['start'] + + def to_dict(self): + return { + 'name': self.name, + 'stage': self.stage, + 'shortname': self.shortname, + 'checks': [check.__name__ for check in self.checks], + 'error': str(self.error), + 'time': self.time, + 'type': self.type, + 'source': self.source, + } + + def serialize(self): + return json.dumps(self.to_dict()) \ No newline at end of file diff --git a/hyperscale/plugins/types/common/plugin.py b/hyperscale/plugins/types/common/plugin.py new file mode 100644 index 0000000..fed05c4 --- /dev/null +++ b/hyperscale/plugins/types/common/plugin.py @@ -0,0 +1,4 @@ +class Plugin: + + def __init__(self, *args, **kwargs) -> None: + pass \ No newline at end of file diff --git a/hyperscale/plugins/types/common/plugin_hook.py b/hyperscale/plugins/types/common/plugin_hook.py new file mode 100644 index 0000000..ae189c7 --- /dev/null +++ b/hyperscale/plugins/types/common/plugin_hook.py @@ -0,0 +1,19 @@ +from typing import Coroutine +from .types import PluginHooks + + +class PluginHook: + + def __init__( + self, + name: str, + shortname: str, + call: Coroutine, + plugin: str = None, + hook_type=PluginHooks.CUSTOM, + ) -> None: + self.name = name + self.shortname = shortname + self.call = call + self.plugin = plugin + self.hook_type = hook_type \ No newline at end of file diff --git a/hyperscale/plugins/types/common/registrar.py b/hyperscale/plugins/types/common/registrar.py new file mode 100644 index 0000000..82991cf --- /dev/null +++ b/hyperscale/plugins/types/common/registrar.py @@ -0,0 +1,45 @@ + +from types import FunctionType +from typing import Any, Dict +from .plugin_hook import PluginHook + + +class PluginRegistrar: + + all: Dict[str, PluginHook] = {} + module_paths: Dict[str, str] = {} + + def __init__(self, hook_type) -> None: + self.hook_type = hook_type + + def __call__(self, plugin_hook: FunctionType) -> Any: + self.module_paths[plugin_hook.__name__] = plugin_hook.__module__ + return self.add_hook(self.hook_type) + + def add_hook(self, hook_type: str): + def wrap_hook(): + def wrapped_method(func): + + hook_name = func.__qualname__ + hook_shortname = func.__name__ + + self.all[hook_name] = PluginHook( + hook_name, + hook_shortname, + func, + hook_type=hook_type + ) + + return func + + return wrapped_method + + return wrap_hook + + +def makePluginRegistrar(): + + return PluginRegistrar + + +plugin_registrar = makePluginRegistrar() \ No newline at end of file diff --git a/hyperscale/plugins/types/common/types.py b/hyperscale/plugins/types/common/types.py new file mode 100644 index 0000000..3843a89 --- /dev/null +++ b/hyperscale/plugins/types/common/types.py @@ -0,0 +1,23 @@ +from enum import Enum + + +class PluginHooks(Enum): + ON_OPTIMIZE='ON_OPTIMIZE' + ON_OPTIMIZER_GET_PARAMS='ON_OPTIMIZER_GET_PARAMS' + ON_OPTIMIZER_UPDATE_PARAMS='ON_OPTIMIZER_UPDATE_PARAMS' + ON_ENGINE_CONNECT='ON_ENGINE_CONNECT' + ON_ENGINE_EXECUTE='ON_ENGINE_EXECUTE' + ON_ENGINE_CLOSE='ON_ENGINE_CLOSE' + ON_PERSONA_SETUP='ON_PERSONA_SETUP' + ON_PERSONA_GENERATE='ON_PERSONA_GENERATE' + ON_PERSONA_SHUTDOWN='ON_PERSONA_SHUTDOWN' + ON_REPORTER_CONNECT='ON_REPORTER_CONNECT' + ON_REPORTER_CLOSE='ON_REPORTER_CLOSE' + ON_PROCESS_EVENTS='ON_PROCESS_EVENTS' + ON_PROCESS_SHARED_STATS='ON_PROCESS_SHARED_STATS' + ON_PROCESS_METRICS='ON_PROCESS_METRICS' + ON_PROCESS_CUSTOM_STATS='ON_PROCESS_CUSTOM_STATS' + ON_PROCESS_ERRORS='ON_PROCESS_ERRORS' + ON_EXTENSION_EXECUTE='ON_EXTENSION_EXECUTE' + ON_EXTENSION_PREPARE='ON_EXTENSION_PREPARE' + CUSTOM='CUSTOM' \ No newline at end of file diff --git a/hyperscale/plugins/types/engine/__init__.py b/hyperscale/plugins/types/engine/__init__.py new file mode 100644 index 0000000..2adc48f --- /dev/null +++ b/hyperscale/plugins/types/engine/__init__.py @@ -0,0 +1,8 @@ +from .engine_plugin import EnginePlugin +from .action import Action +from .result import Result +from .hooks.types import ( + connect, + execute, + close +) \ No newline at end of file diff --git a/hyperscale/plugins/types/engine/action.py b/hyperscale/plugins/types/engine/action.py new file mode 100644 index 0000000..2da1651 --- /dev/null +++ b/hyperscale/plugins/types/engine/action.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import inspect +from typing import Any, Dict, Generic, List, TypeVar + +from hyperscale.core.engines.types.common.base_action import BaseAction + +A = TypeVar('A') + +class Action(BaseAction[A], Generic[A]): + + __slots__ = ( + "source", + "use_security_context", + "security_context", + "type" + ) + + + def __init__(self, + name: str=None, + source: str=None, + user: str=None, + tags: List[Dict[str, str]] = [], + use_security_context: bool = False + ) -> Any: + + self.source = source + self.use_security_context = use_security_context + self.security_context: Any = None + self.type = A + self.plugin_type = None + + super().__init__( + name, + user, + tags + ) + + def setup(self): + pass + + def action_to_serializable(self): + attributes = inspect.getmembers( + self, + lambda attr: not(inspect.isroutine(attr)) + ) + + instance_attributes = [ + attr for attr in attributes if not( + attr[0].startswith('__') and attr[0].endswith('__') + ) + ] + + base_attributes = set(dir(Action) + dir(BaseAction)) + + serializable = { + 'name': self.name, + 'type': str(self.type), + 'plugin_type': self.plugin_type, + 'use_security_context': self.use_security_context, + 'security_context': self.security_context, + 'metadata': { + 'user': self.metadata.user, + 'tags': self.metadata.tags + }, + 'hooks': self.hooks.to_names(), + 'fields': {} + } + for attribute_name, attribute_value in instance_attributes: + + if serializable.get(attribute_name) is None and attribute_name not in base_attributes: + serializable['fields'][attribute_name] = attribute_value + + return serializable \ No newline at end of file diff --git a/hyperscale/plugins/types/engine/engine_plugin.py b/hyperscale/plugins/types/engine/engine_plugin.py new file mode 100644 index 0000000..45ab8a6 --- /dev/null +++ b/hyperscale/plugins/types/engine/engine_plugin.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import asyncio +import inspect +from typing import Any, Awaitable, Generic, TypeVar + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.engines.client.store import ActionsStore +from hyperscale.core.engines.types.common import Timeouts +from hyperscale.core.engines.types.custom.client import ( + MercuryCustomClient as CustomSession, +) +from hyperscale.plugins.types.common.event import Event +from hyperscale.plugins.types.common.plugin import Plugin +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks +from hyperscale.plugins.types.plugin_types import PluginType + +from .action import Action +from .result import Result + +A = TypeVar('A') +R = TypeVar('R') + + +class EnginePlugin(Generic[A, R], Plugin): + action: Action[A] = None + result: Result[R] = None + event: Event[Result[R]] = Event + security_context: Any = None + initialized: bool = False + type=PluginType.ENGINE + name: str=None + + def __init__(self, config: Config) -> None: + + super( + EnginePlugin, + self + ).__init__() + + self.hooks = {} + self.request_type = self.__class__.__name__ + self.next_name = None + self.suspend: bool = False + self.waiter = None + self.actions: ActionsStore = None + self.registered = {} + self.metadata_string: str = None + self.name = self.name + + self.config = config + + + self.action_type = A + self.request_type = R + + methods = inspect.getmembers(self, predicate=inspect.ismethod) + for _, method in methods: + + method_name = method.__qualname__ + hook: PluginHook = plugin_registrar.all.get(method_name) + + if hook: + hook.call = hook.call.__get__(self, self.__class__) + setattr(self, hook.shortname, hook.call) + + self.hooks[hook.hook_type] = hook + + + self.session = CustomSession[A, R]( + self, + concurrency=config.batch_size, + timeouts=Timeouts( + connect_timeout=config.connect_timeout, + total_timeout=config.request_timeout + ), + reset_connections=config.reset_connections + ) + + def __getattr__(self, attribute_name: str): + + session = object.__getattribute__(self, 'session') + + if hasattr(session, attribute_name): + return getattr(session, attribute_name) + + else: + # Default behaviour + return object.__getattribute__(self, attribute_name) + + async def execute(self, *args, **kwargs) -> Awaitable[Result[R]]: + + if self.registered.get(self.next_name) is None: + action: Action[A] = self.action( + self.next_name, + *args, + **kwargs + ) + + action.plugin_type = self.name + + await self.session.prepare(action) + + if self.intercept: + self.actions.store(self.next_name, action, self) + + loop = asyncio.get_event_loop() + self.waiter = loop.create_future() + await self.waiter + + return await self.session.execute_prepared_request( + self.session.registered.get(self.next_name) + ) + + async def close(self): + close_hook = self.hooks.get(PluginHooks.ON_ENGINE_CLOSE) + await close_hook.call() + + + diff --git a/hyperscale/plugins/types/engine/hooks/__init__.py b/hyperscale/plugins/types/engine/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/engine/hooks/types/__init__.py b/hyperscale/plugins/types/engine/hooks/types/__init__.py new file mode 100644 index 0000000..41545cd --- /dev/null +++ b/hyperscale/plugins/types/engine/hooks/types/__init__.py @@ -0,0 +1,3 @@ +from .close import close +from .connect import connect +from .execute import execute \ No newline at end of file diff --git a/hyperscale/plugins/types/engine/hooks/types/close.py b/hyperscale/plugins/types/engine/hooks/types/close.py new file mode 100644 index 0000000..71ff74a --- /dev/null +++ b/hyperscale/plugins/types/engine/hooks/types/close.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_ENGINE_CLOSE) +def close(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/engine/hooks/types/connect.py b/hyperscale/plugins/types/engine/hooks/types/connect.py new file mode 100644 index 0000000..9619c0e --- /dev/null +++ b/hyperscale/plugins/types/engine/hooks/types/connect.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_ENGINE_CONNECT) +def connect(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper diff --git a/hyperscale/plugins/types/engine/hooks/types/execute.py b/hyperscale/plugins/types/engine/hooks/types/execute.py new file mode 100644 index 0000000..d0ab5d1 --- /dev/null +++ b/hyperscale/plugins/types/engine/hooks/types/execute.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_ENGINE_EXECUTE) +def execute(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/engine/result.py b/hyperscale/plugins/types/engine/result.py new file mode 100644 index 0000000..21304cc --- /dev/null +++ b/hyperscale/plugins/types/engine/result.py @@ -0,0 +1,33 @@ + +from typing import Generic, TypeVar + +from hyperscale.core.engines.types.common.base_result import BaseResult + +from .action import Action + +R = TypeVar('R') + +class Result(BaseResult, Generic[R]): + + __slots__ = ( + 'times', + "type" + ) + + def __init__(self, action: Action, error: Exception = None) -> None: + super().__init__( + action.name, + action.source, + action.metadata.user, + action.metadata.tags, + action.plugin_type, + action.hooks.checks, + error + ) + + self.times = {} + + def as_timings(self): + return { + 'total': self.times['complete'] - self.times['start'] + } \ No newline at end of file diff --git a/hyperscale/plugins/types/extension/__init__.py b/hyperscale/plugins/types/extension/__init__.py new file mode 100644 index 0000000..cfaf6c1 --- /dev/null +++ b/hyperscale/plugins/types/extension/__init__.py @@ -0,0 +1,5 @@ +from .extension_plugin import ExtensionPlugin +from .hooks.types import ( + execute, + prepare +) diff --git a/hyperscale/plugins/types/extension/extension_plugin.py b/hyperscale/plugins/types/extension/extension_plugin.py new file mode 100644 index 0000000..2e62f41 --- /dev/null +++ b/hyperscale/plugins/types/extension/extension_plugin.py @@ -0,0 +1,80 @@ +import inspect +from inspect import Parameter +from typing import Any, Dict, Mapping, Union + +from hyperscale.plugins.types.common.plugin import Plugin +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks +from hyperscale.plugins.types.plugin_types import PluginType +from hyperscale.versioning.flags.types.unstable.flag import unstable_threadsafe + +from .types import ExtensionType + + +class ExtensionPlugin(Plugin): + type=PluginType.EXTENSION + name: str=None + + def __init__(self) -> None: + super( + ExtensionPlugin + ).__init__(self) + + self.hooks: Dict[PluginHooks, PluginHook] = {} + self.name = self.name + self._args: Dict[str, Mapping[str, Parameter]] = {} + + methods = inspect.getmembers(self, predicate=inspect.ismethod) + for _, method in methods: + + method_name = method.__qualname__ + hook: PluginHook = plugin_registrar.all.get(method_name) + + if hook: + hook.call = hook.call.__get__(self, self.__class__) + setattr(self, hook.shortname, hook.call) + + self.hooks[hook.hook_type] = hook + args = inspect.signature(hook.call) + + self._args[hook.hook_type] = args.parameters + + self.extension_type: ExtensionType = None + unstable_threadsafe() + + async def execute( + self, + **kwargs + ) -> Dict[str, Any]: + next_args = { + **kwargs + } + + execute_hook = self.hooks.get(PluginHooks.ON_EXTENSION_PREPARE) + hook_args = self._args.get(PluginHooks.ON_EXTENSION_PREPARE) + + result: Union[Dict[str, Any], Any] = await execute_hook.call( + **{ + name: value for name, value in next_args.items() if name in hook_args + } + ) + + if isinstance(result, dict) is False: + result = { + 'extension_data': result + } + + next_args = { + **kwargs, + **result + } + + prepare_hook = self.hooks.get(PluginHooks.ON_EXTENSION_EXECUTE) + hook_args = self._args.get(PluginHooks.ON_EXTENSION_EXECUTE) + + return await prepare_hook.call( + **{ + name: value for name, value in next_args.items() if name in hook_args + } + ) \ No newline at end of file diff --git a/hyperscale/plugins/types/extension/hooks/__init__.py b/hyperscale/plugins/types/extension/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/extension/hooks/types/__init__.py b/hyperscale/plugins/types/extension/hooks/types/__init__.py new file mode 100644 index 0000000..8416423 --- /dev/null +++ b/hyperscale/plugins/types/extension/hooks/types/__init__.py @@ -0,0 +1,2 @@ +from .execute.decorator import execute +from .prepare.decorator import prepare \ No newline at end of file diff --git a/hyperscale/plugins/types/extension/hooks/types/execute/__init__.py b/hyperscale/plugins/types/extension/hooks/types/execute/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/extension/hooks/types/execute/decorator.py b/hyperscale/plugins/types/extension/hooks/types/execute/decorator.py new file mode 100644 index 0000000..cae1631 --- /dev/null +++ b/hyperscale/plugins/types/extension/hooks/types/execute/decorator.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_EXTENSION_EXECUTE) +def execute(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper diff --git a/hyperscale/plugins/types/extension/hooks/types/prepare/__init__.py b/hyperscale/plugins/types/extension/hooks/types/prepare/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/extension/hooks/types/prepare/decorator.py b/hyperscale/plugins/types/extension/hooks/types/prepare/decorator.py new file mode 100644 index 0000000..871cdf9 --- /dev/null +++ b/hyperscale/plugins/types/extension/hooks/types/prepare/decorator.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_EXTENSION_PREPARE) +def prepare(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper diff --git a/hyperscale/plugins/types/extension/types.py b/hyperscale/plugins/types/extension/types.py new file mode 100644 index 0000000..cce7869 --- /dev/null +++ b/hyperscale/plugins/types/extension/types.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class ExtensionType(Enum): + GENERATOR='GENERATOR' \ No newline at end of file diff --git a/hyperscale/plugins/types/optimizer/__init__.py b/hyperscale/plugins/types/optimizer/__init__.py new file mode 100644 index 0000000..4802745 --- /dev/null +++ b/hyperscale/plugins/types/optimizer/__init__.py @@ -0,0 +1,6 @@ +from .optimizer_plugin import OptimizerPlugin +from .hooks.types import ( + get, + update, + optimize +) diff --git a/hyperscale/plugins/types/optimizer/hooks/__init__.py b/hyperscale/plugins/types/optimizer/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/optimizer/hooks/types/__init__.py b/hyperscale/plugins/types/optimizer/hooks/types/__init__.py new file mode 100644 index 0000000..72a1b3a --- /dev/null +++ b/hyperscale/plugins/types/optimizer/hooks/types/__init__.py @@ -0,0 +1,3 @@ +from .get import get +from .update import update +from .optimize import optimize \ No newline at end of file diff --git a/hyperscale/plugins/types/optimizer/hooks/types/get.py b/hyperscale/plugins/types/optimizer/hooks/types/get.py new file mode 100644 index 0000000..8fcf55a --- /dev/null +++ b/hyperscale/plugins/types/optimizer/hooks/types/get.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_OPTIMIZER_GET_PARAMS) +def get(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/optimizer/hooks/types/optimize.py b/hyperscale/plugins/types/optimizer/hooks/types/optimize.py new file mode 100644 index 0000000..0125c99 --- /dev/null +++ b/hyperscale/plugins/types/optimizer/hooks/types/optimize.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_OPTIMIZE) +def optimize(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/optimizer/hooks/types/update.py b/hyperscale/plugins/types/optimizer/hooks/types/update.py new file mode 100644 index 0000000..b859bbd --- /dev/null +++ b/hyperscale/plugins/types/optimizer/hooks/types/update.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_OPTIMIZER_UPDATE_PARAMS) +def update(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/optimizer/optimizer_plugin.py b/hyperscale/plugins/types/optimizer/optimizer_plugin.py new file mode 100644 index 0000000..7d951e8 --- /dev/null +++ b/hyperscale/plugins/types/optimizer/optimizer_plugin.py @@ -0,0 +1,53 @@ +import inspect +from typing import Any, Dict + +from hyperscale.core.graphs.stages.optimize.optimization.algorithms.types.base_algorithm import ( + BaseAlgorithm, +) +from hyperscale.plugins.types.common.plugin import Plugin +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks +from hyperscale.plugins.types.plugin_types import PluginType + + +class OptimizerPlugin(BaseAlgorithm, Plugin): + type=PluginType.OPTIMIZER + name: str=None + + def __init__(self, config: Dict[str, Any]) -> None: + self.hooks: Dict[PluginHooks, PluginHook] = {} + + methods = inspect.getmembers(self, predicate=inspect.ismethod) + for _, method in methods: + + method_name = method.__qualname__ + hook: PluginHook = plugin_registrar.all.get(method_name) + + if hook: + hook.call = hook.call.__get__(self, self.__class__) + setattr(self, hook.shortname, hook.call) + + self.hooks[hook.hook_type] = hook + + + on_get_params = self.hooks.get(PluginHooks.ON_OPTIMIZER_GET_PARAMS) + on_update_params = self.hooks.get(PluginHooks.ON_OPTIMIZER_UPDATE_PARAMS) + on_optimize = self.hooks.get(PluginHooks.ON_OPTIMIZE) + self.name = self.name + + if on_get_params: + self.get_params = on_get_params.call + + if on_update_params: + self.update_params = on_update_params.call + + self.optimize = on_optimize.call + + super().__init__(config) + + + + + + diff --git a/hyperscale/plugins/types/persona/__init__.py b/hyperscale/plugins/types/persona/__init__.py new file mode 100644 index 0000000..e851167 --- /dev/null +++ b/hyperscale/plugins/types/persona/__init__.py @@ -0,0 +1,6 @@ +from .persona_plugin import PersonaPlugin +from .hooks.types import ( + setup, + generate, + shutdown +) \ No newline at end of file diff --git a/hyperscale/plugins/types/persona/hooks/__init__.py b/hyperscale/plugins/types/persona/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/persona/hooks/types/__init__.py b/hyperscale/plugins/types/persona/hooks/types/__init__.py new file mode 100644 index 0000000..b23d173 --- /dev/null +++ b/hyperscale/plugins/types/persona/hooks/types/__init__.py @@ -0,0 +1,3 @@ +from .setup import setup +from .generate import generate +from .shutdown import shutdown \ No newline at end of file diff --git a/hyperscale/plugins/types/persona/hooks/types/generate.py b/hyperscale/plugins/types/persona/hooks/types/generate.py new file mode 100644 index 0000000..ba7b852 --- /dev/null +++ b/hyperscale/plugins/types/persona/hooks/types/generate.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PERSONA_GENERATE) +def generate(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/persona/hooks/types/setup.py b/hyperscale/plugins/types/persona/hooks/types/setup.py new file mode 100644 index 0000000..743ed42 --- /dev/null +++ b/hyperscale/plugins/types/persona/hooks/types/setup.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PERSONA_SETUP) +def setup(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/persona/hooks/types/shutdown.py b/hyperscale/plugins/types/persona/hooks/types/shutdown.py new file mode 100644 index 0000000..4cf53b9 --- /dev/null +++ b/hyperscale/plugins/types/persona/hooks/types/shutdown.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PERSONA_SHUTDOWN) +def shutdown(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/persona/persona_plugin.py b/hyperscale/plugins/types/persona/persona_plugin.py new file mode 100644 index 0000000..b384402 --- /dev/null +++ b/hyperscale/plugins/types/persona/persona_plugin.py @@ -0,0 +1,92 @@ + +import asyncio +import inspect +import time +from typing import Dict, Generic, TypeVar + +from hyperscale.core.engines.client.config import Config +from hyperscale.core.personas.types.default_persona.default_persona import ( + DefaultPersona, + cancel_pending, +) +from hyperscale.plugins.types.common.plugin import Plugin +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks +from hyperscale.plugins.types.plugin_types import PluginType + +T = TypeVar('T') + + +class PersonaPlugin(DefaultPersona, Generic[T], Plugin): + type=PluginType.PERSONA + name: str=None + + def __init__(self, config: Config): + super().__init__(config) + + self.hooks: Dict[PluginHooks, PluginHook] = {} + self.config = config + + methods = inspect.getmembers(self, predicate=inspect.ismethod) + for _, method in methods: + + method_name = method.__qualname__ + hook: PluginHook = plugin_registrar.all.get(method_name) + + if hook: + hook.call = hook.call.__get__(self, self.__class__) + setattr(self, hook.shortname, hook.call) + + self.hooks[hook.hook_type] = hook + + self.name = self.name + + async def execute(self): + + setup_hook = self.hooks.get(PluginHooks.ON_PERSONA_SETUP) + + if setup_hook: + await setup_hook.call() + + hooks = self._hooks + loop = asyncio.get_running_loop() + + generate_hook = self.hooks.get(PluginHooks.ON_PERSONA_GENERATE) + shutdown_hook = self.hooks.get(PluginHooks.ON_PERSONA_SHUTDOWN) + + await self.start_updates() + + self.start = time.monotonic() + completed, pending = await asyncio.wait([ + loop.create_task( + hooks[action_idx].session.execute_prepared_request( + hooks[action_idx].action + ) + ) async for action_idx in generate_hook.call() + ], timeout=self.graceful_stop) + + self.end = time.monotonic() + self.pending_actions = len(pending) + + results = await asyncio.gather(*completed) + + await self.stop_updates() + + await asyncio.gather(*[ + asyncio.create_task( + cancel_pending(pend) + ) for pend in pending + ]) + + for hook in hooks: + await hook.session.close() + + if shutdown_hook: + await shutdown_hook.call() + + self.total_actions = len(set(results)) + self.total_elapsed = self.end - self.start + self.optimized_params = None + + return results diff --git a/hyperscale/plugins/types/plugin_types.py b/hyperscale/plugins/types/plugin_types.py new file mode 100644 index 0000000..c2c4824 --- /dev/null +++ b/hyperscale/plugins/types/plugin_types.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class PluginType(Enum): + OPTIMIZER='OPTIMIZER' + PERSONA='PERSONA' + ENGINE='ENGINE' + REPORTER='REPORTER' + EXTENSION='EXTENSION' \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/__init__.py b/hyperscale/plugins/types/reporter/__init__.py new file mode 100644 index 0000000..4e9fdd2 --- /dev/null +++ b/hyperscale/plugins/types/reporter/__init__.py @@ -0,0 +1,12 @@ +from .reporter_plugin import ReporterPlugin +from .reporter_config import ReporterConfig +from .reporter_metrics import Metrics +from .hooks.types import ( + process_events, + process_shared, + process_metrics, + process_custom, + process_errors, + reporter_connect, + reporter_close +) \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/__init__.py b/hyperscale/plugins/types/reporter/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/plugins/types/reporter/hooks/types/__init__.py b/hyperscale/plugins/types/reporter/hooks/types/__init__.py new file mode 100644 index 0000000..6cb9d0e --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/__init__.py @@ -0,0 +1,7 @@ +from .reporter_connect import reporter_connect +from .reporter_close import reporter_close +from .process_events import process_events +from .process_shared import process_shared +from .process_metrics import process_metrics +from .process_custom import process_custom +from .process_errors import process_errors \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/types/process_custom.py b/hyperscale/plugins/types/reporter/hooks/types/process_custom.py new file mode 100644 index 0000000..e0c078c --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/process_custom.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PROCESS_CUSTOM_STATS) +def process_custom(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/types/process_errors.py b/hyperscale/plugins/types/reporter/hooks/types/process_errors.py new file mode 100644 index 0000000..c6d8666 --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/process_errors.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PROCESS_ERRORS) +def process_errors(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/types/process_events.py b/hyperscale/plugins/types/reporter/hooks/types/process_events.py new file mode 100644 index 0000000..eea919d --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/process_events.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PROCESS_EVENTS) +def process_events(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/types/process_metrics.py b/hyperscale/plugins/types/reporter/hooks/types/process_metrics.py new file mode 100644 index 0000000..28934f3 --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/process_metrics.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PROCESS_METRICS) +def process_metrics(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/types/process_shared.py b/hyperscale/plugins/types/reporter/hooks/types/process_shared.py new file mode 100644 index 0000000..9158ed8 --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/process_shared.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_PROCESS_SHARED_STATS) +def process_shared(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/types/reporter_close.py b/hyperscale/plugins/types/reporter/hooks/types/reporter_close.py new file mode 100644 index 0000000..b38ff77 --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/reporter_close.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_REPORTER_CLOSE) +def reporter_close(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/hooks/types/reporter_connect.py b/hyperscale/plugins/types/reporter/hooks/types/reporter_connect.py new file mode 100644 index 0000000..43c28fa --- /dev/null +++ b/hyperscale/plugins/types/reporter/hooks/types/reporter_connect.py @@ -0,0 +1,20 @@ +import functools + +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks + + +@plugin_registrar(PluginHooks.ON_REPORTER_CONNECT) +def reporter_connect(): + + def wrapper(func) -> PluginHook: + + @functools.wraps(func) + def decorator(*args, **kwargs): + + return func(*args, **kwargs) + + return decorator + + return wrapper \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/reporter_config.py b/hyperscale/plugins/types/reporter/reporter_config.py new file mode 100644 index 0000000..605ab85 --- /dev/null +++ b/hyperscale/plugins/types/reporter/reporter_config.py @@ -0,0 +1,51 @@ + +from __future__ import annotations +import inspect +from typing import Any, Generic, Optional, TypeVar +from pydantic import create_model + + +T = TypeVar('T') + + +class ReporterConfig(Generic[T]): + reporter_type: Optional[str] + + def __init__(self, **data: Any) -> None: + super().__init__() + + attributes = inspect.getmembers( + self, + lambda attr: not(inspect.isroutine(attr)) + ) + + instance_attributes = [ + attr for attr in attributes if not( + attr[0].startswith('__') and attr[0].endswith('__') + ) + ] + + base_attributes = set(dir(ReporterConfig)) + + model_attributes = {} + for attribute_name, attribute_value in instance_attributes: + + if attribute_name not in base_attributes: + model_attributes[attribute_name] = attribute_value + + model_attributes.update(data) + + for attribute_name, attribute_value in data.items(): + setattr(self, attribute_name, attribute_value) + + validation_model = create_model( + self.__class__.__name__, + **{ + field_name: ( + field_type, + ... + ) for field_name, field_type in self.__class__.__annotations__.items() + } + ) + + validation_model(**model_attributes) \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/reporter_metrics.py b/hyperscale/plugins/types/reporter/reporter_metrics.py new file mode 100644 index 0000000..e714414 --- /dev/null +++ b/hyperscale/plugins/types/reporter/reporter_metrics.py @@ -0,0 +1,6 @@ + +from hyperscale.reporting.metric import MetricsSet + + +class Metrics(MetricsSet): + pass \ No newline at end of file diff --git a/hyperscale/plugins/types/reporter/reporter_plugin.py b/hyperscale/plugins/types/reporter/reporter_plugin.py new file mode 100644 index 0000000..e394638 --- /dev/null +++ b/hyperscale/plugins/types/reporter/reporter_plugin.py @@ -0,0 +1,67 @@ +import asyncio +import inspect +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, Generic, TypeVar + +import psutil + +from hyperscale.plugins.types.common.plugin import Plugin +from hyperscale.plugins.types.common.plugin_hook import PluginHook +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.plugins.types.common.types import PluginHooks +from hyperscale.plugins.types.plugin_types import PluginType + +T = TypeVar('T') + + +class ReporterPlugin(Generic[T], Plugin): + type=PluginType.REPORTER + config: T = None + name: str=None + + def __init__(self, config: T) -> None: + + super( + ReporterPlugin, + self + ).__init__() + + self.loop = asyncio.get_event_loop() + self.executor = ThreadPoolExecutor( + max_workers=psutil.cpu_count(logical=False) + ) + self.name = self.name + + self.hooks: Dict[PluginHooks, PluginHook] = {} + + self.config = config + + methods = inspect.getmembers(self, predicate=inspect.ismethod) + for _, method in methods: + + method_name = method.__qualname__ + hook: PluginHook = plugin_registrar.all.get(method_name) + + if hook: + hook.call = hook.call.__get__(self, self.__class__) + setattr(self, hook.shortname, hook.call) + + self.hooks[hook.hook_type] = hook + + connect_hook = self.hooks.get(PluginHooks.ON_REPORTER_CONNECT) + close_hook = self.hooks.get(PluginHooks.ON_REPORTER_CLOSE) + + submit_events_hook = self.hooks.get(PluginHooks.ON_PROCESS_EVENTS) + submit_shared_stats_hook = self.hooks.get(PluginHooks.ON_PROCESS_SHARED_STATS) + submit_metrics_hook = self.hooks.get(PluginHooks.ON_PROCESS_METRICS) + submit_custom_stats_hook = self.hooks.get(PluginHooks.ON_PROCESS_CUSTOM_STATS) + submit_errors_hook = self.hooks.get(PluginHooks.ON_PROCESS_ERRORS) + + self.connect = connect_hook.call + self.close = close_hook.call + + self.submit_events = submit_events_hook.call + self.submit_common = submit_shared_stats_hook.call + self.submit_metrics = submit_metrics_hook.call + self.submit_custom = submit_custom_stats_hook.call + self.submit_errors = submit_errors_hook.call diff --git a/hyperscale/projects/__init__.py b/hyperscale/projects/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/projects/generation/__init__.py b/hyperscale/projects/generation/__init__.py new file mode 100644 index 0000000..821b3ae --- /dev/null +++ b/hyperscale/projects/generation/__init__.py @@ -0,0 +1,2 @@ +from .graph_types import GraphGenerator +from .plugin_types import PluginGenerator \ No newline at end of file diff --git a/hyperscale/projects/generation/generator/__init__.py b/hyperscale/projects/generation/generator/__init__.py new file mode 100644 index 0000000..e3c7630 --- /dev/null +++ b/hyperscale/projects/generation/generator/__init__.py @@ -0,0 +1 @@ +from .generator import Generator \ No newline at end of file diff --git a/hyperscale/projects/generation/generator/generator.py b/hyperscale/projects/generation/generator/generator.py new file mode 100644 index 0000000..09d8dfa --- /dev/null +++ b/hyperscale/projects/generation/generator/generator.py @@ -0,0 +1,121 @@ +import inspect +import sys +import importlib +import ntpath +import importlib +from collections import defaultdict +from typing import Dict, Any, Optional +from pathlib import Path + + +class Generator: + + def __init__(self, generator_types: Dict[str, Any], known_module_paths: Dict[str, str]={}) -> None: + self.generator_types = generator_types + self.known_module_paths = known_module_paths + self.global_imports = {} + self.local_imports = defaultdict(list) + self.locals = [] + + self.serialized_global_imports = [] + self.serialized_local_imports = [] + self.serialized_locals = [] + + def gather_required_items(self, generator_type: str) -> Dict[str, Any]: + generator_item = self.generator_types.get(generator_type) + path = inspect.getfile(generator_item) + + package_dir = Path(path).resolve().parent + package_dir_path = str(package_dir) + package_dir_module = package_dir_path.split('/')[-1] + + package = ntpath.basename(path) + package_slug = package.split('.')[0] + spec = importlib.util.spec_from_file_location(f'{package_dir_module}.{package_slug}', path) + + if path not in sys.path: + sys.path.append(str(package_dir.parent)) + + module = importlib.util.module_from_spec(spec) + sys.modules[module.__name__] = module + + spec.loader.exec_module(module) + + local_file_items = [item for item in dir(module) if not item.startswith("__")] + modules = { + member_name: member for member_name, member in inspect.getmembers(module) if member_name in local_file_items + } + + for module_name, module in modules.items(): + + try: + same_source_file = inspect.getfile(module) == inspect.getfile(generator_item) + + if same_source_file and module_name != generator_item.__name__: + self.locals.append(module) + + except TypeError: + pass + + self.locals.append(generator_item) + + return modules + + def collect_imports(self, generator_type: Optional[str], modules: Dict[str, Any]): + + generator_item = self.generator_types.get(generator_type) + generator_item_name = None + + + if generator_item: + generator_item_name = generator_item.__name__ + + for module_name, module in modules.items(): + if inspect.ismodule(module): + self.global_imports[module.__name__] = module.__name__ + + elif module_name != generator_item_name and module not in self.locals: + + module_path = self.known_module_paths.get(module_name, module.__module__) + minimum_viable_import_path = module_path.split('.') + + + run_minimum_path_search = True + while run_minimum_path_search: + minimum_viable_import_path.pop() + + if len(minimum_viable_import_path) > 0: + candidate_module_path = '.'.join(minimum_viable_import_path) + + source_module = importlib.import_module(candidate_module_path) + source_module_members = { + source_module_name: source_module for source_module_name, source_module in inspect.getmembers(source_module) + } + + if module_name in source_module_members: + module_path = candidate_module_path + else: + run_minimum_path_search = False + + self.local_imports[module_path].append(module_name) + + def serialize_items(self): + + self.serialized_global_imports = [ + f'import {global_import}' for global_import in self.global_imports + ] + + for local_import_module, local_import_items in self.local_imports.items(): + serialized_import_items = '\n\t'.join([ + f'{local_import_item},' for local_import_item in local_import_items + ]) + + self.serialized_local_imports.append( + f'from {local_import_module} import (\n\t{serialized_import_items}\n)' + ) + + self.serialized_locals = [ + inspect.getsource(local) for local in self.locals + ] + + diff --git a/hyperscale/projects/generation/graph_types/__init__.py b/hyperscale/projects/generation/graph_types/__init__.py new file mode 100644 index 0000000..e3f2a7b --- /dev/null +++ b/hyperscale/projects/generation/graph_types/__init__.py @@ -0,0 +1 @@ +from .graph_generator import GraphGenerator \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/graph_generator.py b/hyperscale/projects/generation/graph_types/graph_generator.py new file mode 100644 index 0000000..68239b3 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/graph_generator.py @@ -0,0 +1,166 @@ +from typing import List + +from hyperscale.core.hooks import depends +from hyperscale.core.hooks.types.base.registrar import registrar +from hyperscale.projects.generation.generator import Generator + +from .stages import ( + AnalyzeStage, + OptimizeStage, + SetupStage, +) +from .stages.execute import ( + ExecuteGraphQLHttp2Stage, + ExecuteGraphQLStage, + ExecuteHTTP2Stage, + ExecuteHTTP3Stage, + ExecuteHTTPStage, + ExecutePlaywrightStage, + ExecuteTaskStage, + ExecuteUDPStage, + ExecuteWebsocketStage, +) +from .stages.submit import ( + SubmitAWSLambdaResultsStage, + SubmitAWSTimestreamResultsStage, + SubmitBigQueryResultsStage, + SubmitBigTableResultsStage, + SubmitCassandraResultsStage, + SubmitCloudwatchResultsStage, + SubmitCosmosDBResultsStage, + SubmitCSVResultsStage, + SubmitDatadogResultsStage, + SubmitDogStatsDResultsStage, + SubmitGoogleCloudStorageResultsStage, + SubmitGraphiteResultsStage, + SubmitHoneycombResultsStage, + SubmitInfluxDBResultsStage, + SubmitJSONResultsStage, + SubmitKafkaResultsStage, + SubmitMongoDBResultsStage, + SubmitMySQLResultsStage, + SubmitNetdataResultsStage, + SubmitNewrelicResultsStage, + SubmitPostgresResultsStage, + SubmitPrometheusResultsStage, + SubmitRedisResultsStage, + SubmitS3ResultsStage, + SubmitSnowflakeResultsStage, + SubmitSQLiteResultsStage, + SubmitStatsDResultsStage, + SubmitTelegrafResultsStage, + SubmitTelegrafStatsDResultsStage, + SubmitTimescaleDBResultsStage, +) + + +class GraphGenerator(Generator): + + def __init__(self) -> None: + super().__init__({ + 'analyze': AnalyzeStage, + 'aws-lambda': SubmitAWSLambdaResultsStage, + 'aws-timestream': SubmitAWSTimestreamResultsStage, + 'big-query': SubmitBigQueryResultsStage, + 'big-table': SubmitBigTableResultsStage, + 'cassandra': SubmitCassandraResultsStage, + 'cloudwatch': SubmitCloudwatchResultsStage, + 'cosmosdb': SubmitCosmosDBResultsStage, + 'csv': SubmitCSVResultsStage, + 'datadog': SubmitDatadogResultsStage, + 'dogstatsd': SubmitDogStatsDResultsStage, + 'google-cloud-storage': SubmitGoogleCloudStorageResultsStage, + 'graphite': SubmitGraphiteResultsStage, + 'graphql': ExecuteGraphQLStage, + 'graphql-http2': ExecuteGraphQLHttp2Stage, + 'honeycomb': SubmitHoneycombResultsStage, + 'http':ExecuteHTTPStage, + 'http2': ExecuteHTTP2Stage, + 'http3': ExecuteHTTP3Stage, + 'influxdb': SubmitInfluxDBResultsStage, + 'json': SubmitJSONResultsStage, + 'kafka': SubmitKafkaResultsStage, + 'mongodb': SubmitMongoDBResultsStage, + 'mysql': SubmitMySQLResultsStage, + 'netdata': SubmitNetdataResultsStage, + 'newrelic': SubmitNewrelicResultsStage, + 'optimize': OptimizeStage, + 'playwright': ExecutePlaywrightStage, + 'postgres': SubmitPostgresResultsStage, + 'prometheus': SubmitPrometheusResultsStage, + 'redis': SubmitRedisResultsStage, + 's3': SubmitS3ResultsStage, + 'setup': SetupStage, + 'snowflake': SubmitSnowflakeResultsStage, + 'sqlite': SubmitSQLiteResultsStage, + 'statsd': SubmitStatsDResultsStage, + 'task': ExecuteTaskStage, + 'telegraf': SubmitTelegrafResultsStage, + 'telegraf-statsd': SubmitTelegrafStatsDResultsStage, + 'timescaledb': SubmitTimescaleDBResultsStage, + 'udp': ExecuteUDPStage, + 'websocket': ExecuteWebsocketStage, + 'depends': depends + }, registrar.module_paths) + + self.valid_types = [ + 'analyze', + 'checkpoint', + 'execute', + 'optimize', + 'setup', + 'submit', + 'teardown', + 'validate' + ] + + def generate_graph( + self, + stages: List[str], + engine: str=None, + reporter: str=None + ): + + if engine not in self.generator_types: + engine = 'http' + + if reporter not in self.generator_types: + reporter = 'json' + + for stage in stages: + + generator_type = stage + if stage == "execute": + generator_type = engine + + elif stage == "submit": + generator_type = reporter + + modules = self.gather_required_items(generator_type) + + self.collect_imports(generator_type, modules) + + self.collect_imports( + None, + { + 'depends': depends + } + ) + + self.serialize_items() + + serialized_imports = '\n'.join([ + *self.serialized_global_imports, + *self.serialized_local_imports + ]) + + for idx, serialized_stage in enumerate(self.serialized_locals): + + if idx > 0: + previous_stage_name = self.locals[idx-1].__name__ + self.serialized_locals[idx] = f'@depends({previous_stage_name})\n{serialized_stage}' + + return '\n\n'.join([ + serialized_imports, + *self.serialized_locals + ]) diff --git a/hyperscale/projects/generation/graph_types/stages/__init__.py b/hyperscale/projects/generation/graph_types/stages/__init__.py new file mode 100644 index 0000000..85881b3 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/__init__.py @@ -0,0 +1,3 @@ +from .generated_setup_stage import SetupStage +from .generated_optimize_stage import OptimizeStage +from .generated_analyze_stage import AnalyzeStage diff --git a/hyperscale/projects/generation/graph_types/stages/execute/__init__.py b/hyperscale/projects/generation/graph_types/stages/execute/__init__.py new file mode 100644 index 0000000..6c7f127 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/__init__.py @@ -0,0 +1,9 @@ +from .generated_graphql_execute_stage import ExecuteGraphQLStage +from .generated_graphql_http2_stage import ExecuteGraphQLHttp2Stage +from .generated_http_execute_stage import ExecuteHTTPStage +from .generated_http2_execute_stage import ExecuteHTTP2Stage +from .generated_http3_execute_stage import ExecuteHTTP3Stage +from .generated_playwright_execute_stage import ExecutePlaywrightStage +from .generated_task_execute_stage import ExecuteTaskStage +from .generated_udp_execute_stage import ExecuteUDPStage +from .generated_websocket_execute_stage import ExecuteWebsocketStage \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_execute_stage.py new file mode 100644 index 0000000..b083998 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_execute_stage.py @@ -0,0 +1,17 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecuteGraphQLStage(Execute): + + @action() + async def http_query(self): + return await self.client.graphql.query( + """ + query { + ... + } + """ + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_http2_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_http2_stage.py new file mode 100644 index 0000000..81a882e --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_graphql_http2_stage.py @@ -0,0 +1,17 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecuteGraphQLHttp2Stage(Execute): + + @action() + async def http2_query(self): + return await self.client.graphqlh2.query( + """ + query { + ... + } + """ + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_http2_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_http2_execute_stage.py new file mode 100644 index 0000000..11b7e6a --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_http2_execute_stage.py @@ -0,0 +1,11 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecuteHTTP2Stage(Execute): + + @action() + async def http2_get(self): + return await self.client.http2.get('https://') \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_http3_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_http3_execute_stage.py new file mode 100644 index 0000000..4ba319a --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_http3_execute_stage.py @@ -0,0 +1,11 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecuteHTTP3Stage(Execute): + + @action() + async def http3_get(self): + return await self.client.http3.get('https://') \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_http_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_http_execute_stage.py new file mode 100644 index 0000000..b8f5087 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_http_execute_stage.py @@ -0,0 +1,11 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecuteHTTPStage(Execute): + + @action() + async def http_get(self): + return await self.client.http.get('https://') \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_playwright_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_playwright_execute_stage.py new file mode 100644 index 0000000..8f515a0 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_playwright_execute_stage.py @@ -0,0 +1,15 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecutePlaywrightStage(Execute): + + @action() + async def open_page(self): + return await self.client.playwright.goto('https://') + + @action() + async def click_item(self): + return await self.client.playwright.click('[]') \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_task_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_task_execute_stage.py new file mode 100644 index 0000000..876a4d7 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_task_execute_stage.py @@ -0,0 +1,19 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import task + + +class ExecuteTaskStage(Execute): + + counter=0 + + @task() + async def task_http_get(self): + + response = await self.client.http.get('https://') + + if response.status >= 200 and response.status < 300: + self.counter += 1 + + return response \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_udp_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_udp_execute_stage.py new file mode 100644 index 0000000..c220834 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_udp_execute_stage.py @@ -0,0 +1,15 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecuteUDPStage(Execute): + + @action() + async def udp_send(self): + return await self.client.udp.send('https://', data="PING") + + @action() + async def udp_receive(self): + return await self.client.udp.receive('https://') \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/execute/generated_websocket_execute_stage.py b/hyperscale/projects/generation/graph_types/stages/execute/generated_websocket_execute_stage.py new file mode 100644 index 0000000..5e661be --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/execute/generated_websocket_execute_stage.py @@ -0,0 +1,15 @@ +from hyperscale.core.graphs.stages import ( + Execute, +) +from hyperscale.core.hooks import action + + +class ExecuteWebsocketStage(Execute): + + @action() + async def webbsocket_send(self): + return await self.client.websocket.send('https://', data={"PING": "PONG"}) + + @action() + async def webbsocket_listen(self): + return await self.client.websocket.listen('https://') \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/generated_analyze_stage.py b/hyperscale/projects/generation/graph_types/stages/generated_analyze_stage.py new file mode 100644 index 0000000..f0c865e --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/generated_analyze_stage.py @@ -0,0 +1,5 @@ +from hyperscale.core.graphs.stages import Analyze + + +class AnalyzeStage(Analyze): + pass \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/generated_checkpoint_stage.py b/hyperscale/projects/generation/graph_types/stages/generated_checkpoint_stage.py new file mode 100644 index 0000000..7d0db3b --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/generated_checkpoint_stage.py @@ -0,0 +1,21 @@ +import os +from typing import Any, Dict, List + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.graphs.stages import Checkpoint +from hyperscale.core.hooks.types import save + + +class CheckpointStage(Checkpoint): + + @save(save_path=f'{os.getcwd()}/checkpoint.json') + async def save_results( + self, + results: List[BaseResult]=[] + ) -> List[Dict[str, Any]]: + return [ + { + 'action_id': data.action_id, + 'elapsed': data.complete - data.start + } for data in results + ] \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/generated_optimize_stage.py b/hyperscale/projects/generation/graph_types/stages/generated_optimize_stage.py new file mode 100644 index 0000000..9de29f1 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/generated_optimize_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Optimize + + +class OptimizeStage(Optimize): + optimize_iterations=10 + algorithm='shg' + optimize_params={ + 'batch_size': (0.5, 2) + } \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/generated_setup_stage.py b/hyperscale/projects/generation/graph_types/stages/generated_setup_stage.py new file mode 100644 index 0000000..325bc21 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/generated_setup_stage.py @@ -0,0 +1,6 @@ +from hyperscale.core.graphs.stages import Setup + + +class SetupStage(Setup): + batch_size=1000 + total_time='1m' \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/generated_teardown_stage.py b/hyperscale/projects/generation/graph_types/stages/generated_teardown_stage.py new file mode 100644 index 0000000..7fbbd90 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/generated_teardown_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Teardown +from hyperscale.core.hooks import teardown + + +class TeardownStage(Teardown): + + @teardown() + async def teardown_previous_stage(self): + pass \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/generated_validate_stage.py b/hyperscale/projects/generation/graph_types/stages/generated_validate_stage.py new file mode 100644 index 0000000..88a1a26 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/generated_validate_stage.py @@ -0,0 +1,15 @@ +from typing import Awaitable + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.graphs.stages import Validate +from hyperscale.core.hooks import validate + + +class ValidateStage(Validate): + + @validate('') + async def validate_action(self, action_or_task: Awaitable[BaseResult]): + result: BaseResult = await action_or_task() + assert result is not None + assert result.action_id is not None + assert result.error is False \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/__init__.py b/hyperscale/projects/generation/graph_types/stages/submit/__init__.py new file mode 100644 index 0000000..9322253 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/__init__.py @@ -0,0 +1,30 @@ +from .generated_aws_lambda_results_stage import SubmitAWSLambdaResultsStage +from .generated_aws_timestream_results_stage import SubmitAWSTimestreamResultsStage +from .generated_bigquery_results_stage import SubmitBigQueryResultsStage +from .generated_bigtable_results_stage import SubmitBigTableResultsStage +from .generated_cassandra_results_stage import SubmitCassandraResultsStage +from .generated_cloudwatch_results_stage import SubmitCloudwatchResultsStage +from .generated_cosmosdb_results_stage import SubmitCosmosDBResultsStage +from .generated_csv_results_stage import SubmitCSVResultsStage +from .generated_datadog_results_stage import SubmitDatadogResultsStage +from .generated_dogstatsd_results_stage import SubmitDogStatsDResultsStage +from .generated_google_cloud_storage_results_stage import SubmitGoogleCloudStorageResultsStage +from .generated_graphite_results_stage import SubmitGraphiteResultsStage +from .generated_honeycomb_results_stage import SubmitHoneycombResultsStage +from .generated_influxdb_results_stage import SubmitInfluxDBResultsStage +from .generated_json_results_stage import SubmitJSONResultsStage +from .generated_kafka_results_stage import SubmitKafkaResultsStage +from .generated_mongodb_results_stage import SubmitMongoDBResultsStage +from .generated_mysql_results_stage import SubmitMySQLResultsStage +from .generated_netdata_results_stage import SubmitNetdataResultsStage +from .generated_newrelic_results_stage import SubmitNewrelicResultsStage +from .generated_postgres_results_stage import SubmitPostgresResultsStage +from .generated_prometheus_results_stage import SubmitPrometheusResultsStage +from .generated_redis_results_stage import SubmitRedisResultsStage +from .generated_s3_results_stage import SubmitS3ResultsStage +from .generated_snowflake_results_stage import SubmitSnowflakeResultsStage +from .generated_sqlite_results_stage import SubmitSQLiteResultsStage +from .generated_statsd_results_stage import SubmitStatsDResultsStage +from .generated_telegraf_results_stage import SubmitTelegrafResultsStage +from .generated_telegraf_statsd_results_stage import SubmitTelegrafStatsDResultsStage +from .generated_timescaledb_results_stage import SubmitTimescaleDBResultsStage \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_aws_lambda_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_aws_lambda_results_stage.py new file mode 100644 index 0000000..9c1878e --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_aws_lambda_results_stage.py @@ -0,0 +1,16 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import AWSLambdaConfig + + +class SubmitAWSLambdaResultsStage(Submit): + config=AWSLambdaConfig( + aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID', ''), + aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY', ''), + region_name=os.getenv("AWS_REGION_NAME", ''), + events_lambda='events', + metrics_lambda='metrics', + experiments_lambda='experiments', + streams_lambda='streams' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_aws_timestream_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_aws_timestream_results_stage.py new file mode 100644 index 0000000..7742eb0 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_aws_timestream_results_stage.py @@ -0,0 +1,15 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import AWSTimestreamConfig + + +class SubmitAWSTimestreamResultsStage(Submit): + config=AWSTimestreamConfig( + aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID', ''), + aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY', ''), + region_name=os.getenv("AWS_REGION_NAME", ''), + database_name='results', + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_bigquery_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_bigquery_results_stage.py new file mode 100644 index 0000000..9e48b59 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_bigquery_results_stage.py @@ -0,0 +1,14 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import BigQueryConfig + + +class SubmitBigQueryResultsStage(Submit): + config=BigQueryConfig( + service_account_json_path=os.getenv('GOOGLE_CLOUD_ACCOUNT_JSON_PATH', ''), + project_name='test', + dataset_name='results', + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_bigtable_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_bigtable_results_stage.py new file mode 100644 index 0000000..81d65bb --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_bigtable_results_stage.py @@ -0,0 +1,13 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import BigTableConfig + + +class SubmitBigTableResultsStage(Submit): + config=BigTableConfig( + service_account_json_path=os.getenv('GOOGLE_CLOUD_ACCOUNT_JSON_PATH', ''), + instance_id=os.getenv('GOOGLE_CLOUD_BIGTABLE_INSTANCE_ID', ''), + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_cassandra_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_cassandra_results_stage.py new file mode 100644 index 0000000..c1210fc --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_cassandra_results_stage.py @@ -0,0 +1,16 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import CassandraConfig + + +class SubmitCassandraResultsStage(Submit): + config=CassandraConfig( + hosts=['127.0.0.1'], + port=9042, + username=os.getenv('CASSANDRA_USERNAME', ''), + password=os.getenv('CASSANDRA_PASSWORD', ''), + keyspace='results', + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_cloudwatch_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_cloudwatch_results_stage.py new file mode 100644 index 0000000..3595f59 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_cloudwatch_results_stage.py @@ -0,0 +1,21 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import CloudwatchConfig + + +class SubmitCloudwatchResultsStage(Submit): + config=CloudwatchConfig( + aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID', ''), + aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY', ''), + region_name=os.getenv("AWS_REGION_NAME", ''), + iam_role_arn=os.getenv('AWS_IAM_ROLE_ARN', ''), + events_rule='events', + metrics_rule='metrics', + cloudwatch_source='', + cloudwatch_targets=[{ + 'id': '', + 'arn': '' + }], + aws_resource_arns=[''] + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_cosmosdb_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_cosmosdb_results_stage.py new file mode 100644 index 0000000..e749fcd --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_cosmosdb_results_stage.py @@ -0,0 +1,16 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import CosmosDBConfig + + +class SubmitCosmosDBResultsStage(Submit): + config=CosmosDBConfig( + account_uri=os.getenv('AZURE_COSMOSDB_ACCOUNT_URI', ''), + account_key=os.getenv('AZURE_COSMOSDB_ACCOUNT_KEY', ''), + database='results', + events_container='events', + metrics_container='metrics', + events_partition_key='name', + metrics_partition_key='name' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_csv_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_csv_results_stage.py new file mode 100644 index 0000000..6b5ee89 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_csv_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import CSVConfig + + +class SubmitCSVResultsStage(Submit): + config=CSVConfig( + events_filepath='./events.json', + metrics_filepath='./metrics.json' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_datadog_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_datadog_results_stage.py new file mode 100644 index 0000000..21a6444 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_datadog_results_stage.py @@ -0,0 +1,11 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import DatadogConfig + + +class SubmitDatadogResultsStage(Submit): + config=DatadogConfig( + api_key=os.getenv('DATADOG_API_KEY', ''), + app_key=os.getenv('DATADOG_APP_KEY', '') + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_dogstatsd_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_dogstatsd_results_stage.py new file mode 100644 index 0000000..0433eed --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_dogstatsd_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import DogStatsDConfig + + +class SubmitDogStatsDResultsStage(Submit): + config=DogStatsDConfig( + host='localhost', + port=8125 + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_google_cloud_storage_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_google_cloud_storage_results_stage.py new file mode 100644 index 0000000..75a335e --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_google_cloud_storage_results_stage.py @@ -0,0 +1,13 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import GoogleCloudStorageConfig + + +class SubmitGoogleCloudStorageResultsStage(Submit): + config=GoogleCloudStorageConfig( + service_account_json_path=os.getenv('GOOGLE_CLOUD_ACCOUNT_JSON_PATH', ''), + bucket_namespace='results', + events_bucket='events', + metrics_bucket='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_graphite_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_graphite_results_stage.py new file mode 100644 index 0000000..d871ba1 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_graphite_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import GraphiteConfig + + +class SubmitGraphiteResultsStage(Submit): + config=GraphiteConfig( + host='localhost', + port=2003 + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_honeycomb_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_honeycomb_results_stage.py new file mode 100644 index 0000000..9649f8f --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_honeycomb_results_stage.py @@ -0,0 +1,11 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import HoneycombConfig + + +class SubmitHoneycombResultsStage(Submit): + config=HoneycombConfig( + api_key=os.getenv('HONEYCOMB_API_KEY', ''), + dataset='results' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_influxdb_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_influxdb_results_stage.py new file mode 100644 index 0000000..37d6747 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_influxdb_results_stage.py @@ -0,0 +1,14 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import InfluxDBConfig + + +class SubmitInfluxDBResultsStage(Submit): + config=InfluxDBConfig( + host='localhost:8006', + token=os.getenv('INFLUXDB_API_TOKEN', ''), + organization='', + events_bucket='events', + metrics_bucket='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_json_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_json_results_stage.py new file mode 100644 index 0000000..56072f6 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_json_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import JSONConfig + + +class SubmitJSONResultsStage(Submit): + config=JSONConfig( + events_filepath='./events.json', + metrics_filepath='./metrics.json' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_kafka_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_kafka_results_stage.py new file mode 100644 index 0000000..c49bcf4 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_kafka_results_stage.py @@ -0,0 +1,12 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import KafkaConfig + + +class SubmitKafkaResultsStage(Submit): + config=KafkaConfig( + host='localhost:9092', + client_id='results', + events_topic='events', + metrics_topic='metrics', + compression_type=None + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_mongodb_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_mongodb_results_stage.py new file mode 100644 index 0000000..bea6622 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_mongodb_results_stage.py @@ -0,0 +1,15 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import MongoDBConfig + + +class SubmitMongoDBResultsStage(Submit): + config=MongoDBConfig( + host='localhost:27017', + username=os.getenv('MONGODB_USERNAME', ''), + password=os.getenv('MONGODB_PASSWORD', ''), + database='results', + events_collection='events', + metrics_collection='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_mysql_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_mysql_results_stage.py new file mode 100644 index 0000000..b39c1eb --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_mysql_results_stage.py @@ -0,0 +1,15 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import MySQLConfig + + +class SubmitMySQLResultsStage(Submit): + config=MySQLConfig( + host='127.0.0,1', + database='results', + username=os.getenv('MYSQL_USERNAME', ''), + password=os.getenv('MYSQL_PASSWORD', ''), + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_netdata_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_netdata_results_stage.py new file mode 100644 index 0000000..6094cff --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_netdata_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import NetdataConfig + + +class SubmitNetdataResultsStage(Submit): + config=NetdataConfig( + host='localhost', + port=8125 + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_newrelic_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_newrelic_results_stage.py new file mode 100644 index 0000000..5901489 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_newrelic_results_stage.py @@ -0,0 +1,12 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import NewRelicConfig + + +class SubmitNewrelicResultsStage(Submit): + config=NewRelicConfig( + config_path=os.getenv('NEWRELIC_CONFIG_PATH', ''), + environment=os.getenv('NEWRELIC_ENVIRONMENT', ''), + newrelic_application_name='results' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_postgres_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_postgres_results_stage.py new file mode 100644 index 0000000..f62c582 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_postgres_results_stage.py @@ -0,0 +1,15 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import PostgresConfig + + +class SubmitPostgresResultsStage(Submit): + config=PostgresConfig( + host='127.0.0,1', + database='results', + username=os.getenv('POSTGRES_USERNAME', ''), + password=os.getenv('POSTGRES_PASSWORD', ''), + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_prometheus_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_prometheus_results_stage.py new file mode 100644 index 0000000..4861e17 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_prometheus_results_stage.py @@ -0,0 +1,15 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import PrometheusConfig + + +class SubmitPrometheusResultsStage(Submit): + config=PrometheusConfig( + pushgateway_address='localhost:9091', + auth_request_method='GET', + username=os.getenv('PROMETHEUS_PUSHGATEWAY_USERNAME', ''), + password=os.getenv('PROMETHEUS_PUSHGATEWAY_PASSWORD', ''), + namespace='results', + job_name='results' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_redis_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_redis_results_stage.py new file mode 100644 index 0000000..0a3ed5c --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_redis_results_stage.py @@ -0,0 +1,16 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import RedisConfig + + +class SubmitRedisResultsStage(Submit): + config=RedisConfig( + host='localhost:6379', + username=os.getenv('REDIS_USERNAME', ''), + password=os.getenv('REDIS_PASSWORD', ''), + events_channel='events', + metrics_channel='metrics', + channel_type='pipeline', + secure=True + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_s3_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_s3_results_stage.py new file mode 100644 index 0000000..de8cade --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_s3_results_stage.py @@ -0,0 +1,15 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import S3Config + + +class SubmitS3ResultsStage(Submit): + config=S3Config( + aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID', ''), + aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY', ''), + region_name=os.getenv('AWS_REGION_NAME', ''), + buckets_namespace='results', + events_bucket='events', + metrics_bucket='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_snowflake_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_snowflake_results_stage.py new file mode 100644 index 0000000..e464671 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_snowflake_results_stage.py @@ -0,0 +1,19 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import SnowflakeConfig + + +class SubmitSnowflakeResultsStage(Submit): + config=SnowflakeConfig( + username=os.getenv('SNOWFLAKE_USERNAME', ''), + password=os.getenv('SNOWFLAKE_PASSWORD', ''), + organization_id=os.getenv('SNOWFLAKE_ORGANIZATION', ''), + account_id=os.getenv('SNOWFLAKE_ACCOUNT_ID', ''), + private_key=os.getenv('SNOWFLAKE_PRIVATE_KEY', ''), + warehouse=os.getenv('SNOWFLAKE_WAREHOUSE', ''), + database='results', + database_schema='results', + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_sqlite_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_sqlite_results_stage.py new file mode 100644 index 0000000..5e0b024 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_sqlite_results_stage.py @@ -0,0 +1,12 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import SQLiteConfig + + +class SubmitSQLiteResultsStage(Submit): + config=SQLiteConfig( + path=f'{os.getcwd}/results.db', + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_statsd_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_statsd_results_stage.py new file mode 100644 index 0000000..c5cd19d --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_statsd_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import StatsDConfig + + +class SubmitStatsDResultsStage(Submit): + config=StatsDConfig( + host='localhost', + port=8125 + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_results_stage.py new file mode 100644 index 0000000..94f1692 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import TelegrafConfig + + +class SubmitTelegrafResultsStage(Submit): + config=TelegrafConfig( + host='localhost', + port=8094 + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_statsd_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_statsd_results_stage.py new file mode 100644 index 0000000..2056839 --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_telegraf_statsd_results_stage.py @@ -0,0 +1,9 @@ +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import TelegrafStatsDConfig + + +class SubmitTelegrafStatsDResultsStage(Submit): + config=TelegrafStatsDConfig( + host='0.0.0.0', + port=8125 + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/graph_types/stages/submit/generated_timescaledb_results_stage.py b/hyperscale/projects/generation/graph_types/stages/submit/generated_timescaledb_results_stage.py new file mode 100644 index 0000000..63cc0ac --- /dev/null +++ b/hyperscale/projects/generation/graph_types/stages/submit/generated_timescaledb_results_stage.py @@ -0,0 +1,15 @@ +import os + +from hyperscale.core.graphs.stages import Submit +from hyperscale.reporting.types import TimescaleDBConfig + + +class SubmitTimescaleDBResultsStage(Submit): + config=TimescaleDBConfig( + host='127.0.0,1', + database='results', + username=os.getenv('TIMESCALEDB_USERNAME', ''), + password=os.getenv('TIMESCALEDB_PASSWORD', ''), + events_table='events', + metrics_table='metrics' + ) \ No newline at end of file diff --git a/hyperscale/projects/generation/plugin_types/__init__.py b/hyperscale/projects/generation/plugin_types/__init__.py new file mode 100644 index 0000000..14a0dec --- /dev/null +++ b/hyperscale/projects/generation/plugin_types/__init__.py @@ -0,0 +1 @@ +from .plugin_generator import PluginGenerator \ No newline at end of file diff --git a/hyperscale/projects/generation/plugin_types/plugin_generator.py b/hyperscale/projects/generation/plugin_types/plugin_generator.py new file mode 100644 index 0000000..b762b40 --- /dev/null +++ b/hyperscale/projects/generation/plugin_types/plugin_generator.py @@ -0,0 +1,32 @@ +from hyperscale.plugins.types.common.registrar import plugin_registrar +from hyperscale.projects.generation.generator import Generator + +from .plugins import CustomEngine, CustomOptimizer, CustomPersona, CustomReporter + + +class PluginGenerator(Generator): + + def __init__(self) -> None: + super().__init__({ + 'engine': CustomEngine, + 'optimizer': CustomOptimizer, + 'persona': CustomPersona, + 'reporter': CustomReporter + }, plugin_registrar.module_paths) + + def generate_plugin(self, plugin_type: str) -> str: + modules = self.gather_required_items(plugin_type) + self.collect_imports(plugin_type, modules) + + self.serialize_items() + + serialized_imports = '\n'.join([ + *self.serialized_global_imports, + *self.serialized_local_imports, + + ]) + + return '\n\n'.join([ + serialized_imports, + *self.serialized_locals + ]) \ No newline at end of file diff --git a/hyperscale/projects/generation/plugin_types/plugins/__init__.py b/hyperscale/projects/generation/plugin_types/plugins/__init__.py new file mode 100644 index 0000000..920eb20 --- /dev/null +++ b/hyperscale/projects/generation/plugin_types/plugins/__init__.py @@ -0,0 +1,15 @@ +from .generated_engine_plugin import ( + CustomEngine +) + +from .generated_optimizer_plugin import ( + CustomOptimizer, +) + +from .generated_persona_plugin import ( + CustomPersona +) + +from .generated_reporter_plugin import ( + CustomReporter +) \ No newline at end of file diff --git a/hyperscale/projects/generation/plugin_types/plugins/generated_engine_plugin.py b/hyperscale/projects/generation/plugin_types/plugins/generated_engine_plugin.py new file mode 100644 index 0000000..fc35625 --- /dev/null +++ b/hyperscale/projects/generation/plugin_types/plugins/generated_engine_plugin.py @@ -0,0 +1,63 @@ +from typing import Any, Dict, List + +from hyperscale.plugins.types.engine import ( + Action, + EnginePlugin, + Result, + close, + connect, + execute, +) + + +class CustomAction(Action): + + def __init__( + self, + name: str, + source: str = None, + user: str = None, + tags: List[Dict[str, str]] = [], + use_security_context: bool = False + ) -> None: + super( + CustomAction, + self + ).__init__( + name=name, + source=source, + user=user, + tags=tags, + use_security_context=use_security_context + ) + + +class CustomResult(Result): + + def __init__( + self, + action: CustomAction, + error: Exception = None + ) -> None: + super().__init__( + action, + error + ) + + +class CustomEngine(EnginePlugin[CustomAction, CustomResult]): + cache: Dict[str, Any] = {} + action = CustomAction + result = CustomResult + + @connect() + async def connect_engine(self, action: CustomAction): + pass + + @execute() + async def execute_action(self, action: CustomAction, result: CustomResult): + pass + + @close() + async def close_engine(self): + pass \ No newline at end of file diff --git a/hyperscale/projects/generation/plugin_types/plugins/generated_optimizer_plugin.py b/hyperscale/projects/generation/plugin_types/plugins/generated_optimizer_plugin.py new file mode 100644 index 0000000..21a7dcf --- /dev/null +++ b/hyperscale/projects/generation/plugin_types/plugins/generated_optimizer_plugin.py @@ -0,0 +1,23 @@ +from typing import Any, Callable, Dict + +from scipy.optimize import OptimizeResult + +from hyperscale.plugins.types.optimizer import OptimizerPlugin, get, optimize, update + + +class CustomOptimizer(OptimizerPlugin): + + def __init__(self, config: Dict[str, Any]) -> None: + super().__init__(config) + + @get() + def get_params(self): + return super().get_params() + + @update() + def update_params(self): + return super().update_params() + + @optimize() + def run_dual_annealing(self, func: Callable[..., OptimizeResult]): + pass \ No newline at end of file diff --git a/hyperscale/projects/generation/plugin_types/plugins/generated_persona_plugin.py b/hyperscale/projects/generation/plugin_types/plugins/generated_persona_plugin.py new file mode 100644 index 0000000..b931936 --- /dev/null +++ b/hyperscale/projects/generation/plugin_types/plugins/generated_persona_plugin.py @@ -0,0 +1,36 @@ +import asyncio +import time +from typing import AsyncIterable, Dict, List, Union + +from hyperscale.core.hooks.types.action.hook import ActionHook +from hyperscale.core.hooks.types.base.hook_type import HookType +from hyperscale.core.hooks.types.task.hook import TaskHook +from hyperscale.plugins.types.persona import PersonaPlugin, generate, setup, shutdown + + +class CustomPersona(PersonaPlugin): + + @setup() + async def setup(self, hooks: Dict[HookType, List[Union[ActionHook, TaskHook]]]): + return super().setup(hooks) + + @generate() + async def generate_next(self) -> AsyncIterable[int]: + total_time = self.total_time + elapsed = 0 + idx = 0 + + start = time.time() + + while elapsed < total_time: + yield idx%self.actions_count + await asyncio.sleep(0) + + idx += 1 + + + elapsed = time.time() - start + + @shutdown() + async def shutdown(self): + pass \ No newline at end of file diff --git a/hyperscale/projects/generation/plugin_types/plugins/generated_reporter_plugin.py b/hyperscale/projects/generation/plugin_types/plugins/generated_reporter_plugin.py new file mode 100644 index 0000000..9f0089c --- /dev/null +++ b/hyperscale/projects/generation/plugin_types/plugins/generated_reporter_plugin.py @@ -0,0 +1,54 @@ +from typing import List + +from hyperscale.plugins.types.common import Event +from hyperscale.plugins.types.reporter import ( + Metrics, + ReporterConfig, + ReporterPlugin, + process_custom, + process_errors, + process_events, + process_metrics, + process_shared, + reporter_close, + reporter_connect, +) + + +class CustomReporterConfig(ReporterConfig): + pass + + +class CustomReporter(ReporterPlugin): + config=CustomReporterConfig + + def __init__(self, config: CustomReporterConfig) -> None: + super().__init__(config) + + @reporter_connect() + async def reporter_connect(self): + pass + + @process_events() + async def reporter_process_events(self, events: List[Event]): + pass + + @process_shared() + async def reporter_process_shared_metrics(self, metrics: List[Metrics]): + pass + + @process_metrics() + async def reporter_process_metrics(self, metrics: List[Metrics]): + pass + + @process_custom() + async def reporter_process_custom_metrics(self, metrics: List[Metrics]): + pass + + @process_errors() + async def reporter_process_errors(self, metrics: List[Metrics]): + pass + + @reporter_close() + async def reporter_close(self): + pass \ No newline at end of file diff --git a/hyperscale/projects/management/__init__.py b/hyperscale/projects/management/__init__.py new file mode 100644 index 0000000..ab2309b --- /dev/null +++ b/hyperscale/projects/management/__init__.py @@ -0,0 +1 @@ +from .graphs import GraphManager \ No newline at end of file diff --git a/hyperscale/projects/management/graphs/__init__.py b/hyperscale/projects/management/graphs/__init__.py new file mode 100644 index 0000000..2d6e87f --- /dev/null +++ b/hyperscale/projects/management/graphs/__init__.py @@ -0,0 +1 @@ +from .graph_manager import GraphManager \ No newline at end of file diff --git a/hyperscale/projects/management/graphs/actions/__init__.py b/hyperscale/projects/management/graphs/actions/__init__.py new file mode 100644 index 0000000..76c2c9b --- /dev/null +++ b/hyperscale/projects/management/graphs/actions/__init__.py @@ -0,0 +1,5 @@ +from .fetch import Fetch +from .initialize import Initialize +from .synchronize import Syncrhonize +from .create_gitignore import CreateGitignore +from .config import RepoConfig \ No newline at end of file diff --git a/hyperscale/projects/management/graphs/actions/action.py b/hyperscale/projects/management/graphs/actions/action.py new file mode 100644 index 0000000..c11abba --- /dev/null +++ b/hyperscale/projects/management/graphs/actions/action.py @@ -0,0 +1,139 @@ +import os +import uuid +from pathlib import Path +from typing import List + +from git import RemoteReference +from git.remote import Remote +from git.repo import Repo + +from hyperscale.logging import HyperscaleLogger + +from .config import RepoConfig + + +class RepoAction: + + def __init__(self, config: RepoConfig, discovered_files: List[str]) -> None: + self.action_id = str(uuid.uuid4()) + self.config = config + self.repo: Repo = None + self.remote: Remote = None + self.branch = None + self.git = None + self.discovered_files = discovered_files + self.logger = HyperscaleLogger() + self.logger.initialize() + + def _pull_from_remote(self): + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Cloning from remote - {self.config.uri} - to path - {self.config.path}') + self.repo = Repo.clone_from(self.config.uri, self.config.path) + self.branch = self.repo.create_head(self.config.branch) + + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} -Setting branch - {self.config.branch}') + + self.remote = self.repo.remote(name=self.config.remote) + + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} -Setting remote - {self.config.remote}') + + self.git = self.repo.git + + def _setup(self): + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Re-initializing repo at - {self.config.path}') + self.repo = Repo(self.config.path) + + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} -Setting remote - {self.config.remote}') + self.remote = Remote(self.repo, self.config.remote) + + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} -Setting branch - {self.config.branch}') + self.branch = self.repo.create_head(self.config.branch) + self.git = self.repo.git + + def _checkout(self): + + if self.branch in self.repo.branches and self.branch.name != self.repo.head.name: + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Checking out existing branch - {self.config.branch}') + self.branch.checkout() + + else: + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Creating new branch - {self.config.branch}') + self.branch = self.repo.create_head(self.config.branch) + + self.repo.head.reference = self.branch + + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Setting remote - {self.config.remote} - to track from branch - {self.config.branch}') + + remote_reference = RemoteReference( + self.repo, + f"refs/remotes/{self.config.remote}/{self.branch.name}" + ) + + self.repo.head.reference.set_tracking_branch(remote_reference).checkout() + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Checkout complete') + + def _update_ignore(self, force_create=False): + + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Updating .gitignore at {self.config.path}/.gitignore') + + license_file = os.path.join(self.config.path, 'LICENSE') + readme_path = os.path.join(self.config.path, 'README.md') + + valid_files = [ + license_file, + readme_path, + *self.discovered_files + ] + + gitignore_path = f'{self.config.path}/.gitignore' + + existing_ignore_files = [] + + if os.path.exists(gitignore_path) or force_create: + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Creating or updating .gitignore') + + if os.path.exists(gitignore_path): + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Reading existing .gitignore to check for already ignored items') + with open(gitignore_path, 'r') as hyperscale_gitignore: + existing_ignore_files.extend([ + existing_ignore_file.strip('\n') for existing_ignore_file in hyperscale_gitignore.readlines() + ]) + + with open(gitignore_path, 'a+') as hyperscale_gitignore: + + filter_files: List[str] = ['**/.hyperscale.json'] + candidate_filter_files = [ + str(path.resolve()) for path in Path(self.config.path).rglob('*') if '.git' not in str(path.resolve()) + ] + + existing_ignore_files.extend( + self.repo.ignored(candidate_filter_files) + ) + + for candidate_filter_file in candidate_filter_files: + + candidate_filter_filepath = str(Path(candidate_filter_file).resolve()) + + candidate_relative_path = os.path.relpath(candidate_filter_filepath, self.config.path) + + valid_ignore_candidate = candidate_filter_filepath not in valid_files + not_already_ignored = candidate_filter_filepath not in existing_ignore_files + not_directory = os.path.isdir(candidate_filter_filepath) is False + + if valid_ignore_candidate and not_already_ignored and not_directory: + filter_files.append(candidate_relative_path) + + for ignore_option in self.config.ignore_options: + if ignore_option not in existing_ignore_files: + filter_files.append(ignore_option) + + filter_files_data = '\n'.join([ + filepath for filepath in filter_files + ]) + + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Adding {len(filter_files)} new files to .gitignore') + + if len(filter_files) > 0: + hyperscale_gitignore.writelines(f'\n{filter_files_data}\n') + + self.repo.index.add('.gitignore') + self.logger.hyperscale.sync.debug(f'GitAction: {self.action_id} - Update of .gitignore at - {self.config.path}/.gitignore - complete') diff --git a/hyperscale/projects/management/graphs/actions/config.py b/hyperscale/projects/management/graphs/actions/config.py new file mode 100644 index 0000000..a006e57 --- /dev/null +++ b/hyperscale/projects/management/graphs/actions/config.py @@ -0,0 +1,32 @@ +from typing import Optional + +class RepoConfig: + + def __init__(self, + path: str, + uri: str, + branch: str='main', + remote: str='origin', + sync_message: Optional[str]=None, + username: Optional[str]=None, + password: Optional[str]=None, + ignore_options: Optional[str]=None + ) -> None: + + self.path = path + self.uri = uri + self.branch = branch + self.remote = remote + self.sync_message = sync_message + self.username = username + self.password = password + self.ignore_options = [ + '__pycache__', + '**/*.pyx' + ] + + if isinstance(ignore_options, str) and len(ignore_options) > 0: + self.ignore_options.extend( + ignore_options.split(',') + ) + diff --git a/hyperscale/projects/management/graphs/actions/create_gitignore.py b/hyperscale/projects/management/graphs/actions/create_gitignore.py new file mode 100644 index 0000000..213e0df --- /dev/null +++ b/hyperscale/projects/management/graphs/actions/create_gitignore.py @@ -0,0 +1,27 @@ +import datetime +from typing import List + +from .action import RepoAction +from .config import RepoConfig + + +class CreateGitignore(RepoAction): + + def __init__(self, config: RepoConfig, graph_files: List[str]) -> None: + super( + CreateGitignore, + self + ).__init__(config, graph_files) + + def execute(self): + + self.logger.hyperscale.sync.debug(f'CreateGitignore: {self.action_id} - creating .gitignore at path - {self.config.path}') + self._setup() + self._update_ignore(force_create=True) + + current_time = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + gitignore_mmessage = f"Hyperscale graph update: {self.config.path} - {current_time}" + + + self.logger.hyperscale.sync.debug(f'CreateGitignore: {self.action_id} - making local commit to add new .gitignore at path - {self.config.path}') + self.repo.index.commit(gitignore_mmessage) \ No newline at end of file diff --git a/hyperscale/projects/management/graphs/actions/fetch.py b/hyperscale/projects/management/graphs/actions/fetch.py new file mode 100644 index 0000000..b94ed33 --- /dev/null +++ b/hyperscale/projects/management/graphs/actions/fetch.py @@ -0,0 +1,23 @@ +from typing import List + +from .action import RepoAction +from .config import RepoConfig + + +class Fetch(RepoAction): + + def __init__(self, config: RepoConfig, graph_files: List[str]) -> None: + super( + Fetch, + self + ).__init__(config, graph_files) + + def execute(self): + self.logger.hyperscale.sync.debug(f'FetchAction: {self.action_id} - Cloning from remote - {self.config.uri} - to path - {self.config.path}') + self._pull_from_remote() + + self.logger.hyperscale.sync.debug(f'FetchAction: {self.action_id} - Checking out branch - {self.config.branch} - and updating remote') + self._checkout() + + self.logger.hyperscale.sync.debug(f'FetchAction: {self.action_id} - Updating .gitignore') + self._update_ignore() diff --git a/hyperscale/projects/management/graphs/actions/initialize.py b/hyperscale/projects/management/graphs/actions/initialize.py new file mode 100644 index 0000000..d004132 --- /dev/null +++ b/hyperscale/projects/management/graphs/actions/initialize.py @@ -0,0 +1,65 @@ +import os +from typing import List + +from git.repo import Repo + +from .action import RepoAction +from .config import RepoConfig + + +class Initialize(RepoAction): + + def __init__(self, config: RepoConfig, graph_files: List[str]) -> None: + super( + Initialize, + self + ).__init__(config, graph_files) + + def execute(self): + + if os.path.exists(self.config.path) is False: + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Creating repo directory at path - {self.config.path}') + os.makedirs(self.config.path) + + try: + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Re-initializing repo') + self._setup() + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Updating .gitignore') + self._update_ignore() + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Setting and fetching new branches from remote') + self.remote = self.repo.remote(self.config.remote) + self.remote.fetch() + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Checking out branch - {self.config.branch} - and updating remote tracking') + self._checkout() + + except Exception: + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Initializing new repository at - {self.config.path}') + self.repo = Repo.init(self.config.path) + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Updating .gitignore') + self._update_ignore() + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Adding {len(self.discovered_files)} to new repo in commit') + self.repo.index.add(self.discovered_files) + self.repo.index.commit(f'Initialized new graph repo at - {self.config.path}') + + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Setting remote as - {self.config.remote} - with URL - {self.config.uri}') + remote_names = [ + remote.name for remote in self.repo.remotes + ] + if self.config.remote not in remote_names: + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Creating new remote as - {self.config.remote}') + self.repo.create_remote(self.config.remote, self.config.uri) + + self.remote = self.repo.remote(self.config.remote) + self.remote.set_url(self.config.uri) + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Checking out branch - {self.config.branch} - and updating remote tracking') + self._checkout() + + diff --git a/hyperscale/projects/management/graphs/actions/synchronize.py b/hyperscale/projects/management/graphs/actions/synchronize.py new file mode 100644 index 0000000..ea80ef0 --- /dev/null +++ b/hyperscale/projects/management/graphs/actions/synchronize.py @@ -0,0 +1,54 @@ +import datetime +from typing import List + +from .action import RepoAction +from .config import RepoConfig + + +class Syncrhonize(RepoAction): + + def __init__(self, config: RepoConfig, graph_files: List[str]) -> None: + super( + Syncrhonize, + self + ).__init__(config, graph_files) + + def execute(self): + + self.logger.hyperscale.sync.debug(f'SyncrhonizeAction: {self.action_id} - Re-initializing repo') + self._setup() + + self.logger.hyperscale.sync.debug(f'SyncrhonizeAction: {self.action_id} - Fetchin latest branches from remote') + self.remote.fetch() + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Checking out branch - {self.config.branch} - and updating remote tracking') + self._checkout() + + current_time = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + pre_sync_message = f"Hyperscale graph update: {self.config.path} - {current_time}" + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Running pre-pull commit.') + self.repo.git.add(A=True) + self.repo.index.commit(pre_sync_message) + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Pulling latest from remote repo at - URI: {self.config.uri} - Branch: {self.config.branch}') + self.remote.pull(self.branch.name, rebase=True) + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Updating .gitignore') + self._update_ignore(force_create=True) + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Adding {len(self.discovered_files)} to repo in commit') + self.repo.index.add(self.discovered_files) + + sync_message = self.config.sync_message + + if sync_message is None: + current_time = datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ') + sync_message = f"Hyperscale graph update: {self.config.path} - {current_time}" + + self.repo.index.commit(sync_message) + + self.logger.hyperscale.sync.debug(f'InitializeAction: {self.action_id} - Pushing project changes and updates to remote repo at - URI: {self.config.uri} - Branch: {self.config.branch}') + self.remote.push(self.config.branch, set_upstream=True) + + diff --git a/hyperscale/projects/management/graphs/exceptions/__init__.py b/hyperscale/projects/management/graphs/exceptions/__init__.py new file mode 100644 index 0000000..df1f5eb --- /dev/null +++ b/hyperscale/projects/management/graphs/exceptions/__init__.py @@ -0,0 +1 @@ +from .invalid_action_error import InvalidActionError \ No newline at end of file diff --git a/hyperscale/projects/management/graphs/exceptions/invalid_action_error.py b/hyperscale/projects/management/graphs/exceptions/invalid_action_error.py new file mode 100644 index 0000000..4aa1a2d --- /dev/null +++ b/hyperscale/projects/management/graphs/exceptions/invalid_action_error.py @@ -0,0 +1,12 @@ +from typing import List + + +class InvalidActionError(Exception): + + def __init__(self, specified_workflow_action: str, valid_workflow_actions: List[str]) -> None: + + valid_action_names = '\n-'.join(valid_workflow_actions) + + super().__init__( + f'\n\nError - Invalid workflow action - {specified_workflow_action} - specified. Valid actions are:\n\n{valid_action_names}\n' + ) \ No newline at end of file diff --git a/hyperscale/projects/management/graphs/graph_manager.py b/hyperscale/projects/management/graphs/graph_manager.py new file mode 100644 index 0000000..ebad7d0 --- /dev/null +++ b/hyperscale/projects/management/graphs/graph_manager.py @@ -0,0 +1,119 @@ +import glob +import importlib +import inspect +import ntpath +import sys +import uuid +from pathlib import Path +from typing import Dict, List, Union + +from hyperscale.core.graphs.stages.base.stage import Stage +from hyperscale.logging import HyperscaleLogger +from hyperscale.plugins.types.common.plugin import Plugin + +from .actions import CreateGitignore, Fetch, Initialize, RepoConfig, Syncrhonize +from .exceptions import InvalidActionError + + +class GraphManager: + + def __init__(self, config: RepoConfig, log_level: str='info') -> None: + self.manager_id = str(uuid.uuid4()) + self._actions = { + 'initialize': Initialize, + 'synchronize': Syncrhonize, + 'fetch': Fetch, + 'create-gitignore': CreateGitignore + } + + self.discovered_graphs: Dict[str, str] = {} + self.discovered_plugins: Dict[str, str] = {} + self.config = config + self.log_level = log_level + self.logger = HyperscaleLogger() + self.logger.initialize() + + def execute_workflow(self, workflow_actions: List[str]): + + for workflow_action in workflow_actions: + self.logger.hyperscale.sync.debug(f'GraphManager: {self.manager_id} - Executing workflow action - {workflow_action}') + + init_files = glob.glob(self.config.path + '/**/__init__.py', recursive=True) + + discovered = [ + *list(self.discovered_graphs.values()), + *list(self.discovered_plugins.values()), + *init_files + ] + action: Union[Initialize, Syncrhonize] = self._actions.get(workflow_action)( + self.config, + discovered + ) + + if action is None: + raise InvalidActionError( + workflow_action, + list(self._actions.keys()) + ) + + action.execute() + self.logger.hyperscale.sync.debug(f'GraphManager: {self.manager_id} - Completed workflow action - {workflow_action}') + + def discover_graph_files(self) -> Dict[str, str]: + + candidate_files = glob.glob(self.config.path + '/**/*.py', recursive=True) + self.logger.hyperscale.sync.debug(f'GraphManager: {self.manager_id} - Searching for Graph and Plugin files on path - {self.config.path}') + + for candidate_filepath in candidate_files: + + self.logger.hyperscale.sync.debug(f'GraphManager: {self.manager_id} - Analyzing file: {candidate_filepath}') + + package_dir = Path(candidate_filepath).resolve().parent + package_dir_path = str(package_dir) + package_dir_module = package_dir_path.split('/')[-1] + + package = ntpath.basename(candidate_filepath) + package_slug = package.split('.')[0] + spec = importlib.util.spec_from_file_location(f'{package_dir_module}.{package_slug}', candidate_filepath) + + if candidate_filepath not in sys.path: + sys.path.append(str(package_dir.parent)) + + module = importlib.util.module_from_spec(spec) + sys.modules[module.__name__] = module + + try: + spec.loader.exec_module(module) + + stage_decendants = list({cls.__name__: cls for cls in Stage.__subclasses__()}.values()) + plugin_decendants = list({cls.__name__: cls for cls in Plugin.__subclasses__()}.values()) + + graphs = {} + plugins = {} + for name, obj in inspect.getmembers(module): + if inspect.isclass(obj) and issubclass(obj, Stage) and obj not in stage_decendants: + graphs[name] = obj + + elif inspect.isclass(obj) and issubclass(obj, Plugin) and obj not in plugin_decendants: + plugins[name] = obj + + + if len(graphs) > 0: + self.logger.hyperscale.sync.debug(f'GraphManager: {self.manager_id} - Found Graph file at - {candidate_filepath}') + graph_filepath = Path(candidate_filepath) + self.discovered_graphs[graph_filepath.stem] = str(graph_filepath.resolve()) + + if len(plugins) > 0: + self.logger.hyperscale.sync.debug(f'GraphManager: {self.manager_id} - Found Plugin file at - {candidate_filepath}') + plugin_filepath = Path(candidate_filepath) + self.discovered_plugins[plugin_filepath.stem] = str(plugin_filepath.resolve()) + + except Exception as e: + self.logger.hyperscale.sync.error(f'Encountered error loading file at - {str(e)}.') + pass + + + return { + 'graphs': self.discovered_graphs, + 'plugins': self.discovered_plugins + } \ No newline at end of file diff --git a/hyperscale/reporting/__init__.py b/hyperscale/reporting/__init__.py new file mode 100644 index 0000000..90c8838 --- /dev/null +++ b/hyperscale/reporting/__init__.py @@ -0,0 +1,40 @@ +from .types import ( + AWSLambdaConfig, + AWSTimestreamConfig, + BigQueryConfig, + BigTableConfig, + CassandraConfig, + CloudwatchConfig, + CosmosDBConfig, + CSVConfig, + DatadogConfig, + DogStatsDConfig, + GoogleCloudStorageConfig, + GraphiteConfig, + HoneycombConfig, + InfluxDBConfig, + JSONConfig, + KafkaConfig, + MongoDBConfig, + MySQLConfig, + NetdataConfig, + NewRelicConfig, + PostgresConfig, + PrometheusConfig, + RedisConfig, + S3Config, + SnowflakeConfig, + SQLiteConfig, + StatsDConfig, + TelegrafConfig, + TelegrafStatsDConfig, + TimescaleDBConfig, + XML, + XMLConfig + +) + +from .reporter import ( + Reporter, + ReporterType +) \ No newline at end of file diff --git a/hyperscale/reporting/experiment/__init__.py b/hyperscale/reporting/experiment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/reporting/experiment/experiment_metrics_set.py b/hyperscale/reporting/experiment/experiment_metrics_set.py new file mode 100644 index 0000000..e28a7ad --- /dev/null +++ b/hyperscale/reporting/experiment/experiment_metrics_set.py @@ -0,0 +1,311 @@ +import statistics +import uuid +from typing import Any, Dict, List, Union + +from hyperscale.core.experiments.distribution_types import DistributionTypes +from hyperscale.reporting.metric import MetricsSet + +from .experiment_metrics_set_types import ( + ExperimentSummary, + MutationSummary, + VariantSummary, +) +from .experiments_collection import ExperimentMetricsCollection + +RawSummaryItem = Dict[str, Union[str, int, float, bool, List[float]]] + + +class ExperimentMetricsSet: + + experiments_table_header_keys = [ + 'experiment_name', + 'experiment_randomized', + 'experiment_completed', + 'experiment_succeeded', + 'experiment_failed', + 'experiment_median_aps' + ] + + variants_table_header_keys = [ + 'variant_name', + 'variant_experiment', + 'variant_weight', + 'variant_distribution', + 'variant_distribution_interval', + 'variant_ratio_completed', + 'variant_ratio_succeeded', + 'variant_ratio_failed', + 'variant_ratio_aps', + ] + + variants_stats_table_header_keys = [ + 'variant_name', + 'variant_completed', + 'variant_succeeded', + 'variant_failed', + 'variant_actions_per_second', + ] + + mutations_table_headers_keys = [ + 'mutation_name', + 'mutation_experiment_name', + 'mutation_variant_name', + 'mutation_chance', + 'mutation_targets', + 'mutation_type' + ] + + def __init__(self) -> None: + self.experiment_metrics_set_id = uuid.uuid4() + self.experiment_name: Union[str, None] = None + self.randomized: Union[bool, None] = None + self.participants: List[str] = [] + self.variants: Dict[str, Dict[str, Any]] = {} + self.mutations: Dict[str, List[Dict[str, Any]]] = {} + self.metrics: Dict[str, Dict[str, MetricsSet]] = {} + + self.experiments_table_headers: Dict[str, str] = {} + self.variants_table_headers: Dict[str, str] = {} + self.mutations_table_headers: Dict[str, str] = {} + self.mutations_summaries: Dict[str, str] = {} + + self.experiments_summary: ExperimentSummary = {} + + @classmethod + def experiments_fields(cls): + return cls.experiments_table_header_keys + + @classmethod + def variants_fields(cls): + return list(set([ + *cls.variants_table_header_keys, + *cls.variants_stats_table_header_keys + ])) + + @classmethod + def mutations_fields(cls): + return cls.mutations_table_headers_keys + + def generate_variant_summaries(self) -> Dict[str, VariantSummary]: + + variants_summaries = {} + + for participant in self.participants: + variant = self.variants.get(participant) + mutations = self.mutations.get(participant) + metrics_set = self.metrics.get(participant) + + group_variant_summaries: Dict[str, Union[int, List[float]]] = { + 'variant_completed': 0, + 'variant_succeeded': 0, + 'variant_failed': 0, + 'variant_actions_per_second': [] + } + for variant_metric_set in metrics_set.values(): + group_variant_summaries['variant_completed'] += variant_metric_set.common_stats.get('total') + group_variant_summaries['variant_succeeded'] += variant_metric_set.common_stats.get('succeeded') + group_variant_summaries['variant_failed'] += variant_metric_set.common_stats.get('failed') + group_variant_summaries['variant_actions_per_second'].append( + variant_metric_set.common_stats.get('actions_per_second') + ) + + group_variant_summaries['variant_actions_per_second'] = round( + statistics.median( + group_variant_summaries.get('variant_actions_per_second') + ), 2 + ) + + variant_distribution_type: DistributionTypes = variant.get('variant_distribution_type') + + variant_summary = VariantSummary(**{ + 'variant_name': participant, + 'variant_weight': variant.get('variant_weight'), + 'variant_experiment': '', + 'variant_distribution': variant_distribution_type.name.capitalize(), + 'variant_distribution_interval': variant.get('variant_distribution_interval_duration'), + 'variant_mutation_summaries': {}, + **group_variant_summaries, + }) + + + if len(mutations) > 0: + + for mutation in mutations: + + mutation_name = mutation.get('mutation_name') + + mutations_summary = MutationSummary(**{ + 'mutation_experiment_name': self.experiment_name, + 'mutation_variant_name': participant, + 'mutation_name': mutation_name, + 'mutation_chance': mutation.get('mutation_chance'), + 'mutation_targets': ':'.join(mutation.get('mutation_targets')), + 'mutation_type': mutation.get('mutation_type') + }) + + self.mutations_table_headers.update({ + header_name: header_name.replace( + 'mutation_', '' + ).replace( + '_', ' ' + ) for header_name in mutations_summary.dict().keys() + }) + + self.mutations_summaries[mutation_name] = mutations_summary + + variant_summary.variant_mutation_summaries[mutation_name] = mutations_summary + + variants_summaries[participant] = variant_summary + + return variants_summaries + + def generate_experiment_summary(self) -> None: + variant_summaries = self.generate_variant_summaries() + + experiment_summary = ExperimentSummary(**{ + 'experiment_name': self.experiment_name, + 'experiment_randomized': self.randomized, + 'experiment_completed': 0, + 'experiment_succeeded': 0, + 'experiment_failed': 0, + 'experiment_median_aps': 0., + 'experiment_variant_summaries': {} + }) + + experiment_actions_per_second = [] + + for participant in self.participants: + + selected_variant_summary = variant_summaries.get(participant) + selected_variant_summary.variant_experiment = self.experiment_name + + variant_stats: Dict[str, List[Union[int, float]]] = { + 'alt_variants_completed': [], + 'alt_variants_succeeded': [], + 'alt_variants_failed': [], + 'alt_variants_actions_per_second': [], + } + + for variant_name, variant_summary in variant_summaries.items(): + + if variant_name != participant: + + variant_stats['alt_variants_completed'].append( + variant_summary.variant_completed + ) + + variant_stats['alt_variants_succeeded'].append( + variant_summary.variant_succeeded + ) + + variant_stats['alt_variants_failed'].append( + variant_summary.variant_failed + ) + + variant_stats['alt_variants_actions_per_second'].append( + variant_summary.variant_actions_per_second + ) + + variant_total_completed = selected_variant_summary.variant_completed + mean_alt_variants_total_completed = statistics.mean( + variant_stats.get('alt_variants_completed') + ) + + selected_variant_summary.variant_ratio_completed= round( + variant_total_completed/mean_alt_variants_total_completed, + 2 + ) + + variant_total_succeeded = selected_variant_summary.variant_succeeded + mean_alt_variants_total_succeeded = statistics.mean( + variant_stats.get('alt_variants_succeeded') + ) + + selected_variant_summary.variant_ratio_succeeded = round( + variant_total_succeeded/mean_alt_variants_total_succeeded, + 2 + ) + + variant_total_failed = selected_variant_summary.variant_failed + mean_alt_variants_total_failed = statistics.mean( + variant_stats.get('alt_variants_failed') + ) + + selected_variant_summary.variant_ratio_failed = round( + variant_total_failed/mean_alt_variants_total_failed, + 2 + ) + + variant_total_actions_per_second = selected_variant_summary.variant_actions_per_second + median_alt_variants_actions_per_second = statistics.median( + variant_stats.get('alt_variants_actions_per_second') + ) + + selected_variant_summary.variant_ratio_aps = round( + variant_total_actions_per_second/median_alt_variants_actions_per_second, + 2 + ) + + self.variants_table_headers.update({ + header_name: header_name.replace( + 'variant_', '' + ).replace( + '_', ' ' + ) for header_name in selected_variant_summary.dict().keys() if 'mutation' not in header_name + }) + + experiment_summary.experiment_variant_summaries[participant] = selected_variant_summary + + experiment_summary.experiment_completed += selected_variant_summary.variant_completed + experiment_summary.experiment_succeeded += selected_variant_summary.variant_succeeded + experiment_summary.experiment_failed += selected_variant_summary.variant_failed + + experiment_actions_per_second.append( + selected_variant_summary.variant_actions_per_second + ) + + experiment_summary.experiment_median_aps = statistics.median(experiment_actions_per_second) + + self.experiments_table_headers = { + header_name: header_name.replace( + 'experiment_', '' + ).replace( + '_', ' ' + ) for header_name in experiment_summary.dict().keys() if 'variant' not in header_name + } + + self.experiments_summary = experiment_summary + + + def split_experiments_metrics(self) -> ExperimentMetricsCollection: + + variant_records: List[Dict[str, str]] = [] + mutations_records: List[Dict[str, str]] = [] + mutations_summaries: List[MutationSummary] = [] + + for variant in self.experiments_summary.experiment_variant_summaries.values(): + variant_records.append( + variant.dict( + include={header for header in self.variants_fields()} + ) + ) + + for mutation in variant.variant_mutation_summaries.values(): + mutations_records.append( + mutation.dict( + include={header for header in self.mutations_table_headers_keys} + ) + ) + + mutations_summaries.append(mutation) + + return ExperimentMetricsCollection( + experiment=self.experiments_summary.dict( + include={header for header in self.experiments_table_header_keys} + ), + variants=variant_records, + mutations=mutations_records, + experiment_summary=self.experiments_summary, + variant_summaries=list(self.experiments_summary.experiment_variant_summaries.values()), + mutation_summaries=mutations_summaries + ) diff --git a/hyperscale/reporting/experiment/experiment_metrics_set_types.py b/hyperscale/reporting/experiment/experiment_metrics_set_types.py new file mode 100644 index 0000000..8ca3adc --- /dev/null +++ b/hyperscale/reporting/experiment/experiment_metrics_set_types.py @@ -0,0 +1,176 @@ +from typing import Dict, List, Union + +from pydantic import BaseModel, StrictBool, StrictFloat, StrictInt, StrictStr + +from hyperscale.reporting.metric.metric_types import MetricType +from hyperscale.reporting.tags import Tag + + +class MutationSummary(BaseModel): + mutation_name: StrictStr + mutation_experiment_name: StrictStr + mutation_variant_name: StrictStr + mutation_chance: StrictFloat + mutation_targets: StrictStr + mutation_type: StrictStr + + @property + def stats(self) -> Dict[str, Union[int, float]]: + return { + 'mutation_chance': self.mutation_chance + } + + @property + def record(self) -> Dict[str, Union[str, bool, int, float]]: + return { + 'mutation_name': self.mutation_name, + 'mutation_experiment_name': self.mutation_experiment_name, + 'mutation_variant_name': self.mutation_variant_name, + 'mutation_targets': self.mutation_targets, + 'mutation_type': self.mutation_type, + **self.stats + } + + @property + def types_map(self): + return { + 'mutation_chance': MetricType.SAMPLE + } + + @property + def tags(self): + tag_fields = { + 'mutation_name': self.mutation_name, + 'mutation_experiment_name': self.mutation_experiment_name, + 'mutation_variant_name': self.mutation_variant_name, + 'mutation_targets': self.mutation_targets, + 'mutation_type': self.mutation_type, + } + + return [ + Tag( + tag_field_name, + tag_field_value + ) for tag_field_name, tag_field_value in tag_fields.items() + ] + + +class VariantSummary(BaseModel): + variant_name: StrictStr + variant_experiment: StrictStr + variant_weight: StrictFloat + variant_distribution: Union[StrictStr, None] + variant_distribution_interval: Union[StrictInt, StrictFloat] + variant_completed: StrictInt + variant_succeeded: StrictInt + variant_failed: StrictInt + variant_actions_per_second: Union[List[StrictFloat], StrictFloat] + variant_mutation_summaries: Dict[str, MutationSummary] + variant_ratio_completed: Union[StrictFloat, None] + variant_ratio_succeeded: Union[StrictFloat, None] + variant_ratio_failed: Union[StrictFloat, None] + variant_ratio_aps: Union[StrictFloat, None] + + @property + def stats(self) -> Dict[str, Union[int, float]]: + return { + 'variant_weight': self.variant_weight, + 'variant_distribution_interval': self.variant_distribution_interval, + 'variant_completed': self.variant_completed, + 'variant_succeeded': self.variant_succeeded, + 'variant_failed': self.variant_failed, + 'variant_actions_per_second': self.variant_actions_per_second, + 'variant_ratio_completed': self.variant_ratio_completed, + 'variant_ratio_succeeded': self.variant_ratio_succeeded, + 'variant_ratio_failed': self.variant_ratio_failed, + 'variant_ratio_aps': self.variant_ratio_aps + } + + @property + def record(self) -> Dict[str, Union[str, bool, int, float]]: + return { + 'variant_name': self.variant_name, + 'variant_experiment': self.variant_experiment, + 'variant_distribution': self.variant_distribution, + **self.stats + } + + @property + def types_map(self): + return { + 'variant_weight': MetricType.SAMPLE, + 'variant_distribution_interval': MetricType.SAMPLE, + 'variant_completed': MetricType.COUNT, + 'variant_succeeded': MetricType.COUNT, + 'variant_failed': MetricType.COUNT, + 'variant_actions_per_second': MetricType.RATE, + 'variant_ratio_completed': MetricType.SAMPLE, + 'variant_ratio_succeeded': MetricType.SAMPLE, + 'variant_ratio_failed': MetricType.SAMPLE, + 'variant_ratio_aps': MetricType.SAMPLE + } + + @property + def tags(self): + tag_fields = { + 'variant_name': self.variant_name, + 'variant_experiment': self.variant_experiment, + 'variant_distribution': self.variant_distribution, + } + + return [ + Tag( + tag_field_name, + tag_field_value + ) for tag_field_name, tag_field_value in tag_fields.items() + ] + + +class ExperimentSummary(BaseModel): + experiment_name: StrictStr + experiment_randomized: StrictBool + experiment_completed: StrictInt + experiment_succeeded: StrictInt + experiment_failed: StrictInt + experiment_median_aps: StrictFloat + experiment_variant_summaries: Dict[str, VariantSummary] + + @property + def stats(self) -> Dict[str, Union[int, float]]: + return { + 'experiment_completed': self.experiment_completed, + 'experiment_succeeded': self.experiment_succeeded, + 'experiment_failed': self.experiment_failed, + 'experiment_median_aps': self.experiment_median_aps + } + + @property + def record(self) -> Dict[str, Union[str, bool, int, float]]: + return { + 'experiment_name': self.experiment_name, + 'experiment_randomized': self.experiment_randomized, + **self.stats + } + + @property + def types_map(self): + return { + 'experiment_completed': MetricType.COUNT, + 'experiment_succeeded': MetricType.COUNT, + 'experiment_failed': MetricType.COUNT, + 'experiment_median_aps': MetricType.SAMPLE + } + + @property + def tags(self): + tag_fields = { + 'experiment_name': self.experiment_name, + 'experiment_randomized': self.experiment_randomized, + } + + return [ + Tag( + tag_field_name, + tag_field_value + ) for tag_field_name, tag_field_value in tag_fields.items() + ] \ No newline at end of file diff --git a/hyperscale/reporting/experiment/experiments_collection.py b/hyperscale/reporting/experiment/experiments_collection.py new file mode 100644 index 0000000..3c7088d --- /dev/null +++ b/hyperscale/reporting/experiment/experiments_collection.py @@ -0,0 +1,37 @@ +from pydantic import ( + BaseModel, + StrictBool, + StrictFloat, + StrictInt, + StrictStr +) +from typing import List, Dict, Union +from .experiment_metrics_set_types import ( + ExperimentSummary, + VariantSummary, + MutationSummary +) + + +class ExperimentMetricsCollection(BaseModel): + experiment: Dict[str, Union[StrictBool, StrictFloat, StrictInt, StrictStr]] + variants: List[Dict[str, Union[StrictBool, StrictFloat, StrictInt, StrictStr]]] + mutations: List[Dict[str, Union[StrictBool, StrictFloat, StrictInt, StrictStr]]] + + experiment_summary: ExperimentSummary + variant_summaries: List[VariantSummary] + mutation_summaries: List[MutationSummary] + + +class ExperimentMetricsCollectionSet(BaseModel): + experiments_metrics_fields: List[str] + variants_metrics_fields: List[str] + mutations_metrics_fields: List[str] + experiments: List[Dict[str, Union[StrictBool, StrictFloat, StrictInt, StrictStr]]] + variants: List[Dict[str, Union[StrictBool, StrictFloat, StrictInt, StrictStr]]] + mutations: List[Dict[str, Union[StrictBool, StrictFloat, StrictInt, StrictStr]]] + + experiment_summaries: List[ExperimentSummary] + variant_summaries: List[VariantSummary] + mutation_summaries: List[MutationSummary] + \ No newline at end of file diff --git a/hyperscale/reporting/metric/__init__.py b/hyperscale/reporting/metric/__init__.py new file mode 100644 index 0000000..4ef656b --- /dev/null +++ b/hyperscale/reporting/metric/__init__.py @@ -0,0 +1,4 @@ +from .metrics_set import MetricsSet +from .metrics_group import MetricsGroup +from .custom_metric import CustomMetric +from .metric_types import MetricType \ No newline at end of file diff --git a/hyperscale/reporting/metric/custom_metric.py b/hyperscale/reporting/metric/custom_metric.py new file mode 100644 index 0000000..949fd25 --- /dev/null +++ b/hyperscale/reporting/metric/custom_metric.py @@ -0,0 +1,25 @@ +from typing import Union +from .metric_types import ( + metric_type_map, + MetricType +) + + +class CustomMetric: + + def __init__( + self, + metric_name: str, + metric_shortname: str, + metric_value: Union[float, int], + metric_group: str=None, + metric_type: str='sample' + ) -> None: + self.metric_shortname: str = metric_shortname + self.metric_name: str = metric_name + self.metric_value: Union[float, int] = metric_value + self.metric_group = metric_group + self.metric_type = metric_type_map.get( + metric_type, + MetricType.SAMPLE + ) \ No newline at end of file diff --git a/hyperscale/reporting/metric/metric_types.py b/hyperscale/reporting/metric/metric_types.py new file mode 100644 index 0000000..1672be4 --- /dev/null +++ b/hyperscale/reporting/metric/metric_types.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class MetricType(Enum): + COUNT='COUNT' + RATE='RATE' + DISTRIBUTION='DISTRIBUTION' + SAMPLE='SAMPLE' + + + +metric_type_map = { + 'count': MetricType.COUNT, + 'rate': MetricType.RATE, + 'distribution': MetricType.DISTRIBUTION, + 'sample': MetricType.SAMPLE +} \ No newline at end of file diff --git a/hyperscale/reporting/metric/metrics_group.py b/hyperscale/reporting/metric/metrics_group.py new file mode 100644 index 0000000..8a25045 --- /dev/null +++ b/hyperscale/reporting/metric/metrics_group.py @@ -0,0 +1,124 @@ +import uuid +from typing import Dict, Union + +from hyperscale.reporting.metric.custom_metric import CustomMetric + + +class MetricsGroup: + + def __init__( + self, + name: str, + source: str, + stage: str, + group_name: str, + data: Dict[str, Union[int, float, str, bool]], + common: Dict[str, int] + ) -> None: + + self.metrics_group_id = str(uuid.uuid4()) + + self.name = name + self.stage = stage + self.group_name = group_name + self.data = [] + + self.fields = [ + 'name', + 'stage' + ] + self.values = [ + name, + stage + ] + + self._additional_fields = ["quantiles", "errors", "custom_metrics"] + self.source = source + self._raw_data = data + self._common = common + + flattened_data = dict({ + field: value for field, value in data.items() if field not in self._additional_fields + }) + + metrics_data = dict(flattened_data.get(group_name, {})) + + for metric_name, metric_value in metrics_data.items(): + self.data.append(( + metric_name, + metric_value + )) + + self.fields.append(metric_name) + self.values.append(metric_value) + + for quantile, quantile_value in data.get("quantiles", {}).items(): + self.data.append(( + quantile, + quantile_value + )) + + self.fields.append(quantile) + self.values.append(quantile_value) + + self.unique = list(self.data) + self.unique_fields = list(self.fields) + self.unique_values = list(self.values) + + self.custom_fields: Dict[str, CustomMetric] = data.get('custom_metrics', {}) + + def get(self, field_name: str): + return self._raw_data.get(field_name) + + + @property + def custom(self): + return { + custom_metric.metric_name: custom_metric.metric_value for custom_metric in self.custom_fields.values() + } + + @property + def custom_schemas(self): + return { + custom_metric.metric_name: custom_metric.metric_type for custom_metric in self.custom_fields.values() + } + + @property + def custom_field_names(self): + return list(self.custom_fields.keys()) + + @property + def record(self): + + record_data = { + f'{field}': value for field, value in self.data + } + + custom_field_data = { + f'{field}': value for field, value in self.custom.items() + } + + return { + 'name': self.name, + 'stage': self.stage, + 'group': self.group_name, + **record_data, + **custom_field_data + } + + @property + def raw_data(self): + return self._raw_data + + @property + def stats(self): + stats_metrics = {} + for metric_name, metric_value in self.data: + if isinstance(metric_value, (int, float)): + stats_metrics[metric_name] = metric_value + + return stats_metrics + + @property + def quantiles(self): + return self._raw_data.get('quantiles', {}) \ No newline at end of file diff --git a/hyperscale/reporting/metric/metrics_set.py b/hyperscale/reporting/metric/metrics_set.py new file mode 100644 index 0000000..b4b8571 --- /dev/null +++ b/hyperscale/reporting/metric/metrics_set.py @@ -0,0 +1,101 @@ +import uuid +from typing import Any, Dict, List, Union + +from hyperscale.reporting.metric.custom_metric import CustomMetric +from hyperscale.reporting.tags import Tag + +from .metrics_group import MetricsGroup + + +class MetricsSet: + + def __init__( + self, + name: str, + source: str, + stage: str, + metrics_data: Dict[str, Dict[str, Any]], + tags: Dict[str, str] + ) -> None: + + self.metrics_set_id = str(uuid.uuid4()) + self.groups: Dict[str, MetricsGroup] = {} + + self.name = name + self.source = source + self.stage = stage + self._raw_metrics = metrics_data + self._raw_tags = tags + + self.fields = [ + 'name', + 'stage' + ] + self.values = [ + name, + stage + ] + + self.common_stats = { + 'total': metrics_data.get('total'), + 'succeeded': metrics_data.get('succeeded'), + 'failed': metrics_data.get('failed'), + 'actions_per_second': metrics_data.get('actions_per_second') + } + + self.custom_metrics: Dict[str, CustomMetric] = metrics_data.get('custom') + self.errors: List[Dict[str, Union[str, int]]] = metrics_data.get('errors') + self.tags = [ + Tag(tag_name, tag_value) for tag_name, tag_value in tags.items() + ] + + metrics_groups = metrics_data.get('groups', {}) + for group_name, group in metrics_groups.items(): + self.groups[group_name] = MetricsGroup( + name, + source, + stage, + group_name, + group, + self.common_stats + ) + + record_fields = self.groups.get('total').fields + custom_fields = list(self.groups.get( + 'total' + ).custom_fields.keys()) + + self.fields.extend(record_fields) + self.fields.extend(custom_fields) + + self.fields = list(sorted(set(self.fields))) + + self.quantiles = list(self.groups.get( + 'total' + ).quantiles.keys()) + + self.custom_field_names = self.custom_fields = self.groups.get( + 'total' + ).custom_field_names + + + self.custom_fields = self.groups.get( + 'total' + ).custom_fields + + self.custom_schemas = self.groups.get( + 'total' + ).custom_schemas + + self.stats_fields = list(self.groups.get( + 'total' + ).stats.keys()) + + def serialize(self): + return { + 'name': self.name, + 'source': self.source, + 'stage': self.stage, + 'metrics_data': self._raw_metrics, + 'tags': self._raw_tags + } \ No newline at end of file diff --git a/hyperscale/reporting/metric/metrics_set_types.py b/hyperscale/reporting/metric/metrics_set_types.py new file mode 100644 index 0000000..b9f004c --- /dev/null +++ b/hyperscale/reporting/metric/metrics_set_types.py @@ -0,0 +1,172 @@ +from decimal import Decimal +from typing import Dict, List, Union + +from pydantic import BaseModel, StrictFloat, StrictInt, StrictStr + + +class CommonMetrics(BaseModel): + total: StrictInt + succeeded: StrictInt + failed: StrictInt + aps: StrictFloat + + +class TotalMetrics(BaseModel): + total_med: StrictFloat + total_μ: StrictFloat + total_var: StrictFloat + total_std: StrictFloat + total_min: StrictFloat + total_max: StrictFloat + total_q_10: StrictFloat + total_q_20: StrictFloat + total_q_30: StrictFloat + total_q_40: StrictFloat + total_q_50: StrictFloat + total_q_60: StrictFloat + total_q_70: StrictFloat + total_q_80: StrictFloat + total_q_90: StrictFloat + total_q_95: StrictFloat + total_q_99: StrictFloat + + +class WaitingMetrics(TotalMetrics): + waiting_med: StrictFloat + waiting_μ: StrictFloat + waiting_var: StrictFloat + waiting_std: StrictFloat + waiting_min: StrictFloat + waiting_max: StrictFloat + waiting_q_10: StrictFloat + waiting_q_20: StrictFloat + waiting_q_30: StrictFloat + waiting_q_40: StrictFloat + waiting_q_50: StrictFloat + waiting_q_60: StrictFloat + waiting_q_70: StrictFloat + waiting_q_80: StrictFloat + waiting_q_90: StrictFloat + waiting_q_95: StrictFloat + waiting_q_99: StrictFloat + + +class ConnectingMetrics(WaitingMetrics): + connecting_med: StrictFloat + connecting_μ: StrictFloat + connecting_var: StrictFloat + connecting_std: StrictFloat + connecting_min: StrictFloat + connecting_max: StrictFloat + connecting_q_10: StrictFloat + connecting_q_20: StrictFloat + connecting_q_30: StrictFloat + connecting_q_40: StrictFloat + connecting_q_50: StrictFloat + connecting_q_60: StrictFloat + connecting_q_70: StrictFloat + connecting_q_80: StrictFloat + connecting_q_90: StrictFloat + connecting_q_95: StrictFloat + connecting_q_99: StrictFloat + + +class WritingMetrics(ConnectingMetrics): + writing_med: StrictFloat + writing_μ: StrictFloat + writing_var: StrictFloat + writing_std: StrictFloat + writing_min: StrictFloat + writing_max: StrictFloat + writing_q_10: StrictFloat + writing_q_20: StrictFloat + writing_q_30: StrictFloat + writing_q_40: StrictFloat + writing_q_50: StrictFloat + writing_q_60: StrictFloat + writing_q_70: StrictFloat + writing_q_80: StrictFloat + writing_q_90: StrictFloat + writing_q_95: StrictFloat + writing_q_99: StrictFloat + + +class ReadingMetrics(WritingMetrics): + reading_med: StrictFloat + reading_μ: StrictFloat + reading_var: StrictFloat + reading_std: StrictFloat + reading_min: StrictFloat + reading_max: StrictFloat + reading_q_10: StrictFloat + reading_q_20: StrictFloat + reading_q_30: StrictFloat + reading_q_40: StrictFloat + reading_q_50: StrictFloat + reading_q_60: StrictFloat + reading_q_70: StrictFloat + reading_q_80: StrictFloat + reading_q_90: StrictFloat + reading_q_95: StrictFloat + reading_q_99: StrictFloat + + +class GroupMetricsSet(ReadingMetrics): + + def get_group(self, group: str) -> Dict[str, Union[int, float]]: + return { + key: value for key, value in self.dict().items() if group in key + } + + + @property + def total(self) -> Dict[str, Union[int, float]]: + return { + key: value for key, value in self.dict().items() if 'total' in key + } + + + @property + def waiting(self) -> Dict[str, Union[int, float]]: + return { + key: value for key, value in self.dict().items() if 'waiting' in key + } + + @property + def connecting(self) -> Dict[str, Union[int, float]]: + return { + key: value for key, value in self.dict().items() if 'connecting' in key + } + + @property + def reading(self) -> Dict[str, Union[int, float]]: + return { + key: value for key, value in self.dict().items() if 'reading' in key + } + + @property + def writing(self) -> Dict[str, Union[int, float]]: + return { + key: value for key, value in self.dict().items() if 'writing' in key + } + + +class StageMetrics(BaseModel): + name: StrictStr + persona: StrictStr + batch_size: StrictInt + total: StrictInt + succeeded: StrictInt + failed: StrictInt + aps: StrictFloat + time: StrictFloat + μ_sec: Union[StrictFloat, Decimal] + μ_waiting: Union[StrictFloat, Decimal] + μ_connecting: Union[StrictFloat, Decimal] + μ_reading: Union[StrictFloat, Decimal] + μ_writing: Union[StrictFloat, Decimal] + streamed_completion_rates: Union[List[float], None] + streamed_completed: Union[List[float], None] + streamed_succeeded: Union[List[float], None] + streamed_failed: Union[List[float], None] + streamed_batch_timings: Union[List[float], None] \ No newline at end of file diff --git a/hyperscale/reporting/metric/stage_metrics_summary.py b/hyperscale/reporting/metric/stage_metrics_summary.py new file mode 100644 index 0000000..2177590 --- /dev/null +++ b/hyperscale/reporting/metric/stage_metrics_summary.py @@ -0,0 +1,367 @@ +import statistics +from decimal import Decimal +from typing import Dict, List, Optional + +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics + +from .metrics_set import MetricsSet +from .metrics_set_types import CommonMetrics, GroupMetricsSet, StageMetrics +from .stage_streams_set import StageStreamsSet + + +class StageMetricsSummary: + + def __init__( + self, + stage_name: str=None, + persona_type: str=None, + batch_size: int=None, + total_elapsed: float=None, + stage_streamed_analytics: Optional[List[StreamAnalytics]]=None + ) -> None: + + self.metrics_sets: Dict[str, MetricsSet] = {} + self.action_and_task_metrics: Dict[str, GroupMetricsSet] = {} + + self.stage_streamed_analytics = stage_streamed_analytics + + initial_stream = [] + if self.stage_streamed_analytics: + initial_stream = [ + 0 for _ in range(len( + self.stage_streamed_analytics[0].interval_completed_counts + )) + ] + + persona_type_slug = '-'.join([ + segment.capitalize() for segment in persona_type.split('-') + ]) + + self.stage_metrics = StageMetrics(**{ + 'name': stage_name, + 'persona': persona_type_slug, + 'batch_size': batch_size, + 'total': 0, + 'succeeded': 0, + 'failed': 0, + 'aps': 0., + 'time': total_elapsed, + 'μ_sec': 0., + 'μ_waiting': 0., + 'μ_connecting': 0., + 'μ_reading': 0., + 'μ_writing': 0., + 'streamed_completion_rates': list(initial_stream), + 'streamed_completed': list(initial_stream), + 'streamed_succeeded': list(initial_stream), + 'streamed_failed': list(initial_stream), + 'streamed_batch_timings': list(initial_stream), + }) + + self.stage_table_header_keys = [ + 'name', + 'time', + 'persona', + 'batch_size', + 'total', + 'succeeded', + 'failed', + 'aps', + 'μ_sec', + 'μ_waiting', + 'μ_connecting', + 'μ_reading', + 'μ_writing' + ] + + self.stage_table_headers = { + header_name: header_name.replace( + '_', ' ' + ) for header_name in self.stage_table_header_keys + } + + self.groups: List[str] = [ + 'total', + 'waiting', + 'connecting', + 'writing', + 'reading' + ] + + self.common_fields: List[str] = [ + 'total', + 'succeeded', + 'failed' + ] + + self.fields: List[str] = [ + 'med', + 'μ', + 'var', + 'std', + 'min', + 'max', + 'q_10', + 'q_20', + 'q_30', + 'q_40', + 'q_50', + 'q_60', + 'q_70', + 'q_80', + 'q_90', + 'q_95', + 'q_99' + ] + + self._record_fields: List[str] = [ + 'median', + 'mean', + 'variance', + 'stdev', + 'minimum', + 'maximum', + 'quantile_10th', + 'quantile_20th', + 'quantile_30th', + 'quantile_40th', + 'quantile_50th', + 'quantile_60th', + 'quantile_70th', + 'quantile_80th', + 'quantile_90th', + 'quantile_95th', + 'quantile_99th' + ] + + self._record_fields_map: Dict[str, str] = {} + + for record_field, table_field in zip(self._record_fields, self.fields): + self._record_fields_map[record_field] = table_field + + self.action_or_task_header_keys: List[str] = [] + + for group in self.groups: + for field in self.fields: + self.action_or_task_header_keys.append( + f'{group}_{field}' + ) + + self.action_or_task_headers = { + header_name: header_name.replace( + '_', ' ' + ) for header_name in self.action_or_task_header_keys + } + + self.common_metrics: Dict[str, CommonMetrics] = {} + + @property + def streams(self): + return StageStreamsSet( + self.stage_metrics.name, + self.stage_streamed_analytics + ) + + def calculate_action_and_task_metrics(self): + self._group_action_and_task_metrics() + self._calculate_stage_totals() + + if self.stage_streamed_analytics: + self._calculate_completed_rate_stream() + self._calculate_succeeded_rate_stream() + self._calculate_failed_rate_stream() + self._calculate_batch_timings_stream() + self._calculate_completion_rate_stream() + + def _group_action_and_task_metrics(self): + + for action_or_task_name, metric_set in self.metrics_sets.items(): + + self.stage_metrics.total += metric_set.common_stats.get('total') + self.stage_metrics.succeeded += metric_set.common_stats.get('succeeded') + self.stage_metrics.failed += metric_set.common_stats.get('failed') + + grouped_metrics = {} + common_metrics = {} + + for group_name, group_metrics in metric_set.groups.items(): + + for field_name, field_value in group_metrics.record.items(): + if isinstance(field_value, (int, float,)): + + table_field_name = self._record_fields_map.get(field_name) + metric_key = f'{group_name}_{table_field_name}' + + grouped_metrics[metric_key] = field_value + + for table_key in self.common_fields: + common_metrics[table_key] = metric_set.common_stats.get(table_key) + + common_metrics['aps'] = metric_set.common_stats.get('actions_per_second') + + self.common_metrics[action_or_task_name] = CommonMetrics(**common_metrics) + self.action_and_task_metrics[action_or_task_name] = GroupMetricsSet(**grouped_metrics) + + def _calculate_stage_totals(self): + self.stage_metrics.aps = round( + self.stage_metrics.total/self.stage_metrics.time, + 2 + ) + + for group_metrics_set in self.action_and_task_metrics.values(): + self.stage_metrics.μ_sec = group_metrics_set.total_μ + self.stage_metrics.μ_waiting += group_metrics_set.waiting_μ + self.stage_metrics.μ_connecting += group_metrics_set.connecting_μ + self.stage_metrics.μ_writing += group_metrics_set.writing_μ + self.stage_metrics.μ_reading += group_metrics_set.reading_μ + + + self.stage_metrics.μ_sec = Decimal( + self.stage_metrics.μ_sec/self.stage_metrics.total + ) + + self.stage_metrics.μ_waiting = Decimal( + self.stage_metrics.μ_waiting/self.stage_metrics.total + ) + + self.stage_metrics.μ_connecting = Decimal( + self.stage_metrics.μ_connecting/self.stage_metrics.total + ) + + self.stage_metrics.μ_reading = Decimal( + self.stage_metrics.μ_reading/self.stage_metrics.total + ) + + self.stage_metrics.μ_writing = Decimal( + self.stage_metrics.μ_writing/self.stage_metrics.total + ) + + def _calculate_completion_rate_stream(self): + bins_count = max( + len(stream.interval_completion_rates) for stream in self.stage_streamed_analytics + ) + self.stage_metrics.streamed_completion_rates = [0 for _ in range(bins_count)] + + for idx in range(bins_count): + + self.stage_metrics.streamed_completion_rates[idx] = round( + self.stage_metrics.streamed_completed[idx]/self.stage_metrics.streamed_batch_timings[idx], + 2 + ) + + def _calculate_succeeded_rate_stream(self): + bins_count = max( + len(stream.interval_succeeded_counts) for stream in self.stage_streamed_analytics + ) + + self.stage_metrics.streamed_succeeded = [0 for _ in range(bins_count)] + + for stream in self.stage_streamed_analytics: + for idx, succeeded_count in enumerate(stream.interval_succeeded_counts): + self.stage_metrics.streamed_succeeded[idx] += succeeded_count + + total_succeeded = sum(self.stage_metrics.streamed_succeeded) + + if total_succeeded > self.stage_metrics.succeeded: + stream_error = total_succeeded - self.stage_metrics.succeeded + binned_error = int(stream_error/bins_count) + remainder_error = stream_error%bins_count + + carried = 0 + for idx in range(bins_count): + error = binned_error + carried + corrected_amount = self.stage_metrics.streamed_succeeded[idx] - error + + if corrected_amount > 0: + self.stage_metrics.streamed_succeeded[idx] = corrected_amount + carried = 0 + + else: + carried += error + + self.stage_metrics.streamed_succeeded[-1] -= remainder_error + + def _calculate_failed_rate_stream(self): + bins_count = max( + len(stream.interval_failed_counts) for stream in self.stage_streamed_analytics + ) + + self.stage_metrics.streamed_failed = [0 for _ in range(bins_count)] + + for stream in self.stage_streamed_analytics: + for idx, failed_count in enumerate(stream.interval_failed_counts): + self.stage_metrics.streamed_failed[idx] += failed_count + + total_failed = sum(self.stage_metrics.streamed_failed) + + if total_failed > self.stage_metrics.failed: + stream_error = total_failed - self.stage_metrics.failed + binned_error = int(stream_error/bins_count) + remainder_error = stream_error%bins_count + + carried = 0 + for idx in range(bins_count): + error = binned_error + carried + corrected_amount = self.stage_metrics.streamed_failed[idx] - error + + if corrected_amount > 0: + self.stage_metrics.streamed_failed[idx] = corrected_amount + carried = 0 + + else: + carried += error + + self.stage_metrics.streamed_failed[-1] -= remainder_error + + def _calculate_completed_rate_stream(self): + bins_count = max([ + len(stream.interval_completed_counts) for stream in self.stage_streamed_analytics + ]) + self.stage_metrics.streamed_completed = [0 for _ in range(bins_count)] + + for stream in self.stage_streamed_analytics: + for idx, completed_count in enumerate(stream.interval_completed_counts): + self.stage_metrics.streamed_completed[idx] += completed_count + + total_completed = sum(self.stage_metrics.streamed_completed) + + if total_completed > self.stage_metrics.total: + stream_error = total_completed - self.stage_metrics.total + binned_error = int(stream_error/bins_count) + remainder_error = stream_error%bins_count + + carried = 0 + for idx in range(bins_count): + error = binned_error + carried + corrected_amount = self.stage_metrics.streamed_completed[idx] - error + + if corrected_amount > 0: + self.stage_metrics.streamed_completed[idx] = corrected_amount + carried = 0 + + else: + carried += error + + self.stage_metrics.streamed_completed[-1] -= remainder_error + + def _calculate_batch_timings_stream(self): + bins_count = max([ + len(stream.interval_batch_timings) for stream in self.stage_streamed_analytics + ]) + + streamed_batch_timings = [[] for _ in range(bins_count)] + + for stream in self.stage_streamed_analytics: + for idx, batch_timing in enumerate(stream.interval_batch_timings): + streamed_batch_timings[idx].append(batch_timing) + + for idx, batch_timings in enumerate(streamed_batch_timings): + streamed_batch_timings[idx] = statistics.median(batch_timings) + + for idx, batch_timing in enumerate(streamed_batch_timings): + streamed_batch_timings[idx] = round( + batch_timing, + 2 + ) + + self.stage_metrics.streamed_batch_timings = streamed_batch_timings diff --git a/hyperscale/reporting/metric/stage_streams_set.py b/hyperscale/reporting/metric/stage_streams_set.py new file mode 100644 index 0000000..bd37ed9 --- /dev/null +++ b/hyperscale/reporting/metric/stage_streams_set.py @@ -0,0 +1,161 @@ +import statistics +import uuid +from typing import Callable, Dict, List, Union + +import numpy + +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.reporting.metric.metric_types import MetricType + +CalculationMethod = Callable[[Dict[str,List[Union[int, float]]]], Dict[str, Union[int, float]]] + + +class StageStreamsSet: + + def __init__(self, + stage: str, + stage_streams: List[StreamAnalytics] + ) -> None: + self.stage = stage + self.stream_set_id = uuid.uuid4() + self.interval_completion_rates: List[float] = [] + self.interval_completed_counts: List[int] = [] + self.interval_succeeded_counts: List[int] = [] + self.interval_failed_counts: List[int] = [] + self.interval_batch_timings: List[float] = [] + + for stream in stage_streams: + self.interval_completion_rates.extend(stream.interval_completion_rates) + self.interval_completed_counts.extend(stream.interval_completed_counts) + self.interval_succeeded_counts.extend(stream.interval_succeeded_counts) + self.interval_failed_counts.extend(stream.interval_failed_counts) + self.interval_batch_timings.extend(stream.interval_batch_timings) + + self._stat_types: Dict[str, CalculationMethod] = { + 'median': self._calculate_median, + 'mean': self._calculate_mean, + 'max': self._calculate_max, + 'min': self._calculate_min, + 'stdev': self._calculate_stdev, + 'variance': self._calculate_var, + } + + self.quantiles = [ + 10, + 20, + 25, + 30, + 40, + 50, + 60, + 70, + 75, + 80, + 90, + 95, + 99 + ] + + self.stats: Dict[str, List[Union[int, float]]] = { + 'completed': self.interval_completed_counts, + 'succeeded': self.interval_succeeded_counts, + 'failed': self.interval_failed_counts, + 'batch_time': self.interval_batch_timings, + 'completion_rate': self.interval_completion_rates + } + + + @property + def types_map(self) -> Dict[str, MetricType]: + return { + 'completed': MetricType.COUNT, + 'succeeded': MetricType.COUNT, + 'failed': MetricType.COUNT, + 'batch_time': MetricType.SAMPLE, + 'completion_rate': MetricType.RATE + } + + @property + def grouped(self) -> Dict[str, Dict[str, Union[int, float]]]: + + + grouped: Dict[str, Dict[str, Union[int, float]]] = {} + + for group_name, group_stats in self.stats.items(): + + metrics: Dict[str, Union[int, float]] = { + stat_name: calculation_method( + group_stats + ) for stat_name, calculation_method in self._stat_types.items() + } + + metrics.update( + self._calculate_quantiles(group_stats) + ) + + grouped[group_name] = metrics + + return grouped + + @property + def record(self) -> Dict[str, Union[int, float]]: + + record_stats: Dict[str, Dict[str, Union[int, float]]] = {} + + for group_name, group_stats in self.stats.items(): + for stat_name, calculation_method in self._stat_types.items(): + record_key = f'{stat_name}_{group_name}' + + record_stats[record_key] = calculation_method(group_stats) + + + median_batch_time = record_stats['median_batch_time'] + if median_batch_time == 0: + median_batch_time = 1 + + actions_per_second = record_stats['median_completed']/median_batch_time + actions_per_second_succeeded = record_stats['median_succeeded']/median_batch_time + actions_per_second_failed = record_stats['median_failed']/median_batch_time + + record_stats.update({ + 'actions_per_second': actions_per_second, + 'actions_per_second_succeeded': actions_per_second_succeeded, + 'actions_per_second_failed': actions_per_second_failed + }) + + return record_stats + + def _calculate_mean(self, stats: List[Union[int, float]]) -> float: + return statistics.mean(stats) + + def _calculate_median(self, stats: List[Union[int, float]]) -> Union[int, float]: + return statistics.median(stats) + + def _calculate_min(self, stats:List[Union[int, float]]) -> Union[int, float]: + return min(stats) + + def _calculate_max(self, stats: List[Union[int, float]]) -> Union[int ,float]: + return max(stats) + + def _calculate_stdev(self, stats: List[Union[int, float]]): + return statistics.stdev(stats) + + def _calculate_var(self, stats: List[Union[int, float]]) -> Union[int, float]: + return statistics.variance(stats) + + def _calculate_quantiles(self, stats: List[Union[int, float]]) -> Dict[str, Union[int, float]]: + + quantile_stats: Dict[str, Union[int, float]] = {} + + for quantile in self.quantiles: + stat_key = f'quantile_{quantile}th' + + quantile_stats[stat_key] = numpy.quantile( + stats, + round( + quantile/100, + 2 + ) + ) + + return quantile_stats diff --git a/hyperscale/reporting/processed_result/__init__.py b/hyperscale/reporting/processed_result/__init__.py new file mode 100644 index 0000000..140e421 --- /dev/null +++ b/hyperscale/reporting/processed_result/__init__.py @@ -0,0 +1,2 @@ +from .results import results_types +from .processed_results_group import ProcessedResultsGroup \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/processed_results_group.py b/hyperscale/reporting/processed_result/processed_results_group.py new file mode 100644 index 0000000..1f19697 --- /dev/null +++ b/hyperscale/reporting/processed_result/processed_results_group.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import uuid +from collections import defaultdict +from typing import Any, Dict, Union + +import numpy + +from hyperscale.reporting.stats import Mean, Median, StandardDeviation, Variance + +from .results import results_types +from .types.base_processed_result import BaseProcessedResult +from .types.task_processed_result import TaskProcessedResult + + +def default_count(): + return 0 + + +class ProcessedResultsGroup: + + __slots__ = ( + 'events_group_id', + 'source', + 'groups', + 'events', + 'timings', + 'tags', + 'total', + 'succeeded', + 'failed', + 'errors', + '_streaming_mean', + '_streaming_variance', + '_streaming_stdev', + '_streaming_median' + ) + + def __init__(self) -> None: + + self.events_group_id = str(uuid.uuid4()) + + self.groups: Dict[Dict[str, Union[int, float]]] = {} + self.timings = defaultdict(list) + self.source = None + self.tags = {} + self.total = 0 + self.succeeded = 0 + self.failed = 0 + self.errors = defaultdict(default_count) + + self._streaming_mean = defaultdict(Mean) + self._streaming_variance = defaultdict(Variance) + self._streaming_stdev = defaultdict(StandardDeviation) + self._streaming_median = defaultdict(Median) + + def add( + self, + stage_name: str, + result: Any, + ): + + processed_result: BaseProcessedResult = results_types.get(result.type, TaskProcessedResult)( + stage_name, + result + ) + + if self.source is None: + self.source = processed_result.source + + self.tags.update(processed_result.tags_to_dict()) + + if processed_result.error is None: + self.succeeded += 1 + + else: + self.errors[processed_result.error] += 1 + self.failed += 1 + + for timing_group, timing in processed_result.timings.items(): + if timing > 0: + self.timings[timing_group].append(timing) + + def calculate_stats(self): + + self.total = self.succeeded + self.failed + + for group_name, group_timings in self.timings.items(): + + if len(group_timings) == 0: + group_timings = [0] + + median = float((numpy.median(group_timings))) + mean = float(numpy.mean(group_timings, dtype=numpy.float64)) + variance = float(numpy.var(group_timings, dtype=numpy.float64, ddof=1)) + stdev = float(numpy.std(group_timings, dtype=numpy.float64)) + + self._streaming_median[group_name].update(median) + self._streaming_mean[group_name].update(mean) + self._streaming_variance[group_name].update(variance) + self._streaming_stdev[group_name].update(stdev) + + self.groups[group_name] = { + group_name: { + 'median': median, + 'mean': mean, + 'variance': variance, + 'stdev': stdev, + 'minimum': min(group_timings), + 'maximum': max(group_timings) + } + + } + + def calculate_quantiles(self): + + quantile_ranges = [ + .10, + .20, + .25, + .30, + .40, + .50, + .60, + .70, + .75, + .80, + .90, + .95, + .99 + ] + + for group_name, group_timings in self.timings.items(): + + if len(group_timings) == 0: + group_timings = [0] + + quantiles = { + f'quantile_{int(quantile_range * 100)}th': quantile for quantile, quantile_range in zip( + numpy.quantile( + numpy.array(group_timings), quantile_ranges), + quantile_ranges + ) + } + + self.groups[group_name]['quantiles'] = quantiles \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/results.py b/hyperscale/reporting/processed_result/results.py new file mode 100644 index 0000000..a162af6 --- /dev/null +++ b/hyperscale/reporting/processed_result/results.py @@ -0,0 +1,28 @@ +from hyperscale.core.engines.types.common.types import RequestTypes + +from .types import ( + GraphQLHTTP2ProcessedResult, + GraphQLProcessedResult, + GRPCProcessedResult, + HTTP2ProcessedResult, + HTTP3ProcessedResult, + HTTPProcessedResult, + PlaywrightProcessedResult, + TaskProcessedResult, + UDPProcessedResult, + WebsocketProcessedResult, +) + +results_types = { + RequestTypes.GRAPHQL: GraphQLProcessedResult, + RequestTypes.GRAPHQL_HTTP2: GraphQLHTTP2ProcessedResult, + RequestTypes.GRPC: GRPCProcessedResult, + RequestTypes.HTTP: HTTPProcessedResult, + RequestTypes.HTTP2: HTTP2ProcessedResult, + RequestTypes.HTTP3: HTTP3ProcessedResult, + RequestTypes.PLAYWRIGHT: PlaywrightProcessedResult, + RequestTypes.TASK: TaskProcessedResult, + RequestTypes.UDP: UDPProcessedResult, + RequestTypes.WEBSOCKET: WebsocketProcessedResult +} + diff --git a/hyperscale/reporting/processed_result/types/__init__.py b/hyperscale/reporting/processed_result/types/__init__.py new file mode 100644 index 0000000..b4d5300 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/__init__.py @@ -0,0 +1,10 @@ +from .graphql_processed_result import GraphQLProcessedResult +from .graphql_http2_processed_result import GraphQLHTTP2ProcessedResult +from .grpc_processed_result import GRPCProcessedResult +from .http_processed_result import HTTPProcessedResult +from .http2_processed_result import HTTP2ProcessedResult +from .http3_processed_result import HTTP3ProcessedResult +from .playwright_processed_result import PlaywrightProcessedResult +from .task_processed_result import TaskProcessedResult +from .udp_processed_result import UDPProcessedResult +from .websocket_processed_result import WebsocketProcessedResult \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/base_processed_result.py b/hyperscale/reporting/processed_result/types/base_processed_result.py new file mode 100644 index 0000000..a7a8ae8 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/base_processed_result.py @@ -0,0 +1,86 @@ +import uuid +from typing import Dict, List, Union + +from hyperscale.core.engines.types.common.base_result import BaseResult +from hyperscale.core.hooks.types.base.event import BaseEvent +from hyperscale.reporting.tags import Tag + + +class BaseProcessedResult: + + __slots__ = ( + 'stage_name', + 'event_id', + 'action_id', + 'name', + 'shortname', + 'error', + 'type', + 'source', + 'checks', + 'tags', + 'stage', + 'timings', + 'time' + ) + + def __init__( + self, + stage_name: str, + result: BaseResult + ) -> None: + + self.event_id = str(uuid.uuid4()) + self.action_id = result.action_id + + self.name = result.name + self.shortname = result.name + self.error = result.error + self.timings = {} + self.type = result.type + self.source = result.source + self.checks: List[List[BaseEvent]] = [] + self.stage = stage_name + + self.time = self.timings.get('total', 0) + + self.tags = [ + Tag( + tag.get('name'), + tag.get('value') + ) for tag in result.tags + ] + + + @property + def fields(self): + return ['id', 'name', 'stage', 'time', 'succeeded'] + + @property + def values(self): + return [self.name, self.stage, self.time, self.success] + + @property + def record(self): + return { + 'id': self.event_id, + 'name': self.name, + 'stage': self.stage, + 'time': self.time, + 'succeeded': self.success + } + + @property + def success(self): + return self.error is None + + def tags_to_dict(self): + return { + tag.name: tag.value for tag in self.tags + } + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + raise NotImplementedError('Err. - Implement this method in a specific class inheriting from BaseProcessedResult') + + def serialize(self) -> str: + raise NotImplementedError('Err. - Implement this method in a specific class inheriting from BaseProcessedResult') \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/graphql_http2_processed_result.py b/hyperscale/reporting/processed_result/types/graphql_http2_processed_result.py new file mode 100644 index 0000000..d59f2c4 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/graphql_http2_processed_result.py @@ -0,0 +1,45 @@ +from typing import Dict, Union + +from hyperscale.core.engines.types.graphql_http2 import GraphQLHTTP2Result + +from .http2_processed_result import HTTP2ProcessedResult + + +class GraphQLHTTP2ProcessedResult(HTTP2ProcessedResult): + + __slots__ = ( + 'event_id', + 'action_id', + 'url', + 'ip_addr', + 'method', + 'path', + 'params', + 'hostname', + 'status', + 'headers', + 'data', + 'status', + 'timings', + 'query' + ) + + def __init__( + self, + stage: str, + result: GraphQLHTTP2Result + ) -> None: + super(GraphQLHTTP2ProcessedResult, self).__init__( + stage, + result + ) + + self.query = result.query + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + graphql_result_dict = super().to_dict() + + return { + **graphql_result_dict, + 'query': self.query + } \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/graphql_processed_result.py b/hyperscale/reporting/processed_result/types/graphql_processed_result.py new file mode 100644 index 0000000..4e8fbb4 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/graphql_processed_result.py @@ -0,0 +1,45 @@ +from typing import Dict, Union + +from hyperscale.core.engines.types.graphql import GraphQLResult + +from .http_processed_result import HTTPProcessedResult + + +class GraphQLProcessedResult(HTTPProcessedResult): + + __slots__ = ( + 'event_id', + 'action_id', + 'url', + 'ip_addr', + 'method', + 'path', + 'params', + 'hostname', + 'status', + 'headers', + 'data', + 'status', + 'timings', + 'query' + ) + + def __init__( + self, + stage: str, + result: GraphQLResult + ) -> None: + super(GraphQLProcessedResult, self).__init__( + stage, + result + ) + + self.query = result.query + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + graphql_result_dict = super().to_dict() + + return { + **graphql_result_dict, + 'query': self.query + } \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/grpc_processed_result.py b/hyperscale/reporting/processed_result/types/grpc_processed_result.py new file mode 100644 index 0000000..95914fb --- /dev/null +++ b/hyperscale/reporting/processed_result/types/grpc_processed_result.py @@ -0,0 +1,19 @@ +from hyperscale.core.engines.types.grpc import GRPCResult + +from .http2_processed_result import HTTP2ProcessedResult + + +class GRPCProcessedResult(HTTP2ProcessedResult): + + def __init__( + self, + stage: str, + result: GRPCResult + ) -> None: + super( + GRPCProcessedResult, + self + ).__init__( + stage, + result + ) \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/http2_processed_result.py b/hyperscale/reporting/processed_result/types/http2_processed_result.py new file mode 100644 index 0000000..06818e7 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/http2_processed_result.py @@ -0,0 +1,103 @@ +import json +from typing import Dict, Union + +from hyperscale.core.engines.types.http2 import HTTP2Result + +from .base_processed_result import BaseProcessedResult + + +class HTTP2ProcessedResult(BaseProcessedResult): + + __slots__ = ( + 'event_id', + 'action_id', + 'url', + 'ip_addr', + 'method', + 'path', + 'params', + 'hostname', + 'status', + 'headers', + 'data', + 'status', + 'timings' + ) + + def __init__( + self, + stage: str, + result: HTTP2Result + ) -> None: + super(HTTP2ProcessedResult, self).__init__( + stage, + result + ) + + self.url = result.url + self.ip_addr = result.ip_addr + self.method = result.method + self.path = result.path + self.params = result.params + self.hostname = result.hostname + self.status = None + self.headers: Dict[bytes, bytes] = result.headers + self.data = result.data + self.status = result.status + + self.name = f'{self.method}_{self.shortname}' + + self.time = result.complete - result.start + + self.timings = { + 'total': self.time, + 'waiting': result.start - result.wait_start, + 'connecting': result.connect_end - result.start, + 'writing': result.write_end - result.connect_end, + 'reading': result.complete - result.write_end + } + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + + data = self.data + if isinstance(data, (bytes, bytearray)): + data = data.decode() + + serializable_headers = {} + for key, value in self.headers.items(): + serializable_headers[key.decode()] = value.decode() + + return { + 'name': self.name, + 'stage': self.stage, + 'shortname': self.shortname, + 'checks': [check.__name__ for check in self.checks], + 'error': str(self.error), + 'time': self.time, + 'type': self.type, + 'source': self.source, + 'url': self.url, + 'ip_addr': self.ip_addr, + 'method': self.method, + 'path': self.path, + 'params': self.params, + 'hostname': self.hostname, + 'status': self.status, + 'headers': serializable_headers, + 'data': data, + **self.timings + } + + def serialize(self) -> str: + + data = self.data + if isinstance(data, (bytes, bytearray)): + data = data.decode() + + serializable_headers = {} + for key, value in self.headers.items(): + serializable_headers[key.decode()] = value.decode() + + return json.dumps( + self.to_dict() + ) diff --git a/hyperscale/reporting/processed_result/types/http3_processed_result.py b/hyperscale/reporting/processed_result/types/http3_processed_result.py new file mode 100644 index 0000000..4071788 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/http3_processed_result.py @@ -0,0 +1,16 @@ +from hyperscale.core.engines.types.graphql import GraphQLResult + +from .http_processed_result import HTTPProcessedResult + + +class HTTP3ProcessedResult(HTTPProcessedResult): + + def __init__( + self, + stage: str, + result: GraphQLResult + ) -> None: + super(HTTP3ProcessedResult, self).__init__( + stage, + result + ) \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/http_processed_result.py b/hyperscale/reporting/processed_result/types/http_processed_result.py new file mode 100644 index 0000000..b07badf --- /dev/null +++ b/hyperscale/reporting/processed_result/types/http_processed_result.py @@ -0,0 +1,99 @@ +import json +from typing import Dict, Union + +from hyperscale.core.engines.types.http import HTTPResult + +from .base_processed_result import BaseProcessedResult + + +class HTTPProcessedResult(BaseProcessedResult): + + __slots__ = ( + 'event_id', + 'action_id', + 'url', + 'ip_addr', + 'method', + 'path', + 'params', + 'hostname', + 'status', + 'headers', + 'data', + 'timings' + ) + + def __init__( + self, + stage: str, + result: HTTPResult + ) -> None: + super(HTTPProcessedResult, self).__init__( + stage, + result + ) + + self.url = result.url + self.ip_addr = result.ip_addr + self.method = result.method + self.path = result.path + self.params = result.params + self.hostname = result.hostname + self.status = result.status + self.headers: Dict[bytes, bytes] = result.headers + self.data = result.data + + self.time = result.complete - result.start + + self.timings = { + 'total': self.time, + 'waiting': result.start - result.wait_start, + 'connecting': result.connect_end - result.start, + 'writing': result.write_end - result.connect_end, + 'reading': result.complete - result.write_end + } + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + + data = self.data + if isinstance(data, (bytes, bytearray)): + data = data.decode() + + serializable_headers = {} + for key, value in self.headers.items(): + serializable_headers[key.decode()] = value.decode() + + return { + 'name': self.name, + 'stage': self.stage, + 'shortname': self.shortname, + 'checks': [check.__name__ for check in self.checks], + 'error': str(self.error), + 'time': self.time, + 'type': self.type, + 'source': self.source, + 'url': self.url, + 'ip_addr': self.ip_addr, + 'method': self.method, + 'path': self.path, + 'params': self.params, + 'hostname': self.hostname, + 'status': self.status, + 'headers': serializable_headers, + 'data': data, + **self.timings + } + + def serialize(self) -> str: + + data = self.data + if isinstance(data, (bytes, bytearray)): + data = data.decode() + + serializable_headers = {} + for key, value in self.headers.items(): + serializable_headers[key.decode()] = value.decode() + + return json.dumps( + self.to_dict() + ) diff --git a/hyperscale/reporting/processed_result/types/playwright_processed_result.py b/hyperscale/reporting/processed_result/types/playwright_processed_result.py new file mode 100644 index 0000000..98b7b28 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/playwright_processed_result.py @@ -0,0 +1,79 @@ +import json +from typing import Dict, Union + +from hyperscale.core.engines.types.playwright import PlaywrightResult + +from .base_processed_result import BaseProcessedResult + + +class PlaywrightProcessedResult(BaseProcessedResult): + + __slots__ = ( + 'event_id', + 'action_id', + 'url', + 'headers', + 'command', + 'selector', + 'x_coord', + 'y_coord', + 'frame', + 'timings' + ) + + def __init__( + self, + stage: str, + result: PlaywrightResult + ) -> None: + super( + PlaywrightProcessedResult, + self + ).__init__( + stage, + result + ) + + self.url = result.url + self.headers = result.headers + self.command = result.command + self.selector = result.selector + self.x_coord = result.x_coord + self.y_coord = result.y_coord + self.frame = result.frame + + self.time = result.complete - result.start + + self.timings = { + 'total': self.time, + 'waiting': result.start - result.wait_start, + 'connecting': result.connect_end - result.start, + 'writing': result.write_end - result.connect_end, + 'reading': result.complete - result.write_end + } + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + + return { + 'name': self.name, + 'stage': self.stage, + 'shortname': self.shortname, + 'checks': [check.__name__ for check in self.checks], + 'error': str(self.error), + 'time': self.time, + 'type': self.type, + 'source': self.source, + 'url': self.url, + 'headers': self.headers, + 'command': self.command, + 'selector': self.selector, + 'x_coord': self.x_coord, + 'y_coord': self.y_coord, + 'frame': self.frame, + **self.timings + } + + def serialize(self) -> str: + return json.dumps( + self.to_dict() + ) \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/task_processed_result.py b/hyperscale/reporting/processed_result/types/task_processed_result.py new file mode 100644 index 0000000..5c76e0e --- /dev/null +++ b/hyperscale/reporting/processed_result/types/task_processed_result.py @@ -0,0 +1,50 @@ +import json +from typing import Dict, Union + +from hyperscale.core.engines.types.task.result import TaskResult + +from .base_processed_result import BaseProcessedResult + + +class TaskProcessedResult(BaseProcessedResult): + + def __init__( + self, + stage: str, + result: TaskResult + ) -> None: + super( + TaskProcessedResult, + self + ).__init__( + stage, + result + ) + + self.time = result.complete - result.start + self.timings = { + 'total': self.time, + 'waiting': result.start - result.wait_start, + 'reading': result.complete - result.write_end + } + + self.data = result.data + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + return { + 'name': self.name, + 'stage': self.stage, + 'shortname': self.shortname, + 'checks': [check.__name__ for check in self.checks], + 'error': str(self.error), + 'time': self.time, + 'type': self.type, + 'source': self.source, + 'data': self.data, + **self.timings + } + + def serialize(self) -> str: + return json.dumps( + self.to_dict() + ) \ No newline at end of file diff --git a/hyperscale/reporting/processed_result/types/udp_processed_result.py b/hyperscale/reporting/processed_result/types/udp_processed_result.py new file mode 100644 index 0000000..2733d99 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/udp_processed_result.py @@ -0,0 +1,91 @@ +import json +from typing import Dict, Union + +from hyperscale.core.engines.types.udp import UDPResult + +from .base_processed_result import BaseProcessedResult + + +class UDPProcessedResult(BaseProcessedResult): + + __slots__ = ( + 'event_id', + 'action_id', + 'url', + 'ip_addr', + 'path', + 'method', + 'headers', + 'params', + 'hostname', + 'status', + 'data', + 'timings' + ) + + def __init__( + self, + stage: str, + result: UDPResult + ) -> None: + super(UDPProcessedResult, self).__init__( + stage, + result + ) + + self.url = result.url + self.ip_addr = result.ip_addr + self.path = result.path + self.method = 'n/a' + self.headers = {} + self.params = result.params + self.hostname = result.hostname + self.status = result.status + self.data = result.data + self.name = self.shortname + + self.time = result.complete - result.start + + self.timings = { + 'total': self.time, + 'waiting': result.start - result.wait_start, + 'connecting': result.connect_end - result.start, + 'writing': result.write_end - result.connect_end, + 'reading': result.complete - result.write_end + } + + def to_dict(self) -> Dict[str, Union[str, int, float]]: + + data = self.data + if isinstance(data, (bytes, bytearray)): + data = data.decode() + + return { + 'name': self.name, + 'stage': self.stage, + 'shortname': self.shortname, + 'checks': [check.__name__ for check in self.checks], + 'error': str(self.error), + 'time': self.time, + 'type': self.type, + 'source': self.source, + 'url': self.url, + 'ip_addr': self.ip_addr, + 'method': self.method, + 'path': self.path, + 'params': self.params, + 'hostname': self.hostname, + 'status': self.status, + 'headers': self.headers, + 'data': data + } + + def serialize(self) -> str: + + data = self.data + if isinstance(data, (bytes, bytearray)): + data = data.decode() + + return json.dumps( + self.to_dict() + ) diff --git a/hyperscale/reporting/processed_result/types/websocket_processed_result.py b/hyperscale/reporting/processed_result/types/websocket_processed_result.py new file mode 100644 index 0000000..6690e14 --- /dev/null +++ b/hyperscale/reporting/processed_result/types/websocket_processed_result.py @@ -0,0 +1,19 @@ +from hyperscale.core.engines.types.websocket import WebsocketResult + +from .http_processed_result import HTTPProcessedResult + + +class WebsocketProcessedResult(HTTPProcessedResult): + + def __init__( + self, + stage: str, + result: WebsocketResult + ) -> None: + super( + WebsocketProcessedResult, + self + ).__init__( + stage, + result + ) \ No newline at end of file diff --git a/hyperscale/reporting/reporter.py b/hyperscale/reporting/reporter.py new file mode 100644 index 0000000..5347450 --- /dev/null +++ b/hyperscale/reporting/reporter.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +import os +import threading +import uuid +from typing import Any, Dict, List, TypeVar, Union + +from hyperscale.core.personas.streaming.stream_analytics import StreamAnalytics +from hyperscale.logging import HyperscaleLogger +from hyperscale.plugins.types.reporter.reporter_config import ReporterConfig + +from .experiment.experiment_metrics_set import ExperimentMetricsSet +from .experiment.experiment_metrics_set_types import MutationSummary, VariantSummary +from .experiment.experiments_collection import ( + ExperimentMetricsCollection, + ExperimentMetricsCollectionSet, +) +from .types import ( + CSV, + JSON, + S3, + XML, + AWSLambda, + AWSLambdaConfig, + AWSTimestream, + AWSTimestreamConfig, + BigQuery, + BigQueryConfig, + BigTable, + BigTableConfig, + Cassandra, + CassandraConfig, + Cloudwatch, + CloudwatchConfig, + CosmosDB, + CosmosDBConfig, + CSVConfig, + Datadog, + DatadogConfig, + DogStatsD, + DogStatsDConfig, + GoogleCloudStorage, + GoogleCloudStorageConfig, + Graphite, + GraphiteConfig, + Honeycomb, + HoneycombConfig, + InfluxDB, + InfluxDBConfig, + JSONConfig, + Kafka, + KafkaConfig, + MongoDB, + MongoDBConfig, + MySQL, + MySQLConfig, + Netdata, + NetdataConfig, + NewRelic, + NewRelicConfig, + Postgres, + PostgresConfig, + Prometheus, + PrometheusConfig, + Redis, + RedisConfig, + ReporterTypes, + S3Config, + Snowflake, + SnowflakeConfig, + SQLite, + SQLiteConfig, + StatsD, + StatsDConfig, + Telegraf, + TelegrafConfig, + TelegrafStatsD, + TelegrafStatsDConfig, + TimescaleDB, + TimescaleDBConfig, + XMLConfig, +) + +ReporterType = TypeVar( + 'ReporterType', + AWSLambdaConfig, + AWSTimestreamConfig, + BigQueryConfig, + BigTableConfig, + CassandraConfig, + CloudwatchConfig, + CosmosDBConfig, + CSVConfig, + DatadogConfig, + DogStatsDConfig, + GoogleCloudStorageConfig, + GraphiteConfig, + HoneycombConfig, + InfluxDBConfig, + JSONConfig, + KafkaConfig, + MongoDBConfig, + MySQLConfig, + NetdataConfig, + NewRelicConfig, + PostgresConfig, + PrometheusConfig, + RedisConfig, + S3Config, + SnowflakeConfig, + SQLiteConfig, + StatsDConfig, + TelegrafConfig, + TelegrafStatsDConfig, + TimescaleDBConfig, + XMLConfig +) + + +class Reporter: + reporters = { + ReporterTypes.AWSLambda: lambda config: AWSLambda(config), + ReporterTypes.AWSTimestream: lambda config: AWSTimestream(config), + ReporterTypes.BigQuery: lambda config: BigQuery(config), + ReporterTypes.BigTable: lambda config: BigTable(config), + ReporterTypes.Cassandra: lambda config: Cassandra(config), + ReporterTypes.Cloudwatch: lambda config: Cloudwatch(config), + ReporterTypes.CosmosDB: lambda config: CosmosDB(config), + ReporterTypes.CSV: lambda config: CSV(config), + ReporterTypes.Datadog: lambda config: Datadog(config), + ReporterTypes.DogStatsD: lambda config: DogStatsD(config), + ReporterTypes.GCS: lambda config: GoogleCloudStorage(config), + ReporterTypes.Graphite: lambda config: Graphite(config), + ReporterTypes.Honeycomb: lambda config: Honeycomb(config), + ReporterTypes.InfluxDB: lambda config: InfluxDB(config), + ReporterTypes.JSON: lambda config: JSON(config), + ReporterTypes.Kafka: lambda config: Kafka(config), + ReporterTypes.MongoDB: lambda config: MongoDB(config), + ReporterTypes.MySQL: lambda config: MySQL(config), + ReporterTypes.Netdata: lambda config: Netdata(config), + ReporterTypes.NewRelic: lambda config: NewRelic(config), + ReporterTypes.Postgres: lambda config: Postgres(config), + ReporterTypes.Prometheus: lambda config: Prometheus(config), + ReporterTypes.Redis: lambda config: Redis(config), + ReporterTypes.S3: lambda config: S3(config), + ReporterTypes.Snowflake: lambda config: Snowflake(config), + ReporterTypes.SQLite: lambda config: SQLite(config), + ReporterTypes.StatsD: lambda config: StatsD(config), + ReporterTypes.Telegraf: lambda config: Telegraf(config), + ReporterTypes.TelegrafStatsD: lambda config: TelegrafStatsD(config), + ReporterTypes.TimescaleDB: lambda config: TimescaleDB(config), + ReporterTypes.XML: lambda config: XML(config) + } + + def __init__(self, reporter_config: Union[ReporterConfig, ReporterType]) -> None: + self.reporter_id = str(uuid.uuid4()) + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.graph_name: str=None + self.graph_id: str=None + self.stage_name: str=None + self.stage_id: str=None + self.metadata_string: str=None + self.thread_id = threading.current_thread().ident + self.process_id = os.getpid() + + if reporter_config is None: + reporter_config = JSONConfig() + + self.reporter_type = reporter_config.reporter_type + + + if isinstance(self.reporter_type, ReporterTypes): + self.reporter_type_name = self.reporter_type.name.capitalize() + + elif isinstance(self.reporter_type, str): + self.reporter_type_name = self.reporter_type.capitalize() + + self.reporter_config = reporter_config + + selected_reporter = self.reporters.get(self.reporter_type) + if selected_reporter is None: + self.selected_reporter = JSON(reporter_config) + + else: + self.selected_reporter = selected_reporter(reporter_config) + + async def connect(self): + self.metadata_string = f'Graph - {self.graph_name}:{self.graph_id} - thread:{self.thread_id} - process:{self.process_id} - Stage: {self.stage_name}:{self.stage_id} - Reporter: {self.reporter_type_name}:{self.reporter_id} - ' + self.selected_reporter.metadata_string = self.metadata_string + + await self.logger.filesystem.aio.create_logfile('hyperscale.reporting.log') + self.logger.filesystem.create_filelogger('hyperscale.reporting.log') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting') + await self.selected_reporter.connect() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected') + + async def submit_experiments(self, experiment_metrics_sets: List[ExperimentMetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {len(experiment_metrics_sets)} experiments') + + experiment_metrics: List[ExperimentMetricsCollection] = [ + experiment.split_experiments_metrics() for experiment in experiment_metrics_sets + ] + + experiments: List[Dict[str, str]] = [ + metrics_collection.experiment for metrics_collection in experiment_metrics + ] + + variants: List[Dict[str, str]] = [] + variants_summaries: List[VariantSummary] = [] + for metrics_collection in experiment_metrics: + variants.extend(metrics_collection.variants) + variants_summaries.extend(metrics_collection.variant_summaries) + + mutations: List[MutationSummary] = [] + mutations_summaries: List[MutationSummary] = [] + for metrics_collection in experiment_metrics: + mutations.extend(metrics_collection.mutations) + mutations_summaries.extend(metrics_collection.mutation_summaries) + + experiment_metrics_collection_set = ExperimentMetricsCollectionSet( + experiments_metrics_fields=ExperimentMetricsSet.experiments_fields(), + variants_metrics_fields=ExperimentMetricsSet.variants_fields(), + mutations_metrics_fields=ExperimentMetricsSet.mutations_fields(), + experiments=experiments, + variants=variants, + mutations=mutations, + experiment_summaries=[ + experiment.experiments_summary for experiment in experiment_metrics_sets + ], + variant_summaries=variants_summaries, + mutation_summaries=mutations_summaries + ) + + + await self.selected_reporter.submit_experiments(experiment_metrics_collection_set) + + await self.selected_reporter.submit_variants(experiment_metrics_collection_set) + + if len(mutations) > 0: + await self.selected_reporter.submit_mutations(experiment_metrics_collection_set) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {len(experiments)} experiments') + + async def submit_streams(self, stream_metrics: Dict[str, List[StreamAnalytics]]): + + streams_count = len(stream_metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {streams_count} streams') + await self.selected_reporter.submit_streams(stream_metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {streams_count} streams') + + async def submit_common(self, metrics: List[Any]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {len(metrics)} shared metrics') + await self.selected_reporter.submit_common(metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {len(metrics)} shared metrics') + + async def submit_events(self, events: List[Any]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {len(events)} events') + await self.selected_reporter.submit_events(events) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {len(events)} events') + + async def submit_metrics(self, metrics: List[Any]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {len(metrics)} metrics') + await self.selected_reporter.submit_metrics(metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {len(metrics)} metrics') + + async def submit_custom(self, metrics: List[Any]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {len(metrics)} custom metrics') + await self.selected_reporter.submit_custom(metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {len(metrics)} custom metrics') + + async def submit_errors(self, metrics: List[Any]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {len(metrics)} errors') + await self.selected_reporter.submit_errors(metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {len(metrics)} errors') + + async def submit_system_metrics(self, metrics: List[Any]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting {len(metrics)} system metrics sets') + await self.selected_reporter.submit_session_system_metrics(metrics) + await self.selected_reporter.submit_stage_system_metrics(metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted {len(metrics)} system metrics sets') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing') + await self.selected_reporter.close() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed') \ No newline at end of file diff --git a/hyperscale/reporting/stats/__init__.py b/hyperscale/reporting/stats/__init__.py new file mode 100644 index 0000000..0ddf6b7 --- /dev/null +++ b/hyperscale/reporting/stats/__init__.py @@ -0,0 +1,5 @@ +from .mean import Mean +from .median_absolute_deviation import MedianAbsoluteDeviation +from .standard_deviation import StandardDeviation +from .variance import Variance +from .median import Median \ No newline at end of file diff --git a/hyperscale/reporting/stats/mean.py b/hyperscale/reporting/stats/mean.py new file mode 100644 index 0000000..3042327 --- /dev/null +++ b/hyperscale/reporting/stats/mean.py @@ -0,0 +1,28 @@ +class Mean: + + __slots__ = ( + 'size', + 'previous_size', + 'mean', + 'delta', + 'delta_n' + ) + + def __init__(self): + self.size = 0 + self.previous_size = 0 + self.mean = 0.0 + self.delta = 0.0 + self.delta_n = 0.0 + + def update(self, new_value): + self.previous_size = self.size + self.size += 1 + self.delta = new_value - self.mean + self.delta_n = self.delta / self.size + self.mean += self.delta_n + + return self + + def get(self): + return self.mean diff --git a/hyperscale/reporting/stats/median.py b/hyperscale/reporting/stats/median.py new file mode 100644 index 0000000..06eedfe --- /dev/null +++ b/hyperscale/reporting/stats/median.py @@ -0,0 +1,33 @@ +import heapq + +class Median: + + __slots__ = ( + 'min_heap', + 'max_heap' + ) + + def __init__(self): + self.min_heap = list() + self.max_heap = list() + + def update(self, new_value): + if not self.min_heap: + heapq.heappush(self.min_heap, new_value) + elif new_value >= self.min_heap[0]: + heapq.heappush(self.min_heap, new_value) + else: + heapq.heappush(self.max_heap, -1 * new_value) + + if len(self.min_heap) > len(self.max_heap) + 1: + heapq.heappush(self.max_heap, -1 * heapq.heappop(self.min_heap)) + elif len(self.max_heap) > len(self.min_heap): + heapq.heappush(self.min_heap, -1 * heapq.heappop(self.max_heap)) + + def get(self): + try: + if len(self.min_heap) == len(self.max_heap): + return 0.5 * (-1 * self.max_heap[0] + self.min_heap[0]) + return self.min_heap[0] + except IndexError: + return 0 diff --git a/hyperscale/reporting/stats/median_absolute_deviation.py b/hyperscale/reporting/stats/median_absolute_deviation.py new file mode 100644 index 0000000..3a064d9 --- /dev/null +++ b/hyperscale/reporting/stats/median_absolute_deviation.py @@ -0,0 +1,40 @@ +import tdigest + + +class MedianAbsoluteDeviation: + + __slots__ = ( + 'approx_median', + 'update_interval', + 'records_processed', + 'values', + 'deviations', + 'constant' + ) + + def __init__(self, update_interval=5, sensitivity=0.01, compression_factor=25): + self.approx_median = None + self.update_interval = update_interval + self.records_processed = 0 + self.values = tdigest.TDigest() + self.deviations = tdigest.TDigest(delta=sensitivity,K=compression_factor) + self.constant = 1.4826 + + def update(self, new_value): + if self.approx_median is None: + self.approx_median = new_value + + self.values.update(new_value) + deviation = abs(self.approx_median - new_value) + self.deviations.update(deviation) + + self.records_processed += 1 + + if self.records_processed%self.update_interval == 0: + self.approx_median = self.values.percentile(50) + + def get(self): + if len(self.deviations) > 0: + return self.deviations.percentile(50) * self.constant + else: + return 0 diff --git a/hyperscale/reporting/stats/standard_deviation.py b/hyperscale/reporting/stats/standard_deviation.py new file mode 100644 index 0000000..82ee2bb --- /dev/null +++ b/hyperscale/reporting/stats/standard_deviation.py @@ -0,0 +1,20 @@ +import math +from .variance import ( + Variance +) + +class StandardDeviation: + + __slots__ = ( + 'variance' + ) + + def __init__(self): + self.variance = Variance() + + def update(self, new_value): + self.variance.update(new_value) + return self + + def get(self): + return math.sqrt(self.variance.get()) diff --git a/hyperscale/reporting/stats/variance.py b/hyperscale/reporting/stats/variance.py new file mode 100644 index 0000000..74f92ba --- /dev/null +++ b/hyperscale/reporting/stats/variance.py @@ -0,0 +1,26 @@ +from .mean import Mean + +class Variance: + + __slots__ = ( + 'mean', + 't1', + 't1_sum' + ) + + def __init__(self): + self.mean = Mean() + self.t1 = 0.0 + self.t1_sum = 0 + + def update(self, new_value): + self.mean.update(new_value) + self.t1 = self.mean.delta * self.mean.delta_n * self.mean.previous_size + self.t1_sum += self.t1 + + def get(self): + divisor = (self.mean.size - 1.0) + if divisor == 0: + divisor = 1 + + return self.t1_sum/divisor \ No newline at end of file diff --git a/hyperscale/reporting/system/__init__.py b/hyperscale/reporting/system/__init__.py new file mode 100644 index 0000000..dd5a08a --- /dev/null +++ b/hyperscale/reporting/system/__init__.py @@ -0,0 +1 @@ +from .system_metrics_set import SystemMetricsSet \ No newline at end of file diff --git a/hyperscale/reporting/system/system_metrics_group.py b/hyperscale/reporting/system/system_metrics_group.py new file mode 100644 index 0000000..267d01c --- /dev/null +++ b/hyperscale/reporting/system/system_metrics_group.py @@ -0,0 +1,90 @@ +import statistics +import numpy +from collections import defaultdict +from typing import Dict, Union +from .system_metrics_set_types import ( + CPUMonitorGroup, + MemoryMonitorGroup, + StageSystemMetricsGroup, + SystemMetricsCollection, + SystemMetricGroupType +) + + +class SystemMetricsGroup: + + def __init__( + self, + metrics: Union[CPUMonitorGroup, MemoryMonitorGroup], + metric_group: SystemMetricGroupType + ) -> None: + self.stage_metrics: StageSystemMetricsGroup = defaultdict(dict) + + self.raw_metrics: Union[CPUMonitorGroup, MemoryMonitorGroup] = metrics + self.metrics_group = metric_group + self.metrics: Dict[str, Dict[str, SystemMetricsCollection]] = defaultdict(dict) + self._quantiles = [ + 10, + 20, + 25, + 30, + 40, + 50, + 60, + 70, + 75, + 80, + 90, + 95, + 99 + ] + + self.visibility_filters: Dict[str, Dict[str, bool]] = defaultdict(dict) + + def __iter__(self): + for stage_metrics in self.metrics.values(): + for monitor_metrics in stage_metrics.values(): + yield monitor_metrics + + def aggregate(self): + + for stage_name, metrics in self.raw_metrics.items(): + for monitor_name, monitor_metrics in metrics.collected.items(): + + self.visibility_filters[stage_name][monitor_name] = metrics.visibility_filters[monitor_name] + + if self.metrics_group == SystemMetricGroupType.MEMORY: + metrics_data = [ + round( + metric_value/(1024**3), + 2 + ) for metric_value in monitor_metrics + ] + + else: + metrics_data = monitor_metrics + + if len(metrics_data) > 0: + + self.stage_metrics[stage_name][monitor_name] = metrics.stage_metrics[monitor_name] + + self.metrics[stage_name][monitor_name] = SystemMetricsCollection(**{ + 'stage': stage_name, + 'name': monitor_name, + 'group': self.metrics_group.value, + 'mean': statistics.mean(metrics_data), + 'median': statistics.median(metrics_data), + 'max': max(metrics_data), + 'min': min(metrics_data), + 'stdev': statistics.stdev(metrics_data), + 'variance': statistics.variance(metrics_data), + **{ + f'quantile_{quantile}th': numpy.quantile( + metrics_data, + round( + quantile/100, + 2 + ) + ) for quantile in self._quantiles + } + }) diff --git a/hyperscale/reporting/system/system_metrics_set.py b/hyperscale/reporting/system/system_metrics_set.py new file mode 100644 index 0000000..ecf5af8 --- /dev/null +++ b/hyperscale/reporting/system/system_metrics_set.py @@ -0,0 +1,236 @@ +import uuid +import numpy +import statistics +from collections import defaultdict +from typing import Dict, List, Union +from .system_metrics_group import SystemMetricsGroup +from .system_metrics_set_types import ( + MonitorGroup, + MemoryMonitorGroup, + CPUMonitorGroup, + SystemMetricsCollection, + SessionMetricsCollection, + SystemMetricGroupType +) + + +class SystemMetricsSet: + + metrics_table_keys = [ + 'name', + 'mean', + 'median', + 'max', + 'min', + 'stdev', + 'variance' + ] + + session_metrics_metadata = [ + 'name', + 'group', + ] + + stage_metrics_metadata = [ + 'stage', + 'name', + 'group', + ] + + primary_metrics = [ + 'mean', + 'median', + 'max', + 'min', + 'stdev', + 'variance', + ] + + quantiles = [ + 'quantile_10th', + 'quantile_20th', + 'quantile_25th', + 'quantile_30th', + 'quantile_40th', + 'quantile_50th', + 'quantile_60th', + 'quantile_70th', + 'quantile_75th', + 'quantile_80th', + 'quantile_90th', + 'quantile_95th', + 'quantile_99th' + ] + + metrics_header_keys = [ + 'stage', + 'name', + 'group', + 'mean', + 'median', + 'max', + 'min', + 'stdev', + 'variance', + 'quantile_10th', + 'quantile_20th', + 'quantile_25th', + 'quantile_30th', + 'quantile_40th', + 'quantile_50th', + 'quantile_60th', + 'quantile_70th', + 'quantile_75th', + 'quantile_80th', + 'quantile_90th', + 'quantile_95th', + 'quantile_99th' + ] + + def __init__( + self, + metrics: MonitorGroup, + batch_sizes: Dict[str, int] + ) -> None: + + self.system_metrics_set_id = uuid.uuid4() + self.system_cpu_metrics: Dict[str, List[Union[int, float]]] = defaultdict(list) + self.system_memory_metrics: Dict[str, List[Union[int, float]]] = defaultdict(list) + + self.session_cpu_metrics: Dict[str, SystemMetricsCollection] = {} + self.session_memory_metrics: Dict[str, SystemMetricsCollection] = {} + self.mb_per_vu: Dict[str, SystemMetricsCollection] = {} + self.batch_sizes = batch_sizes + + self._quantiles = [ + 10, + 20, + 25, + 30, + 40, + 50, + 60, + 70, + 75, + 80, + 90, + 95, + 99 + ] + + self.metrics = metrics + + self.cpu_metrics_by_stage: Dict[str, CPUMonitorGroup] = { + stage_name: stage_metrics.get( + 'cpu' + ) for stage_name, stage_metrics in metrics.items() + } + + self.memory_metrics_by_stage: Dict[str, MemoryMonitorGroup] = { + stage_name: stage_metrics.get( + 'memory' + ) for stage_name, stage_metrics in metrics.items() + } + + self.cpu = SystemMetricsGroup( + self.cpu_metrics_by_stage, + SystemMetricGroupType.CPU + ) + + self.memory = SystemMetricsGroup( + self.memory_metrics_by_stage, + SystemMetricGroupType.MEMORY + ) + + def generate_system_summaries(self): + self.cpu.aggregate() + self.memory.aggregate() + + for stage_metrics in self.metrics.values(): + cpu_metrics_group = stage_metrics.get('cpu') + + for monitor_name, monitor_metrics in cpu_metrics_group.collected.items(): + self.system_cpu_metrics[monitor_name].extend(monitor_metrics) + + for stage_name, stage_metrics in self.metrics.items(): + memory_metrics_group = stage_metrics.get('memory') + + for monitor_name, monitor_metrics in memory_metrics_group.collected.items(): + self.system_memory_metrics[monitor_name].extend(monitor_metrics) + + is_visible = memory_metrics_group.visibility_filters[monitor_name] + stage_batch_size = self.batch_sizes.get(stage_name) + + if memory_metrics_group.is_execute_stage and is_visible and stage_batch_size: + + + mb_per_vu = [ + round( + memory_used/(1024**2 * stage_batch_size), + 2 + ) for memory_used in monitor_metrics + ] + + self.mb_per_vu[stage_name] = SystemMetricsCollection(**{ + 'stage': stage_name, + 'name': monitor_name, + 'group': SystemMetricGroupType.MEMORY.value, + 'mean': statistics.mean(mb_per_vu), + 'median': statistics.median(mb_per_vu), + 'max': max(mb_per_vu), + 'min': min(mb_per_vu), + 'stdev': statistics.stdev(mb_per_vu), + 'variance': statistics.variance(mb_per_vu), + **{ + f'quantile_{quantile}th': numpy.quantile( + mb_per_vu, + round( + quantile/100, + 2 + ) + ) for quantile in self._quantiles + } + }) + + + for monitor_name, monitor_metrics in self.system_cpu_metrics.items(): + self.session_cpu_metrics[monitor_name] = SessionMetricsCollection(**{ + 'name': monitor_name, + 'group': SystemMetricGroupType.CPU.value, + 'mean': statistics.mean(monitor_metrics), + 'median': statistics.median(monitor_metrics), + 'max': max(monitor_metrics), + 'min': min(monitor_metrics), + 'stdev': statistics.stdev(monitor_metrics), + 'variance': statistics.variance(monitor_metrics), + **{ + f'quantile_{quantile}th': numpy.quantile( + monitor_metrics, + round( + quantile/100, + 2 + ) + ) for quantile in self._quantiles + } + }) + + for monitor_name, monitor_metrics in self.system_memory_metrics.items(): + self.session_memory_metrics[monitor_name] = SessionMetricsCollection(**{ + 'name': monitor_name, + 'group': SystemMetricGroupType.MEMORY.value, + 'mean': statistics.mean(monitor_metrics), + 'median': statistics.median(monitor_metrics), + 'max': max(monitor_metrics), + 'min': min(monitor_metrics), + 'stdev': statistics.stdev(monitor_metrics), + 'variance': statistics.variance(monitor_metrics), + **{ + f'quantile_{quantile}th': numpy.quantile( + monitor_metrics, + round( + quantile/100, + 2 + ) + ) for quantile in self._quantiles + } + }) diff --git a/hyperscale/reporting/system/system_metrics_set_types.py b/hyperscale/reporting/system/system_metrics_set_types.py new file mode 100644 index 0000000..6ff16ff --- /dev/null +++ b/hyperscale/reporting/system/system_metrics_set_types.py @@ -0,0 +1,212 @@ +from enum import Enum +from typing import Dict, List, Union + +from pydantic import BaseModel, StrictFloat, StrictInt, StrictStr + +from hyperscale.monitoring import CPUMonitor, MemoryMonitor +from hyperscale.reporting.metric.metric_types import MetricType +from hyperscale.reporting.tags import Tag + +MemoryMonitorGroup = Dict[str, MemoryMonitor] + +CPUMonitorGroup = Dict[str, CPUMonitor] + +MonitorGroup = Dict[str, Union[CPUMonitorGroup, MemoryMonitorGroup]] + +StageSystemMetricsGroup = Dict[str, Dict[str, List[Union[int, float]]]] + + +class SystemMetricGroupType(Enum): + CPU='cpu' + MEMORY='memory' + + +class SessionMetricsCollection(BaseModel): + name: StrictStr + group: StrictStr + mean: Union[StrictInt, StrictFloat] + median: Union[StrictInt, StrictFloat] + max: Union[StrictInt, StrictFloat] + min: Union[StrictInt, StrictFloat] + stdev: Union[StrictInt, StrictFloat] + variance: Union[StrictInt, StrictFloat] + quantile_10th: Union[StrictInt, StrictFloat] + quantile_20th: Union[StrictInt, StrictFloat] + quantile_25th: Union[StrictInt, StrictFloat] + quantile_30th: Union[StrictInt, StrictFloat] + quantile_40th: Union[StrictInt, StrictFloat] + quantile_50th: Union[StrictInt, StrictFloat] + quantile_60th: Union[StrictInt, StrictFloat] + quantile_70th: Union[StrictInt, StrictFloat] + quantile_75th: Union[StrictInt, StrictFloat] + quantile_80th: Union[StrictInt, StrictFloat] + quantile_90th: Union[StrictInt, StrictFloat] + quantile_95th: Union[StrictInt, StrictFloat] + quantile_99th: Union[StrictInt, StrictFloat] + + @property + def stats(self) -> Dict[str, Union[int, float]]: + return { + 'mean': self.mean, + 'median': self.median, + 'max': self.max, + 'min': self.min, + 'stdev': self.stdev, + 'variance': self.variance, + 'quantile_10th': self.quantile_10th, + 'quantile_20th': self.quantile_20th, + 'quantile_25th': self.quantile_25th, + 'quantile_30th': self.quantile_30th, + 'quantile_40th': self.quantile_40th, + 'quantile_50th': self.quantile_50th, + 'quantile_60th': self.quantile_60th, + 'quantile_70th': self.quantile_70th, + 'quantile_75th': self.quantile_75th, + 'quantile_80th': self.quantile_80th, + 'quantile_90th': self.quantile_90th, + 'quantile_95th': self.quantile_95th, + 'quantile_99th': self.quantile_99th + } + + @property + def record(self) -> Dict[str, Union[str, bool, int, float]]: + return { + 'name': self.name, + 'group': self.group, + **self.stats + } + + @property + def types_map(self): + return { + 'mean': MetricType.SAMPLE, + 'median': MetricType.SAMPLE, + 'max': MetricType.SAMPLE, + 'min': MetricType.SAMPLE, + 'stdev': MetricType.SAMPLE, + 'variance': MetricType.SAMPLE, + 'quantile_10th': MetricType.SAMPLE, + 'quantile_20th': MetricType.SAMPLE, + 'quantile_25th': MetricType.SAMPLE, + 'quantile_30th': MetricType.SAMPLE, + 'quantile_40th': MetricType.SAMPLE, + 'quantile_50th': MetricType.SAMPLE, + 'quantile_60th': MetricType.SAMPLE, + 'quantile_70th': MetricType.SAMPLE, + 'quantile_75th': MetricType.SAMPLE, + 'quantile_80th': MetricType.SAMPLE, + 'quantile_90th': MetricType.SAMPLE, + 'quantile_95th': MetricType.SAMPLE, + 'quantile_99th': MetricType.SAMPLE, + } + + @property + def tags(self): + tag_fields = { + 'name': self.name, + 'group': self.group + } + + return [ + Tag( + tag_field_name, + tag_field_value + ) for tag_field_name, tag_field_value in tag_fields.items() + ] + + +class SystemMetricsCollection(BaseModel): + stage: StrictStr + name: StrictStr + group: StrictStr + mean: Union[StrictInt, StrictFloat] + median: Union[StrictInt, StrictFloat] + max: Union[StrictInt, StrictFloat] + min: Union[StrictInt, StrictFloat] + stdev: Union[StrictInt, StrictFloat] + variance: Union[StrictInt, StrictFloat] + quantile_10th: Union[StrictInt, StrictFloat] + quantile_20th: Union[StrictInt, StrictFloat] + quantile_25th: Union[StrictInt, StrictFloat] + quantile_30th: Union[StrictInt, StrictFloat] + quantile_40th: Union[StrictInt, StrictFloat] + quantile_50th: Union[StrictInt, StrictFloat] + quantile_60th: Union[StrictInt, StrictFloat] + quantile_70th: Union[StrictInt, StrictFloat] + quantile_75th: Union[StrictInt, StrictFloat] + quantile_80th: Union[StrictInt, StrictFloat] + quantile_90th: Union[StrictInt, StrictFloat] + quantile_95th: Union[StrictInt, StrictFloat] + quantile_99th: Union[StrictInt, StrictFloat] + + @property + def stats(self) -> Dict[str, Union[int, float]]: + return { + 'mean': self.mean, + 'median': self.median, + 'max': self.max, + 'min': self.min, + 'stdev': self.stdev, + 'variance': self.variance, + 'quantile_10th': self.quantile_10th, + 'quantile_20th': self.quantile_20th, + 'quantile_25th': self.quantile_25th, + 'quantile_30th': self.quantile_30th, + 'quantile_40th': self.quantile_40th, + 'quantile_50th': self.quantile_50th, + 'quantile_60th': self.quantile_60th, + 'quantile_70th': self.quantile_70th, + 'quantile_75th': self.quantile_75th, + 'quantile_80th': self.quantile_80th, + 'quantile_90th': self.quantile_90th, + 'quantile_95th': self.quantile_95th, + 'quantile_99th': self.quantile_99th + } + + @property + def record(self) -> Dict[str, Union[str, bool, int, float]]: + return { + 'stage': self.stage, + 'name': self.name, + 'group': self.group, + **self.stats + } + + @property + def types_map(self): + return { + 'mean': MetricType.SAMPLE, + 'median': MetricType.SAMPLE, + 'max': MetricType.SAMPLE, + 'min': MetricType.SAMPLE, + 'stdev': MetricType.SAMPLE, + 'variance': MetricType.SAMPLE, + 'quantile_10th': MetricType.SAMPLE, + 'quantile_20th': MetricType.SAMPLE, + 'quantile_25th': MetricType.SAMPLE, + 'quantile_30th': MetricType.SAMPLE, + 'quantile_40th': MetricType.SAMPLE, + 'quantile_50th': MetricType.SAMPLE, + 'quantile_60th': MetricType.SAMPLE, + 'quantile_70th': MetricType.SAMPLE, + 'quantile_75th': MetricType.SAMPLE, + 'quantile_80th': MetricType.SAMPLE, + 'quantile_90th': MetricType.SAMPLE, + 'quantile_95th': MetricType.SAMPLE, + 'quantile_99th': MetricType.SAMPLE, + } + + @property + def tags(self): + tag_fields = { + 'stage': self.stage, + 'name': self.name, + 'group': self.group + } + + return [ + Tag( + tag_field_name, + tag_field_value + ) for tag_field_name, tag_field_value in tag_fields.items() + ] \ No newline at end of file diff --git a/hyperscale/reporting/tags/__init__.py b/hyperscale/reporting/tags/__init__.py new file mode 100644 index 0000000..8971f60 --- /dev/null +++ b/hyperscale/reporting/tags/__init__.py @@ -0,0 +1 @@ +from .tag import Tag \ No newline at end of file diff --git a/hyperscale/reporting/tags/tag.py b/hyperscale/reporting/tags/tag.py new file mode 100644 index 0000000..77073ea --- /dev/null +++ b/hyperscale/reporting/tags/tag.py @@ -0,0 +1,5 @@ +class Tag: + + def __init__(self, name: str, value: str) -> None: + self.name = name + self.value = value \ No newline at end of file diff --git a/hyperscale/reporting/types/__init__.py b/hyperscale/reporting/types/__init__.py new file mode 100644 index 0000000..59e9f74 --- /dev/null +++ b/hyperscale/reporting/types/__init__.py @@ -0,0 +1,156 @@ +from .common import ReporterTypes + +from .aws_lambda import ( + AWSLambda, + AWSLambdaConfig +) + +from .aws_timestream import ( + AWSTimestream, + AWSTimestreamConfig +) + +from .bigquery import ( + BigQuery, + BigQueryConfig +) + +from .bigtable import ( + BigTable, + BigTableConfig +) + +from .cassandra import ( + Cassandra, + CassandraConfig +) + +from .cloudwatch import ( + Cloudwatch, + CloudwatchConfig +) + +from .cosmosdb import ( + CosmosDB, + CosmosDBConfig +) + +from .csv import ( + CSV, + CSVConfig +) + +from .datadog import ( + Datadog, + DatadogConfig +) + +from .dogstatsd import ( + DogStatsD, + DogStatsDConfig +) + +from .google_cloud_storage import ( + GoogleCloudStorage, + GoogleCloudStorageConfig +) + +from .graphite import ( + Graphite, + GraphiteConfig +) + +from .honeycomb import ( + Honeycomb, + HoneycombConfig +) + +from .influxdb import ( + InfluxDB, + InfluxDBConfig +) + +from .json import ( + JSON, + JSONConfig +) + +from .kafka import ( + Kafka, + KafkaConfig +) + +from .mongodb import ( + MongoDB, + MongoDBConfig +) + +from .mysql import ( + MySQL, + MySQLConfig +) + +from .netdata import ( + Netdata, + NetdataConfig +) + +from .newrelic import ( + NewRelic, + NewRelicConfig +) + +from .postgres import ( + Postgres, + PostgresConfig +) + +from .prometheus import ( + Prometheus, + PrometheusConfig +) + +from .redis import ( + Redis, + RedisConfig +) + +from .s3 import ( + S3, + S3Config +) + +from .snowflake import ( + Snowflake, + SnowflakeConfig +) + +from .sqlite import ( + SQLite, + SQLiteConfig +) + +from .statsd import ( + StatsD, + StatsDConfig +) + +from .telegraf import ( + Telegraf, + TelegrafConfig +) + +from .telegraf_statsd import ( + TelegrafStatsD, + TelegrafStatsDConfig +) + +from .timescaledb import ( + TimescaleDB, + TimescaleDBConfig +) + +from .xml import ( + XML, + XMLConfig +) \ No newline at end of file diff --git a/hyperscale/reporting/types/aws_lambda/__init__.py b/hyperscale/reporting/types/aws_lambda/__init__.py new file mode 100644 index 0000000..917d32b --- /dev/null +++ b/hyperscale/reporting/types/aws_lambda/__init__.py @@ -0,0 +1,2 @@ +from .aws_lambda import AWSLambda +from .aws_lambda_config import AWSLambdaConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/aws_lambda/aws_lambda.py b/hyperscale/reporting/types/aws_lambda/aws_lambda.py new file mode 100644 index 0000000..fde698e --- /dev/null +++ b/hyperscale/reporting/types/aws_lambda/aws_lambda.py @@ -0,0 +1,334 @@ +import asyncio +import functools +import json +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List, Union + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet +from hyperscale.reporting.types import ReporterTypes + +from .aws_lambda_config import AWSLambdaConfig + +try: + import boto3 + has_connector=True +except Exception: + boto3 = None + has_connector=False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class AWSLambda: + + def __init__(self, config: AWSLambdaConfig) -> None: + self.aws_access_key_id = config.aws_access_key_id + self.aws_secret_access_key = config.aws_secret_access_key + self.region_name = config.region_name + + self.events_lambda_name = config.events_lambda + self.streams_lambda_name = config.streams_lambda + + self.metrics_lambda_name = config.metrics_lambda + self.shared_metrics_lambda_name = f'{config.metrics_lambda}_shared' + self.error_metrics_lambda_name = f'{config.metrics_lambda}_error' + self.system_metrics_lambda_name = config.system_metrics_lambda + + self.experiments_lambda_name = config.experiments_lambda + self.variants_lambda_name = f'{config.experiments_lambda}_variants' + self.mutations_lambda_name = f'{config.experiments_lambda}_mutations' + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._client = None + self._loop = asyncio.get_event_loop() + self.session_uuid = str(uuid.uuid4()) + + self.reporter_type = ReporterTypes.AWSLambda + self.reporter_type_name = self.reporter_type.name.capitalize() + self.metadata_string: str = None + + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to AWS - Region: {self.region_name}') + + self._client = await self._loop.run_in_executor( + self._executor, + functools.partial( + boto3.client, + 'lambda', + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.region_name + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Successfully opened connection to AWS - Region: {self.region_name}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Session System Metrics to file - {self.experiments_lambda_name}') + + metrics_sets: List[Dict[str, Union[int, float, str]]] = [] + + for metrics_set in system_metrics_sets: + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics.record) + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.experiments_lambda_name, + Payload=json.dumps(metrics_sets) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Session System Metrics to file - {self.experiments_lambda_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + pass + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Streams to file - {self.streams_lambda_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.streams_lambda_name, + Payload=json.dumps([ + { + 'stage': stream_name, + **stream_set.grouped + } for stream_name, stream_set in stream_metrics.items() + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Streams to file - {self.streams_lambda_name}') + + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Experiments to file - {self.experiments_lambda_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.experiments_lambda_name, + Payload=json.dumps([ + experiment.record for experiment in experiment_metrics.experiment_summaries + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Experiments to file - {self.experiments_lambda_name}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Variant to file - {self.variants_lambda_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.variants_lambda_name, + Payload=json.dumps([ + variant.record for variant in experiment_metrics.variant_summaries + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Variant to file - {self.variants_lambda_name}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Mutation to file - {self.mutations_lambda_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.mutations_lambda_name, + Payload=json.dumps([ + mutation.record for mutation in experiment_metrics.mutation_summaries + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Mutation to file - {self.mutations_lambda_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Lambda - {self.events_lambda_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.events_lambda_name, + Payload=json.dumps([ + event.record for event in events + ]) + ) + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Lambda - {self.events_lambda_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Lambda - {self.shared_metrics_lambda_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.shared_metrics_lambda_name, + Payload=json.dumps([ + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + } for metrics_set in metrics_sets + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Lambda - {self.shared_metrics_lambda_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Lambda - {self.metrics_lambda_name}') + + for metrics_set in metrics: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.metrics_lambda_name, + Payload=json.dumps([ + { + 'group': group_name, + **group.record, + **group.custom + } for group_name, group in metrics_set.groups.items() + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Lambda - {self.metrics_lambda_name}') + + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Lambda - {self.metrics_lambda_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.metrics_lambda_name, + Payload=json.dumps([ + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + metric.metric_shortname: metric.metric_value for metric in metrics_set.custom_metrics.values() + } + } for metrics_set in metrics_sets + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Lambda - {self.metrics_lambda_name}') + + async def submit_errors(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Errors Metrics to Lambda - {self.error_metrics_lambda_name}') + + for metrics_set in metrics: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Errors Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._client.invoke, + FunctionName=self.error_metrics_lambda_name, + Payload=json.dumps([ + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + **error + } for error in metrics_set.errors + ]) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Errors Metrics to Lambda - {self.error_metrics_lambda_name}') + + + async def close(self): + self._executor.shutdown(cancel_futures=True) + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') \ No newline at end of file diff --git a/hyperscale/reporting/types/aws_lambda/aws_lambda_config.py b/hyperscale/reporting/types/aws_lambda/aws_lambda_config.py new file mode 100644 index 0000000..a137975 --- /dev/null +++ b/hyperscale/reporting/types/aws_lambda/aws_lambda_config.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class AWSLambdaConfig(BaseModel): + aws_access_key_id: str + aws_secret_access_key: str + region_name: str + events_lambda: str='hyperscale_events' + metrics_lambda: str='hyperscale_metrics' + system_metrics_lambda: str='hyperscale_system_metrics' + experiments_lambda: Optional[str] + streams_lambda: Optional[str] + reporter_type: ReporterTypes=ReporterTypes.AWSLambda \ No newline at end of file diff --git a/hyperscale/reporting/types/aws_timestream/__init__.py b/hyperscale/reporting/types/aws_timestream/__init__.py new file mode 100644 index 0000000..c9c22bd --- /dev/null +++ b/hyperscale/reporting/types/aws_timestream/__init__.py @@ -0,0 +1,2 @@ +from .aws_timestream import AWSTimestream +from .aws_timestream_config import AWSTimestreamConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/aws_timestream/aws_timestream.py b/hyperscale/reporting/types/aws_timestream/aws_timestream.py new file mode 100644 index 0000000..282b62c --- /dev/null +++ b/hyperscale/reporting/types/aws_timestream/aws_timestream.py @@ -0,0 +1,696 @@ +import asyncio +import functools +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SystemMetricsCollection, + SystemMetricsSet, +) + +from .aws_timestream_config import AWSTimestreamConfig +from .aws_timestream_error_record import AWSTimestreamErrorRecord +from .aws_timestream_record import AWSTimestreamRecord + +try: + + import boto3 + has_connector = True +except Exception: + has_connector = False + + +class AWSTimestream: + + def __init__(self, config: AWSTimestreamConfig) -> None: + self.aws_access_key_id = config.aws_access_key_id + self.aws_secret_access_key = config.aws_secret_access_key + self.region_name = config.region_name + + self.database_name = config.database_name + self.events_table_name = config.events_table + self.streams_table_name = config.streams_table + + self.metrics_table_name = config.metrics_table + self.stage_metrics_table_name = f'{config.metrics_table}_stage' + self.errors_table_name = f'{config.metrics_table}_errors' + + self.experiments_table_name = config.experiments_table + self.variants_table_name = f'{config.experiments_table}_variants' + self.mutations_table_name = f'{config.experiments_table}_mutations' + self.session_system_metrics_table_name = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_name = f'{config.system_metrics_table}_stage' + + self.retention_options = config.retention_options + self.session_uuid = str(uuid.uuid4()) + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self.client = None + self._loop = asyncio.get_event_loop() + self.metadata_string: str = None + + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to AWS - Region: {self.region_name}') + + self.client = await self._loop.run_in_executor( + self._executor, + functools.partial( + boto3.client, + 'timestream-write', + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.region_name + ) + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_database, + DatabaseName=self.database_name + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - if not exists') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Successfully opened connection to AWS - Region: {self.region_name}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Session System Metrics to table - {self.session_system_metrics_table_name}') + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.session_system_metrics_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics - Database: {self.database_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + for metrics_set in metrics_sets: + + stream_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.stage}:{stream_id}') + + for field, value in metrics_set.stats.items(): + timestream_record = AWSTimestreamRecord( + record_type='session_system_metrics', + record_name=metrics_set.name, + record_stage=metrics_set.stage, + group_name=metrics_set.group, + field_name=field, + value=value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.events_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics - Database: {self.database_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Session System Metrics to table - {self.session_system_metrics_table_name}') + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.stage_system_metrics_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics - Database: {self.database_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + for metrics_set in metrics_sets: + + stream_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.stage}:{stream_id}') + + for field, value in metrics_set.stats.items(): + timestream_record = AWSTimestreamRecord( + record_type='stage_system_metrics', + record_name=metrics_set.name, + record_stage=metrics_set.stage, + group_name=metrics_set.group, + field_name=field, + value=value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.events_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics - Database: {self.database_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Streams to table - {self.streams_table_name}') + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.streams_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.streams_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.streams_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.streams_table_name} - if not exists') + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams - Database: {self.database_name} - Table: {self.streams_table_name} - if not exists') + + for stage_name, stream in stream_metrics.items(): + + stream_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream_id}') + + for group_name, group_stats in stream.grouped.items(): + for field, value in group_stats.items(): + timestream_record = AWSTimestreamRecord( + record_type='stream', + record_name=f'{stage_name}_stream', + record_stage=stage_name, + group_name=group_name, + field_name=field, + value=value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.events_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stream - Database: {self.database_name} - Table: {self.streams_table_name} - if not exists') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Variants to table - {self.variants_table_name}') + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.variants_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.variants_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.variants_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.variants_table_name} - if not exists') + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants - Database: {self.database_name} - Table: {self.variants_table_name} - if not exists') + + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variant - {variant.variant_name}:{variant_id}') + + for field, value in variant.record.items(): + timestream_record = AWSTimestreamRecord( + record_type='variant', + record_name=variant.variant_name, + group_name=variant.variant_experiment, + field_name=field, + value=value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.events_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants - Database: {self.database_name} - Table: {self.variants_table_name} - if not exists') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Mutations to table - {self.mutations_table_name}') + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.mutations_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.mutations_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.mutations_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.mutations_table_name} - if not exists') + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations - Database: {self.database_name} - Table: {self.mutations_table_name} - if not exists') + + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations - {mutation.mutation_name}:{mutation_id}') + + for field, value in mutation.record.items(): + timestream_record = AWSTimestreamRecord( + record_type='mutation', + record_name=mutation.mutation_name, + group_name=mutation.mutation_variant_name, + field_name=field, + value=value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.events_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations - Database: {self.database_name} - Table: {self.mutations_table_name} - if not exists') + + async def submit_events(self, events: List[BaseProcessedResult]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.events_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + for event in events: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Event - {event.name}:{event.event_id}') + + for field, value in event.record.items(): + timestream_record = AWSTimestreamRecord( + record_type='event', + record_name=event.name, + record_stage=event.stage, + field_name=field, + value=value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.events_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + async def submit_common(self, metrics: List[MetricsSet]): + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.stage_metrics_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + for metrics_set in metrics: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for field, value in metrics_set.common_stats.items(): + timestream_record = AWSTimestreamRecord( + record_type='stage_metrics', + record_name=metrics_set.name, + record_stage=metrics_set.stage, + group_name='common', + field_name=field, + value=value, + session_uuid=self.session_uuid, + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.stage_metrics_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.metrics_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + for metrics_set in metrics: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - {group_name}') + + metric_result = {**group.stats, **group.custom} + + for field, value in metric_result.items(): + timestream_record = AWSTimestreamRecord( + record_type='metric', + record_name=metrics_set.name, + record_stage=metrics_set.stage, + group_name=group_name, + field_name=field, + value=value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.metrics_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.metrics_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.metrics_table_name} - if not exists') + + records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custom_metric in metrics_set.custom_metrics.values(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - Custom') + + timestream_record = AWSTimestreamRecord( + record_type='metric', + record_name=metrics_set.name, + record_stage=metrics_set.stage, + group_name='custom', + field_name=custom_metric.metric_name, + value=custom_metric.metric_value, + session_uuid=self.session_uuid + ) + + records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=self.metrics_table_name, + Records=records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating table - Database: {self.database_name} - Table: {self.errors_table_name} - if not exists') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + DatabaseName=self.database_name, + TableName=self.errors_table_name, + RetentionProperties=self.retention_options + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created table - Database: {self.database_name} - Table: {self.errors_table_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of table - Database: {self.database_name} - Table: {self.errors_table_name} - if not exists') + + error_records = [] + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Errors Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Errors Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + timestream_record = AWSTimestreamErrorRecord( + record_name=metrics_set.name, + record_stage=metrics_set.stage, + error_message=error.get('message'), + count=error.get('count'), + session_uuid=self.session_uuid + ) + + error_records.append(timestream_record.to_dict()) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.write_records, + DatabaseName=self.database_name, + TableName=f'{self.metrics_table_name}_errors', + Records=error_records, + CommonAttributes={} + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Errors Metrics - Database: {self.database_name} - Table: {self.events_table_name} - if not exists') + + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') \ No newline at end of file diff --git a/hyperscale/reporting/types/aws_timestream/aws_timestream_config.py b/hyperscale/reporting/types/aws_timestream/aws_timestream_config.py new file mode 100644 index 0000000..0183e2a --- /dev/null +++ b/hyperscale/reporting/types/aws_timestream/aws_timestream_config.py @@ -0,0 +1,22 @@ +from typing import Dict + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class AWSTimestreamConfig(BaseModel): + aws_access_key_id: str + aws_secret_access_key: str + region_name: str + database_name: str + events_table: str='events' + metrics_table: str='metrics' + experiments_table: str='experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + retention_options: Dict[str, int] = { + "MemoryStoreRetentionPeriodInHours": 1, + "MagneticStoreRetentionPeriodInDays": 365, + } + reporter_type: ReporterTypes=ReporterTypes.AWSTimestream \ No newline at end of file diff --git a/hyperscale/reporting/types/aws_timestream/aws_timestream_error_record.py b/hyperscale/reporting/types/aws_timestream/aws_timestream_error_record.py new file mode 100644 index 0000000..6978d59 --- /dev/null +++ b/hyperscale/reporting/types/aws_timestream/aws_timestream_error_record.py @@ -0,0 +1,59 @@ + +import time +from numpy import float32, float64, int16, int32, int64 +from typing import Any +from datetime import datetime + + +class AWSTimestreamErrorRecord: + + def __init__( + self, + record_name: str=None, + record_stage: str=None, + error_message: str=None, + count: int=None, + session_uuid: str=None + ) -> None: + + + self.record_type = 'error_metric' + self.record_name = record_name + + self.time = str(int(round(time.time() * 1000))) + self.time_unit = 'MILLISECONDS' + self.dimensions = [ + { + "Name": "type", + "Value": 'error_metric' + }, + { + "Name": "name", + "Value": record_name + }, + { + "Name": "stage", + "Value": record_stage + }, + { + "Name": "error_message", + "Value": error_message + }, + { + "Name": "session_uuid", + "Value": session_uuid + } + ] + self.measure_name = f'{record_name}_errors_{session_uuid}' + self.measure_value = str(count) + self.measure_value_type = 'BIGINT' + + def to_dict(self): + return { + "Time": self.time, + "TimeUnit": self.time_unit, + "Dimensions": self.dimensions, + "MeasureName": self.measure_name, + "MeasureValue": self.measure_value, + "MeasureValueType": self.measure_value_type + } diff --git a/hyperscale/reporting/types/aws_timestream/aws_timestream_record.py b/hyperscale/reporting/types/aws_timestream/aws_timestream_record.py new file mode 100644 index 0000000..464d96d --- /dev/null +++ b/hyperscale/reporting/types/aws_timestream/aws_timestream_record.py @@ -0,0 +1,87 @@ + +import time +from numpy import float32, float64, int16, int32, int64 +from typing import Any +from datetime import datetime + + +class AWSTimestreamRecord: + + def __init__( + self, + record_type: str=None, + record_name: str=None, + record_stage: str=None, + group_name: str=None, + field_name: str=None, + value: Any=None, + session_uuid: str=None + ) -> None: + + measure_value_type = None + + if isinstance(value, (float, float32, float64)): + measure_value_type = "DOUBLE" + + elif isinstance(value, str): + measure_value_type = "VARCHAR" + + elif isinstance(value, bool): + measure_value_type = "BOOLEAN" + + elif isinstance(value, (int, int16, int32, int64)): + measure_value_type = "BIGINT" + + elif isinstance(value, datetime): + value = int(value.timestamp()) + measure_value_type = "TIMESTAMP" + + self.record_type = record_type + self.record_name = record_name + self.field = field_name + + self.time = str(int(round(time.time() * 1000))) + self.time_unit = 'MILLISECONDS' + self.dimensions = [ + { + "Name": "type", + "Value": record_type + }, + { + "Name": "source", + "Value": record_name + }, + { + "Name": "field_name", + "Value": field_name + }, + { + "Name": "session_uuid", + "Value": session_uuid + } + ] + self.measure_name = f'{record_name}_{field_name}_{session_uuid}' + self.measure_value = str(value) + self.measure_value_type = measure_value_type + + if record_stage: + self.dimensions.append({ + "Name": "stage", + "Value": record_stage + }) + + if group_name: + self.dimensions.append({ + "Name": "group", + "Value": group_name + }) + + def to_dict(self): + return { + "Time": self.time, + "TimeUnit": self.time_unit, + "Dimensions": self.dimensions, + "MeasureName": self.measure_name, + "MeasureValue": self.measure_value, + "MeasureValueType": self.measure_value_type + } diff --git a/hyperscale/reporting/types/bigquery/__init__.py b/hyperscale/reporting/types/bigquery/__init__.py new file mode 100644 index 0000000..f7a2f8b --- /dev/null +++ b/hyperscale/reporting/types/bigquery/__init__.py @@ -0,0 +1,2 @@ +from .bigquery import BigQuery +from .bigquery_config import BigQueryConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/bigquery/bigquery.py b/hyperscale/reporting/types/bigquery/bigquery.py new file mode 100644 index 0000000..cacd25d --- /dev/null +++ b/hyperscale/reporting/types/bigquery/bigquery.py @@ -0,0 +1,911 @@ +import asyncio +import functools +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet + +from .bigquery_config import BigQueryConfig + +try: + from google.cloud import bigquery + has_connector = True + +except Exception: + bigquery = None + has_connector = False + + +class BigQuery: + + def __init__(self, config: BigQueryConfig) -> None: + self.service_account_json_path = config.service_account_json_path + self.project_name = config.project_name + self.dataset_name = config.dataset_name + self.retry_timeout = config.retry_timeout + + self.events_table_name = config.events_table + + self.metrics_table_name = config.metrics_table + self.shared_metrics_table_name = f'{self.metrics_table_name}_shared' + self.custom_metrics_table_name = f'{self.metrics_table_name}_custom' + + self.stream_metrics_table_name = config.streams_table + + self.errors_table_name = f'{self.metrics_table_name}_errors' + self.experiments_table_name = config.experiments_table + self.variants_table_name = f'{self.experiments_table_name}_variants' + self.mutations_table_name = f'{self.experiments_table_name}_mutations' + + self.session_system_metrics_table_name = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_name = f'{config.system_metrics_table}_stage' + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self.credentials = None + self.client = None + + self._events_table = None + self._streams_table = None + self._errors_table = None + self._metrics_table = None + + self._experiments_table = None + self._variants_table = None + self._mutations_table = None + + self._session_system_metrics_table = None + self._stage_system_metrics_table = None + + self._custom_metrics_table = None + self._shared_metrics_table = None + + self._loop = asyncio.get_event_loop() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to Google Cloud - Loading account config from - {self.service_account_json_path}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + + self.client = bigquery.Client.from_service_account_json(self.service_account_json_path) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opened connection to Google Cloud - Loaded account config from - {self.service_account_json_path}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + rows = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + + if self._session_system_metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + table_schema = [ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('group', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('median', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('mean', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variance', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('stdev','FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('minimum', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('maximum', 'FLOAT64', mode='REQUIRED') + ] + + for quantile in metrics_set.quantiles: + table_schema.append( + bigquery.SchemaField(quantile, 'FLOAT64') + ) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.session_system_metrics_table_name + ) + + session_system_metrics_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + session_system_metrics_table, + exists_ok=True + ) + ) + + self._session_system_metrics_table = session_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics.record) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics.record) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + self._session_system_metrics_table, + rows, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.session_system_metrics_table_name} - if not exists') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + rows = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + if self._stage_system_metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + table_schema = [ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('stage', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('group', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('median', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('mean', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variance', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('stdev','FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('minimum', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('maximum', 'FLOAT64', mode='REQUIRED') + ] + + for quantile in metrics_set.quantiles: + table_schema.append( + bigquery.SchemaField(quantile, 'FLOAT64') + ) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.stage_system_metrics_table_name + ) + + stage_system_metrics_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + stage_system_metrics_table, + exists_ok=True + ) + ) + + self._stage_system_metrics_table = stage_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + for metrics_set in system_metrics_sets: + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics.record) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + self._stage_system_metrics_table, + rows, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stage_system_metrics_table_name} - if not exists') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stream_metrics_table_name} - if not exists') + + rows = [] + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + if self._streams_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stream_metrics_table_name} - if not exists') + + table_schema = [ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('stage', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('group', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('median', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('mean', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variance', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('stdev','FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('minimum', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('maximum', 'FLOAT64', mode='REQUIRED') + ] + + for quantile in stream.quantiles: + table_schema.append( + bigquery.SchemaField(f'{quantile}', 'FLOAT64') + ) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.stream_metrics_table_name + ) + + streams_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + streams_table, + exists_ok=True + ) + ) + + self._streams_table = streams_table + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stream_metrics_table_name} - if not exists') + + for group_name, group in stream.grouped: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams Group - {group_name}:{stream.stream_set_id}') + rows.append({ + 'name': f'{stage_name}_stream', + 'stage': stage_name, + 'group': group_name, + **group + }) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + self._streams_table, + rows, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.stream_metrics_table_name} - if not exists') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Experiments to table - {self.experiments_table_name}') + + if self._experiments_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.experiments_table_name} - if not exists') + + experiments_table_name = f'{self.project_name}.{self.dataset_name}.{self.experiments_table_name}' + + table_schema = bigquery.Table( + experiments_table_name, + schema=[ + bigquery.SchemaField('experiment_name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('experiment_randomized', 'BOOLEAN', mode='REQUIRED'), + bigquery.SchemaField('experiment_completed', 'INT64', mode='REQUIRED'), + bigquery.SchemaField('experiment_succeeded', 'INT64', mode='REQUIRED'), + bigquery.SchemaField('experiment_failed', 'INT64', mode='REQUIRED'), + bigquery.SchemaField('experiment_median_aps', 'FLOAT64', mode='REQUIRED'), + ]) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.experiments_table_name + ) + + self._experiments_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + self._experiments_table, + exists_ok=True + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.experiments_table_name} - if not exists') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.experiments_table_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + table_reference, + experiment_metrics.experiments, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.experiments_table_name}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Variants to table - {self.variants_table_name}') + + if self._variants_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.variants_table_name} - if not exists') + + variants_table_name = f'{self.project_name}.{self.dataset_name}.{self.variants_table_name}' + + table_schema = bigquery.Table( + variants_table_name, + schema=[ + bigquery.SchemaField('variant_name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('variant_experiment', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('variant_weight', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variant_distribution', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('variant_distribution_interval', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variant_ratio_completed', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variant_ratio_succeeded', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variant_ratio_failed', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variant_ratio_aps', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variant_completed', 'INT64', mode='REQUIRED'), + bigquery.SchemaField('variant_succeeded', 'INT64', mode='REQUIRED'), + bigquery.SchemaField('variant_failed', 'INT64', mode='REQUIRED'), + bigquery.SchemaField('variant_actions_per_second', 'FLOAT64', mode='REQUIRED'), + ]) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.variants_table_name + ) + + self._variants_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + self._variants_table, + exists_ok=True + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.variants_table_name} - if not exists') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.variants_table_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + table_reference, + experiment_metrics.variants, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.variants_table_name}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Mutations to table - {self.mutations_table_name}') + + if self._mutations_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.mutations_table_name} - if not exists') + + mutations_table_name = f'{self.project_name}.{self.dataset_name}.{self.mutations_table_name}' + + table_schema = bigquery.Table( + mutations_table_name, + schema=[ + bigquery.SchemaField('mutation_name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('mutation_experiment_name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('mutation_variant_name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('mutation_chance', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('mutation_targets', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('mutation_type', 'STRING', mode='REQUIRED'), + ]) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.mutations_table_name + ) + + self._mutations_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + self._mutations_table, + exists_ok=True + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.mutations_table_name} - if not exists') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.mutations_table_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + table_reference, + experiment_metrics.mutations, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.mutations_table_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + if self._events_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.events_table_name} - if not exists') + + events_table_name = f'{self.project_name}.{self.dataset_name}.{self.events_table_name}' + + table_schema = bigquery.Table( + events_table_name, + schema=[ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('stage', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('time', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('succeeded', 'BOOLEAN', mode='REQUIRED'), + bigquery.SchemaField('error', 'STRING') + ]) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.events_table_name + ) + + self._events_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + self._events_table, + exists_ok=True + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.events_table_name} - if not exists') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.events_table_name}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + table_reference, + [event.record for event in events], + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.events_table_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + if self.shared_metrics_table_name is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.shared_metrics_table_name} - if not exists') + + table_schema = [ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('stage', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('group', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('total', 'INTEGER', mode='REQUIRED'), + bigquery.SchemaField('succeeded', 'INTEGER', mode='REQUIRED'), + bigquery.SchemaField('failed', 'INTEGER', mode='REQUIRED'), + bigquery.SchemaField('actions_per_second', 'FLOAT64', mode='REQUIRED') + ] + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.shared_metrics_table_name + ) + + shared_metrics_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + shared_metrics_table, + exists_ok=True + ) + ) + + self._shared_metrics_table = shared_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.shared_metrics_table_name} - if not exists') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.shared_metrics_table_name} - if not exists') + + rows = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + rows.append({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + self._shared_metrics_table, + rows, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.shared_metrics_table_name} - if not exists') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.metrics_table_name} - if not exists') + + rows = [] + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.metrics_table_name} - if not exists') + + table_schema = [ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('stage', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('group', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('median', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('mean', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('variance', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('stdev','FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('minimum', 'FLOAT64', mode='REQUIRED'), + bigquery.SchemaField('maximum', 'FLOAT64', mode='REQUIRED') + ] + + for quantile in metrics_set.quantiles: + table_schema.append( + bigquery.SchemaField(f'{quantile}', 'FLOAT64') + ) + + for custom_field_name, bigquery_schema_field in metrics_set.custom_schemas.items(): + table_schema.append( + custom_field_name, + bigquery_schema_field + ) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.metrics_table_name + ) + + metrics_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + metrics_table, + exists_ok=True + ) + ) + + self._metrics_table = metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.metrics_table_name} - if not exists') + + for group_name, group in metrics_set.groups.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {group_name}:{group.metrics_group_id}') + rows.append({ + 'group': group_name, + **group.record + }) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + self._metrics_table, + rows, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.metrics_table_name} - if not exists') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics - Project: {self.project_name} - Dataset: {self.dataset_name}') + + if self._custom_metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.custom_metrics_table_name} - if not exists') + + table_schema = [ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('stage', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('group', 'STRING', mode='REQUIRED') + ] + + for metrics_set in metrics_sets: + for custom_metric in metrics_set.custom_metrics.values(): + + if custom_metric.metric_type == MetricType.COUNT: + table_schema.append( + bigquery.SchemaField( + custom_metric.metric_name, + 'INTEGER', + mode='REQUIRED' + ) + ) + + else: + table_schema.append( + bigquery.SchemaField( + custom_metric.metric_name, + 'FLOAT64', + mode='REQUIRED' + ) + ) + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self._custom_metrics_table_name + ) + + custom_metrics_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + custom_metrics_table, + exists_ok=True + ) + ) + + self._custom_metrics_table = custom_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.custom_metrics_table_name} - if not exists') + + rows = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + rows.append({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric.metric_name: custom_metric.metric_value for custom_metric in metrics_set.custom_metrics.values() + } + }) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + self._custom_metrics_table, + rows, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics - Project: {self.project_name} - Dataset: {self.dataset_name}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Errors Metrics Set - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.errors_table_name} - if not exists') + + rows = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Errors Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._errors_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.errors_table_name} - if not exists') + + table_schema = [ + bigquery.SchemaField('name', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('stage', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('error_message', 'STRING', mode='REQUIRED'), + bigquery.SchemaField('error_count', 'INTEGER', mode='REQUIRED'), + ] + + table_reference = bigquery.TableReference( + bigquery.DatasetReference( + self.project_name, + self.dataset_name + ), + self.errors_table_name + ) + + errors_table = bigquery.Table( + table_reference, + schema=table_schema + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_table, + errors_table, + exists_ok=True + ) + ) + + self._errors_table = errors_table + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created table - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.errors_table_name} - if not exists') + + rows.extend([ + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + } for error in metrics_set.errors + ]) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.insert_rows_json, + self._errors_table, + rows, + retry=bigquery.DEFAULT_RETRY.with_predicate( + lambda exc: exc is not None + ).with_deadline( + self.retry_timeout + ) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Errors Metrics - Project: {self.project_name} - Dataset: {self.dataset_name} - Table: {self.errors_table_name} - if not exists') + + async def close(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + + await self._loop.run_in_executor( + self._executor, + self.client.close + ) \ No newline at end of file diff --git a/hyperscale/reporting/types/bigquery/bigquery_config.py b/hyperscale/reporting/types/bigquery/bigquery_config.py new file mode 100644 index 0000000..48d6945 --- /dev/null +++ b/hyperscale/reporting/types/bigquery/bigquery_config.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class BigQueryConfig(BaseModel): + service_account_json_path: str + project_name: str + dataset_name: str + events_table: str = 'events' + metrics_table: str = 'metrics' + experiments_table: str= 'experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + retry_timeout: int = 10 + reporter_type: ReporterTypes=ReporterTypes.BigQuery + + class Config: + arbitrary_types_allowed = True diff --git a/hyperscale/reporting/types/bigtable/__init__.py b/hyperscale/reporting/types/bigtable/__init__.py new file mode 100644 index 0000000..fd9d6ea --- /dev/null +++ b/hyperscale/reporting/types/bigtable/__init__.py @@ -0,0 +1,2 @@ +from .bigtable import BigTable +from .bigtable_config import BigTableConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/bigtable/bigtable.py b/hyperscale/reporting/types/bigtable/bigtable.py new file mode 100644 index 0000000..7251e44 --- /dev/null +++ b/hyperscale/reporting/types/bigtable/bigtable.py @@ -0,0 +1,907 @@ +import asyncio +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .bigtable_config import BigTableConfig + +try: + from google.auth import load_credentials_from_file + from google.cloud import bigtable + has_connector = True + +except Exception: + bigtable = None + Credentials = None + has_connector = False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + + +class BigTable: + + def __init__(self, config: BigTableConfig) -> None: + + self.service_account_json_path = config.service_account_json_path + + self.instance_id = config.instance_id + self.events_table_id = config.events_table + self.metrics_table_id = config.metrics_table + self.streams_table_id = config.streams_table + + self.experiments_table_id = config.experiments_table + self.variants_table_id = f'{config.experiments_table}_variants' + self.mutations_table_id = f'{config.experiments_table}_mutations' + + self.shared_metrics_table_id = f'{self.metrics_table_id}_metrics' + self.custom_metrics_table_ids = {} + self.errors_table_id = f'{self.metrics_table_id}_errors' + + self.session_system_metrics_table_id = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_id = f'{config.system_metrics_table}_stage' + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + self._events_column_family_id = f'{self.events_table_id}_columns' + self._metrics_column_family_id = f'{self.metrics_table_id}_columns' + self._streams_column_family_id = f'{self.streams_table_id}_columns' + + self._experiments_column_family_id = f'{self.experiments_table_id}_columns' + self._variants_column_family_id = f'{self.experiments_table_id}_variants_columns' + self._mutations_column_family_id = f'{self.experiments_table_id}_mutations_columns' + + + self.session_system_metrics_column_family_id= f'{config.system_metrics_table}_session_columns' + self.stage_system_metrics_column_family_id = f'{config.system_metrics_table}_stage_columns' + + self._shared_metrics_column_family_id = f'{self.metrics_table_id}_shared_columns' + self._custom_metrics_column_family_id = f'{self.metrics_table_id}_custom_columns' + self._errors_column_family_id = f'{self.metrics_table_id}_errors_columns' + + self.instance = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._events_table = None + self._stage_metrics_table = None + self._streams_table = None + + self._experiments_table = None + self._variants_table = None + self._mutations_table = None + + self._custom_metrics_tables = {} + self._metrics_table = None + self._errors_table = None + + self._session_system_metrics_table = None + self._stage_system_metrics_table = None + + self._experiments_table_columns = None + self._variants_table_columns = None + self._mutations_table_columns = None + + self._events_table_columns = None + self._stage_metrics_table_columns = None + self._streams_table_columns = None + + self._session_system_metrics_columns = None + self._stage_system_metrics_columns = None + + self._custom_metrics_table_columns = {} + self._metrics_table_columns = None + self._errors_table_columns = None + + self.credentials = None + self.client = None + self._loop = asyncio.get_event_loop() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to Google Cloud - Loading account config from - {self.service_account_json_path}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + + credentials, project_id = load_credentials_from_file(self.service_account_json_path) + self.client = bigtable.Client( + project=project_id, + credentials=credentials, + admin=True + ) + self.instance = self.client.instance(self.instance_id) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opened connection to Google Cloud - Created Client Instance - ID:{self.instance_id}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opened connection to Google Cloud - Loaded account config from - {self.service_account_json_path}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Table {self.session_system_metrics_table_id}') + self._session_system_metrics_table = self.instance.table(self.session_system_metrics_table_id) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metrics Table - {self.session_system_metrics_table_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._session_system_metrics_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Session System Metrics Table - {self.session_system_metrics_table_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Session System Metrics Column Family - {self.session_system_metrics_table_id} - if not exists') + + self._session_system_metrics_columns = self._session_system_metrics_table.column_family( + self.session_system_metrics_column_family_id + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metrics Column for Column Family - {self.session_system_metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._session_system_metrics_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Session System Metrics Column for Column Family - {self.session_system_metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Session System Metrics Column for Column Family - {self.session_system_metrics_column_family_id} - if not exists') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + + rows = [] + for metrics_set in metrics_sets: + + row_key = f'{metrics_set.name}_{str(uuid.uuid4())}' + row = self._variants_table.direct_row(row_key) + + for field, value in metrics_set.record: + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._variants_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + self._variants_table.mutate_rows, + rows + ) + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Table {self.stage_system_metrics_table_id}') + self._stage_system_metrics_table = self.instance.table(self.stage_system_metrics_table_id) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metrics Table - {self.stage_system_metrics_table_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._stage_system_metrics_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Stage System Metrics Table - {self.stage_system_metrics_table_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Stage System Metrics Column Family - {self.stage_system_metrics_table_id} - if not exists') + + self._stage_system_metrics_columns = self._stage_system_metrics_table.column_family( + self.stage_system_metrics_column_family_id + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metrics Column for Column Family - {self.stage_system_metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._stage_system_metrics_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Stage System Metrics Column for Column Family - {self.stage_system_metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Stage System Metrics Column for Column Family - {self.stage_system_metrics_column_family_id} - if not exists') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + rows = [] + for metrics_set in metrics_sets: + + row_key = f'{metrics_set.name}_{str(uuid.uuid4())}' + row = self._variants_table.direct_row(row_key) + + for field, value in metrics_set.record: + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._variants_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + self._variants_table.mutate_rows, + rows + ) + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Table {self.streams_table_id}') + self._streams_table = self.instance.table(self.streams_table_id) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Streams Column Family - {self._streams_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._streams_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Streams Column Family - {self._streams_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Streams Column Family - {self._streams_column_family_id} - if not exists') + + self._streams_table_columns = self._streams_table.column_family( + self._streams_column_family_id + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Streams Column for Column Family - {self._streams_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._streams_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Streams Column for Column Family - {self._streams_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Streams Column for Column Family - {self._streams_column_family_id} - if not exists') + + rows = [] + for stage_name, stream in stream_metrics.items(): + for group_name, group in stream.grouped.items(): + stream_record = { + 'name': f'{stage_name}_stream', + 'stage': stage_name, + 'group': group_name, + **group + } + + row_key = f'{stage_name}_stream_{str(uuid.uuid4())}' + row = self._variants_table.direct_row(row_key) + + for field, value in stream_record.items(): + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._variants_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + self._variants_table.mutate_rows, + rows + ) + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Table {self.experiments_table_id}') + self._experiments_table = self.instance.table(self.experiments_table_id) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments Column Family - {self._experiments_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._experiments_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Experiments Column Family - {self._experiments_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Experiments Column Family - {self._experiments_column_family_id} - if not exists') + + self._experiments_table_columns = self._experiments_table.column_family( + self._experiments_column_family_id + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments Column for Column Family - {self._experiments_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._experiments_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Experiments Column for Column Family - {self._experiments_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Experiments Column for Column Family - {self._experiments_column_family_id} - if not exists') + + rows = [] + for experiment in experiment_metrics.experiment_summaries: + + row_key = f'{experiment.experiment_name}_{str(uuid.uuid4())}' + row = self._experiments_table.direct_row(row_key) + + for field, value in experiment.record.items(): + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._experiments_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + self._experiments_table.mutate_rows, + rows + ) + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Table {self.variants_table_id}') + self._variants_table = self.instance.table(self.variants_table_id) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variants Column Family - {self._variants_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._variants_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Variants Column Family - {self._variants_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Variants Column Family - {self._variants_column_family_id} - if not exists') + + self._variants_table_columns = self._variants_table.column_family( + self._variants_column_family_id + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variants Column for Column Family - {self._variants_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._variants_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Variants Column for Column Family - {self._variants_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Variants Column for Column Family - {self._variants_column_family_id} - if not exists') + + rows = [] + for variant in experiment_metrics.variant_summaries: + + row_key = f'{variant.variant_name}_{str(uuid.uuid4())}' + row = self._variants_table.direct_row(row_key) + + for field, value in variant.record: + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._variants_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + self._variants_table.mutate_rows, + rows + ) + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Table {self.mutations_table_id}') + self._mutations_table = self.instance.table(self.mutations_table_id) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutations Column Family - {self._mutations_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._mutations_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Mutations Column Family - {self._mutations_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Mutations Column Family - {self._mutations_column_family_id} - if not exists') + + self._mutations_table_columns = self._mutations_table.column_family( + self._mutations_column_family_id + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutations Column for Column Family - {self._mutations_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._mutations_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Mutations Column for Column Family - {self._mutations_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Mutations Column for Column Family - {self._mutations_column_family_id} - if not exists') + + rows = [] + for mutation in experiment_metrics.mutation_summaries: + + row_key = f'{mutation.mutation_name}_{str(uuid.uuid4())}' + row = self._mutations_table.direct_row(row_key) + + for field, value in mutation.record: + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._mutations_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + self._mutations_table.mutate_rows, + rows + ) + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Table {self.events_table_id}') + self._events_table = self.instance.table(self.events_table_id) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Events Column Family - {self._events_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._events_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Events Column Family - {self._events_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Events Column Family - {self._events_column_family_id} - if not exists') + + self._events_table_columns = self._events_table.column_family( + self._events_column_family_id + ) + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Events Column for Column Family - {self._events_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._events_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Events Column for Column Family - {self._events_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Events Column for Column Family - {self._events_column_family_id} - if not exists') + + rows = [] + for event in events: + row_key = f'{event.name}_{str(uuid.uuid4())}' + row = self._events_table.direct_row(row_key) + + for field, value in event.record.items(): + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._events_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + self._events_table.mutate_rows, + rows + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Table {self.events_table_id}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Table {self.shared_metrics_table_id}') + + stage_metrics_table = self.instance.table(self.shared_metrics_table_id) + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metrics Column Family - {self._shared_metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + stage_metrics_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Shared Metrics Column Family - {self._shared_metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Shared Metrics Column Family - {self._shared_metrics_column_family_id} - if not exists') + + self._metrics_table_columns = stage_metrics_table.column_family( + self._shared_metrics_column_family_id + ) + + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metrics Column for Column Family - {self._shared_metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._metrics_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Shared Metrics Column for Column Family - {self._shared_metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Shared Metrics Column for Column Family - {self._shared_metrics_column_family_id} - if not exists') + + + rows = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + row_key = f'{self.shared_metrics_table_id}_{str(uuid.uuid4())}' + row = stage_metrics_table.direct_row(row_key) + + stage_metrics_record = { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + } + + for field, value in stage_metrics_record.items(): + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._metrics_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + stage_metrics_table.mutate_rows, + rows + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Table {self.shared_metrics_table_id}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Table {self.metrics_table_id}') + + metrics_table = self.instance.table(self.metrics_table_id) + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metrics Column Family - {self._metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + metrics_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Metrics Column Family - {self._metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Metrics Column Family - {self._metrics_column_family_id} - if not exists') + + self._metrics_table_columns = metrics_table.column_family( + self._metrics_column_family_id + ) + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metrics Column for Column Family - {self._metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._metrics_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Metrics Column for Column Family - {self._metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Metrics Column for Column Family - {self._metrics_column_family_id} - if not exists') + + rows = [] + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - {group_name}:{group.metrics_group_id}') + + row_key = f'{self.metrics_table_id}_{str(uuid.uuid4())}' + row = metrics_table.direct_row(row_key) + + record = { + 'group': group_name, + **group.record + } + + for field, value in record.items(): + if not isinstance(value, bytes): + value = f'{value}'.encode() + + row.set_cell( + self._metrics_column_family_id, + field, + value, + timestamp=datetime.now() + ) + + rows.append(row) + + await self._loop.run_in_executor( + self._executor, + metrics_table.mutate_rows, + rows + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Table {self.metrics_table_id}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Table {self.metrics_table_id}') + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Group - Custom') + + custom_metrics_table = self.instance.table(self.custom_metrics_table_id) + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Custom Metrics Column Family - {self._custom_metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + custom_metrics_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Custom Metrics Column Family - {self._custom_metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Custom Metrics Column Family - {self._custom_metrics_column_family_id} - if not exists') + + custom_metrics_table_columns = custom_metrics_table.column_family( + self.custom_metrics_table_id + ) + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Custom Metrics Column for Column Family - {self._custom_metrics_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + custom_metrics_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Custom Metrics Column for Column Family - {self._custom_metrics_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Custom Metrics Column for Column Family - {self._custom_metrics_column_family_id} - if not exists') + + rows = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + row_key = f'custom_{str(uuid.uuid4())}' + custom_metrics_table_row = custom_metrics_table.direct_row(row_key) + + custom_metrics_row_data = { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + metric.metric_shortname: metric.metric_value for metric in metrics_set.custom_metrics.values() + } + } + + for field, value in custom_metrics_row_data.items(): + custom_metrics_table_row.set_cell( + field, + f'{value}'.encode() + ) + + rows.append(custom_metrics_table_row) + + await self._loop.run_in_executor( + self._executor, + custom_metrics_table.mutate_rows, + rows + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Table {self.metrics_table_id}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Table {self.errors_table_id}') + + errors_table = self.instance.table(self.errors_table_id) + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Error Metrics Column Family - {self._errors_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + errors_table.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Error Metrics Column Family - {self._errors_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Error Metrics Column Family - {self._errors_column_family_id} - if not exists') + + self._errors_table = errors_table + + self._errors_table_columns = errors_table.column_family( + self._errors_column_family_id + ) + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Error Column for Column Family - {self._errors_column_family_id} - if not exists') + await self._loop.run_in_executor( + self._executor, + self._metrics_table_columns.create + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created Error Column for Column Family - {self._errors_column_family_id} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping creation of Error Column for Column Family - {self._errors_column_family_id} - if not exists') + + rows = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + + row_key = f'{self.errors_table_id}_{str(uuid.uuid4())}' + errors_row = errors_table.direct_row(row_key) + + error_message = error.get('message') + error_count = error.get('count') + + errors_row.set_cell( + self._metrics_column_family_id, + 'error_type', + f'{error_message}'.encode() + ) + + errors_row.set_cell( + self._metrics_column_family_id, + 'error_count', + f'{error_count}'.encode() + ) + + rows.append(errors_row) + + await self._loop.run_in_executor( + self._executor, + errors_table.mutate_rows, + rows + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Table {self.errors_table_id}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self._loop.run_in_executor( + self._executor, + self.client.close + ) + + self._executor.shutdown(cancel_futures=True) + + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + \ No newline at end of file diff --git a/hyperscale/reporting/types/bigtable/bigtable_config.py b/hyperscale/reporting/types/bigtable/bigtable_config.py new file mode 100644 index 0000000..d379268 --- /dev/null +++ b/hyperscale/reporting/types/bigtable/bigtable_config.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class BigTableConfig(BaseModel): + service_account_json_path: str + instance_id: str + events_table: str = 'events' + metrics_table: str = 'metrics' + experiments_table: str= 'experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.BigTable + diff --git a/hyperscale/reporting/types/cassandra/__init__.py b/hyperscale/reporting/types/cassandra/__init__.py new file mode 100644 index 0000000..2355aa1 --- /dev/null +++ b/hyperscale/reporting/types/cassandra/__init__.py @@ -0,0 +1,2 @@ +from .cassandra import Cassandra +from .cassandra_config import CassandraConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/cassandra/cassandra.py b/hyperscale/reporting/types/cassandra/cassandra.py new file mode 100644 index 0000000..501825f --- /dev/null +++ b/hyperscale/reporting/types/cassandra/cassandra.py @@ -0,0 +1,831 @@ +import asyncio +import functools +import os +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .cassandra_config import CassandraConfig + + +def noop_sync_table(): + pass + + +try: + from cassandra.auth import PlainTextAuthProvider + from cassandra.cluster import Cluster + from cassandra.cqlengine import columns, connection + from cassandra.cqlengine.management import sync_table + from cassandra.cqlengine.models import Model + has_connector = True + +except Exception: + columns = object + connection = object + sync_table = noop_sync_table + Model = None + Cluster = None + PlainTextAuthProvider = None + has_connector = False + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class Cassandra: + + def __init__(self, config: CassandraConfig) -> None: + self.cluster = None + self.session = None + + self.hosts = config.hosts + self.port = config.port or 9042 + + self.username = config.username + self.password = config.password + self.keyspace = config.keyspace + + self.events_table_name: str = config.events_table + self.metrics_table_name: str = config.metrics_table + self.streams_table_name: str = config.streams_table + + self.experiments_table_name: str= config.experiments_table + self.variants_table_name: str= f'{config.experiments_table}_variants' + self.mutations_table_name: str= f'{config.experiments_table}_mutations' + + self.shared_metrics_table_name = f'{config.metrics_table}_shared' + self.custom_metrics_table_name = f'{config.metrics_table}_custom' + self.errors_table_name = f'{config.metrics_table}_errors' + + self.session_system_metrics_table_name = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_name = f'{config.system_metrics_table}_stage' + + self.replication_strategy = config.replication_strategy + self.replication = config.replication + self.ssl = config.ssl + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._metrics_table = None + self._errors_table = None + self._events_table = None + self._streams_table = None + + self._experiments_table = None + self._variants_table = None + self._mutations_table = None + + self._session_system_metrics_table = None + self._stage_system_metrics_table = None + + self._shared_metrics_table = None + self._custom_metrics_table = None + + self._executor =ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop = asyncio.get_event_loop() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + host_port_combinations = ', '.join([ + f'{host}:{self.port}' for host in self.hosts + ]) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to Cassandra Cluster at - {host_port_combinations}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Opening session - {self.session_uuid}') + + auth = None + if self.username and self.password: + auth = PlainTextAuthProvider(self.username, self.password) + + + self.cluster = Cluster( + self.hosts, + port=self.port, + auth_provider=auth, + ssl_context=self.ssl + ) + + self.session = await self._loop.run_in_executor( + None, + self.cluster.connect + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Cassandra Cluster at - {host_port_combinations}') + + if self.keyspace is None: + self.keyspace = 'hyperscale' + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Keyspace - {self.keyspace}') + + keyspace_options = f"'class' : '{self.replication_strategy}', 'replication_factor' : {self.replication}" + keyspace_query = f"CREATE KEYSPACE IF NOT EXISTS {self.keyspace} WITH REPLICATION = " + "{" + keyspace_options + "};" + + await self._loop.run_in_executor( + None, + self.session.execute, + keyspace_query + ) + + await self._loop.run_in_executor( + None, + self.session.set_keyspace, + self.keyspace + ) + if os.getenv('CQLENG_ALLOW_SCHEMA_MANAGEMENT') is None: + os.environ['CQLENG_ALLOW_SCHEMA_MANAGEMENT'] = '1' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + connection.setup, + self.hosts, + self.keyspace, + protocol_version=3 + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Keyspace - {self.keyspace}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to - Keyspace: {self.keyspace} - Table: {self.session_system_metrics_table_name}') + + if self._session_system_metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Session System Metrics table - {self.session_system_metrics_table_name} - under keyspace - {self.keyspace}') + + fields = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'group': columns.Text(), + 'median': columns.Float(), + 'mean': columns.Float(), + 'variance': columns.Float(), + 'stdev': columns.Float(), + 'minimum': columns.Float(), + 'maximum': columns.Float(), + 'created_at': columns.DateTime(default=datetime.now) + } + + for quantile_name in SystemMetricsSet.quantiles: + fields[quantile_name] = columns.Float() + + self._session_system_metrics_table = type( + self.session_system_metrics_table_name.capitalize(), + (Model, ), + fields + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._session_system_metrics_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Session System Metrics table - {self.session_system_metrics_table_name} - under keyspace - {self.keyspace}') + + rows: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._session_system_metrics_table.create, + **metrics_set.record + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to - Keyspace: {self.keyspace} - Table: {self.session_system_metrics_table_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to - Keyspace: {self.keyspace} - Table: {self.stage_system_metrics_table_name}') + + if self._stage_system_metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Stage System Metrics table - {self.stage_system_metrics_table_name} - under keyspace - {self.keyspace}') + + fields = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'stage': columns.Text(), + 'group': columns.Text(), + 'median': columns.Float(), + 'mean': columns.Float(), + 'variance': columns.Float(), + 'stdev': columns.Float(), + 'minimum': columns.Float(), + 'maximum': columns.Float(), + 'created_at': columns.DateTime(default=datetime.now) + } + + for quantile_name in SystemMetricsSet.quantiles: + fields[quantile_name] = columns.Float() + + self._stage_system_metrics_table = type( + self.stage_system_metrics_table_name.capitalize(), + (Model, ), + fields + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._stage_system_metrics_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Stage System Metrics table - {self.stage_system_metrics_table_name} - under keyspace - {self.keyspace}') + + rows: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._stage_system_metrics_table.create, + **metrics_set.record + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to - Keyspace: {self.keyspace} - Table: {self.stage_system_metrics_table_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to - Keyspace: {self.keyspace} - Table: {self.streams_table_name}') + + if self._streams_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Streams table - {self.streams_table_name} - under keyspace - {self.keyspace}') + + fields = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'stage': columns.Text(min_length=1), + 'group': columns.Text(), + 'median': columns.Float(), + 'mean': columns.Float(), + 'variance': columns.Float(), + 'stdev': columns.Float(), + 'minimum': columns.Float(), + 'maximum': columns.Float(), + 'created_at': columns.DateTime(default=datetime.now) + } + + self._streams_table = type( + self.streams_table_name.capitalize(), + (Model, ), + fields + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._streams_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Streams table - {self.streams_table_name} - under keyspace - {self.keyspace}') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped: + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._streams_table.create, + **{ + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to - Keyspace: {self.keyspace} - Table: {self.streams_table_name}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to - Keyspace: {self.keyspace} - Table: {self.experiments_table_name}') + + if self._experiments_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Experiments table - {self.experiments_table_name} - under keyspace - {self.keyspace}') + + self._experiments_table = type( + self.experiments_table_name.capitalize(), + (Model, ), + { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'experiment_name': columns.Text(min_length=1, index=True), + 'experiment_randomized': columns.Boolean(), + 'experiment_completed': columns.Integer(), + 'experiment_succeeded': columns.Integer(), + 'experiment_failed': columns.Integer(), + 'experiment_median_aps': columns.Float(), + 'created_at': columns.DateTime(default=datetime.now) + } + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._experiments_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Experiments table - {self.experiments_table_name} - under keyspace - {self.keyspace}') + + for experiment in experiment_metrics.experiments: + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._metrics_table.create, + **experiment + ) + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to - Keyspace: {self.keyspace} - Table: {self.experiments_table_name}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to - Keyspace: {self.keyspace} - Table: {self.variants_table_name}') + + if self._variants_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Variants table - {self.variants_table_name} - under keyspace - {self.keyspace}') + + self._variants_table = type( + self.variants_table_name.capitalize(), + (Model, ), + { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'variant_name': columns.Text(min_length=1, index=True), + 'variant_experiment': columns.Text(min_length=1), + 'variant_weight': columns.Float(), + 'variant_distribution': columns.Text(min_length=1), + 'variant_distribution_interval': columns.Float(), + 'variant_ratio_completed': columns.Float(), + 'variant_ratio_succeeded': columns.Float(), + 'variant_ratio_failed': columns.Float(), + 'variant_ratio_aps': columns.Float(), + 'variant_completed': columns.Integer(), + 'variant_succeeded': columns.Integer(), + 'variant_failed': columns.Integer(), + 'variant_actions_per_second': columns.Float(), + 'created_at': columns.DateTime(default=datetime.now) + } + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._variants_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Variants table - {self.variants_table_name} - under keyspace - {self.keyspace}') + + for variant in experiment_metrics.variants: + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._metrics_table.create, + **variant + ) + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to - Keyspace: {self.keyspace} - Table: {self.variants_table_name}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to - Keyspace: {self.keyspace} - Table: {self.mutations_table_name}') + + if self._mutations_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Mutations table - {self.mutations_table_name} - under keyspace - {self.keyspace}') + + self._mutations_table = type( + self.mutations_table_name.capitalize(), + (Model, ), + { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'mutation_name': columns.Text(min_length=1, index=True), + 'mutation_experiment_name': columns.Text(min_length=1), + 'mutation_variant_name': columns.Text(min_length=1), + 'mutation_chance': columns.Float(), + 'mutation_targets': columns.Text(min_length=1), + 'mutation_type': columns.Text(min_length=1), + 'created_at': columns.DateTime(default=datetime.now) + } + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._mutations_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Mutations table - {self.mutations_table_name} - under keyspace - {self.keyspace}') + + for mutation in experiment_metrics.mutations: + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._metrics_table.create, + **mutation + ) + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to - Keyspace: {self.keyspace} - Table: {self.mutations_table_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to - Keyspace: {self.keyspace} - Table: {self.events_table_name}') + + if self._events_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Events table - {self.events_table_name} - under keyspace - {self.keyspace}') + + self._events_table = type( + self.events_table_name.capitalize(), + (Model, ), + { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'stage': columns.Text(min_length=1), + 'time': columns.Float(), + 'succeeded': columns.Boolean(), + 'created_at': columns.DateTime(default=datetime.now) + } + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._events_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Events table - {self.events_table_name} - under keyspace - {self.keyspace}') + + for event in events: + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._metrics_table.create, + **event.record + ) + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to - Keyspace: {self.keyspace} - Table: {self.events_table_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to - Keyspace: {self.keyspace} - Table: {self.shared_metrics_table_name}') + + if self._shared_metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Shared Metrics table - {self.shared_metrics_table_name} - under keyspace - {self.keyspace}') + + fields = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'stage': columns.Text(min_length=1), + 'group': columns.Text(min_length=1), + 'total': columns.Integer(), + 'succeeded': columns.Integer(), + 'failed': columns.Integer(), + 'actions_per_second': columns.Float() + } + + self._shared_metrics_table = type( + self.shared_metrics_table_name.capitalize(), + (Model, ), + fields + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._shared_metrics_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Shared Metrics table - {self.shared_metrics_table_name} - under keyspace - {self.keyspace}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._metrics_table.create, + **{ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to - Keyspace: {self.keyspace} - Table: {self.shared_metrics_table_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to - Keyspace: {self.keyspace} - Table: {self.metrics_table_name}') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Metrics table - {self.metrics_table_name} - under keyspace - {self.keyspace}') + + fields = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'stage': columns.Text(min_length=1), + 'group': columns.Text(), + 'median': columns.Float(), + 'mean': columns.Float(), + 'variance': columns.Float(), + 'stdev': columns.Float(), + 'minimum': columns.Float(), + 'maximum': columns.Float(), + 'created_at': columns.DateTime(default=datetime.now) + } + + + for quantile_name in metrics_set.quantiles: + fields[quantile_name] = columns.Float() + + for custom_field_name, custom_field_type in metrics_set.custom_schemas: + fields[custom_field_name] = custom_field_type + + + self._metrics_table = type( + self.metrics_table_name.capitalize(), + (Model, ), + fields + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._metrics_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Metrics table - {self.metrics_table_name} - under keyspace - {self.keyspace}') + + for group_name, group in metrics_set.groups.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - {group_name}:{group.metrics_group_id}') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._metrics_table.create, + **{ + **group.record, + 'group': group_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics Set to - Keyspace: {self.keyspace} - Table: {self.metrics_table_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics Set to - Keyspace: {self.keyspace} - Table: {self.metrics_table_name}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Group - Custom') + + if self._custom_metrics_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Custom Metrics table - {self.custom_metrics_table_name} - under keyspace - {self.keyspace}') + + custom_table = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'stage': columns.Text(min_length=1), + 'group': columns.Text(min_length=1) + } + + for metrics_set in metrics_sets: + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + cassandra_column = columns.Integer() + + if custom_metric.metric_type == MetricType.COUNT: + cassandra_column = columns.Integer() + + else: + cassandra_column = columns.Float() + + custom_table[custom_metric_name] = cassandra_column + custom_table[custom_metric_name] = cassandra_column + + self._custom_metrics_table: Model = type( + self.custom_metrics_table_name.capitalize(), + (Model, ), + custom_table + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._custom_metrics_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Custom Metrics table - {self.custom_metrics_table_name} - under keyspace - {self.keyspace}') + + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._custom_metrics_table.create, + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'Custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics Set to - Keyspace: {self.keyspace} - Table: {self.metrics_table_name}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Errors Metrics to - Keyspace: {self.keyspace} - Table: {self.errors_table_name}') + + if self._errors_table is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Errors Metrics table - {self.errors_table_name} - under keyspace - {self.keyspace}') + + errors_table_fields = { + 'id': columns.UUID(primary_key=True, default=uuid.uuid4), + 'name': columns.Text(min_length=1, index=True), + 'stage': columns.Text(min_length=1), + 'error_message': columns.Text(min_length=1), + 'error_count': columns.Integer() + } + + self._errors_table = type( + self.errors_table_name.capitalize() + (Model, ), + errors_table_fields + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + sync_table, + self._errors_table, + keyspaces=[self.keyspace] + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Errors Metrics table - {self.errors_table_name} - under keyspace - {self.keyspace}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Errors Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + await self._loop.run_in_executor( + self._executor, + functools.partial( + self._errors_table.create, + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Errors Metrics to - Keyspace: {self.keyspace} - Table: {self.errors_table_name}') + + async def close(self): + host_port_combinations = ', '.join([ + f'{host}:{self.port}' for host in self.hosts + ]) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connection to Cassandra Cluster at - {host_port_combinations}') + + await self._loop.run_in_executor( + self._executor, + self.cluster.shutdown + ) + + self._executor.shutdown(cancel_futures=True) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connection to Cassandra Cluster at - {host_port_combinations}') \ No newline at end of file diff --git a/hyperscale/reporting/types/cassandra/cassandra_config.py b/hyperscale/reporting/types/cassandra/cassandra_config.py new file mode 100644 index 0000000..ae49183 --- /dev/null +++ b/hyperscale/reporting/types/cassandra/cassandra_config.py @@ -0,0 +1,26 @@ +from ssl import SSLContext +from typing import List, Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class CassandraConfig(BaseModel): + hosts: List[str] = ['127.0.0.1'] + port: int=9042 + username: Optional[str]=None + password: Optional[str]=None + keyspace: str='hyperscale' + events_table: str='events' + metrics_table: str='metrics' + streams_table: str='streams' + experiments_table: str='experiments' + system_metrics_table: str='system_metrics' + replication_strategy: str='SimpleStrategy' + replication: int=3 + ssl: Optional[SSLContext]=None + reporter_type: ReporterTypes=ReporterTypes.Cassandra + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/reporting/types/cloudwatch/__init__.py b/hyperscale/reporting/types/cloudwatch/__init__.py new file mode 100644 index 0000000..c5f9fa4 --- /dev/null +++ b/hyperscale/reporting/types/cloudwatch/__init__.py @@ -0,0 +1,2 @@ +from .cloudwatch import Cloudwatch +from .cloudwatch_config import CloudwatchConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/cloudwatch/cloudwatch.py b/hyperscale/reporting/types/cloudwatch/cloudwatch.py new file mode 100644 index 0000000..ef0a12e --- /dev/null +++ b/hyperscale/reporting/types/cloudwatch/cloudwatch.py @@ -0,0 +1,456 @@ +import asyncio +import datetime +import functools +import json +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .cloudwatch_config import CloudwatchConfig + +try: + import boto3 + has_connector = True + +except Exception: + boto3 = None + has_connector = False + + +class Cloudwatch: + + def __init__(self, config: CloudwatchConfig) -> None: + self.aws_access_key_id = config.aws_access_key_id + self.aws_secret_access_key = config.aws_secret_access_key + self.region_name = config.region_name + self.iam_role_arn = config.iam_role_arn + self.schedule_rate = config.schedule_rate + + self.cloudwatch_targets = config.cloudwatch_targets + self.aws_resource_arns = config.aws_resource_arns + self.submit_timeout = config.submit_timeout + + self.events_rule_name = config.events_rule + self.metrics_rule_name = config.metrics_rule + self.streams_rule_name = config.streams_rule + + self.shared_metrics_rule_name = f'{config.metrics_rule}_shared' + self.errors_rule_name = f'{config.metrics_rule}_errors' + + self.experiments_rule_name = config.experiments_rule + self.variants_rule_name = f'{config.experiments_rule}_variants' + self.mutations_rule_name = f'{config.experiments_rule}_mutations' + + self.session_system_metrics_rule_name = f'{config.system_metrics_rule}_session' + self.stage_system_metrics_rule_name = f'{config.system_metrics_rule}_stage' + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + self.experiments_rule = None + + self.client = None + self._loop = asyncio.get_event_loop() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating AWS Cloudwatch client for region - {self.region_name}') + + self.client = await self._loop.run_in_executor( + self._executor, + functools.partial( + boto3.client, + 'events', + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.region_name + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created AWS Cloudwatch client for region - {self.region_name}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Cloudwatch rule - {self.session_system_metrics_rule_name}') + + rows: List[SessionMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics.record) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics.record) + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=[ + { + 'Time': datetime.datetime.now(), + 'Detail': json.dumps(metrics_set.record), + 'DetailType': self.session_system_metrics_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.session_system_metrics_rule_name + } for metrics_set in rows + ] + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Cloudwatch rule - {self.session_system_metrics_rule_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Cloudwatch rule - {self.stage_system_metrics_rule_name}') + + rows: List[SystemMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics.record) + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=[ + { + 'Time': datetime.datetime.now(), + 'Detail': json.dumps(metrics_set.record), + 'DetailType': self.stage_system_metrics_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.stage_system_metrics_rule_name + } for metrics_set in rows + ] + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Cloudwatch rule - {self.stage_system_metrics_rule_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Cloudwatch rule - {self.streams_rule_name}') + + streams = [] + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream Group - {group_name}:{stream.stream_set_id}') + + streams.append({ + 'Time': datetime.datetime.now(), + 'Detail': json.dumps({ + **group, + 'group': group_name, + 'stage': stage_name, + 'name': f'{stage_name}_streams' + }), + 'DetailType': self.streams_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.streams_rule_name + }) + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=streams + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Cloudwatch rule - {self.streams_rule_name}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Cloudwatch rule - {self.experiments_rule_name}') + + cloudwatch_events = [ + { + 'Time': datetime.datetime.now(), + 'Detail': json.dumps(experiment), + 'DetailType': self.experiments_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.experiments_rule_name + } for experiment in experiment_metrics.experiments + ] + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=cloudwatch_events + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Cloudwatch rule - {self.experiments_rule_name}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Cloudwatch rule - {self.variants_rule_name}') + + cloudwatch_events = [ + { + 'Time': datetime.datetime.now(), + 'Detail': json.dumps(variant), + 'DetailType': self.variants_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.variants_rule_name + } for variant in experiment_metrics.variants + ] + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=cloudwatch_events + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Cloudwatch rule - {self.variants_rule_name}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Cloudwatch rule - {self.mutations_rule_name}') + + cloudwatch_events = [ + { + 'Time': datetime.datetime.now(), + 'Detail': json.dumps(mutation), + 'DetailType': self.mutations_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.mutations_rule_name + } for mutation in experiment_metrics.mutations + ] + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=cloudwatch_events + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Cloudwatch rule - {self.mutations_rule_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Cloudwatch rule - {self.events_rule_name}') + + cloudwatch_events = [ + { + 'Time': datetime.datetime.now(), + 'Detail': json.dumps(event.record), + 'DetailType': self.events_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.events_rule_name + } for event in events + ] + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=cloudwatch_events + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Cloudwatch rule - {self.events_rule_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Cloudwatch rule - {self.metrics_rule_name}') + + common_metrics = [ + { + 'Time': datetime.datetime.now(), + 'Detail': json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }), + 'DetailType': self.shared_metrics_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.shared_metrics_rule_name + } for metrics_set in metrics_sets + ] + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=common_metrics + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Cloudwatch rule - {self.metrics_rule_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Cloudwatch rule - {self.metrics_rule_name}') + + metrics = [] + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - {group_name}:{group.metrics_group_id}') + + metrics.append({ + 'Time': datetime.datetime.now(), + 'Detail': json.dumps({ + **group.record, + 'group': group_name + }), + 'DetailType': self.metrics_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.metrics_rule_name + }) + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=metrics + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Cloudwatch rule - {self.metrics_rule_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Cloudwatch rule - {self.metrics_rule_name}') + + custom_metrics = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - Custom') + + custom_metrics.append({ + 'Time': datetime.datetime.now(), + 'Detail': json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }), + 'DetailType': self.metrics_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.metrics_rule_name + }) + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=custom_metrics + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Cloudwatch rule - {self.metrics_rule_name}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Cloudwatch rule - {self.errors_rule_name}') + + cloudwatch_errors = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + cloudwatch_errors.append({ + 'Time': datetime.datetime.now(), + 'Detail': json.dumps(error), + 'DetailType': self.errors_rule_name, + 'Resources': self.aws_resource_arns, + 'Source': self.errors_rule_name + }) + + await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_events, + Entries=cloudwatch_errors + ) + ), + timeout=self.submit_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Cloudwatch rule - {self.errors_rule_name}') + + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') diff --git a/hyperscale/reporting/types/cloudwatch/cloudwatch_config.py b/hyperscale/reporting/types/cloudwatch/cloudwatch_config.py new file mode 100644 index 0000000..e153733 --- /dev/null +++ b/hyperscale/reporting/types/cloudwatch/cloudwatch_config.py @@ -0,0 +1,27 @@ +from typing import List + +from pydantic import BaseModel, conlist + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class _CloudwatchTarget(BaseModel): + arn: str + id: str + +class CloudwatchConfig(BaseModel): + aws_access_key_id: str + aws_secret_access_key: str + region_name: str + iam_role_arn: str + schedule_rate: str=None + events_rule: str='hyperscale-events' + metrics_rule: str='hyperscale-metrics' + experiments_rule: str='hyperscale-experiments' + streams_rule: str='hyperscale-streams' + system_metrics_rule: str='system_metrics' + cloudwatch_targets: conlist(_CloudwatchTarget, min_length=1) + aws_resource_arns: List[str]=[] + cloudwatch_source: str='hyperscale' + submit_timeout: int=60 + reporter_type: ReporterTypes=ReporterTypes.Cloudwatch \ No newline at end of file diff --git a/hyperscale/reporting/types/common/__init__.py b/hyperscale/reporting/types/common/__init__.py new file mode 100644 index 0000000..ca0932b --- /dev/null +++ b/hyperscale/reporting/types/common/__init__.py @@ -0,0 +1 @@ +from .types import ReporterTypes \ No newline at end of file diff --git a/hyperscale/reporting/types/common/types.py b/hyperscale/reporting/types/common/types.py new file mode 100644 index 0000000..ebb9caa --- /dev/null +++ b/hyperscale/reporting/types/common/types.py @@ -0,0 +1,35 @@ +from enum import Enum + + +class ReporterTypes(Enum): + AWSLambda='aws_lambda' + AWSTimestream='aws_timestream' + BigQuery='bigquery' + BigTable='bigtable' + Cassandra='cassandra' + Cloudwatch='cloudwatch' + CosmosDB='cosmosdb' + CSV='csv' + Datadog='datadog' + DogStatsD='dogstatsd' + GCS='gcs' + Graphite='graphite' + Honeycomb='honeycomb' + InfluxDB='influxdb' + JSON='json' + Kafka='kafka' + MongoDB='mongodb' + MySQL='mysql' + Netdata='netdata' + NewRelic='newrelic' + Postgres='postgres' + Prometheus='prometheus' + Redis='redis' + S3='s3' + Snowflake='snowflake' + SQLite='sqlite' + StatsD='statsd' + Telegraf='telegraf' + TelegrafStatsD='telegraf_statsd' + TimescaleDB='timescaledb' + XML='xml' \ No newline at end of file diff --git a/hyperscale/reporting/types/cosmosdb/__init__.py b/hyperscale/reporting/types/cosmosdb/__init__.py new file mode 100644 index 0000000..6a46e14 --- /dev/null +++ b/hyperscale/reporting/types/cosmosdb/__init__.py @@ -0,0 +1,2 @@ +from .cosmosdb import CosmosDB +from .cosmosdb_config import CosmosDBConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/cosmosdb/cosmosdb.py b/hyperscale/reporting/types/cosmosdb/cosmosdb.py new file mode 100644 index 0000000..ea6a340 --- /dev/null +++ b/hyperscale/reporting/types/cosmosdb/cosmosdb.py @@ -0,0 +1,403 @@ +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .cosmosdb_config import CosmosDBConfig + +try: + from azure.cosmos import PartitionKey + from azure.cosmos.aio import CosmosClient + has_connector = True +except Exception: + CosmosClient = None + PartitionKey = None + has_connector = False + + +class CosmosDB: + + def __init__(self, config: CosmosDBConfig) -> None: + self.account_uri = config.account_uri + self.account_key = config.account_key + + self.database_name = config.database + self.events_container_name = config.events_container + self.metrics_container_name = config.metrics_container + self.streams_container_name = config.streams_container + + self.experiments_container_name = config.experiments_container + self.variants_container_name = f'{config.experiments_container}_variants' + self.mutations_container_name = f'{config.experiments_container}_mutations' + + self.shared_metrics_container_name = f'{self.metrics_container_name}_metrics' + self.custom_metrics_container_name = f'{self.metrics_container_name}_custom' + self.errors_container_name = f'{self.metrics_container}_errors' + + self.session_system_metrics_container_name = f'{config.system_metrics_container}_session' + self.stage_system_metrics_container_name = f'{config.system_metrics_container}_stage' + + self.events_partition_key = config.events_partition_key + self.metrics_partition_key = config.metrics_partition_key + self.streams_partition_key = config.streams_partition_key + self.system_metrics_partition = config.system_metrics_partition + + self.experiments_partition_key = config.experiments_partition_key + self.variants_partition_key = config.variants_partition_key + self.mutations_partition_key = config.mutations_partition_key + + + self.analytics_ttl = config.analytics_ttl + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.events_container = None + self.metrics_container = None + self.streams_container = None + + self.session_system_metrics_container = None + self.stage_system_metrics_container = None + + self.experiments_container = None + self.variants_container = None + self.mutations_container = None + + self.shared_metrics_container = None + self.custom_metrics_container = None + self.errors_container = None + + self.client = None + self.database = None + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to CosmosDB') + + self.client = CosmosClient( + self.account_uri, + credential=self.account_key + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to CosmosDB') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Database - {self.database_name} - if not exists') + self.database = await self.client.create_database_if_not_exists(self.database_name) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Database - {self.database_name}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Session System Metrics container - {self.session_system_metrics_container_name} with Partition Key /{self.system_metrics_partition} if not exists') + self.session_system_metrics_container = await self.database.create_container_if_not_exists( + self.session_system_metrics_container_name, + PartitionKey(f'/{self.system_metrics_partition}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Session System Metrics container - {self.session_system_metrics_container_name} with Partition Key /{self.system_metrics_partition}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to container - {self.session_system_metrics_container_name} with Partition Key /{self.system_metrics_partition}') + + rows: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics Set - {metrics_set.system_metrics_set_id}') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics Group - {metrics_set.name}:{metrics_set.group}') + + await self.session_system_metrics_container.upsert_item({ + 'id': str(uuid.uuid4()), + **metrics_set.record + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to container - {self.session_system_metrics_container_name} with Partition Key /{self.system_metrics_partition}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Stage System Metrics container - {self.stage_system_metrics_container_name} with Partition Key /{self.system_metrics_partition} if not exists') + self.stage_system_metrics_container = await self.database.create_container_if_not_exists( + self.stage_system_metrics_container_name, + PartitionKey(f'/{self.system_metrics_partition}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Stage System Metrics container - {self.stage_system_metrics_container_name} with Partition Key /{self.system_metrics_partition}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to container - {self.stage_system_metrics_container_name} with Partition Key /{self.system_metrics_partition}') + + rows: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics Set - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics.record) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics Group - {metrics_set.name}:{metrics_set.group}') + + await self.stage_system_metrics_container.upsert_item({ + 'id': str(uuid.uuid4()), + **metrics_set.record + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to container - {self.stage_system_metrics_container_name} with Partition Key /{self.system_metrics_partition}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Streams container - {self.streams_container_name} with Partition Key /{self.streams_partition_key} if not exists') + self.streams_container = await self.database.create_container_if_not_exists( + self.streams_container_name, + PartitionKey(f'/{self.streams_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Streams container - {self.streams_container_name} with Partition Key /{self.streams_partition_key}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to container - {self.streams_container_name} with Partition Key /{self.streams_partition_key}') + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams Set - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams Group - {group_name}:{stream.stream_set_id}') + + await self.streams_container.upsert_item({ + 'id': str(uuid.uuid4()), + 'name': f'{stage_name}_stream', + 'stage': stage_name, + 'group': group_name, + **group + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to container - {self.streams_container_name} with Partition Key /{self.streams_partition_key}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Experiments container - {self.experiments_container_name} with Partition Key /{self.experiments_partition_key} if not exists') + self.experiments_container = await self.database.create_container_if_not_exists( + self.experiments_container_name, + PartitionKey(f'/{self.experiments_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Experiment container - {self.experiments_container_name} with Partition Key /{self.experiments_partition_key}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiment to container - {self.experiments_container_name} with Partition Key /{self.experiments_partition_key}') + for experiment in experiment_metrics.experiments: + await self.experiments_container.upsert_item({ + 'id': str(uuid.uuid4()), + **experiment + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to container - {self.experiments_container_name} with Partition Key /{self.experiments_partition_key}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Variants container - {self.variants_container_name} with Partition Key /{self.variants_partition_key} if not exists') + self.variants_container = await self.database.create_container_if_not_exists( + self.variants_container_name, + PartitionKey(f'/{self.variants_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Variants container - {self.variants_container_name} with Partition Key /{self.variants_partition_key}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variant to container - {self.variants_container_name} with Partition Key /{self.variants_partition_key}') + for variant in experiment_metrics.variants: + await self.variants_container.upsert_item({ + 'id': str(uuid.uuid4()), + **variant + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to container - {self.variants_container_name} with Partition Key /{self.variants_partition_key}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Mutations container - {self.mutations_container_name} with Partition Key /{self.mutations_partition_key} if not exists') + self.mutations_container = await self.database.create_container_if_not_exists( + self.mutations_container_name, + PartitionKey(f'/{self.mutations_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Mutations container - {self.mutations_container_name} with Partition Key /{self.mutations_partition_key}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutation to container - {self.mutations_container_name} with Partition Key /{self.mutations_partition_key}') + for mutation in experiment_metrics.mutations: + await self.mutations_container.upsert_item({ + 'id': str(uuid.uuid4()), + **mutation + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to container - {self.mutations_container_name} with Partition Key /{self.mutations_partition_key}') + + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Events container - {self.events_container_name} with Partition Key /{self.events_partition_key} if not exists') + self.events_container = await self.database.create_container_if_not_exists( + self.events_container_name, + PartitionKey(f'/{self.events_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Events container - {self.events_container_name} with Partition Key /{self.events_partition_key}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to container - {self.events_container_name} with Partition Key /{self.events_partition_key}') + for event in events: + await self.events_container.upsert_item({ + 'id': str(uuid.uuid4()), + **event.record + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to container - {self.events_container_name} with Partition Key /{self.events_partition_key}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Shared Metrics container - {self.shared_metrics_container_name} with Partition Key /{self.metrics_partition_key} if not exists') + self.shared_metrics_container = await self.database.create_container_if_not_exists( + self.shared_metrics_container_name, + PartitionKey(f'/{self.metrics_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Shared Metrics container - {self.shared_metrics_container_name} with Partition Key /{self.metrics_partition_key}') + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to container - {self.shared_metrics_container_name} with Partition Key /{self.metrics_partition_key}') + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self.shared_metrics_container.upsert_item({ + 'id': str(uuid.uuid4()), + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to container - {self.shared_metrics_container_name} with Partition Key /{self.metrics_partition_key}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Metrics container - {self.metrics_container_name} with Partition Key /{self.metrics_partition_key} if not exists') + self.metrics_container = await self.database.create_container_if_not_exists( + self.metrics_container_name, + PartitionKey(f'/{self.metrics_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Metrics container - {self.metrics_container_name} with Partition Key /{self.metrics_partition_key}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to container - {self.metrics_container_name} with Partition Key /{self.metrics_partition_key}') + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - {group_name}:{group.metrics_group_id}') + + await self.metrics_container.upsert_item({ + 'id': str(uuid.uuid4()), + 'group': group_name, + **group.record + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to container - {self.metrics_container_name} with Partition Key /{self.metrics_partition_key}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Group - Xuarom') + + custom_metrics_container_name = f'{self.custom_metrics_container_name}_metrics' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Custom Metrics container - {custom_metrics_container_name} with Partition Key /{self.metrics_partition_key} if not exists') + + custom_container = await self.database.create_container_if_not_exists( + custom_metrics_container_name, + PartitionKey(f'/{self.metrics_partition_key}') + ) + + self.custom_metrics_container = custom_container + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Custom Metrics container - {custom_metrics_container_name} with Partition Key /{self.metrics_partition_key}') + + await custom_container.upsert_item({ + 'id': str(uuid.uuid4()), + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to container - {self.metrics_container_name} with Partition Key /{self.metrics_partition_key}') + + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Error Metrics container - {self.errors_container_name} with Partition Key /{self.metrics_partition_key} if not exists') + self.errors_container = await self.database.create_container_if_not_exists( + self.errors_container_name, + PartitionKey(f'/{self.metrics_partition_key}') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created or set Error Metrics container - {self.errors_container_name} with Partition Key /{self.metrics_partition_key}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to container - {self.errors_container_name} with Partition Key /{self.metrics_partition_key}') + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + await self.metrics_container.upsert_item({ + 'id': str(uuid.uuid4()), + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to container - {self.errors_container_name} with Partition Key /{self.metrics_partition_key}') + + async def close(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connection to CosmosDB') + + await self.client.close() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connection to CosmosDB') \ No newline at end of file diff --git a/hyperscale/reporting/types/cosmosdb/cosmosdb_config.py b/hyperscale/reporting/types/cosmosdb/cosmosdb_config.py new file mode 100644 index 0000000..eab188e --- /dev/null +++ b/hyperscale/reporting/types/cosmosdb/cosmosdb_config.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class CosmosDBConfig(BaseModel): + account_uri: str + account_key: str + database: str + events_container: str='events' + metrics_container: str='metrics' + streams_container: str='streams' + experiments_container: str='experiments' + system_metrics_container: str='system_metrics' + events_partition_key: str='name' + metrics_partition_key: str='name' + streams_partition_key: str='name' + experiments_partition_key: str='experiment_name' + variants_partition_key: str='variant_name' + mutations_partition_key: str='mutation_name' + system_metrics_partition: str='name' + analytics_ttl: int=0 + reporter_type: ReporterTypes=ReporterTypes.CosmosDB \ No newline at end of file diff --git a/hyperscale/reporting/types/csv/__init__.py b/hyperscale/reporting/types/csv/__init__.py new file mode 100644 index 0000000..1c40d86 --- /dev/null +++ b/hyperscale/reporting/types/csv/__init__.py @@ -0,0 +1,2 @@ +from .csv import CSV +from .csv_config import CSVConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/csv/csv.py b/hyperscale/reporting/types/csv/csv.py new file mode 100644 index 0000000..8b06260 --- /dev/null +++ b/hyperscale/reporting/types/csv/csv.py @@ -0,0 +1,811 @@ +import asyncio +import csv +import functools +import os +import signal +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Dict, List, TextIO, Union + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric.metrics_set import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet + +from .csv_config import CSVConfig + +has_connector = True + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop, + events_file: TextIO +): + try: + events_file.close() + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class CSV: + + def __init__(self, config: CSVConfig) -> None: + self.events_filepath = config.events_filepath + self.metrics_filepath = Path(config.metrics_filepath).absolute() + self.experiments_filepath = config.experiments_filepath + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + experiments_path = Path(self.experiments_filepath) + experiments_directory = experiments_path.parent + experiments_filename = experiments_path.stem + + self.variants_filepath = os.path.join( + experiments_directory, + f'{experiments_filename}_variants.csv' + ) + + self.mutations_filepath = os.path.join( + experiments_directory, + f'{experiments_filename}_mutations.csv' + ) + + self.streams_filepath = config.streams_filepath + + system_metrics_path = Path(config.system_metrics_filepath) + system_metrics_directory = system_metrics_path.parent + system_metrics_filename = system_metrics_path.stem + + self.stage_system_metrics_filepath = os.path.join( + system_metrics_directory, + f'{system_metrics_filename}_stages.csv' + ) + + self.session_system_metrics_filepath = os.path.join( + system_metrics_directory, + f'{system_metrics_filename}_session.csv' + ) + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._events_csv_writer: csv.DictWriter = None + self._metrics_csv_writer: csv.DictWriter = None + self._stage_metrics_csv_writer: csv.DictWriter = None + self._errors_csv_writer: csv.DictWriter = None + self._custom_metrics_csv_writer: csv.DictWriter = None + self._experiments_writer: csv.DictWriter = None + self._variants_writer: csv.DictWriter = None + self._mutations_writer: csv.DictWriter = None + self._streams_writer: csv.DictWriter = None + self._stage_system_metrics_writer: csv.DictWriter = None + self._session_system_metrics_writer: csv.DictWriter = None + + self._loop: asyncio.AbstractEventLoop = None + + self.events_file: TextIO = None + self.metrics_file: TextIO = None + self.experiments_file: TextIO = None + self.variants_file: TextIO = None + self.mutations_file: TextIO = None + self.streams_file: TextIO = None + self.stage_system_metrics_file: TextIO = None + self.session_system_metrics_file: TextIO = None + + self.write_mode = 'w' if config.overwrite else 'a' + + + filepath = Path(config.metrics_filepath) + base_filepath = filepath.parent + base_filename = filepath.stem + + self.shared_metrics_filepath = os.path.join( + base_filepath, + f'{base_filename}_shared.csv' + ) + + self.custom_metrics_filepath = os.path.join( + base_filepath, + f'{base_filename}_custom.csv' + ) + + self.errors_metrics_filepath = os.path.join( + base_filepath, + f'{base_filename}_errors.csv' + ) + + async def connect(self): + self._loop = asyncio._get_running_loop() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Setting filepaths') + + original_filepath = Path(self.events_filepath) + + directory = original_filepath.parent + filename = original_filepath.stem + + events_file_timestamp = time.time() + + self.events_filepath = os.path.join( + directory, + f'{filename}_{events_file_timestamp}.csv' + ) + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + if self.session_system_metrics_file is None: + self.session_system_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.session_system_metrics_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.session_system_metrics_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Session System Metrics to file - {self.session_system_metrics_filepath}') + + if self._session_system_metrics_writer is None or self.write_mode == 'w': + self._session_system_metrics_writer = csv.DictWriter( + self.session_system_metrics_file, + fieldnames=[ + header for header in SystemMetricsSet.metrics_header_keys if header != 'stage' + ] + ) + + await self._loop.run_in_executor( + self._executor, + self._session_system_metrics_writer.writeheader + ) + + metrics_sets: List[Dict[str, Union[int, float, str]]] = [] + + for metrics_set in system_metrics_sets: + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + await self._loop.run_in_executor( + self._executor, + self._session_system_metrics_writer.writerows, + metrics_sets + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Session System Metrics to file - {self.session_system_metrics_filepath}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + if self.stage_system_metrics_file is None: + self.stage_system_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.stage_system_metrics_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.stage_system_metrics_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Stage System Metrics to file - {self.stage_system_metrics_filepath}') + + if self._stage_system_metrics_writer is None or self.write_mode == 'w': + self._stage_metrics_csv_writer = csv.DictWriter( + self.stage_system_metrics_file, + fieldnames=SystemMetricsSet.metrics_header_keys + ) + + await self._loop.run_in_executor( + self._executor, + self._stage_metrics_csv_writer.writeheader + ) + + metrics_sets: List[Dict[str, Union[int, float, str]]] = [] + + for metrics_set in system_metrics_sets: + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics.record) + + await self._loop.run_in_executor( + self._executor, + self._stage_metrics_csv_writer.writerows, + metrics_sets + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Stage System Metrics to file - {self.stage_system_metrics_filepath}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + if self.streams_file is None: + self.streams_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.streams_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.streams_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Streams to file - {self.streams_filepath}') + + streams_data: List[Dict[str, Union[float, int]]] = [] + + for stream_name, stream in stream_metrics.items(): + + streams_data.extend([{ + 'stage': stream_name, + 'group': group_name, + **group_metrics + } for group_name, group_metrics in stream.grouped.items()]) + + + headers = list(streams_data[0].keys()) + + if self._streams_writer is None or self.write_mode == 'w': + self._streams_writer = csv.DictWriter( + self.streams_file, + fieldnames=headers + ) + + await self._loop.run_in_executor( + self._executor, + self._streams_writer.writeheader + ) + + await self._loop.run_in_executor( + self._executor, + self._streams_writer.writerows, + streams_data + ) + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Experiments to file - {self.experiments_filepath}') + + if self.experiments_file is None: + self.experiments_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.experiments_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.experiments_file + ) + ) + + if self._experiments_writer is None or self.write_mode == 'w': + self._experiments_writer = csv.DictWriter( + self.experiments_file, + fieldnames=experiment_metrics.experiments_metrics_fields + ) + + await self._loop.run_in_executor( + self._executor, + self._experiments_writer.writeheader + ) + + await self._loop.run_in_executor( + self._executor, + self._experiments_writer.writerows, + experiment_metrics.experiments + ) + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + if self.variants_file is None: + self.variants_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.variants_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.variants_file + ) + ) + + if self._variants_writer is None or self.write_mode == 'w': + self._variants_writer = csv.DictWriter( + self.variants_file, + fieldnames=experiment_metrics.variants_metrics_fields + ) + + await self._loop.run_in_executor( + self._executor, + self._variants_writer.writeheader + ) + + await self._loop.run_in_executor( + self._executor, + self._variants_writer.writerows, + experiment_metrics.variants + ) + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + if self.mutations_file is None: + self.mutations_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.mutations_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.mutations_file + ) + ) + + if self._mutations_writer is None or self.write_mode == 'w': + self._mutations_writer = csv.DictWriter( + self.mutations_file, + fieldnames=experiment_metrics.mutations_metrics_fields + ) + + await self._loop.run_in_executor( + self._executor, + self._mutations_writer.writeheader + ) + + await self._loop.run_in_executor( + self._executor, + self._mutations_writer.writerows, + experiment_metrics.mutations + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Experiments to file - {self.experiments_filepath}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Events to file - {self.events_filepath}') + + if self.events_file is None: + self.events_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.events_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.events_file + ) + ) + + for event in events: + if self._events_csv_writer is None or self.write_mode == 'w': + self._events_csv_writer = csv.DictWriter(self.events_file, fieldnames=event.fields) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Writing headers to file - {self.metrics_filepath} - {", ".join(event.fields)}') + + await self._loop.run_in_executor( + self._executor, + self._events_csv_writer.writeheader + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Wrote headers to file - {self.metrics_filepath} - {", ".join(event.fields)}') + + await self._loop.run_in_executor( + self._executor, + self._events_csv_writer.writerow, + event.record + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Events to file - {self.events_filepath}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Shared Metrics to file - {self.metrics_filepath}') + + headers = [ + 'name', + 'stage', + 'group', + 'total', + 'succeeded', + 'failed', + 'actions_per_second' + ] + + shared_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.shared_metrics_filepath, + 'w' + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + shared_metrics_file + ) + ) + + if self._stage_metrics_csv_writer is None: + self._stage_metrics_csv_writer = csv.DictWriter(shared_metrics_file, fieldnames=headers) + + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Writing headers to file - {self.metrics_filepath} - {", ".join(headers)}') + + await self._loop.run_in_executor( + self._executor, + self._stage_metrics_csv_writer.writeheader + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Wrote headers to file - {self.metrics_filepath} - {", ".join(headers)}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self._loop.run_in_executor( + self._executor, + self._stage_metrics_csv_writer.writerow, + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + } + ) + + await self._loop.run_in_executor( + self._executor, + shared_metrics_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Shared Metrics to file - {self.metrics_filepath}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Metrics to file - {self.metrics_filepath}') + + metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.metrics_filepath, + 'w' + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + metrics_file + ) + ) + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_csv_writer is None: + + headers = [ + *metrics_set.fields, + 'group' + ] + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Writing headers to file - {self.metrics_filepath} - {", ".join(headers)}') + + self._metrics_csv_writer = csv.DictWriter(metrics_file, fieldnames=headers) + + await self._loop.run_in_executor( + self._executor, + self._metrics_csv_writer.writeheader + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Wrote headers to file - {self.metrics_filepath} - {", ".join(headers)}') + + for group_name, group in metrics_set.groups.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Group - {group_name}:{group.metrics_group_id}') + + await self._loop.run_in_executor( + self._executor, + self._metrics_csv_writer.writerow, + { + **group.record, + 'group': group_name + } + ) + + await self._loop.run_in_executor( + self._executor, + metrics_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Metrics to file - {self.metrics_filepath}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Custom Metrics to file - {self.metrics_filepath}') + + custom_metrics_file = None + + headers = [ + 'name', + 'stage', + 'group', + ] + + if self._custom_metrics_csv_writer is None: + + custom_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.custom_metrics_filepath, + 'w' + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + custom_metrics_file + ) + ) + + for metrics_set in metrics_sets: + for custom_metric in metrics_set.custom_metrics.values(): + headers.append( + custom_metric.metric_name + ) + + + self._custom_metrics_csv_writer = csv.DictWriter(custom_metrics_file, fieldnames=headers) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Writing headers to file - {self.metrics_filepath} - {", ".join(headers)}') + + await self._loop.run_in_executor( + self._executor, + self._custom_metrics_csv_writer.writeheader + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Wrote headers to file - {self.metrics_filepath} - {", ".join(headers)}') + + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Group - Custom') + + await self._loop.run_in_executor( + self._executor, + self._custom_metrics_csv_writer.writerow, + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + } + ) + + await self._loop.run_in_executor( + self._executor, + custom_metrics_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Custom Metrics to file - {self.metrics_filepath}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Error Metrics to file - {self.metrics_filepath}') + + error_csv_headers = [ + 'name', + 'stage', + 'error_message', + 'error_count' + ] + + errors_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.errors_metrics_filepath, + 'w' + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + errors_file + ) + ) + + if self._errors_csv_writer is None: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Writing headers to file - {self.metrics_filepath} - {", ".join(error_csv_headers)}') + error_csv_writer = csv.DictWriter(errors_file, fieldnames=error_csv_headers) + + await self._loop.run_in_executor( + self._executor, + error_csv_writer.writeheader + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Wrote headers to file - {self.metrics_filepath} - {", ".join(error_csv_headers)}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + await self._loop.run_in_executor( + self._executor, + error_csv_writer.writerow, + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + } + ) + + await self._loop.run_in_executor( + self._executor, + errors_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Error Metrics to file - {self.metrics_filepath}') + + async def close(self): + + if self.events_file: + await self._loop.run_in_executor( + self._executor, + self.events_file.close + ) + + if self.experiments_file: + await self._loop.run_in_executor( + self._executor, + self.experiments_file.close + ) + + if self.variants_file: + await self._loop.run_in_executor( + self._executor, + self.variants_file.close + ) + + if self.mutations_file: + await self._loop.run_in_executor( + self._executor, + self.mutations_file.close + ) + + if self.streams_file: + await self._loop.run_in_executor( + self._executor, + self.streams_file.close + ) + + if self.stage_system_metrics_file: + await self._loop.run_in_executor( + self._executor, + self.stage_system_metrics_file.close + ) + + if self.session_system_metrics_file: + await self._loop.run_in_executor( + self._executor, + self.session_system_metrics_file.close + ) + + self._executor.shutdown(wait=False, cancel_futures=True) + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + diff --git a/hyperscale/reporting/types/csv/csv_config.py b/hyperscale/reporting/types/csv/csv_config.py new file mode 100644 index 0000000..1581a1c --- /dev/null +++ b/hyperscale/reporting/types/csv/csv_config.py @@ -0,0 +1,30 @@ +import os + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class CSVConfig(BaseModel): + events_filepath: str=os.path.join( + os.getcwd(), + 'events.csv' + ) + metrics_filepath: str=os.path.join( + os.getcwd(), + 'metrics.csv' + ) + experiments_filepath: str=os.path.join( + os.getcwd(), + 'experiments.csv' + ) + streams_filepath: str=os.path.join( + os.getcwd(), + 'streams.csv' + ) + system_metrics_filepath: str=os.path.join( + os.getcwd(), + 'system_metrics.csv' + ) + overwrite: bool=True + reporter_type: ReporterTypes=ReporterTypes.CSV \ No newline at end of file diff --git a/hyperscale/reporting/types/datadog/__init__.py b/hyperscale/reporting/types/datadog/__init__.py new file mode 100644 index 0000000..231f575 --- /dev/null +++ b/hyperscale/reporting/types/datadog/__init__.py @@ -0,0 +1,2 @@ +from .datadog import Datadog +from .datadog_config import DatadogConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/datadog/datadog.py b/hyperscale/reporting/types/datadog/datadog.py new file mode 100644 index 0000000..65c66fd --- /dev/null +++ b/hyperscale/reporting/types/datadog/datadog.py @@ -0,0 +1,608 @@ +import uuid +from typing import Dict + +from numpy import float32, float64, int16, int32, int64 + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .datadog_config import DatadogConfig + +try: + # Datadog uses aiosonic + from aiosonic import HTTPClient, TCPConnector, Timeouts + from datadog_api_client import AsyncApiClient, Configuration + from datadog_api_client.v1.api.events_api import EventCreateRequest, EventsApi + from datadog_api_client.v2.api.metrics_api import MetricPayload, MetricsApi + from datadog_api_client.v2.model.metric_point import MetricPoint + from datadog_api_client.v2.model.metric_series import MetricSeries + has_connector = True + +except Exception: + datadog = None + has_connector = False + +from datetime import datetime +from typing import List + + +class Datadog: + + def __init__(self, config: DatadogConfig) -> None: + self.datadog_api_key = config.api_key + self.datadog_app_key = config.app_key + self.event_alert_type = config.event_alert_type or 'info' + self.device_name = config.device_name or 'hyperscale' + self.priority = config.priority + self.custom_fields = config.custom_fields or {} + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.types_map = { + 'total': 'count', + 'succeeded': 'count', + 'failed': 'count', + 'actions_per_second': 'gauge', + 'median': 'gauge', + 'mean': 'gauge', + 'variance': 'gauge', + 'stdev': 'gauge', + 'minimum': 'gauge', + 'maximum': 'gauge', + **self.custom_fields + } + + self._datadog_api_map = { + 'count': 1, + 'rate': 2, + 'gauge': 3, + 'histogram': 4, + } + + self._config = None + self._client = None + self.events_api = None + self.metrics_api = None + self.metric_types_map = { + MetricType.COUNT: 'count', + MetricType.RATE: 'rate', + MetricType.DISTRIBUTION: 'histogram', + MetricType.SAMPLE: 'gauge' + } + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Datadogg API') + + self._config = Configuration() + self._config.api_key["apiKeyAuth"] = self.datadog_api_key + self._config.api_key["appKeyAuth"] = self.datadog_app_key + + self._client = AsyncApiClient(self._config) + + # Datadog's implementation of aiosonic's HTTPClient lacks a lot + # of configurability, incuding actually being able to set request timeouts + # so we substitute our own implementation. + + tcp_connection = TCPConnector(timeouts=Timeouts(sock_connect=30)) + self._client.rest_client._client = HTTPClient(tcp_connection) + + self.events_api = EventsApi(self._client) + self.metrics_api = MetricsApi(self._client) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Datadogg API') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Datadog API') + + metrics_sets: List[SessionMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + system_session_metrics_series: List[MetricSeries] = [] + + for metrics_set in metrics_sets: + + tags = [ + f'group:{metrics_set.group}' + ] + + for field, value in metrics_set.stats.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metric - {metrics_set.name}:{metrics_set.group}:{field}') + + metric_type = metrics_set.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type) + + if isinstance(value, (int, int16, int32, int64)): + value = int(value) + + elif isinstance(value, (float, float32, float64)): + value = float(value) + + series = MetricSeries( + f'{metrics_set.name}_{metrics_set.group}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=value + )], + type=datadog_metric_type, + tags=tags + ) + + system_session_metrics_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(system_session_metrics_series)) + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Datadog API') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Datadog API') + + metrics_sets: List[SystemMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + system_stage_metrics_series: List[MetricSeries] = [] + + for metrics_set in metrics_sets: + + tags = [ + f'group:{metrics_set.group}' + ] + + for field, value in metrics_set.stats.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metric - {metrics_set.name}:{metrics_set.group}:{field}') + + metric_type = metrics_set.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type) + + if isinstance(value, (int, int16, int32, int64)): + value = int(value) + + elif isinstance(value, (float, float32, float64)): + value = float(value) + + series = MetricSeries( + f'{metrics_set.name}_{metrics_set.group}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=value + )], + type=datadog_metric_type, + tags=tags + ) + + system_stage_metrics_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(system_stage_metrics_series)) + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Datadog API') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Datadog API') + + streams_series = [] + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + tags = [ + f'stage:{stage_name}', + f'group:{group}' + ] + + for field, value in group.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stream Metric - {stage_name}:{group_name}:{field}') + + metric_type = stream.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type) + + if isinstance(value, (int, int16, int32, int64)): + value = int(value) + + elif isinstance(value, (float, float32, float64)): + value = float(value) + + series = MetricSeries( + f'{stage_name}_{group}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=value + )], + type=datadog_metric_type, + tags=tags + ) + + streams_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(streams_series)) + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Datadog API') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Datadog API') + + experiments_series = [] + for experiment in experiment_metrics.experiment_summaries: + + experiment_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment - {experiment.experiment_name}:{experiment_id}') + + tags = [ + f'experiment_name:{experiment.experiment_name}', + f'experiment_randomized:{experiment.experiment_randomized}' + ] + + for field, value in experiment.stats: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments - {experiment.experiment_name}:{field}') + + metric_type = experiment.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type.value) + + series = MetricSeries( + f'{experiment.experiment_name}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=value + )], + type=datadog_metric_type, + tags=tags + ) + + experiments_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(experiments_series)) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Datadog API') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Datadog API') + + variant_series = [] + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants - {variant.variant_name}:{variant_id}') + + tags = [ + f'variant_name:{variant.variant_name}', + f'variant_experiment:{variant.variant_experiment}', + f'variant_distribution:{variant.variant_distribution}', + ] + + for field, value in variant.stats: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variant - {variant.variant_name}:{field}') + + metric_type = variant.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type.value) + + series = MetricSeries( + f'{variant.variant_name}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=value + )], + type=datadog_metric_type, + tags=tags + ) + + variant_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(variant_series)) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Datadog API') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Datadog API') + + mutation_series = [] + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations - {mutation.mutation_name}:{mutation_id}') + + tags = [ + f'mutation_name:{mutation.mutation_name}', + f'mutation_experiment_name:{mutation.mutation_experiment_name}', + f'mutation_variant_name:{mutation.mutation_variant_name}', + f'mutation_targets:{mutation.mutation_targets}', + f'mutation_type:{mutation.mutation_type}', + ] + + for field, value in mutation.stats: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutation - {mutation.mutation_name}:{field}') + + metric_type = mutation.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type.value) + + series = MetricSeries( + f'{mutation.mutation_name}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=value + )], + type=datadog_metric_type, + tags=tags + ) + + mutation_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(mutation_series)) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Datadog API') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Datadog API') + + for event in events: + + tags = { + f'{tag.name}:{tag.value}' for tag in event.tags + } + + await self.events_api.create_event( + EventCreateRequest( + title=event.name, + text=event.serialize(), + alert_type=self.event_alert_type, + aggregation_key=event.type, + device_name=self.device_name, + date_happened=datetime.now().strftime('%Y-%m-%dT%H:%M:%S.Z'), + priority=self.priority, + tags=tags, + host=event.hostname + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Datadog API') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Datadog API') + + metrics_series = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in metrics_set.tags + ] + + for field, value in metrics_set.common_stats.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metric - {metrics_set.name}:common:{field}') + + metric_type = self.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type) + + if isinstance(value, (int, int16, int32, int64)): + value = int(value) + + elif isinstance(value, (float, float32, float64)): + value = float(value) + + series = MetricSeries( + f'{metrics_set.name}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=value + )], + type=datadog_metric_type, + tags=[ + *tags, + f'metric_stage:{metrics_set.stage}', + 'group:common' + ] + ) + + metrics_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(metrics_series)) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Datadog API') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Datadog API') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in metrics_set.tags + ] + + metrics_series = [] + for group_name, group in metrics_set.groups.items(): + + for field, value in group.stats.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metric - {metrics_set.name}:{group_name}:{field}') + + metric_type = self.types_map.get(field) + datadog_metric_type = self._datadog_api_map.get(metric_type) + + if isinstance(value, (int, int16, int32, int64)): + value = int(value) + + elif isinstance(value, (float, float32, float64)): + value = float(value) + + series = MetricSeries( + f'{metrics_set.name}_{field}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=float(value) + )], + type=datadog_metric_type, + tags=[ + *tags, + f'metric_stage:{metrics_set.stage}', + f'group:{group_name}' + ] + ) + + metrics_series.append(series) + + for quantile_name, quantile_value in group.quantiles.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metric Quantile - {metrics_set.name}:{group_name}:{quantile_name}th Quantile') + + if isinstance(quantile_value, (int, int16, int32, int64)): + quantile_value = int(quantile_value) + + elif isinstance(quantile_value, (float, float32, float64)): + quantile_value = float(quantile_value) + + datadog_metric_type = self._datadog_api_map.get('gauge') + series = MetricSeries( + f'{metrics_set.name}_{quantile_name}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=float(quantile_value) + )], + type=datadog_metric_type, + tags=[ + *tags, + f'metric_stage:{metrics_set.stage}', + f'group:{group_name}' + ] + ) + + metrics_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(metrics_series)) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Datadog API') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Datadog API') + + metrics_series = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in metrics_set.tags + ] + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + metric_type = self.metric_types_map.get( + custom_metric.metric_type, + 'gauge' + ) + + datadog_metric_type = self._datadog_api_map.get(metric_type) + + series = MetricSeries( + f'{metrics_set.name}_{custom_metric_name}', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=custom_metric.metric_name + )], + type=datadog_metric_type, + tags=[ + *tags, + f'metric_stage:{metrics_set.stage}', + 'group:custom' + ] + ) + + metrics_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(metrics_series)) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Datadog API') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Datadog API') + + error_series = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in metrics_set.tags + ] + + for error in metrics_set.errors: + + + error_message = error.get('error_message') + + series = MetricSeries( + f'{metrics_set.name}_errors', + [MetricPoint( + timestamp=int(datetime.now().timestamp()), + value=int(error.get('count')) + )], + type=self._datadog_api_map.get('count'), + tags=[ + *tags, + f'metric_stage:{metrics_set.stage}', + f'error_message:{error_message}' + ] + ) + + error_series.append(series) + + await self.metrics_api.submit_metrics(MetricPayload(error_series)) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Datadog API') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') \ No newline at end of file diff --git a/hyperscale/reporting/types/datadog/datadog_config.py b/hyperscale/reporting/types/datadog/datadog_config.py new file mode 100644 index 0000000..bbe7c19 --- /dev/null +++ b/hyperscale/reporting/types/datadog/datadog_config.py @@ -0,0 +1,15 @@ +from typing import Dict + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class DatadogConfig(BaseModel): + api_key: str + app_key: str + event_alert_type: str='info' + device_name: str='hyperscale' + priority: str='normal' + custom_fields: Dict[str, str]={} + reporter_type: ReporterTypes=ReporterTypes.Datadog \ No newline at end of file diff --git a/hyperscale/reporting/types/dogstatsd/__init__.py b/hyperscale/reporting/types/dogstatsd/__init__.py new file mode 100644 index 0000000..1d23979 --- /dev/null +++ b/hyperscale/reporting/types/dogstatsd/__init__.py @@ -0,0 +1,2 @@ +from .dogstatsd import DogStatsD +from .dogstatsd_config import DogStatsDConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/dogstatsd/dogstatsd.py b/hyperscale/reporting/types/dogstatsd/dogstatsd.py new file mode 100644 index 0000000..695de3d --- /dev/null +++ b/hyperscale/reporting/types/dogstatsd/dogstatsd.py @@ -0,0 +1,115 @@ + +import uuid +from typing import List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) + +try: + from aio_statsd import DogStatsdClient + + from hyperscale.reporting.types.statsd import StatsD + + from .dogstatsd_config import DogStatsDConfig + has_connector = True + +except Exception: + from hyperscale.reporting.types.empty import Empty as StatsD + DogStatsdClient = None + DogStatsDConfig = None + has_connector = False + + +class DogStatsD(StatsD): + + def __init__(self, config: DogStatsDConfig) -> None: + super(DogStatsD, self).__init__(config) + + self.host = config.host + self.port = config.port + + self.connection = DogStatsdClient( + host=self.host, + port=self.port + ) + + self.types_map = { + 'total': 'increment', + 'succeeded': 'increment', + 'failed': 'increment', + 'actions_per_second': 'histogram', + 'median': 'gauge', + 'mean': 'gauge', + 'variance': 'gauge', + 'stdev': 'gauge', + 'minimum': 'gauge', + 'maximum': 'gauge', + 'quantiles': 'gauge' + } + + self.stat_type_map = { + MetricType.COUNT: 'count', + MetricType.DISTRIBUTION: 'histogram', + MetricType.RATE: 'gauge', + MetricType.SAMPLE: 'gauge' + } + + self._update_map = { + 'count': lambda: NotImplementedError('DogStatsD does not support counts.'), + 'gauge': self.connection.gauge, + 'sets': lambda: NotImplementedError('DogStatsD does not support sets.'), + 'increment': self.connection.increment, + 'histogram': self.connection.histogram, + 'distribution': self.connection.distribution, + 'timer': self.connection.timer + } + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.statsd_type = 'StatsD' + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to {self.statsd_type}') + + for event in events: + time_update_function = self._update_map.get('gauge') + time_update_function(f'{event.name}_time', event.time) + + if event.success: + success_update_function = self._update_map.get('increment') + success_update_function(f'{event.name}_success', 1) + + else: + failed_update_function = self._update_map.get('increment') + failed_update_function(f'{event.name}_failed', 1) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to {self.statsd_type}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + metric_type = self.stat_type_map.get( + custom_metric.metric_type, + 'gauge' + ) + + update_function = self._update_map.get(metric_type) + update_function( + f'{metrics_set.name}_{custom_metric_name}', + custom_metric.metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to {self.statsd_type}') \ No newline at end of file diff --git a/hyperscale/reporting/types/dogstatsd/dogstatsd_config.py b/hyperscale/reporting/types/dogstatsd/dogstatsd_config.py new file mode 100644 index 0000000..84a9fb9 --- /dev/null +++ b/hyperscale/reporting/types/dogstatsd/dogstatsd_config.py @@ -0,0 +1,12 @@ +from typing import Dict + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class DogStatsDConfig(BaseModel): + host: str='localhost' + port: int=8125 + custom_fields: Dict[str, str]={} + reporter_type: ReporterTypes=ReporterTypes.DogStatsD \ No newline at end of file diff --git a/hyperscale/reporting/types/empty/__init__.py b/hyperscale/reporting/types/empty/__init__.py new file mode 100644 index 0000000..83a0343 --- /dev/null +++ b/hyperscale/reporting/types/empty/__init__.py @@ -0,0 +1 @@ +from .empty import Empty \ No newline at end of file diff --git a/hyperscale/reporting/types/empty/empty.py b/hyperscale/reporting/types/empty/empty.py new file mode 100644 index 0000000..b3b3411 --- /dev/null +++ b/hyperscale/reporting/types/empty/empty.py @@ -0,0 +1,7 @@ +from typing import Any + + +class Empty: + + def __init__(self, config: Any) -> None: + pass \ No newline at end of file diff --git a/hyperscale/reporting/types/google_cloud_storage/__init__.py b/hyperscale/reporting/types/google_cloud_storage/__init__.py new file mode 100644 index 0000000..66d556d --- /dev/null +++ b/hyperscale/reporting/types/google_cloud_storage/__init__.py @@ -0,0 +1,2 @@ +from .google_cloud_storage import GoogleCloudStorage +from .google_cloud_storage_config import GoogleCloudStorageConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/google_cloud_storage/google_cloud_storage.py b/hyperscale/reporting/types/google_cloud_storage/google_cloud_storage.py new file mode 100644 index 0000000..4e64a21 --- /dev/null +++ b/hyperscale/reporting/types/google_cloud_storage/google_cloud_storage.py @@ -0,0 +1,666 @@ +import asyncio +import json +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .google_cloud_storage_config import GoogleCloudStorageConfig + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +try: + + from google.cloud import storage + has_connector = True + +except Exception: + storage = None + has_connector = False + +class GoogleCloudStorage: + + def __init__(self, config: GoogleCloudStorageConfig) -> None: + self.service_account_json_path = config.service_account_json_path + + self.bucket_namespace = config.bucket_namespace + self.events_bucket_name = config.events_bucket + self.metrics_bucket_name = config.metrics_bucket + self.streams_bucket_name = config.streams_bucket + + self.experiments_bucket_name = config.experiments_bucket + self.variants_bucket_name = f'{config.experiments_bucket}_variants' + self.mutations_bucket_name = f'{config.experiments_bucket}_mutations' + + self.shared_metrics_bucket_name = f'{config.metrics_bucket}_shared' + self.errors_bucket_name = f'{config.metrics_bucket}_errors' + self.custom_metrics_bucket_name = f'{config.metrics_bucket}_custom' + + self.session_system_metrics_bucket_name = f'{config.system_metrics_bucket}_session' + self.stage_system_metrics_bucket_name = f'{config.system_metrics_bucket}_stage' + + self.credentials = None + self.client = None + + self._events_bucket = None + self._streams_bucket = None + + self._experiments_bucket = None + self._variants_bucket = None + self._mutations_bucket = None + + self._shared_metrics_bucket = None + self._metrics_bucket = None + self._errors_bucket = None + self._custom_metrics_bucket = None + self._session_system_metrics_bucket = None + self._stage_system_metrics_bucket = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop = asyncio.get_event_loop() + + async def connect(self): + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opening amd authorizing connection to Google Cloud - Loading account config from - {self.service_account_json_path}') + self.client = storage.Client.from_service_account_json(self.service_account_json_path) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Opened connection to Google Cloud - Loaded account config from - {self.service_account_json_path}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Session System Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.session_system_metrics_bucket_name} if not exists') + + self._session_system_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.session_system_metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Session System Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.session_system_metrics_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Session System Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.session_system_metrics_bucket_name}') + + self._session_system_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.session_system_metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Session System Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.session_system_metrics_bucket_name}') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.session_system_metrics_bucket_name}') + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics Set - {metrics_set.name}:{metrics_set.group}') + + blob = await self._loop.run_in_executor( + self._executor, + self._streams_bucket.blob, + f'{metrics_set.name}_{metrics_set.group}_{self.session_uuid}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps(metrics_set.record) + ) + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Stage System Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.stage_system_metrics_bucket_name} if not exists') + + self._stage_system_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.stage_system_metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Stage System Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.stage_system_metrics_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Stage System Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.stage_system_metrics_bucket_name}') + + self._stage_system_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.stage_system_metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Stage System Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.stage_system_metrics_bucket_name}') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.stage_system_metrics_bucket_name}') + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics Set - {metrics_set.name}:{metrics_set.group}') + + blob = await self._loop.run_in_executor( + self._executor, + self._streams_bucket.blob, + f'{metrics_set.name}_{metrics_set.group}_{self.session_uuid}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps(metrics_set.record) + ) + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Streams bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.streams_bucket_name} if not exists') + + self._streams_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.streams_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Streams bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.streams_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Streams bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.streams_bucket_name}') + + self._streams_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.streams_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Streams bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.streams_bucket_name}') + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to - Namespace: {self.bucket_namespace} - Bucket: {self.streams_bucket_name}') + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + blob = await self._loop.run_in_executor( + self._executor, + self._streams_bucket.blob, + f'{stage_name}_{group_name}_{self.session_uuid}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps({ + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + }) + ) + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Experiments bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.experiments_bucket_name} if not exists') + + self._experiments_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.experiments_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Experiments bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.experiments_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Experiments bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.experiments_bucket_name}') + + self._experiments_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.experiments_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Experiments bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.experiments_bucket_name}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to - Namespace: {self.bucket_namespace} - Bucket: {self.experiments_bucket_name}') + for experiment in experiment_metrics.experiment_summaries: + + experiment_id = uuid.uuid4() + + blob = await self._loop.run_in_executor( + self._executor, + self._events_bucket.blob, + f'{experiment.experiment_name}_{self.session_uuid}_{experiment_id}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps(experiment.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to - Namespace: {self.bucket_namespace} - Bucket: {self.experiments_bucket_name}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Variants bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.variants_bucket_name} if not exists') + + self._variants_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.variants_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Variants bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.variants_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Variants bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.variants_bucket_name}') + + self._variants_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.variants_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Variants bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.variants_bucket_name}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to - Namespace: {self.bucket_namespace} - Bucket: {self.variants_bucket_name}') + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + blob = await self._loop.run_in_executor( + self._executor, + self._events_bucket.blob, + f'{variant.variant_name}_{self.session_uuid}_{variant_id}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps(variant.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to - Namespace: {self.bucket_namespace} - Bucket: {self.variants_bucket_name}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Mutations bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.mutations_bucket_name} if not exists') + + self._mutations_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.mutations_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Mutations bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.mutations_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Mutations bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.mutations_bucket_name}') + + self._mutations_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.mutations_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Mutations bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.mutations_bucket_name}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to - Namespace: {self.bucket_namespace} - Bucket: {self.mutations_bucket_name}') + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + blob = await self._loop.run_in_executor( + self._executor, + self._events_bucket.blob, + f'{mutation.mutation_name}_{self.session_uuid}_{mutation_id}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps(mutation.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to - Namespace: {self.bucket_namespace} - Bucket: {self.mutations_bucket_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Events bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.events_bucket_name} if not exists') + + self._events_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.events_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Events bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.events_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Events bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.events_bucket_name}') + + self._events_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.events_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Events bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.events_bucket_name}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to - Namespace: {self.bucket_namespace} - Bucket: {self.events_bucket_name}') + for event in events: + blob = await self._loop.run_in_executor( + self._executor, + self._events_bucket.blob, + f'{event.name}_{self.session_uuid}_{event.event_id}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps(event.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to - Namespace: {self.bucket_namespace} - Bucket: {self.events_bucket_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Shared Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.shared_metrics_bucket_name} if not exists') + + self._shared_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.shared_metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Shared Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.shared_metrics_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Shared Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.shared_metrics_bucket_name}') + + self._shared_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.shared_metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Shared Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.shared_metrics_bucket_name}') + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.shared_metrics_bucket_name}') + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + blob = await self._loop.run_in_executor( + self._executor, + self.metrics_bucket.blob, + f'{metrics_set.name}_shared_{self.session_uuid}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.shared_metrics_bucket_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.metrics_bucket_name} if not exists') + + self._metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_{self.metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.metrics_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.metrics_bucket_name}') + + self._metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_{self.metrics_bucket_name}' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.metrics_bucket_name}') + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.metrics_bucket_name}') + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + blob = await self._loop.run_in_executor( + self._executor, + self._metrics_bucket.blob, + f'{metrics_set.name}_{group_name}_{self.session_uuid}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps({ + **group.record, + 'group': group_name + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.metrics_bucket_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Custom Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.custom_metrics_bucket_name} if not exists') + + self._custom_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + self.custom_metrics_bucket_name + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Custom Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.custom_metrics_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Custom Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.custom_metrics_bucket_name}') + + self._custom_metrics_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + self.custom_metrics_bucket_name + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Custom Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.custom_metrics_bucket_name}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.custom_metrics_bucket_name}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + blob = await self._loop.run_in_executor( + self._executor, + self._metrics_bucket.blob, + f'{metrics_set.name}_custom_{self.session_uuid}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.custom_metrics_bucket_name}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + try: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Errors Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.errors_bucket_name} if not exists') + + self._errors_bucket = await self._loop.run_in_executor( + self._executor, + self.client.get_bucket, + f'{self.bucket_namespace}_errors' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Errors Metrics bucket at - Namespace: {self.bucket_namespace} - Bucket: {self.errors_bucket_name}') + + except Exception: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Setting Error Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.errors_bucket_name}') + + self._errors_bucket = await self._loop.run_in_executor( + self._executor, + self.client.create_bucket, + f'{self.bucket_namespace}_errors' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Set Errors Metrics bucket as - Namespace: {self.bucket_namespace} - Bucket: {self.errors_bucket_name}') + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.errors_bucket_name}') + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Errors Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + + blob = await self._loop.run_in_executor( + self._executor, + self.metrics_bucket.blob, + f'{metrics_set.name}_errors_{self.session_uuid}' + ) + + await self._loop.run_in_executor( + self._executor, + blob.upload_from_string, + json.dumps({ + 'metric_name': metrics_set.name, + 'metric_stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Errors Metrics to - Namespace: {self.bucket_namespace} - Bucket: {self.errors_bucket_name}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing Google Cloud connection') + await self._loop.run_in_executor( + self._executor, + self.client.close + ) + + self._executor.shutdown(wait=False, cancel_futures=True) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed Google Cloud connection') \ No newline at end of file diff --git a/hyperscale/reporting/types/google_cloud_storage/google_cloud_storage_config.py b/hyperscale/reporting/types/google_cloud_storage/google_cloud_storage_config.py new file mode 100644 index 0000000..6c52e74 --- /dev/null +++ b/hyperscale/reporting/types/google_cloud_storage/google_cloud_storage_config.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class GoogleCloudStorageConfig(BaseModel): + service_account_json_path: str + bucket_namespace: str + events_bucket: str='events' + metrics_bucket: str='metrics' + experiments_bucket: str='experiments' + streams_bucket: str='stages' + system_metrics_bucket: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.GCS \ No newline at end of file diff --git a/hyperscale/reporting/types/graphite/__init__.py b/hyperscale/reporting/types/graphite/__init__.py new file mode 100644 index 0000000..47f5ce5 --- /dev/null +++ b/hyperscale/reporting/types/graphite/__init__.py @@ -0,0 +1,2 @@ +from .graphite import Graphite +from .graphite_config import GraphiteConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/graphite/graphite.py b/hyperscale/reporting/types/graphite/graphite.py new file mode 100644 index 0000000..def7551 --- /dev/null +++ b/hyperscale/reporting/types/graphite/graphite.py @@ -0,0 +1,299 @@ + +import re +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +try: + from aio_statsd import GraphiteClient + + from hyperscale.reporting.types.statsd import StatsD + + from .graphite_config import GraphiteConfig + has_connector = True + +except Exception: + from hyperscale.reporting.types.empty import Empty as StatsD + GraphiteClient = None + GraphiteConfig = None + has_connector = False + + +class Graphite(StatsD): + + def __init__(self, config: GraphiteConfig) -> None: + super().__init__(config) + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.connection = GraphiteClient( + host=self.host, + port=self.port + ) + + self.statsd_type = 'Graphite' + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to {self.statsd_type}') + + metrics_sets: List[SessionMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Preparing Session System Metrics Set - {metrics_set.system_metrics_set_id}') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + for metrics_set in metrics_sets: + + for metric_field, metric_value in metrics_set.record.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metric Set - {metrics_set.name}:{metrics_set.group}:{metric_field}') + + self.connection.send_graphite( + f'{metrics_set.group}_{metrics_set.name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to {self.statsd_type}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to {self.statsd_type}') + + metrics_sets: List[SystemMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Preparing Stage System Metrics Set - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics.record) + + for metrics_set in metrics_sets: + + for metric_field, metric_value in metrics_set.record.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metric Set - {metrics_set.name}:{metrics_set.group}:{metric_field}') + + self.connection.send_graphite( + f'{metrics_set.group}_{metrics_set.group}_{metrics_set.name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to {self.statsd_type}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to {self.statsd_type}') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + + for metric_field, metric_value in group.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream Metric - {stage_name}:{group_name}:{metric_field}') + + self.connection.send_graphite( + f'{stage_name}_stream_{group_name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to {self.statsd_type}') + + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to {self.statsd_type}') + + for experiment in experiment_metrics.experiment_summaries: + + experiment_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment - {experiment.experiment_name}:{experiment_id}') + + for field, value in experiment.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment field - {experiment.experiment_name}:{field}') + + self.connection.send_graphite( + f'{experiment.experiment_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to {self.statsd_type}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to {self.statsd_type}') + + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variant - {variant.variant_name}:{variant_id}') + + for field, value in variant.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants field - {variant.variant_name}:{field}') + + self.connection.send_graphite( + f'{variant.variant_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to {self.statsd_type}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to {self.statsd_type}') + + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutation - {mutation.mutation_name}:{mutation_id}') + + for field, value in mutation.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutatio field - {mutation.mutation_name}:{field}') + + self.connection.send_graphite( + f'{mutation.mutation_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to {self.statsd_type}') + + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to {self.statsd_type}') + + for event in events: + self.connection.send_graphite(f'{event.name}_time', event.time) + + if event.success: + self.connection.send_graphite(f'{event.name}_success', 1) + + else: + self.connection.send_graphite(f'{event.name}_failed', 1) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to {self.statsd_type}') + + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for field, value in metrics_set.common_stats.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metric - {metrics_set.name}:common:{field}') + + self.connection.send_graphite( + f'{metrics_set.name}_common_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to {self.statsd_type}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to {self.statsd_type}') + + for metrics_set in metrics: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + + for metric_field, metric_value in group.stats.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metric - {metrics_set.name}:{group_name}:{metric_field}') + + self.connection.send_graphite( + f'{metrics_set.name}_{group_name}_{metric_field}', metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitted Metric - {metrics_set.name}:{group_name}:{metric_field}') + + for metric_field, metric_value in group.custom.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metric - {metrics_set.name}:{group_name}:{metric_field}') + + self.connection.send_graphite( + f'{metrics_set.name}_{group_name}_{metric_field}', metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to {self.statsd_type}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metric - {metrics_set.name}:custom:{custom_metric_name}') + + self.connection.send_graphite( + f'{metrics_set.name}_custom_{custom_metric_name}', custom_metric.metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to {self.statsd_type}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + error_message = re.sub( + '[^0-9a-zA-Z]+', + '_', + error.get( + 'message' + ).lower() + ) + + self.connection.send_graphite( + f'{metrics_set.name}_error_{error_message}', + error.get('count') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to {self.statsd_type}') \ No newline at end of file diff --git a/hyperscale/reporting/types/graphite/graphite_config.py b/hyperscale/reporting/types/graphite/graphite_config.py new file mode 100644 index 0000000..fd2cd49 --- /dev/null +++ b/hyperscale/reporting/types/graphite/graphite_config.py @@ -0,0 +1,12 @@ +from typing import Dict + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class GraphiteConfig(BaseModel): + host: str='localhost' + port: int=2003 + custom_fields: Dict[str, str]={} + reporter_type: ReporterTypes=ReporterTypes.Graphite \ No newline at end of file diff --git a/hyperscale/reporting/types/honeycomb/__init__.py b/hyperscale/reporting/types/honeycomb/__init__.py new file mode 100644 index 0000000..7d1d06a --- /dev/null +++ b/hyperscale/reporting/types/honeycomb/__init__.py @@ -0,0 +1,2 @@ +from .honeycomb import Honeycomb +from .honeycomb_config import HoneycombConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/honeycomb/honeycomb.py b/hyperscale/reporting/types/honeycomb/honeycomb.py new file mode 100644 index 0000000..7275a4e --- /dev/null +++ b/hyperscale/reporting/types/honeycomb/honeycomb.py @@ -0,0 +1,381 @@ +import asyncio +import functools +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsSet, +) + +from .honeycomb_config import HoneycombConfig + +try: + import libhoney + has_connector = True + +except Exception: + libhoney = None + has_connector = False + + +class Honeycomb: + + def __init__(self, config: HoneycombConfig) -> None: + self.api_key = config.api_key + self.dataset = config.dataset + self.client = None + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=True)) + self._loop = asyncio.get_event_loop() + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Honeycomb.IO') + self.client = await self._loop.run_in_executor( + None, + functools.partial( + libhoney.init, + writekey=self.api_key, + dataset=self.dataset + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Honeycomb.IO') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Honeycomb.IO') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + for metrics_set in metrics_sets: + + honeycomb_group_metric = libhoney.Event(data=metrics_set.record) + + await self._loop.run_in_executor( + self._executor, + honeycomb_group_metric.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Honeycomb.IO') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Honeycomb.IO') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + for metrics_set in metrics_sets: + + honeycomb_group_metric = libhoney.Event(data=metrics_set.record) + + await self._loop.run_in_executor( + self._executor, + honeycomb_group_metric.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Honeycomb.IO') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Honeycomb.IO') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + + group_metric = { + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + } + + honeycomb_group_metric = libhoney.Event(data=group_metric) + + await self._loop.run_in_executor( + self._executor, + honeycomb_group_metric.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Honeycomb.IO') + + async def submit_experiments(self, expoeriment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Honeycomb.IO') + + for experiment in expoeriment_metrics.experiment_summaries: + + honeycomb_event = libhoney.Event(data={ + **experiment.record + }) + + await self._loop.run_in_executor( + self._executor, + honeycomb_event.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Honeycomb.IO') + + async def submit_variants(self, expoeriment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Honeycomb.IO') + + for variant in expoeriment_metrics.variant_summaries: + + honeycomb_event = libhoney.Event(data={ + **variant.record + }) + + await self._loop.run_in_executor( + self._executor, + honeycomb_event.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Honeycomb.IO') + + async def submit_mutations(self, expoeriment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Honeycomb.IO') + + for mutation in expoeriment_metrics.mutation_summaries: + + honeycomb_event = libhoney.Event(data={ + **mutation.record + }) + + await self._loop.run_in_executor( + self._executor, + honeycomb_event.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Honeycomb.IO') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Honeycomb.IO') + + for event in events: + + honeycomb_event = libhoney.Event(data={ + **event.record + }) + + await self._loop.run_in_executor( + self._executor, + honeycomb_event.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Honeycomb.IO') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Honeycomb.IO') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + group_metric = { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + **metrics_set.common_stats + } + + honeycomb_group_metric = libhoney.Event(data=group_metric) + + await self._loop.run_in_executor( + self._executor, + honeycomb_group_metric.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Honeycomb.IO') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Honeycomb.IO') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + + metric_record = { + **group.stats, + **group.custom, + 'group': group_name + } + + honeycomb_event = libhoney.Event(data=metric_record) + + await self._loop.run_in_executor( + self._executor, + honeycomb_event.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Honeycomb.IO') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Honeycomb.IO') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custm_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + metric_record ={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + custm_metric_name: custom_metric.metric_value + } + + honeycomb_event = libhoney.Event(data=metric_record) + + await self._loop.run_in_executor( + self._executor, + honeycomb_event.send + ) + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Honeycomb.IO') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Honeycomb.IO') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + + error_event = libhoney.Event(data={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + + await self._loop.run_in_executor( + self._executor, + error_event.send + ) + + + await self._loop.run_in_executor( + self._executor, + libhoney.flush + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Honeycomb.IO') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connection Honeycomb.IO') + + await self._loop.run_in_executor( + self._executor, + libhoney.close + ) + + self._executor.shutdown(wait=False, cancel_futures=True) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connection Honeycomb.IO') + diff --git a/hyperscale/reporting/types/honeycomb/honeycomb_config.py b/hyperscale/reporting/types/honeycomb/honeycomb_config.py new file mode 100644 index 0000000..463241f --- /dev/null +++ b/hyperscale/reporting/types/honeycomb/honeycomb_config.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class HoneycombConfig(BaseModel): + api_key: str + dataset: str + reporter_type: ReporterTypes=ReporterTypes.Honeycomb \ No newline at end of file diff --git a/hyperscale/reporting/types/influxdb/__init__.py b/hyperscale/reporting/types/influxdb/__init__.py new file mode 100644 index 0000000..cb2d114 --- /dev/null +++ b/hyperscale/reporting/types/influxdb/__init__.py @@ -0,0 +1,2 @@ +from .influxdb import InfluxDB +from .influxdb_config import InfluxDBConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/influxdb/influxdb.py b/hyperscale/reporting/types/influxdb/influxdb.py new file mode 100644 index 0000000..964e9da --- /dev/null +++ b/hyperscale/reporting/types/influxdb/influxdb.py @@ -0,0 +1,435 @@ +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .influxdb_config import InfluxDBConfig + +try: + from influxdb_client import Point + from influxdb_client.client.influxdb_client_async import InfluxDBClientAsync + has_connector = True + +except Exception: + Point = None + InfluxDBClientAsync = None + has_connector = False + + +class InfluxDB: + + def __init__(self, config: InfluxDBConfig) -> None: + self.host = config.host + self.token = config.token + self.protocol = 'https' if config.secure else 'http' + self.organization = config.organization + self.connect_timeout = config.connect_timeout + + self.events_bucket_name = config.events_bucket + self.metrics_bucket_name = config.metrics_bucket + self.streams_bucket_name = config.streams_bucket + self.shared_metrics_bucket_name = f'{config.metrics_bucket}_shared' + + self.experiments_bucket_name = config.experiments_bucket + self.variants_bucket_name = f'{config.experiments_bucket}_variants' + self.mutations_bucket_name = f'{config.experiments_bucket}_mutations' + + self.errors_bucket_name = f'{config.metrics_bucket}_errors' + self.custom_bucket_name = f'{config.metrics_bucket}_custom' + + self.session_system_metrics_bucket_name = f'{config.system_metrics_bucket}_session' + self.stage_system_metrics_bucket_name = f'{config.system_metrics_bucket}_stage' + + self.client = None + self.write_api = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to InfluxDB at - {self.protocol}://{self.host} - for Organization - {self.organization}') + + self.client = InfluxDBClientAsync( + f'{self.protocol}://{self.host}', + token=self.token, + org=self.organization, + timeout=self.connect_timeout + ) + + self.write_api = self.client.write_api() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to InfluxDB at - {self.protocol}://{self.host} - for Organization - {self.organization}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Bucket - {self.session_system_metrics_bucket_name}') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + points = [] + for metrics_set in metrics_sets: + + point = Point(metrics_set.name) + tags = [ + ("name", metrics_set.name), + ("group", metrics_set.group) + ] + + for tag_name, tag_value in tags: + point.tag(tag_name, tag_value) + + for field, value in metrics_set.record.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}:{field}') + point.field(field, value) + + points.append(point) + + await self.write_api.write( + bucket=self.session_system_metrics_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Bucket - {self.session_system_metrics_bucket_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Bucket - {self.stage_system_metrics_bucket_name}') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + points = [] + for metrics_set in metrics_sets: + + point = Point(metrics_set.name) + tags = [ + ("name", metrics_set.name), + ("group", metrics_set.group) + ] + + for tag_name, tag_value in tags: + point.tag(tag_name, tag_value) + + for field, value in metrics_set.record.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}:{field}') + point.field(field, value) + + points.append(point) + + await self.write_api.write( + bucket=self.stage_system_metrics_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Bucket - {self.stage_system_metrics_bucket_name}') + + async def submit_streams(self, stage_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Bucket - {self.streams_bucket_name}') + + points = [] + for stage_name, stream in stage_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_metrics}:{stream.stream_set_id}') + + stream_name = f'{stage_name}_streams' + + for group_name, group in stream.grouped.items(): + point = Point(stream_name) + tags = [ + ("name", stream_name), + ("stage", stage_name), + ("group", group_name) + ] + + for tag_name, tag_value in tags: + point.tag(tag_name, tag_value) + + for field, value in group.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{group_name}:{field}') + point.field(field, value) + + points.append(point) + + await self.write_api.write( + bucket=self.streams_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Bucket - {self.streams_bucket_name}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Bucket - {self.experiments_bucket_name}') + + points = [] + for experiment in experiment_metrics.experiment_summaries: + point = Point(experiment.experiment_name) + + + for tag in experiment.tags: + point.tag(tag.name, tag.value) + + for field, value in experiment.stats.items(): + point.field(f'{experiment.experiment_name}_{field}', value) + + points.append(point) + + await self.write_api.write( + bucket=self.experiments_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Bucket - {self.experiments_bucket_name}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Bucket - {self.variants_bucket_name}') + + points = [] + for variant in experiment_metrics.variant_summaries: + point = Point(variant.variant_name) + + + for tag in variant.tags: + point.tag(tag.name, tag.value) + + for field, value in variant.stats.items(): + point.field(f'{variant.variant_name}_{field}', value) + + points.append(point) + + await self.write_api.write( + bucket=self.variants_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Bucket - {self.variants_bucket_name}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Bucket - {self.mutations_bucket_name}') + + points = [] + for mutation in experiment_metrics.mutation_summaries: + point = Point(mutation.mutation_name) + + + for tag in mutation.tags: + point.tag(tag.name, tag.value) + + for field, value in mutation.stats.items(): + point.field(f'{mutation.mutation_name}_{field}', value) + + points.append(point) + + await self.write_api.write( + bucket=self.mutations_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Bucket - {self.mutations_bucket_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Bucket - {self.events_bucket_name}') + + points = [] + for event in events: + point = Point(event.name) + + + for tag in event.tags: + point.tag(tag.name, tag.value) + + point.field(f'{event.name}_time', event.time) + + if event.success: + point.field(f'{event.name}_success', 1) + + else: + point.field(f'{event.name}_failed', 1) + + points.append(point) + + await self.write_api.write( + bucket=self.events_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Bucket - {self.events_bucket_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Bucket - {self.shared_metrics_bucket_name}') + + points = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + point = Point(metrics_set.name) + + for tag in metrics_set.tags: + point.tag(tag.name, tag.value) + + metric_record = { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + **metrics_set.common_stats + } + + for field, value in metric_record.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metric - {metrics_set.name}:common:{field}') + point.field(field, value) + + points.append(point) + + await self.write_api.write( + bucket=self.shared_metrics_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Bucket - {self.shared_metrics_bucket_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Bucket - {self.metrics_bucket_name}') + + points = [] + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + point = Point(metrics_set.name) + + for tag in metrics_set.tags: + point.tag(tag.name, tag.value) + + metric_record = { + **group.stats, + **group.custom, + 'group': group_name + } + + for field, value in metric_record.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metric - {metrics_set.name}:{group_name}:{field}') + point.field(field, value) + + points.append(point) + + await self.write_api.write( + bucket=self.metrics_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Bucket - {self.metrics_bucket_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + points = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + point = Point(f'{metrics_set.name}_{custom_metric_name}') + + for tag in metrics_set.tags: + point.tag(tag.name, tag.value) + + metric_record = { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + custom_metric_name: custom_metric.metric_value + } + + for field, value in metric_record.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metric - {metrics_set.name}:custom:{field}') + point.field(field, value) + + points.append(point) + + for point in points: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Bucket - {self.custom_bucket_name}_metrics') + await self.write_api.write( + bucket=self.custom_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Bucket - {self.custom_bucket_name}_metrics') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Errors Metrics to Bucket - {self.errors_bucket_name}') + + points = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Errors Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + point = Point(f'{metrics_set.name}_errors') + point.field( + error.get('message'), + error.get('count') + ) + + points.append(point) + + await self.write_api.write( + bucket=self.errors_bucket_name, + record=points + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Errors Metrics to Bucket - {self.errors_bucket_name}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connectiion to InfluxDB at - {self.protocol}://{self.host} - for Organization - {self.organization}') + + await self.client.close() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connectiion to InfluxDB at - {self.protocol}://{self.host} - for Organization - {self.organization}') diff --git a/hyperscale/reporting/types/influxdb/influxdb_config.py b/hyperscale/reporting/types/influxdb/influxdb_config.py new file mode 100644 index 0000000..a920087 --- /dev/null +++ b/hyperscale/reporting/types/influxdb/influxdb_config.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class InfluxDBConfig(BaseModel): + host: str='localhost:8086' + token: str + organization: str='hyperscale' + connect_timeout: int=10000 + events_bucket: str='events' + metrics_bucket: str='metrics' + experiments_bucket: str='experiments' + streams_bucket: str='streams' + system_metrics_bucket: str='system_metrics' + secure: bool=False + reporter_type: ReporterTypes=ReporterTypes.InfluxDB \ No newline at end of file diff --git a/hyperscale/reporting/types/json/__init__.py b/hyperscale/reporting/types/json/__init__.py new file mode 100644 index 0000000..3eabcd5 --- /dev/null +++ b/hyperscale/reporting/types/json/__init__.py @@ -0,0 +1,2 @@ +from .json import JSON +from .json_config import JSONConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/json/json.py b/hyperscale/reporting/types/json/json.py new file mode 100644 index 0000000..f405c22 --- /dev/null +++ b/hyperscale/reporting/types/json/json.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import asyncio +import functools +import json +import os +import re +import signal +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Dict, List, TextIO, Union + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet + +from .json_config import JSONConfig + +has_connector = True + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop, + events_file: TextIO +): + try: + events_file.close() + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class JSON: + + def __init__(self, config: JSONConfig) -> None: + self.events_filepath = config.events_filepath + self.metrics_filepath = config.metrics_filepath + self.experiments_filepath = config.experiments_filepath + self.streams_filepath = config.streams_filepath + self.system_metrics_filepath = config.system_metrics_filepath + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop: asyncio.AbstractEventLoop = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.events_file: TextIO = None + self.experiments_file: TextIO = None + self.metrics_file: TextIO = None + self.streams_file: TextIO = None + self.system_metrics_file: TextIO = None + + self.write_mode = 'w' if config.overwrite else 'a' + self.pattern = re.compile("_copy[0-9]+") + + async def connect(self): + self._loop = asyncio._get_running_loop() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping connect') + + original_filepath = Path(self.events_filepath) + + directory = original_filepath.parent + filename = original_filepath.stem + + events_file_timestamp =time.time() + self.events_filepath = os.path.join( + directory, + f'{filename}_{events_file_timestamp}.json' + ) + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + if self.system_metrics_file is None: + self.system_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.system_metrics_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.system_metrics_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Session System Metrics to file - {self.system_metrics_filepath}') + + metrics_sets: Dict[str, Dict[str, Union[int, float, str]]] = { + 'session': { + 'cpu': {}, + 'memory': {} + } + } + + for metrics_set in system_metrics_sets: + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + if metrics_sets.get(stage_name) is None: + metrics_sets[stage_name] = { + 'cpu': {}, + 'memory': {} + } + + for monitor_name, monitor_metrics in stage_cpu_metrics.items(): + metrics_sets[stage_name]['cpu'][monitor_name] = monitor_metrics.record + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_name, monitor_metrics in stage_memory_metrics.items(): + metrics_sets[stage_name]['memory'][monitor_name] = monitor_metrics.record + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets[stage_name]['mb_per_vu'] = stage_mb_per_vu_metrics.record + + for monitor_name, monitor_metrics in metrics_set.session_cpu_metrics.items(): + metrics_sets['session']['cpu'][monitor_name] = monitor_metrics.record + + for monitor_name, monitor_metrics in metrics_set.session_memory_metrics.items(): + metrics_sets['session']['memory'][monitor_name] = monitor_metrics.record + + await self._loop.run_in_executor( + self._executor, + functools.partial( + json.dump, + metrics_sets, + self.system_metrics_file, + indent=4 + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Session System Metrics to file - {self.system_metrics_filepath}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + pass + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + if self.streams_file is None: + self.streams_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.streams_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.streams_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Streams to file - {self.streams_filepath}') + + streams_data ={ + stream_name: stream_set.grouped for stream_name, stream_set in stream_metrics.items() + } + + await self._loop.run_in_executor( + self._executor, + functools.partial( + json.dump, + streams_data, + self.streams_file, + indent=4 + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Streams to file - {self.streams_filepath}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Experiments to file - {self.experiments_filepath}') + + if self.experiments_file is None: + self.experiments_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.experiments_filepath, + 'w' + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.experiments_file + ) + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + json.dump, + { + 'experiments': experiment_metrics.experiments, + 'variants': experiment_metrics.variants, + 'mutations': experiment_metrics.mutations + }, + self.experiments_file, + indent=4 + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Experiments to file - {self.experiments_filepath}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + pass + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + pass + + async def submit_events(self, events: List[BaseProcessedResult]): + + if self.events_file is None: + self.events_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.events_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.events_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Events to file - {self.events_filepath}') + + event_records = { + event.event_id: event.record for event in events + } + + if self.write_mode == 'a+': + try: + existing_data = await self._loop.run_in_executor( + self._executor, + functools.partial( + json.load, + self.events_file + ) + ) + + except Exception: + existing_data = {} + + event_records.update(existing_data) + + + await self._loop.run_in_executor( + self._executor, + functools.partial( + json.dump, + event_records, + self.events_file, + indent=4 + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Events to file - {self.events_filepath}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping Shared Metrics') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Metrics to file - {self.metrics_filepath}') + + records = {} + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + groups = {} + for group_name, group in metrics_set.groups.items(): + groups[group_name] = group.record + + groups['custom'] = { + metric.metric_shortname: metric.metric_value for metric in metrics_set.custom_metrics.values() + } + + records[metrics_set.name] = { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'errors': metrics_set.errors, + **metrics_set.common_stats, + 'groups': groups + } + + if self.metrics_file is None: + self.metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.metrics_filepath, + self.write_mode + ) + ) + + await self._loop.run_in_executor( + self._executor, + functools.partial( + json.dump, + records, + self.metrics_file, + indent=4 + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Metrics to file - {self.metrics_filepath}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping Custom Metrics') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping Error Metrics') + + async def close(self): + + if self.events_file: + await self._loop.run_in_executor( + self._executor, + self.events_file.close + ) + + if self.experiments_file: + await self._loop.run_in_executor( + self._executor, + self.experiments_file.close + ) + + if self.metrics_file: + await self._loop.run_in_executor( + self._executor, + self.metrics_file.close + ) + + if self.streams_file: + await self._loop.run_in_executor( + self._executor, + self.streams_file.close + ) + + if self.system_metrics_file: + await self._loop.run_in_executor( + self._executor, + self.system_metrics_file.close + ) + + self._executor.shutdown(wait=False, cancel_futures=True) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') \ No newline at end of file diff --git a/hyperscale/reporting/types/json/json_config.py b/hyperscale/reporting/types/json/json_config.py new file mode 100644 index 0000000..fc3d2d9 --- /dev/null +++ b/hyperscale/reporting/types/json/json_config.py @@ -0,0 +1,30 @@ +import os + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class JSONConfig(BaseModel): + events_filepath: str=os.path.join( + os.getcwd(), + 'events.json' + ) + metrics_filepath: str=os.path.join( + os.getcwd(), + 'metrics.json' + ) + experiments_filepath: str=os.path.join( + os.getcwd(), + 'experiments.json' + ) + streams_filepath: str=os.path.join( + os.getcwd(), + 'streams.json' + ) + system_metrics_filepath: str=os.path.join( + os.getcwd(), + 'system_metrics.json' + ) + overwrite: bool=True + reporter_type: ReporterTypes=ReporterTypes.JSON \ No newline at end of file diff --git a/hyperscale/reporting/types/kafka/__init__.py b/hyperscale/reporting/types/kafka/__init__.py new file mode 100644 index 0000000..90d6990 --- /dev/null +++ b/hyperscale/reporting/types/kafka/__init__.py @@ -0,0 +1,2 @@ +from .kafka import Kafka +from .kafka_config import KafkaConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/kafka/kafka.py b/hyperscale/reporting/types/kafka/kafka.py new file mode 100644 index 0000000..dd78ecb --- /dev/null +++ b/hyperscale/reporting/types/kafka/kafka.py @@ -0,0 +1,427 @@ +import json +import uuid +from typing import Any, Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .kafka_config import KafkaConfig + +try: + + from aiokafka import AIOKafkaProducer + has_connector = True + +except Exception: + AIOKafkaProducer = None + has_connector = False + + +class Kafka: + + def __init__(self, config: KafkaConfig) -> None: + self.host = config.host + self.client_id = config.client_id + + self.events_topic = config.events_topic + self.metrics_topic = config.metrics_topic + self.streams_topic = config.streams_topic + + self.custom_metrics_topic = f'{config.metrics_topic}_custom' + self.shared_metrics_topic = f'{config.metrics_topic}_shared' + self.errors_topic = f'{config.metrics_topic}_errors' + + self.experiments_topic = config.experiments_topic + self.variants_topic = f'{config.experiments_topic}_variants' + self.mutations_topic = f'{config.experiments_topic}_metrics' + + self.session_system_metrics_topic= f'{config.system_metrics_topic}_session' + self.stage_system_metrics_topic = f'{config.system_metrics_topic}_stage' + + self.events_partition = config.events_partition + self.metrics_partition = config.metrics_partition + self.experiments_partition = config.experiments_partition + self.streams_partition = config.streams_partition + + self.system_metrics_partition = config.system_metrics_partition + + self.shared_metrics_partition = config.metrics_partition + self.errors_partition = config.metrics_partition + self.custom_metrics_partition = config.metrics_partition + + self.compression_type = config.compression_type + self.timeout = config.timeout + self.enable_idempotence = config.idempotent or True + self.options: Dict[str, Any] = config.options or {} + self._producer = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Kafka at - {self.host}') + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - Compression Type: {self.compression_type}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - Connection Timeout: {self.timeout}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - Idempotent: {self.enable_idempotence}') + + for option_name, option in self.options.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Using Kafka Options - {option_name.capitalize()}: {option}') + + + self._producer = AIOKafkaProducer( + bootstrap_servers=self.host, + client_id=self.client_id, + compression_type=self.compression_type, + request_timeout_ms=self.timeout, + enable_idempotence=self.enable_idempotence, + **self.options + ) + + await self._producer.start() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Kafka at - {self.host}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metric to Topic - {self.session_system_metrics_topic} - Partition - {self.system_metrics_partition}') + + batch = self._producer.create_batch() + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + for metric_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metric - {metric_set.name}:{metric_set.group}') + + batch.append( + value=json.dumps( + metric_set.record + ).encode('utf-8'), + timestamp=None, + key=bytes(f'{metric_set.name}_{metric_set.group}', 'utf') + ) + + await self._producer.send_batch( + batch, + self.session_system_metrics_topic, + partition=self.system_metrics_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metric to Topic - {self.session_system_metrics_topic} - Partition - {self.system_metrics_partition}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metric to Topic - {self.stage_system_metrics_topic} - Partition - {self.system_metrics_partition}') + + batch = self._producer.create_batch() + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + for metric_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metric - {metric_set.name}:{metric_set.group}') + + batch.append( + value=json.dumps( + metric_set.record + ).encode('utf-8'), + timestamp=None, + key=bytes(f'{metric_set.name}_{metric_set.group}', 'utf') + ) + + await self._producer.send_batch( + batch, + self.stage_system_metrics_topic, + partition=self.system_metrics_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metric to Topic - {self.stage_system_metrics_topic} - Partition - {self.system_metrics_partition}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Topic - {self.streams_topic} - Partition - {self.streams_partition}') + + batch = self._producer.create_batch() + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + batch.append( + value=json.dumps( + { + **group, + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name + } + ).encode('utf-8'), + timestamp=None, + key=bytes(f'{stage_name}_{group_name}', 'utf') + ) + + await self._producer.send_batch( + batch, + self.streams_topic, + partition=self.streams_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Topic - {self.streams_topic} - Partition - {self.streams_partition}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Topic - {self.experiments_topic} - Partition - {self.experiments_partition}') + + batch = self._producer.create_batch() + for experiment in experiment_metrics.experiment_summaries: + + batch.append( + value=json.dumps( + experiment.record + ).encode('utf-8'), + timestamp=None, + key=bytes(experiment.experiment_name, 'utf') + ) + + await self._producer.send_batch( + batch, + self.experiments_topic, + partition=self.experiments_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Topic - {self.experiments_topic} - Partition - {self.experiments_partition}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Topic - {self.variants_topic} - Partition - {self.experiments_partition}') + + batch = self._producer.create_batch() + for variant in experiment_metrics.variant_summaries: + + batch.append( + value=json.dumps( + variant.record + ).encode('utf-8'), + timestamp=None, + key=bytes(variant.variant_name, 'utf') + ) + + await self._producer.send_batch( + batch, + self.variants_topic, + partition=self.experiments_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Topic - {self.variants_topic} - Partition - {self.experiments_partition}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Topic - {self.mutations_topic} - Partition - {self.experiments_partition}') + + batch = self._producer.create_batch() + for mutation in experiment_metrics.mutation_summaries: + + batch.append( + value=json.dumps( + mutation.record + ).encode('utf-8'), + timestamp=None, + key=bytes(mutation.mutation_name, 'utf') + ) + + await self._producer.send_batch( + batch, + self.mutations_topic, + partition=self.events_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Topic - {self.mutations_topic} - Partition - {self.experiments_partition}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Topic - {self.events_topic} - Partition - {self.events_partition}') + + batch = self._producer.create_batch() + for event in events: + + batch.append( + value=json.dumps( + event.record + ).encode('utf-8'), + timestamp=None, + key=bytes(event.name, 'utf') + ) + + await self._producer.send_batch( + batch, + self.events_topic, + partition=self.events_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Topic - {self.events_topic} - Partition - {self.events_partition}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Topic - {self.shared_metrics_topic} - Partition - {self.shared_metrics_partition}') + + batch = self._producer.create_batch() + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + batch.append( + value=json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }).encode('utf-8'), + timestamp=None, + key=bytes(metrics_set.name, 'utf') + ) + + await self._producer.send_batch( + batch, + self.shared_metrics_topic, + partition=self.shared_metrics_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Topic - {self.shared_metrics_topic} - Partition - {self.shared_metrics_partition}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Topic - {self.metrics_topic} - Partition - {self.metrics_partition}') + + batch = self._producer.create_batch() + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + batch.append( + value=json.dumps( + { + **group.record, + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': group_name + } + ).encode('utf-8'), + timestamp=None, + key=bytes(f'{metrics_set.name}_{group_name}', 'utf') + ) + + await self._producer.send_batch( + batch, + self.metrics_topic, + partition=self.metrics_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Topic - {self.metrics_topic} - Partition - {self.metrics_partition}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + batch = self._producer.create_batch() + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Customm Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custom_metic_name, custom_metric in metrics_set.custom_metrics.items(): + + batch.append( + value=json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + custom_metic_name: custom_metric.metric_value + }).encode('utf-8'), + timestamp=None, + key=bytes(f'{metrics_set.name}_{custom_metic_name}', 'utf') + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Customm Metrics to Topic - {self.custom_metrics_topic} - Partition - {self.custom_metrics_partition}') + await self._producer.send_batch( + batch, + self.custom_metrics_topic, + partition=self.custom_metrics_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Customm Metrics to Topic - {self.custom_metrics_topic} - Partition - {self.custom_metrics_partition}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Topic - {self.metrics_topic} - Partition - {self.metrics_partition}') + + batch = self._producer.create_batch() + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + batch.append( + value=json.dumps( + { + 'metric_name': metrics_set.name, + 'metric_stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + } + ).encode('utf-8'), + timestamp=None, + key=bytes(metrics_set.name, 'utf') + ) + + await self._producer.send_batch( + batch, + self.errors_topic, + partition=self.errors_partition + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Topic - {self.metrics_topic} - Partition - {self.metrics_partition}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connection to Kafka at - {self.host}') + + await self._producer.stop() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connection to Kafka at - {self.host}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') \ No newline at end of file diff --git a/hyperscale/reporting/types/kafka/kafka_config.py b/hyperscale/reporting/types/kafka/kafka_config.py new file mode 100644 index 0000000..6efaa06 --- /dev/null +++ b/hyperscale/reporting/types/kafka/kafka_config.py @@ -0,0 +1,25 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class KafkaConfig(BaseModel): + host: str='localhost:9092' + client_id: str='hyperscale' + events_topic: str='events' + metrics_topic: str='metrics' + experiments_topic: str='experiments' + streams_topic: str='streams' + system_metrics_topic: str='system_metrics' + events_partition: int=0 + metrics_partition: int=0 + experiments_partition: int=0 + streams_partition: int=0 + system_metrics_partition: int=0 + compression_type: Optional[str] + timeout: int=1000 + idempotent: bool=True + options: Dict[str, Any]={} + reporter_type: ReporterTypes=ReporterTypes.Kafka \ No newline at end of file diff --git a/hyperscale/reporting/types/mongodb/__init__.py b/hyperscale/reporting/types/mongodb/__init__.py new file mode 100644 index 0000000..f6c0cf6 --- /dev/null +++ b/hyperscale/reporting/types/mongodb/__init__.py @@ -0,0 +1,2 @@ +from .mongodb import MongoDB +from .mongodb_config import MongoDBConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/mongodb/mongodb.py b/hyperscale/reporting/types/mongodb/mongodb.py new file mode 100644 index 0000000..db784cd --- /dev/null +++ b/hyperscale/reporting/types/mongodb/mongodb.py @@ -0,0 +1,236 @@ +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .mongodb_config import MongoDBConfig + +try: + from motor.motor_asyncio import AsyncIOMotorClient + has_connector = True + +except Exception: + AsyncIOMotorClient = None + has_connector = False + + + +class MongoDB: + + def __init__(self, config: MongoDBConfig) -> None: + self.host = config.host + self.username = config.username + self.password = config.password + self.database_name = config.database + + self.events_collection = config.events_collection + self.metrics_collection = config.metrics_collection + self.streams_collection = config.streams_collection + + self.experiments_collection = config.experiments_collection + self.variants_collection = f'{config.experiments_collection}_variants' + self.mutations_collection = f'{config.experiments_collection}_mutations' + + self.session_system_metrics_collection = f'{config.system_metrics_collection}_session' + self.stage_system_metrics_collection = f'{config.system_metrics_collection}_stage' + + self.shared_metrics_collection = f'{self.metrics_collection}_common' + self.errors_collection = f'{self.metrics_collection}_errors' + self.custom_metrics_collection = f'{self.metrics_collection}_custom' + + self.connection: AsyncIOMotorClient = None + self.database = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to MongoDB instance at - {self.host} - Database: {self.database_name}') + + if self.username and self.password: + connection_string = f'mongodb://{self.username}:{self.password}@{self.host}/{self.database_name}' + + else: + connection_string = f'mongodb://{self.host}/{self.database_name}' + + self.connection = AsyncIOMotorClient(connection_string) + self.database = self.connection[self.database_name] + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to MongoDB instance at - {self.host} - Database: {self.database_name}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Bucket - {self.session_system_metrics_collection}') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + await self.database[self.metrics_collection].insert_many(metrics_sets) + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Bucket - {self.session_system_metrics_collection}') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics.record) + + await self.database[self.metrics_collection].insert_many(metrics_sets) + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Bucket - {self.streams_collection}') + + records = [] + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + records.append({ + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + }) + + await self.database[self.metrics_collection].insert_many(records) + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Collection - {self.experiments_collection}') + await self.database[self.events_collection].insert_many(experiment_metrics.experiments) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Collection - {self.experiments_collection}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Collection - {self.variants_collection}') + await self.database[self.events_collection].insert_many(experiment_metrics.variants) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Collection - {self.variants_collection}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Collection - {self.mutations_collection}') + await self.database[self.events_collection].insert_many(experiment_metrics.mutations) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Collection - {self.mutations_collection}') + + async def submit_events(self, events: List[BaseProcessedResult]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Collection - {self.events_collection}') + await self.database[self.events_collection].insert_many( + [event.record for event in events] + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Collection - {self.events_collection}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Bucket - {self.shared_metrics_collection}') + await self.database[self.shared_metrics_collection].insert_many([ + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + } for metrics_set in metrics_sets + ]) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Bucket - {self.shared_metrics_collection}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Bucket - {self.metrics_collection}') + + records = [] + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + records.append({ + 'group': group_name, + **group.record + }) + + await self.database[self.metrics_collection].insert_many(records) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Bucket - {self.metrics_collection}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + records = [] + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + records.append({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Bucket - {self.custom_metrics_collection}') + + await self.database[self.custom_metrics_collection].insert_many(records) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Bucket - {self.custom_metrics_collection}') + + async def submit_errors(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Bucket - {self.errors_collection}') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self.database[self.errors_collection].insert_many([ + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + } for error in metrics_set.errors + ]) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Bucket - {self.errors_collection}') + + async def close(self): + await self.connection.close() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') \ No newline at end of file diff --git a/hyperscale/reporting/types/mongodb/mongodb_config.py b/hyperscale/reporting/types/mongodb/mongodb_config.py new file mode 100644 index 0000000..5021d1f --- /dev/null +++ b/hyperscale/reporting/types/mongodb/mongodb_config.py @@ -0,0 +1,18 @@ +from typing import Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class MongoDBConfig(BaseModel): + host: str='localhost:27017' + username: Optional[str] + password: Optional[str] + database: str='hyperscale' + events_collection: str='events' + metrics_collection: str='metrics' + experiments_collection: str='experiment' + streams_collection: str='streams' + system_metrics_collection: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.MongoDB \ No newline at end of file diff --git a/hyperscale/reporting/types/mysql/__init__.py b/hyperscale/reporting/types/mysql/__init__.py new file mode 100644 index 0000000..cf3b1ed --- /dev/null +++ b/hyperscale/reporting/types/mysql/__init__.py @@ -0,0 +1,2 @@ +from .mysql import MySQL +from .mysql_config import MySQLConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/mysql/mysql.py b/hyperscale/reporting/types/mysql/mysql.py new file mode 100644 index 0000000..d65c031 --- /dev/null +++ b/hyperscale/reporting/types/mysql/mysql.py @@ -0,0 +1,673 @@ +import uuid +import warnings +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .mysql_config import MySQLConfig + +try: + # Aiomysql will raise warnings if a table exists despite us + # explicitly passing "IF NOT EXISTS", so we're going to + # ignore them. + import aiomysql + import sqlalchemy as sa + warnings.filterwarnings('ignore', category=aiomysql.Warning) + + from aiomysql.sa import SAConnection, create_engine + from sqlalchemy.schema import CreateTable + + has_connector = True + +except Exception: + sqlalchemy = object + sa = object + create_engine = object + CreateTable = object + SAConnection = object + OperationalError = object + has_connector = object + + + +class MySQL: + + def __init__(self, config: MySQLConfig) -> None: + self.host = config.host + self.database = config.database + self.username = config.username + self.password = config.password + + self.events_table_name = config.events_table + self.metrics_table_name = config.metrics_table + self.streams_table_name = config.streams_table + + self.experiments_table_name = config.experiments_table + self.variants_table_name = f'{config.experiments_table}_variants' + self.mutations_table_name = f'{config.experiments_table}_mutations' + + self.shared_metrics_table_name = f'{config.metrics_table}_shared' + self.errors_table_name = f'{config.metrics_table}_errors' + self.custom_metrics_table_name = f'{config.metrics_table}_custom' + + self.session_system_metrics_table_name = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_name = f'{config.system_metrics_table}_stage' + + self._events_table = None + self._metrics_table = None + self._streams_table = None + + self._experiments_table = None + self._variants_table = None + self._mutations_table = None + + self._shared_metrics_table = None + self._custom_metrics_table = None + self._errors_table = None + + self._session_system_metrics_table = None + self._stage_system_metrics_table = None + + self.metadata = sa.MetaData() + self._engine = None + self._connection = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.metric_types_map = { + MetricType.COUNT: lambda field_name: sa.Column(field_name, sa.Integer), + MetricType.DISTRIBUTION: lambda field_name: sa.Column(field_name, sa.Float), + MetricType.SAMPLE: lambda field_name: sa.Column(field_name, sa.Float), + MetricType.RATE: lambda field_name: sa.Column(field_name, sa.Float), + } + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to MySQL instance at - {self.host} - Database: {self.database}') + self._engine = await create_engine( + db=self.database, + host=self.host, + user=self.username, + password=self.password + ) + + self._connection: SAConnection = await self._engine.acquire() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to MySQL instance at - {self.host} - Database: {self.database}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async with self._connection.begin() as transaction: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Initiating transaction') + + if self._session_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metrics table - {self.session_system_metrics_table_name} - if not exists') + + session_system_metrics_table = sa.Table( + self.session_system_metrics_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('group', sa.TEXT()), + sa.Column('median', sa.FLOAT), + sa.Column('mean', sa.FLOAT), + sa.Column('variance', sa.FLOAT), + sa.Column('stdev', sa.FLOAT), + sa.Column('minimum', sa.FLOAT), + sa.Column('maximum', sa.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + session_system_metrics_table.append_column( + sa.Column(f'quantile_{quantile}th', sa.FLOAT) + ) + + await self._connection.execute( + CreateTable( + session_system_metrics_table, + if_not_exists=True + ) + ) + + self._session_system_metrics_table = session_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Session System Metrics table - {self.session_system_metrics_table_name}') + + rows: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + await self._connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Initiating transaction') + if self._stage_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metrics table - {self.stage_system_metrics_table_name} - if not exists') + + stage_system_metrics_table = sa.Table( + self.stage_system_metrics_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('stage', sa.VARCHAR(255)), + sa.Column('group', sa.TEXT()), + sa.Column('median', sa.FLOAT), + sa.Column('mean', sa.FLOAT), + sa.Column('variance', sa.FLOAT), + sa.Column('stdev', sa.FLOAT), + sa.Column('minimum', sa.FLOAT), + sa.Column('maximum', sa.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + stage_system_metrics_table.append_column( + sa.Column(f'quantile_{quantile}th', sa.FLOAT) + ) + + await self._connection.execute( + CreateTable( + stage_system_metrics_table, + if_not_exists=True + ) + ) + + self._stage_system_metrics_table = stage_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Stage System Metrics table - {self.stage_system_metrics_table_name}') + + rows: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + await self._connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Initiating transaction') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + if self._streams_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Streams table - {self.streams_table_name} - if not exists') + + stream_table = sa.Table( + self.streams_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('stage', sa.VARCHAR(255)), + sa.Column('group', sa.TEXT()), + sa.Column('median', sa.FLOAT), + sa.Column('mean', sa.FLOAT), + sa.Column('variance', sa.FLOAT), + sa.Column('stdev', sa.FLOAT), + sa.Column('minimum', sa.FLOAT), + sa.Column('maximum', sa.FLOAT) + ) + + for quantile in stream.quantiles: + stream_table.append_column( + sa.Column(f'quantile_{quantile}th', sa.FLOAT) + ) + + await self._connection.execute( + CreateTable( + stream_table, + if_not_exists=True + ) + ) + + self._streams_table = stream_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Streams table - {self.streams_table_name}') + + for group_name, group in stream.grouped.items(): + await self._connection.execute( + self._streams_table.insert(values={ + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Table - {self.streams_table_name}') + + async def submit_experiments(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Initiating transaction') + + for experiment in experiments_metrics.experiment_summaries: + + if self._experiments_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments table - {self.experiments_table_name} - if not exists') + + experiments_table = sa.Table( + self.experiments_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('experiment_name', sa.VARCHAR(255)), + sa.Column('experiment_randomized', sa.Boolean), + sa.Column('experiment_completed', sa.BIGINT), + sa.Column('experiment_succeeded', sa.BIGINT), + sa.Column('experiment_failed', sa.BIGINT), + sa.Column('experiment_median_aps', sa.FLOAT), + ) + + await self._connection.execute(CreateTable(experiments_table, if_not_exists=True)) + + self._experiments_table = experiments_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Experiments table - {self.experiments_table_name}') + + await self._connection.execute(self._experiments_table.insert().values(**experiment.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Table - {self.experiments_table_name}') + + async def submit_variants(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Initiating transaction') + + for variant in experiments_metrics.variant_summaries: + + if self._variants_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variants table - {self.variants_table_name} - if not exists') + + variants_table = sa.Table( + self.variants_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('variant_name', sa.VARCHAR(255)), + sa.Column('variant_experiment', sa.VARCHAR(255)), + sa.Column('variant_weight', sa.FLOAT), + sa.Column('variant_distribution', sa.VARCHAR(255)), + sa.Column('variant_distribution_interval', sa.FLOAT), + sa.Column('variant_completed', sa.BIGINT), + sa.Column('variant_succeeded', sa.BIGINT), + sa.Column('variant_failed', sa.BIGINT), + sa.Column('variant_actions_per_second', sa.FLOAT), + sa.Column('variant_ratio_completed', sa.FLOAT), + sa.Column('variant_ratio_succeeded', sa.FLOAT), + sa.Column('variant_ratio_failed', sa.FLOAT), + sa.Column('variant_ratio_aps', sa.FLOAT), + ) + + await self._connection.execute(CreateTable(variants_table, if_not_exists=True)) + + self._variants_table = variants_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Variants table - {self.variants_table_name}') + + await self._connection.execute(self._variants_table.insert().values(**variant.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Table - {self.variants_table_name}') + + async def submit_mutations(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Initiating transaction') + + for mutation in experiments_metrics.mutation_summaries: + + if self._mutations_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutations table - {self.mutations_table_name} - if not exists') + + mutations_table = sa.Table( + self.mutations_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('mutation_name', sa.VARCHAR(255)), + sa.Column('mutation_experiment_name', sa.VARCHAR(255)), + sa.Column('mutation_variant_name', sa.VARCHAR(255)), + sa.Column('mutation_chance', sa.FLOAT), + sa.Column('mutation_targets', sa.VARCHAR(8192)), + sa.Column('mutation_type', sa.VARCHAR(255)), + ) + + await self._connection.execute(CreateTable(mutations_table, if_not_exists=True)) + + self._mutations_table = mutations_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Mutations table - {self.mutations_table_name}') + + await self._connection.execute(self._mutations_table.insert().values(**mutation.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Table - {self.mutations_table_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Initiating transaction') + + for event in events: + + if self._events_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Events table - {self.events_table_name} - if not exists') + + events_table = sa.Table( + self.events_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('stage', sa.VARCHAR(255)), + sa.Column('time', sa.Float), + sa.Column('succeeded', sa.Boolean), + ) + + + await self._connection.execute(CreateTable(events_table, if_not_exists=True)) + + self._events_table = events_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Events table - {self.events_table_name}') + + await self._connection.execute(self._events_table.insert().values(**event.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Table - {self.events_table_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name}') + + if self._shared_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metrics table - {self.shared_metrics_table_name} - if not exists') + + stage_metrics_table = sa.Table( + self.shared_metrics_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('stage', sa.VARCHAR(255)), + sa.Column('group', sa.VARCHAR(255)), + sa.Column('total', sa.BIGINT), + sa.Column('succeeded', sa.BIGINT), + sa.Column('failed', sa.BIGINT), + sa.Column('actions_per_second', sa.FLOAT) + ) + + await self._connection.execute(CreateTable(stage_metrics_table, if_not_exists=True)) + self._shared_metrics_table = stage_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Shared Metrics table - {self.shared_metrics_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Initiating transaction') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self._connection.execute( + self._shared_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Table - {self.shared_metrics_table_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Initiating transaction') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metrics table - {self.metrics_table_name} - if not exists') + + metrics_table = sa.Table( + self.metrics_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('stage', sa.VARCHAR(255)), + sa.Column('group', sa.TEXT()), + sa.Column('median', sa.FLOAT), + sa.Column('mean', sa.FLOAT), + sa.Column('variance', sa.FLOAT), + sa.Column('stdev', sa.FLOAT), + sa.Column('minimum', sa.FLOAT), + sa.Column('maximum', sa.FLOAT) + ) + + for quantile in metrics_set.quantiles: + metrics_table.append_column( + sa.Column(f'{quantile}', sa.FLOAT) + ) + + for custom_field_name, sql_alchemy_type in metrics_set.custom_schemas: + metrics_table.append_column(custom_field_name, sql_alchemy_type) + + await self._connection.execute(CreateTable(metrics_table, if_not_exists=True)) + + self._metrics_table = metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Metrics table - {self.metrics_table_name}') + + for group_name, group in metrics_set.groups.items(): + await self._connection.execute( + self._metrics_table.insert(values={ + 'group': group_name, + **group.record + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Table - {self.metrics_table_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + if self._custom_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Custom Metrics table - {self.custom_metrics_table_name} - if not exists') + + custom_metrics_table = sa.Table( + self.custom_metrics_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('stage', sa.VARCHAR(255)), + sa.Column('group', sa.VARCHAR(255)) + ) + + for metrics_set in metrics_sets: + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + custom_metrics_table.append_column( + self.metric_types_map.get( + custom_metric.metric_type, + lambda field_name: sa.Column(field_name, sa.Float) + )(custom_metric_name) + ) + + await self._connection.execute( + CreateTable(custom_metrics_table, if_not_exists=True) + ) + + self._custom_metrics_table= custom_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Custom Metrics table - {self.custom_metrics_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics - Initiating transaction') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to table - {self.custom_metrics_table_name}') + + await self._connection.execute( + self._custom_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to table - {self.custom_metrics_table_name}') + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics - Transaction committed') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Initiating transaction') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._errors_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Error Metrics table - {self.errors_table_name} - if not exists') + + errors_table = sa.Table( + self.errors_table_name, + self.metadata, + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('name', sa.VARCHAR(255)), + sa.Column('stage', sa.VARCHAR(255)), + sa.Column('error_message', sa.TEXT), + sa.Column('error_count', sa.Integer) + ) + + await self._connection.execute( + CreateTable(errors_table, if_not_exists=True) + ) + + self._errors_table = errors_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Error Metrics table - {self.errors_table_name}') + + for error in metrics_set.errors: + await self._connection.execute(self._errors_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_messages': error.get('message'), + 'error_count': error.get('count') + })) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Table - {self.errors_table_name}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connectiion to MySQL at - {self.host}') + + await self._connection.close() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connectiion to MySQL at - {self.host}') + + + + + + diff --git a/hyperscale/reporting/types/mysql/mysql_config.py b/hyperscale/reporting/types/mysql/mysql_config.py new file mode 100644 index 0000000..6bb1dcd --- /dev/null +++ b/hyperscale/reporting/types/mysql/mysql_config.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class MySQLConfig(BaseModel): + host: str='127.0.0.1' + database: str + username: str + password: str + events_table: str='events' + metrics_table: str='metrics' + experiments_table: str='experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.MySQL + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/reporting/types/netdata/__init__.py b/hyperscale/reporting/types/netdata/__init__.py new file mode 100644 index 0000000..a758035 --- /dev/null +++ b/hyperscale/reporting/types/netdata/__init__.py @@ -0,0 +1,2 @@ +from .netdata import Netdata +from .netdata_config import NetdataConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/netdata/netdata.py b/hyperscale/reporting/types/netdata/netdata.py new file mode 100644 index 0000000..c3d4c01 --- /dev/null +++ b/hyperscale/reporting/types/netdata/netdata.py @@ -0,0 +1,17 @@ +try: + from hyperscale.reporting.types.statsd.statsd import StatsD + + from .netdata_config import NetdataConfig + has_connector = True + +except Exception: + from hyperscale.reporting.types.empty import Empty as StatsD + NetdataConfig = None + has_connector = False + + +class Netdata(StatsD): + + def __init__(self, config: NetdataConfig) -> None: + super(Netdata, self).__init__(config) + self.statsd_type = 'Netdata' \ No newline at end of file diff --git a/hyperscale/reporting/types/netdata/netdata_config.py b/hyperscale/reporting/types/netdata/netdata_config.py new file mode 100644 index 0000000..ab03c86 --- /dev/null +++ b/hyperscale/reporting/types/netdata/netdata_config.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class NetdataConfig(BaseModel): + host: str='localhost' + port: int=8125 + reporter_type: ReporterTypes=ReporterTypes.Netdata \ No newline at end of file diff --git a/hyperscale/reporting/types/newrelic/__init__.py b/hyperscale/reporting/types/newrelic/__init__.py new file mode 100644 index 0000000..bd84118 --- /dev/null +++ b/hyperscale/reporting/types/newrelic/__init__.py @@ -0,0 +1,2 @@ +from .newrelic import NewRelic +from .newrelic_config import NewRelicConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/newrelic/newrelic.py b/hyperscale/reporting/types/newrelic/newrelic.py new file mode 100644 index 0000000..10e128e --- /dev/null +++ b/hyperscale/reporting/types/newrelic/newrelic.py @@ -0,0 +1,345 @@ +import asyncio +import functools +import re +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +try: + import newrelic.agent + + from .newrelic_config import NewRelicConfig + has_connector=True + +except Exception: + newrelic = None + NewRelicConfig = None + has_connector=False + + +class NewRelic: + + def __init__(self, config: NewRelicConfig) -> None: + self.config_path = config.config_path + self.environment = config.environment + self.registration_timeout = config.registration_timeout + self.shutdown_timeout = config.shutdown_timeout or 60 + self.newrelic_application_name = config.newrelic_application_name + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self.client = None + self._loop = asyncio.get_event_loop() + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to NewRelic - Using config at path - {self.config_path} - Environment - {self.environment}') + + await self._loop.run_in_executor( + self._executor, + functools.partial( + newrelic.agent.initialize, + config_file=self.config_path, + environment=self.environment + ) + ) + + self.client = await self._loop.run_in_executor( + self._executor, + functools.partial( + newrelic.agent.register_application, + name=self.newrelic_application_name, + timeout=self.registration_timeout + ) + ) + + await asyncio.sleep(1) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to NewRelic - Using config at path - {self.config_path} - Environment - {self.environment}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to NewRelic') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + for field, value in metrics_set.record.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}:{field}') + + record_name = f'{metrics_set.name}_{metrics_set.group}_{field}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_metric, + record_name, + value + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to NewRelic') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to NewRelic') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + for field, value in metrics_set.record.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}:{field}') + + record_name = f'{metrics_set.name}_{metrics_set.group}_{field}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_metric, + record_name, + value + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to NewRelic') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to NewRelic') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + for field, value in group.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{group_name}:{field}') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_metric, + f'{stage_name}_{group_name}_{field}', + value + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to NewRelic') + + async def submit_experiments(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to NewRelic') + + for experiment in experiments_metrics.experiment_summaries: + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_event, + experiment.experiment_name, + experiment.record + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to NewRelic') + + async def submit_variants(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to NewRelic') + + for variant in experiments_metrics.variant_summaries: + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_event, + variant.variant_name, + variant.record + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to NewRelic') + + async def submit_mutations(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to NewRelic') + + for mutation in experiments_metrics.mutation_summaries: + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_event, + mutation.mutation_name, + mutation.record + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to NewRelic') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to NewRelic') + + for event in events: + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_event, + event.name, + event.record + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to NewRelic') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to NewRelic') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for field, value in metrics_set.common_stats.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metric - {metrics_set.name}:common:{field}') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_metric, + f'{metrics_set.name}_{field}', + value + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to NewRelic') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to NewRelic') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + for field, value in group.stats.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metric - {metrics_set.name}:{group_name}:{field}') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_metric, + f'{metrics_set.name}_{group_name}_{field}', + value + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to NewRelic') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to NewRelic') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metric - {metrics_set.name}:custom:{custom_metric_name}') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_metric, + f'{metrics_set.name}_custom_{custom_metric_name}', + custom_metric.metric_value + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to NewRelic') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to NewRelic') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + error_message = re.sub( + '[^0-9a-zA-Z]+', + '_', + error.get( + 'message' + ).lower() + ) + + error_message_metric_name = f'{metrics_set.name}_errors_{error_message}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.record_custom_metric, + error_message_metric_name, + error.get('count') + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to NewRelic') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connectiion to NewRelic') + + await self._loop.run_in_executor( + self._executor, + self.client.shutdown + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connectiion to NewRelic') \ No newline at end of file diff --git a/hyperscale/reporting/types/newrelic/newrelic_config.py b/hyperscale/reporting/types/newrelic/newrelic_config.py new file mode 100644 index 0000000..479b803 --- /dev/null +++ b/hyperscale/reporting/types/newrelic/newrelic_config.py @@ -0,0 +1,14 @@ +from typing import Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class NewRelicConfig(BaseModel): + config_path: str + environment: Optional[str]=None + registration_timeout: int=60 + shutdown_timeout: int=60 + newrelic_application_name: str='hyperscale' + reporter_type: ReporterTypes=ReporterTypes.NewRelic \ No newline at end of file diff --git a/hyperscale/reporting/types/postgres/__init__.py b/hyperscale/reporting/types/postgres/__init__.py new file mode 100644 index 0000000..7beecb2 --- /dev/null +++ b/hyperscale/reporting/types/postgres/__init__.py @@ -0,0 +1,2 @@ +from .postgres import Postgres +from .postgres_config import PostgresConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/postgres/postgres.py b/hyperscale/reporting/types/postgres/postgres.py new file mode 100644 index 0000000..569d140 --- /dev/null +++ b/hyperscale/reporting/types/postgres/postgres.py @@ -0,0 +1,744 @@ +# # This is an ugly patch for: https://github.com/aio-libs/aiopg/issues/837 +# import selectors # isort:skip # noqa: F401 + +# selectors._PollLikeSelector.modify = ( # type: ignore +# selectors._BaseSelectorImpl.modify # type: ignore +# ) + +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .postgres_config import PostgresConfig + +try: + import sqlalchemy + from sqlalchemy.dialects.postgresql import UUID + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.ext.asyncio.engine import ( + AsyncConnection, + AsyncEngine, + AsyncTransaction, + ) + from sqlalchemy.schema import CreateTable + + has_connector = True + +except Exception: + UUID = None + sqlalchemy = None + create_engine = None + has_connector = False + + +class Postgres: + + def __init__(self, config: PostgresConfig) -> None: + self.host = config.host + self.database = config.database + self.username = config.username + self.password = config.password + + self.events_table_name = config.events_table + self.metrics_table_name = config.metrics_table + self.streams_table_name = config.streams_table + + self.experiments_table_name = config.experiments_table + self.variants_table_name = f'{config.experiments_table}_variants' + self.mutations_table_name = f'{config.experiments_table}_mutations' + + self.shared_metrics_table_name = f'{config.metrics_table}_shared' + self.errors_table_name = f'{config.metrics_table}_errors' + self.custom_metrics_table_name = f'{config.metrics_table}_custom' + + self.session_system_metrics_table_name = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_name = f'{config.system_metrics_table}_stage' + + self._engine = None + self.metadata = sqlalchemy.MetaData() + + self._events_table = None + self._metrics_table = None + self._streams_table = None + + self._experiments_table = None + self._variants_table = None + self._mutations_table = None + + self._shared_metrics_table = None + self._errors_table = None + self._custom_metrics_table = None + + self._session_system_metrics_table = None + self._stage_system_metrics_table = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + self.sql_type = 'Postgresql' + + self.metric_types_map = { + MetricType.COUNT: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.BIGINT + ), + MetricType.DISTRIBUTION: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + MetricType.SAMPLE: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + MetricType.RATE: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + } + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to {self.sql_type} instance at - {self.host} - Database: {self.database}') + + connection_uri = 'postgresql+asyncpg://' + + if self.username and self.password: + connection_uri = f'{connection_uri}{self.username}:{self.password}@' + + self._engine: AsyncEngine = await create_async_engine( + f'{connection_uri}{self.host}/{self.database}', + echo=False + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to {self.sql_type} instance at - {self.host} - Database: {self.database}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Initiating transaction') + + if self._session_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metrics table - {self.session_system_metrics_table_name} - if not exists') + + session_system_metrics_table = sqlalchemy.Table( + self.session_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + session_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await connection.execute( + CreateTable( + session_system_metrics_table, + if_not_exists=True + ) + ) + + self._session_system_metrics_table = session_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Session System Metrics table - {self.session_system_metrics_table_name}') + + rows: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + await connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Initiating transaction') + if self._stage_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metrics table - {self.stage_system_metrics_table_name} - if not exists') + + stage_system_metrics_table = sqlalchemy.Table( + self.stage_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + stage_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await connection.execute( + CreateTable( + stage_system_metrics_table, + if_not_exists=True + ) + ) + + self._stage_system_metrics_table = stage_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Stage System Metrics table - {self.stage_system_metrics_table_name}') + + rows: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + await connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Initiating transaction') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + if self._streams_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Streams table - {self.streams_table_name} - if not exists') + + stream_table = sqlalchemy.Table( + self.streams_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in stream.quantiles: + stream_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await connection.execute( + CreateTable( + stream_table, + if_not_exists=True + ) + ) + + self._streams_table = stream_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Streams table - {self.streams_table_name}') + + for group_name, group in stream.grouped.items(): + await connection.execute( + self._streams_table.insert(values={ + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Table - {self.streams_table_name}') + + async def submit_experiments(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Initiating transaction') + + for experiment in experiments_metrics.experiment_summaries: + + if self._experiments_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments table - {self.experiments_table_name} - if not exists') + + experiments_table = sqlalchemy.Table( + self.experiments_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('experiment_randomized', sqlalchemy.Boolean), + sqlalchemy.Column('experiment_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_median_aps', sqlalchemy.FLOAT), + ) + + await connection.execute(CreateTable(experiments_table, if_not_exists=True)) + + self._experiments_table = experiments_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Experiments table - {self.experiments_table_name}') + + await connection.execute(self._experiments_table.insert( + values=experiment.record + )) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Table - {self.experiments_table_name}') + + async def submit_variants(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Initiating transaction') + + for variant in experiments_metrics.variant_summaries: + + if self._variants_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variants table - {self.variants_table_name} - if not exists') + + variants_table = sqlalchemy.Table( + self.variants_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_experiment', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_weight', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_distribution', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_distribution_interval', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_actions_per_second', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_completed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_succeeded', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_failed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_aps', sqlalchemy.FLOAT), + ) + + await connection.execute(CreateTable(variants_table, if_not_exists=True)) + + self._variants_table = variants_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Variants table - {self.variants_table_name}') + + await connection.execute(self._variants_table.insert( + values=variant.record + )) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Table - {self.variants_table_name}') + + async def submit_mutations(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Initiating transaction') + + for mutation in experiments_metrics.mutation_summaries: + + if self._mutations_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutations table - {self.mutations_table_name} - if not exists') + + mutations_table = sqlalchemy.Table( + self.mutations_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('mutation_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_chance', sqlalchemy.FLOAT), + sqlalchemy.Column('mutation_targets', sqlalchemy.VARCHAR(8192)), + sqlalchemy.Column('mutation_type', sqlalchemy.VARCHAR(255)), + ) + + await connection.execute(CreateTable(mutations_table, if_not_exists=True)) + + self._mutations_table = mutations_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Mutations table - {self.mutations_table_name}') + + await connection.execute(self._mutations_table.insert( + values=mutation.record + )) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Table - {self.mutations_table_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Initiating transaction') + + for event in events: + + if self._events_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Events table - {self.events_table_name} - if not exists') + + events_table = sqlalchemy.Table( + self.events_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('time', sqlalchemy.FLOAT), + sqlalchemy.Column('succeeded', sqlalchemy.Boolean), + ) + + await connection.execute(CreateTable(events_table, if_not_exists=True)) + self._events_table = events_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Events table - {self.events_table_name}') + + await connection.execute(self._events_table.insert( + values=event.record + )) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Table - {self.events_table_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Initiating transaction') + + if self._shared_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metrics table - {self.shared_metrics_table_name} - if not exists') + + stage_metrics_table = sqlalchemy.Table( + self.shared_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('total', sqlalchemy.BIGINT), + sqlalchemy.Column('succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('failed', sqlalchemy.BIGINT), + sqlalchemy.Column('actions_per_second', sqlalchemy.FLOAT) + ) + + await connection.execute(CreateTable(stage_metrics_table, if_not_exists=True)) + self._shared_metrics_table = stage_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Shared Metrics table - {self.shared_metrics_table_name}') + + for metrics_set in metrics_sets: + await connection.execute( + self._metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Table - {self.shared_metrics_table_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Initiating transaction') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metrics table - {self.metrics_table_name} - if not exists') + + metrics_table = sqlalchemy.Table( + self.metrics_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in metrics_set.quantiles: + metrics_table.append_column( + sqlalchemy.Column(f'{quantile}', sqlalchemy.FLOAT) + ) + + for custom_field_name, sql_alchemy_type in metrics_set.custom_schemas: + metrics_table.append_column(custom_field_name, sql_alchemy_type) + + await connection.execute(CreateTable(metrics_table, if_not_exists=True)) + self._metrics_table = metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Metrics table - {self.metrics_table_name}') + + for group_name, group in metrics_set.groups.items(): + await connection.execute( + self._metrics_table.insert(values={ + **group.record, + 'group': group_name + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Table - {self.metrics_table_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics - Initiating transaction') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to table - {self.custom_metrics_table_name}') + + if self._custom_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Custom Metrics table - {self.custom_metrics_table_name} - if not exists') + + custom_metrics_table = sqlalchemy.Table( + self.custom_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + ) + + for metrics_set in metrics_sets: + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + custom_metrics_table.append_column( + self.metric_types_map.get( + custom_metric.metric_type, + lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ) + )(custom_metric_name) + ) + + await connection.execute( + CreateTable(custom_metrics_table, if_not_exists=True) + ) + + self._custom_metrics_table = custom_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Custom Metrics table - {self.custom_metrics_table_name}') + + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await connection.execute( + self._custom_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to table - {self.custom_metrics_table_name}') + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics - Transaction committed') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name}') + + async with self._engine.connect() as connection: + connection: AsyncConnection = connection + + async with connection.begin() as transaction: + transaction: AsyncTransaction = transaction + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Initiating transaction') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._errors_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Error Metrics table - {self.errors_table_name} - if not exists') + + errors_table = sqlalchemy.Table( + self.errors_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('error_message', sqlalchemy.TEXT), + sqlalchemy.Column('error_count', sqlalchemy.Integer) + ) + + + await connection.execute(CreateTable(errors_table, if_not_exists=True)) + self._errors_table = errors_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Error Metrics table - {self.errors_table_name}') + + for error in metrics_set.errors: + await connection.execute( + self._errors_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Table - {self.errors_table_name}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connectiion to {self.sql_type} at - {self.host}') + + + + diff --git a/hyperscale/reporting/types/postgres/postgres_config.py b/hyperscale/reporting/types/postgres/postgres_config.py new file mode 100644 index 0000000..20dcc12 --- /dev/null +++ b/hyperscale/reporting/types/postgres/postgres_config.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class PostgresConfig(BaseModel): + host: str='localhost' + database: str + username: str + password: str + events_table: str='events' + metrics_table: str='metrics' + experiments_table: str='experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.Postgres + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/reporting/types/prometheus/__init__.py b/hyperscale/reporting/types/prometheus/__init__.py new file mode 100644 index 0000000..f4fbcc1 --- /dev/null +++ b/hyperscale/reporting/types/prometheus/__init__.py @@ -0,0 +1,2 @@ +from .prometheus import Prometheus +from .prometheus_config import PrometheusConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/prometheus/prometheus.py b/hyperscale/reporting/types/prometheus/prometheus.py new file mode 100644 index 0000000..0b1ab3e --- /dev/null +++ b/hyperscale/reporting/types/prometheus/prometheus.py @@ -0,0 +1,722 @@ +import asyncio +import functools +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +try: + from prometheus_client import ( + CollectorRegistry, + push_to_gateway, + ) + from prometheus_client.core import REGISTRY + from prometheus_client.exposition import basic_auth_handler + + from .prometheus_config import PrometheusConfig + from .prometheus_metric import PrometheusMetric + has_connector = True + +except Exception: + PrometheusConfig = None + has_connector = False + basic_auth_handler = lambda: None + + +class Prometheus: + + def __init__(self, config: PrometheusConfig) -> None: + self.pushgateway_address = config.pushgateway_address + self.auth_request_method = config.auth_request_method + self.auth_request_timeout = config.auth_request_timeout + self.auth_request_data = config.auth_request_data + self.username = config.username + self.password = config.password + self.namespace = config.namespace + self.job_name = config.job_name + + self.registry = None + self._auth_handler = None + self._has_auth = False + self._loop = asyncio.get_event_loop() + + self.types_map = { + 'total': 'count', + 'succeeded': 'count', + 'failed': 'count', + 'actions_per_second': 'gauge', + 'median': 'gauge', + 'mean': 'gauge', + 'variance': 'gauge', + 'stdev': 'gauge', + 'minimum': 'gauge', + 'maximum': 'gauge' + } + + self._events: Dict[str, PrometheusMetric] = {} + self._metrics: Dict[str, Dict[str, Dict[str, PrometheusMetric]]] = {} + self._streams: Dict[str, Dict[str, PrometheusMetric]] = {} + self._session_system_metrics: Dict[str, Dict[str, PrometheusMetric]] = {} + self._stage_system_metrics: Dict[str, Dict[str, PrometheusMetric]] = {} + + self._experiments: Dict[str, Dict[str, PrometheusMetric]] = {} + self._variants: Dict[str, Dict[str, PrometheusMetric]] = {} + self._mutations: Dict[str, Dict[str, PrometheusMetric]] = {} + + self._shared_metrics: Dict[str, Dict[str, PrometheusMetric]] = {} + self._custom_metrics: Dict[str, PrometheusMetric] = {} + self._errors: Dict[str, PrometheusMetric] = {} + + self.metric_types_map = { + MetricType.COUNT: 'count', + MetricType.RATE: 'gauge', + MetricType.DISTRIBUTION: 'histogram', + MetricType.SAMPLE: 'gauge' + } + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self) -> None: + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Prometheus Pushgateway at: {self.pushgateway_address}') + + self.registry = CollectorRegistry() + REGISTRY.register(self.registry) + + if self.username and self.password: + self._has_auth = True + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Prometheus Pushgateway at: {self.pushgateway_address}') + + + def _generate_auth(self) -> basic_auth_handler: + return basic_auth_handler( + self.pushgateway_address, + self.auth_request_method, + self.auth_request_timeout, + { + 'Content-Type': 'application/json' + }, + self.auth_request_data, + username=self.username, + password=self.password + ) + + async def _submit_to_pushgateway(self): + if self._has_auth: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Pushing to secure Prometheus Pushgateway via HTTPS') + await self._loop.run_in_executor( + None, + functools.partial( + push_to_gateway, + self.pushgateway_address, + job=self.job_name, + registry=self.registry, + handler=self._generate_auth + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Pushed to secure Prometheus Pushgateway via HTTPS') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Pushing to Prometheus Pushgateway via HTTP') + await self._loop.run_in_executor( + None, + functools.partial( + push_to_gateway, + self.pushgateway_address, + job=self.job_name, + registry=self.registry + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Pushed to Prometheus Pushgateway via HTTP') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + for quantile in SystemMetricsSet.quantiles: + quantile_key = f'quantile_{quantile}th' + self.types_map[quantile_key] = 'gauge' + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Prometheus - Namespace: {self.namespace}') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + session_system_metric_set = self._session_system_metrics.get(metrics_set.name) + if session_system_metric_set is None: + tags = [ + f'name:{metrics_set.name}', + f'group:{metrics_set.group}' + ] + + + fields = {} + + for metric_field in metrics_set.record.keys(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics Group - {metrics_set.name}:{metrics_set.group}:{metric_field}') + metric_type = self.types_map.get(metric_field) + metric_name = f'{metrics_set.name}_{metrics_set.group}_{metric_field}'.replace('.', '_') + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{metrics_set.name} {metrics_set.group} {metric_field}', + metric_labels=tags, + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + fields[metric_field] = prometheus_metric + + self._session_system_metrics[metrics_set.name] = fields + + for field, prometheus_metric in session_system_metric_set.items(): + metric_value = metrics_set.record.get(field) + prometheus_metric.update(metric_value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Prometheus - Namespace: {self.namespace}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + for quantile in SystemMetricsSet.quantiles: + quantile_key = f'quantile_{quantile}th' + self.types_map[quantile_key] = 'gauge' + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Prometheus - Namespace: {self.namespace}') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + stage_system_metric_set = self._stage_system_metrics.get(metrics_set.name) + if stage_system_metric_set is None: + tags = [ + f'name:{metrics_set.name}', + f'group:{metrics_set.group}' + ] + + + fields = {} + + for metric_field in metrics_set.record.keys(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics Group - {metrics_set.name}:{metrics_set.group}:{metric_field}') + metric_type = self.types_map.get(metric_field) + metric_name = f'{metrics_set.name}_{metrics_set.group}_{metric_field}'.replace('.', '_') + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{metrics_set.name} {metrics_set.group} {metric_field}', + metric_labels=tags, + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + fields[metric_field] = prometheus_metric + + self._stage_system_metrics[metrics_set.name] = fields + + for field, prometheus_metric in stage_system_metric_set.items(): + metric_value = metrics_set.record.get(field) + prometheus_metric.update(metric_value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Prometheus - Namespace: {self.namespace}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Prometheus - Namespace: {self.namespace}') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + stream_metric_name = f'{stage_name}_streams' + + stream_metric_set = self._streams.get(stage_name) + if stream_metric_set is None: + stream_metric_set = {} + + for group_name, group in stream.grouped.items(): + group_metrics = stream_metric_set.get(group_name) + + tags = [ + f'name:{stage_name}_streams', + f'stage:{stage_name}', + f'group:{group_name}' + ] + + if group_metrics is None: + group_metrics = {} + + for quantile in stream.quantiles: + quantile_key = f'quantile_{quantile}th' + self.types_map[quantile_key] = 'gauge' + + fields = {} + + for metric_field in group.keys(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream Group - {stage_name}:{group_name}:{metric_field}') + metric_type = self.types_map.get(metric_field) + metric_name = f'{stage_name}_{group_name}_{metric_field}'.replace('.', '_') + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{stream_metric_name} {metric_field}', + metric_labels=tags, + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + fields[metric_field] = prometheus_metric + + group_metrics[group_name] = fields + + stream_metric_set.update(group_metrics) + + self._streams[stage_name] = stream_metric_set + + for group_name, group in stream.grouped.items(): + group_metrics = stream_metric_set.get(group_name) + + for field in group_metrics[group_name]: + metric_value = group.get(field) + group_metrics.update(metric_value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Prometheus - Namespace: {self.namespace}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Prometheus - Namespace: {self.namespace}') + + for experiment in experiment_metrics.experiment_summaries: + + experiment_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments Set - {experiment.experiment_name}:{experiment_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in experiment.tags + ] + + experiment_stats = self._experiments.get(experiment.experiment_name) + if experiment_stats is None: + experiment_stats = {} + + for field in experiment.stats.keys(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment- {experiment.experiment_name}:{field}') + + metric_name = f'{experiment.experiment_name}_{field}'.replace('.', '_') + metric_type = self.types_map.get(field) + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{experiment.experiment_name} {field}', + metric_labels=[ + *tags + ], + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + experiment_stats[field] = prometheus_metric + + self._experiments[experiment.experiment_name] = experiment_stats + + self._experiments[experiment.experiment_name] = experiment_stats + for field, value in experiment.stats.items(): + metric = experiment_stats.get(field) + metric.update(value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Prometheus - Namespace: {self.namespace}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Prometheus - Namespace: {self.namespace}') + + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants Set - {variant.variant_name}:{variant_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in variant.tags + ] + + variant_stats = self._variants.get(variant.variant_name) + if variant_stats is None: + variant_stats = {} + + for field in variant.stats.keys(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants- {variant.variant_name}:{field}') + + metric_name = f'{variant.variant_name}_{field}'.replace('.', '_') + metric_type = self.types_map.get(field) + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{variant.variant_name} {field}', + metric_labels=[ + *tags + ], + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + variant_stats[field] = prometheus_metric + + self._variants[variant.variant_name] = variant_stats + + self._variants[variant.variant_name] = variant_stats + for field, value in variant.stats.items(): + metric = variant_stats.get(field) + metric.update(value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Prometheus - Namespace: {self.namespace}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Prometheus - Namespace: {self.namespace}') + + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations Set - {mutation.mutation_name}:{mutation_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in mutation.tags + ] + + mutation_stats = self._mutations.get(mutation.mutation_name) + if mutation_stats is None: + mutation_stats = {} + + for field in mutation.stats.keys(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations- {mutation.mutation_name}:{field}') + + metric_name = f'{mutation.mutation_name}_{field}'.replace('.', '_') + metric_type = self.types_map.get(field) + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{mutation.mutation_name} {field}', + metric_labels=[ + *tags + ], + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + mutation_stats[field] = prometheus_metric + + self._mutations[mutation.mutation_name] = mutation_stats + + self._mutations[mutation.mutation_name] = mutation_stats + for field, value in mutation.stats.items(): + metric = mutation_stats.get(field) + metric.update(value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Prometheus - Namespace: {self.namespace}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Prometheus - Namespace: {self.namespace}') + + for event in events: + + record = { + f'{event.name}_time': event.time, + f'{event.name}_success': 1 if event.success else 0, + f'{event.name}_failed': 1 if event.success is False else 0 + } + + if self._events.get(event.name) is None: + + fields = {} + + for event_field in record.keys(): + metric_type = self.types_map.get(event_field) + + metric = PrometheusMetric( + f'{event.name}_{event_field}', + metric_type, + metric_description=f'{event.name} {event_field}', + metric_labels=[], + metric_namespace=self.namespace, + registry=self.registry + ) + + metric.create_metric() + + fields[event_field] = metric + + self._events[event.name] = fields + + for event_field, event_value in record.items(): + if event_value and event_field in self.types_map: + self._events[event.name][event_field].update(event_value) + + await self._submit_to_pushgateway() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Prometheus - Namespace: {self.namespace}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Prometheus - Namespace: {self.namespace}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in metrics_set.tags + ] + + shared_metrics = self._shared_metrics.get(metrics_set.name) + if shared_metrics is None: + shared_metrics = {} + + for field in metrics_set.common_stats.keys(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metric - {metrics_set.name}:common:{field}') + + metric_name = f'{metrics_set.name}_{field}'.replace('.', '_') + metric_type = self.types_map.get(field) + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{metrics_set.name} {field}', + metric_labels=[ + *tags, + f'stage:{metrics_set.stage}', + 'group:common' + ], + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + shared_metrics[field] = prometheus_metric + + self._shared_metrics[metrics_set.name] = shared_metrics + for field, value in metrics_set.common_stats.items(): + metric = shared_metrics.get(field) + metric.update(value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Prometheus - Namespace: {self.namespace}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Prometheus - Namespace: {self.namespace}') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + tags = [ + f'{tag.name}:{tag.value}' for tag in metrics_set.tags + ] + + stage_metrics = self._metrics.get(metrics_set.name) + if stage_metrics is None: + stage_metrics = {} + + for group_name, group in metrics_set.groups.items(): + group_metrics = stage_metrics.get(group_name) + + if group_metrics is None: + group_metrics = {} + + for quantile_name in metrics_set.quantiles: + self.types_map[quantile_name] = 'gauge' + + fields = {} + + for metric_field in metrics_set.stats_fields: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metric - {metrics_set.name}:{group_name}:{metric_field}') + metric_type = self.types_map.get(metric_field) + metric_name = f'{metrics_set.name}_{group_name}_{metric_field}'.replace('.', '_') + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{metrics_set.name} {metric_field}', + metric_labels=[ + *tags, + f'stage:{metrics_set.stage}', + f'group:{group_name}' + ], + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + fields[metric_field] = prometheus_metric + + group_metrics[group_name] = fields + + stage_metrics[metrics_set.name] = group_metrics + + self._metrics[metrics_set.name] = stage_metrics + for group_name, group in metrics_set.groups.items(): + group_metrics = stage_metrics.get(group_name) + record = group.record + + for field in group_metrics[group_name]: + metric_value = record.get(field) + field_metric = group_metrics.get(field) + field_metric.update(metric_value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Prometheus - Namespace: {self.namespace}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Prometheus - Namespace: {self.namespace}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + custom_metrics = self._custom_metrics.get(metrics_set.name) + if custom_metrics is None: + custom_metrics = {} + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + # group_metrics = custom_metrics.get(custom_group_name) + + metric_type = self.metric_types_map.get( + custom_metric.metric_type, + 'gauge' + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metric - {metrics_set.name}:custom:{custom_metric_name}') + + metric_name = f'{metrics_set.name}_{custom_metric_name}'.replace('.', '_') + + tags = [ + f'{tag.name}:{tag.value}' for tag in metrics_set.tags + ] + + prometheus_metric = PrometheusMetric( + metric_name, + metric_type, + metric_description=f'{metrics_set.name} {custom_metric_name}', + metric_labels=[ + *tags, + f'stage:{metrics_set.stage}', + 'group:custom', + f'type:{custom_metric.metric_type}' + ], + metric_namespace=self.namespace, + registry=self.registry + ) + prometheus_metric.create_metric() + + custom_metrics[custom_metric_name] = prometheus_metric + + + self._custom_metrics[metrics_set.name] = custom_metrics + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + custom_metric_prometheus_value: PrometheusMetric = custom_metrics.get(custom_metric_name) + custom_metric_prometheus_value.update(custom_metric.metric_value) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Prometheus - Namespace: {self.namespace}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Prometheus - Namespace: {self.namespace}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._errors.get(metrics_set.name) is None: + errors_metric = PrometheusMetric( + f'{metrics_set.name}_errors', + 'count', + metric_description=f'Errors for action - {metrics_set.name}.', + metric_labels=[], + metric_namespace=self.namespace, + registry=self.registry + ) + + errors_metric.create_metric() + self._errors[metrics_set.name] = errors_metric + + for error in metrics_set.errors: + errors_metric.update( + error.get('count'), + labels={ + 'message': error.get('message') + } + ) + + await self._submit_to_pushgateway() + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Prometheus - Namespace: {self.namespace}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + \ No newline at end of file diff --git a/hyperscale/reporting/types/prometheus/prometheus_config.py b/hyperscale/reporting/types/prometheus/prometheus_config.py new file mode 100644 index 0000000..de5845f --- /dev/null +++ b/hyperscale/reporting/types/prometheus/prometheus_config.py @@ -0,0 +1,18 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class PrometheusConfig(BaseModel): + pushgateway_address: str='localhost:9091' + auth_request_method: str='GET' + auth_request_timeout: int=60000 + auth_request_data: Dict[str, Any]={} + username: Optional[str] + password: Optional[str] + namespace: Optional[str] + job_name: str='hyperscale' + custom_fields: Dict[str, str]={} + reporter_type: ReporterTypes=ReporterTypes.Prometheus \ No newline at end of file diff --git a/hyperscale/reporting/types/prometheus/prometheus_metric.py b/hyperscale/reporting/types/prometheus/prometheus_metric.py new file mode 100644 index 0000000..86ec028 --- /dev/null +++ b/hyperscale/reporting/types/prometheus/prometheus_metric.py @@ -0,0 +1,165 @@ +from typing import Any, List +try: + from prometheus_client import ( + Info, + Summary, + Counter, + Gauge, + Histogram, + Enum + ) + +except Exception: + Info = None + Summary = None + Counter = None + Gauge = None + Histogram = None + Enum = None + pass + + +class PrometheusMetric: + + types = { + 'info': Info, + 'summary': Summary, + 'count': Counter, + 'gauge': Gauge, + 'histogram': Histogram, + 'enum': Enum + } + + def __init__( + self, + metric_name: str, + metric_type: str, + metric_description: str=None, + metric_states: List[Any]= None, + metric_labels: List[str]=[], + metric_namespace: str = None, + registry=None + ): + self.name = metric_name + self.type = metric_type + self.description = metric_description + self.states = metric_states + self.label_names = metric_labels + self.namespace = metric_namespace + self.registry = registry + self.prometheus_object = None + + def create_metric(self): + + if self.type == 'enum': + self.prometheus_object = Enum( + self.name, + self.description, + labelnames=(label for label in self.label_names), + states=self.states, + registry=self.registry, + namespace=self.namespace + ) + + elif self.type == 'gauge': + self.prometheus_object = Gauge( + self.name, + self.description, + labelnames=(label for label in self.label_names), + registry=self.registry, + namespace=self.namespace + ) + + elif self.type == 'summary': + self.prometheus_object = Summary( + self.name, + self.description, + labelnames=(label for label in self.label_names), + registry=self.registry, + namespace=self.namespace + ) + + elif self.type == 'count': + self.prometheus_object = Counter( + self.name, + self.description, + labelnames=(label for label in self.label_names), + registry=self.registry, + namespace=self.namespace + ) + + elif self.type == 'histogram': + self.prometheus_object = Histogram( + self.name, + self.description, + labelnames=(label for label in self.label_names), + registry=self.registry, + namespace=self.namespace + ) + + elif self.type == 'info': + self.prometheus_object = Info( + self.name, + self.description, + labelnames=(label for label in self.label_names), + registry=self.registry, + namespace=self.namespace + ) + + self.prometheus_object._labelnames = self.label_names + + self.generators = { + 'info': self._update_info, + 'enum': self._update_enum, + 'histogram': self._update_histogram_or_summary, + 'summary': self._update_histogram_or_summary, + 'count': self._update_count, + 'gauge': self._update_gauge, + } + + def update(self, value=None, labels=None, options=None) -> None: + if labels is None: + labels = {label: '' for label in self.label_names} + + self.prometheus_object.labels(**labels) + + self.generators.get(self.type)(value, labels, options) + + def _update_info(self, value, labels, options) -> None: + if value is None: + value = 'N/A' + + self.prometheus_object.info(value) + + def _update_enum(self, value, labels, options) -> None: + if value is None: + value = 'N/A' + + self.prometheus_object.state(value) + + def _update_histogram_or_summary(self, value, labels, options) -> None: + if value is None: + value = 0 + + self.prometheus_object.observe(value) + + def _update_count(self, value, labels, options) -> None: + self.prometheus_object.inc() + + def _update_gauge(self, value, labels, options) -> None: + if value is None: + value = 1 + + if options is None: + options = {} + + if options.get('update_type') == 'inc': + self.prometheus_object.inc(amount=value) + elif options.get('update_type') == 'dec': + self.prometheus_object.dec(amount=value) + elif options.get('update_type') == 'set_function': + self.prometheus_object.set_function( + options.get('update_function')(value) + ) + else: + self.prometheus_object.set(value) diff --git a/hyperscale/reporting/types/redis/__init__.py b/hyperscale/reporting/types/redis/__init__.py new file mode 100644 index 0000000..d521a90 --- /dev/null +++ b/hyperscale/reporting/types/redis/__init__.py @@ -0,0 +1,2 @@ +from .redis import Redis +from .redis_config import RedisConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/redis/redis.py b/hyperscale/reporting/types/redis/redis.py new file mode 100644 index 0000000..dc6ecce --- /dev/null +++ b/hyperscale/reporting/types/redis/redis.py @@ -0,0 +1,421 @@ +import json +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .redis_config import RedisConfig + +try: + + import aioredis + + has_connector = True + +except Exception: + aioredis = None + has_connector = True + + +class Redis: + + def __init__(self, config: RedisConfig) -> None: + self.host = config.host + self.base = 'rediss' if config.secure else 'redis' + self.username = config.username + self.password = config.password + self.database = config.database + self.events_channel = config.events_channel + self.metrics_channel = config.metrics_channel + self.streams_channel = config.streams_channel + + self.experiments_channel = config.experiments_channel + self.variants_channel = f'{config.experiments_channel}_variants' + self.mutations_channel = f'{config.experiments_channel}_mutations' + + self.shared_metrics_channel = f'{self.metrics_channel}_metrics' + self.errors_channel = f'{self.metrics_channel}_errors' + self.custom_metrics_channel = f'{self.metrics_channel}_custom' + self.session_system_metrics_channel = f'{config.system_metrics_channel}_session' + self.stage_system_metrics_channel = f'{config.system_metrics_channel}_stage' + + self.channel_type = config.channel_type + self.connection = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Redis instance at - {self.base}://{self.host} - Database: {self.database}') + self.connection = await aioredis.from_url( + f'{self.base}://{self.host}', + username=self.username, + password=self.password, + db=self.database + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Redis instance at - {self.base}://{self.host} - Database: {self.database}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Channel - {self.session_system_metrics_channel} - Group: {metrics_set.group}') + await self.connection.publish( + self.session_system_metrics_channel, + json.dumps(metrics_set.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Channel - {self.session_system_metrics_channel} - Group: {metrics_set.group}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Redis Set - {self.session_system_metrics_channel} - Group: {metrics_set.group}') + await self.connection.sadd( + self.session_system_metrics_channel, + json.dumps(metrics_set.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Redis Set - {self.session_system_metrics_channel} - Group: {metrics_set.group}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Channel - {self.stage_system_metrics_channel} - Group: {metrics_set.group}') + await self.connection.publish( + self.stage_system_metrics_channel, + json.dumps(metrics_set.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Channel - {self.stage_system_metrics_channel} - Group: {metrics_set.group}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Redis Set - {self.stage_system_metrics_channel} - Group: {metrics_set.group}') + await self.connection.sadd( + self.stage_system_metrics_channel, + json.dumps(metrics_set.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Redis Set - {self.stage_system_metrics_channel} - Group: {metrics_set.group}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Channel - {self.streams_channel} - Group: {group_name}') + await self.connection.publish( + self.streams_channel, + json.dumps({ + **group, + 'group': group_name, + 'stage': stage_name, + 'name': f'{stage_name}_streams' + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Channel - {self.streams_channel} - Group: {group_name}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Redis Set - {self.streams_channel} - Group: {group_name}') + await self.connection.sadd( + self.streams_channel, + json.dumps({ + **group, + 'group': group_name, + 'stage': stage_name, + 'name': f'{stage_name}_streams' + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Redis Set - {self.streams_channel} - Group: {group_name}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Channel - {self.experiments_channel}') + + for experiment in experiment_metrics.experiment_summaries: + await self.connection.publish( + self.experiments_channel, + json.dumps(experiment.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Channel - {self.experiments_channel}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Redis Set - {self.experiments_channel}') + for experiment in experiment_metrics.experiment_summaries: + await self.connection.sadd( + self.experiments_channel, + json.dumps(experiment.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Redis Set - {self.experiments_channel}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Channel - {self.variants_channel}') + + for variant in experiment_metrics.variant_summaries: + await self.connection.publish( + self.variants_channel, + json.dumps(variant.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Channel - {self.variants_channel}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Redis Set - {self.variants_channel}') + for variant in experiment_metrics.variant_summaries: + await self.connection.sadd( + self.variants_channel, + json.dumps(variant.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Redis Set - {self.variants_channel}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Channel - {self.mutations_channel}') + + for mutation in experiment_metrics.mutation_summaries: + await self.connection.publish( + self.mutations_channel, + json.dumps(mutation.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Channel - {self.mutations_channel}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Redis Set - {self.mutations_channel}') + for mutation in experiment_metrics.mutation_summaries: + await self.connection.sadd( + self.mutations_channel, + json.dumps(mutation.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Redis Set - {self.mutations_channel}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Channel - {self.events_channel}') + + for event in events: + await self.connection.publish( + self.events_channel, + json.dumps(event.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Channel - {self.events_channel}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Redis Set - {self.events_channel}') + for event in events: + await self.connection.sadd( + self.events_channel, + json.dumps(events.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Redis Set - {self.events_channel}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Channel - {self.shared_metrics_channel}') + await self.connection.publish( + self.shared_metrics_channel, + json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Channel - {self.shared_metrics_channel}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Redis Set - {self.shared_metrics_channel}') + await self.connection.sadd( + self.shared_metrics_channel, + json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + **metrics_set.common_stats + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Redis Set - {self.shared_metrics_channel}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Channel - {self.metrics_channel} - Group: {group_name}') + await self.connection.publish( + self.metrics_channel, + json.dumps({ + **group.record, + 'group': group_name + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Channel - {self.metrics_channel} - Group: {group_name}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Redis Set - {self.metrics_channel} - Group: {group_name}') + await self.connection.sadd( + self.metrics_channel, + json.dumps({ + **group.record, + 'group': group_name + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Redis Set - {self.metrics_channel} - Group: {group_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Channel - {self.custom_metrics_channel} - Group: Custom') + await self.connection.publish( + self.custom_metrics_channel, + json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Channel - {self.custom_metrics_channel} - Group: Custom') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to Redis Set - {self.custom_metrics_channel} - Group: Custom') + await self.connection.sadd( + self.custom_metrics_channel, + json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Redis Set - {self.custom_metrics_channel} - Group: Custom') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + + if self.channel_type == 'channel': + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Channel - {self.errors_channel}') + await self.connection.publish( + self.errors_channel, + json.dumps({ + 'metrics_name': metrics_set.name, + 'metrics_stage': metrics_set.stage, + 'errors_message': error.get('message'), + 'errors_count': error.get('count') + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Channel - {self.errors_channel}') + + else: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Reids Set - {self.errors_channel}') + await self.connection.sadd( + self.errors_channel, + json.dumps({ + 'metrics_name': metrics_set.name, + 'metrics_stage': metrics_set.stage, + 'errors_message': error.get('message'), + 'errors_count': error.get('count') + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Reids Set - {self.errors_channel}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connectiion to Redis at - {self.base}://{self.host}') + + await self.connection.close() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connectiion to Redis at - {self.base}://{self.host}') \ No newline at end of file diff --git a/hyperscale/reporting/types/redis/redis_config.py b/hyperscale/reporting/types/redis/redis_config.py new file mode 100644 index 0000000..f3d0b9f --- /dev/null +++ b/hyperscale/reporting/types/redis/redis_config.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class RedisConfig(BaseModel): + host: str='localhost:6379' + username: Optional[str] + password: Optional[str] + database: int=0 + events_channel: str='events' + metrics_channel: str='metrics' + experiments_channel: str='experiments' + streams_channel: str='streams' + system_metrics_channel: str='system_metrics' + channel_type: str='pipeline' + secure: bool=False + reporter_type: ReporterTypes=ReporterTypes.Redis \ No newline at end of file diff --git a/hyperscale/reporting/types/s3/__init__.py b/hyperscale/reporting/types/s3/__init__.py new file mode 100644 index 0000000..4b3fc48 --- /dev/null +++ b/hyperscale/reporting/types/s3/__init__.py @@ -0,0 +1,2 @@ +from .s3 import S3 +from .s3_config import S3Config \ No newline at end of file diff --git a/hyperscale/reporting/types/s3/s3.py b/hyperscale/reporting/types/s3/s3.py new file mode 100644 index 0000000..09c72c5 --- /dev/null +++ b/hyperscale/reporting/types/s3/s3.py @@ -0,0 +1,620 @@ +import asyncio +import functools +import json +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .s3_config import S3Config + +try: + import boto3 + has_connector = True + +except Exception: + boto3 = None + has_connector = False + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +class S3: + + def __init__(self, config: S3Config) -> None: + self.aws_access_key_id = config.aws_access_key_id + self.aws_secret_access_key = config.aws_secret_access_key + self.region_name = config.region_name + self.buckets_namespace = config.buckets_namespace + + self.events_bucket_name = config.events_bucket + self.metrics_bucket_name = config.metrics_bucket + self.streams_bucket_name = config.streams_bucket + + self.experiments_bucket_name = config.experiments_bucket + self.variants_bucket_name = f'{config.experiments_bucket}_variants' + self.mutations_bucket_name = f'{config.experiments_bucket}_mutations' + + self.shared_metrics_bucket_name = f'{config.metrics_bucket}_shared' + self.errors_bucket_name = f'{config.metrics_bucket}_errors' + self.custom_metrics_bucket_name = f'{config.metrics_bucket}_custom' + + self.session_system_metrics_bucket_name = f'{config.system_metrics_bucket}_session' + self.stage_system_metrics_bucket_name = f'{config.system_metrics_bucket}_stage' + + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self.client = None + self._loop = asyncio.get_event_loop() + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to AWS S3 - Region: {self.region_name}') + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + self.client = await self._loop.run_in_executor( + self._executor, + functools.partial( + boto3.client, + 's3', + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.region_name + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to AWS S3 - Region: {self.region_name}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + session_system_metrics_bucket_name = f'{self.buckets_namespace}-{self.session_system_metrics_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Bucket - {session_system_metrics_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Session System Metrics Bucket - {session_system_metrics_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=session_system_metrics_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Session System Metrics Bucket - {session_system_metrics_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Session System Metrics Bucket - {session_system_metrics_bucket_name}') + + metrics_sets: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics Set - {metrics_set.name}:{metrics_set.group}') + + session_system_metrics_set_id = uuid.uuid4() + + metrics_set_set_key = f'{metrics_set.name}_{metrics_set.group}_{session_system_metrics_set_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=session_system_metrics_bucket_name, + Key=metrics_set_set_key, + Body=json.dumps(metrics_set.record) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Bucket - {session_system_metrics_bucket_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + stage_system_metrics_bucket_name = f'{self.buckets_namespace}-{self.stage_system_metrics_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Bucket - {stage_system_metrics_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Stage System Metrics Bucket - {stage_system_metrics_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=stage_system_metrics_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Stage System Metrics Bucket - {stage_system_metrics_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Stage System Metrics Bucket - {stage_system_metrics_bucket_name}') + + metrics_sets: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics Set - {metrics_set.name}:{metrics_set.group}') + + stage_system_metrics_set_id = uuid.uuid4() + + metrics_set_set_key = f'{metrics_set.name}_{metrics_set.group}_{stage_system_metrics_set_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=stage_system_metrics_bucket_name, + Key=metrics_set_set_key, + Body=json.dumps(metrics_set.record) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Bucket - {stage_system_metrics_bucket_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + streams_bucket_name = f'{self.buckets_namespace}-{self.streams_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Bucket - {streams_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Streams Bucket - {streams_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=streams_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Streams Bucket - {streams_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Streams Bucket - {streams_bucket_name}') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams Set - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + streams_set_key = f'{stage_name}_{group_name}_{stream.stream_set_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=streams_bucket_name, + Key=streams_set_key, + Body=json.dumps({ + **group.record, + 'group': group_name, + 'stage': stage_name, + 'name': f'{stage_name}_streams' + }) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Bucket - {streams_bucket_name}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + experiments_bucket_name = f'{self.buckets_namespace}-{self.experiments_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Bucket - {experiments_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Experiments Bucket - {experiments_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=experiments_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Experiments Bucket - {experiments_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Experiments Bucket - {experiments_bucket_name}') + + for experiment in experiment_metrics.experiment_summaries: + + experiment_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment - {experiment.experiment_name}:{experiment_id}') + + experiment_key = f'{experiment.experiment_name}_{experiment_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=experiments_bucket_name, + Key=experiment_key, + Body=json.dumps(experiment.record) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Bucket - {experiments_bucket_name}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + variants_bucket_name = f'{self.buckets_namespace}-{self.variants_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Bucket - {variants_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Variants Bucket - {variants_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=variants_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Variants Bucket - {variants_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Variants Bucket - {variants_bucket_name}') + + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variant - {variant.variant_name}:{variant_id}') + + variant_key = f'{variant.variant_name}_{variant_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=variants_bucket_name, + Key=variant_key, + Body=json.dumps(variant.record) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Bucket - {variants_bucket_name}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + mutations_bucket_name = f'{self.buckets_namespace}-{self.mutations_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Bucket - {mutations_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Mutations Bucket - {mutations_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=mutations_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Mutations Bucket - {mutations_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Mutations Bucket - {mutations_bucket_name}') + + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutation - {mutation.mutation_name}:{mutation_id}') + + mutation_key = f'{mutation.mutation_name}_{mutation_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=mutations_bucket_name, + Key=mutation_key, + Body=json.dumps(mutation.record) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Bucket - {mutations_bucket_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + events_bucket_name = f'{self.buckets_namespace}-{self.events_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Bucket - {events_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Events Bucket - {events_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=events_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Events Bucket - {events_bucket_name} - if not exists') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Events Bucket - {events_bucket_name}') + + + for event in events: + event_key = f'{event.name}_{event.event_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=events_bucket_name, + Key=event_key, + Body=json.dumps(event.record) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Bucket - {events_bucket_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + shared_metrics_bucket_name = f'{self.buckets_namespace}-{self.shared_metrics_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Bucket - {shared_metrics_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Shared Metrics Bucket - {shared_metrics_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=shared_metrics_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Shared Metrics Bucket - {shared_metrics_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Shared Metrics Bucket - {shared_metrics_bucket_name}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + metrics_set_key = f'{metrics_set.name}_{metrics_set.metrics_set_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=shared_metrics_bucket_name, + Key=metrics_set_key, + Body=json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + **metrics_set.common_stats + }) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Bucket - {shared_metrics_bucket_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + metrics_bucket_name = f'{self.buckets_namespace}-{self.metrics_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Bucket - {metrics_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Metrics Bucket - {metrics_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=metrics_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Metrics Bucket - {metrics_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Metrics Bucket - {metrics_bucket_name}') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + metrics_set_key = f'{metrics_set.name}_{group_name}_{group.metrics_group_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=metrics_bucket_name, + Key=metrics_set_key, + Body=json.dumps({ + **group.record, + 'group': group_name + }) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Bucket - {metrics_bucket_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Custom Metrics Bucket - {self.custom_metrics_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=self.custom_metrics_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Custom Metrics Bucket - {self.custom_metrics_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Custom Metrics Bucket - {self.custom_metrics_bucket_name}') + + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + custom_metric_key = f'{metrics_set.name}_custom_{metrics_set.metrics_set_id}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=self.custom_metrics_bucket_name, + Key=custom_metric_key, + Body=json.dumps({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to Bucket - {self.custom_metrics_bucket_name}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + errors_bucket_name = f'{self.buckets_namespace}-{self.errors_bucket_name}' + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Bucket - {errors_bucket_name}') + + try: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Creating Error Metrics Bucket - {errors_bucket_name} - if not exists') + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.create_bucket, + Bucket=errors_bucket_name, + CreateBucketConfiguration={ + 'LocationConstraint': self.region_name + } + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Created Error Metrics Bucket - {errors_bucket_name}') + + except Exception: + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Skipping creation of Error Metrics Bucket - {errors_bucket_name}') + + for metrics_set in metrics_sets: + for error in metrics_set.errors: + timestamp = int(datetime.now().timestamp()) + metric_key = f'{metrics_set.name}-{timestamp}' + + await self._loop.run_in_executor( + self._executor, + functools.partial( + self.client.put_object, + Bucket=errors_bucket_name, + Key=metric_key, + Body=json.dumps({ + 'metrics_name': metrics_set.name, + 'metrics_stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Bucket - {errors_bucket_name}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + self._executor.shutdown() \ No newline at end of file diff --git a/hyperscale/reporting/types/s3/s3_config.py b/hyperscale/reporting/types/s3/s3_config.py new file mode 100644 index 0000000..0df745c --- /dev/null +++ b/hyperscale/reporting/types/s3/s3_config.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class S3Config(BaseModel): + aws_access_key_id: str + aws_secret_access_key: str + region_name: str + buckets_namespace: str + events_bucket: str='events' + metrics_bucket: str='metrics' + experiments_bucket: str='experiments' + streams_bucket: str='streams' + system_metrics_bucket: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.S3 \ No newline at end of file diff --git a/hyperscale/reporting/types/snowflake/__init__.py b/hyperscale/reporting/types/snowflake/__init__.py new file mode 100644 index 0000000..15f36d8 --- /dev/null +++ b/hyperscale/reporting/types/snowflake/__init__.py @@ -0,0 +1,2 @@ +from .snowflake import Snowflake +from .snowflake_config import SnowflakeConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/snowflake/snowflake.py b/hyperscale/reporting/types/snowflake/snowflake.py new file mode 100644 index 0000000..f0eec23 --- /dev/null +++ b/hyperscale/reporting/types/snowflake/snowflake.py @@ -0,0 +1,722 @@ +import asyncio +import signal +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Dict, List + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .snowflake_config import SnowflakeConfig + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop +): + try: + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + + +try: + import sqlalchemy + from snowflake.sqlalchemy import URL + from sqlalchemy import create_engine + from sqlalchemy.schema import CreateTable + + has_connector = True + +except Exception: + snowflake = None + has_connector = False + +class Snowflake: + + def __init__(self, config: SnowflakeConfig) -> None: + self.username = config.username + self.password = config.password + self.organization_id = config.organization_id + self.account_id = config.account_id + self.private_key = config.private_key + self.warehouse = config.warehouse + self.database = config.database + self.schema = config.database_schema + + self.events_table_name = config.events_table + self.metrics_table_name = config.metrics_table + self.streams_table_name = config.streams_table + + self.experiments_table_name = config.experiments_table + self.variants_table_name = f'{config.experiments_table}_variants' + self.mutations_table_name = f'{config.experiments_table}_mutations' + + self.shared_metrics_table_name = f'{config.metrics_table}_shared' + self.errors_table_name = f'{config.metrics_table}_errors' + self.custom_metrics_table_name = f'{config.metrics_table}_custom' + + + self.session_system_metrics_table_name = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_name = f'{config.system_metrics_table}_stage' + + self.connect_timeout = config.connect_timeout + + self.metadata = sqlalchemy.MetaData() + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + + self._engine = None + self._connection = None + + self._events_table = None + self._metrics_table = None + self._streams_table = None + + self._experiments_table = None + self._variants_table = None + self._mutations_table = None + + self._shared_metrics_table = None + self._custom_metrics_table = None + self._errors_table = None + + self._session_system_metrics_table = None + self._stage_system_metrics_table = None + + self.metric_types_map = { + MetricType.COUNT: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.Integer + ), + MetricType.DISTRIBUTION: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + MetricType.SAMPLE: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + MetricType.RATE: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + } + + self._loop = asyncio.get_event_loop() + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + async def connect(self): + + try: + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to Snowflake instance at - Warehouse: {self.warehouse} - Database: {self.database} - Schema: {self.schema}') + self._engine = await self._loop.run_in_executor( + self._executor, + create_engine, + URL( + user=self.username, + password=self.password, + account=self.account_id, + warehouse=self.warehouse, + database=self.database, + schema=self.schema + ) + + ) + + self._connection = await asyncio.wait_for( + self._loop.run_in_executor( + self._executor, + self._engine.connect + ), + timeout=self.connect_timeout + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to Snowflake instance at - Warehouse: {self.warehouse} - Database: {self.database} - Schema: {self.schema}') + + except asyncio.TimeoutError: + raise Exception('Err. - Connection to Snowflake timed out - check your account id, username, and password.') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name}') + + if self._session_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metrics table - {self.session_system_metrics_table_name} - if not exists') + + session_system_metrics_table = sqlalchemy.Table( + self.session_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + session_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable( + session_system_metrics_table, + if_not_exists=True + ) + ) + + self._session_system_metrics_table = session_system_metrics_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Session System Metrics table - {self.session_system_metrics_table_name}') + + rows: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.system_metrics_set_id}') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics) + + for metrics_set in rows: + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._session_system_metrics_table.insert(values=metrics_set.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Table - {self.stage_system_metrics_table_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + if self._stage_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metrics table - {self.stage_system_metrics_table_name} - if not exists') + + stage_system_metrics_table = sqlalchemy.Table( + self.stage_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + stage_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable( + stage_system_metrics_table, + if_not_exists=True + ) + ) + + self._stage_system_metrics_table = stage_system_metrics_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Stage System Metrics table - {self.stage_system_metrics_table_name}') + + rows: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics) + + for metrics_set in rows: + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._stage_system_metrics_table.insert(values=metrics_set.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name}') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + if self._streams_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Streams table - {self.streams_table_name} - if not exists') + + streams_table = sqlalchemy.Table( + self.streams_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in stream.quantiles: + streams_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable( + streams_table, + if_not_exists=True + ) + ) + + self._streams_table = streams_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Streams table - {self.streams_table_name}') + + for group_name, group in stream.grouped.items(): + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._streams_table.insert(values={ + **group, + 'group': group_name, + 'stage': stage_name, + 'name': f'{stage_name}_streams' + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Table - {self.streams_table_name}') + + async def submit_experiments(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name}') + + for experiment in experiments_metrics.experiment_summaries: + + if self._experiments_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments table - {self.experiments_table_name} - if not exists') + experiments_table = sqlalchemy.Table( + self.experiments_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('experiment_randomized', sqlalchemy.Boolean), + sqlalchemy.Column('experiment_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_median_aps', sqlalchemy.FLOAT), + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(experiments_table, if_not_exists=True) + ) + + self._experiments_table = experiments_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Experiments table - {self.experiments_table_name}') + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._experiments_table.insert(values=experiment.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Table - {self.experiments_table_name}') + + async def submit_variants(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name}') + + for variant in experiments_metrics.variant_summaries: + + if self._variants_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variants table - {self.variants_table_name} - if not exists') + variants_table = sqlalchemy.Table( + self.variants_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_experiment', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_weight', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_distribution', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_distribution_interval', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_actions_per_second', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_completed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_succeeded', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_failed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_aps', sqlalchemy.FLOAT), + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(variants_table, if_not_exists=True) + ) + + self._variants_table = variants_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Variants table - {self.variants_table_name}') + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._variants_table.insert(values=variant.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Table - {self.variants_table_name}') + + async def submit_mutations(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name}') + + for variant in experiments_metrics.variant_summaries: + + if self._mutations_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutations table - {self.mutations_table_name} - if not exists') + mutations_table = sqlalchemy.Table( + self.mutations_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('mutation_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_chance', sqlalchemy.FLOAT), + sqlalchemy.Column('mutation_targets', sqlalchemy.VARCHAR(8192)), + sqlalchemy.Column('mutation_type', sqlalchemy.VARCHAR(255)), + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(mutations_table, if_not_exists=True) + ) + + self._mutations_table = mutations_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Mutations table - {self.mutations_table_name}') + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._mutations_table.insert(values=variant.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Table - {self.mutations_table_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name}') + + for event in events: + + if self._events_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Events table - {self.events_table_name} - if not exists') + events_table = sqlalchemy.Table( + self.events_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('time', sqlalchemy.Float), + sqlalchemy.Column('succeeded', sqlalchemy.Boolean), + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(events_table, if_not_exists=True) + ) + + self._events_table = events_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Events table - {self.events_table_name}') + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._events_table.insert(values=event.record) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Table - {self.events_table_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name}') + + if self._shared_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metrics table - {self.shared_metrics_table_name} - if not exists') + + shared_metrics_table = sqlalchemy.Table( + self.shared_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT), + sqlalchemy.Column('total', sqlalchemy.BIGINT), + sqlalchemy.Column('succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('failed', sqlalchemy.BIGINT), + sqlalchemy.Column('actions_per_second', sqlalchemy.FLOAT) + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(shared_metrics_table, if_not_exists=True) + ) + + self._shared_metrics_table = shared_metrics_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Shared Metrics table - {self.shared_metrics_table_name}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._shared_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Table - {self.shared_metrics_table_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name}') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metrics table - {self.metrics_table_name} - if not exists') + + metrics_table = sqlalchemy.Table( + self.metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in metrics_set.quantiles: + metrics_table.append_column( + sqlalchemy.Column(f'{quantile}', sqlalchemy.FLOAT) + ) + + for custom_field_name, sql_alchemy_type in metrics_set.custom_schemas: + metrics_table.append_column(custom_field_name, sql_alchemy_type) + + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(metrics_table, if_not_exists=True) + ) + + self._metrics_table = metrics_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Metrics table - {self.metrics_table_name}') + + for group_name, group in metrics_set.groups.items(): + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._metrics_table.insert(values={ + **group.record, + 'group': group_name + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Table - {self.metrics_table_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to table - {self.custom_metrics_table_name}') + + if self._custom_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Custom Metrics table - {self.custom_metrics_table_name} - if not exists') + + custom_metrics_table = sqlalchemy.Table( + self.custom_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT), + ) + + for metrics_set in metrics_sets: + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + custom_metrics_table.append_column( + self.metric_types_map.get( + custom_metric.metric_type, + lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ) + )(custom_metric_name) + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(custom_metrics_table, if_not_exists=True) + ) + + self._custom_metrics_table = custom_metrics_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Custom Metrics table - {self.custom_metrics_table_name}') + + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._custom_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to table - {self.custom_metrics_table_name}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._errors_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Error Metrics table - {self.errors_table_name} - if not exists') + + errors_table = sqlalchemy.Table( + self.errors_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.TEXT), + sqlalchemy.Column('error_message', sqlalchemy.TEXT), + sqlalchemy.Column('error_count', sqlalchemy.BIGINT) + ) + + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + CreateTable(errors_table, if_not_exists=True) + ) + + self._errors_table = errors_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Error Metrics table - {self.errors_table_name}') + + for error in metrics_set.errors: + await self._loop.run_in_executor( + self._executor, + self._connection.execute, + self._errors_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Table - {self.errors_table_name}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connection to Snowflake instance at - Warehouse: {self.warehouse} - Database: {self.database} - Schema: {self.schema}') + + await self._loop.run_in_executor( + None, + self._connection.close + ) + + self._executor.shutdown() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closed session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connection to Snowflake instance at - Warehouse: {self.warehouse} - Database: {self.database} - Schema: {self.schema}') \ No newline at end of file diff --git a/hyperscale/reporting/types/snowflake/snowflake_config.py b/hyperscale/reporting/types/snowflake/snowflake_config.py new file mode 100644 index 0000000..d98f6a8 --- /dev/null +++ b/hyperscale/reporting/types/snowflake/snowflake_config.py @@ -0,0 +1,26 @@ +from typing import Optional + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class SnowflakeConfig(BaseModel): + username: str + password: str + organization_id: str + account_id: str + private_key: Optional[str] + warehouse: str + database: str + database_schema: str='PUBLIC' + events_table: str='events' + metrics_table: str='metrics' + experiments_table: str='experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + connect_timeout: int=30 + reporter_type: ReporterTypes=ReporterTypes.Snowflake + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/reporting/types/sqlite/__init__.py b/hyperscale/reporting/types/sqlite/__init__.py new file mode 100644 index 0000000..407a9cc --- /dev/null +++ b/hyperscale/reporting/types/sqlite/__init__.py @@ -0,0 +1,2 @@ +from .sqlite import SQLite +from .sqlite_config import SQLiteConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/sqlite/sqlite.py b/hyperscale/reporting/types/sqlite/sqlite.py new file mode 100644 index 0000000..07e9ea4 --- /dev/null +++ b/hyperscale/reporting/types/sqlite/sqlite.py @@ -0,0 +1,661 @@ +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .sqlite_config import SQLiteConfig + +try: + import sqlalchemy + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.schema import CreateTable + + has_connector = True + +except Exception: + ASYNCIO_STRATEGY = None + sqlalchemy = None + CreateTable = None + OperationalError = None + has_connector = False + + + +class SQLite: + + def __init__(self, config: SQLiteConfig) -> None: + self.path = f'sqlite+aiosqlite:///{config.path}' + self.events_table_name = config.events_table + self.metrics_table_name = config.metrics_table + + + self.experiments_table_name = config.experiments_table + self.streams_table_name = config.streams_table + self.variants_table_name = f'{config.experiments_table}_variants' + self.mutations_table_name = f'{config.experiments_table}_mutations' + + self.shared_metrics_table_name = f'{config.metrics_table}_shared' + self.errors_table_name = f'{config.metrics_table}_errors' + self.custom_metrics_table_name = f'{config.metrics_table}_custom' + + self.session_system_metrics_table_name = f'{config.system_metrics_table}_session' + self.stage_system_metrics_table_name = f'{config.system_metrics_table}_stage' + + self.metadata = sqlalchemy.MetaData() + + self.database = None + self._engine = None + + self._events_table = None + self._metrics_table = None + self._streams_table = None + + self._experiments_table = None + self._variants_table = None + self._mutations_table = None + + self._shared_metrics_table = None + self._custom_metrics_table = None + self._errors_table = None + + self._session_system_metrics_table = None + self._stage_system_metrics_table = None + + self.metric_types_map = { + MetricType.COUNT: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.BIGINT + ), + MetricType.DISTRIBUTION: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + MetricType.SAMPLE: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + MetricType.RATE: lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ), + } + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to SQLite at - {self.path} - Database: {self.database}') + self._engine = create_async_engine(self.path) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to SQLite at - {self.path} - Database: {self.database}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async with self._engine.begin() as connection: + + async with connection.begin() as transaction: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Initiating transaction') + + if self._session_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metrics table - {self.session_system_metrics_table_name} - if not exists') + + session_system_metrics_table = sqlalchemy.Table( + self.session_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + session_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await connection.execute( + CreateTable( + session_system_metrics_table, + if_not_exists=True + ) + ) + + self._session_system_metrics_table = session_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Session System Metrics table - {self.session_system_metrics_table_name}') + + rows: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + await connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async with self._engine.begin() as connection: + + async with connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Initiating transaction') + if self._stage_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metrics table - {self.stage_system_metrics_table_name} - if not exists') + + stage_system_metrics_table = sqlalchemy.Table( + self.stage_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + stage_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await connection.execute( + CreateTable( + stage_system_metrics_table, + if_not_exists=True + ) + ) + + self._stage_system_metrics_table = stage_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Stage System Metrics table - {self.stage_system_metrics_table_name}') + + rows: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + await connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name}') + + async with self._engine.begin() as connection: + + async with connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Initiating transaction') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + if self._streams_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Streams table - {self.streams_table_name} - if not exists') + + stream_table = sqlalchemy.Table( + self.streams_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in stream.quantiles: + stream_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await connection.execute( + CreateTable( + stream_table, + if_not_exists=True + ) + ) + + self._streams_table = stream_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Streams table - {self.streams_table_name}') + + for group_name, group in stream.grouped.items(): + await connection.execute( + self._streams_table.insert(values={ + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Table - {self.streams_table_name}') + + async def submit_experiments(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name}') + + async with self._engine.begin() as connection: + + async with connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Initiating transaction') + + for experiment in experiments_metrics.experiment_summaries: + + if self._experiments_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments table - {self.experiments_table_name} - if not exists') + + experiments_table = sqlalchemy.Table( + self.experiments_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('experiment_randomized', sqlalchemy.BOOLEAN), + sqlalchemy.Column('experiment_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_median_aps', sqlalchemy.FLOAT), + ) + + await connection.execute(CreateTable(experiments_table, if_not_exists=True)) + + self._experiments_table = experiments_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Experiments table - {self.experiments_table_name}') + + await connection.execute(self._experiments_table.insert().values(**experiment.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Table - {self.experiments_table_name}') + + async def submit_variants(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name}') + + + async with self._engine.begin() as connection: + + async with connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Initiating transaction') + + for variant in experiments_metrics.variant_summaries: + + if self._variants_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variants table - {self.variants_table_name} - if not exists') + + variants_table = sqlalchemy.Table( + self.variants_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_experiment', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_weight', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_distribution', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_distribution_interval', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_actions_per_second', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_completed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_succeeded', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_failed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_aps', sqlalchemy.FLOAT), + ) + + await connection.execute(CreateTable(variants_table, if_not_exists=True)) + + self._variants_table = variants_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Variants table - {self.variants_table_name}') + + await connection.execute(self._variants_table.insert().values(**variant.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Table - {self.variants_table_name}') + + async def submit_mutations(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name}') + + async with self._engine.begin() as connection: + + async with connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Initiating transaction') + + for mutation in experiments_metrics.mutation_summaries: + + if self._mutations_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutations table - {self.mutations_table_name} - if not exists') + + mutations_table = sqlalchemy.Table( + self.mutations_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('mutation_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_chance', sqlalchemy.FLOAT), + sqlalchemy.Column('mutation_targets', sqlalchemy.VARCHAR(8192)), + sqlalchemy.Column('mutation_type', sqlalchemy.VARCHAR(255)), + ) + + await connection.execute(CreateTable(mutations_table, if_not_exists=True)) + + self._mutations_table = mutations_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Mutations table - {self.mutations_table_name}') + + await connection.execute(self._mutations_table.insert().values(**mutation.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Table - {self.mutations_table_name}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name}') + + async with self._engine.begin() as connection: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Initiating transaction') + + for event in events: + + if self._events_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Events table - {self.events_table_name} - if not exists') + + events_table = sqlalchemy.Table( + self.events_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.INTEGER, primary_key=True,), + sqlalchemy.Column('name', sqlalchemy.TEXT), + sqlalchemy.Column('stage', sqlalchemy.TEXT), + sqlalchemy.Column('time', sqlalchemy.REAL), + sqlalchemy.Column('succeeded', sqlalchemy.INTEGER) + ) + + + await connection.execute(CreateTable(events_table, if_not_exists=True)) + self._events_table = events_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Events table - {self.events_table_name}') + + await connection.execute(self._events_table.insert(values=event.record)) + + await connection.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Table - {self.events_table_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name}') + + async with self._engine.begin() as connection: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Initiating transaction') + + if self._shared_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metrics table - {self.shared_metrics_table_name} - if not exists') + + stage_metrics_table = sqlalchemy.Table( + self.shared_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.INTEGER, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.TEXT), + sqlalchemy.Column('stage', sqlalchemy.TEXT), + sqlalchemy.Column('group', sqlalchemy.TEXT), + sqlalchemy.Column('total', sqlalchemy.INTEGER), + sqlalchemy.Column('succeeded', sqlalchemy.INTEGER), + sqlalchemy.Column('failed', sqlalchemy.INTEGER), + sqlalchemy.Column('actions_per_second', sqlalchemy.REAL) + ) + + await connection.execute(CreateTable(stage_metrics_table, if_not_exists=True)) + self._shared_metrics_table = stage_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Shared Metrics table - {self.shared_metrics_table_name}') + + for metrics_set in metrics_sets: + await connection.execute( + self._shared_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + ) + + await connection.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Table - {self.shared_metrics_table_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name}') + + async with self._engine.begin() as connection: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Initiating transaction') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metrics table - {self.metrics_table_name} - if not exists') + + metrics_table = sqlalchemy.Table( + self.metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.INTEGER, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.TEXT), + sqlalchemy.Column('stage', sqlalchemy.TEXT), + sqlalchemy.Column('group', sqlalchemy.TEXT), + sqlalchemy.Column('median', sqlalchemy.REAL), + sqlalchemy.Column('mean', sqlalchemy.REAL), + sqlalchemy.Column('variance', sqlalchemy.REAL), + sqlalchemy.Column('stdev', sqlalchemy.REAL), + sqlalchemy.Column('minimum', sqlalchemy.REAL), + sqlalchemy.Column('maximum', sqlalchemy.REAL) + ) + + for quantile in metrics_set.quantiles: + metrics_table.append_column( + sqlalchemy.Column(f'{quantile}', sqlalchemy.REAL) + ) + + for custom_field_name, sql_alchemy_type in metrics_set.custom_schemas: + metrics_table.append_column(custom_field_name, sql_alchemy_type) + + await connection.execute(CreateTable(metrics_table, if_not_exists=True)) + self._metrics_table = metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Metrics table - {self.metrics_table_name}') + + for group_name, group in metrics_set.groups.items(): + await connection.execute(self._metrics_table.insert(values={ + **group.record, + 'group': group_name + })) + + await connection.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Table - {self.metrics_table_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + async with self._engine.begin() as connection: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics - Initiating transaction') + + if self._custom_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Custom Metrics table - {self.custom_metrics_table_name} - if not exists') + + custom_metrics_table = sqlalchemy.Table( + self.custom_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.INTEGER, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.TEXT), + sqlalchemy.Column('stage', sqlalchemy.TEXT), + sqlalchemy.Column('group', sqlalchemy.TEXT), + ) + + for metrics_set in metrics_sets: + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + custom_metrics_table.append_column( + self.metric_types_map.get( + custom_metric.metric_type, + lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ) + )(custom_metric_name) + ) + + await connection.execute(CreateTable(custom_metrics_table, if_not_exists=True)) + self._custom_metrics_table = custom_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Custom Metrics table - {self.custom_metrics_table_name}') + + for metrics_set in metrics_sets: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + await connection.execute( + self._custom_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await connection.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitte Cudstom Metrics - Transaction committed') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name}') + + async with self._engine.begin() as connection: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Initiating transaction') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._errors_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Error Metrics table - {self.errors_table_name} - if not exists') + + metrics_errors_table = sqlalchemy.Table( + self.errors_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.INTEGER, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.TEXT), + sqlalchemy.Column('stage', sqlalchemy.TEXT), + sqlalchemy.Column('error_message', sqlalchemy.TEXT), + sqlalchemy.Column('error_count', sqlalchemy.INTEGER) + ) + + await connection.execute(CreateTable(metrics_errors_table, if_not_exists=True)) + + self._errors_table = metrics_errors_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Error Metrics table - {self.errors_table_name}') + + for error in metrics_set.errors: + await connection.execute( + self._errors_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + ) + + await connection.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Table - {self.errors_table_name}') + + async def close(self): + await self._engine.dispose() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') diff --git a/hyperscale/reporting/types/sqlite/sqlite_config.py b/hyperscale/reporting/types/sqlite/sqlite_config.py new file mode 100644 index 0000000..2e37f2b --- /dev/null +++ b/hyperscale/reporting/types/sqlite/sqlite_config.py @@ -0,0 +1,18 @@ +import os + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class SQLiteConfig(BaseModel): + path: str=f'{os.getcwd()}/results.db' + events_table: str='events' + metrics_table: str='metrics' + experiments_table: str='experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.SQLite + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/reporting/types/statsd/__init__.py b/hyperscale/reporting/types/statsd/__init__.py new file mode 100644 index 0000000..a95d7e8 --- /dev/null +++ b/hyperscale/reporting/types/statsd/__init__.py @@ -0,0 +1,2 @@ +from .statsd import StatsD +from .statsd_config import StatsDConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/statsd/statsd.py b/hyperscale/reporting/types/statsd/statsd.py new file mode 100644 index 0000000..63e35bd --- /dev/null +++ b/hyperscale/reporting/types/statsd/statsd.py @@ -0,0 +1,363 @@ +import re +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet, MetricType +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +from .statsd_config import StatsDConfig + +try: + from aio_statsd import StatsdClient + has_connector = True + +except Exception: + StatsdClient = None + has_connector = False + + +class StatsD: + + def __init__(self, config: StatsDConfig) -> None: + self.host = config.host + self.port = config.port + + self.connection = StatsdClient( + host=self.host, + port=self.port + ) + + self.types_map = { + 'total': 'count', + 'succeeded': 'count', + 'failed': 'count', + 'actions_per_second': 'gauge', + 'median': 'gauge', + 'mean': 'gauge', + 'variance': 'gauge', + 'stdev': 'gauge', + 'minimum': 'gauge', + 'maximum': 'gauge', + 'quantiles': 'gauge' + } + + self._update_map = { + 'count': self.connection.counter, + 'gauge': self.connection.gauge, + 'increment': self.connection.increment, + 'sets': self.connection.sets, + 'histogram': lambda: NotImplementedError('StatsD does not support histograms.'), + 'distribution': lambda: NotImplementedError('StatsD does not support distributions.'), + 'timer': self.connection.timer + + } + + self.stat_type_map = { + MetricType.COUNT: 'count', + MetricType.DISTRIBUTION: 'gauge', + MetricType.RATE: 'gauge', + MetricType.SAMPLE: 'gauge' + } + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.statsd_type = 'StatsD' + + async def connect(self): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connecting to {self.statsd_type} at - {self.host}:{self.port}') + await self.connection.connect() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Connected to {self.statsd_type} at - {self.host}:{self.port}') + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to {self.statsd_type}') + + metrics_sets: List[SessionMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Preparing Session System Metrics Set - {metrics_set.system_metrics_set_id}') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + for metrics_set in metrics_sets: + + for metric_field, metric_value in metrics_set.record.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metric Set - {metrics_set.name}:{metrics_set.group}:{metric_field}') + + update_type = metrics_set.types_map.get(metric_field) + stat_type = self.stat_type_map.get(update_type) + update_function = self._update_map.get(stat_type) + + update_function( + f'{metrics_set.name}_{metrics_set.group}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to {self.statsd_type}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to {self.statsd_type}') + + metrics_sets: List[SystemMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Preparing Stage System Metrics Set - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics) + + for metrics_set in metrics_sets: + + for metric_field, metric_value in metrics_set.record.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metric Set - {metrics_set.name}:{metrics_set.group}:{metric_field}') + + update_type = metrics_set.types_map.get(metric_field) + stat_type = self.stat_type_map.get(update_type) + update_function = self._update_map.get(stat_type) + + update_function( + f'{metrics_set.name}_{metrics_set.group}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to {self.statsd_type}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to {self.statsd_type}') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + + for metric_field, metric_value in group.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream Metric - {stage_name}:{group_name}:{metric_field}') + + update_type = stream.types_map.get(group_name) + stat_type = self.stat_type_map.get(update_type) + update_function = self._update_map.get(stat_type) + + update_function( + f'{stage_name}_stream_{group_name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to {self.statsd_type}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to {self.statsd_type}') + + for experiment in experiment_metrics.experiment_summaries: + + experiment_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment - {experiment.experiment_name}:{experiment_id}') + + for field, value in experiment.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment field - {experiment.experiment_name}:{field}') + + update_type = experiment.types_map.get(field) + update_function = self._update_map.get(update_type.value) + + update_function( + f'{experiment.experiment_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to {self.statsd_type}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to {self.statsd_type}') + + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variant - {variant.variant_name}:{variant_id}') + + for field, value in variant.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants field - {variant.variant_name}:{field}') + + update_type = variant.types_map.get(field) + update_function = self._update_map.get(update_type.value) + + update_function( + f'{variant.variant_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to {self.statsd_type}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to {self.statsd_type}') + + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutation - {mutation.mutation_name}:{mutation_id}') + + for field, value in mutation.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutatio field - {mutation.mutation_name}:{field}') + + update_type = mutation.types_map.get(field) + update_function = self._update_map.get(update_type.value) + + update_function( + f'{mutation.mutation_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to {self.statsd_type}') + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to {self.statsd_type}') + + for event in events: + time_update_function = self._update_map.get('gauge') + time_update_function(f'{event.name}_time', event.time) + + if event.success: + success_update_function = self._update_map.get('count') + success_update_function(f'{event.name}_success', 1) + + else: + failed_update_function = self._update_map.get('count') + failed_update_function(f'{event.name}_failed', 1) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to {self.statsd_type}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for field, value in metrics_set.common_stats.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metric - {metrics_set.name}:common:{field}') + + update_type = self.types_map.get(field) + update_function = self._update_map.get(update_type) + + update_function( + f'{metrics_set.name}_common_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to {self.statsd_type}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to {self.statsd_type}') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for group_name, group in metrics_set.groups.items(): + + metric_record = {**group.stats, **group.custom} + metric_types = {**self.types_map, **group.custom_schemas} + + for metric_field, metric_value in metric_record.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metric - {metrics_set.name}:{group_name}:{metric_field}') + + update_type = metric_types.get(metric_field) + update_function = self._update_map.get(update_type) + + update_function( + f'{metrics_set.name}_{group_name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to {self.statsd_type}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + metric_type = self.stat_type_map.get( + custom_metric.metric_type, + 'gauge' + ) + + update_function = self._update_map.get(metric_type) + update_function( + f'{metrics_set.name}_{custom_metric_name}', + custom_metric.metric_value + ) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to {self.statsd_type}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + error_message = re.sub( + '[^0-9a-zA-Z]+', + '_', + error.get( + 'message' + ).lower() + ) + + update_function = self._update_map.get('count') + update_function(f'{metrics_set.name}_errors_{error_message}', error.get('count')) + + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to {self.statsd_type}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connection to {self.statsd_type} at - {self.host}:{self.port}') + await self.connection.close() + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connection to {self.statsd_type} at - {self.host}:{self.port}') \ No newline at end of file diff --git a/hyperscale/reporting/types/statsd/statsd_config.py b/hyperscale/reporting/types/statsd/statsd_config.py new file mode 100644 index 0000000..8c73388 --- /dev/null +++ b/hyperscale/reporting/types/statsd/statsd_config.py @@ -0,0 +1,12 @@ +from typing import Dict + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class StatsDConfig(BaseModel): + host: str='localhost' + port: int=8125 + custom_fields: Dict[str, str]={} + reporter_type: ReporterTypes=ReporterTypes.StatsD \ No newline at end of file diff --git a/hyperscale/reporting/types/telegraf/__init__.py b/hyperscale/reporting/types/telegraf/__init__.py new file mode 100644 index 0000000..94e3795 --- /dev/null +++ b/hyperscale/reporting/types/telegraf/__init__.py @@ -0,0 +1,2 @@ +from .telegraf import Telegraf +from .telegraf_config import TelegrafConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/telegraf/telegraf.py b/hyperscale/reporting/types/telegraf/telegraf.py new file mode 100644 index 0000000..10dcca1 --- /dev/null +++ b/hyperscale/reporting/types/telegraf/telegraf.py @@ -0,0 +1,287 @@ + +import uuid +from typing import Dict, List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +try: + from aio_statsd import TelegrafClient + + from hyperscale.reporting.types.statsd import StatsD + + from .telegraf_config import TelegrafConfig + has_connector = True + +except Exception: + from hyperscale.reporting.types.empty import Empty as StatsD + TelegrafConfig=None + has_connector = False + + +class Telegraf(StatsD): + + def __init__(self, config: TelegrafConfig) -> None: + self.host = config.host + self.port = config.port + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + + self.connection = TelegrafClient( + host=self.host, + port=self.port + ) + + self.statsd_type = 'Telegraf' + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to {self.statsd_type}') + + metrics_sets: List[SessionMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Preparing Session System Metrics Set - {metrics_set.system_metrics_set_id}') + + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + for metrics_set in metrics_sets: + + for metric_field, metric_value in metrics_set.record.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metric Set - {metrics_set.name}:{metrics_set.group}:{metric_field}') + + self.connection.send_telegraf( + f'{metrics_set.group}_{metrics_set.name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to {self.statsd_type}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to {self.statsd_type}') + + metrics_sets: List[SystemMetricsCollection] = [] + for metrics_set in system_metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Preparing Stage System Metrics Set - {metrics_set.system_metrics_set_id}') + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + metrics_sets.append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + metrics_sets.append(stage_mb_per_vu_metrics.record) + + for metrics_set in metrics_sets: + + for metric_field, metric_value in metrics_set.record.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metric Set - {metrics_set.name}:{metrics_set.group}:{metric_field}') + + self.connection.send_telegraf( + f'{metrics_set.group}_{metrics_set.group}_{metrics_set.name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to {self.statsd_type}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to {self.statsd_type}') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream - {stage_name}:{stream.stream_set_id}') + + for group_name, group in stream.grouped.items(): + + for metric_field, metric_value in group.items(): + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stream Metric - {stage_name}:{group_name}:{metric_field}') + + self.connection.send_telegraf( + f'{stage_name}_stream_{group_name}_{metric_field}', + metric_value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to {self.statsd_type}') + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to {self.statsd_type}') + + for experiment in experiment_metrics.experiment_summaries: + + experiment_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment - {experiment.experiment_name}:{experiment_id}') + + for field, value in experiment.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiment field - {experiment.experiment_name}:{field}') + + self.connection.send_telegraf( + f'{experiment.experiment_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to {self.statsd_type}') + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to {self.statsd_type}') + + for variant in experiment_metrics.variant_summaries: + + variant_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variant - {variant.variant_name}:{variant_id}') + + for field, value in variant.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants field - {variant.variant_name}:{field}') + + self.connection.send_telegraf( + f'{variant.variant_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to {self.statsd_type}') + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to {self.statsd_type}') + + for mutation in experiment_metrics.mutation_summaries: + + mutation_id = uuid.uuid4() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutation - {mutation.mutation_name}:{mutation_id}') + + for field, value in mutation.stats: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutatio field - {mutation.mutation_name}:{field}') + + self.connection.send_telegraf( + f'{mutation.mutation_name}_{field}', value + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to {self.statsd_type}') + + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to {self.statsd_type}') + + for event in events: + self.connection.send_telegraf(event.name, {'time': event.time}) + + if event.success: + self.connection.send_telegraf(event.name, {'success': 1}) + + else: + self.connection.send_telegraf(event.name, {'failed': 1}) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to {self.statsd_type}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + self.connection.send_telegraf( + f'{metrics_set.name}_common', + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + } + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to {self.statsd_type}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to {self.statsd_type}') + + for metrics_set in metrics: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + + for group_name, group in metrics_set.groups.items(): + self.connection.send_telegraf( + f'{metrics_set.name}_{group_name}', + { + **group.record, + 'group': group_name + } + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to {self.statsd_type}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + self.connection.send_telegraf( + f'{metrics_set.name}_custom', + { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + } + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to {self.statsd_type}') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to {self.statsd_type}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + self.connection.send_telegraf( + f'{metrics_set.name}_errors', { + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to {self.statsd_type}') \ No newline at end of file diff --git a/hyperscale/reporting/types/telegraf/telegraf_config.py b/hyperscale/reporting/types/telegraf/telegraf_config.py new file mode 100644 index 0000000..37df678 --- /dev/null +++ b/hyperscale/reporting/types/telegraf/telegraf_config.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class TelegrafConfig(BaseModel): + host: str='localhost' + port: int=8094 + reporter_type: ReporterTypes=ReporterTypes.Telegraf \ No newline at end of file diff --git a/hyperscale/reporting/types/telegraf_statsd/__init__.py b/hyperscale/reporting/types/telegraf_statsd/__init__.py new file mode 100644 index 0000000..69eae1a --- /dev/null +++ b/hyperscale/reporting/types/telegraf_statsd/__init__.py @@ -0,0 +1,2 @@ +from .telegraf_statsd import TelegrafStatsD +from .teleraf_statsd_config import TelegrafStatsDConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/telegraf_statsd/telegraf_statsd.py b/hyperscale/reporting/types/telegraf_statsd/telegraf_statsd.py new file mode 100644 index 0000000..9caa269 --- /dev/null +++ b/hyperscale/reporting/types/telegraf_statsd/telegraf_statsd.py @@ -0,0 +1,87 @@ +import uuid +from typing import List + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.metric import MetricType +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) + +try: + from aio_statsd import TelegrafStatsdClient + + from hyperscale.reporting.types.statsd import StatsD + + from .teleraf_statsd_config import TelegrafStatsDConfig + has_connector = True + +except Exception: + from hyperscale.reporting.types.empty import Empty as StatsD + TelegrafStatsDConfig=None + TelegrafStatsdClient = None + has_connector = False + + +class TelegrafStatsD(StatsD): + + def __init__(self, config: TelegrafStatsDConfig) -> None: + super().__init__(config) + self.connection = TelegrafStatsdClient( + host=self.host, + port=self.port + ) + + self.types_map = { + 'total': 'increment', + 'succeeded': 'increment', + 'failed': 'increment', + 'median': 'gauge', + 'mean': 'gauge', + 'variance': 'gauge', + 'stdev': 'gauge', + 'minimum': 'gauge', + 'maximum': 'gauge', + 'quantiles': 'gauge' + } + + self._update_map = { + 'count': lambda: NotImplementedError('TelegrafStatsD does not support counts.'), + 'gauge': self.connection.gauge, + 'sets': lambda: NotImplementedError('TelegrafStatsD does not support sets.'), + 'increment': self.connection.increment, + 'histogram': self.connection.histogram, + 'distribution': self.connection.distribution, + 'timer': self.connection.timer + } + + self.stat_type_map = { + MetricType.COUNT: 'increment', + MetricType.DISTRIBUTION: 'gauge', + MetricType.RATE: 'gauge', + MetricType.SAMPLE: 'gauge' + } + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + self.statsd_type = 'TelegrafStatsD' + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to {self.statsd_type}') + + for event in events: + time_update_function = self._update_map.get('gauge') + time_update_function(f'{event.name}_time', event.time) + + if event.success: + success_update_function = self._update_map.get('increment') + success_update_function(f'{event.name}_success', 1) + + else: + failed_update_function = self._update_map.get('increment') + failed_update_function(f'{event.name}_failed', 1) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to {self.statsd_type}') diff --git a/hyperscale/reporting/types/telegraf_statsd/teleraf_statsd_config.py b/hyperscale/reporting/types/telegraf_statsd/teleraf_statsd_config.py new file mode 100644 index 0000000..30fa074 --- /dev/null +++ b/hyperscale/reporting/types/telegraf_statsd/teleraf_statsd_config.py @@ -0,0 +1,12 @@ +from typing import Dict + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class TelegrafStatsDConfig(BaseModel): + host: str='0.0.0.0' + port: int=8125 + custom_fields: Dict[str, str]={} + reporter_type: ReporterTypes=ReporterTypes.TelegrafStatsD \ No newline at end of file diff --git a/hyperscale/reporting/types/timescaledb/__init__.py b/hyperscale/reporting/types/timescaledb/__init__.py new file mode 100644 index 0000000..a88d552 --- /dev/null +++ b/hyperscale/reporting/types/timescaledb/__init__.py @@ -0,0 +1,2 @@ +from .timescaledb import TimescaleDB +from .timescaledb_config import TimescaleDBConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/timescaledb/timescaledb.py b/hyperscale/reporting/types/timescaledb/timescaledb.py new file mode 100644 index 0000000..c2c2091 --- /dev/null +++ b/hyperscale/reporting/types/timescaledb/timescaledb.py @@ -0,0 +1,674 @@ +import uuid +from datetime import datetime +from typing import Dict, List + +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import ( + SessionMetricsCollection, + SystemMetricsCollection, + SystemMetricsSet, +) + +try: + import sqlalchemy + from sqlalchemy.dialects.postgresql import UUID + from sqlalchemy.schema import CreateTable + + from hyperscale.reporting.types.postgres.postgres import Postgres + + from .timescaledb_config import TimescaleDBConfig + has_connector=True + +except Exception: + sqlalchemy = None + UUID = None + from hyperscale.reporting.types.empty import Empty as Postgres + CreateTable = None + TimescaleDBConfig = None + has_connector = False + + +class TimescaleDB(Postgres): + + def __init__(self, config: TimescaleDBConfig) -> None: + super().__init__(config) + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async with self._connection.begin() as transaction: + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Initiating transaction') + + if self._session_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Session System Metrics table - {self.session_system_metrics_table_name} - if not exists') + + session_system_metrics_table = sqlalchemy.Table( + self.session_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + session_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await self._connection.execute( + CreateTable( + session_system_metrics_table, + if_not_exists=True + ) + ) + + await self._connection.execute( + f"SELECT create_hypertable('{self.session_system_metrics_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.session_system_metrics_table_name} (name, time DESC);") + + self._session_system_metrics_table = session_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Session System Metrics table - {self.session_system_metrics_table_name}') + + rows: List[SessionMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + for monitor_metrics in metrics_set.session_cpu_metrics.values(): + rows.append(monitor_metrics) + + for monitor_metrics in metrics_set.session_memory_metrics.values(): + rows.append(monitor_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics - {metrics_set.name}:{metrics_set.group}') + + await self._connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Session System Metrics to Table - {self.session_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Session System Metrics to Table - {self.session_system_metrics_table_name}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Initiating transaction') + if self._stage_system_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Stage System Metrics table - {self.stage_system_metrics_table_name} - if not exists') + + stage_system_metrics_table = sqlalchemy.Table( + self.stage_system_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in SystemMetricsSet.quantiles: + stage_system_metrics_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await self._connection.execute( + CreateTable( + stage_system_metrics_table, + if_not_exists=True + ) + ) + + await self._connection.execute( + f"SELECT create_hypertable('{self.stage_system_metrics_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.stage_system_metrics_table_name} (name, time DESC);") + + self._stage_system_metrics_table = stage_system_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Stage System Metrics table - {self.stage_system_metrics_table_name}') + + rows: List[SystemMetricsCollection] = [] + + for metrics_set in system_metrics_sets: + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + for monitor_metrics in stage_cpu_metrics.values(): + rows.append(monitor_metrics) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + rows.append(monitor_metrics) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + rows.append(stage_mb_per_vu_metrics) + + for metrics_set in rows: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics - {metrics_set.name}:{metrics_set.group}') + + await self._connection.execute( + self._streams_table.insert(values=metrics_set.record) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Stage System Metrics to Table - {self.stage_system_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Stage System Metrics to Table - {self.stage_system_metrics_table_name}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Initiating transaction') + + for stage_name, stream in stream_metrics.items(): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams - {stage_name}:{stream.stream_set_id}') + + if self._streams_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Streams table - {self.streams_table_name} - if not exists') + + stream_table = sqlalchemy.Table( + self.streams_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT()), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT) + ) + + for quantile in stream.quantiles: + stream_table.append_column( + sqlalchemy.Column(f'quantile_{quantile}th', sqlalchemy.FLOAT) + ) + + await self._connection.execute( + CreateTable( + stream_table, + if_not_exists=True + ) + ) + + await self._connection.execute( + f"SELECT create_hypertable('{self.streams_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.streams_table_name} (name, time DESC);") + + self._streams_table = stream_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Streams table - {self.streams_table_name}') + + for group_name, group in stream.grouped.items(): + await self._connection.execute( + self._streams_table.insert(values={ + 'name': f'{stage_name}_streams', + 'stage': stage_name, + 'group': group_name, + **group + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Streams to Table - {self.streams_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Streams to Table - {self.streams_table_name}') + + + async def submit_experiments(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Initiating transaction') + + for experiment in experiments_metrics.experiment_summaries: + + if self._experiments_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Experiments table - {self.experiments_table_name} - if not exists') + + experiments_table = sqlalchemy.Table( + self.experiments_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('experiment_randomized', sqlalchemy.Boolean), + sqlalchemy.Column('experiment_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('experiment_median_aps', sqlalchemy.FLOAT), + ) + + await self._connection.execute(CreateTable(experiments_table, if_not_exists=True)) + + await self._connection.execute( + f"SELECT create_hypertable('{self.experiments_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.experiments_table_name} (name, time DESC);") + + self._experiments_table = experiments_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Experiments table - {self.experiments_table_name}') + + await self._connection.execute(self._experiments_table.insert().values(**experiment.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Experiments to Table - {self.experiments_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Experiments to Table - {self.experiments_table_name}') + + async def submit_variants(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Initiating transaction') + + for variant in experiments_metrics.variant_summaries: + + if self._variants_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Variants table - {self.variants_table_name} - if not exists') + + variants_table = sqlalchemy.Table( + self.variants_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_experiment', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_weight', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_distribution', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('variant_distribution_interval', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_completed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_failed', sqlalchemy.BIGINT), + sqlalchemy.Column('variant_actions_per_second', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_completed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_succeeded', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_failed', sqlalchemy.FLOAT), + sqlalchemy.Column('variant_ratio_aps', sqlalchemy.FLOAT), + ) + + await self._connection.execute(CreateTable(variants_table, if_not_exists=True)) + + await self._connection.execute( + f"SELECT create_hypertable('{self.variants_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.variants_table_name} (name, time DESC);") + + self._variants_table = variants_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Variants table - {self.variants_table_name}') + + await self._connection.execute(self._variants_table.insert().values(**variant.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Variants to Table - {self.variants_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Variants to Table - {self.variants_table_name}') + + async def submit_mutations(self, experiments_metrics: ExperimentMetricsCollectionSet): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Initiating transaction') + + for mutation in experiments_metrics.mutation_summaries: + + if self._mutations_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Mutations table - {self.mutations_table_name} - if not exists') + + mutations_table = sqlalchemy.Table( + self.mutations_table_name, + self.metadata, + sqlalchemy.Column('id', sqlalchemy.Integer, primary_key=True), + sqlalchemy.Column('mutation_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_experiment_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_variant_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('mutation_chance', sqlalchemy.FLOAT), + sqlalchemy.Column('mutation_targets', sqlalchemy.VARCHAR(8192)), + sqlalchemy.Column('mutation_type', sqlalchemy.VARCHAR(255)), + ) + + await self._connection.execute(CreateTable(mutations_table, if_not_exists=True)) + + await self._connection.execute( + f"SELECT create_hypertable('{self.mutations_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.mutations_table_name} (name, time DESC);") + + self._mutations_table = mutations_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Mutations table - {self.mutations_table_name}') + + await self._connection.execute(self._mutations_table.insert().values(**mutation.record)) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Mutations to Table - {self.mutations_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Mutations to Table - {self.mutations_table_name}') + + + async def submit_events(self, events: List[BaseProcessedResult]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Initiating transaction') + + for event in events: + + if self._events_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Events table - {self.events_table_name} - if not exists') + + events_table = sqlalchemy.Table( + self.events_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), primary_key=True, default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('request_time', sqlalchemy.Float), + sqlalchemy.Column('succeeded', sqlalchemy.Boolean), + sqlalchemy.Column('time', sqlalchemy.TIMESTAMP(timezone=False), nullable=False, default=datetime.now()) + ) + + await self._connection.execute(CreateTable(events_table, if_not_exists=True)) + await self._connection.execute( + f"SELECT create_hypertable('{self.events_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.events_table_name} (name, time DESC);") + + self._events_table = events_table + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Events table - {self.events_table_name}') + + record = event.record + record['request_time'] = record['time'] + del record['time'] + + await self._connection.execute(self._events_table.insert(values={ + **record, + 'time': datetime.now().timestamp() + })) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Events to Table - {self.events_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Events to Table - {self.events_table_name}') + + async def submit_common(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Initiating transaction') + + if self._stage_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Shared Metrics table - {self.shared_metrics_table_name} - if not exists') + + stage_metrics_table = sqlalchemy.Table( + self.stage_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('total', sqlalchemy.BIGINT), + sqlalchemy.Column('succeeded', sqlalchemy.BIGINT), + sqlalchemy.Column('failed', sqlalchemy.BIGINT), + sqlalchemy.Column('actions_per_second', sqlalchemy.FLOAT), + sqlalchemy.Column('time', sqlalchemy.TIMESTAMP(timezone=False), nullable=False, default=datetime.now()) + ) + + await self._connection.execute(CreateTable(stage_metrics_table, if_not_exists=True)) + await self._connection.execute( + f"SELECT create_hypertable('{self.stage_metrics_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.stage_metrics_table_name} (name, time DESC);") + + self._stage_metrics_table = stage_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Shared Metrics table - {self.shared_metrics_table_name}') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + await self._connection.execute( + self._stage_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'common', + **metrics_set.common_stats + }) + ) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Shared Metrics to Table - {self.shared_metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Shared Metrics to Table - {self.shared_metrics_table_name}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Initiating transaction') + + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Metrics table - {self.metrics_table_name} - if not exists') + + metrics_table = sqlalchemy.Table( + self.metrics_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT), + sqlalchemy.Column('median', sqlalchemy.FLOAT), + sqlalchemy.Column('mean', sqlalchemy.FLOAT), + sqlalchemy.Column('variance', sqlalchemy.FLOAT), + sqlalchemy.Column('stdev', sqlalchemy.FLOAT), + sqlalchemy.Column('minimum', sqlalchemy.FLOAT), + sqlalchemy.Column('maximum', sqlalchemy.FLOAT), + sqlalchemy.Column('time', sqlalchemy.TIMESTAMP(timezone=False), nullable=False, default=datetime.now()) + ) + + for quantile in metrics_set.quantiles: + metrics_table.append_column( + sqlalchemy.Column(f'{quantile}', sqlalchemy.FLOAT) + ) + + for custom_field_name, sql_alchemy_type in metrics_set.custom_schemas: + metrics_table.append_column(custom_field_name, sql_alchemy_type) + + await self._connection.execute(CreateTable(metrics_table, if_not_exists=True)) + await self._connection.execute( + f"SELECT create_hypertable('{self.metrics_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.metrics_table_name} (name, time DESC);") + + self._metrics_table = metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Metrics table - {self.metrics_table_name}') + + for group_name, group in metrics_set.groups.items(): + await self._connection.execute(self._metrics_table.insert(values={ + **group.record, + 'group': group_name + })) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Metrics to Table - {self.metrics_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Metrics to Table - {self.metrics_table_name}') + + async def submit_custom(self, metrics_sets: List[MetricsSet]): + + if self._custom_metrics_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Custom Metrics table - {self.custom_metrics_table_name} - if not exists') + + custom_metrics_table = sqlalchemy.Table( + self.custom_metrics_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), default=uuid.uuid4), + sqlalchemy.Column('name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('stage', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('group', sqlalchemy.TEXT), + sqlalchemy.Column('time', sqlalchemy.TIMESTAMP(timezone=False), nullable=False, default=datetime.now()) + ) + + for metrics_set in metrics_sets: + for custom_metric_name, custom_metric in metrics_set.custom_metrics.items(): + + custom_metrics_table.append_column( + self.metric_types_map.get( + custom_metric.metric_type, + lambda field_name: sqlalchemy.Column( + field_name, + sqlalchemy.FLOAT + ) + )(custom_metric_name) + ) + + await self._connection.execute( + CreateTable(custom_metrics_table, if_not_exists=True) + ) + + await self._connection.execute( + f"SELECT create_hypertable('{self.custom_metrics_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.custom_metrics_table_name} (name, time DESC);") + + self._custom_metrics_table = custom_metrics_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Custom Metrics table - {self.custom_metrics_table_name}') + + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics - Initiating transaction') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Custom Metrics to table - {self.custom_metrics_table_name}') + + await self._connection.execute( + self._custom_metrics_table.insert(values={ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'group': 'custom', + **{ + custom_metric_name: custom_metric.metric_value for custom_metric_name, custom_metric in metrics_set.custom_metrics.items() + } + }) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Custom Metrics to table - {self.custom_metrics_table_name}') + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics - Transaction committed') + + async def submit_errors(self, metrics_sets: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name}') + + async with self._connection.begin() as transaction: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Initiating transaction') + + for metrics_set in metrics_sets: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + if self._errors_table is None: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Creating Error Metrics table - {self.errors_table_name} - if not exists') + + errors_table = sqlalchemy.Table( + self.errors_table_name, + self.metadata, + sqlalchemy.Column('id', UUID(as_uuid=True), default=uuid.uuid4), + sqlalchemy.Column('metric_name', sqlalchemy.VARCHAR(255)), + sqlalchemy.Column('metrics_stage', sqlalchemy.TEXT), + sqlalchemy.Column('error_message', sqlalchemy.TEXT), + sqlalchemy.Column('error_count', sqlalchemy.BIGINT), + sqlalchemy.Column('time', sqlalchemy.TIMESTAMP(timezone=False), nullable=False, default=datetime.now()) + ) + + + await self._connection.execute(CreateTable(errors_table, if_not_exists=True)) + await self._connection.execute( + f"SELECT create_hypertable('{self.errors_table_name}', 'time', migrate_data => true, if_not_exists => TRUE, create_default_indexes=>FALSE);" + ) + + await self._connection.execute(f"CREATE INDEX ON {self.errors_table_name}_errors (name, time DESC);") + + self._errors_table = errors_table + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Created or set Error Metrics table - {self.errors_table_name}') + + for error in metrics_set.errors: + await self._connection.execute(self._metrics_table.insert(values={ + 'metric_name': metrics_set.name, + 'metrics_stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + })) + + await transaction.commit() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics to Table - {self.errors_table_name} - Transaction committed') + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Submitted Error Metrics to Table - {self.errors_table_name}') + + async def close(self): + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closing connectiion to {self.sql_type} at - {self.host}') + + await self._connection.close() + self._engine.terminate() + await self._engine.wait_closed() + + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Session Closed - {self.session_uuid}') + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Closed connectiion to {self.sql_type} at - {self.host}') + diff --git a/hyperscale/reporting/types/timescaledb/timescaledb_config.py b/hyperscale/reporting/types/timescaledb/timescaledb_config.py new file mode 100644 index 0000000..ac4981f --- /dev/null +++ b/hyperscale/reporting/types/timescaledb/timescaledb_config.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class TimescaleDBConfig(BaseModel): + host: str='localhost' + database: str + username: str + password: str + events_table: str='events' + metrics_table: str='metrics' + experiments_table: str='experiments' + streams_table: str='streams' + system_metrics_table: str='system_metrics' + reporter_type: ReporterTypes=ReporterTypes.TimescaleDB + + class Config: + arbitrary_types_allowed = True \ No newline at end of file diff --git a/hyperscale/reporting/types/xml/__init__.py b/hyperscale/reporting/types/xml/__init__.py new file mode 100644 index 0000000..ae5eb11 --- /dev/null +++ b/hyperscale/reporting/types/xml/__init__.py @@ -0,0 +1,2 @@ +from .xml import XML +from .xml_config import XMLConfig \ No newline at end of file diff --git a/hyperscale/reporting/types/xml/xml.py b/hyperscale/reporting/types/xml/xml.py new file mode 100644 index 0000000..00be377 --- /dev/null +++ b/hyperscale/reporting/types/xml/xml.py @@ -0,0 +1,689 @@ +import asyncio +import collections +import collections.abc +import functools +import os +import signal +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Dict, List, TextIO, Union +from xml.dom.minidom import parseString + +import psutil + +from hyperscale.logging import HyperscaleLogger +from hyperscale.reporting.experiment.experiments_collection import ( + ExperimentMetricsCollectionSet, +) +from hyperscale.reporting.metric.metrics_set import MetricsSet +from hyperscale.reporting.metric.stage_streams_set import StageStreamsSet +from hyperscale.reporting.processed_result.types.base_processed_result import ( + BaseProcessedResult, +) +from hyperscale.reporting.system.system_metrics_set import SystemMetricsSet + +from .xml_config import XMLConfig + +try: + from dicttoxml import dicttoxml +except Exception: + dicttoxml = object + +collections.Iterable = collections.abc.Iterable + + +def handle_loop_stop( + signame, + executor: ThreadPoolExecutor, + loop: asyncio.AbstractEventLoop, + events_file: TextIO +): + try: + events_file.close() + executor.shutdown(wait=False, cancel_futures=True) + loop.stop() + except Exception: + pass + + +MetricRecord = Dict[str, Union[int, float, str]] +MetricRecordGroup = Dict[str, List[MetricRecord]] +MetricRecordCollection = Dict[str, MetricRecord] + + +class XML: + + def __init__(self, config: XMLConfig) -> None: + self.events_filepath = config.events_filepath + self.metrics_filepath = Path(config.metrics_filepath).absolute() + self.experiments_filepath = config.experiments_filepath + self._executor = ThreadPoolExecutor(max_workers=psutil.cpu_count(logical=False)) + self._loop: asyncio.AbstractEventLoop = None + + self.session_uuid = str(uuid.uuid4()) + self.metadata_string: str = None + self.logger = HyperscaleLogger() + self.logger.initialize() + + experiments_path = Path(self.experiments_filepath) + experiments_directory = experiments_path.parent + experiments_filename = experiments_path.stem + + self.variants_filepath = os.path.join( + experiments_directory, + f'{experiments_filename}_variants.xml' + ) + + self.mutations_filepath = os.path.join( + experiments_directory, + f'{experiments_filename}_mutations.xml' + ) + + filepath = Path(config.metrics_filepath) + base_filepath = filepath.parent + base_filename = filepath.stem + + self.shared_metrics_filepath = os.path.join( + base_filepath, + f'{base_filename}_shared.xml' + ) + + self.custom_metrics_filepath = os.path.join( + base_filepath, + f'{base_filename}_custom.xml' + ) + + self.errors_metrics_filepath = os.path.join( + base_filepath, + f'{base_filename}_errors.xml' + ) + + self.streams_metrics_filepath = config.streams_filepath + + system_metrics_path = Path(config.system_metrics_filepath) + system_metrics_directory = system_metrics_path.parent + system_metrics_filename = system_metrics_path.stem + + self.stage_system_metrics_filepath = os.path.join( + system_metrics_directory, + f'{system_metrics_filename}_stages.xml' + ) + + self.session_system_metrics_filepath = os.path.join( + system_metrics_directory, + f'{system_metrics_filename}_session.xml' + ) + + self.events_file: TextIO = None + self.metrics_file: TextIO = None + self.experiments_file: TextIO = None + self.variants_file: TextIO = None + self.mutations_file: TextIO = None + self.streams_file: TextIO = None + self.stage_system_metrics_file: TextIO = None + self.session_system_metrics_file: TextIO = None + + self.write_mode = 'w' if config.overwrite else 'a' + + async def connect(self): + self._loop = asyncio._get_running_loop() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Setting filepaths') + + original_filepath = Path(self.events_filepath) + + directory = original_filepath.parent + filename = original_filepath.stem + + events_file_timestamp = time.time() + + self.events_filepath = os.path.join( + directory, + f'{filename}_{events_file_timestamp}.xml' + ) + + async def submit_session_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + if self.session_system_metrics_file is None: + self.session_system_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.session_system_metrics_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.session_system_metrics_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Session System Metrics to file - {self.session_system_metrics_filepath}') + + metrics_sets: Dict[str, MetricRecord] = {} + + for metrics_set in system_metrics_sets: + + for monitor_name, monitor_metrics in metrics_set.session_cpu_metrics.items(): + + system_metrics_collection: MetricRecordCollection = {} + + memory_metrics = metrics_set.session_memory_metrics.get(monitor_name) + + system_metrics_collection['cpu'] = monitor_metrics.record + system_metrics_collection['memory'] = memory_metrics.record + + metrics_sets[monitor_name] = system_metrics_collection + + system_metrics_xml = dicttoxml( + metrics_sets, + custom_root='system' + ) + + system_metrics_xml = parseString(system_metrics_xml) + + await self._loop.run_in_executor( + self._executor, + self.session_system_metrics_file.write, + system_metrics_xml.toprettyxml() + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Session System Metrics to file - {self.session_system_metrics_filepath}') + + async def submit_stage_system_metrics(self, system_metrics_sets: List[SystemMetricsSet]): + + if self.stage_system_metrics_file is None: + self.stage_system_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.stage_system_metrics_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.stage_system_metrics_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Stage System Metrics to file - {self.stage_system_metrics_file}') + + metrics_sets: List[MetricRecordGroup]= [] + + for metrics_set in system_metrics_sets: + + cpu_metrics = metrics_set.cpu + memory_metrics = metrics_set.memory + + for stage_name, stage_cpu_metrics in cpu_metrics.metrics.items(): + + stage_system_metrics_record: MetricRecordGroup = { + 'cpu': [], + 'memory': [], + 'mb_per_vu': [] + } + + for monitor_metrics in stage_cpu_metrics.values(): + stage_system_metrics_record['cpu'].append(monitor_metrics.record) + + stage_memory_metrics = memory_metrics.metrics.get(stage_name) + for monitor_metrics in stage_memory_metrics.values(): + stage_system_metrics_record['memory'].append(monitor_metrics.record) + + stage_mb_per_vu_metrics = metrics_set.mb_per_vu.get(stage_name) + + if stage_mb_per_vu_metrics: + stage_system_metrics_record['mb_per_vu'].append(stage_mb_per_vu_metrics.record) + + metrics_sets.append(stage_system_metrics_record) + + system_metrics_xml = dicttoxml( + metrics_sets, + custom_root='system' + ) + + system_metrics_xml = parseString(system_metrics_xml) + + await self._loop.run_in_executor( + self._executor, + self.stage_system_metrics_file.write, + system_metrics_xml.toprettyxml() + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Stage System Metrics to file - {self.stage_system_metrics_filepath}') + + async def submit_streams(self, stream_metrics: Dict[str, StageStreamsSet]): + if self.streams_file is None: + self.streams_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.streams_metrics_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.streams_file + ) + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Streams to file - {self.streams_metrics_filepath}') + + streams_data = [ + { + 'stage': stream_name, + **stream_set.grouped + } for stream_name, stream_set in stream_metrics.items() + ] + + streams_xml = dicttoxml( + streams_data, + custom_root='streams' + ) + + streams_xml = parseString(streams_xml) + + await self._loop.run_in_executor( + self._executor, + self.streams_file.write, + streams_xml.toprettyxml() + ) + + async def submit_experiments(self, experiment_metrics: ExperimentMetricsCollectionSet): + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Experiments to file - {self.experiments_filepath}') + + if self.experiments_file is None: + self.experiments_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.experiments_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.experiments_file + ) + ) + + experiments_xml = dicttoxml( + experiment_metrics.experiments, + custom_root='experiments' + ) + + experiments_xml = parseString(experiments_xml) + + await self._loop.run_in_executor( + self._executor, + self.experiments_file.write, + experiments_xml.toprettyxml() + ) + + async def submit_variants(self, experiment_metrics: ExperimentMetricsCollectionSet): + + if self.variants_file is None: + self.variants_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.variants_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.variants_file + ) + ) + + variants_xml = dicttoxml( + experiment_metrics.variants, + custom_root='variants' + ) + + variants_xml = parseString(variants_xml) + + await self._loop.run_in_executor( + self._executor, + self.variants_file.write, + variants_xml.toprettyxml() + ) + + async def submit_mutations(self, experiment_metrics: ExperimentMetricsCollectionSet): + + if self.mutations_file is None: + self.mutations_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.mutations_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.mutations_file + ) + ) + + + + mutations_xml = dicttoxml( + experiment_metrics.mutations, + custom_root='mutations' + ) + + mutations_xml = parseString(mutations_xml) + + await self._loop.run_in_executor( + self._executor, + self.mutations_file.write, + mutations_xml.toprettyxml() + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Events to file - {self.events_filepath}') + + async def connect(self): + self._loop = asyncio._get_running_loop() + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Skipping connect') + + original_filepath = Path(self.events_filepath) + + directory = original_filepath.parent + filename = original_filepath.stem + + events_file_timestamp = time.time() + + self.events_filepath = os.path.join( + directory, + f'{filename}_{events_file_timestamp}.xml' + ) + + async def submit_events(self, events: List[BaseProcessedResult]): + + if self.events_file is None: + self.events_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.events_filepath, + self.write_mode + ) + ) + + for signame in ('SIGINT', 'SIGTERM', 'SIG_IGN'): + self._loop.add_signal_handler( + getattr(signal, signame), + lambda signame=signame: handle_loop_stop( + signame, + self._executor, + self._loop, + self.events_file + ) + ) + + events_xml = dicttoxml([ + event.to_dict() for event in events + ], custom_root='events') + + events_xml = parseString(events_xml) + + await self._loop.run_in_executor( + self._executor, + self.events_file.write, + events_xml.toprettyxml() + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Events to file - {self.events_filepath}') + + async def submit_common(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Shared Metrics to file - {self.metrics_filepath}') + + shared_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.shared_metrics_filepath, + 'w' + ) + ) + + common_metrics_xml = dicttoxml([ + { + 'name': metric_set.name, + 'stage': metric_set.stage, + 'group': 'common', + **metric_set.common_stats + } for metric_set in metrics + ], custom_root='common_metrics') + + common_metrics_xml = parseString(common_metrics_xml) + + await self._loop.run_in_executor( + self._executor, + shared_metrics_file.write, + common_metrics_xml.toprettyxml() + ) + + await self._loop.run_in_executor( + self._executor, + shared_metrics_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Shared Metrics to file - {self.metrics_filepath}') + + async def submit_metrics(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Metrics to file - {self.metrics_filepath}') + + metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.metrics_filepath, + 'w' + ) + ) + + metrics_data = [] + for metrics_set in metrics: + for group_name, group in metrics_set.groups.items(): + metrics_data.append({ + **group.record, + 'group': group_name + }) + + + metrics_xml = dicttoxml(metrics_data, custom_root='metrics') + metrics_xml = parseString(metrics_xml) + + await self._loop.run_in_executor( + self._executor, + metrics_file.write, + metrics_xml.toprettyxml() + ) + + await self._loop.run_in_executor( + self._executor, + metrics_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Metrics to file - {self.metrics_filepath}') + + async def submit_custom(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Custom Metrics to file - {self.metrics_filepath}') + + custom_metrics_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.custom_metrics_filepath, + 'w' + ) + ) + + metrics_data = [] + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Custom Metrics Group - Custom') + + metrics_data.append({ + **{ + cusom_metric_name: custom_metric.metric_value for cusom_metric_name, custom_metric in metrics_set.custom_metrics.items() + }, + 'group': 'custom' + }) + + custom_metrics_xml = dicttoxml(metrics_data, custom_root='custom_metrics') + custom_metrics_xml = parseString(custom_metrics_xml) + + await self._loop.run_in_executor( + self._executor, + custom_metrics_file.write, + custom_metrics_xml.toprettyxml() + ) + + await self._loop.run_in_executor( + self._executor, + custom_metrics_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Custom Metrics to file - {self.metrics_filepath}') + + + async def submit_errors(self, metrics: List[MetricsSet]): + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saving Error Metrics to file - {self.metrics_filepath}') + + errors_file = await self._loop.run_in_executor( + self._executor, + functools.partial( + open, + self.errors_metrics_filepath, + 'w' + ) + ) + + errors = [] + for metrics_set in metrics: + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Submitting Error Metrics Set - {metrics_set.name}:{metrics_set.metrics_set_id}') + + for error in metrics_set.errors: + errors.append({ + 'name': metrics_set.name, + 'stage': metrics_set.stage, + 'error_message': error.get('message'), + 'error_count': error.get('count') + }) + + errors_xml = dicttoxml(errors, custom_root='errors') + errors_xml = parseString(errors_xml) + + await self._loop.run_in_executor( + self._executor, + errors_file.write, + errors_xml.toprettyxml() + ) + + await self._loop.run_in_executor( + self._executor, + errors_file.close + ) + + await self.logger.filesystem.aio['hyperscale.reporting'].info(f'{self.metadata_string} - Saved Error Metrics to file - {self.metrics_filepath}') + + async def close(self): + + if self.events_file: + await self._loop.run_in_executor( + self._executor, + self.events_file.close + ) + + if self.metrics_file: + await self._loop.run_in_executor( + self._executor, + self.metrics_file.close + ) + + if self.experiments_file: + await self._loop.run_in_executor( + self._executor, + self.experiments_file.close + ) + + if self.variants_file: + await self._loop.run_in_executor( + self._executor, + self.variants_file.close + ) + + if self.mutations_file: + await self._loop.run_in_executor( + self._executor, + self.mutations_file.close + ) + + if self.streams_file: + await self._loop.run_in_executor( + self._executor, + self.streams_file.close + ) + + if self.stage_system_metrics_file: + await self._loop.run_in_executor( + self._executor, + self.stage_system_metrics_file.close + ) + + if self.session_system_metrics_file: + await self._loop.run_in_executor( + self._executor, + self.session_system_metrics_file.close + ) + + self._executor.shutdown(cancel_futures=True) + await self.logger.filesystem.aio['hyperscale.reporting'].debug(f'{self.metadata_string} - Closing session - {self.session_uuid}') diff --git a/hyperscale/reporting/types/xml/xml_config.py b/hyperscale/reporting/types/xml/xml_config.py new file mode 100644 index 0000000..81149db --- /dev/null +++ b/hyperscale/reporting/types/xml/xml_config.py @@ -0,0 +1,30 @@ +import os + +from pydantic import BaseModel + +from hyperscale.reporting.types.common.types import ReporterTypes + + +class XMLConfig(BaseModel): + events_filepath: str=os.path.join( + os.getcwd(), + 'events.xml' + ) + metrics_filepath: str=os.path.join( + os.getcwd(), + 'metrics.xml' + ) + experiments_filepath: str=os.path.join( + os.getcwd(), + 'experiments.xml' + ) + streams_filepath: str=os.path.join( + os.getcwd(), + 'streams.xml' + ) + system_metrics_filepath: str=os.path.join( + os.getcwd(), + 'system_metrics.xml' + ) + overwrite: bool=True + reporter_type: ReporterTypes=ReporterTypes.XML diff --git a/hyperscale/tools/__init__.py b/hyperscale/tools/__init__.py new file mode 100644 index 0000000..8a328d6 --- /dev/null +++ b/hyperscale/tools/__init__.py @@ -0,0 +1,2 @@ +from .data_structures import AsyncList +from .helpers import awaitable \ No newline at end of file diff --git a/hyperscale/tools/data_structures/__init__.py b/hyperscale/tools/data_structures/__init__.py new file mode 100644 index 0000000..781aea4 --- /dev/null +++ b/hyperscale/tools/data_structures/__init__.py @@ -0,0 +1 @@ +from .async_list import AsyncList \ No newline at end of file diff --git a/hyperscale/tools/data_structures/async_list.py b/hyperscale/tools/data_structures/async_list.py new file mode 100644 index 0000000..0741803 --- /dev/null +++ b/hyperscale/tools/data_structures/async_list.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from functools import reduce +from typing import Any, AsyncIterable, List, Union + +from hyperscale.tools.helpers import awaitable, wrap + + +class AsyncList: + + def __init__(self, data=[]): + self.data = data + + def __getitem__(self, start: int, stop: int=None) -> Union[AsyncList, Any]: + if stop: + subset_list = self.data[start:stop] + return AsyncList(subset_list) + + return self.data[start] + + def __setitem__(self, index: int, value: Any) -> None: + self.data[index] = value + + def __add__(self, value: Any) -> List[Any]: + appended_list = [*self.data, value] + return AsyncList(appended_list) + + def __mul__(self, value: int) -> AsyncList: + multiplied_list = self.data * value + return AsyncList(multiplied_list) + + def __rmul__(self, value: int) -> AsyncList: + multiplied_list = self.data * value + return AsyncList(multiplied_list) + + def __iter__(self) -> AsyncIterable: + for item in self.data: + yield item + + async def __aiter__(self) -> AsyncIterable: + for item in self.data: + yield item + + async def enum(self) -> AsyncIterable: + for idx, item in enumerate(self.data): + yield idx, item + + async def size(self) -> int: + return await awaitable(len, self.data) + + async def is_empty(self) -> bool: + list_size = await self.size() + return list_size == 0 + + async def append(self, value) -> None: + self.data = [ + *self.data, + value + ] + + async def prepend(self, value) -> None: + self.data = [ + value, + self.data + ] + + async def insert(self, idx, value) -> None: + size = await self.size(self.data) + if idx >= size: + await self.append(value) + + elif idx < 0: + await self.prepend(value) + + else: + self.data = [ + self.data[:idx-1], + value, + self.data[idx:] + ] + + async def at(self, idx) -> Any: + size = await self.size(self.data) + if idx >= size: + return None + + return self.data[idx] + + async def find(self, value) -> Any: + for item in self.data: + if item == value: + return value + + return None + + async def exists(self, value) -> bool: + found = await self.find(value) + return found is not None + + async def sort(self, **kwargs) -> AsyncList: + sorted_list = await awaitable(sorted, self.data, **kwargs) + return AsyncList(sorted_list) + + async def filter(self, condition) -> AsyncList: + filtered = [] + for item in self.data: + meets_condition = await awaitable(condition, item) + if meets_condition: + filtered = [ + *filtered, + item + ] + + return AsyncList(filtered) + + async def map(self, transform) -> AsyncList: + mapped = [] + for item in self.data: + mapped_value = await awaitable(transform, item) + mapped = [*mapped, mapped_value] + + return AsyncList(mapped) + + async def subscribe(self, hook, *args, **kwargs) -> None: + async_hook = await wrap(hook, args, kwargs) + async for item in async_hook(): + self.data = [ + *self.data, + item + ] + + async def publish(self, hook) -> None: + for item in self.data: + await awaitable(hook, item) + + async def join(self, join_character) -> str: + return await awaitable(self.data.join, join_character) + + async def replace(self, match_value: Any, replacement: Any, occurences: int=-1, in_place=True) -> Union[AsyncList, None]: + if occurences == -1: + occurences = await self.size() + + if in_place: + await self._replace_in_place(match_value, replacement, occurences) + else: + return await self._replace(match_value, replacement, occurences) + + async def _replace_in_place(self, match_value: Any, replacement: Any, occurences: int): + found = 0 + for idx, item in enumerate(self.data): + if item == match_value and found < occurences: + self.data[idx] = replacement + found += 1 + + async def _replace(self, match_value: Any, replacement: Any, occurences: int): + replaced_list = AsyncList(list(self.data)) + + found = 0 + for idx, item in enumerate(self.data): + if item == match_value and found < occurences: + replaced_list[idx] = replacement + found += 1 + + return replaced_list + + async def index(self, match_item: Any, offset: int=0, end: int=None): + try: + return await awaitable(self.data.index, match_item, offset, end) + except ValueError: + return -1 + + async def indexes(self, match_item: Any) -> List[int]: + + occurences = [] + for idx in range(self.data): + if self.data[idx] == match_item: + occurences = [ + *occurences, + idx + ] + + return occurences + + async def count(self, match_item: Any) -> int: + return await awaitable(self.data.count, match_item) + + async def reverse(self) -> None: + await awaitable(self.data.reverse) + + async def pop(self) -> Any: + return await awaitable(self.data.pop) + + async def remove(self, value: int) -> None: + await awaitable(self.data.remove, value) + + async def remove_index(self, index: int) -> AsyncList: + removed = self.data[index:index+1] + return AsyncList(removed) + + async def remove_range(self, start: int=0, stop: int=None) -> AsyncList: + if stop is None: + stop = await self.size() + return AsyncList(self.data[start:]) + elif start == 0: + return AsyncList(self.data[:stop]) + else: + removed = self.data[:start] + self.data[stop:] + return AsyncList(removed) + + async def copy(self) -> AsyncList: + return AsyncList(list(self.data)) + + async def extend(self, value: List[Any]) -> None: + await awaitable(self.data.extend, value) + + async def splice(self, start: int=0, stop: int=None, step=1) -> AsyncList: + spliced = [] + for idx in range(start, stop, step): + spliced = [ + *spliced, + self.data[idx] + ] + + return AsyncList(spliced) + + async def subset(self, values: List[Any]) -> AsyncList: + + found = [] + for value in values: + found_item = await self.find(value) + found = [ + *found, + found_item + ] + + return AsyncList(found) + + async def reduce(self, reducer: function) -> AsyncList: + reduced = await awaitable(reduce, reducer, self.data) + return AsyncList(reduced) + + async def maximum(self, *args, **kwargs): + return await awaitable(max, self.data, *args, **kwargs) + + async def minimum(self, *args, **kwargs): + return await awaitable(min, self.data, *args, **kwargs) + + async def clear(self) -> None: + await awaitable(self.data.clear) diff --git a/hyperscale/tools/filesystem/__init__.py b/hyperscale/tools/filesystem/__init__.py new file mode 100644 index 0000000..af1f92e --- /dev/null +++ b/hyperscale/tools/filesystem/__init__.py @@ -0,0 +1 @@ +from .filesystem import open \ No newline at end of file diff --git a/hyperscale/tools/filesystem/base.py b/hyperscale/tools/filesystem/base.py new file mode 100644 index 0000000..65a278d --- /dev/null +++ b/hyperscale/tools/filesystem/base.py @@ -0,0 +1,36 @@ + +from typing import Coroutine +from typing import Any + +class AsyncBase: + def __init__(self, file, loop, executor): + self._file = file + self._loop = loop + self._executor = executor + + async def __aiter__(self): + """We are our own iterator.""" + return self + + async def __anext__(self): + """Simulate normal file iteration.""" + line = await self.readline() + if line: + return line + else: + raise StopAsyncIteration + + +class AsyncIndirectBase(AsyncBase): + def __init__(self, name, loop, executor, indirect): + self._indirect = indirect + self._name = name + super().__init__(None, loop, executor) + + @property + def _file(self): + return self._indirect() + + @_file.setter + def _file(self, v): + pass # discard writes diff --git a/hyperscale/tools/filesystem/binary.py b/hyperscale/tools/filesystem/binary.py new file mode 100644 index 0000000..bfa5df3 --- /dev/null +++ b/hyperscale/tools/filesystem/binary.py @@ -0,0 +1,110 @@ + + +from .base import AsyncBase, AsyncIndirectBase +from .utils import ( + delegate_to_executor, + proxy_method_directly, + proxy_property_directly, +) + + +@delegate_to_executor( + "close", + "flush", + "isatty", + "read", + "read1", + "readinto", + "readline", + "readlines", + "seek", + "seekable", + "tell", + "truncate", + "writable", + "write", + "writelines", +) +@proxy_method_directly("detach", "fileno", "readable") +@proxy_property_directly("closed", "raw", "name", "mode") +class AsyncBufferedIOBase(AsyncBase): + """The asyncio executor version of io.BufferedWriter and BufferedIOBase.""" + + +@delegate_to_executor("peek") +class AsyncBufferedReader(AsyncBufferedIOBase): + """The asyncio executor version of io.BufferedReader and Random.""" + + +@delegate_to_executor( + "close", + "flush", + "isatty", + "read", + "readall", + "readinto", + "readline", + "readlines", + "seek", + "seekable", + "tell", + "truncate", + "writable", + "write", + "writelines", +) +@proxy_method_directly("fileno", "readable") +@proxy_property_directly("closed", "name", "mode") +class AsyncFileIO(AsyncBase): + """The asyncio executor version of io.FileIO.""" + + +@delegate_to_executor( + "close", + "flush", + "isatty", + "read", + "read1", + "readinto", + "readline", + "readlines", + "seek", + "seekable", + "tell", + "truncate", + "writable", + "write", + "writelines", +) +@proxy_method_directly("detach", "fileno", "readable") +@proxy_property_directly("closed", "raw", "name", "mode") +class AsyncIndirectBufferedIOBase(AsyncIndirectBase): + """The indirect asyncio executor version of io.BufferedWriter and BufferedIOBase.""" + + +@delegate_to_executor("peek") +class AsyncIndirectBufferedReader(AsyncIndirectBufferedIOBase): + """The indirect asyncio executor version of io.BufferedReader and Random.""" + + +@delegate_to_executor( + "close", + "flush", + "isatty", + "read", + "readall", + "readinto", + "readline", + "readlines", + "seek", + "seekable", + "tell", + "truncate", + "writable", + "write", + "writelines", +) +@proxy_method_directly("fileno", "readable") +@proxy_property_directly("closed", "name", "mode") +class AsyncIndirectFileIO(AsyncIndirectBase): + """The indirect asyncio executor version of io.FileIO.""" \ No newline at end of file diff --git a/hyperscale/tools/filesystem/filesystem.py b/hyperscale/tools/filesystem/filesystem.py new file mode 100644 index 0000000..24ab7c7 --- /dev/null +++ b/hyperscale/tools/filesystem/filesystem.py @@ -0,0 +1,129 @@ + +import asyncio +import sys + +from io import ( + FileIO, + TextIOBase, + BufferedReader, + BufferedWriter, + BufferedRandom, + BufferedIOBase, +) +from functools import partial, singledispatch + +from .binary import ( + AsyncBufferedIOBase, + AsyncBufferedReader, + AsyncFileIO, + AsyncIndirectBufferedIOBase +) +from .text import AsyncTextIOWrapper, AsyncTextIndirectIOWrapper + + +sync_open = open + +__all__ = ( + "open", + "stdin", + "stdout", + "stderr", + "stdin_bytes", + "stdout_bytes", + "stderr_bytes", +) + + +async def open( + file, + mode="r", + buffering=-1, + encoding=None, + errors=None, + newline=None, + closefd=True, + opener=None, + *, + loop=None, + executor=None +) -> AsyncTextIOWrapper: + + return await _open( + file, + mode=mode, + buffering=buffering, + encoding=encoding, + errors=errors, + newline=newline, + closefd=closefd, + opener=opener, + loop=loop, + executor=executor, + ) + + +async def _open( + file, + mode="r", + buffering=-1, + encoding=None, + errors=None, + newline=None, + closefd=True, + opener=None, + *, + loop=None, + executor=None +): + """Open an asyncio file.""" + if loop is None: + loop = asyncio.get_event_loop() + cb = partial( + sync_open, + file, + mode=mode, + buffering=buffering, + encoding=encoding, + errors=errors, + newline=newline, + closefd=closefd, + opener=opener, + ) + async_file = await loop.run_in_executor(executor, cb) + + return wrap(async_file, loop=loop, executor=executor) + + +@singledispatch +def wrap(file, *, loop=None, executor=None): + raise TypeError("Unsupported io type: {}.".format(file)) + + +@wrap.register(TextIOBase) +def _(file, *, loop=None, executor=None): + return AsyncTextIOWrapper(file, loop=loop, executor=executor) + + +@wrap.register(BufferedWriter) +@wrap.register(BufferedIOBase) +def _(file, *, loop=None, executor=None): + return AsyncBufferedIOBase(file, loop=loop, executor=executor) + + +@wrap.register(BufferedReader) +@wrap.register(BufferedRandom) +def _(file, *, loop=None, executor=None): + return AsyncBufferedReader(file, loop=loop, executor=executor) + + +@wrap.register(FileIO) +def _(file, *, loop=None, executor=None): + return AsyncFileIO(file, loop=loop, executor=executor) + + +stdin = AsyncTextIndirectIOWrapper('sys.stdin', None, None, indirect=lambda: sys.stdin) +stdout = AsyncTextIndirectIOWrapper('sys.stdout', None, None, indirect=lambda: sys.stdout) +stderr = AsyncTextIndirectIOWrapper('sys.stderr', None, None, indirect=lambda: sys.stderr) +stdin_bytes = AsyncIndirectBufferedIOBase('sys.stdin.buffer', None, None, indirect=lambda: sys.stdin.buffer) +stdout_bytes = AsyncIndirectBufferedIOBase('sys.stdout.buffer', None, None, indirect=lambda: sys.stdout.buffer) +stderr_bytes = AsyncIndirectBufferedIOBase('sys.stderr.buffer', None, None, indirect=lambda: sys.stderr.buffer) \ No newline at end of file diff --git a/hyperscale/tools/filesystem/text.py b/hyperscale/tools/filesystem/text.py new file mode 100644 index 0000000..3043cd5 --- /dev/null +++ b/hyperscale/tools/filesystem/text.py @@ -0,0 +1,69 @@ + +from .base import AsyncBase, AsyncIndirectBase +from .utils import ( + delegate_to_executor, + proxy_method_directly, + proxy_property_directly, +) + + +@delegate_to_executor( + "close", + "flush", + "isatty", + "read", + "readable", + "readline", + "readlines", + "seek", + "seekable", + "tell", + "truncate", + "write", + "writable", + "writelines", +) +@proxy_method_directly("detach", "fileno", "readable") +@proxy_property_directly( + "buffer", + "closed", + "encoding", + "errors", + "line_buffering", + "newlines", + "name", + "mode", +) +class AsyncTextIOWrapper(AsyncBase): + """The asyncio executor version of io.TextIOWrapper.""" + + +@delegate_to_executor( + "close", + "flush", + "isatty", + "read", + "readable", + "readline", + "readlines", + "seek", + "seekable", + "tell", + "truncate", + "write", + "writable", + "writelines", +) +@proxy_method_directly("detach", "fileno", "readable") +@proxy_property_directly( + "buffer", + "closed", + "encoding", + "errors", + "line_buffering", + "newlines", + "name", + "mode", +) +class AsyncTextIndirectIOWrapper(AsyncIndirectBase): + """The indirect asyncio executor version of io.TextIOWrapper.""" \ No newline at end of file diff --git a/hyperscale/tools/filesystem/utils.py b/hyperscale/tools/filesystem/utils.py new file mode 100644 index 0000000..7b2317a --- /dev/null +++ b/hyperscale/tools/filesystem/utils.py @@ -0,0 +1,85 @@ + +import functools +import asyncio +from types import coroutine + +def delegate_to_executor(*attrs): + def cls_builder(cls): + for attr_name in attrs: + setattr(cls, attr_name, _make_delegate_method(attr_name)) + return cls + + return cls_builder + + +def proxy_method_directly(*attrs): + def cls_builder(cls): + for attr_name in attrs: + setattr(cls, attr_name, _make_proxy_method(attr_name)) + return cls + + return cls_builder + + +def proxy_property_directly(*attrs): + def cls_builder(cls): + for attr_name in attrs: + setattr(cls, attr_name, _make_proxy_property(attr_name)) + return cls + + return cls_builder + + +def cond_delegate_to_executor(*attrs): + def cls_builder(cls): + for attr_name in attrs: + setattr(cls, attr_name, _make_cond_delegate_method(attr_name)) + return cls + + return cls_builder + + +def _make_delegate_method(attr_name): + async def method(self, *args, **kwargs): + cb = functools.partial(getattr(self._file, attr_name), *args, **kwargs) + loop = asyncio.get_event_loop() + + if loop != self._loop: + self._loop = loop + + return await self._loop.run_in_executor(self._executor, cb) + + return method + + +def _make_proxy_method(attr_name): + def method(self, *args, **kwargs): + return getattr(self._file, attr_name)(*args, **kwargs) + + return method + + +def _make_proxy_property(attr_name): + def proxy_property(self): + return getattr(self._file, attr_name) + + return property(proxy_property) + + +def _make_cond_delegate_method(attr_name): + """For spooled temp files, delegate only if rolled to file object""" + + @coroutine + def method(self, *args, **kwargs): + if self._file._rolled: + cb = functools.partial(getattr(self._file, attr_name), *args, **kwargs) + loop = asyncio.get_event_loop() + + if loop != self._loop: + self._loop = loop + + return self._loop.run_in_executor(self._executor, cb) + else: + return getattr(self._file, attr_name)(*args, **kwargs) + + return method \ No newline at end of file diff --git a/hyperscale/tools/helpers/__init__.py b/hyperscale/tools/helpers/__init__.py new file mode 100644 index 0000000..75dc681 --- /dev/null +++ b/hyperscale/tools/helpers/__init__.py @@ -0,0 +1,3 @@ +from .awaitable import awaitable +from .cancel import cancel +from .wrap import wrap \ No newline at end of file diff --git a/hyperscale/tools/helpers/awaitable.py b/hyperscale/tools/helpers/awaitable.py new file mode 100644 index 0000000..49d306c --- /dev/null +++ b/hyperscale/tools/helpers/awaitable.py @@ -0,0 +1,19 @@ +import asyncio +import contextvars +import functools + + +async def awaitable(func, *args, **kwargs): + """ + NOTE: Due to a Python 3.9 bug with OSX + we can't use asyncio.to_thread(), so we + have to re-implement it here. + """ + 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/hyperscale/tools/helpers/cancel.py b/hyperscale/tools/helpers/cancel.py new file mode 100644 index 0000000..12e2a25 --- /dev/null +++ b/hyperscale/tools/helpers/cancel.py @@ -0,0 +1,14 @@ +import asyncio + + +async def cancel(pending_item: asyncio.Task) -> None: + pending_item.cancel() + if not pending_item.cancelled(): + try: + await pending_item + + except asyncio.CancelledError: + pass + + except asyncio.IncompleteReadError: + pass diff --git a/hyperscale/tools/helpers/wait.py b/hyperscale/tools/helpers/wait.py new file mode 100644 index 0000000..63fe629 --- /dev/null +++ b/hyperscale/tools/helpers/wait.py @@ -0,0 +1,53 @@ +import asyncio +from typing import List + + +def _release_waiter(waiter: asyncio.Future, *args): + if not waiter.done(): + waiter.set_result(None) + + +async def wait( + fs: List[asyncio.Future], + timeout: float=None +): + loop = asyncio.get_event_loop() + """Internal helper for wait(). + The fs argument must be a collection of Futures. + """ + waiter = loop.create_future() + timeout_handle = None + if timeout is not None: + timeout_handle = loop.call_later(timeout, _release_waiter, waiter) + counter = len(fs) + + def _on_completion(f: asyncio.Future): + nonlocal counter + counter -= 1 + if (counter <= 0 and (not f.cancelled() and + f.exception() is not None)): + if timeout_handle is not None: + timeout_handle.cancel() + if not waiter.done(): + waiter.set_result(None) + + for f in fs: + f.add_done_callback(_on_completion) + + try: + await waiter + finally: + if timeout_handle is not None: + timeout_handle.cancel() + for f in fs: + f.remove_done_callback(_on_completion) + + done, pending = set(), set() + for f in fs: + if f.done(): + done.add(f) + else: + pending.add(f) + + return done, pending + diff --git a/hyperscale/tools/helpers/wrap.py b/hyperscale/tools/helpers/wrap.py new file mode 100644 index 0000000..14ef6b6 --- /dev/null +++ b/hyperscale/tools/helpers/wrap.py @@ -0,0 +1,7 @@ +import contextvars +import functools + + +async def wrap(func, *args, **kwargs): + ctx = contextvars.copy_context() + return functools.partial(ctx.run, func, *args, **kwargs) \ No newline at end of file diff --git a/hyperscale/versioning/__init__.py b/hyperscale/versioning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/versioning/flags/__init__.py b/hyperscale/versioning/flags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/versioning/flags/exceptions/__init__.py b/hyperscale/versioning/flags/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/versioning/flags/exceptions/latest_not_enabled.py b/hyperscale/versioning/flags/exceptions/latest_not_enabled.py new file mode 100644 index 0000000..538bc66 --- /dev/null +++ b/hyperscale/versioning/flags/exceptions/latest_not_enabled.py @@ -0,0 +1,6 @@ +class LatestNotEnabledException(Exception): + + def __init__(self, feature_name: str) -> None: + super().__init__( + f'\nErr. - Attempting to use unstable feature - {feature_name} - wihtout --enable-latest flag.\nPlease pass this flag if you want to use unstable features.\n' + ) \ No newline at end of file diff --git a/hyperscale/versioning/flags/exceptions/unsafe_not_enabled.py b/hyperscale/versioning/flags/exceptions/unsafe_not_enabled.py new file mode 100644 index 0000000..4191772 --- /dev/null +++ b/hyperscale/versioning/flags/exceptions/unsafe_not_enabled.py @@ -0,0 +1,6 @@ +class UnsafeNotEnabledException(Exception): + + def __init__(self, feature_name: str) -> None: + super().__init__( + f'\nErr. - Attempting to use unsafe feature - {feature_name} - wihtout --enable-unsafe flag.\nPlease pass this flag if you want to use unstable features.\n' + ) \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/__init__.py b/hyperscale/versioning/flags/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/versioning/flags/types/base/__init__.py b/hyperscale/versioning/flags/types/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/versioning/flags/types/base/active.py b/hyperscale/versioning/flags/types/base/active.py new file mode 100644 index 0000000..fe2d972 --- /dev/null +++ b/hyperscale/versioning/flags/types/base/active.py @@ -0,0 +1,7 @@ +from .flag_type import FlagTypes + + +active_flags = { + FlagTypes.UNSAFE_FEATURE: False, + FlagTypes.UNSTABLE_FEATURE: False +} \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/base/feature.py b/hyperscale/versioning/flags/types/base/feature.py new file mode 100644 index 0000000..2eb76d2 --- /dev/null +++ b/hyperscale/versioning/flags/types/base/feature.py @@ -0,0 +1,19 @@ +from typing import Any +from .flag_type import FlagTypes + + +class Flag: + + def __init__( + self, + feature_name: str, + feature: Any, + flag_type: FlagTypes, + exception: Exception, + enabled: bool=False + ) -> None: + self.name = feature_name + self.feature = feature + self.type = flag_type + self.exception = exception + self.enabled = enabled \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/base/flag_type.py b/hyperscale/versioning/flags/types/base/flag_type.py new file mode 100644 index 0000000..33f9737 --- /dev/null +++ b/hyperscale/versioning/flags/types/base/flag_type.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class FlagTypes(Enum): + UNSAFE_FEATURE='UNSAFE_FEATURE' + UNSTABLE_FEATURE='UNSTABLE_FEATURE' \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/base/registry.py b/hyperscale/versioning/flags/types/base/registry.py new file mode 100644 index 0000000..4ed2a73 --- /dev/null +++ b/hyperscale/versioning/flags/types/base/registry.py @@ -0,0 +1,66 @@ +from typing import Dict + +from hyperscale.versioning.flags.types.unsafe.feature import UnsafeFeature +from hyperscale.versioning.flags.types.unstable.feature import UnstableFeature + +from .active import active_flags +from .feature import Flag +from .flag_type import FlagTypes + + +class FlagRegistry: + all: Dict[str, Flag] = {} + module_paths: Dict[str, str] = {} + + def __init__(self, flag_type) -> None: + self.flag_type = flag_type + self.flag_types = { + FlagTypes.UNSAFE_FEATURE: lambda *args, **kwargs: UnsafeFeature( + *args, + **kwargs + ), + FlagTypes.UNSTABLE_FEATURE: lambda *args, **kwargs: UnstableFeature( + *args, + **kwargs + ) + } + + def __call__(self, flag): + + self.module_paths[flag.__name__] = flag.__module__ + + def wrap_feature(feature): + + feature_name = feature.__name__ + + flagged_feature: Flag = self.flag_types[self.flag_type] + + + self.all[feature_name] = flagged_feature( + feature_name, + feature + ) + + def wrapped_method(*args, **kwargs): + + selected_feature = self.all.get(feature_name) + + + if active_flags.get(selected_feature.type): + selected_feature.enabled = True + + else: + raise selected_feature.exception(feature_name) + + return selected_feature.feature(*args, **kwargs) + + return wrapped_method + + return wrap_feature + + +def makeRegistrar(): + return FlagRegistry + + +flag_registrar = makeRegistrar() \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/unsafe/__init__.py b/hyperscale/versioning/flags/types/unsafe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/versioning/flags/types/unsafe/feature.py b/hyperscale/versioning/flags/types/unsafe/feature.py new file mode 100644 index 0000000..6c305b1 --- /dev/null +++ b/hyperscale/versioning/flags/types/unsafe/feature.py @@ -0,0 +1,23 @@ +from typing import Any + +from hyperscale.versioning.flags.exceptions.unsafe_not_enabled import ( + UnsafeNotEnabledException, +) +from hyperscale.versioning.flags.types.base.feature import Flag +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + + +class UnsafeFeature(Flag): + + def __init__( + self, + feature_name: str, + feature: Any + ) -> None: + super().__init__( + feature_name, + feature, + FlagTypes.UNSAFE_FEATURE, + UnsafeNotEnabledException + ) + \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/unsafe/flag.py b/hyperscale/versioning/flags/types/unsafe/flag.py new file mode 100644 index 0000000..3b82a7b --- /dev/null +++ b/hyperscale/versioning/flags/types/unsafe/flag.py @@ -0,0 +1,12 @@ +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes +from hyperscale.versioning.flags.types.base.registry import flag_registrar + + +@flag_registrar(FlagTypes.UNSAFE_FEATURE) +def unsafe(feature): + + def wrap_feature(*args, **kwargs): + + return feature(*args, **kwargs) + + return wrap_feature \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/unstable/__init__.py b/hyperscale/versioning/flags/types/unstable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hyperscale/versioning/flags/types/unstable/feature.py b/hyperscale/versioning/flags/types/unstable/feature.py new file mode 100644 index 0000000..104f7d9 --- /dev/null +++ b/hyperscale/versioning/flags/types/unstable/feature.py @@ -0,0 +1,23 @@ +from typing import Any + +from hyperscale.versioning.flags.exceptions.latest_not_enabled import ( + LatestNotEnabledException, +) +from hyperscale.versioning.flags.types.base.feature import Flag +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes + + +class UnstableFeature(Flag): + + def __init__( + self, + feature_name: str, + feature: Any, + ) -> None: + super().__init__( + feature_name, + feature, + FlagTypes.UNSTABLE_FEATURE, + LatestNotEnabledException, + ) + \ No newline at end of file diff --git a/hyperscale/versioning/flags/types/unstable/flag.py b/hyperscale/versioning/flags/types/unstable/flag.py new file mode 100644 index 0000000..a973b89 --- /dev/null +++ b/hyperscale/versioning/flags/types/unstable/flag.py @@ -0,0 +1,17 @@ +from hyperscale.versioning.flags.types.base.flag_type import FlagTypes +from hyperscale.versioning.flags.types.base.registry import flag_registrar + + +@flag_registrar(FlagTypes.UNSTABLE_FEATURE) +def unstable(feature): + + def wrap_feature(*args, **kwargs): + + return feature(*args, **kwargs) + + return wrap_feature + + +@flag_registrar(FlagTypes.UNSTABLE_FEATURE) +def unstable_threadsafe(feature): + return feature \ No newline at end of file diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..bfee4f4 --- /dev/null +++ b/requirements.in @@ -0,0 +1,30 @@ +attr +networkx +aiodns +click +pytz +tzlocal +eventlet +psutil +aiologger +yaspin +fastapi +dill +scipy +art +scikit-learn +uvloop +tdigest +pydantic +GitPython +hyperframe +tabulate +plotille +python3-dtls +zstandard +cryptography +python-dotenv +aioquic +playwright +protobuf +graphql-core \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a355c1d --- /dev/null +++ b/setup.py @@ -0,0 +1,181 @@ +import os + +from setuptools import find_packages, setup + +current_directory = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(current_directory, "README.md"), "r") as readme: + package_description = readme.read() + +version_string = "" +with open(os.path.join(current_directory, ".version"), "r") as version_file: + version_string = version_file.read() + +setup( + name="hyperscale", + version=version_string, + description="Performance testing at scale.", + long_description=package_description, + long_description_content_type="text/markdown", + author="Sean Corbett", + author_email="sean.corbett@umconnect.edu", + url="https://github.com/hyper-light/hyperscale", + packages=find_packages(), + keywords=[ + "pypi", + "cicd", + "python", + "performance", + "testing", + "dag", + "graph", + "workflow", + ], + classifiers=[ + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + install_requires=[ + "aiologger", + "yaspin", + "attr", + "networkx", + "aiodns", + "click", + "psutil", + "dill", + "scipy", + "art", + "scikit-learn", + "uvloop", + "tdigest", + "pydantic", + "GitPython", + "tabulate", + "plotille", + "zstandard", + "cryptography", + "python-dotenv", + "aioquic", + ], + entry_points={ + "console_scripts": [ + "hyperscale=hyperscale.cli:run", + "hyperscale-server=hyperscale.run_uwsgi:run_uwsgi", + ], + }, + extras_require={ + "all": [ + "protobuf", + "graphql-core", + "playwright", + "azure-cosmos", + "libhoney", + "influxdb_client", + "newrelic", + "aio_statsd", + "prometheus-client", + "prometheus-api-client", + "cassandra-driver", + "datadog", + "motor", + "redis", + "aioredis", + "aiomysql", + "psycopg2-binary", + "sqlalchemy", + "sqlalchemy[asyncio]", + "boto3", + "snowflake-sqlalchemy", + "snowflake-connector-python", + "google-cloud-bigquery", + "google-cloud-bigtable", + "google-cloud-storage", + "cryptography", + "aioquic", + "dicttoxml", + "opentelemetry-api", + "datadog_api_client", + "aiokafka", + "haralyzer", + "asyncpg", + "xmltodict", + "python3-dtls", + ], + "all-engines": [ + "protobuf", + "graphql-core", + "playwright", + "cryptography", + "aioquic", + "opentelemetry-api", + ], + "all-reporters": [ + "azure-cosmos", + "libhoney", + "influxdb_client", + "newrelic", + "aio_statsd", + "prometheus-client", + "prometheus-api-client", + "cassandra-driver", + "datadog", + "motor", + "redis", + "aioredis", + "aiomysql", + "psycopg2-binary", + "asyncpg", + "sqlalchemy", + "boto3", + "snowflake-connector-python", + "google-cloud-bigquery", + "google-cloud-bigtable", + "google-cloud-storage", + "dicttoxml", + "datadog-api-client", + "aiosonic", + "aiokafka", + ], + "distributed": [ + "python3-dtls", + ], + "playwright": ["playwright"], + "azure": ["azure-cosmos"], + "honeycomb": ["libhoney"], + "influxdb": ["influxdb_client"], + "newrelic": ["newrelic"], + "statsd": ["aio_statsd"], + "prometheus": ["prometheus-client", "prometheus-api-client"], + "cassandra": ["cassandra-driver"], + "datadog": ["datadog-api-client", "aiosonic"], + "mongodb": ["motor"], + "redis": ["redis", "aioredis"], + "kafka": ["aiokafka"], + "sql": [ + "aiomysql", + "psycopg2-binary", + "asyncpg", + "sqlalchemy", + "sqlalchemy[asyncio]", + ], + "aws": ["boto3"], + "grpc": ["protobuf"], + "graphql": ["graphql-core"], + "http3": ["cryptography", "aioquic"], + "snowflake": ["snowflake-sqlalchemy", "snowflake-connector-python"], + "google": [ + "google-cloud-bigquery", + "google-cloud-bigtable", + "google-cloud-storage", + ], + "xml": ["dicttoxml", "xmltodict"], + "opentelemetry": ["opentelemetry-api"], + "har": ["haralyzer"], + }, + python_requires=">=3.8", +)

w_=7YXrbcY0hBlMx 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 literal 0 HcmV?d00001 diff --git a/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/libssl-1_1.dll b/hyperscale/core_rewrite/engines/client/udp/protocols/dtls/prebuilt/win32-x86/libssl-1_1.dll new file mode 100644 index 0000000000000000000000000000000000000000..0b14cc7db0893d2d71d5dd132ca7ae75cbdc4bba GIT binary patch literal 531456 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