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..2276392 --- /dev/null +++ b/src/peopledatalabs/models/job_posting.py @@ -0,0 +1,89 @@ +""" +Models for input parameters of the Job Posting APIs. +""" + +from datetime import date +from enum import Enum +from typing import Optional + +from pydantic.v1 import ( + BaseModel, + conint, +) + + +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" + + +class JobPostingQuerySearchModel(BaseModel): + """ + Validator model for the job_posting search API when using an Elasticsearch- + style query body. + """ + + query: dict + size: Optional[conint(ge=1, le=100)] = 10 + pretty: Optional[bool] = False + scroll_token: Optional[str] = None + + +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] + size: Optional[conint(ge=1, le=100)] = 10 + pretty: Optional[bool] = False + scroll_token: Optional[str] = None 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..c4f1b70 --- /dev/null +++ b/tests/job_posting/endpoints/test_search.py @@ -0,0 +1,132 @@ +""" +Tests calls to the job_posting/search API. +""" + +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_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. + """ + 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_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. + """ + model = JobPostingParamSearchModel(title="engineer") + assert "is_active" not in model.dict(exclude_none=True) + + +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. + """ + 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 param_model.scroll_token == token + + +@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 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