Import Scripts: Reading a file
An Import Script reads a file by declaring a file setting for
it, and the person running the script chooses the file. The examples on this page read a
spreadsheet setting declared as form_list and a document setting declared as study_config;
use the names you gave yours.
What the file offers depends on the kind of setting it is: an Excel File setting has sheets, and a JSON File or YAML File setting has a document.
Reaching for the wrong one stops the run and tells you which kind of file you have, so a script written for the wrong kind says so on the first line that reads the file rather than part way through.
Reading an Excel file
Start by looking at what the importer provides:
importer.log(f"The file is called {importer.settings['form_list'].name}")
importer.log(f"It has these sheets: {', '.join(importer.settings['form_list'].sheet_names)}")
Pick a sheet by name, then walk its rows:
sheet = importer.settings['form_list'].sheet("Forms")
importer.log(f"{sheet.name} has {len(sheet)} rows under {', '.join(sheet.columns)}")
for row in sheet.rows():
importer.log(f"row {row.number}: {row['Form OID']} is called {row['Form Name']}")
For a sheet with Form OID and Form Name columns and two rows of data, that logs:
Forms has 2 rows under Form OID, Form Name
row 2: DM is called Demographics
row 3: AE is called Adverse Events
Some things worth knowing about that:
The first row is the column headings, so the first row of data is row 2.
row.numberis the row number as you would see it in Excel, which is what makes it useful in a message.len(sheet)counts the rows of data, not the heading.Every value is text, even when the cell looked like a number or a date. If you need a number, convert it yourself with
int(...)orfloat(...).If your headings are not on the first row, say so:
importer.settings['form_list'].sheet("Forms", header_row=3).
Columns you do not know in advance
sheet.columns is the headings in sheet order, and row.items() gives each heading with this
row's value under it:
sheet = importer.settings['form_list'].sheet("Forms")
importer.log(f"columns: {', '.join(sheet.columns)}")
for row in sheet.rows():
for heading, value in row.items():
importer.log(f"{heading} = {value}")
which logs:
columns: Form OID, Form Name, Notes
Form OID = DM
Form Name = Demographics
Notes = core
importer.settings['form_list'].sheet_names does the same one level up, for a workbook whose sheets you do not
know. row.values() gives the values on their own, in the same order as sheet.columns.
Sheets that are not tables
Everything above treats a sheet as a table: a row of headings, then data under it. Plenty of
spreadsheets are not tables. A study identifier might sit on its own in B2, above everything
else. A sheet of settings might be keys down column A and values down column B, with no headings at
all.
For those, read the sheet by cell instead of by row. sheet.cells is the same worksheet read as
values at addresses:
sheet = importer.settings['form_list'].sheet("Study", header_row=5)
importer.log(f"Study {sheet.cells.at('B2')}, version {sheet.cells.at('B3')}")
which logs:
Study ACME-001, version 2.5
B2 and B3 are above the headings, so the rows never reach them. sheet.cells reads the
whole sheet, whatever header_row you chose.
There are two ways to name a cell, because there are two ways you arrive at one:
sheet.cells.at("B2")takes the address you would see in Excel. Use it when you know where the value lives, which is usually because someone told you or a specification says so. Lower case and the$of an absolute reference both work, soat("b2")andat("$B$2")are the same cell.sheet.cells.get(row=2, column=2)takes numbers, both starting at 1. Use it when you are working the numbers out as you go, in a loop. You have to name them, becauseget(2, 2)would not say which was which.
A cell that is empty reads as "", and so does a cell outside the sheet. Reading Z99 of a
small sheet is not an error, and neither is get(row=0, column=1), so you do not have to know
where a sheet stops before you look. That is what lets you compare a row with the one above it
without a special case for the first one.
If a sheet is not a table at all, you can go straight to its cells without naming a header row:
cells = importer.settings['form_list'].cells("Study")
importer.log(cells.at("B2"))
Use that for a sheet too wide or too irregular to read as rows. importer.settings['form_list'].sheet(...) reads
the same cells, so either way in works.
Keys in one column and values in another
If a sheet has no headings at all, say so with header_row=None. Then no row is taken for
headings and every row is data:
settings = {}
for row in importer.settings['form_list'].sheet("Settings", header_row=None).rows():
settings[row.values()[0]] = row.values()[1]
importer.log(f"version is {settings['version']}, site is {settings['site']}")
which, for a sheet holding version and 2.5 on the first row and site and London on
the second, logs:
version is 2.5, site is London
Without header_row=None that sheet loses its first setting, because version and 2.5
would be read as the names of the two columns.
How far the content goes
sheet.cells.last_row and sheet.cells.last_column are the last row and column holding
anything, so a loop can stop where the content does:
cells = importer.settings['form_list'].cells("Sites")
for number in range(1, cells.last_row + 1):
if not cells.get(row=number, column=2):
importer.log(f"{cells.column_letter(2)}{number} has no contact")
For a Sites sheet holding a site name in column A on every row and a contact in column B on all but the second, that logs:
B2 has no contact
cells.column_letter(2) turns the column number back into the letter you see in Excel, so the
message names a cell the reader can go and find. Reporting "row 2, column 2" would leave them
counting columns.
Cells holding a formula
A script reads the result a formula last worked out, not the formula itself. Excel stores that result in the file alongside the formula, so for a file that has been opened and saved in Excel you get the number you would see on screen.
A file that was produced by a program and never opened in Excel may have no result stored. There is nothing to read in that case, so the cell reads as empty, the same as a blank one. If a value you can see in Excel comes back empty, open the file in Excel, save it, and try again.
Merged cells
A merged block holds its value in its top left cell and nothing in the rest. That is how the file
stores it, not something a script chooses, so a heading merged across three columns gives one column
name and not three, and reading the other two by address gives "".
What happens next depends on how wide the merge is, and neither outcome is what you would guess.
A heading merged across two columns leaves one heading with no name. The sheet still reads as a
table, but the second column's values sit under that nameless heading, so asking for the merged
heading by name gives you only the first of them. For Form, Visit merged across two columns,
then Notes, the columns read as:
('Form', 'Visit', '', 'Notes')
and row["Visit"] gives the value from the first of the two merged columns only. The other is
reachable by position, through row.values(), or by cell.
A heading merged across three or more columns, or two merged headings in one row, leaves more than one nameless heading. Those are treated as a repeated heading, and the run stops with:
Sheet 'Forms' has more than one column called ''.
That message is about the blanks the merge left behind, not about a heading you typed twice.
Read a sheet with merged headings by cell, using importer.settings['form_list'].cells("Forms"), and take the
values from the addresses you can see in Excel.
Counting rows, two ways
Two numbers on the same sheet answer different questions, and it is worth keeping them apart:
len(sheet)is how many rows of data sit under the headings.sheet.cells.last_rowis how far down the sheet anything goes at all, headings and everything above them included.
For a sheet with a title on row 1, headings on row 3 and two rows of data, those are 2 and 5. For the Forms sheet above, with headings on row 1 and two rows of data, they are 2 and 3.
Columns that might not be there
row["Form OID"] stops the run if there is no such column, and tells you which columns the sheet
does have. That is usually what you want: a missing column means the file is not the one the script
was written for.
When a column is genuinely optional, ask first:
sheet = importer.settings['form_list'].sheet("Forms")
if not sheet.has_column("Notes"):
importer.warn("This file has no Notes column, so notes will be left empty.")
for row in sheet.rows():
importer.log(f"{row['Form OID']}: {row.get('Notes', 'no note')}")
Warning
row.get("Notes", "no note") gives you "no note" only when the sheet has no
Notes column at all. A row whose Notes cell is simply empty gives you an empty string,
because the column is there. To treat an empty cell as missing, test the value:
note = row.get("Notes") or "no note"
Checking before you read
The same applies to sheets. importer.settings['form_list'].sheet("Fields") stops the run if there is no such
sheet, so check when it is optional:
if not importer.settings['form_list'].has_sheet("Fields"):
importer.error("This file has no Fields sheet.")
Skipping a row rather than stopping
Real files have gaps. A blank row is usually worth a warning and a continue rather than stopping
everything:
for row in importer.settings['form_list'].sheet("Forms").rows():
oid = row["Form OID"].strip()
if not oid:
importer.warn(f"row {row.number} has no Form OID, so it was skipped")
continue
importer.log(f"would create {oid}")
Reading a JSON or YAML file
A JSON or YAML File setting does not have sheets. It has a document, and it starts reading at
importer.settings['study_config'].root.
The examples below use this file. The two are the same document written two ways, and a script reads either of them identically:
study: ONC-2026
forms:
- oid: DM
name: Demographics
fields:
- oid: BRTHDTC
label: Date of Birth
- oid: AE
name: Adverse Events
{
"study": "ONC-2026",
"forms": [
{"oid": "DM", "name": "Demographics",
"fields": [{"oid": "BRTHDTC", "label": "Date of Birth"}]},
{"oid": "AE", "name": "Adverse Events"}
]
}
Walking down
root behaves like python nested dictionaries and lists:
root = importer.settings['study_config'].root
importer.log(f"study {root['study']}")
for form in root["forms"]:
importer.log(f"{form['oid']} is called {form['name']}")
which logs:
study ONC-2026
DM is called Demographics
AE is called Adverse Events
Accessing dictionary and list-like data
This works |
and does what you expect |
|---|---|
|
Mappings by key, lists by position |
|
Membership, against keys for a mapping |
|
Number of keys, or length of the list |
|
Keys, like a dict |
|
Items, like a list |
|
|
|
As on a dict, though they return a tuple and an iterator rather than views |
|
|
There are some key differences:
There is no
values().A missing key stops the run rather than raising
KeyErrorfor you to catch. Useinorgetwhen a key is optional.has(key)is a synonym forinif you prefer it spelled out.A leaf is the plain value, not a node.
root["forms"][0]["oid"]is the string"DM", so.upper()works on it and.keys()does not. Only mappings and lists come back as nodes, and those are the only things worth asking questions of.A leaf keeps the type the file gives it.
trueisTrue,3is the number3and an unquoted2024-01-15in a YAML file is a date, not text. In a YAML file, a value with a leading zero such as0123, a time such as10:30, a code such as53E09, and words such asYesandNostay text, exactly as written.You can pass a number or a date to a command as it is. A number property takes a number as a number, a true/false or date property refuses one, and anything else stores it as text, so a name given as
22.0is22.TrueandFalseare taken only by a true/false property or column.Every node answers the same questions, not just
root.root["forms"][0].keys()is('oid', 'name', 'fields').
items() is how you walk a mapping whose keys you do not know:
for key, value in importer.settings['study_config'].root["forms"][0].items():
importer.log(f"{key} = {value}")
which logs:
oid = DM
name = Demographics
fields = root.forms[0].fields
Note the last line. Printing a branch gives you its position in the document rather than its
contents, which is what you want in a log message and is not what you want if you meant the contents.
Read into it, or use len(), to say something about what it holds.
An optional branch reads the way you would expect:
for form in importer.settings['study_config'].root["forms"]:
if "fields" not in form:
importer.warn(f"{form['oid']} lists no fields")
continue
for field in form["fields"]:
importer.log(f"{form['oid']}.{field['oid']}")
which logs:
DM.BRTHDTC
AE lists no fields
When the document is not what you expected
Reading something that is not there stops the run and says what was there instead, naming the place in the document so you can go and look:
root has no key 'nope'. Its keys are: study, forms.
root.forms is a list, so it is read by position, not by 'first'.
root.forms has 2 entries, so there is no [9].