tablelab 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tablelab/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """tablelab - interactive table inspection in Jupyter.
2
+
3
+ Load a table from a file, see what is in it, and read any one series as a chart
4
+ - either a column down the rows, or a row across the columns. A row is the
5
+ useful direction when each row of the table is a signal (ECG beats, sensor
6
+ sweeps, spectra); when the table carries a label column, the label names the
7
+ chart.
8
+
9
+ The app browses for the file, lists the table's columns in a menu, and offers
10
+ five views of whichever series is chosen: a run chart, a sorted run chart, a
11
+ histogram, a box plot and an ECDF. Where ipympl is installed the chart pans and
12
+ zooms, and grid lines are a checkbox.
13
+
14
+ Usage:
15
+
16
+ from tablelab import main
17
+ main() # empty app, load a file from the UI
18
+ main("data/ecg.csv") # open on a file
19
+
20
+ For table queries and pipelines:
21
+
22
+ from tablelab import query_main
23
+ app = query_main() # Select, Join, Filter, Group, Missing values
24
+
25
+ Or without widgets:
26
+
27
+ from tablelab import load_table, run_chart
28
+ df = load_table("data/ecg.csv")
29
+ run_chart(df.iloc[0, :-1], title="beat 0")
30
+
31
+ Building the widgets takes a moment, so it is deferred until `main` is actually
32
+ looked up rather than done on `import tablelab`.
33
+ """
34
+
35
+ __version__ = "0.5.0"
36
+ __all__ = ["__version__", "main", "TableApp", "query_main", "QueryApp", "load_table", "read_uploaded_dataframe",
37
+ "find_label_column", "describe", "summary_stats",
38
+ "run_chart", "sorted_run_chart", "histogram", "box_plot", "ecdf",
39
+ "draw", "KINDS"]
40
+
41
+ _LAZY = {
42
+ "main": "app",
43
+ "TableApp": "app",
44
+ "QueryApp": "query_app",
45
+ "load_table": "file_readers",
46
+ "read_uploaded_dataframe": "file_readers",
47
+ "find_label_column": "table",
48
+ "describe": "table",
49
+ "summary_stats": "table",
50
+ "run_chart": "charts",
51
+ "sorted_run_chart": "charts",
52
+ "histogram": "charts",
53
+ "box_plot": "charts",
54
+ "ecdf": "charts",
55
+ "draw": "charts",
56
+ "KINDS": "charts",
57
+ }
58
+
59
+
60
+ def __getattr__(name): # PEP 562: keeps `import tablelab` cheap
61
+ if name == "query_main":
62
+ from .query_app import main
63
+ return main
64
+ module = _LAZY.get(name)
65
+ if module is None:
66
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
67
+ import importlib
68
+ return getattr(importlib.import_module(f".{module}", __name__), name)
69
+
70
+
71
+ def __dir__():
72
+ return sorted(__all__)
tablelab/__main__.py ADDED
@@ -0,0 +1,64 @@
1
+ """Command line front end, for when a notebook would be overkill.
2
+
3
+ python -m tablelab FILE --list
4
+ python -m tablelab FILE --col temperature
5
+ python -m tablelab FILE --col temperature --chart histogram --grid
6
+ python -m tablelab FILE --row 0 --out beat.png
7
+ """
8
+ import argparse
9
+
10
+ import matplotlib.pyplot as plt
11
+
12
+ from . import charts, __version__
13
+ from . import table as _table
14
+ from .file_readers import load_table
15
+
16
+
17
+ def main(argv=None):
18
+ p = argparse.ArgumentParser(prog="tablelab", description=__doc__,
19
+ formatter_class=argparse.RawDescriptionHelpFormatter)
20
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
21
+ p.add_argument("file")
22
+ p.add_argument("--col", help="column to plot down the rows (name or index)")
23
+ p.add_argument("--row", type=int, help="row to plot across the columns")
24
+ p.add_argument("--label-col", help="override the detected label column")
25
+ p.add_argument("--chart", default="run", choices=[k for _, k in charts.KINDS],
26
+ help="which view of the series (default: run)")
27
+ p.add_argument("--centre", default="median", choices=["median", "mean", "none"])
28
+ p.add_argument("--grid", action="store_true",
29
+ help="vertical grid lines as well as the horizontal ones")
30
+ p.add_argument("--list", action="store_true", help="describe the table and exit")
31
+ p.add_argument("--out", help="save to this png instead of opening a window")
32
+ args = p.parse_args(argv)
33
+
34
+ df = load_table(args.file)
35
+ label_col = (_table.resolve_column(df, args.label_col) if args.label_col
36
+ else _table.find_label_column(df))
37
+ centre = None if args.centre == "none" else args.centre
38
+ grid = True if args.grid else "y"
39
+
40
+ if args.list or (args.col is None and args.row is None):
41
+ print(f"{args.file}\n{_table.describe(df)}")
42
+ return
43
+
44
+ if args.row is not None:
45
+ values = _table.row_series(df, args.row, label_col)
46
+ title = f"row {args.row}"
47
+ if label_col is not None:
48
+ title += f" · {_table.label_name(label_col)} = {df[label_col].iloc[args.row]}"
49
+ charts.draw(args.chart, values, title=title, xlabel="position",
50
+ ylabel="value", centre=centre, grid=grid)
51
+ else:
52
+ col = _table.resolve_column(df, args.col)
53
+ charts.draw(args.chart, df[col], title=str(col), xlabel="row",
54
+ ylabel=str(col), centre=centre, grid=grid)
55
+
56
+ if args.out:
57
+ plt.savefig(args.out, dpi=150)
58
+ print(f"saved {args.out}")
59
+ else:
60
+ plt.show()
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
tablelab/app.py ADDED
@@ -0,0 +1,431 @@
1
+ """The widget UI: load a table, choose a series, choose a view, read the chart.
2
+
3
+ Two ways to choose a series, because "a column" means different things depending
4
+ on how the table is laid out:
5
+
6
+ Column a column down the rows - each row is an observation
7
+ Row a row across the columns - each row is a signal
8
+
9
+ In Row mode the label column is dropped from the plotted values (it is not part
10
+ of the signal) and its value for that row becomes the chart's title.
11
+
12
+ The chart itself is live where ipympl is installed: the toolbar above it pans
13
+ and zooms, and the grid checkbox is applied to the figure in place rather than
14
+ by redrawing, so ticking it does not throw away the zoom.
15
+ """
16
+ import os
17
+ from .file_readers import table_location
18
+ from html import escape
19
+
20
+ import matplotlib.pyplot as plt
21
+ import ipywidgets as widgets
22
+ from IPython.display import display
23
+
24
+ from .helpers import data as _table
25
+ from .helpers import plotting as charts
26
+ from .helpers import plotting as figures
27
+ from .helpers.data import load_table, read_uploaded_dataframe
28
+ from .helpers.widgets import FilePicker
29
+
30
+ ACCEPTED = ".csv,.tsv,.txt,.json,.jsonl,.parquet,.feather,.xlsx,.xls"
31
+
32
+
33
+ class TableApp:
34
+ """Several loaded tables, one chart. `app.df` is the active DataFrame."""
35
+
36
+ def __init__(self, zoom=True):
37
+ self.df = None
38
+ self.tables = []
39
+ self._type_selection = None
40
+ self._setting_type = False
41
+ self.label_col = None
42
+ self._loading = False # suppress redraws while wiring up a new table
43
+ self._rows = [] # df positions behind the row slider
44
+ self._fig = None # the chart on screen, for the grid toggle
45
+
46
+ # Switching backends is notebook-wide, so it happens once, here, rather
47
+ # than every time a chart is drawn. With `zoom=False` the notebook's own
48
+ # backend is left alone - which may already be an interactive one.
49
+ self.zoom = figures.enable_zoom() if zoom else figures.zoom_active()
50
+
51
+ # -- where the table comes from
52
+ self.browse_btn = widgets.Button(description="Browse...", icon="folder-open",
53
+ tooltip="Pick a file the kernel can see",
54
+ layout=widgets.Layout(width="130px"))
55
+ self.path = widgets.Text(placeholder="local file path or https://… CSV URL",
56
+ layout=widgets.Layout(width="330px"))
57
+ self.load_btn = widgets.Button(description="Add table", icon="plus",
58
+ layout=widgets.Layout(width="100px"))
59
+ self.upload = widgets.FileUpload(accept=ACCEPTED, multiple=False,
60
+ description="Upload",
61
+ tooltip="Send a file from this browser "
62
+ "to the kernel")
63
+ self.picker = FilePicker(self._load_picked)
64
+ self.status = widgets.HTML(self._zoom_hint())
65
+ self.table = widgets.Dropdown(description="table:", disabled=True,
66
+ layout=widgets.Layout(width="520px"))
67
+ self.remove_btn = widgets.Button(description="Remove table", icon="trash",
68
+ disabled=True)
69
+
70
+ # -- which series
71
+ self.mode = widgets.ToggleButtons(
72
+ options=[("Column · down the rows", "column"),
73
+ ("Row · across the columns", "row")],
74
+ value="column", style={"button_width": "190px"}, disabled=True)
75
+ self.column = widgets.Dropdown(description="column:", disabled=True,
76
+ layout=widgets.Layout(width="320px", height="32px"))
77
+ self.label_filter = widgets.Dropdown(description="label:", disabled=True,
78
+ layout=widgets.Layout(width="220px"))
79
+ self.row = widgets.IntSlider(description="row:", min=0, max=0,
80
+ continuous_update=False, disabled=True,
81
+ layout=widgets.Layout(width="420px"))
82
+
83
+ # -- which view of it
84
+ self.kind = widgets.Dropdown(options=charts.KINDS, value="run",
85
+ description="chart:",
86
+ layout=widgets.Layout(width="280px"))
87
+ self.centre = widgets.Dropdown(options=[("median", "median"), ("mean", "mean"),
88
+ ("none", None)],
89
+ value="median", description="centre:",
90
+ layout=widgets.Layout(width="180px"))
91
+ self.grid = widgets.Checkbox(value=False, description="grid lines",
92
+ indent=False,
93
+ layout=widgets.Layout(width="120px"),
94
+ tooltip="Vertical grid lines as well as the "
95
+ "horizontal ones")
96
+
97
+ self.chart = widgets.Output()
98
+ self.text_preview = widgets.Output()
99
+ self.data_type = widgets.Dropdown(
100
+ description="Data type:", options=[], disabled=True,
101
+ layout=widgets.Layout(width="270px", height="32px"),
102
+ style={"description_width": "80px"},
103
+ tooltip="Automatically detected for each column. Choose Categorical to see counts.")
104
+ self.chart_tabs = widgets.Tab(children=[widgets.Box() for _ in charts.KINDS],
105
+ layout=widgets.Layout(display="none"))
106
+ for i, (label, _) in enumerate(charts.KINDS):
107
+ self.chart_tabs.set_title(i, label)
108
+ self.chart_tabs.children[0].children = [self.chart]
109
+ self.preview = widgets.Output()
110
+ self.stats = widgets.Output()
111
+
112
+ self.browse_btn.on_click(lambda _: self.picker.toggle())
113
+ self.load_btn.on_click(lambda _: self._load_path())
114
+ self.upload.observe(self._load_upload, names="value")
115
+ self.table.observe(lambda _: self._select_table(), names="value")
116
+ self.remove_btn.on_click(lambda _: self._remove_table())
117
+ for w in (self.mode, self.column, self.label_filter, self.row,
118
+ self.kind, self.centre):
119
+ w.observe(lambda _: self._draw(), names="value")
120
+ self.mode.observe(lambda _: self._show_controls(), names="value")
121
+ self.grid.observe(self._on_grid, names="value")
122
+ self.chart_tabs.observe(self._on_chart_tab, names="selected_index")
123
+ self.data_type.observe(self._on_data_type, names="value")
124
+
125
+ self._column_box = widgets.HBox([self.column, self.data_type],
126
+ layout=widgets.Layout(align_items="center"))
127
+ self._row_box = widgets.HBox([self.label_filter, self.row])
128
+ self._controls = widgets.Box([self._column_box])
129
+ self._view_box = widgets.HBox([self.centre, self.grid],
130
+ layout=widgets.Layout(display="none"))
131
+
132
+ preview_acc = widgets.Accordion(children=[self.preview], selected_index=None)
133
+ preview_acc.set_title(0, "First rows")
134
+ self.stats_acc = widgets.Accordion(children=[self.stats], selected_index=None,
135
+ layout=widgets.Layout(display="none"))
136
+ self.stats_acc.set_title(0, "Summary statistics")
137
+ self.ui = widgets.VBox([
138
+ widgets.HBox([self.browse_btn, self.path, self.load_btn, self.upload]),
139
+ self.picker.ui, widgets.HBox([self.table, self.remove_btn]), self.status,
140
+ preview_acc, self.mode, self._controls,
141
+ self._view_box, self.chart_tabs, self.text_preview, self.stats_acc,
142
+ ])
143
+
144
+ # -- loading -------------------------------------------------------------
145
+ def _load_path(self):
146
+ if not self.path.value.strip():
147
+ self.picker.open()
148
+ return
149
+ try:
150
+ path, filename = table_location(self.path.value)
151
+ except ValueError as exc:
152
+ self.status.value = escape(str(exc))
153
+ return
154
+ for i, entry in enumerate(self.tables):
155
+ if entry["source"] == path:
156
+ self.table.value = i
157
+ return
158
+ self._accept(lambda: load_table(path), name=filename, source=path)
159
+
160
+ def _load_picked(self, path):
161
+ self.path.value = path
162
+ self._load_path()
163
+
164
+ def _load_upload(self, change):
165
+ files = change["new"]
166
+ if not files:
167
+ return
168
+ f = files[0] if isinstance(files, (list, tuple)) else list(files.values())[0]
169
+ self.path.value = f["name"]
170
+ self._accept(lambda: read_uploaded_dataframe(bytes(f["content"]), f["name"]),
171
+ name=f["name"])
172
+
173
+ def _accept(self, read, name="Table", source=None):
174
+ try:
175
+ df = read()
176
+ except Exception as e:
177
+ self.status.value = ("<pre style='color:#b3261e'>"
178
+ f"{escape(type(e).__name__ + ': ' + str(e))}</pre>")
179
+ return
180
+ self.tables.append({"name": name, "source": source, "df": df})
181
+ self._fill_table_menu(len(self.tables) - 1)
182
+
183
+ def _fill_table_menu(self, selected):
184
+ self._loading = True
185
+ try:
186
+ self.table.options = [(f"{i + 1}: {entry['name']}", i)
187
+ for i, entry in enumerate(self.tables)]
188
+ self.table.value = selected
189
+ self.table.disabled = self.remove_btn.disabled = not self.tables
190
+ finally:
191
+ self._loading = False
192
+ self._select_table()
193
+
194
+ def _remove_table(self):
195
+ i = self.table.value
196
+ if i is None:
197
+ return
198
+ self.tables.pop(i)
199
+ self._fill_table_menu(min(i, len(self.tables) - 1) if self.tables else None)
200
+
201
+ def _select_table(self):
202
+ if self._loading:
203
+ return
204
+ self._type_selection = None
205
+ i = self.table.value
206
+ if i is None:
207
+ self.df = self.label_col = self._fig = None
208
+ self._rows = []
209
+ self._loading = True
210
+ try:
211
+ self.column.options = []
212
+ self.label_filter.options = [("all", None)]
213
+ self.row.value = self.row.max = 0
214
+ for w in (self.mode, self.column, self.label_filter, self.row):
215
+ w.disabled = True
216
+ finally:
217
+ self._loading = False
218
+ self._reset_data_type()
219
+ self._show_numeric_controls(False)
220
+ for output in (self.chart, self.preview, self.stats, self.text_preview):
221
+ output.outputs = ()
222
+ self.status.value = self._zoom_hint()
223
+ return
224
+ entry = self.tables[i]
225
+ self.df = entry["df"]
226
+ self.path.value = entry["source"] or entry["name"]
227
+ self.label_col = _table.find_label_column(self.df)
228
+ self.status.value = f"<pre>{escape(entry['name'])}\n{escape(_table.describe(self.df))}</pre>"
229
+ self._fig = None
230
+ for output in (self.chart, self.stats, self.text_preview):
231
+ output.outputs = ()
232
+
233
+ self._loading = True
234
+ try:
235
+ cols = _table.column_choices(self.df)
236
+ self.column.options = cols
237
+ # A Dropdown built without options does not adopt the first one when
238
+ # options arrive later - it stays on None. Choose explicitly.
239
+ self.column.value = cols[0] if cols else None
240
+ levels = ([("all", None)] + [(str(v), v) for v in
241
+ sorted(self.df[self.label_col].dropna().unique())]
242
+ if self.label_col is not None else [("all", None)])
243
+ self.label_filter.options = levels
244
+ self.label_filter.value = None # "all"
245
+ self.row.value = 0
246
+ for w in (self.mode, self.column, self.label_filter, self.row):
247
+ w.disabled = False
248
+ self.label_filter.disabled = self.label_col is None
249
+ self._set_rows()
250
+ finally:
251
+ self._loading = False
252
+
253
+ self._set_html(self.preview, self.df.head().to_html())
254
+ self._draw()
255
+
256
+ # -- controls ------------------------------------------------------------
257
+ def _show_controls(self):
258
+ self._column_box.children = ([self.column, self.data_type]
259
+ if self.mode.value == "column" else [])
260
+ self._row_box.children = ([self.data_type, self.label_filter, self.row]
261
+ if self.mode.value == "row" else
262
+ [self.label_filter, self.row])
263
+ self._controls.children = [
264
+ self._row_box if self.mode.value == "row" else self._column_box]
265
+
266
+ def _set_rows(self):
267
+ """Which df positions the row slider walks, given the label filter."""
268
+ if self.df is None:
269
+ return
270
+ wanted = self.label_filter.value
271
+ if wanted is None or self.label_col is None:
272
+ self._rows = list(range(len(self.df)))
273
+ else:
274
+ mask = (self.df[self.label_col] == wanted).to_numpy()
275
+ self._rows = [i for i, keep in enumerate(mask) if keep]
276
+ self.row.max = max(len(self._rows) - 1, 0)
277
+ self.row.value = min(self.row.value, self.row.max)
278
+
279
+ # -- the series on screen -------------------------------------------------
280
+ def series(self):
281
+ """The series the chart is showing, with its labels, or None."""
282
+ if self.df is None:
283
+ return None
284
+ if self.mode.value == "row":
285
+ self._set_rows()
286
+ if not self._rows:
287
+ return None
288
+ pos = self._rows[self.row.value]
289
+ title = f"row {pos}"
290
+ if self.label_col is not None:
291
+ title += (f" · {_table.label_name(self.label_col)} = "
292
+ f"{self.df[self.label_col].iloc[pos]}")
293
+ return _table.row_series(self.df, pos, self.label_col), title, "position", "value"
294
+
295
+ col = self.column.value
296
+ if col is None:
297
+ return None
298
+ return self.df[col], str(col), "row", str(col)
299
+
300
+ # -- drawing -------------------------------------------------------------
301
+ def _on_data_type(self, _change):
302
+ if not self._setting_type:
303
+ self._draw()
304
+
305
+ def _reset_data_type(self):
306
+ self._setting_type = True
307
+ try:
308
+ self.data_type.options = []
309
+ self.data_type.disabled = True
310
+ self._type_selection = None
311
+ finally:
312
+ self._setting_type = False
313
+
314
+ def _detect_data_type(self, values):
315
+ selection = (self.table.value, self.mode.value,
316
+ self.column.value if self.mode.value == "column" else None)
317
+ if selection == self._type_selection:
318
+ return
319
+ self._setting_type = True
320
+ try:
321
+ dtype = str(values.dtype)
322
+ options = [(f"Categorical ({dtype})", "categorical")]
323
+ numeric = _table.is_numeric(values)
324
+ if numeric:
325
+ options.insert(0, (f"Numeric ({dtype})", "numeric"))
326
+ self.data_type.options = options
327
+ self.data_type.value = "numeric" if numeric else "categorical"
328
+ self.data_type.disabled = not numeric
329
+ self.data_type.tooltip = (f"Detected: {_table.data_type(values)} ({dtype}). "
330
+ "Choose Categorical to see value counts." if numeric else
331
+ f"Detected: {_table.data_type(values)} ({dtype}). "
332
+ "Numeric drawings require numeric data.")
333
+ self._type_selection = selection
334
+ finally:
335
+ self._setting_type = False
336
+
337
+ def _on_chart_tab(self, change):
338
+ if change["new"] is not None:
339
+ self.kind.value = charts.KINDS[change["new"]][1]
340
+
341
+ def _show_numeric_controls(self, numeric):
342
+ for widget in (self._view_box, self.chart_tabs, self.stats_acc):
343
+ widget.layout.display = "" if numeric else "none"
344
+ self.text_preview.layout.display = "none" if numeric else ""
345
+
346
+ def _draw(self):
347
+ if self.df is None or self._loading:
348
+ return
349
+ chosen = self.series()
350
+ if chosen is None:
351
+ self._reset_data_type()
352
+ self._show_numeric_controls(False)
353
+ self._fig = None
354
+ self.text_preview.outputs = ()
355
+ return
356
+ values, title, xlabel, ylabel = chosen
357
+ self._detect_data_type(values)
358
+ numeric = self.data_type.value == "numeric"
359
+ self._show_numeric_controls(numeric)
360
+
361
+ if not numeric:
362
+ self._show_text_column(values, title)
363
+ return
364
+
365
+ index = [key for _, key in charts.KINDS].index(self.kind.value)
366
+ for i, panel in enumerate(self.chart_tabs.children):
367
+ panel.children = [self.chart] if i == index else []
368
+ self.chart_tabs.selected_index = index
369
+
370
+ # A box plot draws its own median, so a centre line would repeat it.
371
+ self.centre.disabled = self.kind.value not in charts.CENTRED
372
+
373
+ fig = figures.figure()
374
+ charts.draw(self.kind.value, values, title=title, xlabel=xlabel,
375
+ ylabel=ylabel, centre=self.centre.value,
376
+ ax=fig.add_subplot(111),
377
+ grid=True if self.grid.value else "y")
378
+ self._fig = fig
379
+ figures.show_in_output(fig, self.chart)
380
+ self.text_preview.outputs = ()
381
+ self._set_html(self.stats, _table.summary_stats(values).to_html(
382
+ index=False, float_format=lambda v: f"{v:.4g}"))
383
+
384
+ @staticmethod
385
+ def _set_html(output, html):
386
+ output.outputs = ({"output_type": "display_data",
387
+ "data": {"text/html": html}, "metadata": {}},)
388
+
389
+ def _show_text_column(self, values, title):
390
+ """A text column has no chart to draw, but its values can still be counted."""
391
+ self._fig = None
392
+ self.chart.outputs = ()
393
+ counts = values.value_counts(dropna=False).rename_axis(title).reset_index(name="count")
394
+ html = (f"<p style='color:#52514e'><b>{escape(title)}</b>: "
395
+ f"{len(counts)} distinct values. Most common values:</p>"
396
+ + counts.head(20).to_html(index=False))
397
+ self._set_html(self.text_preview, html)
398
+ self.stats.outputs = ()
399
+
400
+ def _on_grid(self, _change):
401
+ """Toggle the grid on the figure already on screen.
402
+
403
+ Redrawing instead would reset the zoom, which is exactly what someone
404
+ ticking the box halfway through an inspection does not want. A static
405
+ figure is closed once shown, so there it does mean a redraw.
406
+ """
407
+ if self._fig is not None and plt.fignum_exists(self._fig.number):
408
+ charts.apply_grid(self._fig, self.grid.value)
409
+ else:
410
+ self._draw()
411
+
412
+ def _zoom_hint(self):
413
+ if self.zoom:
414
+ return ("<span style='color:#52514e'>Pan and zoom with the toolbar "
415
+ "above the chart.</span>")
416
+ return ("<span style='color:#52514e'>Static charts - for the pan/zoom "
417
+ "toolbar: <code>pip install \"tablelab[zoom]\"</code></span>")
418
+
419
+
420
+ def main(path=None, zoom=True):
421
+ """Display the app, optionally opening on `path`.
422
+
423
+ `zoom=False` leaves the notebook's matplotlib backend alone and draws static
424
+ charts, for when the interactive canvas is in the way.
425
+ """
426
+ app = TableApp(zoom=zoom)
427
+ display(app.ui)
428
+ if path is not None:
429
+ app.path.value = str(path)
430
+ app._load_path()
431
+ return app