From 613fdc4dbf7219813ce74685cb4e0a09b4b18429 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 02:20:08 +0000 Subject: [PATCH 01/25] first iteration, GunicornBackendServer work for prod mode --- reflex/config.py | 32 +- reflex/server/__init__.py | 6 + reflex/server/base.py | 14 + reflex/server/granian.py | 36 ++ reflex/server/gunicorn.py | 1195 +++++++++++++++++++++++++++++++++++++ reflex/server/uvicorn.py | 10 + reflex/utils/exec.py | 89 ++- 7 files changed, 1324 insertions(+), 58 deletions(-) create mode 100644 reflex/server/__init__.py create mode 100644 reflex/server/base.py create mode 100644 reflex/server/granian.py create mode 100644 reflex/server/gunicorn.py create mode 100644 reflex/server/uvicorn.py diff --git a/reflex/config.py b/reflex/config.py index 049cc2e83..05d8c8a39 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -34,7 +34,7 @@ except ModuleNotFoundError: from reflex_cli.constants.hosting import Hosting -from reflex import constants +from reflex import constants, server from reflex.base import Base from reflex.utils import console @@ -620,7 +620,7 @@ class Config(Base): # Tailwind config. tailwind: Optional[Dict[str, Any]] = {"plugins": ["@tailwindcss/typography"]} - # Timeout when launching the gunicorn server. TODO(rename this to backend_timeout?) + # Timeout when launching the gunicorn server. TODO(rename this to backend_timeout?); deprecated timeout: int = 120 # Whether to enable or disable nextJS gzip compression. @@ -637,16 +637,16 @@ class Config(Base): # The hosting service frontend URL. cp_web_url: str = Hosting.CP_WEB_URL - # The worker class used in production mode + # The worker class used in production mode; deprecated gunicorn_worker_class: str = "uvicorn.workers.UvicornH11Worker" - # Number of gunicorn workers from user + # Number of gunicorn workers from user; deprecated gunicorn_workers: Optional[int] = None - # Number of requests before a worker is restarted + # Number of requests before a worker is restarted; deprecated gunicorn_max_requests: int = 100 - # Variance limit for max requests; gunicorn only + # Variance limit for max requests; gunicorn only; deprecated gunicorn_max_requests_jitter: int = 25 # Indicate which type of state manager to use @@ -664,6 +664,17 @@ class Config(Base): # Path to file containing key-values pairs to override in the environment; Dotenv format. env_file: Optional[str] = None + # Custom Backend Server + backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( + app=f"reflex.app_module_for_backend:{constants.CompileVars.APP}.{constants.CompileVars.API}", + worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + max_requests=100, + max_requests_jitter=25, + preload_app=True, + timeout=120, + ) + backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer() + def __init__(self, *args, **kwargs): """Initialize the config values. @@ -674,6 +685,7 @@ class Config(Base): Raises: ConfigError: If some values in the config are invalid. """ + print("[reflex.config::Config] start") super().__init__(*args, **kwargs) # Update the config from environment variables. @@ -693,6 +705,14 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) + + print("[reflex.config::Config] --") + for key in ("timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"): + if isinstance(self.backend_server_prod, server.GunicornBackendServer): + value = self.get_value(key) + if value != self.backend_server_prod.get_fields()[key.replace("gunicorn_", "")].default and value is not None: + setattr(self.backend_server_prod, key.replace("gunicorn_", ""), value) + print("[reflex.config::Config] done") @property def module(self) -> str: diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py new file mode 100644 index 000000000..c44f2ca11 --- /dev/null +++ b/reflex/server/__init__.py @@ -0,0 +1,6 @@ + + +from .base import CustomBackendServer +from .granian import GranianBackendServer +from .gunicorn import GunicornBackendServer +from .uvicorn import UvicornBackendServer diff --git a/reflex/server/base.py b/reflex/server/base.py new file mode 100644 index 000000000..c327d5c89 --- /dev/null +++ b/reflex/server/base.py @@ -0,0 +1,14 @@ +from abc import abstractmethod, ABCMeta + +from reflex.base import Base + + +class CustomBackendServer(Base): + + @abstractmethod + def run_prod(self): + raise NotImplementedError() + + @abstractmethod + def run_dev(self): + raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py new file mode 100644 index 000000000..7a2fde844 --- /dev/null +++ b/reflex/server/granian.py @@ -0,0 +1,36 @@ + +from dataclasses import dataclass + +from reflex.server.base import CustomBackendServer + +@dataclass +class HTTP1Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 + keep_alive: bool = True + max_buffer_size: int = 8192 + 4096 * 100 + pipeline_flush: bool = False + + +@dataclass +class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L13 + adaptive_window: bool = False + initial_connection_window_size: int = 1024 * 1024 + initial_stream_window_size: int = 1024 * 1024 + keep_alive_interval: int | None = None + keep_alive_timeout: int = 20 + max_concurrent_streams: int = 200 + max_frame_size: int = 1024 * 16 + max_headers_size: int = 16 * 1024 * 1024 + max_send_buffer_size: int = 1024 * 400 + +try: + import watchfiles +except ImportError: + watchfiles = None + +class GranianBackendServer(CustomBackendServer): + + def run_prod(self): + pass + + def run_dev(self): + pass diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py new file mode 100644 index 000000000..18dfb7841 --- /dev/null +++ b/reflex/server/gunicorn.py @@ -0,0 +1,1195 @@ +from typing import Any, Literal, Callable + +import os +import sys +import ssl +from pydantic import Field + +from gunicorn.app.base import BaseApplication + +import psutil + +from reflex import constants +from reflex.server.base import CustomBackendServer + + +class StandaloneApplication(BaseApplication): + + def __init__(self, app, options=None): + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self): + config = {key: value for key, value in self.options.items() + if key in self.cfg.settings and value is not None} # type: ignore + for key, value in config.items(): + self.cfg.set(key.lower(), value) # type: ignore + + def load(self): + return self.application + + +_mapping_attr_to_cli: dict[str, str] = { + "config": "--config", + "bind": "--bind", + "backlog": "--backlog", + "workers": "--workers", + "worker_class": "--worker-class", + "threads": "--threads", + "worker_connections": "--worker-connections", + "max_requests": "--max-requests", + "max_requests_jitter": "--max-requests-jitter", + "timeout": "--timeout", + "graceful_timeout": "--graceful-timeout", + "keepalive": "--keep-alive", + "limit_request_line": "--limit-request-line", + "limit_request_fields": "--limit-request-fields", + "limit_request_field_size": "--limit-request-field_size", + "reload": "--reload", + "reload_engine": "--reload-engine", + "reload_extra_files": "--reload-extra-file", + "spew": "--spew", + "check_config": "--check-config", + "print_config": "--print-config", + "preload_app": "--preload", + "sendfile": "--no-sendfile", + "reuse_port": "--reuse-port", + "chdir": "--chdir", + "daemon": "--daemon", + "raw_env": "--env", + "pidfile": "--pid", + "worker_tmp_dir": "--worker-tmp-dir", + "user": "--user", + "group": "--group", + "umask": "--umask", + "initgroups": "--initgroups", + "forwarded_allow_ips": "--forwarded-allow-ips", + "accesslog": "--access-logfile", + "disable_redirect_access_to_syslog": "--disable-redirect-access-to-syslog", + "access_log_format": "--access-logformat", + "errorlog": "--error-logfile", + "loglevel": "--log-level", + "capture_output": "--capture-output", + "logger_class": "--logger-class", + "logconfig": "--log-config", + "logconfig_json": "--log-config-json", + "syslog_addr": "--log-syslog-to", + "syslog": "--log-syslog", + "syslog_prefix": "--log-syslog-prefix", + "syslog_facility": "--log-syslog-facility", + "enable_stdio_inheritance": "--enable-stdio-inheritance", + "statsd_host": "--statsd-host", + "dogstatsd_tags": "--dogstatsd-tags", + "statsd_prefix": "--statsd-prefix", + "proc_name": "--name", + "pythonpath": "--pythonpath", + "paste": "--paster", + "proxy_protocol": "--proxy-protocol", + "proxy_allow_ips": "--proxy-allow-from", + "keyfile": "--keyfile", + "certfile": "--certfile", + "ssl_version": "--ssl-version", + "cert_reqs": "--cert-reqs", + "ca_certs": "--ca-certs", + "suppress_ragged_eofs": "--suppress-ragged-eofs", + "do_handshake_on_connect": "--do-handshake-on-connect", + "ciphers": "--ciphers", + "raw_paste_global_conf": "--paste-global", + "permit_obsolete_folding": "--permit-obsolete-folding", + "strip_header_spaces": "--strip-header-spaces", + "permit_unconventional_http_method": "--permit-unconventional-http-method", + "permit_unconventional_http_version": "--permit-unconventional-http-version", + "casefold_http_method": "--casefold-http-method", + "forwarder_headers": "--forwarder-headers", + "header_map": "--header-map", +} + +class GunicornBackendServer(CustomBackendServer): + # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py + + app: str + + config: str = "./gunicorn.conf.py" + """\ + :ref:`The Gunicorn config file`. + + A string of the form ``PATH``, ``file:PATH``, or ``python:MODULE_NAME``. + + Only has an effect when specified on the command line or as part of an + application specific configuration. + + By default, a file named ``gunicorn.conf.py`` will be read from the same + directory where gunicorn is being run. + """ + + wsgi_app: str | None = None + """\ + A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. + """ + + bind: list[str] = ['127.0.0.1:8000'] + """\ + The socket to bind. + + A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, + ``fd://FD``. An IP is a valid ``HOST``. + + .. versionchanged:: 20.0 + Support for ``fd://FD`` got added. + + Multiple addresses can be bound. ex.:: + + $ gunicorn -b 127.0.0.1:8000 -b [::1]:8000 test:app + + will bind the `test:app` application on localhost both on ipv6 + and ipv4 interfaces. + + If the ``PORT`` environment variable is defined, the default + is ``['0.0.0.0:$PORT']``. If it is not defined, the default + is ``['127.0.0.1:8000']``. + """ + + backlog: int = 2048 + """\ + The maximum number of pending connections. + + This refers to the number of clients that can be waiting to be served. + Exceeding this number results in the client getting an error when + attempting to connect. It should only affect servers under significant + load. + + Must be a positive integer. Generally set in the 64-2048 range. + """ + + workers: int = 1 + """\ + The number of worker processes for handling requests. + + A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. + You'll want to vary this a bit to find the best for your particular + application's work load. + + By default, the value of the ``WEB_CONCURRENCY`` environment variable, + which is set by some Platform-as-a-Service providers such as Heroku. If + it is not defined, the default is ``1``. + """ + + worker_class: Literal["sync", "eventlet", "gevent", "tornado", "gthread", "uvicorn.workers.UvicornH11Worker"] = "sync" + """\ + The type of workers to use. + + The default class (``sync``) should handle most "normal" types of + workloads. You'll want to read :doc:`design` for information on when + you might want to choose one of the other worker classes. Required + libraries may be installed using setuptools' ``extras_require`` feature. + + A string referring to one of the following bundled classes: + + * ``sync`` + * ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via + ``pip install gunicorn[eventlet]``) + * ``gevent`` - Requires gevent >= 1.4 (or install it via + ``pip install gunicorn[gevent]``) + * ``tornado`` - Requires tornado >= 0.2 (or install it via + ``pip install gunicorn[tornado]``) + * ``gthread`` - Python 2 requires the futures package to be installed + (or install it via ``pip install gunicorn[gthread]``) + + Optionally, you can provide your own worker by giving Gunicorn a + Python path to a subclass of ``gunicorn.workers.base.Worker``. + This alternative syntax will load the gevent class: + ``gunicorn.workers.ggevent.GeventWorker``. + """ + + threads: int = 1 + """\ + The number of worker threads for handling requests. + + Run each worker with the specified number of threads. + + A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. + You'll want to vary this a bit to find the best for your particular + application's work load. + + If it is not defined, the default is ``1``. + + This setting only affects the Gthread worker type. + + .. note:: + If you try to use the ``sync`` worker type and set the ``threads`` + setting to more than 1, the ``gthread`` worker type will be used + instead. + """ + + worker_connections: int = 1000 + """\ + The maximum number of simultaneous clients. + + This setting only affects the ``gthread``, ``eventlet`` and ``gevent`` worker types. + """ + + max_requests: int = 0 + """\ + The maximum number of requests a worker will process before restarting. + + Any value greater than zero will limit the number of requests a worker + will process before automatically restarting. This is a simple method + to help limit the damage of memory leaks. + + If this is set to zero (the default) then the automatic worker + restarts are disabled. + """ + + max_requests_jitter: int = 0 + """\ + The maximum jitter to add to the *max_requests* setting. + + The jitter causes the restart per worker to be randomized by + ``randint(0, max_requests_jitter)``. This is intended to stagger worker + restarts to avoid all workers restarting at the same time. + """ + + timeout: int = 30 + """\ + Workers silent for more than this many seconds are killed and restarted. + + Value is a positive number or 0. Setting it to 0 has the effect of + infinite timeouts by disabling timeouts for all workers entirely. + + Generally, the default of thirty seconds should suffice. Only set this + noticeably higher if you're sure of the repercussions for sync workers. + For the non sync workers it just means that the worker process is still + communicating and is not tied to the length of time required to handle a + single request. + """ + + graceful_timeout: int = 30 + """\ + Timeout for graceful workers restart. + + After receiving a restart signal, workers have this much time to finish + serving requests. Workers still alive after the timeout (starting from + the receipt of the restart signal) are force killed. + """ + + keepalive: int = 2 + """\ + The number of seconds to wait for requests on a Keep-Alive connection. + + Generally set in the 1-5 seconds range for servers with direct connection + to the client (e.g. when you don't have separate load balancer). When + Gunicorn is deployed behind a load balancer, it often makes sense to + set this to a higher value. + + .. note:: + ``sync`` worker does not support persistent connections and will + ignore this option. + """ + + limit_request_line: int = 4094 + """\ + The maximum size of HTTP request line in bytes. + + This parameter is used to limit the allowed size of a client's + HTTP request-line. Since the request-line consists of the HTTP + method, URI, and protocol version, this directive places a + restriction on the length of a request-URI allowed for a request + on the server. A server needs this value to be large enough to + hold any of its resource names, including any information that + might be passed in the query part of a GET request. Value is a number + from 0 (unlimited) to 8190. + + This parameter can be used to prevent any DDOS attack. + """ + + limit_request_fields: int = 100 + """\ + Limit the number of HTTP headers fields in a request. + + This parameter is used to limit the number of headers in a request to + prevent DDOS attack. Used with the *limit_request_field_size* it allows + more safety. By default this value is 100 and can't be larger than + 32768. + """ + + limit_request_field_size: int = 8190 + """\ + Limit the allowed size of an HTTP request header field. + + Value is a positive number or 0. Setting it to 0 will allow unlimited + header field sizes. + + .. warning:: + Setting this parameter to a very high or unlimited value can open + up for DDOS attacks. + """ + + reload: bool = False + """\ + Restart workers when code changes. + + This setting is intended for development. It will cause workers to be + restarted whenever application code changes. + + The reloader is incompatible with application preloading. When using a + paste configuration be sure that the server block does not import any + application code or the reload will not work as designed. + + The default behavior is to attempt inotify with a fallback to file + system polling. Generally, inotify should be preferred if available + because it consumes less system resources. + + .. note:: + In order to use the inotify reloader, you must have the ``inotify`` + package installed. + """ + + reload_engine: Literal["auto", "poll", "inotify"] = "auto" + """\ + The implementation that should be used to power :ref:`reload`. + + Valid engines are: + + * ``'auto'`` + * ``'poll'`` + * ``'inotify'`` (requires inotify) + """ + + reload_extra_files: list[str] = [] + """\ + Extends :ref:`reload` option to also watch and reload on additional files + (e.g., templates, configurations, specifications, etc.). + """ + + spew: bool = False + """\ + Install a trace function that spews every line executed by the server. + + This is the nuclear option. + """ + + check_config: bool = False + """\ + Check the configuration and exit. The exit status is 0 if the + configuration is correct, and 1 if the configuration is incorrect. + """ + + print_config: bool = False + """\ + Print the configuration settings as fully resolved. Implies :ref:`check-config`. + """ + + preload_app: bool = False + """\ + Load application code before the worker processes are forked. + + By preloading an application you can save some RAM resources as well as + speed up server boot times. Although, if you defer application loading + to each worker process, you can reload your application code easily by + restarting workers. + """ + + sendfile: bool | None = None + """\ + Disables the use of ``sendfile()``. + + If not set, the value of the ``SENDFILE`` environment variable is used + to enable or disable its usage. + """ + + reuse_port: bool = False + """\ + Set the ``SO_REUSEPORT`` flag on the listening socket. + """ + + chdir: str = "." + """\ + Change directory to specified directory before loading apps. + """ + + daemon: bool = False + """\ + Daemonize the Gunicorn process. + + Detaches the server from the controlling terminal and enters the + background. + """ + + raw_env: list[str] = [] + """\ + Set environment variables in the execution environment. + + Should be a list of strings in the ``key=value`` format. + """ + + pidfile: str | None = None + """\ + A filename to use for the PID file. + + If not set, no PID file will be written. + """ + + worker_tmp_dir: str | None = None + """\ + A directory to use for the worker heartbeat temporary file. + + If not set, the default temporary directory will be used. + + .. note:: + The current heartbeat system involves calling ``os.fchmod`` on + temporary file handlers and may block a worker for arbitrary time + if the directory is on a disk-backed filesystem. + + See :ref:`blocking-os-fchmod` for more detailed information + and a solution for avoiding this problem. + """ + + user: int = os.geteuid() + """\ + Switch worker processes to run as this user. + + A valid user id (as an integer) or the name of a user that can be + retrieved with a call to ``pwd.getpwnam(value)`` or ``None`` to not + change the worker process user. + """ + + group: int = os.getegid() + """\ + Switch worker process to run as this group. + + A valid group id (as an integer) or the name of a user that can be + retrieved with a call to ``pwd.getgrnam(value)`` or ``None`` to not + change the worker processes group. + """ + + umask: int = 0 + """\ + A bit mask for the file mode on files written by Gunicorn. + + Note that this affects unix socket permissions. + + A valid value for the ``os.umask(mode)`` call or a string compatible + with ``int(value, 0)`` (``0`` means Python guesses the base, so values + like ``0``, ``0xFF``, ``0022`` are valid for decimal, hex, and octal + representations) + """ + + initgroups: bool = False + """\ + If true, set the worker process's group access list with all of the + groups of which the specified username is a member, plus the specified + group id. + """ + + tmp_upload_dir: str | None = None + """\ + Directory to store temporary request data as they are read. + + This may disappear in the near future. + + This path should be writable by the process permissions set for Gunicorn + workers. If not specified, Gunicorn will choose a system generated + temporary directory. + """ + + secure_scheme_headers: dict[str, Any] = {'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'} + """\ + A dictionary containing headers and values that the front-end proxy + uses to indicate HTTPS requests. If the source IP is permitted by + :ref:`forwarded-allow-ips` (below), *and* at least one request header matches + a key-value pair listed in this dictionary, then Gunicorn will set + ``wsgi.url_scheme`` to ``https``, so your application can tell that the + request is secure. + + If the other headers listed in this dictionary are not present in the request, they will be ignored, + but if the other headers are present and do not match the provided values, then + the request will fail to parse. See the note below for more detailed examples of this behaviour. + + The dictionary should map upper-case header names to exact string + values. The value comparisons are case-sensitive, unlike the header + names, so make sure they're exactly what your front-end proxy sends + when handling HTTPS requests. + + It is important that your front-end proxy configuration ensures that + the headers defined here can not be passed directly from the client. + """ + + forwarded_allow_ips: str = "127.0.0.1,::1" + """\ + Front-end's IPs from which allowed to handle set secure headers. + (comma separated). + + Set to ``*`` to disable checking of front-end IPs. This is useful for setups + where you don't know in advance the IP address of front-end, but + instead have ensured via other means that only your + authorized front-ends can access Gunicorn. + + By default, the value of the ``FORWARDED_ALLOW_IPS`` environment + variable. If it is not defined, the default is ``"127.0.0.1,::1"``. + + .. note:: + + This option does not affect UNIX socket connections. Connections not associated with + an IP address are treated as allowed, unconditionally. + + .. note:: + + The interplay between the request headers, the value of ``forwarded_allow_ips``, and the value of + ``secure_scheme_headers`` is complex. Various scenarios are documented below to further elaborate. + In each case, we have a request from the remote address 134.213.44.18, and the default value of + ``secure_scheme_headers``: + + .. code:: + + secure_scheme_headers = { + 'X-FORWARDED-PROTOCOL': 'ssl', + 'X-FORWARDED-PROTO': 'https', + 'X-FORWARDED-SSL': 'on' + } + + + .. list-table:: + :header-rows: 1 + :align: center + :widths: auto + + * - ``forwarded-allow-ips`` + - Secure Request Headers + - Result + - Explanation + * - ``["127.0.0.1"]`` + - ``X-Forwarded-Proto: https`` + - ``wsgi.url_scheme = "http"`` + - IP address was not allowed + * - ``"*"`` + - + - ``wsgi.url_scheme = "http"`` + - IP address allowed, but no secure headers provided + * - ``"*"`` + - ``X-Forwarded-Proto: https`` + - ``wsgi.url_scheme = "https"`` + - IP address allowed, one request header matched + * - ``["134.213.44.18"]`` + - ``X-Forwarded-Ssl: on`` ``X-Forwarded-Proto: http`` + - ``InvalidSchemeHeaders()`` raised + - IP address allowed, but the two secure headers disagreed on if HTTPS was used + """ + + accesslog: str | None = None + """\ + The Access log file to write to. + + ``'-'`` means log to stdout. + """ + + disable_redirect_access_to_syslog: bool = False + """\ + Disable redirect access logs to syslog. + """ + + access_log_format: str = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + """\ + The access log format. + + =========== =========== + Identifier Description + =========== =========== + h remote address + l ``'-'`` + u user name (if HTTP Basic auth used) + t date of the request + r status line (e.g. ``GET / HTTP/1.1``) + m request method + U URL path without query string + q query string + H protocol + s status + B response length + b response length or ``'-'`` (CLF format) + f referrer (note: header is ``referer``) + a user agent + T request time in seconds + M request time in milliseconds + D request time in microseconds + L request time in decimal seconds + p process ID + {header}i request header + {header}o response header + {variable}e environment variable + =========== =========== + + Use lowercase for header and environment variable names, and put + ``{...}x`` names inside ``%(...)s``. For example:: + + %({x-forwarded-for}i)s + """ + + errorlog: str = "-" + """\ + The Error log file to write to. + + Using ``'-'`` for FILE makes gunicorn log to stderr. + """ + + loglevel: Literal["debug", "info", "warning", "error", "critical"] = "info" + """\ + The granularity of Error log outputs. + """ + + capture_output: bool = False + """\ + Redirect stdout/stderr to specified file in :ref:`errorlog`. + """ + + logger_class: str = "gunicorn.glogging.Logger" + """\ + The logger you want to use to log events in Gunicorn. + + The default class (``gunicorn.glogging.Logger``) handles most + normal usages in logging. It provides error and access logging. + + You can provide your own logger by giving Gunicorn a Python path to a + class that quacks like ``gunicorn.glogging.Logger``. + """ + + logconfig: str | None = None + """\ + The log config file to use. + Gunicorn uses the standard Python logging module's Configuration + file format. + """ + + logconfig_dict: dict = {} + """\ + The log config dictionary to use, using the standard Python + logging module's dictionary configuration format. This option + takes precedence over the :ref:`logconfig` and :ref:`logconfig-json` options, + which uses the older file configuration format and JSON + respectively. + + Format: https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig + + For more context you can look at the default configuration dictionary for logging, + which can be found at ``gunicorn.glogging.CONFIG_DEFAULTS``. + """ + + logconfig_json: str | None = None + """\ + The log config to read config from a JSON file + + Format: https://docs.python.org/3/library/logging.config.html#logging.config.jsonConfig + """ + + syslog_addr: str = ( + "unix:///var/run/syslog" + if sys.platform == "darwin" + else ( + "unix:///var/run/log" + if sys.platform in ('freebsd', 'dragonfly', ) + else ( + "unix:///dev/log" + if sys.platform == "openbsd" + else "udp://localhost:514" + ) + ) + ) + """\ + Address to send syslog messages. + + Address is a string of the form: + * ``unix://PATH#TYPE`` : for unix domain socket, ``TYPE`` can be ``stream`` for the stream driver or ``dgram`` for the dgram driver, ``stream`` is the default + * ``udp://HOST:PORT`` : for UDP sockets + * ``tcp://HOST:PORT`` : for TCP sockets + """ + + syslog: bool = False + """\ + Send *Gunicorn* logs to syslog. + """ + + syslog_prefix: str | None = None + """\ + Makes Gunicorn use the parameter as program-name in the syslog entries. + + All entries will be prefixed by ``gunicorn.``. By default the + program name is the name of the process. + """ + + syslog_facility: str = "user" + """\ + Syslog facility name + """ + + enable_stdio_inheritance: bool = False + """\ + Enable stdio inheritance. + + Enable inheritance for stdio file descriptors in daemon mode. + + Note: To disable the Python stdout buffering, you can to set the user + environment variable ``PYTHONUNBUFFERED`` . + """ + + statsd_host: str | None = None + """\ + The address of the StatsD server to log to. + + Address is a string of the form: + + * ``unix://PATH`` : for a unix domain socket. + * ``HOST:PORT`` : for a network address + """ + + dogstatsd_tags: str = "" + """\ + A comma-delimited list of datadog statsd (dogstatsd) tags to append to + statsd metrics. + """ + + statsd_prefix: str = "" + """\ + Prefix to use when emitting statsd metrics (a trailing ``.`` is added, + if not provided). + """ + + proc_name: str | None = None + """\ + A base to use with setproctitle for process naming. + + This affects things like ``ps`` and ``top``. If you're going to be + running more than one instance of Gunicorn you'll probably want to set a + name to tell them apart. This requires that you install the setproctitle + module. + + If not set, the *default_proc_name* setting will be used. + """ + + default_proc_name: str = "gunicorn" + """\ + Internal setting that is adjusted for each type of application. + """ + + pythonpath: str | None = None + """\ + A comma-separated list of directories to add to the Python path. + + e.g. + ``'/home/djangoprojects/myproject,/home/python/mylibrary'``. + """ + + paste: str | None = None + """\ + Load a PasteDeploy config file. The argument may contain a ``#`` + symbol followed by the name of an app section from the config file, + e.g. ``production.ini#admin``. + + At this time, using alternate server blocks is not supported. Use the + command line arguments to control server configuration instead. + """ + + on_starting: Callable = lambda server: None + """\ + Called just before the master process is initialized. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + on_reload: Callable = lambda server: None + """\ + Called to recycle workers during a reload via SIGHUP. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + when_ready: Callable = lambda server: None + """\ + Called just after the server is started. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + pre_fork: Callable = lambda server, worker: None + """\ + Called just before a worker is forked. + + The callable needs to accept two instance variables for the Arbiter and + new Worker. + """ + + post_fork: Callable = lambda server, worker: None + """\ + Called just after a worker has been forked. + + The callable needs to accept two instance variables for the Arbiter and + new Worker. + """ + + post_worker_init: Callable = lambda worker: None + """\ + Called just after a worker has initialized the application. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + worker_int: Callable = lambda worker: None + """\ + Called just after a worker exited on SIGINT or SIGQUIT. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + worker_abort: Callable = lambda worker: None + """\ + Called when a worker received the SIGABRT signal. + + This call generally happens on timeout. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + pre_exec: Callable = lambda server: None + """\ + Called just before a new master process is forked. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + pre_request: Callable = lambda worker, req: worker.log.debug("%s %s", req.method, req.path) + """\ + Called just before a worker processes the request. + + The callable needs to accept two instance variables for the Worker and + the Request. + """ + + post_request: Callable = lambda worker, req, environ, resp: None + """\ + Called after a worker processes the request. + + The callable needs to accept two instance variables for the Worker and + the Request. + """ + + child_exit: Callable = lambda server, worker: None + """\ + Called just after a worker has been exited, in the master process. + + The callable needs to accept two instance variables for the Arbiter and + the just-exited Worker. + """ + + worker_exit: Callable = lambda server, worker: None + """\ + Called just after a worker has been exited, in the worker process. + + The callable needs to accept two instance variables for the Arbiter and + the just-exited Worker. + """ + + nworkers_changed: Callable = lambda server, new_value, old_value: None + """\ + Called just after *num_workers* has been changed. + + The callable needs to accept an instance variable of the Arbiter and + two integers of number of workers after and before change. + + If the number of workers is set for the first time, *old_value* would + be ``None``. + """ + + on_exit: Callable = lambda server: None + """\ + Called just before exiting Gunicorn. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + ssl_context: Callable = lambda config, default_ssl_context_factory: default_ssl_context_factory() + """\ + Called when SSLContext is needed. + + Allows customizing SSL context. + + The callable needs to accept an instance variable for the Config and + a factory function that returns default SSLContext which is initialized + with certificates, private key, cert_reqs, and ciphers according to + config and can be further customized by the callable. + The callable needs to return SSLContext object. + + Following example shows a configuration file that sets the minimum TLS version to 1.3: + + .. code-block:: python + + def ssl_context(conf, default_ssl_context_factory): + import ssl + context = default_ssl_context_factory() + context.minimum_version = ssl.TLSVersion.TLSv1_3 + return context + """ + + proxy_protocol: bool = False + """\ + Enable detect PROXY protocol (PROXY mode). + + Allow using HTTP and Proxy together. It may be useful for work with + stunnel as HTTPS frontend and Gunicorn as HTTP server. + + PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt + + Example for stunnel config:: + + [https] + protocol = proxy + accept = 443 + connect = 80 + cert = /etc/ssl/certs/stunnel.pem + key = /etc/ssl/certs/stunnel.key + """ + + proxy_allow_ips: str = "127.0.0.1,::1" + """\ + Front-end's IPs from which allowed accept proxy requests (comma separated). + + Set to ``*`` to disable checking of front-end IPs. This is useful for setups + where you don't know in advance the IP address of front-end, but + instead have ensured via other means that only your + authorized front-ends can access Gunicorn. + + .. note:: + + This option does not affect UNIX socket connections. Connections not associated with + an IP address are treated as allowed, unconditionally. + """ + + keyfile: str | None = None + """\ + SSL key file + """ + + certfile: str | None = None + """\ + SSL certificate file + """ + + ssl_version: int = ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + """\ + SSL version to use (see stdlib ssl module's). + + .. deprecated:: 21.0 + The option is deprecated and it is currently ignored. Use :ref:`ssl-context` instead. + + ============= ============ + --ssl-version Description + ============= ============ + SSLv3 SSLv3 is not-secure and is strongly discouraged. + SSLv23 Alias for TLS. Deprecated in Python 3.6, use TLS. + TLS Negotiate highest possible version between client/server. + Can yield SSL. (Python 3.6+) + TLSv1 TLS 1.0 + TLSv1_1 TLS 1.1 (Python 3.4+) + TLSv1_2 TLS 1.2 (Python 3.4+) + TLS_SERVER Auto-negotiate the highest protocol version like TLS, + but only support server-side SSLSocket connections. + (Python 3.6+) + ============= ============ + + .. versionchanged:: 19.7 + The default value has been changed from ``ssl.PROTOCOL_TLSv1`` to + ``ssl.PROTOCOL_SSLv23``. + .. versionchanged:: 20.0 + This setting now accepts string names based on ``ssl.PROTOCOL_`` + constants. + .. versionchanged:: 20.0.1 + The default value has been changed from ``ssl.PROTOCOL_SSLv23`` to + ``ssl.PROTOCOL_TLS`` when Python >= 3.6 . + """ + + cert_reqs: int = ssl.CERT_NONE + """\ + Whether client certificate is required (see stdlib ssl module's) + + =========== =========================== + --cert-reqs Description + =========== =========================== + `0` ``ssl.CERT_NONE`` + `1` ``ssl.CERT_OPTIONAL`` + `2` ``ssl.CERT_REQUIRED`` + =========== =========================== + """ + + ca_certs: str | None = None + """\ + CA certificates file + """ + + suppress_ragged_eofs: bool = True + """\ + Suppress ragged EOFs (see stdlib ssl module's) + """ + + do_handshake_on_connect: bool = False + """\ + Whether to perform SSL handshake on socket connect (see stdlib ssl module's) + """ + + ciphers: str | None = None + """\ + SSL Cipher suite to use, in the format of an OpenSSL cipher list. + + By default we use the default cipher list from Python's ``ssl`` module, + which contains ciphers considered strong at the time of each Python + release. + + As a recommended alternative, the Open Web App Security Project (OWASP) + offers `a vetted set of strong cipher strings rated A+ to C- + `_. + OWASP provides details on user-agent compatibility at each security level. + + See the `OpenSSL Cipher List Format Documentation + `_ + for details on the format of an OpenSSL cipher list. + """ + + raw_paste_global_conf: list[str] = [] + """\ + Set a PasteDeploy global config variable in ``key=value`` form. + + The option can be specified multiple times. + + The variables are passed to the PasteDeploy entrypoint. Example:: + + $ gunicorn -b 127.0.0.1:8000 --paste development.ini --paste-global FOO=1 --paste-global BAR=2 + """ + + permit_obsolete_folding: bool = False + """\ + Permit requests employing obsolete HTTP line folding mechanism + + The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be + employed in HTTP request headers from standards-compliant HTTP clients. + + This option is provided to diagnose backwards-incompatible changes. + Use with care and only if necessary. Temporary; the precise effect of this option may + change in a future version, or it may be removed altogether. + """ + + strip_header_spaces: bool = False + """\ + Strip spaces present between the header name and the the ``:``. + + This is known to induce vulnerabilities and is not compliant with the HTTP/1.1 standard. + See https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn. + + Use with care and only if necessary. Deprecated; scheduled for removal in 25.0.0 + """ + + permit_unconventional_http_method: bool = False + """\ + Permit HTTP methods not matching conventions, such as IANA registration guidelines + + This permits request methods of length less than 3 or more than 20, + methods with lowercase characters or methods containing the # character. + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + If unset, Gunicorn will apply nonstandard restrictions and cause 400 response status + in cases where otherwise 501 status is expected. While this option does modify that + behaviour, it should not be depended upon to guarantee standards-compliant behaviour. + Rather, it is provided temporarily, to assist in diagnosing backwards-incompatible + changes around the incomplete application of those restrictions. + + Use with care and only if necessary. Temporary; scheduled for removal in 24.0.0 + """ + + permit_unconventional_http_version: bool = False + """\ + Permit HTTP version not matching conventions of 2023 + + This disables the refusal of likely malformed request lines. + It is unusual to specify HTTP 1 versions other than 1.0 and 1.1. + + This option is provided to diagnose backwards-incompatible changes. + Use with care and only if necessary. Temporary; the precise effect of this option may + change in a future version, or it may be removed altogether. + """ + + casefold_http_method: bool = False + """\ + Transform received HTTP methods to uppercase + + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + This option is provided because previous versions of gunicorn defaulted to this behaviour. + + Use with care and only if necessary. Deprecated; scheduled for removal in 24.0.0 + """ + + forwarder_headers: str = "SCRIPT_NAME,PATH_INFO" + """\ + A list containing upper-case header field names that the front-end proxy + (see :ref:`forwarded-allow-ips`) sets, to be used in WSGI environment. + + This option has no effect for headers not present in the request. + + This option can be used to transfer ``SCRIPT_NAME``, ``PATH_INFO`` + and ``REMOTE_USER``. + + It is important that your front-end proxy configuration ensures that + the headers defined here can not be passed directly from the client. + """ + + header_map: Literal["drop", "refuse", "dangerous"] = "drop" + """\ + Configure how header field names are mapped into environ + + Headers containing underscores are permitted by RFC9110, + but gunicorn joining headers of different names into + the same environment variable will dangerously confuse applications as to which is which. + + The safe default ``drop`` is to silently drop headers that cannot be unambiguously mapped. + The value ``refuse`` will return an error if a request contains *any* such header. + The value ``dangerous`` matches the previous, not advisable, behaviour of mapping different + header field names into the same environ name. + + If the source is permitted as explained in :ref:`forwarded-allow-ips`, *and* the header name is + present in :ref:`forwarder-headers`, the header is mapped into environment regardless of + the state of this setting. + + Use with care and only if necessary and after considering if your problem could + instead be solved by specifically renaming or rewriting only the intended headers + on a proxy in front of Gunicorn. + """ + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + + def run_prod(self) -> list[str]: + print("[reflex.server.gunicorn::GunicornBackendServer] start") + command = ["gunicorn"] + + for key,field in self.get_fields().items(): + if key != "app": + value = self.__getattribute__(key) + if key == "preload": + print(_mapping_attr_to_cli.get(key, None), value, field.default) + if _mapping_attr_to_cli.get(key, None): + if value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + command.append(_mapping_attr_to_cli[key]) + else: + command += [_mapping_attr_to_cli[key], str(value)] + + print("[reflex.server.gunicorn::GunicornBackendServer] done") + return command + [f"reflex.app_module_for_backend:{constants.CompileVars.APP}()"] + + def run_dev(self): + StandaloneApplication( + app=self.app, + options=self.dict().items() + ).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py new file mode 100644 index 000000000..b76f12a3e --- /dev/null +++ b/reflex/server/uvicorn.py @@ -0,0 +1,10 @@ +from reflex.server.base import CustomBackendServer + + +class UvicornBackendServer(CustomBackendServer): + + def run_prod(self): + pass + + def run_dev(self): + pass diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 5291de095..4e72a550c 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -2,6 +2,9 @@ from __future__ import annotations +from abc import abstractmethod, ABCMeta +from typing import IO, Any, Literal, Sequence, Type + import hashlib import json import os @@ -9,12 +12,18 @@ import platform import re import subprocess import sys +import asyncio +import ssl +from configparser import RawConfigParser from pathlib import Path from urllib.parse import urljoin +from pydantic import Field +from dataclasses import dataclass import psutil -from reflex import constants +from reflex import constants, server +from reflex.base import Base from reflex.config import environment, get_config from reflex.constants.base import LogLevel from reflex.utils import console, path_ops @@ -194,6 +203,7 @@ def get_app_module(): The app module for the backend. """ return f"reflex.app_module_for_backend:{constants.CompileVars.APP}" +### REWORK <-- def get_granian_target(): @@ -314,65 +324,36 @@ def run_backend_prod( loglevel: The log level. frontend_present: Whether the frontend is present. """ + print("[reflex.utils.exec::run_backend_prod] start") if not frontend_present: notify_backend() + config = get_config() + if should_use_granian(): run_granian_backend_prod(host, port, loglevel) else: - run_uvicorn_backend_prod(host, port, loglevel) + from reflex.utils import processes + backend_server_prod = config.backend_server_prod + if isinstance(backend_server_prod, server.GunicornBackendServer): + backend_server_prod.app = f"{get_app_module()}()" + backend_server_prod.preload_app = True + backend_server_prod.loglevel = loglevel.value # type: ignore + backend_server_prod.bind = [f"{host}:{port}"] + backend_server_prod.threads = _get_backend_workers() + backend_server_prod.workers = _get_backend_workers() -def run_uvicorn_backend_prod(host, port, loglevel): - """Run the backend in production mode using Uvicorn. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - from reflex.utils import processes - - config = get_config() - - app_module = get_app_module() - - RUN_BACKEND_PROD = f"gunicorn --worker-class {config.gunicorn_worker_class} --max-requests {config.gunicorn_max_requests} --max-requests-jitter {config.gunicorn_max_requests_jitter} --preload --timeout {config.timeout} --log-level critical".split() - RUN_BACKEND_PROD_WINDOWS = f"uvicorn --limit-max-requests {config.gunicorn_max_requests} --timeout-keep-alive {config.timeout}".split() - command = ( - [ - *RUN_BACKEND_PROD_WINDOWS, - "--host", - host, - "--port", - str(port), - app_module, - ] - if constants.IS_WINDOWS - else [ - *RUN_BACKEND_PROD, - "--bind", - f"{host}:{port}", - "--threads", - str(_get_backend_workers()), - f"{app_module}()", - ] - ) - - command += [ - "--log-level", - loglevel.value, - "--workers", - str(_get_backend_workers()), - ] - processes.new_process( - command, - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) + print(backend_server_prod.run_prod()) + processes.new_process( + backend_server_prod.run_prod(), + run=True, + show_logs=True, + env={ + environment.REFLEX_SKIP_COMPILE.name: "true" + }, # skip compile for prod backend + ) + print("[reflex.utils.exec::run_backend_prod] done") def run_granian_backend_prod(host, port, loglevel): @@ -416,6 +397,7 @@ def run_granian_backend_prod(host, port, loglevel): ) +### REWORK--> def output_system_info(): """Show system information if the loglevel is in DEBUG.""" if console._LOG_LEVEL > constants.LogLevel.DEBUG: @@ -540,3 +522,6 @@ def should_skip_compile() -> bool: removal_version="0.7.0", ) return environment.REFLEX_SKIP_COMPILE.get() + + +### REWORK <-- From 81290dcc99be7552f55fd6072a8caedff7ea1a75 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 16:29:16 +0000 Subject: [PATCH 02/25] add granian prod&dev, add gunicorn dev --- reflex/config.py | 53 ++++++-- reflex/server/__init__.py | 2 +- reflex/server/base.py | 85 +++++++++++- reflex/server/granian.py | 268 +++++++++++++++++++++++++++++++++++--- reflex/server/gunicorn.py | 186 +++++++++++++++++--------- reflex/server/uvicorn.py | 31 +++++ reflex/utils/exec.py | 202 ++++------------------------ 7 files changed, 556 insertions(+), 271 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 05d8c8a39..3b8b88310 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -665,15 +665,28 @@ class Config(Base): env_file: Optional[str] = None # Custom Backend Server - backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - app=f"reflex.app_module_for_backend:{constants.CompileVars.APP}.{constants.CompileVars.API}", - worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - max_requests=100, - max_requests_jitter=25, - preload_app=True, - timeout=120, + # backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( + # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + # max_requests=100, + # max_requests_jitter=25, + # timeout=120, + # ) + backend_server_prod: server.CustomBackendServer = server.GranianBackendServer( + threads=2, + workers=4, ) - backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer() + backend_server_dev: server.CustomBackendServer = server.GranianBackendServer( + threads=1, + workers=1, + ) + # backend_server_dev: server.CustomBackendServer = server.GunicornBackendServer( + # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + # max_requests=100, + # max_requests_jitter=25, + # timeout=120, + # threads=1, + # workers=1, + # ) def __init__(self, *args, **kwargs): """Initialize the config values. @@ -705,14 +718,26 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - - print("[reflex.config::Config] --") - for key in ("timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"): + + for key in ( + "timeout", + "gunicorn_worker_class", + "gunicorn_workers", + "gunicorn_max_requests", + "gunicorn_max_requests_jitter", + ): if isinstance(self.backend_server_prod, server.GunicornBackendServer): value = self.get_value(key) - if value != self.backend_server_prod.get_fields()[key.replace("gunicorn_", "")].default and value is not None: - setattr(self.backend_server_prod, key.replace("gunicorn_", ""), value) - print("[reflex.config::Config] done") + if ( + value + != self.backend_server_prod.get_fields()[ + key.replace("gunicorn_", "") + ].default + and value is not None + ): + setattr( + self.backend_server_prod, key.replace("gunicorn_", ""), value + ) @property def module(self) -> str: diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py index c44f2ca11..9633ddc7c 100644 --- a/reflex/server/__init__.py +++ b/reflex/server/__init__.py @@ -1,4 +1,4 @@ - +"""Import every *BackendServer.""" from .base import CustomBackendServer from .granian import GranianBackendServer diff --git a/reflex/server/base.py b/reflex/server/base.py index c327d5c89..c821c2686 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -1,14 +1,95 @@ -from abc import abstractmethod, ABCMeta +"""The base for CustomBackendServer.""" +from __future__ import annotations + +import os +from abc import abstractmethod +from pathlib import Path + +from reflex import constants from reflex.base import Base +from reflex.constants.base import Env, LogLevel class CustomBackendServer(Base): - + """BackendServer base.""" + + @staticmethod + def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): + """Get the app module for the backend. + + Returns: + The app module for the backend. + """ + import reflex + + if for_granian_target: + app_path = str(Path(reflex.__file__).parent / "app_module_for_backend.py") + else: + app_path = "reflex.app_module_for_backend" + + return f"{app_path}:{constants.CompileVars.APP}{f'.{constants.CompileVars.API}' if add_extra_api else ''}" + + def get_available_cpus(self) -> int: + """Get available cpus.""" + return os.cpu_count() or 1 + + def get_max_workers(self) -> int: + """Get max workers.""" + # https://docs.gunicorn.org/en/latest/settings.html#workers + return (os.cpu_count() or 1) * 4 + 1 + + def get_recommended_workers(self) -> int: + """Get recommended workers.""" + # https://docs.gunicorn.org/en/latest/settings.html#workers + return (os.cpu_count() or 1) * 2 + 1 + + def get_max_threads(self, wait_time_ms: int = 50, service_time_ms: int = 5) -> int: + """Get max threads.""" + # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html + # Brian Goetz formula + return int(self.get_available_cpus() * (1 + wait_time_ms / service_time_ms)) + + def get_recommended_threads( + self, + target_reqs: int | None = None, + wait_time_ms: int = 50, + service_time_ms: int = 5, + ) -> int: + """Get recommended threads.""" + # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html + max_available_threads = self.get_max_threads() + + if target_reqs: + # Little's law formula + need_threads = target_reqs * ( + (wait_time_ms / 1000) + (service_time_ms / 1000) + ) + else: + need_threads = self.get_max_threads(wait_time_ms, service_time_ms) + + return int( + max_available_threads + if need_threads > max_available_threads + else need_threads + ) + + @abstractmethod + def check_import(self, extra: bool = False): + """Check package importation.""" + raise NotImplementedError() + + @abstractmethod + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + raise NotImplementedError() + @abstractmethod def run_prod(self): + """Run in production mode.""" raise NotImplementedError() @abstractmethod def run_dev(self): + """Run in development mode.""" raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 7a2fde844..fda03912c 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -1,36 +1,272 @@ +"""The GranianBackendServer.""" +from __future__ import annotations + +import sys from dataclasses import dataclass +from dataclasses import field as dc_field +from pathlib import Path +from typing import Any, Literal, Type +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer +from reflex.utils import console + @dataclass -class HTTP1Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 - keep_alive: bool = True - max_buffer_size: int = 8192 + 4096 * 100 - pipeline_flush: bool = False +class HTTP1Settings: + """Granian HTTP1Settings.""" + + # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 + keep_alive: bool = dc_field(default=True) + max_buffer_size: int = dc_field(default=8192 + 4096 * 100) + pipeline_flush: bool = dc_field(default=False) @dataclass class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L13 - adaptive_window: bool = False - initial_connection_window_size: int = 1024 * 1024 - initial_stream_window_size: int = 1024 * 1024 - keep_alive_interval: int | None = None - keep_alive_timeout: int = 20 - max_concurrent_streams: int = 200 - max_frame_size: int = 1024 * 16 - max_headers_size: int = 16 * 1024 * 1024 - max_send_buffer_size: int = 1024 * 400 + """Granian HTTP2Settings.""" + + adaptive_window: bool = dc_field(default=False) + initial_connection_window_size: int = dc_field(default=1024 * 1024) + initial_stream_window_size: int = dc_field(default=1024 * 1024) + keep_alive_interval: int | None = dc_field(default=None) + keep_alive_timeout: int = dc_field(default=20) + max_concurrent_streams: int = dc_field(default=200) + max_frame_size: int = dc_field(default=1024 * 16) + max_headers_size: int = dc_field(default=16 * 1024 * 1024) + max_send_buffer_size: int = dc_field(default=1024 * 400) + try: - import watchfiles + import watchfiles # type: ignore except ImportError: watchfiles = None +_mapping_attr_to_cli: dict[str, str] = { + "address": "--host", + "port": "--port", + "interface": "--interface", + "http": "--http", + "websockets": "--ws", # NOTE: when `websockets` True: `--ws`; False: `--no-ws` + "workers": "--workers", + "threads": "--threads", + "blocking_threads": "--blocking-threads", + "threading_mode": "--threading-mode", + "loop": "--loop", + "loop_opt": "--opt", # NOTE: when `loop_opt` True: `--opt`; False: `--no-opt` + "backlog": "--backlog", + "backpressure": "--backpressure", + "http1_keep_alive": "--http1-keep-alive", + "http1_max_buffer_size": "--http1-max-buffer-size", + "http1_pipeline_flush": "--http1-pipeline-flush", + "http2_adaptive_window": "--http2-adaptive-window", + "http2_initial_connection_window_size": "--http2-initial-connection-window-size", + "http2_initial_stream_window_size": "--http2-initial-stream-window-size", + "http2_keep_alive_interval": "--http2-keep-alive-interval", + "http2_keep_alive_timeout": "--http2-keep-alive-timeout", + "http2_max_concurrent_streams": "--http2-max-concurrent-streams", + "http2_max_frame_size": "--http2-max-frame-size", + "http2_max_headers_size": "--http2-max-headers-size", + "http2_max_send_buffer_size": "--http2-max-send-buffer-size", + "log_enabled": "--log", # NOTE: when `log_enabled` True: `--log`; False: `--no-log` + "log_level": "--log-level", + "log_access": "--log-access", # NOTE: when `log_access` True: `--log-access`; False: `--no-log-access` + "log_access_format": "--access-log-fmt", + "ssl_cert": "--ssl-certificate", + "ssl_key": "--ssl-keyfile", + "ssl_key_password": "--ssl-keyfile-password", + "url_path_prefix": "--url-path-prefix", + "respawn_failed_workers": "--respawn-failed-workers", # NOTE: when `respawn_failed_workers` True: `--respawn-failed-workers`; False: `--no-respawn-failed-workers` + "respawn_interval": "--respawn-interval", + "workers_lifetime": "--workers-lifetime", + "factory": "--factory", # NOTE: when `factory` True: `--factory`; False: `--no-factory` + "reload": "--reload", # NOTE: when `reload` True: `--reload`; False: `--no-reload` + "reload_paths": "--reload-paths", + "reload_ignore_dirs": "--reload-ignore-dirs", + "reload_ignore_patterns": "--reload-ignore-patterns", + "reload_ignore_paths": "--reload-ignore-paths", + "process_name": "--process-name", + "pid_file": "--pid-file", +} + + class GranianBackendServer(CustomBackendServer): + """Granian backendServer.""" + + # https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/server.py#L69 + + target: str | None = None + address: str = "127.0.0.1" + port: int = 8000 + interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = "rsgi" + workers: int = 0 + threads: int = 0 + blocking_threads: int | None = None + threading_mode: Literal["runtime", "workers"] = "workers" + loop: Literal["auto", "asyncio", "uvloop"] = "auto" + loop_opt: bool = False + http: Literal["auto", "1", "2"] = "auto" + websockets: bool = True + backlog: int = 1024 + backpressure: int | None = None + + # http1_settings: HTTP1Settings | None = None + # NOTE: child of http1_settings, needed only for cli mode + http1_keep_alive: bool = HTTP1Settings.keep_alive + http1_max_buffer_size: int = HTTP1Settings.max_buffer_size + http1_pipeline_flush: bool = HTTP1Settings.pipeline_flush + + # http2_settings: HTTP2Settings | None = None + # NOTE: child of http2_settings, needed only for cli mode + http2_adaptive_window: bool = HTTP2Settings.adaptive_window + http2_initial_connection_window_size: int = ( + HTTP2Settings.initial_connection_window_size + ) + http2_initial_stream_window_size: int = HTTP2Settings.initial_stream_window_size + http2_keep_alive_interval: int | None = HTTP2Settings.keep_alive_interval + http2_keep_alive_timeout: int = HTTP2Settings.keep_alive_timeout + http2_max_concurrent_streams: int = HTTP2Settings.max_concurrent_streams + http2_max_frame_size: int = HTTP2Settings.max_frame_size + http2_max_headers_size: int = HTTP2Settings.max_headers_size + http2_max_send_buffer_size: int = HTTP2Settings.max_send_buffer_size + + log_enabled: bool = True + log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = "info" + log_dictconfig: dict[str, Any] | None = None + log_access: bool = False + log_access_format: str | None = None + ssl_cert: Path | None = None + ssl_key: Path | None = None + ssl_key_password: str | None = None + url_path_prefix: str | None = None + respawn_failed_workers: bool = False + respawn_interval: float = 3.5 + workers_lifetime: int | None = None + factory: bool = False + reload: bool = False + reload_paths: list[Path] | None = None + reload_ignore_dirs: list[str] | None = None + reload_ignore_patterns: list[str] | None = None + reload_ignore_paths: list[Path] | None = None + reload_filter: Type[getattr(watchfiles, "BaseFilter", None)] | None = None # type: ignore + process_name: str | None = None + pid_file: Path | None = None + + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("granian") is None: + errors.append( + 'The `granian` package is required to run `GranianBackendServer`. Run `pip install "granian>=1.6.0"`.' + ) + + if find_spec("watchfiles") is None and extra: + # NOTE: the `\[` is for force `rich.Console` to not consider it like a color or anything else which he not printing `[.*]` + errors.append( + r'Using --reload in `GranianBackendServer` requires the granian\[reload] extra. Run `pip install "granian\[reload]>=1.6.0"`.' + ) # type: ignore + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + self.target = self.get_app_module(for_granian_target=True, add_extra_api=True) + self.log_level = loglevel.value # type: ignore + self.address = host + self.port = port + self.interface = "asgi" # prevent obvious error + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_threads := self.get_max_workers()): + self.workers = max_threads + + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads + + if env == Env.DEV: + from reflex.config import get_config # prevent circular import + + self.reload = True + self.reload_paths = [Path(get_config().app_name)] + self.reload_ignore_dirs = [".web"] def run_prod(self): - pass + """Run in production mode.""" + self.check_import() + command = ["granian"] + + for key, field in self.get_fields().items(): + if key != "target": + value = getattr(self, key) + if _mapping_attr_to_cli.get(key) and value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + command.append( + f"--{'no-' if value is False else ''}{_mapping_attr_to_cli[key][2:]}" + ) + else: + command += [_mapping_attr_to_cli[key], str(value)] + + return command + [ + self.get_app_module(for_granian_target=True, add_extra_api=True) + ] def run_dev(self): - pass + """Run in development mode.""" + self.check_import(extra=self.reload) + from granian import Granian + + exclude_keys = ( + "http1_keep_alive", + "http1_max_buffer_size", + "http1_pipeline_flush", + "http2_adaptive_window", + "http2_initial_connection_window_size", + "http2_initial_stream_window_size", + "http2_keep_alive_interval", + "http2_keep_alive_timeout", + "http2_max_concurrent_streams", + "http2_max_frame_size", + "http2_max_headers_size", + "http2_max_send_buffer_size", + ) + model = self.get_fields() + Granian( + **{ + **{ + key: value + for key, value in self.dict().items() + if key not in exclude_keys and value != model[key].default + }, + "http1_settings": HTTP1Settings( + self.http1_keep_alive, + self.http1_max_buffer_size, + self.http1_pipeline_flush, + ), + "http2_settings": HTTP2Settings( + self.http2_adaptive_window, + self.http2_initial_connection_window_size, + self.http2_initial_stream_window_size, + self.http2_keep_alive_interval, + self.http2_keep_alive_timeout, + self.http2_max_concurrent_streams, + self.http2_max_frame_size, + self.http2_max_headers_size, + self.http2_max_send_buffer_size, + ), + } + ).serve() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 18dfb7841..659cb5262 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -1,34 +1,15 @@ -from typing import Any, Literal, Callable +"""The GunicornBackendServer.""" + +from __future__ import annotations import os -import sys import ssl -from pydantic import Field +import sys +from typing import Any, Callable, Literal -from gunicorn.app.base import BaseApplication - -import psutil - -from reflex import constants +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer - - -class StandaloneApplication(BaseApplication): - - def __init__(self, app, options=None): - self.options = options or {} - self.application = app - super().__init__() - - def load_config(self): - config = {key: value for key, value in self.options.items() - if key in self.cfg.settings and value is not None} # type: ignore - for key, value in config.items(): - self.cfg.set(key.lower(), value) # type: ignore - - def load(self): - return self.application - +from reflex.utils import console _mapping_attr_to_cli: dict[str, str] = { "config": "--config", @@ -105,10 +86,13 @@ _mapping_attr_to_cli: dict[str, str] = { "header_map": "--header-map", } + class GunicornBackendServer(CustomBackendServer): + """Gunicorn backendServer.""" + # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - app: str + app_uri: str | None config: str = "./gunicorn.conf.py" """\ @@ -128,7 +112,7 @@ class GunicornBackendServer(CustomBackendServer): A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. """ - bind: list[str] = ['127.0.0.1:8000'] + bind: list[str] = ["127.0.0.1:8000"] """\ The socket to bind. @@ -162,7 +146,7 @@ class GunicornBackendServer(CustomBackendServer): Must be a positive integer. Generally set in the 64-2048 range. """ - workers: int = 1 + workers: int = 0 """\ The number of worker processes for handling requests. @@ -175,7 +159,14 @@ class GunicornBackendServer(CustomBackendServer): it is not defined, the default is ``1``. """ - worker_class: Literal["sync", "eventlet", "gevent", "tornado", "gthread", "uvicorn.workers.UvicornH11Worker"] = "sync" + worker_class: Literal[ + "sync", + "eventlet", + "gevent", + "tornado", + "gthread", + "uvicorn.workers.UvicornH11Worker", + ] = "sync" """\ The type of workers to use. @@ -202,7 +193,7 @@ class GunicornBackendServer(CustomBackendServer): ``gunicorn.workers.ggevent.GeventWorker``. """ - threads: int = 1 + threads: int = 0 """\ The number of worker threads for handling requests. @@ -493,7 +484,11 @@ class GunicornBackendServer(CustomBackendServer): temporary directory. """ - secure_scheme_headers: dict[str, Any] = {'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'} + secure_scheme_headers: dict[str, Any] = { + "X-FORWARDED-PROTOCOL": "ssl", + "X-FORWARDED-PROTO": "https", + "X-FORWARDED-SSL": "on", + } """\ A dictionary containing headers and values that the front-end proxy uses to indicate HTTPS requests. If the source IP is permitted by @@ -588,7 +583,9 @@ class GunicornBackendServer(CustomBackendServer): Disable redirect access logs to syslog. """ - access_log_format: str = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + access_log_format: str = ( + '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + ) """\ The access log format. @@ -686,7 +683,11 @@ class GunicornBackendServer(CustomBackendServer): if sys.platform == "darwin" else ( "unix:///var/run/log" - if sys.platform in ('freebsd', 'dragonfly', ) + if sys.platform + in ( + "freebsd", + "dragonfly", + ) else ( "unix:///dev/log" if sys.platform == "openbsd" @@ -858,7 +859,9 @@ class GunicornBackendServer(CustomBackendServer): The callable needs to accept a single instance variable for the Arbiter. """ - pre_request: Callable = lambda worker, req: worker.log.debug("%s %s", req.method, req.path) + pre_request: Callable = lambda worker, req: worker.log.debug( + "%s %s", req.method, req.path + ) """\ Called just before a worker processes the request. @@ -908,7 +911,9 @@ class GunicornBackendServer(CustomBackendServer): The callable needs to accept a single instance variable for the Arbiter. """ - ssl_context: Callable = lambda config, default_ssl_context_factory: default_ssl_context_factory() + ssl_context: Callable = ( + lambda config, default_ssl_context_factory: default_ssl_context_factory() + ) """\ Called when SSLContext is needed. @@ -975,7 +980,9 @@ class GunicornBackendServer(CustomBackendServer): SSL certificate file """ - ssl_version: int = ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + ssl_version: int = ( + ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + ) """\ SSL version to use (see stdlib ssl module's). @@ -1163,33 +1170,96 @@ class GunicornBackendServer(CustomBackendServer): on a proxy in front of Gunicorn. """ - # def __init__(self, *args, **kwargs): - # super().__init__(*args, **kwargs) + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("gunicorn") is None: + errors.append( + 'The `gunicorn` package is required to run `GunicornBackendServer`. Run `pip install "gunicorn>=20.1.0"`.' + ) + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + self.app_uri = f"{self.get_app_module()}()" + self.loglevel = loglevel.value # type: ignore + self.bind = [f"{host}:{port}"] + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_threads := self.get_max_workers()): + self.workers = max_threads + + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads + self.preload_app = True + + if env == Env.DEV: + self.reload = True def run_prod(self) -> list[str]: - print("[reflex.server.gunicorn::GunicornBackendServer] start") + """Run in production mode.""" + self.check_import() + command = ["gunicorn"] - for key,field in self.get_fields().items(): + for key, field in self.get_fields().items(): if key != "app": - value = self.__getattribute__(key) - if key == "preload": - print(_mapping_attr_to_cli.get(key, None), value, field.default) - if _mapping_attr_to_cli.get(key, None): - if value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): + value = getattr(self, key) + if _mapping_attr_to_cli.get(key) and value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + if (key == "sendfile" and value is False) or ( + key != "sendfile" and value + ): command.append(_mapping_attr_to_cli[key]) - else: - command += [_mapping_attr_to_cli[key], str(value)] + else: + command += [_mapping_attr_to_cli[key], str(value)] - print("[reflex.server.gunicorn::GunicornBackendServer] done") - return command + [f"reflex.app_module_for_backend:{constants.CompileVars.APP}()"] + return command + [f"{self.get_app_module()}()"] def run_dev(self): - StandaloneApplication( - app=self.app, - options=self.dict().items() - ).run() + """Run in development mode.""" + self.check_import() + console.info( + "For development mode, we recommand to use `UvicornBackendServer` than `GunicornBackendServer`" + ) + + from gunicorn.app.base import BaseApplication + from gunicorn.util import import_app as gunicorn_import_app + + options_ = self.dict() + options_.pop("app", None) + + class StandaloneApplication(BaseApplication): + def __init__(self, app_uri, options=None): + self.options = options or {} + self.app_uri = app_uri + super().__init__() + + def load_config(self): + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } # type: ignore + for key, value in config.items(): + self.cfg.set(key.lower(), value) # type: ignore + + def load(self): + return gunicorn_import_app(self.app_uri) + + StandaloneApplication(app_uri=self.app_uri, options=options_).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index b76f12a3e..cb5131c4a 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -1,10 +1,41 @@ +"""The UvicornBackendServer.""" + +from __future__ import annotations + +import sys + +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer +from reflex.utils import console +# TODO class UvicornBackendServer(CustomBackendServer): + """Uvicorn backendServer.""" + + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("uvicorn") is None: + errors.append( + 'The `uvicorn` package is required to run `UvicornBackendServer`. Run `pip install "uvicorn>=0.20.0"`.' + ) + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + pass def run_prod(self): + """Run in production mode.""" pass def run_dev(self): + """Run in development mode.""" pass diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 4e72a550c..d04ca60d8 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -2,9 +2,6 @@ from __future__ import annotations -from abc import abstractmethod, ABCMeta -from typing import IO, Any, Literal, Sequence, Type - import hashlib import json import os @@ -12,20 +9,14 @@ import platform import re import subprocess import sys -import asyncio -import ssl -from configparser import RawConfigParser from pathlib import Path from urllib.parse import urljoin -from pydantic import Field -from dataclasses import dataclass import psutil -from reflex import constants, server -from reflex.base import Base +from reflex import constants from reflex.config import environment, get_config -from reflex.constants.base import LogLevel +from reflex.constants.base import Env, LogLevel from reflex.utils import console, path_ops from reflex.utils.prerequisites import get_web_dir @@ -187,42 +178,11 @@ def run_frontend_prod(root: Path, port: str, backend_present=True): ) -def should_use_granian(): - """Whether to use Granian for backend. - - Returns: - True if Granian should be used. - """ - return environment.REFLEX_USE_GRANIAN.get() - - -def get_app_module(): - """Get the app module for the backend. - - Returns: - The app module for the backend. - """ - return f"reflex.app_module_for_backend:{constants.CompileVars.APP}" ### REWORK <-- - - -def get_granian_target(): - """Get the Granian target for the backend. - - Returns: - The Granian target for the backend. - """ - import reflex - - app_module_path = Path(reflex.__file__).parent / "app_module_for_backend.py" - - return f"{str(app_module_path)}:{constants.CompileVars.APP}.{constants.CompileVars.API}" - - def run_backend( host: str, port: int, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, + loglevel: LogLevel = LogLevel.ERROR, frontend_present: bool = False, ): """Run the backend. @@ -234,6 +194,7 @@ def run_backend( frontend_present: Whether the frontend is present. """ web_dir = get_web_dir() + config = get_config() # Create a .nocompile file to skip compile for backend. if web_dir.exists(): (web_dir / constants.NOCOMPILE_FILE).touch() @@ -242,78 +203,15 @@ def run_backend( notify_backend() # Run the backend in development mode. - if should_use_granian(): - run_granian_backend(host, port, loglevel) - else: - run_uvicorn_backend(host, port, loglevel) - - -def run_uvicorn_backend(host, port, loglevel: LogLevel): - """Run the backend in development mode using Uvicorn. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - import uvicorn - - uvicorn.run( - app=f"{get_app_module()}.{constants.CompileVars.API}", - host=host, - port=port, - log_level=loglevel.value, - reload=True, - reload_dirs=[get_config().app_name], - ) - - -def run_granian_backend(host, port, loglevel: LogLevel): - """Run the backend in development mode using Granian. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - console.debug("Using Granian for backend") - try: - from granian import Granian # type: ignore - from granian.constants import Interfaces # type: ignore - from granian.log import LogLevels # type: ignore - - Granian( - target=get_granian_target(), - address=host, - port=port, - interface=Interfaces.ASGI, - log_level=LogLevels(loglevel.value), - reload=True, - reload_paths=[Path(get_config().app_name)], - reload_ignore_dirs=[".web"], - ).serve() - except ImportError: - console.error( - 'InstallError: REFLEX_USE_GRANIAN is set but `granian` is not installed. (run `pip install "granian[reload]>=1.6.0"`)' - ) - os._exit(1) - - -def _get_backend_workers(): - from reflex.utils import processes - - config = get_config() - return ( - processes.get_num_workers() - if not config.gunicorn_workers - else config.gunicorn_workers - ) + backend_server_prod = config.backend_server_prod + backend_server_prod.setup(host, port, loglevel, Env.DEV) + backend_server_prod.run_dev() def run_backend_prod( host: str, port: int, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, + loglevel: LogLevel = LogLevel.ERROR, frontend_present: bool = False, ): """Run the backend. @@ -324,77 +222,24 @@ def run_backend_prod( loglevel: The log level. frontend_present: Whether the frontend is present. """ - print("[reflex.utils.exec::run_backend_prod] start") - if not frontend_present: - notify_backend() + from reflex.utils import processes config = get_config() - if should_use_granian(): - run_granian_backend_prod(host, port, loglevel) - else: - from reflex.utils import processes + if not frontend_present: + notify_backend() - backend_server_prod = config.backend_server_prod - if isinstance(backend_server_prod, server.GunicornBackendServer): - backend_server_prod.app = f"{get_app_module()}()" - backend_server_prod.preload_app = True - backend_server_prod.loglevel = loglevel.value # type: ignore - backend_server_prod.bind = [f"{host}:{port}"] - backend_server_prod.threads = _get_backend_workers() - backend_server_prod.workers = _get_backend_workers() - - print(backend_server_prod.run_prod()) - processes.new_process( - backend_server_prod.run_prod(), - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) - print("[reflex.utils.exec::run_backend_prod] done") - - -def run_granian_backend_prod(host, port, loglevel): - """Run the backend in production mode using Granian. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - from reflex.utils import processes - - try: - from granian.constants import Interfaces # type: ignore - - command = [ - "granian", - "--workers", - str(_get_backend_workers()), - "--log-level", - "critical", - "--host", - host, - "--port", - str(port), - "--interface", - str(Interfaces.ASGI), - get_granian_target(), - ] - processes.new_process( - command, - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) - except ImportError: - console.error( - 'InstallError: REFLEX_USE_GRANIAN is set but `granian` is not installed. (run `pip install "granian[reload]>=1.6.0"`)' - ) + # Run the backend in production mode. + backend_server_prod = config.backend_server_prod + backend_server_prod.setup(host, port, loglevel, Env.PROD) + processes.new_process( + backend_server_prod.run_prod(), + run=True, + show_logs=True, + env={ + environment.REFLEX_SKIP_COMPILE.name: "true" + }, # skip compile for prod backend + ) ### REWORK--> @@ -522,6 +367,3 @@ def should_skip_compile() -> bool: removal_version="0.7.0", ) return environment.REFLEX_SKIP_COMPILE.get() - - -### REWORK <-- From a1f009bc3e6d2200afc8f449c9c1371d074df5cb Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 16:37:28 +0000 Subject: [PATCH 03/25] gunicorn `app_uri` are optional --- reflex/server/gunicorn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 659cb5262..c0bfb3b50 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -92,7 +92,7 @@ class GunicornBackendServer(CustomBackendServer): # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - app_uri: str | None + app_uri: str | None = None config: str = "./gunicorn.conf.py" """\ From 0dfe34a05ae48a428eaf10c438d4a506db4f2bc5 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 9 Nov 2024 21:02:39 +0000 Subject: [PATCH 04/25] drop pydantic for dataclass & add uvicorn prod/dev --- reflex/config.py | 20 +- reflex/server/__init__.py | 7 + reflex/server/base.py | 178 ++++- reflex/server/granian.py | 310 +++++---- reflex/server/gunicorn.py | 1372 ++++++++----------------------------- reflex/server/uvicorn.py | 228 +++++- reflex/utils/exec.py | 6 +- 7 files changed, 857 insertions(+), 1264 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 3b8b88310..d67446b36 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -665,28 +665,13 @@ class Config(Base): env_file: Optional[str] = None # Custom Backend Server - # backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - # max_requests=100, - # max_requests_jitter=25, - # timeout=120, - # ) - backend_server_prod: server.CustomBackendServer = server.GranianBackendServer( + backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( threads=2, workers=4, ) - backend_server_dev: server.CustomBackendServer = server.GranianBackendServer( - threads=1, + backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, ) - # backend_server_dev: server.CustomBackendServer = server.GunicornBackendServer( - # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - # max_requests=100, - # max_requests_jitter=25, - # timeout=120, - # threads=1, - # workers=1, - # ) def __init__(self, *args, **kwargs): """Initialize the config values. @@ -698,7 +683,6 @@ class Config(Base): Raises: ConfigError: If some values in the config are invalid. """ - print("[reflex.config::Config] start") super().__init__(*args, **kwargs) # Update the config from environment variables. diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py index 9633ddc7c..b39dc8049 100644 --- a/reflex/server/__init__.py +++ b/reflex/server/__init__.py @@ -4,3 +4,10 @@ from .base import CustomBackendServer from .granian import GranianBackendServer from .gunicorn import GunicornBackendServer from .uvicorn import UvicornBackendServer + +__all__ = [ + "CustomBackendServer", + "GranianBackendServer", + "GunicornBackendServer", + "UvicornBackendServer", +] diff --git a/reflex/server/base.py b/reflex/server/base.py index c821c2686..012511233 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -4,16 +4,160 @@ from __future__ import annotations import os from abc import abstractmethod +from dataclasses import Field, dataclass +from dataclasses import field as dc_field from pathlib import Path +from typing import Any, Callable, Sequence from reflex import constants -from reflex.base import Base from reflex.constants.base import Env, LogLevel +ReturnCliTypeFn = Callable[[Any], str] -class CustomBackendServer(Base): + +class CliType: + """Cli type transformer.""" + + @staticmethod + def default(fmt: str) -> ReturnCliTypeFn: + """Default cli transformer. + + Example: + fmt: `'--env-file {value}'` + value: `'/config.conf'` + result => `'--env-file /config.conf'` + """ + + def wrapper(value: bool) -> str: + return fmt.format(value=value) + + return wrapper + + @staticmethod + def boolean(fmt: str, bool_value: bool = True) -> ReturnCliTypeFn: + """When cli mode args only show when we want to activate it. + + Example: + fmt: `'--reload'` + value: `False` + result => `''` + + Example: + fmt: `'--reload'` + value: `True` + result => `'--reload'` + """ + + def wrapper(value: bool) -> str: + return fmt if value is bool_value else "" + + return wrapper + + @staticmethod + def boolean_toggle( + fmt: str, + toggle_kw: str = "no", + toggle_sep: str = "-", + toggle_value: bool = False, + **kwargs, + ) -> ReturnCliTypeFn: + """When the cli mode is a boolean toggle `--access-log`/`--no-access-log`. + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `False` + toggle_value: `False` (default) + result => `'--no-access-log'` + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `True` + toggle_value: `False` (default) + result => `'--access-log'` + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `True` + toggle_value: `True` + result => `'--no-access-log'` + """ + + def wrapper(value: bool) -> str: + return fmt.format( + **kwargs, + toggle_kw=(toggle_kw if value is toggle_value else ""), + toggle_sep=(toggle_sep if value is toggle_value else ""), + ) + + return wrapper + + @staticmethod + def multiple( + fmt: str, + join_sep: str | None = None, + value_transformer: Callable[[Any], str] = lambda value: str(value), + ) -> ReturnCliTypeFn: + r"""When the cli mode need multiple args or single args from an sequence. + + Example (Multiple args mode): + fmt: `'--header {value}'`. + data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + result => `'--header \"X-Forwarded-Proto=https\" --header \"X-Forwarded-For=0.0.0.0\"'` + + Example (Single args mode): + fmt: `--headers {values}` + data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + join_sep (required): `';'` + result => `--headers \"X-Forwarded-Proto=https;X-Forwarded-For=0.0.0.0\"` + + Example (Single args mode): + fmt: `--headers {values}` + data_list: `[('X-Forwarded-Proto', 'https'), ('X-Forwarded-For', '0.0.0.0')]` + join_sep (required): `';'` + value_transformer: `lambda value: f'{value[0]}:{value[1]}'` + result => `--headers \"X-Forwarded-Proto:https;X-Forwarded-For:0.0.0.0\"` + """ + + def wrapper(values: Sequence[str]) -> str: + return ( + fmt.format( + values=join_sep.join(value_transformer(value) for value in values) + ) + if join_sep + else " ".join( + [fmt.format(value=value_transformer(value)) for value in values] + ) + ) + + return wrapper + + +def field_( + *, + default: Any = None, + metadata_cli: ReturnCliTypeFn | None = None, + exclude: bool = False, + **kwargs, +): + """Custom dataclass field builder.""" + params_ = { + "default": default, + "metadata": {"cli": metadata_cli, "exclude": exclude}, + **kwargs, + } + + if kwargs.get("default_factory", False): + params_.pop("default", None) + + return dc_field(**params_) + + +@dataclass +class CustomBackendServer: """BackendServer base.""" + _app_uri: str = field_(default="", metadata_cli=None, exclude=True) + @staticmethod def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): """Get the app module for the backend. @@ -74,8 +218,36 @@ class CustomBackendServer(Base): else need_threads ) + def get_fields(self) -> dict[str, Field]: + """Return all the fields.""" + return self.__dataclass_fields__ + + def get_values(self) -> dict[str, Any]: + """Return all values.""" + return { + key: getattr(self, key) + for key, field in self.__dataclass_fields__.items() + if field.metadata["exclude"] is False + } + + def is_default_value(self, key, value: Any | None = None) -> bool: + """Check if the `value` is the same value from default context.""" + from dataclasses import MISSING + + field = self.get_fields()[key] + if value is None: + value = getattr(self, key, None) + + if field.default != MISSING: + return value == field.default + else: + if field.default_factory != MISSING: + return value == field.default_factory() + + return False + @abstractmethod - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index fda03912c..2a76d8c53 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -6,10 +6,10 @@ import sys from dataclasses import dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Literal, Type +from typing import Any, Literal from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console @@ -38,122 +38,160 @@ class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba max_send_buffer_size: int = dc_field(default=1024 * 400) -try: - import watchfiles # type: ignore -except ImportError: - watchfiles = None - -_mapping_attr_to_cli: dict[str, str] = { - "address": "--host", - "port": "--port", - "interface": "--interface", - "http": "--http", - "websockets": "--ws", # NOTE: when `websockets` True: `--ws`; False: `--no-ws` - "workers": "--workers", - "threads": "--threads", - "blocking_threads": "--blocking-threads", - "threading_mode": "--threading-mode", - "loop": "--loop", - "loop_opt": "--opt", # NOTE: when `loop_opt` True: `--opt`; False: `--no-opt` - "backlog": "--backlog", - "backpressure": "--backpressure", - "http1_keep_alive": "--http1-keep-alive", - "http1_max_buffer_size": "--http1-max-buffer-size", - "http1_pipeline_flush": "--http1-pipeline-flush", - "http2_adaptive_window": "--http2-adaptive-window", - "http2_initial_connection_window_size": "--http2-initial-connection-window-size", - "http2_initial_stream_window_size": "--http2-initial-stream-window-size", - "http2_keep_alive_interval": "--http2-keep-alive-interval", - "http2_keep_alive_timeout": "--http2-keep-alive-timeout", - "http2_max_concurrent_streams": "--http2-max-concurrent-streams", - "http2_max_frame_size": "--http2-max-frame-size", - "http2_max_headers_size": "--http2-max-headers-size", - "http2_max_send_buffer_size": "--http2-max-send-buffer-size", - "log_enabled": "--log", # NOTE: when `log_enabled` True: `--log`; False: `--no-log` - "log_level": "--log-level", - "log_access": "--log-access", # NOTE: when `log_access` True: `--log-access`; False: `--no-log-access` - "log_access_format": "--access-log-fmt", - "ssl_cert": "--ssl-certificate", - "ssl_key": "--ssl-keyfile", - "ssl_key_password": "--ssl-keyfile-password", - "url_path_prefix": "--url-path-prefix", - "respawn_failed_workers": "--respawn-failed-workers", # NOTE: when `respawn_failed_workers` True: `--respawn-failed-workers`; False: `--no-respawn-failed-workers` - "respawn_interval": "--respawn-interval", - "workers_lifetime": "--workers-lifetime", - "factory": "--factory", # NOTE: when `factory` True: `--factory`; False: `--no-factory` - "reload": "--reload", # NOTE: when `reload` True: `--reload`; False: `--no-reload` - "reload_paths": "--reload-paths", - "reload_ignore_dirs": "--reload-ignore-dirs", - "reload_ignore_patterns": "--reload-ignore-patterns", - "reload_ignore_paths": "--reload-ignore-paths", - "process_name": "--process-name", - "pid_file": "--pid-file", -} - - +@dataclass class GranianBackendServer(CustomBackendServer): - """Granian backendServer.""" + """Granian backendServer. - # https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/server.py#L69 + https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/cli.py#L52 (until Granian has the proper documentation) - target: str | None = None - address: str = "127.0.0.1" - port: int = 8000 - interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = "rsgi" - workers: int = 0 - threads: int = 0 - blocking_threads: int | None = None - threading_mode: Literal["runtime", "workers"] = "workers" - loop: Literal["auto", "asyncio", "uvloop"] = "auto" - loop_opt: bool = False - http: Literal["auto", "1", "2"] = "auto" - websockets: bool = True - backlog: int = 1024 - backpressure: int | None = None + """ - # http1_settings: HTTP1Settings | None = None - # NOTE: child of http1_settings, needed only for cli mode - http1_keep_alive: bool = HTTP1Settings.keep_alive - http1_max_buffer_size: int = HTTP1Settings.max_buffer_size - http1_pipeline_flush: bool = HTTP1Settings.pipeline_flush - - # http2_settings: HTTP2Settings | None = None - # NOTE: child of http2_settings, needed only for cli mode - http2_adaptive_window: bool = HTTP2Settings.adaptive_window - http2_initial_connection_window_size: int = ( - HTTP2Settings.initial_connection_window_size + address: str = field_( + default="127.0.0.1", metadata_cli=CliType.default("--host {value}") + ) + port: int = field_(default=8000, metadata_cli=CliType.default("--port {value}")) + interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = field_( + default="rsgi", metadata_cli=CliType.default("--interface {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) + threads: int = field_(default=0, metadata_cli=CliType.default("--threads {value}")) + blocking_threads: int | None = field_( + default=None, metadata_cli=CliType.default("--blocking-threads {value}") + ) + threading_mode: Literal["runtime", "workers"] = field_( + default="workers", metadata_cli=CliType.default("--threading-mode {value}") + ) + loop: Literal["auto", "asyncio", "uvloop"] = field_( + default="auto", metadata_cli=CliType.default("--loop {value}") + ) + loop_opt: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}opt"), + ) + http: Literal["auto", "1", "2"] = field_( + default="auto", metadata_cli=CliType.default("--http {value}") + ) + websockets: bool = field_( + default=True, metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}ws") + ) + backlog: int = field_( + default=1024, metadata_cli=CliType.default("--backlog {value}") + ) + backpressure: int | None = field_( + default=None, metadata_cli=CliType.default("--backpressure {value}") + ) + http1_keep_alive: bool = field_( + default=True, metadata_cli=CliType.default("--http1-keep-alive {value}") + ) + http1_max_buffer_size: int = field_( + default=417792, metadata_cli=CliType.default("--http1-max-buffer-size {value}") + ) + http1_pipeline_flush: bool = field_( + default=False, metadata_cli=CliType.default("--http1-pipeline-flush {value}") + ) + http2_adaptive_window: bool = field_( + default=False, metadata_cli=CliType.default("--http2-adaptive-window {value}") + ) + http2_initial_connection_window_size: int = field_( + default=1048576, + metadata_cli=CliType.default("--http2-initial-connection-window-size {value}"), + ) + http2_initial_stream_window_size: int = field_( + default=1048576, + metadata_cli=CliType.default("--http2-initial-stream-window-size {value}"), + ) + http2_keep_alive_interval: int | None = field_( + default=None, + metadata_cli=CliType.default("--http2-keep-alive-interval {value}"), + ) + http2_keep_alive_timeout: int = field_( + default=20, metadata_cli=CliType.default("--http2-keep-alive-timeout {value}") + ) + http2_max_concurrent_streams: int = field_( + default=200, + metadata_cli=CliType.default("--http2-max-concurrent-streams {value}"), + ) + http2_max_frame_size: int = field_( + default=16384, metadata_cli=CliType.default("--http2-max-frame-size {value}") + ) + http2_max_headers_size: int = field_( + default=16777216, + metadata_cli=CliType.default("--http2-max-headers-size {value}"), + ) + http2_max_send_buffer_size: int = field_( + default=409600, + metadata_cli=CliType.default("--http2-max-send-buffer-size {value}"), + ) + log_enabled: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}log"), + ) + log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = ( + field_(default="info", metadata_cli=CliType.default("--log-level {value}")) + ) + log_dictconfig: dict[str, Any] | None = field_(default=None, metadata_cli=None) + log_access: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}log-access"), + ) + log_access_format: str | None = field_( + default=None, metadata_cli=CliType.default("--access-log-fmt {value}") + ) + ssl_cert: Path | None = field_( + default=None, metadata_cli=CliType.default("--ssl-certificate {value}") + ) + ssl_key: Path | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile {value}") + ) + ssl_key_password: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile-password {value}") + ) + url_path_prefix: str | None = field_( + default=None, metadata_cli=CliType.default("--url-path-prefix {value}") + ) + respawn_failed_workers: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle( + "--{toggle_kw}{toggle_sep}respawn-failed-workers" + ), + ) + respawn_interval: float = field_( + default=3.5, metadata_cli=CliType.default("--respawn-interval {value}") + ) + workers_lifetime: int | None = field_( + default=None, metadata_cli=CliType.default("--workers-lifetime {value}") + ) + factory: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}factory"), + ) + reload: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}reload"), + ) + reload_paths: list[Path] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-paths {value}") + ) + reload_ignore_dirs: list[str] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-dirs {value}") + ) + reload_ignore_patterns: list[str] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-patterns {value}") + ) + reload_ignore_paths: list[Path] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-paths {value}") + ) + reload_filter: object | None = field_( # type: ignore + default=None, metadata_cli=None + ) + process_name: str | None = field_( + default=None, metadata_cli=CliType.default("--process-name {value}") + ) + pid_file: Path | None = field_( + default=None, metadata_cli=CliType.default("--pid-file {value}") ) - http2_initial_stream_window_size: int = HTTP2Settings.initial_stream_window_size - http2_keep_alive_interval: int | None = HTTP2Settings.keep_alive_interval - http2_keep_alive_timeout: int = HTTP2Settings.keep_alive_timeout - http2_max_concurrent_streams: int = HTTP2Settings.max_concurrent_streams - http2_max_frame_size: int = HTTP2Settings.max_frame_size - http2_max_headers_size: int = HTTP2Settings.max_headers_size - http2_max_send_buffer_size: int = HTTP2Settings.max_send_buffer_size - log_enabled: bool = True - log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = "info" - log_dictconfig: dict[str, Any] | None = None - log_access: bool = False - log_access_format: str | None = None - ssl_cert: Path | None = None - ssl_key: Path | None = None - ssl_key_password: str | None = None - url_path_prefix: str | None = None - respawn_failed_workers: bool = False - respawn_interval: float = 3.5 - workers_lifetime: int | None = None - factory: bool = False - reload: bool = False - reload_paths: list[Path] | None = None - reload_ignore_dirs: list[str] | None = None - reload_ignore_patterns: list[str] | None = None - reload_ignore_paths: list[Path] | None = None - reload_filter: Type[getattr(watchfiles, "BaseFilter", None)] | None = None # type: ignore - process_name: str | None = None - pid_file: Path | None = None - - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -164,11 +202,10 @@ class GranianBackendServer(CustomBackendServer): 'The `granian` package is required to run `GranianBackendServer`. Run `pip install "granian>=1.6.0"`.' ) - if find_spec("watchfiles") is None and extra: - # NOTE: the `\[` is for force `rich.Console` to not consider it like a color or anything else which he not printing `[.*]` + if find_spec("watchfiles") is None and self.reload: errors.append( - r'Using --reload in `GranianBackendServer` requires the granian\[reload] extra. Run `pip install "granian\[reload]>=1.6.0"`.' - ) # type: ignore + 'Using `--reload` in `GranianBackendServer` requires the `watchfiles` extra. Run `pip install "watchfiles~=0.21"`.' + ) if errors: console.error("\n".join(errors)) @@ -176,18 +213,18 @@ class GranianBackendServer(CustomBackendServer): def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - self.target = self.get_app_module(for_granian_target=True, add_extra_api=True) + self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) self.log_level = loglevel.value # type: ignore self.address = host self.port = port - self.interface = "asgi" # prevent obvious error + self.interface = "asgi" # NOTE: prevent obvious error if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: self.workers = self.get_recommended_workers() else: - if self.workers > (max_threads := self.get_max_workers()): - self.workers = max_threads + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers if self.threads == self.get_fields()["threads"].default: self.threads = self.get_recommended_threads() @@ -208,26 +245,18 @@ class GranianBackendServer(CustomBackendServer): command = ["granian"] for key, field in self.get_fields().items(): - if key != "target": - value = getattr(self, key) - if _mapping_attr_to_cli.get(key) and value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): - command.append( - f"--{'no-' if value is False else ''}{_mapping_attr_to_cli[key][2:]}" - ) - else: - command += [_mapping_attr_to_cli[key], str(value)] + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") - return command + [ - self.get_app_module(for_granian_target=True, add_extra_api=True) - ] + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" - self.check_import(extra=self.reload) + self.check_import() from granian import Granian exclude_keys = ( @@ -244,14 +273,17 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - model = self.get_fields() Granian( **{ **{ key: value - for key, value in self.dict().items() - if key not in exclude_keys and value != model[key].default + for key, value in self.get_values().items() + if ( + key not in exclude_keys + and not self.is_default_value(key, value) + ) }, + "target": self._app_uri, "http1_settings": HTTP1Settings( self.http1_keep_alive, self.http1_max_buffer_size, diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index c0bfb3b50..53544da7b 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -2,13 +2,12 @@ from __future__ import annotations -import os -import ssl import sys +from dataclasses import dataclass from typing import Any, Callable, Literal -from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.constants.base import IS_WINDOWS, Env, LogLevel +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console _mapping_attr_to_cli: dict[str, str] = { @@ -87,78 +86,25 @@ _mapping_attr_to_cli: dict[str, str] = { } +@dataclass class GunicornBackendServer(CustomBackendServer): - """Gunicorn backendServer.""" + """Gunicorn backendServer. - # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - - app_uri: str | None = None - - config: str = "./gunicorn.conf.py" - """\ - :ref:`The Gunicorn config file`. - - A string of the form ``PATH``, ``file:PATH``, or ``python:MODULE_NAME``. - - Only has an effect when specified on the command line or as part of an - application specific configuration. - - By default, a file named ``gunicorn.conf.py`` will be read from the same - directory where gunicorn is being run. - """ - - wsgi_app: str | None = None - """\ - A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. - """ - - bind: list[str] = ["127.0.0.1:8000"] - """\ - The socket to bind. - - A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, - ``fd://FD``. An IP is a valid ``HOST``. - - .. versionchanged:: 20.0 - Support for ``fd://FD`` got added. - - Multiple addresses can be bound. ex.:: - - $ gunicorn -b 127.0.0.1:8000 -b [::1]:8000 test:app - - will bind the `test:app` application on localhost both on ipv6 - and ipv4 interfaces. - - If the ``PORT`` environment variable is defined, the default - is ``['0.0.0.0:$PORT']``. If it is not defined, the default - is ``['127.0.0.1:8000']``. - """ - - backlog: int = 2048 - """\ - The maximum number of pending connections. - - This refers to the number of clients that can be waiting to be served. - Exceeding this number results in the client getting an error when - attempting to connect. It should only affect servers under significant - load. - - Must be a positive integer. Generally set in the 64-2048 range. - """ - - workers: int = 0 - """\ - The number of worker processes for handling requests. - - A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. - You'll want to vary this a bit to find the best for your particular - application's work load. - - By default, the value of the ``WEB_CONCURRENCY`` environment variable, - which is set by some Platform-as-a-Service providers such as Heroku. If - it is not defined, the default is ``1``. + https://docs.gunicorn.org/en/latest/settings.html """ + config: str = field_( + default="./gunicorn.conf.py", metadata_cli=CliType.default("--config {value}") + ) + bind: list[str] = field_( + default=None, + default_factory=lambda: ["127.0.0.1:8000"], + metadata_cli=CliType.multiple("--bind {value}"), + ) + backlog: int = field_( + default=2048, metadata_cli=CliType.default("--backlog {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) worker_class: Literal[ "sync", "eventlet", @@ -166,1016 +112,258 @@ class GunicornBackendServer(CustomBackendServer): "tornado", "gthread", "uvicorn.workers.UvicornH11Worker", - ] = "sync" - """\ - The type of workers to use. - - The default class (``sync``) should handle most "normal" types of - workloads. You'll want to read :doc:`design` for information on when - you might want to choose one of the other worker classes. Required - libraries may be installed using setuptools' ``extras_require`` feature. - - A string referring to one of the following bundled classes: - - * ``sync`` - * ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via - ``pip install gunicorn[eventlet]``) - * ``gevent`` - Requires gevent >= 1.4 (or install it via - ``pip install gunicorn[gevent]``) - * ``tornado`` - Requires tornado >= 0.2 (or install it via - ``pip install gunicorn[tornado]``) - * ``gthread`` - Python 2 requires the futures package to be installed - (or install it via ``pip install gunicorn[gthread]``) - - Optionally, you can provide your own worker by giving Gunicorn a - Python path to a subclass of ``gunicorn.workers.base.Worker``. - This alternative syntax will load the gevent class: - ``gunicorn.workers.ggevent.GeventWorker``. - """ - - threads: int = 0 - """\ - The number of worker threads for handling requests. - - Run each worker with the specified number of threads. - - A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. - You'll want to vary this a bit to find the best for your particular - application's work load. - - If it is not defined, the default is ``1``. - - This setting only affects the Gthread worker type. - - .. note:: - If you try to use the ``sync`` worker type and set the ``threads`` - setting to more than 1, the ``gthread`` worker type will be used - instead. - """ - - worker_connections: int = 1000 - """\ - The maximum number of simultaneous clients. - - This setting only affects the ``gthread``, ``eventlet`` and ``gevent`` worker types. - """ - - max_requests: int = 0 - """\ - The maximum number of requests a worker will process before restarting. - - Any value greater than zero will limit the number of requests a worker - will process before automatically restarting. This is a simple method - to help limit the damage of memory leaks. - - If this is set to zero (the default) then the automatic worker - restarts are disabled. - """ - - max_requests_jitter: int = 0 - """\ - The maximum jitter to add to the *max_requests* setting. - - The jitter causes the restart per worker to be randomized by - ``randint(0, max_requests_jitter)``. This is intended to stagger worker - restarts to avoid all workers restarting at the same time. - """ - - timeout: int = 30 - """\ - Workers silent for more than this many seconds are killed and restarted. - - Value is a positive number or 0. Setting it to 0 has the effect of - infinite timeouts by disabling timeouts for all workers entirely. - - Generally, the default of thirty seconds should suffice. Only set this - noticeably higher if you're sure of the repercussions for sync workers. - For the non sync workers it just means that the worker process is still - communicating and is not tied to the length of time required to handle a - single request. - """ - - graceful_timeout: int = 30 - """\ - Timeout for graceful workers restart. - - After receiving a restart signal, workers have this much time to finish - serving requests. Workers still alive after the timeout (starting from - the receipt of the restart signal) are force killed. - """ - - keepalive: int = 2 - """\ - The number of seconds to wait for requests on a Keep-Alive connection. - - Generally set in the 1-5 seconds range for servers with direct connection - to the client (e.g. when you don't have separate load balancer). When - Gunicorn is deployed behind a load balancer, it often makes sense to - set this to a higher value. - - .. note:: - ``sync`` worker does not support persistent connections and will - ignore this option. - """ - - limit_request_line: int = 4094 - """\ - The maximum size of HTTP request line in bytes. - - This parameter is used to limit the allowed size of a client's - HTTP request-line. Since the request-line consists of the HTTP - method, URI, and protocol version, this directive places a - restriction on the length of a request-URI allowed for a request - on the server. A server needs this value to be large enough to - hold any of its resource names, including any information that - might be passed in the query part of a GET request. Value is a number - from 0 (unlimited) to 8190. - - This parameter can be used to prevent any DDOS attack. - """ - - limit_request_fields: int = 100 - """\ - Limit the number of HTTP headers fields in a request. - - This parameter is used to limit the number of headers in a request to - prevent DDOS attack. Used with the *limit_request_field_size* it allows - more safety. By default this value is 100 and can't be larger than - 32768. - """ - - limit_request_field_size: int = 8190 - """\ - Limit the allowed size of an HTTP request header field. - - Value is a positive number or 0. Setting it to 0 will allow unlimited - header field sizes. - - .. warning:: - Setting this parameter to a very high or unlimited value can open - up for DDOS attacks. - """ - - reload: bool = False - """\ - Restart workers when code changes. - - This setting is intended for development. It will cause workers to be - restarted whenever application code changes. - - The reloader is incompatible with application preloading. When using a - paste configuration be sure that the server block does not import any - application code or the reload will not work as designed. - - The default behavior is to attempt inotify with a fallback to file - system polling. Generally, inotify should be preferred if available - because it consumes less system resources. - - .. note:: - In order to use the inotify reloader, you must have the ``inotify`` - package installed. - """ - - reload_engine: Literal["auto", "poll", "inotify"] = "auto" - """\ - The implementation that should be used to power :ref:`reload`. - - Valid engines are: - - * ``'auto'`` - * ``'poll'`` - * ``'inotify'`` (requires inotify) - """ - - reload_extra_files: list[str] = [] - """\ - Extends :ref:`reload` option to also watch and reload on additional files - (e.g., templates, configurations, specifications, etc.). - """ - - spew: bool = False - """\ - Install a trace function that spews every line executed by the server. - - This is the nuclear option. - """ - - check_config: bool = False - """\ - Check the configuration and exit. The exit status is 0 if the - configuration is correct, and 1 if the configuration is incorrect. - """ - - print_config: bool = False - """\ - Print the configuration settings as fully resolved. Implies :ref:`check-config`. - """ - - preload_app: bool = False - """\ - Load application code before the worker processes are forked. - - By preloading an application you can save some RAM resources as well as - speed up server boot times. Although, if you defer application loading - to each worker process, you can reload your application code easily by - restarting workers. - """ - - sendfile: bool | None = None - """\ - Disables the use of ``sendfile()``. - - If not set, the value of the ``SENDFILE`` environment variable is used - to enable or disable its usage. - """ - - reuse_port: bool = False - """\ - Set the ``SO_REUSEPORT`` flag on the listening socket. - """ - - chdir: str = "." - """\ - Change directory to specified directory before loading apps. - """ - - daemon: bool = False - """\ - Daemonize the Gunicorn process. - - Detaches the server from the controlling terminal and enters the - background. - """ - - raw_env: list[str] = [] - """\ - Set environment variables in the execution environment. - - Should be a list of strings in the ``key=value`` format. - """ - - pidfile: str | None = None - """\ - A filename to use for the PID file. - - If not set, no PID file will be written. - """ - - worker_tmp_dir: str | None = None - """\ - A directory to use for the worker heartbeat temporary file. - - If not set, the default temporary directory will be used. - - .. note:: - The current heartbeat system involves calling ``os.fchmod`` on - temporary file handlers and may block a worker for arbitrary time - if the directory is on a disk-backed filesystem. - - See :ref:`blocking-os-fchmod` for more detailed information - and a solution for avoiding this problem. - """ - - user: int = os.geteuid() - """\ - Switch worker processes to run as this user. - - A valid user id (as an integer) or the name of a user that can be - retrieved with a call to ``pwd.getpwnam(value)`` or ``None`` to not - change the worker process user. - """ - - group: int = os.getegid() - """\ - Switch worker process to run as this group. - - A valid group id (as an integer) or the name of a user that can be - retrieved with a call to ``pwd.getgrnam(value)`` or ``None`` to not - change the worker processes group. - """ - - umask: int = 0 - """\ - A bit mask for the file mode on files written by Gunicorn. - - Note that this affects unix socket permissions. - - A valid value for the ``os.umask(mode)`` call or a string compatible - with ``int(value, 0)`` (``0`` means Python guesses the base, so values - like ``0``, ``0xFF``, ``0022`` are valid for decimal, hex, and octal - representations) - """ - - initgroups: bool = False - """\ - If true, set the worker process's group access list with all of the - groups of which the specified username is a member, plus the specified - group id. - """ - - tmp_upload_dir: str | None = None - """\ - Directory to store temporary request data as they are read. - - This may disappear in the near future. - - This path should be writable by the process permissions set for Gunicorn - workers. If not specified, Gunicorn will choose a system generated - temporary directory. - """ - - secure_scheme_headers: dict[str, Any] = { - "X-FORWARDED-PROTOCOL": "ssl", - "X-FORWARDED-PROTO": "https", - "X-FORWARDED-SSL": "on", - } - """\ - A dictionary containing headers and values that the front-end proxy - uses to indicate HTTPS requests. If the source IP is permitted by - :ref:`forwarded-allow-ips` (below), *and* at least one request header matches - a key-value pair listed in this dictionary, then Gunicorn will set - ``wsgi.url_scheme`` to ``https``, so your application can tell that the - request is secure. - - If the other headers listed in this dictionary are not present in the request, they will be ignored, - but if the other headers are present and do not match the provided values, then - the request will fail to parse. See the note below for more detailed examples of this behaviour. - - The dictionary should map upper-case header names to exact string - values. The value comparisons are case-sensitive, unlike the header - names, so make sure they're exactly what your front-end proxy sends - when handling HTTPS requests. - - It is important that your front-end proxy configuration ensures that - the headers defined here can not be passed directly from the client. - """ - - forwarded_allow_ips: str = "127.0.0.1,::1" - """\ - Front-end's IPs from which allowed to handle set secure headers. - (comma separated). - - Set to ``*`` to disable checking of front-end IPs. This is useful for setups - where you don't know in advance the IP address of front-end, but - instead have ensured via other means that only your - authorized front-ends can access Gunicorn. - - By default, the value of the ``FORWARDED_ALLOW_IPS`` environment - variable. If it is not defined, the default is ``"127.0.0.1,::1"``. - - .. note:: - - This option does not affect UNIX socket connections. Connections not associated with - an IP address are treated as allowed, unconditionally. - - .. note:: - - The interplay between the request headers, the value of ``forwarded_allow_ips``, and the value of - ``secure_scheme_headers`` is complex. Various scenarios are documented below to further elaborate. - In each case, we have a request from the remote address 134.213.44.18, and the default value of - ``secure_scheme_headers``: - - .. code:: - - secure_scheme_headers = { - 'X-FORWARDED-PROTOCOL': 'ssl', - 'X-FORWARDED-PROTO': 'https', - 'X-FORWARDED-SSL': 'on' - } - - - .. list-table:: - :header-rows: 1 - :align: center - :widths: auto - - * - ``forwarded-allow-ips`` - - Secure Request Headers - - Result - - Explanation - * - ``["127.0.0.1"]`` - - ``X-Forwarded-Proto: https`` - - ``wsgi.url_scheme = "http"`` - - IP address was not allowed - * - ``"*"`` - - - - ``wsgi.url_scheme = "http"`` - - IP address allowed, but no secure headers provided - * - ``"*"`` - - ``X-Forwarded-Proto: https`` - - ``wsgi.url_scheme = "https"`` - - IP address allowed, one request header matched - * - ``["134.213.44.18"]`` - - ``X-Forwarded-Ssl: on`` ``X-Forwarded-Proto: http`` - - ``InvalidSchemeHeaders()`` raised - - IP address allowed, but the two secure headers disagreed on if HTTPS was used - """ - - accesslog: str | None = None - """\ - The Access log file to write to. - - ``'-'`` means log to stdout. - """ - - disable_redirect_access_to_syslog: bool = False - """\ - Disable redirect access logs to syslog. - """ - - access_log_format: str = ( - '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + ] = field_(default="sync", metadata_cli=CliType.default("--worker-class {value}")) + threads: int = field_(default=0, metadata_cli=CliType.default("--threads {value}")) + worker_connections: int = field_( + default=1000, metadata_cli=CliType.default("--worker-connections {value}") ) - """\ - The access log format. - - =========== =========== - Identifier Description - =========== =========== - h remote address - l ``'-'`` - u user name (if HTTP Basic auth used) - t date of the request - r status line (e.g. ``GET / HTTP/1.1``) - m request method - U URL path without query string - q query string - H protocol - s status - B response length - b response length or ``'-'`` (CLF format) - f referrer (note: header is ``referer``) - a user agent - T request time in seconds - M request time in milliseconds - D request time in microseconds - L request time in decimal seconds - p process ID - {header}i request header - {header}o response header - {variable}e environment variable - =========== =========== - - Use lowercase for header and environment variable names, and put - ``{...}x`` names inside ``%(...)s``. For example:: - - %({x-forwarded-for}i)s - """ - - errorlog: str = "-" - """\ - The Error log file to write to. - - Using ``'-'`` for FILE makes gunicorn log to stderr. - """ - - loglevel: Literal["debug", "info", "warning", "error", "critical"] = "info" - """\ - The granularity of Error log outputs. - """ - - capture_output: bool = False - """\ - Redirect stdout/stderr to specified file in :ref:`errorlog`. - """ - - logger_class: str = "gunicorn.glogging.Logger" - """\ - The logger you want to use to log events in Gunicorn. - - The default class (``gunicorn.glogging.Logger``) handles most - normal usages in logging. It provides error and access logging. - - You can provide your own logger by giving Gunicorn a Python path to a - class that quacks like ``gunicorn.glogging.Logger``. - """ - - logconfig: str | None = None - """\ - The log config file to use. - Gunicorn uses the standard Python logging module's Configuration - file format. - """ - - logconfig_dict: dict = {} - """\ - The log config dictionary to use, using the standard Python - logging module's dictionary configuration format. This option - takes precedence over the :ref:`logconfig` and :ref:`logconfig-json` options, - which uses the older file configuration format and JSON - respectively. - - Format: https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig - - For more context you can look at the default configuration dictionary for logging, - which can be found at ``gunicorn.glogging.CONFIG_DEFAULTS``. - """ - - logconfig_json: str | None = None - """\ - The log config to read config from a JSON file - - Format: https://docs.python.org/3/library/logging.config.html#logging.config.jsonConfig - """ - - syslog_addr: str = ( - "unix:///var/run/syslog" - if sys.platform == "darwin" - else ( - "unix:///var/run/log" - if sys.platform - in ( - "freebsd", - "dragonfly", - ) - else ( - "unix:///dev/log" - if sys.platform == "openbsd" - else "udp://localhost:514" - ) - ) + max_requests: int = field_( + default=0, metadata_cli=CliType.default("--max-requests {value}") ) - """\ - Address to send syslog messages. - - Address is a string of the form: - * ``unix://PATH#TYPE`` : for unix domain socket, ``TYPE`` can be ``stream`` for the stream driver or ``dgram`` for the dgram driver, ``stream`` is the default - * ``udp://HOST:PORT`` : for UDP sockets - * ``tcp://HOST:PORT`` : for TCP sockets - """ - - syslog: bool = False - """\ - Send *Gunicorn* logs to syslog. - """ - - syslog_prefix: str | None = None - """\ - Makes Gunicorn use the parameter as program-name in the syslog entries. - - All entries will be prefixed by ``gunicorn.``. By default the - program name is the name of the process. - """ - - syslog_facility: str = "user" - """\ - Syslog facility name - """ - - enable_stdio_inheritance: bool = False - """\ - Enable stdio inheritance. - - Enable inheritance for stdio file descriptors in daemon mode. - - Note: To disable the Python stdout buffering, you can to set the user - environment variable ``PYTHONUNBUFFERED`` . - """ - - statsd_host: str | None = None - """\ - The address of the StatsD server to log to. - - Address is a string of the form: - - * ``unix://PATH`` : for a unix domain socket. - * ``HOST:PORT`` : for a network address - """ - - dogstatsd_tags: str = "" - """\ - A comma-delimited list of datadog statsd (dogstatsd) tags to append to - statsd metrics. - """ - - statsd_prefix: str = "" - """\ - Prefix to use when emitting statsd metrics (a trailing ``.`` is added, - if not provided). - """ - - proc_name: str | None = None - """\ - A base to use with setproctitle for process naming. - - This affects things like ``ps`` and ``top``. If you're going to be - running more than one instance of Gunicorn you'll probably want to set a - name to tell them apart. This requires that you install the setproctitle - module. - - If not set, the *default_proc_name* setting will be used. - """ - - default_proc_name: str = "gunicorn" - """\ - Internal setting that is adjusted for each type of application. - """ - - pythonpath: str | None = None - """\ - A comma-separated list of directories to add to the Python path. - - e.g. - ``'/home/djangoprojects/myproject,/home/python/mylibrary'``. - """ - - paste: str | None = None - """\ - Load a PasteDeploy config file. The argument may contain a ``#`` - symbol followed by the name of an app section from the config file, - e.g. ``production.ini#admin``. - - At this time, using alternate server blocks is not supported. Use the - command line arguments to control server configuration instead. - """ - - on_starting: Callable = lambda server: None - """\ - Called just before the master process is initialized. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - on_reload: Callable = lambda server: None - """\ - Called to recycle workers during a reload via SIGHUP. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - when_ready: Callable = lambda server: None - """\ - Called just after the server is started. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - pre_fork: Callable = lambda server, worker: None - """\ - Called just before a worker is forked. - - The callable needs to accept two instance variables for the Arbiter and - new Worker. - """ - - post_fork: Callable = lambda server, worker: None - """\ - Called just after a worker has been forked. - - The callable needs to accept two instance variables for the Arbiter and - new Worker. - """ - - post_worker_init: Callable = lambda worker: None - """\ - Called just after a worker has initialized the application. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - worker_int: Callable = lambda worker: None - """\ - Called just after a worker exited on SIGINT or SIGQUIT. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - worker_abort: Callable = lambda worker: None - """\ - Called when a worker received the SIGABRT signal. - - This call generally happens on timeout. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - pre_exec: Callable = lambda server: None - """\ - Called just before a new master process is forked. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - pre_request: Callable = lambda worker, req: worker.log.debug( - "%s %s", req.method, req.path + max_requests_jitter: int = field_( + default=0, metadata_cli=CliType.default("--max-requests-jitter {value}") ) - """\ - Called just before a worker processes the request. - - The callable needs to accept two instance variables for the Worker and - the Request. - """ - - post_request: Callable = lambda worker, req, environ, resp: None - """\ - Called after a worker processes the request. - - The callable needs to accept two instance variables for the Worker and - the Request. - """ - - child_exit: Callable = lambda server, worker: None - """\ - Called just after a worker has been exited, in the master process. - - The callable needs to accept two instance variables for the Arbiter and - the just-exited Worker. - """ - - worker_exit: Callable = lambda server, worker: None - """\ - Called just after a worker has been exited, in the worker process. - - The callable needs to accept two instance variables for the Arbiter and - the just-exited Worker. - """ - - nworkers_changed: Callable = lambda server, new_value, old_value: None - """\ - Called just after *num_workers* has been changed. - - The callable needs to accept an instance variable of the Arbiter and - two integers of number of workers after and before change. - - If the number of workers is set for the first time, *old_value* would - be ``None``. - """ - - on_exit: Callable = lambda server: None - """\ - Called just before exiting Gunicorn. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - ssl_context: Callable = ( - lambda config, default_ssl_context_factory: default_ssl_context_factory() + timeout: int = field_(default=30, metadata_cli=CliType.default("--timeout {value}")) + graceful_timeout: int = field_( + default=30, metadata_cli=CliType.default("--graceful-timeout {value}") ) - """\ - Called when SSLContext is needed. - - Allows customizing SSL context. - - The callable needs to accept an instance variable for the Config and - a factory function that returns default SSLContext which is initialized - with certificates, private key, cert_reqs, and ciphers according to - config and can be further customized by the callable. - The callable needs to return SSLContext object. - - Following example shows a configuration file that sets the minimum TLS version to 1.3: - - .. code-block:: python - - def ssl_context(conf, default_ssl_context_factory): - import ssl - context = default_ssl_context_factory() - context.minimum_version = ssl.TLSVersion.TLSv1_3 - return context - """ - - proxy_protocol: bool = False - """\ - Enable detect PROXY protocol (PROXY mode). - - Allow using HTTP and Proxy together. It may be useful for work with - stunnel as HTTPS frontend and Gunicorn as HTTP server. - - PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt - - Example for stunnel config:: - - [https] - protocol = proxy - accept = 443 - connect = 80 - cert = /etc/ssl/certs/stunnel.pem - key = /etc/ssl/certs/stunnel.key - """ - - proxy_allow_ips: str = "127.0.0.1,::1" - """\ - Front-end's IPs from which allowed accept proxy requests (comma separated). - - Set to ``*`` to disable checking of front-end IPs. This is useful for setups - where you don't know in advance the IP address of front-end, but - instead have ensured via other means that only your - authorized front-ends can access Gunicorn. - - .. note:: - - This option does not affect UNIX socket connections. Connections not associated with - an IP address are treated as allowed, unconditionally. - """ - - keyfile: str | None = None - """\ - SSL key file - """ - - certfile: str | None = None - """\ - SSL certificate file - """ - - ssl_version: int = ( - ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + keepalive: int = field_( + default=2, metadata_cli=CliType.default("--keep-alive {value}") + ) + limit_request_line: int = field_( + default=4094, metadata_cli=CliType.default("--limit-request-line {value}") + ) + limit_request_fields: int = field_( + default=100, metadata_cli=CliType.default("--limit-request-fields {value}") + ) + limit_request_field_size: int = field_( + default=8190, metadata_cli=CliType.default("--limit-request-field_size {value}") + ) + reload: bool = field_(default=False, metadata_cli=CliType.boolean("--reload")) + reload_engine: Literal["auto", "poll", "inotify"] = field_( + default="auto", metadata_cli=CliType.default("--reload-engine {value}") + ) + reload_extra_files: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.default("--reload-extra-file {value}"), + ) + spew: bool = field_(default=False, metadata_cli=CliType.boolean("--spew")) + check_config: bool = field_( + default=False, metadata_cli=CliType.boolean("--check-config") + ) + print_config: bool = field_( + default=False, metadata_cli=CliType.boolean("--print-config") + ) + preload_app: bool = field_(default=False, metadata_cli=CliType.boolean("--preload")) + sendfile: bool | None = field_( + default=None, metadata_cli=CliType.boolean("--no-sendfile", bool_value=False) + ) + reuse_port: bool = field_( + default=False, metadata_cli=CliType.boolean("--reuse-port") + ) + chdir: str = field_(default=".", metadata_cli=CliType.default("--chdir {value}")) + daemon: bool = field_(default=False, metadata_cli=CliType.boolean("--daemon")) + raw_env: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.multiple("--env {value}"), + ) + pidfile: str | None = field_( + default=None, metadata_cli=CliType.default("--pid {value}") + ) + worker_tmp_dir: str | None = field_( + default=None, metadata_cli=CliType.default("--worker-tmp-dir {value}") + ) + user: int = field_(default=1000, metadata_cli=CliType.default("--user {value}")) + group: int = field_(default=1000, metadata_cli=CliType.default("--group {value}")) + umask: int = field_(default=0, metadata_cli=CliType.default("--umask {value}")) + initgroups: bool = field_( + default=False, metadata_cli=CliType.boolean("--initgroups") + ) + tmp_upload_dir: str | None = field_(default=None, metadata_cli=None) + secure_scheme_headers: dict[str, Any] = field_( + default=None, + default_factory=lambda: { + "X-FORWARDED-PROTOCOL": "ssl", + "X-FORWARDED-PROTO": "https", + "X-FORWARDED-SSL": "on", + }, + metadata_cli=None, + ) + forwarded_allow_ips: str = field_( + default="127.0.0.1,::1", + metadata_cli=CliType.default("--forwarded-allow-ips {value}"), + ) + accesslog: str | None = field_( + default=None, metadata_cli=CliType.default("--access-logfile {value}") + ) + disable_redirect_access_to_syslog: bool = field_( + default=False, + metadata_cli=CliType.boolean("--disable-redirect-access-to-syslog"), + ) + access_log_format: str = field_( + default='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"', + metadata_cli=CliType.default("--access-logformat {value}"), + ) + errorlog: str = field_( + default="-", metadata_cli=CliType.default("--error-logfile {value}") + ) + loglevel: Literal["debug", "info", "warning", "error", "critical"] = field_( + default="info", metadata_cli=CliType.default("--log-level {value}") + ) + capture_output: bool = field_( + default=False, metadata_cli=CliType.boolean("--capture-output") + ) + logger_class: str = field_( + default="gunicorn.glogging.Logger", + metadata_cli=CliType.default("--logger-class {value}"), + ) + logconfig: str | None = field_( + default=None, metadata_cli=CliType.default("--log-config {value}") + ) + logconfig_dict: dict = field_( + default=None, default_factory=lambda: {}, metadata_cli=None + ) + logconfig_json: str | None = field_( + default=None, metadata_cli=CliType.default("--log-config-json {value}") + ) + syslog_addr: str = field_( + default="udp://localhost:514", + metadata_cli=CliType.default("--log-syslog-to {value}"), + ) + syslog: bool = field_(default=False, metadata_cli=CliType.boolean("--log-syslog")) + syslog_prefix: str | None = field_( + default=None, metadata_cli=CliType.default("--log-syslog-prefix {value}") + ) + syslog_facility: str = field_( + default="user", metadata_cli=CliType.default("--log-syslog-facility {value}") + ) + enable_stdio_inheritance: bool = field_( + default=False, metadata_cli=CliType.boolean("--enable-stdio-inheritance") + ) + statsd_host: str | None = field_( + default=None, metadata_cli=CliType.default("--statsd-host {value}") + ) + dogstatsd_tags: str = field_( + default="", metadata_cli=CliType.default("--dogstatsd-tags {value}") + ) + statsd_prefix: str = field_( + default="", metadata_cli=CliType.default("--statsd-prefix {value}") + ) + proc_name: str | None = field_( + default=None, metadata_cli=CliType.default("--name {value}") + ) + default_proc_name: str = field_(default="gunicorn", metadata_cli=None) + pythonpath: str | None = field_( + default=None, metadata_cli=CliType.default("--pythonpath {value}") + ) + paste: str | None = field_( + default=None, metadata_cli=CliType.default("--paster {value}") + ) + on_starting: Callable = field_(default=lambda server: None, metadata_cli=None) + on_reload: Callable = field_(default=lambda server: None, metadata_cli=None) + when_ready: Callable = field_(default=lambda server: None, metadata_cli=None) + pre_fork: Callable = field_(default=lambda server, worker: None, metadata_cli=None) + post_fork: Callable = field_(default=lambda server, worker: None, metadata_cli=None) + post_worker_init: Callable = field_(default=lambda worker: None, metadata_cli=None) + worker_int: Callable = field_(default=lambda worker: None, metadata_cli=None) + worker_abort: Callable = field_(default=lambda worker: None, metadata_cli=None) + pre_exec: Callable = field_(default=lambda server: None, metadata_cli=None) + pre_request: Callable = field_( + default=lambda worker, req: worker.log.debug("%s %s", req.method, req.path), + metadata_cli=None, + ) + post_request: Callable = field_( + default=lambda worker, req, environ, resp: None, metadata_cli=None + ) + child_exit: Callable = field_( + default=lambda server, worker: None, metadata_cli=None + ) + worker_exit: Callable = field_( + default=lambda server, worker: None, metadata_cli=None + ) + nworkers_changed: Callable = field_( + default=lambda server, new_value, old_value: None, metadata_cli=None + ) + on_exit: Callable = field_(default=lambda server: None, metadata_cli=None) + ssl_context: Callable[[Any, Any], Any] = field_( + default=lambda config, + default_ssl_context_factory: default_ssl_context_factory(), + metadata_cli=None, + ) + proxy_protocol: bool = field_( + default=False, metadata_cli=CliType.boolean("--proxy-protocol") + ) + proxy_allow_ips: str = field_( + default="127.0.0.1,::1", + metadata_cli=CliType.default("--proxy-allow-from {value}"), + ) + keyfile: str | None = field_( + default=None, metadata_cli=CliType.default("--keyfile {value}") + ) + certfile: str | None = field_( + default=None, metadata_cli=CliType.default("--certfile {value}") + ) + ssl_version: int = field_( + default=2, metadata_cli=CliType.default("--ssl-version {value}") + ) + cert_reqs: int = field_( + default=0, metadata_cli=CliType.default("--cert-reqs {value}") + ) + ca_certs: str | None = field_( + default=None, metadata_cli=CliType.default("--ca-certs {value}") + ) + suppress_ragged_eofs: bool = field_( + default=True, metadata_cli=CliType.boolean("--suppress-ragged-eofs") + ) + do_handshake_on_connect: bool = field_( + default=False, metadata_cli=CliType.boolean("--do-handshake-on-connect") + ) + ciphers: str | None = field_( + default=None, metadata_cli=CliType.default("--ciphers {value}") + ) + raw_paste_global_conf: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.multiple("--paste-global {value}"), + ) + permit_obsolete_folding: bool = field_( + default=False, metadata_cli=CliType.boolean("--permit-obsolete-folding") + ) + strip_header_spaces: bool = field_( + default=False, metadata_cli=CliType.boolean("--strip-header-spaces") + ) + permit_unconventional_http_method: bool = field_( + default=False, + metadata_cli=CliType.boolean("--permit-unconventional-http-method"), + ) + permit_unconventional_http_version: bool = field_( + default=False, + metadata_cli=CliType.boolean("--permit-unconventional-http-version"), + ) + casefold_http_method: bool = field_( + default=False, metadata_cli=CliType.boolean("--casefold-http-method") + ) + forwarder_headers: str = field_( + default="SCRIPT_NAME,PATH_INFO", + metadata_cli=CliType.default("--forwarder-headers {value}"), + ) + header_map: Literal["drop", "refuse", "dangerous"] = field_( + default="drop", metadata_cli=CliType.default("--header-map {value}") ) - """\ - SSL version to use (see stdlib ssl module's). - .. deprecated:: 21.0 - The option is deprecated and it is currently ignored. Use :ref:`ssl-context` instead. - - ============= ============ - --ssl-version Description - ============= ============ - SSLv3 SSLv3 is not-secure and is strongly discouraged. - SSLv23 Alias for TLS. Deprecated in Python 3.6, use TLS. - TLS Negotiate highest possible version between client/server. - Can yield SSL. (Python 3.6+) - TLSv1 TLS 1.0 - TLSv1_1 TLS 1.1 (Python 3.4+) - TLSv1_2 TLS 1.2 (Python 3.4+) - TLS_SERVER Auto-negotiate the highest protocol version like TLS, - but only support server-side SSLSocket connections. - (Python 3.6+) - ============= ============ - - .. versionchanged:: 19.7 - The default value has been changed from ``ssl.PROTOCOL_TLSv1`` to - ``ssl.PROTOCOL_SSLv23``. - .. versionchanged:: 20.0 - This setting now accepts string names based on ``ssl.PROTOCOL_`` - constants. - .. versionchanged:: 20.0.1 - The default value has been changed from ``ssl.PROTOCOL_SSLv23`` to - ``ssl.PROTOCOL_TLS`` when Python >= 3.6 . - """ - - cert_reqs: int = ssl.CERT_NONE - """\ - Whether client certificate is required (see stdlib ssl module's) - - =========== =========================== - --cert-reqs Description - =========== =========================== - `0` ``ssl.CERT_NONE`` - `1` ``ssl.CERT_OPTIONAL`` - `2` ``ssl.CERT_REQUIRED`` - =========== =========================== - """ - - ca_certs: str | None = None - """\ - CA certificates file - """ - - suppress_ragged_eofs: bool = True - """\ - Suppress ragged EOFs (see stdlib ssl module's) - """ - - do_handshake_on_connect: bool = False - """\ - Whether to perform SSL handshake on socket connect (see stdlib ssl module's) - """ - - ciphers: str | None = None - """\ - SSL Cipher suite to use, in the format of an OpenSSL cipher list. - - By default we use the default cipher list from Python's ``ssl`` module, - which contains ciphers considered strong at the time of each Python - release. - - As a recommended alternative, the Open Web App Security Project (OWASP) - offers `a vetted set of strong cipher strings rated A+ to C- - `_. - OWASP provides details on user-agent compatibility at each security level. - - See the `OpenSSL Cipher List Format Documentation - `_ - for details on the format of an OpenSSL cipher list. - """ - - raw_paste_global_conf: list[str] = [] - """\ - Set a PasteDeploy global config variable in ``key=value`` form. - - The option can be specified multiple times. - - The variables are passed to the PasteDeploy entrypoint. Example:: - - $ gunicorn -b 127.0.0.1:8000 --paste development.ini --paste-global FOO=1 --paste-global BAR=2 - """ - - permit_obsolete_folding: bool = False - """\ - Permit requests employing obsolete HTTP line folding mechanism - - The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be - employed in HTTP request headers from standards-compliant HTTP clients. - - This option is provided to diagnose backwards-incompatible changes. - Use with care and only if necessary. Temporary; the precise effect of this option may - change in a future version, or it may be removed altogether. - """ - - strip_header_spaces: bool = False - """\ - Strip spaces present between the header name and the the ``:``. - - This is known to induce vulnerabilities and is not compliant with the HTTP/1.1 standard. - See https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn. - - Use with care and only if necessary. Deprecated; scheduled for removal in 25.0.0 - """ - - permit_unconventional_http_method: bool = False - """\ - Permit HTTP methods not matching conventions, such as IANA registration guidelines - - This permits request methods of length less than 3 or more than 20, - methods with lowercase characters or methods containing the # character. - HTTP methods are case sensitive by definition, and merely uppercase by convention. - - If unset, Gunicorn will apply nonstandard restrictions and cause 400 response status - in cases where otherwise 501 status is expected. While this option does modify that - behaviour, it should not be depended upon to guarantee standards-compliant behaviour. - Rather, it is provided temporarily, to assist in diagnosing backwards-incompatible - changes around the incomplete application of those restrictions. - - Use with care and only if necessary. Temporary; scheduled for removal in 24.0.0 - """ - - permit_unconventional_http_version: bool = False - """\ - Permit HTTP version not matching conventions of 2023 - - This disables the refusal of likely malformed request lines. - It is unusual to specify HTTP 1 versions other than 1.0 and 1.1. - - This option is provided to diagnose backwards-incompatible changes. - Use with care and only if necessary. Temporary; the precise effect of this option may - change in a future version, or it may be removed altogether. - """ - - casefold_http_method: bool = False - """\ - Transform received HTTP methods to uppercase - - HTTP methods are case sensitive by definition, and merely uppercase by convention. - - This option is provided because previous versions of gunicorn defaulted to this behaviour. - - Use with care and only if necessary. Deprecated; scheduled for removal in 24.0.0 - """ - - forwarder_headers: str = "SCRIPT_NAME,PATH_INFO" - """\ - A list containing upper-case header field names that the front-end proxy - (see :ref:`forwarded-allow-ips`) sets, to be used in WSGI environment. - - This option has no effect for headers not present in the request. - - This option can be used to transfer ``SCRIPT_NAME``, ``PATH_INFO`` - and ``REMOTE_USER``. - - It is important that your front-end proxy configuration ensures that - the headers defined here can not be passed directly from the client. - """ - - header_map: Literal["drop", "refuse", "dangerous"] = "drop" - """\ - Configure how header field names are mapped into environ - - Headers containing underscores are permitted by RFC9110, - but gunicorn joining headers of different names into - the same environment variable will dangerously confuse applications as to which is which. - - The safe default ``drop`` is to silently drop headers that cannot be unambiguously mapped. - The value ``refuse`` will return an error if a request contains *any* such header. - The value ``dangerous`` matches the previous, not advisable, behaviour of mapping different - header field names into the same environ name. - - If the source is permitted as explained in :ref:`forwarded-allow-ips`, *and* the header name is - present in :ref:`forwarder-headers`, the header is mapped into environment regardless of - the state of this setting. - - Use with care and only if necessary and after considering if your problem could - instead be solved by specifically renaming or rewriting only the intended headers - on a proxy in front of Gunicorn. - """ - - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" from importlib.util import find_spec errors: list[str] = [] + if IS_WINDOWS: + errors.append( + "The `GunicornBackendServer` only works on UNIX machines. We recommend using the `UvicornBackendServer` for Windows machines." + ) + if find_spec("gunicorn") is None: errors.append( 'The `gunicorn` package is required to run `GunicornBackendServer`. Run `pip install "gunicorn>=20.1.0"`.' @@ -1187,7 +375,7 @@ class GunicornBackendServer(CustomBackendServer): def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - self.app_uri = f"{self.get_app_module()}()" + self._app_uri = f"{self.get_app_module()}()" self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] @@ -1211,25 +399,17 @@ class GunicornBackendServer(CustomBackendServer): def run_prod(self) -> list[str]: """Run in production mode.""" self.check_import() - command = ["gunicorn"] for key, field in self.get_fields().items(): - if key != "app": - value = getattr(self, key) - if _mapping_attr_to_cli.get(key) and value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): - if (key == "sendfile" and value is False) or ( - key != "sendfile" and value - ): - command.append(_mapping_attr_to_cli[key]) - else: - command += [_mapping_attr_to_cli[key], str(value)] + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") - return command + [f"{self.get_app_module()}()"] + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" @@ -1241,25 +421,29 @@ class GunicornBackendServer(CustomBackendServer): from gunicorn.app.base import BaseApplication from gunicorn.util import import_app as gunicorn_import_app - options_ = self.dict() - options_.pop("app", None) + model = self.get_fields() + options_ = { + key: value + for key, value in self.get_values().items() + if value != model[key].default + } class StandaloneApplication(BaseApplication): def __init__(self, app_uri, options=None): self.options = options or {} - self.app_uri = app_uri + self._app_uri = app_uri super().__init__() def load_config(self): config = { key: value for key, value in self.options.items() - if key in self.cfg.settings and value is not None - } # type: ignore + if key in self.cfg.settings and value is not None # type: ignore + } for key, value in config.items(): self.cfg.set(key.lower(), value) # type: ignore def load(self): - return gunicorn_import_app(self.app_uri) + return gunicorn_import_app(self._app_uri) - StandaloneApplication(app_uri=self.app_uri, options=options_).run() + StandaloneApplication(app_uri=self._app_uri, options=options_).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index cb5131c4a..8d167eacb 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -2,18 +2,188 @@ from __future__ import annotations +# `UvicornBackendServer` defer from other `*BackendServer`, because `uvicorn` he is natively integrated inside the reflex project via Fastapi (same for asyncio) +import asyncio +import os +import ssl import sys +from configparser import RawConfigParser +from dataclasses import dataclass +from pathlib import Path +from typing import IO, Any, Awaitable, Callable + +from uvicorn import Config, Server +from uvicorn.config import ( + LOGGING_CONFIG, + SSL_PROTOCOL_VERSION, + HTTPProtocolType, + InterfaceType, + LifespanType, + LoopSetupType, + WSProtocolType, +) from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console -# TODO +@dataclass class UvicornBackendServer(CustomBackendServer): - """Uvicorn backendServer.""" + """Uvicorn backendServer. - def check_import(self, extra: bool = False): + https://www.uvicorn.org/settings/ + """ + + host: str = field_( + default="127.0.0.1", metadata_cli=CliType.default("--host {value}") + ) + port: int = field_(default=8000, metadata_cli=CliType.default("--port {value}")) + uds: str | None = field_( + default=None, metadata_cli=CliType.default("--uds {value}") + ) + fd: int | None = field_(default=None, metadata_cli=CliType.default("--fd {value}")) + loop: LoopSetupType = field_( + default="auto", metadata_cli=CliType.default("--loop {value}") + ) + http: type[asyncio.Protocol] | HTTPProtocolType = field_( + default="auto", metadata_cli=CliType.default("--http {value}") + ) + ws: type[asyncio.Protocol] | WSProtocolType = field_( + default="auto", metadata_cli=CliType.default("--ws {value}") + ) + ws_max_size: int = field_( + default=16777216, metadata_cli=CliType.default("--ws-max-size {value}") + ) + ws_max_queue: int = field_( + default=32, metadata_cli=CliType.default("--ws-max-queue {value}") + ) + ws_ping_interval: float | None = field_( + default=20.0, metadata_cli=CliType.default("--ws-ping-interval {value}") + ) + ws_ping_timeout: float | None = field_( + default=20.0, metadata_cli=CliType.default("--ws-ping-timeout {value}") + ) + ws_per_message_deflate: bool = field_( + default=True, metadata_cli=CliType.default("--ws-per-message-deflate {value}") + ) + lifespan: LifespanType = field_( + default="auto", metadata_cli=CliType.default("--lifespan {value}") + ) + env_file: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--env-file {value}") + ) + log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = field_( + default=None, + default_factory=lambda: LOGGING_CONFIG, + metadata_cli=CliType.default("--log-config {value}"), + ) + log_level: str | int | None = field_( + default=None, metadata_cli=CliType.default("--log-level {value}") + ) + access_log: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}access-log"), + ) + use_colors: bool | None = field_( + default=None, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}use-colors"), + ) + interface: InterfaceType = field_( + default="auto", metadata_cli=CliType.default("--interface {value}") + ) + reload: bool = field_( + default=False, metadata_cli=CliType.default("--reload {value}") + ) + reload_dirs: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("--reload_dir {value}") + ) + reload_delay: float = field_( + default=0.25, metadata_cli=CliType.default("--reload-delay {value}") + ) + reload_includes: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("----reload-include {value}") + ) + reload_excludes: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-exclude {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) + proxy_headers: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}proxy-headers"), + ) + server_header: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}server-header"), + ) + date_header: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}date-header"), + ) + forwarded_allow_ips: list[str] | str | None = field_( + default=None, + metadata_cli=CliType.multiple("--forwarded-allow-ips {value}", join_sep=","), + ) + root_path: str = field_( + default="", metadata_cli=CliType.default("--root-path {value}") + ) + limit_concurrency: int | None = field_( + default=None, metadata_cli=CliType.default("--limit-concurrency {value}") + ) + limit_max_requests: int | None = field_( + default=None, metadata_cli=CliType.default("--limit-max-requests {value}") + ) + backlog: int = field_( + default=2048, metadata_cli=CliType.default("--backlog {value}") + ) + timeout_keep_alive: int = field_( + default=5, metadata_cli=CliType.default("--timeout-keep-alive {value}") + ) + timeout_notify: int = field_(default=30, metadata_cli=None) + timeout_graceful_shutdown: int | None = field_( + default=None, + metadata_cli=CliType.default("--timeout-graceful-shutdown {value}"), + ) + callback_notify: Callable[..., Awaitable[None]] | None = field_( + default=None, metadata_cli=None + ) + ssl_keyfile: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile {value}") + ) + ssl_certfile: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--ssl-certfile {value}") + ) + ssl_keyfile_password: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile-password {value}") + ) + ssl_version: int = field_( + default=SSL_PROTOCOL_VERSION, + metadata_cli=CliType.default("--ssl-version {value}"), + ) + ssl_cert_reqs: int = field_( + default=ssl.CERT_NONE, metadata_cli=CliType.default("--ssl-cert-reqs {value}") + ) + ssl_ca_certs: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-ca-certs {value}") + ) + ssl_ciphers: str = field_( + default="TLSv1", metadata_cli=CliType.default("--ssl-ciphers {value}") + ) + headers: list[tuple[str, str]] | None = field_( + default=None, + metadata_cli=CliType.multiple( + "--header {value}", value_transformer=lambda value: f"{value[0]}:{value[1]}" + ), + ) + factory: bool = field_( + default=False, metadata_cli=CliType.default("--factory {value}") + ) + h11_max_incomplete_event_size: int | None = field_( + default=None, + metadata_cli=CliType.default("--h11-max-incomplete-event-size {value}"), + ) + + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -24,18 +194,62 @@ class UvicornBackendServer(CustomBackendServer): 'The `uvicorn` package is required to run `UvicornBackendServer`. Run `pip install "uvicorn>=0.20.0"`.' ) + if find_spec("watchfiles") is None and ( + self.reload_includes and self.reload_excludes + ): + errors.append( + 'Using `--reload-include` and `--reload-exclude` in `UvicornBackendServer` requires the `watchfiles` extra. Run `pip install "watchfiles>=0.13"`.' + ) + if errors: console.error("\n".join(errors)) sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - pass + self._app_uri = self.get_app_module(add_extra_api=True) + self.log_level = loglevel.value + self.host = host + self.port = port + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers + + if env == Env.DEV: + from reflex.config import get_config # prevent circular import + + self.reload = True + self.reload_dirs = [str(Path(get_config().app_name))] def run_prod(self): """Run in production mode.""" - pass + self.check_import() + command = ["uvicorn"] + + for key, field in self.get_fields().items(): + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") + + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" - pass + self.check_import() + + options_ = { + key: value + for key, value in self.get_values().items() + if not self.is_default_value(key, value) + } + + Server( + config=Config(**options_, app=self._app_uri), + ).run() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index d04ca60d8..46a431dc5 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -203,9 +203,9 @@ def run_backend( notify_backend() # Run the backend in development mode. - backend_server_prod = config.backend_server_prod - backend_server_prod.setup(host, port, loglevel, Env.DEV) - backend_server_prod.run_dev() + backend_server_dev = config.backend_server_dev + backend_server_dev.setup(host, port, loglevel, Env.DEV) + backend_server_dev.run_dev() def run_backend_prod( From 37ec2d5d4ea36f9d4ae88506e2d97b3776215e06 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 9 Nov 2024 21:05:50 +0000 Subject: [PATCH 05/25] remove unused var --- reflex/server/gunicorn.py | 75 --------------------------------------- 1 file changed, 75 deletions(-) diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 53544da7b..4cdb150fa 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -10,81 +10,6 @@ from reflex.constants.base import IS_WINDOWS, Env, LogLevel from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console -_mapping_attr_to_cli: dict[str, str] = { - "config": "--config", - "bind": "--bind", - "backlog": "--backlog", - "workers": "--workers", - "worker_class": "--worker-class", - "threads": "--threads", - "worker_connections": "--worker-connections", - "max_requests": "--max-requests", - "max_requests_jitter": "--max-requests-jitter", - "timeout": "--timeout", - "graceful_timeout": "--graceful-timeout", - "keepalive": "--keep-alive", - "limit_request_line": "--limit-request-line", - "limit_request_fields": "--limit-request-fields", - "limit_request_field_size": "--limit-request-field_size", - "reload": "--reload", - "reload_engine": "--reload-engine", - "reload_extra_files": "--reload-extra-file", - "spew": "--spew", - "check_config": "--check-config", - "print_config": "--print-config", - "preload_app": "--preload", - "sendfile": "--no-sendfile", - "reuse_port": "--reuse-port", - "chdir": "--chdir", - "daemon": "--daemon", - "raw_env": "--env", - "pidfile": "--pid", - "worker_tmp_dir": "--worker-tmp-dir", - "user": "--user", - "group": "--group", - "umask": "--umask", - "initgroups": "--initgroups", - "forwarded_allow_ips": "--forwarded-allow-ips", - "accesslog": "--access-logfile", - "disable_redirect_access_to_syslog": "--disable-redirect-access-to-syslog", - "access_log_format": "--access-logformat", - "errorlog": "--error-logfile", - "loglevel": "--log-level", - "capture_output": "--capture-output", - "logger_class": "--logger-class", - "logconfig": "--log-config", - "logconfig_json": "--log-config-json", - "syslog_addr": "--log-syslog-to", - "syslog": "--log-syslog", - "syslog_prefix": "--log-syslog-prefix", - "syslog_facility": "--log-syslog-facility", - "enable_stdio_inheritance": "--enable-stdio-inheritance", - "statsd_host": "--statsd-host", - "dogstatsd_tags": "--dogstatsd-tags", - "statsd_prefix": "--statsd-prefix", - "proc_name": "--name", - "pythonpath": "--pythonpath", - "paste": "--paster", - "proxy_protocol": "--proxy-protocol", - "proxy_allow_ips": "--proxy-allow-from", - "keyfile": "--keyfile", - "certfile": "--certfile", - "ssl_version": "--ssl-version", - "cert_reqs": "--cert-reqs", - "ca_certs": "--ca-certs", - "suppress_ragged_eofs": "--suppress-ragged-eofs", - "do_handshake_on_connect": "--do-handshake-on-connect", - "ciphers": "--ciphers", - "raw_paste_global_conf": "--paste-global", - "permit_obsolete_folding": "--permit-obsolete-folding", - "strip_header_spaces": "--strip-header-spaces", - "permit_unconventional_http_method": "--permit-unconventional-http-method", - "permit_unconventional_http_version": "--permit-unconventional-http-version", - "casefold_http_method": "--casefold-http-method", - "forwarder_headers": "--forwarder-headers", - "header_map": "--header-map", -} - @dataclass class GunicornBackendServer(CustomBackendServer): From 5d7890b5349993820a3dff7e73bd99ace96eaabe Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 15:32:10 +0000 Subject: [PATCH 06/25] [IMPL] - add `get_backend_bind()` & `shutdown()` --- reflex/server/base.py | 16 ++++++++++++++-- reflex/server/granian.py | 13 +++++++++++-- reflex/server/gunicorn.py | 23 ++++++++++++++++++++++- reflex/server/uvicorn.py | 20 ++++++++++++++++++-- reflex/utils/exec.py | 6 ++---- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/reflex/server/base.py b/reflex/server/base.py index 012511233..b19a6acb5 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -7,7 +7,7 @@ from abc import abstractmethod from dataclasses import Field, dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Callable, Sequence +from typing import Any, Callable, Sequence, ClassVar from reflex import constants from reflex.constants.base import Env, LogLevel @@ -156,7 +156,9 @@ def field_( class CustomBackendServer: """BackendServer base.""" - _app_uri: str = field_(default="", metadata_cli=None, exclude=True) + _env: ClassVar[Env] = field_(default=Env.DEV, metadata_cli=None, exclude=True, repr = False, init = False) + _app: ClassVar[Any] = field_(default=None, metadata_cli=None, exclude=True, repr = False, init = False) + _app_uri: ClassVar[str] = field_(default="", metadata_cli=None, exclude=True, repr = False, init = False) @staticmethod def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): @@ -246,6 +248,11 @@ class CustomBackendServer: return False + @abstractmethod + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + raise NotImplementedError() + @abstractmethod def check_import(self): """Check package importation.""" @@ -265,3 +272,8 @@ class CustomBackendServer: def run_dev(self): """Run in development mode.""" raise NotImplementedError() + + @abstractmethod + async def shutdown(self): + """Shutdown the backend server.""" + raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 2a76d8c53..547929398 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -191,6 +191,9 @@ class GranianBackendServer(CustomBackendServer): default=None, metadata_cli=CliType.default("--pid-file {value}") ) + def get_backend_bind(self) -> tuple[str, int]: + return self.address, self.port + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -218,6 +221,7 @@ class GranianBackendServer(CustomBackendServer): self.address = host self.port = port self.interface = "asgi" # NOTE: prevent obvious error + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -273,7 +277,7 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - Granian( + self._app = Granian( **{ **{ key: value @@ -301,4 +305,9 @@ class GranianBackendServer(CustomBackendServer): self.http2_max_send_buffer_size, ), } - ).serve() + ) + self._app.serve() + + async def shutdown(self): + if self._app and self._env == Env.DEV: + self._app.shutdown() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 4cdb150fa..be0b62729 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -278,6 +278,11 @@ class GunicornBackendServer(CustomBackendServer): default="drop", metadata_cli=CliType.default("--header-map {value}") ) + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + host, port = self.bind[0].split(":") + return host, int(port) + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -303,6 +308,7 @@ class GunicornBackendServer(CustomBackendServer): self._app_uri = f"{self.get_app_module()}()" self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -370,5 +376,20 @@ class GunicornBackendServer(CustomBackendServer): def load(self): return gunicorn_import_app(self._app_uri) + + def stop(self): + from gunicorn.arbiter import Arbiter - StandaloneApplication(app_uri=self._app_uri, options=options_).run() + Arbiter(self).stop() + + self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) + self._app.run() + + async def shutdown(self): + """Shutdown the backend server.""" + if self._app and self._env == Env.DEV: + self._app.stop() # type: ignore + + # TODO: complicated because currently `*BackendServer` don't execute the server command, he just create it + # if self._env == Env.PROD: + # pass diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index 8d167eacb..7f96c6070 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -183,6 +183,10 @@ class UvicornBackendServer(CustomBackendServer): metadata_cli=CliType.default("--h11-max-incomplete-event-size {value}"), ) + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + return self.host, self.port + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -211,6 +215,7 @@ class UvicornBackendServer(CustomBackendServer): self.log_level = loglevel.value self.host = host self.port = port + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -250,6 +255,17 @@ class UvicornBackendServer(CustomBackendServer): if not self.is_default_value(key, value) } - Server( + self._app = Server( config=Config(**options_, app=self._app_uri), - ).run() + ) + self._app.run() + + async def shutdown(self): + """Shutdown the backend server.""" + if self._app and self._env == Env.DEV: + self._app.shutdown() # type: ignore + + # TODO: hard because currently `*BackendServer` don't execute the server command, he just create it + # if self._env == Env.PROD: + # pass + diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 46a431dc5..866ebb1f1 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -178,7 +178,6 @@ def run_frontend_prod(root: Path, port: str, backend_present=True): ) -### REWORK <-- def run_backend( host: str, port: int, @@ -237,12 +236,11 @@ def run_backend_prod( run=True, show_logs=True, env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend + environment.REFLEX_SKIP_COMPILE.name: "true" # skip compile for prod backend + }, ) -### REWORK--> def output_system_info(): """Show system information if the loglevel is in DEBUG.""" if console._LOG_LEVEL > constants.LogLevel.DEBUG: From 9fb47d6cdfddf7cc97ed3b90842b533b566a8db5 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 16:36:19 +0000 Subject: [PATCH 07/25] deprecated config field --- reflex/config.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index d67446b36..2e06d2abf 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -668,6 +668,9 @@ class Config(Base): backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( threads=2, workers=4, + max_requests=100, + max_requests_jitter=25, + timeout=120 ) backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, @@ -702,26 +705,15 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - - for key in ( - "timeout", - "gunicorn_worker_class", - "gunicorn_workers", - "gunicorn_max_requests", - "gunicorn_max_requests_jitter", + + if any( + getattr(self.get_fields().get(key, None), "default", None) == self.get_value(key) + for key in ("timeout","gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter" ) ): - if isinstance(self.backend_server_prod, server.GunicornBackendServer): - value = self.get_value(key) - if ( - value - != self.backend_server_prod.get_fields()[ - key.replace("gunicorn_", "") - ].default - and value is not None - ): - setattr( - self.backend_server_prod, key.replace("gunicorn_", ""), value - ) + console.warn( + 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.' + ) + @property def module(self) -> str: From accc39a764f638f9332c0f2384dea97df14359e0 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 21:17:38 +0000 Subject: [PATCH 08/25] lint & format --- reflex/config.py | 20 ++--- reflex/server/base.py | 156 +++++++++++++++++++++++++++++++++----- reflex/server/granian.py | 30 ++++++-- reflex/server/gunicorn.py | 31 ++++++-- reflex/server/uvicorn.py | 32 +++++--- 5 files changed, 216 insertions(+), 53 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 2e06d2abf..c22c7ec2b 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -666,11 +666,7 @@ class Config(Base): # Custom Backend Server backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - threads=2, - workers=4, - max_requests=100, - max_requests_jitter=25, - timeout=120 + threads=2, workers=4, max_requests=100, max_requests_jitter=25, timeout=120 ) backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, @@ -705,15 +701,21 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - + if any( - getattr(self.get_fields().get(key, None), "default", None) == self.get_value(key) - for key in ("timeout","gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter" ) + getattr(self.get_fields().get(key, None), "default", None) + == self.get_value(key) + for key in ( + "timeout", + "gunicorn_worker_class", + "gunicorn_workers", + "gunicorn_max_requests", + "gunicorn_max_requests_jitter", + ) ): console.warn( 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.' ) - @property def module(self) -> str: diff --git a/reflex/server/base.py b/reflex/server/base.py index b19a6acb5..c78c90224 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -7,7 +7,7 @@ from abc import abstractmethod from dataclasses import Field, dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Callable, Sequence, ClassVar +from typing import Any, Callable, ClassVar, Sequence from reflex import constants from reflex.constants.base import Env, LogLevel @@ -26,6 +26,12 @@ class CliType: fmt: `'--env-file {value}'` value: `'/config.conf'` result => `'--env-file /config.conf'` + + Args: + fmt (str): format + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -46,6 +52,13 @@ class CliType: fmt: `'--reload'` value: `True` result => `'--reload'` + + Args: + fmt (str): format + bool_value (bool): boolean value used for toggle condition + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -80,6 +93,16 @@ class CliType: value: `True` toggle_value: `True` result => `'--no-access-log'` + + Args: + fmt (str): format + toggle_kw (str): keyword used when toggled. Defaults to "no". + toggle_sep (str): separator used when toggled. Defaults to "-". + toggle_value (bool): boolean value used for toggle condition. Defaults to False. + **kwargs: Keyword arguments to pass to the format string function. + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -102,6 +125,7 @@ class CliType: Example (Multiple args mode): fmt: `'--header {value}'`. data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + join_sep: `None` result => `'--header \"X-Forwarded-Proto=https\" --header \"X-Forwarded-For=0.0.0.0\"'` Example (Single args mode): @@ -116,6 +140,14 @@ class CliType: join_sep (required): `';'` value_transformer: `lambda value: f'{value[0]}:{value[1]}'` result => `--headers \"X-Forwarded-Proto:https;X-Forwarded-For:0.0.0.0\"` + + Args: + fmt (str): format + join_sep (str): separator used + value_transformer (Callable[[Any], str]): function used for transformer the element + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(values: Sequence[str]) -> str: @@ -139,7 +171,17 @@ def field_( exclude: bool = False, **kwargs, ): - """Custom dataclass field builder.""" + """Custom dataclass field builder. + + Args: + default (Any): default value. Defaults to None. + metadata_cli (ReturnCliTypeFn | None): cli wrapper function. Defaults to None. + exclude (bool): used for excluding the field to the server configuration (system field). Defaults to False. + **kwargs: Keyword arguments to pass to the field dataclasses function. + + Returns: + Field: return the field dataclasses + """ params_ = { "default": default, "metadata": {"cli": metadata_cli, "exclude": exclude}, @@ -156,16 +198,28 @@ def field_( class CustomBackendServer: """BackendServer base.""" - _env: ClassVar[Env] = field_(default=Env.DEV, metadata_cli=None, exclude=True, repr = False, init = False) - _app: ClassVar[Any] = field_(default=None, metadata_cli=None, exclude=True, repr = False, init = False) - _app_uri: ClassVar[str] = field_(default="", metadata_cli=None, exclude=True, repr = False, init = False) + _env: ClassVar[Env] = field_( + default=Env.DEV, metadata_cli=None, exclude=True, repr=False, init=False + ) + _app: ClassVar[Any] = field_( + default=None, metadata_cli=None, exclude=True, repr=False, init=False + ) + _app_uri: ClassVar[str] = field_( + default="", metadata_cli=None, exclude=True, repr=False, init=False + ) @staticmethod - def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): + def get_app_module( + for_granian_target: bool = False, add_extra_api: bool = False + ) -> str: """Get the app module for the backend. + Args: + for_granian_target (bool): make the return compatible with Granian. Defaults to False. + add_extra_api (bool): add the keyword "api" at the end (needed for Uvicorn & Granian). Defaults to False. + Returns: - The app module for the backend. + str: The app module for the backend. """ import reflex @@ -177,21 +231,41 @@ class CustomBackendServer: return f"{app_path}:{constants.CompileVars.APP}{f'.{constants.CompileVars.API}' if add_extra_api else ''}" def get_available_cpus(self) -> int: - """Get available cpus.""" + """Get available cpus. + + Returns: + int: number of available cpu cores + """ return os.cpu_count() or 1 def get_max_workers(self) -> int: - """Get max workers.""" + """Get maximum workers. + + Returns: + int: get the maximum number of workers + """ # https://docs.gunicorn.org/en/latest/settings.html#workers return (os.cpu_count() or 1) * 4 + 1 def get_recommended_workers(self) -> int: - """Get recommended workers.""" + """Get recommended workers. + + Returns: + int: get the recommended number of workers + """ # https://docs.gunicorn.org/en/latest/settings.html#workers return (os.cpu_count() or 1) * 2 + 1 def get_max_threads(self, wait_time_ms: int = 50, service_time_ms: int = 5) -> int: - """Get max threads.""" + """Get maximum threads. + + Args: + wait_time_ms (int): the mean waiting duration targeted. Defaults to 50. + service_time_ms (int): the mean working duration. Defaults to 5. + + Returns: + int: get the maximum number of threads + """ # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html # Brian Goetz formula return int(self.get_available_cpus() * (1 + wait_time_ms / service_time_ms)) @@ -202,7 +276,16 @@ class CustomBackendServer: wait_time_ms: int = 50, service_time_ms: int = 5, ) -> int: - """Get recommended threads.""" + """Get recommended threads. + + Args: + target_reqs (int | None): number of requests targeted. Defaults to None. + wait_time_ms (int): the mean waiting duration targeted. Defaults to 50. + service_time_ms (int): the mean working duration. Defaults to 5. + + Returns: + int: get the recommended number of threads + """ # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html max_available_threads = self.get_max_threads() @@ -221,19 +304,35 @@ class CustomBackendServer: ) def get_fields(self) -> dict[str, Field]: - """Return all the fields.""" + """Return all the fields. + + Returns: + dict[str, Field]: return the fields dictionary + """ return self.__dataclass_fields__ def get_values(self) -> dict[str, Any]: - """Return all values.""" + """Return all values. + + Returns: + dict[str, Any]: returns the value of the fields + """ return { key: getattr(self, key) for key, field in self.__dataclass_fields__.items() if field.metadata["exclude"] is False } - def is_default_value(self, key, value: Any | None = None) -> bool: - """Check if the `value` is the same value from default context.""" + def is_default_value(self, key: str, value: Any | None = None) -> bool: + """Check if the `value` is the same value from default context. + + Args: + key (str): the name of the field + value (Any | None, optional): the value to check if is equal to the default value. Defaults to None. + + Returns: + bool: result of the condition of value are equal to the default value + """ from dataclasses import MISSING field = self.get_fields()[key] @@ -250,9 +349,13 @@ class CustomBackendServer: @abstractmethod def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ raise NotImplementedError() - + @abstractmethod def check_import(self): """Check package importation.""" @@ -260,12 +363,23 @@ class CustomBackendServer: @abstractmethod def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ raise NotImplementedError() @abstractmethod - def run_prod(self): - """Run in production mode.""" + def run_prod(self) -> list[str]: + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ raise NotImplementedError() @abstractmethod diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 547929398..a87faee93 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -192,6 +192,11 @@ class GranianBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ return self.address, self.port def check_import(self): @@ -215,13 +220,20 @@ class GranianBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) # type: ignore self.log_level = loglevel.value # type: ignore self.address = host self.port = port self.interface = "asgi" # NOTE: prevent obvious error - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -244,7 +256,11 @@ class GranianBackendServer(CustomBackendServer): self.reload_ignore_dirs = [".web"] def run_prod(self): - """Run in production mode.""" + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["granian"] @@ -261,7 +277,7 @@ class GranianBackendServer(CustomBackendServer): def run_dev(self): """Run in development mode.""" self.check_import() - from granian import Granian + from granian import Granian # type: ignore exclude_keys = ( "http1_keep_alive", @@ -277,7 +293,8 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - self._app = Granian( + + self._app = Granian( # type: ignore **{ **{ key: value @@ -309,5 +326,6 @@ class GranianBackendServer(CustomBackendServer): self._app.serve() async def shutdown(self): + """Shutdown the backend server.""" if self._app and self._env == Env.DEV: self._app.shutdown() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index be0b62729..5e47044cf 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -279,10 +279,14 @@ class GunicornBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ host, port = self.bind[0].split(":") return host, int(port) - + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -304,11 +308,18 @@ class GunicornBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = f"{self.get_app_module()}()" + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = f"{self.get_app_module()}()" # type: ignore self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -328,7 +339,11 @@ class GunicornBackendServer(CustomBackendServer): self.reload = True def run_prod(self) -> list[str]: - """Run in production mode.""" + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["gunicorn"] @@ -376,13 +391,13 @@ class GunicornBackendServer(CustomBackendServer): def load(self): return gunicorn_import_app(self._app_uri) - + def stop(self): from gunicorn.arbiter import Arbiter Arbiter(self).stop() - self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) + self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) # type: ignore self._app.run() async def shutdown(self): diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index 7f96c6070..e164fefee 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -184,9 +184,13 @@ class UvicornBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ return self.host, self.port - + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -210,12 +214,19 @@ class UvicornBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = self.get_app_module(add_extra_api=True) + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = self.get_app_module(add_extra_api=True) # type: ignore self.log_level = loglevel.value self.host = host self.port = port - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -230,8 +241,12 @@ class UvicornBackendServer(CustomBackendServer): self.reload = True self.reload_dirs = [str(Path(get_config().app_name))] - def run_prod(self): - """Run in production mode.""" + def run_prod(self) -> list[str]: + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["uvicorn"] @@ -255,7 +270,7 @@ class UvicornBackendServer(CustomBackendServer): if not self.is_default_value(key, value) } - self._app = Server( + self._app = Server( # type: ignore config=Config(**options_, app=self._app_uri), ) self._app.run() @@ -268,4 +283,3 @@ class UvicornBackendServer(CustomBackendServer): # TODO: hard because currently `*BackendServer` don't execute the server command, he just create it # if self._env == Env.PROD: # pass - From 668801367bcd785e85c8eb388d3a1285659759d4 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 02:20:08 +0000 Subject: [PATCH 09/25] first iteration, GunicornBackendServer work for prod mode --- reflex/config.py | 32 +- reflex/server/__init__.py | 6 + reflex/server/base.py | 14 + reflex/server/granian.py | 36 ++ reflex/server/gunicorn.py | 1195 +++++++++++++++++++++++++++++++++++++ reflex/server/uvicorn.py | 10 + reflex/utils/exec.py | 89 ++- 7 files changed, 1324 insertions(+), 58 deletions(-) create mode 100644 reflex/server/__init__.py create mode 100644 reflex/server/base.py create mode 100644 reflex/server/granian.py create mode 100644 reflex/server/gunicorn.py create mode 100644 reflex/server/uvicorn.py diff --git a/reflex/config.py b/reflex/config.py index 88230cefe..e0bd32484 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -36,7 +36,7 @@ except ModuleNotFoundError: from reflex_cli.constants.hosting import Hosting -from reflex import constants +from reflex import constants, server from reflex.base import Base from reflex.utils import console @@ -639,7 +639,7 @@ class Config(Base): # Tailwind config. tailwind: Optional[Dict[str, Any]] = {"plugins": ["@tailwindcss/typography"]} - # Timeout when launching the gunicorn server. TODO(rename this to backend_timeout?) + # Timeout when launching the gunicorn server. TODO(rename this to backend_timeout?); deprecated timeout: int = 120 # Whether to enable or disable nextJS gzip compression. @@ -656,16 +656,16 @@ class Config(Base): # The hosting service frontend URL. cp_web_url: str = Hosting.CP_WEB_URL - # The worker class used in production mode + # The worker class used in production mode; deprecated gunicorn_worker_class: str = "uvicorn.workers.UvicornH11Worker" - # Number of gunicorn workers from user + # Number of gunicorn workers from user; deprecated gunicorn_workers: Optional[int] = None - # Number of requests before a worker is restarted + # Number of requests before a worker is restarted; deprecated gunicorn_max_requests: int = 100 - # Variance limit for max requests; gunicorn only + # Variance limit for max requests; gunicorn only; deprecated gunicorn_max_requests_jitter: int = 25 # Indicate which type of state manager to use @@ -683,6 +683,17 @@ class Config(Base): # Path to file containing key-values pairs to override in the environment; Dotenv format. env_file: Optional[str] = None + # Custom Backend Server + backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( + app=f"reflex.app_module_for_backend:{constants.CompileVars.APP}.{constants.CompileVars.API}", + worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + max_requests=100, + max_requests_jitter=25, + preload_app=True, + timeout=120, + ) + backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer() + def __init__(self, *args, **kwargs): """Initialize the config values. @@ -693,6 +704,7 @@ class Config(Base): Raises: ConfigError: If some values in the config are invalid. """ + print("[reflex.config::Config] start") super().__init__(*args, **kwargs) # Update the config from environment variables. @@ -712,6 +724,14 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) + + print("[reflex.config::Config] --") + for key in ("timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"): + if isinstance(self.backend_server_prod, server.GunicornBackendServer): + value = self.get_value(key) + if value != self.backend_server_prod.get_fields()[key.replace("gunicorn_", "")].default and value is not None: + setattr(self.backend_server_prod, key.replace("gunicorn_", ""), value) + print("[reflex.config::Config] done") @property def module(self) -> str: diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py new file mode 100644 index 000000000..c44f2ca11 --- /dev/null +++ b/reflex/server/__init__.py @@ -0,0 +1,6 @@ + + +from .base import CustomBackendServer +from .granian import GranianBackendServer +from .gunicorn import GunicornBackendServer +from .uvicorn import UvicornBackendServer diff --git a/reflex/server/base.py b/reflex/server/base.py new file mode 100644 index 000000000..c327d5c89 --- /dev/null +++ b/reflex/server/base.py @@ -0,0 +1,14 @@ +from abc import abstractmethod, ABCMeta + +from reflex.base import Base + + +class CustomBackendServer(Base): + + @abstractmethod + def run_prod(self): + raise NotImplementedError() + + @abstractmethod + def run_dev(self): + raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py new file mode 100644 index 000000000..7a2fde844 --- /dev/null +++ b/reflex/server/granian.py @@ -0,0 +1,36 @@ + +from dataclasses import dataclass + +from reflex.server.base import CustomBackendServer + +@dataclass +class HTTP1Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 + keep_alive: bool = True + max_buffer_size: int = 8192 + 4096 * 100 + pipeline_flush: bool = False + + +@dataclass +class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L13 + adaptive_window: bool = False + initial_connection_window_size: int = 1024 * 1024 + initial_stream_window_size: int = 1024 * 1024 + keep_alive_interval: int | None = None + keep_alive_timeout: int = 20 + max_concurrent_streams: int = 200 + max_frame_size: int = 1024 * 16 + max_headers_size: int = 16 * 1024 * 1024 + max_send_buffer_size: int = 1024 * 400 + +try: + import watchfiles +except ImportError: + watchfiles = None + +class GranianBackendServer(CustomBackendServer): + + def run_prod(self): + pass + + def run_dev(self): + pass diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py new file mode 100644 index 000000000..18dfb7841 --- /dev/null +++ b/reflex/server/gunicorn.py @@ -0,0 +1,1195 @@ +from typing import Any, Literal, Callable + +import os +import sys +import ssl +from pydantic import Field + +from gunicorn.app.base import BaseApplication + +import psutil + +from reflex import constants +from reflex.server.base import CustomBackendServer + + +class StandaloneApplication(BaseApplication): + + def __init__(self, app, options=None): + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self): + config = {key: value for key, value in self.options.items() + if key in self.cfg.settings and value is not None} # type: ignore + for key, value in config.items(): + self.cfg.set(key.lower(), value) # type: ignore + + def load(self): + return self.application + + +_mapping_attr_to_cli: dict[str, str] = { + "config": "--config", + "bind": "--bind", + "backlog": "--backlog", + "workers": "--workers", + "worker_class": "--worker-class", + "threads": "--threads", + "worker_connections": "--worker-connections", + "max_requests": "--max-requests", + "max_requests_jitter": "--max-requests-jitter", + "timeout": "--timeout", + "graceful_timeout": "--graceful-timeout", + "keepalive": "--keep-alive", + "limit_request_line": "--limit-request-line", + "limit_request_fields": "--limit-request-fields", + "limit_request_field_size": "--limit-request-field_size", + "reload": "--reload", + "reload_engine": "--reload-engine", + "reload_extra_files": "--reload-extra-file", + "spew": "--spew", + "check_config": "--check-config", + "print_config": "--print-config", + "preload_app": "--preload", + "sendfile": "--no-sendfile", + "reuse_port": "--reuse-port", + "chdir": "--chdir", + "daemon": "--daemon", + "raw_env": "--env", + "pidfile": "--pid", + "worker_tmp_dir": "--worker-tmp-dir", + "user": "--user", + "group": "--group", + "umask": "--umask", + "initgroups": "--initgroups", + "forwarded_allow_ips": "--forwarded-allow-ips", + "accesslog": "--access-logfile", + "disable_redirect_access_to_syslog": "--disable-redirect-access-to-syslog", + "access_log_format": "--access-logformat", + "errorlog": "--error-logfile", + "loglevel": "--log-level", + "capture_output": "--capture-output", + "logger_class": "--logger-class", + "logconfig": "--log-config", + "logconfig_json": "--log-config-json", + "syslog_addr": "--log-syslog-to", + "syslog": "--log-syslog", + "syslog_prefix": "--log-syslog-prefix", + "syslog_facility": "--log-syslog-facility", + "enable_stdio_inheritance": "--enable-stdio-inheritance", + "statsd_host": "--statsd-host", + "dogstatsd_tags": "--dogstatsd-tags", + "statsd_prefix": "--statsd-prefix", + "proc_name": "--name", + "pythonpath": "--pythonpath", + "paste": "--paster", + "proxy_protocol": "--proxy-protocol", + "proxy_allow_ips": "--proxy-allow-from", + "keyfile": "--keyfile", + "certfile": "--certfile", + "ssl_version": "--ssl-version", + "cert_reqs": "--cert-reqs", + "ca_certs": "--ca-certs", + "suppress_ragged_eofs": "--suppress-ragged-eofs", + "do_handshake_on_connect": "--do-handshake-on-connect", + "ciphers": "--ciphers", + "raw_paste_global_conf": "--paste-global", + "permit_obsolete_folding": "--permit-obsolete-folding", + "strip_header_spaces": "--strip-header-spaces", + "permit_unconventional_http_method": "--permit-unconventional-http-method", + "permit_unconventional_http_version": "--permit-unconventional-http-version", + "casefold_http_method": "--casefold-http-method", + "forwarder_headers": "--forwarder-headers", + "header_map": "--header-map", +} + +class GunicornBackendServer(CustomBackendServer): + # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py + + app: str + + config: str = "./gunicorn.conf.py" + """\ + :ref:`The Gunicorn config file`. + + A string of the form ``PATH``, ``file:PATH``, or ``python:MODULE_NAME``. + + Only has an effect when specified on the command line or as part of an + application specific configuration. + + By default, a file named ``gunicorn.conf.py`` will be read from the same + directory where gunicorn is being run. + """ + + wsgi_app: str | None = None + """\ + A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. + """ + + bind: list[str] = ['127.0.0.1:8000'] + """\ + The socket to bind. + + A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, + ``fd://FD``. An IP is a valid ``HOST``. + + .. versionchanged:: 20.0 + Support for ``fd://FD`` got added. + + Multiple addresses can be bound. ex.:: + + $ gunicorn -b 127.0.0.1:8000 -b [::1]:8000 test:app + + will bind the `test:app` application on localhost both on ipv6 + and ipv4 interfaces. + + If the ``PORT`` environment variable is defined, the default + is ``['0.0.0.0:$PORT']``. If it is not defined, the default + is ``['127.0.0.1:8000']``. + """ + + backlog: int = 2048 + """\ + The maximum number of pending connections. + + This refers to the number of clients that can be waiting to be served. + Exceeding this number results in the client getting an error when + attempting to connect. It should only affect servers under significant + load. + + Must be a positive integer. Generally set in the 64-2048 range. + """ + + workers: int = 1 + """\ + The number of worker processes for handling requests. + + A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. + You'll want to vary this a bit to find the best for your particular + application's work load. + + By default, the value of the ``WEB_CONCURRENCY`` environment variable, + which is set by some Platform-as-a-Service providers such as Heroku. If + it is not defined, the default is ``1``. + """ + + worker_class: Literal["sync", "eventlet", "gevent", "tornado", "gthread", "uvicorn.workers.UvicornH11Worker"] = "sync" + """\ + The type of workers to use. + + The default class (``sync``) should handle most "normal" types of + workloads. You'll want to read :doc:`design` for information on when + you might want to choose one of the other worker classes. Required + libraries may be installed using setuptools' ``extras_require`` feature. + + A string referring to one of the following bundled classes: + + * ``sync`` + * ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via + ``pip install gunicorn[eventlet]``) + * ``gevent`` - Requires gevent >= 1.4 (or install it via + ``pip install gunicorn[gevent]``) + * ``tornado`` - Requires tornado >= 0.2 (or install it via + ``pip install gunicorn[tornado]``) + * ``gthread`` - Python 2 requires the futures package to be installed + (or install it via ``pip install gunicorn[gthread]``) + + Optionally, you can provide your own worker by giving Gunicorn a + Python path to a subclass of ``gunicorn.workers.base.Worker``. + This alternative syntax will load the gevent class: + ``gunicorn.workers.ggevent.GeventWorker``. + """ + + threads: int = 1 + """\ + The number of worker threads for handling requests. + + Run each worker with the specified number of threads. + + A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. + You'll want to vary this a bit to find the best for your particular + application's work load. + + If it is not defined, the default is ``1``. + + This setting only affects the Gthread worker type. + + .. note:: + If you try to use the ``sync`` worker type and set the ``threads`` + setting to more than 1, the ``gthread`` worker type will be used + instead. + """ + + worker_connections: int = 1000 + """\ + The maximum number of simultaneous clients. + + This setting only affects the ``gthread``, ``eventlet`` and ``gevent`` worker types. + """ + + max_requests: int = 0 + """\ + The maximum number of requests a worker will process before restarting. + + Any value greater than zero will limit the number of requests a worker + will process before automatically restarting. This is a simple method + to help limit the damage of memory leaks. + + If this is set to zero (the default) then the automatic worker + restarts are disabled. + """ + + max_requests_jitter: int = 0 + """\ + The maximum jitter to add to the *max_requests* setting. + + The jitter causes the restart per worker to be randomized by + ``randint(0, max_requests_jitter)``. This is intended to stagger worker + restarts to avoid all workers restarting at the same time. + """ + + timeout: int = 30 + """\ + Workers silent for more than this many seconds are killed and restarted. + + Value is a positive number or 0. Setting it to 0 has the effect of + infinite timeouts by disabling timeouts for all workers entirely. + + Generally, the default of thirty seconds should suffice. Only set this + noticeably higher if you're sure of the repercussions for sync workers. + For the non sync workers it just means that the worker process is still + communicating and is not tied to the length of time required to handle a + single request. + """ + + graceful_timeout: int = 30 + """\ + Timeout for graceful workers restart. + + After receiving a restart signal, workers have this much time to finish + serving requests. Workers still alive after the timeout (starting from + the receipt of the restart signal) are force killed. + """ + + keepalive: int = 2 + """\ + The number of seconds to wait for requests on a Keep-Alive connection. + + Generally set in the 1-5 seconds range for servers with direct connection + to the client (e.g. when you don't have separate load balancer). When + Gunicorn is deployed behind a load balancer, it often makes sense to + set this to a higher value. + + .. note:: + ``sync`` worker does not support persistent connections and will + ignore this option. + """ + + limit_request_line: int = 4094 + """\ + The maximum size of HTTP request line in bytes. + + This parameter is used to limit the allowed size of a client's + HTTP request-line. Since the request-line consists of the HTTP + method, URI, and protocol version, this directive places a + restriction on the length of a request-URI allowed for a request + on the server. A server needs this value to be large enough to + hold any of its resource names, including any information that + might be passed in the query part of a GET request. Value is a number + from 0 (unlimited) to 8190. + + This parameter can be used to prevent any DDOS attack. + """ + + limit_request_fields: int = 100 + """\ + Limit the number of HTTP headers fields in a request. + + This parameter is used to limit the number of headers in a request to + prevent DDOS attack. Used with the *limit_request_field_size* it allows + more safety. By default this value is 100 and can't be larger than + 32768. + """ + + limit_request_field_size: int = 8190 + """\ + Limit the allowed size of an HTTP request header field. + + Value is a positive number or 0. Setting it to 0 will allow unlimited + header field sizes. + + .. warning:: + Setting this parameter to a very high or unlimited value can open + up for DDOS attacks. + """ + + reload: bool = False + """\ + Restart workers when code changes. + + This setting is intended for development. It will cause workers to be + restarted whenever application code changes. + + The reloader is incompatible with application preloading. When using a + paste configuration be sure that the server block does not import any + application code or the reload will not work as designed. + + The default behavior is to attempt inotify with a fallback to file + system polling. Generally, inotify should be preferred if available + because it consumes less system resources. + + .. note:: + In order to use the inotify reloader, you must have the ``inotify`` + package installed. + """ + + reload_engine: Literal["auto", "poll", "inotify"] = "auto" + """\ + The implementation that should be used to power :ref:`reload`. + + Valid engines are: + + * ``'auto'`` + * ``'poll'`` + * ``'inotify'`` (requires inotify) + """ + + reload_extra_files: list[str] = [] + """\ + Extends :ref:`reload` option to also watch and reload on additional files + (e.g., templates, configurations, specifications, etc.). + """ + + spew: bool = False + """\ + Install a trace function that spews every line executed by the server. + + This is the nuclear option. + """ + + check_config: bool = False + """\ + Check the configuration and exit. The exit status is 0 if the + configuration is correct, and 1 if the configuration is incorrect. + """ + + print_config: bool = False + """\ + Print the configuration settings as fully resolved. Implies :ref:`check-config`. + """ + + preload_app: bool = False + """\ + Load application code before the worker processes are forked. + + By preloading an application you can save some RAM resources as well as + speed up server boot times. Although, if you defer application loading + to each worker process, you can reload your application code easily by + restarting workers. + """ + + sendfile: bool | None = None + """\ + Disables the use of ``sendfile()``. + + If not set, the value of the ``SENDFILE`` environment variable is used + to enable or disable its usage. + """ + + reuse_port: bool = False + """\ + Set the ``SO_REUSEPORT`` flag on the listening socket. + """ + + chdir: str = "." + """\ + Change directory to specified directory before loading apps. + """ + + daemon: bool = False + """\ + Daemonize the Gunicorn process. + + Detaches the server from the controlling terminal and enters the + background. + """ + + raw_env: list[str] = [] + """\ + Set environment variables in the execution environment. + + Should be a list of strings in the ``key=value`` format. + """ + + pidfile: str | None = None + """\ + A filename to use for the PID file. + + If not set, no PID file will be written. + """ + + worker_tmp_dir: str | None = None + """\ + A directory to use for the worker heartbeat temporary file. + + If not set, the default temporary directory will be used. + + .. note:: + The current heartbeat system involves calling ``os.fchmod`` on + temporary file handlers and may block a worker for arbitrary time + if the directory is on a disk-backed filesystem. + + See :ref:`blocking-os-fchmod` for more detailed information + and a solution for avoiding this problem. + """ + + user: int = os.geteuid() + """\ + Switch worker processes to run as this user. + + A valid user id (as an integer) or the name of a user that can be + retrieved with a call to ``pwd.getpwnam(value)`` or ``None`` to not + change the worker process user. + """ + + group: int = os.getegid() + """\ + Switch worker process to run as this group. + + A valid group id (as an integer) or the name of a user that can be + retrieved with a call to ``pwd.getgrnam(value)`` or ``None`` to not + change the worker processes group. + """ + + umask: int = 0 + """\ + A bit mask for the file mode on files written by Gunicorn. + + Note that this affects unix socket permissions. + + A valid value for the ``os.umask(mode)`` call or a string compatible + with ``int(value, 0)`` (``0`` means Python guesses the base, so values + like ``0``, ``0xFF``, ``0022`` are valid for decimal, hex, and octal + representations) + """ + + initgroups: bool = False + """\ + If true, set the worker process's group access list with all of the + groups of which the specified username is a member, plus the specified + group id. + """ + + tmp_upload_dir: str | None = None + """\ + Directory to store temporary request data as they are read. + + This may disappear in the near future. + + This path should be writable by the process permissions set for Gunicorn + workers. If not specified, Gunicorn will choose a system generated + temporary directory. + """ + + secure_scheme_headers: dict[str, Any] = {'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'} + """\ + A dictionary containing headers and values that the front-end proxy + uses to indicate HTTPS requests. If the source IP is permitted by + :ref:`forwarded-allow-ips` (below), *and* at least one request header matches + a key-value pair listed in this dictionary, then Gunicorn will set + ``wsgi.url_scheme`` to ``https``, so your application can tell that the + request is secure. + + If the other headers listed in this dictionary are not present in the request, they will be ignored, + but if the other headers are present and do not match the provided values, then + the request will fail to parse. See the note below for more detailed examples of this behaviour. + + The dictionary should map upper-case header names to exact string + values. The value comparisons are case-sensitive, unlike the header + names, so make sure they're exactly what your front-end proxy sends + when handling HTTPS requests. + + It is important that your front-end proxy configuration ensures that + the headers defined here can not be passed directly from the client. + """ + + forwarded_allow_ips: str = "127.0.0.1,::1" + """\ + Front-end's IPs from which allowed to handle set secure headers. + (comma separated). + + Set to ``*`` to disable checking of front-end IPs. This is useful for setups + where you don't know in advance the IP address of front-end, but + instead have ensured via other means that only your + authorized front-ends can access Gunicorn. + + By default, the value of the ``FORWARDED_ALLOW_IPS`` environment + variable. If it is not defined, the default is ``"127.0.0.1,::1"``. + + .. note:: + + This option does not affect UNIX socket connections. Connections not associated with + an IP address are treated as allowed, unconditionally. + + .. note:: + + The interplay between the request headers, the value of ``forwarded_allow_ips``, and the value of + ``secure_scheme_headers`` is complex. Various scenarios are documented below to further elaborate. + In each case, we have a request from the remote address 134.213.44.18, and the default value of + ``secure_scheme_headers``: + + .. code:: + + secure_scheme_headers = { + 'X-FORWARDED-PROTOCOL': 'ssl', + 'X-FORWARDED-PROTO': 'https', + 'X-FORWARDED-SSL': 'on' + } + + + .. list-table:: + :header-rows: 1 + :align: center + :widths: auto + + * - ``forwarded-allow-ips`` + - Secure Request Headers + - Result + - Explanation + * - ``["127.0.0.1"]`` + - ``X-Forwarded-Proto: https`` + - ``wsgi.url_scheme = "http"`` + - IP address was not allowed + * - ``"*"`` + - + - ``wsgi.url_scheme = "http"`` + - IP address allowed, but no secure headers provided + * - ``"*"`` + - ``X-Forwarded-Proto: https`` + - ``wsgi.url_scheme = "https"`` + - IP address allowed, one request header matched + * - ``["134.213.44.18"]`` + - ``X-Forwarded-Ssl: on`` ``X-Forwarded-Proto: http`` + - ``InvalidSchemeHeaders()`` raised + - IP address allowed, but the two secure headers disagreed on if HTTPS was used + """ + + accesslog: str | None = None + """\ + The Access log file to write to. + + ``'-'`` means log to stdout. + """ + + disable_redirect_access_to_syslog: bool = False + """\ + Disable redirect access logs to syslog. + """ + + access_log_format: str = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + """\ + The access log format. + + =========== =========== + Identifier Description + =========== =========== + h remote address + l ``'-'`` + u user name (if HTTP Basic auth used) + t date of the request + r status line (e.g. ``GET / HTTP/1.1``) + m request method + U URL path without query string + q query string + H protocol + s status + B response length + b response length or ``'-'`` (CLF format) + f referrer (note: header is ``referer``) + a user agent + T request time in seconds + M request time in milliseconds + D request time in microseconds + L request time in decimal seconds + p process ID + {header}i request header + {header}o response header + {variable}e environment variable + =========== =========== + + Use lowercase for header and environment variable names, and put + ``{...}x`` names inside ``%(...)s``. For example:: + + %({x-forwarded-for}i)s + """ + + errorlog: str = "-" + """\ + The Error log file to write to. + + Using ``'-'`` for FILE makes gunicorn log to stderr. + """ + + loglevel: Literal["debug", "info", "warning", "error", "critical"] = "info" + """\ + The granularity of Error log outputs. + """ + + capture_output: bool = False + """\ + Redirect stdout/stderr to specified file in :ref:`errorlog`. + """ + + logger_class: str = "gunicorn.glogging.Logger" + """\ + The logger you want to use to log events in Gunicorn. + + The default class (``gunicorn.glogging.Logger``) handles most + normal usages in logging. It provides error and access logging. + + You can provide your own logger by giving Gunicorn a Python path to a + class that quacks like ``gunicorn.glogging.Logger``. + """ + + logconfig: str | None = None + """\ + The log config file to use. + Gunicorn uses the standard Python logging module's Configuration + file format. + """ + + logconfig_dict: dict = {} + """\ + The log config dictionary to use, using the standard Python + logging module's dictionary configuration format. This option + takes precedence over the :ref:`logconfig` and :ref:`logconfig-json` options, + which uses the older file configuration format and JSON + respectively. + + Format: https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig + + For more context you can look at the default configuration dictionary for logging, + which can be found at ``gunicorn.glogging.CONFIG_DEFAULTS``. + """ + + logconfig_json: str | None = None + """\ + The log config to read config from a JSON file + + Format: https://docs.python.org/3/library/logging.config.html#logging.config.jsonConfig + """ + + syslog_addr: str = ( + "unix:///var/run/syslog" + if sys.platform == "darwin" + else ( + "unix:///var/run/log" + if sys.platform in ('freebsd', 'dragonfly', ) + else ( + "unix:///dev/log" + if sys.platform == "openbsd" + else "udp://localhost:514" + ) + ) + ) + """\ + Address to send syslog messages. + + Address is a string of the form: + * ``unix://PATH#TYPE`` : for unix domain socket, ``TYPE`` can be ``stream`` for the stream driver or ``dgram`` for the dgram driver, ``stream`` is the default + * ``udp://HOST:PORT`` : for UDP sockets + * ``tcp://HOST:PORT`` : for TCP sockets + """ + + syslog: bool = False + """\ + Send *Gunicorn* logs to syslog. + """ + + syslog_prefix: str | None = None + """\ + Makes Gunicorn use the parameter as program-name in the syslog entries. + + All entries will be prefixed by ``gunicorn.``. By default the + program name is the name of the process. + """ + + syslog_facility: str = "user" + """\ + Syslog facility name + """ + + enable_stdio_inheritance: bool = False + """\ + Enable stdio inheritance. + + Enable inheritance for stdio file descriptors in daemon mode. + + Note: To disable the Python stdout buffering, you can to set the user + environment variable ``PYTHONUNBUFFERED`` . + """ + + statsd_host: str | None = None + """\ + The address of the StatsD server to log to. + + Address is a string of the form: + + * ``unix://PATH`` : for a unix domain socket. + * ``HOST:PORT`` : for a network address + """ + + dogstatsd_tags: str = "" + """\ + A comma-delimited list of datadog statsd (dogstatsd) tags to append to + statsd metrics. + """ + + statsd_prefix: str = "" + """\ + Prefix to use when emitting statsd metrics (a trailing ``.`` is added, + if not provided). + """ + + proc_name: str | None = None + """\ + A base to use with setproctitle for process naming. + + This affects things like ``ps`` and ``top``. If you're going to be + running more than one instance of Gunicorn you'll probably want to set a + name to tell them apart. This requires that you install the setproctitle + module. + + If not set, the *default_proc_name* setting will be used. + """ + + default_proc_name: str = "gunicorn" + """\ + Internal setting that is adjusted for each type of application. + """ + + pythonpath: str | None = None + """\ + A comma-separated list of directories to add to the Python path. + + e.g. + ``'/home/djangoprojects/myproject,/home/python/mylibrary'``. + """ + + paste: str | None = None + """\ + Load a PasteDeploy config file. The argument may contain a ``#`` + symbol followed by the name of an app section from the config file, + e.g. ``production.ini#admin``. + + At this time, using alternate server blocks is not supported. Use the + command line arguments to control server configuration instead. + """ + + on_starting: Callable = lambda server: None + """\ + Called just before the master process is initialized. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + on_reload: Callable = lambda server: None + """\ + Called to recycle workers during a reload via SIGHUP. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + when_ready: Callable = lambda server: None + """\ + Called just after the server is started. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + pre_fork: Callable = lambda server, worker: None + """\ + Called just before a worker is forked. + + The callable needs to accept two instance variables for the Arbiter and + new Worker. + """ + + post_fork: Callable = lambda server, worker: None + """\ + Called just after a worker has been forked. + + The callable needs to accept two instance variables for the Arbiter and + new Worker. + """ + + post_worker_init: Callable = lambda worker: None + """\ + Called just after a worker has initialized the application. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + worker_int: Callable = lambda worker: None + """\ + Called just after a worker exited on SIGINT or SIGQUIT. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + worker_abort: Callable = lambda worker: None + """\ + Called when a worker received the SIGABRT signal. + + This call generally happens on timeout. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + pre_exec: Callable = lambda server: None + """\ + Called just before a new master process is forked. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + pre_request: Callable = lambda worker, req: worker.log.debug("%s %s", req.method, req.path) + """\ + Called just before a worker processes the request. + + The callable needs to accept two instance variables for the Worker and + the Request. + """ + + post_request: Callable = lambda worker, req, environ, resp: None + """\ + Called after a worker processes the request. + + The callable needs to accept two instance variables for the Worker and + the Request. + """ + + child_exit: Callable = lambda server, worker: None + """\ + Called just after a worker has been exited, in the master process. + + The callable needs to accept two instance variables for the Arbiter and + the just-exited Worker. + """ + + worker_exit: Callable = lambda server, worker: None + """\ + Called just after a worker has been exited, in the worker process. + + The callable needs to accept two instance variables for the Arbiter and + the just-exited Worker. + """ + + nworkers_changed: Callable = lambda server, new_value, old_value: None + """\ + Called just after *num_workers* has been changed. + + The callable needs to accept an instance variable of the Arbiter and + two integers of number of workers after and before change. + + If the number of workers is set for the first time, *old_value* would + be ``None``. + """ + + on_exit: Callable = lambda server: None + """\ + Called just before exiting Gunicorn. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + ssl_context: Callable = lambda config, default_ssl_context_factory: default_ssl_context_factory() + """\ + Called when SSLContext is needed. + + Allows customizing SSL context. + + The callable needs to accept an instance variable for the Config and + a factory function that returns default SSLContext which is initialized + with certificates, private key, cert_reqs, and ciphers according to + config and can be further customized by the callable. + The callable needs to return SSLContext object. + + Following example shows a configuration file that sets the minimum TLS version to 1.3: + + .. code-block:: python + + def ssl_context(conf, default_ssl_context_factory): + import ssl + context = default_ssl_context_factory() + context.minimum_version = ssl.TLSVersion.TLSv1_3 + return context + """ + + proxy_protocol: bool = False + """\ + Enable detect PROXY protocol (PROXY mode). + + Allow using HTTP and Proxy together. It may be useful for work with + stunnel as HTTPS frontend and Gunicorn as HTTP server. + + PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt + + Example for stunnel config:: + + [https] + protocol = proxy + accept = 443 + connect = 80 + cert = /etc/ssl/certs/stunnel.pem + key = /etc/ssl/certs/stunnel.key + """ + + proxy_allow_ips: str = "127.0.0.1,::1" + """\ + Front-end's IPs from which allowed accept proxy requests (comma separated). + + Set to ``*`` to disable checking of front-end IPs. This is useful for setups + where you don't know in advance the IP address of front-end, but + instead have ensured via other means that only your + authorized front-ends can access Gunicorn. + + .. note:: + + This option does not affect UNIX socket connections. Connections not associated with + an IP address are treated as allowed, unconditionally. + """ + + keyfile: str | None = None + """\ + SSL key file + """ + + certfile: str | None = None + """\ + SSL certificate file + """ + + ssl_version: int = ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + """\ + SSL version to use (see stdlib ssl module's). + + .. deprecated:: 21.0 + The option is deprecated and it is currently ignored. Use :ref:`ssl-context` instead. + + ============= ============ + --ssl-version Description + ============= ============ + SSLv3 SSLv3 is not-secure and is strongly discouraged. + SSLv23 Alias for TLS. Deprecated in Python 3.6, use TLS. + TLS Negotiate highest possible version between client/server. + Can yield SSL. (Python 3.6+) + TLSv1 TLS 1.0 + TLSv1_1 TLS 1.1 (Python 3.4+) + TLSv1_2 TLS 1.2 (Python 3.4+) + TLS_SERVER Auto-negotiate the highest protocol version like TLS, + but only support server-side SSLSocket connections. + (Python 3.6+) + ============= ============ + + .. versionchanged:: 19.7 + The default value has been changed from ``ssl.PROTOCOL_TLSv1`` to + ``ssl.PROTOCOL_SSLv23``. + .. versionchanged:: 20.0 + This setting now accepts string names based on ``ssl.PROTOCOL_`` + constants. + .. versionchanged:: 20.0.1 + The default value has been changed from ``ssl.PROTOCOL_SSLv23`` to + ``ssl.PROTOCOL_TLS`` when Python >= 3.6 . + """ + + cert_reqs: int = ssl.CERT_NONE + """\ + Whether client certificate is required (see stdlib ssl module's) + + =========== =========================== + --cert-reqs Description + =========== =========================== + `0` ``ssl.CERT_NONE`` + `1` ``ssl.CERT_OPTIONAL`` + `2` ``ssl.CERT_REQUIRED`` + =========== =========================== + """ + + ca_certs: str | None = None + """\ + CA certificates file + """ + + suppress_ragged_eofs: bool = True + """\ + Suppress ragged EOFs (see stdlib ssl module's) + """ + + do_handshake_on_connect: bool = False + """\ + Whether to perform SSL handshake on socket connect (see stdlib ssl module's) + """ + + ciphers: str | None = None + """\ + SSL Cipher suite to use, in the format of an OpenSSL cipher list. + + By default we use the default cipher list from Python's ``ssl`` module, + which contains ciphers considered strong at the time of each Python + release. + + As a recommended alternative, the Open Web App Security Project (OWASP) + offers `a vetted set of strong cipher strings rated A+ to C- + `_. + OWASP provides details on user-agent compatibility at each security level. + + See the `OpenSSL Cipher List Format Documentation + `_ + for details on the format of an OpenSSL cipher list. + """ + + raw_paste_global_conf: list[str] = [] + """\ + Set a PasteDeploy global config variable in ``key=value`` form. + + The option can be specified multiple times. + + The variables are passed to the PasteDeploy entrypoint. Example:: + + $ gunicorn -b 127.0.0.1:8000 --paste development.ini --paste-global FOO=1 --paste-global BAR=2 + """ + + permit_obsolete_folding: bool = False + """\ + Permit requests employing obsolete HTTP line folding mechanism + + The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be + employed in HTTP request headers from standards-compliant HTTP clients. + + This option is provided to diagnose backwards-incompatible changes. + Use with care and only if necessary. Temporary; the precise effect of this option may + change in a future version, or it may be removed altogether. + """ + + strip_header_spaces: bool = False + """\ + Strip spaces present between the header name and the the ``:``. + + This is known to induce vulnerabilities and is not compliant with the HTTP/1.1 standard. + See https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn. + + Use with care and only if necessary. Deprecated; scheduled for removal in 25.0.0 + """ + + permit_unconventional_http_method: bool = False + """\ + Permit HTTP methods not matching conventions, such as IANA registration guidelines + + This permits request methods of length less than 3 or more than 20, + methods with lowercase characters or methods containing the # character. + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + If unset, Gunicorn will apply nonstandard restrictions and cause 400 response status + in cases where otherwise 501 status is expected. While this option does modify that + behaviour, it should not be depended upon to guarantee standards-compliant behaviour. + Rather, it is provided temporarily, to assist in diagnosing backwards-incompatible + changes around the incomplete application of those restrictions. + + Use with care and only if necessary. Temporary; scheduled for removal in 24.0.0 + """ + + permit_unconventional_http_version: bool = False + """\ + Permit HTTP version not matching conventions of 2023 + + This disables the refusal of likely malformed request lines. + It is unusual to specify HTTP 1 versions other than 1.0 and 1.1. + + This option is provided to diagnose backwards-incompatible changes. + Use with care and only if necessary. Temporary; the precise effect of this option may + change in a future version, or it may be removed altogether. + """ + + casefold_http_method: bool = False + """\ + Transform received HTTP methods to uppercase + + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + This option is provided because previous versions of gunicorn defaulted to this behaviour. + + Use with care and only if necessary. Deprecated; scheduled for removal in 24.0.0 + """ + + forwarder_headers: str = "SCRIPT_NAME,PATH_INFO" + """\ + A list containing upper-case header field names that the front-end proxy + (see :ref:`forwarded-allow-ips`) sets, to be used in WSGI environment. + + This option has no effect for headers not present in the request. + + This option can be used to transfer ``SCRIPT_NAME``, ``PATH_INFO`` + and ``REMOTE_USER``. + + It is important that your front-end proxy configuration ensures that + the headers defined here can not be passed directly from the client. + """ + + header_map: Literal["drop", "refuse", "dangerous"] = "drop" + """\ + Configure how header field names are mapped into environ + + Headers containing underscores are permitted by RFC9110, + but gunicorn joining headers of different names into + the same environment variable will dangerously confuse applications as to which is which. + + The safe default ``drop`` is to silently drop headers that cannot be unambiguously mapped. + The value ``refuse`` will return an error if a request contains *any* such header. + The value ``dangerous`` matches the previous, not advisable, behaviour of mapping different + header field names into the same environ name. + + If the source is permitted as explained in :ref:`forwarded-allow-ips`, *and* the header name is + present in :ref:`forwarder-headers`, the header is mapped into environment regardless of + the state of this setting. + + Use with care and only if necessary and after considering if your problem could + instead be solved by specifically renaming or rewriting only the intended headers + on a proxy in front of Gunicorn. + """ + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + + def run_prod(self) -> list[str]: + print("[reflex.server.gunicorn::GunicornBackendServer] start") + command = ["gunicorn"] + + for key,field in self.get_fields().items(): + if key != "app": + value = self.__getattribute__(key) + if key == "preload": + print(_mapping_attr_to_cli.get(key, None), value, field.default) + if _mapping_attr_to_cli.get(key, None): + if value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + command.append(_mapping_attr_to_cli[key]) + else: + command += [_mapping_attr_to_cli[key], str(value)] + + print("[reflex.server.gunicorn::GunicornBackendServer] done") + return command + [f"reflex.app_module_for_backend:{constants.CompileVars.APP}()"] + + def run_dev(self): + StandaloneApplication( + app=self.app, + options=self.dict().items() + ).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py new file mode 100644 index 000000000..b76f12a3e --- /dev/null +++ b/reflex/server/uvicorn.py @@ -0,0 +1,10 @@ +from reflex.server.base import CustomBackendServer + + +class UvicornBackendServer(CustomBackendServer): + + def run_prod(self): + pass + + def run_dev(self): + pass diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 5291de095..4e72a550c 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -2,6 +2,9 @@ from __future__ import annotations +from abc import abstractmethod, ABCMeta +from typing import IO, Any, Literal, Sequence, Type + import hashlib import json import os @@ -9,12 +12,18 @@ import platform import re import subprocess import sys +import asyncio +import ssl +from configparser import RawConfigParser from pathlib import Path from urllib.parse import urljoin +from pydantic import Field +from dataclasses import dataclass import psutil -from reflex import constants +from reflex import constants, server +from reflex.base import Base from reflex.config import environment, get_config from reflex.constants.base import LogLevel from reflex.utils import console, path_ops @@ -194,6 +203,7 @@ def get_app_module(): The app module for the backend. """ return f"reflex.app_module_for_backend:{constants.CompileVars.APP}" +### REWORK <-- def get_granian_target(): @@ -314,65 +324,36 @@ def run_backend_prod( loglevel: The log level. frontend_present: Whether the frontend is present. """ + print("[reflex.utils.exec::run_backend_prod] start") if not frontend_present: notify_backend() + config = get_config() + if should_use_granian(): run_granian_backend_prod(host, port, loglevel) else: - run_uvicorn_backend_prod(host, port, loglevel) + from reflex.utils import processes + backend_server_prod = config.backend_server_prod + if isinstance(backend_server_prod, server.GunicornBackendServer): + backend_server_prod.app = f"{get_app_module()}()" + backend_server_prod.preload_app = True + backend_server_prod.loglevel = loglevel.value # type: ignore + backend_server_prod.bind = [f"{host}:{port}"] + backend_server_prod.threads = _get_backend_workers() + backend_server_prod.workers = _get_backend_workers() -def run_uvicorn_backend_prod(host, port, loglevel): - """Run the backend in production mode using Uvicorn. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - from reflex.utils import processes - - config = get_config() - - app_module = get_app_module() - - RUN_BACKEND_PROD = f"gunicorn --worker-class {config.gunicorn_worker_class} --max-requests {config.gunicorn_max_requests} --max-requests-jitter {config.gunicorn_max_requests_jitter} --preload --timeout {config.timeout} --log-level critical".split() - RUN_BACKEND_PROD_WINDOWS = f"uvicorn --limit-max-requests {config.gunicorn_max_requests} --timeout-keep-alive {config.timeout}".split() - command = ( - [ - *RUN_BACKEND_PROD_WINDOWS, - "--host", - host, - "--port", - str(port), - app_module, - ] - if constants.IS_WINDOWS - else [ - *RUN_BACKEND_PROD, - "--bind", - f"{host}:{port}", - "--threads", - str(_get_backend_workers()), - f"{app_module}()", - ] - ) - - command += [ - "--log-level", - loglevel.value, - "--workers", - str(_get_backend_workers()), - ] - processes.new_process( - command, - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) + print(backend_server_prod.run_prod()) + processes.new_process( + backend_server_prod.run_prod(), + run=True, + show_logs=True, + env={ + environment.REFLEX_SKIP_COMPILE.name: "true" + }, # skip compile for prod backend + ) + print("[reflex.utils.exec::run_backend_prod] done") def run_granian_backend_prod(host, port, loglevel): @@ -416,6 +397,7 @@ def run_granian_backend_prod(host, port, loglevel): ) +### REWORK--> def output_system_info(): """Show system information if the loglevel is in DEBUG.""" if console._LOG_LEVEL > constants.LogLevel.DEBUG: @@ -540,3 +522,6 @@ def should_skip_compile() -> bool: removal_version="0.7.0", ) return environment.REFLEX_SKIP_COMPILE.get() + + +### REWORK <-- From 93b070e0a3268371f00799cd470a97dc9bc177aa Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 16:29:16 +0000 Subject: [PATCH 10/25] add granian prod&dev, add gunicorn dev --- reflex/config.py | 53 ++++++-- reflex/server/__init__.py | 2 +- reflex/server/base.py | 85 +++++++++++- reflex/server/granian.py | 268 +++++++++++++++++++++++++++++++++++--- reflex/server/gunicorn.py | 186 +++++++++++++++++--------- reflex/server/uvicorn.py | 31 +++++ reflex/utils/exec.py | 202 ++++------------------------ 7 files changed, 556 insertions(+), 271 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index e0bd32484..c96540cee 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -684,15 +684,28 @@ class Config(Base): env_file: Optional[str] = None # Custom Backend Server - backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - app=f"reflex.app_module_for_backend:{constants.CompileVars.APP}.{constants.CompileVars.API}", - worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - max_requests=100, - max_requests_jitter=25, - preload_app=True, - timeout=120, + # backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( + # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + # max_requests=100, + # max_requests_jitter=25, + # timeout=120, + # ) + backend_server_prod: server.CustomBackendServer = server.GranianBackendServer( + threads=2, + workers=4, ) - backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer() + backend_server_dev: server.CustomBackendServer = server.GranianBackendServer( + threads=1, + workers=1, + ) + # backend_server_dev: server.CustomBackendServer = server.GunicornBackendServer( + # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + # max_requests=100, + # max_requests_jitter=25, + # timeout=120, + # threads=1, + # workers=1, + # ) def __init__(self, *args, **kwargs): """Initialize the config values. @@ -724,14 +737,26 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - - print("[reflex.config::Config] --") - for key in ("timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"): + + for key in ( + "timeout", + "gunicorn_worker_class", + "gunicorn_workers", + "gunicorn_max_requests", + "gunicorn_max_requests_jitter", + ): if isinstance(self.backend_server_prod, server.GunicornBackendServer): value = self.get_value(key) - if value != self.backend_server_prod.get_fields()[key.replace("gunicorn_", "")].default and value is not None: - setattr(self.backend_server_prod, key.replace("gunicorn_", ""), value) - print("[reflex.config::Config] done") + if ( + value + != self.backend_server_prod.get_fields()[ + key.replace("gunicorn_", "") + ].default + and value is not None + ): + setattr( + self.backend_server_prod, key.replace("gunicorn_", ""), value + ) @property def module(self) -> str: diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py index c44f2ca11..9633ddc7c 100644 --- a/reflex/server/__init__.py +++ b/reflex/server/__init__.py @@ -1,4 +1,4 @@ - +"""Import every *BackendServer.""" from .base import CustomBackendServer from .granian import GranianBackendServer diff --git a/reflex/server/base.py b/reflex/server/base.py index c327d5c89..c821c2686 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -1,14 +1,95 @@ -from abc import abstractmethod, ABCMeta +"""The base for CustomBackendServer.""" +from __future__ import annotations + +import os +from abc import abstractmethod +from pathlib import Path + +from reflex import constants from reflex.base import Base +from reflex.constants.base import Env, LogLevel class CustomBackendServer(Base): - + """BackendServer base.""" + + @staticmethod + def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): + """Get the app module for the backend. + + Returns: + The app module for the backend. + """ + import reflex + + if for_granian_target: + app_path = str(Path(reflex.__file__).parent / "app_module_for_backend.py") + else: + app_path = "reflex.app_module_for_backend" + + return f"{app_path}:{constants.CompileVars.APP}{f'.{constants.CompileVars.API}' if add_extra_api else ''}" + + def get_available_cpus(self) -> int: + """Get available cpus.""" + return os.cpu_count() or 1 + + def get_max_workers(self) -> int: + """Get max workers.""" + # https://docs.gunicorn.org/en/latest/settings.html#workers + return (os.cpu_count() or 1) * 4 + 1 + + def get_recommended_workers(self) -> int: + """Get recommended workers.""" + # https://docs.gunicorn.org/en/latest/settings.html#workers + return (os.cpu_count() or 1) * 2 + 1 + + def get_max_threads(self, wait_time_ms: int = 50, service_time_ms: int = 5) -> int: + """Get max threads.""" + # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html + # Brian Goetz formula + return int(self.get_available_cpus() * (1 + wait_time_ms / service_time_ms)) + + def get_recommended_threads( + self, + target_reqs: int | None = None, + wait_time_ms: int = 50, + service_time_ms: int = 5, + ) -> int: + """Get recommended threads.""" + # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html + max_available_threads = self.get_max_threads() + + if target_reqs: + # Little's law formula + need_threads = target_reqs * ( + (wait_time_ms / 1000) + (service_time_ms / 1000) + ) + else: + need_threads = self.get_max_threads(wait_time_ms, service_time_ms) + + return int( + max_available_threads + if need_threads > max_available_threads + else need_threads + ) + + @abstractmethod + def check_import(self, extra: bool = False): + """Check package importation.""" + raise NotImplementedError() + + @abstractmethod + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + raise NotImplementedError() + @abstractmethod def run_prod(self): + """Run in production mode.""" raise NotImplementedError() @abstractmethod def run_dev(self): + """Run in development mode.""" raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 7a2fde844..fda03912c 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -1,36 +1,272 @@ +"""The GranianBackendServer.""" +from __future__ import annotations + +import sys from dataclasses import dataclass +from dataclasses import field as dc_field +from pathlib import Path +from typing import Any, Literal, Type +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer +from reflex.utils import console + @dataclass -class HTTP1Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 - keep_alive: bool = True - max_buffer_size: int = 8192 + 4096 * 100 - pipeline_flush: bool = False +class HTTP1Settings: + """Granian HTTP1Settings.""" + + # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 + keep_alive: bool = dc_field(default=True) + max_buffer_size: int = dc_field(default=8192 + 4096 * 100) + pipeline_flush: bool = dc_field(default=False) @dataclass class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L13 - adaptive_window: bool = False - initial_connection_window_size: int = 1024 * 1024 - initial_stream_window_size: int = 1024 * 1024 - keep_alive_interval: int | None = None - keep_alive_timeout: int = 20 - max_concurrent_streams: int = 200 - max_frame_size: int = 1024 * 16 - max_headers_size: int = 16 * 1024 * 1024 - max_send_buffer_size: int = 1024 * 400 + """Granian HTTP2Settings.""" + + adaptive_window: bool = dc_field(default=False) + initial_connection_window_size: int = dc_field(default=1024 * 1024) + initial_stream_window_size: int = dc_field(default=1024 * 1024) + keep_alive_interval: int | None = dc_field(default=None) + keep_alive_timeout: int = dc_field(default=20) + max_concurrent_streams: int = dc_field(default=200) + max_frame_size: int = dc_field(default=1024 * 16) + max_headers_size: int = dc_field(default=16 * 1024 * 1024) + max_send_buffer_size: int = dc_field(default=1024 * 400) + try: - import watchfiles + import watchfiles # type: ignore except ImportError: watchfiles = None +_mapping_attr_to_cli: dict[str, str] = { + "address": "--host", + "port": "--port", + "interface": "--interface", + "http": "--http", + "websockets": "--ws", # NOTE: when `websockets` True: `--ws`; False: `--no-ws` + "workers": "--workers", + "threads": "--threads", + "blocking_threads": "--blocking-threads", + "threading_mode": "--threading-mode", + "loop": "--loop", + "loop_opt": "--opt", # NOTE: when `loop_opt` True: `--opt`; False: `--no-opt` + "backlog": "--backlog", + "backpressure": "--backpressure", + "http1_keep_alive": "--http1-keep-alive", + "http1_max_buffer_size": "--http1-max-buffer-size", + "http1_pipeline_flush": "--http1-pipeline-flush", + "http2_adaptive_window": "--http2-adaptive-window", + "http2_initial_connection_window_size": "--http2-initial-connection-window-size", + "http2_initial_stream_window_size": "--http2-initial-stream-window-size", + "http2_keep_alive_interval": "--http2-keep-alive-interval", + "http2_keep_alive_timeout": "--http2-keep-alive-timeout", + "http2_max_concurrent_streams": "--http2-max-concurrent-streams", + "http2_max_frame_size": "--http2-max-frame-size", + "http2_max_headers_size": "--http2-max-headers-size", + "http2_max_send_buffer_size": "--http2-max-send-buffer-size", + "log_enabled": "--log", # NOTE: when `log_enabled` True: `--log`; False: `--no-log` + "log_level": "--log-level", + "log_access": "--log-access", # NOTE: when `log_access` True: `--log-access`; False: `--no-log-access` + "log_access_format": "--access-log-fmt", + "ssl_cert": "--ssl-certificate", + "ssl_key": "--ssl-keyfile", + "ssl_key_password": "--ssl-keyfile-password", + "url_path_prefix": "--url-path-prefix", + "respawn_failed_workers": "--respawn-failed-workers", # NOTE: when `respawn_failed_workers` True: `--respawn-failed-workers`; False: `--no-respawn-failed-workers` + "respawn_interval": "--respawn-interval", + "workers_lifetime": "--workers-lifetime", + "factory": "--factory", # NOTE: when `factory` True: `--factory`; False: `--no-factory` + "reload": "--reload", # NOTE: when `reload` True: `--reload`; False: `--no-reload` + "reload_paths": "--reload-paths", + "reload_ignore_dirs": "--reload-ignore-dirs", + "reload_ignore_patterns": "--reload-ignore-patterns", + "reload_ignore_paths": "--reload-ignore-paths", + "process_name": "--process-name", + "pid_file": "--pid-file", +} + + class GranianBackendServer(CustomBackendServer): + """Granian backendServer.""" + + # https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/server.py#L69 + + target: str | None = None + address: str = "127.0.0.1" + port: int = 8000 + interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = "rsgi" + workers: int = 0 + threads: int = 0 + blocking_threads: int | None = None + threading_mode: Literal["runtime", "workers"] = "workers" + loop: Literal["auto", "asyncio", "uvloop"] = "auto" + loop_opt: bool = False + http: Literal["auto", "1", "2"] = "auto" + websockets: bool = True + backlog: int = 1024 + backpressure: int | None = None + + # http1_settings: HTTP1Settings | None = None + # NOTE: child of http1_settings, needed only for cli mode + http1_keep_alive: bool = HTTP1Settings.keep_alive + http1_max_buffer_size: int = HTTP1Settings.max_buffer_size + http1_pipeline_flush: bool = HTTP1Settings.pipeline_flush + + # http2_settings: HTTP2Settings | None = None + # NOTE: child of http2_settings, needed only for cli mode + http2_adaptive_window: bool = HTTP2Settings.adaptive_window + http2_initial_connection_window_size: int = ( + HTTP2Settings.initial_connection_window_size + ) + http2_initial_stream_window_size: int = HTTP2Settings.initial_stream_window_size + http2_keep_alive_interval: int | None = HTTP2Settings.keep_alive_interval + http2_keep_alive_timeout: int = HTTP2Settings.keep_alive_timeout + http2_max_concurrent_streams: int = HTTP2Settings.max_concurrent_streams + http2_max_frame_size: int = HTTP2Settings.max_frame_size + http2_max_headers_size: int = HTTP2Settings.max_headers_size + http2_max_send_buffer_size: int = HTTP2Settings.max_send_buffer_size + + log_enabled: bool = True + log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = "info" + log_dictconfig: dict[str, Any] | None = None + log_access: bool = False + log_access_format: str | None = None + ssl_cert: Path | None = None + ssl_key: Path | None = None + ssl_key_password: str | None = None + url_path_prefix: str | None = None + respawn_failed_workers: bool = False + respawn_interval: float = 3.5 + workers_lifetime: int | None = None + factory: bool = False + reload: bool = False + reload_paths: list[Path] | None = None + reload_ignore_dirs: list[str] | None = None + reload_ignore_patterns: list[str] | None = None + reload_ignore_paths: list[Path] | None = None + reload_filter: Type[getattr(watchfiles, "BaseFilter", None)] | None = None # type: ignore + process_name: str | None = None + pid_file: Path | None = None + + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("granian") is None: + errors.append( + 'The `granian` package is required to run `GranianBackendServer`. Run `pip install "granian>=1.6.0"`.' + ) + + if find_spec("watchfiles") is None and extra: + # NOTE: the `\[` is for force `rich.Console` to not consider it like a color or anything else which he not printing `[.*]` + errors.append( + r'Using --reload in `GranianBackendServer` requires the granian\[reload] extra. Run `pip install "granian\[reload]>=1.6.0"`.' + ) # type: ignore + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + self.target = self.get_app_module(for_granian_target=True, add_extra_api=True) + self.log_level = loglevel.value # type: ignore + self.address = host + self.port = port + self.interface = "asgi" # prevent obvious error + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_threads := self.get_max_workers()): + self.workers = max_threads + + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads + + if env == Env.DEV: + from reflex.config import get_config # prevent circular import + + self.reload = True + self.reload_paths = [Path(get_config().app_name)] + self.reload_ignore_dirs = [".web"] def run_prod(self): - pass + """Run in production mode.""" + self.check_import() + command = ["granian"] + + for key, field in self.get_fields().items(): + if key != "target": + value = getattr(self, key) + if _mapping_attr_to_cli.get(key) and value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + command.append( + f"--{'no-' if value is False else ''}{_mapping_attr_to_cli[key][2:]}" + ) + else: + command += [_mapping_attr_to_cli[key], str(value)] + + return command + [ + self.get_app_module(for_granian_target=True, add_extra_api=True) + ] def run_dev(self): - pass + """Run in development mode.""" + self.check_import(extra=self.reload) + from granian import Granian + + exclude_keys = ( + "http1_keep_alive", + "http1_max_buffer_size", + "http1_pipeline_flush", + "http2_adaptive_window", + "http2_initial_connection_window_size", + "http2_initial_stream_window_size", + "http2_keep_alive_interval", + "http2_keep_alive_timeout", + "http2_max_concurrent_streams", + "http2_max_frame_size", + "http2_max_headers_size", + "http2_max_send_buffer_size", + ) + model = self.get_fields() + Granian( + **{ + **{ + key: value + for key, value in self.dict().items() + if key not in exclude_keys and value != model[key].default + }, + "http1_settings": HTTP1Settings( + self.http1_keep_alive, + self.http1_max_buffer_size, + self.http1_pipeline_flush, + ), + "http2_settings": HTTP2Settings( + self.http2_adaptive_window, + self.http2_initial_connection_window_size, + self.http2_initial_stream_window_size, + self.http2_keep_alive_interval, + self.http2_keep_alive_timeout, + self.http2_max_concurrent_streams, + self.http2_max_frame_size, + self.http2_max_headers_size, + self.http2_max_send_buffer_size, + ), + } + ).serve() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 18dfb7841..659cb5262 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -1,34 +1,15 @@ -from typing import Any, Literal, Callable +"""The GunicornBackendServer.""" + +from __future__ import annotations import os -import sys import ssl -from pydantic import Field +import sys +from typing import Any, Callable, Literal -from gunicorn.app.base import BaseApplication - -import psutil - -from reflex import constants +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer - - -class StandaloneApplication(BaseApplication): - - def __init__(self, app, options=None): - self.options = options or {} - self.application = app - super().__init__() - - def load_config(self): - config = {key: value for key, value in self.options.items() - if key in self.cfg.settings and value is not None} # type: ignore - for key, value in config.items(): - self.cfg.set(key.lower(), value) # type: ignore - - def load(self): - return self.application - +from reflex.utils import console _mapping_attr_to_cli: dict[str, str] = { "config": "--config", @@ -105,10 +86,13 @@ _mapping_attr_to_cli: dict[str, str] = { "header_map": "--header-map", } + class GunicornBackendServer(CustomBackendServer): + """Gunicorn backendServer.""" + # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - app: str + app_uri: str | None config: str = "./gunicorn.conf.py" """\ @@ -128,7 +112,7 @@ class GunicornBackendServer(CustomBackendServer): A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. """ - bind: list[str] = ['127.0.0.1:8000'] + bind: list[str] = ["127.0.0.1:8000"] """\ The socket to bind. @@ -162,7 +146,7 @@ class GunicornBackendServer(CustomBackendServer): Must be a positive integer. Generally set in the 64-2048 range. """ - workers: int = 1 + workers: int = 0 """\ The number of worker processes for handling requests. @@ -175,7 +159,14 @@ class GunicornBackendServer(CustomBackendServer): it is not defined, the default is ``1``. """ - worker_class: Literal["sync", "eventlet", "gevent", "tornado", "gthread", "uvicorn.workers.UvicornH11Worker"] = "sync" + worker_class: Literal[ + "sync", + "eventlet", + "gevent", + "tornado", + "gthread", + "uvicorn.workers.UvicornH11Worker", + ] = "sync" """\ The type of workers to use. @@ -202,7 +193,7 @@ class GunicornBackendServer(CustomBackendServer): ``gunicorn.workers.ggevent.GeventWorker``. """ - threads: int = 1 + threads: int = 0 """\ The number of worker threads for handling requests. @@ -493,7 +484,11 @@ class GunicornBackendServer(CustomBackendServer): temporary directory. """ - secure_scheme_headers: dict[str, Any] = {'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'} + secure_scheme_headers: dict[str, Any] = { + "X-FORWARDED-PROTOCOL": "ssl", + "X-FORWARDED-PROTO": "https", + "X-FORWARDED-SSL": "on", + } """\ A dictionary containing headers and values that the front-end proxy uses to indicate HTTPS requests. If the source IP is permitted by @@ -588,7 +583,9 @@ class GunicornBackendServer(CustomBackendServer): Disable redirect access logs to syslog. """ - access_log_format: str = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + access_log_format: str = ( + '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + ) """\ The access log format. @@ -686,7 +683,11 @@ class GunicornBackendServer(CustomBackendServer): if sys.platform == "darwin" else ( "unix:///var/run/log" - if sys.platform in ('freebsd', 'dragonfly', ) + if sys.platform + in ( + "freebsd", + "dragonfly", + ) else ( "unix:///dev/log" if sys.platform == "openbsd" @@ -858,7 +859,9 @@ class GunicornBackendServer(CustomBackendServer): The callable needs to accept a single instance variable for the Arbiter. """ - pre_request: Callable = lambda worker, req: worker.log.debug("%s %s", req.method, req.path) + pre_request: Callable = lambda worker, req: worker.log.debug( + "%s %s", req.method, req.path + ) """\ Called just before a worker processes the request. @@ -908,7 +911,9 @@ class GunicornBackendServer(CustomBackendServer): The callable needs to accept a single instance variable for the Arbiter. """ - ssl_context: Callable = lambda config, default_ssl_context_factory: default_ssl_context_factory() + ssl_context: Callable = ( + lambda config, default_ssl_context_factory: default_ssl_context_factory() + ) """\ Called when SSLContext is needed. @@ -975,7 +980,9 @@ class GunicornBackendServer(CustomBackendServer): SSL certificate file """ - ssl_version: int = ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + ssl_version: int = ( + ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + ) """\ SSL version to use (see stdlib ssl module's). @@ -1163,33 +1170,96 @@ class GunicornBackendServer(CustomBackendServer): on a proxy in front of Gunicorn. """ - # def __init__(self, *args, **kwargs): - # super().__init__(*args, **kwargs) + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("gunicorn") is None: + errors.append( + 'The `gunicorn` package is required to run `GunicornBackendServer`. Run `pip install "gunicorn>=20.1.0"`.' + ) + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + self.app_uri = f"{self.get_app_module()}()" + self.loglevel = loglevel.value # type: ignore + self.bind = [f"{host}:{port}"] + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_threads := self.get_max_workers()): + self.workers = max_threads + + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads + self.preload_app = True + + if env == Env.DEV: + self.reload = True def run_prod(self) -> list[str]: - print("[reflex.server.gunicorn::GunicornBackendServer] start") + """Run in production mode.""" + self.check_import() + command = ["gunicorn"] - for key,field in self.get_fields().items(): + for key, field in self.get_fields().items(): if key != "app": - value = self.__getattribute__(key) - if key == "preload": - print(_mapping_attr_to_cli.get(key, None), value, field.default) - if _mapping_attr_to_cli.get(key, None): - if value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): + value = getattr(self, key) + if _mapping_attr_to_cli.get(key) and value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + if (key == "sendfile" and value is False) or ( + key != "sendfile" and value + ): command.append(_mapping_attr_to_cli[key]) - else: - command += [_mapping_attr_to_cli[key], str(value)] + else: + command += [_mapping_attr_to_cli[key], str(value)] - print("[reflex.server.gunicorn::GunicornBackendServer] done") - return command + [f"reflex.app_module_for_backend:{constants.CompileVars.APP}()"] + return command + [f"{self.get_app_module()}()"] def run_dev(self): - StandaloneApplication( - app=self.app, - options=self.dict().items() - ).run() + """Run in development mode.""" + self.check_import() + console.info( + "For development mode, we recommand to use `UvicornBackendServer` than `GunicornBackendServer`" + ) + + from gunicorn.app.base import BaseApplication + from gunicorn.util import import_app as gunicorn_import_app + + options_ = self.dict() + options_.pop("app", None) + + class StandaloneApplication(BaseApplication): + def __init__(self, app_uri, options=None): + self.options = options or {} + self.app_uri = app_uri + super().__init__() + + def load_config(self): + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } # type: ignore + for key, value in config.items(): + self.cfg.set(key.lower(), value) # type: ignore + + def load(self): + return gunicorn_import_app(self.app_uri) + + StandaloneApplication(app_uri=self.app_uri, options=options_).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index b76f12a3e..cb5131c4a 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -1,10 +1,41 @@ +"""The UvicornBackendServer.""" + +from __future__ import annotations + +import sys + +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer +from reflex.utils import console +# TODO class UvicornBackendServer(CustomBackendServer): + """Uvicorn backendServer.""" + + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("uvicorn") is None: + errors.append( + 'The `uvicorn` package is required to run `UvicornBackendServer`. Run `pip install "uvicorn>=0.20.0"`.' + ) + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + pass def run_prod(self): + """Run in production mode.""" pass def run_dev(self): + """Run in development mode.""" pass diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 4e72a550c..d04ca60d8 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -2,9 +2,6 @@ from __future__ import annotations -from abc import abstractmethod, ABCMeta -from typing import IO, Any, Literal, Sequence, Type - import hashlib import json import os @@ -12,20 +9,14 @@ import platform import re import subprocess import sys -import asyncio -import ssl -from configparser import RawConfigParser from pathlib import Path from urllib.parse import urljoin -from pydantic import Field -from dataclasses import dataclass import psutil -from reflex import constants, server -from reflex.base import Base +from reflex import constants from reflex.config import environment, get_config -from reflex.constants.base import LogLevel +from reflex.constants.base import Env, LogLevel from reflex.utils import console, path_ops from reflex.utils.prerequisites import get_web_dir @@ -187,42 +178,11 @@ def run_frontend_prod(root: Path, port: str, backend_present=True): ) -def should_use_granian(): - """Whether to use Granian for backend. - - Returns: - True if Granian should be used. - """ - return environment.REFLEX_USE_GRANIAN.get() - - -def get_app_module(): - """Get the app module for the backend. - - Returns: - The app module for the backend. - """ - return f"reflex.app_module_for_backend:{constants.CompileVars.APP}" ### REWORK <-- - - -def get_granian_target(): - """Get the Granian target for the backend. - - Returns: - The Granian target for the backend. - """ - import reflex - - app_module_path = Path(reflex.__file__).parent / "app_module_for_backend.py" - - return f"{str(app_module_path)}:{constants.CompileVars.APP}.{constants.CompileVars.API}" - - def run_backend( host: str, port: int, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, + loglevel: LogLevel = LogLevel.ERROR, frontend_present: bool = False, ): """Run the backend. @@ -234,6 +194,7 @@ def run_backend( frontend_present: Whether the frontend is present. """ web_dir = get_web_dir() + config = get_config() # Create a .nocompile file to skip compile for backend. if web_dir.exists(): (web_dir / constants.NOCOMPILE_FILE).touch() @@ -242,78 +203,15 @@ def run_backend( notify_backend() # Run the backend in development mode. - if should_use_granian(): - run_granian_backend(host, port, loglevel) - else: - run_uvicorn_backend(host, port, loglevel) - - -def run_uvicorn_backend(host, port, loglevel: LogLevel): - """Run the backend in development mode using Uvicorn. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - import uvicorn - - uvicorn.run( - app=f"{get_app_module()}.{constants.CompileVars.API}", - host=host, - port=port, - log_level=loglevel.value, - reload=True, - reload_dirs=[get_config().app_name], - ) - - -def run_granian_backend(host, port, loglevel: LogLevel): - """Run the backend in development mode using Granian. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - console.debug("Using Granian for backend") - try: - from granian import Granian # type: ignore - from granian.constants import Interfaces # type: ignore - from granian.log import LogLevels # type: ignore - - Granian( - target=get_granian_target(), - address=host, - port=port, - interface=Interfaces.ASGI, - log_level=LogLevels(loglevel.value), - reload=True, - reload_paths=[Path(get_config().app_name)], - reload_ignore_dirs=[".web"], - ).serve() - except ImportError: - console.error( - 'InstallError: REFLEX_USE_GRANIAN is set but `granian` is not installed. (run `pip install "granian[reload]>=1.6.0"`)' - ) - os._exit(1) - - -def _get_backend_workers(): - from reflex.utils import processes - - config = get_config() - return ( - processes.get_num_workers() - if not config.gunicorn_workers - else config.gunicorn_workers - ) + backend_server_prod = config.backend_server_prod + backend_server_prod.setup(host, port, loglevel, Env.DEV) + backend_server_prod.run_dev() def run_backend_prod( host: str, port: int, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, + loglevel: LogLevel = LogLevel.ERROR, frontend_present: bool = False, ): """Run the backend. @@ -324,77 +222,24 @@ def run_backend_prod( loglevel: The log level. frontend_present: Whether the frontend is present. """ - print("[reflex.utils.exec::run_backend_prod] start") - if not frontend_present: - notify_backend() + from reflex.utils import processes config = get_config() - if should_use_granian(): - run_granian_backend_prod(host, port, loglevel) - else: - from reflex.utils import processes + if not frontend_present: + notify_backend() - backend_server_prod = config.backend_server_prod - if isinstance(backend_server_prod, server.GunicornBackendServer): - backend_server_prod.app = f"{get_app_module()}()" - backend_server_prod.preload_app = True - backend_server_prod.loglevel = loglevel.value # type: ignore - backend_server_prod.bind = [f"{host}:{port}"] - backend_server_prod.threads = _get_backend_workers() - backend_server_prod.workers = _get_backend_workers() - - print(backend_server_prod.run_prod()) - processes.new_process( - backend_server_prod.run_prod(), - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) - print("[reflex.utils.exec::run_backend_prod] done") - - -def run_granian_backend_prod(host, port, loglevel): - """Run the backend in production mode using Granian. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - from reflex.utils import processes - - try: - from granian.constants import Interfaces # type: ignore - - command = [ - "granian", - "--workers", - str(_get_backend_workers()), - "--log-level", - "critical", - "--host", - host, - "--port", - str(port), - "--interface", - str(Interfaces.ASGI), - get_granian_target(), - ] - processes.new_process( - command, - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) - except ImportError: - console.error( - 'InstallError: REFLEX_USE_GRANIAN is set but `granian` is not installed. (run `pip install "granian[reload]>=1.6.0"`)' - ) + # Run the backend in production mode. + backend_server_prod = config.backend_server_prod + backend_server_prod.setup(host, port, loglevel, Env.PROD) + processes.new_process( + backend_server_prod.run_prod(), + run=True, + show_logs=True, + env={ + environment.REFLEX_SKIP_COMPILE.name: "true" + }, # skip compile for prod backend + ) ### REWORK--> @@ -522,6 +367,3 @@ def should_skip_compile() -> bool: removal_version="0.7.0", ) return environment.REFLEX_SKIP_COMPILE.get() - - -### REWORK <-- From 9e0a504bbc1d34c3d39d42a0921ae13a806774a9 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 16:37:28 +0000 Subject: [PATCH 11/25] gunicorn `app_uri` are optional --- reflex/server/gunicorn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 659cb5262..c0bfb3b50 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -92,7 +92,7 @@ class GunicornBackendServer(CustomBackendServer): # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - app_uri: str | None + app_uri: str | None = None config: str = "./gunicorn.conf.py" """\ From 06d31a2884059818a846e289266cdf43c522b90e Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 9 Nov 2024 21:02:39 +0000 Subject: [PATCH 12/25] drop pydantic for dataclass & add uvicorn prod/dev --- reflex/config.py | 20 +- reflex/server/__init__.py | 7 + reflex/server/base.py | 178 ++++- reflex/server/granian.py | 310 +++++---- reflex/server/gunicorn.py | 1372 ++++++++----------------------------- reflex/server/uvicorn.py | 228 +++++- reflex/utils/exec.py | 6 +- 7 files changed, 857 insertions(+), 1264 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index c96540cee..1316fd2f1 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -684,28 +684,13 @@ class Config(Base): env_file: Optional[str] = None # Custom Backend Server - # backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - # max_requests=100, - # max_requests_jitter=25, - # timeout=120, - # ) - backend_server_prod: server.CustomBackendServer = server.GranianBackendServer( + backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( threads=2, workers=4, ) - backend_server_dev: server.CustomBackendServer = server.GranianBackendServer( - threads=1, + backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, ) - # backend_server_dev: server.CustomBackendServer = server.GunicornBackendServer( - # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - # max_requests=100, - # max_requests_jitter=25, - # timeout=120, - # threads=1, - # workers=1, - # ) def __init__(self, *args, **kwargs): """Initialize the config values. @@ -717,7 +702,6 @@ class Config(Base): Raises: ConfigError: If some values in the config are invalid. """ - print("[reflex.config::Config] start") super().__init__(*args, **kwargs) # Update the config from environment variables. diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py index 9633ddc7c..b39dc8049 100644 --- a/reflex/server/__init__.py +++ b/reflex/server/__init__.py @@ -4,3 +4,10 @@ from .base import CustomBackendServer from .granian import GranianBackendServer from .gunicorn import GunicornBackendServer from .uvicorn import UvicornBackendServer + +__all__ = [ + "CustomBackendServer", + "GranianBackendServer", + "GunicornBackendServer", + "UvicornBackendServer", +] diff --git a/reflex/server/base.py b/reflex/server/base.py index c821c2686..012511233 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -4,16 +4,160 @@ from __future__ import annotations import os from abc import abstractmethod +from dataclasses import Field, dataclass +from dataclasses import field as dc_field from pathlib import Path +from typing import Any, Callable, Sequence from reflex import constants -from reflex.base import Base from reflex.constants.base import Env, LogLevel +ReturnCliTypeFn = Callable[[Any], str] -class CustomBackendServer(Base): + +class CliType: + """Cli type transformer.""" + + @staticmethod + def default(fmt: str) -> ReturnCliTypeFn: + """Default cli transformer. + + Example: + fmt: `'--env-file {value}'` + value: `'/config.conf'` + result => `'--env-file /config.conf'` + """ + + def wrapper(value: bool) -> str: + return fmt.format(value=value) + + return wrapper + + @staticmethod + def boolean(fmt: str, bool_value: bool = True) -> ReturnCliTypeFn: + """When cli mode args only show when we want to activate it. + + Example: + fmt: `'--reload'` + value: `False` + result => `''` + + Example: + fmt: `'--reload'` + value: `True` + result => `'--reload'` + """ + + def wrapper(value: bool) -> str: + return fmt if value is bool_value else "" + + return wrapper + + @staticmethod + def boolean_toggle( + fmt: str, + toggle_kw: str = "no", + toggle_sep: str = "-", + toggle_value: bool = False, + **kwargs, + ) -> ReturnCliTypeFn: + """When the cli mode is a boolean toggle `--access-log`/`--no-access-log`. + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `False` + toggle_value: `False` (default) + result => `'--no-access-log'` + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `True` + toggle_value: `False` (default) + result => `'--access-log'` + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `True` + toggle_value: `True` + result => `'--no-access-log'` + """ + + def wrapper(value: bool) -> str: + return fmt.format( + **kwargs, + toggle_kw=(toggle_kw if value is toggle_value else ""), + toggle_sep=(toggle_sep if value is toggle_value else ""), + ) + + return wrapper + + @staticmethod + def multiple( + fmt: str, + join_sep: str | None = None, + value_transformer: Callable[[Any], str] = lambda value: str(value), + ) -> ReturnCliTypeFn: + r"""When the cli mode need multiple args or single args from an sequence. + + Example (Multiple args mode): + fmt: `'--header {value}'`. + data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + result => `'--header \"X-Forwarded-Proto=https\" --header \"X-Forwarded-For=0.0.0.0\"'` + + Example (Single args mode): + fmt: `--headers {values}` + data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + join_sep (required): `';'` + result => `--headers \"X-Forwarded-Proto=https;X-Forwarded-For=0.0.0.0\"` + + Example (Single args mode): + fmt: `--headers {values}` + data_list: `[('X-Forwarded-Proto', 'https'), ('X-Forwarded-For', '0.0.0.0')]` + join_sep (required): `';'` + value_transformer: `lambda value: f'{value[0]}:{value[1]}'` + result => `--headers \"X-Forwarded-Proto:https;X-Forwarded-For:0.0.0.0\"` + """ + + def wrapper(values: Sequence[str]) -> str: + return ( + fmt.format( + values=join_sep.join(value_transformer(value) for value in values) + ) + if join_sep + else " ".join( + [fmt.format(value=value_transformer(value)) for value in values] + ) + ) + + return wrapper + + +def field_( + *, + default: Any = None, + metadata_cli: ReturnCliTypeFn | None = None, + exclude: bool = False, + **kwargs, +): + """Custom dataclass field builder.""" + params_ = { + "default": default, + "metadata": {"cli": metadata_cli, "exclude": exclude}, + **kwargs, + } + + if kwargs.get("default_factory", False): + params_.pop("default", None) + + return dc_field(**params_) + + +@dataclass +class CustomBackendServer: """BackendServer base.""" + _app_uri: str = field_(default="", metadata_cli=None, exclude=True) + @staticmethod def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): """Get the app module for the backend. @@ -74,8 +218,36 @@ class CustomBackendServer(Base): else need_threads ) + def get_fields(self) -> dict[str, Field]: + """Return all the fields.""" + return self.__dataclass_fields__ + + def get_values(self) -> dict[str, Any]: + """Return all values.""" + return { + key: getattr(self, key) + for key, field in self.__dataclass_fields__.items() + if field.metadata["exclude"] is False + } + + def is_default_value(self, key, value: Any | None = None) -> bool: + """Check if the `value` is the same value from default context.""" + from dataclasses import MISSING + + field = self.get_fields()[key] + if value is None: + value = getattr(self, key, None) + + if field.default != MISSING: + return value == field.default + else: + if field.default_factory != MISSING: + return value == field.default_factory() + + return False + @abstractmethod - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index fda03912c..2a76d8c53 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -6,10 +6,10 @@ import sys from dataclasses import dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Literal, Type +from typing import Any, Literal from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console @@ -38,122 +38,160 @@ class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba max_send_buffer_size: int = dc_field(default=1024 * 400) -try: - import watchfiles # type: ignore -except ImportError: - watchfiles = None - -_mapping_attr_to_cli: dict[str, str] = { - "address": "--host", - "port": "--port", - "interface": "--interface", - "http": "--http", - "websockets": "--ws", # NOTE: when `websockets` True: `--ws`; False: `--no-ws` - "workers": "--workers", - "threads": "--threads", - "blocking_threads": "--blocking-threads", - "threading_mode": "--threading-mode", - "loop": "--loop", - "loop_opt": "--opt", # NOTE: when `loop_opt` True: `--opt`; False: `--no-opt` - "backlog": "--backlog", - "backpressure": "--backpressure", - "http1_keep_alive": "--http1-keep-alive", - "http1_max_buffer_size": "--http1-max-buffer-size", - "http1_pipeline_flush": "--http1-pipeline-flush", - "http2_adaptive_window": "--http2-adaptive-window", - "http2_initial_connection_window_size": "--http2-initial-connection-window-size", - "http2_initial_stream_window_size": "--http2-initial-stream-window-size", - "http2_keep_alive_interval": "--http2-keep-alive-interval", - "http2_keep_alive_timeout": "--http2-keep-alive-timeout", - "http2_max_concurrent_streams": "--http2-max-concurrent-streams", - "http2_max_frame_size": "--http2-max-frame-size", - "http2_max_headers_size": "--http2-max-headers-size", - "http2_max_send_buffer_size": "--http2-max-send-buffer-size", - "log_enabled": "--log", # NOTE: when `log_enabled` True: `--log`; False: `--no-log` - "log_level": "--log-level", - "log_access": "--log-access", # NOTE: when `log_access` True: `--log-access`; False: `--no-log-access` - "log_access_format": "--access-log-fmt", - "ssl_cert": "--ssl-certificate", - "ssl_key": "--ssl-keyfile", - "ssl_key_password": "--ssl-keyfile-password", - "url_path_prefix": "--url-path-prefix", - "respawn_failed_workers": "--respawn-failed-workers", # NOTE: when `respawn_failed_workers` True: `--respawn-failed-workers`; False: `--no-respawn-failed-workers` - "respawn_interval": "--respawn-interval", - "workers_lifetime": "--workers-lifetime", - "factory": "--factory", # NOTE: when `factory` True: `--factory`; False: `--no-factory` - "reload": "--reload", # NOTE: when `reload` True: `--reload`; False: `--no-reload` - "reload_paths": "--reload-paths", - "reload_ignore_dirs": "--reload-ignore-dirs", - "reload_ignore_patterns": "--reload-ignore-patterns", - "reload_ignore_paths": "--reload-ignore-paths", - "process_name": "--process-name", - "pid_file": "--pid-file", -} - - +@dataclass class GranianBackendServer(CustomBackendServer): - """Granian backendServer.""" + """Granian backendServer. - # https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/server.py#L69 + https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/cli.py#L52 (until Granian has the proper documentation) - target: str | None = None - address: str = "127.0.0.1" - port: int = 8000 - interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = "rsgi" - workers: int = 0 - threads: int = 0 - blocking_threads: int | None = None - threading_mode: Literal["runtime", "workers"] = "workers" - loop: Literal["auto", "asyncio", "uvloop"] = "auto" - loop_opt: bool = False - http: Literal["auto", "1", "2"] = "auto" - websockets: bool = True - backlog: int = 1024 - backpressure: int | None = None + """ - # http1_settings: HTTP1Settings | None = None - # NOTE: child of http1_settings, needed only for cli mode - http1_keep_alive: bool = HTTP1Settings.keep_alive - http1_max_buffer_size: int = HTTP1Settings.max_buffer_size - http1_pipeline_flush: bool = HTTP1Settings.pipeline_flush - - # http2_settings: HTTP2Settings | None = None - # NOTE: child of http2_settings, needed only for cli mode - http2_adaptive_window: bool = HTTP2Settings.adaptive_window - http2_initial_connection_window_size: int = ( - HTTP2Settings.initial_connection_window_size + address: str = field_( + default="127.0.0.1", metadata_cli=CliType.default("--host {value}") + ) + port: int = field_(default=8000, metadata_cli=CliType.default("--port {value}")) + interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = field_( + default="rsgi", metadata_cli=CliType.default("--interface {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) + threads: int = field_(default=0, metadata_cli=CliType.default("--threads {value}")) + blocking_threads: int | None = field_( + default=None, metadata_cli=CliType.default("--blocking-threads {value}") + ) + threading_mode: Literal["runtime", "workers"] = field_( + default="workers", metadata_cli=CliType.default("--threading-mode {value}") + ) + loop: Literal["auto", "asyncio", "uvloop"] = field_( + default="auto", metadata_cli=CliType.default("--loop {value}") + ) + loop_opt: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}opt"), + ) + http: Literal["auto", "1", "2"] = field_( + default="auto", metadata_cli=CliType.default("--http {value}") + ) + websockets: bool = field_( + default=True, metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}ws") + ) + backlog: int = field_( + default=1024, metadata_cli=CliType.default("--backlog {value}") + ) + backpressure: int | None = field_( + default=None, metadata_cli=CliType.default("--backpressure {value}") + ) + http1_keep_alive: bool = field_( + default=True, metadata_cli=CliType.default("--http1-keep-alive {value}") + ) + http1_max_buffer_size: int = field_( + default=417792, metadata_cli=CliType.default("--http1-max-buffer-size {value}") + ) + http1_pipeline_flush: bool = field_( + default=False, metadata_cli=CliType.default("--http1-pipeline-flush {value}") + ) + http2_adaptive_window: bool = field_( + default=False, metadata_cli=CliType.default("--http2-adaptive-window {value}") + ) + http2_initial_connection_window_size: int = field_( + default=1048576, + metadata_cli=CliType.default("--http2-initial-connection-window-size {value}"), + ) + http2_initial_stream_window_size: int = field_( + default=1048576, + metadata_cli=CliType.default("--http2-initial-stream-window-size {value}"), + ) + http2_keep_alive_interval: int | None = field_( + default=None, + metadata_cli=CliType.default("--http2-keep-alive-interval {value}"), + ) + http2_keep_alive_timeout: int = field_( + default=20, metadata_cli=CliType.default("--http2-keep-alive-timeout {value}") + ) + http2_max_concurrent_streams: int = field_( + default=200, + metadata_cli=CliType.default("--http2-max-concurrent-streams {value}"), + ) + http2_max_frame_size: int = field_( + default=16384, metadata_cli=CliType.default("--http2-max-frame-size {value}") + ) + http2_max_headers_size: int = field_( + default=16777216, + metadata_cli=CliType.default("--http2-max-headers-size {value}"), + ) + http2_max_send_buffer_size: int = field_( + default=409600, + metadata_cli=CliType.default("--http2-max-send-buffer-size {value}"), + ) + log_enabled: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}log"), + ) + log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = ( + field_(default="info", metadata_cli=CliType.default("--log-level {value}")) + ) + log_dictconfig: dict[str, Any] | None = field_(default=None, metadata_cli=None) + log_access: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}log-access"), + ) + log_access_format: str | None = field_( + default=None, metadata_cli=CliType.default("--access-log-fmt {value}") + ) + ssl_cert: Path | None = field_( + default=None, metadata_cli=CliType.default("--ssl-certificate {value}") + ) + ssl_key: Path | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile {value}") + ) + ssl_key_password: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile-password {value}") + ) + url_path_prefix: str | None = field_( + default=None, metadata_cli=CliType.default("--url-path-prefix {value}") + ) + respawn_failed_workers: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle( + "--{toggle_kw}{toggle_sep}respawn-failed-workers" + ), + ) + respawn_interval: float = field_( + default=3.5, metadata_cli=CliType.default("--respawn-interval {value}") + ) + workers_lifetime: int | None = field_( + default=None, metadata_cli=CliType.default("--workers-lifetime {value}") + ) + factory: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}factory"), + ) + reload: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}reload"), + ) + reload_paths: list[Path] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-paths {value}") + ) + reload_ignore_dirs: list[str] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-dirs {value}") + ) + reload_ignore_patterns: list[str] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-patterns {value}") + ) + reload_ignore_paths: list[Path] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-paths {value}") + ) + reload_filter: object | None = field_( # type: ignore + default=None, metadata_cli=None + ) + process_name: str | None = field_( + default=None, metadata_cli=CliType.default("--process-name {value}") + ) + pid_file: Path | None = field_( + default=None, metadata_cli=CliType.default("--pid-file {value}") ) - http2_initial_stream_window_size: int = HTTP2Settings.initial_stream_window_size - http2_keep_alive_interval: int | None = HTTP2Settings.keep_alive_interval - http2_keep_alive_timeout: int = HTTP2Settings.keep_alive_timeout - http2_max_concurrent_streams: int = HTTP2Settings.max_concurrent_streams - http2_max_frame_size: int = HTTP2Settings.max_frame_size - http2_max_headers_size: int = HTTP2Settings.max_headers_size - http2_max_send_buffer_size: int = HTTP2Settings.max_send_buffer_size - log_enabled: bool = True - log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = "info" - log_dictconfig: dict[str, Any] | None = None - log_access: bool = False - log_access_format: str | None = None - ssl_cert: Path | None = None - ssl_key: Path | None = None - ssl_key_password: str | None = None - url_path_prefix: str | None = None - respawn_failed_workers: bool = False - respawn_interval: float = 3.5 - workers_lifetime: int | None = None - factory: bool = False - reload: bool = False - reload_paths: list[Path] | None = None - reload_ignore_dirs: list[str] | None = None - reload_ignore_patterns: list[str] | None = None - reload_ignore_paths: list[Path] | None = None - reload_filter: Type[getattr(watchfiles, "BaseFilter", None)] | None = None # type: ignore - process_name: str | None = None - pid_file: Path | None = None - - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -164,11 +202,10 @@ class GranianBackendServer(CustomBackendServer): 'The `granian` package is required to run `GranianBackendServer`. Run `pip install "granian>=1.6.0"`.' ) - if find_spec("watchfiles") is None and extra: - # NOTE: the `\[` is for force `rich.Console` to not consider it like a color or anything else which he not printing `[.*]` + if find_spec("watchfiles") is None and self.reload: errors.append( - r'Using --reload in `GranianBackendServer` requires the granian\[reload] extra. Run `pip install "granian\[reload]>=1.6.0"`.' - ) # type: ignore + 'Using `--reload` in `GranianBackendServer` requires the `watchfiles` extra. Run `pip install "watchfiles~=0.21"`.' + ) if errors: console.error("\n".join(errors)) @@ -176,18 +213,18 @@ class GranianBackendServer(CustomBackendServer): def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - self.target = self.get_app_module(for_granian_target=True, add_extra_api=True) + self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) self.log_level = loglevel.value # type: ignore self.address = host self.port = port - self.interface = "asgi" # prevent obvious error + self.interface = "asgi" # NOTE: prevent obvious error if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: self.workers = self.get_recommended_workers() else: - if self.workers > (max_threads := self.get_max_workers()): - self.workers = max_threads + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers if self.threads == self.get_fields()["threads"].default: self.threads = self.get_recommended_threads() @@ -208,26 +245,18 @@ class GranianBackendServer(CustomBackendServer): command = ["granian"] for key, field in self.get_fields().items(): - if key != "target": - value = getattr(self, key) - if _mapping_attr_to_cli.get(key) and value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): - command.append( - f"--{'no-' if value is False else ''}{_mapping_attr_to_cli[key][2:]}" - ) - else: - command += [_mapping_attr_to_cli[key], str(value)] + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") - return command + [ - self.get_app_module(for_granian_target=True, add_extra_api=True) - ] + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" - self.check_import(extra=self.reload) + self.check_import() from granian import Granian exclude_keys = ( @@ -244,14 +273,17 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - model = self.get_fields() Granian( **{ **{ key: value - for key, value in self.dict().items() - if key not in exclude_keys and value != model[key].default + for key, value in self.get_values().items() + if ( + key not in exclude_keys + and not self.is_default_value(key, value) + ) }, + "target": self._app_uri, "http1_settings": HTTP1Settings( self.http1_keep_alive, self.http1_max_buffer_size, diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index c0bfb3b50..53544da7b 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -2,13 +2,12 @@ from __future__ import annotations -import os -import ssl import sys +from dataclasses import dataclass from typing import Any, Callable, Literal -from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.constants.base import IS_WINDOWS, Env, LogLevel +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console _mapping_attr_to_cli: dict[str, str] = { @@ -87,78 +86,25 @@ _mapping_attr_to_cli: dict[str, str] = { } +@dataclass class GunicornBackendServer(CustomBackendServer): - """Gunicorn backendServer.""" + """Gunicorn backendServer. - # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - - app_uri: str | None = None - - config: str = "./gunicorn.conf.py" - """\ - :ref:`The Gunicorn config file`. - - A string of the form ``PATH``, ``file:PATH``, or ``python:MODULE_NAME``. - - Only has an effect when specified on the command line or as part of an - application specific configuration. - - By default, a file named ``gunicorn.conf.py`` will be read from the same - directory where gunicorn is being run. - """ - - wsgi_app: str | None = None - """\ - A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. - """ - - bind: list[str] = ["127.0.0.1:8000"] - """\ - The socket to bind. - - A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, - ``fd://FD``. An IP is a valid ``HOST``. - - .. versionchanged:: 20.0 - Support for ``fd://FD`` got added. - - Multiple addresses can be bound. ex.:: - - $ gunicorn -b 127.0.0.1:8000 -b [::1]:8000 test:app - - will bind the `test:app` application on localhost both on ipv6 - and ipv4 interfaces. - - If the ``PORT`` environment variable is defined, the default - is ``['0.0.0.0:$PORT']``. If it is not defined, the default - is ``['127.0.0.1:8000']``. - """ - - backlog: int = 2048 - """\ - The maximum number of pending connections. - - This refers to the number of clients that can be waiting to be served. - Exceeding this number results in the client getting an error when - attempting to connect. It should only affect servers under significant - load. - - Must be a positive integer. Generally set in the 64-2048 range. - """ - - workers: int = 0 - """\ - The number of worker processes for handling requests. - - A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. - You'll want to vary this a bit to find the best for your particular - application's work load. - - By default, the value of the ``WEB_CONCURRENCY`` environment variable, - which is set by some Platform-as-a-Service providers such as Heroku. If - it is not defined, the default is ``1``. + https://docs.gunicorn.org/en/latest/settings.html """ + config: str = field_( + default="./gunicorn.conf.py", metadata_cli=CliType.default("--config {value}") + ) + bind: list[str] = field_( + default=None, + default_factory=lambda: ["127.0.0.1:8000"], + metadata_cli=CliType.multiple("--bind {value}"), + ) + backlog: int = field_( + default=2048, metadata_cli=CliType.default("--backlog {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) worker_class: Literal[ "sync", "eventlet", @@ -166,1016 +112,258 @@ class GunicornBackendServer(CustomBackendServer): "tornado", "gthread", "uvicorn.workers.UvicornH11Worker", - ] = "sync" - """\ - The type of workers to use. - - The default class (``sync``) should handle most "normal" types of - workloads. You'll want to read :doc:`design` for information on when - you might want to choose one of the other worker classes. Required - libraries may be installed using setuptools' ``extras_require`` feature. - - A string referring to one of the following bundled classes: - - * ``sync`` - * ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via - ``pip install gunicorn[eventlet]``) - * ``gevent`` - Requires gevent >= 1.4 (or install it via - ``pip install gunicorn[gevent]``) - * ``tornado`` - Requires tornado >= 0.2 (or install it via - ``pip install gunicorn[tornado]``) - * ``gthread`` - Python 2 requires the futures package to be installed - (or install it via ``pip install gunicorn[gthread]``) - - Optionally, you can provide your own worker by giving Gunicorn a - Python path to a subclass of ``gunicorn.workers.base.Worker``. - This alternative syntax will load the gevent class: - ``gunicorn.workers.ggevent.GeventWorker``. - """ - - threads: int = 0 - """\ - The number of worker threads for handling requests. - - Run each worker with the specified number of threads. - - A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. - You'll want to vary this a bit to find the best for your particular - application's work load. - - If it is not defined, the default is ``1``. - - This setting only affects the Gthread worker type. - - .. note:: - If you try to use the ``sync`` worker type and set the ``threads`` - setting to more than 1, the ``gthread`` worker type will be used - instead. - """ - - worker_connections: int = 1000 - """\ - The maximum number of simultaneous clients. - - This setting only affects the ``gthread``, ``eventlet`` and ``gevent`` worker types. - """ - - max_requests: int = 0 - """\ - The maximum number of requests a worker will process before restarting. - - Any value greater than zero will limit the number of requests a worker - will process before automatically restarting. This is a simple method - to help limit the damage of memory leaks. - - If this is set to zero (the default) then the automatic worker - restarts are disabled. - """ - - max_requests_jitter: int = 0 - """\ - The maximum jitter to add to the *max_requests* setting. - - The jitter causes the restart per worker to be randomized by - ``randint(0, max_requests_jitter)``. This is intended to stagger worker - restarts to avoid all workers restarting at the same time. - """ - - timeout: int = 30 - """\ - Workers silent for more than this many seconds are killed and restarted. - - Value is a positive number or 0. Setting it to 0 has the effect of - infinite timeouts by disabling timeouts for all workers entirely. - - Generally, the default of thirty seconds should suffice. Only set this - noticeably higher if you're sure of the repercussions for sync workers. - For the non sync workers it just means that the worker process is still - communicating and is not tied to the length of time required to handle a - single request. - """ - - graceful_timeout: int = 30 - """\ - Timeout for graceful workers restart. - - After receiving a restart signal, workers have this much time to finish - serving requests. Workers still alive after the timeout (starting from - the receipt of the restart signal) are force killed. - """ - - keepalive: int = 2 - """\ - The number of seconds to wait for requests on a Keep-Alive connection. - - Generally set in the 1-5 seconds range for servers with direct connection - to the client (e.g. when you don't have separate load balancer). When - Gunicorn is deployed behind a load balancer, it often makes sense to - set this to a higher value. - - .. note:: - ``sync`` worker does not support persistent connections and will - ignore this option. - """ - - limit_request_line: int = 4094 - """\ - The maximum size of HTTP request line in bytes. - - This parameter is used to limit the allowed size of a client's - HTTP request-line. Since the request-line consists of the HTTP - method, URI, and protocol version, this directive places a - restriction on the length of a request-URI allowed for a request - on the server. A server needs this value to be large enough to - hold any of its resource names, including any information that - might be passed in the query part of a GET request. Value is a number - from 0 (unlimited) to 8190. - - This parameter can be used to prevent any DDOS attack. - """ - - limit_request_fields: int = 100 - """\ - Limit the number of HTTP headers fields in a request. - - This parameter is used to limit the number of headers in a request to - prevent DDOS attack. Used with the *limit_request_field_size* it allows - more safety. By default this value is 100 and can't be larger than - 32768. - """ - - limit_request_field_size: int = 8190 - """\ - Limit the allowed size of an HTTP request header field. - - Value is a positive number or 0. Setting it to 0 will allow unlimited - header field sizes. - - .. warning:: - Setting this parameter to a very high or unlimited value can open - up for DDOS attacks. - """ - - reload: bool = False - """\ - Restart workers when code changes. - - This setting is intended for development. It will cause workers to be - restarted whenever application code changes. - - The reloader is incompatible with application preloading. When using a - paste configuration be sure that the server block does not import any - application code or the reload will not work as designed. - - The default behavior is to attempt inotify with a fallback to file - system polling. Generally, inotify should be preferred if available - because it consumes less system resources. - - .. note:: - In order to use the inotify reloader, you must have the ``inotify`` - package installed. - """ - - reload_engine: Literal["auto", "poll", "inotify"] = "auto" - """\ - The implementation that should be used to power :ref:`reload`. - - Valid engines are: - - * ``'auto'`` - * ``'poll'`` - * ``'inotify'`` (requires inotify) - """ - - reload_extra_files: list[str] = [] - """\ - Extends :ref:`reload` option to also watch and reload on additional files - (e.g., templates, configurations, specifications, etc.). - """ - - spew: bool = False - """\ - Install a trace function that spews every line executed by the server. - - This is the nuclear option. - """ - - check_config: bool = False - """\ - Check the configuration and exit. The exit status is 0 if the - configuration is correct, and 1 if the configuration is incorrect. - """ - - print_config: bool = False - """\ - Print the configuration settings as fully resolved. Implies :ref:`check-config`. - """ - - preload_app: bool = False - """\ - Load application code before the worker processes are forked. - - By preloading an application you can save some RAM resources as well as - speed up server boot times. Although, if you defer application loading - to each worker process, you can reload your application code easily by - restarting workers. - """ - - sendfile: bool | None = None - """\ - Disables the use of ``sendfile()``. - - If not set, the value of the ``SENDFILE`` environment variable is used - to enable or disable its usage. - """ - - reuse_port: bool = False - """\ - Set the ``SO_REUSEPORT`` flag on the listening socket. - """ - - chdir: str = "." - """\ - Change directory to specified directory before loading apps. - """ - - daemon: bool = False - """\ - Daemonize the Gunicorn process. - - Detaches the server from the controlling terminal and enters the - background. - """ - - raw_env: list[str] = [] - """\ - Set environment variables in the execution environment. - - Should be a list of strings in the ``key=value`` format. - """ - - pidfile: str | None = None - """\ - A filename to use for the PID file. - - If not set, no PID file will be written. - """ - - worker_tmp_dir: str | None = None - """\ - A directory to use for the worker heartbeat temporary file. - - If not set, the default temporary directory will be used. - - .. note:: - The current heartbeat system involves calling ``os.fchmod`` on - temporary file handlers and may block a worker for arbitrary time - if the directory is on a disk-backed filesystem. - - See :ref:`blocking-os-fchmod` for more detailed information - and a solution for avoiding this problem. - """ - - user: int = os.geteuid() - """\ - Switch worker processes to run as this user. - - A valid user id (as an integer) or the name of a user that can be - retrieved with a call to ``pwd.getpwnam(value)`` or ``None`` to not - change the worker process user. - """ - - group: int = os.getegid() - """\ - Switch worker process to run as this group. - - A valid group id (as an integer) or the name of a user that can be - retrieved with a call to ``pwd.getgrnam(value)`` or ``None`` to not - change the worker processes group. - """ - - umask: int = 0 - """\ - A bit mask for the file mode on files written by Gunicorn. - - Note that this affects unix socket permissions. - - A valid value for the ``os.umask(mode)`` call or a string compatible - with ``int(value, 0)`` (``0`` means Python guesses the base, so values - like ``0``, ``0xFF``, ``0022`` are valid for decimal, hex, and octal - representations) - """ - - initgroups: bool = False - """\ - If true, set the worker process's group access list with all of the - groups of which the specified username is a member, plus the specified - group id. - """ - - tmp_upload_dir: str | None = None - """\ - Directory to store temporary request data as they are read. - - This may disappear in the near future. - - This path should be writable by the process permissions set for Gunicorn - workers. If not specified, Gunicorn will choose a system generated - temporary directory. - """ - - secure_scheme_headers: dict[str, Any] = { - "X-FORWARDED-PROTOCOL": "ssl", - "X-FORWARDED-PROTO": "https", - "X-FORWARDED-SSL": "on", - } - """\ - A dictionary containing headers and values that the front-end proxy - uses to indicate HTTPS requests. If the source IP is permitted by - :ref:`forwarded-allow-ips` (below), *and* at least one request header matches - a key-value pair listed in this dictionary, then Gunicorn will set - ``wsgi.url_scheme`` to ``https``, so your application can tell that the - request is secure. - - If the other headers listed in this dictionary are not present in the request, they will be ignored, - but if the other headers are present and do not match the provided values, then - the request will fail to parse. See the note below for more detailed examples of this behaviour. - - The dictionary should map upper-case header names to exact string - values. The value comparisons are case-sensitive, unlike the header - names, so make sure they're exactly what your front-end proxy sends - when handling HTTPS requests. - - It is important that your front-end proxy configuration ensures that - the headers defined here can not be passed directly from the client. - """ - - forwarded_allow_ips: str = "127.0.0.1,::1" - """\ - Front-end's IPs from which allowed to handle set secure headers. - (comma separated). - - Set to ``*`` to disable checking of front-end IPs. This is useful for setups - where you don't know in advance the IP address of front-end, but - instead have ensured via other means that only your - authorized front-ends can access Gunicorn. - - By default, the value of the ``FORWARDED_ALLOW_IPS`` environment - variable. If it is not defined, the default is ``"127.0.0.1,::1"``. - - .. note:: - - This option does not affect UNIX socket connections. Connections not associated with - an IP address are treated as allowed, unconditionally. - - .. note:: - - The interplay between the request headers, the value of ``forwarded_allow_ips``, and the value of - ``secure_scheme_headers`` is complex. Various scenarios are documented below to further elaborate. - In each case, we have a request from the remote address 134.213.44.18, and the default value of - ``secure_scheme_headers``: - - .. code:: - - secure_scheme_headers = { - 'X-FORWARDED-PROTOCOL': 'ssl', - 'X-FORWARDED-PROTO': 'https', - 'X-FORWARDED-SSL': 'on' - } - - - .. list-table:: - :header-rows: 1 - :align: center - :widths: auto - - * - ``forwarded-allow-ips`` - - Secure Request Headers - - Result - - Explanation - * - ``["127.0.0.1"]`` - - ``X-Forwarded-Proto: https`` - - ``wsgi.url_scheme = "http"`` - - IP address was not allowed - * - ``"*"`` - - - - ``wsgi.url_scheme = "http"`` - - IP address allowed, but no secure headers provided - * - ``"*"`` - - ``X-Forwarded-Proto: https`` - - ``wsgi.url_scheme = "https"`` - - IP address allowed, one request header matched - * - ``["134.213.44.18"]`` - - ``X-Forwarded-Ssl: on`` ``X-Forwarded-Proto: http`` - - ``InvalidSchemeHeaders()`` raised - - IP address allowed, but the two secure headers disagreed on if HTTPS was used - """ - - accesslog: str | None = None - """\ - The Access log file to write to. - - ``'-'`` means log to stdout. - """ - - disable_redirect_access_to_syslog: bool = False - """\ - Disable redirect access logs to syslog. - """ - - access_log_format: str = ( - '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + ] = field_(default="sync", metadata_cli=CliType.default("--worker-class {value}")) + threads: int = field_(default=0, metadata_cli=CliType.default("--threads {value}")) + worker_connections: int = field_( + default=1000, metadata_cli=CliType.default("--worker-connections {value}") ) - """\ - The access log format. - - =========== =========== - Identifier Description - =========== =========== - h remote address - l ``'-'`` - u user name (if HTTP Basic auth used) - t date of the request - r status line (e.g. ``GET / HTTP/1.1``) - m request method - U URL path without query string - q query string - H protocol - s status - B response length - b response length or ``'-'`` (CLF format) - f referrer (note: header is ``referer``) - a user agent - T request time in seconds - M request time in milliseconds - D request time in microseconds - L request time in decimal seconds - p process ID - {header}i request header - {header}o response header - {variable}e environment variable - =========== =========== - - Use lowercase for header and environment variable names, and put - ``{...}x`` names inside ``%(...)s``. For example:: - - %({x-forwarded-for}i)s - """ - - errorlog: str = "-" - """\ - The Error log file to write to. - - Using ``'-'`` for FILE makes gunicorn log to stderr. - """ - - loglevel: Literal["debug", "info", "warning", "error", "critical"] = "info" - """\ - The granularity of Error log outputs. - """ - - capture_output: bool = False - """\ - Redirect stdout/stderr to specified file in :ref:`errorlog`. - """ - - logger_class: str = "gunicorn.glogging.Logger" - """\ - The logger you want to use to log events in Gunicorn. - - The default class (``gunicorn.glogging.Logger``) handles most - normal usages in logging. It provides error and access logging. - - You can provide your own logger by giving Gunicorn a Python path to a - class that quacks like ``gunicorn.glogging.Logger``. - """ - - logconfig: str | None = None - """\ - The log config file to use. - Gunicorn uses the standard Python logging module's Configuration - file format. - """ - - logconfig_dict: dict = {} - """\ - The log config dictionary to use, using the standard Python - logging module's dictionary configuration format. This option - takes precedence over the :ref:`logconfig` and :ref:`logconfig-json` options, - which uses the older file configuration format and JSON - respectively. - - Format: https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig - - For more context you can look at the default configuration dictionary for logging, - which can be found at ``gunicorn.glogging.CONFIG_DEFAULTS``. - """ - - logconfig_json: str | None = None - """\ - The log config to read config from a JSON file - - Format: https://docs.python.org/3/library/logging.config.html#logging.config.jsonConfig - """ - - syslog_addr: str = ( - "unix:///var/run/syslog" - if sys.platform == "darwin" - else ( - "unix:///var/run/log" - if sys.platform - in ( - "freebsd", - "dragonfly", - ) - else ( - "unix:///dev/log" - if sys.platform == "openbsd" - else "udp://localhost:514" - ) - ) + max_requests: int = field_( + default=0, metadata_cli=CliType.default("--max-requests {value}") ) - """\ - Address to send syslog messages. - - Address is a string of the form: - * ``unix://PATH#TYPE`` : for unix domain socket, ``TYPE`` can be ``stream`` for the stream driver or ``dgram`` for the dgram driver, ``stream`` is the default - * ``udp://HOST:PORT`` : for UDP sockets - * ``tcp://HOST:PORT`` : for TCP sockets - """ - - syslog: bool = False - """\ - Send *Gunicorn* logs to syslog. - """ - - syslog_prefix: str | None = None - """\ - Makes Gunicorn use the parameter as program-name in the syslog entries. - - All entries will be prefixed by ``gunicorn.``. By default the - program name is the name of the process. - """ - - syslog_facility: str = "user" - """\ - Syslog facility name - """ - - enable_stdio_inheritance: bool = False - """\ - Enable stdio inheritance. - - Enable inheritance for stdio file descriptors in daemon mode. - - Note: To disable the Python stdout buffering, you can to set the user - environment variable ``PYTHONUNBUFFERED`` . - """ - - statsd_host: str | None = None - """\ - The address of the StatsD server to log to. - - Address is a string of the form: - - * ``unix://PATH`` : for a unix domain socket. - * ``HOST:PORT`` : for a network address - """ - - dogstatsd_tags: str = "" - """\ - A comma-delimited list of datadog statsd (dogstatsd) tags to append to - statsd metrics. - """ - - statsd_prefix: str = "" - """\ - Prefix to use when emitting statsd metrics (a trailing ``.`` is added, - if not provided). - """ - - proc_name: str | None = None - """\ - A base to use with setproctitle for process naming. - - This affects things like ``ps`` and ``top``. If you're going to be - running more than one instance of Gunicorn you'll probably want to set a - name to tell them apart. This requires that you install the setproctitle - module. - - If not set, the *default_proc_name* setting will be used. - """ - - default_proc_name: str = "gunicorn" - """\ - Internal setting that is adjusted for each type of application. - """ - - pythonpath: str | None = None - """\ - A comma-separated list of directories to add to the Python path. - - e.g. - ``'/home/djangoprojects/myproject,/home/python/mylibrary'``. - """ - - paste: str | None = None - """\ - Load a PasteDeploy config file. The argument may contain a ``#`` - symbol followed by the name of an app section from the config file, - e.g. ``production.ini#admin``. - - At this time, using alternate server blocks is not supported. Use the - command line arguments to control server configuration instead. - """ - - on_starting: Callable = lambda server: None - """\ - Called just before the master process is initialized. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - on_reload: Callable = lambda server: None - """\ - Called to recycle workers during a reload via SIGHUP. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - when_ready: Callable = lambda server: None - """\ - Called just after the server is started. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - pre_fork: Callable = lambda server, worker: None - """\ - Called just before a worker is forked. - - The callable needs to accept two instance variables for the Arbiter and - new Worker. - """ - - post_fork: Callable = lambda server, worker: None - """\ - Called just after a worker has been forked. - - The callable needs to accept two instance variables for the Arbiter and - new Worker. - """ - - post_worker_init: Callable = lambda worker: None - """\ - Called just after a worker has initialized the application. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - worker_int: Callable = lambda worker: None - """\ - Called just after a worker exited on SIGINT or SIGQUIT. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - worker_abort: Callable = lambda worker: None - """\ - Called when a worker received the SIGABRT signal. - - This call generally happens on timeout. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - pre_exec: Callable = lambda server: None - """\ - Called just before a new master process is forked. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - pre_request: Callable = lambda worker, req: worker.log.debug( - "%s %s", req.method, req.path + max_requests_jitter: int = field_( + default=0, metadata_cli=CliType.default("--max-requests-jitter {value}") ) - """\ - Called just before a worker processes the request. - - The callable needs to accept two instance variables for the Worker and - the Request. - """ - - post_request: Callable = lambda worker, req, environ, resp: None - """\ - Called after a worker processes the request. - - The callable needs to accept two instance variables for the Worker and - the Request. - """ - - child_exit: Callable = lambda server, worker: None - """\ - Called just after a worker has been exited, in the master process. - - The callable needs to accept two instance variables for the Arbiter and - the just-exited Worker. - """ - - worker_exit: Callable = lambda server, worker: None - """\ - Called just after a worker has been exited, in the worker process. - - The callable needs to accept two instance variables for the Arbiter and - the just-exited Worker. - """ - - nworkers_changed: Callable = lambda server, new_value, old_value: None - """\ - Called just after *num_workers* has been changed. - - The callable needs to accept an instance variable of the Arbiter and - two integers of number of workers after and before change. - - If the number of workers is set for the first time, *old_value* would - be ``None``. - """ - - on_exit: Callable = lambda server: None - """\ - Called just before exiting Gunicorn. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - ssl_context: Callable = ( - lambda config, default_ssl_context_factory: default_ssl_context_factory() + timeout: int = field_(default=30, metadata_cli=CliType.default("--timeout {value}")) + graceful_timeout: int = field_( + default=30, metadata_cli=CliType.default("--graceful-timeout {value}") ) - """\ - Called when SSLContext is needed. - - Allows customizing SSL context. - - The callable needs to accept an instance variable for the Config and - a factory function that returns default SSLContext which is initialized - with certificates, private key, cert_reqs, and ciphers according to - config and can be further customized by the callable. - The callable needs to return SSLContext object. - - Following example shows a configuration file that sets the minimum TLS version to 1.3: - - .. code-block:: python - - def ssl_context(conf, default_ssl_context_factory): - import ssl - context = default_ssl_context_factory() - context.minimum_version = ssl.TLSVersion.TLSv1_3 - return context - """ - - proxy_protocol: bool = False - """\ - Enable detect PROXY protocol (PROXY mode). - - Allow using HTTP and Proxy together. It may be useful for work with - stunnel as HTTPS frontend and Gunicorn as HTTP server. - - PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt - - Example for stunnel config:: - - [https] - protocol = proxy - accept = 443 - connect = 80 - cert = /etc/ssl/certs/stunnel.pem - key = /etc/ssl/certs/stunnel.key - """ - - proxy_allow_ips: str = "127.0.0.1,::1" - """\ - Front-end's IPs from which allowed accept proxy requests (comma separated). - - Set to ``*`` to disable checking of front-end IPs. This is useful for setups - where you don't know in advance the IP address of front-end, but - instead have ensured via other means that only your - authorized front-ends can access Gunicorn. - - .. note:: - - This option does not affect UNIX socket connections. Connections not associated with - an IP address are treated as allowed, unconditionally. - """ - - keyfile: str | None = None - """\ - SSL key file - """ - - certfile: str | None = None - """\ - SSL certificate file - """ - - ssl_version: int = ( - ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + keepalive: int = field_( + default=2, metadata_cli=CliType.default("--keep-alive {value}") + ) + limit_request_line: int = field_( + default=4094, metadata_cli=CliType.default("--limit-request-line {value}") + ) + limit_request_fields: int = field_( + default=100, metadata_cli=CliType.default("--limit-request-fields {value}") + ) + limit_request_field_size: int = field_( + default=8190, metadata_cli=CliType.default("--limit-request-field_size {value}") + ) + reload: bool = field_(default=False, metadata_cli=CliType.boolean("--reload")) + reload_engine: Literal["auto", "poll", "inotify"] = field_( + default="auto", metadata_cli=CliType.default("--reload-engine {value}") + ) + reload_extra_files: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.default("--reload-extra-file {value}"), + ) + spew: bool = field_(default=False, metadata_cli=CliType.boolean("--spew")) + check_config: bool = field_( + default=False, metadata_cli=CliType.boolean("--check-config") + ) + print_config: bool = field_( + default=False, metadata_cli=CliType.boolean("--print-config") + ) + preload_app: bool = field_(default=False, metadata_cli=CliType.boolean("--preload")) + sendfile: bool | None = field_( + default=None, metadata_cli=CliType.boolean("--no-sendfile", bool_value=False) + ) + reuse_port: bool = field_( + default=False, metadata_cli=CliType.boolean("--reuse-port") + ) + chdir: str = field_(default=".", metadata_cli=CliType.default("--chdir {value}")) + daemon: bool = field_(default=False, metadata_cli=CliType.boolean("--daemon")) + raw_env: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.multiple("--env {value}"), + ) + pidfile: str | None = field_( + default=None, metadata_cli=CliType.default("--pid {value}") + ) + worker_tmp_dir: str | None = field_( + default=None, metadata_cli=CliType.default("--worker-tmp-dir {value}") + ) + user: int = field_(default=1000, metadata_cli=CliType.default("--user {value}")) + group: int = field_(default=1000, metadata_cli=CliType.default("--group {value}")) + umask: int = field_(default=0, metadata_cli=CliType.default("--umask {value}")) + initgroups: bool = field_( + default=False, metadata_cli=CliType.boolean("--initgroups") + ) + tmp_upload_dir: str | None = field_(default=None, metadata_cli=None) + secure_scheme_headers: dict[str, Any] = field_( + default=None, + default_factory=lambda: { + "X-FORWARDED-PROTOCOL": "ssl", + "X-FORWARDED-PROTO": "https", + "X-FORWARDED-SSL": "on", + }, + metadata_cli=None, + ) + forwarded_allow_ips: str = field_( + default="127.0.0.1,::1", + metadata_cli=CliType.default("--forwarded-allow-ips {value}"), + ) + accesslog: str | None = field_( + default=None, metadata_cli=CliType.default("--access-logfile {value}") + ) + disable_redirect_access_to_syslog: bool = field_( + default=False, + metadata_cli=CliType.boolean("--disable-redirect-access-to-syslog"), + ) + access_log_format: str = field_( + default='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"', + metadata_cli=CliType.default("--access-logformat {value}"), + ) + errorlog: str = field_( + default="-", metadata_cli=CliType.default("--error-logfile {value}") + ) + loglevel: Literal["debug", "info", "warning", "error", "critical"] = field_( + default="info", metadata_cli=CliType.default("--log-level {value}") + ) + capture_output: bool = field_( + default=False, metadata_cli=CliType.boolean("--capture-output") + ) + logger_class: str = field_( + default="gunicorn.glogging.Logger", + metadata_cli=CliType.default("--logger-class {value}"), + ) + logconfig: str | None = field_( + default=None, metadata_cli=CliType.default("--log-config {value}") + ) + logconfig_dict: dict = field_( + default=None, default_factory=lambda: {}, metadata_cli=None + ) + logconfig_json: str | None = field_( + default=None, metadata_cli=CliType.default("--log-config-json {value}") + ) + syslog_addr: str = field_( + default="udp://localhost:514", + metadata_cli=CliType.default("--log-syslog-to {value}"), + ) + syslog: bool = field_(default=False, metadata_cli=CliType.boolean("--log-syslog")) + syslog_prefix: str | None = field_( + default=None, metadata_cli=CliType.default("--log-syslog-prefix {value}") + ) + syslog_facility: str = field_( + default="user", metadata_cli=CliType.default("--log-syslog-facility {value}") + ) + enable_stdio_inheritance: bool = field_( + default=False, metadata_cli=CliType.boolean("--enable-stdio-inheritance") + ) + statsd_host: str | None = field_( + default=None, metadata_cli=CliType.default("--statsd-host {value}") + ) + dogstatsd_tags: str = field_( + default="", metadata_cli=CliType.default("--dogstatsd-tags {value}") + ) + statsd_prefix: str = field_( + default="", metadata_cli=CliType.default("--statsd-prefix {value}") + ) + proc_name: str | None = field_( + default=None, metadata_cli=CliType.default("--name {value}") + ) + default_proc_name: str = field_(default="gunicorn", metadata_cli=None) + pythonpath: str | None = field_( + default=None, metadata_cli=CliType.default("--pythonpath {value}") + ) + paste: str | None = field_( + default=None, metadata_cli=CliType.default("--paster {value}") + ) + on_starting: Callable = field_(default=lambda server: None, metadata_cli=None) + on_reload: Callable = field_(default=lambda server: None, metadata_cli=None) + when_ready: Callable = field_(default=lambda server: None, metadata_cli=None) + pre_fork: Callable = field_(default=lambda server, worker: None, metadata_cli=None) + post_fork: Callable = field_(default=lambda server, worker: None, metadata_cli=None) + post_worker_init: Callable = field_(default=lambda worker: None, metadata_cli=None) + worker_int: Callable = field_(default=lambda worker: None, metadata_cli=None) + worker_abort: Callable = field_(default=lambda worker: None, metadata_cli=None) + pre_exec: Callable = field_(default=lambda server: None, metadata_cli=None) + pre_request: Callable = field_( + default=lambda worker, req: worker.log.debug("%s %s", req.method, req.path), + metadata_cli=None, + ) + post_request: Callable = field_( + default=lambda worker, req, environ, resp: None, metadata_cli=None + ) + child_exit: Callable = field_( + default=lambda server, worker: None, metadata_cli=None + ) + worker_exit: Callable = field_( + default=lambda server, worker: None, metadata_cli=None + ) + nworkers_changed: Callable = field_( + default=lambda server, new_value, old_value: None, metadata_cli=None + ) + on_exit: Callable = field_(default=lambda server: None, metadata_cli=None) + ssl_context: Callable[[Any, Any], Any] = field_( + default=lambda config, + default_ssl_context_factory: default_ssl_context_factory(), + metadata_cli=None, + ) + proxy_protocol: bool = field_( + default=False, metadata_cli=CliType.boolean("--proxy-protocol") + ) + proxy_allow_ips: str = field_( + default="127.0.0.1,::1", + metadata_cli=CliType.default("--proxy-allow-from {value}"), + ) + keyfile: str | None = field_( + default=None, metadata_cli=CliType.default("--keyfile {value}") + ) + certfile: str | None = field_( + default=None, metadata_cli=CliType.default("--certfile {value}") + ) + ssl_version: int = field_( + default=2, metadata_cli=CliType.default("--ssl-version {value}") + ) + cert_reqs: int = field_( + default=0, metadata_cli=CliType.default("--cert-reqs {value}") + ) + ca_certs: str | None = field_( + default=None, metadata_cli=CliType.default("--ca-certs {value}") + ) + suppress_ragged_eofs: bool = field_( + default=True, metadata_cli=CliType.boolean("--suppress-ragged-eofs") + ) + do_handshake_on_connect: bool = field_( + default=False, metadata_cli=CliType.boolean("--do-handshake-on-connect") + ) + ciphers: str | None = field_( + default=None, metadata_cli=CliType.default("--ciphers {value}") + ) + raw_paste_global_conf: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.multiple("--paste-global {value}"), + ) + permit_obsolete_folding: bool = field_( + default=False, metadata_cli=CliType.boolean("--permit-obsolete-folding") + ) + strip_header_spaces: bool = field_( + default=False, metadata_cli=CliType.boolean("--strip-header-spaces") + ) + permit_unconventional_http_method: bool = field_( + default=False, + metadata_cli=CliType.boolean("--permit-unconventional-http-method"), + ) + permit_unconventional_http_version: bool = field_( + default=False, + metadata_cli=CliType.boolean("--permit-unconventional-http-version"), + ) + casefold_http_method: bool = field_( + default=False, metadata_cli=CliType.boolean("--casefold-http-method") + ) + forwarder_headers: str = field_( + default="SCRIPT_NAME,PATH_INFO", + metadata_cli=CliType.default("--forwarder-headers {value}"), + ) + header_map: Literal["drop", "refuse", "dangerous"] = field_( + default="drop", metadata_cli=CliType.default("--header-map {value}") ) - """\ - SSL version to use (see stdlib ssl module's). - .. deprecated:: 21.0 - The option is deprecated and it is currently ignored. Use :ref:`ssl-context` instead. - - ============= ============ - --ssl-version Description - ============= ============ - SSLv3 SSLv3 is not-secure and is strongly discouraged. - SSLv23 Alias for TLS. Deprecated in Python 3.6, use TLS. - TLS Negotiate highest possible version between client/server. - Can yield SSL. (Python 3.6+) - TLSv1 TLS 1.0 - TLSv1_1 TLS 1.1 (Python 3.4+) - TLSv1_2 TLS 1.2 (Python 3.4+) - TLS_SERVER Auto-negotiate the highest protocol version like TLS, - but only support server-side SSLSocket connections. - (Python 3.6+) - ============= ============ - - .. versionchanged:: 19.7 - The default value has been changed from ``ssl.PROTOCOL_TLSv1`` to - ``ssl.PROTOCOL_SSLv23``. - .. versionchanged:: 20.0 - This setting now accepts string names based on ``ssl.PROTOCOL_`` - constants. - .. versionchanged:: 20.0.1 - The default value has been changed from ``ssl.PROTOCOL_SSLv23`` to - ``ssl.PROTOCOL_TLS`` when Python >= 3.6 . - """ - - cert_reqs: int = ssl.CERT_NONE - """\ - Whether client certificate is required (see stdlib ssl module's) - - =========== =========================== - --cert-reqs Description - =========== =========================== - `0` ``ssl.CERT_NONE`` - `1` ``ssl.CERT_OPTIONAL`` - `2` ``ssl.CERT_REQUIRED`` - =========== =========================== - """ - - ca_certs: str | None = None - """\ - CA certificates file - """ - - suppress_ragged_eofs: bool = True - """\ - Suppress ragged EOFs (see stdlib ssl module's) - """ - - do_handshake_on_connect: bool = False - """\ - Whether to perform SSL handshake on socket connect (see stdlib ssl module's) - """ - - ciphers: str | None = None - """\ - SSL Cipher suite to use, in the format of an OpenSSL cipher list. - - By default we use the default cipher list from Python's ``ssl`` module, - which contains ciphers considered strong at the time of each Python - release. - - As a recommended alternative, the Open Web App Security Project (OWASP) - offers `a vetted set of strong cipher strings rated A+ to C- - `_. - OWASP provides details on user-agent compatibility at each security level. - - See the `OpenSSL Cipher List Format Documentation - `_ - for details on the format of an OpenSSL cipher list. - """ - - raw_paste_global_conf: list[str] = [] - """\ - Set a PasteDeploy global config variable in ``key=value`` form. - - The option can be specified multiple times. - - The variables are passed to the PasteDeploy entrypoint. Example:: - - $ gunicorn -b 127.0.0.1:8000 --paste development.ini --paste-global FOO=1 --paste-global BAR=2 - """ - - permit_obsolete_folding: bool = False - """\ - Permit requests employing obsolete HTTP line folding mechanism - - The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be - employed in HTTP request headers from standards-compliant HTTP clients. - - This option is provided to diagnose backwards-incompatible changes. - Use with care and only if necessary. Temporary; the precise effect of this option may - change in a future version, or it may be removed altogether. - """ - - strip_header_spaces: bool = False - """\ - Strip spaces present between the header name and the the ``:``. - - This is known to induce vulnerabilities and is not compliant with the HTTP/1.1 standard. - See https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn. - - Use with care and only if necessary. Deprecated; scheduled for removal in 25.0.0 - """ - - permit_unconventional_http_method: bool = False - """\ - Permit HTTP methods not matching conventions, such as IANA registration guidelines - - This permits request methods of length less than 3 or more than 20, - methods with lowercase characters or methods containing the # character. - HTTP methods are case sensitive by definition, and merely uppercase by convention. - - If unset, Gunicorn will apply nonstandard restrictions and cause 400 response status - in cases where otherwise 501 status is expected. While this option does modify that - behaviour, it should not be depended upon to guarantee standards-compliant behaviour. - Rather, it is provided temporarily, to assist in diagnosing backwards-incompatible - changes around the incomplete application of those restrictions. - - Use with care and only if necessary. Temporary; scheduled for removal in 24.0.0 - """ - - permit_unconventional_http_version: bool = False - """\ - Permit HTTP version not matching conventions of 2023 - - This disables the refusal of likely malformed request lines. - It is unusual to specify HTTP 1 versions other than 1.0 and 1.1. - - This option is provided to diagnose backwards-incompatible changes. - Use with care and only if necessary. Temporary; the precise effect of this option may - change in a future version, or it may be removed altogether. - """ - - casefold_http_method: bool = False - """\ - Transform received HTTP methods to uppercase - - HTTP methods are case sensitive by definition, and merely uppercase by convention. - - This option is provided because previous versions of gunicorn defaulted to this behaviour. - - Use with care and only if necessary. Deprecated; scheduled for removal in 24.0.0 - """ - - forwarder_headers: str = "SCRIPT_NAME,PATH_INFO" - """\ - A list containing upper-case header field names that the front-end proxy - (see :ref:`forwarded-allow-ips`) sets, to be used in WSGI environment. - - This option has no effect for headers not present in the request. - - This option can be used to transfer ``SCRIPT_NAME``, ``PATH_INFO`` - and ``REMOTE_USER``. - - It is important that your front-end proxy configuration ensures that - the headers defined here can not be passed directly from the client. - """ - - header_map: Literal["drop", "refuse", "dangerous"] = "drop" - """\ - Configure how header field names are mapped into environ - - Headers containing underscores are permitted by RFC9110, - but gunicorn joining headers of different names into - the same environment variable will dangerously confuse applications as to which is which. - - The safe default ``drop`` is to silently drop headers that cannot be unambiguously mapped. - The value ``refuse`` will return an error if a request contains *any* such header. - The value ``dangerous`` matches the previous, not advisable, behaviour of mapping different - header field names into the same environ name. - - If the source is permitted as explained in :ref:`forwarded-allow-ips`, *and* the header name is - present in :ref:`forwarder-headers`, the header is mapped into environment regardless of - the state of this setting. - - Use with care and only if necessary and after considering if your problem could - instead be solved by specifically renaming or rewriting only the intended headers - on a proxy in front of Gunicorn. - """ - - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" from importlib.util import find_spec errors: list[str] = [] + if IS_WINDOWS: + errors.append( + "The `GunicornBackendServer` only works on UNIX machines. We recommend using the `UvicornBackendServer` for Windows machines." + ) + if find_spec("gunicorn") is None: errors.append( 'The `gunicorn` package is required to run `GunicornBackendServer`. Run `pip install "gunicorn>=20.1.0"`.' @@ -1187,7 +375,7 @@ class GunicornBackendServer(CustomBackendServer): def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - self.app_uri = f"{self.get_app_module()}()" + self._app_uri = f"{self.get_app_module()}()" self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] @@ -1211,25 +399,17 @@ class GunicornBackendServer(CustomBackendServer): def run_prod(self) -> list[str]: """Run in production mode.""" self.check_import() - command = ["gunicorn"] for key, field in self.get_fields().items(): - if key != "app": - value = getattr(self, key) - if _mapping_attr_to_cli.get(key) and value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): - if (key == "sendfile" and value is False) or ( - key != "sendfile" and value - ): - command.append(_mapping_attr_to_cli[key]) - else: - command += [_mapping_attr_to_cli[key], str(value)] + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") - return command + [f"{self.get_app_module()}()"] + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" @@ -1241,25 +421,29 @@ class GunicornBackendServer(CustomBackendServer): from gunicorn.app.base import BaseApplication from gunicorn.util import import_app as gunicorn_import_app - options_ = self.dict() - options_.pop("app", None) + model = self.get_fields() + options_ = { + key: value + for key, value in self.get_values().items() + if value != model[key].default + } class StandaloneApplication(BaseApplication): def __init__(self, app_uri, options=None): self.options = options or {} - self.app_uri = app_uri + self._app_uri = app_uri super().__init__() def load_config(self): config = { key: value for key, value in self.options.items() - if key in self.cfg.settings and value is not None - } # type: ignore + if key in self.cfg.settings and value is not None # type: ignore + } for key, value in config.items(): self.cfg.set(key.lower(), value) # type: ignore def load(self): - return gunicorn_import_app(self.app_uri) + return gunicorn_import_app(self._app_uri) - StandaloneApplication(app_uri=self.app_uri, options=options_).run() + StandaloneApplication(app_uri=self._app_uri, options=options_).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index cb5131c4a..8d167eacb 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -2,18 +2,188 @@ from __future__ import annotations +# `UvicornBackendServer` defer from other `*BackendServer`, because `uvicorn` he is natively integrated inside the reflex project via Fastapi (same for asyncio) +import asyncio +import os +import ssl import sys +from configparser import RawConfigParser +from dataclasses import dataclass +from pathlib import Path +from typing import IO, Any, Awaitable, Callable + +from uvicorn import Config, Server +from uvicorn.config import ( + LOGGING_CONFIG, + SSL_PROTOCOL_VERSION, + HTTPProtocolType, + InterfaceType, + LifespanType, + LoopSetupType, + WSProtocolType, +) from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console -# TODO +@dataclass class UvicornBackendServer(CustomBackendServer): - """Uvicorn backendServer.""" + """Uvicorn backendServer. - def check_import(self, extra: bool = False): + https://www.uvicorn.org/settings/ + """ + + host: str = field_( + default="127.0.0.1", metadata_cli=CliType.default("--host {value}") + ) + port: int = field_(default=8000, metadata_cli=CliType.default("--port {value}")) + uds: str | None = field_( + default=None, metadata_cli=CliType.default("--uds {value}") + ) + fd: int | None = field_(default=None, metadata_cli=CliType.default("--fd {value}")) + loop: LoopSetupType = field_( + default="auto", metadata_cli=CliType.default("--loop {value}") + ) + http: type[asyncio.Protocol] | HTTPProtocolType = field_( + default="auto", metadata_cli=CliType.default("--http {value}") + ) + ws: type[asyncio.Protocol] | WSProtocolType = field_( + default="auto", metadata_cli=CliType.default("--ws {value}") + ) + ws_max_size: int = field_( + default=16777216, metadata_cli=CliType.default("--ws-max-size {value}") + ) + ws_max_queue: int = field_( + default=32, metadata_cli=CliType.default("--ws-max-queue {value}") + ) + ws_ping_interval: float | None = field_( + default=20.0, metadata_cli=CliType.default("--ws-ping-interval {value}") + ) + ws_ping_timeout: float | None = field_( + default=20.0, metadata_cli=CliType.default("--ws-ping-timeout {value}") + ) + ws_per_message_deflate: bool = field_( + default=True, metadata_cli=CliType.default("--ws-per-message-deflate {value}") + ) + lifespan: LifespanType = field_( + default="auto", metadata_cli=CliType.default("--lifespan {value}") + ) + env_file: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--env-file {value}") + ) + log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = field_( + default=None, + default_factory=lambda: LOGGING_CONFIG, + metadata_cli=CliType.default("--log-config {value}"), + ) + log_level: str | int | None = field_( + default=None, metadata_cli=CliType.default("--log-level {value}") + ) + access_log: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}access-log"), + ) + use_colors: bool | None = field_( + default=None, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}use-colors"), + ) + interface: InterfaceType = field_( + default="auto", metadata_cli=CliType.default("--interface {value}") + ) + reload: bool = field_( + default=False, metadata_cli=CliType.default("--reload {value}") + ) + reload_dirs: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("--reload_dir {value}") + ) + reload_delay: float = field_( + default=0.25, metadata_cli=CliType.default("--reload-delay {value}") + ) + reload_includes: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("----reload-include {value}") + ) + reload_excludes: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-exclude {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) + proxy_headers: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}proxy-headers"), + ) + server_header: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}server-header"), + ) + date_header: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}date-header"), + ) + forwarded_allow_ips: list[str] | str | None = field_( + default=None, + metadata_cli=CliType.multiple("--forwarded-allow-ips {value}", join_sep=","), + ) + root_path: str = field_( + default="", metadata_cli=CliType.default("--root-path {value}") + ) + limit_concurrency: int | None = field_( + default=None, metadata_cli=CliType.default("--limit-concurrency {value}") + ) + limit_max_requests: int | None = field_( + default=None, metadata_cli=CliType.default("--limit-max-requests {value}") + ) + backlog: int = field_( + default=2048, metadata_cli=CliType.default("--backlog {value}") + ) + timeout_keep_alive: int = field_( + default=5, metadata_cli=CliType.default("--timeout-keep-alive {value}") + ) + timeout_notify: int = field_(default=30, metadata_cli=None) + timeout_graceful_shutdown: int | None = field_( + default=None, + metadata_cli=CliType.default("--timeout-graceful-shutdown {value}"), + ) + callback_notify: Callable[..., Awaitable[None]] | None = field_( + default=None, metadata_cli=None + ) + ssl_keyfile: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile {value}") + ) + ssl_certfile: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--ssl-certfile {value}") + ) + ssl_keyfile_password: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile-password {value}") + ) + ssl_version: int = field_( + default=SSL_PROTOCOL_VERSION, + metadata_cli=CliType.default("--ssl-version {value}"), + ) + ssl_cert_reqs: int = field_( + default=ssl.CERT_NONE, metadata_cli=CliType.default("--ssl-cert-reqs {value}") + ) + ssl_ca_certs: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-ca-certs {value}") + ) + ssl_ciphers: str = field_( + default="TLSv1", metadata_cli=CliType.default("--ssl-ciphers {value}") + ) + headers: list[tuple[str, str]] | None = field_( + default=None, + metadata_cli=CliType.multiple( + "--header {value}", value_transformer=lambda value: f"{value[0]}:{value[1]}" + ), + ) + factory: bool = field_( + default=False, metadata_cli=CliType.default("--factory {value}") + ) + h11_max_incomplete_event_size: int | None = field_( + default=None, + metadata_cli=CliType.default("--h11-max-incomplete-event-size {value}"), + ) + + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -24,18 +194,62 @@ class UvicornBackendServer(CustomBackendServer): 'The `uvicorn` package is required to run `UvicornBackendServer`. Run `pip install "uvicorn>=0.20.0"`.' ) + if find_spec("watchfiles") is None and ( + self.reload_includes and self.reload_excludes + ): + errors.append( + 'Using `--reload-include` and `--reload-exclude` in `UvicornBackendServer` requires the `watchfiles` extra. Run `pip install "watchfiles>=0.13"`.' + ) + if errors: console.error("\n".join(errors)) sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - pass + self._app_uri = self.get_app_module(add_extra_api=True) + self.log_level = loglevel.value + self.host = host + self.port = port + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers + + if env == Env.DEV: + from reflex.config import get_config # prevent circular import + + self.reload = True + self.reload_dirs = [str(Path(get_config().app_name))] def run_prod(self): """Run in production mode.""" - pass + self.check_import() + command = ["uvicorn"] + + for key, field in self.get_fields().items(): + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") + + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" - pass + self.check_import() + + options_ = { + key: value + for key, value in self.get_values().items() + if not self.is_default_value(key, value) + } + + Server( + config=Config(**options_, app=self._app_uri), + ).run() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index d04ca60d8..46a431dc5 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -203,9 +203,9 @@ def run_backend( notify_backend() # Run the backend in development mode. - backend_server_prod = config.backend_server_prod - backend_server_prod.setup(host, port, loglevel, Env.DEV) - backend_server_prod.run_dev() + backend_server_dev = config.backend_server_dev + backend_server_dev.setup(host, port, loglevel, Env.DEV) + backend_server_dev.run_dev() def run_backend_prod( From 79f4424835cd05c562f1b6ef9173acb932cb9779 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 9 Nov 2024 21:05:50 +0000 Subject: [PATCH 13/25] remove unused var --- reflex/server/gunicorn.py | 75 --------------------------------------- 1 file changed, 75 deletions(-) diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 53544da7b..4cdb150fa 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -10,81 +10,6 @@ from reflex.constants.base import IS_WINDOWS, Env, LogLevel from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console -_mapping_attr_to_cli: dict[str, str] = { - "config": "--config", - "bind": "--bind", - "backlog": "--backlog", - "workers": "--workers", - "worker_class": "--worker-class", - "threads": "--threads", - "worker_connections": "--worker-connections", - "max_requests": "--max-requests", - "max_requests_jitter": "--max-requests-jitter", - "timeout": "--timeout", - "graceful_timeout": "--graceful-timeout", - "keepalive": "--keep-alive", - "limit_request_line": "--limit-request-line", - "limit_request_fields": "--limit-request-fields", - "limit_request_field_size": "--limit-request-field_size", - "reload": "--reload", - "reload_engine": "--reload-engine", - "reload_extra_files": "--reload-extra-file", - "spew": "--spew", - "check_config": "--check-config", - "print_config": "--print-config", - "preload_app": "--preload", - "sendfile": "--no-sendfile", - "reuse_port": "--reuse-port", - "chdir": "--chdir", - "daemon": "--daemon", - "raw_env": "--env", - "pidfile": "--pid", - "worker_tmp_dir": "--worker-tmp-dir", - "user": "--user", - "group": "--group", - "umask": "--umask", - "initgroups": "--initgroups", - "forwarded_allow_ips": "--forwarded-allow-ips", - "accesslog": "--access-logfile", - "disable_redirect_access_to_syslog": "--disable-redirect-access-to-syslog", - "access_log_format": "--access-logformat", - "errorlog": "--error-logfile", - "loglevel": "--log-level", - "capture_output": "--capture-output", - "logger_class": "--logger-class", - "logconfig": "--log-config", - "logconfig_json": "--log-config-json", - "syslog_addr": "--log-syslog-to", - "syslog": "--log-syslog", - "syslog_prefix": "--log-syslog-prefix", - "syslog_facility": "--log-syslog-facility", - "enable_stdio_inheritance": "--enable-stdio-inheritance", - "statsd_host": "--statsd-host", - "dogstatsd_tags": "--dogstatsd-tags", - "statsd_prefix": "--statsd-prefix", - "proc_name": "--name", - "pythonpath": "--pythonpath", - "paste": "--paster", - "proxy_protocol": "--proxy-protocol", - "proxy_allow_ips": "--proxy-allow-from", - "keyfile": "--keyfile", - "certfile": "--certfile", - "ssl_version": "--ssl-version", - "cert_reqs": "--cert-reqs", - "ca_certs": "--ca-certs", - "suppress_ragged_eofs": "--suppress-ragged-eofs", - "do_handshake_on_connect": "--do-handshake-on-connect", - "ciphers": "--ciphers", - "raw_paste_global_conf": "--paste-global", - "permit_obsolete_folding": "--permit-obsolete-folding", - "strip_header_spaces": "--strip-header-spaces", - "permit_unconventional_http_method": "--permit-unconventional-http-method", - "permit_unconventional_http_version": "--permit-unconventional-http-version", - "casefold_http_method": "--casefold-http-method", - "forwarder_headers": "--forwarder-headers", - "header_map": "--header-map", -} - @dataclass class GunicornBackendServer(CustomBackendServer): From be4ef583dc7bd2b8f91fbbc19077fc8d171a4b73 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 15:32:10 +0000 Subject: [PATCH 14/25] [IMPL] - add `get_backend_bind()` & `shutdown()` --- reflex/server/base.py | 16 ++++++++++++++-- reflex/server/granian.py | 13 +++++++++++-- reflex/server/gunicorn.py | 23 ++++++++++++++++++++++- reflex/server/uvicorn.py | 20 ++++++++++++++++++-- reflex/utils/exec.py | 6 ++---- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/reflex/server/base.py b/reflex/server/base.py index 012511233..b19a6acb5 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -7,7 +7,7 @@ from abc import abstractmethod from dataclasses import Field, dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Callable, Sequence +from typing import Any, Callable, Sequence, ClassVar from reflex import constants from reflex.constants.base import Env, LogLevel @@ -156,7 +156,9 @@ def field_( class CustomBackendServer: """BackendServer base.""" - _app_uri: str = field_(default="", metadata_cli=None, exclude=True) + _env: ClassVar[Env] = field_(default=Env.DEV, metadata_cli=None, exclude=True, repr = False, init = False) + _app: ClassVar[Any] = field_(default=None, metadata_cli=None, exclude=True, repr = False, init = False) + _app_uri: ClassVar[str] = field_(default="", metadata_cli=None, exclude=True, repr = False, init = False) @staticmethod def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): @@ -246,6 +248,11 @@ class CustomBackendServer: return False + @abstractmethod + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + raise NotImplementedError() + @abstractmethod def check_import(self): """Check package importation.""" @@ -265,3 +272,8 @@ class CustomBackendServer: def run_dev(self): """Run in development mode.""" raise NotImplementedError() + + @abstractmethod + async def shutdown(self): + """Shutdown the backend server.""" + raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 2a76d8c53..547929398 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -191,6 +191,9 @@ class GranianBackendServer(CustomBackendServer): default=None, metadata_cli=CliType.default("--pid-file {value}") ) + def get_backend_bind(self) -> tuple[str, int]: + return self.address, self.port + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -218,6 +221,7 @@ class GranianBackendServer(CustomBackendServer): self.address = host self.port = port self.interface = "asgi" # NOTE: prevent obvious error + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -273,7 +277,7 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - Granian( + self._app = Granian( **{ **{ key: value @@ -301,4 +305,9 @@ class GranianBackendServer(CustomBackendServer): self.http2_max_send_buffer_size, ), } - ).serve() + ) + self._app.serve() + + async def shutdown(self): + if self._app and self._env == Env.DEV: + self._app.shutdown() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 4cdb150fa..be0b62729 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -278,6 +278,11 @@ class GunicornBackendServer(CustomBackendServer): default="drop", metadata_cli=CliType.default("--header-map {value}") ) + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + host, port = self.bind[0].split(":") + return host, int(port) + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -303,6 +308,7 @@ class GunicornBackendServer(CustomBackendServer): self._app_uri = f"{self.get_app_module()}()" self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -370,5 +376,20 @@ class GunicornBackendServer(CustomBackendServer): def load(self): return gunicorn_import_app(self._app_uri) + + def stop(self): + from gunicorn.arbiter import Arbiter - StandaloneApplication(app_uri=self._app_uri, options=options_).run() + Arbiter(self).stop() + + self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) + self._app.run() + + async def shutdown(self): + """Shutdown the backend server.""" + if self._app and self._env == Env.DEV: + self._app.stop() # type: ignore + + # TODO: complicated because currently `*BackendServer` don't execute the server command, he just create it + # if self._env == Env.PROD: + # pass diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index 8d167eacb..7f96c6070 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -183,6 +183,10 @@ class UvicornBackendServer(CustomBackendServer): metadata_cli=CliType.default("--h11-max-incomplete-event-size {value}"), ) + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + return self.host, self.port + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -211,6 +215,7 @@ class UvicornBackendServer(CustomBackendServer): self.log_level = loglevel.value self.host = host self.port = port + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -250,6 +255,17 @@ class UvicornBackendServer(CustomBackendServer): if not self.is_default_value(key, value) } - Server( + self._app = Server( config=Config(**options_, app=self._app_uri), - ).run() + ) + self._app.run() + + async def shutdown(self): + """Shutdown the backend server.""" + if self._app and self._env == Env.DEV: + self._app.shutdown() # type: ignore + + # TODO: hard because currently `*BackendServer` don't execute the server command, he just create it + # if self._env == Env.PROD: + # pass + diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 46a431dc5..866ebb1f1 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -178,7 +178,6 @@ def run_frontend_prod(root: Path, port: str, backend_present=True): ) -### REWORK <-- def run_backend( host: str, port: int, @@ -237,12 +236,11 @@ def run_backend_prod( run=True, show_logs=True, env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend + environment.REFLEX_SKIP_COMPILE.name: "true" # skip compile for prod backend + }, ) -### REWORK--> def output_system_info(): """Show system information if the loglevel is in DEBUG.""" if console._LOG_LEVEL > constants.LogLevel.DEBUG: From 61b5937d164883c2e8bca62cbdead60c35d4b1ef Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 16:36:19 +0000 Subject: [PATCH 15/25] deprecated config field --- reflex/config.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 1316fd2f1..c128eedc5 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -687,6 +687,9 @@ class Config(Base): backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( threads=2, workers=4, + max_requests=100, + max_requests_jitter=25, + timeout=120 ) backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, @@ -721,26 +724,15 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - - for key in ( - "timeout", - "gunicorn_worker_class", - "gunicorn_workers", - "gunicorn_max_requests", - "gunicorn_max_requests_jitter", + + if any( + getattr(self.get_fields().get(key, None), "default", None) == self.get_value(key) + for key in ("timeout","gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter" ) ): - if isinstance(self.backend_server_prod, server.GunicornBackendServer): - value = self.get_value(key) - if ( - value - != self.backend_server_prod.get_fields()[ - key.replace("gunicorn_", "") - ].default - and value is not None - ): - setattr( - self.backend_server_prod, key.replace("gunicorn_", ""), value - ) + console.warn( + 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.' + ) + @property def module(self) -> str: From 8221f4962be9b9288be2e8d1ae74bc83cd8e69db Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 21:17:38 +0000 Subject: [PATCH 16/25] lint & format --- reflex/config.py | 20 ++--- reflex/server/base.py | 156 +++++++++++++++++++++++++++++++++----- reflex/server/granian.py | 30 ++++++-- reflex/server/gunicorn.py | 31 ++++++-- reflex/server/uvicorn.py | 32 +++++--- 5 files changed, 216 insertions(+), 53 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index c128eedc5..75fe1571b 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -685,11 +685,7 @@ class Config(Base): # Custom Backend Server backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - threads=2, - workers=4, - max_requests=100, - max_requests_jitter=25, - timeout=120 + threads=2, workers=4, max_requests=100, max_requests_jitter=25, timeout=120 ) backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, @@ -724,15 +720,21 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - + if any( - getattr(self.get_fields().get(key, None), "default", None) == self.get_value(key) - for key in ("timeout","gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter" ) + getattr(self.get_fields().get(key, None), "default", None) + == self.get_value(key) + for key in ( + "timeout", + "gunicorn_worker_class", + "gunicorn_workers", + "gunicorn_max_requests", + "gunicorn_max_requests_jitter", + ) ): console.warn( 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.' ) - @property def module(self) -> str: diff --git a/reflex/server/base.py b/reflex/server/base.py index b19a6acb5..c78c90224 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -7,7 +7,7 @@ from abc import abstractmethod from dataclasses import Field, dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Callable, Sequence, ClassVar +from typing import Any, Callable, ClassVar, Sequence from reflex import constants from reflex.constants.base import Env, LogLevel @@ -26,6 +26,12 @@ class CliType: fmt: `'--env-file {value}'` value: `'/config.conf'` result => `'--env-file /config.conf'` + + Args: + fmt (str): format + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -46,6 +52,13 @@ class CliType: fmt: `'--reload'` value: `True` result => `'--reload'` + + Args: + fmt (str): format + bool_value (bool): boolean value used for toggle condition + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -80,6 +93,16 @@ class CliType: value: `True` toggle_value: `True` result => `'--no-access-log'` + + Args: + fmt (str): format + toggle_kw (str): keyword used when toggled. Defaults to "no". + toggle_sep (str): separator used when toggled. Defaults to "-". + toggle_value (bool): boolean value used for toggle condition. Defaults to False. + **kwargs: Keyword arguments to pass to the format string function. + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -102,6 +125,7 @@ class CliType: Example (Multiple args mode): fmt: `'--header {value}'`. data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + join_sep: `None` result => `'--header \"X-Forwarded-Proto=https\" --header \"X-Forwarded-For=0.0.0.0\"'` Example (Single args mode): @@ -116,6 +140,14 @@ class CliType: join_sep (required): `';'` value_transformer: `lambda value: f'{value[0]}:{value[1]}'` result => `--headers \"X-Forwarded-Proto:https;X-Forwarded-For:0.0.0.0\"` + + Args: + fmt (str): format + join_sep (str): separator used + value_transformer (Callable[[Any], str]): function used for transformer the element + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(values: Sequence[str]) -> str: @@ -139,7 +171,17 @@ def field_( exclude: bool = False, **kwargs, ): - """Custom dataclass field builder.""" + """Custom dataclass field builder. + + Args: + default (Any): default value. Defaults to None. + metadata_cli (ReturnCliTypeFn | None): cli wrapper function. Defaults to None. + exclude (bool): used for excluding the field to the server configuration (system field). Defaults to False. + **kwargs: Keyword arguments to pass to the field dataclasses function. + + Returns: + Field: return the field dataclasses + """ params_ = { "default": default, "metadata": {"cli": metadata_cli, "exclude": exclude}, @@ -156,16 +198,28 @@ def field_( class CustomBackendServer: """BackendServer base.""" - _env: ClassVar[Env] = field_(default=Env.DEV, metadata_cli=None, exclude=True, repr = False, init = False) - _app: ClassVar[Any] = field_(default=None, metadata_cli=None, exclude=True, repr = False, init = False) - _app_uri: ClassVar[str] = field_(default="", metadata_cli=None, exclude=True, repr = False, init = False) + _env: ClassVar[Env] = field_( + default=Env.DEV, metadata_cli=None, exclude=True, repr=False, init=False + ) + _app: ClassVar[Any] = field_( + default=None, metadata_cli=None, exclude=True, repr=False, init=False + ) + _app_uri: ClassVar[str] = field_( + default="", metadata_cli=None, exclude=True, repr=False, init=False + ) @staticmethod - def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): + def get_app_module( + for_granian_target: bool = False, add_extra_api: bool = False + ) -> str: """Get the app module for the backend. + Args: + for_granian_target (bool): make the return compatible with Granian. Defaults to False. + add_extra_api (bool): add the keyword "api" at the end (needed for Uvicorn & Granian). Defaults to False. + Returns: - The app module for the backend. + str: The app module for the backend. """ import reflex @@ -177,21 +231,41 @@ class CustomBackendServer: return f"{app_path}:{constants.CompileVars.APP}{f'.{constants.CompileVars.API}' if add_extra_api else ''}" def get_available_cpus(self) -> int: - """Get available cpus.""" + """Get available cpus. + + Returns: + int: number of available cpu cores + """ return os.cpu_count() or 1 def get_max_workers(self) -> int: - """Get max workers.""" + """Get maximum workers. + + Returns: + int: get the maximum number of workers + """ # https://docs.gunicorn.org/en/latest/settings.html#workers return (os.cpu_count() or 1) * 4 + 1 def get_recommended_workers(self) -> int: - """Get recommended workers.""" + """Get recommended workers. + + Returns: + int: get the recommended number of workers + """ # https://docs.gunicorn.org/en/latest/settings.html#workers return (os.cpu_count() or 1) * 2 + 1 def get_max_threads(self, wait_time_ms: int = 50, service_time_ms: int = 5) -> int: - """Get max threads.""" + """Get maximum threads. + + Args: + wait_time_ms (int): the mean waiting duration targeted. Defaults to 50. + service_time_ms (int): the mean working duration. Defaults to 5. + + Returns: + int: get the maximum number of threads + """ # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html # Brian Goetz formula return int(self.get_available_cpus() * (1 + wait_time_ms / service_time_ms)) @@ -202,7 +276,16 @@ class CustomBackendServer: wait_time_ms: int = 50, service_time_ms: int = 5, ) -> int: - """Get recommended threads.""" + """Get recommended threads. + + Args: + target_reqs (int | None): number of requests targeted. Defaults to None. + wait_time_ms (int): the mean waiting duration targeted. Defaults to 50. + service_time_ms (int): the mean working duration. Defaults to 5. + + Returns: + int: get the recommended number of threads + """ # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html max_available_threads = self.get_max_threads() @@ -221,19 +304,35 @@ class CustomBackendServer: ) def get_fields(self) -> dict[str, Field]: - """Return all the fields.""" + """Return all the fields. + + Returns: + dict[str, Field]: return the fields dictionary + """ return self.__dataclass_fields__ def get_values(self) -> dict[str, Any]: - """Return all values.""" + """Return all values. + + Returns: + dict[str, Any]: returns the value of the fields + """ return { key: getattr(self, key) for key, field in self.__dataclass_fields__.items() if field.metadata["exclude"] is False } - def is_default_value(self, key, value: Any | None = None) -> bool: - """Check if the `value` is the same value from default context.""" + def is_default_value(self, key: str, value: Any | None = None) -> bool: + """Check if the `value` is the same value from default context. + + Args: + key (str): the name of the field + value (Any | None, optional): the value to check if is equal to the default value. Defaults to None. + + Returns: + bool: result of the condition of value are equal to the default value + """ from dataclasses import MISSING field = self.get_fields()[key] @@ -250,9 +349,13 @@ class CustomBackendServer: @abstractmethod def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ raise NotImplementedError() - + @abstractmethod def check_import(self): """Check package importation.""" @@ -260,12 +363,23 @@ class CustomBackendServer: @abstractmethod def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ raise NotImplementedError() @abstractmethod - def run_prod(self): - """Run in production mode.""" + def run_prod(self) -> list[str]: + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ raise NotImplementedError() @abstractmethod diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 547929398..a87faee93 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -192,6 +192,11 @@ class GranianBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ return self.address, self.port def check_import(self): @@ -215,13 +220,20 @@ class GranianBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) # type: ignore self.log_level = loglevel.value # type: ignore self.address = host self.port = port self.interface = "asgi" # NOTE: prevent obvious error - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -244,7 +256,11 @@ class GranianBackendServer(CustomBackendServer): self.reload_ignore_dirs = [".web"] def run_prod(self): - """Run in production mode.""" + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["granian"] @@ -261,7 +277,7 @@ class GranianBackendServer(CustomBackendServer): def run_dev(self): """Run in development mode.""" self.check_import() - from granian import Granian + from granian import Granian # type: ignore exclude_keys = ( "http1_keep_alive", @@ -277,7 +293,8 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - self._app = Granian( + + self._app = Granian( # type: ignore **{ **{ key: value @@ -309,5 +326,6 @@ class GranianBackendServer(CustomBackendServer): self._app.serve() async def shutdown(self): + """Shutdown the backend server.""" if self._app and self._env == Env.DEV: self._app.shutdown() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index be0b62729..5e47044cf 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -279,10 +279,14 @@ class GunicornBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ host, port = self.bind[0].split(":") return host, int(port) - + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -304,11 +308,18 @@ class GunicornBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = f"{self.get_app_module()}()" + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = f"{self.get_app_module()}()" # type: ignore self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -328,7 +339,11 @@ class GunicornBackendServer(CustomBackendServer): self.reload = True def run_prod(self) -> list[str]: - """Run in production mode.""" + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["gunicorn"] @@ -376,13 +391,13 @@ class GunicornBackendServer(CustomBackendServer): def load(self): return gunicorn_import_app(self._app_uri) - + def stop(self): from gunicorn.arbiter import Arbiter Arbiter(self).stop() - self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) + self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) # type: ignore self._app.run() async def shutdown(self): diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index 7f96c6070..e164fefee 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -184,9 +184,13 @@ class UvicornBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ return self.host, self.port - + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -210,12 +214,19 @@ class UvicornBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = self.get_app_module(add_extra_api=True) + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = self.get_app_module(add_extra_api=True) # type: ignore self.log_level = loglevel.value self.host = host self.port = port - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -230,8 +241,12 @@ class UvicornBackendServer(CustomBackendServer): self.reload = True self.reload_dirs = [str(Path(get_config().app_name))] - def run_prod(self): - """Run in production mode.""" + def run_prod(self) -> list[str]: + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["uvicorn"] @@ -255,7 +270,7 @@ class UvicornBackendServer(CustomBackendServer): if not self.is_default_value(key, value) } - self._app = Server( + self._app = Server( # type: ignore config=Config(**options_, app=self._app_uri), ) self._app.run() @@ -268,4 +283,3 @@ class UvicornBackendServer(CustomBackendServer): # TODO: hard because currently `*BackendServer` don't execute the server command, he just create it # if self._env == Env.PROD: # pass - From ea06469370537e5016710dfcd2d0234c07c9d16a Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 02:20:08 +0000 Subject: [PATCH 17/25] first iteration, GunicornBackendServer work for prod mode --- reflex/config.py | 32 +- reflex/server/__init__.py | 6 + reflex/server/base.py | 14 + reflex/server/granian.py | 36 ++ reflex/server/gunicorn.py | 1195 +++++++++++++++++++++++++++++++++++++ reflex/server/uvicorn.py | 10 + reflex/utils/exec.py | 89 ++- 7 files changed, 1324 insertions(+), 58 deletions(-) create mode 100644 reflex/server/__init__.py create mode 100644 reflex/server/base.py create mode 100644 reflex/server/granian.py create mode 100644 reflex/server/gunicorn.py create mode 100644 reflex/server/uvicorn.py diff --git a/reflex/config.py b/reflex/config.py index 0579b019f..6b10efba5 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -36,7 +36,7 @@ except ModuleNotFoundError: from reflex_cli.constants.hosting import Hosting -from reflex import constants +from reflex import constants, server from reflex.base import Base from reflex.utils import console @@ -649,7 +649,7 @@ class Config(Base): # Tailwind config. tailwind: Optional[Dict[str, Any]] = {"plugins": ["@tailwindcss/typography"]} - # Timeout when launching the gunicorn server. TODO(rename this to backend_timeout?) + # Timeout when launching the gunicorn server. TODO(rename this to backend_timeout?); deprecated timeout: int = 120 # Whether to enable or disable nextJS gzip compression. @@ -666,16 +666,16 @@ class Config(Base): # The hosting service frontend URL. cp_web_url: str = Hosting.HOSTING_SERVICE_UI - # The worker class used in production mode + # The worker class used in production mode; deprecated gunicorn_worker_class: str = "uvicorn.workers.UvicornH11Worker" - # Number of gunicorn workers from user + # Number of gunicorn workers from user; deprecated gunicorn_workers: Optional[int] = None - # Number of requests before a worker is restarted + # Number of requests before a worker is restarted; deprecated gunicorn_max_requests: int = 100 - # Variance limit for max requests; gunicorn only + # Variance limit for max requests; gunicorn only; deprecated gunicorn_max_requests_jitter: int = 25 # Indicate which type of state manager to use @@ -696,6 +696,17 @@ class Config(Base): # Path to file containing key-values pairs to override in the environment; Dotenv format. env_file: Optional[str] = None + # Custom Backend Server + backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( + app=f"reflex.app_module_for_backend:{constants.CompileVars.APP}.{constants.CompileVars.API}", + worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + max_requests=100, + max_requests_jitter=25, + preload_app=True, + timeout=120, + ) + backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer() + def __init__(self, *args, **kwargs): """Initialize the config values. @@ -706,6 +717,7 @@ class Config(Base): Raises: ConfigError: If some values in the config are invalid. """ + print("[reflex.config::Config] start") super().__init__(*args, **kwargs) # Update the config from environment variables. @@ -725,6 +737,14 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) + + print("[reflex.config::Config] --") + for key in ("timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"): + if isinstance(self.backend_server_prod, server.GunicornBackendServer): + value = self.get_value(key) + if value != self.backend_server_prod.get_fields()[key.replace("gunicorn_", "")].default and value is not None: + setattr(self.backend_server_prod, key.replace("gunicorn_", ""), value) + print("[reflex.config::Config] done") @property def module(self) -> str: diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py new file mode 100644 index 000000000..c44f2ca11 --- /dev/null +++ b/reflex/server/__init__.py @@ -0,0 +1,6 @@ + + +from .base import CustomBackendServer +from .granian import GranianBackendServer +from .gunicorn import GunicornBackendServer +from .uvicorn import UvicornBackendServer diff --git a/reflex/server/base.py b/reflex/server/base.py new file mode 100644 index 000000000..c327d5c89 --- /dev/null +++ b/reflex/server/base.py @@ -0,0 +1,14 @@ +from abc import abstractmethod, ABCMeta + +from reflex.base import Base + + +class CustomBackendServer(Base): + + @abstractmethod + def run_prod(self): + raise NotImplementedError() + + @abstractmethod + def run_dev(self): + raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py new file mode 100644 index 000000000..7a2fde844 --- /dev/null +++ b/reflex/server/granian.py @@ -0,0 +1,36 @@ + +from dataclasses import dataclass + +from reflex.server.base import CustomBackendServer + +@dataclass +class HTTP1Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 + keep_alive: bool = True + max_buffer_size: int = 8192 + 4096 * 100 + pipeline_flush: bool = False + + +@dataclass +class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L13 + adaptive_window: bool = False + initial_connection_window_size: int = 1024 * 1024 + initial_stream_window_size: int = 1024 * 1024 + keep_alive_interval: int | None = None + keep_alive_timeout: int = 20 + max_concurrent_streams: int = 200 + max_frame_size: int = 1024 * 16 + max_headers_size: int = 16 * 1024 * 1024 + max_send_buffer_size: int = 1024 * 400 + +try: + import watchfiles +except ImportError: + watchfiles = None + +class GranianBackendServer(CustomBackendServer): + + def run_prod(self): + pass + + def run_dev(self): + pass diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py new file mode 100644 index 000000000..18dfb7841 --- /dev/null +++ b/reflex/server/gunicorn.py @@ -0,0 +1,1195 @@ +from typing import Any, Literal, Callable + +import os +import sys +import ssl +from pydantic import Field + +from gunicorn.app.base import BaseApplication + +import psutil + +from reflex import constants +from reflex.server.base import CustomBackendServer + + +class StandaloneApplication(BaseApplication): + + def __init__(self, app, options=None): + self.options = options or {} + self.application = app + super().__init__() + + def load_config(self): + config = {key: value for key, value in self.options.items() + if key in self.cfg.settings and value is not None} # type: ignore + for key, value in config.items(): + self.cfg.set(key.lower(), value) # type: ignore + + def load(self): + return self.application + + +_mapping_attr_to_cli: dict[str, str] = { + "config": "--config", + "bind": "--bind", + "backlog": "--backlog", + "workers": "--workers", + "worker_class": "--worker-class", + "threads": "--threads", + "worker_connections": "--worker-connections", + "max_requests": "--max-requests", + "max_requests_jitter": "--max-requests-jitter", + "timeout": "--timeout", + "graceful_timeout": "--graceful-timeout", + "keepalive": "--keep-alive", + "limit_request_line": "--limit-request-line", + "limit_request_fields": "--limit-request-fields", + "limit_request_field_size": "--limit-request-field_size", + "reload": "--reload", + "reload_engine": "--reload-engine", + "reload_extra_files": "--reload-extra-file", + "spew": "--spew", + "check_config": "--check-config", + "print_config": "--print-config", + "preload_app": "--preload", + "sendfile": "--no-sendfile", + "reuse_port": "--reuse-port", + "chdir": "--chdir", + "daemon": "--daemon", + "raw_env": "--env", + "pidfile": "--pid", + "worker_tmp_dir": "--worker-tmp-dir", + "user": "--user", + "group": "--group", + "umask": "--umask", + "initgroups": "--initgroups", + "forwarded_allow_ips": "--forwarded-allow-ips", + "accesslog": "--access-logfile", + "disable_redirect_access_to_syslog": "--disable-redirect-access-to-syslog", + "access_log_format": "--access-logformat", + "errorlog": "--error-logfile", + "loglevel": "--log-level", + "capture_output": "--capture-output", + "logger_class": "--logger-class", + "logconfig": "--log-config", + "logconfig_json": "--log-config-json", + "syslog_addr": "--log-syslog-to", + "syslog": "--log-syslog", + "syslog_prefix": "--log-syslog-prefix", + "syslog_facility": "--log-syslog-facility", + "enable_stdio_inheritance": "--enable-stdio-inheritance", + "statsd_host": "--statsd-host", + "dogstatsd_tags": "--dogstatsd-tags", + "statsd_prefix": "--statsd-prefix", + "proc_name": "--name", + "pythonpath": "--pythonpath", + "paste": "--paster", + "proxy_protocol": "--proxy-protocol", + "proxy_allow_ips": "--proxy-allow-from", + "keyfile": "--keyfile", + "certfile": "--certfile", + "ssl_version": "--ssl-version", + "cert_reqs": "--cert-reqs", + "ca_certs": "--ca-certs", + "suppress_ragged_eofs": "--suppress-ragged-eofs", + "do_handshake_on_connect": "--do-handshake-on-connect", + "ciphers": "--ciphers", + "raw_paste_global_conf": "--paste-global", + "permit_obsolete_folding": "--permit-obsolete-folding", + "strip_header_spaces": "--strip-header-spaces", + "permit_unconventional_http_method": "--permit-unconventional-http-method", + "permit_unconventional_http_version": "--permit-unconventional-http-version", + "casefold_http_method": "--casefold-http-method", + "forwarder_headers": "--forwarder-headers", + "header_map": "--header-map", +} + +class GunicornBackendServer(CustomBackendServer): + # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py + + app: str + + config: str = "./gunicorn.conf.py" + """\ + :ref:`The Gunicorn config file`. + + A string of the form ``PATH``, ``file:PATH``, or ``python:MODULE_NAME``. + + Only has an effect when specified on the command line or as part of an + application specific configuration. + + By default, a file named ``gunicorn.conf.py`` will be read from the same + directory where gunicorn is being run. + """ + + wsgi_app: str | None = None + """\ + A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. + """ + + bind: list[str] = ['127.0.0.1:8000'] + """\ + The socket to bind. + + A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, + ``fd://FD``. An IP is a valid ``HOST``. + + .. versionchanged:: 20.0 + Support for ``fd://FD`` got added. + + Multiple addresses can be bound. ex.:: + + $ gunicorn -b 127.0.0.1:8000 -b [::1]:8000 test:app + + will bind the `test:app` application on localhost both on ipv6 + and ipv4 interfaces. + + If the ``PORT`` environment variable is defined, the default + is ``['0.0.0.0:$PORT']``. If it is not defined, the default + is ``['127.0.0.1:8000']``. + """ + + backlog: int = 2048 + """\ + The maximum number of pending connections. + + This refers to the number of clients that can be waiting to be served. + Exceeding this number results in the client getting an error when + attempting to connect. It should only affect servers under significant + load. + + Must be a positive integer. Generally set in the 64-2048 range. + """ + + workers: int = 1 + """\ + The number of worker processes for handling requests. + + A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. + You'll want to vary this a bit to find the best for your particular + application's work load. + + By default, the value of the ``WEB_CONCURRENCY`` environment variable, + which is set by some Platform-as-a-Service providers such as Heroku. If + it is not defined, the default is ``1``. + """ + + worker_class: Literal["sync", "eventlet", "gevent", "tornado", "gthread", "uvicorn.workers.UvicornH11Worker"] = "sync" + """\ + The type of workers to use. + + The default class (``sync``) should handle most "normal" types of + workloads. You'll want to read :doc:`design` for information on when + you might want to choose one of the other worker classes. Required + libraries may be installed using setuptools' ``extras_require`` feature. + + A string referring to one of the following bundled classes: + + * ``sync`` + * ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via + ``pip install gunicorn[eventlet]``) + * ``gevent`` - Requires gevent >= 1.4 (or install it via + ``pip install gunicorn[gevent]``) + * ``tornado`` - Requires tornado >= 0.2 (or install it via + ``pip install gunicorn[tornado]``) + * ``gthread`` - Python 2 requires the futures package to be installed + (or install it via ``pip install gunicorn[gthread]``) + + Optionally, you can provide your own worker by giving Gunicorn a + Python path to a subclass of ``gunicorn.workers.base.Worker``. + This alternative syntax will load the gevent class: + ``gunicorn.workers.ggevent.GeventWorker``. + """ + + threads: int = 1 + """\ + The number of worker threads for handling requests. + + Run each worker with the specified number of threads. + + A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. + You'll want to vary this a bit to find the best for your particular + application's work load. + + If it is not defined, the default is ``1``. + + This setting only affects the Gthread worker type. + + .. note:: + If you try to use the ``sync`` worker type and set the ``threads`` + setting to more than 1, the ``gthread`` worker type will be used + instead. + """ + + worker_connections: int = 1000 + """\ + The maximum number of simultaneous clients. + + This setting only affects the ``gthread``, ``eventlet`` and ``gevent`` worker types. + """ + + max_requests: int = 0 + """\ + The maximum number of requests a worker will process before restarting. + + Any value greater than zero will limit the number of requests a worker + will process before automatically restarting. This is a simple method + to help limit the damage of memory leaks. + + If this is set to zero (the default) then the automatic worker + restarts are disabled. + """ + + max_requests_jitter: int = 0 + """\ + The maximum jitter to add to the *max_requests* setting. + + The jitter causes the restart per worker to be randomized by + ``randint(0, max_requests_jitter)``. This is intended to stagger worker + restarts to avoid all workers restarting at the same time. + """ + + timeout: int = 30 + """\ + Workers silent for more than this many seconds are killed and restarted. + + Value is a positive number or 0. Setting it to 0 has the effect of + infinite timeouts by disabling timeouts for all workers entirely. + + Generally, the default of thirty seconds should suffice. Only set this + noticeably higher if you're sure of the repercussions for sync workers. + For the non sync workers it just means that the worker process is still + communicating and is not tied to the length of time required to handle a + single request. + """ + + graceful_timeout: int = 30 + """\ + Timeout for graceful workers restart. + + After receiving a restart signal, workers have this much time to finish + serving requests. Workers still alive after the timeout (starting from + the receipt of the restart signal) are force killed. + """ + + keepalive: int = 2 + """\ + The number of seconds to wait for requests on a Keep-Alive connection. + + Generally set in the 1-5 seconds range for servers with direct connection + to the client (e.g. when you don't have separate load balancer). When + Gunicorn is deployed behind a load balancer, it often makes sense to + set this to a higher value. + + .. note:: + ``sync`` worker does not support persistent connections and will + ignore this option. + """ + + limit_request_line: int = 4094 + """\ + The maximum size of HTTP request line in bytes. + + This parameter is used to limit the allowed size of a client's + HTTP request-line. Since the request-line consists of the HTTP + method, URI, and protocol version, this directive places a + restriction on the length of a request-URI allowed for a request + on the server. A server needs this value to be large enough to + hold any of its resource names, including any information that + might be passed in the query part of a GET request. Value is a number + from 0 (unlimited) to 8190. + + This parameter can be used to prevent any DDOS attack. + """ + + limit_request_fields: int = 100 + """\ + Limit the number of HTTP headers fields in a request. + + This parameter is used to limit the number of headers in a request to + prevent DDOS attack. Used with the *limit_request_field_size* it allows + more safety. By default this value is 100 and can't be larger than + 32768. + """ + + limit_request_field_size: int = 8190 + """\ + Limit the allowed size of an HTTP request header field. + + Value is a positive number or 0. Setting it to 0 will allow unlimited + header field sizes. + + .. warning:: + Setting this parameter to a very high or unlimited value can open + up for DDOS attacks. + """ + + reload: bool = False + """\ + Restart workers when code changes. + + This setting is intended for development. It will cause workers to be + restarted whenever application code changes. + + The reloader is incompatible with application preloading. When using a + paste configuration be sure that the server block does not import any + application code or the reload will not work as designed. + + The default behavior is to attempt inotify with a fallback to file + system polling. Generally, inotify should be preferred if available + because it consumes less system resources. + + .. note:: + In order to use the inotify reloader, you must have the ``inotify`` + package installed. + """ + + reload_engine: Literal["auto", "poll", "inotify"] = "auto" + """\ + The implementation that should be used to power :ref:`reload`. + + Valid engines are: + + * ``'auto'`` + * ``'poll'`` + * ``'inotify'`` (requires inotify) + """ + + reload_extra_files: list[str] = [] + """\ + Extends :ref:`reload` option to also watch and reload on additional files + (e.g., templates, configurations, specifications, etc.). + """ + + spew: bool = False + """\ + Install a trace function that spews every line executed by the server. + + This is the nuclear option. + """ + + check_config: bool = False + """\ + Check the configuration and exit. The exit status is 0 if the + configuration is correct, and 1 if the configuration is incorrect. + """ + + print_config: bool = False + """\ + Print the configuration settings as fully resolved. Implies :ref:`check-config`. + """ + + preload_app: bool = False + """\ + Load application code before the worker processes are forked. + + By preloading an application you can save some RAM resources as well as + speed up server boot times. Although, if you defer application loading + to each worker process, you can reload your application code easily by + restarting workers. + """ + + sendfile: bool | None = None + """\ + Disables the use of ``sendfile()``. + + If not set, the value of the ``SENDFILE`` environment variable is used + to enable or disable its usage. + """ + + reuse_port: bool = False + """\ + Set the ``SO_REUSEPORT`` flag on the listening socket. + """ + + chdir: str = "." + """\ + Change directory to specified directory before loading apps. + """ + + daemon: bool = False + """\ + Daemonize the Gunicorn process. + + Detaches the server from the controlling terminal and enters the + background. + """ + + raw_env: list[str] = [] + """\ + Set environment variables in the execution environment. + + Should be a list of strings in the ``key=value`` format. + """ + + pidfile: str | None = None + """\ + A filename to use for the PID file. + + If not set, no PID file will be written. + """ + + worker_tmp_dir: str | None = None + """\ + A directory to use for the worker heartbeat temporary file. + + If not set, the default temporary directory will be used. + + .. note:: + The current heartbeat system involves calling ``os.fchmod`` on + temporary file handlers and may block a worker for arbitrary time + if the directory is on a disk-backed filesystem. + + See :ref:`blocking-os-fchmod` for more detailed information + and a solution for avoiding this problem. + """ + + user: int = os.geteuid() + """\ + Switch worker processes to run as this user. + + A valid user id (as an integer) or the name of a user that can be + retrieved with a call to ``pwd.getpwnam(value)`` or ``None`` to not + change the worker process user. + """ + + group: int = os.getegid() + """\ + Switch worker process to run as this group. + + A valid group id (as an integer) or the name of a user that can be + retrieved with a call to ``pwd.getgrnam(value)`` or ``None`` to not + change the worker processes group. + """ + + umask: int = 0 + """\ + A bit mask for the file mode on files written by Gunicorn. + + Note that this affects unix socket permissions. + + A valid value for the ``os.umask(mode)`` call or a string compatible + with ``int(value, 0)`` (``0`` means Python guesses the base, so values + like ``0``, ``0xFF``, ``0022`` are valid for decimal, hex, and octal + representations) + """ + + initgroups: bool = False + """\ + If true, set the worker process's group access list with all of the + groups of which the specified username is a member, plus the specified + group id. + """ + + tmp_upload_dir: str | None = None + """\ + Directory to store temporary request data as they are read. + + This may disappear in the near future. + + This path should be writable by the process permissions set for Gunicorn + workers. If not specified, Gunicorn will choose a system generated + temporary directory. + """ + + secure_scheme_headers: dict[str, Any] = {'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'} + """\ + A dictionary containing headers and values that the front-end proxy + uses to indicate HTTPS requests. If the source IP is permitted by + :ref:`forwarded-allow-ips` (below), *and* at least one request header matches + a key-value pair listed in this dictionary, then Gunicorn will set + ``wsgi.url_scheme`` to ``https``, so your application can tell that the + request is secure. + + If the other headers listed in this dictionary are not present in the request, they will be ignored, + but if the other headers are present and do not match the provided values, then + the request will fail to parse. See the note below for more detailed examples of this behaviour. + + The dictionary should map upper-case header names to exact string + values. The value comparisons are case-sensitive, unlike the header + names, so make sure they're exactly what your front-end proxy sends + when handling HTTPS requests. + + It is important that your front-end proxy configuration ensures that + the headers defined here can not be passed directly from the client. + """ + + forwarded_allow_ips: str = "127.0.0.1,::1" + """\ + Front-end's IPs from which allowed to handle set secure headers. + (comma separated). + + Set to ``*`` to disable checking of front-end IPs. This is useful for setups + where you don't know in advance the IP address of front-end, but + instead have ensured via other means that only your + authorized front-ends can access Gunicorn. + + By default, the value of the ``FORWARDED_ALLOW_IPS`` environment + variable. If it is not defined, the default is ``"127.0.0.1,::1"``. + + .. note:: + + This option does not affect UNIX socket connections. Connections not associated with + an IP address are treated as allowed, unconditionally. + + .. note:: + + The interplay between the request headers, the value of ``forwarded_allow_ips``, and the value of + ``secure_scheme_headers`` is complex. Various scenarios are documented below to further elaborate. + In each case, we have a request from the remote address 134.213.44.18, and the default value of + ``secure_scheme_headers``: + + .. code:: + + secure_scheme_headers = { + 'X-FORWARDED-PROTOCOL': 'ssl', + 'X-FORWARDED-PROTO': 'https', + 'X-FORWARDED-SSL': 'on' + } + + + .. list-table:: + :header-rows: 1 + :align: center + :widths: auto + + * - ``forwarded-allow-ips`` + - Secure Request Headers + - Result + - Explanation + * - ``["127.0.0.1"]`` + - ``X-Forwarded-Proto: https`` + - ``wsgi.url_scheme = "http"`` + - IP address was not allowed + * - ``"*"`` + - + - ``wsgi.url_scheme = "http"`` + - IP address allowed, but no secure headers provided + * - ``"*"`` + - ``X-Forwarded-Proto: https`` + - ``wsgi.url_scheme = "https"`` + - IP address allowed, one request header matched + * - ``["134.213.44.18"]`` + - ``X-Forwarded-Ssl: on`` ``X-Forwarded-Proto: http`` + - ``InvalidSchemeHeaders()`` raised + - IP address allowed, but the two secure headers disagreed on if HTTPS was used + """ + + accesslog: str | None = None + """\ + The Access log file to write to. + + ``'-'`` means log to stdout. + """ + + disable_redirect_access_to_syslog: bool = False + """\ + Disable redirect access logs to syslog. + """ + + access_log_format: str = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + """\ + The access log format. + + =========== =========== + Identifier Description + =========== =========== + h remote address + l ``'-'`` + u user name (if HTTP Basic auth used) + t date of the request + r status line (e.g. ``GET / HTTP/1.1``) + m request method + U URL path without query string + q query string + H protocol + s status + B response length + b response length or ``'-'`` (CLF format) + f referrer (note: header is ``referer``) + a user agent + T request time in seconds + M request time in milliseconds + D request time in microseconds + L request time in decimal seconds + p process ID + {header}i request header + {header}o response header + {variable}e environment variable + =========== =========== + + Use lowercase for header and environment variable names, and put + ``{...}x`` names inside ``%(...)s``. For example:: + + %({x-forwarded-for}i)s + """ + + errorlog: str = "-" + """\ + The Error log file to write to. + + Using ``'-'`` for FILE makes gunicorn log to stderr. + """ + + loglevel: Literal["debug", "info", "warning", "error", "critical"] = "info" + """\ + The granularity of Error log outputs. + """ + + capture_output: bool = False + """\ + Redirect stdout/stderr to specified file in :ref:`errorlog`. + """ + + logger_class: str = "gunicorn.glogging.Logger" + """\ + The logger you want to use to log events in Gunicorn. + + The default class (``gunicorn.glogging.Logger``) handles most + normal usages in logging. It provides error and access logging. + + You can provide your own logger by giving Gunicorn a Python path to a + class that quacks like ``gunicorn.glogging.Logger``. + """ + + logconfig: str | None = None + """\ + The log config file to use. + Gunicorn uses the standard Python logging module's Configuration + file format. + """ + + logconfig_dict: dict = {} + """\ + The log config dictionary to use, using the standard Python + logging module's dictionary configuration format. This option + takes precedence over the :ref:`logconfig` and :ref:`logconfig-json` options, + which uses the older file configuration format and JSON + respectively. + + Format: https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig + + For more context you can look at the default configuration dictionary for logging, + which can be found at ``gunicorn.glogging.CONFIG_DEFAULTS``. + """ + + logconfig_json: str | None = None + """\ + The log config to read config from a JSON file + + Format: https://docs.python.org/3/library/logging.config.html#logging.config.jsonConfig + """ + + syslog_addr: str = ( + "unix:///var/run/syslog" + if sys.platform == "darwin" + else ( + "unix:///var/run/log" + if sys.platform in ('freebsd', 'dragonfly', ) + else ( + "unix:///dev/log" + if sys.platform == "openbsd" + else "udp://localhost:514" + ) + ) + ) + """\ + Address to send syslog messages. + + Address is a string of the form: + * ``unix://PATH#TYPE`` : for unix domain socket, ``TYPE`` can be ``stream`` for the stream driver or ``dgram`` for the dgram driver, ``stream`` is the default + * ``udp://HOST:PORT`` : for UDP sockets + * ``tcp://HOST:PORT`` : for TCP sockets + """ + + syslog: bool = False + """\ + Send *Gunicorn* logs to syslog. + """ + + syslog_prefix: str | None = None + """\ + Makes Gunicorn use the parameter as program-name in the syslog entries. + + All entries will be prefixed by ``gunicorn.``. By default the + program name is the name of the process. + """ + + syslog_facility: str = "user" + """\ + Syslog facility name + """ + + enable_stdio_inheritance: bool = False + """\ + Enable stdio inheritance. + + Enable inheritance for stdio file descriptors in daemon mode. + + Note: To disable the Python stdout buffering, you can to set the user + environment variable ``PYTHONUNBUFFERED`` . + """ + + statsd_host: str | None = None + """\ + The address of the StatsD server to log to. + + Address is a string of the form: + + * ``unix://PATH`` : for a unix domain socket. + * ``HOST:PORT`` : for a network address + """ + + dogstatsd_tags: str = "" + """\ + A comma-delimited list of datadog statsd (dogstatsd) tags to append to + statsd metrics. + """ + + statsd_prefix: str = "" + """\ + Prefix to use when emitting statsd metrics (a trailing ``.`` is added, + if not provided). + """ + + proc_name: str | None = None + """\ + A base to use with setproctitle for process naming. + + This affects things like ``ps`` and ``top``. If you're going to be + running more than one instance of Gunicorn you'll probably want to set a + name to tell them apart. This requires that you install the setproctitle + module. + + If not set, the *default_proc_name* setting will be used. + """ + + default_proc_name: str = "gunicorn" + """\ + Internal setting that is adjusted for each type of application. + """ + + pythonpath: str | None = None + """\ + A comma-separated list of directories to add to the Python path. + + e.g. + ``'/home/djangoprojects/myproject,/home/python/mylibrary'``. + """ + + paste: str | None = None + """\ + Load a PasteDeploy config file. The argument may contain a ``#`` + symbol followed by the name of an app section from the config file, + e.g. ``production.ini#admin``. + + At this time, using alternate server blocks is not supported. Use the + command line arguments to control server configuration instead. + """ + + on_starting: Callable = lambda server: None + """\ + Called just before the master process is initialized. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + on_reload: Callable = lambda server: None + """\ + Called to recycle workers during a reload via SIGHUP. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + when_ready: Callable = lambda server: None + """\ + Called just after the server is started. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + pre_fork: Callable = lambda server, worker: None + """\ + Called just before a worker is forked. + + The callable needs to accept two instance variables for the Arbiter and + new Worker. + """ + + post_fork: Callable = lambda server, worker: None + """\ + Called just after a worker has been forked. + + The callable needs to accept two instance variables for the Arbiter and + new Worker. + """ + + post_worker_init: Callable = lambda worker: None + """\ + Called just after a worker has initialized the application. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + worker_int: Callable = lambda worker: None + """\ + Called just after a worker exited on SIGINT or SIGQUIT. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + worker_abort: Callable = lambda worker: None + """\ + Called when a worker received the SIGABRT signal. + + This call generally happens on timeout. + + The callable needs to accept one instance variable for the initialized + Worker. + """ + + pre_exec: Callable = lambda server: None + """\ + Called just before a new master process is forked. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + pre_request: Callable = lambda worker, req: worker.log.debug("%s %s", req.method, req.path) + """\ + Called just before a worker processes the request. + + The callable needs to accept two instance variables for the Worker and + the Request. + """ + + post_request: Callable = lambda worker, req, environ, resp: None + """\ + Called after a worker processes the request. + + The callable needs to accept two instance variables for the Worker and + the Request. + """ + + child_exit: Callable = lambda server, worker: None + """\ + Called just after a worker has been exited, in the master process. + + The callable needs to accept two instance variables for the Arbiter and + the just-exited Worker. + """ + + worker_exit: Callable = lambda server, worker: None + """\ + Called just after a worker has been exited, in the worker process. + + The callable needs to accept two instance variables for the Arbiter and + the just-exited Worker. + """ + + nworkers_changed: Callable = lambda server, new_value, old_value: None + """\ + Called just after *num_workers* has been changed. + + The callable needs to accept an instance variable of the Arbiter and + two integers of number of workers after and before change. + + If the number of workers is set for the first time, *old_value* would + be ``None``. + """ + + on_exit: Callable = lambda server: None + """\ + Called just before exiting Gunicorn. + + The callable needs to accept a single instance variable for the Arbiter. + """ + + ssl_context: Callable = lambda config, default_ssl_context_factory: default_ssl_context_factory() + """\ + Called when SSLContext is needed. + + Allows customizing SSL context. + + The callable needs to accept an instance variable for the Config and + a factory function that returns default SSLContext which is initialized + with certificates, private key, cert_reqs, and ciphers according to + config and can be further customized by the callable. + The callable needs to return SSLContext object. + + Following example shows a configuration file that sets the minimum TLS version to 1.3: + + .. code-block:: python + + def ssl_context(conf, default_ssl_context_factory): + import ssl + context = default_ssl_context_factory() + context.minimum_version = ssl.TLSVersion.TLSv1_3 + return context + """ + + proxy_protocol: bool = False + """\ + Enable detect PROXY protocol (PROXY mode). + + Allow using HTTP and Proxy together. It may be useful for work with + stunnel as HTTPS frontend and Gunicorn as HTTP server. + + PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt + + Example for stunnel config:: + + [https] + protocol = proxy + accept = 443 + connect = 80 + cert = /etc/ssl/certs/stunnel.pem + key = /etc/ssl/certs/stunnel.key + """ + + proxy_allow_ips: str = "127.0.0.1,::1" + """\ + Front-end's IPs from which allowed accept proxy requests (comma separated). + + Set to ``*`` to disable checking of front-end IPs. This is useful for setups + where you don't know in advance the IP address of front-end, but + instead have ensured via other means that only your + authorized front-ends can access Gunicorn. + + .. note:: + + This option does not affect UNIX socket connections. Connections not associated with + an IP address are treated as allowed, unconditionally. + """ + + keyfile: str | None = None + """\ + SSL key file + """ + + certfile: str | None = None + """\ + SSL certificate file + """ + + ssl_version: int = ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + """\ + SSL version to use (see stdlib ssl module's). + + .. deprecated:: 21.0 + The option is deprecated and it is currently ignored. Use :ref:`ssl-context` instead. + + ============= ============ + --ssl-version Description + ============= ============ + SSLv3 SSLv3 is not-secure and is strongly discouraged. + SSLv23 Alias for TLS. Deprecated in Python 3.6, use TLS. + TLS Negotiate highest possible version between client/server. + Can yield SSL. (Python 3.6+) + TLSv1 TLS 1.0 + TLSv1_1 TLS 1.1 (Python 3.4+) + TLSv1_2 TLS 1.2 (Python 3.4+) + TLS_SERVER Auto-negotiate the highest protocol version like TLS, + but only support server-side SSLSocket connections. + (Python 3.6+) + ============= ============ + + .. versionchanged:: 19.7 + The default value has been changed from ``ssl.PROTOCOL_TLSv1`` to + ``ssl.PROTOCOL_SSLv23``. + .. versionchanged:: 20.0 + This setting now accepts string names based on ``ssl.PROTOCOL_`` + constants. + .. versionchanged:: 20.0.1 + The default value has been changed from ``ssl.PROTOCOL_SSLv23`` to + ``ssl.PROTOCOL_TLS`` when Python >= 3.6 . + """ + + cert_reqs: int = ssl.CERT_NONE + """\ + Whether client certificate is required (see stdlib ssl module's) + + =========== =========================== + --cert-reqs Description + =========== =========================== + `0` ``ssl.CERT_NONE`` + `1` ``ssl.CERT_OPTIONAL`` + `2` ``ssl.CERT_REQUIRED`` + =========== =========================== + """ + + ca_certs: str | None = None + """\ + CA certificates file + """ + + suppress_ragged_eofs: bool = True + """\ + Suppress ragged EOFs (see stdlib ssl module's) + """ + + do_handshake_on_connect: bool = False + """\ + Whether to perform SSL handshake on socket connect (see stdlib ssl module's) + """ + + ciphers: str | None = None + """\ + SSL Cipher suite to use, in the format of an OpenSSL cipher list. + + By default we use the default cipher list from Python's ``ssl`` module, + which contains ciphers considered strong at the time of each Python + release. + + As a recommended alternative, the Open Web App Security Project (OWASP) + offers `a vetted set of strong cipher strings rated A+ to C- + `_. + OWASP provides details on user-agent compatibility at each security level. + + See the `OpenSSL Cipher List Format Documentation + `_ + for details on the format of an OpenSSL cipher list. + """ + + raw_paste_global_conf: list[str] = [] + """\ + Set a PasteDeploy global config variable in ``key=value`` form. + + The option can be specified multiple times. + + The variables are passed to the PasteDeploy entrypoint. Example:: + + $ gunicorn -b 127.0.0.1:8000 --paste development.ini --paste-global FOO=1 --paste-global BAR=2 + """ + + permit_obsolete_folding: bool = False + """\ + Permit requests employing obsolete HTTP line folding mechanism + + The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be + employed in HTTP request headers from standards-compliant HTTP clients. + + This option is provided to diagnose backwards-incompatible changes. + Use with care and only if necessary. Temporary; the precise effect of this option may + change in a future version, or it may be removed altogether. + """ + + strip_header_spaces: bool = False + """\ + Strip spaces present between the header name and the the ``:``. + + This is known to induce vulnerabilities and is not compliant with the HTTP/1.1 standard. + See https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn. + + Use with care and only if necessary. Deprecated; scheduled for removal in 25.0.0 + """ + + permit_unconventional_http_method: bool = False + """\ + Permit HTTP methods not matching conventions, such as IANA registration guidelines + + This permits request methods of length less than 3 or more than 20, + methods with lowercase characters or methods containing the # character. + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + If unset, Gunicorn will apply nonstandard restrictions and cause 400 response status + in cases where otherwise 501 status is expected. While this option does modify that + behaviour, it should not be depended upon to guarantee standards-compliant behaviour. + Rather, it is provided temporarily, to assist in diagnosing backwards-incompatible + changes around the incomplete application of those restrictions. + + Use with care and only if necessary. Temporary; scheduled for removal in 24.0.0 + """ + + permit_unconventional_http_version: bool = False + """\ + Permit HTTP version not matching conventions of 2023 + + This disables the refusal of likely malformed request lines. + It is unusual to specify HTTP 1 versions other than 1.0 and 1.1. + + This option is provided to diagnose backwards-incompatible changes. + Use with care and only if necessary. Temporary; the precise effect of this option may + change in a future version, or it may be removed altogether. + """ + + casefold_http_method: bool = False + """\ + Transform received HTTP methods to uppercase + + HTTP methods are case sensitive by definition, and merely uppercase by convention. + + This option is provided because previous versions of gunicorn defaulted to this behaviour. + + Use with care and only if necessary. Deprecated; scheduled for removal in 24.0.0 + """ + + forwarder_headers: str = "SCRIPT_NAME,PATH_INFO" + """\ + A list containing upper-case header field names that the front-end proxy + (see :ref:`forwarded-allow-ips`) sets, to be used in WSGI environment. + + This option has no effect for headers not present in the request. + + This option can be used to transfer ``SCRIPT_NAME``, ``PATH_INFO`` + and ``REMOTE_USER``. + + It is important that your front-end proxy configuration ensures that + the headers defined here can not be passed directly from the client. + """ + + header_map: Literal["drop", "refuse", "dangerous"] = "drop" + """\ + Configure how header field names are mapped into environ + + Headers containing underscores are permitted by RFC9110, + but gunicorn joining headers of different names into + the same environment variable will dangerously confuse applications as to which is which. + + The safe default ``drop`` is to silently drop headers that cannot be unambiguously mapped. + The value ``refuse`` will return an error if a request contains *any* such header. + The value ``dangerous`` matches the previous, not advisable, behaviour of mapping different + header field names into the same environ name. + + If the source is permitted as explained in :ref:`forwarded-allow-ips`, *and* the header name is + present in :ref:`forwarder-headers`, the header is mapped into environment regardless of + the state of this setting. + + Use with care and only if necessary and after considering if your problem could + instead be solved by specifically renaming or rewriting only the intended headers + on a proxy in front of Gunicorn. + """ + + # def __init__(self, *args, **kwargs): + # super().__init__(*args, **kwargs) + + def run_prod(self) -> list[str]: + print("[reflex.server.gunicorn::GunicornBackendServer] start") + command = ["gunicorn"] + + for key,field in self.get_fields().items(): + if key != "app": + value = self.__getattribute__(key) + if key == "preload": + print(_mapping_attr_to_cli.get(key, None), value, field.default) + if _mapping_attr_to_cli.get(key, None): + if value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + command.append(_mapping_attr_to_cli[key]) + else: + command += [_mapping_attr_to_cli[key], str(value)] + + print("[reflex.server.gunicorn::GunicornBackendServer] done") + return command + [f"reflex.app_module_for_backend:{constants.CompileVars.APP}()"] + + def run_dev(self): + StandaloneApplication( + app=self.app, + options=self.dict().items() + ).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py new file mode 100644 index 000000000..b76f12a3e --- /dev/null +++ b/reflex/server/uvicorn.py @@ -0,0 +1,10 @@ +from reflex.server.base import CustomBackendServer + + +class UvicornBackendServer(CustomBackendServer): + + def run_prod(self): + pass + + def run_dev(self): + pass diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 621c4a608..a1d7030ab 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -2,6 +2,9 @@ from __future__ import annotations +from abc import abstractmethod, ABCMeta +from typing import IO, Any, Literal, Sequence, Type + import hashlib import json import os @@ -9,12 +12,18 @@ import platform import re import subprocess import sys +import asyncio +import ssl +from configparser import RawConfigParser from pathlib import Path from urllib.parse import urljoin +from pydantic import Field +from dataclasses import dataclass import psutil -from reflex import constants +from reflex import constants, server +from reflex.base import Base from reflex.config import environment, get_config from reflex.constants.base import LogLevel from reflex.utils import console, path_ops @@ -194,6 +203,7 @@ def get_app_module(): The app module for the backend. """ return f"reflex.app_module_for_backend:{constants.CompileVars.APP}" +### REWORK <-- def get_granian_target(): @@ -316,65 +326,36 @@ def run_backend_prod( loglevel: The log level. frontend_present: Whether the frontend is present. """ + print("[reflex.utils.exec::run_backend_prod] start") if not frontend_present: notify_backend() + config = get_config() + if should_use_granian(): run_granian_backend_prod(host, port, loglevel) else: - run_uvicorn_backend_prod(host, port, loglevel) + from reflex.utils import processes + backend_server_prod = config.backend_server_prod + if isinstance(backend_server_prod, server.GunicornBackendServer): + backend_server_prod.app = f"{get_app_module()}()" + backend_server_prod.preload_app = True + backend_server_prod.loglevel = loglevel.value # type: ignore + backend_server_prod.bind = [f"{host}:{port}"] + backend_server_prod.threads = _get_backend_workers() + backend_server_prod.workers = _get_backend_workers() -def run_uvicorn_backend_prod(host, port, loglevel): - """Run the backend in production mode using Uvicorn. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - from reflex.utils import processes - - config = get_config() - - app_module = get_app_module() - - RUN_BACKEND_PROD = f"gunicorn --worker-class {config.gunicorn_worker_class} --max-requests {config.gunicorn_max_requests} --max-requests-jitter {config.gunicorn_max_requests_jitter} --preload --timeout {config.timeout} --log-level critical".split() - RUN_BACKEND_PROD_WINDOWS = f"uvicorn --limit-max-requests {config.gunicorn_max_requests} --timeout-keep-alive {config.timeout}".split() - command = ( - [ - *RUN_BACKEND_PROD_WINDOWS, - "--host", - host, - "--port", - str(port), - app_module, - ] - if constants.IS_WINDOWS - else [ - *RUN_BACKEND_PROD, - "--bind", - f"{host}:{port}", - "--threads", - str(_get_backend_workers()), - f"{app_module}()", - ] - ) - - command += [ - "--log-level", - loglevel.value, - "--workers", - str(_get_backend_workers()), - ] - processes.new_process( - command, - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) + print(backend_server_prod.run_prod()) + processes.new_process( + backend_server_prod.run_prod(), + run=True, + show_logs=True, + env={ + environment.REFLEX_SKIP_COMPILE.name: "true" + }, # skip compile for prod backend + ) + print("[reflex.utils.exec::run_backend_prod] done") def run_granian_backend_prod(host, port, loglevel): @@ -418,6 +399,7 @@ def run_granian_backend_prod(host, port, loglevel): ) +### REWORK--> def output_system_info(): """Show system information if the loglevel is in DEBUG.""" if console._LOG_LEVEL > constants.LogLevel.DEBUG: @@ -540,3 +522,6 @@ def should_skip_compile() -> bool: removal_version="0.7.0", ) return environment.REFLEX_SKIP_COMPILE.get() + + +### REWORK <-- From 60ff80027030dd4ed12e6dfc8470753286c9c3e7 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 16:29:16 +0000 Subject: [PATCH 18/25] add granian prod&dev, add gunicorn dev --- reflex/config.py | 53 ++++++-- reflex/server/__init__.py | 2 +- reflex/server/base.py | 85 +++++++++++- reflex/server/granian.py | 268 +++++++++++++++++++++++++++++++++++--- reflex/server/gunicorn.py | 186 +++++++++++++++++--------- reflex/server/uvicorn.py | 31 +++++ reflex/utils/exec.py | 204 ++++------------------------- 7 files changed, 556 insertions(+), 273 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 6b10efba5..852383122 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -697,15 +697,28 @@ class Config(Base): env_file: Optional[str] = None # Custom Backend Server - backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - app=f"reflex.app_module_for_backend:{constants.CompileVars.APP}.{constants.CompileVars.API}", - worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - max_requests=100, - max_requests_jitter=25, - preload_app=True, - timeout=120, + # backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( + # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + # max_requests=100, + # max_requests_jitter=25, + # timeout=120, + # ) + backend_server_prod: server.CustomBackendServer = server.GranianBackendServer( + threads=2, + workers=4, ) - backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer() + backend_server_dev: server.CustomBackendServer = server.GranianBackendServer( + threads=1, + workers=1, + ) + # backend_server_dev: server.CustomBackendServer = server.GunicornBackendServer( + # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore + # max_requests=100, + # max_requests_jitter=25, + # timeout=120, + # threads=1, + # workers=1, + # ) def __init__(self, *args, **kwargs): """Initialize the config values. @@ -737,14 +750,26 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - - print("[reflex.config::Config] --") - for key in ("timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"): + + for key in ( + "timeout", + "gunicorn_worker_class", + "gunicorn_workers", + "gunicorn_max_requests", + "gunicorn_max_requests_jitter", + ): if isinstance(self.backend_server_prod, server.GunicornBackendServer): value = self.get_value(key) - if value != self.backend_server_prod.get_fields()[key.replace("gunicorn_", "")].default and value is not None: - setattr(self.backend_server_prod, key.replace("gunicorn_", ""), value) - print("[reflex.config::Config] done") + if ( + value + != self.backend_server_prod.get_fields()[ + key.replace("gunicorn_", "") + ].default + and value is not None + ): + setattr( + self.backend_server_prod, key.replace("gunicorn_", ""), value + ) @property def module(self) -> str: diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py index c44f2ca11..9633ddc7c 100644 --- a/reflex/server/__init__.py +++ b/reflex/server/__init__.py @@ -1,4 +1,4 @@ - +"""Import every *BackendServer.""" from .base import CustomBackendServer from .granian import GranianBackendServer diff --git a/reflex/server/base.py b/reflex/server/base.py index c327d5c89..c821c2686 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -1,14 +1,95 @@ -from abc import abstractmethod, ABCMeta +"""The base for CustomBackendServer.""" +from __future__ import annotations + +import os +from abc import abstractmethod +from pathlib import Path + +from reflex import constants from reflex.base import Base +from reflex.constants.base import Env, LogLevel class CustomBackendServer(Base): - + """BackendServer base.""" + + @staticmethod + def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): + """Get the app module for the backend. + + Returns: + The app module for the backend. + """ + import reflex + + if for_granian_target: + app_path = str(Path(reflex.__file__).parent / "app_module_for_backend.py") + else: + app_path = "reflex.app_module_for_backend" + + return f"{app_path}:{constants.CompileVars.APP}{f'.{constants.CompileVars.API}' if add_extra_api else ''}" + + def get_available_cpus(self) -> int: + """Get available cpus.""" + return os.cpu_count() or 1 + + def get_max_workers(self) -> int: + """Get max workers.""" + # https://docs.gunicorn.org/en/latest/settings.html#workers + return (os.cpu_count() or 1) * 4 + 1 + + def get_recommended_workers(self) -> int: + """Get recommended workers.""" + # https://docs.gunicorn.org/en/latest/settings.html#workers + return (os.cpu_count() or 1) * 2 + 1 + + def get_max_threads(self, wait_time_ms: int = 50, service_time_ms: int = 5) -> int: + """Get max threads.""" + # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html + # Brian Goetz formula + return int(self.get_available_cpus() * (1 + wait_time_ms / service_time_ms)) + + def get_recommended_threads( + self, + target_reqs: int | None = None, + wait_time_ms: int = 50, + service_time_ms: int = 5, + ) -> int: + """Get recommended threads.""" + # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html + max_available_threads = self.get_max_threads() + + if target_reqs: + # Little's law formula + need_threads = target_reqs * ( + (wait_time_ms / 1000) + (service_time_ms / 1000) + ) + else: + need_threads = self.get_max_threads(wait_time_ms, service_time_ms) + + return int( + max_available_threads + if need_threads > max_available_threads + else need_threads + ) + + @abstractmethod + def check_import(self, extra: bool = False): + """Check package importation.""" + raise NotImplementedError() + + @abstractmethod + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + raise NotImplementedError() + @abstractmethod def run_prod(self): + """Run in production mode.""" raise NotImplementedError() @abstractmethod def run_dev(self): + """Run in development mode.""" raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 7a2fde844..fda03912c 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -1,36 +1,272 @@ +"""The GranianBackendServer.""" +from __future__ import annotations + +import sys from dataclasses import dataclass +from dataclasses import field as dc_field +from pathlib import Path +from typing import Any, Literal, Type +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer +from reflex.utils import console + @dataclass -class HTTP1Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 - keep_alive: bool = True - max_buffer_size: int = 8192 + 4096 * 100 - pipeline_flush: bool = False +class HTTP1Settings: + """Granian HTTP1Settings.""" + + # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L6 + keep_alive: bool = dc_field(default=True) + max_buffer_size: int = dc_field(default=8192 + 4096 * 100) + pipeline_flush: bool = dc_field(default=False) @dataclass class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba3fd93bca10300e91d1498bee6df9e3576/granian/http.py#L13 - adaptive_window: bool = False - initial_connection_window_size: int = 1024 * 1024 - initial_stream_window_size: int = 1024 * 1024 - keep_alive_interval: int | None = None - keep_alive_timeout: int = 20 - max_concurrent_streams: int = 200 - max_frame_size: int = 1024 * 16 - max_headers_size: int = 16 * 1024 * 1024 - max_send_buffer_size: int = 1024 * 400 + """Granian HTTP2Settings.""" + + adaptive_window: bool = dc_field(default=False) + initial_connection_window_size: int = dc_field(default=1024 * 1024) + initial_stream_window_size: int = dc_field(default=1024 * 1024) + keep_alive_interval: int | None = dc_field(default=None) + keep_alive_timeout: int = dc_field(default=20) + max_concurrent_streams: int = dc_field(default=200) + max_frame_size: int = dc_field(default=1024 * 16) + max_headers_size: int = dc_field(default=16 * 1024 * 1024) + max_send_buffer_size: int = dc_field(default=1024 * 400) + try: - import watchfiles + import watchfiles # type: ignore except ImportError: watchfiles = None +_mapping_attr_to_cli: dict[str, str] = { + "address": "--host", + "port": "--port", + "interface": "--interface", + "http": "--http", + "websockets": "--ws", # NOTE: when `websockets` True: `--ws`; False: `--no-ws` + "workers": "--workers", + "threads": "--threads", + "blocking_threads": "--blocking-threads", + "threading_mode": "--threading-mode", + "loop": "--loop", + "loop_opt": "--opt", # NOTE: when `loop_opt` True: `--opt`; False: `--no-opt` + "backlog": "--backlog", + "backpressure": "--backpressure", + "http1_keep_alive": "--http1-keep-alive", + "http1_max_buffer_size": "--http1-max-buffer-size", + "http1_pipeline_flush": "--http1-pipeline-flush", + "http2_adaptive_window": "--http2-adaptive-window", + "http2_initial_connection_window_size": "--http2-initial-connection-window-size", + "http2_initial_stream_window_size": "--http2-initial-stream-window-size", + "http2_keep_alive_interval": "--http2-keep-alive-interval", + "http2_keep_alive_timeout": "--http2-keep-alive-timeout", + "http2_max_concurrent_streams": "--http2-max-concurrent-streams", + "http2_max_frame_size": "--http2-max-frame-size", + "http2_max_headers_size": "--http2-max-headers-size", + "http2_max_send_buffer_size": "--http2-max-send-buffer-size", + "log_enabled": "--log", # NOTE: when `log_enabled` True: `--log`; False: `--no-log` + "log_level": "--log-level", + "log_access": "--log-access", # NOTE: when `log_access` True: `--log-access`; False: `--no-log-access` + "log_access_format": "--access-log-fmt", + "ssl_cert": "--ssl-certificate", + "ssl_key": "--ssl-keyfile", + "ssl_key_password": "--ssl-keyfile-password", + "url_path_prefix": "--url-path-prefix", + "respawn_failed_workers": "--respawn-failed-workers", # NOTE: when `respawn_failed_workers` True: `--respawn-failed-workers`; False: `--no-respawn-failed-workers` + "respawn_interval": "--respawn-interval", + "workers_lifetime": "--workers-lifetime", + "factory": "--factory", # NOTE: when `factory` True: `--factory`; False: `--no-factory` + "reload": "--reload", # NOTE: when `reload` True: `--reload`; False: `--no-reload` + "reload_paths": "--reload-paths", + "reload_ignore_dirs": "--reload-ignore-dirs", + "reload_ignore_patterns": "--reload-ignore-patterns", + "reload_ignore_paths": "--reload-ignore-paths", + "process_name": "--process-name", + "pid_file": "--pid-file", +} + + class GranianBackendServer(CustomBackendServer): + """Granian backendServer.""" + + # https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/server.py#L69 + + target: str | None = None + address: str = "127.0.0.1" + port: int = 8000 + interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = "rsgi" + workers: int = 0 + threads: int = 0 + blocking_threads: int | None = None + threading_mode: Literal["runtime", "workers"] = "workers" + loop: Literal["auto", "asyncio", "uvloop"] = "auto" + loop_opt: bool = False + http: Literal["auto", "1", "2"] = "auto" + websockets: bool = True + backlog: int = 1024 + backpressure: int | None = None + + # http1_settings: HTTP1Settings | None = None + # NOTE: child of http1_settings, needed only for cli mode + http1_keep_alive: bool = HTTP1Settings.keep_alive + http1_max_buffer_size: int = HTTP1Settings.max_buffer_size + http1_pipeline_flush: bool = HTTP1Settings.pipeline_flush + + # http2_settings: HTTP2Settings | None = None + # NOTE: child of http2_settings, needed only for cli mode + http2_adaptive_window: bool = HTTP2Settings.adaptive_window + http2_initial_connection_window_size: int = ( + HTTP2Settings.initial_connection_window_size + ) + http2_initial_stream_window_size: int = HTTP2Settings.initial_stream_window_size + http2_keep_alive_interval: int | None = HTTP2Settings.keep_alive_interval + http2_keep_alive_timeout: int = HTTP2Settings.keep_alive_timeout + http2_max_concurrent_streams: int = HTTP2Settings.max_concurrent_streams + http2_max_frame_size: int = HTTP2Settings.max_frame_size + http2_max_headers_size: int = HTTP2Settings.max_headers_size + http2_max_send_buffer_size: int = HTTP2Settings.max_send_buffer_size + + log_enabled: bool = True + log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = "info" + log_dictconfig: dict[str, Any] | None = None + log_access: bool = False + log_access_format: str | None = None + ssl_cert: Path | None = None + ssl_key: Path | None = None + ssl_key_password: str | None = None + url_path_prefix: str | None = None + respawn_failed_workers: bool = False + respawn_interval: float = 3.5 + workers_lifetime: int | None = None + factory: bool = False + reload: bool = False + reload_paths: list[Path] | None = None + reload_ignore_dirs: list[str] | None = None + reload_ignore_patterns: list[str] | None = None + reload_ignore_paths: list[Path] | None = None + reload_filter: Type[getattr(watchfiles, "BaseFilter", None)] | None = None # type: ignore + process_name: str | None = None + pid_file: Path | None = None + + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("granian") is None: + errors.append( + 'The `granian` package is required to run `GranianBackendServer`. Run `pip install "granian>=1.6.0"`.' + ) + + if find_spec("watchfiles") is None and extra: + # NOTE: the `\[` is for force `rich.Console` to not consider it like a color or anything else which he not printing `[.*]` + errors.append( + r'Using --reload in `GranianBackendServer` requires the granian\[reload] extra. Run `pip install "granian\[reload]>=1.6.0"`.' + ) # type: ignore + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + self.target = self.get_app_module(for_granian_target=True, add_extra_api=True) + self.log_level = loglevel.value # type: ignore + self.address = host + self.port = port + self.interface = "asgi" # prevent obvious error + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_threads := self.get_max_workers()): + self.workers = max_threads + + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads + + if env == Env.DEV: + from reflex.config import get_config # prevent circular import + + self.reload = True + self.reload_paths = [Path(get_config().app_name)] + self.reload_ignore_dirs = [".web"] def run_prod(self): - pass + """Run in production mode.""" + self.check_import() + command = ["granian"] + + for key, field in self.get_fields().items(): + if key != "target": + value = getattr(self, key) + if _mapping_attr_to_cli.get(key) and value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + command.append( + f"--{'no-' if value is False else ''}{_mapping_attr_to_cli[key][2:]}" + ) + else: + command += [_mapping_attr_to_cli[key], str(value)] + + return command + [ + self.get_app_module(for_granian_target=True, add_extra_api=True) + ] def run_dev(self): - pass + """Run in development mode.""" + self.check_import(extra=self.reload) + from granian import Granian + + exclude_keys = ( + "http1_keep_alive", + "http1_max_buffer_size", + "http1_pipeline_flush", + "http2_adaptive_window", + "http2_initial_connection_window_size", + "http2_initial_stream_window_size", + "http2_keep_alive_interval", + "http2_keep_alive_timeout", + "http2_max_concurrent_streams", + "http2_max_frame_size", + "http2_max_headers_size", + "http2_max_send_buffer_size", + ) + model = self.get_fields() + Granian( + **{ + **{ + key: value + for key, value in self.dict().items() + if key not in exclude_keys and value != model[key].default + }, + "http1_settings": HTTP1Settings( + self.http1_keep_alive, + self.http1_max_buffer_size, + self.http1_pipeline_flush, + ), + "http2_settings": HTTP2Settings( + self.http2_adaptive_window, + self.http2_initial_connection_window_size, + self.http2_initial_stream_window_size, + self.http2_keep_alive_interval, + self.http2_keep_alive_timeout, + self.http2_max_concurrent_streams, + self.http2_max_frame_size, + self.http2_max_headers_size, + self.http2_max_send_buffer_size, + ), + } + ).serve() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 18dfb7841..659cb5262 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -1,34 +1,15 @@ -from typing import Any, Literal, Callable +"""The GunicornBackendServer.""" + +from __future__ import annotations import os -import sys import ssl -from pydantic import Field +import sys +from typing import Any, Callable, Literal -from gunicorn.app.base import BaseApplication - -import psutil - -from reflex import constants +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer - - -class StandaloneApplication(BaseApplication): - - def __init__(self, app, options=None): - self.options = options or {} - self.application = app - super().__init__() - - def load_config(self): - config = {key: value for key, value in self.options.items() - if key in self.cfg.settings and value is not None} # type: ignore - for key, value in config.items(): - self.cfg.set(key.lower(), value) # type: ignore - - def load(self): - return self.application - +from reflex.utils import console _mapping_attr_to_cli: dict[str, str] = { "config": "--config", @@ -105,10 +86,13 @@ _mapping_attr_to_cli: dict[str, str] = { "header_map": "--header-map", } + class GunicornBackendServer(CustomBackendServer): + """Gunicorn backendServer.""" + # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - app: str + app_uri: str | None config: str = "./gunicorn.conf.py" """\ @@ -128,7 +112,7 @@ class GunicornBackendServer(CustomBackendServer): A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. """ - bind: list[str] = ['127.0.0.1:8000'] + bind: list[str] = ["127.0.0.1:8000"] """\ The socket to bind. @@ -162,7 +146,7 @@ class GunicornBackendServer(CustomBackendServer): Must be a positive integer. Generally set in the 64-2048 range. """ - workers: int = 1 + workers: int = 0 """\ The number of worker processes for handling requests. @@ -175,7 +159,14 @@ class GunicornBackendServer(CustomBackendServer): it is not defined, the default is ``1``. """ - worker_class: Literal["sync", "eventlet", "gevent", "tornado", "gthread", "uvicorn.workers.UvicornH11Worker"] = "sync" + worker_class: Literal[ + "sync", + "eventlet", + "gevent", + "tornado", + "gthread", + "uvicorn.workers.UvicornH11Worker", + ] = "sync" """\ The type of workers to use. @@ -202,7 +193,7 @@ class GunicornBackendServer(CustomBackendServer): ``gunicorn.workers.ggevent.GeventWorker``. """ - threads: int = 1 + threads: int = 0 """\ The number of worker threads for handling requests. @@ -493,7 +484,11 @@ class GunicornBackendServer(CustomBackendServer): temporary directory. """ - secure_scheme_headers: dict[str, Any] = {'X-FORWARDED-PROTOCOL': 'ssl', 'X-FORWARDED-PROTO': 'https', 'X-FORWARDED-SSL': 'on'} + secure_scheme_headers: dict[str, Any] = { + "X-FORWARDED-PROTOCOL": "ssl", + "X-FORWARDED-PROTO": "https", + "X-FORWARDED-SSL": "on", + } """\ A dictionary containing headers and values that the front-end proxy uses to indicate HTTPS requests. If the source IP is permitted by @@ -588,7 +583,9 @@ class GunicornBackendServer(CustomBackendServer): Disable redirect access logs to syslog. """ - access_log_format: str = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + access_log_format: str = ( + '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + ) """\ The access log format. @@ -686,7 +683,11 @@ class GunicornBackendServer(CustomBackendServer): if sys.platform == "darwin" else ( "unix:///var/run/log" - if sys.platform in ('freebsd', 'dragonfly', ) + if sys.platform + in ( + "freebsd", + "dragonfly", + ) else ( "unix:///dev/log" if sys.platform == "openbsd" @@ -858,7 +859,9 @@ class GunicornBackendServer(CustomBackendServer): The callable needs to accept a single instance variable for the Arbiter. """ - pre_request: Callable = lambda worker, req: worker.log.debug("%s %s", req.method, req.path) + pre_request: Callable = lambda worker, req: worker.log.debug( + "%s %s", req.method, req.path + ) """\ Called just before a worker processes the request. @@ -908,7 +911,9 @@ class GunicornBackendServer(CustomBackendServer): The callable needs to accept a single instance variable for the Arbiter. """ - ssl_context: Callable = lambda config, default_ssl_context_factory: default_ssl_context_factory() + ssl_context: Callable = ( + lambda config, default_ssl_context_factory: default_ssl_context_factory() + ) """\ Called when SSLContext is needed. @@ -975,7 +980,9 @@ class GunicornBackendServer(CustomBackendServer): SSL certificate file """ - ssl_version: int = ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + ssl_version: int = ( + ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + ) """\ SSL version to use (see stdlib ssl module's). @@ -1163,33 +1170,96 @@ class GunicornBackendServer(CustomBackendServer): on a proxy in front of Gunicorn. """ - # def __init__(self, *args, **kwargs): - # super().__init__(*args, **kwargs) + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("gunicorn") is None: + errors.append( + 'The `gunicorn` package is required to run `GunicornBackendServer`. Run `pip install "gunicorn>=20.1.0"`.' + ) + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + self.app_uri = f"{self.get_app_module()}()" + self.loglevel = loglevel.value # type: ignore + self.bind = [f"{host}:{port}"] + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_threads := self.get_max_workers()): + self.workers = max_threads + + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads + self.preload_app = True + + if env == Env.DEV: + self.reload = True def run_prod(self) -> list[str]: - print("[reflex.server.gunicorn::GunicornBackendServer] start") + """Run in production mode.""" + self.check_import() + command = ["gunicorn"] - for key,field in self.get_fields().items(): + for key, field in self.get_fields().items(): if key != "app": - value = self.__getattribute__(key) - if key == "preload": - print(_mapping_attr_to_cli.get(key, None), value, field.default) - if _mapping_attr_to_cli.get(key, None): - if value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): + value = getattr(self, key) + if _mapping_attr_to_cli.get(key) and value != field.default: + if isinstance(value, list): + for v in value: + command += [_mapping_attr_to_cli[key], str(v)] + elif isinstance(value, bool): + if (key == "sendfile" and value is False) or ( + key != "sendfile" and value + ): command.append(_mapping_attr_to_cli[key]) - else: - command += [_mapping_attr_to_cli[key], str(value)] + else: + command += [_mapping_attr_to_cli[key], str(value)] - print("[reflex.server.gunicorn::GunicornBackendServer] done") - return command + [f"reflex.app_module_for_backend:{constants.CompileVars.APP}()"] + return command + [f"{self.get_app_module()}()"] def run_dev(self): - StandaloneApplication( - app=self.app, - options=self.dict().items() - ).run() + """Run in development mode.""" + self.check_import() + console.info( + "For development mode, we recommand to use `UvicornBackendServer` than `GunicornBackendServer`" + ) + + from gunicorn.app.base import BaseApplication + from gunicorn.util import import_app as gunicorn_import_app + + options_ = self.dict() + options_.pop("app", None) + + class StandaloneApplication(BaseApplication): + def __init__(self, app_uri, options=None): + self.options = options or {} + self.app_uri = app_uri + super().__init__() + + def load_config(self): + config = { + key: value + for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } # type: ignore + for key, value in config.items(): + self.cfg.set(key.lower(), value) # type: ignore + + def load(self): + return gunicorn_import_app(self.app_uri) + + StandaloneApplication(app_uri=self.app_uri, options=options_).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index b76f12a3e..cb5131c4a 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -1,10 +1,41 @@ +"""The UvicornBackendServer.""" + +from __future__ import annotations + +import sys + +from reflex.constants.base import Env, LogLevel from reflex.server.base import CustomBackendServer +from reflex.utils import console +# TODO class UvicornBackendServer(CustomBackendServer): + """Uvicorn backendServer.""" + + def check_import(self, extra: bool = False): + """Check package importation.""" + from importlib.util import find_spec + + errors: list[str] = [] + + if find_spec("uvicorn") is None: + errors.append( + 'The `uvicorn` package is required to run `UvicornBackendServer`. Run `pip install "uvicorn>=0.20.0"`.' + ) + + if errors: + console.error("\n".join(errors)) + sys.exit() + + def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): + """Setup.""" + pass def run_prod(self): + """Run in production mode.""" pass def run_dev(self): + """Run in development mode.""" pass diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index a1d7030ab..06a61a293 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -2,9 +2,6 @@ from __future__ import annotations -from abc import abstractmethod, ABCMeta -from typing import IO, Any, Literal, Sequence, Type - import hashlib import json import os @@ -12,20 +9,14 @@ import platform import re import subprocess import sys -import asyncio -import ssl -from configparser import RawConfigParser from pathlib import Path from urllib.parse import urljoin -from pydantic import Field -from dataclasses import dataclass import psutil -from reflex import constants, server -from reflex.base import Base +from reflex import constants from reflex.config import environment, get_config -from reflex.constants.base import LogLevel +from reflex.constants.base import Env, LogLevel from reflex.utils import console, path_ops from reflex.utils.prerequisites import get_web_dir @@ -187,44 +178,11 @@ def run_frontend_prod(root: Path, port: str, backend_present=True): ) -def should_use_granian(): - """Whether to use Granian for backend. - - Returns: - True if Granian should be used. - """ - return environment.REFLEX_USE_GRANIAN.get() - - -def get_app_module(): - """Get the app module for the backend. - - Returns: - The app module for the backend. - """ - return f"reflex.app_module_for_backend:{constants.CompileVars.APP}" ### REWORK <-- - - -def get_granian_target(): - """Get the Granian target for the backend. - - Returns: - The Granian target for the backend. - """ - import reflex - - app_module_path = Path(reflex.__file__).parent / "app_module_for_backend.py" - - return ( - f"{app_module_path!s}:{constants.CompileVars.APP}.{constants.CompileVars.API}" - ) - - def run_backend( host: str, port: int, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, + loglevel: LogLevel = LogLevel.ERROR, frontend_present: bool = False, ): """Run the backend. @@ -236,6 +194,7 @@ def run_backend( frontend_present: Whether the frontend is present. """ web_dir = get_web_dir() + config = get_config() # Create a .nocompile file to skip compile for backend. if web_dir.exists(): (web_dir / constants.NOCOMPILE_FILE).touch() @@ -244,78 +203,15 @@ def run_backend( notify_backend() # Run the backend in development mode. - if should_use_granian(): - run_granian_backend(host, port, loglevel) - else: - run_uvicorn_backend(host, port, loglevel) - - -def run_uvicorn_backend(host, port, loglevel: LogLevel): - """Run the backend in development mode using Uvicorn. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - import uvicorn - - uvicorn.run( - app=f"{get_app_module()}.{constants.CompileVars.API}", - host=host, - port=port, - log_level=loglevel.value, - reload=True, - reload_dirs=[get_config().app_name], - ) - - -def run_granian_backend(host, port, loglevel: LogLevel): - """Run the backend in development mode using Granian. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - console.debug("Using Granian for backend") - try: - from granian import Granian # type: ignore - from granian.constants import Interfaces # type: ignore - from granian.log import LogLevels # type: ignore - - Granian( - target=get_granian_target(), - address=host, - port=port, - interface=Interfaces.ASGI, - log_level=LogLevels(loglevel.value), - reload=True, - reload_paths=[Path(get_config().app_name)], - reload_ignore_dirs=[".web"], - ).serve() - except ImportError: - console.error( - 'InstallError: REFLEX_USE_GRANIAN is set but `granian` is not installed. (run `pip install "granian[reload]>=1.6.0"`)' - ) - os._exit(1) - - -def _get_backend_workers(): - from reflex.utils import processes - - config = get_config() - return ( - processes.get_num_workers() - if not config.gunicorn_workers - else config.gunicorn_workers - ) + backend_server_prod = config.backend_server_prod + backend_server_prod.setup(host, port, loglevel, Env.DEV) + backend_server_prod.run_dev() def run_backend_prod( host: str, port: int, - loglevel: constants.LogLevel = constants.LogLevel.ERROR, + loglevel: LogLevel = LogLevel.ERROR, frontend_present: bool = False, ): """Run the backend. @@ -326,77 +222,24 @@ def run_backend_prod( loglevel: The log level. frontend_present: Whether the frontend is present. """ - print("[reflex.utils.exec::run_backend_prod] start") - if not frontend_present: - notify_backend() + from reflex.utils import processes config = get_config() - if should_use_granian(): - run_granian_backend_prod(host, port, loglevel) - else: - from reflex.utils import processes + if not frontend_present: + notify_backend() - backend_server_prod = config.backend_server_prod - if isinstance(backend_server_prod, server.GunicornBackendServer): - backend_server_prod.app = f"{get_app_module()}()" - backend_server_prod.preload_app = True - backend_server_prod.loglevel = loglevel.value # type: ignore - backend_server_prod.bind = [f"{host}:{port}"] - backend_server_prod.threads = _get_backend_workers() - backend_server_prod.workers = _get_backend_workers() - - print(backend_server_prod.run_prod()) - processes.new_process( - backend_server_prod.run_prod(), - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) - print("[reflex.utils.exec::run_backend_prod] done") - - -def run_granian_backend_prod(host, port, loglevel): - """Run the backend in production mode using Granian. - - Args: - host: The app host - port: The app port - loglevel: The log level. - """ - from reflex.utils import processes - - try: - from granian.constants import Interfaces # type: ignore - - command = [ - "granian", - "--workers", - str(_get_backend_workers()), - "--log-level", - "critical", - "--host", - host, - "--port", - str(port), - "--interface", - str(Interfaces.ASGI), - get_granian_target(), - ] - processes.new_process( - command, - run=True, - show_logs=True, - env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend - ) - except ImportError: - console.error( - 'InstallError: REFLEX_USE_GRANIAN is set but `granian` is not installed. (run `pip install "granian[reload]>=1.6.0"`)' - ) + # Run the backend in production mode. + backend_server_prod = config.backend_server_prod + backend_server_prod.setup(host, port, loglevel, Env.PROD) + processes.new_process( + backend_server_prod.run_prod(), + run=True, + show_logs=True, + env={ + environment.REFLEX_SKIP_COMPILE.name: "true" + }, # skip compile for prod backend + ) ### REWORK--> @@ -522,6 +365,3 @@ def should_skip_compile() -> bool: removal_version="0.7.0", ) return environment.REFLEX_SKIP_COMPILE.get() - - -### REWORK <-- From 1076847c68b7909a77dbe1e63655ea8d4c702243 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Fri, 8 Nov 2024 16:37:28 +0000 Subject: [PATCH 19/25] gunicorn `app_uri` are optional --- reflex/server/gunicorn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 659cb5262..c0bfb3b50 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -92,7 +92,7 @@ class GunicornBackendServer(CustomBackendServer): # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - app_uri: str | None + app_uri: str | None = None config: str = "./gunicorn.conf.py" """\ From 7ecabafdd0ca1b2026946b8a35899093aa560bb5 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 9 Nov 2024 21:02:39 +0000 Subject: [PATCH 20/25] drop pydantic for dataclass & add uvicorn prod/dev --- reflex/config.py | 20 +- reflex/server/__init__.py | 7 + reflex/server/base.py | 178 ++++- reflex/server/granian.py | 310 +++++---- reflex/server/gunicorn.py | 1372 ++++++++----------------------------- reflex/server/uvicorn.py | 228 +++++- reflex/utils/exec.py | 6 +- 7 files changed, 857 insertions(+), 1264 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 852383122..20401e6ac 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -697,28 +697,13 @@ class Config(Base): env_file: Optional[str] = None # Custom Backend Server - # backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - # max_requests=100, - # max_requests_jitter=25, - # timeout=120, - # ) - backend_server_prod: server.CustomBackendServer = server.GranianBackendServer( + backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( threads=2, workers=4, ) - backend_server_dev: server.CustomBackendServer = server.GranianBackendServer( - threads=1, + backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, ) - # backend_server_dev: server.CustomBackendServer = server.GunicornBackendServer( - # worker_class="uvicorn.workers.UvicornH11Worker", # type: ignore - # max_requests=100, - # max_requests_jitter=25, - # timeout=120, - # threads=1, - # workers=1, - # ) def __init__(self, *args, **kwargs): """Initialize the config values. @@ -730,7 +715,6 @@ class Config(Base): Raises: ConfigError: If some values in the config are invalid. """ - print("[reflex.config::Config] start") super().__init__(*args, **kwargs) # Update the config from environment variables. diff --git a/reflex/server/__init__.py b/reflex/server/__init__.py index 9633ddc7c..b39dc8049 100644 --- a/reflex/server/__init__.py +++ b/reflex/server/__init__.py @@ -4,3 +4,10 @@ from .base import CustomBackendServer from .granian import GranianBackendServer from .gunicorn import GunicornBackendServer from .uvicorn import UvicornBackendServer + +__all__ = [ + "CustomBackendServer", + "GranianBackendServer", + "GunicornBackendServer", + "UvicornBackendServer", +] diff --git a/reflex/server/base.py b/reflex/server/base.py index c821c2686..012511233 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -4,16 +4,160 @@ from __future__ import annotations import os from abc import abstractmethod +from dataclasses import Field, dataclass +from dataclasses import field as dc_field from pathlib import Path +from typing import Any, Callable, Sequence from reflex import constants -from reflex.base import Base from reflex.constants.base import Env, LogLevel +ReturnCliTypeFn = Callable[[Any], str] -class CustomBackendServer(Base): + +class CliType: + """Cli type transformer.""" + + @staticmethod + def default(fmt: str) -> ReturnCliTypeFn: + """Default cli transformer. + + Example: + fmt: `'--env-file {value}'` + value: `'/config.conf'` + result => `'--env-file /config.conf'` + """ + + def wrapper(value: bool) -> str: + return fmt.format(value=value) + + return wrapper + + @staticmethod + def boolean(fmt: str, bool_value: bool = True) -> ReturnCliTypeFn: + """When cli mode args only show when we want to activate it. + + Example: + fmt: `'--reload'` + value: `False` + result => `''` + + Example: + fmt: `'--reload'` + value: `True` + result => `'--reload'` + """ + + def wrapper(value: bool) -> str: + return fmt if value is bool_value else "" + + return wrapper + + @staticmethod + def boolean_toggle( + fmt: str, + toggle_kw: str = "no", + toggle_sep: str = "-", + toggle_value: bool = False, + **kwargs, + ) -> ReturnCliTypeFn: + """When the cli mode is a boolean toggle `--access-log`/`--no-access-log`. + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `False` + toggle_value: `False` (default) + result => `'--no-access-log'` + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `True` + toggle_value: `False` (default) + result => `'--access-log'` + + Example: + fmt: `'--{toggle_kw}{toggle_sep}access-log'` + value: `True` + toggle_value: `True` + result => `'--no-access-log'` + """ + + def wrapper(value: bool) -> str: + return fmt.format( + **kwargs, + toggle_kw=(toggle_kw if value is toggle_value else ""), + toggle_sep=(toggle_sep if value is toggle_value else ""), + ) + + return wrapper + + @staticmethod + def multiple( + fmt: str, + join_sep: str | None = None, + value_transformer: Callable[[Any], str] = lambda value: str(value), + ) -> ReturnCliTypeFn: + r"""When the cli mode need multiple args or single args from an sequence. + + Example (Multiple args mode): + fmt: `'--header {value}'`. + data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + result => `'--header \"X-Forwarded-Proto=https\" --header \"X-Forwarded-For=0.0.0.0\"'` + + Example (Single args mode): + fmt: `--headers {values}` + data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + join_sep (required): `';'` + result => `--headers \"X-Forwarded-Proto=https;X-Forwarded-For=0.0.0.0\"` + + Example (Single args mode): + fmt: `--headers {values}` + data_list: `[('X-Forwarded-Proto', 'https'), ('X-Forwarded-For', '0.0.0.0')]` + join_sep (required): `';'` + value_transformer: `lambda value: f'{value[0]}:{value[1]}'` + result => `--headers \"X-Forwarded-Proto:https;X-Forwarded-For:0.0.0.0\"` + """ + + def wrapper(values: Sequence[str]) -> str: + return ( + fmt.format( + values=join_sep.join(value_transformer(value) for value in values) + ) + if join_sep + else " ".join( + [fmt.format(value=value_transformer(value)) for value in values] + ) + ) + + return wrapper + + +def field_( + *, + default: Any = None, + metadata_cli: ReturnCliTypeFn | None = None, + exclude: bool = False, + **kwargs, +): + """Custom dataclass field builder.""" + params_ = { + "default": default, + "metadata": {"cli": metadata_cli, "exclude": exclude}, + **kwargs, + } + + if kwargs.get("default_factory", False): + params_.pop("default", None) + + return dc_field(**params_) + + +@dataclass +class CustomBackendServer: """BackendServer base.""" + _app_uri: str = field_(default="", metadata_cli=None, exclude=True) + @staticmethod def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): """Get the app module for the backend. @@ -74,8 +218,36 @@ class CustomBackendServer(Base): else need_threads ) + def get_fields(self) -> dict[str, Field]: + """Return all the fields.""" + return self.__dataclass_fields__ + + def get_values(self) -> dict[str, Any]: + """Return all values.""" + return { + key: getattr(self, key) + for key, field in self.__dataclass_fields__.items() + if field.metadata["exclude"] is False + } + + def is_default_value(self, key, value: Any | None = None) -> bool: + """Check if the `value` is the same value from default context.""" + from dataclasses import MISSING + + field = self.get_fields()[key] + if value is None: + value = getattr(self, key, None) + + if field.default != MISSING: + return value == field.default + else: + if field.default_factory != MISSING: + return value == field.default_factory() + + return False + @abstractmethod - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index fda03912c..2a76d8c53 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -6,10 +6,10 @@ import sys from dataclasses import dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Literal, Type +from typing import Any, Literal from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console @@ -38,122 +38,160 @@ class HTTP2Settings: # https://github.com/emmett-framework/granian/blob/261ceba max_send_buffer_size: int = dc_field(default=1024 * 400) -try: - import watchfiles # type: ignore -except ImportError: - watchfiles = None - -_mapping_attr_to_cli: dict[str, str] = { - "address": "--host", - "port": "--port", - "interface": "--interface", - "http": "--http", - "websockets": "--ws", # NOTE: when `websockets` True: `--ws`; False: `--no-ws` - "workers": "--workers", - "threads": "--threads", - "blocking_threads": "--blocking-threads", - "threading_mode": "--threading-mode", - "loop": "--loop", - "loop_opt": "--opt", # NOTE: when `loop_opt` True: `--opt`; False: `--no-opt` - "backlog": "--backlog", - "backpressure": "--backpressure", - "http1_keep_alive": "--http1-keep-alive", - "http1_max_buffer_size": "--http1-max-buffer-size", - "http1_pipeline_flush": "--http1-pipeline-flush", - "http2_adaptive_window": "--http2-adaptive-window", - "http2_initial_connection_window_size": "--http2-initial-connection-window-size", - "http2_initial_stream_window_size": "--http2-initial-stream-window-size", - "http2_keep_alive_interval": "--http2-keep-alive-interval", - "http2_keep_alive_timeout": "--http2-keep-alive-timeout", - "http2_max_concurrent_streams": "--http2-max-concurrent-streams", - "http2_max_frame_size": "--http2-max-frame-size", - "http2_max_headers_size": "--http2-max-headers-size", - "http2_max_send_buffer_size": "--http2-max-send-buffer-size", - "log_enabled": "--log", # NOTE: when `log_enabled` True: `--log`; False: `--no-log` - "log_level": "--log-level", - "log_access": "--log-access", # NOTE: when `log_access` True: `--log-access`; False: `--no-log-access` - "log_access_format": "--access-log-fmt", - "ssl_cert": "--ssl-certificate", - "ssl_key": "--ssl-keyfile", - "ssl_key_password": "--ssl-keyfile-password", - "url_path_prefix": "--url-path-prefix", - "respawn_failed_workers": "--respawn-failed-workers", # NOTE: when `respawn_failed_workers` True: `--respawn-failed-workers`; False: `--no-respawn-failed-workers` - "respawn_interval": "--respawn-interval", - "workers_lifetime": "--workers-lifetime", - "factory": "--factory", # NOTE: when `factory` True: `--factory`; False: `--no-factory` - "reload": "--reload", # NOTE: when `reload` True: `--reload`; False: `--no-reload` - "reload_paths": "--reload-paths", - "reload_ignore_dirs": "--reload-ignore-dirs", - "reload_ignore_patterns": "--reload-ignore-patterns", - "reload_ignore_paths": "--reload-ignore-paths", - "process_name": "--process-name", - "pid_file": "--pid-file", -} - - +@dataclass class GranianBackendServer(CustomBackendServer): - """Granian backendServer.""" + """Granian backendServer. - # https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/server.py#L69 + https://github.com/emmett-framework/granian/blob/fc11808ed177362fcd9359a455a733065ddbc505/granian/cli.py#L52 (until Granian has the proper documentation) - target: str | None = None - address: str = "127.0.0.1" - port: int = 8000 - interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = "rsgi" - workers: int = 0 - threads: int = 0 - blocking_threads: int | None = None - threading_mode: Literal["runtime", "workers"] = "workers" - loop: Literal["auto", "asyncio", "uvloop"] = "auto" - loop_opt: bool = False - http: Literal["auto", "1", "2"] = "auto" - websockets: bool = True - backlog: int = 1024 - backpressure: int | None = None + """ - # http1_settings: HTTP1Settings | None = None - # NOTE: child of http1_settings, needed only for cli mode - http1_keep_alive: bool = HTTP1Settings.keep_alive - http1_max_buffer_size: int = HTTP1Settings.max_buffer_size - http1_pipeline_flush: bool = HTTP1Settings.pipeline_flush - - # http2_settings: HTTP2Settings | None = None - # NOTE: child of http2_settings, needed only for cli mode - http2_adaptive_window: bool = HTTP2Settings.adaptive_window - http2_initial_connection_window_size: int = ( - HTTP2Settings.initial_connection_window_size + address: str = field_( + default="127.0.0.1", metadata_cli=CliType.default("--host {value}") + ) + port: int = field_(default=8000, metadata_cli=CliType.default("--port {value}")) + interface: Literal["asgi", "asginl", "rsgi", "wsgi"] = field_( + default="rsgi", metadata_cli=CliType.default("--interface {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) + threads: int = field_(default=0, metadata_cli=CliType.default("--threads {value}")) + blocking_threads: int | None = field_( + default=None, metadata_cli=CliType.default("--blocking-threads {value}") + ) + threading_mode: Literal["runtime", "workers"] = field_( + default="workers", metadata_cli=CliType.default("--threading-mode {value}") + ) + loop: Literal["auto", "asyncio", "uvloop"] = field_( + default="auto", metadata_cli=CliType.default("--loop {value}") + ) + loop_opt: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}opt"), + ) + http: Literal["auto", "1", "2"] = field_( + default="auto", metadata_cli=CliType.default("--http {value}") + ) + websockets: bool = field_( + default=True, metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}ws") + ) + backlog: int = field_( + default=1024, metadata_cli=CliType.default("--backlog {value}") + ) + backpressure: int | None = field_( + default=None, metadata_cli=CliType.default("--backpressure {value}") + ) + http1_keep_alive: bool = field_( + default=True, metadata_cli=CliType.default("--http1-keep-alive {value}") + ) + http1_max_buffer_size: int = field_( + default=417792, metadata_cli=CliType.default("--http1-max-buffer-size {value}") + ) + http1_pipeline_flush: bool = field_( + default=False, metadata_cli=CliType.default("--http1-pipeline-flush {value}") + ) + http2_adaptive_window: bool = field_( + default=False, metadata_cli=CliType.default("--http2-adaptive-window {value}") + ) + http2_initial_connection_window_size: int = field_( + default=1048576, + metadata_cli=CliType.default("--http2-initial-connection-window-size {value}"), + ) + http2_initial_stream_window_size: int = field_( + default=1048576, + metadata_cli=CliType.default("--http2-initial-stream-window-size {value}"), + ) + http2_keep_alive_interval: int | None = field_( + default=None, + metadata_cli=CliType.default("--http2-keep-alive-interval {value}"), + ) + http2_keep_alive_timeout: int = field_( + default=20, metadata_cli=CliType.default("--http2-keep-alive-timeout {value}") + ) + http2_max_concurrent_streams: int = field_( + default=200, + metadata_cli=CliType.default("--http2-max-concurrent-streams {value}"), + ) + http2_max_frame_size: int = field_( + default=16384, metadata_cli=CliType.default("--http2-max-frame-size {value}") + ) + http2_max_headers_size: int = field_( + default=16777216, + metadata_cli=CliType.default("--http2-max-headers-size {value}"), + ) + http2_max_send_buffer_size: int = field_( + default=409600, + metadata_cli=CliType.default("--http2-max-send-buffer-size {value}"), + ) + log_enabled: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}log"), + ) + log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = ( + field_(default="info", metadata_cli=CliType.default("--log-level {value}")) + ) + log_dictconfig: dict[str, Any] | None = field_(default=None, metadata_cli=None) + log_access: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}log-access"), + ) + log_access_format: str | None = field_( + default=None, metadata_cli=CliType.default("--access-log-fmt {value}") + ) + ssl_cert: Path | None = field_( + default=None, metadata_cli=CliType.default("--ssl-certificate {value}") + ) + ssl_key: Path | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile {value}") + ) + ssl_key_password: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile-password {value}") + ) + url_path_prefix: str | None = field_( + default=None, metadata_cli=CliType.default("--url-path-prefix {value}") + ) + respawn_failed_workers: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle( + "--{toggle_kw}{toggle_sep}respawn-failed-workers" + ), + ) + respawn_interval: float = field_( + default=3.5, metadata_cli=CliType.default("--respawn-interval {value}") + ) + workers_lifetime: int | None = field_( + default=None, metadata_cli=CliType.default("--workers-lifetime {value}") + ) + factory: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}factory"), + ) + reload: bool = field_( + default=False, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}reload"), + ) + reload_paths: list[Path] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-paths {value}") + ) + reload_ignore_dirs: list[str] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-dirs {value}") + ) + reload_ignore_patterns: list[str] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-patterns {value}") + ) + reload_ignore_paths: list[Path] | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-ignore-paths {value}") + ) + reload_filter: object | None = field_( # type: ignore + default=None, metadata_cli=None + ) + process_name: str | None = field_( + default=None, metadata_cli=CliType.default("--process-name {value}") + ) + pid_file: Path | None = field_( + default=None, metadata_cli=CliType.default("--pid-file {value}") ) - http2_initial_stream_window_size: int = HTTP2Settings.initial_stream_window_size - http2_keep_alive_interval: int | None = HTTP2Settings.keep_alive_interval - http2_keep_alive_timeout: int = HTTP2Settings.keep_alive_timeout - http2_max_concurrent_streams: int = HTTP2Settings.max_concurrent_streams - http2_max_frame_size: int = HTTP2Settings.max_frame_size - http2_max_headers_size: int = HTTP2Settings.max_headers_size - http2_max_send_buffer_size: int = HTTP2Settings.max_send_buffer_size - log_enabled: bool = True - log_level: Literal["critical", "error", "warning", "warn", "info", "debug"] = "info" - log_dictconfig: dict[str, Any] | None = None - log_access: bool = False - log_access_format: str | None = None - ssl_cert: Path | None = None - ssl_key: Path | None = None - ssl_key_password: str | None = None - url_path_prefix: str | None = None - respawn_failed_workers: bool = False - respawn_interval: float = 3.5 - workers_lifetime: int | None = None - factory: bool = False - reload: bool = False - reload_paths: list[Path] | None = None - reload_ignore_dirs: list[str] | None = None - reload_ignore_patterns: list[str] | None = None - reload_ignore_paths: list[Path] | None = None - reload_filter: Type[getattr(watchfiles, "BaseFilter", None)] | None = None # type: ignore - process_name: str | None = None - pid_file: Path | None = None - - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -164,11 +202,10 @@ class GranianBackendServer(CustomBackendServer): 'The `granian` package is required to run `GranianBackendServer`. Run `pip install "granian>=1.6.0"`.' ) - if find_spec("watchfiles") is None and extra: - # NOTE: the `\[` is for force `rich.Console` to not consider it like a color or anything else which he not printing `[.*]` + if find_spec("watchfiles") is None and self.reload: errors.append( - r'Using --reload in `GranianBackendServer` requires the granian\[reload] extra. Run `pip install "granian\[reload]>=1.6.0"`.' - ) # type: ignore + 'Using `--reload` in `GranianBackendServer` requires the `watchfiles` extra. Run `pip install "watchfiles~=0.21"`.' + ) if errors: console.error("\n".join(errors)) @@ -176,18 +213,18 @@ class GranianBackendServer(CustomBackendServer): def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - self.target = self.get_app_module(for_granian_target=True, add_extra_api=True) + self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) self.log_level = loglevel.value # type: ignore self.address = host self.port = port - self.interface = "asgi" # prevent obvious error + self.interface = "asgi" # NOTE: prevent obvious error if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: self.workers = self.get_recommended_workers() else: - if self.workers > (max_threads := self.get_max_workers()): - self.workers = max_threads + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers if self.threads == self.get_fields()["threads"].default: self.threads = self.get_recommended_threads() @@ -208,26 +245,18 @@ class GranianBackendServer(CustomBackendServer): command = ["granian"] for key, field in self.get_fields().items(): - if key != "target": - value = getattr(self, key) - if _mapping_attr_to_cli.get(key) and value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): - command.append( - f"--{'no-' if value is False else ''}{_mapping_attr_to_cli[key][2:]}" - ) - else: - command += [_mapping_attr_to_cli[key], str(value)] + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") - return command + [ - self.get_app_module(for_granian_target=True, add_extra_api=True) - ] + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" - self.check_import(extra=self.reload) + self.check_import() from granian import Granian exclude_keys = ( @@ -244,14 +273,17 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - model = self.get_fields() Granian( **{ **{ key: value - for key, value in self.dict().items() - if key not in exclude_keys and value != model[key].default + for key, value in self.get_values().items() + if ( + key not in exclude_keys + and not self.is_default_value(key, value) + ) }, + "target": self._app_uri, "http1_settings": HTTP1Settings( self.http1_keep_alive, self.http1_max_buffer_size, diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index c0bfb3b50..53544da7b 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -2,13 +2,12 @@ from __future__ import annotations -import os -import ssl import sys +from dataclasses import dataclass from typing import Any, Callable, Literal -from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.constants.base import IS_WINDOWS, Env, LogLevel +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console _mapping_attr_to_cli: dict[str, str] = { @@ -87,78 +86,25 @@ _mapping_attr_to_cli: dict[str, str] = { } +@dataclass class GunicornBackendServer(CustomBackendServer): - """Gunicorn backendServer.""" + """Gunicorn backendServer. - # https://github.com/benoitc/gunicorn/blob/bacbf8aa5152b94e44aa5d2a94aeaf0318a85248/gunicorn/config.py - - app_uri: str | None = None - - config: str = "./gunicorn.conf.py" - """\ - :ref:`The Gunicorn config file`. - - A string of the form ``PATH``, ``file:PATH``, or ``python:MODULE_NAME``. - - Only has an effect when specified on the command line or as part of an - application specific configuration. - - By default, a file named ``gunicorn.conf.py`` will be read from the same - directory where gunicorn is being run. - """ - - wsgi_app: str | None = None - """\ - A WSGI application path in pattern ``$(MODULE_NAME):$(VARIABLE_NAME)``. - """ - - bind: list[str] = ["127.0.0.1:8000"] - """\ - The socket to bind. - - A string of the form: ``HOST``, ``HOST:PORT``, ``unix:PATH``, - ``fd://FD``. An IP is a valid ``HOST``. - - .. versionchanged:: 20.0 - Support for ``fd://FD`` got added. - - Multiple addresses can be bound. ex.:: - - $ gunicorn -b 127.0.0.1:8000 -b [::1]:8000 test:app - - will bind the `test:app` application on localhost both on ipv6 - and ipv4 interfaces. - - If the ``PORT`` environment variable is defined, the default - is ``['0.0.0.0:$PORT']``. If it is not defined, the default - is ``['127.0.0.1:8000']``. - """ - - backlog: int = 2048 - """\ - The maximum number of pending connections. - - This refers to the number of clients that can be waiting to be served. - Exceeding this number results in the client getting an error when - attempting to connect. It should only affect servers under significant - load. - - Must be a positive integer. Generally set in the 64-2048 range. - """ - - workers: int = 0 - """\ - The number of worker processes for handling requests. - - A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. - You'll want to vary this a bit to find the best for your particular - application's work load. - - By default, the value of the ``WEB_CONCURRENCY`` environment variable, - which is set by some Platform-as-a-Service providers such as Heroku. If - it is not defined, the default is ``1``. + https://docs.gunicorn.org/en/latest/settings.html """ + config: str = field_( + default="./gunicorn.conf.py", metadata_cli=CliType.default("--config {value}") + ) + bind: list[str] = field_( + default=None, + default_factory=lambda: ["127.0.0.1:8000"], + metadata_cli=CliType.multiple("--bind {value}"), + ) + backlog: int = field_( + default=2048, metadata_cli=CliType.default("--backlog {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) worker_class: Literal[ "sync", "eventlet", @@ -166,1016 +112,258 @@ class GunicornBackendServer(CustomBackendServer): "tornado", "gthread", "uvicorn.workers.UvicornH11Worker", - ] = "sync" - """\ - The type of workers to use. - - The default class (``sync``) should handle most "normal" types of - workloads. You'll want to read :doc:`design` for information on when - you might want to choose one of the other worker classes. Required - libraries may be installed using setuptools' ``extras_require`` feature. - - A string referring to one of the following bundled classes: - - * ``sync`` - * ``eventlet`` - Requires eventlet >= 0.24.1 (or install it via - ``pip install gunicorn[eventlet]``) - * ``gevent`` - Requires gevent >= 1.4 (or install it via - ``pip install gunicorn[gevent]``) - * ``tornado`` - Requires tornado >= 0.2 (or install it via - ``pip install gunicorn[tornado]``) - * ``gthread`` - Python 2 requires the futures package to be installed - (or install it via ``pip install gunicorn[gthread]``) - - Optionally, you can provide your own worker by giving Gunicorn a - Python path to a subclass of ``gunicorn.workers.base.Worker``. - This alternative syntax will load the gevent class: - ``gunicorn.workers.ggevent.GeventWorker``. - """ - - threads: int = 0 - """\ - The number of worker threads for handling requests. - - Run each worker with the specified number of threads. - - A positive integer generally in the ``2-4 x $(NUM_CORES)`` range. - You'll want to vary this a bit to find the best for your particular - application's work load. - - If it is not defined, the default is ``1``. - - This setting only affects the Gthread worker type. - - .. note:: - If you try to use the ``sync`` worker type and set the ``threads`` - setting to more than 1, the ``gthread`` worker type will be used - instead. - """ - - worker_connections: int = 1000 - """\ - The maximum number of simultaneous clients. - - This setting only affects the ``gthread``, ``eventlet`` and ``gevent`` worker types. - """ - - max_requests: int = 0 - """\ - The maximum number of requests a worker will process before restarting. - - Any value greater than zero will limit the number of requests a worker - will process before automatically restarting. This is a simple method - to help limit the damage of memory leaks. - - If this is set to zero (the default) then the automatic worker - restarts are disabled. - """ - - max_requests_jitter: int = 0 - """\ - The maximum jitter to add to the *max_requests* setting. - - The jitter causes the restart per worker to be randomized by - ``randint(0, max_requests_jitter)``. This is intended to stagger worker - restarts to avoid all workers restarting at the same time. - """ - - timeout: int = 30 - """\ - Workers silent for more than this many seconds are killed and restarted. - - Value is a positive number or 0. Setting it to 0 has the effect of - infinite timeouts by disabling timeouts for all workers entirely. - - Generally, the default of thirty seconds should suffice. Only set this - noticeably higher if you're sure of the repercussions for sync workers. - For the non sync workers it just means that the worker process is still - communicating and is not tied to the length of time required to handle a - single request. - """ - - graceful_timeout: int = 30 - """\ - Timeout for graceful workers restart. - - After receiving a restart signal, workers have this much time to finish - serving requests. Workers still alive after the timeout (starting from - the receipt of the restart signal) are force killed. - """ - - keepalive: int = 2 - """\ - The number of seconds to wait for requests on a Keep-Alive connection. - - Generally set in the 1-5 seconds range for servers with direct connection - to the client (e.g. when you don't have separate load balancer). When - Gunicorn is deployed behind a load balancer, it often makes sense to - set this to a higher value. - - .. note:: - ``sync`` worker does not support persistent connections and will - ignore this option. - """ - - limit_request_line: int = 4094 - """\ - The maximum size of HTTP request line in bytes. - - This parameter is used to limit the allowed size of a client's - HTTP request-line. Since the request-line consists of the HTTP - method, URI, and protocol version, this directive places a - restriction on the length of a request-URI allowed for a request - on the server. A server needs this value to be large enough to - hold any of its resource names, including any information that - might be passed in the query part of a GET request. Value is a number - from 0 (unlimited) to 8190. - - This parameter can be used to prevent any DDOS attack. - """ - - limit_request_fields: int = 100 - """\ - Limit the number of HTTP headers fields in a request. - - This parameter is used to limit the number of headers in a request to - prevent DDOS attack. Used with the *limit_request_field_size* it allows - more safety. By default this value is 100 and can't be larger than - 32768. - """ - - limit_request_field_size: int = 8190 - """\ - Limit the allowed size of an HTTP request header field. - - Value is a positive number or 0. Setting it to 0 will allow unlimited - header field sizes. - - .. warning:: - Setting this parameter to a very high or unlimited value can open - up for DDOS attacks. - """ - - reload: bool = False - """\ - Restart workers when code changes. - - This setting is intended for development. It will cause workers to be - restarted whenever application code changes. - - The reloader is incompatible with application preloading. When using a - paste configuration be sure that the server block does not import any - application code or the reload will not work as designed. - - The default behavior is to attempt inotify with a fallback to file - system polling. Generally, inotify should be preferred if available - because it consumes less system resources. - - .. note:: - In order to use the inotify reloader, you must have the ``inotify`` - package installed. - """ - - reload_engine: Literal["auto", "poll", "inotify"] = "auto" - """\ - The implementation that should be used to power :ref:`reload`. - - Valid engines are: - - * ``'auto'`` - * ``'poll'`` - * ``'inotify'`` (requires inotify) - """ - - reload_extra_files: list[str] = [] - """\ - Extends :ref:`reload` option to also watch and reload on additional files - (e.g., templates, configurations, specifications, etc.). - """ - - spew: bool = False - """\ - Install a trace function that spews every line executed by the server. - - This is the nuclear option. - """ - - check_config: bool = False - """\ - Check the configuration and exit. The exit status is 0 if the - configuration is correct, and 1 if the configuration is incorrect. - """ - - print_config: bool = False - """\ - Print the configuration settings as fully resolved. Implies :ref:`check-config`. - """ - - preload_app: bool = False - """\ - Load application code before the worker processes are forked. - - By preloading an application you can save some RAM resources as well as - speed up server boot times. Although, if you defer application loading - to each worker process, you can reload your application code easily by - restarting workers. - """ - - sendfile: bool | None = None - """\ - Disables the use of ``sendfile()``. - - If not set, the value of the ``SENDFILE`` environment variable is used - to enable or disable its usage. - """ - - reuse_port: bool = False - """\ - Set the ``SO_REUSEPORT`` flag on the listening socket. - """ - - chdir: str = "." - """\ - Change directory to specified directory before loading apps. - """ - - daemon: bool = False - """\ - Daemonize the Gunicorn process. - - Detaches the server from the controlling terminal and enters the - background. - """ - - raw_env: list[str] = [] - """\ - Set environment variables in the execution environment. - - Should be a list of strings in the ``key=value`` format. - """ - - pidfile: str | None = None - """\ - A filename to use for the PID file. - - If not set, no PID file will be written. - """ - - worker_tmp_dir: str | None = None - """\ - A directory to use for the worker heartbeat temporary file. - - If not set, the default temporary directory will be used. - - .. note:: - The current heartbeat system involves calling ``os.fchmod`` on - temporary file handlers and may block a worker for arbitrary time - if the directory is on a disk-backed filesystem. - - See :ref:`blocking-os-fchmod` for more detailed information - and a solution for avoiding this problem. - """ - - user: int = os.geteuid() - """\ - Switch worker processes to run as this user. - - A valid user id (as an integer) or the name of a user that can be - retrieved with a call to ``pwd.getpwnam(value)`` or ``None`` to not - change the worker process user. - """ - - group: int = os.getegid() - """\ - Switch worker process to run as this group. - - A valid group id (as an integer) or the name of a user that can be - retrieved with a call to ``pwd.getgrnam(value)`` or ``None`` to not - change the worker processes group. - """ - - umask: int = 0 - """\ - A bit mask for the file mode on files written by Gunicorn. - - Note that this affects unix socket permissions. - - A valid value for the ``os.umask(mode)`` call or a string compatible - with ``int(value, 0)`` (``0`` means Python guesses the base, so values - like ``0``, ``0xFF``, ``0022`` are valid for decimal, hex, and octal - representations) - """ - - initgroups: bool = False - """\ - If true, set the worker process's group access list with all of the - groups of which the specified username is a member, plus the specified - group id. - """ - - tmp_upload_dir: str | None = None - """\ - Directory to store temporary request data as they are read. - - This may disappear in the near future. - - This path should be writable by the process permissions set for Gunicorn - workers. If not specified, Gunicorn will choose a system generated - temporary directory. - """ - - secure_scheme_headers: dict[str, Any] = { - "X-FORWARDED-PROTOCOL": "ssl", - "X-FORWARDED-PROTO": "https", - "X-FORWARDED-SSL": "on", - } - """\ - A dictionary containing headers and values that the front-end proxy - uses to indicate HTTPS requests. If the source IP is permitted by - :ref:`forwarded-allow-ips` (below), *and* at least one request header matches - a key-value pair listed in this dictionary, then Gunicorn will set - ``wsgi.url_scheme`` to ``https``, so your application can tell that the - request is secure. - - If the other headers listed in this dictionary are not present in the request, they will be ignored, - but if the other headers are present and do not match the provided values, then - the request will fail to parse. See the note below for more detailed examples of this behaviour. - - The dictionary should map upper-case header names to exact string - values. The value comparisons are case-sensitive, unlike the header - names, so make sure they're exactly what your front-end proxy sends - when handling HTTPS requests. - - It is important that your front-end proxy configuration ensures that - the headers defined here can not be passed directly from the client. - """ - - forwarded_allow_ips: str = "127.0.0.1,::1" - """\ - Front-end's IPs from which allowed to handle set secure headers. - (comma separated). - - Set to ``*`` to disable checking of front-end IPs. This is useful for setups - where you don't know in advance the IP address of front-end, but - instead have ensured via other means that only your - authorized front-ends can access Gunicorn. - - By default, the value of the ``FORWARDED_ALLOW_IPS`` environment - variable. If it is not defined, the default is ``"127.0.0.1,::1"``. - - .. note:: - - This option does not affect UNIX socket connections. Connections not associated with - an IP address are treated as allowed, unconditionally. - - .. note:: - - The interplay between the request headers, the value of ``forwarded_allow_ips``, and the value of - ``secure_scheme_headers`` is complex. Various scenarios are documented below to further elaborate. - In each case, we have a request from the remote address 134.213.44.18, and the default value of - ``secure_scheme_headers``: - - .. code:: - - secure_scheme_headers = { - 'X-FORWARDED-PROTOCOL': 'ssl', - 'X-FORWARDED-PROTO': 'https', - 'X-FORWARDED-SSL': 'on' - } - - - .. list-table:: - :header-rows: 1 - :align: center - :widths: auto - - * - ``forwarded-allow-ips`` - - Secure Request Headers - - Result - - Explanation - * - ``["127.0.0.1"]`` - - ``X-Forwarded-Proto: https`` - - ``wsgi.url_scheme = "http"`` - - IP address was not allowed - * - ``"*"`` - - - - ``wsgi.url_scheme = "http"`` - - IP address allowed, but no secure headers provided - * - ``"*"`` - - ``X-Forwarded-Proto: https`` - - ``wsgi.url_scheme = "https"`` - - IP address allowed, one request header matched - * - ``["134.213.44.18"]`` - - ``X-Forwarded-Ssl: on`` ``X-Forwarded-Proto: http`` - - ``InvalidSchemeHeaders()`` raised - - IP address allowed, but the two secure headers disagreed on if HTTPS was used - """ - - accesslog: str | None = None - """\ - The Access log file to write to. - - ``'-'`` means log to stdout. - """ - - disable_redirect_access_to_syslog: bool = False - """\ - Disable redirect access logs to syslog. - """ - - access_log_format: str = ( - '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + ] = field_(default="sync", metadata_cli=CliType.default("--worker-class {value}")) + threads: int = field_(default=0, metadata_cli=CliType.default("--threads {value}")) + worker_connections: int = field_( + default=1000, metadata_cli=CliType.default("--worker-connections {value}") ) - """\ - The access log format. - - =========== =========== - Identifier Description - =========== =========== - h remote address - l ``'-'`` - u user name (if HTTP Basic auth used) - t date of the request - r status line (e.g. ``GET / HTTP/1.1``) - m request method - U URL path without query string - q query string - H protocol - s status - B response length - b response length or ``'-'`` (CLF format) - f referrer (note: header is ``referer``) - a user agent - T request time in seconds - M request time in milliseconds - D request time in microseconds - L request time in decimal seconds - p process ID - {header}i request header - {header}o response header - {variable}e environment variable - =========== =========== - - Use lowercase for header and environment variable names, and put - ``{...}x`` names inside ``%(...)s``. For example:: - - %({x-forwarded-for}i)s - """ - - errorlog: str = "-" - """\ - The Error log file to write to. - - Using ``'-'`` for FILE makes gunicorn log to stderr. - """ - - loglevel: Literal["debug", "info", "warning", "error", "critical"] = "info" - """\ - The granularity of Error log outputs. - """ - - capture_output: bool = False - """\ - Redirect stdout/stderr to specified file in :ref:`errorlog`. - """ - - logger_class: str = "gunicorn.glogging.Logger" - """\ - The logger you want to use to log events in Gunicorn. - - The default class (``gunicorn.glogging.Logger``) handles most - normal usages in logging. It provides error and access logging. - - You can provide your own logger by giving Gunicorn a Python path to a - class that quacks like ``gunicorn.glogging.Logger``. - """ - - logconfig: str | None = None - """\ - The log config file to use. - Gunicorn uses the standard Python logging module's Configuration - file format. - """ - - logconfig_dict: dict = {} - """\ - The log config dictionary to use, using the standard Python - logging module's dictionary configuration format. This option - takes precedence over the :ref:`logconfig` and :ref:`logconfig-json` options, - which uses the older file configuration format and JSON - respectively. - - Format: https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig - - For more context you can look at the default configuration dictionary for logging, - which can be found at ``gunicorn.glogging.CONFIG_DEFAULTS``. - """ - - logconfig_json: str | None = None - """\ - The log config to read config from a JSON file - - Format: https://docs.python.org/3/library/logging.config.html#logging.config.jsonConfig - """ - - syslog_addr: str = ( - "unix:///var/run/syslog" - if sys.platform == "darwin" - else ( - "unix:///var/run/log" - if sys.platform - in ( - "freebsd", - "dragonfly", - ) - else ( - "unix:///dev/log" - if sys.platform == "openbsd" - else "udp://localhost:514" - ) - ) + max_requests: int = field_( + default=0, metadata_cli=CliType.default("--max-requests {value}") ) - """\ - Address to send syslog messages. - - Address is a string of the form: - * ``unix://PATH#TYPE`` : for unix domain socket, ``TYPE`` can be ``stream`` for the stream driver or ``dgram`` for the dgram driver, ``stream`` is the default - * ``udp://HOST:PORT`` : for UDP sockets - * ``tcp://HOST:PORT`` : for TCP sockets - """ - - syslog: bool = False - """\ - Send *Gunicorn* logs to syslog. - """ - - syslog_prefix: str | None = None - """\ - Makes Gunicorn use the parameter as program-name in the syslog entries. - - All entries will be prefixed by ``gunicorn.``. By default the - program name is the name of the process. - """ - - syslog_facility: str = "user" - """\ - Syslog facility name - """ - - enable_stdio_inheritance: bool = False - """\ - Enable stdio inheritance. - - Enable inheritance for stdio file descriptors in daemon mode. - - Note: To disable the Python stdout buffering, you can to set the user - environment variable ``PYTHONUNBUFFERED`` . - """ - - statsd_host: str | None = None - """\ - The address of the StatsD server to log to. - - Address is a string of the form: - - * ``unix://PATH`` : for a unix domain socket. - * ``HOST:PORT`` : for a network address - """ - - dogstatsd_tags: str = "" - """\ - A comma-delimited list of datadog statsd (dogstatsd) tags to append to - statsd metrics. - """ - - statsd_prefix: str = "" - """\ - Prefix to use when emitting statsd metrics (a trailing ``.`` is added, - if not provided). - """ - - proc_name: str | None = None - """\ - A base to use with setproctitle for process naming. - - This affects things like ``ps`` and ``top``. If you're going to be - running more than one instance of Gunicorn you'll probably want to set a - name to tell them apart. This requires that you install the setproctitle - module. - - If not set, the *default_proc_name* setting will be used. - """ - - default_proc_name: str = "gunicorn" - """\ - Internal setting that is adjusted for each type of application. - """ - - pythonpath: str | None = None - """\ - A comma-separated list of directories to add to the Python path. - - e.g. - ``'/home/djangoprojects/myproject,/home/python/mylibrary'``. - """ - - paste: str | None = None - """\ - Load a PasteDeploy config file. The argument may contain a ``#`` - symbol followed by the name of an app section from the config file, - e.g. ``production.ini#admin``. - - At this time, using alternate server blocks is not supported. Use the - command line arguments to control server configuration instead. - """ - - on_starting: Callable = lambda server: None - """\ - Called just before the master process is initialized. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - on_reload: Callable = lambda server: None - """\ - Called to recycle workers during a reload via SIGHUP. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - when_ready: Callable = lambda server: None - """\ - Called just after the server is started. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - pre_fork: Callable = lambda server, worker: None - """\ - Called just before a worker is forked. - - The callable needs to accept two instance variables for the Arbiter and - new Worker. - """ - - post_fork: Callable = lambda server, worker: None - """\ - Called just after a worker has been forked. - - The callable needs to accept two instance variables for the Arbiter and - new Worker. - """ - - post_worker_init: Callable = lambda worker: None - """\ - Called just after a worker has initialized the application. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - worker_int: Callable = lambda worker: None - """\ - Called just after a worker exited on SIGINT or SIGQUIT. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - worker_abort: Callable = lambda worker: None - """\ - Called when a worker received the SIGABRT signal. - - This call generally happens on timeout. - - The callable needs to accept one instance variable for the initialized - Worker. - """ - - pre_exec: Callable = lambda server: None - """\ - Called just before a new master process is forked. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - pre_request: Callable = lambda worker, req: worker.log.debug( - "%s %s", req.method, req.path + max_requests_jitter: int = field_( + default=0, metadata_cli=CliType.default("--max-requests-jitter {value}") ) - """\ - Called just before a worker processes the request. - - The callable needs to accept two instance variables for the Worker and - the Request. - """ - - post_request: Callable = lambda worker, req, environ, resp: None - """\ - Called after a worker processes the request. - - The callable needs to accept two instance variables for the Worker and - the Request. - """ - - child_exit: Callable = lambda server, worker: None - """\ - Called just after a worker has been exited, in the master process. - - The callable needs to accept two instance variables for the Arbiter and - the just-exited Worker. - """ - - worker_exit: Callable = lambda server, worker: None - """\ - Called just after a worker has been exited, in the worker process. - - The callable needs to accept two instance variables for the Arbiter and - the just-exited Worker. - """ - - nworkers_changed: Callable = lambda server, new_value, old_value: None - """\ - Called just after *num_workers* has been changed. - - The callable needs to accept an instance variable of the Arbiter and - two integers of number of workers after and before change. - - If the number of workers is set for the first time, *old_value* would - be ``None``. - """ - - on_exit: Callable = lambda server: None - """\ - Called just before exiting Gunicorn. - - The callable needs to accept a single instance variable for the Arbiter. - """ - - ssl_context: Callable = ( - lambda config, default_ssl_context_factory: default_ssl_context_factory() + timeout: int = field_(default=30, metadata_cli=CliType.default("--timeout {value}")) + graceful_timeout: int = field_( + default=30, metadata_cli=CliType.default("--graceful-timeout {value}") ) - """\ - Called when SSLContext is needed. - - Allows customizing SSL context. - - The callable needs to accept an instance variable for the Config and - a factory function that returns default SSLContext which is initialized - with certificates, private key, cert_reqs, and ciphers according to - config and can be further customized by the callable. - The callable needs to return SSLContext object. - - Following example shows a configuration file that sets the minimum TLS version to 1.3: - - .. code-block:: python - - def ssl_context(conf, default_ssl_context_factory): - import ssl - context = default_ssl_context_factory() - context.minimum_version = ssl.TLSVersion.TLSv1_3 - return context - """ - - proxy_protocol: bool = False - """\ - Enable detect PROXY protocol (PROXY mode). - - Allow using HTTP and Proxy together. It may be useful for work with - stunnel as HTTPS frontend and Gunicorn as HTTP server. - - PROXY protocol: http://haproxy.1wt.eu/download/1.5/doc/proxy-protocol.txt - - Example for stunnel config:: - - [https] - protocol = proxy - accept = 443 - connect = 80 - cert = /etc/ssl/certs/stunnel.pem - key = /etc/ssl/certs/stunnel.key - """ - - proxy_allow_ips: str = "127.0.0.1,::1" - """\ - Front-end's IPs from which allowed accept proxy requests (comma separated). - - Set to ``*`` to disable checking of front-end IPs. This is useful for setups - where you don't know in advance the IP address of front-end, but - instead have ensured via other means that only your - authorized front-ends can access Gunicorn. - - .. note:: - - This option does not affect UNIX socket connections. Connections not associated with - an IP address are treated as allowed, unconditionally. - """ - - keyfile: str | None = None - """\ - SSL key file - """ - - certfile: str | None = None - """\ - SSL certificate file - """ - - ssl_version: int = ( - ssl.PROTOCOL_TLS if hasattr(ssl, "PROTOCOL_TLS") else ssl.PROTOCOL_SSLv23 + keepalive: int = field_( + default=2, metadata_cli=CliType.default("--keep-alive {value}") + ) + limit_request_line: int = field_( + default=4094, metadata_cli=CliType.default("--limit-request-line {value}") + ) + limit_request_fields: int = field_( + default=100, metadata_cli=CliType.default("--limit-request-fields {value}") + ) + limit_request_field_size: int = field_( + default=8190, metadata_cli=CliType.default("--limit-request-field_size {value}") + ) + reload: bool = field_(default=False, metadata_cli=CliType.boolean("--reload")) + reload_engine: Literal["auto", "poll", "inotify"] = field_( + default="auto", metadata_cli=CliType.default("--reload-engine {value}") + ) + reload_extra_files: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.default("--reload-extra-file {value}"), + ) + spew: bool = field_(default=False, metadata_cli=CliType.boolean("--spew")) + check_config: bool = field_( + default=False, metadata_cli=CliType.boolean("--check-config") + ) + print_config: bool = field_( + default=False, metadata_cli=CliType.boolean("--print-config") + ) + preload_app: bool = field_(default=False, metadata_cli=CliType.boolean("--preload")) + sendfile: bool | None = field_( + default=None, metadata_cli=CliType.boolean("--no-sendfile", bool_value=False) + ) + reuse_port: bool = field_( + default=False, metadata_cli=CliType.boolean("--reuse-port") + ) + chdir: str = field_(default=".", metadata_cli=CliType.default("--chdir {value}")) + daemon: bool = field_(default=False, metadata_cli=CliType.boolean("--daemon")) + raw_env: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.multiple("--env {value}"), + ) + pidfile: str | None = field_( + default=None, metadata_cli=CliType.default("--pid {value}") + ) + worker_tmp_dir: str | None = field_( + default=None, metadata_cli=CliType.default("--worker-tmp-dir {value}") + ) + user: int = field_(default=1000, metadata_cli=CliType.default("--user {value}")) + group: int = field_(default=1000, metadata_cli=CliType.default("--group {value}")) + umask: int = field_(default=0, metadata_cli=CliType.default("--umask {value}")) + initgroups: bool = field_( + default=False, metadata_cli=CliType.boolean("--initgroups") + ) + tmp_upload_dir: str | None = field_(default=None, metadata_cli=None) + secure_scheme_headers: dict[str, Any] = field_( + default=None, + default_factory=lambda: { + "X-FORWARDED-PROTOCOL": "ssl", + "X-FORWARDED-PROTO": "https", + "X-FORWARDED-SSL": "on", + }, + metadata_cli=None, + ) + forwarded_allow_ips: str = field_( + default="127.0.0.1,::1", + metadata_cli=CliType.default("--forwarded-allow-ips {value}"), + ) + accesslog: str | None = field_( + default=None, metadata_cli=CliType.default("--access-logfile {value}") + ) + disable_redirect_access_to_syslog: bool = field_( + default=False, + metadata_cli=CliType.boolean("--disable-redirect-access-to-syslog"), + ) + access_log_format: str = field_( + default='%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"', + metadata_cli=CliType.default("--access-logformat {value}"), + ) + errorlog: str = field_( + default="-", metadata_cli=CliType.default("--error-logfile {value}") + ) + loglevel: Literal["debug", "info", "warning", "error", "critical"] = field_( + default="info", metadata_cli=CliType.default("--log-level {value}") + ) + capture_output: bool = field_( + default=False, metadata_cli=CliType.boolean("--capture-output") + ) + logger_class: str = field_( + default="gunicorn.glogging.Logger", + metadata_cli=CliType.default("--logger-class {value}"), + ) + logconfig: str | None = field_( + default=None, metadata_cli=CliType.default("--log-config {value}") + ) + logconfig_dict: dict = field_( + default=None, default_factory=lambda: {}, metadata_cli=None + ) + logconfig_json: str | None = field_( + default=None, metadata_cli=CliType.default("--log-config-json {value}") + ) + syslog_addr: str = field_( + default="udp://localhost:514", + metadata_cli=CliType.default("--log-syslog-to {value}"), + ) + syslog: bool = field_(default=False, metadata_cli=CliType.boolean("--log-syslog")) + syslog_prefix: str | None = field_( + default=None, metadata_cli=CliType.default("--log-syslog-prefix {value}") + ) + syslog_facility: str = field_( + default="user", metadata_cli=CliType.default("--log-syslog-facility {value}") + ) + enable_stdio_inheritance: bool = field_( + default=False, metadata_cli=CliType.boolean("--enable-stdio-inheritance") + ) + statsd_host: str | None = field_( + default=None, metadata_cli=CliType.default("--statsd-host {value}") + ) + dogstatsd_tags: str = field_( + default="", metadata_cli=CliType.default("--dogstatsd-tags {value}") + ) + statsd_prefix: str = field_( + default="", metadata_cli=CliType.default("--statsd-prefix {value}") + ) + proc_name: str | None = field_( + default=None, metadata_cli=CliType.default("--name {value}") + ) + default_proc_name: str = field_(default="gunicorn", metadata_cli=None) + pythonpath: str | None = field_( + default=None, metadata_cli=CliType.default("--pythonpath {value}") + ) + paste: str | None = field_( + default=None, metadata_cli=CliType.default("--paster {value}") + ) + on_starting: Callable = field_(default=lambda server: None, metadata_cli=None) + on_reload: Callable = field_(default=lambda server: None, metadata_cli=None) + when_ready: Callable = field_(default=lambda server: None, metadata_cli=None) + pre_fork: Callable = field_(default=lambda server, worker: None, metadata_cli=None) + post_fork: Callable = field_(default=lambda server, worker: None, metadata_cli=None) + post_worker_init: Callable = field_(default=lambda worker: None, metadata_cli=None) + worker_int: Callable = field_(default=lambda worker: None, metadata_cli=None) + worker_abort: Callable = field_(default=lambda worker: None, metadata_cli=None) + pre_exec: Callable = field_(default=lambda server: None, metadata_cli=None) + pre_request: Callable = field_( + default=lambda worker, req: worker.log.debug("%s %s", req.method, req.path), + metadata_cli=None, + ) + post_request: Callable = field_( + default=lambda worker, req, environ, resp: None, metadata_cli=None + ) + child_exit: Callable = field_( + default=lambda server, worker: None, metadata_cli=None + ) + worker_exit: Callable = field_( + default=lambda server, worker: None, metadata_cli=None + ) + nworkers_changed: Callable = field_( + default=lambda server, new_value, old_value: None, metadata_cli=None + ) + on_exit: Callable = field_(default=lambda server: None, metadata_cli=None) + ssl_context: Callable[[Any, Any], Any] = field_( + default=lambda config, + default_ssl_context_factory: default_ssl_context_factory(), + metadata_cli=None, + ) + proxy_protocol: bool = field_( + default=False, metadata_cli=CliType.boolean("--proxy-protocol") + ) + proxy_allow_ips: str = field_( + default="127.0.0.1,::1", + metadata_cli=CliType.default("--proxy-allow-from {value}"), + ) + keyfile: str | None = field_( + default=None, metadata_cli=CliType.default("--keyfile {value}") + ) + certfile: str | None = field_( + default=None, metadata_cli=CliType.default("--certfile {value}") + ) + ssl_version: int = field_( + default=2, metadata_cli=CliType.default("--ssl-version {value}") + ) + cert_reqs: int = field_( + default=0, metadata_cli=CliType.default("--cert-reqs {value}") + ) + ca_certs: str | None = field_( + default=None, metadata_cli=CliType.default("--ca-certs {value}") + ) + suppress_ragged_eofs: bool = field_( + default=True, metadata_cli=CliType.boolean("--suppress-ragged-eofs") + ) + do_handshake_on_connect: bool = field_( + default=False, metadata_cli=CliType.boolean("--do-handshake-on-connect") + ) + ciphers: str | None = field_( + default=None, metadata_cli=CliType.default("--ciphers {value}") + ) + raw_paste_global_conf: list[str] = field_( + default=None, + default_factory=lambda: [], + metadata_cli=CliType.multiple("--paste-global {value}"), + ) + permit_obsolete_folding: bool = field_( + default=False, metadata_cli=CliType.boolean("--permit-obsolete-folding") + ) + strip_header_spaces: bool = field_( + default=False, metadata_cli=CliType.boolean("--strip-header-spaces") + ) + permit_unconventional_http_method: bool = field_( + default=False, + metadata_cli=CliType.boolean("--permit-unconventional-http-method"), + ) + permit_unconventional_http_version: bool = field_( + default=False, + metadata_cli=CliType.boolean("--permit-unconventional-http-version"), + ) + casefold_http_method: bool = field_( + default=False, metadata_cli=CliType.boolean("--casefold-http-method") + ) + forwarder_headers: str = field_( + default="SCRIPT_NAME,PATH_INFO", + metadata_cli=CliType.default("--forwarder-headers {value}"), + ) + header_map: Literal["drop", "refuse", "dangerous"] = field_( + default="drop", metadata_cli=CliType.default("--header-map {value}") ) - """\ - SSL version to use (see stdlib ssl module's). - .. deprecated:: 21.0 - The option is deprecated and it is currently ignored. Use :ref:`ssl-context` instead. - - ============= ============ - --ssl-version Description - ============= ============ - SSLv3 SSLv3 is not-secure and is strongly discouraged. - SSLv23 Alias for TLS. Deprecated in Python 3.6, use TLS. - TLS Negotiate highest possible version between client/server. - Can yield SSL. (Python 3.6+) - TLSv1 TLS 1.0 - TLSv1_1 TLS 1.1 (Python 3.4+) - TLSv1_2 TLS 1.2 (Python 3.4+) - TLS_SERVER Auto-negotiate the highest protocol version like TLS, - but only support server-side SSLSocket connections. - (Python 3.6+) - ============= ============ - - .. versionchanged:: 19.7 - The default value has been changed from ``ssl.PROTOCOL_TLSv1`` to - ``ssl.PROTOCOL_SSLv23``. - .. versionchanged:: 20.0 - This setting now accepts string names based on ``ssl.PROTOCOL_`` - constants. - .. versionchanged:: 20.0.1 - The default value has been changed from ``ssl.PROTOCOL_SSLv23`` to - ``ssl.PROTOCOL_TLS`` when Python >= 3.6 . - """ - - cert_reqs: int = ssl.CERT_NONE - """\ - Whether client certificate is required (see stdlib ssl module's) - - =========== =========================== - --cert-reqs Description - =========== =========================== - `0` ``ssl.CERT_NONE`` - `1` ``ssl.CERT_OPTIONAL`` - `2` ``ssl.CERT_REQUIRED`` - =========== =========================== - """ - - ca_certs: str | None = None - """\ - CA certificates file - """ - - suppress_ragged_eofs: bool = True - """\ - Suppress ragged EOFs (see stdlib ssl module's) - """ - - do_handshake_on_connect: bool = False - """\ - Whether to perform SSL handshake on socket connect (see stdlib ssl module's) - """ - - ciphers: str | None = None - """\ - SSL Cipher suite to use, in the format of an OpenSSL cipher list. - - By default we use the default cipher list from Python's ``ssl`` module, - which contains ciphers considered strong at the time of each Python - release. - - As a recommended alternative, the Open Web App Security Project (OWASP) - offers `a vetted set of strong cipher strings rated A+ to C- - `_. - OWASP provides details on user-agent compatibility at each security level. - - See the `OpenSSL Cipher List Format Documentation - `_ - for details on the format of an OpenSSL cipher list. - """ - - raw_paste_global_conf: list[str] = [] - """\ - Set a PasteDeploy global config variable in ``key=value`` form. - - The option can be specified multiple times. - - The variables are passed to the PasteDeploy entrypoint. Example:: - - $ gunicorn -b 127.0.0.1:8000 --paste development.ini --paste-global FOO=1 --paste-global BAR=2 - """ - - permit_obsolete_folding: bool = False - """\ - Permit requests employing obsolete HTTP line folding mechanism - - The folding mechanism was deprecated by rfc7230 Section 3.2.4 and will not be - employed in HTTP request headers from standards-compliant HTTP clients. - - This option is provided to diagnose backwards-incompatible changes. - Use with care and only if necessary. Temporary; the precise effect of this option may - change in a future version, or it may be removed altogether. - """ - - strip_header_spaces: bool = False - """\ - Strip spaces present between the header name and the the ``:``. - - This is known to induce vulnerabilities and is not compliant with the HTTP/1.1 standard. - See https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn. - - Use with care and only if necessary. Deprecated; scheduled for removal in 25.0.0 - """ - - permit_unconventional_http_method: bool = False - """\ - Permit HTTP methods not matching conventions, such as IANA registration guidelines - - This permits request methods of length less than 3 or more than 20, - methods with lowercase characters or methods containing the # character. - HTTP methods are case sensitive by definition, and merely uppercase by convention. - - If unset, Gunicorn will apply nonstandard restrictions and cause 400 response status - in cases where otherwise 501 status is expected. While this option does modify that - behaviour, it should not be depended upon to guarantee standards-compliant behaviour. - Rather, it is provided temporarily, to assist in diagnosing backwards-incompatible - changes around the incomplete application of those restrictions. - - Use with care and only if necessary. Temporary; scheduled for removal in 24.0.0 - """ - - permit_unconventional_http_version: bool = False - """\ - Permit HTTP version not matching conventions of 2023 - - This disables the refusal of likely malformed request lines. - It is unusual to specify HTTP 1 versions other than 1.0 and 1.1. - - This option is provided to diagnose backwards-incompatible changes. - Use with care and only if necessary. Temporary; the precise effect of this option may - change in a future version, or it may be removed altogether. - """ - - casefold_http_method: bool = False - """\ - Transform received HTTP methods to uppercase - - HTTP methods are case sensitive by definition, and merely uppercase by convention. - - This option is provided because previous versions of gunicorn defaulted to this behaviour. - - Use with care and only if necessary. Deprecated; scheduled for removal in 24.0.0 - """ - - forwarder_headers: str = "SCRIPT_NAME,PATH_INFO" - """\ - A list containing upper-case header field names that the front-end proxy - (see :ref:`forwarded-allow-ips`) sets, to be used in WSGI environment. - - This option has no effect for headers not present in the request. - - This option can be used to transfer ``SCRIPT_NAME``, ``PATH_INFO`` - and ``REMOTE_USER``. - - It is important that your front-end proxy configuration ensures that - the headers defined here can not be passed directly from the client. - """ - - header_map: Literal["drop", "refuse", "dangerous"] = "drop" - """\ - Configure how header field names are mapped into environ - - Headers containing underscores are permitted by RFC9110, - but gunicorn joining headers of different names into - the same environment variable will dangerously confuse applications as to which is which. - - The safe default ``drop`` is to silently drop headers that cannot be unambiguously mapped. - The value ``refuse`` will return an error if a request contains *any* such header. - The value ``dangerous`` matches the previous, not advisable, behaviour of mapping different - header field names into the same environ name. - - If the source is permitted as explained in :ref:`forwarded-allow-ips`, *and* the header name is - present in :ref:`forwarder-headers`, the header is mapped into environment regardless of - the state of this setting. - - Use with care and only if necessary and after considering if your problem could - instead be solved by specifically renaming or rewriting only the intended headers - on a proxy in front of Gunicorn. - """ - - def check_import(self, extra: bool = False): + def check_import(self): """Check package importation.""" from importlib.util import find_spec errors: list[str] = [] + if IS_WINDOWS: + errors.append( + "The `GunicornBackendServer` only works on UNIX machines. We recommend using the `UvicornBackendServer` for Windows machines." + ) + if find_spec("gunicorn") is None: errors.append( 'The `gunicorn` package is required to run `GunicornBackendServer`. Run `pip install "gunicorn>=20.1.0"`.' @@ -1187,7 +375,7 @@ class GunicornBackendServer(CustomBackendServer): def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - self.app_uri = f"{self.get_app_module()}()" + self._app_uri = f"{self.get_app_module()}()" self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] @@ -1211,25 +399,17 @@ class GunicornBackendServer(CustomBackendServer): def run_prod(self) -> list[str]: """Run in production mode.""" self.check_import() - command = ["gunicorn"] for key, field in self.get_fields().items(): - if key != "app": - value = getattr(self, key) - if _mapping_attr_to_cli.get(key) and value != field.default: - if isinstance(value, list): - for v in value: - command += [_mapping_attr_to_cli[key], str(v)] - elif isinstance(value, bool): - if (key == "sendfile" and value is False) or ( - key != "sendfile" and value - ): - command.append(_mapping_attr_to_cli[key]) - else: - command += [_mapping_attr_to_cli[key], str(value)] + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") - return command + [f"{self.get_app_module()}()"] + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" @@ -1241,25 +421,29 @@ class GunicornBackendServer(CustomBackendServer): from gunicorn.app.base import BaseApplication from gunicorn.util import import_app as gunicorn_import_app - options_ = self.dict() - options_.pop("app", None) + model = self.get_fields() + options_ = { + key: value + for key, value in self.get_values().items() + if value != model[key].default + } class StandaloneApplication(BaseApplication): def __init__(self, app_uri, options=None): self.options = options or {} - self.app_uri = app_uri + self._app_uri = app_uri super().__init__() def load_config(self): config = { key: value for key, value in self.options.items() - if key in self.cfg.settings and value is not None - } # type: ignore + if key in self.cfg.settings and value is not None # type: ignore + } for key, value in config.items(): self.cfg.set(key.lower(), value) # type: ignore def load(self): - return gunicorn_import_app(self.app_uri) + return gunicorn_import_app(self._app_uri) - StandaloneApplication(app_uri=self.app_uri, options=options_).run() + StandaloneApplication(app_uri=self._app_uri, options=options_).run() diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index cb5131c4a..8d167eacb 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -2,18 +2,188 @@ from __future__ import annotations +# `UvicornBackendServer` defer from other `*BackendServer`, because `uvicorn` he is natively integrated inside the reflex project via Fastapi (same for asyncio) +import asyncio +import os +import ssl import sys +from configparser import RawConfigParser +from dataclasses import dataclass +from pathlib import Path +from typing import IO, Any, Awaitable, Callable + +from uvicorn import Config, Server +from uvicorn.config import ( + LOGGING_CONFIG, + SSL_PROTOCOL_VERSION, + HTTPProtocolType, + InterfaceType, + LifespanType, + LoopSetupType, + WSProtocolType, +) from reflex.constants.base import Env, LogLevel -from reflex.server.base import CustomBackendServer +from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console -# TODO +@dataclass class UvicornBackendServer(CustomBackendServer): - """Uvicorn backendServer.""" + """Uvicorn backendServer. - def check_import(self, extra: bool = False): + https://www.uvicorn.org/settings/ + """ + + host: str = field_( + default="127.0.0.1", metadata_cli=CliType.default("--host {value}") + ) + port: int = field_(default=8000, metadata_cli=CliType.default("--port {value}")) + uds: str | None = field_( + default=None, metadata_cli=CliType.default("--uds {value}") + ) + fd: int | None = field_(default=None, metadata_cli=CliType.default("--fd {value}")) + loop: LoopSetupType = field_( + default="auto", metadata_cli=CliType.default("--loop {value}") + ) + http: type[asyncio.Protocol] | HTTPProtocolType = field_( + default="auto", metadata_cli=CliType.default("--http {value}") + ) + ws: type[asyncio.Protocol] | WSProtocolType = field_( + default="auto", metadata_cli=CliType.default("--ws {value}") + ) + ws_max_size: int = field_( + default=16777216, metadata_cli=CliType.default("--ws-max-size {value}") + ) + ws_max_queue: int = field_( + default=32, metadata_cli=CliType.default("--ws-max-queue {value}") + ) + ws_ping_interval: float | None = field_( + default=20.0, metadata_cli=CliType.default("--ws-ping-interval {value}") + ) + ws_ping_timeout: float | None = field_( + default=20.0, metadata_cli=CliType.default("--ws-ping-timeout {value}") + ) + ws_per_message_deflate: bool = field_( + default=True, metadata_cli=CliType.default("--ws-per-message-deflate {value}") + ) + lifespan: LifespanType = field_( + default="auto", metadata_cli=CliType.default("--lifespan {value}") + ) + env_file: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--env-file {value}") + ) + log_config: dict[str, Any] | str | RawConfigParser | IO[Any] | None = field_( + default=None, + default_factory=lambda: LOGGING_CONFIG, + metadata_cli=CliType.default("--log-config {value}"), + ) + log_level: str | int | None = field_( + default=None, metadata_cli=CliType.default("--log-level {value}") + ) + access_log: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}access-log"), + ) + use_colors: bool | None = field_( + default=None, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}use-colors"), + ) + interface: InterfaceType = field_( + default="auto", metadata_cli=CliType.default("--interface {value}") + ) + reload: bool = field_( + default=False, metadata_cli=CliType.default("--reload {value}") + ) + reload_dirs: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("--reload_dir {value}") + ) + reload_delay: float = field_( + default=0.25, metadata_cli=CliType.default("--reload-delay {value}") + ) + reload_includes: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("----reload-include {value}") + ) + reload_excludes: list[str] | str | None = field_( + default=None, metadata_cli=CliType.multiple("--reload-exclude {value}") + ) + workers: int = field_(default=0, metadata_cli=CliType.default("--workers {value}")) + proxy_headers: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}proxy-headers"), + ) + server_header: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}server-header"), + ) + date_header: bool = field_( + default=True, + metadata_cli=CliType.boolean_toggle("--{toggle_kw}{toggle_sep}date-header"), + ) + forwarded_allow_ips: list[str] | str | None = field_( + default=None, + metadata_cli=CliType.multiple("--forwarded-allow-ips {value}", join_sep=","), + ) + root_path: str = field_( + default="", metadata_cli=CliType.default("--root-path {value}") + ) + limit_concurrency: int | None = field_( + default=None, metadata_cli=CliType.default("--limit-concurrency {value}") + ) + limit_max_requests: int | None = field_( + default=None, metadata_cli=CliType.default("--limit-max-requests {value}") + ) + backlog: int = field_( + default=2048, metadata_cli=CliType.default("--backlog {value}") + ) + timeout_keep_alive: int = field_( + default=5, metadata_cli=CliType.default("--timeout-keep-alive {value}") + ) + timeout_notify: int = field_(default=30, metadata_cli=None) + timeout_graceful_shutdown: int | None = field_( + default=None, + metadata_cli=CliType.default("--timeout-graceful-shutdown {value}"), + ) + callback_notify: Callable[..., Awaitable[None]] | None = field_( + default=None, metadata_cli=None + ) + ssl_keyfile: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile {value}") + ) + ssl_certfile: str | os.PathLike[str] | None = field_( + default=None, metadata_cli=CliType.default("--ssl-certfile {value}") + ) + ssl_keyfile_password: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-keyfile-password {value}") + ) + ssl_version: int = field_( + default=SSL_PROTOCOL_VERSION, + metadata_cli=CliType.default("--ssl-version {value}"), + ) + ssl_cert_reqs: int = field_( + default=ssl.CERT_NONE, metadata_cli=CliType.default("--ssl-cert-reqs {value}") + ) + ssl_ca_certs: str | None = field_( + default=None, metadata_cli=CliType.default("--ssl-ca-certs {value}") + ) + ssl_ciphers: str = field_( + default="TLSv1", metadata_cli=CliType.default("--ssl-ciphers {value}") + ) + headers: list[tuple[str, str]] | None = field_( + default=None, + metadata_cli=CliType.multiple( + "--header {value}", value_transformer=lambda value: f"{value[0]}:{value[1]}" + ), + ) + factory: bool = field_( + default=False, metadata_cli=CliType.default("--factory {value}") + ) + h11_max_incomplete_event_size: int | None = field_( + default=None, + metadata_cli=CliType.default("--h11-max-incomplete-event-size {value}"), + ) + + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -24,18 +194,62 @@ class UvicornBackendServer(CustomBackendServer): 'The `uvicorn` package is required to run `UvicornBackendServer`. Run `pip install "uvicorn>=0.20.0"`.' ) + if find_spec("watchfiles") is None and ( + self.reload_includes and self.reload_excludes + ): + errors.append( + 'Using `--reload-include` and `--reload-exclude` in `UvicornBackendServer` requires the `watchfiles` extra. Run `pip install "watchfiles>=0.13"`.' + ) + if errors: console.error("\n".join(errors)) sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup.""" - pass + self._app_uri = self.get_app_module(add_extra_api=True) + self.log_level = loglevel.value + self.host = host + self.port = port + + if env == Env.PROD: + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers + + if env == Env.DEV: + from reflex.config import get_config # prevent circular import + + self.reload = True + self.reload_dirs = [str(Path(get_config().app_name))] def run_prod(self): """Run in production mode.""" - pass + self.check_import() + command = ["uvicorn"] + + for key, field in self.get_fields().items(): + if ( + field.metadata["exclude"] is False + and field.metadata["cli"] + and not self.is_default_value(key, (value := getattr(self, key))) + ): + command += field.metadata["cli"](value).split(" ") + + return command + [self._app_uri] def run_dev(self): """Run in development mode.""" - pass + self.check_import() + + options_ = { + key: value + for key, value in self.get_values().items() + if not self.is_default_value(key, value) + } + + Server( + config=Config(**options_, app=self._app_uri), + ).run() diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 06a61a293..4daaa69b8 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -203,9 +203,9 @@ def run_backend( notify_backend() # Run the backend in development mode. - backend_server_prod = config.backend_server_prod - backend_server_prod.setup(host, port, loglevel, Env.DEV) - backend_server_prod.run_dev() + backend_server_dev = config.backend_server_dev + backend_server_dev.setup(host, port, loglevel, Env.DEV) + backend_server_dev.run_dev() def run_backend_prod( From 70e790a4d11ccf725feb36d0cc5670c9e04bdf1b Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 9 Nov 2024 21:05:50 +0000 Subject: [PATCH 21/25] remove unused var --- reflex/server/gunicorn.py | 75 --------------------------------------- 1 file changed, 75 deletions(-) diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 53544da7b..4cdb150fa 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -10,81 +10,6 @@ from reflex.constants.base import IS_WINDOWS, Env, LogLevel from reflex.server.base import CliType, CustomBackendServer, field_ from reflex.utils import console -_mapping_attr_to_cli: dict[str, str] = { - "config": "--config", - "bind": "--bind", - "backlog": "--backlog", - "workers": "--workers", - "worker_class": "--worker-class", - "threads": "--threads", - "worker_connections": "--worker-connections", - "max_requests": "--max-requests", - "max_requests_jitter": "--max-requests-jitter", - "timeout": "--timeout", - "graceful_timeout": "--graceful-timeout", - "keepalive": "--keep-alive", - "limit_request_line": "--limit-request-line", - "limit_request_fields": "--limit-request-fields", - "limit_request_field_size": "--limit-request-field_size", - "reload": "--reload", - "reload_engine": "--reload-engine", - "reload_extra_files": "--reload-extra-file", - "spew": "--spew", - "check_config": "--check-config", - "print_config": "--print-config", - "preload_app": "--preload", - "sendfile": "--no-sendfile", - "reuse_port": "--reuse-port", - "chdir": "--chdir", - "daemon": "--daemon", - "raw_env": "--env", - "pidfile": "--pid", - "worker_tmp_dir": "--worker-tmp-dir", - "user": "--user", - "group": "--group", - "umask": "--umask", - "initgroups": "--initgroups", - "forwarded_allow_ips": "--forwarded-allow-ips", - "accesslog": "--access-logfile", - "disable_redirect_access_to_syslog": "--disable-redirect-access-to-syslog", - "access_log_format": "--access-logformat", - "errorlog": "--error-logfile", - "loglevel": "--log-level", - "capture_output": "--capture-output", - "logger_class": "--logger-class", - "logconfig": "--log-config", - "logconfig_json": "--log-config-json", - "syslog_addr": "--log-syslog-to", - "syslog": "--log-syslog", - "syslog_prefix": "--log-syslog-prefix", - "syslog_facility": "--log-syslog-facility", - "enable_stdio_inheritance": "--enable-stdio-inheritance", - "statsd_host": "--statsd-host", - "dogstatsd_tags": "--dogstatsd-tags", - "statsd_prefix": "--statsd-prefix", - "proc_name": "--name", - "pythonpath": "--pythonpath", - "paste": "--paster", - "proxy_protocol": "--proxy-protocol", - "proxy_allow_ips": "--proxy-allow-from", - "keyfile": "--keyfile", - "certfile": "--certfile", - "ssl_version": "--ssl-version", - "cert_reqs": "--cert-reqs", - "ca_certs": "--ca-certs", - "suppress_ragged_eofs": "--suppress-ragged-eofs", - "do_handshake_on_connect": "--do-handshake-on-connect", - "ciphers": "--ciphers", - "raw_paste_global_conf": "--paste-global", - "permit_obsolete_folding": "--permit-obsolete-folding", - "strip_header_spaces": "--strip-header-spaces", - "permit_unconventional_http_method": "--permit-unconventional-http-method", - "permit_unconventional_http_version": "--permit-unconventional-http-version", - "casefold_http_method": "--casefold-http-method", - "forwarder_headers": "--forwarder-headers", - "header_map": "--header-map", -} - @dataclass class GunicornBackendServer(CustomBackendServer): From 19f6dc5edccf4d274ee24aa149ecb75dfd1ac218 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 15:32:10 +0000 Subject: [PATCH 22/25] [IMPL] - add `get_backend_bind()` & `shutdown()` --- reflex/server/base.py | 16 ++++++++++++++-- reflex/server/granian.py | 13 +++++++++++-- reflex/server/gunicorn.py | 23 ++++++++++++++++++++++- reflex/server/uvicorn.py | 20 ++++++++++++++++++-- reflex/utils/exec.py | 6 ++---- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/reflex/server/base.py b/reflex/server/base.py index 012511233..b19a6acb5 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -7,7 +7,7 @@ from abc import abstractmethod from dataclasses import Field, dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Callable, Sequence +from typing import Any, Callable, Sequence, ClassVar from reflex import constants from reflex.constants.base import Env, LogLevel @@ -156,7 +156,9 @@ def field_( class CustomBackendServer: """BackendServer base.""" - _app_uri: str = field_(default="", metadata_cli=None, exclude=True) + _env: ClassVar[Env] = field_(default=Env.DEV, metadata_cli=None, exclude=True, repr = False, init = False) + _app: ClassVar[Any] = field_(default=None, metadata_cli=None, exclude=True, repr = False, init = False) + _app_uri: ClassVar[str] = field_(default="", metadata_cli=None, exclude=True, repr = False, init = False) @staticmethod def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): @@ -246,6 +248,11 @@ class CustomBackendServer: return False + @abstractmethod + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + raise NotImplementedError() + @abstractmethod def check_import(self): """Check package importation.""" @@ -265,3 +272,8 @@ class CustomBackendServer: def run_dev(self): """Run in development mode.""" raise NotImplementedError() + + @abstractmethod + async def shutdown(self): + """Shutdown the backend server.""" + raise NotImplementedError() diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 2a76d8c53..547929398 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -191,6 +191,9 @@ class GranianBackendServer(CustomBackendServer): default=None, metadata_cli=CliType.default("--pid-file {value}") ) + def get_backend_bind(self) -> tuple[str, int]: + return self.address, self.port + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -218,6 +221,7 @@ class GranianBackendServer(CustomBackendServer): self.address = host self.port = port self.interface = "asgi" # NOTE: prevent obvious error + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -273,7 +277,7 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - Granian( + self._app = Granian( **{ **{ key: value @@ -301,4 +305,9 @@ class GranianBackendServer(CustomBackendServer): self.http2_max_send_buffer_size, ), } - ).serve() + ) + self._app.serve() + + async def shutdown(self): + if self._app and self._env == Env.DEV: + self._app.shutdown() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 4cdb150fa..be0b62729 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -278,6 +278,11 @@ class GunicornBackendServer(CustomBackendServer): default="drop", metadata_cli=CliType.default("--header-map {value}") ) + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + host, port = self.bind[0].split(":") + return host, int(port) + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -303,6 +308,7 @@ class GunicornBackendServer(CustomBackendServer): self._app_uri = f"{self.get_app_module()}()" self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -370,5 +376,20 @@ class GunicornBackendServer(CustomBackendServer): def load(self): return gunicorn_import_app(self._app_uri) + + def stop(self): + from gunicorn.arbiter import Arbiter - StandaloneApplication(app_uri=self._app_uri, options=options_).run() + Arbiter(self).stop() + + self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) + self._app.run() + + async def shutdown(self): + """Shutdown the backend server.""" + if self._app and self._env == Env.DEV: + self._app.stop() # type: ignore + + # TODO: complicated because currently `*BackendServer` don't execute the server command, he just create it + # if self._env == Env.PROD: + # pass diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index 8d167eacb..7f96c6070 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -183,6 +183,10 @@ class UvicornBackendServer(CustomBackendServer): metadata_cli=CliType.default("--h11-max-incomplete-event-size {value}"), ) + def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port""" + return self.host, self.port + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -211,6 +215,7 @@ class UvicornBackendServer(CustomBackendServer): self.log_level = loglevel.value self.host = host self.port = port + self._env = env if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -250,6 +255,17 @@ class UvicornBackendServer(CustomBackendServer): if not self.is_default_value(key, value) } - Server( + self._app = Server( config=Config(**options_, app=self._app_uri), - ).run() + ) + self._app.run() + + async def shutdown(self): + """Shutdown the backend server.""" + if self._app and self._env == Env.DEV: + self._app.shutdown() # type: ignore + + # TODO: hard because currently `*BackendServer` don't execute the server command, he just create it + # if self._env == Env.PROD: + # pass + diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 4daaa69b8..12fa555e6 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -178,7 +178,6 @@ def run_frontend_prod(root: Path, port: str, backend_present=True): ) -### REWORK <-- def run_backend( host: str, port: int, @@ -237,12 +236,11 @@ def run_backend_prod( run=True, show_logs=True, env={ - environment.REFLEX_SKIP_COMPILE.name: "true" - }, # skip compile for prod backend + environment.REFLEX_SKIP_COMPILE.name: "true" # skip compile for prod backend + }, ) -### REWORK--> def output_system_info(): """Show system information if the loglevel is in DEBUG.""" if console._LOG_LEVEL > constants.LogLevel.DEBUG: From f70c444833c5a250de21ae0668862751fb249587 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 16:36:19 +0000 Subject: [PATCH 23/25] deprecated config field --- reflex/config.py | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index 20401e6ac..da381ae91 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -700,6 +700,9 @@ class Config(Base): backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( threads=2, workers=4, + max_requests=100, + max_requests_jitter=25, + timeout=120 ) backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, @@ -734,26 +737,15 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - - for key in ( - "timeout", - "gunicorn_worker_class", - "gunicorn_workers", - "gunicorn_max_requests", - "gunicorn_max_requests_jitter", + + if any( + getattr(self.get_fields().get(key, None), "default", None) == self.get_value(key) + for key in ("timeout","gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter" ) ): - if isinstance(self.backend_server_prod, server.GunicornBackendServer): - value = self.get_value(key) - if ( - value - != self.backend_server_prod.get_fields()[ - key.replace("gunicorn_", "") - ].default - and value is not None - ): - setattr( - self.backend_server_prod, key.replace("gunicorn_", ""), value - ) + console.warn( + 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.' + ) + @property def module(self) -> str: From 4a24b3640dd75f72dd0be923e77b16e08b4efbf4 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sat, 23 Nov 2024 21:17:38 +0000 Subject: [PATCH 24/25] lint & format --- reflex/config.py | 20 ++--- reflex/server/base.py | 156 +++++++++++++++++++++++++++++++++----- reflex/server/granian.py | 30 ++++++-- reflex/server/gunicorn.py | 31 ++++++-- reflex/server/uvicorn.py | 32 +++++--- 5 files changed, 216 insertions(+), 53 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index da381ae91..b98eab834 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -698,11 +698,7 @@ class Config(Base): # Custom Backend Server backend_server_prod: server.CustomBackendServer = server.GunicornBackendServer( - threads=2, - workers=4, - max_requests=100, - max_requests_jitter=25, - timeout=120 + threads=2, workers=4, max_requests=100, max_requests_jitter=25, timeout=120 ) backend_server_dev: server.CustomBackendServer = server.UvicornBackendServer( workers=1, @@ -737,15 +733,21 @@ class Config(Base): raise ConfigError( "REDIS_URL is required when using the redis state manager." ) - + if any( - getattr(self.get_fields().get(key, None), "default", None) == self.get_value(key) - for key in ("timeout","gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter" ) + getattr(self.get_fields().get(key, None), "default", None) + == self.get_value(key) + for key in ( + "timeout", + "gunicorn_worker_class", + "gunicorn_workers", + "gunicorn_max_requests", + "gunicorn_max_requests_jitter", + ) ): console.warn( 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.' ) - @property def module(self) -> str: diff --git a/reflex/server/base.py b/reflex/server/base.py index b19a6acb5..c78c90224 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -7,7 +7,7 @@ from abc import abstractmethod from dataclasses import Field, dataclass from dataclasses import field as dc_field from pathlib import Path -from typing import Any, Callable, Sequence, ClassVar +from typing import Any, Callable, ClassVar, Sequence from reflex import constants from reflex.constants.base import Env, LogLevel @@ -26,6 +26,12 @@ class CliType: fmt: `'--env-file {value}'` value: `'/config.conf'` result => `'--env-file /config.conf'` + + Args: + fmt (str): format + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -46,6 +52,13 @@ class CliType: fmt: `'--reload'` value: `True` result => `'--reload'` + + Args: + fmt (str): format + bool_value (bool): boolean value used for toggle condition + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -80,6 +93,16 @@ class CliType: value: `True` toggle_value: `True` result => `'--no-access-log'` + + Args: + fmt (str): format + toggle_kw (str): keyword used when toggled. Defaults to "no". + toggle_sep (str): separator used when toggled. Defaults to "-". + toggle_value (bool): boolean value used for toggle condition. Defaults to False. + **kwargs: Keyword arguments to pass to the format string function. + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(value: bool) -> str: @@ -102,6 +125,7 @@ class CliType: Example (Multiple args mode): fmt: `'--header {value}'`. data_list: `['X-Forwarded-Proto=https', 'X-Forwarded-For=0.0.0.0']` + join_sep: `None` result => `'--header \"X-Forwarded-Proto=https\" --header \"X-Forwarded-For=0.0.0.0\"'` Example (Single args mode): @@ -116,6 +140,14 @@ class CliType: join_sep (required): `';'` value_transformer: `lambda value: f'{value[0]}:{value[1]}'` result => `--headers \"X-Forwarded-Proto:https;X-Forwarded-For:0.0.0.0\"` + + Args: + fmt (str): format + join_sep (str): separator used + value_transformer (Callable[[Any], str]): function used for transformer the element + + Returns: + ReturnCliTypeFn: function wrapper """ def wrapper(values: Sequence[str]) -> str: @@ -139,7 +171,17 @@ def field_( exclude: bool = False, **kwargs, ): - """Custom dataclass field builder.""" + """Custom dataclass field builder. + + Args: + default (Any): default value. Defaults to None. + metadata_cli (ReturnCliTypeFn | None): cli wrapper function. Defaults to None. + exclude (bool): used for excluding the field to the server configuration (system field). Defaults to False. + **kwargs: Keyword arguments to pass to the field dataclasses function. + + Returns: + Field: return the field dataclasses + """ params_ = { "default": default, "metadata": {"cli": metadata_cli, "exclude": exclude}, @@ -156,16 +198,28 @@ def field_( class CustomBackendServer: """BackendServer base.""" - _env: ClassVar[Env] = field_(default=Env.DEV, metadata_cli=None, exclude=True, repr = False, init = False) - _app: ClassVar[Any] = field_(default=None, metadata_cli=None, exclude=True, repr = False, init = False) - _app_uri: ClassVar[str] = field_(default="", metadata_cli=None, exclude=True, repr = False, init = False) + _env: ClassVar[Env] = field_( + default=Env.DEV, metadata_cli=None, exclude=True, repr=False, init=False + ) + _app: ClassVar[Any] = field_( + default=None, metadata_cli=None, exclude=True, repr=False, init=False + ) + _app_uri: ClassVar[str] = field_( + default="", metadata_cli=None, exclude=True, repr=False, init=False + ) @staticmethod - def get_app_module(for_granian_target: bool = False, add_extra_api: bool = False): + def get_app_module( + for_granian_target: bool = False, add_extra_api: bool = False + ) -> str: """Get the app module for the backend. + Args: + for_granian_target (bool): make the return compatible with Granian. Defaults to False. + add_extra_api (bool): add the keyword "api" at the end (needed for Uvicorn & Granian). Defaults to False. + Returns: - The app module for the backend. + str: The app module for the backend. """ import reflex @@ -177,21 +231,41 @@ class CustomBackendServer: return f"{app_path}:{constants.CompileVars.APP}{f'.{constants.CompileVars.API}' if add_extra_api else ''}" def get_available_cpus(self) -> int: - """Get available cpus.""" + """Get available cpus. + + Returns: + int: number of available cpu cores + """ return os.cpu_count() or 1 def get_max_workers(self) -> int: - """Get max workers.""" + """Get maximum workers. + + Returns: + int: get the maximum number of workers + """ # https://docs.gunicorn.org/en/latest/settings.html#workers return (os.cpu_count() or 1) * 4 + 1 def get_recommended_workers(self) -> int: - """Get recommended workers.""" + """Get recommended workers. + + Returns: + int: get the recommended number of workers + """ # https://docs.gunicorn.org/en/latest/settings.html#workers return (os.cpu_count() or 1) * 2 + 1 def get_max_threads(self, wait_time_ms: int = 50, service_time_ms: int = 5) -> int: - """Get max threads.""" + """Get maximum threads. + + Args: + wait_time_ms (int): the mean waiting duration targeted. Defaults to 50. + service_time_ms (int): the mean working duration. Defaults to 5. + + Returns: + int: get the maximum number of threads + """ # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html # Brian Goetz formula return int(self.get_available_cpus() * (1 + wait_time_ms / service_time_ms)) @@ -202,7 +276,16 @@ class CustomBackendServer: wait_time_ms: int = 50, service_time_ms: int = 5, ) -> int: - """Get recommended threads.""" + """Get recommended threads. + + Args: + target_reqs (int | None): number of requests targeted. Defaults to None. + wait_time_ms (int): the mean waiting duration targeted. Defaults to 50. + service_time_ms (int): the mean working duration. Defaults to 5. + + Returns: + int: get the recommended number of threads + """ # https://engineering.zalando.com/posts/2019/04/how-to-set-an-ideal-thread-pool-size.html max_available_threads = self.get_max_threads() @@ -221,19 +304,35 @@ class CustomBackendServer: ) def get_fields(self) -> dict[str, Field]: - """Return all the fields.""" + """Return all the fields. + + Returns: + dict[str, Field]: return the fields dictionary + """ return self.__dataclass_fields__ def get_values(self) -> dict[str, Any]: - """Return all values.""" + """Return all values. + + Returns: + dict[str, Any]: returns the value of the fields + """ return { key: getattr(self, key) for key, field in self.__dataclass_fields__.items() if field.metadata["exclude"] is False } - def is_default_value(self, key, value: Any | None = None) -> bool: - """Check if the `value` is the same value from default context.""" + def is_default_value(self, key: str, value: Any | None = None) -> bool: + """Check if the `value` is the same value from default context. + + Args: + key (str): the name of the field + value (Any | None, optional): the value to check if is equal to the default value. Defaults to None. + + Returns: + bool: result of the condition of value are equal to the default value + """ from dataclasses import MISSING field = self.get_fields()[key] @@ -250,9 +349,13 @@ class CustomBackendServer: @abstractmethod def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ raise NotImplementedError() - + @abstractmethod def check_import(self): """Check package importation.""" @@ -260,12 +363,23 @@ class CustomBackendServer: @abstractmethod def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ raise NotImplementedError() @abstractmethod - def run_prod(self): - """Run in production mode.""" + def run_prod(self) -> list[str]: + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ raise NotImplementedError() @abstractmethod diff --git a/reflex/server/granian.py b/reflex/server/granian.py index 547929398..a87faee93 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -192,6 +192,11 @@ class GranianBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ return self.address, self.port def check_import(self): @@ -215,13 +220,20 @@ class GranianBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) # type: ignore self.log_level = loglevel.value # type: ignore self.address = host self.port = port self.interface = "asgi" # NOTE: prevent obvious error - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -244,7 +256,11 @@ class GranianBackendServer(CustomBackendServer): self.reload_ignore_dirs = [".web"] def run_prod(self): - """Run in production mode.""" + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["granian"] @@ -261,7 +277,7 @@ class GranianBackendServer(CustomBackendServer): def run_dev(self): """Run in development mode.""" self.check_import() - from granian import Granian + from granian import Granian # type: ignore exclude_keys = ( "http1_keep_alive", @@ -277,7 +293,8 @@ class GranianBackendServer(CustomBackendServer): "http2_max_headers_size", "http2_max_send_buffer_size", ) - self._app = Granian( + + self._app = Granian( # type: ignore **{ **{ key: value @@ -309,5 +326,6 @@ class GranianBackendServer(CustomBackendServer): self._app.serve() async def shutdown(self): + """Shutdown the backend server.""" if self._app and self._env == Env.DEV: self._app.shutdown() diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index be0b62729..5e47044cf 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -279,10 +279,14 @@ class GunicornBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ host, port = self.bind[0].split(":") return host, int(port) - + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -304,11 +308,18 @@ class GunicornBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = f"{self.get_app_module()}()" + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = f"{self.get_app_module()}()" # type: ignore self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -328,7 +339,11 @@ class GunicornBackendServer(CustomBackendServer): self.reload = True def run_prod(self) -> list[str]: - """Run in production mode.""" + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["gunicorn"] @@ -376,13 +391,13 @@ class GunicornBackendServer(CustomBackendServer): def load(self): return gunicorn_import_app(self._app_uri) - + def stop(self): from gunicorn.arbiter import Arbiter Arbiter(self).stop() - self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) + self._app = StandaloneApplication(app_uri=self._app_uri, options=options_) # type: ignore self._app.run() async def shutdown(self): diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index 7f96c6070..e164fefee 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -184,9 +184,13 @@ class UvicornBackendServer(CustomBackendServer): ) def get_backend_bind(self) -> tuple[str, int]: - """Return the backend host and port""" + """Return the backend host and port. + + Returns: + tuple[str, int]: The host address and port. + """ return self.host, self.port - + def check_import(self): """Check package importation.""" from importlib.util import find_spec @@ -210,12 +214,19 @@ class UvicornBackendServer(CustomBackendServer): sys.exit() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): - """Setup.""" - self._app_uri = self.get_app_module(add_extra_api=True) + """Setup. + + Args: + host (str): host address + port (int): port address + loglevel (LogLevel): log level + env (Env): prod/dev environment + """ + self._app_uri = self.get_app_module(add_extra_api=True) # type: ignore self.log_level = loglevel.value self.host = host self.port = port - self._env = env + self._env = env # type: ignore if env == Env.PROD: if self.workers == self.get_fields()["workers"].default: @@ -230,8 +241,12 @@ class UvicornBackendServer(CustomBackendServer): self.reload = True self.reload_dirs = [str(Path(get_config().app_name))] - def run_prod(self): - """Run in production mode.""" + def run_prod(self) -> list[str]: + """Run in production mode. + + Returns: + list[str]: Command ready to be executed + """ self.check_import() command = ["uvicorn"] @@ -255,7 +270,7 @@ class UvicornBackendServer(CustomBackendServer): if not self.is_default_value(key, value) } - self._app = Server( + self._app = Server( # type: ignore config=Config(**options_, app=self._app_uri), ) self._app.run() @@ -268,4 +283,3 @@ class UvicornBackendServer(CustomBackendServer): # TODO: hard because currently `*BackendServer` don't execute the server command, he just create it # if self._env == Env.PROD: # pass - From 1ce3569fbfd410bc9fbcc4da32704107a3302476 Mon Sep 17 00:00:00 2001 From: KronosDev-Pro Date: Sun, 12 Jan 2025 05:24:56 +0000 Subject: [PATCH 25/25] [FIX] - formatter/linter, prevent failed backend server start --- reflex/config.py | 7 ++++-- reflex/server/base.py | 7 ++---- reflex/server/granian.py | 41 +++++++++++++++----------------- reflex/server/gunicorn.py | 42 +++++++++++++++------------------ reflex/server/uvicorn.py | 35 +++++++++++----------------- reflex/utils/exec.py | 49 +++++++++++++++++++++++++++++---------- 6 files changed, 96 insertions(+), 85 deletions(-) diff --git a/reflex/config.py b/reflex/config.py index b98eab834..3f0b512d0 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -745,8 +745,11 @@ class Config(Base): "gunicorn_max_requests_jitter", ) ): - console.warn( - 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.' + console.deprecate( + 'The following reflex configuration fields are obsolete: "timeout", "gunicorn_worker_class", "gunicorn_workers", "gunicorn_max_requests", "gunicorn_max_requests_jitter"\nplease update your configuration.', + reason="Use `config.backend_server_dev` or `config.backend_server_prod` instead in your `rxconfig.py`.", + deprecation_version="0.7.x", + removal_version="x.x.x", ) @property diff --git a/reflex/server/base.py b/reflex/server/base.py index c78c90224..fdd231393 100644 --- a/reflex/server/base.py +++ b/reflex/server/base.py @@ -1,4 +1,5 @@ """The base for CustomBackendServer.""" +# ruff: noqa: RUF009 from __future__ import annotations @@ -297,11 +298,7 @@ class CustomBackendServer: else: need_threads = self.get_max_threads(wait_time_ms, service_time_ms) - return int( - max_available_threads - if need_threads > max_available_threads - else need_threads - ) + return int(min(need_threads, max_available_threads)) def get_fields(self) -> dict[str, Field]: """Return all the fields. diff --git a/reflex/server/granian.py b/reflex/server/granian.py index a87faee93..4fb61f4f2 100644 --- a/reflex/server/granian.py +++ b/reflex/server/granian.py @@ -1,8 +1,8 @@ """The GranianBackendServer.""" +# ruff: noqa: RUF009 from __future__ import annotations -import sys from dataclasses import dataclass from dataclasses import field as dc_field from pathlib import Path @@ -200,7 +200,11 @@ class GranianBackendServer(CustomBackendServer): return self.address, self.port def check_import(self): - """Check package importation.""" + """Check package importation. + + Raises: + ImportError: raise when some required packaging missing. + """ from importlib.util import find_spec errors: list[str] = [] @@ -217,7 +221,7 @@ class GranianBackendServer(CustomBackendServer): if errors: console.error("\n".join(errors)) - sys.exit() + raise ImportError() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup. @@ -228,6 +232,7 @@ class GranianBackendServer(CustomBackendServer): loglevel (LogLevel): log level env (Env): prod/dev environment """ + self.check_import() self._app_uri = self.get_app_module(for_granian_target=True, add_extra_api=True) # type: ignore self.log_level = loglevel.value # type: ignore self.address = host @@ -235,25 +240,17 @@ class GranianBackendServer(CustomBackendServer): self.interface = "asgi" # NOTE: prevent obvious error self._env = env # type: ignore - if env == Env.PROD: - if self.workers == self.get_fields()["workers"].default: - self.workers = self.get_recommended_workers() - else: - if self.workers > (max_workers := self.get_max_workers()): - self.workers = max_workers + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers - if self.threads == self.get_fields()["threads"].default: - self.threads = self.get_recommended_threads() - else: - if self.threads > (max_threads := self.get_max_threads()): - self.threads = max_threads - - if env == Env.DEV: - from reflex.config import get_config # prevent circular import - - self.reload = True - self.reload_paths = [Path(get_config().app_name)] - self.reload_ignore_dirs = [".web"] + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads def run_prod(self): """Run in production mode. @@ -272,7 +269,7 @@ class GranianBackendServer(CustomBackendServer): ): command += field.metadata["cli"](value).split(" ") - return command + [self._app_uri] + return [*command, self._app_uri] def run_dev(self): """Run in development mode.""" diff --git a/reflex/server/gunicorn.py b/reflex/server/gunicorn.py index 5e47044cf..63a6ba9ce 100644 --- a/reflex/server/gunicorn.py +++ b/reflex/server/gunicorn.py @@ -1,8 +1,8 @@ """The GunicornBackendServer.""" +# ruff: noqa: RUF009 from __future__ import annotations -import sys from dataclasses import dataclass from typing import Any, Callable, Literal @@ -288,7 +288,11 @@ class GunicornBackendServer(CustomBackendServer): return host, int(port) def check_import(self): - """Check package importation.""" + """Check package importation. + + Raises: + ImportError: raise when some required packaging missing. + """ from importlib.util import find_spec errors: list[str] = [] @@ -305,7 +309,7 @@ class GunicornBackendServer(CustomBackendServer): if errors: console.error("\n".join(errors)) - sys.exit() + raise ImportError() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup. @@ -316,27 +320,23 @@ class GunicornBackendServer(CustomBackendServer): loglevel (LogLevel): log level env (Env): prod/dev environment """ + self.check_import() self._app_uri = f"{self.get_app_module()}()" # type: ignore self.loglevel = loglevel.value # type: ignore self.bind = [f"{host}:{port}"] self._env = env # type: ignore - if env == Env.PROD: - if self.workers == self.get_fields()["workers"].default: - self.workers = self.get_recommended_workers() - else: - if self.workers > (max_threads := self.get_max_workers()): - self.workers = max_threads + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_threads := self.get_max_workers()): + self.workers = max_threads - if self.threads == self.get_fields()["threads"].default: - self.threads = self.get_recommended_threads() - else: - if self.threads > (max_threads := self.get_max_threads()): - self.threads = max_threads - self.preload_app = True - - if env == Env.DEV: - self.reload = True + if self.threads == self.get_fields()["threads"].default: + self.threads = self.get_recommended_threads() + else: + if self.threads > (max_threads := self.get_max_threads()): + self.threads = max_threads def run_prod(self) -> list[str]: """Run in production mode. @@ -355,7 +355,7 @@ class GunicornBackendServer(CustomBackendServer): ): command += field.metadata["cli"](value).split(" ") - return command + [self._app_uri] + return [*command, self._app_uri] def run_dev(self): """Run in development mode.""" @@ -404,7 +404,3 @@ class GunicornBackendServer(CustomBackendServer): """Shutdown the backend server.""" if self._app and self._env == Env.DEV: self._app.stop() # type: ignore - - # TODO: complicated because currently `*BackendServer` don't execute the server command, he just create it - # if self._env == Env.PROD: - # pass diff --git a/reflex/server/uvicorn.py b/reflex/server/uvicorn.py index e164fefee..7caf91e0b 100644 --- a/reflex/server/uvicorn.py +++ b/reflex/server/uvicorn.py @@ -1,4 +1,5 @@ """The UvicornBackendServer.""" +# ruff: noqa: RUF009 from __future__ import annotations @@ -6,10 +7,8 @@ from __future__ import annotations import asyncio import os import ssl -import sys from configparser import RawConfigParser from dataclasses import dataclass -from pathlib import Path from typing import IO, Any, Awaitable, Callable from uvicorn import Config, Server @@ -192,7 +191,11 @@ class UvicornBackendServer(CustomBackendServer): return self.host, self.port def check_import(self): - """Check package importation.""" + """Check package importation. + + Raises: + ImportError: raise when some required packaging missing. + """ from importlib.util import find_spec errors: list[str] = [] @@ -211,7 +214,7 @@ class UvicornBackendServer(CustomBackendServer): if errors: console.error("\n".join(errors)) - sys.exit() + raise ImportError() def setup(self, host: str, port: int, loglevel: LogLevel, env: Env): """Setup. @@ -222,24 +225,18 @@ class UvicornBackendServer(CustomBackendServer): loglevel (LogLevel): log level env (Env): prod/dev environment """ + self.check_import() self._app_uri = self.get_app_module(add_extra_api=True) # type: ignore self.log_level = loglevel.value self.host = host self.port = port self._env = env # type: ignore - if env == Env.PROD: - if self.workers == self.get_fields()["workers"].default: - self.workers = self.get_recommended_workers() - else: - if self.workers > (max_workers := self.get_max_workers()): - self.workers = max_workers - - if env == Env.DEV: - from reflex.config import get_config # prevent circular import - - self.reload = True - self.reload_dirs = [str(Path(get_config().app_name))] + if self.workers == self.get_fields()["workers"].default: + self.workers = self.get_recommended_workers() + else: + if self.workers > (max_workers := self.get_max_workers()): + self.workers = max_workers def run_prod(self) -> list[str]: """Run in production mode. @@ -258,7 +255,7 @@ class UvicornBackendServer(CustomBackendServer): ): command += field.metadata["cli"](value).split(" ") - return command + [self._app_uri] + return [*command, self._app_uri] def run_dev(self): """Run in development mode.""" @@ -279,7 +276,3 @@ class UvicornBackendServer(CustomBackendServer): """Shutdown the backend server.""" if self._app and self._env == Env.DEV: self._app.shutdown() # type: ignore - - # TODO: hard because currently `*BackendServer` don't execute the server command, he just create it - # if self._env == Env.PROD: - # pass diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 12fa555e6..b30915575 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -10,6 +10,7 @@ import re import subprocess import sys from pathlib import Path +from threading import Barrier, Event from urllib.parse import urljoin import psutil @@ -22,6 +23,8 @@ from reflex.utils.prerequisites import get_web_dir # For uvicorn windows bug fix (#2335) frontend_process = None +barrier = Barrier(2) +failed_start_signal = Event() def detect_package_change(json_file_path: Path) -> str: @@ -61,8 +64,14 @@ def kill(proc_pid: int): process.kill() -def notify_backend(): - """Output a string notifying where the backend is running.""" +def notify_backend(only_backend: bool = False): + """Output a string notifying where the backend is running. + + Args: + only_backend: Whether the frontend is present. + """ + if not only_backend: + barrier.wait() console.print( f"Backend running at: [bold green]http://0.0.0.0:{get_config().backend_port}[/bold green]" ) @@ -110,8 +119,14 @@ def run_process_and_launch_url(run_command: list[str], backend_present=True): console.print( f"App running at: [bold green]{url}[/bold green]{' (Frontend-only mode)' if not backend_present else ''}" ) + if backend_present: - notify_backend() + barrier.wait() + if failed_start_signal.is_set(): + kill(process.pid) + process = None + break + first_run = False else: console.print("New packages detected: Updating app...") @@ -130,7 +145,7 @@ def run_process_and_launch_url(run_command: list[str], backend_present=True): kill(process.pid) process = None break # for line in process.stdout - if process is not None: + if (process is not None) or (failed_start_signal.is_set() and process is None): break # while True @@ -198,12 +213,17 @@ def run_backend( if web_dir.exists(): (web_dir / constants.NOCOMPILE_FILE).touch() - if not frontend_present: - notify_backend() - # Run the backend in development mode. backend_server_dev = config.backend_server_dev - backend_server_dev.setup(host, port, loglevel, Env.DEV) + try: + backend_server_dev.setup(host, port, loglevel, Env.DEV) + except ImportError: + if frontend_present: + failed_start_signal.set() + barrier.wait() # for unlock frontend server + return + + notify_backend(not frontend_present) backend_server_dev.run_dev() @@ -225,12 +245,17 @@ def run_backend_prod( config = get_config() - if not frontend_present: - notify_backend() - # Run the backend in production mode. backend_server_prod = config.backend_server_prod - backend_server_prod.setup(host, port, loglevel, Env.PROD) + try: + backend_server_prod.setup(host, port, loglevel, Env.PROD) + except ImportError: + if frontend_present: + failed_start_signal.set() + barrier.wait() # for unlock frontend server + return + + notify_backend(not frontend_present) processes.new_process( backend_server_prod.run_prod(), run=True,