From bbaee49336cef1f2c4bdf520851291f6ff91e9f2 Mon Sep 17 00:00:00 2001 From: jniestroy Date: Wed, 20 May 2026 11:09:14 -0400 Subject: [PATCH 1/3] new classes for ai-ready --- fairscape_models/defined_term.py | 24 ++++++++++++++++++++++ fairscape_models/person.py | 34 ++++++++++++++++++++++++++++++++ fairscape_models/rocrate.py | 20 ++++++++++++++----- fairscape_models/schema.py | 1 + 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 fairscape_models/defined_term.py create mode 100644 fairscape_models/person.py diff --git a/fairscape_models/defined_term.py b/fairscape_models/defined_term.py new file mode 100644 index 0000000..ce5445e --- /dev/null +++ b/fairscape_models/defined_term.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import Any, Dict, Optional, Union + +from fairscape_models.fairscape_base import IdentifierValue + + +class DefinedTerm(BaseModel): + """Schema.org DefinedTerm. `@id` is the ontology IRI when one exists + (MeSH, EDAM, Cellosaurus, etc.); `identifier` is set to the same IRI when + `@id` is an ARK fallback so the external identifier is still discoverable. + """ + guid: Optional[str] = Field(default=None, alias="@id") + metadataType: str = Field(default="DefinedTerm", alias="@type") + name: str + termCode: Optional[str] = Field(default=None) + inDefinedTermSet: Optional[Union[str, IdentifierValue, Dict[str, Any]]] = Field( + default=None, + description="The ontology / scheme this term belongs to. Either a reference stub ({\"@id\": \"...\"}) or an inline scheme dict.", + ) + identifier: Optional[str] = Field( + default=None, + description="External IRI for this term (e.g. MeSH / EDAM / Cellosaurus URI). Set when @id is an ARK fallback.", + ) + model_config = ConfigDict(extra="allow", populate_by_name=True) diff --git a/fairscape_models/person.py b/fairscape_models/person.py new file mode 100644 index 0000000..1484977 --- /dev/null +++ b/fairscape_models/person.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional, Union + +from fairscape_models.fairscape_base import IdentifierValue + + +class Organization(BaseModel): + """Schema.org Organization. `identifier` is the ROR URI when available.""" + guid: Optional[str] = Field(default=None, alias="@id") + metadataType: str = Field(default="Organization", alias="@type") + name: str + identifier: Optional[str] = Field( + default=None, + description="Persistent identifier for the organization, typically a ROR URI (https://ror.org/...)." + ) + url: Optional[str] = Field(default=None) + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class Person(BaseModel): + """Schema.org Person. `identifier` is the ORCID URI when available.""" + guid: Optional[str] = Field(default=None, alias="@id") + metadataType: str = Field(default="Person", alias="@type") + name: str + identifier: Optional[str] = Field( + default=None, + description="Persistent identifier for the person, typically an ORCID URI (https://orcid.org/...)." + ) + email: Optional[str] = Field(default=None) + affiliation: Optional[Union[str, IdentifierValue, Organization]] = Field( + default=None, + description="Affiliation as a plain string, a reference stub to an Organization in @graph, or an inline Organization.", + ) + model_config = ConfigDict(extra="allow", populate_by_name=True) diff --git a/fairscape_models/rocrate.py b/fairscape_models/rocrate.py index 7a604f9..08cd563 100644 --- a/fairscape_models/rocrate.py +++ b/fairscape_models/rocrate.py @@ -18,6 +18,8 @@ from fairscape_models.sample import Sample from fairscape_models.activity import Activity from fairscape_models.digital_object import DigitalObject +from fairscape_models.person import Person, Organization +from fairscape_models.defined_term import DefinedTerm from fairscape_models._version import __version__ class ContactPoint(BaseModel): @@ -129,10 +131,10 @@ class ROCrateMetadataElem(BaseModel): hasPart: List[IdentifierValue] = Field(description="Dataset, Software, Computation, and other entities that are part of this RO-Crate, referenced by identifier.") # Attribution — D4D_Motivation: Creator, FundingMechanism - author: Union[str, List[str]] = Field(description="Who created the dataset (e.g. which team, research group) and on behalf of which entity (e.g. company, institution, organization).") + author: Union[str, IdentifierValue, Person, List[Union[str, IdentifierValue, Person]]] = Field(description="Who created the dataset. Accepts a plain name string, a Person object (with optional ORCID identifier), a {\"@id\": \"...\"} reference stub to a Person in @graph, or a list of any of those. Plain strings remain valid for backwards compatibility.") publisher: Optional[str] = Field(default=None, description="Organization or person responsible for publishing or distributing the dataset.") - principalInvestigator: Optional[str] = Field(default=None, description="A key individual (Principal Investigator) responsible for or overseeing dataset creation.") - funder: Optional[str] = Field(default=None, description="Who funded the creation of the dataset? Include grant names and numbers where applicable.") + principalInvestigator: Optional[Union[str, IdentifierValue, Person]] = Field(default=None, description="A key individual (Principal Investigator) responsible for or overseeing dataset creation. Accepts a plain name string, a reference stub ({\"@id\": \"...\"}) to a Person in @graph, or an inline Person object.") + funder: Optional[Union[str, IdentifierValue, Person]] = Field(default=None, description="Who funded the creation of the dataset? Include grant names and numbers where applicable. Accepts a plain name string, a reference stub, or an inline Person/Organization object.") contactEmail: Optional[str] = Field(default=None, description="Email address for questions or correspondence about the dataset.") citation: Optional[str] = Field(default=None, description="Preferred citation string for this dataset.") associatedPublication: Optional[Union[str, List[str]]] = Field(default=None, description="Publication(s) associated with or describing this dataset.") @@ -158,7 +160,9 @@ class ROCrateMetadataElem(BaseModel): fdaRegulated: Optional[bool] = Field(default=None, description="Whether this dataset is subject to FDA regulations (e.g. clinical trial data, medical device data).") deidentified: Optional[bool] = Field(default=None, description="Whether the dataset has been de-identified to remove or obscure personally identifiable information.") humanSubjectResearch: Optional[str] = Field(default=None, description="Does this dataset involve human subjects? Indicate Yes/No and describe the nature of human subjects involvement.") - dataGovernanceCommittee: Optional[str] = Field(default=None, description="Name or contact for the data governance committee responsible for oversight, access control, and policy enforcement for this dataset.") + dataGovernanceCommittee: Optional[Union[str, IdentifierValue, Person]] = Field(default=None, description="Name or contact for the data governance committee responsible for oversight, access control, and policy enforcement for this dataset. Accepts a plain name string, a reference stub, or an inline Person.") + ethicalReviewContacts: Optional[List[Union[IdentifierValue, Person]]] = Field(default=None, description="People to contact about the ethical review process for this dataset. Each entry is a Person in @graph (by-reference stub) or an inline Person.") + about: Optional[List[Union[IdentifierValue, DefinedTerm, str]]] = Field(default=None, description="Subjects this dataset is about, ideally as ontology-grounded DefinedTerm entries (MeSH, EDAM, Cellosaurus, etc.) referenced from @graph. Supports AI-Ready Rubric 2.a (Semantics).") # Checksums md5: Optional[str] = Field(default=None, description="MD5 checksum of the digital object content") @@ -353,6 +357,9 @@ class ROCrateV1_2(BaseModel): Activity, Annotation, DigitalObject, + Person, + Organization, + DefinedTerm, GenericMetadataElem ]] = Field(alias="@graph") @@ -374,7 +381,10 @@ def validate_metadata_graph(cls, values: Dict[str, Any]) -> Dict[str, Any]: "Schema": Schema, "BioChemEntity": BioChemEntity, "MedicalCondition": MedicalCondition, - "ROCrate": ROCrateMetadataElem + "ROCrate": ROCrateMetadataElem, + "Person": Person, + "Organization": Organization, + "DefinedTerm": DefinedTerm, } def normalize_type(type_str): diff --git a/fairscape_models/schema.py b/fairscape_models/schema.py index 27cfbbf..7c1b414 100644 --- a/fairscape_models/schema.py +++ b/fairscape_models/schema.py @@ -69,6 +69,7 @@ class Schema(FairscapeEVIBaseModel): alias="@context" ) metadataType: str = Field(alias="@type", default= "evi:Schema") + conformsTo: Optional[Union[List[IdentifierValue],IdentifierValue]] = Field(default={"@id": "https://json-schema.org/draft/2020-12/schema"}) properties: Dict[str, Property] schemaType: Optional[str] = Field(default="object", alias="type") additionalProperties: Optional[bool] = Field(default=True) From 35f8c1df0b2aba6fee031b951866b15c12b5e8dc Mon Sep 17 00:00:00 2001 From: jniestroy Date: Wed, 27 May 2026 13:11:37 -0400 Subject: [PATCH 2/3] summary stats plus allow lists --- .../conversion/mapping/FairscapeDatasheet.py | 6 +- .../conversion/models/FairscapeDatasheet.py | 26 ++-- fairscape_models/dataset.py | 132 +++++++++++++++++- fairscape_models/rocrate.py | 19 +-- 4 files changed, 154 insertions(+), 29 deletions(-) diff --git a/fairscape_models/conversion/mapping/FairscapeDatasheet.py b/fairscape_models/conversion/mapping/FairscapeDatasheet.py index abf3c2e..8fe6f35 100644 --- a/fairscape_models/conversion/mapping/FairscapeDatasheet.py +++ b/fairscape_models/conversion/mapping/FairscapeDatasheet.py @@ -123,10 +123,10 @@ def _extract_id(value: Any) -> Optional[str]: USECASES_MAPPING = { - "intended_use": {"source_key": "rai:dataUseCases"}, - "limitations": {"source_key": "rai:dataLimitations"}, + "intended_use": {"source_key": "rai:dataUseCases", "parser": _list_to_str}, + "limitations": {"source_key": "rai:dataLimitations", "parser": _list_to_str}, "prohibited_uses": {"source_key": "prohibitedUses", "fallback_source_key": "additionalProperty", "fallback_parser": from_additional_property("Prohibited Uses")}, - "potential_sources_of_bias": {"source_key": "rai:dataBiases"}, + "potential_sources_of_bias": {"source_key": "rai:dataBiases", "parser": _list_to_str}, "maintenance_plan": {"source_key": "rai:dataReleaseMaintenancePlan"}, # Additional RAI fields diff --git a/fairscape_models/conversion/models/FairscapeDatasheet.py b/fairscape_models/conversion/models/FairscapeDatasheet.py index a87d7bc..d5a72d0 100644 --- a/fairscape_models/conversion/models/FairscapeDatasheet.py +++ b/fairscape_models/conversion/models/FairscapeDatasheet.py @@ -57,19 +57,19 @@ class OverviewSection(BaseModel): class UseCasesSection(BaseModel): """Datasheet section for describing dataset use cases, limitations, and biases.""" - intended_use: Optional[str] = Field( + intended_use: Optional[Union[str, List[str]]] = Field( default=None, description="Recommended dataset uses (e.g., training, validation)" ) - limitations: Optional[str] = Field( + limitations: Optional[Union[str, List[str]]] = Field( default=None, description="Known limitations and non-recommended uses" ) prohibited_uses: Optional[str] = Field( default=None, description="Explicitly prohibited uses (subset of limitations)" ) - potential_sources_of_bias: Optional[str] = Field( + potential_sources_of_bias: Optional[Union[str, List[str]]] = Field( default=None, description="Description of known biases in the dataset" ) - maintenance_plan: Optional[str] = Field( + maintenance_plan: Optional[Union[str, List[str]]] = Field( default=None, description="Versioning, maintainers, and deprecation policies" ) @@ -77,7 +77,7 @@ class UseCasesSection(BaseModel): data_collection: Optional[str] = Field( default=None, description="Description of data collection methodology" ) - data_collection_type: Optional[str] = Field( + data_collection_type: Optional[Union[str, List[str]]] = Field( default=None, description="Type of data collection" ) data_collection_missing_data: Optional[str] = Field( @@ -86,28 +86,28 @@ class UseCasesSection(BaseModel): data_collection_raw_data: Optional[str] = Field( default=None, description="Description of raw data from collection" ) - data_collection_timeframe: Optional[str] = Field( + data_collection_timeframe: Optional[Union[str, List[str]]] = Field( default=None, description="Timeframe of data collection" ) data_imputation_protocol: Optional[str] = Field( default=None, description="Protocol used for data imputation" ) - data_manipulation_protocol: Optional[str] = Field( + data_manipulation_protocol: Optional[Union[str, List[str]]] = Field( default=None, description="Protocol used for data manipulation" ) - data_preprocessing_protocol: Optional[str] = Field( + data_preprocessing_protocol: Optional[Union[str, List[str]]] = Field( default=None, description="Protocol used for data preprocessing" ) data_annotation_protocol: Optional[str] = Field( default=None, description="Protocol used for data annotation" ) - data_annotation_platform: Optional[str] = Field( + data_annotation_platform: Optional[Union[str, List[str]]] = Field( default=None, description="Platform used for data annotation" ) - data_annotation_analysis: Optional[str] = Field( + data_annotation_analysis: Optional[Union[str, List[str]]] = Field( default=None, description="Analysis of data annotations" ) - personal_sensitive_information: Optional[str] = Field( + personal_sensitive_information: Optional[Union[str, List[str]]] = Field( default=None, description="Description of personal/sensitive information" ) data_social_impact: Optional[str] = Field( @@ -116,10 +116,10 @@ class UseCasesSection(BaseModel): annotations_per_item: Optional[str] = Field( default=None, description="Number of annotations per item" ) - annotator_demographics: Optional[str] = Field( + annotator_demographics: Optional[Union[str, List[str]]] = Field( default=None, description="Demographics of annotators" ) - machine_annotation_tools: Optional[str] = Field( + machine_annotation_tools: Optional[Union[str, List[str]]] = Field( default=None, description="Machine tools used for annotation" ) diff --git a/fairscape_models/dataset.py b/fairscape_models/dataset.py index 4709887..682790a 100644 --- a/fairscape_models/dataset.py +++ b/fairscape_models/dataset.py @@ -1,11 +1,37 @@ +import csv +from pathlib import Path from pydantic import BaseModel, Field, ConfigDict, AliasChoices, model_validator -from typing import Optional, List, Union +from typing import Optional, List, Union, Tuple from enum import Enum from fairscape_models.fairscape_base import IdentifierValue, DATASET_TYPE from fairscape_models.digital_object import DigitalObject +TABULAR_FORMATS = {"csv", "tsv", "text/csv", "text/tab-separated-values"} +TABULAR_EXTENSIONS = {".csv", ".tsv"} + + +def _count_csv(path: Path, delimiter: str) -> Tuple[int, int]: + """Stream a csv/tsv counting data rows and columns (header excluded from rowCount).""" + with path.open("r", newline="", encoding="utf-8", errors="replace") as f: + reader = csv.reader(f, delimiter=delimiter) + try: + header = next(reader) + except StopIteration: + return 0, 0 + cols = len(header) + rows = sum(1 for _ in reader) + return rows, cols + + +def _human_size(n: int) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}" + n /= 1024 + + class SplitType(str, Enum): """Croissant-aligned split type semantics. @@ -61,6 +87,110 @@ class Dataset(DigitalObject): derivedFrom: Optional[List[IdentifierValue]] = Field(default=[]) splits: Optional[List[Split]] = Field(default=None) + # statistics support AI-Ready rubric 2.b Statistics). + contentSize: Optional[str] = Field(default=None, description="Total size of the dataset content (e.g. '2.4 GB', '150 MB').") + rowCount: Optional[int] = Field(default=None, description="Number of rows / records for tabular datasets.") + columnCount: Optional[int] = Field(default=None, description="Number of columns / fields for tabular datasets.") + sampleSize: Optional[int] = Field(default=None, description="Number of samples represented by the dataset (often == rowCount for tabular data, but may differ).") + hasSummaryStatistics: Optional[Union[str, IdentifierValue]] = Field(default=None, description="Reference to a summary statistics entity describing distributions, counts, and key statistics for this dataset.") + + def add_summary_stats( + self, + file_path: Optional[Union[str, Path]] = None, + crate_root: Optional[Union[str, Path]] = None, + ) -> "Dataset": + """Compute row/column counts for a tabular Dataset and produce a linked stats child. + + Reads ``self.contentUrl`` (or an explicit ``file_path`` override) as csv/tsv + using the stdlib ``csv`` module — no pandas / no extra deps. Populates + ``rowCount``, ``columnCount``, ``contentSize``, ``sampleSize`` on ``self`` + and on a newly-constructed child ``Dataset``. Sets ``self.hasSummaryStatistics`` + to the child's ``@id`` and returns the child so the caller can append it + to the RO-Crate ``@graph``. + + Remote URLs and non-csv/tsv formats are out of scope here — use the + ``fairscape augment summary-stats`` CLI command for parquet / http(s) / + per-column statistics. + """ + resolved = self._resolve_tabular_path(file_path, crate_root) + delimiter = "\t" if resolved.suffix.lower() == ".tsv" or "tab" in (self.fileFormat or "").lower() else "," + rows, cols = _count_csv(resolved, delimiter) + size_bytes = resolved.stat().st_size + size_str = _human_size(size_bytes) + + self.rowCount = rows + self.columnCount = cols + self.contentSize = size_str + if self.sampleSize is None: + self.sampleSize = rows + + stats_guid = f"{self.guid.rstrip('/')}/summary-stats" + stats = Dataset( + guid=stats_guid, + name=f"{self.name} — Summary Statistics", + author=self.author, + description=f"Row and column counts for {self.name} ({self.guid}), generated from the source tabular file.", + datePublished=self.datePublished, + keywords=(self.keywords or []) + ["summary-statistics"], + fileFormat="application/json", + rowCount=rows, + columnCount=cols, + contentSize=size_str, + sampleSize=rows, + derivedFrom=[IdentifierValue(**{"@id": self.guid})], + ) + self.hasSummaryStatistics = IdentifierValue(**{"@id": stats_guid}) + return stats + + def _resolve_tabular_path( + self, + file_path: Optional[Union[str, Path]], + crate_root: Optional[Union[str, Path]], + ) -> Path: + if file_path is not None: + p = Path(file_path) + if not p.exists(): + raise FileNotFoundError(f"file_path does not exist: {p}") + self._require_tabular(p) + return p + + if not self.contentUrl: + raise ValueError(f"Dataset {self.guid} has no contentUrl; pass file_path explicitly.") + + url = self.contentUrl if isinstance(self.contentUrl, str) else self.contentUrl[0] + if url.startswith(("http://", "https://")): + raise NotImplementedError( + "Remote contentUrl is not supported by add_summary_stats; use the " + "`fairscape augment summary-stats` CLI command, or pass file_path." + ) + + if url.startswith("file://"): + rel = url[len("file://"):].lstrip("/") + if crate_root is None: + raise ValueError( + f"contentUrl {url!r} is crate-relative; pass crate_root to resolve." + ) + p = Path(crate_root) / rel + else: + p = Path(url) + if not p.is_absolute() and crate_root is not None: + p = Path(crate_root) / p + + if not p.exists(): + raise FileNotFoundError(f"Resolved contentUrl does not exist: {p}") + self._require_tabular(p) + return p + + def _require_tabular(self, path: Path) -> None: + fmt = (self.fileFormat or "").lower() + ext = path.suffix.lower() + if fmt in TABULAR_FORMATS or ext in TABULAR_EXTENSIONS: + return + raise ValueError( + f"Dataset {self.guid} is not csv/tsv (fileFormat={self.fileFormat!r}, " + f"extension={ext!r}); use the CLI augment summary-stats command for richer formats." + ) + @model_validator(mode='after') def populate_prov_fields(self): """Auto-populate PROV-O fields from EVI fields""" diff --git a/fairscape_models/rocrate.py b/fairscape_models/rocrate.py index 08cd563..720db30 100644 --- a/fairscape_models/rocrate.py +++ b/fairscape_models/rocrate.py @@ -161,7 +161,6 @@ class ROCrateMetadataElem(BaseModel): deidentified: Optional[bool] = Field(default=None, description="Whether the dataset has been de-identified to remove or obscure personally identifiable information.") humanSubjectResearch: Optional[str] = Field(default=None, description="Does this dataset involve human subjects? Indicate Yes/No and describe the nature of human subjects involvement.") dataGovernanceCommittee: Optional[Union[str, IdentifierValue, Person]] = Field(default=None, description="Name or contact for the data governance committee responsible for oversight, access control, and policy enforcement for this dataset. Accepts a plain name string, a reference stub, or an inline Person.") - ethicalReviewContacts: Optional[List[Union[IdentifierValue, Person]]] = Field(default=None, description="People to contact about the ethical review process for this dataset. Each entry is a Person in @graph (by-reference stub) or an inline Person.") about: Optional[List[Union[IdentifierValue, DefinedTerm, str]]] = Field(default=None, description="Subjects this dataset is about, ideally as ontology-grounded DefinedTerm entries (MeSH, EDAM, Cellosaurus, etc.) referenced from @graph. Supports AI-Ready Rubric 2.a (Semantics).") # Checksums @@ -192,7 +191,7 @@ class ROCrateMetadataElem(BaseModel): alias="rai:dataCollection", default=None, description="What mechanisms or procedures were used to collect the data (e.g. hardware sensors, manual curation, software APIs)? Also covers how these mechanisms were validated. (rai:dataCollection)" ) - rai_data_collection_type: Optional[List[str]] = Field( + rai_data_collection_type: Optional[Union[str, List[str]]] = Field( alias="rai:dataCollectionType", default=None, description="Data collection type(s). Recommended values: Surveys, Secondary Data Analysis, Physical Data Collection, Direct Measurement, Document Analysis, Manual Human Curator, Software Collection, Experiments, Web Scraping, Web API, Focus Groups, Self-Reporting, Customer Feedback Data, User-Generated Content Data, Passive Data Collection, Others. (rai:dataCollectionType)" ) @@ -204,7 +203,7 @@ class ROCrateMetadataElem(BaseModel): alias="rai:dataCollectionRawData", default=None, description="Description of raw data sources before preprocessing, cleaning, or labeling. Documents where the original data comes from and how it can be accessed. (rai:dataCollectionRawData)" ) - rai_data_collection_timeframe: Optional[List[str]] = Field( + rai_data_collection_timeframe: Optional[Union[str, List[str]]] = Field( alias="rai:dataCollectionTimeframe", default=None, description="Over what timeframe was the data collected, and does this timeframe match the creation timeframe of the underlying data? Provide start and end dates where possible. (rai:dataCollectionTimeframe)" ) @@ -216,7 +215,7 @@ class ROCrateMetadataElem(BaseModel): alias="rai:dataManipulationProtocol", default=None, description="Was any cleaning of the data done (e.g. removal of instances, processing of missing values, deduplication, filtering)? If so, describe the cleaning procedures applied. (rai:dataManipulationProtocol)" ) - rai_data_preprocessing_protocol: Optional[List[str]] = Field( + rai_data_preprocessing_protocol: Optional[Union[str, List[str]]] = Field( alias="rai:dataPreprocessingProtocol", default=None, description="Was any preprocessing of the data done (e.g. discretization or bucketing, tokenization, feature extraction, normalization)? Describe the steps required to bring collected data to a state that can be processed by an ML model or algorithm. (rai:dataPreprocessingProtocol)" ) @@ -224,15 +223,15 @@ class ROCrateMetadataElem(BaseModel): alias="rai:dataAnnotationProtocol", default=None, description="Annotation methodology, tasks, and protocols followed during labeling. Includes annotation guidelines, quality control procedures, task definitions, workforce type, annotation characteristics, and label distributions. (rai:dataAnnotationProtocol)" ) - rai_data_annotation_platform: Optional[List[str]] = Field( + rai_data_annotation_platform: Optional[Union[str, List[str]]] = Field( alias="rai:dataAnnotationPlatform", default=None, description="Platform or tool used for annotation (e.g. Label Studio, Prodigy, Amazon Mechanical Turk, custom annotation tool). (rai:dataAnnotationPlatform)" ) - rai_data_annotation_analysis: Optional[List[str]] = Field( + rai_data_annotation_analysis: Optional[Union[str, List[str]]] = Field( alias="rai:dataAnnotationAnalysis", default=None, description="Analysis of annotation quality, inter-annotator agreement metrics (e.g. Cohen's kappa, Fleiss' kappa), and systematic patterns in disagreements between annotators of different socio-demographic groups. Covers how final dataset labels relate to individual annotator responses. (rai:dataAnnotationAnalysis)" ) - rai_personal_sensitive_information: Optional[List[str]] = Field( + rai_personal_sensitive_information: Optional[Union[str, List[str]]] = Field( alias="rai:personalSensitiveInformation", default=None, description="Does the dataset contain data that might be considered sensitive (e.g. race, sexual orientation, religion, biometrics)? List sensitive attribute types present: Gender, Socio-economic status, Geography, Language, Age, Culture, Experience or Seniority, others. (rai:personalSensitiveInformation)" ) @@ -244,11 +243,7 @@ class ROCrateMetadataElem(BaseModel): alias="rai:annotationsPerItem", default=None, description="Number of annotations collected per data item. Multiple annotations per item enable calculation of inter-annotator agreement. (rai:annotationsPerItem)" ) - rai_annotator_demographics: Optional[List[str]] = Field( - alias="rai:annotatorDemographics", default=None, - description="Demographic information about annotators, if available and relevant (e.g. geographic location, language background, expertise level, age group, gender). (rai:annotatorDemographics)" - ) - rai_machine_annotation_tools: Optional[List[str]] = Field( + rai_machine_annotation_tools: Optional[Union[str, List[str]]] = Field( alias="rai:machineAnnotationTools", default=None, description="Automated or machine-learning-based annotation tools used in dataset creation, including NLP pipelines, computer vision models, or other automated labeling systems. Format each entry as 'ToolName version' (e.g. 'spaCy 3.5.0'). (rai:machineAnnotationTools)" ) From 9a1acb073eaf2032668f4b6a8a3f127ab00ef3e9 Mon Sep 17 00:00:00 2001 From: jniestroy Date: Wed, 27 May 2026 13:16:03 -0400 Subject: [PATCH 3/3] tests --- tests/test_article.py | 52 +++++++++++ tests/test_claim.py | 50 +++++++++++ tests/test_container.py | 48 +++++++++++ tests/test_dataset.py | 187 +++++++++++++++++++++++++++++++++++++++- tests/test_service.py | 48 +++++++++++ 5 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 tests/test_article.py create mode 100644 tests/test_claim.py create mode 100644 tests/test_container.py create mode 100644 tests/test_service.py diff --git a/tests/test_article.py b/tests/test_article.py new file mode 100644 index 0000000..4cfc4a0 --- /dev/null +++ b/tests/test_article.py @@ -0,0 +1,52 @@ +import pytest +from pydantic import ValidationError +from fairscape_models.article import Article +from fairscape_models.fairscape_base import ARTICLE_TYPE, IdentifierValue + + +@pytest.fixture +def article_minimal_data(): + return { + "@id": "ark:59852/test-article", + "name": "Test Article", + "author": "Test Author", + "description": "An article about testing things in great detail.", + } + + +def test_article_instantiation(article_minimal_data): + article = Article.model_validate(article_minimal_data) + assert article.guid == article_minimal_data["@id"] + assert article.name == article_minimal_data["name"] + assert article.additionalType == ARTICLE_TYPE + assert article.metadataType == ["prov:Entity", "https://w3id.org/EVI#Article"] + assert article.wasAttributedTo == ["Test Author"] + + +def test_article_multiple_authors(article_minimal_data): + article_minimal_data["author"] = ["Author 1", "Author 2"] + article = Article.model_validate(article_minimal_data) + assert article.wasAttributedTo == ["Author 1", "Author 2"] + + +def test_article_empty_author(article_minimal_data): + article_minimal_data["author"] = [] + article = Article.model_validate(article_minimal_data) + assert article.wasAttributedTo == [] + + +def test_article_with_optional_fields(article_minimal_data): + article_minimal_data["datePublished"] = "2024-01-01" + article_minimal_data["keywords"] = ["k1", "k2"] + article_minimal_data["hasPart"] = [{"@id": "ark:59852/claim-1"}] + article = Article.model_validate(article_minimal_data) + assert article.datePublished == "2024-01-01" + assert article.keywords == ["k1", "k2"] + assert len(article.hasPart) == 1 + assert isinstance(article.hasPart[0], IdentifierValue) + + +def test_article_missing_required_field(article_minimal_data): + del article_minimal_data["name"] + with pytest.raises(ValidationError): + Article.model_validate(article_minimal_data) diff --git a/tests/test_claim.py b/tests/test_claim.py new file mode 100644 index 0000000..5884c1c --- /dev/null +++ b/tests/test_claim.py @@ -0,0 +1,50 @@ +import pytest +from pydantic import ValidationError +from fairscape_models.claim import Claim +from fairscape_models.fairscape_base import CLAIM_TYPE, IdentifierValue + + +@pytest.fixture +def claim_minimal_data(): + return { + "@id": "ark:59852/test-claim", + "name": "Test Claim", + "author": "Test Author", + "description": "A claim describing something important.", + "evi:state": "This is the textual representation of the claim.", + } + + +def test_claim_instantiation(claim_minimal_data): + claim = Claim.model_validate(claim_minimal_data) + assert claim.guid == claim_minimal_data["@id"] + assert claim.claimText == claim_minimal_data["evi:state"] + assert claim.additionalType == CLAIM_TYPE + assert claim.metadataType == ["prov:Entity", "https://w3id.org/EVI#Claim"] + assert claim.wasAttributedTo == ["Test Author"] + + +def test_claim_multiple_authors(claim_minimal_data): + claim_minimal_data["author"] = ["Author 1", "Author 2"] + claim = Claim.model_validate(claim_minimal_data) + assert claim.wasAttributedTo == ["Author 1", "Author 2"] + + +def test_claim_empty_author(claim_minimal_data): + claim_minimal_data["author"] = [] + claim = Claim.model_validate(claim_minimal_data) + assert claim.wasAttributedTo == [] + + +def test_claim_with_supported_by(claim_minimal_data): + claim_minimal_data["supportedBy"] = [{"@id": "ark:59852/article-1"}] + claim = Claim.model_validate(claim_minimal_data) + assert len(claim.supportedBy) == 1 + assert isinstance(claim.supportedBy[0], IdentifierValue) + assert claim.supportedBy[0].guid == "ark:59852/article-1" + + +def test_claim_missing_required_field(claim_minimal_data): + del claim_minimal_data["evi:state"] + with pytest.raises(ValidationError): + Claim.model_validate(claim_minimal_data) diff --git a/tests/test_container.py b/tests/test_container.py new file mode 100644 index 0000000..e5891ee --- /dev/null +++ b/tests/test_container.py @@ -0,0 +1,48 @@ +import pytest +from pydantic import ValidationError +from fairscape_models.container import Container +from fairscape_models.fairscape_base import CONTAINER_TYPE, IdentifierValue + + +@pytest.fixture +def container_minimal_data(): + return { + "@id": "ark:59852/test-container", + "name": "Test Container", + "author": "Test Author", + "description": "A container of digital objects for testing purposes.", + } + + +def test_container_instantiation(container_minimal_data): + container = Container.model_validate(container_minimal_data) + assert container.guid == container_minimal_data["@id"] + assert container.additionalType == CONTAINER_TYPE + assert container.metadataType == ["prov:Entity", "https://w3id.org/EVI#Container"] + assert container.wasAttributedTo == ["Test Author"] + + +def test_container_multiple_authors(container_minimal_data): + container_minimal_data["author"] = ["Author 1", "Author 2"] + container = Container.model_validate(container_minimal_data) + assert container.wasAttributedTo == ["Author 1", "Author 2"] + + +def test_container_empty_author(container_minimal_data): + container_minimal_data["author"] = [] + container = Container.model_validate(container_minimal_data) + assert container.wasAttributedTo == [] + + +def test_container_with_packages(container_minimal_data): + container_minimal_data["evi:packages"] = [{"@id": "ark:59852/pkg-1"}] + container = Container.model_validate(container_minimal_data) + assert len(container.packages) == 1 + assert isinstance(container.packages[0], IdentifierValue) + assert container.packages[0].guid == "ark:59852/pkg-1" + + +def test_container_missing_required_field(container_minimal_data): + del container_minimal_data["name"] + with pytest.raises(ValidationError): + Container.model_validate(container_minimal_data) diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 511e912..49826ec 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -1,6 +1,13 @@ import pytest +from pathlib import Path from pydantic import ValidationError -from fairscape_models.dataset import Dataset +from fairscape_models.dataset import ( + Dataset, + Split, + SplitType, + _count_csv, + _human_size, +) from fairscape_models.fairscape_base import IdentifierValue def test_dataset_instantiation(dataset_minimal_data): @@ -126,4 +133,180 @@ def test_dataset_custom_type_overwritten_by_validator(dataset_minimal_data): """A caller-supplied @type is always replaced by the model validator.""" dataset_minimal_data["@type"] = "CustomType" dataset = Dataset.model_validate(dataset_minimal_data) - assert dataset.metadataType == ["prov:Entity", "https://w3id.org/EVI#Dataset"] \ No newline at end of file + assert dataset.metadataType == ["prov:Entity", "https://w3id.org/EVI#Dataset"] + + +def test_count_csv_with_data(tmp_path): + p = tmp_path / "data.csv" + p.write_text("a,b,c\n1,2,3\n4,5,6\n") + rows, cols = _count_csv(p, ",") + assert rows == 2 + assert cols == 3 + + +def test_count_csv_empty_file(tmp_path): + p = tmp_path / "empty.csv" + p.write_text("") + rows, cols = _count_csv(p, ",") + assert rows == 0 + assert cols == 0 + + +def test_count_csv_tsv(tmp_path): + p = tmp_path / "data.tsv" + p.write_text("a\tb\n1\t2\n") + rows, cols = _count_csv(p, "\t") + assert rows == 1 + assert cols == 2 + + +def test_human_size_bytes(): + assert _human_size(500) == "500 B" + + +def test_human_size_kb(): + assert _human_size(2048) == "2.0 KB" + + +def test_human_size_mb(): + assert _human_size(5 * 1024 * 1024) == "5.0 MB" + + +def test_human_size_terabytes_clamps(): + # Forces the `unit == "TB"` early-return branch + big = 5 * 1024 ** 4 + assert _human_size(big).endswith("TB") + + +def test_split_model_defaults(): + split = Split(name="train") + assert split.name == "train" + assert split.splitType is None + assert split.isSample is None + + +def test_split_model_with_fields(): + split = Split( + name="train", + description="training split", + splitType=SplitType.TRAIN, + query="SELECT *", + queryType="sql", + sourceDatasets=[IdentifierValue(**{"@id": "ark:59852/src"})], + isSample=True, + isRandom=False, + samplingStrategy="stratified", + ) + assert split.splitType == SplitType.TRAIN + assert split.sourceDatasets[0].guid == "ark:59852/src" + + +def _make_dataset(tmp_path, content_url=None, file_format="text/csv"): + return Dataset.model_validate({ + "@id": "ark:59852/tabular", + "name": "Tabular", + "author": "A", + "datePublished": "2024-01-01", + "description": "A tabular dataset for testing summary stats.", + "keywords": ["t"], + "format": file_format, + "contentUrl": content_url, + }) + + +def test_add_summary_stats_with_explicit_file_path(tmp_path): + csv_path = tmp_path / "data.csv" + csv_path.write_text("a,b\n1,2\n3,4\n5,6\n") + ds = _make_dataset(tmp_path) + stats = ds.add_summary_stats(file_path=csv_path) + assert ds.rowCount == 3 + assert ds.columnCount == 2 + assert ds.sampleSize == 3 + assert ds.contentSize.endswith("B") + assert isinstance(ds.hasSummaryStatistics, IdentifierValue) + assert stats.guid == f"{ds.guid}/summary-stats" + assert stats.rowCount == 3 + assert stats.fileFormat == "application/json" + assert "summary-statistics" in stats.keywords + + +def test_add_summary_stats_preserves_sample_size(tmp_path): + csv_path = tmp_path / "data.csv" + csv_path.write_text("a,b\n1,2\n") + ds = _make_dataset(tmp_path) + ds.sampleSize = 42 + ds.add_summary_stats(file_path=csv_path) + assert ds.sampleSize == 42 + + +def test_add_summary_stats_tsv_via_file_format(tmp_path): + # Use .tsv extension so _require_tabular passes, and a tab-formatted file + tsv_path = tmp_path / "data.tsv" + tsv_path.write_text("a\tb\n1\t2\n") + ds = _make_dataset(tmp_path, file_format="tab-separated-values") + ds.add_summary_stats(file_path=tsv_path) + assert ds.columnCount == 2 + assert ds.rowCount == 1 + + +def test_add_summary_stats_file_path_missing(tmp_path): + ds = _make_dataset(tmp_path) + with pytest.raises(FileNotFoundError): + ds.add_summary_stats(file_path=tmp_path / "nope.csv") + + +def test_add_summary_stats_no_content_url(tmp_path): + ds = _make_dataset(tmp_path) + with pytest.raises(ValueError, match="no contentUrl"): + ds.add_summary_stats() + + +def test_add_summary_stats_remote_url_not_supported(tmp_path): + ds = _make_dataset(tmp_path, content_url="https://example.org/data.csv") + with pytest.raises(NotImplementedError): + ds.add_summary_stats() + + +def test_add_summary_stats_file_url_without_crate_root(tmp_path): + ds = _make_dataset(tmp_path, content_url="file:///data.csv") + with pytest.raises(ValueError, match="crate-relative"): + ds.add_summary_stats() + + +def test_add_summary_stats_file_url_with_crate_root(tmp_path): + csv_path = tmp_path / "inner.csv" + csv_path.write_text("a,b\n1,2\n") + ds = _make_dataset(tmp_path, content_url="file:///inner.csv") + ds.add_summary_stats(crate_root=tmp_path) + assert ds.rowCount == 1 + assert ds.columnCount == 2 + + +def test_add_summary_stats_content_url_list(tmp_path): + csv_path = tmp_path / "data.csv" + csv_path.write_text("a\n1\n") + ds = _make_dataset(tmp_path, content_url=[str(csv_path)]) + ds.add_summary_stats() + assert ds.rowCount == 1 + + +def test_add_summary_stats_relative_content_url_with_crate_root(tmp_path): + csv_path = tmp_path / "rel.csv" + csv_path.write_text("a,b\n1,2\n") + ds = _make_dataset(tmp_path, content_url="rel.csv") + ds.add_summary_stats(crate_root=tmp_path) + assert ds.rowCount == 1 + + +def test_add_summary_stats_resolved_path_missing(tmp_path): + ds = _make_dataset(tmp_path, content_url=str(tmp_path / "missing.csv")) + with pytest.raises(FileNotFoundError, match="Resolved contentUrl"): + ds.add_summary_stats() + + +def test_add_summary_stats_non_tabular_format(tmp_path): + bad_path = tmp_path / "data.parquet" + bad_path.write_bytes(b"not tabular") + ds = _make_dataset(tmp_path, file_format="application/parquet") + with pytest.raises(ValueError, match="not csv/tsv"): + ds.add_summary_stats(file_path=bad_path) \ No newline at end of file diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..faab494 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,48 @@ +import pytest +from pydantic import ValidationError +from fairscape_models.service import Service +from fairscape_models.fairscape_base import SERVICE_TYPE, IdentifierValue + + +@pytest.fixture +def service_minimal_data(): + return { + "@id": "ark:59852/test-service", + "name": "Test Service", + "description": "A service that does useful things on demand.", + } + + +def test_service_instantiation(service_minimal_data): + service = Service.model_validate(service_minimal_data) + assert service.guid == service_minimal_data["@id"] + assert service.name == service_minimal_data["name"] + assert service.metadataType == ["prov:SoftwareAgent", "https://w3id.org/EVI#Service"] + + +def test_service_short_description(service_minimal_data): + service_minimal_data["description"] = "short" + with pytest.raises(ValidationError): + Service.model_validate(service_minimal_data) + + +def test_service_with_optional_fields(service_minimal_data): + service_minimal_data["serviceUrl"] = "https://example.org/svc" + service_minimal_data["associatedPublication"] = "doi:10.1/x" + service_minimal_data["additionalDocumentation"] = "https://example.org/docs" + service_minimal_data["usedByComputation"] = [{"@id": "ark:59852/comp-1"}] + service_minimal_data["isPartOf"] = [{"@id": "ark:59852/crate-1"}] + service = Service.model_validate(service_minimal_data) + assert service.serviceUrl == "https://example.org/svc" + assert isinstance(service.usedByComputation[0], IdentifierValue) + assert isinstance(service.isPartOf[0], IdentifierValue) + + +def test_service_missing_required_field(service_minimal_data): + del service_minimal_data["name"] + with pytest.raises(ValidationError): + Service.model_validate(service_minimal_data) + + +def test_service_type_constant(): + assert SERVICE_TYPE == "Service"