From 26c4220a431eccadcd887249f0a86642e82132a9 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Thu, 9 Apr 2026 14:43:59 -0400 Subject: [PATCH 1/5] Rename `AGENTS.md` to `CLAUDE.md` --- AGENTS.md => CLAUDE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename AGENTS.md => CLAUDE.md (100%) diff --git a/AGENTS.md b/CLAUDE.md similarity index 100% rename from AGENTS.md rename to CLAUDE.md From 2ebc71fb32d9573eec62d5081984170d05100bec Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Fri, 10 Apr 2026 14:33:45 -0400 Subject: [PATCH 2/5] Add Commits section and `uv run ...` to CLAUDE.md --- CLAUDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2aec920f..3fb344e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,19 @@ # Documentation for AI Coding Assistants +## Commits + +Each commit should do exactly one thing so that its diff is easy to +review. If a task involves multiple changes, split them into separate +commits. For example, whenever code is moved and changed, or a file is +renamed and changed, do the move or the rename in one commit and make +the changes in another. If files need to be reformatted with ruff, do +that and commit before making code changes. + ## Commands +The `commcare-export` codebases uses a virtualenv managed by uv. Prefix +commands with `uv run ...` to run them in the virtualenv. + * Run tests: `uv run pytest -m "not dbtest" [path/to/file.py::Class::method]` * Check typing: `uv run mypy commcare_export/ tests/` * Check linting: `uv run ruff check` From 7f19e52e9cb1a741cfa3a5f7b32c2a71984710ac Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Fri, 10 Apr 2026 14:39:08 -0400 Subject: [PATCH 3/5] Use PEP 8 values for `ruff format` --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 406097b8..a86b51ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,3 +127,9 @@ local_scheme = "no-local-version" testpaths = ["tests"] python_files = ["test_*.py"] addopts = ["-vv", "--tb=short"] + +[tool.ruff] +line-length = 79 + +[tool.ruff.format] +quote-style = "single" From c34f5dc64ea5f038da117e4a6e70ca73b3058d75 Mon Sep 17 00:00:00 2001 From: Norman Hooper Date: Fri, 10 Apr 2026 15:05:02 -0400 Subject: [PATCH 4/5] Reformat writers.py and test_writers.py with ruff (PEP 8) Line length 79, single quotes. Co-Authored-By: Claude Opus 4.6 (1M context) --- commcare_export/writers.py | 101 +++++++----- tests/test_writers.py | 327 +++++++++++++++++++++---------------- 2 files changed, 240 insertions(+), 188 deletions(-) diff --git a/commcare_export/writers.py b/commcare_export/writers.py index e143bbc2..54efe9c8 100644 --- a/commcare_export/writers.py +++ b/commcare_export/writers.py @@ -54,6 +54,7 @@ class TableWriter: If the implementing class does not need any setup, no-op defaults have been provided. """ + support_checkpoints = False # set to False if writer does not support writing to the same table @@ -121,8 +122,8 @@ def __init__(self, file): except ImportError: raise Exception( "It doesn't look like this machine is configured for " - "Excel export. To export to Excel you have to run the " - "command: pip install openpyxl" + 'Excel export. To export to Excel you have to run the ' + 'command: pip install openpyxl' ) self.file = file @@ -141,7 +142,7 @@ def get_sheet(self, table): name = table.name if name not in self.sheets: sheet = self.book.create_sheet() - sheet.title = name[:self.max_table_name_size] + sheet.title = name[: self.max_table_name_size] sheet.append([ensure_text(v) for v in table.headings]) self.sheets[name] = sheet @@ -160,8 +161,8 @@ def __init__(self, file): except ImportError: raise Exception( "It doesn't look like this machine is configured for " - "excel export. To export to excel you have to run the " - "command: pip install xlwt" + 'excel export. To export to excel you have to run the ' + 'command: pip install xlwt' ) self.file = file @@ -183,8 +184,8 @@ def write_table(self, table): def get_sheet(self, table): name = table.name if name not in self.sheets: - sheet = self.book.add_sheet(name[:self.max_table_name_size]) - sheet.title = name[:self.max_table_name_size] + sheet = self.book.add_sheet(name[: self.max_table_name_size]) + sheet.title = name[: self.max_table_name_size] for colnum, val in enumerate(table.headings): sheet.write(0, colnum, ensure_text(val)) @@ -215,9 +216,9 @@ def write_table(self, table): else: assert self.tables[table.name].headings == list(table.headings) - self.tables[table.name].rows = list( - self.tables[table.name].rows - ) + [[to_jvalue(v) for v in row] for row in table.rows] + self.tables[table.name].rows = list(self.tables[table.name].rows) + [ + [to_jvalue(v) for v in row] for row in table.rows + ] class StreamingMarkdownTableWriter(TableWriter): @@ -225,6 +226,7 @@ class StreamingMarkdownTableWriter(TableWriter): Writes markdown to an output stream, where each table just comes one after the other """ + supports_multi_table_write = False def __init__(self, output_stream, compute_widths=False): @@ -235,9 +237,9 @@ def write_table(self, table): col_widths = None if self.compute_widths: col_widths = self._get_column_widths(table) - row_template = ' | '.join([ - f'{{:<{width}}}' for width in col_widths - ]) + row_template = ' | '.join( + [f'{{:<{width}}}' for width in col_widths] + ) else: row_template = ' | '.join(['{}'] * len(table.headings)) @@ -254,9 +256,7 @@ def write_table(self, table): for row in table.rows: text_row = (ensure_text(val, convert_none=True) for val in row) - self.output_stream.write( - f'| {row_template.format(*text_row)} |\n' - ) + self.output_stream.write(f'| {row_template.format(*text_row)} |\n') def _get_column_widths(self, table): all_rows = [table.headings] + list(table.rows) @@ -320,7 +320,7 @@ def max_column_length(self): return 128 if self.is_oracle: return 128 - raise Exception(f"Unknown database dialect: {self.db_url}") + raise Exception(f'Unknown database dialect: {self.db_url}') @property def metadata(self): @@ -357,6 +357,7 @@ class SqlTableWriter(SqlMixin, TableWriter): Write tables to a database specified by URL (TODO) with "upsert" based on primary key. """ + support_checkpoints = True required_columns = ['id'] @@ -374,9 +375,7 @@ def get_explicit_type(self, data_type): return get_sqlalchemy_type(data_type) except UnknownDataType: if data_type: - logger.warning( - f"Found unknown data type '{data_type}'" - ) + logger.warning(f"Found unknown data type '{data_type}'") return self.best_type_for('') # todo: more explicit fallback def best_type_for(self, val): @@ -404,7 +403,7 @@ def best_type_for(self, val): if len(val) < self.MAX_VARCHAR_LEN: return sqlalchemy.Unicode( max(len(val), self.MIN_VARCHAR_LEN), - collation=self.collation + collation=self.collation, ) else: return sqlalchemy.UnicodeText(collation=self.collation) @@ -424,7 +423,7 @@ def best_type_for(self, val): elif self.is_oracle: return sqlalchemy.Unicode(4000, collation=self.collation) else: - raise Exception(f"Unknown database dialect: {self.db_url}") + raise Exception(f'Unknown database dialect: {self.db_url}') else: # We do not have a name for "bottom" in SQL aka the type # whose least upper bound with any other type is the other @@ -453,22 +452,30 @@ def compatible(self, source_type, dest_type): compatibility = { sqlalchemy.String: (sqlalchemy.Text,), sqlalchemy.Integer: (sqlalchemy.String, sqlalchemy.Text), - sqlalchemy.Boolean: - (sqlalchemy.String, sqlalchemy.Text, sqlalchemy.Integer), - sqlalchemy.DateTime: - (sqlalchemy.String, sqlalchemy.Text, sqlalchemy.Date), + sqlalchemy.Boolean: ( + sqlalchemy.String, + sqlalchemy.Text, + sqlalchemy.Integer, + ), + sqlalchemy.DateTime: ( + sqlalchemy.String, + sqlalchemy.Text, + sqlalchemy.Date, + ), sqlalchemy.Date: (sqlalchemy.String, sqlalchemy.Text), } # add dialect specific types try: - compatibility[sqlalchemy.JSON - ] = (sqlalchemy.dialects.postgresql.json.JSON,) + compatibility[sqlalchemy.JSON] = ( + sqlalchemy.dialects.postgresql.json.JSON, + ) except AttributeError: pass try: - compatibility[sqlalchemy.Boolean - ] += (sqlalchemy.dialects.mssql.base.BIT,) + compatibility[sqlalchemy.Boolean] += ( + sqlalchemy.dialects.mssql.base.BIT, + ) except AttributeError: pass @@ -517,7 +524,7 @@ def make_table_compatible(self, table, row_dict, data_type_dict): ) op.add_column( table.name, - sqlalchemy.Column(column, val_type, nullable=True) + sqlalchemy.Column(column, val_type, nullable=True), ) self.metadata.clear() table = self.get_table(table.name) @@ -551,26 +558,26 @@ def create_table(self, table_name, row_dict, data_type_dict): sqlalchemy.Table( table_name, sqlalchemy.MetaData(), - *self._get_columns_for_data(row_dict, data_type_dict) + *self._get_columns_for_data(row_dict, data_type_dict), ) ).compile(self.connection.engine) logger.warning( f"Table '{table_name}' does not exist. Creating table " - f"with:\n{create_sql}" + f'with:\n{create_sql}' ) empty_cols = [ - name for (name, val) in row_dict.items() + name + for (name, val) in row_dict.items() if val is None and name not in data_type_dict ] if empty_cols: logger.warning( - "This schema does not include the following columns " - "since we are unable to determine the column type at " - f"this stage: {empty_cols}" + 'This schema does not include the following columns ' + 'since we are unable to determine the column type at ' + f'this stage: {empty_cols}' ) op.create_table( - table_name, - *self._get_columns_for_data(row_dict, data_type_dict) + table_name, *self._get_columns_for_data(row_dict, data_type_dict) ) self.metadata.clear() return self.get_table(table_name) @@ -591,9 +598,11 @@ def upsert(self, table, row_dict): insert = table.insert().values(**row_dict) self.connection.execute(insert) except sqlalchemy.exc.IntegrityError: - update = (table.update() - .where(table.c.id == row_dict['id']) - .values(**row_dict)) + update = ( + table.update() + .where(table.c.id == row_dict['id']) + .values(**row_dict) + ) self.connection.execute(update) def write_table(self, table_spec: TableSpec) -> None: @@ -621,9 +630,11 @@ def _get_columns_for_data(self, row_dict, data_type_dict): sqlalchemy.Column( column_name, self.get_data_type(data_type_dict[column_name], val), - nullable=True + nullable=True, ) for (column_name, val) in row_dict.items() - if ((val is not None or data_type_dict[column_name]) - and column_name != 'id') + if ( + (val is not None or data_type_dict[column_name]) + and column_name != 'id' + ) ] diff --git a/tests/test_writers.py b/tests/test_writers.py index 3111ef13..9b144a2e 100644 --- a/tests/test_writers.py +++ b/tests/test_writers.py @@ -25,16 +25,12 @@ def writer(db_params): @pytest.fixture() def strict_writer(db_params): return SqlTableWriter( - db_params['url'], - poolclass=sqlalchemy.pool.NullPool, - strict_types=True + db_params['url'], poolclass=sqlalchemy.pool.NullPool, strict_types=True ) TYPE_MAP = { - 'mysql': { - bool: lambda x: int(x) - }, + 'mysql': {bool: lambda x: int(x)}, } @@ -43,6 +39,7 @@ def _type_convert(connection, row): Different databases store and return values differently so convert the values in the expected row to match the DB. """ + def convert(type_map, value): func = type_map.get(value.__class__, None) return func(value) if func else value @@ -59,11 +56,12 @@ def _check_excel2007_output(filename): assert output_wb.sheetnames == ['foo'] foo_sheet = output_wb['foo'] - assert [ - [cell.value for cell in row] for row in foo_sheet['A1:C3'] - ] == [ + assert [[cell.value for cell in row] for row in foo_sheet['A1:C3']] == [ ['a', 'bjørn', 'c'], - ['1', '2', '3' + [ + '1', + '2', + '3', ], # Note how pyxl does some best-effort parsing to *whatever* type ['4', '日本', '6'], ] @@ -76,24 +74,38 @@ def _test_types(writer, table_name): **{ 'name': table_name, 'headings': ['id', 'a', 'b', 'c', 'd', 'e'], - 'rows': [[ - 'bizzle', 1, 'yo', True, datetime.date(2015, 1, 1), - datetime.datetime(2014, 4, 2, 18, 56, 12) - ], [ - 'bazzle', 4, '日本', False, - datetime.date(2015, 1, 2), - datetime.datetime(2014, 5, 1, 11, 16, 45) - ]] + 'rows': [ + [ + 'bizzle', + 1, + 'yo', + True, + datetime.date(2015, 1, 1), + datetime.datetime(2014, 4, 2, 18, 56, 12), + ], + [ + 'bazzle', + 4, + '日本', + False, + datetime.date(2015, 1, 2), + datetime.datetime(2014, 5, 1, 11, 16, 45), + ], + ], } ) ) with writer: connection = writer.connection - result = dict([ - (row['id'], row) for row in connection - .execute(f'SELECT id, a, b, c, d, e FROM {table_name}') - ]) + result = dict( + [ + (row['id'], row) + for row in connection.execute( + f'SELECT id, a, b, c, d, e FROM {table_name}' + ) + ] + ) assert len(result) == 2 expected = { @@ -103,7 +115,7 @@ def _test_types(writer, table_name): 'b': 'yo', 'c': True, 'd': datetime.date(2015, 1, 1), - 'e': datetime.datetime(2014, 4, 2, 18, 56, 12) + 'e': datetime.datetime(2014, 4, 2, 18, 56, 12), }, 'bazzle': { 'id': 'bazzle', @@ -111,8 +123,8 @@ def _test_types(writer, table_name): 'b': '日本', 'c': False, 'd': datetime.date(2015, 1, 2), - 'e': datetime.datetime(2014, 5, 1, 11, 16, 45) - } + 'e': datetime.datetime(2014, 5, 1, 11, 16, 45), + }, } for id, row in result.items(): @@ -122,28 +134,27 @@ def _test_types(writer, table_name): def _get_column_lengths(connection, table_name): return { - row['COLUMN_NAME']: row for row in connection.execute( - "SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH " - "FROM INFORMATION_SCHEMA.COLUMNS " + row['COLUMN_NAME']: row + for row in connection.execute( + 'SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH ' + 'FROM INFORMATION_SCHEMA.COLUMNS ' f"WHERE TABLE_NAME = '{table_name}';" ) } class TestWriters: - def test_jvalue_table_writer(self): writer = JValueTableWriter() writer.write_table( TableSpec( **{ - 'name': - 'foo', + 'name': 'foo', 'headings': ['a', 'bjørn', 'c', 'd'], 'rows': [ [1, '2', 3, datetime.date(2015, 1, 1)], [4, '日本', 6, datetime.date(2015, 1, 2)], - ] + ], } ) ) @@ -153,26 +164,25 @@ def test_jvalue_table_writer(self): **{ 'name': 'foo', 'headings': ['a', 'bjørn', 'c', 'd'], - 'rows': [[5, 'bob', 9, - datetime.date(2018, 1, 2)],] + 'rows': [ + [5, 'bob', 9, datetime.date(2018, 1, 2)], + ], } ) ) assert writer.tables == { - 'foo': - TableSpec( - **{ - 'name': - 'foo', - 'headings': ['a', 'bjørn', 'c', 'd'], - 'rows': [ - [1, '2', 3, '2015-01-01'], - [4, '日本', 6, '2015-01-02'], - [5, 'bob', 9, '2018-01-02'], - ], - } - ) + 'foo': TableSpec( + **{ + 'name': 'foo', + 'headings': ['a', 'bjørn', 'c', 'd'], + 'rows': [ + [1, '2', 3, '2015-01-01'], + [4, '日本', 6, '2015-01-02'], + [5, 'bob', 9, '2018-01-02'], + ], + } + ) } def test_excel2007_table_writer(self): @@ -186,7 +196,7 @@ def test_excel2007_table_writer(self): 'rows': [ [1, '2', 3], [4, '日本', 6], - ] + ], } ) ) @@ -201,7 +211,9 @@ def test_excel2007_table_writer_write_multi(self): **{ 'name': 'foo', 'headings': ['a', 'bjørn', 'c'], - 'rows': [[1, '2', 3],] + 'rows': [ + [1, '2', 3], + ], } ) ) @@ -211,7 +223,9 @@ def test_excel2007_table_writer_write_multi(self): **{ 'name': 'foo', 'headings': ['a', 'bjørn', 'c'], - 'rows': [[4, '日本', 6],] + 'rows': [ + [4, '日本', 6], + ], } ) ) @@ -228,7 +242,7 @@ def test_csv_table_writer(self): 'rows': [ [1, '2', 3], [4, '日本', 6], - ] + ], } ) ) @@ -248,7 +262,6 @@ def test_csv_table_writer(self): @pytest.mark.dbtest class TestSQLWriters: - def test_insert(self, writer): with writer: writer.write_table( @@ -259,7 +272,7 @@ def test_insert(self, writer): 'rows': [ ['bizzle', 1, 2, 3], ['bazzle', 4, 5, 6], - ] + ], } ) ) @@ -267,21 +280,27 @@ def test_insert(self, writer): # We can use raw SQL instead of SqlAlchemy expressions because # we built the DB above with writer: - result = dict([(row['id'], row) for row in writer.connection - .execute('SELECT id, a, b, c FROM foo_insert')]) + result = dict( + [ + (row['id'], row) + for row in writer.connection.execute( + 'SELECT id, a, b, c FROM foo_insert' + ) + ] + ) assert len(result) == 2 assert dict(result['bizzle']) == { 'id': 'bizzle', 'a': 1, 'b': 2, - 'c': 3 + 'c': 3, } assert dict(result['bazzle']) == { 'id': 'bazzle', 'a': 4, 'b': 5, - 'c': 6 + 'c': 6, } def test_upsert(self, writer): @@ -291,17 +310,21 @@ def test_upsert(self, writer): **{ 'name': 'foo_upsert', 'headings': ['id', 'a', 'b', 'c'], - 'rows': [['zing', 3, None, 5]] + 'rows': [['zing', 3, None, 5]], } ) ) # don't select column 'b' since it hasn't been created yet with writer: - result = dict([ - (row['id'], row) for row in - writer.connection.execute('SELECT id, a, c FROM foo_upsert') - ]) + result = dict( + [ + (row['id'], row) + for row in writer.connection.execute( + 'SELECT id, a, c FROM foo_upsert' + ) + ] + ) assert len(result) == 1 assert dict(result['zing']) == {'id': 'zing', 'a': 3, 'c': 5} @@ -309,13 +332,12 @@ def test_upsert(self, writer): writer.write_table( TableSpec( **{ - 'name': - 'foo_upsert', + 'name': 'foo_upsert', 'headings': ['id', 'a', 'b', 'c'], 'rows': [ ['bizzle', 1, 'yo', 3], ['bazzle', 4, '日本', 6], - ] + ], } ) ) @@ -323,21 +345,27 @@ def test_upsert(self, writer): # We can use raw SQL instead of SqlAlchemy expressions because # we built the DB above with writer: - result = dict([(row['id'], row) for row in writer.connection - .execute('SELECT id, a, b, c FROM foo_upsert')]) + result = dict( + [ + (row['id'], row) + for row in writer.connection.execute( + 'SELECT id, a, b, c FROM foo_upsert' + ) + ] + ) assert len(result) == 3 assert dict(result['bizzle']) == { 'id': 'bizzle', 'a': 1, 'b': 'yo', - 'c': 3 + 'c': 3, } assert dict(result['bazzle']) == { 'id': 'bazzle', 'a': 4, 'b': '日本', - 'c': 6 + 'c': 6, } with writer: @@ -346,7 +374,9 @@ def test_upsert(self, writer): **{ 'name': 'foo_upsert', 'headings': ['id', 'a', 'b', 'c'], - 'rows': [['bizzle', 7, '本', 9],] + 'rows': [ + ['bizzle', 7, '本', 9], + ], } ) ) @@ -354,21 +384,27 @@ def test_upsert(self, writer): # We can use raw SQL instead of SqlAlchemy expressions because # we built the DB above with writer: - result = dict([(row['id'], row) for row in writer.connection - .execute('SELECT id, a, b, c FROM foo_upsert')]) + result = dict( + [ + (row['id'], row) + for row in writer.connection.execute( + 'SELECT id, a, b, c FROM foo_upsert' + ) + ] + ) assert len(result) == 3 assert dict(result['bizzle']) == { 'id': 'bizzle', 'a': 7, 'b': '本', - 'c': 9 + 'c': 9, } assert dict(result['bazzle']) == { 'id': 'bazzle', 'a': 4, 'b': '日本', - 'c': 6 + 'c': 6, } def test_types(self, writer): @@ -381,14 +417,18 @@ def test_change_type(self, writer): writer.write_table( TableSpec( **{ - 'name': - 'foo_fancy_type_changes', + 'name': 'foo_fancy_type_changes', 'headings': ['id', 'a', 'b', 'c', 'd', 'e'], - 'rows': [[ - 'bizzle', 'yo dude', '本', 'true', - datetime.datetime(2015, 2, 13), - '2014-08-01T11:23:45:00.0000Z' - ],] + 'rows': [ + [ + 'bizzle', + 'yo dude', + '本', + 'true', + datetime.datetime(2015, 2, 13), + '2014-08-01T11:23:45:00.0000Z', + ], + ], } ) ) @@ -396,11 +436,14 @@ def test_change_type(self, writer): # We can use raw SQL instead of SqlAlchemy expressions because # we built the DB above with writer: - result = dict([ - (row['id'], row) for row in writer.connection.execute( - 'SELECT id, a, b, c, d, e FROM foo_fancy_type_changes' - ) - ]) + result = dict( + [ + (row['id'], row) + for row in writer.connection.execute( + 'SELECT id, a, b, c, d, e FROM foo_fancy_type_changes' + ) + ] + ) assert len(result) == 2 expected = { @@ -410,7 +453,7 @@ def test_change_type(self, writer): 'b': '本', 'c': 'true', 'd': datetime.date(2015, 2, 13), - 'e': '2014-08-01T11:23:45:00.0000Z' + 'e': '2014-08-01T11:23:45:00.0000Z', }, 'bazzle': { 'id': 'bazzle', @@ -418,8 +461,8 @@ def test_change_type(self, writer): 'b': '日本', 'c': 'false', 'd': datetime.date(2015, 1, 2), - 'e': '2014-05-01 11:16:45' - } + 'e': '2014-05-01 11:16:45', + }, } if 'mysql' in writer.connection.engine.driver: @@ -473,19 +516,14 @@ def test_json_type(self, writer): 'name': 'foo_with_json', 'headings': ['id', 'json_col'], 'rows': [ - ['simple', { - 'k1': 'v1', - 'k2': 'v2' - }], - ['with_lists', { - 'l1': ['i1', 'i2'] - }], + ['simple', {'k1': 'v1', 'k2': 'v2'}], + ['with_lists', {'l1': ['i1', 'i2']}], ['complex', complex_object], ], 'data_types': [ 'text', 'json', - ] + ], } ) ) @@ -493,26 +531,27 @@ def test_json_type(self, writer): # We can use raw SQL instead of SqlAlchemy expressions because # we built the DB above with writer: - result = dict([(row['id'], row) for row in writer.connection - .execute('SELECT id, json_col FROM foo_with_json')]) + result = dict( + [ + (row['id'], row) + for row in writer.connection.execute( + 'SELECT id, json_col FROM foo_with_json' + ) + ] + ) assert len(result) == 3 assert dict(result['simple']) == { 'id': 'simple', - 'json_col': { - 'k1': 'v1', - 'k2': 'v2' - } + 'json_col': {'k1': 'v1', 'k2': 'v2'}, } assert dict(result['with_lists']) == { 'id': 'with_lists', - 'json_col': { - 'l1': ['i1', 'i2'] - } + 'json_col': {'l1': ['i1', 'i2']}, } assert dict(result['complex']) == { 'id': 'complex', - 'json_col': complex_object + 'json_col': complex_object, } def test_explicit_types(self, strict_writer): @@ -531,7 +570,7 @@ def test_explicit_types(self, strict_writer): 'integer', 'text', None, - ] + ], } ) ) @@ -539,10 +578,14 @@ def test_explicit_types(self, strict_writer): # We can use raw SQL instead of SqlAlchemy expressions because # we built the DB above with strict_writer: - result = dict([ - (row['id'], row) for row in strict_writer.connection - .execute('SELECT id, a, b, c, d FROM foo_explicit_types') - ]) + result = dict( + [ + (row['id'], row) + for row in strict_writer.connection.execute( + 'SELECT id, a, b, c, d FROM foo_explicit_types' + ) + ] + ) assert len(result) == 2 # a casts strings to ints, b casts ints to text, c default falls back to ints, d default falls back to text @@ -551,14 +594,14 @@ def test_explicit_types(self, strict_writer): 'a': 1, 'b': '2', 'c': 3, - 'd': '7' + 'd': '7', } assert dict(result['bazzle']) == { 'id': 'bazzle', 'a': 4, 'b': '5', 'c': 6, - 'd': '8' + 'd': '8', } def test_mssql_nvarchar_length_upsize(self, writer): @@ -572,28 +615,27 @@ def test_mssql_nvarchar_length_upsize(self, writer): writer.write_table( TableSpec( **{ - 'name': - 'mssql_nvarchar_length', + 'name': 'mssql_nvarchar_length', 'headings': ['id', 'some_data', 'big_data'], 'rows': [ [ - 'bizzle', (b'\0' * 800).decode('utf-8'), - (b'\0' * 901).decode('utf-8') + 'bizzle', + (b'\0' * 800).decode('utf-8'), + (b'\0' * 901).decode('utf-8'), ], [ - 'bazzle', (b'\0' * 500).decode('utf-8'), - (b'\0' * 800).decode('utf-8') + 'bazzle', + (b'\0' * 500).decode('utf-8'), + (b'\0' * 800).decode('utf-8'), ], - ] + ], } ) ) connection = writer.connection - result = _get_column_lengths( - connection, 'mssql_nvarchar_length' - ) + result = _get_column_lengths(connection, 'mssql_nvarchar_length') assert result['some_data'] == ('some_data', 'nvarchar', 900) # nvarchar(max) is listed as -1 assert result['big_data'] == ('big_data', 'nvarchar', -1) @@ -603,20 +645,20 @@ def test_mssql_nvarchar_length_upsize(self, writer): writer.write_table( TableSpec( **{ - 'name': - 'mssql_nvarchar_length', + 'name': 'mssql_nvarchar_length', 'headings': ['id', 'some_data', 'big_data'], - 'rows': [[ - 'sizzle', (b'\0' * 901).decode('utf-8'), - (b'\0' * 901).decode('utf-8') - ],] + 'rows': [ + [ + 'sizzle', + (b'\0' * 901).decode('utf-8'), + (b'\0' * 901).decode('utf-8'), + ], + ], } ) ) - result = _get_column_lengths( - connection, 'mssql_nvarchar_length' - ) + result = _get_column_lengths(connection, 'mssql_nvarchar_length') assert result['some_data'] == ('some_data', 'nvarchar', -1) assert result['big_data'] == ('big_data', 'nvarchar', -1) @@ -633,9 +675,7 @@ def test_mssql_nvarchar_length_downsize(self, writer): 'mssql_nvarchar_length_downsize', metadata, sqlalchemy.Column( - 'id', - sqlalchemy.NVARCHAR(length=100), - primary_key=True + 'id', sqlalchemy.NVARCHAR(length=100), primary_key=True ), sqlalchemy.Column( 'some_data', sqlalchemy.NVARCHAR(length=None) @@ -647,19 +687,20 @@ def test_mssql_nvarchar_length_downsize(self, writer): writer.write_table( TableSpec( **{ - 'name': - 'mssql_nvarchar_length', + 'name': 'mssql_nvarchar_length', 'headings': ['id', 'some_data'], 'rows': [ [ - 'bizzle', (b'\0' * 800).decode('utf-8'), - (b'\0' * 800).decode('utf-8') + 'bizzle', + (b'\0' * 800).decode('utf-8'), + (b'\0' * 800).decode('utf-8'), ], [ - 'bazzle', (b'\0' * 500).decode('utf-8'), - (b'\0' * 800).decode('utf-8') + 'bazzle', + (b'\0' * 500).decode('utf-8'), + (b'\0' * 800).decode('utf-8'), ], - ] + ], } ) ) From 2e342164d11dfa13e8aafa65863abb4fb768e56f Mon Sep 17 00:00:00 2001 From: Norman Hooper <708421+kaapstorm@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:55:15 +0100 Subject: [PATCH 5/5] Fix grammar Co-authored-by: Daniel Miller --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3fb344e2..b31bf430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ that and commit before making code changes. ## Commands -The `commcare-export` codebases uses a virtualenv managed by uv. Prefix +The `commcare-export` codebase uses a virtualenv managed by uv. Prefix commands with `uv run ...` to run them in the virtualenv. * Run tests: `uv run pytest -m "not dbtest" [path/to/file.py::Class::method]`