Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ContrastingSounds/8bee2bbdf4cad1922e416a05bffa46cf to your computer and use it in GitHub Desktop.
Save ContrastingSounds/8bee2bbdf4cad1922e416a05bffa46cf to your computer and use it in GitHub Desktop.
Generate state machine diagrams automatically from a spreadsheet of states and transitions. Supports Google Sheets and Excel.
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# State Machine maintenance using spreadsheets\n",
"State machines are a useful way of modeling business processes. However, they can also be difficult to define and maintain, especially for non-technical users. This simple script and associated spreadsheet template makes it easy to define a set of state machines using a spreadsheet, and export them as diagrams in PDF format.\n",
"\n",
"Spreadsheet: https://drive.google.com/open?id=1jW7fwFa0Onyys1YDByWd1aFd9p7Bqtny9yZgR4iFPtE\n",
"\n",
"Diagrams: https://drive.google.com/open?id=11PPz-JqNi0O2KBC3Ys13rUflSLpf8fv_\n",
"\n",
"_(Note that the spreadsheet includes a few tabs in addition to the State and Event sheets used in this script. These support the data validation logic that ensure consistent data, and provide the user with some 'point and click' functionality for ease of use.)_"
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"\n",
"import pandas as pd\n",
"from graphviz import Digraph\n",
"import pygsheets\n",
"from pprint import pprint as pp\n",
"from PyPDF2 import PdfFileMerger, PdfFileReader"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Parameters\n",
"Note that the required parameters depends on whether you are using Google Sheets or an Excel Spreadsheet.\n",
"\n",
"Instructions on how to get a credentials file for Google Sheets: \n",
"https://developers.google.com/sheets/api/quickstart/python\n",
"\n",
"Remember that you have to share your Google Sheet with the service_email associated with the credentials file."
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"# Required Parameters\n",
"SOURCE = 'Google'\n",
"OUTPUT_DIR = 'Diagrams'\n",
"OUTPUT_FILE = 'State Machine Diagrams.pdf'\n",
"\n",
"# Parameters for Google Sheets\n",
"GOOGLE_CREDS = 'g_creds.json'\n",
"GOOGLE_WORKBOOK = 'ACME State Machines'\n",
"\n",
"# Parameters for an Excel Spreadsheet\n",
"INPUT_DIR = 'Model Spreadsheets'\n",
"EXCEL_FILE = 'KPI Details.xlsx'\n",
"\n",
"dataframes = None"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Model definition\n",
"Although the required format of the model does not change, it is possible to rename the worksheets and column headers if required."
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [],
"source": [
"model = { \n",
" 'states': {\n",
" 'sheet': 'States',\n",
" 'entity': 'Entity',\n",
" 'display_name': 'State',\n",
" 'full_name': 'Full Name',\n",
" 'details': 'Label Detail',\n",
" },\n",
" 'transitions': {\n",
" 'sheet': 'Events',\n",
" 'entity': 'Entity',\n",
" 'from': 'From State',\n",
" 'to': 'To State',\n",
" 'display_name': 'Event',\n",
" 'full_name': 'Full Name',\n",
" 'details': 'Details',\n",
" }\n",
"}"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Google Sheets\n",
"The pygsheets API downloads individual Google Sheets as dataframes. This code downloads the two required dataframes, for states and transitions, based on the sheet names defined in the model dictionary."
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"service_email : <account_info>@<account_info-000000>.iam.gserviceaccount.com\n"
]
}
],
"source": [
"if SOURCE == 'Google':\n",
" gc = pygsheets.authorize(service_file=GOOGLE_CREDS)\n",
"\n",
" workbook = gc.open(GOOGLE_WORKBOOK)\n",
" output_dir = 'ACME'\n",
"\n",
" dataframes = {}\n",
" for name, values in model.items():\n",
" try:\n",
" worksheet = workbook.worksheet(property='title', value=values['sheet'])\n",
" dataframes[values['sheet']] = worksheet.get_as_df()\n",
" except pygsheets.WorksheetNotFound:\n",
" print(f'Could not find model definition for {name}. Worksheet missing: {values[\"sheet\"]}')"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Excel\n",
"Pandas can return a dictionary of dataframes containing all worksheets in a workbook, so a single call is made in the case of Excel-based models. Only the two required worksheets are processed later."
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [],
"source": [
"if SOURCE == 'Excel':\n",
" dataframes = pd.read_excel(EXCEL_FILE, sheet_name=None)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Export to Graphviz"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"1. If a file already exists with the chosen name, remove it.\n",
"2. Get a list of the state machines"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [],
"source": [
"try:\n",
" os.remove(os.path.join(OUTPUT_DIR, OUTPUT_FILE))\n",
"except FileNotFoundError:\n",
" pass\n",
"\n",
"sheet = model['states']['sheet']\n",
"column = model['states']['entity']\n",
"machines = dataframes[sheet][1:][column].unique()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"For each state machine:\n",
"1. Get the correct column names for the 'states' worksheet from the model dictionary\n",
"2. Create a start node i.e. a simple black circle\n",
"3. Create the state nodes themselves, including additional label details if available.\n",
"4. Get the correct column names for the 'transitions' worksheet from the model dictionary\n",
"5. Create the transitions between states, including additional label details if available.\n",
"6. Add the graph to the dictionary\n",
"7. Render as a PDF file"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [],
"source": [
"graphs = {}\n",
"for machine in machines:\n",
" file_name = os.path.join(OUTPUT_DIR, machine + '.gv')\n",
" graph = Digraph('GoCardless_' + machine, filename=file_name)\n",
" graph.attr(rankdir='LR', size='8,5')\n",
" \n",
" # Get the correct column names from the model dictionary\n",
" sheet = dataframes[model['states']['sheet']]\n",
" entity = model['states']['entity']\n",
" display_name = model['states']['display_name']\n",
" full_name = model['states']['full_name']\n",
" details = model['states']['details']\n",
" states = sheet[(sheet[entity] == machine) & (sheet[display_name] != '–NEW ENTITY–')]\n",
" \n",
" # Create a start node i.e. a simple black circle\n",
" graph.attr('node', shape='circle', style='filled', fillcolor='black', width='0.3', fontname='helvetica')\n",
" graph.node('state.entity.new', label='')\n",
" \n",
" # Create the state nodes themselves, including additional label details if available.\n",
" graph.attr('node', shape='Mrecord', width='2', style='solid')\n",
" for state in states.to_dict(orient='record'):\n",
" if type(state[details]) != str:\n",
" if state[display_name] == state[full_name]:\n",
" label = f'{state[display_name]}|\\\\n\\\\n\\\\n\\\\n'\n",
" else:\n",
" label = f'{state[display_name]}|\\\\n{state[full_name]}\\\\n\\\\n'\n",
" else:\n",
" if state[display_name] == state[details]:\n",
" label = f'{state[display_name]}|\\\\n\\\\n\\\\n\\\\n'\n",
" else:\n",
" label = f'{state[display_name]}|\\\\n{state[details]}\\\\n\\\\n'\n",
" graph.node(state[full_name], label=label)\n",
"\n",
" # Get the correct column names from the model dictionary\n",
" sheet = dataframes[model['transitions']['sheet']]\n",
" entity = model['transitions']['entity']\n",
" from_ = model['transitions']['from']\n",
" to = model['transitions']['to']\n",
" display_name = model['transitions']['display_name']\n",
" full_name = model['transitions']['full_name']\n",
" details = model['transitions']['details']\n",
" transitions = sheet[sheet[entity] == machine]\n",
" \n",
" # Create the transitions between states, including additional label details if available.\n",
" graph.attr('edge', fontname='helvetica', fontcolor='blue')\n",
" for transition in transitions.to_dict(orient='record'):\n",
" if type(transition[details]) != str :\n",
" if transition[display_name] != transition[full_name]:\n",
" label = f'{transition[display_name]}\\\\n{transition[full_name]}'\n",
" else:\n",
" label = transition[display_name]\n",
" elif transition[details] == '':\n",
" label = transition[display_name]\n",
" else:\n",
" label = f'{transition[display_name]}\\\\n({transition[details]})'\n",
" graph.edge(transition[from_], \n",
" transition[to], \n",
" label=label)\n",
"\n",
" # Add the graph to the dictionary, and render as a PDF file\n",
" graphs[machine] = graph\n",
" graph.render()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Check all graphs have been created"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'Account': <graphviz.dot.Digraph at 0x11045ce80>,\n",
" 'Customer': <graphviz.dot.Digraph at 0x11045ce48>,\n",
" 'Lead': <graphviz.dot.Digraph at 0x10b415eb8>,\n",
" 'Opportunity': <graphviz.dot.Digraph at 0x11045cef0>,\n",
" 'Transfer': <graphviz.dot.Digraph at 0x10b440518>}"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"graphs"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Render a graph in the browser (optional)"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
"<!-- Generated by graphviz version 2.38.0 (20140413.2041)\n",
" -->\n",
"<!-- Title: GoCardless_Customer Pages: 1 -->\n",
"<svg width=\"576pt\" height=\"49pt\"\n",
" viewBox=\"0.00 0.00 576.00 49.50\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
"<g id=\"graph0\" class=\"graph\" transform=\"scale(0.423037 0.423037) rotate(0) translate(4 113)\">\n",
"<title>GoCardless_Customer</title>\n",
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-113 1357.58,-113 1357.58,4 -4,4\"/>\n",
"<!-- state.entity.new -->\n",
"<g id=\"node1\" class=\"node\"><title>state.entity.new</title>\n",
"<ellipse fill=\"black\" stroke=\"black\" cx=\"11\" cy=\"-38.5\" rx=\"11\" ry=\"11\"/>\n",
"</g>\n",
"<!-- state.customer.visitor -->\n",
"<g id=\"node2\" class=\"node\"><title>state.customer.visitor</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M167.009,-0.5C167.009,-0.5 287.009,-0.5 287.009,-0.5 293.009,-0.5 299.009,-6.5 299.009,-12.5 299.009,-12.5 299.009,-64.5 299.009,-64.5 299.009,-70.5 293.009,-76.5 287.009,-76.5 287.009,-76.5 167.009,-76.5 167.009,-76.5 161.009,-76.5 155.009,-70.5 155.009,-64.5 155.009,-64.5 155.009,-12.5 155.009,-12.5 155.009,-6.5 161.009,-0.5 167.009,-0.5\"/>\n",
"<text text-anchor=\"middle\" x=\"227.009\" y=\"-61.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Visitor</text>\n",
"<polyline fill=\"none\" stroke=\"black\" points=\"155.009,-54.5 299.009,-54.5 \"/>\n",
"<text text-anchor=\"middle\" x=\"227.009\" y=\"-23.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Goal: Activation</text>\n",
"</g>\n",
"<!-- state.entity.new&#45;&gt;state.customer.visitor -->\n",
"<g id=\"edge1\" class=\"edge\"><title>state.entity.new&#45;&gt;state.customer.visitor</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M22.035,-38.5C43.9382,-38.5 97.8271,-38.5 144.715,-38.5\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"144.908,-42.0001 154.908,-38.5 144.908,-35.0001 144.908,-42.0001\"/>\n",
"<text text-anchor=\"middle\" x=\"88.5044\" y=\"-41.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">Visitor Referred</text>\n",
"</g>\n",
"<!-- state.entity.new&#45;&gt;state.customer.visitor -->\n",
"<g id=\"edge2\" class=\"edge\"><title>state.entity.new&#45;&gt;state.customer.visitor</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M20.1924,-31.4348C25.4653,-27.3681 32.681,-22.6496 40,-20.5 73.4298,-10.6816 111.545,-12.2683 144.533,-17.4191\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"144.15,-20.9042 154.595,-19.1234 145.32,-14.0025 144.15,-20.9042\"/>\n",
"<text text-anchor=\"middle\" x=\"88.5044\" y=\"-23.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">Site Visited</text>\n",
"</g>\n",
"<!-- state.customer.email_sign_up -->\n",
"<g id=\"node3\" class=\"node\"><title>state.customer.email_sign_up</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M502.602,-0.5C502.602,-0.5 622.602,-0.5 622.602,-0.5 628.602,-0.5 634.602,-6.5 634.602,-12.5 634.602,-12.5 634.602,-64.5 634.602,-64.5 634.602,-70.5 628.602,-76.5 622.602,-76.5 622.602,-76.5 502.602,-76.5 502.602,-76.5 496.602,-76.5 490.602,-70.5 490.602,-64.5 490.602,-64.5 490.602,-12.5 490.602,-12.5 490.602,-6.5 496.602,-0.5 502.602,-0.5\"/>\n",
"<text text-anchor=\"middle\" x=\"562.602\" y=\"-61.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Email Sign Up</text>\n",
"<polyline fill=\"none\" stroke=\"black\" points=\"490.602,-54.5 634.602,-54.5 \"/>\n",
"<text text-anchor=\"middle\" x=\"562.602\" y=\"-23.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Goal: Retention</text>\n",
"</g>\n",
"<!-- state.customer.visitor&#45;&gt;state.customer.email_sign_up -->\n",
"<g id=\"edge3\" class=\"edge\"><title>state.customer.visitor&#45;&gt;state.customer.email_sign_up</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M299.164,-38.5C352.305,-38.5 425.009,-38.5 480.42,-38.5\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"480.556,-42.0001 490.556,-38.5 480.556,-35.0001 480.556,-42.0001\"/>\n",
"<text text-anchor=\"middle\" x=\"394.805\" y=\"-55.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">Sign Up</text>\n",
"<text text-anchor=\"middle\" x=\"394.805\" y=\"-41.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">(Email &amp; Password Only)</text>\n",
"</g>\n",
"<!-- state.customer.email_sign_up&#45;&gt;state.customer.email_sign_up -->\n",
"<g id=\"edge4\" class=\"edge\"><title>state.customer.email_sign_up&#45;&gt;state.customer.email_sign_up</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M531.667,-76.5248C533.161,-86.9972 543.473,-95 562.602,-95 575.454,-95 584.326,-91.3874 589.218,-85.8404\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"592.506,-87.0694 593.537,-76.5248 586.155,-84.125 592.506,-87.0694\"/>\n",
"<text text-anchor=\"middle\" x=\"562.602\" y=\"-97.8\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">Lead Created</text>\n",
"</g>\n",
"<!-- state.customer.account_sign_up -->\n",
"<g id=\"node4\" class=\"node\"><title>state.customer.account_sign_up</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M918.38,-0.5C918.38,-0.5 1038.38,-0.5 1038.38,-0.5 1044.38,-0.5 1050.38,-6.5 1050.38,-12.5 1050.38,-12.5 1050.38,-64.5 1050.38,-64.5 1050.38,-70.5 1044.38,-76.5 1038.38,-76.5 1038.38,-76.5 918.38,-76.5 918.38,-76.5 912.38,-76.5 906.38,-70.5 906.38,-64.5 906.38,-64.5 906.38,-12.5 906.38,-12.5 906.38,-6.5 912.38,-0.5 918.38,-0.5\"/>\n",
"<text text-anchor=\"middle\" x=\"978.38\" y=\"-61.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Account Sign Up</text>\n",
"<polyline fill=\"none\" stroke=\"black\" points=\"906.38,-54.5 1050.38,-54.5 \"/>\n",
"<text text-anchor=\"middle\" x=\"978.38\" y=\"-23.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Goal: Revenue</text>\n",
"</g>\n",
"<!-- state.customer.email_sign_up&#45;&gt;state.customer.account_sign_up -->\n",
"<g id=\"edge5\" class=\"edge\"><title>state.customer.email_sign_up&#45;&gt;state.customer.account_sign_up</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M634.733,-38.5C707.376,-38.5 819.999,-38.5 896.115,-38.5\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"896.139,-42.0001 906.139,-38.5 896.139,-35.0001 896.139,-42.0001\"/>\n",
"<text text-anchor=\"middle\" x=\"770.491\" y=\"-55.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">Card Details Saved</text>\n",
"<text text-anchor=\"middle\" x=\"770.491\" y=\"-41.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">(Both Premium or Standard Accounts)</text>\n",
"</g>\n",
"<!-- state.customer.account_sign_up&#45;&gt;state.customer.account_sign_up -->\n",
"<g id=\"edge6\" class=\"edge\"><title>state.customer.account_sign_up&#45;&gt;state.customer.account_sign_up</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M950.427,-76.5248C951.777,-86.9972 961.095,-95 978.38,-95 989.993,-95 998.01,-91.3874 1002.43,-85.8404\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"1005.7,-87.1006 1006.33,-76.5248 999.241,-84.3957 1005.7,-87.1006\"/>\n",
"<text text-anchor=\"middle\" x=\"978.38\" y=\"-97.8\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">Transfer Submitted</text>\n",
"</g>\n",
"<!-- state.customer.revenue_generator -->\n",
"<g id=\"node5\" class=\"node\"><title>state.customer.revenue_generator</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M1221.58,-0.5C1221.58,-0.5 1341.58,-0.5 1341.58,-0.5 1347.58,-0.5 1353.58,-6.5 1353.58,-12.5 1353.58,-12.5 1353.58,-64.5 1353.58,-64.5 1353.58,-70.5 1347.58,-76.5 1341.58,-76.5 1341.58,-76.5 1221.58,-76.5 1221.58,-76.5 1215.58,-76.5 1209.58,-70.5 1209.58,-64.5 1209.58,-64.5 1209.58,-12.5 1209.58,-12.5 1209.58,-6.5 1215.58,-0.5 1221.58,-0.5\"/>\n",
"<text text-anchor=\"middle\" x=\"1281.58\" y=\"-61.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Revenue Generator</text>\n",
"<polyline fill=\"none\" stroke=\"black\" points=\"1209.58,-54.5 1353.58,-54.5 \"/>\n",
"<text text-anchor=\"middle\" x=\"1281.58\" y=\"-23.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\">Goals: Referral</text>\n",
"</g>\n",
"<!-- state.customer.account_sign_up&#45;&gt;state.customer.revenue_generator -->\n",
"<g id=\"edge7\" class=\"edge\"><title>state.customer.account_sign_up&#45;&gt;state.customer.revenue_generator</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M1050.76,-38.5C1095.41,-38.5 1153.05,-38.5 1199.44,-38.5\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"1199.45,-42.0001 1209.45,-38.5 1199.45,-35.0001 1199.45,-42.0001\"/>\n",
"<text text-anchor=\"middle\" x=\"1129.98\" y=\"-41.3\" font-family=\"Helvetica,sans-Serif\" font-size=\"14.00\" fill=\"blue\">Transfer Completed</text>\n",
"</g>\n",
"</g>\n",
"</svg>\n"
],
"text/plain": [
"<graphviz.dot.Digraph at 0x11045ce48>"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"graphs['Customer']"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"### Compile all PDFs into a single document"
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [],
"source": [
"merger = PdfFileMerger()\n",
"files = [x for x in os.listdir(OUTPUT_DIR) if x.endswith('.pdf')]\n",
"for fname in sorted(files):\n",
" merger.append(PdfFileReader(open(os.path.join(OUTPUT_DIR, fname), 'rb')))\n",
"merger.write(os.path.join(OUTPUT_DIR, OUTPUT_FILE))\n",
"\n",
"files = [x for x in os.listdir(OUTPUT_DIR) if x.endswith('.gv')]\n",
"files += [x for x in os.listdir(OUTPUT_DIR) if x.endswith('.gv.pdf')]\n",
"\n",
"for fname in sorted(files):\n",
" os.remove(os.path.join(OUTPUT_DIR, fname))"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.3"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
@burguetjf
Copy link

Thank you, this is brilliant !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment