nedbat / coveragepy

The code coverage tool for Python
https://coverage.readthedocs.io
Apache License 2.0
3.02k stars 433 forks source link

coverage counts python dash callbacks as missed... #1663

Closed mitch-shiver closed 1 year ago

mitch-shiver commented 1 year ago

Hi @nedbat great tool, thanks for making it available!

I am using Python 3.9 along with the Dash module from Plotly to create a web app, but once I have installed coverage (v7.2.7), for some reason the Dash callback that I created is not counted as covered by the output report, even though I have clear evidence that the callback function itself is getting called as expected.

Code:

import dash
import dash_table
import dash_html_components as html
import dash_core_components as dcc
from dash.dependencies import Input, Output
import pandas as pd

def getDataTable():
    stats = [['joe',17],['bob',28],['bill',66],['mike',4]]
    dfStats = pd.DataFrame(stats, columns=['Name','Stat'])
    return html.Div([dash_table.DataTable(
        id='statsTable',
        columns=[{"name": i, "id": i} for i in dfStats.columns],
        data=dfStats.to_dict('records'),),
        html.Div(dcc.Interval(id='hiddenDivRefreshUI', interval=1000, n_intervals=0, disabled = False))])

app = dash.Dash(__name__)

app.layout = getDataTable()

### This callback updates the update text
@app.callback(
    Output('statsTable', 'data'),
    Input('hiddenDivRefreshUI', 'n_intervals'))
def updateTextUpdate(ack):
    print('made it here')
    stats = [['joe',17],['bob',28],['bill',66],['mike',4]]
    dfStats = pd.DataFrame(stats, columns=['Name','Stat'])
    currentData = dfStats.to_dict('records')
    return currentData

if __name__ == '__main__':
    app.run_server(host='0.0.0.0', debug=True)

Normal execution: python testapp.py

Coverage execution: coverage run testapp.py

Expected behavior: When the webpage at http://0.0.0.0:8050 is loaded, the callback function (updateTextUpdate) is called every second as a result of the dcc.Interval setting. I can see this is happening because I observe the 'made it here' text appearing in the command prompt window where I have launched the app (and I see it for both the normal and coverage execution above). However, the output report (below) shows this code as missed/missing. I would expect that it shows as covered.

Report output:

$ coverage report -m
Name         Stmts   Miss  Cover   Missing
------------------------------------------
testapp.py      21      5    76%   26-30
------------------------------------------
TOTAL           21      5    76%

Output of coverage debug sys:

-- sys -------------------------------------------------------
               coverage_version: 7.2.7
                coverage_module: /home/pmc/.local/lib/python3.9/site-packages/coverage/__init__.py
                         tracer: -none-
                        CTracer: available
           plugins.file_tracers: -none-
            plugins.configurers: -none-
      plugins.context_switchers: -none-
              configs_attempted: .coveragerc
                                 setup.cfg
                                 tox.ini
                                 pyproject.toml
                   configs_read: -none-
                    config_file: None
                config_contents: -none-
                      data_file: -none-
                         python: 3.9.4 (default, Jul 26 2023, 10:21:03) [GCC 4.8.5 20150623 (Red Hat 4.8.5-39)]
                       platform: Linux-3.10.0-862.el7.x86_64-x86_64-with-glibc2.17
                 implementation: CPython
                     executable: /usr/local/bin/python
                   def_encoding: utf-8
                    fs_encoding: utf-8
                            pid: 35111
                            cwd: /usr/bin/nova/other/internalDashboard
                           path: /home/pmc/.local/bin
                                 /usr/local/lib/python.zip
                                 /usr/local/lib/python
                                 /usr/local/lib/python/lib-dynload
                                 /home/pmc/.local/lib/python/site-packages
                                 /usr/local/lib/python/site-packages
                    environment: HOME = /home/pmc
                   command_line: /home/pmc/.local/bin/coverage debug sys
         sqlite3_sqlite_version: 3.7.17
             sqlite3_temp_store: 0
        sqlite3_compile_options: DISABLE_DIRSYNC, ENABLE_COLUMN_METADATA, ENABLE_FTS3, ENABLE_RTREE,
                                 ENABLE_UNLOCK_NOTIFY, SECURE_DELETE, TEMP_STORE=1, THREADSAFE=1

Output of pip freeze:

asgiref==3.4.1
bitarray==2.2.4
Brotli==1.0.9
certifi==2021.5.30
cffi==1.14.6
charset-normalizer==2.0.4
click==8.0.1
configparser==5.0.2
confluent-kafka==1.7.0
coverage==7.2.7
dash==1.21.0
dash-bootstrap-components==0.12.2
dash-core-components==1.17.1
dash-html-components==1.1.4
dash-table==4.12.0
dash-useful-components==0.1.0
docopt==0.6.2
fastapi==0.67.0
Flask==2.0.1
Flask-Compress==1.10.1
future==0.18.2
gnureadline==8.0.0
h11==0.12.0
hdfs==2.6.0
idna==3.2
impala==0.2
impyla==0.17.0
itsdangerous==2.0.1
Jinja2==3.0.1
JPype1==1.3.0
ld==0.5.0
MarkupSafe==2.0.1
numpy==1.21.1
pandas==1.3.1
plotly==5.1.0
ply==3.11
pure-sasl==0.6.2
pycparser==2.20
pydantic==1.8.2
PyHive==0.6.4
python-dateutil==2.8.2
python-rapidjson==1.6
pytz==2021.1
requests==2.26.0
sasl==0.3.1
six==1.16.0
starlette==0.14.2
tenacity==8.0.1
thrift==0.11.0
thrift-sasl==0.4.3
typing-extensions==3.10.0.0
urllib3==1.26.6
uvicorn==0.14.0
visdcc==0.0.40
Werkzeug==2.0.1
nedbat commented 1 year ago

As a web application, your code might be running in a subprocess. Have you enabled subprocess measurement? https://coverage.readthedocs.io/en/latest/subprocess.html

Alternately, you might be able to ask Dash to not spawn subprocesses. Let me know if it worked.

mitch-shiver commented 1 year ago

Hi Ned, I instrumented to detect any child processes running, once before launching the app and then each time the interval times out, code and results below.  I believe this means there are no subprocesses running.  However I will try the other option you recommended (enable subprocess measurement) and respond back with any results... Code updates:

@app.callback(
    Output('statsTable', 'data'),
    Input('hiddenDivRefreshUI', 'n_intervals'))
def updateTextUpdate(ack):
    print('made it here')
    print('after: {}'.format(active_children()))
    stats = [['joe',17],['bob',28],['bill',66],['mike',4]]
    dfStats = pd.DataFrame(stats, columns=['Name','Stat'])
    currentData = dfStats.to_dict('records')
    return currentData

if __name__ == '__main__':
    print('before: {}'.format(active_children()))
    app.run_server(host='0.0.0.0', debug=True)

Output: image

To your knowledge has anyone used the coverage module in conjunction with Dash apps?

Thanks, Mitch

On Wednesday, July 26, 2023 at 01:14:45 PM PDT, Ned Batchelder ***@***.***> wrote:  

As a web application, your code might be running in a subprocess. Have you enabled subprocess measurement? https://coverage.readthedocs.io/en/latest/subprocess.html

Alternately, you might be able to ask Dash to not spawn subprocesses. Let me know if it worked.

— Reply to this email directly, view it on GitHub, or unsubscribe. You are receiving this because you authored the thread.Message ID: @.***>

mitch-shiver commented 1 year ago

hmm, I enabled subprocess coverage management using the instructions in the help files, details below, but I do not believe that Dash is kicking off any subprocesses. I reached out to Plotly to ask about it, will update when they respond. However using the criteria you suggested, the issue does not seem to be subprocess-related. Any other ideas? I was hoping to use coverage to instrument a large Dash Python app to help understand code coverage, but a typical Dash app is 70-80% callbacks...

Code update:

import dash
import dash_table
import dash_html_components as html
import dash_core_components as dcc
from dash.dependencies import Input, Output
import pandas as pd
from multiprocessing import active_children

def getDataTable():
    stats = [['joe',17],['bob',28],['bill',66],['mike',4]]
    dfStats = pd.DataFrame(stats, columns=['Name','Stat'])
    return html.Div([dash_table.DataTable(
        id='statsTable',
        columns=[{"name": i, "id": i} for i in dfStats.columns],
        data=dfStats.to_dict('records'),),
        html.Div(dcc.Interval(id='hiddenDivRefreshUI', interval=1000, n_intervals=0, disabled = False))])

app = dash.Dash(__name__)

app.layout = getDataTable()

### This callback updates the update text
@app.callback(
    Output('statsTable', 'data'),
    Input('hiddenDivRefreshUI', 'n_intervals'))
def updateTextUpdate(ack):
    print('made it here')
    print('after: {}'.format(active_children()))
    stats = [['joe',17],['bob',28],['bill',66],['mike',4]]
    dfStats = pd.DataFrame(stats, columns=['Name','Stat'])
    currentData = dfStats.to_dict('records')
    return currentData

if __name__ == '__main__':
    print('before: {}'.format(active_children()))
    app.run_server(host='0.0.0.0', debug=True)

sitecustomize.py contents:

import coverage
print('Launching subprocess coverage settings...')
coverage.process_startup()

Report output:

$ coverage report -m
Launching subprocess coverage settings...
Name         Stmts   Miss Branch BrPart  Cover   Missing
--------------------------------------------------------
testapp.py      21      6      2      0    74%   27-32
--------------------------------------------------------
TOTAL           21      6      2      0    74%

Command Line output:

$ coverage run testapp.py
Launching subprocess coverage settings...
before: []
Dash is running on http://0.0.0.0:8050/

 * Serving Flask app 'testapp' (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production deployment.
   Use a production WSGI server instead.
 * Debug mode: on
Launching subprocess coverage settings...
before: []
made it here
after: []
made it here
after: []
made it here
after: []
nedbat commented 1 year ago

Try changing this line:

app.run_server(host='0.0.0.0', debug=True)

to:

app.run_server(host='0.0.0.0', debug=True, dev_tools_hot_reload=False)
mitch-shiver commented 1 year ago

Hi @nedbat,

I made that change, cleared the existing report and reran the coverage execution, but the outcome report was the same as before (below). I am adding a second callback now to see if all callbacks are ignored this way or if something is special about this callback. To your knowledge has anyone used the coverage module in conjunction with Dash apps?

Output:

$ coverage report -m
Name         Stmts   Miss  Cover   Missing
------------------------------------------
testapp.py      24      6    75%   27-32
------------------------------------------
TOTAL           24      6    75%
nedbat commented 1 year ago

Experimenting with your code showed the problem: debug=True makes dash run the code inside a debugger. That clobbers coverage's trace function, so the code isn't measured. If you change to this, the code will be 100% covered:

app.run_server(host='0.0.0.0', debug=False)
mitch-shiver commented 1 year ago

Awesome, works!

$ coverage report -m
Name         Stmts   Miss  Cover   Missing
------------------------------------------
testapp.py      24      0   100%
------------------------------------------
TOTAL           24      0   100%