feat: add streaming of rdf xml dataset with resume functionality #44

Merged
aditya merged 4 commits from streaming-xml into stream-download-convert-upload-merge-15-refactor-rdf-converters 2026-01-14 14:24:07 +00:00
Member

Add functionality to stream rdf/xml dataset, it reads line-by-line looking for complete rdf:Description blocks, parse each block to extract triples.

Add functionality to stream rdf/xml dataset, it reads line-by-line looking for complete <rdf:Description> blocks, parse each block to extract triples.
brent.edwards requested changes 2026-01-06 01:13:08 +00:00
Dismissed
brent.edwards left a comment
Member

The most important problem: Please use a real XML parser instead of trying to parse files directly.

I recommend looking at pull parsers in Python. Two that I found immediately were lxml.etree.iterparse and xml.etree.ElementTree.iterparse (standard library), but feel free to use any XML parser that you find easy.

The most important problem: Please use a real XML parser instead of trying to parse files directly. I recommend looking at pull parsers in Python. Two that I found immediately were `lxml.etree.iterparse` and `xml.etree.ElementTree.iterparse` (standard library), but feel free to use any XML parser that you find easy.
@@ -220,0 +222,4 @@
xml_declaration: str | None,
rdf_root: str | None,
description_lines: list[str],
) -> str:
Member

If you make the below changes, there always will be xml_declaration and rdf_root lines. So you won't need the | None of the xml_declaration and rdf_root declaration, and you won't need lines 236-245.

If you make the below changes, there always will be `xml_declaration` and `rdf_root` lines. So you won't need the `| None` of the `xml_declaration` and `rdf_root` declaration, and you won't need lines 236-245.
Author
Member

Removed the direct parse logic, and implemented the parsing using xml.etree.ElementTree

Removed the direct parse logic, and implemented the parsing using xml.etree.ElementTree
brent.edwards marked this conversation as resolved
@@ -220,0 +311,4 @@
depth = 0
for line in lines:
line_count += 1
Member

It's more Pythonic to combine lines 313-314 as:

for line_count, line in enumerate(lines)

especially since you don't use line_count outside the loop.

It's more Pythonic to combine lines 313-314 as: ``` for line_count, line in enumerate(lines) ``` especially since you don't use `line_count` outside the loop.
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +321,4 @@
# Extract XML declaration (line 1)
if stripped.startswith("<?xml"):
root_line = line
continue
Member

I'm VERY, VERY nervous when we try to do our own parsing of something as complex as https://www.w3.org/TR/2006/REC-xml11-20060816/ .

For example, lines 322 parses correctly-formed XML files... but it also allows XML files that incorrectly have multiple prologs.

We should use a real XML parser.

I'm VERY, VERY nervous when we try to do our own parsing of something as complex as https://www.w3.org/TR/2006/REC-xml11-20060816/ . For example, lines 322 parses correctly-formed XML files... but it also allows XML files that incorrectly have multiple prologs. We should use a real XML parser.
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +326,4 @@
# Extract root RDF element with namespaces (line 2)
if stripped.startswith("<rdf:RDF") and rdf_root_line is None:
rdf_root_line = line
continue
Member

I'm very nervous about line 327-329.

This will definitely ONLY work with uniprot files.

I recommend using a real XML parser and asking it to read the namespace. Especially since this code wouldn't work if the rdf:RDF namespace were split over several lines.

I'm very nervous about line 327-329. This will definitely ONLY work with uniprot files. I recommend using a real XML parser and asking it to read the namespace. Especially since this code wouldn't work if the `rdf:RDF` namespace were split over several lines.
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +334,4 @@
depth = 1
current_description = [line]
# Handle self-closing tags: <rdf:Description ... />
if stripped.rstrip().endswith("/>"):
Member

Why the rstrip()? Line 315 writes that it was already stripped.

Why the `rstrip()`? Line 315 writes that it was already stripped.
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +349,4 @@
if description_count >= chunk_size:
yield triple_batch
triple_batch = []
description_count = 0
Member

Lines 341-352 and lines 385-401 are very similar. Instead of duplicating, could they be turned into a method?

Lines 341-352 and lines 385-401 are very similar. Instead of duplicating, could they be turned into a method?
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +358,4 @@
# Count opening/closing tags to track depth correctly
# Opening tags: <tag> or <tag ...> (not </tag or <tag/>)
# Find all <tag patterns (this excludes </tag> because regex requires [a-zA-Z_] after <)
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:361:89: E501 Line too long (100 > 88)
    |
359 |             # Count opening/closing tags to track depth correctly
360 |             # Opening tags: <tag> or <tag ...> (not </tag or <tag/>)
361 |             # Find all <tag patterns (this excludes </tag> because regex requires [a-zA-Z_] after <)
    |                                                                                         ^^^^^^^^^^^^ E501
362 |             all_tag_starts = re.findall(r'<([a-zA-Z_][\w:.-]*)', stripped)
363 |             # Closing tags: </tag>
    |
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:361:89: E501 Line too long (100 > 88) | 359 | # Count opening/closing tags to track depth correctly 360 | # Opening tags: <tag> or <tag ...> (not </tag or <tag/>) 361 | # Find all <tag patterns (this excludes </tag> because regex requires [a-zA-Z_] after <) | ^^^^^^^^^^^^ E501 362 | all_tag_starts = re.findall(r'<([a-zA-Z_][\w:.-]*)', stripped) 363 | # Closing tags: </tag> | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +359,4 @@
# Count opening/closing tags to track depth correctly
# Opening tags: <tag> or <tag ...> (not </tag or <tag/>)
# Find all <tag patterns (this excludes </tag> because regex requires [a-zA-Z_] after <)
all_tag_starts = re.findall(r'<([a-zA-Z_][\w:.-]*)', stripped)
Member

Lines 362, 364, and 367 count tags that happen inside comments.

XML is complex. Really. Please fix this by using a real XML parser instead of trying to parse XML directly.

Lines 362, 364, and 367 count tags that happen inside comments. XML is complex. Really. Please fix this by using a real XML parser instead of trying to parse XML directly.
brent.edwards marked this conversation as resolved
@@ -220,0 +364,4 @@
closing_tags = len(re.findall(r'</([a-zA-Z_][\w:.-]*)>', stripped))
# Self-closing tags: <tag ... /> (these don't change depth)
# Find tags that end with /> before the next >
self_closing_tags = len(re.findall(r'<([a-zA-Z_][\w:.-]*)[^>]*/>', stripped))
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:367:89: E501 Line too long (89 > 88)
    |
365 |             # Self-closing tags: <tag ... /> (these don't change depth)
366 |             # Find tags that end with /> before the next >
367 |             self_closing_tags = len(re.findall(r'<([a-zA-Z_][\w:.-]*)[^>]*/>', stripped))
    |                                                                                         ^ E501
368 |
369 |             # Opening tags = all tag starts - self-closing tags
    |
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:367:89: E501 Line too long (89 > 88) | 365 | # Self-closing tags: <tag ... /> (these don't change depth) 366 | # Find tags that end with /> before the next > 367 | self_closing_tags = len(re.findall(r'<([a-zA-Z_][\w:.-]*)[^>]*/>', stripped)) | ^ E501 368 | 369 | # Opening tags = all tag starts - self-closing tags | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +367,4 @@
self_closing_tags = len(re.findall(r'<([a-zA-Z_][\w:.-]*)[^>]*/>', stripped))
# Opening tags = all tag starts - self-closing tags
# (all_tag_starts already excludes closing tags, so we only subtract self-closing)
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:370:89: E501 Line too long (94 > 88)
    |
369 |             # Opening tags = all tag starts - self-closing tags
370 |             # (all_tag_starts already excludes closing tags, so we only subtract self-closing)
    |                                                                                         ^^^^^^ E501
371 |             opening_tags = len(all_tag_starts) - self_closing_tags
    |
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:370:89: E501 Line too long (94 > 88) | 369 | # Opening tags = all tag starts - self-closing tags 370 | # (all_tag_starts already excludes closing tags, so we only subtract self-closing) | ^^^^^^ E501 371 | opening_tags = len(all_tag_starts) - self_closing_tags | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +377,4 @@
# Check if Description element is closed
if "</rdf:Description>" in stripped:
# The closing tag was already accounted for in closing_tags above
if depth == 0:
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:378:13: SIM102 Use a single `if` statement instead of nested `if` statements
    |
377 |               # Check if Description element is closed
378 | /             if "</rdf:Description>" in stripped:
379 | |                 # The closing tag was already accounted for in closing_tags above
380 | |                 if depth == 0:
    | |______________________________^ SIM102
381 |                       # Complete Description element found
382 |                       in_description = False
    |
    = help: Combine `if` statements using `and`
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:378:13: SIM102 Use a single `if` statement instead of nested `if` statements | 377 | # Check if Description element is closed 378 | / if "</rdf:Description>" in stripped: 379 | | # The closing tag was already accounted for in closing_tags above 380 | | if depth == 0: | |______________________________^ SIM102 381 | # Complete Description element found 382 | in_description = False | = help: Combine `if` statements using `and` ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -220,0 +405,4 @@
if line_count % PROGRESS_LOG_INTERVAL == 0:
console.print(
f"[dim]Processed {line_count:,} lines, {description_count:,} descriptions...[/dim]"
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:408:89: E501 Line too long (99 > 88)
    |
406 |         if line_count % PROGRESS_LOG_INTERVAL == 0:
407 |             console.print(
408 |                 f"[dim]Processed {line_count:,} lines, {description_count:,} descriptions...[/dim]"
    |                                                                                         ^^^^^^^^^^^ E501
409 |             )
    |
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:408:89: E501 Line too long (99 > 88) | 406 | if line_count % PROGRESS_LOG_INTERVAL == 0: 407 | console.print( 408 | f"[dim]Processed {line_count:,} lines, {description_count:,} descriptions...[/dim]" | ^^^^^^^^^^^ E501 409 | ) | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -227,3 +435,3 @@
Args:
lines: Iterator yielding text lines of RDF data
format: RDF serialization format (supported: nt, ntriples, turtle, ttl)
format: RDF serialization format (supported: nt, ntriples, turtle, ttl, xml, rdf, rdfxml)
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:437:89: E501 Line too long (97 > 88)
    |
435 |     Args:
436 |         lines: Iterator yielding text lines of RDF data
437 |         format: RDF serialization format (supported: nt, ntriples, turtle, ttl, xml, rdf, rdfxml)
    |                                                                                         ^^^^^^^^^ E501
438 |         chunk_size: Number of triples per chunk for Turtle parsing
    |
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:437:89: E501 Line too long (97 > 88) | 435 | Args: 436 | lines: Iterator yielding text lines of RDF data 437 | format: RDF serialization format (supported: nt, ntriples, turtle, ttl, xml, rdf, rdfxml) | ^^^^^^^^^ E501 438 | chunk_size: Number of triples per chunk for Turtle parsing | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -252,3 +462,3 @@
def _pick_zip_member(zf: zipfile.ZipFile, *, prefer_exts: tuple[str, ...] = (".ttl", ".nt")) -> str:
def _pick_zip_member(zf: zipfile.ZipFile, *, prefer_exts: tuple[str, ...] = (".ttl", ".nt", ".rdf", ".xml")) -> str:
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:464:89: E501 Line too long (116 > 88)
    |
464 | def _pick_zip_member(zf: zipfile.ZipFile, *, prefer_exts: tuple[str, ...] = (".ttl", ".nt", ".rdf", ".xml")) -> str:
    |                                                                                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E501
465 |     """Select the best RDF member file from a ZIP archive."""
466 |     candidates = []
    |
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:464:89: E501 Line too long (116 > 88) | 464 | def _pick_zip_member(zf: zipfile.ZipFile, *, prefer_exts: tuple[str, ...] = (".ttl", ".nt", ".rdf", ".xml")) -> str: | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E501 465 | """Select the best RDF member file from a ZIP archive.""" 466 | candidates = [] | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -989,0 +1206,4 @@
"ntriples": "ntriples",
}
rdf_format = format_mapping.get(registry_format, registry_format)
console.print(f"[dim]Format auto-detected from registry: {registry_format}{rdf_format}[/dim]")
Member

ruff check reports:.

scripts/rdf_to_hf_incremental.py:1209:89: E501 Line too long (109 > 88)
     |
1207 |             }
1208 |             rdf_format = format_mapping.get(registry_format, registry_format)
1209 |             console.print(f"[dim]Format auto-detected from registry: {registry_format} → {rdf_format}[/dim]")
     |                                                                                         ^^^^^^^^^^^^^^^^^^^^^ E501
1210 |         elif input_type == "file":
1211 |             # Infer from file extension, handling compressed files
     |
`ruff check` reports:. ``` scripts/rdf_to_hf_incremental.py:1209:89: E501 Line too long (109 > 88) | 1207 | } 1208 | rdf_format = format_mapping.get(registry_format, registry_format) 1209 | console.print(f"[dim]Format auto-detected from registry: {registry_format} → {rdf_format}[/dim]") | ^^^^^^^^^^^^^^^^^^^^^ E501 1210 | elif input_type == "file": 1211 | # Infer from file extension, handling compressed files | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -1001,3 +1233,3 @@
}
rdf_format = format_map.get(ext, "turtle")
console.print(f"[dim]Format inferred from extension: {rdf_format}[/dim]")
console.print(f"[dim]Format inferred from extension: {ext}{rdf_format}[/dim]")
Member

ruff check reports:

scripts/rdf_to_hf_incremental.py:1235:89: E501 Line too long (93 > 88)
     |
1233 |             }
1234 |             rdf_format = format_map.get(ext, "turtle")
1235 |             console.print(f"[dim]Format inferred from extension: {ext} → {rdf_format}[/dim]")
     |                                                                                         ^^^^^ E501
1236 |
1237 |     console.print(f"[dim]Rows per shard: {args.rows_per_shard:,}, Record batch size: {args.record_batch_size:,}[/dim]")
     |
`ruff check` reports: ``` scripts/rdf_to_hf_incremental.py:1235:89: E501 Line too long (93 > 88) | 1233 | } 1234 | rdf_format = format_map.get(ext, "turtle") 1235 | console.print(f"[dim]Format inferred from extension: {ext} → {rdf_format}[/dim]") | ^^^^^ E501 1236 | 1237 | console.print(f"[dim]Rows per shard: {args.rows_per_shard:,}, Record batch size: {args.record_batch_size:,}[/dim]") | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -27,0 +41,4 @@
if protocol == "ftp":
yield from stream_ftp_bytes(url, config=config)
elif protocol in ("http", "https"):
yield from stream_http_bytes_impl(url, headers=headers, start_byte=start_byte, config=config)
Member

ruff check reports:

src/streaming/http_stream.py:44:89: E501 Line too long (101 > 88)
   |
42 |         yield from stream_ftp_bytes(url, config=config)
43 |     elif protocol in ("http", "https"):
44 |         yield from stream_http_bytes_impl(url, headers=headers, start_byte=start_byte, config=config)
   |                                                                                         ^^^^^^^^^^^^^ E501
45 |     else:
46 |         raise ValueError(f"Unsupported protocol: {protocol}. Supported: http, https, ftp")
   |
`ruff check` reports: ``` src/streaming/http_stream.py:44:89: E501 Line too long (101 > 88) | 42 | yield from stream_ftp_bytes(url, config=config) 43 | elif protocol in ("http", "https"): 44 | yield from stream_http_bytes_impl(url, headers=headers, start_byte=start_byte, config=config) | ^^^^^^^^^^^^^ E501 45 | else: 46 | raise ValueError(f"Unsupported protocol: {protocol}. Supported: http, https, ftp") | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -27,0 +43,4 @@
elif protocol in ("http", "https"):
yield from stream_http_bytes_impl(url, headers=headers, start_byte=start_byte, config=config)
else:
raise ValueError(f"Unsupported protocol: {protocol}. Supported: http, https, ftp")
Member

ruff check reports:

src/streaming/http_stream.py:46:89: E501 Line too long (90 > 88)
   |
44 |         yield from stream_http_bytes_impl(url, headers=headers, start_byte=start_byte, config=config)
45 |     else:
46 |         raise ValueError(f"Unsupported protocol: {protocol}. Supported: http, https, ftp")
   |                                                                                         ^^ E501
   |
`ruff check` reports: ``` src/streaming/http_stream.py:46:89: E501 Line too long (90 > 88) | 44 | yield from stream_http_bytes_impl(url, headers=headers, start_byte=start_byte, config=config) 45 | else: 46 | raise ValueError(f"Unsupported protocol: {protocol}. Supported: http, https, ftp") | ^^ E501 | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -76,0 +148,4 @@
raise ValueError(f"File not found on FTP server: {url}") from e
elif "timed out" in reason.lower():
# Retry on timeout
if attempt > cfg.max_retries:
Member

I think that this line should be

if attempt >= cfg.max_retries:

Do you agree?

I think that this line should be ``` if attempt >= cfg.max_retries: ``` Do you agree?
brent.edwards marked this conversation as resolved
@@ -76,0 +150,4 @@
# Retry on timeout
if attempt > cfg.max_retries:
raise
sleep_s = min(cfg.backoff_base_s * (2 ** (attempt - 1)), cfg.backoff_max_s)
Member

Because the definition of sleep_s is a little complex and it's repeated three times (lines 153, 159, and 166), I would rather it be in its own function, in case we want to change how to calculate the sleep time.


In other news, ruff check reports:

src/streaming/http_stream.py:153:89: E501 Line too long (91 > 88)
    |
151 |                 if attempt > cfg.max_retries:
152 |                     raise
153 |                 sleep_s = min(cfg.backoff_base_s * (2 ** (attempt - 1)), cfg.backoff_max_s)
    |                                                                                         ^^^ E501
154 |                 time.sleep(sleep_s)
155 |             else:
    |
Because the definition of `sleep_s` is a little complex and it's repeated three times (lines 153, 159, and 166), I would rather it be in its own function, in case we want to change how to calculate the sleep time. --- In other news, `ruff check` reports: ``` src/streaming/http_stream.py:153:89: E501 Line too long (91 > 88) | 151 | if attempt > cfg.max_retries: 152 | raise 153 | sleep_s = min(cfg.backoff_base_s * (2 ** (attempt - 1)), cfg.backoff_max_s) | ^^^ E501 154 | time.sleep(sleep_s) 155 | else: | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -76,0 +156,4 @@
# Other errors: retry if we have attempts left
if attempt > cfg.max_retries:
raise
sleep_s = min(cfg.backoff_base_s * (2 ** (attempt - 1)), cfg.backoff_max_s)
Member

ruff check reports:

src/streaming/http_stream.py:159:89: E501 Line too long (91 > 88)
    |
157 |                 if attempt > cfg.max_retries:
158 |                     raise
159 |                 sleep_s = min(cfg.backoff_base_s * (2 ** (attempt - 1)), cfg.backoff_max_s)
    |                                                                                         ^^^ E501
160 |                 time.sleep(sleep_s)
    |
`ruff check` reports: ``` src/streaming/http_stream.py:159:89: E501 Line too long (91 > 88) | 157 | if attempt > cfg.max_retries: 158 | raise 159 | sleep_s = min(cfg.backoff_base_s * (2 ** (attempt - 1)), cfg.backoff_max_s) | ^^^ E501 160 | time.sleep(sleep_s) | ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
@@ -76,0 +159,4 @@
sleep_s = min(cfg.backoff_base_s * (2 ** (attempt - 1)), cfg.backoff_max_s)
time.sleep(sleep_s)
except Exception as e:
Member

ruff check reports:

src/streaming/http_stream.py:162:29: F841 [*] Local variable `e` is assigned to but never used
    |
160 |                 time.sleep(sleep_s)
161 |
162 |         except Exception as e:
    |                             ^ F841
163 |             # Unexpected errors: retry if we have attempts left
164 |             if attempt > cfg.max_retries:
    |
    = help: Remove assignment to unused variable `e`
`ruff check` reports: ``` src/streaming/http_stream.py:162:29: F841 [*] Local variable `e` is assigned to but never used | 160 | time.sleep(sleep_s) 161 | 162 | except Exception as e: | ^ F841 163 | # Unexpected errors: retry if we have attempts left 164 | if attempt > cfg.max_retries: | = help: Remove assignment to unused variable `e` ```
Author
Member

Fixed !!

Fixed !!
brent.edwards marked this conversation as resolved
brent.edwards approved these changes 2026-01-07 03:48:13 +00:00
brent.edwards left a comment
Member

EXCELLENT work, Aditya! Thank you for fixing everything.

EXCELLENT work, Aditya! Thank you for fixing everything.
khird approved these changes 2026-01-12 15:10:25 +00:00
@@ -220,0 +302,4 @@
return triples
except Exception as e:
logger.error(f"Error parsing Description XML: {e}")
logger.debug(f"XML content (first 500 chars): {xml_content[:500]}")
First-time contributor

Is it possible to get the XML content where the parse error occurred? The specific crash-provoking section may be more relevant than the first 500 of the document, which is likely to be mainly boilerplate anyway.

Is it possible to get the XML content where the parse error occurred? The specific crash-provoking section may be more relevant than the first 500 of the document, which is likely to be mainly boilerplate anyway.
@@ -21,1 +28,4 @@
return min(base_s * (2 ** (attempt - 1)), max_s)
def stream_http_bytes(
First-time contributor

Method name no longer reflects functionality as this is a manager-method supporting both ftp and http now

Method name no longer reflects functionality as this is a manager-method supporting both ftp and http now
aditya force-pushed streaming-xml from 7724415794 to 255ccad4d4 2026-01-13 11:51:12 +00:00 Compare
CoreRasurae approved these changes 2026-01-13 21:55:42 +00:00
CoreRasurae left a comment
First-time contributor

Looks good to me. i am approving it.

Looks good to me. i am approving it.
aditya added 1 commit 2026-01-14 12:44:29 +00:00
aditya merged commit b696d86835 into stream-download-convert-upload-merge-15-refactor-rdf-converters 2026-01-14 14:24:07 +00:00
Sign in to join this conversation.
No Label
4 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cleverdatasets/dataset-uploader#44