Importing CSV data¶
A table of a SQLite model can import CSV data from a file or a URL. These sections cover the table, its sources of CSV data, and how it finds the CSV dialect.
A CSV table¶
A CSVSqliteTable imports CSV data into a table of the model. An attrs row class gives
the table its columns: the table converts each CSV row into the class before it inserts
it. So the converters, defaults, factories, and validators of the class apply.
from datetime import datetime
from io import StringIO
import attrs
from composeui.core.sqlitemodel import CSVSqliteTable, SqliteModel, csv_field, define_csv_row, table
@define_csv_row
class Reading:
recorded: datetime = csv_field(name="Recorded", date_format="%d/%m/%Y %H:%M")
value: float = csv_field(name="Value", validator=attrs.validators.ge(0))
station: str = csv_field(name="Station", default="unknown")
class Readings(CSVSqliteTable, row_type=Reading, name="reading"):
pass
class ReadingModel(SqliteModel):
readings = table(Readings)
model = ReadingModel()
try:
text = StringIO("Recorded;Value\n25/10/2026 17:00;12.5\n")
for count in model.readings.fill(text, delimiter=";", batch_size=1000):
print(f"Submitted {count} rows")
source = model.readings.source(headers={"recorded": "Recorded at", "value": "Value"})
print(f"Source contains {source.count()} rows")
finally:
model.close()
The row class.
@define_csv_rowappliesattrs.definewith its default options. It also tells type checkers thatcsv_fielddeclares a field, not a default value.csv_field(name="Header")gives the CSV header of the field. A row class with@attrs.definealso works when the app runs, but a type checker then reads eachcsv_fieldas a default value.Type checks with mypy. Turn on the plugin of ComposeUI:
[tool.mypy] plugins = ["composeui.mypy"]
Install mypy yourself: ComposeUI does not import it when the app runs. The plugin gives mypy the CSV fields, as attrs fields, and the types of converters such as
str.strip. So mypy checks the arguments of the class as attrs builds them: with their converters, defaults, factories, aliases, andinit=Falsefields.Columns. Each field is a column of the same name. The CSV header of a column is its
csv_fieldname, else its field name: plainattrsfields work too.The types of the fields are
str,int,bool,float,bytes,decimal.Decimal,datetime.date, anddatetime.datetime, or anOptionalof one of them.An
Optionalfield accepts SQLNULL. The other fields areNOT NULL.A default fills a column that the CSV data doesn’t have, not an empty cell.
A computed field (
init=False) is stored too.The table has no primary key and no index. Use SQLite’s
rowidto identify a row, and add the indexes in the table’screatescript.
A source for a large table.
model.readings.source()gives aSqliteSourceof the rows, for aSourceTableView.The types of its columns come from the row class, and their headers are the CSV headers.
headers={"field": "Label", ...}selects, orders, and labels the columns. An unknown field raisesKeyError.The source identifies the rows by SQLite’s hidden row id, and follows the table’s
changedsignal. When the fields take all three names of the row id (rowid,_rowid_, andoid, in any case),source()raisesValueError.For a query of your own, or a key of your own, build a
SqliteSourceyourself.
Conversion. A field without a converter goes through the
cattrsconverter of the views.csv_fieldpasses the options ofattrs, such asconverter,validator,default, andfactory. Give a converter for other values, for example to turn “yes” and “no” into booleans. The validators run after the conversion.Dates.
date_formatreads the text withdatetime.strptime. The field gets adateor adatetime, as its annotation says. Without a format, the text is ISO 8601.A field with a
date_formatmust be adateor adatetime. The mypy plugin refuses another type, and the table refuses it when Python defines it.A field has a
date_formator aconverter, not both.SQLite keeps the dates as ISO 8601 text. A time zone offset that the format reads is kept.
Input.
fillreads an iterable of lines of text, which you open and close. Open a file withnewline=""and its encoding. To import a file, usefill_from_file, which opens and closes it. To import a URL, usefill_from_url. The other keyword arguments, such asdelimiterandfieldnames, go tocsv.DictReader.Progress and transactions.
fill(text, batch_size=100_000, limit=None)follows the rules ofinsert_batches: it yields the rows sent so far, and commits all the batches together once the loop reads it to the end.limitsets the number of rows to import.filldoes not close the input.Invalid rows. An error of conversion or of validation (
ValueErrororTypeError) becomes aRefusedError, caused by the original error. The whole import rolls back, with the batches before it. In a callback,self.refusing(...)shows the refusal. In a job, let it reachjob.failed, and show it in the callback of that signal. An empty or wrong row class is an error of the code, not a refusal.Studies. A CSV table is part of the study, like the other tables. After Open or a reload, build the sources again from the table’s
changedsignal: the study does not save them with the views. Use the rows that the study holds: do not import them again.
CSV files¶
fill_from_file(path, ...) imports a CSV file, plain or gzip. The path is a string or a
pathlib.Path, absolute or from the current directory. The method tells a gzip file by
its content, not by its suffix:
from contextlib import closing
model = ReadingModel() # the model of the previous example
try:
with closing(
model.readings.fill_from_file("readings.csv.gz", sniff=True, batch_size=1000)
) as batches:
for count in batches:
print(f"Submitted {count} rows")
finally:
model.close()
encoding="utf-8"sets the encoding of the text.batch_size,limit, and the CSV options are those offill.The method adds the rows to the table, and follows the transaction rules of
fill. Closing it early closes the file, and rolls the import back.An error of the file, of the decompression, or of the decoding goes to the caller. A missing file raises
FileNotFoundError.
CSV URLs¶
fill_from_url(url, ...) imports CSV data from an HTTP, HTTPS, or file URL. It tells gzip
data by its content, also without a .gz suffix. It reads no other compression:
from contextlib import closing
from pathlib import Path
model = ReadingModel() # the model of the first example
url = Path("readings.csv.gz").resolve().as_uri() # or an https:// URL
try:
with closing(model.readings.fill_from_url(url, delimiter=";", batch_size=1000)) as batches:
for count in batches:
print(f"Submitted {count} rows")
finally:
model.close()
encoding="utf-8"sets the encoding of the text.timeout=30.0is the time, in seconds, that each read of the socket waits, not the time of the whole download.batch_size,limit, and the CSV options are those offill.The method adds the rows to the table, and follows the transaction rules of
fill. Closing it early closes the response, and rolls the import back.It keeps no copy of the download, and imports the rows again into a table that already has them.
To read CSV data from a URL without importing it, use open_csv_url from the same module.
It keeps the newlines for the CSV reader, and closes the text and the response at the end
of the block:
import csv
from composeui.core.sqlitemodel import open_csv_url
with open_csv_url(url) as text:
print(next(csv.DictReader(text, delimiter=";")))
An error of the network, of the decompression, or of the decoding goes to the caller. An error of conversion or of validation in
fill_from_urlbecomes aRefusedError, as infill. A URL of another scheme raisesValueError.Give these functions trusted URLs only. They accept every host, follow redirects, and read local files.
A download takes time: run it in a job. The
Uber pickups example imports its CSV with
fill_from_url, and shows the count of rows in a
reusable loading task.
CSV dialect detection¶
sniff=True makes fill_from_file and fill_from_url find the CSV dialect, such as the
delimiter, with csv.Sniffer.
The sample.
sniff_sample_size=8192is the number of characters that the sniffer reads, after the decompression and the decoding. For long headers or rows, give a larger sample, for examplesniff_sample_size=65536. Withsniff=True, a size that is not positive raisesValueError. Without it, the size is ignored.No seek. The import reads the sample again from memory, with its partial lines and its quoted newlines. So an HTTP stream works too. The sample can read past the rows of a batch or of
limit, but the import keeps only the rows asked for.Your options win.
delimiter,quotechar, anddialectreplace what the sniffer finds. Withoutsniff=True, the import reads commas, or the options that you give.The header. The sniffer does not guess whether the first row is a header: the first row names the columns, unless you give
fieldnames.When it fails. When the sniffer finds no dialect, the import raises a
RefusedError. Its message says to give a larger sample, or to turn sniffing off and give the CSV options. A larger sample can help, but the sniffer can still guess wrong. For a known format, leave sniffing off and give its options.Errors. An import with sniffing rolls back as one without: on an error, or when the loop stops early.