Repository: mlflow/mlflow

Issue:
[BUG] MLflow 2.3 requires Flask>=2, but requirements file states Flask<3
### Issues Policy acknowledgement

- [X] I have read and agree to submit bug reports in accordance with the [issues policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md)

### Willingness to contribute

Yes. I can contribute a fix for this bug independently.

### MLflow version

- Client: x
- Tracking server: 2.3.x


### System information

- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**: Server running python:3.8.10 docker image.
- **Python version**: 3.8.10
- **yarn version, if running the dev UI**: N/A


### Describe the problem

The MLflow requirements file specifies `Flask<3`, but if you try to run an MLflow server with version 2.3 and Flask<2, the UI will not be able to be loaded due to the usage of a keyword introduced in Flask 2.0.

The problematic line is here https://github.com/mlflow/mlflow/blob/branch-2.3/mlflow/server/__init__.py#L84

Two possible solutions I can see are:
1. Support both the Flask 2 and Flask 1 versions of the keyword by changing the line linked above to:
```py
try:
  # Flask version is greater or equal to than 2.0
  return send_from_directory(STATIC_DIR, path, max_age=2419200)
except TypeError:
   # Flask version is less than 2.0
  return send_from_directory(STATIC_DIR, path, cache_timeout=2419200)
```
2. Just update the `mlflow` package requirements to be `flask<3,flask>=2`

Personally I prefer option 1 (there may be a cleaner way to implement it) because then organizations that use MLflow in environments that also use Flask<2 (like mine) do not need to upgrade Flask throughout the rest of the organization to use MLflow 2.3.

### Tracking information

<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
```shell
N/A
```


### Code to reproduce issue

<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
Docker file:
```
FROM python:3.8.10

RUN pip install flask==1.* && \
    # Other pins needed to get Flask<2 to work (with MLflow<2.3 but not 2.3)
    pip install jinja2==3.0.3 && \
    pip install itsdangerous==2.0.1 && \
    pip install werkzeug==2.0.3 && \
    pip install markupsafe==2.0.1
RUN pip install mlflow==2.3.1

EXPOSE 5000

CMD mlflow server --host 0.0.0.0 --port 5000
```

Build and run the image:
```
docker build -f Dockerfile . -t mlflow-repro
docker run -p 5000:5000 mlflow-repro:latest
```
Then navigate to `0.0.0.0:5000` and you'll see a blank page. Look back at the docker output to see the errors (shown in next section)


### Stack trace

<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
```
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python3.8/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python3.8/site-packages/mlflow/server/__init__.py", line 84, in serve_static_file
    return send_from_directory(STATIC_DIR, path, max_age=2419200)
  File "/usr/local/lib/python3.8/site-packages/flask/helpers.py", line 771, in send_from_directory
    return send_file(filename, **options)
TypeError: send_file() got an unexpected keyword argument 'max_age'
```


### Other info / logs

<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
```
[2023-05-17 16:37:23 +0000] [19] [INFO] Starting gunicorn 20.1.0
[2023-05-17 16:37:23 +0000] [19] [INFO] Listening at: http://0.0.0.0:5000 (19)
[2023-05-17 16:37:23 +0000] [19] [INFO] Using worker: sync
[2023-05-17 16:37:23 +0000] [21] [INFO] Booting worker with pid: 21
[2023-05-17 16:37:23 +0000] [22] [INFO] Booting worker with pid: 22
[2023-05-17 16:37:24 +0000] [27] [INFO] Booting worker with pid: 27
[2023-05-17 16:37:24 +0000] [28] [INFO] Booting worker with pid: 28
2023/05/17 16:37:40 ERROR mlflow.server: Exception on /static-files/static/js/main.80fd8eef.js [GET]
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python3.8/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python3.8/site-packages/mlflow/server/__init__.py", line 84, in serve_static_file
    return send_from_directory(STATIC_DIR, path, max_age=2419200)
  File "/usr/local/lib/python3.8/site-packages/flask/helpers.py", line 771, in send_from_directory
    return send_file(filename, **options)
TypeError: send_file() got an unexpected keyword argument 'max_age'
2023/05/17 16:37:40 ERROR mlflow.server: Exception on /static-files/static/css/main.45d71c4b.css [GET]
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python3.8/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python3.8/site-packages/mlflow/server/__init__.py", line 84, in serve_static_file
    return send_from_directory(STATIC_DIR, path, max_age=2419200)
  File "/usr/local/lib/python3.8/site-packages/flask/helpers.py", line 771, in send_from_directory
    return send_file(filename, **options)
TypeError: send_file() got an unexpected keyword argument 'max_age'
2023/05/17 16:37:40 ERROR mlflow.server: Exception on /static-files/favicon.ico [GET]
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 2447, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1952, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1821, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python3.8/site-packages/flask/_compat.py", line 39, in reraise
    raise value
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1950, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.8/site-packages/flask/app.py", line 1936, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python3.8/site-packages/mlflow/server/__init__.py", line 84, in serve_static_file
    return send_from_directory(STATIC_DIR, path, max_age=2419200)
  File "/usr/local/lib/python3.8/site-packages/flask/helpers.py", line 771, in send_from_directory
    return send_file(filename, **options)
TypeError: send_file() got an unexpected keyword argument 'max_age'
```


### What component(s) does this bug affect?

- [ ] `area/artifacts`: Artifact stores and artifact logging
- [ ] `area/build`: Build and test infrastructure for MLflow
- [ ] `area/docs`: MLflow documentation pages
- [ ] `area/examples`: Example code
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
- [ ] `area/recipes`: Recipes, Recipe APIs, Recipe configs, Recipe Templates
- [ ] `area/projects`: MLproject format, project running backends
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
- [X] `area/server-infra`: MLflow Tracking server backend
- [ ] `area/tracking`: Tracking Service, tracking client APIs, autologging

### What interface(s) does this bug affect?

- [X] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
- [ ] `area/docker`: Docker use across MLflow's components, such as MLflow Projects and MLflow Models
- [ ] `area/sqlalchemy`: Use of SQLAlchemy in the Tracking Service or Model Registry
- [ ] `area/windows`: Windows support

### What language(s) does this bug affect?

- [ ] `language/r`: R APIs and clients
- [ ] `language/java`: Java APIs and clients
- [ ] `language/new`: Proposals for new client languages

### What integration(s) does this bug affect?

- [ ] `integrations/azure`: Azure and Azure ML integrations
- [ ] `integrations/sagemaker`: SageMaker integrations
- [ ] `integrations/databricks`: Databricks integrations


Discussion:
@kevingreer We can check the flask version and use `cache_timeout` if it's < 2.0, otherwise `max_age`. I believe this is safer than catching `TypeError`.
Agreed with @harupy , i.e., we can do:

```
if Version(flask.__version__) >= Version("2.0"):
  # Flask version is greater or equal to than 2.0
  return send_from_directory(STATIC_DIR, path, max_age=2419200)
else:
   # Flask version is less than 2.0
  return send_from_directory(STATIC_DIR, path, cache_timeout=2419200)
```

Resolving patch:
diff --git a/mlflow/server/__init__.py b/mlflow/server/__init__.py
--- a/mlflow/server/__init__.py
+++ b/mlflow/server/__init__.py
@@ -4,8 +4,9 @@
 import textwrap
 import importlib.metadata
 
+from packaging.version import Version
+from flask import __version__ as flask_version
 from flask import Flask, send_from_directory, Response
-
 from mlflow.exceptions import MlflowException
 from mlflow.server import handlers
 from mlflow.server.handlers import (
@@ -32,6 +33,7 @@
 
 app = Flask(__name__, static_folder=REL_STATIC_DIR)
 STATIC_DIR = os.path.join(app.root_path, REL_STATIC_DIR)
+IS_FLASK_V1 = Version(flask_version) < Version("2.0")
 
 
 for http_path, handler, methods in handlers.get_endpoints():
@@ -81,7 +83,10 @@ def serve_get_metric_history_bulk():
 # The files are hashed based on source code, so ok to send Cache-Control headers via max_age.
 @app.route(_add_static_prefix("/static-files/<path:path>"))
 def serve_static_file(path):
-    return send_from_directory(STATIC_DIR, path, max_age=2419200)
+    if IS_FLASK_V1:
+        return send_from_directory(STATIC_DIR, path, cache_timeout=2419200)
+    else:
+        return send_from_directory(STATIC_DIR, path, max_age=2419200)
 
 
 # Serve the index.html for the React App for all other routes.


Test patch: