Repository: alex9smith/gdelt-doc-api

Issue:
New feature: multiple nears
I found myself writing around the code to get multiple `near` statements in the query, so thought it could be useful to have in the package.

This would probably work very much like `multi_repeat`.

Discussion:
@alex9smith: Thanks very much @kepler471 for your contribution on this. It's now available in version 1.8.0

Solution patch:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 1c9098c..5cabab6 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -8,19 +8,19 @@ jobs:
   build-and-publish:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v2
-    - uses: actions/setup-python@v2
-      with:
-        python-version: '3.9'
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: "3.9"
 
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        pip install setuptools wheel twine
-    - name: Build and publish
-      env:
-        TWINE_USERNAME: __token__
-        TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
-      run: |
-        python setup.py sdist bdist_wheel
-        twine upload dist/*
\ No newline at end of file
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install setuptools wheel twine
+      - name: Build and publish
+        env:
+          TWINE_USERNAME: __token__
+          TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+        run: |
+          python setup.py sdist bdist_wheel
+          twine upload dist/*
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 50dcae0..921e312 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,15 +12,15 @@ jobs:
   test:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v2
-    - uses: actions/setup-python@v2
-      with:
-        python-version: '3.9'
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: "3.13"
 
-    - name: Install dependencies
-      run: |
-        python -m pip install --upgrade pip
-        pip install -r requirements.txt
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
 
-    - name: Run tests
-      run: python -m unittest
+      - name: Run tests
+        run: python -m unittest
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fef0e61..a1d9935 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # Changelog
 
+## 1.7.0
+
+Add the ability to filter based on 3 letter language (#38)
+
+## 1.6.0
+
+Only support Python 3.10 and above (#39)
+Format all files with prettier (#40)
+Update package dependencies (#41)
+
 ## 1.5.0
 
 Provide user agent in requests to the API (#22)
diff --git a/README.md b/README.md
index 9e69b94..f42fbed 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,8 @@ pip install gdeltdoc
 ```
 
 ## Use
-The `ArtList` and `Timeline*` query modes are supported. 
+
+The `ArtList` and `Timeline*` query modes are supported.
 
 ```python
 from gdeltdoc import GdeltDoc, Filters
@@ -34,18 +35,22 @@ timeline = gd.timeline_search("timelinevol", f)
 ```
 
 ### Article List
+
 The article list mode of the API generates a list of news articles that match the filters. The client returns this as a pandas DataFrame with columns `url`, `url_mobile`, `title`, `seendate`, `socialimage`, `domain`, `language`, `sourcecountry`.
 
 ### Timeline Search
+
 There are 5 available modes when making a timeline search:
-* `timelinevol` - a timeline of the volume of news coverage matching the filters, represented as a percentage of the total news articles monitored by GDELT.
-* `timelinevolraw` - similar to `timelinevol`, but has the actual number of articles and a total rather than a percentage
-* `timelinelang` - similar to `timelinevol` but breaks the total articles down by published language. Each language is returned as a separate column in the DataFrame.
-* `timelinesourcecountry` - similar to `timelinevol` but breaks the total articles down by the country they were published in. Each country is returned as a separate column in the DataFrame.
-* `timelinetone` - a timeline of the average tone of the news coverage matching the filters. See [GDELT's documentation](https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/) for more information about the tone metric.
+
+- `timelinevol` - a timeline of the volume of news coverage matching the filters, represented as a percentage of the total news articles monitored by GDELT.
+- `timelinevolraw` - similar to `timelinevol`, but has the actual number of articles and a total rather than a percentage
+- `timelinelang` - similar to `timelinevol` but breaks the total articles down by published language. Each language is returned as a separate column in the DataFrame.
+- `timelinesourcecountry` - similar to `timelinevol` but breaks the total articles down by the country they were published in. Each country is returned as a separate column in the DataFrame.
+- `timelinetone` - a timeline of the average tone of the news coverage matching the filters. See [GDELT's documentation](https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/) for more information about the tone metric.
 
 ### Filters
-The search query passed to the API is constructed from a `gdeltdoc.Filters` object. 
+
+The search query passed to the API is constructed from a `gdeltdoc.Filters` object.
 
 ```python
 from gdeltdoc import Filters, near, repeat
@@ -63,47 +68,59 @@ f = Filters(
 )
 ```
 
-Filters for `keyword`, `domain`, `domain_exact`, `country` and `theme` can be passed either as a single string or as a list of strings. If a list is passed, the values in the list are wrappeed in a boolean OR.
+Filters for `keyword`, `domain`, `domain_exact`, `country`, `language` and `theme` can be passed either as a single string or as a list of strings. If a list is passed, the values in the list are wrappeed in a boolean OR.
 
 You must pass either `start_date` and `end_date`, or `timespan`
 
-* `start_date` - The start date for the filter in YYYY-MM-DD format. The API officially only supports the most recent 3 months of articles. Making a request for an earlier date range may still return data, but it's not guaranteed.
-* `end_date` - The end date for the filter in YYYY-MM-DD format.
-* `timespan` - A timespan to search for, relative to the time of the request. Must match one of the API's timespan formats - https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/ 
-* `num_records` - The number of records to return. Only used in article list mode and can be up to 250.
-* `keyword` - Return articles containing the exact phrase `keyword` within the article text.
-* `domain` - Return articles from the specified domain. Does not require an exact match so passing "cnn.com" will match articles from `cnn.com`, `subdomain.cnn.com` and `notactuallycnn.com`.
-* `domain_exact` - Similar to `domain`, but requires an exact match.
-* `country` - Return articles published in a country or list of countries, formatted as the FIPS 2 letter country code.
-* `theme` - Return articles that cover one of GDELT's GKG Themes. A full list of themes can be found [here](http://data.gdeltproject.org/api/v2/guides/LOOKUP-GKGTHEMES.TXT)
-* `near` - Return articles containing words close to each other in the text. Use `near()` to construct. eg. `near = near(5, "airline", "climate")`.
-* `repeat` - Return articles containing a single word repeated at least a number of times. Use `repeat()` to construct. eg. `repeat =repeat(3, "environment")`, or `multi_repeat()` if you want to use multiple restrictions eg. `repeat = multi_repeat([(2, "airline"), (3, "airport")], "AND")`
+- `start_date` - The start date for the filter in YYYY-MM-DD format. The API officially only supports the most recent 3 months of articles. Making a request for an earlier date range may still return data, but it's not guaranteed.
+- `end_date` - The end date for the filter in YYYY-MM-DD format.
+- `timespan` - A timespan to search for, relative to the time of the request. Must match one of the API's timespan formats - https://blog.gdeltproject.org/gdelt-doc-2-0-api-debuts/
+- `num_records` - The number of records to return. Only used in article list mode and can be up to 250.
+- `keyword` - Return articles containing the exact phrase `keyword` within the article text.
+- `domain` - Return articles from the specified domain. Does not require an exact match so passing "cnn.com" will match articles from `cnn.com`, `subdomain.cnn.com` and `notactuallycnn.com`.
+- `domain_exact` - Similar to `domain`, but requires an exact match.
+- `country` - Return articles published in a country or list of countries, formatted as the FIPS 2 letter country code.
+- `language` - Return articles published in the given language, formatted as the ISO 639 language code.
+- `theme` - Return articles that cover one of GDELT's GKG Themes. A full list of themes can be found [here](http://data.gdeltproject.org/api/v2/guides/LOOKUP-GKGTHEMES.TXT)
+- `near` - Return articles containing words close to each other in the text. Use `near()` to construct. eg. `near = near(5, "airline", "climate")`.
+- `repeat` - Return articles containing a single word repeated at least a number of times. Use `repeat()` to construct. eg. `repeat =repeat(3, "environment")`, or `multi_repeat()` if you want to use multiple restrictions eg. `repeat = multi_repeat([(2, "airline"), (3, "airport")], "AND")`
 
 ## Developing gdelt-doc-api
 
 PRs & issues are very welcome!
 
 ### Setup
+
 It's recommended to use a virtual environment for development. Set one up with
+
 ```
 python -m venv venv
 ```
+
 and activate it (on Mac or Linux)
+
 ```
 source venv/bin/activate
 ```
+
 Then install the requirements
+
 ```
 pip install -r requirements.txt
 ```
+
 Tests for this package use `unittest`. Run them with
+
 ```
 python -m unittest
 ```
+
 If your PR adds a new feature or helper, please also add some tests
 
 ### Publishing
-There's a bit of automation set up to help publish a new version of the package to PyPI, 
+
+There's a bit of automation set up to help publish a new version of the package to PyPI,
+
 1. Make sure the version string has been updated since the last release. This package follows semantic versioning.
 2. Create a new release in the Github UI, using the new version as the release name
 3. Watch as the `publish.yml` Github action builds the package and pushes it to PyPI
diff --git a/gdeltdoc/__init__.py b/gdeltdoc/__init__.py
index a458079..843a664 100644
--- a/gdeltdoc/__init__.py
+++ b/gdeltdoc/__init__.py
@@ -1,5 +1,5 @@
 from gdeltdoc.api_client import GdeltDoc
-from gdeltdoc.filters import Filters, near, repeat, multi_repeat, VALID_TIMESPAN_UNITS
+from gdeltdoc.filters import Filters, near, multi_near, repeat, multi_repeat, VALID_TIMESPAN_UNITS
 from gdeltdoc._version import version
 
 __version__ = version
diff --git a/gdeltdoc/_version.py b/gdeltdoc/_version.py
index 02c070f..a55413d 100644
--- a/gdeltdoc/_version.py
+++ b/gdeltdoc/_version.py
@@ -1 +1 @@
-version = "1.5.0"
\ No newline at end of file
+version = "1.7.0"
diff --git a/gdeltdoc/api_client.py b/gdeltdoc/api_client.py
index d786ec7..f6b5ef9 100644
--- a/gdeltdoc/api_client.py
+++ b/gdeltdoc/api_client.py
@@ -9,6 +9,7 @@ from gdeltdoc.helpers import load_json
 
 from gdeltdoc._version import version
 
+
 class GdeltDoc:
     """
     API client for the GDELT 2.0 Doc API
@@ -105,7 +106,9 @@ class GdeltDoc:
         """
         timeline = self._query(mode, filters.query_string)
 
-        results = {"datetime": [entry["date"] for entry in timeline["timeline"][0]["data"]]}
+        results = {
+            "datetime": [entry["date"] for entry in timeline["timeline"][0]["data"]]
+        }
 
         for series in timeline["timeline"]:
             results[series["series"]] = [entry["value"] for entry in series["data"]]
@@ -154,15 +157,20 @@ class GdeltDoc:
 
         response = requests.get(
             f"https://api.gdeltproject.org/api/v2/doc/doc?query={query_string}&mode={mode}&format=json",
-            headers=headers
+            headers=headers,
         )
 
         if response.status_code not in [200, 202]:
-            raise ValueError("The gdelt api returned a non-successful statuscode. This is the response message: {}".
-                             format(response.text))
+            raise ValueError(
+                "The gdelt api returned a non-successful statuscode. This is the response message: {}".format(
+                    response.text
+                )
+            )
 
         # Response is text/html if it's an error and application/json if it's ok
         if "text/html" in response.headers["content-type"]:
-            raise ValueError(f"The query was not valid. The API error message was: {response.text.strip()}")
+            raise ValueError(
+                f"The query was not valid. The API error message was: {response.text.strip()}"
+            )
 
         return load_json(response.content, self.max_depth_json_parsing)
diff --git a/gdeltdoc/filters.py b/gdeltdoc/filters.py
index 05e2a6f..c3e65a3 100644
--- a/gdeltdoc/filters.py
+++ b/gdeltdoc/filters.py
@@ -5,6 +5,7 @@ Filter = Union[List[str], str]
 
 VALID_TIMESPAN_UNITS = ["min", "h", "hours", "d", "days", "w", "weeks", "m", "months"]
 
+
 def near(n: int, *args) -> str:
     """
     Build the filter to find articles containing words that occur within
@@ -19,6 +20,24 @@ def near(n: int, *args) -> str:
     return f"near{str(n)}:" + '"' + " ".join([a for a in args]) + '" '
 
 
+def multi_near(nears: List[Tuple[int, str, ...]], method: str = "OR") -> str:
+    """
+    Build the filter to find articles containing multiple sets of near terms.
+
+    eg. multi_near([(5, "airline", "crisis"), (10, "airline", "climate", "change")]) finds "airline" and "crisis"
+    within 5 words, and/or "airline", "climate", and "change" within 10 words
+    """
+    if method not in ["AND", "OR"]:
+        raise ValueError(f"method must be one of AND or OR, not {method}")
+
+    nears = [near(n, *args) for (n, *args) in nears]
+
+    paren_flag = len(nears) != 1 and method == "OR"
+    l_pad, r_pad = paren_flag * "(", paren_flag * ") "
+
+    return l_pad + f"{method} ".join(nears) + r_pad
+
+
 def repeat(n: int, keyword: str) -> str:
     """
     Build the filter to find articles containing `keyword` at least `n` times.
@@ -70,6 +89,7 @@ class Filters:
         near: Optional[str] = None,
         repeat: Optional[str] = None,
         country: Optional[Filter] = None,
+        language: Optional[Filter] = None,
         theme: Optional[Filter] = None,
     ) -> None:
         """
@@ -121,6 +141,9 @@ class Filters:
         country
             Return articles published in a country, formatted as the FIPS 2 letter country code.
 
+        language
+            Return articles published in the given language, formatted as the ISO 639 language code.
+
         theme
             Return articles that cover one of GDELT's GKG Themes. A full list of themes can be
             found here: http://data.gdeltproject.org/api/v2/guides/LOOKUP-GKGTHEMES.TXT
@@ -150,6 +173,9 @@ class Filters:
         if country:
             self.query_params.append(self._filter_to_string("sourcecountry", country))
 
+        if language:
+            self.query_params.append(self._filter_to_string("sourcelang", language))
+
         if theme:
             self.query_params.append(self._filter_to_string("theme", theme))
 
@@ -256,13 +282,19 @@ class Filters:
         """
 
         value = timespan.rstrip(ascii_lowercase)
-        unit = timespan[len(value):]
+        unit = timespan[len(value) :]
 
         if unit not in VALID_TIMESPAN_UNITS:
-            raise ValueError(f"Timespan {timespan} is invalid. {unit} is not a supported unit, must be one of {' '.join(VALID_TIMESPAN_UNITS)}")
+            raise ValueError(
+                f"Timespan {timespan} is invalid. {unit} is not a supported unit, must be one of {' '.join(VALID_TIMESPAN_UNITS)}"
+            )
 
         if not all(d in digits for d in value):
-            raise ValueError(f"Timespan {timespan} is invalid. {value} could not be converted into an integer")
+            raise ValueError(
+                f"Timespan {timespan} is invalid. {value} could not be converted into an integer"
+            )
 
         if unit == "min" and int(value) < 60:
-            raise ValueError(f"Timespan {timespan} is invalid. Period must be at least 60 minutes")
+            raise ValueError(
+                f"Timespan {timespan} is invalid. Period must be at least 60 minutes"
+            )
diff --git a/gdeltdoc/helpers.py b/gdeltdoc/helpers.py
index 499feca..7e30356 100644
--- a/gdeltdoc/helpers.py
+++ b/gdeltdoc/helpers.py
@@ -22,8 +22,11 @@ def load_json(json_message, max_recursion_depth: int = 100, recursion_depth: int
         if isinstance(json_message, bytes):
             json_message.decode("utf-8")
         json_message = list(json_message)
-        json_message[idx_to_replace] = ' '
-        new_message = ''.join(str(m) for m in json_message)
-        return load_json(json_message=new_message, max_recursion_depth=max_recursion_depth,
-                         recursion_depth=recursion_depth+1)
+        json_message[idx_to_replace] = " "
+        new_message = "".join(str(m) for m in json_message)
+        return load_json(
+            json_message=new_message,
+            max_recursion_depth=max_recursion_depth,
+            recursion_depth=recursion_depth + 1,
+        )
     return result
diff --git a/requirements.txt b/requirements.txt
index 719c8ed..ec8feda 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,2 @@
-pandas>=1.1.4
-requests>=2.25.1
+pandas>=2.2.3
+requests>=2.32.3
diff --git a/setup.py b/setup.py
index f939299..735ca86 100644
--- a/setup.py
+++ b/setup.py
@@ -10,13 +10,15 @@ with open("gdeltdoc/_version.py", "r") as g:
     version = "1.0.0"
     for line in g.readlines():
         if "version" in line:
-            version = line.split("=")[1].replace("\n", "").replace('"', "").replace(" ", "")
+            version = (
+                line.split("=")[1].replace("\n", "").replace('"', "").replace(" ", "")
+            )
 
 setuptools.setup(
     name="gdeltdoc",
     version=version,
-    author="Alex Smith",
-    author_email="alex@alexsmith.dev",
+    author="Alex Whitehead-Smith",
+    author_email="alex.me.smith@gmail.com",
     description="A client for the GDELT 2.0 Doc API",
     long_description=long_description,
     long_description_content_type="text/markdown",
@@ -27,6 +29,6 @@ setuptools.setup(
         "License :: OSI Approved :: MIT License",
         "Operating System :: OS Independent",
     ],
-    python_requires='>=3.6',
+    python_requires=">=3.10",
     install_requires=requirements,
-)
\ No newline at end of file
+)


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index 116c66b..78266fb 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -15,9 +15,7 @@ class ArticleSearchTestCast(unittest.TestCase):
         self.end_date = (datetime.today() - timedelta(days=6)).strftime("%Y-%m-%d")
 
         f = Filters(
-            keyword="environment",
-            start_date=self.start_date,
-            end_date=self.end_date
+            keyword="environment", start_date=self.start_date, end_date=self.end_date
         )
         self.articles = GdeltDoc().article_search(f)
 
@@ -46,7 +44,7 @@ class ArticleSearchTestCast(unittest.TestCase):
         # This test could fail if there really are no articles
         # that match the filter, but given the query used for
         # This tests could fail if there really are no articles
-        # that match the filter, but given the query used for 
+        # that match the filter, but given the query used for
         # testing that's very unlikely.
         self.assertGreaterEqual(self.articles.shape[0], 1)
 
@@ -59,19 +57,23 @@ class TimelineSearchTestCase(unittest.TestCase):
     # Make one set of API calls per tests suite run, not one per test
     @classmethod
     def setUpClass(cls):
-        cls.start_date = (datetime.today() - timedelta(days = 7)).strftime("%Y-%m-%d")
-        cls.end_date = (datetime.today() - timedelta(days = 6)).strftime("%Y-%m-%d")
+        cls.start_date = (datetime.today() - timedelta(days=7)).strftime("%Y-%m-%d")
+        cls.end_date = (datetime.today() - timedelta(days=6)).strftime("%Y-%m-%d")
 
         f = Filters(
-            keyword="environment",
-            start_date=cls.start_date,
-            end_date=cls.end_date
+            keyword="environment", start_date=cls.start_date, end_date=cls.end_date
         )
 
         gd = GdeltDoc()
         cls.all_results = [
-            gd.timeline_search(mode, f) for mode in 
-            ["timelinevol", "timelinevolraw", "timelinelang", "timelinetone", "timelinesourcecountry"]
+            gd.timeline_search(mode, f)
+            for mode in [
+                "timelinevol",
+                "timelinevolraw",
+                "timelinelang",
+                "timelinetone",
+                "timelinesourcecountry",
+            ]
         ]
 
     def test_all_modes_return_a_df(self):
@@ -80,9 +82,7 @@ class TimelineSearchTestCase(unittest.TestCase):
         )
 
     def test_all_modes_return_data(self):
-        self.assertTrue(
-            all([result.shape[0] >= 1 for result in self.all_results])
-        )
+        self.assertTrue(all([result.shape[0] >= 1 for result in self.all_results]))
 
     def test_unsupported_mode(self):
         with self.assertRaisesRegex(ValueError, "not in supported API modes"):
@@ -91,8 +91,8 @@ class TimelineSearchTestCase(unittest.TestCase):
                 Filters(
                     keyword="environment",
                     start_date=self.start_date,
-                    end_date=self.end_date
-                )
+                    end_date=self.end_date,
+                ),
             )
 
     def test_vol_has_two_columns(self):
@@ -101,8 +101,11 @@ class TimelineSearchTestCase(unittest.TestCase):
     def test_vol_raw_has_three_columns(self):
         self.assertEqual(self.all_results[1].shape[1], 3)
 
+
 class QueryTestCase(unittest.TestCase):
 
     def test_handles_invalid_query_string(self):
-        with self.assertRaisesRegex(ValueError, "The query was not valid. The API error message was"):
-            GdeltDoc()._query("artlist", "environment&timespan=mins15")
\ No newline at end of file
+        with self.assertRaisesRegex(
+            ValueError, "The query was not valid. The API error message was"
+        ):
+            GdeltDoc()._query("artlist", "environment&timespan=mins15")
diff --git a/tests/test_filters.py b/tests/test_filters.py
index 244c970..d6f8373 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -1,61 +1,113 @@
-from gdeltdoc import Filters, near, repeat, multi_repeat, VALID_TIMESPAN_UNITS
+from gdeltdoc import Filters, near, multi_near, repeat, multi_repeat, VALID_TIMESPAN_UNITS
 
 import unittest
 
 
 class FiltersTestCase(unittest.TestCase):
     """
-    Test that the correct query strings are generated from 
+    Test that the correct query strings are generated from
     various filters.
     """
+
     def test_single_keyword_filter(self):
         f = Filters(keyword="airline", start_date="2020-03-01", end_date="2020-03-02")
-        self.assertEqual(f.query_string,
-                         '"airline" &startdatetime=20200301000000&enddatetime=20200302000000&maxrecords=250')
+        self.assertEqual(
+            f.query_string,
+            '"airline" &startdatetime=20200301000000&enddatetime=20200302000000&maxrecords=250',
+        )
 
     def test_single_keyphrase_filter(self):
-        f = Filters(keyword="climate change", start_date="2020-03-01", end_date="2020-03-02")
-        self.assertEqual(f.query_string,
-                         '"climate change" &startdatetime=20200301000000&enddatetime=20200302000000&maxrecords=250')
+        f = Filters(
+            keyword="climate change", start_date="2020-03-01", end_date="2020-03-02"
+        )
+        self.assertEqual(
+            f.query_string,
+            '"climate change" &startdatetime=20200301000000&enddatetime=20200302000000&maxrecords=250',
+        )
 
     def test_multiple_keywords(self):
-        f = Filters(keyword=["airline", "climate"], start_date = "2020-05-13", end_date = "2020-05-14")
-        self.assertEqual(f.query_string,
-                         '(airline OR climate) &startdatetime=20200513000000&'
-                         'enddatetime=20200514000000&maxrecords=250')
+        f = Filters(
+            keyword=["airline", "climate"],
+            start_date="2020-05-13",
+            end_date="2020-05-14",
+        )
+        self.assertEqual(
+            f.query_string,
+            "(airline OR climate) &startdatetime=20200513000000&"
+            "enddatetime=20200514000000&maxrecords=250",
+        )
 
     def test_multiple_themes(self):
-        f = Filters(theme=["ENV_CLIMATECHANGE", "LEADER"], start_date="2020-05-13", end_date="2020-05-14")
-        self.assertEqual(f.query_string,
-                         '(theme:ENV_CLIMATECHANGE OR theme:LEADER) &startdatetime=20200513000000&'
-                         'enddatetime=20200514000000&maxrecords=250')
+        f = Filters(
+            theme=["ENV_CLIMATECHANGE", "LEADER"],
+            start_date="2020-05-13",
+            end_date="2020-05-14",
+        )
+        self.assertEqual(
+            f.query_string,
+            "(theme:ENV_CLIMATECHANGE OR theme:LEADER) &startdatetime=20200513000000&"
+            "enddatetime=20200514000000&maxrecords=250",
+        )
 
     def test_theme_and_keyword(self):
-        f = Filters(keyword="airline", theme="ENV_CLIMATECHANGE", start_date="2020-05-13", end_date="2020-05-14")
-        self.assertEqual(f.query_string,
-                         '"airline" theme:ENV_CLIMATECHANGE &startdatetime=20200513000000&'
-                         'enddatetime=20200514000000&maxrecords=250')
+        f = Filters(
+            keyword="airline",
+            theme="ENV_CLIMATECHANGE",
+            start_date="2020-05-13",
+            end_date="2020-05-14",
+        )
+        self.assertEqual(
+            f.query_string,
+            '"airline" theme:ENV_CLIMATECHANGE &startdatetime=20200513000000&'
+            "enddatetime=20200514000000&maxrecords=250",
+        )
 
 
 class NearTestCast(unittest.TestCase):
     """
     Test that `near()` generates the right filters and errors.
     """
+
     def test_two_words(self):
         self.assertEqual(near(5, "airline", "crisis"), 'near5:"airline crisis" ')
 
     def test_three_words(self):
-        self.assertEqual(near(10, "airline", "climate", "change"), 'near10:"airline climate change" ')
+        self.assertEqual(
+            near(10, "airline", "climate", "change"), 'near10:"airline climate change" '
+        )
 
     def test_one_word(self):
         with self.assertRaisesRegex(ValueError, "At least two words"):
             near(5, "airline")
 
 
+class MultiNearTestCast(unittest.TestCase):
+    """
+    Test that `near()` generates the right filters and errors.
+    """
+    def test_single_near(self):
+        self.assertEqual(multi_near([(5, "airline", "crisis")]), near(5, "airline", "crisis"))
+
+    def test_two_nears(self):
+        self.assertEqual(
+            multi_near([(5, "airline", "crisis"), (10, "airline", "climate", "change")]),
+            "(" + near(5, "airline", "crisis") + "OR " +
+            near(10, "airline", "climate", "change") + ") "
+        )
+
+    def test_two_nears_AND(self):
+        self.assertEqual(
+            multi_near([(5, "airline", "crisis"), (10, "airline", "climate", "change")], method="AND"),
+            near(5, "airline", "crisis") + "AND " + near(10, "airline", "climate", "change")
+        )
+
+
+
 class RepeatTestCase(unittest.TestCase):
     """
     Test that `repeat()` generates the correct filters and errors.
     """
+
     def test_repeat(self):
         self.assertEqual(repeat(3, "environment"), 'repeat3:"environment" ')
 
@@ -63,32 +115,41 @@ class RepeatTestCase(unittest.TestCase):
         with self.assertRaisesRegex(ValueError, "single word"):
             repeat(5, "climate change   ")
 
+
 class MultiRepeatTestCase(unittest.TestCase):
     """
     Test that `multi_repeat() generates the correct filters and errors.
     """
 
     def test_multi_repeat(self):
-        self.assertEqual(multi_repeat([(2, "airline"), (3, "airport")], "AND"), 'repeat2:"airline" AND repeat3:"airport" ')
+        self.assertEqual(
+            multi_repeat([(2, "airline"), (3, "airport")], "AND"),
+            'repeat2:"airline" AND repeat3:"airport" ',
+        )
 
     def test_multi_repeat_or(self):
-        self.assertEqual(multi_repeat([(2, "airline"), (3, "airport")], "OR"), '(repeat2:"airline" OR repeat3:"airport" )')
+        self.assertEqual(
+            multi_repeat([(2, "airline"), (3, "airport")], "OR"),
+            '(repeat2:"airline" OR repeat3:"airport" )',
+        )
 
     def test_multi_repeat_checks_method(self):
         with self.assertRaisesRegex(ValueError, "method must be one of AND or OR"):
             multi_repeat([(2, "airline"), (3, "airport")], "NOT_A_METHOD")
 
+
 class TimespanTestCase(unittest.TestCase):
     """
     Test that `Filter._validate_timespan` validates timespans correctly
     """
+
     def test_allows_valid_units(self):
         for unit in VALID_TIMESPAN_UNITS:
             try:
                 Filters._validate_timespan(f"60{unit}")
             except ValueError:
                 self.fail()
-    
+
     def test_forbids_invalid_units(self):
         with self.assertRaisesRegex(ValueError, "is not a supported unit"):
             Filters._validate_timespan(f"60milliseconds")