Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,550 changes: 2,472 additions & 2,078 deletions poetry.lock

Large diffs are not rendered by default.

71 changes: 42 additions & 29 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ maintainers = [
{ name = "Redash maintainers and contributors", email = "<maintainers@redash.io>" }
]
readme = "README.md"
dependencies = []

[tool.black]
target-version = ['py38']
Expand All @@ -26,70 +25,76 @@ force-exclude = '''
python = ">=3.13,<3.14"
advocate = "1.0.0"
aniso8601 = "8.0.0"
authlib = "0.15.5"
authlib = "1.7.2"
backoff = "2.2.1"
blinker = "1.6.2"
blinker = "1.9.0"
click = "8.1.3"
cryptography = "43.0.1"
cryptography = "48.0.0"
disposable-email-domains = ">=0.0.52"
flask = "2.3.2"

# Flask 3.1.x with SQLAlchemy 1.4+ and Flask-SQLAlchemy 3.x
flask = "3.1.3"

flask-limiter = "3.3.1"
flask-login = "0.6.0"
flask-login = "0.6.3"
flask-mail = "0.9.1"
flask-migrate = "2.5.2"
flask-migrate = "4.0.7"
flask-restful = "0.3.10"
flask-sqlalchemy = "2.5.1"
flask-sqlalchemy = "3.0.5"
flask-talisman = "0.7.0"
flask-wtf = "1.1.1"
flask-wtf = "1.3.0"
funcy = "1.13"
gevent = "25.9.1"
greenlet = "3.3.2"
gunicorn = "22.0.0"
httplib2 = "0.19.0"
itsdangerous = "2.1.2"
jinja2 = "3.1.5"
itsdangerous = "2.2.0"
jinja2 = "3.1.6"
jsonschema = "3.1.1"
markupsafe = "2.1.1"
maxminddb-geolite2 = "2018.703"
parsedatetime = "2.6"
passlib = "1.7.3"
psycopg2-binary = "2.9.11"
pyjwt = "2.4.0"
pyopenssl = "24.2.1"
pyjwt = "2.13.0"
pyopenssl = "26.2.0"
pypd = "1.1.0"
pysaml2 = "7.3.1"
pystache = "0.6.0"
python-dateutil = "2.9.0.post0"
python-dotenv = "0.19.2"
python-dotenv = "1.2.2"
pytz = ">=2019.3"
pyyaml = "6.0.1"
redis = "4.6.0"
regex = "2023.8.8"
requests = "2.32.3"
requests = "2.33.0"
restrictedpython = "8.1"
rq = "1.16.1"
pyasynchat = "1.0.5"
rq-scheduler = "0.13.1"
semver = "2.8.1"
sentry-sdk = "1.45.1"
sqlalchemy = "1.3.24"
sqlalchemy = "1.4.53"
sqlalchemy-searchable = "1.2.0"
sqlalchemy-utils = "0.38.3"
sqlparse = "0.5.0"
sqlparse = "0.5.4"
sshtunnel = "0.1.5"
statsd = "3.3.0"
supervisor = "4.1.0"
supervisor = "4.3.0"
supervisor-checks = "0.8.1"
ua-parser = "0.18.0"
urllib3 = "1.26.19"
urllib3 = "1.26.20"
user-agents = "2.0"
werkzeug = "2.3.8"
werkzeug = "3.1.6"
wtforms = "2.2.1"
xlsxwriter = "3.2.9"
tzlocal = "4.3.1"
pyodbc = "5.3.0"
debugpy = "^1.8.9"
paramiko = "3.4.1"
pyasn1 = "0.6.3"
mako = "1.3.12"
pynacl = "1.6.2"
oracledb = "2.5.1"
ibm-db = { version = "^3.2.7", markers = "platform_machine == 'x86_64' or platform_machine == 'AMD64'" }

Expand All @@ -98,24 +103,29 @@ optional = true

[tool.poetry.group.all_ds.dependencies]
atsd-client = "3.0.5"
azure-core = ">=1.38.0"
azure-kusto-data = "5.0.1"
boto3 = "1.28.8"
botocore = "1.31.8"
boto3 = "1.43.7"
botocore = "1.43.7"
cassandra-driver = "3.29.3"
certifi = ">=2019.9.11"
cmem-cmempy = "21.2.3"
databend-py = "0.4.6"
databend-sqlalchemy = "0.2.4"
duckdb = "1.3.2"
google-api-python-client = "2.190.0"
grpcio = ">=1.80.0,<2"
gspread = "5.11.2"
h11 = ">=0.16.0"
httpcore = ">=1.0.9"
impyla = "0.22.0"
influxdb = "5.2.3"
influxdb-client = "1.38.0"
marshmallow = ">=3.26.2"
memsql = "3.2.0"
mysqlclient = "2.1.1"
numpy = "2.4.2"
nzalchemy = "^11.0.2"
nzalchemy = "^11.1.2"
nzpy = ">=1.15"
oauth2client = "4.1.3"
openpyxl = "3.1.5"
Expand All @@ -137,7 +147,7 @@ python-rapidjson = "1.20"
requests-aws-sign = "0.1.5"
sasl = ">=0.4a1"
simple-salesforce = "0.74.3"
snowflake-connector-python = "3.12.3"
snowflake-connector-python = "4.5.0"
td-client = "1.5.0"
thrift = ">=0.8.0"
thrift-sasl = ">=0.1.0"
Expand All @@ -156,14 +166,17 @@ ldap3 = "2.9.1"
optional = true

[tool.poetry.group.dev.dependencies]
pytest = "7.4.0"
coverage = "7.2.7"
pytest = "9.0.3"
coverage = "7.14.0"
filelock = ">=3.20.3"
freezegun = "1.5.5"
jwcrypto = "1.5.6"
jwcrypto = "1.5.7"
mock = "5.0.2"
pre-commit = "3.3.3"
pre-commit = "4.3.0"
pygments = ">=2.20.0"
ptpython = "3.0.23"
pytest-cov = "4.1.0"
pytest-cov = "6.0.0"
virtualenv = ">=20.36.1"
watchdog = "3.0.0"
ruff = "0.0.289"

Expand Down
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
[pytest]
norecursedirs = *.egg .eggs dist build docs .tox
pythonpath = .
filterwarnings =
once::DeprecationWarning
once::PendingDeprecationWarning
addopts = -v --tb=short --maxfail=10 --durations=10
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s [%(levelname)8s] %(name)s: %(message)s
36 changes: 28 additions & 8 deletions redash/authentication/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import timedelta
from urllib.parse import urlsplit, urlunsplit

from flask import jsonify, redirect, request, session, url_for
from flask import current_app, jsonify, redirect, request, session, url_for
from flask_login import LoginManager, login_user, logout_user, user_logged_in
from sqlalchemy.orm.exc import NoResultFound
from werkzeug.exceptions import Unauthorized
Expand Down Expand Up @@ -43,10 +43,6 @@ def sign(key, path, expires):

@login_manager.user_loader
def load_user(user_id_with_identity):
user = api_key_load_user_from_request(request)
if user:
return user

org = current_org._get_current_object()

try:
Expand Down Expand Up @@ -238,11 +234,24 @@ def logout_and_redirect_to_index():


def init_app(app):
from flask import g

from redash.authentication import ldap_auth, remote_user_auth, saml_auth
from redash.authentication.google_oauth import (
create_google_oauth_blueprint,
)

# Flask clears `g` when the application context ends. Unit tests keep a
# single app context for the whole case while issuing many requests via
# the test client, so per-request values must be reset here. Otherwise
# Flask-Login's `g._login_user` and `current_org`'s `g.org` leak across
# requests and auth/org resolution breaks.
@app.before_request
def reset_request_g_cache():
if current_app.config.get("TESTING"):
g.pop("_login_user", None)
g.pop("org", None)

login_manager.init_app(app)
login_manager.anonymous_user = models.AnonymousUser
login_manager.REMEMBER_COOKIE_DURATION = settings.REMEMBER_COOKIE_DURATION
Expand All @@ -269,17 +278,28 @@ def extend_session():


def create_and_login_user(org, name, email, picture=None):
from flask import current_app

is_testing = current_app.config.get("TESTING", False)

# Use flush in testing to avoid transaction conflicts, commit in production
def save_changes():
if is_testing:
models.db.session.flush()
else:
models.db.session.commit()

try:
user_object = models.User.get_by_email_and_org(email, org)
if user_object.is_disabled:
return None
if user_object.is_invitation_pending:
user_object.is_invitation_pending = False
models.db.session.commit()
save_changes()
if user_object.name != name:
logger.debug("Updating user name (%r -> %r)", user_object.name, name)
user_object.name = name
models.db.session.commit()
save_changes()
except NoResultFound:
logger.debug("Creating user object (%r)", name)
user_object = models.User(
Expand All @@ -291,7 +311,7 @@ def create_and_login_user(org, name, email, picture=None):
group_ids=[org.default_group.id],
)
models.db.session.add(user_object)
models.db.session.commit()
save_changes()

login_user(user_object, remember=True)

Expand Down
2 changes: 2 additions & 0 deletions redash/authentication/google_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ def create_google_oauth_blueprint(app):
CONF_URL = "https://accounts.google.com/.well-known/openid-configuration"
oauth.register(
name="google",
client_id=settings.GOOGLE_CLIENT_ID,
client_secret=settings.GOOGLE_CLIENT_SECRET,
server_metadata_url=CONF_URL,
client_kwargs={"scope": "openid email profile"},
)
Expand Down
5 changes: 1 addition & 4 deletions redash/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,8 @@ def send_test_mail(email=None):
@manager.command("shell")
@with_appcontext
def shell():
import sys

from flask.globals import _app_ctx_stack
from ptpython import repl

app = _app_ctx_stack.top.app
app = current_app._get_current_object()

repl.embed(globals=app.make_shell_context())
87 changes: 55 additions & 32 deletions redash/cli/rq.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@
from rq import Connection
from rq.worker import WorkerStatus
from sqlalchemy.orm import configure_mappers
from supervisor_checks import check_runner
from supervisor_checks.check_modules import base

# supervisor_checks is an optional dependency: it is only needed for the
# `healthcheck` CLI command and currently has no wheel for Python 3.13. Skip
# importing it (and defining WorkerHealthcheck, which inherits from one of its
# classes) when it isn't installed so the rest of the CLI keeps working.
try:
from supervisor_checks import check_runner
from supervisor_checks.check_modules import base

SUPERVISOR_CHECKS_AVAILABLE = True
except ImportError as e:
print(f"Warning: supervisor_checks not available: {e}")
check_runner = None
base = None
SUPERVISOR_CHECKS_AVAILABLE = False

from redash import rq_redis_connection
from redash.tasks import (
Expand Down Expand Up @@ -47,47 +60,57 @@ def worker(queues):
w.work()


class WorkerHealthcheck(base.BaseCheck):
NAME = "RQ Worker Healthcheck"
if SUPERVISOR_CHECKS_AVAILABLE:

class WorkerHealthcheck(base.BaseCheck):
NAME = "RQ Worker Healthcheck"

def __call__(self, process_spec):
pid = process_spec["pid"]
all_workers = Worker.all(connection=rq_redis_connection)
workers = [w for w in all_workers if w.hostname == socket.gethostname() and w.pid == pid]

def __call__(self, process_spec):
pid = process_spec["pid"]
all_workers = Worker.all(connection=rq_redis_connection)
workers = [w for w in all_workers if w.hostname == socket.gethostname() and w.pid == pid]
if not workers:
self._log(
f"Cannot find worker for hostname {socket.gethostname()} and pid {pid}. ==> Is healthy? False"
)
return False

if not workers:
self._log(f"Cannot find worker for hostname {socket.gethostname()} and pid {pid}. ==> Is healthy? False")
return False
worker = workers.pop()

worker = workers.pop()
is_busy = worker.get_state() == WorkerStatus.BUSY

is_busy = worker.get_state() == WorkerStatus.BUSY
time_since_seen = datetime.datetime.utcnow() - worker.last_heartbeat
seen_lately = time_since_seen.seconds < 60

time_since_seen = datetime.datetime.utcnow() - worker.last_heartbeat
seen_lately = time_since_seen.seconds < 60
total_jobs_in_watched_queues = sum([len(q.jobs) for q in worker.queues])
has_nothing_to_do = total_jobs_in_watched_queues == 0

total_jobs_in_watched_queues = sum([len(q.jobs) for q in worker.queues])
has_nothing_to_do = total_jobs_in_watched_queues == 0
is_healthy = is_busy or seen_lately or has_nothing_to_do

is_healthy = is_busy or seen_lately or has_nothing_to_do
self._log(
"Worker %s healthcheck: Is busy? %s. "
"Seen lately? %s (%d seconds ago). "
"Has nothing to do? %s (%d jobs in watched queues). "
"==> Is healthy? %s",
worker.key,
is_busy,
seen_lately,
time_since_seen.seconds,
has_nothing_to_do,
total_jobs_in_watched_queues,
is_healthy,
)

self._log(
"Worker %s healthcheck: Is busy? %s. "
"Seen lately? %s (%d seconds ago). "
"Has nothing to do? %s (%d jobs in watched queues). "
"==> Is healthy? %s",
worker.key,
is_busy,
seen_lately,
time_since_seen.seconds,
has_nothing_to_do,
total_jobs_in_watched_queues,
is_healthy,
)
return is_healthy

return is_healthy
else:
WorkerHealthcheck = None


@manager.command()
def healthcheck():
if not SUPERVISOR_CHECKS_AVAILABLE:
print("Error: supervisor_checks not available. Cannot perform healthcheck.")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Click CLI commands do not use return values as exit codes. return 1 in a @manager.command() function will be ignored and the process will exit with code 0, so monitoring scripts relying on exit status will incorrectly treat a failed healthcheck as successful.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At redash/cli/rq.py, line 114:

<comment>Click CLI commands do not use return values as exit codes. `return 1` in a `@manager.command()` function will be ignored and the process will exit with code 0, so monitoring scripts relying on exit status will incorrectly treat a failed healthcheck as successful.</comment>

<file context>
@@ -47,47 +60,57 @@ def worker(queues):
 @manager.command()
 def healthcheck():
+    if not SUPERVISOR_CHECKS_AVAILABLE:
+        print("Error: supervisor_checks not available. Cannot perform healthcheck.")
+        return 1
     return check_runner.CheckRunner("worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})]).run()
</file context>

return 1
return check_runner.CheckRunner("worker_healthcheck", "worker", None, [(WorkerHealthcheck, {})]).run()
Loading