From 72209ebf8e07906f7368dd08cc496ee84f99b1cd Mon Sep 17 00:00:00 2001 From: Jeremy Alan Schwartz Date: Wed, 15 Apr 2026 15:42:11 +0200 Subject: [PATCH 1/4] First go at trying to add job posting --- README.md | 58 ++++++++++ src/peopledatalabs/endpoints/job_posting.py | 41 +++++++ src/peopledatalabs/main.py | 8 ++ src/peopledatalabs/models/job_posting.py | 111 ++++++++++++++++++ tests/job_posting/__init__.py | 0 tests/job_posting/endpoints/__init__.py | 0 tests/job_posting/endpoints/test_search.py | 121 ++++++++++++++++++++ 7 files changed, 339 insertions(+) create mode 100644 src/peopledatalabs/endpoints/job_posting.py create mode 100644 src/peopledatalabs/models/job_posting.py create mode 100644 tests/job_posting/__init__.py create mode 100644 tests/job_posting/endpoints/__init__.py create mode 100644 tests/job_posting/endpoints/test_search.py diff --git a/README.md b/README.md index 434c9c7..381fd57 100644 --- a/README.md +++ b/README.md @@ -329,6 +329,58 @@ else: ) ``` +### Getting Job Posting Data + +#### By Search (Elasticsearch) + +```python +es_query = { + "query": { + "bool": { + "must": [ + {"term": {"title_role": "engineering"}}, + {"term": {"remote_work_policy": "remote"}}, + ] + } + } +} +data = { + "query": es_query, + "size": 10, + "pretty": True, +} +result = client.job_posting.search(**data) +if result.ok: + print(result.text) +else: + print( + f"Status: {result.status_code}" + f"\nReason: {result.reason}" + f"\nMessage: {result.json()['error']['message']}" + ) +``` + +#### By Search (Field Parameters) + +```python +data = { + "title_role": "engineering", + "remote_work_policy": "remote", + "is_active": True, + "size": 10, + "pretty": True, +} +result = client.job_posting.search(**data) +if result.ok: + print(result.text) +else: + print( + f"Status: {result.status_code}" + f"\nReason: {result.reason}" + f"\nMessage: {result.json()['error']['message']}" + ) +``` + ### Using supporting APIs #### Get Autocomplete Suggestions @@ -449,6 +501,12 @@ PDLPY(sandbox=True) | [Person Identify API](https://docs.peopledatalabs.com/docs/identify-api) | `PDLPY.person.identify(**params)` | | [Person Changelog API](https://docs.peopledatalabs.com/docs/person-changelog-api) | `PDLPY.person.changelog(**params)` | +**Job Posting Endpoints** + +| API Endpoint | PDLPY Function | +| ----------------------------------------------------------------------------------------------- | --------------------------------------- | +| [Job Posting Search API](https://docs.peopledatalabs.com/docs/job-posting-search-api) | `PDLPY.job_posting.search(**params)` | + **Company Endpoints** | API Endpoint | PDLPY Function | diff --git a/src/peopledatalabs/endpoints/job_posting.py b/src/peopledatalabs/endpoints/job_posting.py new file mode 100644 index 0000000..c881fc1 --- /dev/null +++ b/src/peopledatalabs/endpoints/job_posting.py @@ -0,0 +1,41 @@ +""" +Defines all API endpoints for the 'Job Posting' section. +""" + +from pydantic.v1.dataclasses import dataclass + +from . import Endpoint +from ..models import job_posting as job_posting_models +from ..logger import get_logger + + +logger = get_logger("endpoints.job_posting") + + +@dataclass +class JobPosting(Endpoint): + """ + Class for all APIs of "job_posting" type. + """ + + section: str = "job_posting" + + def search(self, **kwargs): + """ + Calls PeopleDataLabs' job_posting/search API. + + Dispatches to the Elasticsearch-style validator when 'query' is + provided, and to the field-based validator otherwise. + + Args: + **kwargs: Parameters for the API as defined + in the documentation. + + Returns: + A requests.Response object with the result of the HTTP call. + """ + if "query" in kwargs: + model = job_posting_models.JobPostingQuerySearchModel + else: + model = job_posting_models.JobPostingParamSearchModel + return self._search(model, **kwargs) diff --git a/src/peopledatalabs/main.py b/src/peopledatalabs/main.py index c8669a1..ce88edb 100644 --- a/src/peopledatalabs/main.py +++ b/src/peopledatalabs/main.py @@ -12,6 +12,7 @@ from .endpoints import Endpoint from .endpoints.person import Person from .endpoints.company import Company +from .endpoints.job_posting import JobPosting from .endpoints.location import Location from .endpoints.school import School from .logger import get_logger @@ -177,3 +178,10 @@ def person(self): Calls API from the person section. """ return Person(self.api_key, self.base_path) + + @property + def job_posting(self): + """ + Calls API from the job_posting section. + """ + return JobPosting(self.api_key, self.base_path) diff --git a/src/peopledatalabs/models/job_posting.py b/src/peopledatalabs/models/job_posting.py new file mode 100644 index 0000000..c25b821 --- /dev/null +++ b/src/peopledatalabs/models/job_posting.py @@ -0,0 +1,111 @@ +""" +Models for input parameters of the Job Posting APIs. +""" + +import base64 +import json +from datetime import date +from enum import Enum +from typing import Any, List, Optional + +from pydantic.v1 import ( + BaseModel, + conint, + validator, +) + + +class RemoteWorkPolicy(str, Enum): + """ + Valid values for 'remote_work_policy' on job_posting search. + """ + + remote = "remote" + onsite = "onsite" + + +class SalaryPeriod(str, Enum): + """ + Valid values for 'salary_period' on job_posting search. + """ + + year = "year" + month = "month" + week = "week" + day = "day" + hour = "hour" + + +def _parse_scroll_token(v): + """ + Accepts either a decoded list or a base64-url-encoded JSON string and + normalizes to a list. Mirrors the server-side scroll_token decoding. + """ + if isinstance(v, str): + json_str = base64.urlsafe_b64decode(v.encode()).decode() + v = json.loads(json_str) + return v + + +class JobPostingQuerySearchModel(BaseModel): + """ + Validator model for the job_posting search API when using an + Elasticsearch-style query body. + """ + + query: dict + size: Optional[conint(le=100)] = 10 + pretty: Optional[bool] = False + scroll_token: Optional[List[Any]] = None + + _parse_scroll_token = validator( + "scroll_token", pre=True, allow_reuse=True + )(_parse_scroll_token) + + +class JobPostingParamSearchModel(BaseModel): + """ + Validator model for the job_posting search API when using the + field-based parameter form (no 'query' body). + """ + + id: Optional[str] + first_seen_min: Optional[date] + first_seen_max: Optional[date] + deactivated_date_min: Optional[date] + deactivated_date_max: Optional[date] + + title: Optional[str] + title_class: Optional[str] + title_role: Optional[str] + title_sub_role: Optional[str] + title_levels: Optional[str] + + company_id: Optional[str] + company_name: Optional[str] + company_industry: Optional[str] + company_industry_v2: Optional[str] + company_website: Optional[str] + company_profile: Optional[str] + + location: Optional[str] + description: Optional[str] + + salary_range_min: Optional[int] + salary_range_max: Optional[int] + salary_currency: Optional[str] + salary_period: Optional[SalaryPeriod] + + remote_work_policy: Optional[RemoteWorkPolicy] + inferred_skills: Optional[str] + last_verified_min: Optional[date] + last_verified_max: Optional[date] + + is_active: Optional[bool] = False + size: Optional[conint(ge=1, le=100)] = 10 + pretty: Optional[bool] = False + scroll_token: Optional[List[Any]] = None + + _parse_scroll_token = validator( + "scroll_token", pre=True, allow_reuse=True + )(_parse_scroll_token) diff --git a/tests/job_posting/__init__.py b/tests/job_posting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/job_posting/endpoints/__init__.py b/tests/job_posting/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/job_posting/endpoints/test_search.py b/tests/job_posting/endpoints/test_search.py new file mode 100644 index 0000000..21aa7a7 --- /dev/null +++ b/tests/job_posting/endpoints/test_search.py @@ -0,0 +1,121 @@ +""" +Tests calls to the job_posting/search API. +""" + +import base64 +import json +import logging + +import pytest +from pydantic.v1 import ValidationError +import requests + +from peopledatalabs.errors import EmptyParametersException +from peopledatalabs.models.job_posting import ( + JobPostingParamSearchModel, + JobPostingQuerySearchModel, +) + + +logging.basicConfig() +logger = logging.getLogger("PeopleDataLabs.tests.job_posting.search") + + +@pytest.mark.usefixtures("client_with_fake_api_key") +def test_search_empty_params_throw_error(client_with_fake_api_key): + """ + Tests calling the search method without parameters. + + Should raise EmptyParametersException. + """ + with pytest.raises(EmptyParametersException): + client_with_fake_api_key.job_posting.search() + + +def test_param_model_size_out_of_range_raises_validation_error(): + """ + Param-mode size must be between 1 and 100. + """ + with pytest.raises(ValidationError): + JobPostingParamSearchModel(title="engineer", size=0) + with pytest.raises(ValidationError): + JobPostingParamSearchModel(title="engineer", size=101) + + +def test_param_model_invalid_remote_work_policy_raises_validation_error(): + """ + remote_work_policy must be one of the enum values. + """ + with pytest.raises(ValidationError): + JobPostingParamSearchModel(remote_work_policy="hybrid") + + +def test_param_model_invalid_salary_period_raises_validation_error(): + """ + salary_period must be one of the enum values. + """ + with pytest.raises(ValidationError): + JobPostingParamSearchModel(salary_period="fortnight") + + +def test_query_model_scroll_token_accepts_base64_string(): + """ + scroll_token may be passed as a base64-url-encoded JSON string and is + decoded into a list. + """ + raw = [1, 2, 3] + encoded = base64.urlsafe_b64encode( + json.dumps(raw).encode() + ).decode() + model = JobPostingQuerySearchModel(query={"match_all": {}}, scroll_token=encoded) + assert model.scroll_token == raw + + +def test_query_model_scroll_token_accepts_list_passthrough(): + """ + scroll_token already in list form passes through unchanged. + """ + model = JobPostingQuerySearchModel( + query={"match_all": {}}, scroll_token=[1, 2, 3] + ) + assert model.scroll_token == [1, 2, 3] + + +@pytest.mark.usefixtures("client") +def test_api_endpoint_search_query(client): + """ + Tests successful execution of search API by ES query. + """ + es_query = { + "query": { + "bool": { + "must": [ + {"term": {"title_role": "engineering"}}, + ] + } + } + } + data = { + "query": es_query, + "size": 10, + "pretty": True, + } + response = client.job_posting.search(**data) + assert isinstance(response, requests.Response) + assert response.status_code == 200 + + +@pytest.mark.usefixtures("client") +def test_api_endpoint_search_params(client): + """ + Tests successful execution of search API by field parameters. + """ + data = { + "title_role": "engineering", + "remote_work_policy": "remote", + "size": 10, + "pretty": True, + } + response = client.job_posting.search(**data) + assert isinstance(response, requests.Response) + assert response.status_code == 200 From 9571d60a813a009f8ac930a0664ae48f1ac7f21a Mon Sep 17 00:00:00 2001 From: Jeremy Alan Schwartz Date: Thu, 16 Apr 2026 15:52:05 +0200 Subject: [PATCH 2/4] fixes for api params --- src/peopledatalabs/models/job_posting.py | 32 +++------------- tests/job_posting/endpoints/test_search.py | 43 ++++++++++++++-------- 2 files changed, 32 insertions(+), 43 deletions(-) diff --git a/src/peopledatalabs/models/job_posting.py b/src/peopledatalabs/models/job_posting.py index c25b821..bd2fa87 100644 --- a/src/peopledatalabs/models/job_posting.py +++ b/src/peopledatalabs/models/job_posting.py @@ -2,16 +2,13 @@ Models for input parameters of the Job Posting APIs. """ -import base64 -import json from datetime import date from enum import Enum -from typing import Any, List, Optional +from typing import Optional from pydantic.v1 import ( BaseModel, conint, - validator, ) @@ -36,17 +33,6 @@ class SalaryPeriod(str, Enum): hour = "hour" -def _parse_scroll_token(v): - """ - Accepts either a decoded list or a base64-url-encoded JSON string and - normalizes to a list. Mirrors the server-side scroll_token decoding. - """ - if isinstance(v, str): - json_str = base64.urlsafe_b64decode(v.encode()).decode() - v = json.loads(json_str) - return v - - class JobPostingQuerySearchModel(BaseModel): """ Validator model for the job_posting search API when using an @@ -54,13 +40,9 @@ class JobPostingQuerySearchModel(BaseModel): """ query: dict - size: Optional[conint(le=100)] = 10 + size: Optional[conint(ge=1, le=100)] = 10 pretty: Optional[bool] = False - scroll_token: Optional[List[Any]] = None - - _parse_scroll_token = validator( - "scroll_token", pre=True, allow_reuse=True - )(_parse_scroll_token) + scroll_token: Optional[str] = None class JobPostingParamSearchModel(BaseModel): @@ -101,11 +83,7 @@ class JobPostingParamSearchModel(BaseModel): last_verified_min: Optional[date] last_verified_max: Optional[date] - is_active: Optional[bool] = False + is_active: Optional[bool] size: Optional[conint(ge=1, le=100)] = 10 pretty: Optional[bool] = False - scroll_token: Optional[List[Any]] = None - - _parse_scroll_token = validator( - "scroll_token", pre=True, allow_reuse=True - )(_parse_scroll_token) + scroll_token: Optional[str] = None diff --git a/tests/job_posting/endpoints/test_search.py b/tests/job_posting/endpoints/test_search.py index 21aa7a7..fe8e121 100644 --- a/tests/job_posting/endpoints/test_search.py +++ b/tests/job_posting/endpoints/test_search.py @@ -2,8 +2,6 @@ Tests calls to the job_posting/search API. """ -import base64 -import json import logging import pytest @@ -42,6 +40,16 @@ def test_param_model_size_out_of_range_raises_validation_error(): JobPostingParamSearchModel(title="engineer", size=101) +def test_query_model_size_out_of_range_raises_validation_error(): + """ + Query-mode size must also be between 1 and 100. + """ + with pytest.raises(ValidationError): + JobPostingQuerySearchModel(query={"match_all": {}}, size=0) + with pytest.raises(ValidationError): + JobPostingQuerySearchModel(query={"match_all": {}}, size=101) + + def test_param_model_invalid_remote_work_policy_raises_validation_error(): """ remote_work_policy must be one of the enum values. @@ -58,27 +66,30 @@ def test_param_model_invalid_salary_period_raises_validation_error(): JobPostingParamSearchModel(salary_period="fortnight") -def test_query_model_scroll_token_accepts_base64_string(): +def test_param_model_is_active_omitted_by_default(): """ - scroll_token may be passed as a base64-url-encoded JSON string and is - decoded into a list. + is_active is an opt-in filter and must not be sent unless the caller + sets it explicitly. """ - raw = [1, 2, 3] - encoded = base64.urlsafe_b64encode( - json.dumps(raw).encode() - ).decode() - model = JobPostingQuerySearchModel(query={"match_all": {}}, scroll_token=encoded) - assert model.scroll_token == raw + model = JobPostingParamSearchModel(title="engineer") + assert "is_active" not in model.dict(exclude_none=True) -def test_query_model_scroll_token_accepts_list_passthrough(): +def test_scroll_token_round_trips_as_opaque_string(): """ - scroll_token already in list form passes through unchanged. + scroll_token is the opaque base64 token returned by the API and must + be passed back unchanged on subsequent calls. """ - model = JobPostingQuerySearchModel( - query={"match_all": {}}, scroll_token=[1, 2, 3] + token = "eyJhIjogMX0=" + query_model = JobPostingQuerySearchModel( + query={"match_all": {}}, scroll_token=token + ) + assert query_model.scroll_token == token + + param_model = JobPostingParamSearchModel( + title="engineer", scroll_token=token ) - assert model.scroll_token == [1, 2, 3] + assert param_model.scroll_token == token @pytest.mark.usefixtures("client") From 433c8fd7971ba70d9da543949b5f466e9632433a Mon Sep 17 00:00:00 2001 From: Jeremy Alan Schwartz Date: Fri, 17 Apr 2026 14:37:02 +0200 Subject: [PATCH 3/4] docformatter --- src/peopledatalabs/models/job_posting.py | 8 ++++---- tests/job_posting/endpoints/test_search.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/peopledatalabs/models/job_posting.py b/src/peopledatalabs/models/job_posting.py index bd2fa87..2276392 100644 --- a/src/peopledatalabs/models/job_posting.py +++ b/src/peopledatalabs/models/job_posting.py @@ -35,8 +35,8 @@ class SalaryPeriod(str, Enum): class JobPostingQuerySearchModel(BaseModel): """ - Validator model for the job_posting search API when using an - Elasticsearch-style query body. + Validator model for the job_posting search API when using an Elasticsearch- + style query body. """ query: dict @@ -47,8 +47,8 @@ class JobPostingQuerySearchModel(BaseModel): class JobPostingParamSearchModel(BaseModel): """ - Validator model for the job_posting search API when using the - field-based parameter form (no 'query' body). + Validator model for the job_posting search API when using the field-based + parameter form (no 'query' body). """ id: Optional[str] diff --git a/tests/job_posting/endpoints/test_search.py b/tests/job_posting/endpoints/test_search.py index fe8e121..c4f1b70 100644 --- a/tests/job_posting/endpoints/test_search.py +++ b/tests/job_posting/endpoints/test_search.py @@ -68,8 +68,8 @@ def test_param_model_invalid_salary_period_raises_validation_error(): def test_param_model_is_active_omitted_by_default(): """ - is_active is an opt-in filter and must not be sent unless the caller - sets it explicitly. + is_active is an opt-in filter and must not be sent unless the caller sets + it explicitly. """ model = JobPostingParamSearchModel(title="engineer") assert "is_active" not in model.dict(exclude_none=True) @@ -77,8 +77,8 @@ def test_param_model_is_active_omitted_by_default(): def test_scroll_token_round_trips_as_opaque_string(): """ - scroll_token is the opaque base64 token returned by the API and must - be passed back unchanged on subsequent calls. + scroll_token is the opaque base64 token returned by the API and must be + passed back unchanged on subsequent calls. """ token = "eyJhIjogMX0=" query_model = JobPostingQuerySearchModel( From e2912801d61c535c972379b4c6541b2fcea04070 Mon Sep 17 00:00:00 2001 From: Jeremy Alan Schwartz Date: Fri, 17 Apr 2026 14:56:30 +0200 Subject: [PATCH 4/4] update tests with new versions --- tests/person/endpoints/test_changelog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/person/endpoints/test_changelog.py b/tests/person/endpoints/test_changelog.py index 3ad5969..c643027 100644 --- a/tests/person/endpoints/test_changelog.py +++ b/tests/person/endpoints/test_changelog.py @@ -31,7 +31,7 @@ def test_api_endpoint_changelog(client): Tests successful execution of changelog API. """ changelog = client.person.changelog( - current_version="32.0", origin_version="31.2", type="updated" + current_version="33.0", origin_version="32.0", type="updated" ) assert isinstance(changelog, requests.Response) assert changelog.status_code == 200