Skip to content

Commit d20bd31

Browse files
committed
Add remaining SearchEndpoint response shapes
1 parent a865144 commit d20bd31

3 files changed

Lines changed: 237 additions & 0 deletions

File tree

‎tests/conftest.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,49 @@ def job_summary_payload():
66
"""Sample payload matching the API's job summary schema."""
77
return {
88
"MatchedObjectId": "1",
9+
"PositionID": "24-123456",
910
"PositionTitle": "Engineer",
1011
"OrganizationName": "NASA",
12+
"DepartmentName": "National Aeronautics and Space Administration",
13+
"PositionURI": "https://example.com/job/1",
14+
"ApplyURI": ["https://example.com/apply/1"],
1115
"PositionLocationDisplay": "Houston, TX",
16+
"PositionLocation": [
17+
{
18+
"LocationName": "Houston, Texas",
19+
"LocationCode": "TX1234",
20+
"CountryCode": "US",
21+
"CountrySubDivisionCode": "TX",
22+
"CityName": "Houston",
23+
"Latitude": "29.7604",
24+
"Longitude": "-95.3698",
25+
}
26+
],
27+
"JobCategory": [{"Code": "0801", "Name": "General Engineering"}],
28+
"JobGrade": [{"Code": "GS", "CurrentGrade": "12"}],
29+
"PositionSchedule": [{"Code": "1", "Name": "Full-time"}],
30+
"PositionOfferingType": [{"Code": "15317", "Name": "Permanent"}],
1231
"MinimumRange": 50000.0,
1332
"MaximumRange": 100000.0,
33+
"PositionRemuneration": [
34+
{
35+
"MinimumRange": "50000",
36+
"MaximumRange": "100000",
37+
"RateIntervalCode": "PA",
38+
"Description": "Per Year",
39+
}
40+
],
1441
"ApplicationCloseDate": "2024-01-01",
42+
"UserArea": {
43+
"Details": {
44+
"JobSummary": "Design and build spacecraft components.",
45+
"HiringPath": "public;vet",
46+
"WhoMayApply": {
47+
"Name": "Open to the public",
48+
"Code": "public",
49+
},
50+
}
51+
},
1552
}
1653

1754

‎tests/unit/test_search.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,38 @@ def test_remuneration_max_less_than_min(self):
3535

3636

3737
class TestSearchEndpointResponses:
38+
def test_job_summary_parses_nested_fields(self, job_summary_payload):
39+
summary = SearchEndpoint.JobSummary.model_validate(job_summary_payload)
40+
41+
assert summary.position_id == "24-123456"
42+
assert summary.position_uri == "https://example.com/job/1"
43+
assert summary.apply_uri == ["https://example.com/apply/1"]
44+
assert (
45+
summary.department_name == "National Aeronautics and Space Administration"
46+
)
47+
assert summary.locations_display == "Houston, TX"
48+
49+
assert len(summary.locations) == 1
50+
location = summary.locations[0]
51+
assert location.city_name == "Houston"
52+
assert location.state_code == "TX"
53+
assert location.latitude == pytest.approx(29.7604)
54+
assert location.longitude == pytest.approx(-95.3698)
55+
56+
assert summary.job_categories[0].code == "0801"
57+
assert summary.job_grades[0].current_grade == "12"
58+
assert summary.position_schedules[0].name == "Full-time"
59+
assert summary.position_offerings[0].code == "15317"
60+
61+
assert summary.salary_range() == (50000.0, 100000.0)
62+
assert summary.hiring_paths() == ["public", "vet"]
63+
assert summary.summary() == "Design and build spacecraft components."
64+
65+
assert summary.user_area
66+
assert summary.user_area.details
67+
assert summary.user_area.details.who_may_apply
68+
assert summary.user_area.details.who_may_apply.name == "Open to the public"
69+
3870
def test_search_result_jobs_parsing(self, search_result_item):
3971
items = [
4072
search_result_item,

‎usajobsapi/endpoints/search.py‎

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,21 +216,189 @@ def to_params(self) -> Dict[str, str]:
216216
# Response shapes
217217
# ---
218218

219+
class JobCategory(BaseModel):
220+
"""Represents a job series classification associated with a posting."""
221+
222+
code: Optional[str] = Field(default=None, alias="Code")
223+
name: Optional[str] = Field(default=None, alias="Name")
224+
225+
class JobGrade(BaseModel):
226+
"""Represents the job grade (e.g. GS) tied to the posting."""
227+
228+
code: Optional[str] = Field(default=None, alias="Code")
229+
current_grade: Optional[str] = Field(default=None, alias="CurrentGrade")
230+
231+
class PositionSchedule(BaseModel):
232+
"""Represents the work schedule for the position."""
233+
234+
code: Optional[str] = Field(default=None, alias="Code")
235+
name: Optional[str] = Field(default=None, alias="Name")
236+
237+
class PositionOfferingType(BaseModel):
238+
"""Represents the appointment type (e.g. permanent, term)."""
239+
240+
code: Optional[str] = Field(default=None, alias="Code")
241+
name: Optional[str] = Field(default=None, alias="Name")
242+
243+
class PositionRemuneration(BaseModel):
244+
"""Represents a salary range entry for the position."""
245+
246+
minimum: Optional[float] = Field(default=None, alias="MinimumRange")
247+
maximum: Optional[float] = Field(default=None, alias="MaximumRange")
248+
rate_interval_code: Optional[str] = Field(
249+
default=None, alias="RateIntervalCode"
250+
)
251+
description: Optional[str] = Field(default=None, alias="Description")
252+
253+
@field_validator("minimum", "maximum", mode="before")
254+
@classmethod
255+
def _normalize_amount(cls, value: Any) -> Optional[float]:
256+
"""Normalize remuneration amounts to floats."""
257+
258+
if value in (None, ""):
259+
return None
260+
if isinstance(value, (int, float)):
261+
return float(value)
262+
if isinstance(value, str):
263+
cleaned = value.replace(",", "").replace("$", "").strip()
264+
if not cleaned:
265+
return None
266+
try:
267+
return float(cleaned)
268+
except ValueError:
269+
return None
270+
return None
271+
272+
class JobLocation(BaseModel):
273+
"""Represents a structured job location entry."""
274+
275+
name: Optional[str] = Field(default=None, alias="LocationName")
276+
code: Optional[str] = Field(default=None, alias="LocationCode")
277+
country_code: Optional[str] = Field(default=None, alias="CountryCode")
278+
state_code: Optional[str] = Field(default=None, alias="CountrySubDivisionCode")
279+
city_name: Optional[str] = Field(default=None, alias="CityName")
280+
latitude: Optional[float] = Field(default=None, alias="Latitude")
281+
longitude: Optional[float] = Field(default=None, alias="Longitude")
282+
283+
@field_validator("latitude", "longitude", mode="before")
284+
@classmethod
285+
def _normalize_coordinate(cls, value: Any) -> Optional[float]:
286+
"""Normalize coordinate values to floats."""
287+
288+
if value in (None, ""):
289+
return None
290+
try:
291+
return float(value)
292+
except (TypeError, ValueError):
293+
return None
294+
295+
class WhoMayApplyInfo(BaseModel):
296+
"""Represents the structured WhoMayApply block."""
297+
298+
name: Optional[str] = Field(default=None, alias="Name")
299+
code: Optional[str] = Field(default=None, alias="Code")
300+
301+
class UserAreaDetails(BaseModel):
302+
"""Represents metadata stored under the UserArea.Details field."""
303+
304+
job_summary: Optional[str] = Field(default=None, alias="JobSummary")
305+
hiring_path: Optional[str] = Field(default=None, alias="HiringPath")
306+
who_may_apply: Optional[SearchEndpoint.WhoMayApplyInfo] = Field(
307+
default=None, alias="WhoMayApply"
308+
)
309+
310+
class UserArea(BaseModel):
311+
"""Wrapper for additional USAJOBS metadata."""
312+
313+
details: Optional[SearchEndpoint.UserAreaDetails] = Field(
314+
default=None, alias="Details"
315+
)
316+
219317
class JobSummary(BaseModel):
220318
"""Normalized representation of a search result item."""
221319

222320
id: str = Field(alias="MatchedObjectId")
321+
position_id: Optional[str] = Field(default=None, alias="PositionID")
223322
position_title: str = Field(alias="PositionTitle")
323+
position_uri: Optional[str] = Field(default=None, alias="PositionURI")
324+
# URI to apply for the job offering
325+
apply_uri: List[str] = Field(default_factory=list, alias="ApplyURI")
224326
organization_name: Optional[str] = Field(default=None, alias="OrganizationName")
327+
department_name: Optional[str] = Field(default=None, alias="DepartmentName")
225328
locations_display: Optional[str] = Field(
226329
default=None, alias="PositionLocationDisplay"
227330
)
331+
locations: List[SearchEndpoint.JobLocation] = Field(
332+
default_factory=list, alias="PositionLocation"
333+
)
334+
job_categories: List[SearchEndpoint.JobCategory] = Field(
335+
default_factory=list, alias="JobCategory"
336+
)
337+
job_grades: List[SearchEndpoint.JobGrade] = Field(
338+
default_factory=list, alias="JobGrade"
339+
)
340+
position_schedules: List[SearchEndpoint.PositionSchedule] = Field(
341+
default_factory=list, alias="PositionSchedule"
342+
)
343+
position_offerings: List[SearchEndpoint.PositionOfferingType] = Field(
344+
default_factory=list, alias="PositionOfferingType"
345+
)
346+
user_area: Optional[SearchEndpoint.UserArea] = Field(
347+
default=None, alias="UserArea"
348+
)
349+
qualification_summary: Optional[str] = Field(
350+
default=None, alias="QualificationSummary"
351+
)
228352
min_salary: Optional[float] = Field(default=None, alias="MinimumRange")
229353
max_salary: Optional[float] = Field(default=None, alias="MaximumRange")
354+
position_remuneration: List[SearchEndpoint.PositionRemuneration] = Field(
355+
default_factory=list, alias="PositionRemuneration"
356+
)
357+
publication_start_date: Optional[str] = Field(
358+
default=None, alias="PublicationStartDate"
359+
)
230360
application_close_date: Optional[str] = Field(
231361
default=None, alias="ApplicationCloseDate"
232362
)
233363

364+
def summary(self) -> Optional[str]:
365+
"""Helper method returning the most descriptive summary for the job."""
366+
367+
if self.user_area and self.user_area.details:
368+
details = self.user_area.details
369+
if details and details.job_summary:
370+
return details.job_summary
371+
return self.qualification_summary
372+
373+
def salary_range(self) -> tuple[Optional[float], Optional[float]]:
374+
"""Helper method returning the salary range inferred from remuneration metadata."""
375+
376+
if self.position_remuneration:
377+
minima = [
378+
r.minimum
379+
for r in self.position_remuneration
380+
if r.minimum is not None
381+
]
382+
maxima = [
383+
r.maximum
384+
for r in self.position_remuneration
385+
if r.maximum is not None
386+
]
387+
min_val = min(minima) if minima else None
388+
max_val = max(maxima) if maxima else None
389+
if min_val is not None or max_val is not None:
390+
return min_val, max_val
391+
return self.min_salary, self.max_salary
392+
393+
def hiring_paths(self) -> List[str]:
394+
"""Helper method returning normalized hiring path codes from the user area."""
395+
396+
if self.user_area and self.user_area.details:
397+
raw = self.user_area.details.hiring_path
398+
if raw:
399+
return [path.strip() for path in raw.split(";") if path.strip()]
400+
return []
401+
234402
class SearchResult(BaseModel):
235403
"""Model of paginated search results."""
236404

0 commit comments

Comments
 (0)