Skip to content

Commit 9a9cfc2

Browse files
committed
Merge branch '0.5.1' into 'main'
merging 0.5.1 changes See merge request ado/core/external/elink2/python!7
2 parents 90d76f3 + 8c4a3c2 commit 9a9cfc2

File tree

12 files changed

+406
-105
lines changed

12 files changed

+406
-105
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,11 @@
135135
## 0.5.0 - 7/9/2025
136136
- **Possible breaking change** Moved WorkflowStatus enumeration from Revision subclass to standalone class
137137
- Added documentation for enumerations in README
138+
139+
## 0.5.1 - 7/30/2025
140+
- Made non-required pydantic class attributes "fully Optional" allowing null or None
141+
- Add MM/DD/YYYY as an acceptable "publication_date" format for dict/JSON Record creation
142+
- Fix issue in query results iterating incorrectly
143+
- Adding "examples" to repository containing several sample code examples
144+
- **[Breaking change]** Removed field date_released; replaced by date_first_released and date_last_released
145+
- Fix issue with unexpected list values

examples/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# E-Link 2 Python Examples
2+
3+
Including some sample operations and scenarios for use of the python connector library. Common to each
4+
of these will be the assumption of certain environmental variables present to facilitate the API
5+
connection. Set these in the interpreter environment prior to running these code samples, generally
6+
within the python virtual environment if applicable (using venv or pipenv). Ensure the `elinkapi`
7+
python dependency is installed in the virtual environment prior to running the samples.
8+
9+
| Variable | Purpose | Default |
10+
| -- | -- | -- |
11+
| TARGET | Define the target URL for the API to use | https://www.osti.gov/elink2api/ |
12+
| TOKEN | Your user account API access token value | None |
13+
14+
## Examples
15+
16+
A few sample python projects demonstrating usage of the connector library.
17+
18+
### Search
19+
20+
Example search of the most-recently-updated 50 records in "SV" workflow status, if any. Shows record querying and iterating through set of results.
21+
22+
`python search.py`
23+
24+
### Reserve DOI
25+
26+
Reserve a DOI for a dataset at OSTI, given a site code and title from the command-line. Example usage of DOI reservation API call.
27+
28+
`python reserve.py --site SITECODE --title TITLE`
29+
30+
```
31+
usage: reserve [-h] -s SITE -t TITLE
32+
33+
Reserve a DOI at OSTI.
34+
35+
options:
36+
-h, --help show this help message and exit
37+
-s SITE, --site SITE Indicate the site code to use.
38+
-t TITLE, --title TITLE
39+
Required document title to use for reservation.
40+
```
41+
42+
### Details
43+
44+
Query a single record by its OSTI ID and display various details about its metadata and state, including any pending issues from audit logs if
45+
applicable. Demonstrates pulling in single OSTI ID record and use of the Record class, as well as exception handling for "not found" or
46+
"permission denied" cases.
47+
48+
`python details.py --id OSTIID`
49+

examples/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import os
2+
3+
TARGET = os.getenv("TARGET", "https://www.osti.gov/elink2api/")
4+
TOKEN = os.getenv("TOKEN")

examples/details.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from elinkapi import Elink, ForbiddenException, NotFoundException, WorkflowStatus, Identifier
2+
import argparse, sys
3+
from config import TARGET, TOKEN
4+
5+
def audit(logs):
6+
"""
7+
Print out details from the audit logs.
8+
"""
9+
for log in logs:
10+
print (f"- {log.type} on {log.audit_date.strftime('%Y-%m-%d %H:%M:%S')}, state {log.status}")
11+
for message in log.messages:
12+
print (f" * {message}")
13+
14+
def print_person(p):
15+
"""
16+
Print details on a particular Person record.
17+
"""
18+
print (" * {0}, {1} {2} {3}".format(p.last_name,
19+
p.first_name,
20+
p.middle_name if p.middle_name else "",
21+
p.contributor_type if p.contributor_type else ""))
22+
23+
def print_organization(o):
24+
"""
25+
Print organization details.
26+
"""
27+
print (" * {0} {1}".format(o.name,
28+
o.contributor_type if o.contributor_type else ""))
29+
30+
def info(argv):
31+
"""
32+
Retrieve information on a particular OSTI ID if you have access to it.
33+
"""
34+
parser = argparse.ArgumentParser("details", description="Find record information at OSTI.")
35+
parser.add_argument("-i", "--id", help="OSTI ID to find.", type=int, required=True)
36+
args = parser.parse_args()
37+
38+
# make a link to the API
39+
api = Elink(target = TARGET, token=TOKEN)
40+
41+
# look for it
42+
try:
43+
record = api.get_single_record(args.id)
44+
45+
print (f"Details for record OSTI ID {record.osti_id}")
46+
print (f"Title: {record.title}")
47+
print (f"Product type: {record.product_type}")
48+
49+
if record.doi:
50+
print (f"DOI {record.doi}")
51+
52+
print ("")
53+
54+
if WorkflowStatus.Released.value==record.workflow_status:
55+
print ("Record RELEASED.")
56+
elif WorkflowStatus.Saved.value==record.workflow_status:
57+
print ("Record in SAVED state.")
58+
elif WorkflowStatus.FailedRelease.value==record.workflow_status or WorkflowStatus.FailedValidation.value==record.workflow_status:
59+
print ("Record in FAILED state, reasons:")
60+
61+
audit(record.audit_logs)
62+
elif WorkflowStatus.Validated.value==record.workflow_status:
63+
print ("Record is PENDING in validated state, logs:")
64+
65+
audit(record.audit_logs)
66+
else:
67+
print (f"Record status: {record.workflow_status}")
68+
69+
print (f"Publication Date {record.publication_date}")
70+
71+
print ("Current Revision Dates:")
72+
print (f" * Added {record.date_metadata_added}")
73+
print (f" * Updated {record.date_metadata_updated}")
74+
print (f" * First Submitted {record.date_submitted_to_osti_first}")
75+
print (f" * Last Submitted {record.date_submitted_to_osti_last}")
76+
print (f" * First Released {record.date_released_first}")
77+
print (f" * Last Released {record.date_released_last}")
78+
79+
print (f"Description:\n{record.description}")
80+
81+
print ("Persons:")
82+
83+
print ("- AUTHORS")
84+
for person in list(filter(lambda x: x.type=="AUTHOR", record.persons)):
85+
print_person(person)
86+
87+
print ("- CONTRIBUTORS")
88+
for person in list(filter(lambda c: c.type=="CONTRIBUTING", record.persons)):
89+
print_person(person)
90+
91+
print ("Organizations:")
92+
print ("- SPONSORING")
93+
for organization in list(filter(lambda x: x.type=="SPONSOR", record.organizations)):
94+
print_organization(organization)
95+
96+
print ("- RESEARCHING")
97+
for organization in list(filter(lambda x: x.type=="RESEARCHING", record.organizations)):
98+
print_organization(organization)
99+
print ("- CONTRIBUTING")
100+
for organization in list(filter(lambda x: x.type=="CONTRIBUTING", record.organizations)):
101+
print_organization(organization)
102+
103+
print ("Identifiers:")
104+
for identifier in record.identifiers:
105+
print ("- {0} ({1})".format(identifier.value, Identifier.Type(identifier.type).name))
106+
107+
print ("\nMedia Information:")
108+
109+
if not record.media:
110+
print ("- No media associated.")
111+
112+
for media in record.media:
113+
print (f"- Media ID {media.media_id}, added {media.date_added}, updated {media.date_updated}. State {media.status}")
114+
print (" Files:")
115+
for file in media.files:
116+
print(f" * Media File ID {file.media_file_id} Status {file.status}")
117+
if file.processing_exceptions:
118+
print(f" - Exceptions: {file.processing_exceptions}")
119+
120+
except ForbiddenException as e:
121+
print (f"Access denied to ID {args.id}")
122+
except NotFoundException as e:
123+
print (f"OSTI ID {args.id} was not found at OSTI.")
124+
125+
126+
# When called, run this procedure
127+
if __name__ == '__main__':
128+
info(sys.argv[1:])

examples/reserve.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from elinkapi import Elink, ProductType, BadRequestException, ForbiddenException
2+
import argparse, sys
3+
from config import TARGET, TOKEN
4+
5+
def reserve(argv):
6+
"""
7+
Attempt to reserve a DOI with OSTI. The site code and title are required. The OSTI ID and DOI value will be returned if
8+
successful.
9+
"""
10+
parser = argparse.ArgumentParser("reserve", description="Reserve a DOI at OSTI.")
11+
parser.add_argument("-s", "--site", help="Indicate the site code to use.", type=str, required=True)
12+
parser.add_argument("-t", "--title", help="Required document title to use for reservation.", type=str, required=True)
13+
args = parser.parse_args()
14+
15+
# make a link to the API
16+
api = Elink(target = TARGET, token=TOKEN)
17+
18+
# register if we can
19+
try:
20+
reservation = api.reserve_doi(site_ownership_code = args.site, title= args.title, product_type = ProductType.Dataset.value)
21+
22+
# indicate the DOI and OSTI ID we got
23+
print (f"Successfully registered DOI {reservation.doi} for record at OSTI, ID={reservation.osti_id}")
24+
except ForbiddenException as e:
25+
print (f"Access to reserve DOI for site {args.site} denied.")
26+
except BadRequestException as e:
27+
print (f"Reservation request failed, status {e.status_code}: {e.message}")
28+
29+
30+
# When called, run this procedure
31+
if __name__ == '__main__':
32+
reserve(sys.argv[1:])

examples/search.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from pydantic import ValidationError
2+
from config import TARGET, TOKEN
3+
from elinkapi import Elink, WorkflowStatus
4+
import sys, argparse
5+
6+
"""
7+
Search example: Find listing of records by workflow state, up to indicated count.
8+
"""
9+
10+
def search_for(argv):
11+
"""
12+
Perform search query.
13+
14+
Arguments:
15+
state (str) -- requested workflow status
16+
count (int) -- number of rows to list
17+
"""
18+
19+
parser = argparse.ArgumentParser("search", description="Find list of records at OSTI for a given workflow status code.")
20+
parser.add_argument("-s", "--status", help="Workflow status to find. Default is SV.", type=str, default=WorkflowStatus.Validated.value)
21+
parser.add_argument("-p", "--product", help="Product type to find.", type=str)
22+
parser.add_argument("-c", "--count", help="Number of records to display, default is 50.", type=int, default=50)
23+
args = parser.parse_args()
24+
25+
# make sure the status is valid, will throw ValueError if not valid
26+
status = WorkflowStatus(args.status)
27+
28+
# make an API link
29+
api = Elink(target = TARGET, token = TOKEN)
30+
31+
# get a query (might throw a pydantic error)
32+
parameters = { "workflow_status": args.status, "sortby": "date_metadata_updated" }
33+
34+
if args.product:
35+
parameters['product_type'] = args.product
36+
37+
try:
38+
query = api.query_records(**parameters)
39+
40+
# print the summary, header, and records
41+
print (f"Found {query.total_rows} matching records in state {status.name}, first {args.count}.\n")
42+
43+
print ("#".center(3, "_"), "OSTI ID".center(10,"_"), "TITLE".center(60, "_"), "UPDATED".center(20, "_"))
44+
45+
for n, record in enumerate(query):
46+
print (f'{n+1:2d}. {record.osti_id:10d} {record.title:60.60s} {record.date_metadata_updated.strftime("%Y-%m-%d %H:%M:%S")}')
47+
48+
# stop at requested count (0-based)
49+
if n==(args.count-1):
50+
break
51+
except ValidationError as e:
52+
print (f"One or more record validation errors, cannot retrieve details.")
53+
54+
55+
if __name__ == "__main__":
56+
search_for(sys.argv[1:])

pyproject.toml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "elinkapi"
7-
version = "0.5.0"
7+
version = "0.5.1"
88
authors = [
9-
{ name="Jacob Samar", email="samarj@osti.gov" },
10-
{ name="Neal Ensor", email="[email protected]" }
9+
{ name="Neal Ensor", email="ensorn@osti.gov" },
10+
{ name="Jacob Samar" }
1111
]
1212
description = "OSTI E-Link 2 API package"
1313
readme = "README.md"
@@ -30,3 +30,5 @@ development = ["twine", "build"]
3030
[project.urls]
3131
Homepage = "https://github.com/doecode/elinkapi"
3232
Issues = "https://github.com/doecode/elinkapi/issues"
33+
Changelog = "https://github.com/doecode/elinkapi/blob/main/CHANGELOG.md"
34+
Examples = "https://github.com/doecode/elinkapi/tree/main/examples"

src/elinkapi/affiliation.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
22
from .utils import Validation
3+
from typing import Optional
34

45
class Affiliation(BaseModel):
56
"""
@@ -10,8 +11,8 @@ class Affiliation(BaseModel):
1011
"""
1112
model_config = ConfigDict(validate_assignment=True)
1213

13-
name:str = None
14-
ror_id:str = None
14+
name:Optional[str] = None
15+
ror_id:Optional[str] = None
1516

1617
@model_validator(mode = 'after')
1718
def name_or_ror(self):
@@ -22,5 +23,6 @@ def name_or_ror(self):
2223
@field_validator("ror_id")
2324
@classmethod
2425
def validate_ror_id(cls, value: str) -> str:
25-
Validation.find_ror_value(value)
26+
if value:
27+
Validation.find_ror_value(value)
2628
return value

src/elinkapi/organization.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from enum import Enum
22
from .identifier import Identifier
33
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
4-
from typing import List
4+
from typing import List, Optional
55
from .utils import Validation
66
from .contribution import Contribution
77

@@ -28,10 +28,10 @@ class Type(Enum):
2828
PAMS_TD_INST="PAMS_TD_INST"
2929

3030
type:str
31-
name:str = None
32-
contributor_type: str = None
33-
identifiers: List[Identifier] = None
34-
ror_id:str = None
31+
name:Optional[str] = None
32+
contributor_type: Optional[str] = None
33+
identifiers: Optional[List[Identifier]] = None
34+
ror_id:Optional[str] = None
3535

3636
@model_validator(mode = 'after')
3737
def name_or_ror(self):

src/elinkapi/person.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from enum import Enum
22
from pydantic import BaseModel, ConfigDict, field_validator
3-
from typing import List
3+
from typing import List, Optional
44
from .affiliation import Affiliation
55
from .contribution import Contribution
66

@@ -30,14 +30,14 @@ class Type(Enum):
3030
PRINCIPAL_INVESTIGATOR="SBIZ_PI"
3131

3232
type: str
33-
first_name: str = None
34-
middle_name: str = None
33+
first_name: Optional[str] = None
34+
middle_name: Optional[str] = None
3535
last_name: str
36-
orcid: str = None
37-
phone: str = None
38-
email: List[str] = None
39-
affiliations: List[Affiliation] = None
40-
contributor_type: str = None
36+
orcid: Optional[str] = None
37+
phone: Optional[str] = None
38+
email: Optional[List[Optional[str]]] = None
39+
affiliations: Optional[List[Affiliation]] = None
40+
contributor_type: Optional[str] = None
4141

4242
@field_validator("type")
4343
@classmethod
@@ -49,7 +49,7 @@ def type_must_be_valid(cls, value) -> str:
4949
@field_validator("contributor_type")
5050
@classmethod
5151
def contributor_must_be_valid(cls, value) -> str:
52-
if value not in [type.value for type in Contribution]:
52+
if value and value not in [type.value for type in Contribution]:
5353
raise ValueError('Unknown contributor type value {}.'.format(value))
5454
return value
5555

0 commit comments

Comments
 (0)