diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index fad6a954..2b043f76 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -49,11 +49,11 @@ ENV PYENV_ROOT="${HOME}/.pyenv" ENV PATH="${PYENV_ROOT}/shims:${PYENV_ROOT}/bin:${HOME}/.local/bin:$PATH" RUN echo "done 0" \ - && curl https://pyenv.run | bash \ + && curl -ksS https://pyenv.run | bash \ && echo "done 1" \ && pyenv install ${PYTHON_VERSION} \ && echo "done 2" \ && pyenv global ${PYTHON_VERSION} \ && echo "done 3" \ - && curl -sSL https://install.python-poetry.org | python3 - \ + && curl -ksSL https://install.python-poetry.org | python3 - \ && poetry config virtualenvs.in-project true \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5fd5b588..b787d482 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,7 @@ "ms-python.black-formatter", "ms-python.isort", "donjayamanne.python-extension-pack", - "Codeium.codeium" + "aaron-bond.better-comments" ] } } diff --git a/.github/workflows/python-unittest.yml b/.github/workflows/python-unittest.yml new file mode 100644 index 00000000..f9d8505e --- /dev/null +++ b/.github/workflows/python-unittest.yml @@ -0,0 +1,47 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python unit test + +on: + push: + branches: + - 'main' + pull_request: + branches: + - '**' + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up Poetry + uses: Gr1N/setup-poetry@v9 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/pypoetry/virtualenvs + key: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} + restore-keys: ${{ runner.os }}-poetry-${{ hashFiles('poetry.lock') }} + + - name: Install dependencies + run: poetry install --without dev --no-root --no-interaction --no-ansi + + - name: Launch unit test + run: poetry run python -m unittest -v \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e9e6a805 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/src/helpers.py b/src/helpers.py index afea1587..13943429 100644 --- a/src/helpers.py +++ b/src/helpers.py @@ -13,7 +13,7 @@ def parse_date(date: str) -> str: - """Transform a string "Lundi 16 janvier 2025 into a datetime object""" + """Transform a string "Lundi 16 janvier 2025" into a datetime object""" date_match = re.search( DATE_REGX, date, @@ -29,7 +29,6 @@ def parse_date(date: str) -> str: def parse_name(name: str) -> Tuple[str, str]: splited_name = name.split(" ") # ? we split to remove the gender prefix - # ? we remove the gender prefix splited_name = splited_name[1:] # ? some depute have a composed name like Marc de Fleurian # ? so we take the first entry as first name diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/scrutin_analyse_data.html b/tests/data/scrutin_analyse_data.html new file mode 100644 index 00000000..18c1570f --- /dev/null +++ b/tests/data/scrutin_analyse_data.html @@ -0,0 +1,3689 @@ + + + + Analyse du scrutin n°3186 - 17e législature - Assemblée nationale + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + + + +
+
+ + + + + + +
+
+
+ +
+
+

Première séance du mardi 28 octobre 2025

+
+
+

Scrutin public n°3186 sur l'amendement n° 2493 de M. Prud'homme après l'article 12 (examen prioritaire) du projet de loi de finances pour 2026 (première lecture).

+
+
+
+ +
+
+
+ +
+
+ +
Created with Highcharts 7.2.2Contre 78.8 %Nombre de votes: 178
Détail du diagramme :
  • Pour : 42 députés
  • Contre : 178 députés
  • Abstention : 6 députés
+ +
+
+
+
+
+ Synthèse du vote +
+
+
+
+ Nombre de votants : 226 +
+
+ Nombre de suffrages exprimés : 220 +
+
+ Majorité absolue des suffrages exprimés : 111 +
+
+
+
    +
  • + Pour l'adoption : 42 +
  • +
  • + Contre : 178 +
  • +
  • + Abstention : 6 +
  • +
+
+
+ L'Assemblée nationale n'a pas adopté +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + +
+ +
+
+
+ Voir l'amendement n°2493 (Rejeté) +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Voir le compte rendu de la séance +
+
+ +
+
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+ +
+
+
+ Visualiser les votes des députés dans l'hémicycle +
+
+ +
+
+
+ + +
+ + + + +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+

Répartition des votes par groupe

+
+ + + + + + + + + + + + +
+
+
+

Mises au point

+
+
+
+ (Sous réserve des dispositions de l'article 68, alinéa 4, du Règlement de l'Assemblée nationale) +
+
+
+
+
+

Votes des groupes

+
+
+ + + + + + +
    +
  • +
    +
    + +
    +
    +

    + Les Démocrates +

    +
    +
    +
      +
    • + Contre : 11 +
    • +
    • + Non votant : 1 +
    • +
    +
  • +
+ + + + + +
+
+
+
+
+
+ +
+
+ + + +
+ +
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/tests/data/scrutins_data.html b/tests/data/scrutins_data.html new file mode 100644 index 00000000..bf12bf6e --- /dev/null +++ b/tests/data/scrutins_data.html @@ -0,0 +1,2474 @@ + + + + Scrutins publics - 17e législature - Assemblée nationale + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+ + + + +
+
+ + + + + + +
+
+ + + +
+ + +
+ + + +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + +
+
+
+
+
+
+
+ + +
+ +
+
+ + + +
+ +
+
+ + + + + + + + + +
+ + lunmarmerjeuvensamdim + +
293012345678910111213141516171819202122232425262728293031123456789
\ No newline at end of file diff --git a/tests/test_scrutin_analyse.py b/tests/test_scrutin_analyse.py new file mode 100644 index 00000000..7e44ff41 --- /dev/null +++ b/tests/test_scrutin_analyse.py @@ -0,0 +1,70 @@ +import unittest +from unittest.mock import patch + +from bs4 import BeautifulSoup + +from src.models import ScrutinAnalyse +from src.parsers import ScrutinAnalyseParser + + +class TestScrutinAnalyse(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + with open("tests/data/scrutin_analyse_data.html", "r", encoding="utf-8") as f: + cls.sample_html = f.read() + + def setUp(self) -> None: + # ? link is required since id is extracted from the url + self.parser = ScrutinAnalyseParser(am_url="https://www.assemblee-nationale.fr/dyn/17/scrutins/3186") + + patch_fetch_page = patch.object(ScrutinAnalyseParser, "_fetch_page", side_effect=self.mock_fetch_page) + patch_fetch_vizu = patch.object( + ScrutinAnalyseParser, "_extract_visualiser", side_effect=self.mock_fetch_vizualizer + ) + + self.mocked_fetch_page = patch_fetch_page.start() + self.mocked_fetch_vizu = patch_fetch_vizu.start() + + self.addCleanup(patch_fetch_page.stop) + self.addCleanup(patch_fetch_vizu.stop) + + def mock_fetch_page(self, url: str): + return BeautifulSoup(self.sample_html, "html.parser") + + def mock_fetch_vizualizer(self, visualiser_url: str) -> str: + # ? extract the url from the sample html to simulate the process + # ? real vizualizer requires selenium so we just return the url here + page = BeautifulSoup(self.sample_html, "html.parser") + visualizer_url = page.find("div", attrs={"data-targetembedid": "embedHemicycle"}) + url = visualizer_url.get("data-content") + + return url + + def test_fetch_scrutin_analyse_data(self): + self.parser.fetch_scrutin_analyse_data() + self.assertIsInstance(self.parser.scrutin_analyse, ScrutinAnalyse) + self.assertEqual(self.mocked_fetch_page.call_count, 1) + + self.assertEqual(self.parser.scrutin_analyse.id, 3186) + self.assertEqual(self.parser.scrutin_analyse.date, "2025-10-28") + self.assertEqual( + self.parser.scrutin_analyse.title, + "Scrutin public n°3186 sur l'amendement n° 2493 de M. Prud'homme après l'article 12 (examen prioritaire) du projet de loi de finances pour 2026 (première lecture).", + ) + + self.assertIsNotNone(self.parser.scrutin_analyse.visualizer) + self.assertIn("hemicycle", self.parser.scrutin_analyse.visualizer) + + self.assertIsInstance(self.parser.scrutin_analyse.vote_for, list) + self.assertEqual(len(self.parser.scrutin_analyse.vote_for), 42) + + self.assertIsInstance(self.parser.scrutin_analyse.vote_against, list) + self.assertEqual(len(self.parser.scrutin_analyse.vote_against), 178) + + self.assertIsInstance(self.parser.scrutin_analyse.vote_abstention, list) + self.assertEqual(len(self.parser.scrutin_analyse.vote_abstention), 6) + + self.assertIsInstance(self.parser.scrutin_analyse.vote_absent, list) + self.assertEqual(len(self.parser.scrutin_analyse.vote_absent), 15) + + self.assertFalse(self.parser.scrutin_analyse.adopted) diff --git a/tests/test_scrutin_parser.py b/tests/test_scrutin_parser.py new file mode 100644 index 00000000..9884e856 --- /dev/null +++ b/tests/test_scrutin_parser.py @@ -0,0 +1,64 @@ +import unittest +from unittest.mock import patch + +from bs4 import BeautifulSoup + +from src.models import Scrutin +from src.parsers import ScrutinParser + + +class TestScrutinParser(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + with open("tests/data/scrutins_data.html", "r", encoding="utf-8") as f: + cls.sample_html = f.read() + + def setUp(self) -> None: + self.parser = ScrutinParser(legislature=17, max_page=1) + + patch_fetch_page = patch.object(ScrutinParser, "_fetch_page", side_effect=self.mock_fetch_page) + + self.mocked_fetch_page = patch_fetch_page.start() + + self.addCleanup(patch_fetch_page.stop) + + def mock_fetch_page(self, an_url: str): + return BeautifulSoup(self.sample_html, "html.parser") + + def test_parse_scrutins(self): + self.parser.parse_scrutins() + self.assertEqual(self.mocked_fetch_page.call_count, 2) + + self.assertIsInstance(self.parser.scrutins, list) + self.assertEqual(len(self.parser.scrutins), 10) + + expected_ids = [3159, 3160, 3124, 3125, 3166, 3165, 3167, 3137, 3168, 3150] + + for scrutin in self.parser.scrutins: + self.assertIsInstance(scrutin, Scrutin) + self.assertIsNotNone(scrutin.id) + self.assertIsInstance(scrutin.id, int) + self.assertIn(scrutin.id, expected_ids) + + self.assertIsNotNone(scrutin.name) + self.assertIsInstance(scrutin.name, str) + + self.assertIsNotNone(scrutin.url) + self.assertIsInstance(scrutin.url, str) + + self.assertIsNotNone(scrutin.text_url) + self.assertIsInstance(scrutin.text_url, str) + + self.assertIsNotNone(scrutin.date) + self.assertIsInstance(scrutin.date, str) + + self.assertIsInstance(scrutin.adopted, bool) + + self.assertIsNotNone(scrutin.vote_for) + self.assertIsInstance(scrutin.vote_for, int) + + self.assertIsNotNone(scrutin.vote_against) + self.assertIsInstance(scrutin.vote_against, int) + + self.assertIsNotNone(scrutin.vote_abstention) + self.assertIsInstance(scrutin.vote_abstention, int)