├── .cache └── plugin │ ├── privacy │ └── assets │ │ └── external │ │ ├── fonts.googleapis.com │ │ ├── css.49ea35f2 │ │ └── css.49ea35f2.css │ │ ├── fonts.gstatic.com │ │ └── s │ │ │ ├── roboto │ │ │ └── v47 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2 │ │ │ │ ├── KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2 │ │ │ │ ├── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2 │ │ │ │ └── KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2 │ │ │ └── robotomono │ │ │ └── v23 │ │ │ ├── L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSV0mf0h.woff2 │ │ │ ├── L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSZ0mf0h.woff2 │ │ │ ├── L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSd0mf0h.woff2 │ │ │ ├── L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0mQ.woff2 │ │ │ ├── L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSt0mf0h.woff2 │ │ │ ├── L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSx0mf0h.woff2 │ │ │ ├── L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtElOUlYIw.woff2 │ │ │ ├── L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEleUlYIw.woff2 │ │ │ ├── L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEluUlYIw.woff2 │ │ │ ├── L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEm-Ul.woff2 │ │ │ ├── L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEmOUlYIw.woff2 │ │ │ └── L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEn-UlYIw.woff2 │ │ ├── plausible.io │ │ └── js │ │ │ └── plausible.js │ │ └── unpkg.com │ │ └── mermaid@11 │ │ └── dist │ │ └── mermaid.min.js │ └── social │ ├── 007fd41a719d772616a3c1848ade562b.png │ ├── 05ceee6950c7116aec3d6611373171b9.png │ ├── 0d2b8b2b33e82c0687bb6d8eb7a87f30.png │ ├── 28435ff9c41fe8c2402115daff5e4665.png │ ├── 4a87426cfe41dc259abda1723816131d.png │ ├── 5d0c4a82bff0efcdb4e0a53ab86ae793.png │ ├── 838625a33e6ea9e992947ebf47ba1403.png │ ├── 89522f3336e95f88231429eec2a6efaa.png │ ├── a24e261e1c75d57c550b886a03385190.png │ ├── b9cfe996145ce3228d0bc16486f25faa.png │ ├── ea64f41c531c08bf7df625a229626b14.png │ ├── f0752f71b35581ebe617a271cee01cc0.png │ └── fonts │ └── Roboto │ ├── Black Italic.ttf │ ├── Black.ttf │ ├── Bold Italic.ttf │ ├── Bold.ttf │ ├── Condensed Black Italic.ttf │ ├── Condensed Black.ttf │ ├── Condensed Bold Italic.ttf │ ├── Condensed Bold.ttf │ ├── Condensed ExtraBold Italic.ttf │ ├── Condensed ExtraBold.ttf │ ├── Condensed ExtraLight Italic.ttf │ ├── Condensed ExtraLight.ttf │ ├── Condensed Italic.ttf │ ├── Condensed Light Italic.ttf │ ├── Condensed Light.ttf │ ├── Condensed Medium Italic.ttf │ ├── Condensed Medium.ttf │ ├── Condensed Regular.ttf │ ├── Condensed SemiBold Italic.ttf │ ├── Condensed SemiBold.ttf │ ├── Condensed Thin Italic.ttf │ ├── Condensed Thin.ttf │ ├── ExtraBold Italic.ttf │ ├── ExtraBold.ttf │ ├── ExtraLight Italic.ttf │ ├── ExtraLight.ttf │ ├── Italic.ttf │ ├── Light Italic.ttf │ ├── Light.ttf │ ├── Medium Italic.ttf │ ├── Medium.ttf │ ├── Regular.ttf │ ├── SemiBold Italic.ttf │ ├── SemiBold.ttf │ ├── SemiCondensed Black Italic.ttf │ ├── SemiCondensed Black.ttf │ ├── SemiCondensed Bold Italic.ttf │ ├── SemiCondensed Bold.ttf │ ├── SemiCondensed ExtraBold Italic.ttf │ ├── SemiCondensed ExtraBold.ttf │ ├── SemiCondensed ExtraLight Italic.ttf │ ├── SemiCondensed ExtraLight.ttf │ ├── SemiCondensed Italic.ttf │ ├── SemiCondensed Light Italic.ttf │ ├── SemiCondensed Light.ttf │ ├── SemiCondensed Medium Italic.ttf │ ├── SemiCondensed Medium.ttf │ ├── SemiCondensed Regular.ttf │ ├── SemiCondensed SemiBold Italic.ttf │ ├── SemiCondensed SemiBold.ttf │ ├── SemiCondensed Thin Italic.ttf │ ├── SemiCondensed Thin.ttf │ ├── Thin Italic.ttf │ └── Thin.ttf ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── docs.yml │ ├── publish.yml │ ├── release-bot.yml │ └── test.yml ├── .gitignore ├── .markdownlint.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── docs ├── assets │ ├── favicon.ico │ ├── favicon.svg │ └── logo.svg ├── how-to-guides │ ├── geospatial-queries.md │ ├── integrating-with-pandas.md │ ├── paging-results.md │ ├── working-with-rate-limits.md │ └── working-with-the-client.md ├── index.md ├── overrides │ └── partials │ │ ├── footer.html │ │ └── integrations │ │ └── analytics │ │ └── plausible.html ├── reference │ ├── asyncopenaq.md │ ├── exceptions.md │ ├── openaq.md │ └── responses.md ├── stylesheets │ └── extra.css └── tutorial │ └── getting-started.md ├── mkdocs.yml ├── openaq ├── __init__.py ├── _async │ ├── __init__.py │ ├── client.py │ ├── models │ │ ├── __init__.py │ │ ├── base.py │ │ ├── countries.py │ │ ├── instruments.py │ │ ├── licenses.py │ │ ├── locations.py │ │ ├── manufacturers.py │ │ ├── measurements.py │ │ ├── owners.py │ │ ├── parameters.py │ │ ├── providers.py │ │ └── sensors.py │ └── transport.py ├── _sync │ ├── __init__.py │ ├── client.py │ ├── models │ │ ├── __init__.py │ │ ├── base.py │ │ ├── countries.py │ │ ├── instruments.py │ │ ├── licenses.py │ │ ├── locations.py │ │ ├── manufacturers.py │ │ ├── measurements.py │ │ ├── owners.py │ │ ├── parameters.py │ │ ├── providers.py │ │ └── sensors.py │ └── transport.py ├── shared │ ├── __init__.py │ ├── client.py │ ├── exceptions.py │ ├── models.py │ ├── responses.py │ ├── transport.py │ └── types.py └── vendor │ └── humps.py ├── pyproject.toml └── tests ├── integration ├── __init__.py ├── test_async_client.py └── test_sync_client.py └── unit ├── __init__.py ├── async └── test_async_resources.py ├── mocks.py ├── resources ├── country.json ├── instrument.json ├── license.json ├── location.json ├── manufacturer.json ├── measurement.json ├── owner.json ├── parameter.json ├── provider.json └── sensor.json ├── responses ├── countries.json ├── instruments.json ├── licenses.json ├── locations.json ├── locations_variation.json ├── manufacturers.json ├── measurements.json ├── owners.json ├── parameters.json ├── providers.json └── sensors.json ├── sync ├── test_sync_resources.py └── test_transport.py ├── test_exceptions.py ├── test_shared_client.py ├── test_shared_models.py ├── test_shared_responses.py └── test_sync_client.py /.cache/plugin/privacy/assets/external/fonts.googleapis.com/css.49ea35f2: -------------------------------------------------------------------------------- 1 | css.49ea35f2.css -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkAnkaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBXkaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkBnka.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkC3kaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCHkaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCXkaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkCnkaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkaHkaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmkenkaWzU.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3-UBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3CUBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3GUBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3OUBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3iUBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBA.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMawCUBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/roboto/v47/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMaxKUBGEe.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSV0mf0h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSV0mf0h.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSZ0mf0h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSZ0mf0h.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSd0mf0h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSd0mf0h.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0mQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSh0mQ.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSt0mf0h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSt0mf0h.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSx0mf0h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xTDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vrtSM1J-gEPT5Ese6hmHSx0mf0h.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtElOUlYIw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtElOUlYIw.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEleUlYIw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEleUlYIw.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEluUlYIw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEluUlYIw.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEm-Ul.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEm-Ul.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEmOUlYIw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEmOUlYIw.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEn-UlYIw.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/.cache/plugin/privacy/assets/external/fonts.gstatic.com/s/robotomono/v23/L0xdDF4xlVMF-BfR8bXMIjhOsXG-q2oeuFoqFrlnAIe2Imhk1T8rbociImtEn-UlYIw.woff2 -------------------------------------------------------------------------------- /.cache/plugin/privacy/assets/external/plausible.io/js/plausible.js: -------------------------------------------------------------------------------- 1 | !function(){var a,o=window.location,r=window.document,t=r.currentScript,l=t.getAttribute("data-api")||new URL(t.src).origin+"/api/event",s=t.getAttribute("data-domain");function c(t,e,n){e&&console.warn("Ignoring Event: "+e),n&&n.callback&&n.callback(),"pageview"===t&&(a=!0)}var d=o.href,u={},w=-1,v=!1,p=null,h=0;function n(){var t=r.body||{},e=r.documentElement||{};return Math.max(t.scrollHeight||0,t.offsetHeight||0,t.clientHeight||0,e.scrollHeight||0,e.offsetHeight||0,e.clientHeight||0)}function e(){var t=r.body||{},e=r.documentElement||{},n=window.innerHeight||e.clientHeight||0,e=window.scrollY||e.scrollTop||t.scrollTop||0;return f<=n?f:e+n}function i(){return p?h+(Date.now()-p):h}var f=n(),g=e();function b(){var t=i();!a&&(w 26 | If applicable, please add a self-contained, 27 | [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) 28 | demonstrating the bug. 29 | render: Python 30 | 31 | - type: textarea 32 | id: version 33 | attributes: 34 | label: Python, openaq & OS Version 35 | description: | 36 | Which version of Python & OpenAQ python are you using, and which Operating System? 37 | 38 | Please run the following command and copy the output below: 39 | 40 | ```bash 41 | python -c "import openaq.version; print(openaq.version.version_info())" 42 | ``` 43 | 44 | render: Text 45 | validations: 46 | required: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | 3 | contact_links: 4 | - name: Ask a question / advice on using OpenAQ 5 | url: https://join.slack.com/t/openaq/shared_invite/zt-yzqlgsva-v6McumTjy2BZnegIK9XCVw 6 | about: Connect with OpenAQ community -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: OpenAQ Python Feature request 2 | description: Suggest a new feature for OpenAQ python 3 | labels: [feature request] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thank you for contributing to OpenAQ python 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Description 14 | description: | 15 | Please give as much detail as possible about the feature you would like to suggest. 16 | 17 | You might like to add: 18 | * A demonstration of how code might look when using the feature 19 | * Your use case(s) for the feature 20 | * Why the feature should be added to OpenAQ python. 21 | validations: 22 | required: true 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | python-packages: 9 | patterns: 10 | - "*" -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: generate and publish docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Configure AWS credentials 17 | uses: aws-actions/configure-aws-credentials@v4 18 | with: 19 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 20 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 21 | aws-region: ${{ secrets.AWS_REGION }} 22 | 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.11' 27 | cache: 'pip' 28 | 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install hatch 33 | 34 | - name: Build docs 35 | run: hatch run docs:build 36 | 37 | - name: s3 sync 38 | uses: jakejarvis/s3-sync-action@master 39 | with: 40 | args: --follow-symlinks --delete 41 | env: 42 | SOURCE_DIR: 'site' 43 | AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} 44 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 45 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 46 | AWS_REGION: ${{ secrets.AWS_REGION }} 47 | 48 | - name: invalidate 49 | uses: chetan/invalidate-cloudfront-action@master 50 | env: 51 | DISTRIBUTION: ${{ secrets.AWS_CF_DISTRIBUTION_ID }} 52 | PATHS: '/*' 53 | AWS_REGION: ${{ secrets.AWS_REGION }} 54 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 55 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 56 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | environment: 15 | name: release 16 | 17 | permissions: 18 | contents: read 19 | id-token: write 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: '3.11' 27 | cache: 'pip' 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install hatch 32 | - name: Build package 33 | run: hatch build 34 | - name: Publish package distributions to PyPI 35 | uses: pypa/gh-action-pypi-publish@release/v1 36 | -------------------------------------------------------------------------------- /.github/workflows/release-bot.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | workflow_dispatch: 5 | 6 | name: release-bot.yaml 7 | 8 | permissions: read-all 9 | 10 | jobs: 11 | release-bot: 12 | runs-on: ubuntu-latest 13 | env: 14 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 15 | steps: 16 | - name: Post a message in a channel 17 | uses: slackapi/slack-github-action@v2.0.0 18 | with: 19 | webhook: ${{ secrets.SLACK_WEBHOOK_URL }} 20 | webhook-type: incoming-webhook 21 | payload: | 22 | text: "*New OpenAQ Python SDK Release ${{ github.event.release.tag_name }}*: Read the changelog at https://github.com/openaq/openaq-python/releases/tag/${{ github.event.release.tag_name }}" 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: '3.12' 22 | cache: 'pip' 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install hatch 27 | - name: Coverage 28 | run: hatch run +py=${{ matrix.python-version }} test:cov 29 | 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v5 32 | with: 33 | files: coverage.xml 34 | fail_ci_if_error: true 35 | verbose: true 36 | slug: openaq/openaq-python 37 | env: 38 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .pytest_cache 3 | .mypy_cache 4 | .DS_Store 5 | .coverage 6 | .ruff_cache 7 | site 8 | .vscode 9 | coverage.xml -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD046":false, 3 | "MD013": false 4 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | ## [0.4.0] - 2025-03-31 8 | 9 | ### Updated 10 | 11 | - Client rate limiting functionality 12 | - Drop support for Python 3.8 13 | - Type annotations removing old `Dict` and `List` in favor of `dict` and `list` 14 | 15 | ### Added 16 | 17 | - Mypy type checking 18 | - `ServiceUnavailableError` HTTP error exception. 19 | - `BadGatewayError` HTTP error exception. 20 | - Separated `RateLimitError` and `HTTPRateLimitError` into separated exception. 21 | 22 | ## [0.3.0] - 2024-10-01 23 | 24 | **Breaking changes** 25 | 26 | ### Added 27 | 28 | - Added new methods for passing API key value through environment variable. 29 | - Added new v3 `locations.latest()` and `parameters.latest()` methods. 30 | - Updated `measurements.list()` methods to match new v3 measurements endpoints. 31 | 32 | 33 | ## [0.2.1] - 2024-02-15 34 | 35 | ### Fixed 36 | 37 | - Resolves issue that breaks `OpenAQ.locations()` method and `AsyncOpenAQ.locations()` from upstream API change. Checks for and ignore fields not included in response model. 38 | 39 | ## [0.2.0] - 2023-12-21 40 | 41 | ### Added 42 | 43 | - `parameters_id` arguments for `OpenAQ.locations()` method and `AsyncOpenAQ.locations()` method 44 | - Added `Forbidden` and `ServerError` exceptions to `__all__` export. 45 | - vendored pyhump, removed as `pyproject.toml` dependency 46 | 47 | ## [0.1.1] - 2023-10-31 48 | 49 | ### Fixed 50 | 51 | - `AsyncOpenAQ` client `close()` method. 52 | 53 | ## [0.1.0] - 2023-10-31 54 | 55 | Initial release 56 | 57 | 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the OpenAQ Python SDK 2 | 3 | We welcome contributions to the OpenAQ Python SDK! Here's a guide on how to 4 | contribute effectively: 5 | 6 | ## Code of Conduct 7 | 8 | Please review and adhere to our Code of Conduct: 9 | [https://github.com/openaq/.github/blob/main/CODE_OF_CONDUCT.md](https://github.com/openaq/.github/blob/main/CODE_OF_CONDUCT.md)] 10 | 11 | ## Reporting Issues and Questions 12 | 13 | 1. Search for Existing Issues: Before creating a new issue, search the existing 14 | issues to see if your problem has already been reported. 15 | 2. Use Issue Templates: When reporting bugs or proposing features, please use 16 | one of the provided issue templates: 17 | - Bug Report: Clearly describe the bug, including steps to reproduce it. 18 | - Feature Request: Explain the desired feature and its benefits. 19 | 20 | ### For General Questions 21 | 22 | The issue tracker is primarily for reporting bugs and requesting features. For 23 | general questions, discussions, or seeking help, please visit the project 24 | discussions: 25 | [https://github.com/openaq/openaq-python/discussions](https://github.com/openaq/openaq-python/discussions) 26 | 27 | ## Submitting Pull Requests 28 | 29 | 1. Fork the OpenAQ Python SDK repository to your GitHub 30 | account. 31 | 2. Create a new branch for your feature or bug fix. 32 | 3. Make your changes and commit them with clear, concise 33 | commit messages. 34 | 4. Push your branch to your forked repository. 35 | 5. Create a pull request from your branch to the main 36 | repository. 37 | 6. Ensure that your pull request is linked to an existing 38 | issue. **(Pull requests must be linked to an existing issue.)** 39 | 40 | ### Pull Request Guidelines 41 | 42 | - **Adhere to Coding Standards:** Follow the existing coding style and 43 | conventions. 44 | - **Write Clear Commit Messages:** Use clear and concise commit messages that 45 | describe the changes made. 46 | - **Add Tests:** Write unit tests to cover your changes. 47 | - **Document Your Changes:** Update the documentation if necessary. 48 | - **Be Patient and Respectful:** Be patient and respectful of other 49 | contributors. 50 | 51 | ## Additional Tips 52 | 53 | - Start Small: If you're new to open source, start with small contributions like 54 | fixing typos or improving documentation. 55 | - Ask Questions: Feel free to ask questions on the project discussions forum. 56 | - Be Proactive: Be proactive in addressing feedback and making improvements to 57 | your pull request. 58 | 59 | By following these guidelines, you can help make the OpenAQ Python SDK even 60 | better! 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) OpenAQ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAQ Python SDK 2 | 3 | The official Python SDK for the OpenAQ API. 4 | 5 | > :warning: OpenAQ python is still under active development and may be unstable until a v1.0.0 release 6 | 7 | [![PyPI - Version](https://img.shields.io/pypi/v/openaq.svg)](https://pypi.org/project/openaq) 8 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openaq.svg)](https://pypi.org/project/openaq) 9 | ![Static Badge](https://img.shields.io/badge/type%20checked-mypy-039dfc) 10 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 11 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 12 | [![slack](https://img.shields.io/badge/Slack-OpenAQ-blue?logo=slack&color=%23198cff 13 | )](https://join.slack.com/t/openaq/shared_invite/zt-yzqlgsva-v6McumTjy2BZnegIK9XCVw) 14 | 15 | ----- 16 | 17 | ## Table of Contents 18 | 19 | - [Installation](#installation) 20 | - [Documentation](#documentation) 21 | - [License](#license) 22 | 23 | ## Installation 24 | 25 | OpenAQ python is availble on pip. 26 | 27 | ```console 28 | pip install openaq 29 | ``` 30 | 31 | ## Documentation 32 | 33 | Documentation available at [python.openaq.org](https://python.openaq.org) 34 | 35 | Documentation can also be run locally using `hatch run docs:serve` 36 | 37 | ## License 38 | 39 | The OpenAQ Python SDK is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. 40 | 41 | ## Development 42 | 43 | Code is styled according to [black](https://github.com/psf/black), imports are sorted using [isort](https://pycqa.github.io/isort/), and code is linted using [ruff](https://github.com/astral-sh/ruff). 44 | 45 | Codebase can be automatically formatted and linted by running: 46 | 47 | ```console 48 | hatch run style:fmt 49 | ``` 50 | 51 | style can be checked with: 52 | 53 | ```console 54 | hatch run style:check 55 | ``` 56 | 57 | [mypy](https://mypy-lang.org/) static type checking: 58 | 59 | ```console 60 | hatch run types:check 61 | ``` 62 | 63 | Testing uses [pytest](https://docs.pytest.org/en/7.4.x/). 64 | 65 | ```console 66 | hatch run test:test 67 | ``` 68 | 69 | ## Acknowledgements 70 | 71 | For many years [py-openaq](https://github.com/dhhagan/py-openaq) by David Hagan filled the gap for a Python API SDK for the OpenAQ API. Thank you to David for many years of maintaining py-openaq and for taking the original step to develop a Python tool for OpenAQ. 72 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | A vulnerability is a technical issue with the OpenAQ Python library which 4 | attackers or hackers could use to exploit the library. 5 | 6 | This policy covers only vulnerabilities in the OpenAQ Python library. 7 | 8 | You will not be paid a reward for reporting a vulnerability (known as a ‘bug 9 | bounty’). 10 | 11 | If the security vulnerability is related to the OpenAQ website or 12 | OpenAQ API service see the security policy at: . 13 | 14 | ## Reporting a Vulnerability 15 | 16 | When you are investigating and reporting the vulnerability for OpenAQ Python you 17 | must not: 18 | 19 | - break the law 20 | - access unnecessary or excessive amounts of data 21 | - modify data 22 | - use high-intensity invasive or destructive scanning tools to find 23 | vulnerabilities 24 | - try a denial of service (DOS) - for example overwhelming a service on 25 | openaq.org with a high volume of requests 26 | - tell other people about the vulnerability you have found until we have 27 | disclosed it 28 | - social engineer, phish or physically attack our staff or infrastructure 29 | - demand money to disclose a vulnerability 30 | 31 | If you think you found a vulnerability, and even if you are not sure about it, 32 | please report it right away by sending an email to: . 33 | Please try to be as thorough as possible, describing all the steps and example 34 | code to reproduce the security issue. 35 | -------------------------------------------------------------------------------- /docs/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/docs/assets/favicon.ico -------------------------------------------------------------------------------- /docs/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 16 | 35 | 40 | 45 | 50 | 51 | -------------------------------------------------------------------------------- /docs/how-to-guides/geospatial-queries.md: -------------------------------------------------------------------------------- 1 | # Geospatial Queries 2 | 3 | The OpenAQ API provides two methods for querying features with geospatial 4 | features: 5 | 6 | 1. Point and radius 7 | 2. Bounding box 8 | 9 | The API documentation describes these methods in detail at: 10 | . 11 | 12 | !!! warning 13 | 14 | The query parameters for `coordiantes` and `radius` cannot be used with the `bbox`, only one method can be used in a single call. Mixing methods will result in a [`ValidationError`](../reference/exceptions.md#openaq.shared.exceptions.ValidationError) exception. 15 | 16 | ## Point and radius 17 | 18 | The `locations.list()` function exposes the point and radius parameters through 19 | function parameters named `radius` and `coordinates`. The `coordinates` 20 | parameter takes a tuple of floats representing the center point and the `radius` 21 | value represents the distance in meters to search around the the `coordinates` 22 | point. The coordinates point tuple accepts WGS84 coordinates in the form 23 | latitude, longitude (Y,X). 24 | 25 | === "Sync" 26 | 27 | ```py 28 | import pprint 29 | 30 | from openaq import OpenAQ 31 | 32 | 33 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 34 | locations = client.locations.list( 35 | coordinates=(13.74433, 100.54365), 36 | radius=10_000, 37 | limit=1000 38 | ) 39 | pprint.pp(locations) 40 | client.close() 41 | ``` 42 | 43 | === "Async" 44 | 45 | ```py 46 | import asyncio 47 | import pprint 48 | 49 | from openaq import AsyncOpenAQ 50 | 51 | async def main(): 52 | client = AsyncOpenAQ(api_key='replace-with-a-valid-openaq-api-key') 53 | location = await client.locations.list(coordinates=(13.74433,100.54365), radius=10_000, limit=1000) 54 | pprint.pp(locations) 55 | await client.close() 56 | 57 | if __name__ == '__main__': 58 | loop = asyncio.get_event_loop() 59 | loop.run_until_complete(main()) 60 | ``` 61 | 62 | This will print a list of locations (up to 1,000 per page) within a 10 kilometer radius of the point 13.74433,100.54365, a point in central Bangkok, Thailand. 63 | 64 | ## Bounding box 65 | 66 | The `locations.list()` function also exposes the bounding box parameter through a 67 | function parameters named `bbox`. A bounding box represented a rectangular area represented as a list of WGS84 coordinate values in the order: minimum X, minimum Y, maximum X, maximum Y. 68 | 69 | === "Sync" 70 | 71 | ```py 72 | import pprint 73 | 74 | from openaq import OpenAQ 75 | 76 | 77 | client = OpenAQ(api_key="replace-with-a-valid-openaq-api-key") 78 | locations = client.locations.list( 79 | bbox=(5.488869, -0.396881, 5.732144, -0.021973), limit=1000 80 | ) 81 | pprint.pp(locations) 82 | client.close() 83 | ``` 84 | 85 | === "Async" 86 | 87 | ```py 88 | import asyncio 89 | import pprint 90 | 91 | from openaq import AsyncOpenAQ 92 | 93 | 94 | async def main(): 95 | client = AsyncOpenAQ(api_key="replace-with-a-valid-openaq-api-key") 96 | locations = await client.locations.list( 97 | bbox=(5.488869, -0.396881, 5.732144, -0.021973), limit=1000 98 | ) 99 | pprint.pp(locations) 100 | await client.close() 101 | 102 | 103 | if __name__ == "__main__": 104 | loop = asyncio.get_event_loop() 105 | loop.run_until_complete(main()) 106 | ``` 107 | 108 | ### Generating a bounding box from a polygon 109 | 110 | We can generate a bounding box from an aribtrary polygon from a file, such as GeoJSON or Shapefile. 111 | 112 | ```sh 113 | pip install shapely 114 | ``` 115 | 116 | !!! info 117 | 118 | For this example we use `shapely` but there are many libraries that can provide similar functionality to read and process geospatial data in Python. 119 | 120 | For this example 121 | 122 | === "Sync" 123 | 124 | ```py 125 | from openaq import OpenAQ 126 | import httpx 127 | import shapely 128 | 129 | 130 | client = OpenAQ(api_key="replace-with-a-valid-openaq-api-key") 131 | 132 | res = httpx.get("https://maps.lacity.org/lahub/rest/services/Boundaries/MapServer/7/query?outFields=*&where=1%3D1&f=geojson") 133 | 134 | los_angeles = shapely.from_geojson(res.text) 135 | 136 | locations = client.locations.list( 137 | bbox=los_angles.bounds, limit=1000 138 | ) 139 | pprint.pp(locations) 140 | client.close() 141 | ``` 142 | 143 | === "Async" 144 | 145 | ```py 146 | import asyncio 147 | import pprint 148 | 149 | from openaq import AsyncOpenAQ 150 | 151 | 152 | async def main(): 153 | client = AsyncOpenAQ(api_key="replace-with-a-valid-openaq-api-key") 154 | locations = await client.locations.list( 155 | bbox=(5.488869, -0.396881, 5.732144, -0.021973), limit=1000 156 | ) 157 | pprint.pp(locations) 158 | await client.close() 159 | 160 | 161 | if __name__ == "__main__": 162 | loop = asyncio.get_event_loop() 163 | loop.run_until_complete(main()) 164 | ``` 165 | -------------------------------------------------------------------------------- /docs/how-to-guides/integrating-with-pandas.md: -------------------------------------------------------------------------------- 1 | # Integrating with Pandas 2 | 3 | The OpenAQ Python SDK is designed around native Python data structures 4 | and in turn does not return data as vectorized arrays such as data frames used 5 | by packages like Numpy, Pandas and others. Because native Python data structures 6 | are used integrating these types of libraries is straightforward. 7 | 8 | Because the data returned from the OpenAQ API is highly nested as a deserialized 9 | Python object we need to flatten the resulting response so that it fits as a 10 | 2-dimensional array in Pandas. Fortunarely, Pandas provides a function to 11 | facilitate this, `json_normalize`: 12 | 13 | === "Sync" 14 | 15 | ```py 16 | from openaq import OpenAQ 17 | from pandas import json_normalize 18 | 19 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 20 | response = client.locations.list( 21 | bbox=[-0.464172,5.449908,0.030212,5.691491] 22 | ) 23 | data = response.dict() 24 | df = json_normalize(data['results']) 25 | client.close() 26 | ``` 27 | 28 | === "Async" 29 | 30 | ```py 31 | import asyncio 32 | 33 | from openaq import AsyncOpenAQ 34 | from pandas import json_normalize 35 | 36 | async def main(): 37 | client = AsyncOpenAQ(api_key='replace-with-a-valid-openaq-api-key') 38 | response = await client.locations.list( 39 | bbox=[-0.464172,5.449908,0.030212,5.691491] 40 | ) 41 | data = response.dict() 42 | df = json_normalize(data['results']) 43 | await client.close() 44 | 45 | if __name__ == '__main__': 46 | loop = asyncio.get_event_loop() 47 | loop.run_until_complete(main()) 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/how-to-guides/paging-results.md: -------------------------------------------------------------------------------- 1 | # Paging results 2 | 3 | The OpenAQ API supports pagination to allow fetching of large amounts of data 4 | through smaller pages of results. Pagination is controlled through the `page` 5 | and `limit` query parameters. All resource `list()` methods in OpenAQ Python 6 | provide access to these query parameters through keyword arguments. These values 7 | default to `page=1` and `limit=100`. The `limit` parameter has a maximum value 8 | of 1,000. 9 | 10 | For small result sets, we can use the `found` value from the response `meta` 11 | object to find the total number of pages to loop through. 12 | 13 | ```py hl_lines="13" 14 | from math import ceil 15 | 16 | from openaq import OpenAQ 17 | 18 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 19 | 20 | locations = client.locations.list() 21 | meta = locations.meta 22 | found = meta.found 23 | 24 | limit = 1000 25 | 26 | pages = ceil(found/limit) 27 | 28 | for page in pages: 29 | locations.client.list(limit=limit, page=page) 30 | 31 | client.close() 32 | ``` 33 | 34 | We can then divide the value in `found` by the chosen `limit` value and round up 35 | any remainder with `math.ceil` to get the total number of pages. We can then use 36 | that value to loop through all the result pages. 37 | 38 | ```py hl_lines="13-16" 39 | from math import ceil 40 | 41 | from openaq import OpenAQ 42 | 43 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 44 | 45 | locations = client.locations.list() 46 | meta = locations.meta 47 | found = meta.found 48 | 49 | limit = 1000 50 | 51 | pages = ceil(found/limit) 52 | 53 | for page in pages: 54 | client.locations.list(limit=limit, page=page) 55 | 56 | client.close() 57 | ``` 58 | 59 | For large result sets such as in measurements the `meta` `found` value will 60 | provide an estimate, not the actual number of results. For this we can use a 61 | different pattern, looping through the pages until we encounter a page with no 62 | results. 63 | 64 | ```py hl_lines="15-17" 65 | from openaq import OpenAQ 66 | 67 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 68 | 69 | locations = client.measurements.list() 70 | 71 | limit = 1000 72 | 73 | results = True 74 | 75 | page_num = 1 76 | 77 | while results: 78 | measurements = client.measurements.list(limit=limit, page=page_num) 79 | if len(measurements.results) == 0: 80 | results = False 81 | page_num += 1 82 | 83 | client.close() 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/how-to-guides/working-with-rate-limits.md: -------------------------------------------------------------------------------- 1 | # Working with API rate limits 2 | 3 | OpenAQ limits the number of API requests you can make in a set time to ensure 4 | fair access for all users and prevent overuse. 5 | 6 | A more detailed overview of the API rate limits is also available at the main 7 | documentation site: 8 | 9 | 10 | 11 | With each response the OpenAQ API returns HTTP headers with rate limit 12 | information. The OpenAQ Python SDK also exposes these values through the 13 | `headers` field in the response object. 14 | 15 | ```pycon hl_lines="5" 16 | >>> from openaq import OpenAQ 17 | ... 18 | >>> client = OpenAQ() 19 | >>> locations = client.locations.list() 20 | >>> print(locations.headers) 21 | Headers( 22 | x_ratelimit_limit=60, 23 | x_ratelimit_remaining=59, 24 | x_ratelimit_used=1, 25 | x_ratelimit_reset=58 26 | ) 27 | ``` 28 | 29 | The OpenAQ Python SDK automatically tracks these headers internally. If the rate 30 | limit has been exceeded the client will throw a `RateLimitError` exception. Once 31 | the rate limit reset period has passed the client will send requests up to 32 | allotted rate limit amount and period. 33 | -------------------------------------------------------------------------------- /docs/how-to-guides/working-with-the-client.md: -------------------------------------------------------------------------------- 1 | # Create an instance of the client 2 | 3 | OpenAQ Python provides a synchronous client via the `OpenAQ` class and an 4 | asynchronous client via the `AsyncOpenAQ` class, for working with 5 | `async`/`await` within event loops. This guide will show the options on how to 6 | create an instance of the client class. 7 | 8 | === "Sync" 9 | 10 | ```py 11 | from openaq import OpenAQ 12 | 13 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 14 | ``` 15 | 16 | === "Async" 17 | 18 | ```py 19 | from openaq import AsyncOpenAQ 20 | 21 | client = AsyncOpenAQ(api_key='replace-with-a-valid-openaq-api-key') 22 | ``` 23 | 24 | The OpenAQ API key can be passed directly as an argument on the creation of the 25 | client as shown above. Alternatively, we can use the `OPENAQ_API_KEY` environment 26 | variable to set the api_key value without directly setting the value on client 27 | instantiation. e.g.: 28 | 29 | ```sh 30 | OPENAQ_API_KEY=my-openaq-api-key python main.py 31 | ``` 32 | 33 | Where `main.py` is something like: 34 | 35 | === "Sync" 36 | 37 | ```py title="main.py" 38 | from openaq import OpenAQ 39 | 40 | client = OpenAQ() 41 | # client.api_key will be 'my-openaq-api-key per' the OPENAQ_API_KEY 42 | # environment variable 43 | ``` 44 | 45 | === "Async" 46 | 47 | ```py title="main.py" 48 | from openaq import AsyncOpenAQ 49 | 50 | client = AsyncOpenAQ() 51 | # client.api_key will be 'my-openaq-api-key' per the OPENAQ_API_KEY 52 | # environment variable 53 | ``` 54 | 55 | Setting the API key via the client class argument on instantiation will also 56 | supercede the implicit setting of `api_key` through the `OPENAQ_API_KEY` 57 | environment variable. 58 | 59 | `openaq` uses [httpx](https://www.python-httpx.org/) under-the-hood to make http 60 | calls to the OpenAQ API. The OpenAQ client follows the same pattern as 61 | [httpx](https://www.python-httpx.org/) for opening and closing connections. Once 62 | the client is instantiated an `httpx.Client` (or `httpx.AsyncClient`) is opened 63 | and must be explicitly closed after use. This allows for more efficient usage of 64 | network resources by maintaining an open connection. 65 | 66 | === "Sync" 67 | 68 | ```py 69 | from openaq import OpenAQ 70 | 71 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 72 | client.locations.get(2178) 73 | client.close() 74 | ``` 75 | 76 | === "Async" 77 | 78 | ```py 79 | import asyncio 80 | 81 | from openaq import AsyncOpenAQ 82 | 83 | async def main(): 84 | client = AsyncOpenAQ(api_key='replace-with-a-valid-openaq-api-key') 85 | await client.locations.get(2178) 86 | await client.close() 87 | 88 | if __name__ == '__main__': 89 | loop = asyncio.get_event_loop() 90 | loop.run_until_complete(main()) 91 | ``` 92 | 93 | Alternatively, we can use a context manager to handle closing the connection for 94 | us: 95 | 96 | === "Sync" 97 | 98 | ```py 99 | from openaq import OpenAQ 100 | 101 | with OpenAQ(api_key='replace-with-a-valid-openaq-api-key') as client: 102 | client.locations.get(2178) 103 | ``` 104 | 105 | === "Async" 106 | 107 | ```py 108 | import asyncio 109 | 110 | from openaq import AsyncOpenAQ 111 | 112 | async def main(): 113 | async with AsyncOpenAQ(api_key='replace-with-a-valid-openaq-api-key') as client: 114 | await client.locations.get(2178) 115 | 116 | if __name__ == '__main__': 117 | loop = asyncio.get_event_loop() 118 | loop.run_until_complete(main()) 119 | ``` 120 | 121 | ## API key 122 | 123 | An API Key is required to make requests with the OpenAQ API. 124 | 125 | We can add an API Key to OpenAQ Python one of two ways. As shown above, the API 126 | key string can be directly passed when instantiating the `OpenAQ` or 127 | `AsyncOpenAQ` class via the `api_key` argument. Alternatively, if a key is not 128 | passed to the constructor `OpenAQ` and `AsyncOpenAQ` will automatically look for 129 | a system environment variable named `OPENAQ-API-KEY` and set the value of that 130 | to the `api_key` argument. Directly passing a value to the `api_key` argument in 131 | the client constructors will always override an environment variable. 132 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # OpenAQ Python SDK 2 | 3 | !!! warning 4 | 5 | The OpenAQ python client is still under active development and may be unstable until 6 | a v1.0.0 release. 7 | 8 | The OpenAQ Python SDK provides a Python interface for interacting the with 9 | OpenAQ API. The library is compatible with Python versions 3.9, 3.10, 3.11 10 | ,3.12 and 3.13. 11 | 12 | Features: 13 | 14 | - Synchronous and Asynchronous client options - clients to support standard 15 | synchronous function calls or asynchronous event loop with async/await. 16 | - Comprehensive type annotations - type hinting for improve text editor hints 17 | and autocomplete. 18 | - Deserialized response classes - Python object for easier attribute access, 19 | with options for json and dictionary representations. 20 | -------------------------------------------------------------------------------- /docs/overrides/partials/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /docs/overrides/partials/integrations/analytics/plausible.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/reference/asyncopenaq.md: -------------------------------------------------------------------------------- 1 | ::: openaq._async.client 2 | options: 3 | heading_level: 3 4 | show_bases: false 5 | show_root_heading: false 6 | show_source: true 7 | 8 | __Locations__ 9 | 10 | ::: openaq._async.models.locations.Locations 11 | options: 12 | heading_level: 3 13 | show_bases: false 14 | show_root_heading: false 15 | show_source: true 16 | 17 | __Measurements__ 18 | 19 | ::: openaq._async.models.measurements.Measurements 20 | options: 21 | heading_level: 3 22 | show_bases: false 23 | show_root_heading: false 24 | show_source: true 25 | 26 | __Countries__ 27 | 28 | ::: openaq._async.models.countries.Countries 29 | options: 30 | heading_level: 3 31 | show_bases: false 32 | show_root_heading: false 33 | show_source: true 34 | 35 | __Instruments__ 36 | 37 | ::: openaq._async.models.instruments.Instruments 38 | options: 39 | heading_level: 3 40 | show_bases: false 41 | show_root_heading: false 42 | show_source: true 43 | 44 | __Licenses__ 45 | 46 | ::: openaq._async.models.licenses.Licenses 47 | options: 48 | heading_level: 3 49 | show_bases: false 50 | show_root_heading: false 51 | show_source: true 52 | 53 | __Manufacturers__ 54 | 55 | ::: openaq._async.models.manufacturers.Manufacturers 56 | options: 57 | heading_level: 3 58 | show_bases: false 59 | show_root_heading: false 60 | show_source: true 61 | 62 | __Owners__ 63 | 64 | ::: openaq._async.models.owners.Owners 65 | options: 66 | heading_level: 3 67 | show_bases: false 68 | show_root_heading: false 69 | show_source: true 70 | 71 | __Parameters__ 72 | 73 | ::: openaq._async.models.parameters.Parameters 74 | options: 75 | heading_level: 3 76 | show_bases: false 77 | show_root_heading: false 78 | show_source: true 79 | 80 | __Providers__ 81 | 82 | ::: openaq._async.models.providers.Providers 83 | options: 84 | heading_level: 3 85 | show_bases: false 86 | show_root_heading: false 87 | show_source: true 88 | 89 | __Sensors__ 90 | 91 | ::: openaq._async.models.sensors.Sensors 92 | options: 93 | heading_level: 3 94 | show_bases: false 95 | show_root_heading: false 96 | show_source: true -------------------------------------------------------------------------------- /docs/reference/exceptions.md: -------------------------------------------------------------------------------- 1 | ::: openaq.shared.exceptions 2 | options: 3 | heading_level: 2 4 | members: 5 | - ApiKeyMissingError 6 | - AuthError 7 | - BadRequestError 8 | - BadGatewayError 9 | - ForbiddenError 10 | - GatewayTimeoutError 11 | - HTTPRateLimitError 12 | - NotAuthorizedError 13 | - NotFoundError 14 | - RateLimitError 15 | - ServerError 16 | - ServiceUnavailableError 17 | - ValidationError 18 | show_root_heading: false 19 | show_source: false 20 | -------------------------------------------------------------------------------- /docs/reference/openaq.md: -------------------------------------------------------------------------------- 1 | ::: openaq._sync.client 2 | options: 3 | heading_level: 3 4 | show_bases: false 5 | show_root_heading: false 6 | show_source: true 7 | 8 | __Locations__ 9 | 10 | ::: openaq._sync.models.locations.Locations 11 | options: 12 | heading_level: 3 13 | show_bases: false 14 | show_root_heading: false 15 | show_source: true 16 | 17 | __Measurements__ 18 | 19 | ::: openaq._sync.models.measurements.Measurements 20 | options: 21 | heading_level: 3 22 | show_bases: false 23 | show_root_heading: false 24 | show_source: true 25 | 26 | __Countries__ 27 | 28 | ::: openaq._sync.models.countries.Countries 29 | options: 30 | heading_level: 3 31 | show_bases: false 32 | show_root_heading: false 33 | show_source: true 34 | 35 | __Instruments__ 36 | 37 | ::: openaq._sync.models.instruments.Instruments 38 | options: 39 | heading_level: 3 40 | show_bases: false 41 | show_root_heading: false 42 | show_source: true 43 | 44 | __Manufacturers__ 45 | 46 | ::: openaq._sync.models.manufacturers.Manufacturers 47 | options: 48 | heading_level: 3 49 | show_bases: false 50 | show_root_heading: false 51 | show_source: true 52 | 53 | __Licenses__ 54 | 55 | ::: openaq._sync.models.licenses.Licenses 56 | options: 57 | heading_level: 3 58 | show_bases: false 59 | show_root_heading: false 60 | show_source: true 61 | 62 | __Owners__ 63 | 64 | ::: openaq._sync.models.owners.Owners 65 | options: 66 | heading_level: 3 67 | show_bases: false 68 | show_root_heading: false 69 | show_source: true 70 | 71 | __Parameters__ 72 | 73 | ::: openaq._sync.models.parameters.Parameters 74 | options: 75 | heading_level: 3 76 | show_bases: false 77 | show_root_heading: false 78 | show_source: true 79 | 80 | __Providers__ 81 | 82 | ::: openaq._sync.models.providers.Providers 83 | options: 84 | heading_level: 3 85 | show_bases: false 86 | show_root_heading: false 87 | show_source: true 88 | 89 | __Sensors__ 90 | 91 | ::: openaq._sync.models.sensors.Sensors 92 | options: 93 | heading_level: 3 94 | show_bases: false 95 | show_root_heading: false 96 | show_source: true 97 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-primary-fg-color: #198CFF; 3 | --md-secondary-fg-color: #6A5CD8; 4 | } 5 | 6 | @view-transition { 7 | navigation: auto; 8 | } -------------------------------------------------------------------------------- /docs/tutorial/getting-started.md: -------------------------------------------------------------------------------- 1 | 2 | In this tutorial, we will learn how to install and setup the OpenAQ Python SDK and query a location from the OpenAQ API. 3 | 4 | ## Install OpenAQ Python 5 | 6 | With Python and pip installed on our system we can install the OpenAQ Python SDK via pip. 7 | 8 | ```sh 9 | pip install openaq 10 | ``` 11 | 12 | ## Register for an OpenAQ API Key 13 | 14 | Visit [explore.openaq.org/register](https://explore.openaq.org/register). 15 | 16 | !!! warning 17 | 18 | For this tutorial we will use a _placeholder_ API Key: 'replace-with-a-valid-openaq-api-key'. __Do not__ use this API key in your code, it will not work. Replace the placeholder value with the key you receive after signing up. 19 | 20 | ## The Code 21 | 22 | We will now walkthrough the following code to access a single monitoring location from the OpenAQ API. 23 | 24 | !!! note 25 | 26 | The OpenAQ Python SDK provide synchronous and asynchronous options. For this walkthrough we will use just the synchronous client for simplicity. 27 | 28 | ```py 29 | from openaq import OpenAQ 30 | 31 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 32 | 33 | location = client.locations.get(2178) 34 | 35 | client.close() 36 | 37 | print(location) 38 | ``` 39 | 40 | ### Step 1: Import the OpenAQ class from openaq 41 | 42 | ```py hl_lines="1" 43 | from openaq import OpenAQ 44 | 45 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 46 | 47 | location = client.locations.get(2178) 48 | 49 | client.close() 50 | 51 | print(location) 52 | ``` 53 | 54 | `OpenAQ` is a Python class that provides access to communicate with API resources. 55 | 56 | ### Step 2: Instantiate the client 57 | 58 | ```py hl_lines="3" 59 | from openaq import OpenAQ 60 | 61 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 62 | 63 | location = client.locations.get(2178) 64 | 65 | client.close() 66 | 67 | print(location) 68 | ``` 69 | 70 | Here the `client` variable will be an instance of the class `OpenAQ`. 71 | 72 | This will be the main variable we will use to interact with API. 73 | 74 | ### Step 3: Call the locations get with a locations ID of 2178 75 | 76 | ```py hl_lines="5" 77 | from openaq import OpenAQ 78 | 79 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 80 | 81 | location = client.locations.get(2178) 82 | 83 | client.close() 84 | 85 | print(location) 86 | ``` 87 | 88 | Within the client we access the `locations` resource and call the `get` method to retrieve a single location by its ID 89 | 90 | ### Step 4: Close the client connection 91 | 92 | ```py hl_lines="7" 93 | from openaq import OpenAQ 94 | 95 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 96 | 97 | location = client.locations.get(2178) 98 | 99 | client.close() 100 | 101 | print(location) 102 | ``` 103 | 104 | We close the client to ensure connections are properly cleaned up. 105 | 106 | ### Step 5: Print the results 107 | 108 | ```py hl_lines="9" 109 | from openaq import OpenAQ 110 | 111 | client = OpenAQ(api_key='replace-with-a-valid-openaq-api-key') 112 | 113 | location = client.locations.get(2178) 114 | 115 | client.close() 116 | 117 | print(location) 118 | ``` 119 | 120 | We print the results to our console to see the data from the requested location. 121 | 122 | The output should look something like this: 123 | 124 | ```pycon 125 | 126 | LocationsResponse(meta=Meta(name='openaq-api', website='/', page=1, limit=100, found=1), results=[Location(id=2178, name='Del Norte', locality='Albuquerque', timezone='America/Denver', country=CountryBase(id=13, code='US', name='United States of America'), owner=OwnerBase(id=4, name='Unknown Governmental Organization'), provider=ProviderBase(id=119, name='AirNow'), is_mobile=False, is_monitor=True, instruments=[InstrumentBase(id=2, name='Government Monitor')], sensors=[SensorBase(id=3917, name='o3 ppm', parameter=ParameterBase(id=10, name='o3', units='ppm', display_name='O₃')), SensorBase(id=3916, name='no2 ppm', parameter=ParameterBase(id=7, name='no2', units='ppm', display_name='NO₂')), SensorBase(id=25227, name='co ppm', parameter=ParameterBase(id=8, name='co', units='ppm', display_name='CO')), SensorBase(id=3919, name='pm10 µg/m³', parameter=ParameterBase(id=1, name='pm10', units='µg/m³', display_name='PM10')), SensorBase(id=4272226, name='no ppm', parameter=ParameterBase(id=35, name='no', units='ppm', display_name='NO')), SensorBase(id=4272103, name='nox ppm', parameter=ParameterBase(id=19840, name='nox', units='ppm', display_name='NOx')), SensorBase(id=3918, name='so2 ppm', parameter=ParameterBase(id=9, name='so2', units='ppm', display_name='SO₂')), SensorBase(id=3920, name='pm25 µg/m³', parameter=ParameterBase(id=2, name='pm25', units='µg/m³', display_name='PM2.5'))], coordinates=Coordinates(latitude=35.1353, longitude=-106.584702), bounds=[-106.584702, 35.1353, -106.584702, 35.1353], distance=None, datetime_first=Datetime(utc='2016-03-06T19:00:00+00:00', local='2016-03-06T12:00:00-07:00'), datetime_last=Datetime(utc='2023-10-31T13:00:00+00:00', local='2023-10-31T07:00:00-06:00'))]) 127 | ``` 128 | 129 | ## Conclusion 130 | 131 | You have now successfully requested and downloaded data from the OpenAQ API with the OpenAQ Python SDK. To learn more check out the [how-to guides](../how-to-guides/working-with-the-client.md) and [reference](../reference/openaq.md) documentation. 132 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: OpenAQ Python SDK 2 | site_url: https://python.openaq.org 3 | repo_url: https://github.com/openaq/openaq-python 4 | repo_name: openaq/openaq-python 5 | 6 | theme: 7 | name: "material" 8 | font: false 9 | logo: assets/logo.svg 10 | favicon: assets/favicon.ico 11 | palette: 12 | primary: custom 13 | features: 14 | - search.suggest 15 | - content.tabs.link 16 | - navigation.tracking 17 | - navigation.path 18 | - navigation.top 19 | icon: 20 | repo: fontawesome/brands/github 21 | custom_dir: docs/overrides 22 | 23 | extra: 24 | analytics: 25 | provider: plausible 26 | domain: python.openaq.org 27 | feedback: 28 | title: Was this page helpful? 29 | ratings: 30 | - icon: material/emoticon-happy-outline 31 | name: This page was helpful 32 | data: good 33 | note: >- 34 | Thanks for your feedback! 35 | 36 | - icon: material/emoticon-sad-outline 37 | name: This page could be improved 38 | data: bad 39 | note: >- 40 | Thanks for your feedback! 41 | social: 42 | - icon: fontawesome/brands/slack 43 | link: https://join.slack.com/t/openaq/shared_invite/zt-yzqlgsva-v6McumTjy2BZnegIK9XCVw 44 | - icon: fontawesome/brands/x-twitter 45 | link: https://x.com/@openaq 46 | - icon: fontawesome/brands/bluesky 47 | link: https://bsky.app/profile/openaq.org 48 | - icon: fontawesome/brands/github 49 | link: https://github.com/openaq 50 | - icon: fontawesome/brands/medium 51 | link: https://openaq.medium.com 52 | 53 | extra_css: 54 | - stylesheets/extra.css 55 | 56 | plugins: 57 | - mkdocstrings 58 | - search 59 | - social 60 | - privacy 61 | - material-plausible 62 | 63 | 64 | markdown_extensions: 65 | - admonition 66 | - codehilite: 67 | css_class: highlight 68 | - pymdownx.details 69 | - pymdownx.superfences 70 | - pymdownx.highlight: 71 | anchor_linenums: true 72 | line_spans: __span 73 | pygments_lang_class: true 74 | - pymdownx.inlinehilite 75 | - pymdownx.snippets 76 | - pymdownx.tabbed: 77 | alternate_style: true 78 | - pymdownx.critic 79 | - pymdownx.tilde 80 | - toc: 81 | title: On this page 82 | 83 | nav: 84 | - About OpenAQ Python: index.md 85 | - Tutorial: 86 | - tutorial/getting-started.md 87 | - How-To Guides: 88 | - Working with the client: how-to-guides/working-with-the-client.md 89 | - Paging results: how-to-guides/paging-results.md 90 | - Geospatial queries: how-to-guides/geospatial-queries.md 91 | - Working with rate limits: how-to-guides/working-with-rate-limits.md 92 | - Integrating with Pandas: how-to-guides/integrating-with-pandas.md 93 | - Reference: 94 | - OpenAQ: reference/openaq.md 95 | - AsyncOpenAQ: reference/asyncopenaq.md 96 | - Responses: reference/responses.md 97 | - Exceptions: reference/exceptions.md 98 | -------------------------------------------------------------------------------- /openaq/__init__.py: -------------------------------------------------------------------------------- 1 | """OpenAQ Python SDK.""" 2 | 3 | import logging 4 | 5 | __version__ = "0.4.0" 6 | 7 | 8 | logger = logging.getLogger("openaq") 9 | logger.addHandler(logging.NullHandler()) 10 | 11 | 12 | from ._async.client import AsyncOpenAQ as AsyncOpenAQ 13 | from ._sync.client import OpenAQ as OpenAQ 14 | from .shared.exceptions import ( 15 | ApiKeyMissingError, 16 | AuthError, 17 | BadGatewayError, 18 | BadRequestError, 19 | ForbiddenError, 20 | GatewayTimeoutError, 21 | HTTPRateLimitError, 22 | NotAuthorizedError, 23 | NotFoundError, 24 | RateLimitError, 25 | ServiceUnavailableError, 26 | ServerError, 27 | ValidationError, 28 | ) 29 | 30 | __all__ = [ 31 | "OpenAQ", 32 | "AsyncOpenAQ", 33 | "ApiKeyMissingError", 34 | "AuthError", 35 | "NotAuthorizedError", 36 | "NotFoundError", 37 | "ValidationError", 38 | "GatewayTimeoutError", 39 | "HTTPRateLimitError", 40 | "RateLimitError", 41 | "BadRequestError", 42 | "ForbiddenError", 43 | "ServerError", 44 | "ServiceUnavailableError", 45 | "BadGatewayError", 46 | ] 47 | -------------------------------------------------------------------------------- /openaq/_async/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/openaq/_async/__init__.py -------------------------------------------------------------------------------- /openaq/_async/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Mapping 4 | 5 | from openaq._async.models.countries import Countries 6 | from openaq._async.models.instruments import Instruments 7 | from openaq._async.models.licenses import Licenses 8 | from openaq._async.models.locations import Locations 9 | from openaq._async.models.manufacturers import Manufacturers 10 | from openaq._async.models.measurements import Measurements 11 | from openaq._async.models.owners import Owners 12 | from openaq._async.models.parameters import Parameters 13 | from openaq._async.models.providers import Providers 14 | from openaq._async.models.sensors import Sensors 15 | from openaq.shared.client import ( 16 | DEFAULT_BASE_URL, 17 | BaseClient, 18 | ) 19 | 20 | from .transport import AsyncTransport 21 | 22 | 23 | class AsyncOpenAQ(BaseClient[AsyncTransport]): 24 | """OpenAQ asynchronous client. 25 | 26 | Args: 27 | api_key: The API key for accessing the service. 28 | headers: Additional headers to be sent with the request. 29 | base_url: The base URL for the API endpoint. 30 | 31 | Note: 32 | An API key can either be passed directly to the OpenAQ client class at 33 | instantiation or can be accessed from a system environment variable 34 | name `OPENAQ-API-KEY`. An API key added at instantiation will always 35 | override one set in the environment variable. 36 | 37 | Warning: 38 | Although the `api_key` parameter is not required for instantiating the 39 | OpenAQ client, an API Key is required for using the OpenAQ API. 40 | 41 | Raises: 42 | AuthError: Authentication error, improperly supplied credentials. 43 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 44 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 45 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 46 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 47 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 48 | RateLimitError: Raised when managed client exceeds rate limit. 49 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 50 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 51 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 52 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 53 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 54 | 55 | """ 56 | 57 | def __init__( 58 | self, 59 | api_key: str | None = None, 60 | headers: Mapping[str, str] = {}, 61 | base_url: str = DEFAULT_BASE_URL, 62 | transport: AsyncTransport = AsyncTransport(), 63 | ) -> None: 64 | super().__init__(transport, headers, api_key, base_url) 65 | 66 | self.countries = Countries(self) 67 | self.instruments = Instruments(self) 68 | self.licenses = Licenses(self) 69 | self.locations = Locations(self) 70 | self.manufacturers = Manufacturers(self) 71 | self.measurements = Measurements(self) 72 | self.owners = Owners(self) 73 | self.providers = Providers(self) 74 | self.parameters = Parameters(self) 75 | self.sensors = Sensors(self) 76 | 77 | @property 78 | def transport(self) -> AsyncTransport: 79 | return self._transport 80 | 81 | async def _do( 82 | self, 83 | method: str, 84 | path: str, 85 | *, 86 | params: Mapping[str, Any] | None = None, 87 | headers: Mapping[str, str] | None = None, 88 | ): 89 | self._check_rate_limit() 90 | request_headers = self.build_request_headers(headers) 91 | url = self._base_url + path 92 | data = await self.transport.send_request( 93 | method=method, url=url, params=params, headers=request_headers 94 | ) 95 | return data 96 | 97 | async def _get( 98 | self, 99 | path: str, 100 | *, 101 | params: Mapping[str, str] | None = None, 102 | headers: Mapping[str, Any] | None = None, 103 | ): 104 | return await self._do("get", path, params=params, headers=headers) 105 | 106 | async def close(self): 107 | await self._transport.close() 108 | 109 | async def __aenter__(self) -> AsyncOpenAQ: 110 | return self 111 | 112 | async def __aexit__(self, *_: Any): 113 | await self.close() 114 | -------------------------------------------------------------------------------- /openaq/_async/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/openaq/_async/models/__init__.py -------------------------------------------------------------------------------- /openaq/_async/models/base.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from openaq._async.client import AsyncOpenAQ 5 | 6 | 7 | class AsyncResourceBase: 8 | """Base model for async resources. 9 | 10 | Handles the instantiation of the parent client object. 11 | 12 | 13 | Attributes: 14 | client: an instance of OpenAQ async client object. 15 | 16 | """ 17 | 18 | def __init__( 19 | self, 20 | client: "AsyncOpenAQ", 21 | ): 22 | """Initialize the SyncResourceBase. 23 | 24 | Args: 25 | client (OpenAQ): The client instance to interact with the OpenAQ API. 26 | """ 27 | self._client = client 28 | -------------------------------------------------------------------------------- /openaq/_async/models/countries.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import CountriesResponse 5 | 6 | from .base import AsyncResourceBase 7 | 8 | 9 | class Countries(AsyncResourceBase): 10 | """This provides methods to retrieve country data from the OpenAQ API.""" 11 | 12 | async def get(self, countries_id: int) -> CountriesResponse: 13 | """Retrieve specific country data by its countries ID. 14 | 15 | Args: 16 | countries_id: The countries ID of the country to retrieve. 17 | 18 | Returns: 19 | CountriesResponse: An instance representing the retrieved country. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | country = await self._client._get(f"/countries/{countries_id}") 36 | return CountriesResponse.read_response(country) 37 | 38 | async def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | parameters_id: int | None = None, 45 | providers_id: int | None = None, 46 | ) -> CountriesResponse: 47 | """List countries based on provided filters. 48 | 49 | Provides the ability to filter the countries resource by the given arguments. 50 | 51 | * `page` - Specifies the page number of results to retrieve 52 | * `limit` - Sets the number of results generated per page 53 | * `providers_id` - Filter results by selected providers ID(s) 54 | * `parameters_id` - Filters results by selected parameters ID(s) 55 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 56 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 57 | 58 | Args: 59 | page: The page number. Page count is countries found / limit. 60 | limit: The number of results returned per page. 61 | order_by: Order by operators for results. 62 | sort_order: Sort order (asc/desc). 63 | parameters_id: Single parameters ID or an array of IDs. 64 | providers_id: Single providers ID or an array of IDs. 65 | 66 | Returns: 67 | CountriesResponse: An instance representing the list of retrieved countries. 68 | 69 | Raises: 70 | AuthError: Authentication error, improperly supplied credentials. 71 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 72 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 73 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 74 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 75 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 76 | RateLimitError: Raised when managed client exceeds rate limit. 77 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 78 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 79 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 80 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 81 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 82 | """ 83 | params = build_query_params( 84 | page=page, 85 | limit=limit, 86 | order_by=order_by, 87 | sort_order=sort_order, 88 | parameters_id=parameters_id, 89 | providers_id=providers_id, 90 | ) 91 | 92 | countries = await self._client._get("/countries", params=params) 93 | return CountriesResponse.read_response(countries) 94 | -------------------------------------------------------------------------------- /openaq/_async/models/instruments.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import InstrumentsResponse 5 | 6 | from .base import AsyncResourceBase 7 | 8 | 9 | class Instruments(AsyncResourceBase): 10 | """This provides methods to retrieve instrument data from the OpenAQ API.""" 11 | 12 | async def get(self, providers_id: int) -> InstrumentsResponse: 13 | """Retrieve specific instrument data by its providers ID. 14 | 15 | Args: 16 | providers_id: The providers ID of the instrument to retrieve. 17 | 18 | Returns: 19 | InstrumentsResponse: An instance representing the retrieved instrument. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | instrument = await self._client._get(f"/instruments/{providers_id}") 36 | return InstrumentsResponse.read_response(instrument) 37 | 38 | async def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | ) -> InstrumentsResponse: 45 | """List instruments based on provided filters. 46 | 47 | Provides the ability to filter the instruments resource by the given arguments. 48 | 49 | * `page` - Specifies the page number of results to retrieve. 50 | * `limit` - Sets the number of results generated per page 51 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 52 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 53 | 54 | Args: 55 | page: The page number. Page count is instruments found / limit. 56 | limit: The number of results returned per page. 57 | order_by: Order by operators for results. 58 | sort_order: Sort order (asc/desc). 59 | 60 | Returns: 61 | InstrumentsResponse: An instance representing the list of retrieved instruments. 62 | 63 | Raises: 64 | AuthError: Authentication error, improperly supplied credentials. 65 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 66 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 67 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 68 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 69 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 70 | RateLimitError: Raised when managed client exceeds rate limit. 71 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 72 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 73 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 74 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 75 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 76 | """ 77 | params = build_query_params( 78 | page=page, limit=limit, order_by=order_by, sort_order=sort_order 79 | ) 80 | 81 | instruments = await self._client._get("/instruments", params=params) 82 | return InstrumentsResponse.read_response(instruments) 83 | -------------------------------------------------------------------------------- /openaq/_async/models/licenses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import LicensesResponse 5 | 6 | from .base import AsyncResourceBase 7 | 8 | 9 | class Licenses(AsyncResourceBase): 10 | """This provides methods to retrieve air monitor locations resource from the OpenAQ API.""" 11 | 12 | async def get(self, licenses_id: int) -> LicensesResponse: 13 | """Retrieve a specific license by its licenses ID. 14 | 15 | Args: 16 | licenses_id: The licenses ID of the license to retrieve. 17 | 18 | Returns: 19 | LicensesReponse: An instance representing the retrieved license. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | license = await self._client._get(f"/licenses/{licenses_id}") 36 | return LicensesResponse.read_response(license) 37 | 38 | async def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | ) -> LicensesResponse: 45 | """List licenses based on provided filters. 46 | 47 | Provides the ability to filter the locations resource by the given arguments. 48 | 49 | * `page` - Specifies the page number of results to retrieve 50 | * `limit` - Sets the number of results generated per page 51 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 52 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 53 | 54 | Args: 55 | page: The page number. Page count is locations found / limit. 56 | limit: The number of results returned per page. 57 | order_by: Order by operators for results. 58 | sort_order: Sort order (asc/desc). 59 | 60 | Returns: 61 | LicensesReponse: An instance representing the list of retrieved licenses. 62 | 63 | Raises: 64 | AuthError: Authentication error, improperly supplied credentials. 65 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 66 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 67 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 68 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 69 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 70 | RateLimitError: Raised when managed client exceeds rate limit. 71 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 72 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 73 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 74 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 75 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 76 | """ 77 | params = build_query_params( 78 | page=page, 79 | limit=limit, 80 | order_by=order_by, 81 | sort_order=sort_order, 82 | ) 83 | 84 | licenses = await self._client._get("/licenses", params=params) 85 | return LicensesResponse.read_response(licenses) 86 | -------------------------------------------------------------------------------- /openaq/_async/models/measurements.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | 5 | from openaq.shared.models import build_measurements_path, build_query_params 6 | from openaq.shared.responses import MeasurementsResponse 7 | from openaq.shared.types import Data, Rollup 8 | 9 | from .base import AsyncResourceBase 10 | 11 | 12 | class Measurements(AsyncResourceBase): 13 | """This provides methods to retrieve and list the air quality measurements from the OpenAQ API.""" 14 | 15 | async def list( 16 | self, 17 | sensors_id: int, 18 | data: Data | None = None, 19 | rollup: Rollup | None = None, 20 | date_from: datetime.datetime | str | None = "2016-10-10", 21 | date_to: datetime.datetime | str | None = None, 22 | page: int = 1, 23 | limit: int = 1000, 24 | ) -> MeasurementsResponse: 25 | """List air quality measurements based on provided filters. 26 | 27 | Provides the ability to filter the measurements resource by location, date range, 28 | pagination settings, and specific parameters. 29 | 30 | * `sensors_id` - Filters measurements to a specific sensors ID (required) 31 | * `data` - the base measurement unit to query. options are 'measurements', 'hours', 'days', 'years' 32 | * `rollup` - the period by which to rollup the base measurement data. Options are 'hourly', 'daily', 'yearly' 33 | * `date_from` - Declare a start time for data retrieval 34 | * `date_to` - Declare an end time or data retrieval 35 | * `page` - Specifies the page number of results to retrieve 36 | * `limit` - Sets the number of results generated per page 37 | 38 | Args: 39 | sensors_id: The ID of the sensor for which measurements should be retrieved. 40 | data: The base measurement unit to query 41 | rollup: The period by which to rollup the base measurement data. 42 | date_from: Starting date for the measurement retrieval. Can be a datetime object or ISO-8601 formatted date or datetime string. 43 | date_to: Ending date for the measurement retrieval. Can be a datetime object or ISO-8601 formatted date or datetime string. 44 | page: The page number to fetch. Page count is determined by total measurements found divided by the limit. 45 | limit: The number of results returned per page. 46 | 47 | Returns: 48 | MeasurementsResponse: An instance representing the list of retrieved air quality measurements. 49 | 50 | Raises: 51 | AuthError: Authentication error, improperly supplied credentials. 52 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 53 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 54 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 55 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 56 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 57 | RateLimitError: Raised when managed client exceeds rate limit. 58 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 59 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 60 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 61 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 62 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 63 | """ 64 | params = build_query_params( 65 | page=page, limit=limit, date_from=date_from, date_to=date_to 66 | ) 67 | path = build_measurements_path(sensors_id, data, rollup) 68 | 69 | measurements = await self._client._get(path, params=params) 70 | return MeasurementsResponse.read_response(measurements) 71 | -------------------------------------------------------------------------------- /openaq/_async/models/owners.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import OwnersResponse 5 | 6 | from .base import AsyncResourceBase 7 | 8 | 9 | class Owners(AsyncResourceBase): 10 | """This provides methods to retrieve owner data from the OpenAQ API.""" 11 | 12 | async def get(self, owners_id: int) -> OwnersResponse: 13 | """Retrieve specific owner data by its owners ID. 14 | 15 | Args: 16 | owners_id: The owners ID of the owner to retrieve. 17 | 18 | Returns: 19 | OwnersResponse: An instance representing the retrieved owner. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | owner = await self._client._get(f"/owners/{owners_id}") 36 | return OwnersResponse.read_response(owner) 37 | 38 | async def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | ) -> OwnersResponse: 45 | """List owners based on provided filters. 46 | 47 | Provides the ability to filter the owners resource by the given arguments. 48 | 49 | * `page` - Specifies the page number of results to retrieve 50 | * `limit` - Sets the number of results generated per page 51 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 52 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 53 | 54 | Args: 55 | page: The page number. Page count is owners found / limit. 56 | limit: The number of results returned per page. 57 | order_by: Order by operators for results. 58 | sort_order: Sort order (asc/desc). 59 | 60 | Returns: 61 | OwnersResponse: An instance representing the list of retrieved owners. 62 | 63 | Raises: 64 | AuthError: Authentication error, improperly supplied credentials. 65 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 66 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 67 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 68 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 69 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 70 | RateLimitError: Raised when managed client exceeds rate limit. 71 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 72 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 73 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 74 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 75 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 76 | """ 77 | params = build_query_params( 78 | page=page, limit=limit, order_by=order_by, sort_order=sort_order 79 | ) 80 | 81 | owners = await self._client._get("/owners", params=params) 82 | return OwnersResponse.read_response(owners) 83 | -------------------------------------------------------------------------------- /openaq/_async/models/sensors.py: -------------------------------------------------------------------------------- 1 | from openaq.shared.responses import SensorsResponse 2 | 3 | from .base import AsyncResourceBase 4 | 5 | 6 | class Sensors(AsyncResourceBase): 7 | """This provides methods to retrieve sensor data from the OpenAQ API.""" 8 | 9 | async def get(self, sensors_id: int) -> SensorsResponse: 10 | """Retrieve specific sensor data by its sensors ID. 11 | 12 | Args: 13 | sensors_id: The sensors ID of the sensor to retrieve. 14 | 15 | Returns: 16 | SensorsResponse: An instance representing the retrieved sensor. 17 | 18 | Raises: 19 | AuthError: Authentication error, improperly supplied credentials. 20 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 21 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 22 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 23 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 24 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 25 | RateLimitError: Raised when managed client exceeds rate limit. 26 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 27 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 28 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 29 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 30 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 31 | """ 32 | sensor_response = await self._client._get(f"/sensors/{sensors_id}") 33 | return SensorsResponse.read_response(sensor_response) 34 | -------------------------------------------------------------------------------- /openaq/_async/transport.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Mapping 4 | 5 | import httpx 6 | 7 | from ..shared.transport import BaseTransport, check_response 8 | 9 | 10 | class AsyncTransport(BaseTransport): 11 | def __init__(self): 12 | self.client = httpx.AsyncClient() 13 | 14 | async def send_request( 15 | self, 16 | method: str, 17 | url: str, 18 | params: Mapping[str, str] | None, 19 | headers: Mapping[str, Any], 20 | ): 21 | request = httpx.Request( 22 | method=method, 23 | url=url, 24 | params=params, 25 | headers=headers, 26 | ) 27 | res = await self.client.send(request) 28 | return check_response(res) 29 | 30 | async def close(self): 31 | await self.client.aclose() 32 | -------------------------------------------------------------------------------- /openaq/_sync/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/openaq/_sync/__init__.py -------------------------------------------------------------------------------- /openaq/_sync/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Mapping 4 | 5 | from openaq._sync.models.countries import Countries 6 | from openaq._sync.models.instruments import Instruments 7 | from openaq._sync.models.licenses import Licenses 8 | from openaq._sync.models.locations import Locations 9 | from openaq._sync.models.manufacturers import Manufacturers 10 | from openaq._sync.models.measurements import Measurements 11 | from openaq._sync.models.owners import Owners 12 | from openaq._sync.models.parameters import Parameters 13 | from openaq._sync.models.providers import Providers 14 | from openaq._sync.models.sensors import Sensors 15 | from openaq.shared.client import ( 16 | DEFAULT_BASE_URL, 17 | BaseClient, 18 | ) 19 | from openaq.shared.exceptions import RateLimitError 20 | 21 | from .transport import Transport 22 | 23 | 24 | class OpenAQ(BaseClient[Transport]): 25 | """OpenAQ syncronous client. 26 | 27 | Args: 28 | api_key: The API key for accessing the service. 29 | headers: Additional headers to be sent with the request. 30 | base_url: The base URL for the API endpoint. 31 | 32 | Note: 33 | An API key can either be passed directly to the OpenAQ client class at 34 | instantiation or can be accessed from a system environment variable 35 | name `OPENAQ-API-KEY`. An API key added at instantiation will always 36 | override one set in the environment variable. 37 | 38 | Warning: 39 | Although the `api_key` parameter is not required for instantiating the 40 | OpenAQ client, an API Key is required for using the OpenAQ API. 41 | 42 | Raises: 43 | AuthError: Authentication error, improperly supplied credentials. 44 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 45 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 46 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 47 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 48 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 49 | RateLimitError: Raised when managed client exceeds rate limit. 50 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 51 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 52 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 53 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 54 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 55 | 56 | """ 57 | 58 | def __init__( 59 | self, 60 | api_key: str | None = None, 61 | headers: Mapping[str, str] = {}, 62 | base_url: str = DEFAULT_BASE_URL, 63 | _transport: Transport = Transport(), 64 | ) -> None: 65 | super().__init__(_transport, headers, api_key, base_url) 66 | 67 | self.countries = Countries(self) 68 | self.instruments = Instruments(self) 69 | self.licenses = Licenses(self) 70 | self.locations = Locations(self) 71 | self.manufacturers = Manufacturers(self) 72 | self.measurements = Measurements(self) 73 | self.owners = Owners(self) 74 | self.providers = Providers(self) 75 | self.parameters = Parameters(self) 76 | self.sensors = Sensors(self) 77 | 78 | @property 79 | def transport(self) -> Transport: 80 | return self._transport 81 | 82 | def _do( 83 | self, 84 | method: str, 85 | path: str, 86 | *, 87 | params: Mapping[str, Any] | None = None, 88 | headers: Mapping[str, str] | None = None, 89 | ): 90 | self._check_rate_limit() 91 | request_headers = self.build_request_headers(headers) 92 | url = self._base_url + path 93 | data = self.transport.send_request( 94 | method=method, url=url, params=params, headers=request_headers 95 | ) 96 | self._set_rate_limit(data.headers) 97 | return data 98 | 99 | def _get( 100 | self, 101 | path: str, 102 | *, 103 | params: Mapping[str, str] | None = None, 104 | headers: Mapping[str, Any] | None = None, 105 | ): 106 | return self._do("get", path, params=params, headers=headers) 107 | 108 | def close(self): 109 | self._transport.close() 110 | 111 | def __enter__(self) -> OpenAQ: 112 | return self 113 | 114 | def __exit__(self, *_: Any): 115 | self.close() 116 | -------------------------------------------------------------------------------- /openaq/_sync/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/openaq/_sync/models/__init__.py -------------------------------------------------------------------------------- /openaq/_sync/models/base.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from openaq._sync.client import OpenAQ 5 | 6 | 7 | class SyncResourceBase: 8 | """Base model for sync resources. 9 | 10 | Handles the instantiation of the parent client object. 11 | 12 | 13 | Attributes: 14 | client: an instance of OpenAQ client object. 15 | 16 | """ 17 | 18 | def __init__( 19 | self, 20 | client: "OpenAQ", 21 | ): 22 | """Initialize the AsyncResourceBase. 23 | 24 | Args: 25 | client (OpenAQ): The client instance to interact with the OpenAQ API. 26 | """ 27 | self._client = client 28 | -------------------------------------------------------------------------------- /openaq/_sync/models/countries.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import CountriesResponse 5 | 6 | from .base import SyncResourceBase 7 | 8 | 9 | class Countries(SyncResourceBase): 10 | """Provides methods to retrieve the country resource from the OpenAQ API.""" 11 | 12 | def get(self, countries_id: int) -> CountriesResponse: 13 | """Retrieve specific country data by its countries ID. 14 | 15 | Args: 16 | countries_id: The countries ID of the country to retrieve. 17 | 18 | Returns: 19 | CountriesResponse: An instance representing the retrieved country. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | country_response = self._client._get(f"/countries/{countries_id}") 36 | return CountriesResponse.read_response(country_response) 37 | 38 | def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | parameters_id: int | None = None, 45 | providers_id: int | None = None, 46 | ) -> CountriesResponse: 47 | """List countries based on provided filters. 48 | 49 | Provides the ability to filter the countries resource by the given arguments. 50 | 51 | * `page` - Specifies the page number of results to retrieve 52 | * `limit` - Sets the number of results generated per page 53 | * `providers_id` - Filter results by selected providers ID(s) 54 | * `parameters_id` - Filters results by selected parameters ID(s) 55 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 56 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 57 | 58 | Args: 59 | page: The page number. Page count is countries found / limit. 60 | limit: The number of results returned per page. 61 | order_by: Order by operators for results. 62 | sort_order: Sort order (asc/desc). 63 | parameters_id: Single parameters ID or an array of IDs. 64 | providers_id: Single providers ID or an array of IDs. 65 | 66 | Returns: 67 | CountriesResponse: An instance representing the list of retrieved countries. 68 | 69 | Raises: 70 | AuthError: Authentication error, improperly supplied credentials. 71 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 72 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 73 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 74 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 75 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 76 | RateLimitError: Raised when managed client exceeds rate limit. 77 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 78 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 79 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 80 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 81 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 82 | """ 83 | params = build_query_params( 84 | page=page, 85 | limit=limit, 86 | order_by=order_by, 87 | sort_order=sort_order, 88 | parameters_id=parameters_id, 89 | providers_id=providers_id, 90 | ) 91 | 92 | countries_response = self._client._get("/countries", params=params) 93 | return CountriesResponse.read_response(countries_response) 94 | -------------------------------------------------------------------------------- /openaq/_sync/models/instruments.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import InstrumentsResponse 5 | 6 | from .base import SyncResourceBase 7 | 8 | 9 | class Instruments(SyncResourceBase): 10 | """Provides methods to retrieve the instrument resource from the OpenAQ API.""" 11 | 12 | def get(self, providers_id: int) -> InstrumentsResponse: 13 | """Retrieve specific instrument data by its providers ID. 14 | 15 | Args: 16 | providers_id: The providers ID of the instrument to retrieve. 17 | 18 | Returns: 19 | InstrumentsResponse: An instance representing the retrieved instrument. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | instrument_response = self._client._get(f"/instruments/{providers_id}") 36 | return InstrumentsResponse.read_response(instrument_response) 37 | 38 | def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | ) -> InstrumentsResponse: 45 | """List instruments based on provided filters. 46 | 47 | Provides the ability to filter the instruments resource by the given arguments. 48 | 49 | * `page` - Specifies the page number of results to retrieve. 50 | * `limit` - Sets the number of results generated per page 51 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 52 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 53 | 54 | Args: 55 | page: The page number. Page count is instruments found / limit. 56 | limit: The number of results returned per page. 57 | order_by: Order by operators for results. 58 | sort_order: Sort order (asc/desc). 59 | 60 | Returns: 61 | InstrumentsResponse: An instance representing the list of retrieved instruments. 62 | 63 | Raises: 64 | AuthError: Authentication error, improperly supplied credentials. 65 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 66 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 67 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 68 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 69 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 70 | RateLimitError: Raised when managed client exceeds rate limit. 71 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 72 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 73 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 74 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 75 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 76 | """ 77 | params = build_query_params( 78 | page=page, limit=limit, order_by=order_by, sort_order=sort_order 79 | ) 80 | 81 | instruments_response = self._client._get("/instruments", params=params) 82 | return InstrumentsResponse.read_response(instruments_response) 83 | -------------------------------------------------------------------------------- /openaq/_sync/models/licenses.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import LicensesResponse 5 | 6 | from .base import SyncResourceBase 7 | 8 | 9 | class Licenses(SyncResourceBase): 10 | """Provides methods to retrieve the license resource from the OpenAQ API.""" 11 | 12 | def get(self, licenses_id: int) -> LicensesResponse: 13 | """Retrieve a specific license by its licenses ID. 14 | 15 | Args: 16 | licenses_id: The licenses ID of the license to retrieve. 17 | 18 | Returns: 19 | LicensesReponse: An instance representing the retrieved license. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | license = self._client._get(f"/licenses/{licenses_id}") 36 | return LicensesResponse.read_response(license) 37 | 38 | def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | ) -> LicensesResponse: 45 | """List licenses based on provided filters. 46 | 47 | Provides the ability to filter the locations resource by the given arguments. 48 | 49 | * `page` - Specifies the page number of results to retrieve 50 | * `limit` - Sets the number of results generated per page 51 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 52 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 53 | 54 | Args: 55 | page: The page number. Page count is locations found / limit. 56 | limit: The number of results returned per page. 57 | order_by: Order by operators for results. 58 | sort_order: Sort order (asc/desc). 59 | 60 | Returns: 61 | LicensesReponse: An instance representing the list of retrieved licenses. 62 | 63 | Raises: 64 | AuthError: Authentication error, improperly supplied credentials. 65 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 66 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 67 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 68 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 69 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 70 | RateLimitError: Raised when managed client exceeds rate limit. 71 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 72 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 73 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 74 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 75 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 76 | """ 77 | params = build_query_params( 78 | page=page, 79 | limit=limit, 80 | order_by=order_by, 81 | sort_order=sort_order, 82 | ) 83 | 84 | licenses = self._client._get("/licenses", params=params) 85 | return LicensesResponse.read_response(licenses) 86 | -------------------------------------------------------------------------------- /openaq/_sync/models/measurements.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | 5 | from openaq.shared.models import build_measurements_path, build_query_params 6 | from openaq.shared.responses import MeasurementsResponse 7 | from openaq.shared.types import Data, Rollup 8 | 9 | from .base import SyncResourceBase 10 | 11 | 12 | class Measurements(SyncResourceBase): 13 | """Provides methods to retrieve the measurements resource from the OpenAQ API.""" 14 | 15 | def list( 16 | self, 17 | sensors_id: int, 18 | data: Data | None = None, 19 | rollup: Rollup | None = None, 20 | datetime_from: datetime.datetime | str | None = "2016-10-10", 21 | datetime_to: datetime.datetime | str | None = None, 22 | page: int = 1, 23 | limit: int = 1000, 24 | ) -> MeasurementsResponse: 25 | """List air quality measurements based on provided filters. 26 | 27 | Provides the ability to return sensor measurements resource by date range, 28 | data periods and aggregation rollups, and pagination settings. 29 | 30 | * `sensors_id` - Filters measurements to a specific sensors ID (required) 31 | * `data` - the base measurement unit to query. options are 'measurements', 'hours', 'days', 'years' 32 | * `rollup` - the period by which to rollup the base measurement data. Options are 'hourly', 'daily', 'yearly' 33 | * `datetime_from` - Declare a start time for data retrieval 34 | * `datetime_to` - Declare an end time or data retrieval 35 | * `page` - Specifies the page number of results to retrieve 36 | * `limit` - Sets the number of results generated per page 37 | 38 | Args: 39 | sensors_id: The ID of the sensor for which measurements should be retrieved. 40 | data: The base measurement unit to query 41 | rollup: The period by which to rollup the base measurement data. 42 | datetime_from: Starting date for the measurement retrieval. Can be a datetime object or ISO-8601 formatted date or datetime string. 43 | datetime_to: Ending date for the measurement retrieval. Can be a datetime object or ISO-8601 formatted date or datetime string. 44 | page: The page number to fetch. Page count is determined by total measurements found divided by the limit. 45 | limit: The number of results returned per page. 46 | 47 | Returns: 48 | MeasurementsResponse: An instance representing the list of retrieved air quality measurements. 49 | 50 | Raises: 51 | AuthError: Authentication error, improperly supplied credentials. 52 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 53 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 54 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 55 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 56 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 57 | RateLimitError: Raised when managed client exceeds rate limit. 58 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 59 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 60 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 61 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 62 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 63 | """ 64 | params = build_query_params( 65 | page=page, 66 | limit=limit, 67 | datetime_from=datetime_from, 68 | datetime_to=datetime_to, 69 | ) 70 | path = build_measurements_path(sensors_id, data, rollup) 71 | 72 | measurements_response = self._client._get(path, params=params) 73 | 74 | return MeasurementsResponse.read_response(measurements_response) 75 | -------------------------------------------------------------------------------- /openaq/_sync/models/owners.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from openaq.shared.models import build_query_params 4 | from openaq.shared.responses import OwnersResponse 5 | 6 | from .base import SyncResourceBase 7 | 8 | 9 | class Owners(SyncResourceBase): 10 | """Provides methods to retrieve the owner resource from the OpenAQ API.""" 11 | 12 | def get(self, owners_id: int) -> OwnersResponse: 13 | """Retrieve specific owner data by its owners ID. 14 | 15 | Args: 16 | owners_id: The owners ID of the owner to retrieve. 17 | 18 | Returns: 19 | OwnersResponse: An instance representing the retrieved owner. 20 | 21 | Raises: 22 | AuthError: Authentication error, improperly supplied credentials. 23 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 24 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 25 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 26 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 27 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 28 | RateLimitError: Raised when managed client exceeds rate limit. 29 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 30 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 31 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 32 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 33 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 34 | """ 35 | owner_response = self._client._get(f"/owners/{owners_id}") 36 | return OwnersResponse.read_response(owner_response) 37 | 38 | def list( 39 | self, 40 | page: int = 1, 41 | limit: int = 1000, 42 | order_by: str | None = None, 43 | sort_order: str | None = None, 44 | ) -> OwnersResponse: 45 | """List owners based on provided filters. 46 | 47 | Provides the ability to filter the owners resource by the given arguments. 48 | 49 | * `page` - Specifies the page number of results to retrieve 50 | * `limit` - Sets the number of results generated per page 51 | * `order_by` - Determines the fields by which results are sorted; available values are `id` 52 | * `sort_order` - Works in tandem with `order_by` to specify the direction: either `asc` (ascending) or `desc` (descending) 53 | 54 | Args: 55 | page: The page number. Page count is owners found / limit. 56 | limit: The number of results returned per page. 57 | order_by: Order by operators for results. 58 | sort_order: Sort order (asc/desc). 59 | 60 | Returns: 61 | OwnersResponse: An instance representing the list of retrieved owners. 62 | 63 | Raises: 64 | AuthError: Authentication error, improperly supplied credentials. 65 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 66 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 67 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 68 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 69 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 70 | RateLimitError: Raised when managed client exceeds rate limit. 71 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 72 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 73 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 74 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 75 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 76 | """ 77 | params = build_query_params( 78 | page=page, limit=limit, order_by=order_by, sort_order=sort_order 79 | ) 80 | 81 | owners_response = self._client._get("/owners", params=params) 82 | return OwnersResponse.read_response(owners_response) 83 | -------------------------------------------------------------------------------- /openaq/_sync/models/sensors.py: -------------------------------------------------------------------------------- 1 | from openaq.shared.responses import SensorsResponse 2 | 3 | from .base import SyncResourceBase 4 | 5 | 6 | class Sensors(SyncResourceBase): 7 | """Provides methods to retrieve the sensor resource from the OpenAQ API.""" 8 | 9 | def get(self, sensors_id: int) -> SensorsResponse: 10 | """Retrieve specific sensor data by its sensors ID. 11 | 12 | Args: 13 | sensors_id: The sensors ID of the sensor to retrieve. 14 | 15 | Returns: 16 | SensorsResponse: An instance representing the retrieved sensor. 17 | 18 | Raises: 19 | AuthError: Authentication error, improperly supplied credentials. 20 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 21 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 22 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 23 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 24 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 25 | RateLimitError: Raised when managed client exceeds rate limit. 26 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 27 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 28 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 29 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 30 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 31 | """ 32 | sensor_response = self._client._get(f"/sensors/{sensors_id}") 33 | return SensorsResponse.read_response(sensor_response) 34 | -------------------------------------------------------------------------------- /openaq/_sync/transport.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Mapping 4 | 5 | import httpx 6 | 7 | from openaq.shared.transport import check_response 8 | 9 | 10 | class Transport: 11 | def __init__(self): 12 | self.client = httpx.Client() 13 | 14 | def send_request( 15 | self, 16 | method: str, 17 | url: str, 18 | params: Mapping[str, str] | None, 19 | headers: Mapping[str, Any], 20 | ): 21 | """Sends an HTTP request using the provided method, URL, parameters, and headers.""" 22 | request = httpx.Request( 23 | method=method, 24 | url=url, 25 | params=params, 26 | headers=headers, 27 | ) 28 | res = self.client.send(request) 29 | return check_response(res) 30 | 31 | def close(self): 32 | self.client.close() 33 | -------------------------------------------------------------------------------- /openaq/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/openaq/shared/__init__.py -------------------------------------------------------------------------------- /openaq/shared/exceptions.py: -------------------------------------------------------------------------------- 1 | """Custom exceptions to catch various HTTP codes returned by the OpenAQ API.""" 2 | 3 | from typing import Literal 4 | 5 | 6 | class ClientError(Exception): 7 | """Base class for all client exceptions.""" 8 | 9 | pass 10 | 11 | 12 | class AuthError(ClientError): 13 | """Authentication error, improperly supplied credentials.""" 14 | 15 | pass 16 | 17 | 18 | class ApiKeyMissingError(AuthError): 19 | """API Key missing error.""" 20 | 21 | pass 22 | 23 | 24 | class BadRequestError(ClientError): 25 | """HTTP 400 - Client request error. 26 | 27 | Attributes: 28 | status_code: HTTP status code 29 | """ 30 | 31 | status_code: int = 400 32 | 33 | 34 | class NotAuthorizedError(AuthError): 35 | """HTTP 401- Not authorized. 36 | 37 | Attributes: 38 | status_code: HTTP status code 39 | """ 40 | 41 | status_code: Literal[401] = 401 42 | 43 | 44 | class ForbiddenError(AuthError): 45 | """HTTP 403 - Forbidden. 46 | 47 | Attributes: 48 | status_code: HTTP status code 49 | """ 50 | 51 | status_code: Literal[403] = 403 52 | 53 | 54 | class NotFoundError(ClientError): 55 | """HTTP 404 - Resource not found. 56 | 57 | Attributes: 58 | status_code: HTTP status code 59 | """ 60 | 61 | status_code: Literal[404] = 404 62 | 63 | 64 | class ValidationError(BadRequestError): 65 | """HTTP 422 - Client request with invalid parameters. 66 | 67 | Attributes: 68 | status_code: HTTP status code 69 | """ 70 | 71 | status_code: Literal[422] = 422 72 | 73 | 74 | class RateLimitError(ClientError): 75 | """Exception for catching rate limit exceedances from client.""" 76 | 77 | 78 | class HTTPRateLimitError(ClientError): 79 | """HTTP 429 - Client request exceeds rate limits. 80 | 81 | Attributes: 82 | status_code: HTTP status code 83 | """ 84 | 85 | status_code: Literal[429] = 429 86 | 87 | 88 | class ServerError(Exception): 89 | """HTTP 500 - Server or service failure. 90 | 91 | Attributes: 92 | status_code: HTTP status code 93 | """ 94 | 95 | status_code: int = 500 96 | 97 | 98 | class BadGatewayError(ServerError): 99 | """HTTP 502 - Indicates that the gateway or proxy received an invalid response from the upstream server. 100 | 101 | Attributes: 102 | status_code: HTTP status code 103 | """ 104 | 105 | status_code: Literal[502] = 502 106 | 107 | 108 | class ServiceUnavailableError(ServerError): 109 | """HTTP 503 - Indicates that the server is not ready to handle the request. 110 | 111 | Attributes: 112 | status_code: HTTP status code 113 | """ 114 | 115 | status_code: Literal[503] = 503 116 | 117 | 118 | class GatewayTimeoutError(ServerError): 119 | """HTTP 504 - Timeout from the gateway after failing to route request to destination service. 120 | 121 | Attributes: 122 | status_code: HTTP status code 123 | """ 124 | 125 | status_code: Literal[504] = 504 126 | -------------------------------------------------------------------------------- /openaq/shared/models.py: -------------------------------------------------------------------------------- 1 | """Shared utility functions for working with query parameter models.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | from typing import Any, Mapping 7 | 8 | from .exceptions import NotFoundError 9 | from .types import Data, Rollup 10 | 11 | 12 | def build_query_params(**kwargs) -> Mapping[str, Any]: 13 | """Prepares keyword arguments to a dict for httpx query parameters. 14 | 15 | Loops through keyword args, if the value is of type list, tuple, or datetime.datetime, 16 | it makes appropriate conversions. This prevents httpx from splitting list or tuple types 17 | to individual query params e.g. coordinates=42,42 instead of coordinates=42&coordinates=42. 18 | For datetime.datetime types, it converts them to ISO 8601 formatted strings. 19 | 20 | Args: 21 | **kwargs: Arbitrary keyword arguments. 22 | 23 | Returns: 24 | dictionary of the prepared values. 25 | 26 | """ 27 | params = {} 28 | for k, v in kwargs.items(): 29 | if v is not None: 30 | if isinstance(v, list) or isinstance(v, tuple): 31 | v = ",".join([str(x) for x in v]) 32 | elif isinstance(v, datetime.datetime): 33 | v = v.isoformat() 34 | params[k] = v 35 | return params 36 | 37 | 38 | def build_measurements_path( 39 | sensors_id: int, data: Data | None = None, rollup: Rollup | None = None 40 | ): 41 | """Prepares and builds the path for measurements endpoint using data and rollup parameters. 42 | 43 | Args: 44 | sensors_id: sensors ID 45 | data: the base measurement unit to query. options are 'measurements', 'hours', 'days', 'years' 46 | rollup: the period by which to rollup the base measurement data. Options are 'hourly', 'daily', 'yearly' 47 | 48 | Returns: 49 | string of url path 50 | 51 | Raises: 52 | NotFoundError: 53 | """ 54 | base_path = f'/sensors/{sensors_id}' 55 | if data == 'measurements' and rollup in ( 56 | 'hourofday', 57 | 'dayofweek', 58 | 'monthofyear', 59 | 'yearly', 60 | ): 61 | raise NotFoundError() 62 | if data == 'hours' and rollup == 'hourly': 63 | raise NotFoundError() 64 | if data == 'days' and rollup == 'daily': 65 | raise NotFoundError() 66 | if data == 'days' and rollup == 'hourofday': 67 | raise NotFoundError() 68 | if data == 'years' and rollup in ('monthly', 'yearly'): 69 | raise NotFoundError() 70 | if data == 'years' and rollup in ('hourofday', 'dayofweek', 'monthofyear'): 71 | raise NotFoundError() 72 | if data == 'measurements' or data == None: 73 | path = base_path + '/measurements' 74 | if data == 'hours': 75 | path = base_path + '/hours' 76 | if data == 'days': 77 | path = base_path + '/days' 78 | if data == 'years': 79 | path = base_path + '/years' 80 | if rollup: 81 | path += f'/{rollup}' 82 | return path 83 | -------------------------------------------------------------------------------- /openaq/shared/transport.py: -------------------------------------------------------------------------------- 1 | """Base class and utlity functions for working with client transport.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from abc import ABC, abstractmethod 7 | from http import HTTPStatus 8 | from typing import Any, Mapping 9 | 10 | from httpx import Response 11 | 12 | from openaq.shared.exceptions import ( 13 | BadGatewayError, 14 | BadRequestError, 15 | ForbiddenError, 16 | GatewayTimeoutError, 17 | HTTPRateLimitError, 18 | NotAuthorizedError, 19 | NotFoundError, 20 | ServerError, 21 | ServiceUnavailableError, 22 | ValidationError, 23 | ) 24 | 25 | logger = logging.getLogger("transport") 26 | 27 | 28 | class BaseTransport(ABC): 29 | """Base class for client transport classes.""" 30 | 31 | @abstractmethod 32 | async def send_request( 33 | self, 34 | method: str, 35 | url: str, 36 | params: Mapping[str, str], 37 | headers: Mapping[str, Any], 38 | ): 39 | """Sends request using transport. To be overridden in subclass. 40 | 41 | Args: 42 | method: HTTP method name 43 | url: URL to send requestion to 44 | params: query parameters to add to URL 45 | headers: HTTP headers to include wiht request 46 | """ 47 | raise NotImplementedError 48 | 49 | @abstractmethod 50 | async def close(self): 51 | """Closes transport connection. To be overridden in subclass.""" 52 | raise NotImplementedError 53 | 54 | 55 | def check_response(res: Response) -> Response | None: 56 | """Checks the HTTP response of the request. 57 | 58 | Args: 59 | res: an httpx.Response object 60 | 61 | Returns: 62 | httpx.Response 63 | 64 | Raises: 65 | AuthError: Authentication error, improperly supplied credentials. 66 | BadRequestError: Raised for HTTP 400 error, indicating a client request error. 67 | NotAuthorizedError: Raised for HTTP 401 error, indicating the client is not authorized. 68 | ForbiddenError: Raised for HTTP 403 error, indicating the request is forbidden. 69 | NotFoundError: Raised for HTTP 404 error, indicating a resource is not found. 70 | ValidationError: Raised for HTTP 422 error, indicating invalid request parameters. 71 | HTTPRateLimitError: Raised for HTTP 429 error, indicating rate limit exceeded. 72 | ServerError: Raised for HTTP 500 error, indicating an internal server error or unexpected server-side issue. 73 | BadGatewayError: Raised for HTTP 502, indicating that the gateway or proxy received an invalid response from the upstream server. 74 | ServiceUnavailableError: Raised for HTTP 503, indicating that the server is not ready to handle the request. 75 | GatewayTimeoutError: Raised for HTTP 504 error, indicating a gateway timeout. 76 | """ 77 | if res.status_code >= HTTPStatus.OK and res.status_code < HTTPStatus.BAD_REQUEST: 78 | return res 79 | elif res.status_code == HTTPStatus.BAD_REQUEST: 80 | logger.exception(f"HTTP {res.status_code} - {res.text}") 81 | raise BadRequestError(res.text) 82 | elif res.status_code == HTTPStatus.NOT_FOUND: 83 | logger.exception(f"HTTP {res.status_code} - {res.text}") 84 | raise NotFoundError(res.text) 85 | elif res.status_code == HTTPStatus.FORBIDDEN: 86 | logger.exception(f"HTTP {res.status_code} - {res.text}") 87 | raise ForbiddenError(res.text) 88 | elif res.status_code == HTTPStatus.UNPROCESSABLE_ENTITY: 89 | logger.exception(f"HTTP {res.status_code} - {res.text}") 90 | raise ValidationError(res.text) 91 | elif res.status_code == HTTPStatus.TOO_MANY_REQUESTS: 92 | logger.exception(f"HTTP {res.status_code} - {res.text}") 93 | raise HTTPRateLimitError(res.text) 94 | elif res.status_code == HTTPStatus.UNAUTHORIZED: 95 | logger.exception(f"HTTP {res.status_code} - {res.text}") 96 | raise NotAuthorizedError(res.text) 97 | elif res.status_code == HTTPStatus.INTERNAL_SERVER_ERROR: 98 | logger.exception(f"HTTP {res.status_code} - {res.text}") 99 | raise ServerError(res.text) 100 | elif res.status_code == HTTPStatus.BAD_GATEWAY: 101 | logger.exception(f"HTTP {res.status_code} - {res.text}") 102 | raise BadGatewayError(res.text) 103 | elif res.status_code == HTTPStatus.SERVICE_UNAVAILABLE: 104 | logger.exception(f"HTTP {res.status_code} - {res.text}") 105 | raise ServiceUnavailableError(res.text) 106 | elif res.status_code == HTTPStatus.GATEWAY_TIMEOUT: 107 | logger.exception(f"HTTP {res.status_code} - {res.text}") 108 | raise GatewayTimeoutError( 109 | "Your request timed out on the server. " 110 | "Consider reducing the complexity of your request." 111 | ) 112 | else: 113 | logger.exception(f"HTTP {res.status_code} - {res.text}") 114 | raise Exception 115 | -------------------------------------------------------------------------------- /openaq/shared/types.py: -------------------------------------------------------------------------------- 1 | """Shared custom types.""" 2 | 3 | from typing import Literal 4 | 5 | Rollup = Literal[ 6 | 'hourly', 'daily', 'monthly', 'yearly', 'hourofday', 'dayofweek', 'monthofyear' 7 | ] 8 | Data = Literal['measurements', 'hours', 'days', 'years'] 9 | -------------------------------------------------------------------------------- /openaq/vendor/humps.py: -------------------------------------------------------------------------------- 1 | """This module is a vendored version of the 'humps' package originally found at: https://github.com/nficano/humps. 2 | 3 | The original package is licensed under the "Unlicense", a public domain equivalent license. 4 | For more details, see: https://github.com/nficano/humps/blob/master/LICENSE 5 | Thanks to Nick Ficano for the original implementation. 6 | """ 7 | 8 | import re 9 | from collections.abc import Mapping 10 | 11 | UNDERSCORE_RE = re.compile(r"(?<=[^\-_])[\-_]+[^\-_]") 12 | ACRONYM_RE = re.compile(r"([A-Z\d]+)(?=[A-Z\d]|$)") 13 | SPLIT_RE = re.compile(r"([\-_]*(?<=[^0-9])(?=[A-Z])[^A-Z]*[\-_]*)") 14 | 15 | 16 | def camelize(str_or_iter): 17 | """Convert a string, dict, or list of dicts to camel case. 18 | 19 | :param str_or_iter: 20 | A string or iterable. 21 | :type str_or_iter: Union[list, dict, str] 22 | :rtype: Union[list, dict, str] 23 | :returns: 24 | camelized string, dictionary, or list of dictionaries. 25 | """ 26 | if isinstance(str_or_iter, (list, Mapping)): 27 | return _process_keys(str_or_iter, camelize) 28 | 29 | s = _is_none(str_or_iter) 30 | if s.isupper() or s.isnumeric(): 31 | return str_or_iter 32 | 33 | if len(s) != 0 and not s[:2].isupper(): 34 | s = s[0].lower() + s[1:] 35 | 36 | # For string "hello_world", match will contain 37 | # the regex capture group for "_w". 38 | return UNDERSCORE_RE.sub(lambda m: m.group(0)[-1].upper(), s) 39 | 40 | 41 | def decamelize(str_or_iter): 42 | """Convert a string, dict, or list of dicts to snake case. 43 | 44 | :param str_or_iter: 45 | A string or iterable. 46 | :type str_or_iter: Union[list, dict, str] 47 | :rtype: Union[list, dict, str] 48 | :returns: 49 | snake cased string, dictionary, or list of dictionaries. 50 | """ 51 | if isinstance(str_or_iter, (list, Mapping)): 52 | return _process_keys(str_or_iter, decamelize) 53 | 54 | s = _is_none(str_or_iter) 55 | if s.isupper() or s.isnumeric(): 56 | return str_or_iter 57 | 58 | return _separate_words(_fix_abbreviations(s)).lower() 59 | 60 | 61 | def _is_none(_in): 62 | """Determines if the input is None and returns a string with white-space removed. 63 | 64 | :param _in: input 65 | :return: 66 | an empty sting if _in is None, 67 | else the input is returned with white-space removed 68 | """ 69 | return "" if _in is None else re.sub(r"\s+", "", str(_in)) 70 | 71 | 72 | def _separate_words(string, separator="_"): 73 | """Split words that are separated by case differentiation. 74 | 75 | :param string: Original string. 76 | :param separator: String by which the individual 77 | words will be put back together. 78 | :returns: 79 | New string. 80 | """ 81 | return separator.join(s for s in SPLIT_RE.split(string) if s) 82 | 83 | 84 | def _process_keys(str_or_iter, fn): 85 | if isinstance(str_or_iter, list): 86 | return [_process_keys(k, fn) for k in str_or_iter] 87 | if isinstance(str_or_iter, Mapping): 88 | return {fn(k): _process_keys(v, fn) for k, v in str_or_iter.items()} 89 | return str_or_iter 90 | 91 | 92 | def _fix_abbreviations(string): 93 | """Rewrite incorrectly cased acronyms, initialisms, and abbreviations, allowing them to be decamelized correctly. For example, given the string "APIResponse", this function is responsible for ensuring the output is "api_response" instead of "a_p_i_response". 94 | 95 | :param string: A string that may contain an incorrectly cased abbreviation. 96 | :type string: str 97 | :rtype: str 98 | :returns: 99 | A rewritten string that is safe for decamelization. 100 | """ 101 | return ACRONYM_RE.sub(lambda m: m.group(0).title(), string) 102 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "openaq" 7 | description = "Official OpenAQ Python SDK." 8 | readme = "README.md" 9 | requires-python = ">=3.9" 10 | license = "MIT" 11 | keywords = [] 12 | authors = [ 13 | { name = "Russ Biggs", email = "russ@openaq.org" }, 14 | { name = "Gabe Fosse", email = "gabe@openaq.org" }, 15 | ] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Programming Language :: Python :: Implementation :: CPython", 25 | "Programming Language :: Python :: Implementation :: PyPy", 26 | ] 27 | dependencies = ["httpx==0.28.1"] 28 | dynamic = ["version"] 29 | 30 | [project.optional-dependencies] 31 | all = ["orjson >=3.2.1"] 32 | 33 | [project.urls] 34 | Documentation = "https://github.com/openaq/openaq-python#readme" 35 | Issues = "https://github.com/openaq/openaq-python/issues" 36 | Source = "https://github.com/openaq/openaq-python" 37 | 38 | [tool.hatch.version] 39 | path = "openaq/__init__.py" 40 | 41 | 42 | [tool.hatch.envs.test] 43 | dependencies = [ 44 | "coverage[toml]", 45 | "pytest", 46 | "pytest-asyncio", 47 | "pytest-cov", 48 | "pytest-mock", 49 | "respx", 50 | "freezegun", 51 | ] 52 | 53 | [tool.pytest.ini_options] 54 | testpaths = ["tests/unit"] 55 | 56 | 57 | 58 | [tool.hatch.envs.test.scripts] 59 | test-unit = 'pytest tests/unit/ -s -vv' 60 | test-integration = 'pytest tests/integration/ -s -vv' 61 | cov = 'pytest --cov --cov-branch --cov-report=xml' 62 | 63 | [[tool.hatch.envs.test.matrix]] 64 | python = ["3.9", "3.10", "3.11", "3.12", "3.13"] 65 | 66 | [tool.hatch.envs.types] 67 | detached = true 68 | dependencies = ["mypy"] 69 | 70 | [[tool.mypy.overrides]] 71 | module = ["httpx.*", "openaq"] 72 | ignore_missing_imports = true 73 | 74 | [tool.hatch.envs.types.scripts] 75 | check = "mypy -p openaq --install-types --non-interactive" 76 | 77 | [tool.hatch.envs.style] 78 | detached = true 79 | dependencies = ["ruff", "black", "isort", "pydocstyle"] 80 | 81 | [tool.hatch.envs.style.scripts] 82 | check = ["ruff check", "black --check --diff .", "isort --check-only --diff ."] 83 | fmt = ["isort format", "black .", "check"] 84 | 85 | [tool.hatch.envs.docs] 86 | dependencies = [ 87 | "mkdocs", 88 | "mkdocstrings[python]", 89 | "mkdocs-material[imaging]", 90 | "material-plausible-plugin", 91 | ] 92 | 93 | [tool.hatch.envs.docs.scripts] 94 | build = ["mkdocs build -c"] 95 | serve = ["mkdocs serve -a localhost:8090"] 96 | 97 | [tool.coverage.run] 98 | branch = true 99 | parallel = true 100 | omit = ["openaq/__about__.py", "openaq/vendor/*", "tests/*"] 101 | 102 | [tool.coverage.report] 103 | exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] 104 | exclude_also = [ 105 | "raise AssertionError", 106 | "raise NotImplementedError", 107 | "@(abc\\.)?abstractmethod", 108 | ] 109 | 110 | [tool.ruff] 111 | extend-exclude = ["tests"] 112 | 113 | ignore = [ 114 | "E501", # line too long, handled by black 115 | "D104", 116 | ] 117 | select = ["D"] 118 | 119 | [tool.ruff.pydocstyle] 120 | convention = "google" 121 | 122 | [tool.black] 123 | target-version = ['py313'] 124 | skip-string-normalization = true 125 | 126 | [tool.isort] 127 | multi_line_output = 3 128 | include_trailing_comma = true 129 | force_grid_wrap = 0 130 | combine_as_imports = true 131 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openaq/openaq-python/6ddc67264629af335176de1add64b1d2a75a20ab/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_async_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | import pytest 5 | 6 | from openaq._async.client import AsyncOpenAQ 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def event_loop(request): 11 | """ 12 | Redefine the event loop to support session/module-scoped fixtures; 13 | see https://github.com/pytest-dev/pytest-asyncio/issues/68 14 | """ 15 | policy = asyncio.get_event_loop_policy() 16 | loop = policy.new_event_loop() 17 | 18 | try: 19 | yield loop 20 | finally: 21 | loop.close() 22 | 23 | 24 | @pytest.mark.asyncio(scope="class") 25 | class TestAsyncClient: 26 | loop: asyncio.AbstractEventLoop 27 | 28 | @pytest.fixture(autouse=True) 29 | def setup(self): 30 | self.client = AsyncOpenAQ(base_url=os.environ.get("TEST_BASE_URL")) 31 | 32 | async def test_locations_list(self): 33 | await self.client.locations.list() 34 | 35 | async def test_locations_get(self): 36 | await self.client.locations.get(1) 37 | 38 | async def test_latest_get(self): 39 | await self.client.locations.latest(1) 40 | 41 | async def test_countries_list(self): 42 | await self.client.countries.list() 43 | 44 | async def test_countries_get(self): 45 | await self.client.countries.get(1) 46 | 47 | async def test_licenses_list(self): 48 | await self.client.licenses.list() 49 | 50 | async def test_licenses_get(self): 51 | await self.client.licenses.get(1) 52 | 53 | async def test_owners_list(self): 54 | await self.client.owners.list() 55 | 56 | async def test_owners_get(self): 57 | await self.client.owners.get(1) 58 | 59 | async def test_parameters_list(self): 60 | await self.client.parameters.list() 61 | 62 | async def test_parameters_get(self): 63 | await self.client.parameters.get(1) 64 | 65 | async def test_parameters_latest(self): 66 | await self.client.parameters.latest(1) 67 | 68 | async def test_providers_list(self): 69 | await self.client.providers.list() 70 | 71 | async def test_providers_get(self): 72 | await self.client.providers.get(1) 73 | 74 | async def test_instruments_list(self): 75 | await self.client.instruments.list() 76 | 77 | async def test_instruments_get(self): 78 | await self.client.instruments.get(1) 79 | 80 | async def test_manufacturers_list(self): 81 | await self.client.manufacturers.list() 82 | 83 | async def test_manufacturers_get(self): 84 | await self.client.manufacturers.get(1) 85 | 86 | async def test_manufacturers_instruments(self): 87 | await self.client.manufacturers.instruments(1) 88 | 89 | async def test_sensors_get(self): 90 | await self.client.sensors.get(1) 91 | -------------------------------------------------------------------------------- /tests/integration/test_sync_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from openaq._sync.client import OpenAQ 6 | 7 | 8 | class TestClient: 9 | @pytest.fixture(autouse=True) 10 | def setup(self): 11 | self.client = OpenAQ(base_url=os.environ.get("TEST_BASE_URL")) 12 | 13 | def test_locations_list(self): 14 | self.client.locations.list() 15 | 16 | def test_locations_get(self): 17 | self.client.locations.get(1) 18 | 19 | def test_countries_list(self): 20 | self.client.countries.list() 21 | 22 | def test_countries_get(self): 23 | self.client.countries.get(1) 24 | 25 | def test_licenses_list(self): 26 | self.client.licenses.list() 27 | 28 | def test_licenses_get(self): 29 | self.client.licenses.get(1) 30 | 31 | def test_owners_list(self): 32 | self.client.owners.list() 33 | 34 | def test_owners_get(self): 35 | self.client.owners.get(1) 36 | 37 | def test_parameters_list(self): 38 | self.client.parameters.list() 39 | 40 | def test_parameters_get(self): 41 | self.client.parameters.get(1) 42 | 43 | def test_parameters_latest(self): 44 | self.client.parameters.latest(1) 45 | 46 | def test_providers_list(self): 47 | self.client.providers.list() 48 | 49 | def test_providers_get(self): 50 | self.client.providers.get(1) 51 | 52 | def test_instruments_list(self): 53 | self.client.instruments.list() 54 | 55 | def test_instruments_get(self): 56 | self.client.instruments.get(1) 57 | 58 | def test_manufacturers_list(self): 59 | self.client.manufacturers.list() 60 | 61 | def test_manufacturers_get(self): 62 | self.client.manufacturers.get(1) 63 | 64 | def test_manufacturers_instruments(self): 65 | self.client.manufacturers.instruments(1) 66 | 67 | def test_sensors_get(self): 68 | self.client.sensors.get(1) 69 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-present OpenAQ 2 | # 3 | # SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /tests/unit/async/test_async_resources.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from openaq._async.models.base import AsyncResourceBase 4 | 5 | 6 | def test_sync_resource_base_init(): 7 | mock_client = MagicMock() 8 | resource = AsyncResourceBase(client=mock_client) 9 | assert resource._client == mock_client 10 | -------------------------------------------------------------------------------- /tests/unit/mocks.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any, Mapping 3 | from unittest import mock 4 | 5 | import httpx 6 | import pytest 7 | 8 | 9 | class MockTransport: 10 | def __init__(self, response: httpx.Response = None): 11 | self.response = response 12 | 13 | def send_request( 14 | self, 15 | method: str, 16 | url: str, 17 | params: Mapping[str, str], 18 | headers: Mapping[str, Any], 19 | ): 20 | return self.response 21 | 22 | def close(self): 23 | ... 24 | 25 | 26 | @pytest.fixture(scope='class') 27 | def mock_openaq_api_key_env_vars(scope='class'): 28 | with mock.patch.dict( 29 | os.environ, {"OPENAQ_API_KEY": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} 30 | ): 31 | yield 32 | -------------------------------------------------------------------------------- /tests/unit/resources/country.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "id": 7, 4 | "code": "FJ", 5 | "name": "Fiji", 6 | "datetimeFirst": "2021-01-08T23:15:31Z", 7 | "datetimeLast": "2023-08-28T21:33:44Z", 8 | "parameters": [ 9 | { 10 | "id": 1, 11 | "name": "pm10", 12 | "units": "µg/m³", 13 | "displayName": null 14 | }, 15 | { 16 | "id": 2, 17 | "name": "pm25", 18 | "units": "µg/m³", 19 | "displayName": null 20 | }, 21 | { 22 | "id": 19, 23 | "name": "pm1", 24 | "units": "µg/m³", 25 | "displayName": null 26 | }, 27 | { 28 | "id": 100, 29 | "name": "temperature", 30 | "units": "c", 31 | "displayName": null 32 | }, 33 | { 34 | "id": 125, 35 | "name": "um003", 36 | "units": "particles/cm³", 37 | "displayName": null 38 | }, 39 | { 40 | "id": 126, 41 | "name": "um010", 42 | "units": "particles/cm³", 43 | "displayName": null 44 | }, 45 | { 46 | "id": 128, 47 | "name": "temperature", 48 | "units": "f", 49 | "displayName": null 50 | }, 51 | { 52 | "id": 129, 53 | "name": "um050", 54 | "units": "particles/cm³", 55 | "displayName": null 56 | }, 57 | { 58 | "id": 130, 59 | "name": "um025", 60 | "units": "particles/cm³", 61 | "displayName": null 62 | }, 63 | { 64 | "id": 132, 65 | "name": "pressure", 66 | "units": "mb", 67 | "displayName": null 68 | }, 69 | { 70 | "id": 133, 71 | "name": "um005", 72 | "units": "particles/cm³", 73 | "displayName": null 74 | }, 75 | { 76 | "id": 134, 77 | "name": "humidity", 78 | "units": "%", 79 | "displayName": null 80 | }, 81 | { 82 | "id": 135, 83 | "name": "um100", 84 | "units": "particles/cm³", 85 | "displayName": null 86 | }, 87 | { 88 | "id": 150, 89 | "name": "voc", 90 | "units": "iaq", 91 | "displayName": null 92 | } 93 | ] 94 | } 95 | 96 | -------------------------------------------------------------------------------- /tests/unit/resources/instrument.json: -------------------------------------------------------------------------------- 1 | { "id": 1, "name": "N/A", "isMonitor": false, "manufacturer": { "id": 1, "name": "OpenAQ admin" } } -------------------------------------------------------------------------------- /tests/unit/resources/license.json: -------------------------------------------------------------------------------- 1 | {"id":31,"name":"Taiwan Open Government Data License","commercialUseAllowed":true,"attributionRequired":true,"shareAlikeRequired":true,"modificationAllowed":true,"redistributionAllowed":true,"sourceUrl":"https://data.gov.tw/en/license"} -------------------------------------------------------------------------------- /tests/unit/resources/location.json: -------------------------------------------------------------------------------- 1 | { "id": 2178, "name": "Del Norte", "locality": "Albuquerque", "timezone": "America/Denver", "country": { "id": 13, "code": "US", "name": "United States of America" }, "owner": { "id": 4, "name": "Unknown Governmental Organization" }, "provider": { "id": 119, "name": "AirNow" }, "isMobile": false, "isMonitor": true, "instruments": [{ "id": 2, "name": "Government Monitor" }], "sensors": [ { "id": 3918, "name": "so2 ppm", "parameter": { "id": 9, "name": "so2", "units": "ppm", "displayName": "SO₂" } }, { "id": 25227, "name": "co ppm", "parameter": { "id": 8, "name": "co", "units": "ppm", "displayName": "CO" } }, { "id": 4272226, "name": "no ppm", "parameter": { "id": 35, "name": "no", "units": "ppm", "displayName": "NO" } }, { "id": 4272103, "name": "nox ppm", "parameter": { "id": 19840, "name": "nox", "units": "ppm", "displayName": "NOx" } }, { "id": 3917, "name": "o3 ppm", "parameter": { "id": 10, "name": "o3", "units": "ppm", "displayName": "O₃" } }, { "id": 3920, "name": "pm25 µg/m³", "parameter": { "id": 2, "name": "pm25", "units": "µg/m³", "displayName": "PM2.5" } }, { "id": 3916, "name": "no2 ppm", "parameter": { "id": 7, "name": "no2", "units": "ppm", "displayName": "NO₂" } }, { "id": 3919, "name": "pm10 µg/m³", "parameter": { "id": 1, "name": "pm10", "units": "µg/m³", "displayName": "PM10" } } ], "coordinates": { "latitude": 35.1353, "longitude": -106.584702 }, "bounds": [-106.584702, 35.1353, -106.584702, 35.1353], "distance": null, "datetimeFirst": { "utc": "2016-03-06T19:00:00+00:00", "local": "2016-03-06T12:00:00-07:00" }, "datetimeLast": { "utc": "2023-10-13T14:00:00+00:00", "local": "2023-10-13T08:00:00-06:00" } } -------------------------------------------------------------------------------- /tests/unit/resources/manufacturer.json: -------------------------------------------------------------------------------- 1 | { "id": 1, "name": "OpenAQ admin", "instruments": [ { "id": 1, "name": "N/A" } ] } -------------------------------------------------------------------------------- /tests/unit/resources/measurement.json: -------------------------------------------------------------------------------- 1 | { "period": { "label": "1hour", "interval": "01:00:00", "datetimeFrom": { "utc": "2023-10-16T12:00:00+00:00", "local": "2023-10-16T06:00:00-06:00" }, "datetimeTo": { "utc": "2023-10-16T13:00:00+00:00", "local": "2023-10-16T07:00:00-06:00" } }, "value": 6, "parameter": { "id": 2, "name": "pm25", "units": "µg/m³", "displayName": null }, "coordinates": null, "summary": { "min": 6, "q02": 6, "q25": 6, "median": 6, "q75": 6, "q98": 6, "max": 6, "sd": null }, "coverage": { "expectedCount": 1, "expectedInterval": "01:00:00", "observedCount": 1, "observedInterval": "01:00:00", "percentComplete": 100, "percentCoverage": 100, "datetimeFrom": { "utc": "2023-10-16T13:00:00+00:00", "local": "2023-10-16T07:00:00-06:00" }, "datetimeTo": { "utc": "2023-10-16T13:00:00+00:00", "local": "2023-10-16T07:00:00-06:00" } } } -------------------------------------------------------------------------------- /tests/unit/resources/owner.json: -------------------------------------------------------------------------------- 1 | { "id": 1, "name": "OpenAQ admin" } -------------------------------------------------------------------------------- /tests/unit/resources/parameter.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2, 3 | "name": "pm25", 4 | "units": "µg/m³", 5 | "displayName": "PM2.5", 6 | "description": "Particulate matter less than 2.5 micrometers in diameter mass concentration" 7 | } 8 | -------------------------------------------------------------------------------- /tests/unit/resources/provider.json: -------------------------------------------------------------------------------- 1 | {"id":43,"name":"Abu Dhabi","sourceName":"adairquality-ae","exportPrefix":"abudhabi","datetimeAdded":"2023-03-29T20:23:57.054584Z","datetimeFirst":"2022-10-28T16:00:00Z","datetimeLast":"2024-05-20T14:00:00Z","entitiesId":1,"parameters":[{"id":1,"name":"pm10","units":"µg/m³","displayName":null},{"id":3,"name":"o3","units":"µg/m³","displayName":null},{"id":4,"name":"co","units":"µg/m³","displayName":null},{"id":5,"name":"no2","units":"µg/m³","displayName":null},{"id":6,"name":"so2","units":"µg/m³","displayName":null}],"bbox":{"type":"Polygon","coordinates":[[[52.75479,23.09569],[52.75479,24.48889],[55.76579,24.48889],[55.76579,23.09569],[52.75479,23.09569]]]}} -------------------------------------------------------------------------------- /tests/unit/resources/sensor.json: -------------------------------------------------------------------------------- 1 | {"id": 3916,"name": "no2 ppm","parameter": {"id": 7,"name": "no2","units": "ppm","displayName": "NO₂"},"datetimeFirst": {"utc": "2016-03-06T20:00:00Z","local": "2016-03-06T13:00:00-07:00"},"datetimeLast": {"utc": "2024-08-26T21:00:00Z","local": "2024-08-26T15:00:00-06:00"},"coverage": {"expectedCount": 74281,"expectedInterval": "74281:00:00","observedCount": 55369,"observedInterval": "55369:00:00","percentComplete": 75.0,"percentCoverage": 75.0,"datetimeFrom": null,"datetimeTo": null},"latest": {"datetime": {"utc": "2024-08-26T21:00:00Z","local": "2024-08-26T15:00:00-06:00"},"value": 0.0007,"coordinates": {"latitude": 35.1353,"longitude": -106.584702}},"summary": {"min": 0.0,"q02": null,"q25": null,"median": null,"q75": null,"q98": null,"max": 0.05,"avg": 0.008717821872214676,"sd": null}} -------------------------------------------------------------------------------- /tests/unit/responses/countries.json: -------------------------------------------------------------------------------- 1 | { "meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 1 }, "results": [ { "id": 7, "code": "FJ", "name": "Fiji", "datetimeFirst": "2021-01-08T23:15:31Z", "datetimeLast": "2023-08-28T21:33:44Z", "parameters": [ { "id": 1, "name": "pm10", "units": "µg/m³", "displayName": null }, { "id": 2, "name": "pm25", "units": "µg/m³", "displayName": null }, { "id": 19, "name": "pm1", "units": "µg/m³", "displayName": null }, { "id": 100, "name": "temperature", "units": "c", "displayName": null }, { "id": 125, "name": "um003", "units": "particles/cm³", "displayName": null }, { "id": 126, "name": "um010", "units": "particles/cm³", "displayName": null }, { "id": 128, "name": "temperature", "units": "f", "displayName": null }, { "id": 129, "name": "um050", "units": "particles/cm³", "displayName": null }, { "id": 130, "name": "um025", "units": "particles/cm³", "displayName": null }, { "id": 132, "name": "pressure", "units": "mb", "displayName": null }, { "id": 133, "name": "um005", "units": "particles/cm³", "displayName": null }, { "id": 134, "name": "humidity", "units": "%", "displayName": null }, { "id": 135, "name": "um100", "units": "particles/cm³", "displayName": null }, { "id": 150, "name": "voc", "units": "iaq", "displayName": null } ] } ] } -------------------------------------------------------------------------------- /tests/unit/responses/instruments.json: -------------------------------------------------------------------------------- 1 | { "meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 6 }, "results": [ { "id": 1, "name": "N/A", "isMonitor": false, "manufacturer": { "id": 1, "name": "OpenAQ admin" } }, { "id": 2, "name": "Government Monitor", "isMonitor": true, "manufacturer": { "id": 4, "name": "Unknown Governmental Organization" } }, { "id": 3, "name": "PurpleAir Sensor", "isMonitor": false, "manufacturer": { "id": 8, "name": "PurpleAir" } }, { "id": 4, "name": "Clarity Sensor", "isMonitor": false, "manufacturer": { "id": 9, "name": "Clarity" } }, { "id": 5, "name": "HabitatMap Sensor", "isMonitor": false, "manufacturer": { "id": 11, "name": "HabitatMap" } }, { "id": 6, "name": "Senstate Sensor", "isMonitor": false, "manufacturer": { "id": 10, "name": "Senstate" } } ] } -------------------------------------------------------------------------------- /tests/unit/responses/licenses.json: -------------------------------------------------------------------------------- 1 | { "meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 6 }, "results": [{"id":30,"name":"CC BY-SA 4.0 DEED","commercialUseAllowed":true,"attributionRequired":true,"shareAlikeRequired":true,"modificationAllowed":true,"redistributionAllowed":true,"sourceUrl":"https://creativecommons.org/licenses/by-sa/4.0/"},{"id":31,"name":"Taiwan Open Government Data License","commercialUseAllowed":true,"attributionRequired":true,"shareAlikeRequired":true,"modificationAllowed":true,"redistributionAllowed":true,"sourceUrl":"https://data.gov.tw/en/license"} ] } -------------------------------------------------------------------------------- /tests/unit/responses/locations.json: -------------------------------------------------------------------------------- 1 | { "meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 1 }, "results": [ { "id": 2178, "name": "Del Norte", "locality": "Albuquerque", "timezone": "America/Denver", "country": { "id": 13, "code": "US", "name": "United States of America" }, "owner": { "id": 4, "name": "Unknown Governmental Organization" }, "provider": { "id": 119, "name": "AirNow" }, "isMobile": false, "isMonitor": true, "instruments": [{ "id": 2, "name": "Government Monitor" }], "sensors": [ { "id": 3918, "name": "so2 ppm", "parameter": { "id": 9, "name": "so2", "units": "ppm", "displayName": "SO₂" } }, { "id": 25227, "name": "co ppm", "parameter": { "id": 8, "name": "co", "units": "ppm", "displayName": "CO" } }, { "id": 4272226, "name": "no ppm", "parameter": { "id": 35, "name": "no", "units": "ppm", "displayName": "NO" } }, { "id": 4272103, "name": "nox ppm", "parameter": { "id": 19840, "name": "nox", "units": "ppm", "displayName": "NOx" } }, { "id": 3917, "name": "o3 ppm", "parameter": { "id": 10, "name": "o3", "units": "ppm", "displayName": "O₃" } }, { "id": 3920, "name": "pm25 µg/m³", "parameter": { "id": 2, "name": "pm25", "units": "µg/m³", "displayName": "PM2.5" } }, { "id": 3916, "name": "no2 ppm", "parameter": { "id": 7, "name": "no2", "units": "ppm", "displayName": "NO₂" } }, { "id": 3919, "name": "pm10 µg/m³", "parameter": { "id": 1, "name": "pm10", "units": "µg/m³", "displayName": "PM10" } } ], "coordinates": { "latitude": 35.1353, "longitude": -106.584702 }, "bounds": [-106.584702, 35.1353, -106.584702, 35.1353], "distance": null, "datetimeFirst": { "utc": "2016-03-06T19:00:00+00:00", "local": "2016-03-06T12:00:00-07:00" }, "datetimeLast": { "utc": "2023-10-13T14:00:00+00:00", "local": "2023-10-13T08:00:00-06:00" } } ] } -------------------------------------------------------------------------------- /tests/unit/responses/locations_variation.json: -------------------------------------------------------------------------------- 1 | { "meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 1 }, "results": [ { "id": 2178, "name": "Del Norte", "locality": null, "timezone": "America/Denver", "country": { "id": 13, "code": "US", "name": "United States of America" }, "owner": { "id": 4, "name": "Unknown Governmental Organization" }, "provider": { "id": 119, "name": "AirNow" }, "isMobile": false, "isMonitor": true, "instruments": [{ "id": 2, "name": "Government Monitor" }], "sensors": [ { "id": 3918, "name": "so2 ppm", "parameter": { "id": 9, "name": "so2", "units": "ppm", "displayName": "SO₂" } }, { "id": 25227, "name": "co ppm", "parameter": { "id": 8, "name": "co", "units": "ppm", "displayName": "CO" } }, { "id": 4272226, "name": "no ppm", "parameter": { "id": 35, "name": "no", "units": "ppm", "displayName": "NO" } }, { "id": 4272103, "name": "nox ppm", "parameter": { "id": 19840, "name": "nox", "units": "ppm", "displayName": "NOx" } }, { "id": 3917, "name": "o3 ppm", "parameter": { "id": 10, "name": "o3", "units": "ppm", "displayName": "O₃" } }, { "id": 3920, "name": "pm25 µg/m³", "parameter": { "id": 2, "name": "pm25", "units": "µg/m³", "displayName": "PM2.5" } }, { "id": 3916, "name": "no2 ppm", "parameter": { "id": 7, "name": "no2", "units": "ppm", "displayName": "NO₂" } }, { "id": 3919, "name": "pm10 µg/m³", "parameter": { "id": 1, "name": "pm10", "units": "µg/m³", "displayName": "PM10" } } ], "coordinates": { "latitude": 35.1353, "longitude": -106.584702 }, "bounds": [-106.584702, 35.1353, -106.584702, 35.1353], "distance": null, "datetimeFirst": { "utc": "2016-03-06T19:00:00+00:00", "local": "2016-03-06T12:00:00-07:00" }, "datetimeLast": { "utc": "2023-10-13T14:00:00+00:00", "local": "2023-10-13T08:00:00-06:00" } } ] } -------------------------------------------------------------------------------- /tests/unit/responses/manufacturers.json: -------------------------------------------------------------------------------- 1 | { "meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 6 }, "results": [ { "id": 1, "name": "OpenAQ admin", "instruments": [ { "id": 1, "name": "N/A" } ] }, { "id": 4, "name": "Unknown Governmental Organization", "instruments": [ { "id": 2, "name": "Government Monitor" } ] }, { "id": 8, "name": "PurpleAir", "instruments": [ { "id": 3, "name": "PurpleAir Sensor" } ] }, { "id": 9, "name": "Clarity", "instruments": [ { "id": 4, "name": "Clarity Sensor" } ] }, { "id": 10, "name": "Senstate", "instruments": [ { "id": 6, "name": "Senstate Sensor" } ] }, { "id": 11, "name": "HabitatMap", "instruments": [ { "id": 5, "name": "HabitatMap Sensor" } ] } ] } -------------------------------------------------------------------------------- /tests/unit/responses/measurements.json: -------------------------------------------------------------------------------- 1 | { "meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 2 }, "results": [ { "period": { "label": "1hour", "interval": "01:00:00", "datetimeFrom": { "utc": "2023-10-16T12:00:00+00:00", "local": "2023-10-16T06:00:00-06:00" }, "datetimeTo": { "utc": "2023-10-16T13:00:00+00:00", "local": "2023-10-16T07:00:00-06:00" } }, "value": 6, "parameter": { "id": 2, "name": "pm25", "units": "µg/m³", "displayName": null }, "coordinates": null, "summary": { "min": 6, "q02": 6, "q25": 6, "median": 6, "q75": 6, "q98": 6, "max": 6, "sd": null }, "coverage": { "expectedCount": 1, "expectedInterval": "01:00:00", "observedCount": 1, "observedInterval": "01:00:00", "percentComplete": 100, "percentCoverage": 100, "datetimeFrom": { "utc": "2023-10-16T13:00:00+00:00", "local": "2023-10-16T07:00:00-06:00" }, "datetimeTo": { "utc": "2023-10-16T13:00:00+00:00", "local": "2023-10-16T07:00:00-06:00" } } }, { "period": { "label": "1hour", "interval": "01:00:00", "datetimeFrom": { "utc": "2023-10-16T13:00:00+00:00", "local": "2023-10-16T07:00:00-06:00" }, "datetimeTo": { "utc": "2023-10-16T14:00:00+00:00", "local": "2023-10-16T08:00:00-06:00" } }, "value": 7.5, "parameter": { "id": 2, "name": "pm25", "units": "µg/m³", "displayName": null }, "coordinates": null, "summary": { "min": 7.5, "q02": 7.5, "q25": 7.5, "median": 7.5, "q75": 7.5, "q98": 7.5, "max": 7.5, "sd": null }, "coverage": { "expectedCount": 1, "expectedInterval": "01:00:00", "observedCount": 1, "observedInterval": "01:00:00", "percentComplete": 100, "percentCoverage": 100, "datetimeFrom": { "utc": "2023-10-16T14:00:00+00:00", "local": "2023-10-16T08:00:00-06:00" }, "datetimeTo": { "utc": "2023-10-16T14:00:00+00:00", "local": "2023-10-16T08:00:00-06:00" } } } ] } -------------------------------------------------------------------------------- /tests/unit/responses/owners.json: -------------------------------------------------------------------------------- 1 | {"meta": { "name": "openaq-api", "website": "/", "page": 1, "limit": 100, "found": 4 }, "results": [ { "id": 1, "name": "OpenAQ admin" }, { "id": 4, "name": "Unknown Governmental Organization" }, { "id": 5, "name": "Unknown Research Organization" }, { "id": 6, "name": "Unknown Community Organization" } ] } -------------------------------------------------------------------------------- /tests/unit/responses/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "name": "openaq-api", 4 | "website": "/", 5 | "page": 1, 6 | "limit": 100, 7 | "found": 1 8 | }, 9 | "results": [ 10 | { 11 | "id": 2, 12 | "name": "pm25", 13 | "units": "µg/m³", 14 | "displayName": "PM2.5", 15 | "description": "Particulate matter less than 2.5 micrometers in diameter mass concentration" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tests/unit/responses/providers.json: -------------------------------------------------------------------------------- 1 | {"meta":{"name":"openaq-api","website":"/","page":1,"limit":1,"found":158},"results":[{"id":43,"name":"Abu Dhabi","sourceName":"adairquality-ae","exportPrefix":"abudhabi","datetimeAdded":"2023-03-29T20:23:57.054584Z","datetimeFirst":"2022-10-28T16:00:00Z","datetimeLast":"2024-05-20T14:00:00Z","entitiesId":1,"parameters":[{"id":1,"name":"pm10","units":"µg/m³","displayName":null},{"id":3,"name":"o3","units":"µg/m³","displayName":null},{"id":4,"name":"co","units":"µg/m³","displayName":null},{"id":5,"name":"no2","units":"µg/m³","displayName":null},{"id":6,"name":"so2","units":"µg/m³","displayName":null}],"bbox":{"type":"Polygon","coordinates":[[[52.75479,23.09569],[52.75479,24.48889],[55.76579,24.48889],[55.76579,23.09569],[52.75479,23.09569]]]}}]} -------------------------------------------------------------------------------- /tests/unit/responses/sensors.json: -------------------------------------------------------------------------------- 1 | {"meta":{"name":"openaq-api","website":"/","page":1,"limit":100,"found":8},"results":[{"id":3916,"name":"no2 ppm","parameter":{"id":7,"name":"no2","units":"ppm","displayName":"NO₂"},"datetimeFirst":{"utc":"2016-03-06T20:00:00Z","local":"2016-03-06T13:00:00-07:00"},"datetimeLast":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"coverage":{"expectedCount":74281,"expectedInterval":"74281:00:00","observedCount":55369,"observedInterval":"55369:00:00","percentComplete":75.0,"percentCoverage":75.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"value":0.0007,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":0.0,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":0.05,"avg":0.008717821872214676,"sd":null}},{"id":3918,"name":"so2 ppm","parameter":{"id":9,"name":"so2","units":"ppm","displayName":"SO₂"},"datetimeFirst":{"utc":"2016-03-06T20:00:00Z","local":"2016-03-06T13:00:00-07:00"},"datetimeLast":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"coverage":{"expectedCount":74281,"expectedInterval":"74281:00:00","observedCount":56738,"observedInterval":"56738:00:00","percentComplete":76.0,"percentCoverage":76.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"value":0.0003,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":0.0,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":0.01,"avg":0.0004021463570515162,"sd":null}},{"id":3920,"name":"pm25 µg/m³","parameter":{"id":2,"name":"pm25","units":"µg/m³","displayName":"PM2.5"},"datetimeFirst":{"utc":"2016-03-06T20:00:00Z","local":"2016-03-06T13:00:00-07:00"},"datetimeLast":{"utc":"2024-08-26T20:00:00Z","local":"2024-08-26T14:00:00-06:00"},"coverage":{"expectedCount":74280,"expectedInterval":"74280:00:00","observedCount":56521,"observedInterval":"56521:00:00","percentComplete":76.0,"percentCoverage":76.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T20:00:00Z","local":"2024-08-26T14:00:00-06:00"},"value":4.1,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":-4.9,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":169.9,"avg":5.779898468115439,"sd":null}},{"id":3917,"name":"o3 ppm","parameter":{"id":10,"name":"o3","units":"ppm","displayName":"O₃"},"datetimeFirst":{"utc":"2016-03-06T20:00:00Z","local":"2016-03-06T13:00:00-07:00"},"datetimeLast":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"coverage":{"expectedCount":74281,"expectedInterval":"74281:00:00","observedCount":57925,"observedInterval":"57925:00:00","percentComplete":78.0,"percentCoverage":78.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"value":0.059,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":0.0,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":0.092,"avg":0.03580498661195457,"sd":null}},{"id":3919,"name":"pm10 µg/m³","parameter":{"id":1,"name":"pm10","units":"µg/m³","displayName":"PM10"},"datetimeFirst":{"utc":"2016-03-06T20:00:00Z","local":"2016-03-06T13:00:00-07:00"},"datetimeLast":{"utc":"2024-08-26T20:00:00Z","local":"2024-08-26T14:00:00-06:00"},"coverage":{"expectedCount":74280,"expectedInterval":"74280:00:00","observedCount":58936,"observedInterval":"58936:00:00","percentComplete":79.0,"percentCoverage":79.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T20:00:00Z","local":"2024-08-26T14:00:00-06:00"},"value":15.0,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":-5.0,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":987.0,"avg":20.403254948170144,"sd":null}},{"id":25227,"name":"co ppm","parameter":{"id":8,"name":"co","units":"ppm","displayName":"CO"},"datetimeFirst":{"utc":"2019-08-06T01:00:00Z","local":"2019-08-05T19:00:00-06:00"},"datetimeLast":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"coverage":{"expectedCount":44348,"expectedInterval":"44348:00:00","observedCount":34977,"observedInterval":"34977:00:00","percentComplete":79.0,"percentCoverage":79.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"value":0.1,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":-0.1,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":2.31,"avg":0.22669790310918872,"sd":null}},{"id":4272103,"name":"nox ppm","parameter":{"id":19840,"name":"nox","units":"ppm","displayName":"NOx"},"datetimeFirst":{"utc":"2023-03-29T18:00:00Z","local":"2023-03-29T12:00:00-06:00"},"datetimeLast":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"coverage":{"expectedCount":12387,"expectedInterval":"12387:00:00","observedCount":10879,"observedInterval":"10879:00:00","percentComplete":88.0,"percentCoverage":88.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"value":0.001,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":-0.007,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":0.182,"avg":0.009197976326842345,"sd":null}},{"id":4272226,"name":"no ppm","parameter":{"id":35,"name":"no","units":"ppm","displayName":"NO"},"datetimeFirst":{"utc":"2023-03-29T18:00:00Z","local":"2023-03-29T12:00:00-06:00"},"datetimeLast":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"coverage":{"expectedCount":12387,"expectedInterval":"12387:00:00","observedCount":10879,"observedInterval":"10879:00:00","percentComplete":88.0,"percentCoverage":88.0,"datetimeFrom":null,"datetimeTo":null},"latest":{"datetime":{"utc":"2024-08-26T21:00:00Z","local":"2024-08-26T15:00:00-06:00"},"value":0.0,"coordinates":{"latitude":35.1353,"longitude":-106.584702}},"summary":{"min":-0.006,"q02":null,"q25":null,"median":null,"q75":null,"q98":null,"max":0.136,"avg":0.0024027300496372887,"sd":null}}]} -------------------------------------------------------------------------------- /tests/unit/sync/test_sync_resources.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, Mock 2 | 3 | import pytest 4 | 5 | from openaq._sync.models.base import SyncResourceBase 6 | from openaq._sync.models.measurements import Measurements 7 | from openaq.shared.exceptions import NotFoundError 8 | from openaq.shared.responses import MeasurementsResponse 9 | 10 | 11 | @pytest.fixture 12 | def mock_client(): 13 | return Mock() 14 | 15 | 16 | def test_sync_resource_base_init(): 17 | mock_client = MagicMock() 18 | resource = SyncResourceBase(client=mock_client) 19 | assert resource._client == mock_client 20 | 21 | 22 | @pytest.fixture 23 | def measurements(mock_client): 24 | return Measurements(mock_client) 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "data,rollup,expected_endpoint", 29 | [ 30 | (None, None, "/sensors/1/measurements"), 31 | ('measurements', None, "/sensors/1/measurements"), 32 | ('measurements', 'hourly', "/sensors/1/measurements/hourly"), 33 | ('hours', None, "/sensors/1/hours"), 34 | ('hours', 'daily', "/sensors/1/hours/daily"), 35 | ('days', None, "/sensors/1/days"), 36 | ('days', 'yearly', "/sensors/1/days/yearly"), 37 | ('years', None, "/sensors/1/years"), 38 | ], 39 | ) 40 | def test_measurements_list_endpoints( 41 | measurements, mock_client, mocker, data, rollup, expected_endpoint 42 | ): 43 | mock_response = Mock() 44 | MeasurementsResponse.read_response = Mock(return_value=mock_response) 45 | mock_client._get.return_value = mock_response 46 | mock_params = {'page': 1, 'limit': 1000, 'datetime_from': '2016-10-10'} 47 | mocker.patch('openaq.shared.models.build_query_params', return_value=mock_params) 48 | measurements.list(sensors_id=1, data=data, rollup=rollup) 49 | 50 | call_args = mock_client._get.call_args 51 | 52 | assert call_args[0][0] == expected_endpoint 53 | assert call_args[1]['params'] == mock_params 54 | 55 | 56 | @pytest.mark.parametrize( 57 | "data,rollup", 58 | [ 59 | ( 60 | 'hours', 61 | 'hourly', 62 | ), 63 | ( 64 | 'days', 65 | 'daily', 66 | ), 67 | ( 68 | 'years', 69 | 'yearly', 70 | ), 71 | ], 72 | ) 73 | def test_measurements_list_endpoints_not_founds(measurements, data, rollup): 74 | with pytest.raises(NotFoundError): 75 | measurements.list(sensors_id=1, data=data, rollup=rollup) 76 | -------------------------------------------------------------------------------- /tests/unit/sync/test_transport.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | import httpx 4 | 5 | from openaq._sync.transport import Transport 6 | 7 | 8 | def request_matches(expected_request): 9 | def matcher(request): 10 | return ( 11 | request.method == expected_request.method 12 | and request.url == expected_request.url 13 | and request.headers == expected_request.headers 14 | ) 15 | 16 | return matcher 17 | 18 | 19 | @patch('httpx.Client') 20 | def test_send_request(mock_httpx_client): 21 | mock_client_instance = mock_httpx_client.return_value 22 | 23 | mock_response = MagicMock(spec=httpx.Response) 24 | mock_response.status_code = 200 25 | 26 | mock_client_instance.send.return_value = mock_response 27 | 28 | with patch('openaq._sync.transport.check_response') as mock_check_response: 29 | mock_check_response.return_value = 'processed_response' 30 | 31 | transport = Transport() 32 | 33 | result = transport.send_request( 34 | method='GET', 35 | url='https://api.openaq.org/v3/locations', 36 | params={'limit': '100'}, 37 | headers={'x-api-key': 'foobar'}, 38 | ) 39 | 40 | mock_httpx_client.assert_called_once() 41 | expected_request = httpx.Request( 42 | method='GET', 43 | url='https://api.openaq.org/v3/locations', 44 | params={'limit': '100'}, 45 | headers={'x-api-key': 'foobar'}, 46 | ) 47 | mock_client_instance.send.assert_called_once() 48 | actual_request = mock_client_instance.send.call_args[0][0] 49 | assert request_matches(expected_request)(actual_request) 50 | mock_check_response.assert_called_once_with(mock_response) 51 | assert result == 'processed_response' 52 | 53 | 54 | @patch('httpx.Client') 55 | def test_close(mock_httpx_client): 56 | mock_client_instance = mock_httpx_client.return_value 57 | transport = Transport() 58 | transport.close() 59 | mock_client_instance.close.assert_called_once() 60 | -------------------------------------------------------------------------------- /tests/unit/test_exceptions.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | 4 | from openaq.shared.exceptions import ( 5 | BadGatewayError, 6 | BadRequestError, 7 | ForbiddenError, 8 | GatewayTimeoutError, 9 | HTTPRateLimitError, 10 | NotAuthorizedError, 11 | NotFoundError, 12 | ServerError, 13 | ServiceUnavailableError, 14 | ValidationError, 15 | ) 16 | from openaq.shared.transport import check_response 17 | 18 | 19 | @pytest.mark.parametrize( 20 | "http_code,exception", 21 | [ 22 | (400, BadRequestError), 23 | (401, NotAuthorizedError), 24 | (403, ForbiddenError), 25 | (404, NotFoundError), 26 | (418, Exception), 27 | (422, ValidationError), 28 | (429, HTTPRateLimitError), 29 | (500, ServerError), 30 | (502, BadGatewayError), 31 | (503, ServiceUnavailableError), 32 | (504, GatewayTimeoutError), 33 | ], 34 | ) 35 | def test_check_response(http_code, exception): 36 | response = httpx.Response(http_code) 37 | with pytest.raises(exception): 38 | check_response(response) 39 | 40 | 41 | @pytest.mark.parametrize("http_code", [200, 201, 202, 204]) 42 | def test_check_response_successful(http_code): 43 | response = httpx.Response(http_code) 44 | assert check_response(response) == response 45 | -------------------------------------------------------------------------------- /tests/unit/test_shared_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from openaq.shared.exceptions import NotFoundError 6 | from openaq.shared.models import build_measurements_path, build_query_params 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "kwargs,expected", 11 | [ 12 | ({}, {}), 13 | ( 14 | { 15 | 'page': 1, 16 | 'limit': 100, 17 | "radius": 42, 18 | "coordinates": [32.3, 42.3], 19 | 'bbox': [42.0, 42.0, 42.0, 42.0], 20 | "providers_id": 42, 21 | "countries_id": 42, 22 | "parameters_id": 2, 23 | 'iso': 'us', 24 | 'monitor': True, 25 | 'mobile': False, 26 | 'order_by': 'id', 27 | 'sort_order': 'asc', 28 | 'datetime_from': datetime(2024, 8, 22), 29 | }, 30 | { 31 | 'page': 1, 32 | 'limit': 100, 33 | "radius": 42, 34 | "coordinates": '32.3,42.3', 35 | 'bbox': '42.0,42.0,42.0,42.0', 36 | "providers_id": 42, 37 | "countries_id": 42, 38 | "parameters_id": 2, 39 | 'iso': 'us', 40 | 'monitor': True, 41 | 'mobile': False, 42 | 'order_by': 'id', 43 | 'sort_order': 'asc', 44 | 'datetime_from': '2024-08-22T00:00:00', 45 | }, 46 | ), 47 | ( 48 | { 49 | "providers_id": [1, 2, 3], 50 | "countries_id": [1, 2, 3], 51 | "parameters_id": [1, 2, 10], 52 | }, 53 | { 54 | "providers_id": '1,2,3', 55 | "countries_id": '1,2,3', 56 | "parameters_id": '1,2,10', 57 | }, 58 | ), 59 | ], 60 | ) 61 | def test_build_query_params(kwargs, expected): 62 | params = build_query_params(**kwargs) 63 | assert expected == params 64 | 65 | 66 | @pytest.mark.parametrize( 67 | "sensors_id, data, rollup, expected", 68 | [ 69 | (42, None, None, "/sensors/42/measurements"), 70 | (42, 'measurements', None, "/sensors/42/measurements"), 71 | (42, 'measurements', 'hourly', "/sensors/42/measurements/hourly"), 72 | (42, 'measurements', 'daily', "/sensors/42/measurements/daily"), 73 | (42, 'hours', None, "/sensors/42/hours"), 74 | (42, 'hours', 'daily', "/sensors/42/hours/daily"), 75 | (42, 'hours', 'monthly', "/sensors/42/hours/monthly"), 76 | (42, 'hours', 'yearly', "/sensors/42/hours/yearly"), 77 | (42, 'hours', 'hourofday', "/sensors/42/hours/hourofday"), 78 | (42, 'hours', 'dayofweek', "/sensors/42/hours/dayofweek"), 79 | (42, 'hours', 'monthofyear', "/sensors/42/hours/monthofyear"), 80 | (42, 'days', None, "/sensors/42/days"), 81 | (42, 'days', 'monthly', "/sensors/42/days/monthly"), 82 | (42, 'days', 'yearly', "/sensors/42/days/yearly"), 83 | (42, 'days', 'dayofweek', "/sensors/42/days/dayofweek"), 84 | (42, 'days', 'monthofyear', "/sensors/42/days/monthofyear"), 85 | (42, 'years', None, "/sensors/42/years"), 86 | ], 87 | ) 88 | def test_build_measurements_path(sensors_id, data, rollup, expected): 89 | path = build_measurements_path(sensors_id, data, rollup) 90 | assert path == expected 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "sensors_id, data, rollup", 95 | [ 96 | (42, 'measurements', 'yearly'), 97 | ( 98 | 42, 99 | 'measurements', 100 | 'hourofday', 101 | ), 102 | ( 103 | 42, 104 | 'measurements', 105 | 'dayofweek', 106 | ), 107 | ( 108 | 42, 109 | 'measurements', 110 | 'monthofyear', 111 | ), 112 | ( 113 | 42, 114 | 'hours', 115 | 'hourly', 116 | ), 117 | ( 118 | 42, 119 | 'days', 120 | 'daily', 121 | ), 122 | ( 123 | 42, 124 | 'days', 125 | 'hourofday', 126 | ), 127 | ( 128 | 42, 129 | 'years', 130 | 'yearly', 131 | ), 132 | ( 133 | 42, 134 | 'years', 135 | 'hourofday', 136 | ), 137 | ( 138 | 42, 139 | 'years', 140 | 'dayofweek', 141 | ), 142 | ( 143 | 42, 144 | 'years', 145 | 'monthofyear', 146 | ), 147 | ], 148 | ) 149 | def test_build_measurements_path_throws(sensors_id, data, rollup): 150 | with pytest.raises(NotFoundError): 151 | build_measurements_path(sensors_id, data, rollup) 152 | -------------------------------------------------------------------------------- /tests/unit/test_sync_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | from pathlib import Path 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from openaq import __version__ 9 | from openaq._sync.client import OpenAQ 10 | from openaq.shared.exceptions import ApiKeyMissingError 11 | 12 | from .mocks import MockTransport 13 | 14 | 15 | @pytest.fixture 16 | def mock_config_file(): 17 | mock_toml_content = b"""api-key='test_api_key'""" 18 | with mock.patch.object(Path, 'is_file', return_value=True): 19 | with mock.patch( 20 | 'builtins.open', mock.mock_open(read_data=mock_toml_content) 21 | ) as mock_file: 22 | yield mock_file 23 | 24 | 25 | class TestClient: 26 | @pytest.fixture() 27 | def setup(self): 28 | self.client = OpenAQ(api_key="abc123-def456-ghi789", _transport=MockTransport) 29 | 30 | @pytest.fixture() 31 | def mock_openaq_api_key_env_vars(self): 32 | with mock.patch.dict( 33 | os.environ, {"OPENAQ_API_KEY": "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p"} 34 | ): 35 | yield 36 | 37 | def test_transport_property(self, setup): 38 | assert self.client.transport == MockTransport 39 | with pytest.raises(AttributeError): 40 | self.client.transport = MockTransport 41 | 42 | def test_default_client_params(self, setup): 43 | assert self.client._base_url == "https://api.openaq.org/v3/" 44 | 45 | def test_default_headers(self, setup): 46 | assert ( 47 | self.client.headers["User-Agent"] 48 | == f"openaq-python-{__version__}-{platform.python_version()}" 49 | ) 50 | assert self.client.headers["Accept"] == "application/json" 51 | 52 | def test_custom_headers(self, setup): 53 | self.client = OpenAQ( 54 | api_key="abc123-def456-ghi789", 55 | base_url="https://mycustom.openaq.org", 56 | _transport=MockTransport(), 57 | ) 58 | assert self.client.headers["X-API-Key"] == "abc123-def456-ghi789" 59 | 60 | def test_client_params(self, setup): 61 | self.client = OpenAQ( 62 | api_key="abc123-def456-ghi789", 63 | base_url="https://mycustom.openaq.org", 64 | _transport=MockTransport(), 65 | ) 66 | assert self.client._base_url == "https://mycustom.openaq.org" 67 | 68 | def test_api_env_var(self, mock_openaq_api_key_env_vars): 69 | """ 70 | tests that api_key is set from environment variable 71 | """ 72 | client = OpenAQ(_transport=MockTransport) 73 | assert client.api_key == "openaq-1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p" 74 | 75 | @pytest.mark.usefixtures("mock_config_file") 76 | def test_api_key_from_config(self): 77 | if int(platform.python_version_tuple()[1]) >= 11: 78 | client = OpenAQ(_transport=MockTransport) 79 | assert client.api_key == "test_api_key" 80 | else: 81 | with pytest.raises(ApiKeyMissingError): 82 | client = OpenAQ(_transport=MockTransport) 83 | 84 | def test_api_key_arg_override_env_var(self, setup, mock_openaq_api_key_env_vars): 85 | """ 86 | tests that api_key argument overrides api key value set in system environment variable 87 | """ 88 | assert self.client.api_key == "abc123-def456-ghi789" 89 | 90 | def test_api_key_arg_override_env_var(self, setup, mock_config_file): 91 | """ 92 | tests that api_key argument overrides api key value set in openaq config 93 | """ 94 | assert self.client.api_key == "abc123-def456-ghi789" 95 | 96 | def test_api_key_arg_override_env_vars_config( 97 | self, setup, mock_openaq_api_key_env_vars, mock_config_file 98 | ): 99 | """ 100 | tests that api_key argument overrides api key value set in config file and system environment variable 101 | """ 102 | assert self.client.api_key == "abc123-def456-ghi789" 103 | --------------------------------------------------------------------------------